数据分布优化

使用 Redis Cluster 时,数据会先按照 CRC 算法的计算值对 slot 取模,然后分散到不同的实例上保存。虽然这种方法实现起来比较简单,但是很容易导致一个问题:数据倾斜。

数据倾斜可以分为以下两类:

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

如果发生了数据倾斜,那么保存了大量数据,或者是保存了热点数据的实例的处理压力就会增大,速度变慢,甚至还可能会引起这个实例的内存资源耗尽,从而崩溃。这是我们在应用切片集群时要避免的。

1. 数据量倾斜的成因和应对

当数据量倾斜发生时,数据在切片集群的多个实例上分布不均衡,大量数据集中到了一个或几个实例上。
2cb89b2d1b319fb43a5d1b94d7929685.webp
那么,数据量倾斜是怎么产生的呢?这主要有以下三个原因:

1.1 bigkey 导致倾斜

第一个原因是,某个实例上正好保存了 bigkey。bigkey 的 value 值很大(String 类型)或者是 bigkey 保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。而且,bigkey 的操作一般都会造成实例 IO 线程阻塞,如果 bigkey 的访问量较大,就会影响这个实例上的其它请求被处理的速度。

为了避免 bigkey 造成的数据倾斜,一个根本的应对方法是,我们在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中。此外,如果 bigkey 正好是集合类型,我们还可以把 bigkey 拆分成很多个小的集合类型数据,分散保存在不同的实例上。

1.2 slot 分配不均衡导致倾斜

如果集群运维人员没有均衡地分配 slot,就会有大量的数据被分配到同一个 slot 中,而同一个 slot 只会在一个实例上分布,这就会导致大量数据被集中到一个实例上,造成数据倾斜。

Redis Cluster 一共有 16384 个 slot,假设集群一共有 5 个实例,其中,实例 1 的硬件配置较高,运维人员在给实例分配 slot 时,就可能会给实例 1 多分配些 slot,把实例 1 的资源充分利用起来。但是,我们其实并不知道数据和 slot 的对应关系,这种做法就可能会导致大量数据正好被映射到实例 1 上的 slot,造成数据倾斜,给实例 1 带来访问压力。

为了应对这个问题,我们可以通过运维规范,在分配之前,我们就要避免把过多的 slot 分配到同一个实例。如果是已经分配好 slot 的集群,我们可以先查看 slot 和实例的具体分配关系,从而判断是否有过多的 slot 集中到了同一个实例。如果有的话,就将部分 slot 迁移到其它实例,从而避免数据倾斜。

Redis Cluster 提供了 CLUSTER SLOTS 命令用来查看 slot 的分配情况,如下示例,slot 0 到 slot 4095 被分配到了实例 192.168.10.3 上,而 slot 12288 到 slot 16383 被分配到了实例 192.168.10.5 上。

  1. 127.0.0.1:6379> cluster slots
  2. 1) 1) (integer) 0
  3. 2) (integer) 4095
  4. 3) 1) "192.168.10.3"
  5. 2) (integer) 6379
  6. 2) 1) (integer) 12288
  7. 2) (integer) 16383
  8. 3) 1) "192.168.10.5"
  9. 2) (integer) 6379

如果某一个实例上有太多的 slot,我们就可以使用迁移命令把这些 slot 迁移到其它实例上。在 Redis Cluster 中,我们可以使用 3 个命令完成 slot 迁移。

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

举个例子,假设我们要把 slot 300 从源实例(ID 为 3)迁移到目标实例(ID 为 5),我们可以分成 5 步。

第 1 步,在目标实例 5 上,我们把 slot 300 的源实例设置为实例 3,表示要从实例 3 上迁入 slot 300。

  1. CLUSTER SETSLOT 300 IMPORTING 3

第 2 步,在源实例 3 上,我们把 slot 300 的目标实例设置为 5,表示 slot 300 要迁出到实例 5 上。

  1. CLUSTER SETSLOT 300 MIGRATING 5

第 3 步,从 slot 300 中获取 100 个 key。因为 slot 中的 key 数量可能很多,所以我们需要在客户端上多次执行下面的这条命令,分批次获得并迁移 key。

  1. CLUSTER GETKEYSINSLOT 300 100

第 4 步,我们把刚才获取的 100 个 key 中的 key1 迁移到目标实例 5 上(IP 为 192.168.10.5),同时把要迁入的数据库设置为 0 号数据库,把迁移的超时时间设置为 timeout。然后重复执行 MIGRATE 命令,直到把 100 个 key 都迁移完。也可以使用 KEYS 选项一次迁移多个 key,这样可以提升迁移效率。

  1. MIGRATE 192.168.10.5 6379 key1 0 timeout
  2. MIGRATE 192.168.10.5 6379 "" 0 timeout KEYS key1 key2 key3

最后,重复执行第 3 和第 4 步,直到 slot 中的所有 key 都迁移完成。

1.3 Hash Tag 导致倾斜

Hash Tag 是指加在键值对 key 中的一对花括号 {}。这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的内容进行计算。否则,客户端会计算整个 key 的 CRC16 值。

举个例子,假设 key 是 user:profile:3231,我们把其中的 3231 作为 Hash Tag,此时,key 就变成了 user:profile:{3231}。当客户端计算这个 key 的 CRC16 值时,就只会计算 3231 的 CRC16 值。否则,客户端会计算整个 user:profile:3231 的 CRC16 值。

使用 Hash Tag 的好处是,如果不同 key 的 Hash Tag 内容都是一样的,那么,这些 key 对应的数据会被映射到同一个 slot 中,同时会被分配到同一个实例上。

那么,Hash Tag 一般用在什么场景呢?其实,它主要是用在 Redis Cluster 和 Codis 中,支持事务操作和范围查询。因为 Redis Cluster 和 Codis 本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能先把这些数据读取到业务层进行事务处理,或者是逐个查询每个实例,得到范围查询的结果。这样操作起来非常麻烦,所以,我们可以使用 Hash Tag 把要执行事务操作或是范围查询的数据映射到同一个实例上,这样就能很轻松地实现事务或范围查询了。

但是,使用 Hash Tag 的潜在问题,就是大量的数据可能被集中到一个实例上,导致数据倾斜,集群中的负载不均衡。那么,该怎么应对这种问题呢?我们就需要在范围查询、事务执行的需求和数据倾斜带来的访问压力之间,进行取舍了。

我的建议是,如果使用 Hash Tag 进行切片的数据会带来较大的访问压力,就优先考虑避免数据倾斜,最好不要使用 Hash Tag 进行数据切片。因为事务和范围查询都还可以放在客户端来执行,而数据倾斜会导致实例不稳定,造成服务不可用。

2. 数据访问倾斜的成因和应对

发生数据访问倾斜的根本原因,就是实例上存在热点数据(比如新闻应用中的热点新闻内容、电商促销活动中的热门商品信息)。一旦热点数据被存在了某个实例中,那么,这个实例的请求访问量就会远高于其它实例,面临巨大的访问压力,如下图所示:
94b1ca50143db1d09c60475fa7b41820.webp
那么,我们该如何应对呢?

和数据量倾斜不同,热点数据通常是一个或几个数据,所以,直接重新分配 slot 并不能解决热点数据的问题。通常来说,热点数据以服务读操作为主,在这种情况下,我们可以采用热点数据多副本的方法来应对。

具体做法是,我们把热点数据复制多份,在每一个数据副本的 key 中增加一个随机前缀,让它和其它副本数据不会被映射到同一个 slot 中。这样一来,热点数据既有多个副本可以同时服务请求,同时,这些副本数据的 key 又不一样,会被映射到不同的 slot 中。在给这些 slot 分配实例时,我们也要注意把它们分配到不同的实例上,那么,热点数据的访问压力就被分散到不同的实例上了。

这里,有个地方需要注意下,热点数据多副本方法只能针对只读的热点数据。如果热点数据是有读有写的话,就不适合采用多副本方法了,因为要保证多副本间的数据一致性,会带来额外的开销。对于有读有写的热点数据,我们就要给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。

集群通信开销

Redis Cluster 能保存的数据量以及支撑的吞吐量,跟集群的实例规模密切相关。官方给出的 Redis Cluster 的规模上限,就是一个集群运行 1000 个实例。为什么要限定集群规模呢?这里的一个关键因素就是,实例间的通信开销会随着实例规模增加而增大,在集群超过一定规模时(比如 800 节点),集群吞吐量反而会下降。所以,集群的实际规模会受到限制。

1. Gossip 协议

Redis Cluster 在运行时,每个实例上都会保存 slot 和实例的对应关系(也就是 slot 映射表)以及自身的状态信息。为了让集群中的每个实例都知道其它所有实例的状态信息,实例之间会按照一定的规则进行通信。这个规则就是 Gossip 协议。

Gossip 协议的工作原理可以概括成两点。

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

二是,一个实例在接收到 PING 消息后,会给发送 PING 消息的实例,发送一个 PONG 消息。PONG 消息包含的内容和 PING 消息一样。下图显示了两个实例间进行 PING、PONG 消息传递的情况。
5eacfc36c4233ae7c99f80b1511yyb86.webp
Gossip 协议可以保证在一段时间后,集群中的每一个实例都能获得其它所有实例的状态信息。这样一来,即使有新节点加入、节点故障、slot 变更等事件发生,实例间也可以通过 PING、PONG 消息的传递,完成集群状态在每个实例上的同步。

经过刚刚的分析,我们可以很直观地看到,实例间使用 Gossip 协议进行通信时,通信开销受到通信消息大小和通信频率这两方面的影响,消息越大、频率越高,相应的通信开销也就越大。如果想要实现高效的通信,可以从这两方面入手去调优。接下来,我们就来具体分析下这两方面的实际情况。

1.1 Gossip 消息大小

Redis 实例发送的 PING 消息的消息体是由 clusterMsgDataGossip 结构体组成的,其定义如下:

  1. typedef struct {
  2. char nodename[CLUSTER_NAMELEN]; //40字节
  3. uint32_t ping_sent; //4字节
  4. uint32_t pong_received; //4字节
  5. char ip[NET_IP_STR_LEN]; //46字节
  6. uint16_t port; //2字节
  7. uint16_t cport; //2字节
  8. uint16_t flags; //2字节
  9. uint32_t notused1; //4字节
  10. } clusterMsgDataGossip;

其中,CLUSTER_NAMELEN 和 NET_IP_STR_LEN 的值分别是 40 和 46,分别表示 nodename 和 ip 这两个字节数组的长度是 40 字节和 46 字节,我们再把结构体中其它信息的大小加起来,就可以得到一个 Gossip 消息的大小了,即 104 字节。

每个实例在发送 Gossip 消息时,除了传递自身的状态信息,默认还会传递集群十分之一实例的状态信息。所以,对于一个包含了 1000 个实例的集群来说,每个实例发送一个 PING 消息时,会包含 100 个实例的状态信息,总的数据量是 10400 字节,再加上发送实例自身的信息,一个 Gossip 消息大约是 10KB。

此外,为了让 slot 映射表能够在不同实例间传播,PING 消息中还带有一个长度为 16384 bit 的 Bitmap,这个 Bitmap 的每一位对应了一个 slot,如果某一位为 1,就表示这个 slot 属于当前实例。这个 Bitmap 大小换算成字节后是 2KB。因此,一个 PING 消息的大小约是 12KB。

虽然从绝对值上来看,12KB 并不算很大,但是,如果实例正常处理的单个请求只有几 KB 的话,那么,实例为了维护集群状态一致传输的 PING/PONG 消息,就要比单个业务请求大了。而且,每个实例都会给其它实例发送 PING/PONG 消息。随着集群规模增加,这些心跳消息的数量也会越多,会占据一部分集群的网络通信带宽,进而会降低集群服务正常客户端请求的吞吐量。

除了心跳消息大小会影响到通信开销,如果实例间通信非常频繁,也会导致集群网络带宽被频繁占用。那么,Redis Cluster 中实例的通信频率是什么样的呢?

1.2 实例间通信频率

Redis Cluster 的实例启动后,默认会每秒从本地的实例列表中随机选出 5 个实例,再从这 5 个实例中找出一个最久没有通信的实例,把 PING 消息发送给该实例。这是实例周期性发送 PING 消息的基本做法。

但这里有一个问题:实例选出来的这个最久没有通信的实例,是从随机选出的 5 个实例中挑选的,这并不能保证这个实例就一定是整个集群中最久没有通信的实例。所以,可能会出现有些实例一直没有被发送 PING 消息,导致它们维护的集群状态已经过期了。

为了避免这种情况,Redis Cluster 中的实例会按照每 100ms 一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG 消息的时间,已经大于配置项 cluster-node-timeout 的一半了,就会立刻给该实例发送 PING 消息,更新这个实例上的集群状态信息。

当集群规模扩大后,因为网络拥塞或是不同服务器间的流量竞争,会导致实例间的网络通信延迟增加。如果有部分实例无法收到其它实例发送的 PONG 消息,就会引起实例之间频繁地发送 PING 消息,这又会对集群网络通信带来额外的开销了。我们来总结下单实例每秒会发送的 PING 消息数量,如下所示:

  1. PING 消息发送数量 = 1 + 10 * 实例数(最近一次接收 PONG 消息的时间超出 cluster-node-timeout/2

其中,1 是指单实例常规按照每 1 秒发送一个 PING 消息,10 是指每 1 秒内实例会执行 10 次检查,每次检查后会给 PONG 消息超时的实例发送消息。

假设单个实例检测发现,每 100 毫秒有 10 个实例的 PONG 消息接收超时,那么,这个实例每秒就会发送 101 个 PING 消息,约占 1.2MB/s 带宽。如果集群中有 30 个实例按照这种频率发送消息,就会占用 36MB/s 带宽,这就会挤占集群中用于服务正常请求的带宽。

2. 如何降低实例间的通信开销?

为了降低实例间的通信开销,从原理上说,我们可以减小实例传输的消息大小(PING/PONG 消息、slot 分配信息),但是,因为集群实例依赖 PING、PONG 消息和 slot 分配信息来维持集群状态的统一,一旦减小了传递的消息大小,就会导致实例间的通信信息减少,不利于集群维护,所以,我们不能采用这种方式。

那么,我们能不能降低实例间发送消息的频率呢?我们知道,实例间发送消息的频率有两个。

  • 每个实例每 1 秒发送一条 PING 消息。这个频率不算高,如果再降低该频率的话,集群中各实例的状态可能就没办法及时传播了。
  • 每个实例每 100 毫秒会做一次检测,给 PONG 消息接收超过 cluster-node-timeout/2 的节点发送 PING 消息。实例按照每 100 毫秒进行检测的频率,是 Redis 实例默认的周期性检查任务的统一频率,我们一般不需要修改它。

配置项 cluster-node-timeout 定义了集群实例被判断为故障的心跳超时时间,默认 15 秒。如果该值比较小,在大规模集群中,就会比较频繁地出现 PONG 消息接收超时的情况,从而导致实例每秒要执行 10 次给超时实例发送 PING 消息的操作。所以,为了避免过多的心跳消息挤占集群带宽,我们可以调大 cluster-node-timeout 配置值。这样 PONG 消息接收超时的情况有所缓解,单实例也不用频繁地每秒执行 10 次心跳发送操作。

当然,我们也不要把 cluster-node-timeout 调得太大,否则,如果实例真的发生了故障,我们就需要等待 cluster-node-timeout 时长后,才能检测出这个故障,这又会导致实际的故障恢复时间被延长,会影响到集群服务的正常使用。