redis sentinel与raft协议

redis的高可用性

  前一篇文章提到,redis用复制来解决可靠性,一份数据做多份冗余。但是,当master节点因为某种原因下线,还必须有一种机制能让slave节点自动地转化成master节点,对外提供写服务,保证系统的高可用性。redis的sentinel就是这样的一种机制。

sentinel与raft

  redis的sentinel是一个独立的进程,其代码的基础仍然是redis的IO多路复用,单线程多客户端的结构,但在启动时候通过指定启动项和配置文件,将创建一个独立的进程,与普通的redis进程进行解耦。一个master节点往往需要多个sentinel进程来监控,当master节点下线时,多个sentinel需要通过一个分布式协议来进行leader选举,选出的leader将负责从该master的slave节点内选出一个,成为新的master,让其他slave对其进行复制。由于当前比较流行且可靠的raft协议正是解决分布式系统中节点下线或者脑裂等问题,redis就用raft协议来进行leader选举。这个功能只用到raft协议的一小部分。

配置文件

  sentinel是通过配置文件启动的,在配置文件里会保存sentinel所监控的master,slave和其他sentinel等信息,每当有其他节点的状态变化时,都会修改配置文件,让这些更改落盘,这样即便sentinel下线,再重启之后也可以恢复。

定时任务

  redis的sentinel进程也是通过定时事件来处理主要的业务,包括向其他节点发送心跳,发送自身的信息以及接收其他sentinel,slave和master的心跳以及redis进程的主要信息等。在定时任务中还会发起leader选举以及进行slave向master的切换。这些任务的完成需要一个状态机,sentinel主要就是通过这个状态机来实现raft协议以及slave到master的升级。定时任务会把所有sentinel知道的master,slave,还有其他sentinel都遍历一遍,处理相关的状态变化。

  sentinel的定时任务还会每次都修改一下下一次定时任务的触发时间,从而让多个sentinel的定时任务事件错开,避免在选leader时大家都差不多时间开始,导致票数被拆分。这与一般raft协议的做法有区别,raft协议是在每次发起投票时,每个节点先等一个随机的时间,但两者达到的效果都是一样的。

titl模式

  sentinel是通过心跳来获取其他slave,master和sentinel的在线以及详细信息的,但是心跳除了因为网络分区,节点下线等问题丢失以外,自身机器的繁忙程度或者修改系统时间这些意外的操作也可能影响sentinel的心跳正常工作,因此redis为sentinel提供了一个特殊的模式,称为titl,这个模式下,sentinel 仍然会进行监控并收集信息,它只是不执行诸如故障转移、下线判断之类的操作而已。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void sentinelCheckTiltCondition(void) {
// 计算当前时间
mstime_t now = mstime();
// 计算上次运行 sentinel 和当前时间的差
mstime_t delta = now - sentinel.previous_time;
// 如果差为负数,或者大于 2 秒钟,那么进入 TILT 模式
if (delta < 0 || delta > SENTINEL_TILT_TRIGGER) {
// 打开标记
sentinel.tilt = 1;
// 记录进入 TILT 模式的开始时间
sentinel.tilt_start_time = mstime();
// 打印事件
sentinelEvent(REDIS_WARNING,"+tilt",NULL,"#tilt mode entered");
}
// 更新最后一次 sentinel 运行时间
sentinel.previous_time = mstime();
}

  每次定时任务redis都会执行上面这个函数,每次执行这个函数时都会产生一个sentinel.previous_time,表示这次执行sentinel逻辑的时间。如果上次执行sentinel的时间早于当前时间(说明系统时间发生改变)或者两次执行sentinel的时间相隔过长(代表当前系统繁忙,进程不能正常工作),sentinel就会进入titl模式。

发送心跳

  sentinel会在配置文件里指定监控哪些master,但这些master可能会有其他sentinel同时在监控,这些master每一个都会跟着一个或多个slave,sentinel对这些slave和sentinel都是通过心跳来动态发现的,而不是在初始的配置文件里指定。当然,在发现以后,这些状态会保存在配置文件中,在下次启动时就会加载。

  sentinel的心跳分为3种,一种是ping命令,用来检测进程是否在线;一种是info命令,用来获取进程的详细信息,master的slave就是通过这个命令的返回来动态发现的;一种是频道信息,用来获取其他sentinel的信息,master的sentinel就是通过这个频道信息来动态发现的。

  sentinel中通过哈希表保存所有的master,每一个master都是sentinelRedisInstance结构体。对于每一个master,都会有一个slave表,一个sentinel表,都是哈希表,保存了sentinelRedisInstance结构体,用来保存这个master的所有slave和sentinel。

  在info命令的返回中,如果发现了新的slave,则会创建新的sentinelRedisInstance;在频道信息返回中,如果发现了新的sentinel,则也会创建新的sentinelRedisInstance。这时候仅仅初始化一些状态信息,并不会真正创建网络连接。

创建网络连接及重连

  sentinel在定时任务中创建对所有master,sentinel,slave的网路连接,以及对一些长期没有响应的连接进行重连。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
void sentinelReconnectInstance(sentinelRedisInstance *ri) {
// 示例未断线(已连接),返回
if (!(ri->flags & SRI_DISCONNECTED)) return;
/* Commands connection. */
// 对所有实例创建一个用于发送 Redis 命令的连接
if (ri->cc == NULL) {
// 连接实例
ri->cc = redisAsyncConnect(ri->addr->ip,ri->addr->port);
// 连接出错
if (ri->cc->err) {
sentinelEvent(REDIS_DEBUG,"-cmd-link-reconnection",ri,"%@ #%s",
ri->cc->errstr);
sentinelKillLink(ri,ri->cc);
// 连接成功
} else {
// 设置连接属性
ri->cc_conn_time = mstime();
ri->cc->data = ri;
redisAeAttach(server.el,ri->cc);
// 设置连线 callback
redisAsyncSetConnectCallback(ri->cc,
sentinelLinkEstablishedCallback);
// 设置断线 callback
redisAsyncSetDisconnectCallback(ri->cc,
sentinelDisconnectCallback);
// 发送 AUTH 命令,验证身份
sentinelSendAuthIfNeeded(ri,ri->cc);
sentinelSetClientName(ri,ri->cc,"cmd");
/* Send a PING ASAP when reconnecting. */
sentinelSendPing(ri);
}
}
/* Pub / Sub */
// 对主服务器和从服务器,创建一个用于订阅频道的连接
if ((ri->flags & (SRI_MASTER|SRI_SLAVE)) && ri->pc == NULL) {
// 连接实例
ri->pc = redisAsyncConnect(ri->addr->ip,ri->addr->port);
// 连接出错
if (ri->pc->err) {
sentinelEvent(REDIS_DEBUG,"-pubsub-link-reconnection",ri,"%@ #%s",
ri->pc->errstr);
sentinelKillLink(ri,ri->pc);
// 连接成功
} else {
int retval;
// 设置连接属性
ri->pc_conn_time = mstime();
ri->pc->data = ri;
redisAeAttach(server.el,ri->pc);
// 设置连接 callback
redisAsyncSetConnectCallback(ri->pc,
sentinelLinkEstablishedCallback);
// 设置断线 callback
redisAsyncSetDisconnectCallback(ri->pc,
sentinelDisconnectCallback);
// 发送 AUTH 命令,验证身份
sentinelSendAuthIfNeeded(ri,ri->pc);
// 为客户但设置名字 "pubsub"
sentinelSetClientName(ri,ri->pc,"pubsub");
/* Now we subscribe to the Sentinels "Hello" channel. */
// 发送 SUBSCRIBE __sentinel__:hello 命令,订阅频道
retval = redisAsyncCommand(ri->pc,
sentinelReceiveHelloMessages, NULL, "SUBSCRIBE %s",
SENTINEL_HELLO_CHANNEL);
// 订阅出错,断开连接
if (retval != REDIS_OK) {
/* If we can't subscribe, the Pub/Sub connection is useless
* and we can simply disconnect it and try again. */
sentinelKillLink(ri,ri->pc);
return;
}
}
}
/* Clear the DISCONNECTED flags only if we have both the connections
* (or just the commands connection if this is a sentinel instance). */
// 如果实例是主服务器或者从服务器,那么当 cc 和 pc 两个连接都创建成功时,关闭 DISCONNECTED 标识
// 如果实例是 Sentinel ,那么当 cc 连接创建成功时,关闭 DISCONNECTED 标识
if (ri->cc && (ri->flags & SRI_SENTINEL || ri->pc))
ri->flags &= ~SRI_DISCONNECTED;
}

  对所有实例创建一个命令连接,对所有非sentinel实例创建频道连接。如果连接创建成功,把sentinelRedisInstance状态的SRI_DISCONNECTED标志位去掉。

故障转移

主观下线

  当前sentinel认为某个实例下线,称为主观下线。redis只需要判断上次发送ping的时间与当前的时间是否超过一定的值,如果超过的时间过长,说明当前sentinel与该实例的通信有问题,因为sentinel只有当上次ping返回之后,才会发送下一个ping命令。这时候,sentinel会把该实例的状态设为主观下线SRI_S_DOWN,并按需要可能会断开当前的连接,在下一次定时任务的时候尝试重连。

客观下线

  只有在判断某个实例客观下线(即大部分的节点都认为该实例下线),sentinel才会进行slave到master的升级的故障恢复操作。在隔一定时间,sentinel都会向sentinel针对某个master实例发送SENTINEL is-master-down-by-addr命令,收集其他sentinel对于该master是否在线的意见,当大部分的sentinel都认为该master不在线时,触发一次故障转移。

SENTINEL_FAILOVER_STATE_WAIT_START

  当大部分的sentinel判断某master客观下线,那么该master进入SENTINEL_FAILOVER_STATE_WAIT_START状态,处于该状态时sentinel会发起一次leader选举,并进入一个新的纪元,这个纪元的概念与raft协议的term是一致的。

  leader选举的过程跟raft协议比较相似,且简单很多,因为不涉及log的一致性判断,不需要让选出来的leader比较log的term和index,基本是采取先到先得的原则,sentinel收到其他sentinel的投票请求,会采纳第一个sentinel的票,而返回给其他sentinel自己之前采纳过的sentinel。由于每个sentinel只投一个sentinel,且只有获得大部分的sentinel的票时才当选,因此必然能够保证有且仅有一个leader被选上。且之前提到过,每个sentinel的定时任务的执行频率都是随机化的,基本不会产生票数被拆分的问题。

  所有的sentinel都可以发起leader选举,但只有一个sentinel来执行这个故障转移,当已经发起了一次选leader,但发现自己没选上,则会等一段时间(故障转移超时时间)后解除这一状态,重新判断客观下线,然后触发故障转移;但一般情况下,如果别的sentinel先发起选举,当前sentinel收到投票请求时,会更新一个叫做failover_start_time的变量,那么即便在判断客观下线后,也要判断当前时间是否与failover_start_time相隔2*故障转移超时时间,从而避免多个sentinel同时发起故障转移。raft协议里本身是没有这一逻辑的,那是因为当某个节点当选了master,会发心跳给slave,slave收到心跳就表明集群里已经存在leader,就不会再发起leader选举。而redis里没有这一leader到follower的心跳通信,所以需要用这个faileover_start_time来过滤一下。

SENTINEL_FAILOVER_STATE_SELECT_SLAVE

  如果当前sentinel成为leader,则进入SENTINEL_FAILOVER_STATE_SELECT_SLAVE状态,进行故障转移。这一步是在该master的所有slave中选出一个作为新的master。

  当选的几个条件:

  1. 没有下线
  2. 没有长时间没有收到该slave的info信息
  3. 没有与master长时间断开。

  这几个条件限定了slave的数据比较新,且与sentinel的通信可靠。

  然后,对候选的slave进行排序:

  1. 比较优先级,较小的优先级优先。
  2. 复制偏移量较大者优先
  3. 运行id较小者优先

SENTINEL_FAILOVER_STATE_SEND_SLAVEOF_NOONE

  选出了slave,就进入SENTINEL_FAILOVER_STATE_SEND_SLAVEOF_NOONE状态,给该slave发送slaveof no one命令。

SENTINEL_FAILOVER_STATE_WAIT_PROMOTION

  在info命令的返回里判断该slave的角色是否变成master,是则进入下一个状态,否则超时等到下一次定时任务重新触发故障转移。

SENTINEL_FAILOVER_STATE_RECONF_SLAVES

  当选中的slave角色已变成master,就要向原master的其他slave发送slaveof命令,让它们复制新的master,这时候它们的状态均加上SRI_RECONF_SENT标志位,代表复制请求已发出。保证一定数量的slave并发发起向新master的同步,同样在info命令的返回里判断这些slave是否同步完毕。info命令里如果发现slave复制的master的ip地址和端口号与新master一致,那么slave进入SRI_RECONF_INPROG状态;当发现slave的slave_master_link_status == SENTINEL_MASTER_LINK_STATUS_UP成立,那么说明slave已经完成对master的同步,此时slave进入SRI_RECONF_DONE状态。当所有slave都进入SRI_RECONF_DONE状态,则故障转移基本结束,sentinel进入SENTINEL_FAILOVER_STATE_UPDATE_CONFIG状态。

SENTINEL_FAILOVER_STATE_UPDATE_CONFIG

  该状态主要是修改配置文件,让sentinel的master,slave和sentinel都在故障转移后进行相应更新,例如把原master的ip,port等修改成新master等等,把状态都初始化,并重置所有网络连接。