36 秒杀

秒杀场景可以分成秒杀前、秒杀中和秒杀后三个阶段。

主要特征:

  1. 瞬时并发高(数据库千级并发,Redis万级并发)
  2. 读多写少,读数据比较简单

秒杀过程:
1.秒杀前:
尽量把商品详情页的页面元素静态化,然后使用 CDN 或是浏览器把这些静态化的元素缓存起来。
无需使用Redis。

2.秒杀中:
这个阶段的操作就是三个:库存查验、库存扣减和订单处理,并发压力在库存查验上。

  • 使用Redis保存库存量,这样一来,请求可以直接从 Redis 中读取库存并进行查验。
  • 使用Redis进行库存扣减
  • 使用数据库进行订单处理(保证事务)

注:为了避免请求查询到旧的库存值,库存查验和库存扣减这两个操作需要保证原子性(
lua脚本)。

3.秒杀结束后:
失败用户刷新商品详情,成功用户刷新订单详情。
无需使用Redis。

原子操作

在秒杀场景中,一个商品的库存对应了两个信息,分别是总库存量和已秒杀量。
其中,itemID 是商品的编号,total 是总库存量,ordered 是已秒杀量。

key: itemID
value: {total: N, ordered: M}

方法1:lua脚本

#获取商品库存信息            
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
#将总库存转换为数值
local total = tonumber(counts[1])
#将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])  
#如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存         
if ordered + k <= total then
    #更新已秒杀的库存量
    redis.call("HINCRBY",KEYS[1],"ordered",k)                              return k;  
end               
return 0

方法2:分布式锁
先让客户端向 Redis 申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减。

//使用商品ID作为key
key = itemID
//使用客户端唯一标识作为value
val = clientUniqueID
//申请分布式锁,Timeout是超时时间
lock =acquireLock(key, val, Timeout)
//当拿到锁后,才能进行库存查验和扣减
if(lock == True) {
   //库存查验和扣减
   availStock = DECR(key, k)
   //库存已经扣减完了,释放锁,返回秒杀失败
   if (availStock < 0) {
      releaseLock(key, val)
      return error
   }
   //库存扣减成功,释放锁
   else{
     releaseLock(key, val)
     //订单处理
   }
}
//没有拿到锁,直接返回
else
   return

注意:使用分布式锁时,客户端需要先向 Redis 请求锁,只有请求到了锁,才能进行库存查验等操作,这样一来,客户端在争抢分布式锁时,大部分秒杀请求本身就会因为抢不到锁而被拦截。

建议:分布式锁和业务数据放在集群不同实例上,可以减轻业务数据实例压力。

小结

秒杀优化:

  1. 前端静态化:利用CDN和浏览器缓存。
  2. 请求拦截和控流:拦截恶意请求(黑名单),限制请求数量。
  3. 库存过期时间处理:不要设置过期时间,避免缓存击穿。
  4. 数据库订单异常处理:增加订单重试功能,保证订单成功处理。

建议:处理秒杀的业务数据用单独的实例保存,不要和日常业务放在一起。

问题:使用多个实例的切片集群来分担秒杀请求,是否是一个好方法?
优点:集群分担请求,可以降低单实例压力;
缺点:

  1. 请求不平均时会数据倾斜,增大单个实例压力,导致没有全部卖出;
  2. 获取库存时需要查询多个切片,这种情况建议不展示库存。

37 数据倾斜

数据倾斜有两类:

  • 数据量倾斜:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。
  • 数据访问倾斜:每个实例的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。

产生原因

数据倾斜的原因分别是某个实例上保存了 bigkey、Slot 分配不均衡以及 Hash Tag。

bigkey

  1. bigkey 的 value 值很大(String 类型),或者是 bigkey 保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。
  2. bigkey 的操作一般都会造成实例 IO 线程阻塞,影响访问速度。

解决方法:

  1. 避免把过多数据放在一个key中
  2. 把集合类型的bigkey拆分成很多小集合,保存在不同实例上。

Slot分配不均衡

大量的数据被分配到同一个 Slot 中,而同一个 Slot 只会在一个实例上分布,这就会导致,大量数据被集中到一个实例上,造成数据倾斜。

查看Slot分配情况:

CLUSTER SLOTS

Slot迁移:

  1. CLUSTER SETSLOT:使用不同的选项进行三种设置,分别是设置 Slot 要迁入的目标实例,Slot 要迁出的源实例,以及 Slot 所属的实例。
  2. CLUSTER GETKEYSINSLOT:获取某个 Slot 中一定数量的 key。
  3. MIGRATE:把一个 key 从源实例实际迁移到目标实例。

    #1. 从实例 3 上迁入 Slot 300
    CLUSTER SETSLOT 300 IMPORTING 3
    
    # 2. 迁到实例5
    CLUSTER SETSLOT 300 MIGRATING 5
    
    # 3. 分批次迁移,一次100个key
    CLUSTER GETKEYSINSLOT 300 100
    
    # 4. 执行迁移,设置数据库编号(0)和迁移超时时间
    MIGRATE 192.168.10.5 6379 key1 0 timeout
    
    # 重复3,4步直到所有key迁移完成

Hash Tag

Hash Tag 是指加在键值对 key 中的一对花括号{}。
这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的 key 内容进行计算。
如果没用 Hash Tag 的话,客户端计算整个 key 的 CRC16 的值。
比如key为user:profile:{3231}时,只计算3231的CRC16值。

好处:HashTag相同时,数据会映射到同一个实例上。
应用场景:
主要是用在 Redis Cluster 和 Codis 中,支持事务操作和范围查询。因为 Redis Cluster 和 Codis 本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。所以使用 Hash Tag 把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。

应对方法

  1. 如果热点数据以服务读操作为主,在这种情况下,我们可以采用热点数据多副本的方法来应对。
    具体做法是,把热点数据复制多份,在每一个数据副本的 key 中增加一个随机前缀,让它和其它副本数据不会被映射到同一个 Slot 中。
  2. 对于有读有写的热点数据,我们就要给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。

小结

建议:在构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的 Slot。

38 通信开销

Redis Cluster 的规模上限,是一个集群运行 1000 个实例。
实例间的通信开销会随着实例规模增加而增大,在集群超过一定规模时(比如 800 节点),集群吞吐量反而会下降。

Gossip 协议

Redis Cluster 在运行时,每个实例上都会保存 Slot 和实例的对应关系(也就是 Slot 映射表),以及自身的状态信息。

为了让集群中的每个实例都知道其它所有实例的状态信息,实例之间会按照一定的规则进行通信。这个规则就是 Gossip 协议。

  1. 每个实例之间会按照一定的频率,从集群中随机挑选一些实例,把 PING 消息发送给挑选出来的实例,用来检测这些实例是否在线,并交换彼此的状态信息。PING 消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及 Slot 映射表。
  2. 实例在接收到 PING 消息后,会给发送 PING 消息的实例,发送一个 PONG 消息。PONG 消息包含的内容和 PING 消息一样。

Gossip 协议可以保证在一段时间后,集群中的每一个实例都能获得其它所有实例的状态信息。

使用 Gossip 协议进行通信时,通信开销受到通信消息大小和通信频率这两方面的影响,消息越大、频率越高,相应的通信开销也就越大。

消息大小

1.对于一个包含了 1000 个实例的集群来说,每个实例发送一个 PING 消息时,会包含 100 个实例的状态信息,总的数据量是 10400 字节,再加上发送实例自身的信息,一个 Gossip 消息大约是 10KB。
2.为了让 Slot 映射表能够在不同实例间传播,PING 消息中还带有一个长度为 16,384 bit 的 Bitmap,这个 Bitmap 的每一位对应了一个 Slot,如果某一位为 1,就表示这个 Slot 属于当前实例。这个 Bitmap 大小换算成字节后,是 2KB。

把实例状态信息和 Slot 分配信息相加,就可以得到一个 PING 消息的大小了,大约是 12KB。每个实例发送了 PING 消息后,还会收到返回的 PONG 消息,两个消息加起来有 24KB。

通信频率

  1. Redis Cluster 的实例启动后,默认会每秒从本地的实例列表中随机选出 5 个实例,再从这 5 个实例中找出一个最久没有通信的实例,把 PING 消息发送给该实例。
  2. Redis Cluster 的实例会按照每 100ms 一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG 消息的时间,已经大于配置项 cluster-node-timeout 的一半了(cluster-node-timeout/2),就会立刻给该实例发送 PING 消息,更新这个实例上的集群状态信息。

优化:
配置项 cluster-node-timeout 定义了集群实例被判断为故障的心跳超时时间,默认是 15 秒,可以调大到20~25秒。

可以在调整 cluster-node-timeout 值的前后,使用 tcpdump 命令抓取实例发送心跳信息网络包的情况。

tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap

IT小马
1.2k 声望166 粉丝

Php - Go - Vue - 云原生