《Redis设计与实现》 哨兵机制 Sentinel

本文最后更新于 2025年8月30日 晚上

哨兵 Sentinel

哨兵是Redis高可用性解决方案:由一个或多个哨兵实例组成的哨兵系统可以监视任意多个主服务器,并在被监视的主服务器进入下限状态时,自动将下线的主服务器属下的某个从服务器升级为新的主服务器,然后由新的主服务器代替已下线的主服务器继续处理命令请求。

启动并初始化哨兵

以下两种命令都可以启动一个哨兵:

1
2
3
4
5
#1
redis-sentinel /path/to/your/sentinel.conf

#2
redis-server /path/to/your/sentinel.conf --sentinel

这两个命令的效果完全相同。

当一个哨兵启动时,它需要执行以下步骤:

  • 初始化服务器
    • 哨兵实际上就是一个在特殊模式下运行的Redis服务器,因此,第一件事就是初始化一个普通的Redis服务器。然后再对这个服务器做后续操作。
    • 初始化普通Redis服务器时不会进行任何的RDB或AOF数据恢复。因为哨兵服务器中不存在数据库。
  • 将普通Redis服务器使用的代码替换成哨兵专用代码
    • 创建普通的Redis服务器后,要将里面的代码替换为哨兵用的代码。
    • 替换代码有客户端允许发送的命令:原先的GET, SET等对数据库进行操作的命令都不再有效
    • 还会替换默认的端口号等等常量值
  • 初始化哨兵状态
    • 程序里会初始化一个哨兵结构
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
struct sentinelState {

// 当前纪元,用于实现故障转移
uint64_t current_epoch;

// 保存了所有被这个 sentinel 监视的主服务器
// 字典的键是主服务器的名字
// 字典的值则是一个指向 sentinelRedisInstance 结构的指针
dict *masters;

// 是否进入了 TILT 模式?
int tilt;

// 目前正在执行的脚本的数量
int running_scripts;

// 进入 TILT 模式的时间
mstime_t tilt_start_time;

// 最后一次执行时间处理器的时间
mstime_t previous_time;

// 一个 FIFO 队列,包含了所有需要执行的用户脚本
list *scripts_queue;

} sentinel;

  • 根据给定的配置文件,初始化哨兵的监视主服务器列表(初始化*master属性)
    • master属性的配置就被存放在sentinel.conf配置文件中
    • 配置文件中每一个对应的master定义都会为哨兵初始化一个sentinelRedisInstance结构
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
typedef struct sentinelRedisInstance {

// 标识值,记录了实例的类型,以及该实例的当前状态
int flags;

// 实例的名字
// 主服务器的名字由用户在配置文件中设置
// 从服务器以及 Sentinel 的名字由 Sentinel 自动设置
// 格式为 ip:port ,例如 "127.0.0.1:26379"
char *name;

// 实例的运行 ID
char *runid;

// 配置纪元,用于实现故障转移
uint64_t config_epoch;

// 实例的地址
sentinelAddr *addr;

// SENTINEL down-after-milliseconds 选项设定的值
// 实例无响应多少毫秒之后才会被判断为主观下线(subjectively down)
mstime_t down_after_period;

// SENTINEL monitor <master-name> <IP> <port> <quorum> 选项中的 quorum 参数
// 判断这个实例为客观下线(objectively down)所需的支持投票数量
int quorum;

// SENTINEL parallel-syncs <master-name> <number> 选项的值
// 在执行故障转移操作时,可以同时对新的主服务器进行同步的从服务器数量
int parallel_syncs;

// SENTINEL failover-timeout <master-name> <ms> 选项的值
// 刷新故障迁移状态的最大时限
mstime_t failover_timeout;

// ...

} sentinelRedisInstance;

typedef struct sentinelAddr {

char *ip;

int port;

} sentinelAddr;

  • 创建连向主服务器的网络连接
    • 对监视的每一个主服务器,哨兵都会建立一个网络链接。哨兵会成为这些主服务器的客户端,以能够发送命令且接受消息。
    • 对于每一个被哨兵监视的主服务器来说,哨兵都会建立两个连向主服务器的异步连接:
      • 命令连接,用于发送命令和接收命令回复
      • 订阅连接,用于订阅主服务器的__sentinel__.hello频道
    • 订阅连接是做什么用的?
      • Redis目前的发布订阅功能中,发送信息都不会被保存到Redis服务器中,如果在信息发送时,想要接受信息的客户端不在线或断线,则这个客户端将会丢失这条消息。因此,哨兵的订阅就是为了不丢失__sentinel__.hello频道的任何信息。

获取各个服务器的信息

哨兵获取各个服务器信息的方式是一种十分拓扑的方式。书中讲的非常细致,但这里我主要总结一下流程:

  • 哨兵每隔十秒钟用命令连接向自己监视的所有主服务器发送一次INFO命令,并通过分析回复信息来获取每隔主服务器当前的信息

    • 主服务器通常会返回两方面的信息:

      • 主服务器本身的信息
      • 主服务器属下所有从服务器信息
    • 收到的主服务器信息会用于更新哨兵中的这个主服务器的实例结构

    • 收到的从服务器信息会用于更新哨兵中的这个主服务器的从服务器字典

类似以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Server
...
run_id:7611c59dc3a29aa6fa0609f841bb6a1019008a9c
...
# Replication
role:master # 代表消息来自于主服务器
...
# 代表三个从服务器信息
slave0:ip=127.0.0.1,port=11111,state=online,offset=43,lag=0
slave1:ip=127.0.0.1,port=22222,state=online,offset=43,lag=0
slave2:ip=127.0.0.1,port=33333,state=online,offset=43,lag=0
...
# Other sections
...

  • 当哨兵发现了新的从服务器(来自于主服务器返回的从服务器信息,并基于观察这个从服务器是否在之前就存在于这个主服务器中)是,将会与这个从服务器建立命令连接和订阅连接。目的与连接主服务器相同。
    • 如果这个从服务器也是其他服务器的主服务器,则会继续进行更新。
  • 哨兵每隔十秒钟也会向所有从服务器发送一次INFO命令,返回的信息也会被用于更新从服务器的实例结构。

类似以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# Server
...
run_id:32be0699dd27b410f7c90dada3a6fab17f97899f # 选举时也可能会用到
...
# Replication
role:slave # 代表消息来自于从服务器
master_host:127.0.0.1
master_port:6379
master_link_status:up # 这个很重要,在后面选新主服务器时会用到
slave_repl_offset:11887
slave_priority:100 # 这个也很重要,选举时也会用到
# Other sections
...

  • 哨兵每隔两秒钟会向所有监视的主服务器和从服务器发送命令:PUBLISH __sentinel__:hello "<s_ip>,<s_port>,<s_runid>,<s_epoch>,<m_name>,<m_ip>,<m_port>,<m_epoch>"
    • 命令中包含了该哨兵的一些信息和该哨兵监视下的主服务器信息
      • 如果是发送给从服务器,则是包含该从服务器正在复制的主服务器信息
  • 接受来自主服务器和从服务器的频道信息:哨兵在建立起订阅连接之后,便会向连接服务器发送命令SUBSCRIBE __sentinel__:hello
    • 对这个频道的订阅会持续到哨兵与该服务器的连接断开为止
    • 可以注意到一点,由于之前说了哨兵会每隔两秒向所有主从服务器发送发布消息,因此,Redis可以使用这个机制,来让哨兵接受到其他连接了同一个服务器的哨兵的订阅消息。而订阅消息里面又有发布消息的哨兵的信息,因此,哨兵可以通过这个性质来相互建立连接。
    • 哨兵会持续维护自己监视中的所有主服务器的哨兵字典,以在每一次收到订阅消息时,判断是否要更新对应的哨兵结构,还是要创建一个新的哨兵结构并放进对应的字典中。
  • 连接其他哨兵服务器时只会建立命令连接而不会有订阅连接。

至此,就完成了获取服务器信息并维护对各个服务器的连接操作。

检查下线状态的机制

主观下线状态

哨兵会对所有建立了命令连接的实例每秒发送一次PING命令,并通过实例返回的PING命令回复来判断实例是否在线。回复仅可能有以下两种情况:

  • 有效回复:实例返回+PONG, -LOADING, -MASTERDOWN三种回复的其中一种
  • 无效回复:除有效回复外的其他回复,或者指定时限内没有任何回复

对于这个指定时限,哨兵配置文件中的down-after-milliseconds选项指定了这个时限。如果一个实例在这个时限内连续相哨兵返回无效回复,那么哨兵将在这个实例结构中的flags属性打开SRI_S_DOWN标识,以标识这个实力已经进入主观下线状态。

用户设置的down_after_milliseconds选项的值,会被同时用于判断所有连接于这个哨兵的其他所有服务器的主观下线状态。当然,都说是主观下线状态了,因此这个状态也只是这个哨兵认为的而已。

1
2
sentinel monitor master 127.0.0.1 6379 2
sentinel down-after-milliseconds master 50000

这个配置代表了如果有任意一个服务器在50000毫秒之内持续给这个哨兵发送无效回复,那么这个哨兵便会将其视为主观下线。

如果有另一个哨兵将这个值设得更大,那么另一个哨兵则不见得也会将其视为主观下线。

客观下线状态

哨兵如何判断一个主服务器是否是真的下线了?其实逻辑很简单:哨兵会向所有监视这个主服务器的哨兵进行询问,看他们是否也认为这个主服务器进入了下线状态。如果收到肯定的答复足够多,则这个哨兵会将这个主服务器视为客观下线,并且执行故障转移操作

  • 当一个哨兵认为一个主服务器进入了主观下线状态时,会向所有监视该服务器的哨兵发送一个SENTINEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>命令,已询问其他哨兵是否同意主服务器已下线。
    • 这里的<ip> <port>指的是对应主服务器的IP和端口号
    • <current_epoch为这个哨兵当前的配置纪元,用于选举领头哨兵
  • 目标哨兵接收到源哨兵的SENTINEL is-master-down-by-addr命令时,会分析并取出命令请求中的各个参数,并判断对应的主服务器是否下线。然后对源哨兵进行回复。
  • 源哨兵收到回复后,会统计其他哨兵同意主服务器下线的数量,当达到配置指定的所需数量时,便会将主服务器实例结构的flag属性的SRI_O_DOWN表示打开,表示主服务器已经进入客观下线状态。
    • 这个配置是类似于sentinel monitor master 127.0.0.1 6379 2。这个例子表示该哨兵在收到包括自己在内同意主服务器下线的数量达到了2时,便会将主服务器设为客观下线状态。

选举领头哨兵

选举领头哨兵基本上用的是一种受限的Gossip/Flooding协议。

简单来说就是如下几个点:

  • 只有监视着这个下线服务器的哨兵才能够参与选举(废话)。
  • 所有哨兵在一个选举期间只能有一次选一个哨兵为局部领头哨兵的机会。
  • 所有发现主服务器进入客观下线的哨兵都会要求其他哨兵将自己设置为局部领头哨兵。
  • 哨兵选另一个哨兵为局部领头哨兵的规则是先到先得,假如A,B两个哨兵同时要求C哨兵将自己设置为局部领头哨兵,如果C先处理了A,那么就不会再处理B。
  • 假如以个没有将主服务器视为客观下线的哨兵发送选举消息给了另一个相同状态的哨兵,接收方则会无视这个消息。
  • 源哨兵在接收到目标哨兵返回的命令回复之后,会检查回复中的leader_epoch参数的值是否与自己的配置纪元相同。如果相同,则继续取出恢复中的leader_runid参数,如果leader_runid与原哨兵的运行ID一只,则表示目标哨兵将源哨兵设置成了局部领头哨兵。
  • 只要有任何一个哨兵被半数以上的哨兵设置成了局部领头哨兵,那这个哨兵将成为领头哨兵,并执行故障转移操作。
    • 由于每一个哨兵在一个配置纪元中只能设置一次局部领头哨兵,所以当任意一个哨兵有半数以上的票,则代表尘埃落定。
  • 哨兵选举结束后就会将自己的配置纪元加1。此时,如果这个哨兵还接收到了来自于其他哨兵的选举信息,由于它的配置纪元比接收到的信息中的纪元不一致,因此它会无视这些消息。

最终所有参与了本次选举的哨兵都最多会做一次投票,并且所有哨兵最终的配置纪元都会加一。

故障转移

当一个哨兵被正式选为领头哨兵时,该哨兵将对已下线的主服务器执行故障转移操作。流程如下:

  • 从已下线的主服务器属下的所有从服务器中,选出一个从服务器,将其转为主服务器
  • 让已下线的主服务器属下的所有其他从服务器改为复制新的主服务器
  • 将以下线的主服务器设为新主服务器的从服务器,并持续监视该服务器,当其重新上线时便会正式成为从服务器

挑选新的主服务器

领头哨兵会将已下线主服务器的所有从服务器保存到一个列表里面,然后按照以下规则,一项一项地对列表进行过滤:

  • 删除列表中所有处于下线或者断线状态的从服务器,这可以保证列表中剩余的从服务器都是正常在线的。

    • 这个状态是出于哨兵的角度来看的,意味着这个从服务器长时间没有正常与哨兵进行联系。
  • 删除列表中所有最近五秒内没有回复过领头哨兵的INFO命令的从服务器,这可以保证列表中剩余的从服务器都是最近成功进行过通信的。

  • 删除所有与已下线主服务器连接断开超过down-after-milliseconds * 10毫秒的从服务器:down-after-milliseconds选项指定了判断主服务器下线所需的时间,而删除断开时长超过down-after-milliseconds * 10毫秒的从服务器,则可以保证列表中剩余的从服务器都没有过早地与主服务器断开连接,换句话说,列表中剩余的从服务器保存的数据都是比较新的。

    • 这个状态是基于从服务器角度来看的。这个服务器可以相应哨兵,但是它可能与主服务器的连接出了问题。
    • 哨兵怎么得到这个值的?
      • 哨兵每隔十秒都会向从服务器发送一次INFO,返回的信息中有一个条目叫master_link_status。当这个条目的值为down时,意味着这个从服务器丢失了对主服务器连接。此时,哨兵会对这个从服务器设置一个计时器(其实就是记录当前系统时间)。如果哨兵在下一次发送INFO消息且收到该条目的值为on,则会取消这个计时器。在挑选新主服务器时,如果在从服务器实例结构中发现该属性为off,则会用当前系统时间与对应的计时器进行计算。以此即可得到从服务器与主服务器连接断开的时间。

之后,领头哨兵将根据从服务器的优先级,对列表中剩余的从服务器进行排序,并选出其中优先级最高的从服务器。

如果有多个具有相同最高优先级的从服务器,那么领头哨兵将按照从服务器的复制偏移量,对具有相同最高优先级的所有从服务器进行排序,并选出其中偏移量最大的从服务器(复制偏移量最大的从服务器就是保存着最新数据的从服务器)。

最后,如果有多个优先级最高、复制偏移量最大的从服务器,那么领头哨兵将按照运行ID对这些从服务器进行排序,并选出其中运行ID最小的从服务器。

再选出新的主服务器之后,领头哨兵会向新的主服务器发送一个SLAVEOF no one命令,让其成为主服务器。

修改从服务器的复制目标

一言以蔽之,就是领头哨兵会向所有将要成为新主服务器的从服务器发送一个SLAVEOF命令,让他们进行复制。

对于已经下线的,将来也要成为新主服务器的那个主服务器,在它上线时,领头哨兵也会对它发送一个SLAVEOF命令

更多问题

主服务器错误下线的时候,没有任何一个从服务器中的数据完成了同步,即便进行了故障转移也会丢失数据。这种情况如何处理?

  • Redis基于工程上的权衡,它在设计上就是允许一定的数据丢失。分布式系统无法同时满足一致性(Consistency),可用性(Availability)和分区容错性(Partition Tolerance)。Redis的主从复制与哨兵机制的设计倾向于AP,在发生主节点故障时,会有限保证服务可用,并接受一定程度的数据丢失风险。因此,不用处理。如果真的要保证强一致性的话,可以考虑用别的框架(ZooKeeper等)。如果就是想用Redis,那可以考虑将配置文件中对于数据同步的参数调得更严格一些,以降低这种情况发生的可能。

《Redis设计与实现》 哨兵机制 Sentinel
http://example.com/2025/08/29/Redis学习/《Redis设计与实现》哨兵机制 Sentinel/
作者
Clain Chen
发布于
2025年8月29日
许可协议