最近要做区块链项目,要生成很多唯一ID做业务号之类的,所以趁此机会学习学习。
分布式ID的几种生成方案
UUID
之前一直是用的UUID生成唯一ID,好处显而易见,方便快捷,坏处就是:
- 数据库里不好做索引,每次生成的ID是无序的,无法保证趋势递增。
- UUID的字符串存储,存储空间大,查询效率慢。
- ID本事无业务含义,不可读。
很明显,用来做业务号不是UUID的应用场景,使用UUID应该要保障不要求递增,无确实含义的场景,比如说做令牌Token使用。
MySQL主键自增
这个方案就是利用了MySQL的主键自增auto_increment,默认每次ID加1。
这样做的好处:
- id是有序的,能够保证自增。
- 查询效率高,具有一定的业务可读。
坏处也是显而易见:单点问题,对于单个数据库压力过大,高并发扛不住。
解决方案是按步长自增:
这样能够解决一个单点的问题,缺陷是:
- 一旦把步长定好后,就无法扩容。
- 虽然相比于单机的方式,数据库压力小了很多,但是还是有一定压力的。
数据库自增ID改进方案
- 【用户服务】在注册一个用户时,需要一个用户ID;会请求【生成ID服务(是独立的应用)】的接口。
- 【生成ID服务】会去查询数据库,找到user_tag的id,现在的max_id为0,step=1000。(可以加上行锁,防止两个事务请求到相同的结果)
- 【生成ID服务】把max_id和step返回给【用户服务】;并且把max_id更新为max_id = max_id + step,即更新为1000。
- 【用户服务】获得max_id=0,step=1000;这个用户服务可以用ID=【max_id + 1,max_id+step】区间的ID,即为【1,1000】
- 【用户服务】会把这个区间保存到jvm中。用户服务】需要用到ID的时候,在区间【1,1000】中依次获取id,可采用AtomicLong中的getAndIncrement方法。
- 如果把区间的值用完了,再去请求【生产ID服务】接口,获取到max_id为1000,即可以用【max_id + 1,max_id+step】区间的ID,即为【1001,2000】
这个方案就非常完美的解决了数据库自增的问题,而且可以自行定义max_id的起点,和step步长,非常方便扩容。
而且也解决了数据库压力的问题,因为在一段区间内,是在jvm内存中获取的,而不需要每次请求数据库。即使数据库宕机了,系统也不受影响,ID还能维持一段时间。
雪花算法(SnowFlake)
SnowFlake算法生成id的结果是一个64bit大小的整数(也就是long类型,或者说bigInt类型),它的结构如下图:
- 1bit-不用:
因为二进制中最高位是符号位,1表示负数,0表示正数。生成的id一般都是用整数,所以最高位固定为0。 - 41bit-时间戳:
用来记录时间戳,毫秒级。如果只是用来记录整数的时间戳的话,那么41bit实际上可以记载:
2 41 / ( 365 ∗ 24 ∗ 60 ∗ 60 ∗ 1000 m s ) 2^{41}/(365*24*60*60*1000ms) 241/(365∗24∗60∗60∗1000ms),约为69年。 - 10bit-工作机器id:
用来记录工作机器id,那么可以部署的机器数目为 2 10 = 1024 2^{10}=1024 210=1024台机器,包括5位datacenterId(机房id)和5位workerId(机器id)。 - 12bit-序列号:
用来记录同毫秒内产生的不同id,也就是一台机器上同一毫秒的并发量是 2 12 = 4096 2^{12}=4096 212=4096次。
SnowFlake可以保证:
- 所有生成的id按时间趋势递增
- 整个分布式系统内不会产生重复id(因为有datacenterId和workerId来做区分)
这里分析一下生成ID的函数。
//下一个ID生成算法
//使用了synchronized生成ID,做一个阻塞,防止同一毫秒内生成相同的12位序列号
public synchronized long nextId() {
long timestamp = timeGen();//获取到当前的时间戳
//获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
//获取当前时间戳如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始。
if (lastTimestamp == timestamp) {
sequence = (sequence + 1) & sequenceMask;
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
//将上次时间戳值刷新
lastTimestamp = timestamp;
/**
* 返回结果:
* (timestamp - twepoch) << timestampLeftShift) 表示将时间戳减去初始时间戳,再左移相应位数
* (datacenterId << datacenterIdShift) 表示将数据id左移相应位数
* (workerId << workerIdShift) 表示将工作id左移相应位数
* | 是按位或运算符,例如:x | y,只有当x,y都为0的时候结果才为0,其它情况结果都为1。
* 因为个部分只有相应位上的值有意义,其它位上都是0,所以将各部分的值进行 | 运算就能得到最终拼接好的id
*/
return ((timestamp - twepoch) << timestampLeftShift) |
(datacenterId << datacenterIdShift) |
(workerId << workerIdShift) |
sequence;
}
优点:
- 此方案每秒能够产生409.6万个ID,性能快
- 时间戳在高位,自增序列在低位,整个ID是趋势递增的,按照时间有序递增
- 灵活度高,可以根据业务需求,调整bit位的划分,满足不同的需求
缺点:
- 依赖机器的时钟,如果服务器时钟回拨,会导致重复ID生成。
在分布式场景中,服务器时钟回拨会经常遇到(时间校准,以及其他因素,可能导致服务器时间回退),一般存在10ms之间的回拨;小伙伴们就说这点10ms,很短可以不考虑吧。但此算法就是建立在毫秒级别的生成方案,一旦回拨,就很有可能存在重复ID。
雪花算法的优化
synchronized
关键字:
此锁的目的是为了保证在多线程的情况下,只有一个线程进入方法体生成ID,保证并发情况下生成ID的唯一性,如果在竞争激烈情况下,自旋锁+ CAS原子变量的方式或许是更为合理的选择,可以达到优化部分性能的目的。
- 时钟回拨问题:
UidGenerator是百度开源的Java语言实现,基于Snowflake算法的唯一ID生成器。另外,它通过消费未来时间克服了雪花算法的并发限制。UidGenerator提前生成ID并缓存在RingBuffer中。
RingBuffer,如下图所示,它本质上是一个数组,数组中每个项被称为slot。UidGenerator设计了两个RingBuffer,一个保存唯一ID,一个保存flag。RingBuffer的尺寸是2^n,n必须是正整数:
-
RingBuffer of Flag:
保存flag这个RingBuffer的每个slot的值都是0或者1,0是CAN_PUT_FLAG的标志位,1是CAN_TAKE_FLAG的标识位。也就是可以放置UID或者拿取UID的标识。 -
RingBuffer of UID:
保存唯一ID的RingBuffer有两个指针,Tail指针和Cursor指针。
Tail指针表示最后一个生成的唯一ID。如果这个指针追上了Cursor指针,意味着RingBuffer已经满了。这时候,不允许再继续生成ID了。
Cursor指针表示最后一个已经给消费的唯一ID。如果Cursor指针追上了Tail指针,意味着RingBuffer已经空了。这时候,不允许再继续获取ID了。
初始化阶段:
- 根据boostPower的值确定RingBuffer的size。bufferSize= 2 13 2^{13} 213, 扩容后bufferSize = 2 13 2^{13} 213<<boostPower(位移操作)。
- 构造RingBuffer,默认paddingFactor为50。这个值的意思是当RingBuffer中剩余可用ID数量少于50%的时候,就会触发一个异步线程往RingBuffer中填充新的唯一ID。
- 初始化PUT和TAKE的拒绝策略,也就是满了或者空了之后应该怎么做。
- 初始化填满RingBuffer中所有slot(填满所有ID)。
百度UidGenerator的优势:
- 不依赖系统时间:
传统的雪花算法实现都是通过System.currentTimeMillis()来获取时间并与上一次时间进行比较,这样的实现严重依赖服务器的时间。而UidGenerator的时间类型是AtomicLong,且通过incrementAndGet()方法获取下一次的时间,从而脱离了对服务器时间的依赖,也就不会有时钟回拨的问题(这种做法也有一个小问题,即分布式ID中的时间信息可能并不是这个ID真正产生的时间点,例如:获取的某分布式ID的值为3200169789968523265,它的反解析结果为{“timestamp”:“2019-05-02 23:26:39”,“workerId”:“21”,“sequence”:“1”},但是这个ID可能并不是在"2019-05-02 23:26:39"这个时间产生的)。 - 使用缓存
Redis自增id
利用redis的incr原子性操作自增,一般算法为:年份 + 当天距当年第多少天 + 天数 + 小时 + redis自增。
优点:
- 有序递增,可读性强。
- 性能还可以。
缺点:
- 占用带宽,每次要向redis进行请求,并发强依赖了Redis。
- ID安全性的问题,如:Redis方案中,用户是可以预测下一个ID号是多少,因为算法是递增的。(当然自增的ID都存在这样的问题)
比如,竞争对手第一天中午12点下个订单,就可以看到平台的订单ID是多少,第二天中午12点再下一单,又平台订单ID到多少。这样就可以猜到平台1天能产生多少订单了。
Zookeeper有序节点
通过创建ZK的顺序模式的节点,可以生成全局唯一的ID。