目录
前言
回顾二进制
二进制概念
运算法则
位(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
逻辑运算二进制得非(!)运算:各位取反。
数据存储得最小单位。每个二进制数字0或者1就是1个位。
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;
补码比其它码多一位,这是为什么呢?问题出在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做标识
UUID.randomUUID(),UUID的标准型包含32个16进制数字,以连字号分为五段,形式位 8-4-4-4-12的36个字符,性能非常高,本地生成,没有网络消耗。
无序,无法预测他的生成顺序,不能生成递增有序的数字
首先分布式id一般都会作为主键,但是按照mysql官方推荐主键尽量越短越好,UUID每一个都很长,所以不是很推荐。
比如做DB主键的场景下,UUID就非常不适用MySQL官方明确的说明
既然分布式ID是主键,然后主键是包含索引的,而mysql的索引是通过B+树来实现的,每一次新的UUID数据的插入,为了查询的优化,都会对索引底层的B+树进行修改,因为UUID数据是无序的,所以每一次UUID数据的插入都会对主键的B+树进行很大的修改,这一点很不好,插入完全无序,不但会导致一些中间节点产生分裂,也会白白创造出很多不饱和的节点,这样大大降低了数据库插入的性能。
UUID只能保证全局唯一性,不满足后买你的趋势递增,单调递增
在分布式里面,数据库的自增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也会增加。
满足了:递增性、单调性、唯一性
比如定义好步长和机器台数之后,如果要添加机器该怎么办,假设现在有一台机器发号是:1,2,3,4(步长是1),这个时候需要扩容机器一台,可以这样做:把第二台机器的初始值设置得比第一台超过很多,貌似还好,但是假设线上如果有100台机器,这个时候扩容要怎么做,简直是噩梦,所以系统水平扩展方案复杂难以实现。
每次获取ID都得读写一次数据库,非常影响性能,不符合分布式ID里面得延迟低和高QPS得规则(在高并发下,如果都去数据库里面获取ID,那是非常影响性能的)
因为Redis是单线程,天生保证原子性,可以使用原子操作INCR和INCEBY来实现、
注意:在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
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
<4> 12bit-序列号,用来记录同毫秒内产生的不同id
同一毫秒的ID数量 = 1024 * 4096 = 4194304,所以最大可以支持单应用差不多四百万的并发量。
注意:上面总体是64位,具体位数可自行配置,
若想运行更久,需要增加时间戳位数;
若想支持更多节点,可增加工作机器Id位数;
若想支持更高并发,增加序列号位数。
能满足高并发分布式系统环境ID不重复,比如大家熟知的分布式场景下的数据库表的ID生成。
在高并发,以及分布式环境下,除了生成不重复id,每秒可生成百万个不重复id,生成效率极高。
基于时间戳,可以保证基本有序递增,很多业务场景都有这个需求。
不依赖第三方的库,或者中间件,算法简单,在内存中进行。
注意!注意!注意!以下代码亲测过,并有详细的注释解说,直接拿走,不谢!!!
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 …… …… …… (之后的结果就不一一展示了)
第一步:引入hutool工具依赖包
cn.hutool hutool-all5.3.1
第二步: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
问题
在获取时间的时候,可能会出现时间回拨的问题,什么是时间回拨呢?
原因
时间回拨就是服务器上的时间突然倒退到之前的时间。
解决方案
机器 ID(5 位)和数据中心 ID(5 位)配置没有解决(不一定各是5位,可自行配置),分布式部署的时候会使用相同的配置,仍然有 ID 重复的风险。
解决方案
注意:使用ip地址时要考虑到使用docker容器部署时ip可能会相同的情况。
表示 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位的二进制能表示的最大数值。
如果前端直接使用服务端生成的long 类型 id,会发生精度丢失的问题,因为 JS 中Number是16位的(指的是十进制的数字),而雪花算法计算出来最长的数字是19位的,这个时候需要用 String 作为中间转换,输出到前端即可。
作者:筱白爱学习!!
欢迎关注转发评论点赞沟通,您的支持是筱白的动力!