前言
为了保证服务的高可用, 我们通常会将其部署到多个节点(或者多个容器). 但是某些异步任务我们希望只交给一个实例来处理, 或者说, 要在这些服务中选出一个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已经选出来了, 没有加锁也无需自旋, 是不是很简单呢!
设计
初次选举过程
- 初始版本号为0, 服务实例初始化时都试图更新选举版本号, 但只有一个成功, 成功者成为leader
- leader定时向MySQL发送心跳, 维护来之不易的版本号
- follower定时探测leader最后一次心跳时间, 如果超过时限(比如5s)就认为leader宕机
leader宕机之后的改选
- 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
服务实现
选举服务具备以下职责
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;
@Async @Scheduled(cron = "*/5 * * * * ?") public void tick() { if (!electionService.isLeader()) { return; } if (electionService.tick()) { log.info("leader: 心跳发送成功, 继续作威作福"); } else { log.info("leader: 心跳发送失败, 失去leader资格"); } }
@Async @Scheduled(cron = "*/5 * * * * ?") public void detectAndReelect() { try { 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"; private long tickIntervalMillis = 5000; private AtomicLong version = new AtomicLong(0L); private AtomicBoolean isLeader = new AtomicBoolean(false);
@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)
参考资料