Redis脑裂
在日常的项目中,一般都会使用redis作为缓存层,加速数据的读取和临时存储。同时,为了防止服务器因为某些突发情况导致Redis不可用,所以我们一般都会采用哨兵模式或者集群模式,至于为什么不用主从,在之前的文章中已经说明过了。
但是,虽然使用了这种高可用的架构模式,尽量减少线上的运维与数据的丢失,但是这种高可用的架构需要多机器部署,多机器之前物理性的问题也可能造成问题。
接下来我们就来说说发现Redis脑裂的过程,这里我们线上环境使用的是哨兵模式。
脑裂的发现过程
第一步:确认是不是数据同步出现了问题
在主从集群中发生数据丢失,最常见的原因就是主库的数据还没有同步到从库,结果主库发生了故障,等从库升级为主库后,未同步的数据就丢失了。
例如在高并发的场景下,主库还在积极写入,但是由于网络问题,主库进行数据同步时较为缓慢,从库并没有跟上主库的节奏,此时主库宕机,导致从库数据不全。
如果是这种情况的数据丢失,我们可以通过比对主从库上的复制进度差值来进行判断,也就是计算 master_repl_offset 和 slave_repl_offset 的差值。如果从库上的 slave_repl_offset 小于原主库的 master_repl_offset,那么,我们就可以认定数据丢失是由数据同步未完成导致的。
但是,一般情况下都会同时部署监控工具来监测主库上的 master_repl_offset,以及从库上的 slave_repl_offset。但是,当发现数据丢失后,新主库升级前的 slave_repl_offset,以及原主库的 master_repl_offset,它们是一致的,也就是说,这个升级为新主库的从库,在升级时已经和原主库的数据保持一致了。那么,为什么还会出现客户端发送的数据丢失呢?
第二步:排查客户端的操作日志,发现脑裂现象
在排查客户端的操作日志时,我们发现,在主从切换后的一段时间内,有一个客户端仍然在和原主库通信,并没有和升级的新主库进行交互。这就相当于主从集群中同时有了两个主库。根据这个迹象,我们就想到了在分布式主从集群发生故障时会出现的一个问题:脑裂。
但是,不同客户端给两个主库发送数据写操作,按道理来说,只会导致新数据会分布在不同的主库上,并不会造成数据丢失。那么,为什么我们的数据仍然丢失了呢?
第三步:发现是原主库假故障导致的脑裂
我们是采用哨兵机制进行主从切换的,当主从切换发生时,一定是有超过预设数量(quorum 配置项)的哨兵实例和主库的心跳都超时了,才会把主库判断为客观下线,然后,哨兵开始执行切换操作。哨兵切换完成后,客户端会和新主库进行通信,发送请求操作。
但是,在切换过程中,既然客户端仍然和原主库通信,这就表明,原主库并没有真的发生故障(例如主库进程挂掉)。因此,主库是由于某些原因无法处理请求,也没有响应哨兵的心跳,才被哨兵错误地判断为客观下线的。结果,在被判断下线之后,原主库又重新开始处理请求了,而此时,哨兵还没有完成主从切换,客户端仍然可以和原主库通信,客户端发送的写操作就会在原主库上写入数据了。
为了验证原主库只是“假故障”,我们也查看了原主库所在服务器的资源使用监控记录。
的确,我们看到原主库所在的机器有一段时间的 CPU 利用率突然特别高,这是我们在机器上部署的一个数据采集程序导致的。因为这个程序基本把机器的 CPU 都用满了,导致 Redis 主库无法响应心跳了,在这个期间内,哨兵就把主库判断为客观下线,开始主从切换了。不过,这个数据采集程序很快恢复正常,CPU 的使用率也降下来了。此时,原主库又开始正常服务请求了。
正因为原主库并没有真的发生故障,我们在客户端操作日志中就看到了和原主库的通信记录。等到从库被升级为新主库后,主从集群里就有两个主库了。
此时,脑裂的发现过程就讲完了。之后等到主从完成切换后,原主库就会清空数据,接受主库的RDB文件。
哨兵模式下,会有客户端会订阅信息通道,当发送switch-master时,客户端接收到消息后切换主库的IP,所以在哨兵发布switch-master命令之前,客户端还是按照旧主库正常读写,所以这部分读写的数据就丢失了
那么,在生产过程中,我们如何去应对脑裂数据的丢失?
如何应对脑裂问题?
刚刚说了,主从集群中的数据丢失事件,归根结底是因为发生了脑裂。所以,我们必须要找到应对脑裂问题的策略。
既然问题是出在原主库发生假故障后仍然能接收请求上,我们就开始在主从集群机制的配置项中查找是否有限制主库接收请求的设置。
在Redis中,提供了两个配置项来限制主库的请求处理,分别是 min-slaves-to-write 和 min-slaves-max-lag。
- min-slaves-to-write:这个配置项设置了主库能进行数据同步的最少从库数量;
- min-slaves-max-lag:这个配置项设置了主从库间进行数据复制时,从库给主库发送 ACK 消息的最大延迟(以秒为单位)。
有了这两个配置项后,我们就可以轻松地应对脑裂问题了。具体咋做呢?
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的请求了。
举个栗子,
假设我们将 min-slaves-to-write 设置为 1,把 min-slaves-max-lag 设置为 12s,把哨兵的 down-after-milliseconds 设置为 10s,主库因为某些原因卡住了 15s,导致哨兵判断主库客观下线,开始进行主从切换。同时,因为原主库卡住了 15s,没有一个从库能和原主库在 12s 内进行数据复制,原主库也无法接收客户端请求了。这样一来,主从切换完成后,也只有新主库能接收请求,不会发生脑裂,也就不会发生数据丢失的问题了。
最后,我们再把问题抛到redis集群中,本质还是一样的,我们以Redis cluster为例。
如图所示,如果此时ECS1脱离了整个集群的网络架构,但是与客户端的连接良好会发生什么问题呢?
我们先从客户端的请求方式来说,先将key进行 CRC16 计算完值后,再对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个hash槽,再根据节点与槽位的映射信息找到对应的节点,进行命令的执行。
正常情况下,分配好之后,是有一份slot-node(槽位-节点)的映射关系表,当客户端请求之后,会将该表告知客户端,客户端进行保存。这是因为Redis为了追求性能最大化,并没有采用代理的方式而是采用客户端直连节点的方式。
所有,每当槽位与节点之间的映射发生变化,都会告知客户端,客户端再进行更新保存,具体表现为当前的key若不在原映射表对应的节点下,节点接收到后会发送moved命令,客户端捕捉到moved重定向错误后,更新slots缓存。
此时,如果ECS1脱离集群的网络架构,原集群通过Gossip通信发现master1下线,并发起故障转移,选取slave1为新的主节点,拿到了master1所有的slots成为了新的master1。
对于新加入的客户端,所有的数据都在新的master1上进行读取和写入,这里并没有什么问题,但是对于原有的客户端,由于slot的槽位并没有发生改动,所以也不回发送moved命令,最终存在很多数据落在了原来的master1上,导致存在重复的主节点,造成集群脑裂。
之后待网络情况恢复后,会给原master1发送replicaiton of命令让其成为从节点,就会导致数据的丢失。
所以集群的脑裂问题发生比哨兵更加严重, 需要对min-slaves-to-write 和 min-slaves-max-lag这两个参数设置的更加严谨。
min-slaves-to-write 和 min-slaves-max-lag命令还是无法严格保证数据不丢失,它只能是尽量减少数据的丢失,脑裂产生问题的本质原因是,Redis 主从集群内部没有通过共识算法,来维护多个节点数据的强一致性。它不像 Zookeeper 那样,每次写请求必须大多数节点写成功后才认为成功。当脑裂发生时,Zookeeper 主节点被孤立,此时无法写入大多数节点,写请求会直接返回失败,因此它可以保证集群数据的一致性。
另外关于 min-slaves-to-write,有一点也需要注意:如果只有 1 个从库,当把 min-slaves-to-write 设置为 1 时,在运维时需要小心一些,当日常对从库做维护时,例如更换从库的实例,需要先添加新的从库,再移除旧的从库才可以,或者使用 config set 修改 min-slaves-to-write 为 0 再做操作,否则会导致主库拒绝写,影响到业务。
参考
极客时间-Redis核心技术与实战
《Redis开发与运维》
《Redis设计与实现》