集群的数据倾斜和通信开销问题
参考:
# 1. 数据分布优化:如何应对数据倾斜?
在 Redis 切片集群中,数据都会按照 CRC 算法的计算值对 Slot 取模,然后再映射到某个实例上。这种方法虽然实现简单,但容易导致一个问题:数据倾斜。
数据倾斜有两类:
- 数据量倾斜:在某些情况下,实例上的数据分布不均衡,某个实例上的数据特别多。
- 数据访问倾斜:虽然每个集群实例上的数据量相差不大,但是某个实例上的数据是热点数据,被访问得非常频繁。
如果发生了数据倾斜,那么保存了大量数据,或者是保存了热点数据的实例的处理压力就会增大,速度变慢,甚至还可能会引起这个实例的内存资源耗尽,从而崩溃。这是我们在应用切片集群时要避免的。
这一大节将讨论数据倾斜是如何发生的,以及如何应对。
# 1.1 数据量倾斜的成因和应对方法
当数据量倾斜发生时,数据在切片集群的多个实例上分布不均衡,大量数据集中到了一个或几个实例上,如下图所示:
那么,数据量倾斜是怎么产生的呢?这主要有三个原因,分别是某个实例上保存了bigkey、Slot分配不均衡以及Hash Tag。接下来,我们就一个一个来分析,同时我还会给你讲解相应的解决方案。
# 1.1.1 bigkey 导致倾斜
第一个原因是,某个实例上正好保存了bigkey。bigkey的value值很大(String类型),或者是bigkey保存了大量集合元素(集合类型),会导致这个实例的数据量增加,内存资源消耗也相应增加。
而且,bigkey的操作一般都会造成实例IO线程阻塞,如果bigkey的访问量比较大,就会影响到这个实例上的其它请求被处理的速度。
其实,bigkey已经是我们课程中反复提到的一个关键点了。为了避免bigkey造成的数据倾斜,一个根本的应对方法是,我们在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对中。此外,如果 bigkey 正好是集合类型,那可以把它拆成多个小的集合类型数据,分散保存在不同的实例上。
需要注意的是,当 bigkey 访问量较大时,也会造成数据访问倾斜。
# 1.1.2 Slot 分配不均衡导致倾斜
如果集群运维人员没有均衡地分配 Slot,就会有大量的数据被分配到同一个 Slot 中,而同一个 Slot 只会在一个实例上分布,这就会导致,大量数据被集中到一个实例上,造成数据倾斜。
为了应对这个问题,我们可以通过运维规范,在分配之前,我们就要避免把过多的 Slot 分配到同一个实例。如果是已经分配好Slot的集群,我们可以先查看Slot和实例的具体分配关系,从而判断是否有过多的Slot集中到了同一个实例。如果有的话,就将部分Slot迁移到其它实例,从而避免数据倾斜。
不同集群上查看Slot分配情况的方式不同:如果是Redis Cluster,就用CLUSTER SLOTS命令;如果是Codis,就可以在codis dashboard上查看。
Redis Cluster 中 slot 的迁移
比如说,我们执行CLUSTER SLOTS命令查看Slot分配情况。命令返回结果显示,Slot 0 到Slot 4095被分配到了实例192.168.10.3上,而Slot 12288到Slot 16383被分配到了实例192.168.10.5上。
127.0.0.1:6379> cluster slots
1) 1) (integer) 0
2) (integer) 4095
3) 1) "192.168.10.3"
2) (integer) 6379
2) 1) (integer) 12288
2) (integer) 16383
3) 1) "192.168.10.5"
2) (integer) 6379
2
3
4
5
6
7
8
9
10
如果某一个实例上有太多的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。
CLUSTER SETSLOT 300 IMPORTING 3
第2步,在源实例3上,我们把Slot 300的目标实例设置为5,这表示,Slot 300要迁出到实例5上,如下所示:
CLUSTER SETSLOT 300 MIGRATING 5
第3步,从Slot 300中获取100 个key。因为Slot中的key数量可能很多,所以我们需要在客户端上多次执行下面的这条命令,分批次获得并迁移key。
CLUSTER GETKEYSINSLOT 300 100
第4步,我们把刚才获取的100个key中的key1迁移到目标实例5上(IP为192.168.10.5),同时把要迁入的数据库设置为0号数据库,把迁移的超时时间设置为timeout。我们重复执行MIGRATE命令,把100个key都迁移完。
MIGRATE 192.168.10.5 6379 key1 0 timeout
最后,我们重复执行第3和第4步,直到Slot中的所有key都迁移完成。
从Redis 3.0.6开始,你也可以使用KEYS选项,一次迁移多个key(key1、2、3),这样可以提升迁移效率。
MIGRATE 192.168.10.5 6379 "" 0 timeout KEYS key1 key2 key3
对于Codis来说,我们可以执行下面的命令进行数据迁移。其中,我们把dashboard组件的连接地址设置为ADDR,并且把Slot 300迁移到编号为6的codis server group上。
codis-admin --dashboard=ADDR -slot-action --create --sid=300 --gid=6
除了 bigkey 和 Slot 分配不均衡会导致数据量倾斜,还有一个导致倾斜的原因,就是使用了 Hash Tag 进行数据切片。
# 1.1.3 Hash Tag 导致倾斜
Hash Tag 是指加在键值对 key 中的一对花括号 {}
。这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的 key 内容进行计算。如果没用 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进行数据切片。因为事务和范围查询都还可以放在客户端来执行,而数据倾斜会导致实例不稳定,造成服务不可用。
以上介绍了数据量倾斜的原因以及应对方法。下面看一下数据访问倾斜的原因和应对方法。
# 1.2 数据访问倾斜的成因和应对方法
发生数据访问倾斜的根本原因,就是实例上存在热点数据。一旦热点数据被存在了某个实例中,那么,这个实例的请求访问量就会远高于其它实例,面临巨大的访问压力。如下图所示:
如何应对呢?和数据量倾斜不同,热点数据通常是一个或几个数据,所以,直接重新分配 Slot 并不能解决热点数据的问题。
通常来说,热点数据以服务读操作为主,在这种情况下,我们可以采用热点数据多副本的方法来应对。这个方法的具体做法是,我们把热点数据复制多份,在每一个数据副本的key中增加一个随机前缀,让它和其它副本数据不会被映射到同一个Slot中。这样一来,热点数据既有多个副本可以同时服务请求,同时,这些副本数据的key又不一样,会被映射到不同的Slot中。在给这些Slot分配实例时,我们也要注意把它们分配到不同的实例上,那么,热点数据的访问压力就被分散到不同的实例上了。
这里,有个地方需要注意下,热点数据多副本方法只能针对只读的热点数据。如果热点数据是有读有写的话,就不适合采用多副本方法了,因为要保证多副本间的数据一致性,会带来额外的开销。
对于有读有写的热点数据,我们就要给实例本身增加资源了,例如使用配置更高的机器,来应对大量的访问压力。
# 1.3 小结
这一大节介绍了数据倾斜的两种情况:数据量倾斜和数据访问倾斜。
造成数据量倾斜的原因主要有三个:
- 数据中有bigkey,导致某个实例的数据量增加;
- Slot手工分配不均,导致某个或某些实例上有大量数据;
- 使用了Hash Tag,导致数据集中到某些实例上。
而数据访问倾斜的主要原因就是有热点数据存在,导致大量访问请求集中到了热点数据所在的实例上。
下表对以上情况进行了总结:
如果已经发生了数据倾斜,我们可以通过数据迁移来缓解数据倾斜的影响。
最后,关于集群的实例资源配置,我再给你一个小建议:在构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的Slot。
# 2. 通信开销:限制 Redis Cluster 规模的关键因素
Redis Cluster能保存的数据量以及支撑的吞吐量,跟集群的实例规模密切相关。Redis官方给出了Redis Cluster的规模上限,就是一个集群运行1000个实例。
限制 Redis 集群数量的一个关键因素就是:实例间的通信开销会随着实例规模增加而增大。在集群超过一定规模时(比如 800 节点),集群吞吐量反而会下降。所以,集群的实际规模会受到限制。
这一大节将讨论集群实例间的通信开销是如何影响Redis Cluster规模的,以及如何降低实例间的通信开销。
# 2.1 实例通信方法和对集群规模的影响
Redis Cluster 在运行时,每个实例上都会保存 Slot 和实例的对应关系(也就是Slot映射表),以及自身的状态信息。实例之间为了互通这些状态信息,会使用 Gossip 协议来进行通信。
Gossip协议的工作原理可以概括成两点。
- 一是,每个实例之间会按照一定的频率,从集群中随机挑选一些实例,把PING消息发送给挑选出来的实例,用来检测这些实例是否在线,并交换彼此的状态信息。PING消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及Slot映射表。
- 二是,一个实例在接收到PING消息后,会给发送PING消息的实例,发送一个PONG消息。PONG消息包含的内容和PING消息一样。
下图显示了两个实例间进行 PING、PONG 消息传递的情况:
Gossip 协议可以保证在一段时间后,集群中的每一个实例都能获得其它所有实例的状态信息。这样一来,即使有新节点加入、节点故障、Slot 变更等事件发生,实例间也可以通过 PING、PONG 消息的传递,完成集群状态在每个实例上的同步。
可以直观看到,实例之间使用 Gossip 协议通信时,通信开销会受到通信消息大小和通信频率这两方面的影响,消息越大、频率越高,相应的通信开销也就越大。如果想要实现高效的通信,可以从这两方面入手去调优。
# 2.1.1 Gossip 消息大小
Redis 实例发送的 PING 消息的消息体是由 clusterMsgDataGossip 结构体组成的,这个结构体的定义如下所示:
typedef struct {
char nodename[CLUSTER_NAMELEN]; //40字节
uint32_t ping_sent; //4字节
uint32_t pong_received; //4字节
char ip[NET_IP_STR_LEN]; //46字节
uint16_t port; //2字节
uint16_t cport; //2字节
uint16_t flags; //2字节
uint32_t notused1; //4字节
} clusterMsgDataGossip;
2
3
4
5
6
7
8
9
10
其中,CLUSTER_NAMELEN和NET_IP_STR_LEN的值分别是40和46,分别表示,nodename和ip这两个字节数组的长度是40字节和46字节,我们再把结构体中其它信息的大小加起来,就可以得到一个Gossip消息的大小了,即104字节。
每个实例在发送一个Gossip消息时,除了会传递自身的状态信息,默认还会传递集群十分之一实例的状态信息。
所以,对于一个包含了1000个实例的集群来说,每个实例发送一个PING消息时,会包含100个实例的状态信息,总的数据量是 10400字节,再加上发送实例自身的信息,一个Gossip消息大约是10KB。
此外,为了让Slot映射表能够在不同实例间传播,PING消息中还带有一个长度为 16,384 bit 的 Bitmap,这个Bitmap的每一位对应了一个Slot,如果某一位为1,就表示这个Slot属于当前实例。这个Bitmap大小换算成字节后,是2KB。我们把实例状态信息和Slot分配信息相加,就可以得到一个PING消息的大小了,大约是12KB。
PONG消息和PING消息的内容一样,所以,它的大小大约是12KB。每个实例发送了PING消息后,还会收到返回的PONG消息,两个消息加起来有24KB。
虽然从绝对值上来看,24KB 并不算很大,但是,如果实例正常处理的单个请求只有几KB的话,那么,实例为了维护集群状态一致传输的PING/PONG消息,就要比单个业务请求大了。而且,每个实例都会给其它实例发送PING/PONG消息。随着集群规模增加,这些心跳消息的数量也会越多,会占据一部分集群的网络通信带宽,进而会降低集群服务正常客户端请求的吞吐量。
除了心跳消息大小会影响到通信开销,如果实例间通信非常频繁,也会导致集群网络带宽被频繁占用。那么,Redis Cluster 中实例的通信频率是什么样的呢?
# 2.1.2 实例间通信频率
Redis Cluster 的实例启动后,默认会每秒从本地的实例列表中随机选出5个实例,再从这5个实例中找出一个最久没有通信的实例,把PING消息发送给该实例。这是实例周期性发送PING消息的基本做法。
但是,这里有一个问题:实例选出来的这个最久没有通信的实例,毕竟是从随机选出的5个实例中挑选的,这并不能保证这个实例就一定是整个集群中最久没有通信的实例。所以,这有可能会出现,有些实例一直没有被发送PING消息,导致它们维护的集群状态已经过期了。
为了避免这种情况,Redis Cluster的实例会按照每100ms一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG消息的时间,已经大于配置项 cluster-node-timeout的一半了(cluster-node-timeout/2),就会立刻给该实例发送 PING消息,更新这个实例上的集群状态信息。
当集群规模扩大之后,因为网络拥塞或是不同服务器间的流量竞争,会导致实例间的网络通信延迟增加。如果有部分实例无法收到其它实例发送的PONG消息,就会引起实例之间频繁地发送PING消息,这又会对集群网络通信带来额外的开销了。
我们来总结下单实例每秒会发送的PING消息数量,如下所示:
PING消息发送数量 = 1 + 10 * 实例数(最近一次接收PONG消息的时间超出cluster-node-timeout/2)
其中,1是指单实例常规按照每1秒发送一个PING消息,10是指每1秒内实例会执行10次检查,每次检查后会给PONG消息超时的实例发送消息。
我来借助一个例子,带你分析一下在这种通信频率下,PING消息占用集群带宽的情况。
假设单个实例检测发现,每100毫秒有10个实例的PONG消息接收超时,那么,这个实例每秒就会发送101个PING消息,约占1.2MB/s带宽。如果集群中有30个实例按照这种频率发送消息,就会占用36MB/s带宽,这就会挤占集群中用于服务正常请求的带宽。
所以,我们要想办法降低实例间的通信开销,那该怎么做呢?
# 2.2 如何降低实例间的通信开销?
为了降低实例间的通信开销,从原理上说,我们可以减小实例传输的消息大小(PING/PONG消息、Slot分配信息),但是,因为集群实例依赖PING、PONG消息和Slot分配信息,来维持集群状态的统一,一旦减小了传递的消息大小,就会导致实例间的通信信息减少,不利于集群维护,所以,我们不能采用这种方式。
那么,我们能不能降低实例间发送消息的频率呢?我们先来分析一下。
经过刚才的学习,我们现在知道,实例间发送消息的频率有两个。
- 每个实例每1秒发送一条PING消息。这个频率不算高,如果再降低该频率的话,集群中各实例的状态可能就没办法及时传播了。
- 每个实例每100毫秒会做一次检测,给PONG消息接收超过cluster-node-timeout/2的节点发送PING消息。实例按照每100毫秒进行检测的频率,是Redis实例默认的周期性检查任务的统一频率,我们一般不需要修改它。
那么,就只有cluster-node-timeout这个配置项可以修改了。
配置项cluster-node-timeout定义了集群实例被判断为故障的心跳超时时间,默认是15秒。如果cluster-node-timeout值比较小,那么,在大规模集群中,就会比较频繁地出现PONG消息接收超时的情况,从而导致实例每秒要执行10次“给PONG消息超时的实例发送PING消息”这个操作。
所以,为了避免过多的心跳消息挤占集群带宽,我们可以调大cluster-node-timeout值,比如说调大到20秒或25秒。这样一来, PONG消息接收超时的情况就会有所缓解,单实例也不用频繁地每秒执行10次心跳发送操作了。
当然,我们也不要把cluster-node-timeout调得太大,否则,如果实例真的发生了故障,我们就需要等待cluster-node-timeout时长后,才能检测出这个故障,这又会导致实际的故障恢复时间被延长,会影响到集群服务的正常使用。
为了验证调整cluster-node-timeout值后,是否能减少心跳消息占用的集群网络带宽,我给你提个小建议:你可以在调整cluster-node-timeout值的前后,使用tcpdump命令抓取实例发送心跳信息网络包的情况。
例如,执行下面的命令后,我们可以抓取到192.168.10.3机器上的实例从16379端口发送的心跳网络包,并把网络包的内容保存到r1.cap文件中:
tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap
通过分析网络包的数量和大小,就可以判断调整cluster-node-timeout值前后,心跳消息占用的带宽情况了。
建议:如果不是特别需要大容量集群,最好 把Redis Cluster 的规模控制在 400~500 个实例。
假设单个实例每秒能支撑8万请求操作(8万QPS),每个主实例配置1个从实例,那么,400~ 500个实例可支持 1600万~2000万QPS(200/250个主实例*8万QPS=1600/2000万QPS),这个吞吐量性能可以满足不少业务应用的需求。