我正在参加「掘金·启航计划」
一、引言
公司之前做过一次关于分布式主键生成调研,因为做的是IM相关的业务,部分业务中的id有实际含义,例如:在消息服务中,每条消息都对应着一个id,用户已读未读或者拉取消息列表则主要通过id来分析。因此生成的id要满足以下几种情况
- 唯一性
- 线性单调递增而非趋势递增
- 高可用,掉线重连后跨度区间要小
- 高并发
二、方案调研
调研了市面上常用的分布式主键解决方案,包括uuid、雪花算法、
[美团leaf] tech.meituan.com/2017/04/21/…
[微信序列号] www.infoq.cn/article/wec…
[百度UidGenerator] github.com/baidu/uid-g…
有兴趣大家也可以了解下大厂的分布式主键解决方案。各个方案对比如下
| 方案 | 唯一性 | 递增趋势 | 高可用 | 区间跨度小 | 高并发 | 存在问题 |
|---|---|---|---|---|---|---|
| uuid | ✅ | ❎ | ✅ | ❎ | ✅ | uuid不适合作为主键 |
| 雪花算法 | ✅ | 单调递增 | ✅ | ❎ | ✅ | 雪花算法高度依赖于时间戳,无法直接解决时间同步带来的回拨问题。且掉线前后所生成的id跨度区间大。每秒生成的个数又限制于低位 |
| 美团leaf | ✅ | 趋势递增 | ✅ | ✅ | ✅ | 采用号段的模式发取Id,导致递增趋势为整体趋势递增,即后发放的id可能比先发放的id小,这是在消息模式中不可容忍的 |
| 微信序列号 | ✅ | 单调递增 | ✅ | ✅ | ✅ | 微信采用的方案于项目之前方案类似一致,则是通过路由的方式,让指定的服务路由到指定的节点生成id,但是这种情况一旦部分节点宕机,服务将处于部分不可用状态 |
| 百度UidGenerator | ✅ | 趋势递增 | ❎ | ❎ | ✅ | 百度的双buff思想让qps峰值到达500w,但是因为大部分id都在内存中,其他服务不可见,因此服务一旦宕机重连后,先后生成的id跨度非常大。且多台机器生成的id也是整体呈趋势递增。 |
在公司项目中,单调递增趋势是必需品,因此这里针对此问题,采用下面解决方案。如果是普通项目,非常建议使用百度UidGenerator分布式主键。
三、解决方案
核心思想,利用中间价redis。使用官方提供的redisson客户端进行原子性操作,保证了整体趋势为 单调递增、数据的唯一性。
这里不讨论redis在主从同步的时候产生多锁或者其他极端情况,默认是信任中间价的。
同时,借鉴百度的双buff思想,维护slot槽位概念,解决申请id过慢的问题,同时维护批量生产id的功能。由于采用的是redisson,因此高可用、掉线重连等问题也迎刃而解。
核心逻辑如下:
Solt,根据不同业务,路由到不同的槽位,确保相同业务的id是单调递增的。项目初始化的时候会维护slotId,curId,maxId
/**
* 槽 (curId,maxId]
*/
@Data
public class Slot {
/**
* 槽位编号
*/
private int slotId;
/**
* 当前已发出去的最大id
*/
private AtomicLong curId;
/**
* 可产生的最大id
*/
private AtomicLong maxId;
}
获取id核心代码:
//slotId 槽位id,size 申请的id数量
long currentCurId = slotCache.increment(getSlotCurIdKey(slotId), size);
// 获取该槽位的起始值
long initValue = SlotUtil.getInitValue(slotId);
//判断redis值是否合法,即currentCurId一定要大于>initValue,否则证明这个值没有初始化过
// 小于起始值,认为redis值非法
if (currentCurId <= initValue) {
// 获取分布式锁
RLock lock = redissonClient.getLock("redisson:refreshslot:" + slotId);
// 加锁
lock.lock();
try {
// 再获取一次
currentCurId = slotCache.incrementSlotCurId(slotId, size);
if (currentCurId <= initValue) {
// 刷新槽位(同步):redis值非法
//syncRefreshSlot 就是往数据库里写入当前的槽位信息,根据设定好的步长来在数据库中持久化maxId
clusterSlotManager.syncRefreshSlot(new Slot(slotId));
// 再获取一次
currentCurId = slotCache.incrementSlotCurId(slotId, size);
}
} finally {
// 释放锁
lock.unlock();
}
}
// 批量获取
if (size > 1) {
// 先前的curId
long oldCurId = currentCurId - size;
// 刷新槽位的时机
if (oldCurId / slotStep < currentCurId / slotStep) {
// 刷新槽位(异步)
clusterSlotManager.asyncRefreshSlot(new Slot(slotId));
}
// 组装返回值
for (long id = oldCurId + 1; id <= currentCurId; id++) {
list.add(id);
}
} else {
// 刷新槽位的时机
if (currentCurId % slotStep == 0) {
// 刷新槽位(异步)
clusterSlotManager.asyncRefreshSlot(new Slot(slotId));
}
// 组装返回值
list.add(currentCurId);
}
return list;
起始值为 1000000,步长为100000
这里说一下,刷新Slot的几个时机
- 在项目启动的时候,会初始化Slot(从数据库中读取),如果有记录则把当前的Slot赋值
- 在生成id时,currentCurId <= initValue时
- 这里采用的是双倍步长,即maxId和curId 一开始的时候相距两个步长 ,当oldCurId / slotStep < currentCurId / slotStep 即使用id到达可用的50%以后,会刷新maxId。 currentCurId % slotStep == 0 同理。
以上即为公司的分布式主键id解决方案,解决了IM聊天类系统分布式主键id非单调递增的主要问题,这里只讲解大概思路,详细代码由于未取得开源允许,这里就不再详解
四、性能测试
机器配置(48核 128G)
| 发压机:XXXXXX | 服务:XXXXXXXX |
|---|---|
| 内存:128G | |
| CPU:48核 | |
| 配置内存:1G |
getId(携程号)
集群版
# 500携程 执行 30 秒
[linkdood@localhost ant-chenlong]$ ./ant -c 500 -d 30 -r idgenerator-getId xxx.xxx.xxx.xxxx:xxxxx
Running 30s of test @ xxx.xxx.xxx.xxxx:xxxxx
500 goroutine(s) running concurrently
3168693 requests in 30.00sec
success:3168693
failed:0
Requests/sec(TPS): 105623.10
Avg Req Time: 4.73ms
Fastest Request: 0.56ms
Slowest Request: 1024.31ms
TP50: 4.58ms
TP90: 5.11ms
TP99: 8.34ms
listIds(携程号,100)
集群版
# 500携程 执行 30 秒
[linkdood@localhost ant-chenlong]$ ./ant -c 500 -d 30 -r idgenerator-listIds xxx.xxx.xxx.xxxx:xxxxx
Running 30s of test @ xxx.xxx.xxx.xxxx:xxxxx
500 goroutine(s) running concurrently
2928423 requests in 30.00sec
success:2928423
failed:0
Requests/sec(TPS): 97614.10
Avg Req Time: 5.12ms
Fastest Request: 0.72ms
Slowest Request: 1027.99ms
TP50: 4.75ms
TP90: 5.35ms
TP99: 16.03ms
测试结果
| getId(携程号) | listIds(携程号,100) |
|---|---|
| 集群版:105623 tps | 集群版:97614 tps |