分布式id笔记
对分布式id的要求
- 全局唯一性:不可重复
- 趋势递增:为了DB的B+树结构
- 单调递增:下一个id一定大于上一个,为了实现事务
- 信息安全:id连续可能容易被爬取数据,订单号可能会被竞对知道销量
- 低延迟:TP999低
- 高QPS:支持高qps
- 可用性高:可用性要高
TP是一种服务测试指标,指的是在一段时间内,统计该方法每次调用所耗的时间,并将这些时间按照从小打到的顺序进行排序,并取出结果为:总次数*指标数=对应TP指标的值,在去除排序好的时间
一般的指标有TP50、TP90、TP99、TP999
常见的分布式id实现
UUID
uuid标准模型包含32个16进制数字,以链字号分为5段,形式为8-4-4-4-12的36个字符,示例550e8400-e29b-41d4-a716-446655440000
优点
性能极低,本地生成,没有网络消耗
缺点
- 不易存储:uuid太长,16字节128位,通常以36长度字符串表示,很多场景不适用
- 信息不安全:基于MAC地址生成的UUID可能会导致MAC地址暴露
- DB问题:uuid作为主键或要添加索引时不适用
类snowflake方案
通过64个bit位来实现,这64个bit位被划分为几部分,以实现高并发下分布式id的生成
- 最高位1bit不使用
- 41bit作为时间戳,可以使用约69年的时间
- 10bit作为workId,可根据需要划分,例如5bitIDC标识+5bit机器标识
- 12bit序列号,可以表示2^12个id
优点
- 毫秒数在高位,自增序列在低位,整个id都是趋势递增的
- 不依赖数据库等三方的系统,生成id性能很高
- 可根据自身业务分配bit位,灵活性高
缺点
强制依赖机器时钟,如果时钟回拨,会导致重复发号
Mongdb ObjectID
类似于snowflake方法,通过时间+机器码+pid+inc共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符
数据库生成
通过设置auto_increment_increment和auto_increment_offset来保证id自增
优点
- 实现简单,成本小,有DBA维护
- id单调递增
缺点
- 强依赖DB,且DB可能存在主从问题
- id发号性能瓶颈限制在单台MySQL的读写性能
MySQL的性能瓶颈有办法解决吗?
有,可以使用分布式+步长的方式。每台机器设置不同的初始值,且步长与机器数相等。假如有两台机器,那么我第一台机器的初始值为1,第二台机器的初始值为2,两台机器步长都为2,这样第一台机器生成的值为:1, 3, 5, 7, 9;而第二台机器生成的值为2, 4, 6, 8, 10;这样就实现了交叉生成的方法
这种方法也存在缺点:
- 系统水平扩展比较困难, 加减机器调整步长实现起来较为复杂
- id没有了单调递增的特性, 只是趋势递增, 这还好
- 数据库压力大
美团Leaf
Leaf-segment数据库方案
基于数据库方案做了部分改变。原方案每次获取id都得读写一次数据库,这会增大数据库的压力。Leaf-segment使用proxy server批量获取,每次获取一个segment(由步长决定大小)号段的值,用完之后再去数据库获取新的号段,这样可以大大减轻数据库的压力。针对于不同的业务需求也会使用biz_tag进行业务上的区分。大致架构如下:
表设计如下:
+-------------+--------------+------+-----+-------------------+-----------------------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+-------------------+-----------------------------+
| biz_tag | varchar(128) | NO | PRI | | |
| max_id | bigint(20) | NO | | 1 | |
| step | int(11) | NO | | NULL | |
| desc | varchar(256) | YES | | NULL | |
| update_time | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
+-------------+--------------+------+-----+-------------------+-----------------------------+
- max_id表示该biz_tag目前所分配的id号段最大值
- step表示每次分配的号段长度
该方案在性能扩展方面也有所改善,它使用了号段思想,将某个数量范围内的数据存在一起,而不是交叉存储,这样在扩容时只要把新的号段分配给新的机器即可,前提是不修改步长。
优点
- 方便做线性扩展
- id号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求
- 容灾性高,Leaf服务内有号段缓存,DB宕机可以短时间保证服务可用
- 可自定义max_id方便业务从原有服务迁移
缺点
- id不够随机,有信息安全问题
- TP999波动大,在缓存使用完后响应速度严重依赖MySQL更新IO速度
- 强依赖DB
优化
对于TP999波动做出了如下优化:
使用双缓存机制,在号段被使用到10%时启用一步线程去获取下一号段并做好缓存,当当前号段被使用完成,将提前缓存好的下一号段缓存作为当前缓存,可以有效减少并提前预支所要付出的IO代价。两个缓存来回切换也可以增加DB宕机给开发者的反应时间。
对DB高可用做了如下优化:
采用一主两从方式部署DB且分机房部署,Master和Slave采用半同步方式同步数据,且做好监控等方案。
半同步: 保证如果源崩溃,他已提交的所有事物都已传输至少一个副本(不会等待所有同步都完成才视为同步完成)
Leaf-snowflake方案
对64bit位的使用还是按照基础snowflake进行拆分,不同的是10bit workId在Leaf服务规模大的情况下实现动态分配较为困难,所以Leaf-snowflake使用了zookeeper的持久顺序节点特性自动对snowflake节点配置workId
启动顺序如下:
- 启动Leaf-snowflake服务,连接zookeeper,在leaf_forever父节点下检查自己是否已经注册过
- 如果注册过直接取回自己的workId,启动服务
- 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号作为自己的workId号,启动服务
优化
弱依赖zookeeper
在本地文件系统上缓存一个workId文件,这样可以在zookeeper出问题需要重启时保证服务的正常启动,这样就实现了对zookeeper的弱依赖
解决时钟问题
snowflake强依赖系统时间,需要解决时钟回拨问题
简单来说就是:
- 获取Leaf节点的时间计算平均值与自身系统时间作比较来判断时间是否回拨
- 定时上报时间
- 还有一种方案是阻塞时间直到时钟追上当前时间,这种方案会阻塞需要考虑场景且需要看时钟回拨时间不能太长
- 建立完善的监控机制
百度UidGenerator
与常规的雪花算法不同,百度的雪花算法UidGenerator支持自定义时间戳、工作机器和序号等各部分的位数
- Sign-1bit:固定的符号标识,表示生成唯一id为正数
- delta second-28bit:当前时间,相对于时间基点2016-05-20的增量值,可配置
- work node id-22bit:机器id,最多可支持约420w次机器启动
- sequence-13bit:每秒下的并发序列,13bit最多可支持每秒8192个并发
内部通过两种方式实现:DefaultUidGenerator和CachedUidGenerator
DefaultUidGenerator
默认实现,与普通的雪花算法基本相同,不同的是百度的算法使用秒为单位
项目启动时,初始化代码如下
@Override
public void afterPropertiesSet() throws Exception {
// initialize bits allocator
bitsAllocator = new BitsAllocator(timeBits, workerBits, seqBits);
// initialize worker id
workerId = workerIdAssigner.assignWorkerId();
if (workerId > bitsAllocator.getMaxWorkerId()) {
throw new RuntimeException("Worker id " + workerId + " exceeds the max " + bitsAllocator.getMaxWorkerId());
}
LOGGER.info("Initialized bits(1, {}, {}, {}) for workerID:{}", timeBits, workerBits, seqBits, workerId);
}
@Transactional
public long assignWorkerId() {
// build worker node entity
WorkerNodeEntity workerNodeEntity = buildWorkerNode();
// add worker node for new (ignore the same IP + PORT)
workerNodeDAO.addWorkerNode(workerNodeEntity);
LOGGER.info("Add worker node:" + workerNodeEntity);
return workerNodeEntity.getId();
}
每个实例都会去向WORKER_NODE表中添加一条数据,并将id取出作为自己实例的workId,这里就区别于美团Leaf的zookeeper管理
获取方法如下:
@Override
public long getUID() throws UidGenerateException {
try {
return nextId();
} catch (Exception e) {
LOGGER.error("Generate unique id exception. ", e);
throw new UidGenerateException(e);
}
}
protected synchronized long nextId() {
long currentSecond = getCurrentSecond();
// Clock moved backwards, refuse to generate uid
if (currentSecond < lastSecond) {
long refusedSeconds = lastSecond - currentSecond;
throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
}
// At the same second, increase sequence
if (currentSecond == lastSecond) {
sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
// Exceed the max sequence, we wait the next second to generate uid
if (sequence == 0) {
currentSecond = getNextSecond(lastSecond);
}
// At the different second, sequence restart from zero
} else {
sequence = 0L;
}
lastSecond = currentSecond;
// Allocate bits for UID
return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
}
public long allocate(long deltaSeconds, long workerId, long sequence) {
return (deltaSeconds << timestampShift) | (workerId << workerIdShift) | sequence;
}
这种默认的实现方式很简单,是标准的雪花算法生成方式且从上面的方法可以看出,该策略在对于时钟回拨的情况做出的应对方法是抛出异常Clock moved backwards. Refusing for refusedSeconds seconds
CachedUidGenerator
缓存实现,使用RingBuffer环形数组作为缓存不再实时计算,RingBuffer的容量默认为sequence的大小且为2^N
- Tail指针:指向当前最后一个可用uid的位置,表示Producer生产的最大序列号。Tail不能超过Cursor,即生产者不能覆盖未消费的slot
- Cursor指针:指向下一个获取uid的位置,其一定小于Tail,表示Consumer消费到的最小序列号。Cursor不能超过Tail,即不能消费未生产的slot
项目启动时,初始化代码如下:
@Override
public void afterPropertiesSet() throws Exception {
// initialize workerId & bitsAllocator
super.afterPropertiesSet();
// initialize RingBuffer & RingBufferPaddingExecutor
this.initRingBuffer();
LOGGER.info("Initialized RingBuffer successfully.");
}
private void initRingBuffer() {
// initialize RingBuffer
int bufferSize = ((int) bitsAllocator.getMaxSequence() + 1) << boostPower;
this.ringBuffer = new RingBuffer(bufferSize, paddingFactor);
LOGGER.info("Initialized ring buffer size:{}, paddingFactor:{}", bufferSize, paddingFactor);
// initialize RingBufferPaddingExecutor
boolean usingSchedule = (scheduleInterval != null);
this.bufferPaddingExecutor = new BufferPaddingExecutor(ringBuffer, this::nextIdsForOneSecond, usingSchedule);
if (usingSchedule) {
bufferPaddingExecutor.setScheduleInterval(scheduleInterval);
}
LOGGER.info("Initialized BufferPaddingExecutor. Using schdule:{}, interval:{}", usingSchedule, scheduleInterval);
// set rejected put/take handle policy
this.ringBuffer.setBufferPaddingExecutor(bufferPaddingExecutor);
if (rejectedPutBufferHandler != null) {
this.ringBuffer.setRejectedPutHandler(rejectedPutBufferHandler);
}
if (rejectedTakeBufferHandler != null) {
this.ringBuffer.setRejectedTakeHandler(rejectedTakeBufferHandler);
}
// fill in all slots of the RingBuffer
bufferPaddingExecutor.paddingBuffer();
// start buffer padding threads
bufferPaddingExecutor.start();
}
启动时采用RingBuffer缓存已经生成的uid,并行化uid的生产和消费,同时对CacheLine进行补齐,避免了为共享问题。同时采用未来时间解决了sequence天然存在的并发限制。
关于为共享参考此文章:www.cnblogs.com/cyfonly/p/5…
获取uid代码非常简单,就是直接获取RingBuffer中已经缓存好的uid,代码如下:
@Override
public long getUID() {
try {
return ringBuffer.take();
} catch (Exception e) {
LOGGER.error("Generate unique id exception. ", e);
throw new UidGenerateException(e);
}
}
public long take() {
// spin get next available cursor
long currentCursor = cursor.get();
long nextCursor = cursor.updateAndGet(old -> old == tail.get() ? old : old + 1);
// check for safety consideration, it never occurs
Assert.isTrue(nextCursor >= currentCursor, "Curosr can't move back");
// trigger padding in an async-mode if reach the threshold
long currentTail = tail.get();
if (currentTail - nextCursor < paddingThreshold) {
LOGGER.info("Reach the padding threshold:{}. tail:{}, cursor:{}, rest:{}", paddingThreshold, currentTail,
nextCursor, currentTail - nextCursor);
bufferPaddingExecutor.asyncPadding();
}
// cursor catch the tail, means that there is no more available UID to take
if (nextCursor == currentCursor) {
rejectedTakeHandler.rejectTakeBuffer(this);
}
// 1. check next slot flag is CAN_TAKE_FLAG
int nextCursorIndex = calSlotIndex(nextCursor);
Assert.isTrue(flags[nextCursorIndex].get() == CAN_TAKE_FLAG, "Curosr not in can take status");
// 2. get UID from next slot
// 3. set next slot flag as CAN_PUT_FLAG.
long uid = slots[nextCursorIndex];
flags[nextCursorIndex].set(CAN_PUT_FLAG);
// Note that: Step 2,3 can not swap. If we set flag before get value of slot, the producer may overwrite the
// slot with a new UID, and this may cause the consumer take the UID twice after walk a round the ring
return uid;
}