Redis Cluster 原理与管理

从事Redis Cluster相关工具开发半年多, 记录一下对它的理解和集群管理的想法吧. 这里不复述Redis Cluster基础的东西, 需先看官方文档.
Redis Cluster 要求客户端使用新的协议, 我们公司为此开发了 corvus 这个proxy来让客户端可以继续使用单机Redis协议. 不像twemproxy和codis, corvus本身无状态, 不需要依赖zookeeper, 而只是从redis集群中拉取集群信息来做路由表.
Redis Cluster 使用gossip协议维护集群信息. 它的gossip协议并不能严格保证集群信息一致, 在误用或极端情况下, 集群信息并不能自动恢复一致, 而且不容易修复. 使用Redis Cluster需要理解透其中原理, 不随意乱做变更操作, 并且要有一套成熟的运维系统.
我们的业务对缓存可用性要求较高, 使用Redis Cluster的方针是首要保证能够快速创建一个可用的集群, 其次要严格限制可对集群做的变更操作, 还有尽可能用小集群.

集群信息一致性问题

这里不是指数据的一致性, 而是集群信息的一致性. 最重要的两个集群信息是主从角色和slot的归属. 个人感觉集群信息管理松散混乱, 但是在一般情况下能维持一致性. 如果真出现了不一致的问题, 建议不要浪费时间, 直接重建集群吧. 有些坑不是一时半会能解决的.
为什么我不提节点列表的一致性问题? 固然集群里面有哪些节点这个信息可以说是所有其它信息的基础, 但是从实用的角度来说, 这可以由运维系统来保证不出问题, 下面另述.
主从和slot的一致性是由epoch来管理的. epoch就像Raft中的term, 但仅仅是像. 每个节点有一个自己独特的epoch和整个集群的epoch, 为简化下面都称为node epoch和cluster epoch. node epoch一直递增, 其表示某节点最后一次变成主节点或获取新slot所有权的逻辑时间. cluster epoch则是整个集群中最大的那个node epoch. 我们称递增node epoch为bump epoch, 它会用当前的cluster epoch加一来更新自己的node epoch.
在使用gossip协议中, 如果多个节点声称不同的集群信息, 那对于某个节点来说究竟要相信谁呢? Redis Cluster规定了每个主节点的epoch都不可以相同. 而一个节点只会去相信拥有更大node epoch的节点声称的信息, 因为更大的epoch代表更新的集群信息.
原则上:
(1)如果epoch不变, 集群就不应该有变更(包括选举和迁移槽位)
(2)每个节点的node epoch都是独一无二的
(3)拥有越高epoch的节点, 集群信息越新

Epoch Collision

实际上, 在迁移slot或者使用cluster failover的时候, 如果多个节点同时bump epoch, 就有可能出现多个节点拥有同一个epoch, 违反上述原则(2)和(3). 这个时候拥有较小node id的节点就会自动再一次bump epoch, 以保证原则(3). 而原则(2)实际上因此也并不严格成立, 因为解决epoch collision需要一小段时间.

选举

从节点选举的时候其实没什么问题, 就是一个从节点抢选票的过程. 我们称管理相同slot集合的所有主从节点为一个分片. 选举的时候, 挂掉分片的所有从节点会向其它分片的所有主节点索取选票, 如果取到的选票超过分片数的半数, 该从节点就选举成功.

slot

最大的问题在于slot. 我们遇到过数次迁移slot失败后出现slot不一致的情况. 如果还没搞懂它怎么管slot, 请记住下面这句话:
不要用乱用cluster setslot node.
我相信大多数不一致问题都是我们作死用这个命令造成的. 除了它我暂时还没找到有什么大概率的情况会导致不一致.

slot 管理

首先我们搞清楚slot究竟是怎么管的. 每个节点都有一份16384长的表对应每个slot究竟归哪个节点, 并且会保存当前节点所认为的其它节点的node epoch. 这样每个slot实际上绑定了一个节点及其node epoch. 然后由自认为拥有某slot的节点来负责通知其它节点这个slot的归属. 其它节点收到这个消息后, 会对比该slot原先绑定节点的node epoch, 如果收到的是更大的node epoch则更新, 否则不予理睬. 除此之外, 除了使用slot相关命令做变更, 集群没有其它途径修改slot的归属.

     slot x 是我管的, 我的node epoch是 y
node A ------------------------------> node B
          (原来slot x归node C管, 如果 y 比 node C 的node epoch大, 我就更新slot x的归属)

这实际上依赖上述的原则(3), 并且相信slot的旧主人还没有更新epoch.

迁移slot的一致性

下面来看迁移slot如何保证slot归属的一致性.
从node A迁移一个槽位到node B的流程是:
(1) node A 设置migrating flag, node B 设置importing flag
(2) 迁移所有该slot的数据到node B
(3) 对两个节点使用cluster setslot node来消除importing和migrating flag, 并且设置槽位
重点在于迁移最后一步消除importing flag使用的cluster setslot node, 如果对一个节点使用cluster setslot node的时候节点有importing flag, 节点会bump epoch, 这样这个节点声称slot所有权时别的节点就会认可.
但是这里并没有跑一遍选举中的投票流程. 如果另外一个节点也同时bump epoch, 就出现epoch collision. 这里是一个不完美但又略精妙的地方. 不管这个清importing flag的节点在解决collision后是否获得更高的epoch, 其epoch肯定大于migrating那个节点之前的epoch.
但这里还是有漏洞, 万一node B在广播自己的新node epoch前, node A做了什么变更而获取了一个更大的node epoch呢? 万一发生collision的是node A和node B两个节点呢? 这个时候假如node A的node id更小, node A会拿到更大的新epoch. 只要某个节点先收到node A的消息, 这个slot的迁移信息就永远写不进这个节点了, 因为node A的node epoch比node B更大.
上面提到的cluster setslot node的问题在于, 如果节点没有importing flag, 它会直接设置槽位, 但不会增加自己的node epoch. 这样当他告诉别的节点对这个槽位的所有权时, 其他节点并不认可. 这实际上违反了上述原则(1). 详细见这里. 所以实在要在迁移slot以外的地方用这个命令, 必须要给它发一次cluster bumpepoch.

运维系统

运维成百上千大大小小的集群不是写脚本能胜任的事情. 官方那个Ruby脚本绝对不能作为最终方案. 现在我们的方案是以一个可靠的运维系统为基础把Redis Cluster池化.

检查, 容错, 重试, 回滚

实际运维的时候会有各种极端情况. 做任何变更操作, 都要先确保集群是一致并且稳定的. 稳定是指已经没有还没同步的信息, 例如多个主节点有相同的epoch而未处理. 如果集群本身不稳定, 有可能触发上述迁移slot的时候发生epoch collision. 而且对于每一步操作, 一定要检查前提条件是否成立, 例如迁slot最后用cluster setslot node时需先检查有没有importing flag. 还要确保操作是否完成. Redis回一个OK并不能表示操作没有问题, 因为大部分redis变更命令都是异步的. 例如踢节点的时候, 假如过了60秒还有节点认为被踢的节点还在, 就会因为gossip的传播把那个节点重新加进集群.
还要有容错. 例如在对集群操作的时候Redis给你返回Loading Error, 这个时候Redis是处于不能处理大部分命令的状态, 连cluster nodes都不能. 这个时候运维系统要等待并不断检查节点可以接受命令没有.
基本上每个变更操作都是大操作, 操作跑到一半可能只是部分挂了, 这时要重试, 实在不行要尽可能回滚.

用chunk管理节点

为了简化管理, 我们规定了集群的规格. 具体做法是每个主节点有且只有一个从节点. 并且以4个节点为最小的管理单位, 我们称为chunk. 一个chunk有两主两从, 分布在两台机器上面, 每台机器两个节点, 且4个节点内互相组成主从关系, 要求负责一个分片的主从分布在不同的机器上面.

一个chunk:
machine A    machine B
 master 1 \/ master 2
  slave 2 /\ slave 1

所有的集群都由 n 个chunk组成而成.
首先为了方便管理部署了不同集群的机器, 要把节点分组管理才容易. 其次, 这么做保证了主从不可能在同一台机器上面. 然后在扩容跟缩容的时候, 只要增加或剔除chunk就好了, 可以尽可能平均每台机器的节点数, 但又不会破坏主从关系. 并且要求一个集群使用的机器数量最少为3台, 这样一台挂了也不会导致有slot没人管. 我们曾想过用6个节点为一个chunk, 但是在分配chunk的时候找不出一种好的分配算法, 而4个却找到了分配算法.
我们只使用1主对应1从, 是因为我们还未发现多个从节点有什么好处, 而且从节点不能顶请求压力还因为主从同步消耗不少资源. 如果把读分一部分流量到从节点还会读到旧数据, 而且还提高选举延迟发生的概率.
并且应当关掉replica migration, Redis Cluster自身管理松散, 但实践中应当严格规定好节点的分布.

chunk分配算法

下面简述如何分配chunk. 输入是每台机器的节点数, 要求拥有最多节点数的机器上的节点数, 不能超过总节点数的一半. 并且每台机器的节点数是偶数, 总节点数是4的倍数(一个chunk4个节点). 算法会把这些节点按照chunk的定义组成一个一个chunk, 并且一定能找到一种分配结果.
算法每次循环:
(1)找出还没组成chunk的节点数最多的那台机器
(2)然后再找出这台机器跟哪台机器拥有最少的共同chunk数
(3)从这两台机器各取两个节点, 组成一个chunk
其中(1)保证了算法能终止. (2)使一台机器挂掉后, 主从切换后, 压力能够尽可能平均分到多台机器上.
我们证明了算法能终止, 关键点是每次循环拥有最多节点数的机器上的节点数, 不超过总节点数的一半能一直成立, 证明这里就不写了.

下面是各个运维操作要怎么做.

创建集群

用上述的分配算法算好哪台机器部署哪些节点, 然后往上面部署. 我们没有用官方那个冗长的流程来创建集群, 而是伪造nodes.conf这个用来存集群信息的文件, 然后把相应的节点进程都拉起来, 最后调整一下主从角色(因为拉起集群的时候可能发生了主从切换), 这样一个集群就好了. 用这种办法还有个好处, 我们可以自己构造node id, 把用于管理的元信息放在里面.

扩容

首先用建集群的方法建一个没有槽位的集群, 然后用cluster meet把两个集群融合起来, 等待所有新节点都成功加进去了, 再去均分槽位. 如果有节点硬是加不进去(一直处于handshake), 踢掉所有新节点, 重新来过. 因为总是可以回滚干净, 所以不用担心扩容失败会导致集群不一致.

下面的操作还未实现, 先给出方案.

并行迁移slot

有人在github给Redis提过这个需求, 希望脚本可以并行迁slot, 作者似乎不想实现这个功能. 迁移slot一直都是一个很慢的操作, redis已经改了几次方案了, 但明明并行迁移就可以大大加快迁移速度, 而且只要运维脚本去做就好了, 为什么作者不这么做呢? 我猜测是怕一致性会有问题. 上面提到, 如果migrating和importing的两个节点都bump epoch, 是有可能导致集群信息不一致的. 但实际上还是可以做的. 因为基本上在迁移槽位的时候, 一个节点要么是迁入方, 要么是迁出方, 迁出方除非发生什么特殊情况, 例如epoch collision, 不然是不会bump epoch的. 防止epoch collision的办法是操作前先查一遍集群的epoch稳定了没有. 另外, 在cluster setslot node之后, 要查一遍是不是所有节点都认可了自己的所有权, 如果不是, 先cluster bumpepoch, 然后再靠gossip来广播. 如果检查一段时间后发现还是没得到所有节点的认可, 重复上述流程直到所有节点都认同自己对slot的所有权.

迁移机器

有时候机器挂了或者有问题, 想把集群某台机器的节点迁移到另一台机器上. 这个时候可以把nodes.conf文件拷贝到新机器上, 改掉nodes.conf中的ip, 把原节点关掉, 把新节点拉起来, 加进去集群里面. 这利用了只要节点的node id一样, Redis就会把新节点替换掉原节点, 并且自动更新ip和port.

备份集群

这个主要是为了绕过集群不一致的问题. 在做迁移slot前, 先copy一份rdb文件在本地, 如果集群出现不一致并且难以修复, 在原来的机器上重新建立一个除了节点port, 其它跟迁移slot前一模一样的集群, 并且用上之前备份的rdb文件. 最后把不一致的集群删掉, 用新集群替换老集群.

吐槽

Redis Cluster一个进程一个节点会导致难以管理集群. 从方便管理的角度来看, 一个集群在一台机器应当只有一个集群实例, 用多线程或多进程, 每个线程/进程管理该实例的一部分槽位. 现在这种单进程的做法导致大集群产生很大的ping包流量, 有一个几百个节点的集群光放在那里没有任何请求都有300MB的流量.
Redis Cluster的集群协议理论上只保证了正常流程中集群信息能一致. 只要有一套完善的运维系统, 它仍然是一个不完美但可用的方案.

编辑于 2017-02-07