Redis学习记录:jedis哨兵模式接入拓扑刷新源码分析

  • 最近在做容器redis宕机自愈优化时遇到一个问题:用户使用jedis接入,若redis进行全量宕机后自愈,jedis可能无法感知主从角色变化导致readonly。

  • 本篇文章简要记录一下排查过程和涉及到的部分源码分析。

一、问题背景

1.环境背景

容器化部署一个哨兵集群与多个主从集群,用户通过哨兵对外暴露的地址与<MasterName>获取主从集群地址进行业务操作。

注:业务端使用jedis的JedisSentinelPool进行接入。

2.模拟故障

手动删除一个主从集群中的所有主从实例pod,等待operator介入自愈。

operator检测到全量宕机,按顺序执行如下操作:

  1. 拉起进程

  2. 进行选主

  3. 恢复复制关系

  4. 移除并重新添加哨兵监控配置

等待自愈完成后,pod地址已全部变化,故哨兵中<MasterName>对应的主地址也已经变化。

3.业务异常

全量宕机故障注入后,jedis业务操作出现异常,持续重试且失败。

operator介入自愈完成后,集群状态已恢复正常,但存量的jedis业务客户端并未恢复,扔在重试且失败。

重启jedis业务客户端或新启动业务客户端,均可以正常操作主从集群。

使用lettuce客户端接入模拟此流程,可以正常恢复。

二、相关源码分析

redis源码版本: 6.2.x
jedis源码版本: 5.2.0

这种故障场景,lettuce客户端可以正常自愈,但是jedis客户端不行,说明lettuce没有刷新主从实例拓扑。

之前看过lettuce源码,可以得知lettuce的主从拓扑刷新是通过订阅哨兵事件来实现的;所以首先推测jedis也是类似思路,猜测这次异常是因为jedis没有收到事件导致的。

1. jedis源码

首先先找下JedisSentinelPool相关的源码。

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
// src/main/java/redis/clients/jedis/JedisSentinelPool.java +333
protected class MasterListener extends Thread {
...
@Override
public void run() {
...
while (running.get()) {

try {
// double check that it is not being shutdown
if (!running.get()) {
break;
}

final HostAndPort hostPort = new HostAndPort(host, port);
j = new Jedis(hostPort, sentinelClientConfig);

// 初始化拓扑
// code for active refresh
List<String> masterAddr = j.sentinelGetMasterAddrByName(masterName);
if (masterAddr == null || masterAddr.size() != 2) {
LOG.warn("Can not get master addr, master name: {}. Sentinel: {}.", masterName,
hostPort);
} else {
initMaster(toHostAndPort(masterAddr));
}

// 配置拓扑刷新事件
j.subscribe(new JedisPubSub() {
@Override
public void onMessage(String channel, String message) {
LOG.debug("Sentinel {} published: {}.", hostPort, message);

String[] switchMasterMsg = message.split(" ");

if (switchMasterMsg.length > 3) {

if (masterName.equals(switchMasterMsg[0])) {
// 刷新拓扑
initMaster(toHostAndPort(Arrays.asList(switchMasterMsg[3], switchMasterMsg[4])));
} else {
LOG.debug(
"Ignoring message on +switch-master for master name {}, our master name is {}",
switchMasterMsg[0], masterName);
}

} else {
LOG.error("Invalid message received on Sentinel {} on channel +switch-master: {}",
hostPort, message);
}
}
}, "+switch-master"); // 仅订阅switch-master事件

} catch (JedisException e) {

if (running.get()) {
LOG.error("Lost connection to Sentinel at {}:{}. Sleeping 5000ms and retrying.", host,
port, e);
try {
Thread.sleep(subscribeRetryWaitTimeMillis);
} catch (InterruptedException e1) {
LOG.error("Sleep interrupted: ", e1);
}
} else {
LOG.debug("Unsubscribing from Sentinel at {}:{}", host, port);
}
} finally {
if (j != null) {
j.close();
}
}
}
}
...
}

可以看出来,jedis这边刷新拓扑仅能通过+switch-master事件触发,没有像lettuce一样订阅多种事件。

而且拓扑刷新的操作也比较简单,就是把哨兵channel的msg拆解后直接更新。

2. redis源码

然后再看下+switch-master事件相关的源码。

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
// sentinel.c
// 按协议解析哨兵间hello消息
void sentinelProcessHelloMessage(char *hello, int hello_len) {
...
if (numtokens == 8) {
...
/* Update master info if received configuration is newer. */
if (si && master->config_epoch < master_config_epoch) {
master->config_epoch = master_config_epoch;
if (master_port != master->addr->port ||
!sentinelAddrEqualsHostname(master->addr, token[5]))
{
sentinelAddr *old_addr;

sentinelEvent(LL_WARNING,"+config-update-from",si,"%@");
sentinelEvent(LL_WARNING,"+switch-master",
master,"%s %s %d %s %d",
master->name,
announceSentinelAddr(master->addr), master->addr->port,
token[5], master_port);

old_addr = dupSentinelAddr(master->addr);
sentinelResetMasterAndChangeAddress(master, token[5], master_port);
sentinelCallClientReconfScript(master,
SENTINEL_OBSERVER,"start",
old_addr,master->addr);
releaseSentinelAddr(old_addr);
}
}
...
}
...
}
...
// sentinel.c
// 用于将从实例提升为主
void sentinelFailoverSwitchToPromotedSlave(sentinelRedisInstance *master) {
sentinelRedisInstance *ref = master->promoted_slave ?
master->promoted_slave : master;

sentinelEvent(LL_WARNING,"+switch-master",master,"%s %s %d %s %d",
master->name, announceSentinelAddr(master->addr), master->addr->port,
announceSentinelAddr(ref->addr), ref->addr->port);

sentinelResetMasterAndChangeAddress(master,ref->addr->hostname,ref->addr->port);
}

在redis中,+switch-master事件只会在两种情况下被发出,相关源码如上。

  1. 哨兵配置过期,通过其余哨兵同步配置后,会发出+switch-master事件。

  2. 哨兵触发failover,且走到了需要将从实例提升为主的步骤后,会发出+switch-master事件。

三、问题原因分析

通过上文,可以较为清晰的汇总出如下信息:

  1. 使用jedis作为业务客户端接入哨兵主从,jedis通过+switch-master事件刷新拓扑。

  2. 当redis哨兵间不存在过期配置时,redis仅在failover流程中发出+switch-master事件。

  3. 全量宕机场景operator介入自愈时,未触发failover流程。

由此问题已经被发现,即jedis使用哨兵接入时,并未考虑到全量宕机恢复这种场景,jedis仅能通过+switch-master事件来感知拓扑变化。

lettuce在这方面显然做的更全面一些,会监听多种哨兵事件来刷新拓扑,所以在operator介入自愈后,可以通过感知其他事件来刷新拓扑。

四、问题解决

上文已知问题根因为jedis未感知拓扑变化,所以我们手动触发一次failover流程让jedis感知即可。

经过测试,在手动触发failover流程后,jedis业务客户端恢复,可以正常执行业务操作。

从operator视角来看,全量宕机自愈之后,自动触发一次failover流程理论上也能解决这个问题,但是failover流程相对不可控因素太多了,不太稳定。

所以operator这边暂时不进行适配,考虑到这种场景概率还是比较低的,我觉得提供SOP手册即可。