《Redis开发与运维》读书笔记(十)集群(未完成)

第十章 集群

Redis Cluster在Redis 3.0版本提供,解决分布式需求。在此之前的解决方案:

  • 客户端分区:优点是分区逻辑可控,缺点是需要自己处理数据路由、高可用、故障转移等问题。
  • 代理方案:优点是简化客户端分布式逻辑和升级维护便利,缺点是加重架构部署复杂度和性能损耗。

10.1 数据分布

10.1.1 数据分布理论

分布式首先要解决的问题是:将整个数据集按照分区规则映射到多个节点。常见的数据分区规则有哈希分区和顺序分区。

(1)节点取余分区

  • 方案:使用特定数据(Redis键或用户ID),根据节点数量通过哈希公式来决定映射的节点:hash(key) % N 。扩容时采用翻倍扩容,避免数据映射全部被打乱导致全量迁移的情况。
  • 优点:简单,常用于分库分表规则,提前根据数据量划分好分区数。
  • 缺点:当节点数量变化,需要重新计算,导致数据重新迁移。

(2)一致性哈希分区

  • 方案:为系统中每个节点分配一个token,范围一般在 0 ~ 2^32^ ,所有token构成一个哈希环。数据读写执行节点查找操作时,先根据key计算hash值,然后顺时针找到第一个大于等于哈希值的token节点。

  • 优点:加入和删除节点只影响哈希环中相邻的节点,对其它节点无影响。

  • 缺点:

    • 加减节点会造成哈希环中部分数据无法命中,需要手动处理掉,因此常用于缓存场景。
    • 当使用少量节点时,节点变化将大范围影响哈希环中的数据映射,不适合少量数据的分布式方案。
    • 增减节点需要增加或减少一半的节点才能保证数据负载均衡。

(3)虚拟槽分区

巧妙使用哈希空间,使用分散度良好的哈希函数把所有数据映射到一个固定范围的整数集合中,每个整数看作一个槽(slot,范围在0~16383),是集群内数据管理和迁移的基本单位。主要目的是方便数据拆分和集群扩展。

每个节点负责一定范围的槽,每个槽的数据都比较均匀,将数据平均划分到这些节点。

10.1.2 Redis数据分区

Redis Cluster采用虚拟槽分区,计算公式:slot = CRC16(key) & 16383 。每个节点负责维护一部分槽以及其所映射的键值数据。

特点:

  • 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
  • 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
  • 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

10.1.3 集群功能限制

集群比单机模式有一些限制:

  1. key批量操作支持有限。如mset、mget,目前只支持相同slot值得key执行批量操作。
  2. key事务操作支持有限。只支持多key在同一节点事务操作。
  3. key作为数据分区的最小粒度,不能将一个大的键值对象如hash、list映射到不同节点。
  4. 不支持多数据库空间,单机可以支持16个数据库,集群只能使用db0。
  5. 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。

10.2 搭建集群

10.2.1 准备节点

节点数量要至少为6才能保证高可用。每个节点需要开启cluster-enabled yes,启用集群模式。建议所有节点统一目录,划分为三个conf、data、log,分别存放配置、数据和日志相关文件。将6个节点配置统一放在conf下:

1
2
3
4
5
6
7
8
# 节点端口
port 6379
# 开启集群模式
cluster-enabled yes
# 节点超时时间,单位毫秒
cluster-node-timeout 15000
# 集群内部配置文件,命名规则redis-{port}.conf
cluster-config-file "nodes-6379.conf"

依次启动所有节点:

1
2
3
4
5
$ redis-server conf/redis-6379.conf
......
$ redis-server conf/redis-6384.conf
# 检测节点日志是否正确
$ cat log/redis-6379.log

首次启动若没有集群配置文件,会根据cluster-config-file自动创建,通过端口区分不同节点防止同一机器下多个节点彼此覆盖。

当集群内节点信息发生变化,如添加节点、节点下线、故障转移等。节点会自动保存集群状态到配置文件中。Redis会自动维持集群配置文件,不需要手动修改。文件中记录的文件ID,是一个40位16进制字符串,用于唯一标识集群内一个节点,不同于运行ID的是只在集群初始化创建一次,后者则每次重启都会变化。

通过 cluster nodes 命令获取集群节点状态,节点之间不知道彼此存在,只能通过节点握手才能建立联系。

10.2.2 节点握手

指一批集群模式的节点通过Gossip协议进行通信,由客户端发起命令:cluster meet {ip} {port} 。如图客户端节点让6379与6380握手通信。

cluster meet是一个异步命令,执行后立即返回。主要作用是交换节点状态信息。

  1. 节点6379本地创建6380的节点对象,发送meet消息。
  2. 节点6380收到消息后,保存并回复pong消息。
  3. 之后二者定期通过ping/pong消息进行通信。

只需在任意节点上执行命令加入新节点,握手状态会在集群内传播,节点能自动发现新节点并发起握手。

全部节点建立握手后,集群并不能立即工作,此时处于下线状态,禁止所有的数据读写。被分配的槽是0,因为没有映射的节点,只有所有槽都分配给节点后才进入在线状态。

10.2.3 分配槽

将所有数据映射到16384个槽中,每个key会映射到固定的槽,之后才能响应和槽相关的命令。通过命令 cluster addslots 为节点分配槽,通过bash批量设置:

1
2
3
$ redis-cli -h 127.0.0.1 -p 6379 cluster addslots {0...5461}
$ redis-cli -h 127.0.0.1 -p 6380 cluster addslots {5462...10922}
$ redis-cli -h 127.0.0.1 -p 6381 cluster addslots {10923...16383}

分配后,集群进入在线状态。

目前只分配了3个节点,每个节点还需要一个从节点来保证故障时进行转移。首次启动的节点和被分配槽的都是主节点,从节点负责负责主节点槽信息和数据。通过命令 cluster replicate {nodeId} 使节点变为从节点(nodeId为主节点Id)。

1
2
3
4
5
6
127.0.0.1:6382>cluster replicate cb.......
OK
127.0.0.1:6383>cluster replicate 8e.......
OK
127.0.0.1:6384>cluster replicate 4b.......
OK

主从节点之间仍使用主从复制机制。

10.2.4 用redis-trib.rb搭建集群

redis-trib.rb 是Ruby实现的集群管理工具,通过Cluster相关命令简化集群创建、检查、槽迁移和均衡等常见运维操作。

准备节点:

1
2
3
4
5
6
redis-server conf/redis-6481.conf
redis-server conf/redis-6482.conf
redis-server conf/redis-6483.conf
redis-server conf/redis-6484.conf
redis-server conf/redis-6485.conf
redis-server conf/redis-6486.conf

创建集群:使用 redis-trib.rb create 命令完成节点握手和槽分配过程。replicates指定每个主节点配置多少了从节点,命令会尽可能保证主从节点不分配同一台机器,所以顺序会有所不同。提供的节点必须不包含任何槽/数据,否则会拒绝创建集群

1
2
redis-trib.rb create --replicates 1 127.0.0.1:6481 127.0.0.1:6482 127.0.0.1:6483 127.0.0.1:6484 127.0.0.1:6485 127.0.0.1:6486
# 会打印相关信息,先主后从,输入yes同意计划

集群完整性检查:所有的槽都分配到存活的节点上,只要有一个就表示不完整。通过 redis-trib.rb check 命令检测,只要提供一个节点地址就可以检查整个集群的情况。


10.3 节点通信

10.3.1 通信流程

  • 元数据:节点负责哪些数据,是否出现故障等。
  • 维护元数据方式:集中式和P2P方式。Redis采用P2P的Gossip(流言)协议,节点彼此不断的交换信息。

通信过程说明:

  1. 集群每个节点都会单独开辟一个TCP通道,通信端口号在基础端口号上加10000。
  2. 每个节点在固定周期内通过特点规则选择几个节点发送ping消息。
  3. 接收到ping消息的节点用pong消息响应。

每个节点通过固定规则选择要通信的节点,可能知道全部或部分节点,只要节点间可以正常通信,最终集群会达到一致的状态。

10.3.2 Gossip消息

Gossip消息是信息交换的载体,可以分类为:

  • meet消息:通知新节点加入,通知接收者加入当前集群,并开始周期性的ping/pong交换。
  • ping消息:集群内交换最频繁的消息,每个节点每秒向多个节点发送,封装了自身和其他部分节点的状态数据,来检测节点是否在线以及交换彼此状态。
  • pong消息:作为meet或ping消息的响应确认通信正常,封装了自身状态数据。节点也可以向集群广播自身pong消息来更新自身状态。
  • fail消息:当节点判断集群内另一个节点下线时,广播该消息,其他节点收到后会将其更新为下线状态。

消息分为消息头和消息体,消息头结构:

集群中所有消息都采用相同的消息头结构clusterMsg,包含如节点id、槽映射、节点标识等。消息体则采用clusterMsgData结构:

消息类型通过消息头的type字段来区分。每个消息体包含该节点的多个clusterMsgDataGossip结构数据,用于信息交换:

接收到ping或meet消息时,接收节点处理流程:

  • 解析消息头:如果发送节点是新节点且消息是meet类型,则加入到本地节点列表;如果是已知节点,则尝试更新发送节点的状态,如映射关系、主从角色等。
  • 解析消息体:如果clusterMsgDataGossip数组包含的节点是新节点,则尝试发起握手流程;如果是已知节点,则根据flags判断是否下线,用于故障转移。

10.3.3 节点选择

集群内节点通信采用固定频率(定时任务每秒执行10次),节点每次选择需要通信的节点列表非常重要,选择过多会导致成本过高;选择过少会降低交换频率影响故障判定、新节点发现速度等。

消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量。

(1)选择发送消息的节点数量

每次会随机选择5个节点找出最久没有通信的节点发送ping消息,用来保证Gossip的随机性。每100毫秒都会扫描本地节点列表,如果发现节点最久一次接受pong消息的时间大于cluster_node_timeout / 2,则立刻发送ping消息。

每个节点每秒需要发送的ping消息数量:1 + 10 * num (node.pong_received > cluster_node_timeout / 2) 。所以参数cluster_node_timeout影响到消息发送节点数,当带宽紧张时可以适当调大该参数。如果过度调大会影响消息交换的频率从而影响故障转移、槽信息更新、新节点发现速度。

(2)消息数据量

消息头占用空间的字段是myslots[CLUSTER_SLOTS/8],相对固定的占用2KB。消息体会携带一定数量的其他节点信息用于信息交换:

消息体携带数据量和集群节点数目紧密相关,因此集群规模越大通信的成本也越高。


10.4 集群伸缩

10.4.1 伸缩原理

不影响对外服务的情况下,Redis集群也可以动态添加或下线节点。

原理可以抽象为槽和对应数据在不同节点之间灵活移动,假设关系如下:

三个主节点分别维护自己的槽和数据,此时添加一个节点来扩容集群:

每个节点都把负责的一部分槽和数据迁移到新节点。集群伸缩 = 槽和数据在节点之间移动

10.4.2 扩容集群

扩容包括三个步骤:

(1)准备新节点

提前准备好节点并运行在集群模式下,配置好并启动节点。首先会作为孤儿节点运行,此时结构:

(2)加入集群

新节点在任意节点通过 cluster meet 命令加入到集群。集群间一段时间的交换信息后统一发现新节点。

新节点刚开始都是主节点状态,但因为没有负责的槽,所以不能接受任何读写操作。后续有两种选择:

  • 为它迁移槽和数据实现扩容。
  • 作为其它主节点的从节点负责故障转移。

redis-trib.rb也实现了添加新节点命令,并且还支持直接添加为从节点:

1
redis-trib.rb add-node new_host:new_port existing_host:existing_port --slave --master-id <arg>

该命令内部会执行新节点状态检查,如果新节点已经加入其它集群或包含数据,会放弃加入操作并打印如下信息:

1
[ERR] Node 127.0.0.1:6385 is not empty. Either the node already knows other nodes (check with CLUSTER NODES) or contains some key in database 0.

(3)迁移槽和数据

迁移槽的过程集群可以正常提供读写服务。槽是Redis集群数据管理的基本单位,首先要为新节点制定槽的迁移计划,确认原节点有哪些槽要迁移到新节点。迁移计划要确保最终每个节点负责相似数量的槽,从而保证各个节点的数据均匀。

迁移计划确定后开始逐个迁移数据。

数据迁移过程是逐个槽进行的:

  1. 对目标节点发送 cluster setslot {slot} importing {sourceNodeId} 命令,让目标节点准备导入槽的数据。
  2. 对源节点发送 cluster setslot {slot} migrating {targetNodeId} 命令,让源节点准备迁出槽的数据。
  3. 源节点循环执行 cluster getkeysinslot {slot} {count} 命令,获取count个属于槽 {slot} 的值。
  4. 在源节点执行 migrate {targetIp} {targetPort} “” 0 {timeout} keys {key…} 命令,把获取的键通过流水线机制批量迁移到目标节点(该命令在3.0.6版本前只能单个执行,批量可以极大降低网络IO次数)。‘
  5. 重复执行步骤3和4直到槽下所有的键值数据迁移到目标节点。
  6. 向集群内所有主节点发送 cluster setslot {slot} node {targetNodeId} 命令,通知槽分配给目标节点。为了保证槽节点映射及时传播,需要遍历发送给所有主节点更新被迁移的槽执行新节点。

redis-trib提供了槽重分片功能:

1
redis-trib.rb reshard host:port --from <arg> --to <arg> --slots <arg> --yes --timeout <arg> --pipeline <arg>
  • host:port :必传参数,集群内任意节点地址,用来获取整个集群信息。
  • --from :制定源节点的id,如果有多个源节点,使用逗号分隔,如果是all源节点变为集群内所有主节点,在迁移过程提示用户输入。
  • --to :需要迁移的目标节点的id, 只能填写一个,在迁移过程提示用户输入。
  • --slots :需要迁移槽的总数量,在迁移过程提示用户输入。
  • --yes :当打印出reshard执行计划时,是否需要用户输入yes确认后再执行reshard。
  • --timeout :控制每次migrate操作的超时时间,默认为60000毫秒。
  • --pipeline :控制每次批量迁移键的数量,默认为10。

打印出集群每个节点信息后,reshard命令需要确认迁移的槽数量。因为槽应用hash运算,无需强制要求节点负责槽的顺序性。

最后可以把6386作为6385的从节点来保证整个集群的高可用。使用 cluster replicate {masterNodeId} 命令,slaveof 在集群模式下不支持。从节点内部除了对主节点发起全量复制之外,还需要更新本地节点的集群相关状态。

10.4.3 收缩集群

收缩意味着缩减规模,需要从现有集群中安全下线部分节点。

流程:

  • 首先确定是否有槽,有则需要先迁移到其他节点。
  • 下线节点不再负责槽或是从节点时,可以通知集群其他节点忘记下线的节点,所有节点都忘记后可以关闭。

(1)下线迁移槽

原理与节点扩容的迁移槽过程相同。下线6381和6384,6381是主节点,6384是它的从节点。收缩与扩容迁移方向相反,直接使用 redis-trib.rb reshard 命令完成槽迁移。因为每次执行只有一个目标节点,需要连续执行三次。

(2)忘记节点

下线节点的槽迁移完毕,剩下就是让集群忘记该节点。需要一种健壮的机制让其他节点不再与下线节点进行Gossip消息交换。Redis提供 cluster forget {downNodeId} 命令来实现该功能。

节点收到命令后把nodeId指定的节点加入到禁用列表中,有效期为60秒,超过后会再次参加消息交换。线上不建议直接使用该命令下线节点,需要跟大量节点交互,实际操作繁琐且容易遗漏节点。建议使用 redis-trib.rb del-node {host:port} {downNodeId} 命令。

del-node命令实现了安全下线的后续操作,对于主从节点都下线的场景,要先下线从节点,否则会有不必要的全量复制。


10.5 请求路由

Redis集群对客户端通信协议做了较大的修改,为了追求性能最大化,没有采用代理的方式而是客户端直连节点的方式。

10.5.1 请求重定向

集群模式下,Redis接收任何键相关命令时首先要计算对应的槽,再根据槽找到对应节点,若节点是自身则处理键命令;否则回复MOVED重定向错误,通知客户端请求正确的节点。

命令 cluster keyslot {key} 返回key对应的槽。重定向的信息包含键对应的槽以及对应节点地址,方便客户端再向正确节点发送。使用redis-cli命令可以添加 -c 参数支持自动重定向。

节点只回复重定向响应,并不负责转发。

(1)计算槽

根据键的有效部分使用CRC16函数计算出散列值,再取对16383的余数,使每个键都可以映射到0~16383槽范围内。

伪代码:如果键内容包括大括号字符,计算槽的有效部分是括号内的内容,否则是键的全部内容。

括号内的内容又叫hash_tag,可以利用其让不同键有相同slot,常用于IO优化。比如集群模式下使用mget等批量调用时,键列表必须有相同的slot,否则会报错。

1
2
3
4
5
127.0.0.1:6385> mget user:10086:friends user:10086:videos
(error) CROSSSLOT Keys in request don`t hash to the same slot
127.0.0.1:6385> mget user:{10086}:friends user:{10086}:videos
1) "friends"
2) "videos"

(2)槽节点查找

集群内通过消息交换,每个节点都知道所有节点的槽信息,保存在clusterState结构中:

slots数组表示槽和节点对应关系,实现请求重定向伪代码:

1
2
3
4
5
6
7
8
def execute_or_redirect(key) :
int slot = key_hash_slot(key);
# 借助slots数组判断
ClusterNode node = slots[slot];
if (node == clusterState.myself) :
return executeCommand(key);
else :
return '(error) MOVED (slot) {node.ip}:{node.port}';

这种随机连接任一节点获取键对应节点的客户端叫Dummy客户端,实现简单但每次都可能需要重定向增加了IO开销。Redis集群客户端通常都采用另一种Smart客户端。

客户端选择参考:Redis-Client

10.5.2 Smart客户端

(1)原理

Smart客户端通过内部维护slot->node的映射关系,本地就可以实现键到节点的查找。MOVED重定向负责协助客户端更新该关系。

流程:

  1. 首先在JedisCluster初始化时会选择一个运行节点,初始化槽和节点映射关系,使用 cluster slots 命令完成。
  2. JedisCluster解析 cluster slots 结果缓存在本地,并为每个节点创建唯一的JedisPool连接池。映射关系在JedisClusterInfoCache类中。
  3. JedisCluster执行键命令:
    1. 计算slot并根据slots缓存获取目标节点连接,发送命令。
    2. 如果出现连接错误,使用随机连接重新执行键命令,每次命令重试对redirections参数减一。
    3. 捕获到MOVED重定向错误,使用 cluster slots 命令更新slots缓存。
    4. 重复执行1到3步,直到命令执行成功,或当redirections<=0时抛出JedisClusterMaxRedirectionsException异常。

Smart客户端需要结合异常和重试机制时刻保证集群的slots同步,存在问题:

  1. 客户端内部维护slots缓存表,并且针对每个节点维护连接池,当集群规模较大时,客户端会维护非常多的连接,消耗大量内存。

  2. 常见错误 JedisClusterMaxRedirectionsException(“Too many Cluster redirections?”) 隐藏了内部错误细节,原因是节点宕机或请求超时都会抛出 JedisConnectionException,触发了随机重试,当重试次数耗尽会抛出该异常。

  3. JedisConnectionException 出现时,可能是集群节点故障需要随机重试来更新slots缓存,有几种情况会抛出此异常:

    • Jedis连接节点发生socket错误时。
    • 所有命令/Lua脚本读写超时。
    • JedisPool连接池获取可用Jedis对象超时。

    前两点需要更新slots缓存,第三点则不需要,所以2.8.1版本后改为抛出JedisException。

  4. Redis集群支持自动故障转移,故障发现到完成转移需要一定时间,宕机期间指向该节点的命令都会触发随机重试,每次收到MOVED重定向后都会调用JedisClusterInfoCache的renewSlotCache方法:

    先获取写锁,再执行 cluster slots 命令初始化缓存,集群所有键命令都会执行 getSlotPool 方法计算槽对应节点,内部要求读锁。从而导致所有请求都会造成阻塞,高并发场景比较影响吞吐,这个现象叫cluster slots风暴

    • 重试机制导致IO通信放大问题。比如默认重试5次,当抛出JedisClusterMaxRedirectionsException异常时,内部至少需要9次IO通信:5次发送+2次ping命令保证随机节点正常+2次cluster slots命令初始化slots缓存,导致异常判定时间变长。
    • 个别节点操作异常导致频繁的更新slots缓存,多次调用cluster slots命令,高并发时将过度消耗节点资源,如果集群slot与node映射庞大则命令返回信息越多,问题越严重。
    • 频繁触发更新本地slots缓存操作,内部使用了写锁,阻塞对集群所有键命令的调用。

    2.8.2针对上述问题优化:优化后命令发送次数变为4次重试命令+1次cluster slots命令=5次,同时避免了不必要的cluster slots并发调用。

    • 接收到JedisConnectionException时不再轻易初始化slots缓存,大幅降低内部IO次数:只有当重试次数到最后一次或出现MovedDataException时再执行。

    • 当更新slots缓存时,不再使用ping命令检测节点活跃度,并且使用redis covering变量保证同一时刻只有一个线程更新slots缓存,优化了写锁阻塞和cluster slots调用次数:

(2)JedisCluster

定义

Smart客户端对应类为JedisCluster,初始化:

1
2
3
public JedisCluster(Set<HostAndPort> jedisClusterNode, int connectionTimeout, int soTimeout, int maxAttempts, final GenericObjectPoolConfig poolConfig) {
...
}
  • jedisClusterNode:所有Redis Cluster节点信息,一部分也可以,因为客户端可以通过cluster slots自动发现。
  • connectionTimeout:连接超时时间。
  • soTimeout:读写超时时间。
  • maxAttempts:重试次数。
  • poolConfig:连接池参数。

初始化过程:

JedisCluster可以直接通过get、set等命令调用。使用时需要注意:

  • 包含了所有节点的连接池,建议使用单例。
  • 每次操作完成后,不需要管理连接池的借还,内部已经完成。
  • 不需要执行close操作,会将所有连接池执行destroy。
多节点命令

有些命令如keys、flushall、删除指定模式的键等需要遍历所有节点才能完成。

步骤:

  1. 通过 jedisCluster.getClusterNodes() 获取所有节点的连接池。
  2. 使用 info replication 筛选步骤1的主节点。
  3. 遍历主节点,使用scan命令找到指定模式的key,使用Pipeline机制删除。
1
2
3
4
// 每次遍历1000个key,讲user开头的key全部删除
String pattern = "user*";
int scanCounter = 1000;
delRedisClusterByPattern(jedisCluster, pattern, scanCounter);

(3)批量操作的方法

因为key分布到各个节点,无法实现mget、mset等功能,可以利用CRC16算法计算出key对应的slot,以及Smart客户端保存了slot和节点对应关系的特性,将属于同一个Redis节点的key进行归档,然后分别对每个节点对应的子key列表执行mget或者pipeline操作(无底洞优化)。

(4)使用Lua、事务等特性的方法

Lua、事务所操作的key必须在一个节点上,Cluster提供了hashtag来支持这种场景。步骤:

  1. 将事务中所有key添加hashtag。
  2. 使用CRC16计算hashtag对应的slot。
  3. 获取指定slot对应的节点连接池JedisPool。
  4. 在JedisPool上执行事务。

10.5.3 ASK重定向

(1)客户端ASK重定向流程

槽迁移时,slot对应的数据从源节点到目标节点迁移过程,客户端需要做到智能识别,保证键命令可正常执行。期间可能存在一部分数据在源节点,另一部分在目标节点。当出现这种情况时,客户端命令执行流程发生变化:

  1. 客户端根据本地slots缓存发送命令到源节点,如果存在键对象则直接执行并返回结果给客户端。
  2. 如果键对象不存在,则可能存在于目标节点,这时源节点会回复ASK重定向异常。格式如下:(error) ASK {slot} {targetIP}:{targetPort} 。
  3. 客户端从ASK重定向异常提取出目标节点信息,发送asking命令到目标节点打开客户端连接标识从,再执行键命令。如果存在则执行,不存在则返回不存在信息。

ASK重定向整体流程:

ASK于MOVED虽然都是对客户端重定向控制,但ASK说明集群正在进行slot数据迁移,客户端无法知道何时完成,不会更新slots缓存。MOVED说明键对应的槽已经指定到新的节点,需要更新slots缓存。

(2)节点内部处理

为了支持ASK重定向,源节点和目标节点在内部的clusterState结构中维护当前正在迁移的槽信息,用于识别槽迁移情况:

1
2
3
4
5
6
7
typedef struct clusterState {
clusterNode *myself; /* 自身节点 */
clusterNode *slots[CLUSTER_SLOTS]; /* 槽和节点映射数组 */
clusterNode *migrating_slots_to[CLUSTER_SLOTS]; /* 正在迁出的槽节点数组 */
clusterNode *importing_slots_from[CLUSTER_SLOTS]; /* 正在迁入的槽节点数组 */
...
} clusterState;

节点每次接收到键命令,会根据clusterState内的迁移属性进行命令处理:

  • 如果键所在的槽由当前节点负责,但键不存在则查找migrating_slots_to数组查看槽是否正在迁出,如果是返回ASK重定向。
  • 如果客户端发送asking命令打开了CLIENT_ASKING标识,则该客户端下次发送键命令时查找importing_slots_from数组获取clusterNode,如果指向自身则执行命令。需要注意的是,asking命令是一次性命令,每次执行完后客户端标识都会修改回原状态,因此每次客户端接收到ASK重定向后都需要发送asking命令。
  • 批量操作。ASK重定向对单键命令支持得很完善,但是开发过程中经常要使用批量操作,当槽处于迁移状态时会受到影响。

案例,手动使用迁移命令让槽4096处于迁移状态,数据各自分散在目标和源节点:

1
2
3
4
5
6
7
8
9
10
11
# 6379节点准备导入槽4096数据
127.0.0.1:6379> cluster setslot 4096 importing 1a......
# 6385节点准备导入槽4096数据
127.0.0.1:6385> cluster setslot 4096 importing cf......
# 查看槽4096下的数据
127.0.0.1:6385> cluster getkeysinslot 4096 100
1) "key:test:5028"
2) "key:test:68253"
3) "key:test:79212"
# 迁移键key:test:68253和key:test:79212到6379节点
127.0.0.1:6385> migrate 127.0.0.1 6379 "" 0 5000 keys key:test:68253 key:test:79212

现在槽4096下3个数据分别位于6379和6380两个节点,使用Jedis客户端执行批量操作:

1
2
3
4
5
6
7
8
@Test
public void mgetOnAskTest() {
JedisCluster jedisCluster = new JedisCluster(new HostAndPort("127.0.0.1", 6379));
List<String> results = jedisCluster.mget("key:test:68253", "key:test:79212");
System.out.println(results);
results = jedisCluster.mget("key:test:5028", "key:test:68253", "key:test:79212");
System.out.println(results);
}

测试结果分析:

1
2
3
4
{value:68253, value:79212}
redis.clients.jedis.exceptions.JedisDataException: TRYAGAIN Multiple keys request during rehashing of slot
at redis.clients.jedis.Protocol.processErrpr(Protocol.java:127)
...
  • 第一个mget运行成功,因为键key:test:68253和key:test:79212已经迁移到目标节点,当mget键列表都处于源节点/目标节点时,运行成功。
  • 第二个mget抛出异常,当键列表中任何键不存在于源节点时,抛出异常。

Pipeline代码:

Jedis没有开放slot到Jedis的查询,使用了匿名内部类暴露JedisSlotBasedConnectionHandler。通过Jedis获取Pipeline对象组合3条get命令一次发送。

1
2
3
redis.clients.jedis.exceptions.JedisAskDataException: ASK 4096 127.0.0.1:6379 
redis.clients.jedis.exceptions.JedisAskDataException: ASK 4096 127.0.0.1:6379
value:5028

结果分析:返回结果并没有直接抛出异常,而是把ASK异常JedisAskDataException包含在结果集中。但是使用Pipeline的批量操作也无法支持由于slot迁移导致的键列表跨节点问题。但得益于Pipeline未直接抛出异常,可以借助JedisAskDataException内返回的目标节点信息,手动重定向请求给目标节点:

以上测试能成功的前提是:

  1. Pipeline严格按照键发送的顺序返回结果,即使出现异常也是如此。
  2. 理解ASK重定向之后,可以手动发起ASK流程保证Pipeline的结果正确性。

10.6 故障转移

10.6.1 故障发现

当集群内某个节点出现问题时,需要一种健壮的方式保证识别出节点是否发生故障。故障发现也通过消息传播机制ping/pong来实现。

  • 主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点意见,可能存在误判情况。
  • 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

(1)主观下线

每个节点都会定期向其他节点发送ping消息,接收节点回复pong消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。

流程:

  1. 节点a发送ping消息给节点b,如果通信正常将接收到pong消息,节点a更新最近一次与节点b的通信时间。
  2. 如果节点a与节点b通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点a记录的与节点b最后通信时间将无法更新。
  3. 节点a内的定时任务检测到与节点b最后通信时间超高cluster-node-timeout时,更新本地对节点b的状态为主观下线(pfail)。

每个节点内的clusterState结构需要保存其他节点信息,用于从自身视角判断其他节点的状态。结构关键属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct clusterState {
clusterNode *myself; /* 自身节点 */
dict *nodes; /* 当前集群内所有节点的字典集合,key为节点ID,value为对应节点ClusterNode结构 */
...
} clusterState;

// 字典nodes属性中的clusterNode结构保存了节点的状态
typedef struct clusterNode {
int flags; /* 当前节点状态,如:主从角色,是否下线等 */
mstime_t ping_sent; /* 最后一次与该节点发送ping消息的时间 */
mstime_t pong_received; /* 最后一次接收到该节点pong消息的时间 */
...
} clusterNode;

其中最重要的属性是flags,用于标示该节点对应状态,取值范围如下:

主观下线判断伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 定时任务,默认每秒执行10
def clusterCron():
// ...忽略其他代码
for (node in server.cluster.nodes):
// 忽略自身节点比较
if (node.flags == CLUSTER_NODE_MYSELF):
continue;
// 系统当前时间
long now = mstime();
// 自身节点最后一次与该节点PING通信的时间差
long delay = new - node.ping_sent;
// 如果通信时间差超过cluster_node_timeout,将该节点标记为PFAIL
if (delay > server.cluster_node_timeout):
node.flags = CLUSTER_NODE_PFAIL;

只有一个节点认为主观下线并不能准确判断是否故障:

节点6379与6385通信中断,导致6379判断6385为主观下线状态,但6380与6385节点之间通信正常,这种情况不能判定6385发生故障。

(2)客观下线

当某个节点判断另个节点主观下线后,相应节点状态会跟随消息在集群内传播。ping/pong消息的消息体会携带集群1/10的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态,会在本地找到故障节点的ClusterNode结构,保存到下线报告链表。

1
2
3
4
struct clusterNode { /* 认为是主观下线的clusterNode结构 */
list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */
...
};

通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。存在两个问题:

  1. 为什么必须是负责槽的主节点参与故障发现决策?因为集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。
  2. 为什么半数以上处理槽的主节点?必须半数以上是为了应对网络分区,等原因造成的集群分割情况,被分割的小集群因为无法完成从主观下线到客观下线这一关键过程,从而防止小集群完成故障转移之后继续对外提供服务。

客观下线流程:

  1. 当消息体内含有其他节点的pfail状态会判断发送节点的状态,如果发送节点是主节点则对报告的pfail状态处理,从节点则忽略。
  2. 找到pfail对应的节点结构,更新clusterNode内部下线报告链表。
  3. 根据更新后的下线报告链表尝试进行客观下线。

  • 维护下线报告链表

    每个节点ClusterNode中都会维护一个下线链表,保存其他节点对该节点的下线报告:

    1
    2
    3
    typedef struct clusterNodeFailReport {
    struct clusterNode *node; /* 报告该节点为主观下线的节点 */ mstime_t time; /* 最近收到下线报告的时间 */
    } clusterNodeFailReport;

    当接收到fail状态时,会维护对应节点的下线报告链表。每个下线报告都有有效期(cluster-node-time * 2 时间内需要收集一般以上槽节点的下线报告),过期会在尝试客观下线时检测出并删除。这个限制主要是针对故障误报。

  • 尝试客观下线

    节点每次收到其他节点的pfail状态,都会尝试触发客观下线。

fail消息的消息体只包含故障节点的ID,广播fail消息是客观下线的最后一步:

  • 通知集群内所有节点标记节点客观下线并立即生效。
  • 通知故障节点的从节点完成故障转移流程。

当出现网络分区时,集群可能被切为一大一小两块,大的集群持有半数槽节点完成客观下线广播fail消息,小的集群则无法收到。但当网络恢复后,最终还是会通过Gossip消息传播到所有节点。

10.6.2 故障恢复

下线节点是持有槽的主节点,需要从它的从节点选一个代替。从节点通过内部定时任务发现其所属主节点进入客观下线,就会触发故障恢复流程。

(1)资格检查

每个从节点检查最后与主节点断开时间,如果超过 cluster-node-time * cluster-slave-validity-factor(默认为10),则该从节点不具备资格。

(2)准备选举时间

从节点具有资格则更新触发故障选举的时间,到达该才继续流程,相关字段:

1
2
3
4
5
struct clusterState {
...
mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */
int failover_auth_rank; /* 记录当前从节点排名 */
}

采用延迟触发机制的原因:通过对多个从节点使用不同选举时间来支持优先级问题,复制偏移量越大说明从节点延迟越低,则其就应该有更高的优先级替代主节点。越高优先级的从节点越早触发故障选举流程。

(3)发起选举

从节点定时任务检测到达故障选举时间(failover_auth_time),发起选举流程:

  1. 更新配置纪元:

    是一个只增不减的整数,每个主节点用其标识自身版本,从节点会复制此纪元。整个集群会维护一个全局的配置纪元(clusterState.currentEpoch),用来记录集群内最大版本。可以通过 cluster ingo 查看配置。

    1
    2
    cluster_current_epoch // 集群最大配置纪元
    cluster_my_epoch // 当前主节点配置纪元

    配置纪元随着ping/pong消息在集群内传播,当发送和接收都是主节点且纪元相等表示出现冲突,nodeId更大的一方会递增全局纪元并赋值给当前节点来区分冲突。

    作用:

    • 标识集群内每个主节点的不同版本和集群最大版本。
    • 每次集群发生重要事件时(指出现新的主节点),从节点竞争选举,都会递增全局配置纪元并赋值给相关主节点,用来记录这一事件。
    • 主节点具有更大配置纪元代表更新的集群状态,当节点间进行ping/pong消息交换时,如出现slots等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。

    应用场景:

    • 新节点加入。
    • 槽节点映射冲突检测。
    • 从节点投票选举冲突检测。
  1. 广播选举消息:集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同ping消息只是将type类型变为FAILOVER_AUTH_REQUEST。

(4)选举投票

只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),其在一个配置纪元内都有唯一的选票,接到一个请求投票的从节点消息时回复FAILOVER_AUTH_ACK消息作为投票,相同配置纪元内其他从节点的选举消息将忽略。

投票过程其实是一个领导者选举的过程,每个配置纪元内持有槽的主节点只能投票给一个从节点,只有一个从节点会获得N/2+1的选票。Redis不使用从节点进行领导者选举,因为从节点数必须大于等于3个才能保证凑够N/2+1个节点,将导致从节点资源浪费。使用主节点的方案即使只有一个从节点也可以完成选举。

从节点收集到N/2+1个持有槽的主节点投票时,从节点执行替换主节点操作:

故障主节点也算在投票数内,所以容易因选票不足导致故障转移失败,部署时需要部署3台物理机以上避免单点问题。

投票作废:每个配置纪元代表了一次选举周期,如果在开始投票后的cluster-node-time*2时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

(5)替换主节点

  1. 当前从节点取消复制变为主节点。
  2. 执行clusterDelSlot操作撤销故障主节点负责的槽,并执行clusterAddSlot把这些槽委派给自己。
  3. 向集群广播自己的pong消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。

10.6.3 故障转移时间

估算故障转移时间:

  1. 主观下线(pfail)识别时间=cluster-node-timeout。
  2. 主观下线状态消息传播时间<=cluster-node-timeout/2。消息通信机制对超过cluster-node-timeout/2未通信节点会发起ping消息,消息体在选择包含哪些节点时会优先选取下线状态节点,通常这段时间内能够收集到半数以上主节点的pfail报告从而完成故障发现。
  3. 从节点转移时间<=1000毫秒。由于存在延迟发起选举机制,偏移量最大的从节点会最多延迟1秒发起选举。通常第一次选举就会成功,所以从节点执行转移时间在1秒以内。

公式:failover-time(毫秒) <= cluster-node-timeout + cluster-node-timeout/2 + 1000 。故障转移时间跟cluster-node-timeout参数息息相关,默认15秒。

10.6.4 故障转移演练

通过 kill -9 强制关闭主节点6385进程。

通过 cluster nodes 确认集群状态。

强制关闭6385进程:

1
2
3
$ ps -ef | grep redis-server | grep 6385
501 1362 1 0 10:50 0:11.65 redis-server *:6385 [cluster]
$ kill -9 1362
  • 从节点6386于主节点复制中断。
  • 6379与6380两个主节点都标记6385为主观下线,超过半数因此标记为客观下线状态。
  • 从节点识别正在复制的主节点进入客观下线后准备选举时间,日志打印了选举延迟964毫秒之后执行,并会打印当前从节点复制偏移量。
  • 延迟选举时间到达后,从节点更新配置纪元并发起故障选举。
  • 6379和6380主节点为从节点6386投票。
  • 从节点获取2个主节点投票之后,超过半数执行替换主节点操作,从而完成故障转移。

成功完成故障转移后,对已经出现故障节点6385进行恢复:

  1. 重新启动故障节点6385.

  2. 6385节点启动后发现自己负责的槽指派给另一个节点,则以现有集群配置为准,变为新节点6386的从节点。

  3. 集群内其他节点接收到6385发来的ping消息,清空客观下线状态。

  4. 6385节点变为从节点,对主节点6386发起复制流程。

  5. 最终集群状态。


10.7 集群运维

10.7.1 集群完整性

集群有任一槽未指派节点时都会导致整个集群不可用,执行任何键命令返回(error)CLUSTERDOWN Hash slot not served错误。当持有槽的主节点下线,从故障转移下线到自动转移完成期间整个集群是不可用状态。大部分业务场景无法容忍,可以配置cluster-require-full-coverage配置为no,主节点故障时只影响它负责槽的相关命令执行。

10.7.2 带宽消耗

集群内Gossip消息通信本身消耗带宽,官方建议集群最多规模在1000以内,消息消耗带宽体现:

  • 消息发送频率:跟cluster-node-timeout密切相关,当节点发现与其他节点最后通信时间超过cluster-node-timeout/2时会直接发送ping消息。
  • 消息数据量:每个消息主要的数据占用包含:slots槽数组(2KB空间)和整个集群1/10的状态数据(10个节点状态数据约1KB)。
  • 节点部署的机器规模:机器带宽的上线是固定的,因此相同规模的集群分布的机器越多,每台机器划分的节点越均匀,则集群内整体的可用带宽越高。

集群带宽消耗分为:读写命令消耗 + Gossip消息消耗。搭建集群时需要根据业务数据规模和消息通信成本做出合理规划:

  1. 在满足业务需要的前提下尽量避免扩大集群。同一个系统可以针对不同业务场景拆分使用多套集群,每个集群既满足伸缩性和故障转移要求,可以规避大规模集群的弊端。
  2. 适度提高cluster-node-timeout降低消息发送频率,同时cluster-node-timeout还影响故障转移的速度,需要根据自身业务场景兼顾二者平衡。
  3. 如果条件允许集群尽量均匀分布在更多机器上,避免集中部署。

10.7.3 Pub/Sub广播问题

Redis 2.0版本提供发布订阅功能,用于针对频道实现消息的发布和订阅,在集群模式下内部实现对所有pulish命令都会向所有节点进行广播,造成每条publish数据都会在集群内所有节点传播一次,加重带宽负担。

命令演示:

  1. 对集群所有主从节点执行subscribe命令订阅cluster_pub_spread频道,用于验证集群是否广播消息。

    1
    2
    3
    127.0.0.1:6379> subscribe cluster_pub_spread
    ...
    127.0.0.1:6386> subscribe cluster_pub_spread
  2. 在6379节点上发布频道为cluster_pub_spread的消息。

    1
    127.0.0.1:6379> pubscribe cluster_pub_spread message_body_1
  3. 集群内所有的节点订阅客户端全部收到消息。

    1
    2
    3
    4
    5
    127.0.0.1:6380> subscribe cluster_pub_spread
    1) "message"
    2) "cluster_pub_spread"
    3) "message_body_1"
    ...

10.7.4 集群倾斜

(1)数据倾斜

分类:

  • 节点和槽分配严重不均。
  • 不同槽对应键数量差异过大。
  • 集合对象包含大量元素。
  • 内存相关配置不一致。

(2)请求倾斜

集群,

10.7.5 集群读写分离

10.7.6 手动故障转移

10.7.7 数据迁移


参考:

🔗 《Redis开发与运维》