目录
前言
回顾二进制
二进制概念
运算法则
位(Bit)
字节(Byte)
字符
字符集
二进制原码、反码、补码
有符号数和无符号数
疑问:为什么不是-127 ~ 127 ?
为什么需要分布式全局唯一ID以及分布式ID得业务需求?
ID生成规则部分硬性要求
ID生成系统的可用性要求
通用解决方案
1. UUID
优点
缺点
2. 数据库自增主键
优点
缺点
3. 基于Redis生成全局ID策略
单机版
集群分布式
缺点
SnowFlake雪花算法
雪花算法结构
雪花算法的优缺点
优点
缺点
雪花算法的具体实现
SpringtBoot整合雪花算法 (基于hutool工具类)
第三方基于SnowFlake算法生成的唯一ID
雪花算法相关问题以及解决方案
1. 时间回拨问题
2.工作机器ID可能会重复的问题
3. -1L ^ (-1L << x) 表示什么?
4.前端直接使用发生精度丢失
前言
本文章首先回顾一下二进制相关知识点,方便猿友们阅读的更清晰,不至于读完还是一脸懵的状态;其次,现阶段为什么需要分布式全局唯一ID以及分布式ID的业务需求,进行详细描述一下;以及此问题提出后一般通用的解决方案和优缺点;最后详细描述一下本文章的核心功能点——雪花算法(SnowFlake)。
吾在写本文章时也是处于不是太懂具体实现以及优劣势,才下决定好好研究一波,希望能够帮助到还不明白的猿友们,若本文有不足之处,欢迎大佬点评!!!
回顾二进制
二进制概念
二进制是计算技术中广泛采用的一种数制。二进制数据是用0和1两个数码来表示的数。
它的基数位2,进位规则是“逢二进一”,借位规则是“借一当二”,由18世纪德国数理哲学大师莱布尼兹发现。
当前的计算机系统使用的基本上是二进制系统,数据在计算机中主要是以补码的形式存储的。
计算机中的二进制则是一个非常微小的开关,用“开”来表示1,“关”来表示0。
运算法则
二进制的运算算术运算
二进制的加法(+):0+0=0,0+1=1,1+0=1,1+1=10(向高位进位);例:7=111;10=1010;3=11
二进制的减法(-):0-0=0,0-1=1(先高位错位)1-0=1,1-1=0(模二加运算或异或运算);
二进制的乘法(*):0*0=0;0*1=0;1*0=0;1*1=1;
二进制的除法(/):0/0=0,0/1=0,1/0=0(无意义),1/1=1;
逻辑运算二进制的或(|)运算:遇1得1
逻辑运算二进制的与(&)运算:遇0得0
逻辑运算二进制得非(!)运算:各位取反。
位(Bit)
数据存储得最小单位。每个二进制数字0或者1就是1个位。
字节(Byte)
8个位构成一个字节;即1byte(字节)= 8bit(位)
- 1KB = 1024B(字节);
- 1MB = 1024KB;(2^10 B)
- 1GB = 1024MB;(2^20 B)
- 1TB = 1024GB; (2^30 B)
字符
a、A、中、+、*、@……均表示一个字符;
一般utf-8编码下,一个汉字 字符 占用3个字节;
一般gbk编码下,一个汉字 字符 占用2个字节。
字符集
即各种各个字符的集合,也就是说哪些汉字,字母(A、b、c)和符号(空格、引号..)会被收入标准中
二进制原码、反码、补码
正数 | 负数 | |
---|---|---|
原码 | 原码 | 原码 |
反码 | 原码 | 原码符号位外按位取反 |
补码 | 原码 | 反码+1 |
有符号数和无符号数
无符号数中,所有的位都用于直接表示该值的大小。
有符号数中,最高位用于表示正负。
例:
8位2进制表示的:
无符号数的范围为0(00000000B) ~ 255 (11111111B);
有符号数的范围为-128(10000000B) ~ 127 (01111111B);
255 = 1*2^7 + 1*2^6 + 1*2^5 +1*2^4 +1*2^3 +1*2^2 +1*2^1 +1*2^0;
127 = 1*2^6 + 1*2^5 +1*2^4 +1*2^3 +1*2^2 +1*2^1 +1*2^0;
疑问:为什么不是-127 ~ 127 ?
补码比其它码多一位,这是为什么呢?问题出在0上。
[+0]原码=0000 0000, [-0]原码=1000 0000
[+0]反码=0000 0000, [-0]反码=1111 1111
[+0]补码=0000 0000, [-0]补码=0000 0000
反码表示法规定:正数的反码与其原码相同。负数的反码是对其原码逐位取反,但符号位除外。
在规定中,8位二进制码能表示的反码范围是-127~127。
-128没有反码。
为什么规定-128没有反码呢?
首先看-0,[-0]原码=1000 000,其中1是符号位,根据反码规定,算出[-0]反码=1111 1111, 再看-128,[-128]原码=1000 000,假如让-128也有反码,根据反码规定,则[-128]反码=1111 1111,
-128的反码和-0的反码相同,所以为了避免面混淆,有了-0,便不能有-128,这是反码规则决定的
因此八位二进制表示的范围为:-128~0~127。此处的0为正0,负0表示了-128。
为什么需要分布式全局唯一ID以及分布式ID得业务需求?
在复杂分布式系统中,往往需要对大量得数据和消息进行唯一标识,如美团点评得金融、支付、餐饮、酒店、订单、优惠券都需要由唯一ID做标识
ID生成规则部分硬性要求
- 全局唯一
- 趋势递增
- 在MySQL得InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用Btree的数据结构来存储索引,在主键的选择上面应该尽量使用有序的主键保证写入性能
- 单调递增
- 保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求
- 信息安全
- 如果ID是连续,恶意用户的爬取工作就非常容易做了,直接按照顺序下载指定URL即可,如果是订单号就危险了,竞争对手可以直接知道我们一天的单量,所以在一些应用场景下,需要ID无规则不规则。
- 含时间戳
- 一样能够快速在开发中了解这个分布式ID什么时候生成的
ID生成系统的可用性要求
- 高可用
- 发布一个获取分布式ID请求,服务器就要保证99.999%的情况下给我创建一个唯一分布式ID
- 低延迟
- 发一个获取分布式ID的请求,服务器就要快,极速
- 高QPS
- (每秒查询率(QPS,Queries-per-second)是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准)。
- 例如并发一口气10万个创建分布式ID请求同时杀过来,服务器要顶得住且一下子成功创建10万个分布式ID
- (每秒查询率(QPS,Queries-per-second)是对一个特定的查询服务器在规定时间内所处理流量多少的衡量标准)。
通用解决方案
1. UUID
UUID.randomUUID(),UUID的标准型包含32个16进制数字,以连字号分为五段,形式位 8-4-4-4-12的36个字符,性能非常高,本地生成,没有网络消耗。
优点
- 能够保证独立性,程序可以在不同的数据库间迁移,效果不受影响。
- 保证生成的ID不仅是表独立的,而且是库独立的,这点在你想切分数据库的时候尤为重要。
缺点
- 入数据库性能差,因为UUID是无序的
无序,无法预测他的生成顺序,不能生成递增有序的数字
首先分布式id一般都会作为主键,但是按照mysql官方推荐主键尽量越短越好,UUID每一个都很长,所以不是很推荐。
- 主键,ID最为主键时,在特定的环境下会存在一些问题
比如做DB主键的场景下,UUID就非常不适用MySQL官方明确的说明
- 索引,B+树索引的分裂
既然分布式ID是主键,然后主键是包含索引的,而mysql的索引是通过B+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的B+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键的B+树进行很大的修改,这一点很不好,插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。
UUID只能保证全局唯一性,不满足后买你的趋势递增,单调递增
2. 数据库自增主键
在分布式里面,数据库的自增ID机制的主要原理是:数据库自增ID和mysql数据库的replace into实现的,这里的replace into跟insert功能类似,不同点在于:replace into首先尝试插入数据列表中,如果发现表中已经有此行数据(根据主机那或者唯一索引判断)则先删除,在插入,否则直接插入新数据。
REPLCAE INTO的含义是插入一条记录,如果表中唯一索引的值遇到冲突,则替换老数据
create table t_test(
id bigint(20) unsigned not null auto_increment primary key,
stub char(1) not null default '',
unique key stub (stub)
)
REPLACE into t_test(stub) values('b');
select LAST_INSERT_ID();
每次插入的时候,发现都会把原来的数据给替换,并且ID也会增加。
满足了:递增性、单调性、唯一性
优点
- 非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护。
- ID号单调自增,可以实现一些对ID有特殊要求的业务。
缺点
- 不适合做分布式ID,系统水平扩展比较困难
比如定义好步长和机器台数之后,如果要添加机器该怎么办,假设现在有一台机器发号是:1,2,3,4(步长是1),这个时候需要扩容机器一台,可以这样做:把第二台机器的初始值设置得比第一台超过很多,貌似还好,但是假设线上如果有100台机器,这个时候扩容要怎么做,简直是噩梦,所以系统水平扩展方案复杂难以实现。
- 数据库压力大
每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面得延迟低和高QPS得规则(在高并发下,如果都去数据库里面获取ID,那是非常影响性能的)
- 数据库迁移以及分表分库困难
- 不同数据库语法和实现不同,数据库迁移的时候或多数据库版本支持的时候需要处理。
- 在单个数据库或读写分离或一主多从的情况下,只有一个主库可以生成。
- 如果涉及多个系统需要合并或者数据迁移比较麻烦。
- 分表分库的时候麻烦。
3. 基于Redis生成全局ID策略
单机版
因为Redis是单线程,天生保证原子性,可以使用原子操作INCR和INCEBY来实现、
- INCRBY:设置增长步长。key是redis中的键,将key所存储的值加上增量integer。如果key不存在,那么key的值就会被初始化为0,然后在执行incrby命令。
- INCR:key是redis中的键,只是每次都是默认自增1。
集群分布式
注意:在Redis集群情况下,同样和MySQL一样需要设置不同的增长步长,同时key一定要设置有效期,可以使用Redis集群来获取更高的吞吐量。
假设一个集群中有5台Redis,可以初始化每台Redis的值分别是1,2,3,4,5,然后设置步长都是5
各个Redis生成的ID为:
A:1 6 11 16 21
B:2 7 12 17 22
C:3 8 13 18 23
D:4 9 14 19 24
E:5 10 15 20 25
缺点
- Redis集群的维护和保养比较麻烦,配置麻烦。
- 要设置单点故障,哨兵值守。
SnowFlake雪花算法
SnowFlake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。
核心组成:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit是机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生4096个ID),最后还有一个符号位,永远是0.
核心思想:分布式、唯一。
雪花算法结构
结构格式(64bit):1bit保留 + 41bit时间戳 + 10bit机器 + 12bit序列号
<1> 1bit-保留不用
因为二进制中最高位是符号位,1标识负数,0标识正数,生成的id一般都是用整数,所以最高位固定为0.
<2> 41bit-用来记录时间戳(毫秒)
41位可以表示2^41-1个数字,如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0至2^41-1,减1是因为可表示的数值范围是从0开始算的,而不是1.也就是说41位可以表示2^41-1个毫秒的值,转化成单位年则是:
(2^41−1)/(1000∗60∗60∗24∗365)=69年 ,也就是说这个时间戳可以使用69年不重复
注意:其时间戳的算法是1970年1月1日到指点时间所经过的毫秒或秒数,那咱们把开始时间从2021年开始,就可以延长41位时间戳能表达的最大时间,所以这里实际指的是相对自定义开始时间的时间戳。
<3> 10bit-用来记录工作机器id
- 可以部署在2^10=1024个节点,包括5位datacenterId和5位workerId
- 5位(bit)可以表示的最大正整数是2^5-1=31,即可以用0、1、2、3、……31这32个数字,来表示不同的datecenterId或者workerId
<4> 12bit-序列号,用来记录同毫秒内产生的不同id
- 12位(bit)可以表示的最大正整数是2^12-1=4095,即可以用0、1、2、3、……4095这4096个数字,来表示同一机器同一时间戳(毫秒)内产生的4096个ID序号
- SnowFlake算法在同一毫秒内最多可以生成多少个全局唯一ID?
同一毫秒的ID数量 = 1024 * 4096 = 4194304,所以最大可以支持单应用差不多四百万的并发量。
注意:上面总体是64位,具体位数可自行配置,
若想运行更久,需要增加时间戳位数;
若想支持更多节点,可增加工作机器Id位数;
若想支持更高并发,增加序列号位数。
雪花算法的优缺点
优点
- 系统环境ID不重复
能满足高并发分布式系统环境ID不重复,比如大家熟知的分布式场景下的数据库表的ID生成。
- 生成效率极高
在高并发,以及分布式环境下,除了生成不重复id,每秒可生成百万个不重复id,生成效率极高。
- 保证基本有序递增
基于时间戳,可以保证基本有序递增,很多业务场景都有这个需求。
- 不依赖第三方库
不依赖第三方的库,或者中间件,算法简单,在内存中进行。
缺点
- 强依赖系统时钟,如果主机时间回拨,则会造成重复ID
- 在单机上是递增的,但由于涉及到分布式环境,每台机器上的时钟不可能完全同步,有时候会出现不是全局递增的情况,此缺点可以认为无所谓,一般分布式ID只要求趋势递增,并不会严格要求递增,90%的需求只要求趋势递增。
雪花算法的具体实现
注意!注意!注意!以下代码亲测过,并有详细的注释解说,直接拿走,不谢!!!
public class SnowFlakeUtil {
/**
* 初始时间戳,可以根据业务需求更改时间戳
*/
private final long twepoch = 11681452025134L;
/**
* 机器ID所占位数,长度为5位
*/
private final long workerIdBits = 5L;
/**
* 数据标识ID所占位数,长度位5位
*/
private final long datacenterIdBits = 5L;
/**
* 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
*/
private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
/**
* 支持的最大数据标识id,结果是31
*/
private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
/**
* 序列在id中占的位数
*/
private final long sequenceBits = 12L;
/**
* 工作机器ID向左移12位
*/
private final long workerIdShift = sequenceBits;
/**
* 数据标识id向左移17位(12+5)
*/
private final long dataCenterIdShift = sequenceBits + workerIdBits;
/**
* 时间截向左移22位(5+5+12)
*/
private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
/**
* 序列号最大值; 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
*/
private final long sequenceMask = -1L ^ (-1L << sequenceBits);
/**
* 工作机器ID(0~31),2进制5位 32位减掉1位 31个
*/
private volatile long workerId;
/**
* 数据中心ID(0~31),2进制5位 32位减掉1位 31个
*/
private volatile long datacenterId;
/**
* 毫秒内序列(0~4095),2进制12位 4096 - 1 = 4095个
*/
private volatile long sequence = 0L;
/**
* 上次时间戳,初始值为负数
*/
private volatile long lastTimestamp = -1L;
// ==============================Constructors=====================================
/**
* 有参构造
* @param workerId 工作机器ID(0~31)
* @param datacenterId 数据中心ID(0~31)
* @param sequence 毫秒内序列(0~4095)
*/
public SnowFlakeUtil(long workerId, long datacenterId, long sequence){
// sanity check for workerId
if (workerId > maxWorkerId || workerId < 0) {
throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0",maxWorkerId));
}
if (datacenterId > maxDatacenterId || datacenterId < 0) {
throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0",maxDatacenterId));
}
System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d",
timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId);
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
// ==============================Methods==========================================
/**
* 获得下一个ID (该方法是线程安全的)
* 如果一个线程反复获取Synchronized锁,那么synchronized锁将变成偏向锁。
* @return 生成的ID
*/
public synchronized long nextId() {
// 获取当前时间的时间戳,单位(毫秒)
long timestamp = timeGen();
// 获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常
if (timestamp < lastTimestamp) {
System.err.printf("当前时间戳不能小于上次时间戳,上次时间戳为: %d.", lastTimestamp);
throw new RuntimeException(String.format("当前时间戳不能小于上次时间戳,生成ID失败. 时间戳差值: %d milliseconds",
lastTimestamp - timestamp));
}
// 获取当前时间戳如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始。
if (lastTimestamp == timestamp) {
/* 逻辑:意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,
这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围 */
// sequence:毫秒内序列(0~4095); sequenceMask: 序列号最大值;
sequence = (sequence + 1) & sequenceMask;
/* 逻辑:当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID */
if (sequence == 0) {
timestamp = tilNextMillis(lastTimestamp);
}
} else {
sequence = 0;
}
// 将上次时间戳值刷新(逻辑:记录一下最近一次生成id的时间戳,单位是毫秒)
lastTimestamp = timestamp;
/* 核心逻辑:生成一个64bit的id;
先将当前时间戳左移,放到41 bit那儿;
将机房id左移放到5 bit那儿;
将机器id左移放到5 bit那儿;
将序号放最后12 bit
最后拼接起来成一个64 bit的二进制数字,转换成10进制就是个long型 */
/*
* 返回结果:
* (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;
}
/**
* 上次时间戳与当前时间戳进行比较
* 逻辑:当某一毫秒的时间,产生的id数 超过4095,系统会进入等待,直到下一毫秒,系统继续产生ID
* @param lastTimestamp 上次时间戳
* @return 若当前时间戳小于等于上次时间戳(时间回拨了),则返回最新当前时间戳; 否则,返回当前时间戳
*/
private long tilNextMillis(long lastTimestamp) {
long timestamp = timeGen();
while (timestamp <= lastTimestamp) {
timestamp = timeGen();
}
return timestamp;
}
/**
* 获取系统时间戳
* @return 当前时间的时间戳 14位
*/
private long timeGen(){
return System.currentTimeMillis();
}
public static void main(String[] args) {
SnowFlakeUtil snowFlakeUtil = new SnowFlakeUtil(1,1,0);
System.out.println(snowFlakeUtil.timeGen());
for (int i = 0; i < 100; i++) {
System.out.println("雪花算法生成第【"+(i+1)+"】个ID:"+ snowFlakeUtil.nextId());
}
}
}
以上代码执行结果:
雪花算法生成第【1】个ID:-5049534853385416704
雪花算法生成第【2】个ID:-5049534853385416703
雪花算法生成第【3】个ID:-5049534853385416702
雪花算法生成第【4】个ID:-5049534853385416701
雪花算法生成第【5】个ID:-5049534853385416700
雪花算法生成第【6】个ID:-5049534853385416699
雪花算法生成第【7】个ID:-5049534853385416698
雪花算法生成第【8】个ID:-5049534853385416697
雪花算法生成第【9】个ID:-5049534853385416696
雪花算法生成第【10】个ID:-5049534853385416695
……
……
…… (之后的结果就不一一展示了)
SpringtBoot整合雪花算法 (基于hutool工具类)
第一步:引入hutool工具依赖包
<!-- hutool工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.3.1</version>
</dependency>
第二步:Springboot整合具体代码实现
import cn.hutool.core.lang.Snowflake;
import cn.hutool.core.net.NetUtil;
import cn.hutool.core.util.IdUtil;
import javax.annotation.PostConstruct;
/**
* SpringtBoot整合雪花算法 (基于hutool工具类)
*/
public class SnowFlakeHutoolTestController {
/**
* 工作机器ID(0~31),2进制5位 32位减掉1位 31个
*/
private long workerId = 0;
/**
* 数据中心ID(0~31),2进制5位 32位减掉1位 31个
*/
private long datacenterId = 1;
/**
* 雪花算法对象
*/
private Snowflake snowFlake = IdUtil.createSnowflake(workerId, datacenterId);
@PostConstruct
public void init() {
try {
// 将网络ip转换成long
workerId = NetUtil.ipv4ToLong(NetUtil.getLocalhostStr());
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 获取雪花ID,默认使用网络IP作为工作机器ID
* @return ID
*/
public synchronized long snowflakeId() {
return this.snowFlake.nextId();
}
/**
* 获取雪花ID
* @param workerId 工作机器ID
* @param datacenterId 数据中心ID
* @return ID
*/
public synchronized long snowflakeId(long workerId, long datacenterId) {
Snowflake snowflake = IdUtil.createSnowflake(workerId, datacenterId);
return snowflake.nextId();
}
public static void main(String[] args) {
SnowFlakeHutoolTestController snowFlakeDemo = new SnowFlakeHutoolTestController();
for (int i = 0; i < 20; i++) {
int finalI = i;
new Thread(() -> {
System.out.println("雪花算法生成第【"+(finalI +1)+"】个ID:"+ snowFlakeDemo.snowflakeId());
}, String.valueOf(i)).start();
}
}
}
以上代码执行结果:
雪花算法生成第【2】个ID:1646777064113700865
雪花算法生成第【5】个ID:1646777064113700868
雪花算法生成第【3】个ID:1646777064113700866
雪花算法生成第【4】个ID:1646777064113700867
雪花算法生成第【1】个ID:1646777064113700864
雪花算法生成第【11】个ID:1646777064113700873
雪花算法生成第【10】个ID:1646777064113700872
雪花算法生成第【8】个ID:1646777064113700871
雪花算法生成第【7】个ID:1646777064113700870
雪花算法生成第【6】个ID:1646777064113700869
雪花算法生成第【16】个ID:1646777064113700877
雪花算法生成第【13】个ID:1646777064113700876
雪花算法生成第【12】个ID:1646777064113700875
雪花算法生成第【9】个ID:1646777064113700874
雪花算法生成第【17】个ID:1646777064113700881
雪花算法生成第【19】个ID:1646777064113700880
雪花算法生成第【15】个ID:1646777064113700879
雪花算法生成第【14】个ID:1646777064113700878
雪花算法生成第【20】个ID:1646777064113700883
雪花算法生成第【18】个ID:1646777064113700882
第三方基于SnowFlake算法生成的唯一ID
- 百度UIDGenerator:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
- UidGenerator是Java实现的,基于SnowFlake算法的唯一ID生成器。UidGenerator以组件形式工作在应用项目中,支持自定义workerId位数和初始化策略,从而适用于docker等虚拟化环境下实例自动重启、漂移等场景。在实现上, UidGenerator通过借用未来时间来解决sequence天然存在的并发限制; 采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题. 最终单机QPS可达600万。
- 美团Leaf:https://tech.meituan.com/2019/03/07/open-source-project-leaf.html
- leaf-segment 方案
- 优化:双buffer + 预分配
- 容灾:Mysql DB 一主两从,异地机房,半同步方式
- 缺点:如果用segment号段式方案:id是递增,可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。
- leaf-segment 方案
- leaf-snowflake方案
- 使用Zookeeper持久顺序节点的特性自动对snowflake节点配置workerID
- 1.启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
- 2.如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
- 3.如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
- 使用Zookeeper持久顺序节点的特性自动对snowflake节点配置workerID
雪花算法相关问题以及解决方案
1. 时间回拨问题
问题
在获取时间的时候,可能会出现时间回拨的问题,什么是时间回拨呢?
原因
时间回拨就是服务器上的时间突然倒退到之前的时间。
- 认为原因,把系统环境的时间改了
- 有时候不同的机器上需要同步时间,可能不同机器之间存在误差,那么可能会出现时间回拨问题
解决方案
- 回拨时间小的时候,不生成ID,循环等待到时间点到达。(只适合时钟回拨较小的)
- 利用拓展位,回拨之后再扩展位上加1就可以了,这样ID依然可以保持唯一。但是找个要求我们提前预留出位数,要么从机器id中,要么从序列号中,腾出一定的位,在时间回拨的时候,这个位置+1.
- 缓存workerID,减少第三方组件的依赖
- 由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警
2.工作机器ID可能会重复的问题
机器 ID(5 位)和数据中心 ID(5 位)配置没有解决(不一定各是5位,可自行配置),分布式部署的时候会使用相同的配置,仍然有 ID 重复的风险。
解决方案
- workId 使用服务器 hostName 生成,dataCenterId 使用 IP 生成,这样可以最大限度防止 10 位机器码重复,但是由于两个 ID 都不能超过 32,只能取余数,还是难免产生重复,但是实际使用中,hostName 和 IP 的配置一般连续或相近,只要不是刚好相隔 32 位,就不会有问题,况且,hostName 和 IP 同时相隔 32 的情况更加是几乎不可能的事,平时做的分布式部署,一般也不会超过 10 台容器
注意:使用ip地址时要考虑到使用docker容器部署时ip可能会相同的情况。
- 所有的节点共用一个数据库配置,每次节点重启,往mysql某个自建的表中新增一条数据,主键id自增并且自增id 要和 2^10-1 做按位与操作,防止总计重启次数超过 2^10 后溢出。使用这个自增的id作为机器码,这样能保证机器码绝对不重复。如果是TiDB这种分布式数据库(id自增分片不连续),按位与操作后,还要注意不能拿到相同的workId。
3. -1L ^ (-1L << x) 表示什么?
表示 x 位二进制可以表示多少个数值,假设x为3:
在计算机中,第一位是符号位,负数的反码是除了符号位,1变0,0变1, 而补码则是反码+1:
-1L 原码:1000 0001
-1L 反码:1111 1110
-1L 补码:1111 1111
从上面的结果可以知道,-1L其实在二进制里面其实就是全部为1,那么 -1L 左移动 3位,其实得到 1111 1000
,也就是最后3位是0,再与-1L
异或计算之后,其实得到的,就是后面3位全是1。-1L ^ (-1L << x)
表示的其实就是x位全是1的值,也就是x位的二进制能表示的最大数值。
4.前端直接使用发生精度丢失
如果前端直接使用服务端生成的long 类型 id,会发生精度丢失的问题,因为 JS 中Number是16位的(指的是十进制的数字),而雪花算法计算出来最长的数字是19位的,这个时候需要用 String 作为中间转换,输出到前端即可。
作者:筱白爱学习!!
欢迎关注转发评论点赞沟通,您的支持是筱白的动力!