自律给我自由

积累 自驱 自制 坚持

0%

用MySQL实现leader选举

前言

为了保证服务的高可用, 我们通常会将其部署到多个节点(或者多个容器). 但是某些异步任务我们希望只交给一个实例来处理, 或者说, 要在这些服务中选出一个leader.

zookeeper的菜谱里有一道招牌菜就是leader election, 不过今天我们不打算点这道菜, 看看用随手可得的MySQL能否满足我们的需求.

leader-election

关于leader选举我们知道:

  • 选举是一个低频的动作, 只是在竞选的时候有一定的并发
  • 选举完成leader和follower都要保持会话(比如心跳), leader挂掉所有的follower可以及时参加选举
  • 如果leader从假死状态中恢复发现自己已经不是leader了, 则自动变身为follower
  • 如果leader和follower之间没有交互, 各服务实例只需要知道自己是不是leader就行了

在这里MySQL扮演的是一个第三方服务, 来协调leader和follower之间的关系, 不过MySQL本身是没有这个功能的, 我们可以利用它的一些特性来实现.

预备知识

选举时, 多个实例只有一个能竞争成功, 用CAS算法再合适不过了. MySQL的CAS可以用版本号来实现.

比如有一个表如下字段: id, key, value, version(int), 每次更新value时都带着版本号

1
update tbl set value = ?, version = version + 1 where key = ? version = ?
  • 更新前先获得当前的版本号, 比如初始版本是1, 则大家都拿到了1
  • 所有人都在执行上述更新, 都想把version变成2, MySQL保证了同一时刻只会有一个人实现了他的愿望
  • 唯一的幸运儿更新完成后, 其他人拿着1就变成了脏数据, 自然更新失败

至此leader已经选出来了, 没有加锁也无需自旋, 是不是很简单呢!

设计

初次选举过程

elect

  • 初始版本号为0, 服务实例初始化时都试图更新选举版本号, 但只有一个成功, 成功者成为leader
  • leader定时向MySQL发送心跳, 维护来之不易的版本号
  • follower定时探测leader最后一次心跳时间, 如果超过时限(比如5s)就认为leader宕机

leader宕机之后的改选

reelect

  • follower探测到leader心跳中断, 不论是卡主还是宕机, 都认为leader挂掉
  • 获取最后一次心跳的版本号, 重复上述选举过程

实现

竞争表

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `leader_election` (
`elect_key` varchar(255) NOT NULL COMMENT '选举的key',
`version` bigint NOT NULL DEFAULT 0 COMMENT '当前版本号, 每次选举和心跳都会在此字段进行cas',
`tick_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '心跳时间',
`leader_id` bigint NOT NULL DEFAULT 0 COMMENT 'leader所在实例的id',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`elect_key`),
KEY `idx_version` (`version`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='leader选举';
  • 这里不需要value字段
  • leader会定时发送tick_time, follower也会定时探测leader的心跳时间, 超过一定时间就认为leader已经宕机了, 开始重新选举
  • leader_id可有可无, 如果leader于follower之间有交互这里可以换成ip:port

服务实现

选举服务具备以下职责

election-service

  • tick 是leader的操作, 向MySQL发送心跳, 就是将version自增然后用CAS写入
  • doesLeaderCrashed follower的操作, 探测leader是否宕机, 不管leader是刚好卡主还是真的宕掉了, 只要最后一次心跳距今超过5s就算宕机
  • elect 初次选举, 初始版本号固定为0
  • reelect 改选, leader宕机之后的再次选举, 初始版本设定为最后一次心跳的版本
  • isLeader 供其他服务调用, 判断自身实例是否是leader
  • getServiceId 每个服务都有一个全局唯一的id

leader的心跳和follower的探活都由定时任务来发起

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
@Autowired
private ElectionService electionService;

/** leader: 发送心跳 */
@Async
@Scheduled(cron = "*/5 * * * * ?")
public void tick() {
if (!electionService.isLeader()) {
return;
}
if (electionService.tick()) {
log.info("leader: 心跳发送成功, 继续作威作福");
} else {
log.info("leader: 心跳发送失败, 失去leader资格");
}
}

/** follower: 虎视眈眈, 持续探测leader是否宕机, 如果宕机则重新开始选举 */
@Async
@Scheduled(cron = "*/5 * * * * ?")
public void detectAndReelect() {
try {
// 这里停留1s是为了当主宕机了, follower可以尽快发现
// 如果不停留则有可能等到下一波探活才能发现
Thread.sleep(1000);
} catch (InterruptedException ignored) {
}

if (electionService.isLeader()) {
return;
}
if (electionService.doesLeaderCrashed()) {
if (electionService.reelect()) {
log.info("follower探测leader状态: eader宕机, 通过竞选, 成功上位");
} else {
log.info("follower探测leader状态: eader宕机, 竞选失败, 再等机会");
}
} else {
log.info("follower探测leader状态: leader在岗, 等待机会");
}
}

选举服务的具体实现

初始化变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private String key = "leader-election"; // 选举的key
private long tickIntervalMillis = 5000; // 心跳间隔, 毫秒
private AtomicLong version = new AtomicLong(0L); // 如果竞选leader成功, 则要维护版本号
private AtomicBoolean isLeader = new AtomicBoolean(false); // 当前是否是leader

// 初始化选举是必须的
@PostConstruct
private void init() {
boolean won = elect();
if (won) {
log.info("初选成功, 成为leader: version-> {}, id-> {}", version, id);
} else {
log.info("初选失败, 不骄不躁, 勤勤恳恳, 努力工作");
}
}

选举的过程

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
@Transactional
@Override
public boolean elect() {
Election e = electionMapper.selectOne(key);
if (e == null) {
throw new ElectionException("没有找到elect_key: " + key);
}

return electing(0);
}

@Transactional
@Override
public boolean reelect() {
Election e = electionMapper.selectOne(key);
if (e == null) {
throw new ElectionException("没有找到elect_key: " + key);
}

return electing(e.getVersion());
}

private boolean electing(long initVersion) {
boolean updated = compareAndSetVersionAndLeaderId(initVersion);
if (updated) {
log.debug("竞选leader成功, 版本号置为1");
version.set(1);
} else {
log.debug("竞选leader失败, 等下次吧");
}
isLeader.set(updated);

return updated;
}

心跳

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
@Override
public boolean tick() throws ElectionException {
if (!isLeader()) {
throw new ElectionException("当前不是leader, 不能发送心跳");
}

boolean updated = compareAndSetVersion(version.get(), version.incrementAndGet());
isLeader.set(updated);
if (!updated) {
version.set(0L);
}

return updated;
}

探活

follower的动作, 检查leader上次探活距今有没有超过5s, 超过就认为leader宕掉了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public boolean doesLeaderCrashed() throws ElectionException {
if (isLeader()) {
throw new ElectionException("当前不是follower, 不能探测leader是否活跃");
}

Election e = electionMapper.selectOne(key);
if (e == null) {
throw new ElectionException("没有找到elect_key: " + key);
}

long tickTime = e.getTickTime().getTime();
long currTime = System.currentTimeMillis();
if ((currTime - tickTime) > tickIntervalMillis) {
log.debug("leader嗝屁了, 终于熬出头了");
return true;
} else {
log.debug("leader仍然在岗, 别灰心, 持续探测");
return false;
}
}

总结

用MySQL实现的leader选举, 没有引入其它组件, 全程没有加锁, 没有过多对MySQL的操作, 实现简单高效. 不过仍然有以下不足:

  • leader的保持全靠自己的心跳, 时效性差, 一旦假死则有可能下次心跳才能发现自己挂了
  • 重新选举没法立即进行, 仍然依赖自身的探活机制
  • 无法避免羊群效应

综上所述, 如果你的服务对选举的及时性有强制要求, 而且引入其它组件(如zookeeper)又不太方便的时候, 不妨尝试一下这个方法, 不会让你失望哦!

(刘蒙, liumeng.trm@foxmail.com)

参考资料