分布式ID生成策略
在分布式系统中,肯定避免不了获取全局唯一ID,用于业务主键,本节主要学习分布式ID常用的生成方法。
一、UUID
UUID (Universally Unique Identifier),通用唯一识别码。UUID是基于当前时间、计数器(counter)和硬件标识(通常为无线网卡的MAC地址)等数据计算生成的。
UUID是JDK提供的工具类,在util包,UUID的生成方式:
for (int i = 0; i < 3; i++) { TimeUnit.MILLISECONDS.sleep(10); UUID uuid = UUID.randomUUID(); System.out.println(uuid.toString() + "----" + uuid.toString().length()); }
生成的UUID示例:
9b896468-1f9b-4b04-84e7-adedc078f368----36
e772633c-fb6a-4aa3-90cd-52d86e43a0c1----36
45d74ae0-8025-4570-a8b7-fea6d0c94177----36
UUID通过当前日期与时间、时钟序列、全局唯一的机器识别号生成,是一组32位数的16进制数字,以连字号分隔的五组来显示,总共有 36个字符。
UUID做分布式ID的优缺点:
优点 |
生成简单,性能好 |
缺点 |
UUID是无序的,无法保证趋势递增; 存储在数据库中查询效率低下影响性能; 占用空间大,传输效率低 |
分布式id一般作为业务主键存储在数据库中,一般业务主键都包含索引,mysql的索引通过B+树实现,UUID数据插入数据库,数据库为优化查询会对索引底层的b+树进行修改,由于UUID是无序的,每次UUID数据的插入都会对主键生成的b+树做很大的调整,影响性能,所以UUID一般不用做分布式ID的生成。
二、数据库主键自增ID
2.1、单数据库场景
创建数据库表结构时,可以给对应表的主键设置自动递增。
单数据库场景,使用自增主键可满足基本要求。
2.2、分库分表场景
数据库主键自增方式仅适用于单个数据库的场景,若在分库分表的场景中,直接利用单数据库主键自增无法保证ID唯一。这种场景下,可以将主键独立出来单独维护,创建主键维护表。
主键维护表结构:
1 CREATE TABLE `id_generator` ( 2 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id', 3 `value` varchar(2) NOT NULL, 4 PRIMARY KEY (`id`) , 5 UNIQUE KEY `uk_id_generator_value` (`value`) USING BTREE 6 ) ENGINE=InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET=utf8mb4;
在插入数据之前,从主键维护表中获取对应的主键值保证ID的唯一性。
1 BEGIN; 2 REPLACE INTO id_generator (`value`) values ('id') ; 3 SELECT LAST_INSERT_ID() as resultId from dual; 4 COMMIT;
Replace into 语法特点:表中旧行与主键或唯一索引列具有相同的值,则插入新行之前删除该旧行。
2.3、多主模式
在数据的双主模式集群中,每个数据库实例都可生成自增的ID。此时控制每个数据库实例主键维护表中的主键初始值及自增步长,可以保证ID的唯一。
查看主键自增属性
show variables like '%increment%'
详情如下:
auto_increment_increment:表示自增步长
假设有双主模式中数据库A、B,在A、B数据库中的操作演示如下:
A数据库,设置初始值为1,步长为2:
主键维护表设置初始值为1, 详情如下:
1 CREATE TABLE `id_generator` ( 2 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id', 3 `value` varchar(2) NOT NULL, 4 PRIMARY KEY (`id`) , 5 UNIQUE KEY `uk_id_generator_value` (`value`) USING BTREE 6 ) ENGINE=InnoDB AUTO_INCREMENT 1 DEFAULT CHARSET=utf8mb4;
调整步长为2
set auto_increment_increment = 2
B数据库,设置初始值为2,步长为2:
主键维护表设置初始值为2, 详情如下:
1 CREATE TABLE `id_generator` ( 2 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键id', 3 `value` varchar(2) NOT NULL, 4 PRIMARY KEY (`id`) , 5 UNIQUE KEY `uk_id_generator_value` (`value`) USING BTREE 6 ) ENGINE=InnoDB AUTO_INCREMENT 2 DEFAULT CHARSET=utf8mb4;
调整步长为2:
set auto_increment_increment = 2
最终各数据库实例获取ID详情如下:
2.4、号段模式
号段模式是分布式ID生成常用方式之一,号段模式原理是基于业务服务标识,从数据库中批量获取自增ID,每次从数据库中取出一个范围数值,将获取的范围值添加到系统内存中。
1 CREATE TABLE `id_generator` ( 2 `id` int(10) NOT NULL, 3 `max_id` bigint(20) NOT NULL COMMENT '当前最大id', 4 `step` int(8) NOT NULL COMMENT '号段的步长', 5 `busi_type` tinyint(1) NOT NULL COMMENT '业务类型 0 - 订单;1 - 商品;2-物流', 6 `version` int(8) NOT NULL COMMENT '版本号', 7 PRIMARY KEY (`id`) 8 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
version 作为一个乐观锁,每次都更新version,保证并发时数据的正确性。等号段ID用完,再次向数据库申请新号段,对max_id做一次更新操作, update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。
由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。
缺点:服务器重启,单点故障会造成ID不连续。可做如下优化:将查询到的号段信息存储在Redis中做缓存,而非系统内存中。
2.5、总结
基于数据库自增主键获取分布式ID,在高并发的场景下,性能会成为瓶颈,不易拓展。
三、Redis的INCR命令生成分布式ID
基于全局唯一ID的特性,可以通过Redis的INCR命令来生成全局唯一ID。基于Redis的INCR指令生成分布式ID的优缺点:
优点 |
1、不依赖于数据库,性能优于数据库 2、数字ID有序,对分页处理和排序都很友好 |
缺点 |
1、需要引入redis,增加系统复杂度 2、生成ID持久化、单节点宕机故障问题 |
针对故障问题可以通过Redis集群来处理,比如有三个Redis的Master节点。可以初始化每台Redis的值分别是1,2,3,然后分别把分布式ID的KEY用Hash Tags固定每一个master节点,步长就是master节点的个数。各个Redis生成的ID为:A:1,4,7;B:2,5,8;C:3,6,9。
Redis分布式ID的简单demo:
1 @Autowired 2 private StringRedisTemplate redisTemplate; 3 @Test 4 public void testRedisId() { 5 nextId("order"); 6 } 7 /** 8 * 根据业务类型获取分布式ID 9 * @param busiType 业务类型 10 * @return 11 */ 12 public long nextId(String busiType) { 13 return redisTemplate.opsForValue().increment("disId:" + busiType); 14 }
四、雪花算法
4.1、雪花算法简介
雪花算法(Snowflake)是由Twitter开源的分布式ID生成算法,用于在不同的机器上生成唯一ID的算法。雪花算法会生成一个64bit的数字作为分布式ID,保证这个ID自增并且全局唯一。在Java中64Bit为的整数为Long类型,Snowflake算法生成的分布式ID在Java中用long类型来储存。生成的64位ID结构如下:
·占用1bit,第一位为符号位,不使用;
·41位的时间戳,41bit位可以表示2^41个数,每个数代表的是毫秒,雪花算法的时间年限是(2^41)/(1000×60×60×24×365)=69年;
·10bit表示是机器数。其中5位datacenterId,5位workerId。最大部署机器数: 2^ 10 = 1024台机器。在实际集群部署中,通常为每个集群节点配置不同的workerId、datacenterId来解决生成的分布式ID冲突问题。
·12bit位是自增序列,表示2^12=4096个数,每个节点同一毫秒内可以生成4096个ID,若达到这个最大值,通过while循环阻塞到下一个毫秒,直到获得新的时间戳。
4.2、雪花算法实现
1 /** 2 * Twitter_Snowflake 3 * SnowFlake的结构如下(每部分用-分开): 4 * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 5 * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0 6 * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截) 7 * 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69 8 * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId 9 * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号 10 * 加起来刚好64位,为一个Long型。 11 * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。 12 */ 13 public class SnowflakeIdWorker { 14 15 // ==============================Fields=========================================== 16 /** 17 * 开始时间截 (2020-11-03,一旦确定不可更改,否则时间被回调,或者改变,可能会造成id重复或冲突) 18 */ 19 private final long twepoch = 1604374294980L; 20 21 /** 22 * 机器id所占的位数 23 */ 24 private final long workerIdBits = 5L; 25 26 /** 27 * 数据标识id所占的位数 28 */ 29 private final long datacenterIdBits = 5L; 30 31 /** 32 * 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数) 33 */ 34 private final long maxWorkerId = -1L ^ (-1L << workerIdBits); 35 36 /** 37 * 支持的最大数据标识id,结果是31 38 */ 39 private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); 40 41 /** 42 * 序列在id中占的位数 43 */ 44 private final long sequenceBits = 12L; 45 46 /** 47 * 机器ID向左移12位 48 */ 49 private final long workerIdShift = sequenceBits; 50 51 /** 52 * 数据标识id向左移17位(12+5) 53 */ 54 private final long datacenterIdShift = sequenceBits + workerIdBits; 55 56 /** 57 * 时间截向左移22位(5+5+12) 58 */ 59 private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; 60 61 /** 62 * 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095) 63 */ 64 private final long sequenceMask = -1L ^ (-1L << sequenceBits); 65 66 /** 67 * 工作机器ID(0~31) 68 */ 69 private long workerId; 70 71 /** 72 * 数据中心ID(0~31) 73 */ 74 private long datacenterId; 75 76 /** 77 * 毫秒内序列(0~4095) 78 */ 79 private long sequence = 0L; 80 81 /** 82 * 上次生成ID的时间截 83 */ 84 private long lastTimestamp = -1L; 85 86 //==============================Constructors===================================== 87 88 /** 89 * 构造函数 90 * 91 */ 92 public SnowflakeIdWorker() { 93 this.workerId = 0L; 94 this.datacenterId = 0L; 95 } 96 97 /** 98 * 构造函数 99 * 100 * @param workerId 工作ID (0~31) 101 * @param datacenterId 数据中心ID (0~31) 102 */ 103 public SnowflakeIdWorker(long workerId, long datacenterId) { 104 if (workerId > maxWorkerId || workerId < 0) { 105 throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId)); 106 } 107 if (datacenterId > maxDatacenterId || datacenterId < 0) { 108 throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId)); 109 } 110 this.workerId = workerId; 111 this.datacenterId = datacenterId; 112 } 113 114 // ==============================Methods========================================== 115 116 /** 117 * 获得下一个ID (该方法是线程安全的) 118 * 119 * @return SnowflakeId 120 */ 121 public synchronized long nextId() { 122 long timestamp = timeGen(); 123 124 //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常 125 if (timestamp < lastTimestamp) { 126 throw new RuntimeException( 127 String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); 128 } 129 130 //如果是同一时间生成的,则进行毫秒内序列 131 if (lastTimestamp == timestamp) { 132 sequence = (sequence + 1) & sequenceMask; 133 //毫秒内序列溢出 134 if (sequence == 0) { 135 //阻塞到下一个毫秒,获得新的时间戳 136 timestamp = tilNextMillis(lastTimestamp); 137 } 138 } 139 //时间戳改变,毫秒内序列重置 140 else { 141 sequence = 0L; 142 } 143 144 //上次生成ID的时间截 145 lastTimestamp = timestamp; 146 147 //移位并通过或运算拼到一起组成64位的ID 148 return ((timestamp - twepoch) << timestampLeftShift) // 149 | (datacenterId << datacenterIdShift) // 150 | (workerId << workerIdShift) // 151 | sequence; 152 } 153 154 /** 155 * 阻塞到下一个毫秒,直到获得新的时间戳 156 * 157 * @param lastTimestamp 上次生成ID的时间截 158 * @return 当前时间戳 159 */ 160 protected long tilNextMillis(long lastTimestamp) { 161 long timestamp = timeGen(); 162 while (timestamp <= lastTimestamp) { 163 timestamp = timeGen(); 164 } 165 return timestamp; 166 } 167 168 /** 169 * 返回以毫秒为单位的当前时间 170 * 171 * @return 当前时间(毫秒) 172 */ 173 protected long timeGen() { 174 return System.currentTimeMillis(); 175 } 176 177 /** 178 * 随机id生成,使用雪花算法 179 * 180 * @return 181 */ 182 public static String getSnowId() { 183 SnowflakeIdWorker sf = new SnowflakeIdWorker(); 184 String id = String.valueOf(sf.nextId()); 185 return id; 186 } 187 188 //=========================================Test========================================= 189 190 /** 191 * 测试 192 */ 193 public static void main(String[] args) { 194 SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0); 195 for (int i = 0; i < 1000; i++) { 196 long id = idWorker.nextId(); 197 System.out.println(id); 198 } 199 } 200 }
雪花算法优点:
适合高并发场景,每秒可生成百万个ID;不依赖第三方库或中间件;基于时间戳,生成的ID趋势递增,并且有序。
雪花算法缺点
雪花算法生成分布式ID依赖于系统时间,如果服务器时钟回拨可能会生成重复id。
4.3、雪花算法原理
在雪花算法的实现中,41位比特位是用当前系统时间减去设置的初始时间戳值,可根据需求执行定义,只要不大于系统时间即可。也可根据规划的系统运营生命周期,适当的缩减64Bit中时间戳所占用的比特数,用来提高每毫秒生成分布式ID的数量。
五、基于雪花算法的拓展
5.1、Uidgenerator(百度)
UidGenerator是Java实现的, 基于Snowflake算法的唯一ID生成器。UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数和初始化策略。 UidGenerator通过借用未来时间来解决雪花算法中sequence天然存在的并发限制; 采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题。
Snowflake算法描述:指定机器 & 同一时刻 & 某一并发序列,是唯一的。据此可生成一个64 bits的唯一ID(long)。Uidgenerator对64Bit的结构定义做了调整,调整后结构如下:
默认采用上图字节分配方式:
sign(1bit) :固定1bit符号标识,即生成的UID为正数。
delta seconds (28 bits) :当前时间,相对于时间基点"2016-05-20"的增量值,单位:秒,最多可支持约8.7年
worker id (22 bits) :机器id,最多可支持约420w次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。
sequence (13 bits) :每秒下的并发序列,13 bits可支持每秒8192个并发。
false sharing 伪共享:
两个CPU不断争夺缓存行控制权,不断使对方的缓存行失效,写数据回内存的行为导致性能下降。这种行为就叫做cache伪共享。
关于伪共享概念参考博客:https://blog.csdn.net/qq_28119741/article/details/102815659
源码地址:https://github.com/baidu/uid-generator
中文文档地址:https://github.com/baidu/uid-generator/blob/master/README.zh_cn.md
5,2、美团(Leaf)
由美团开发,开源项目链接:https://github.com/Meituan-Dianping/Leaf
Leaf同时支持号段模式和snowflake算法模式,可以切换使用。ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。
Leaf的snowflake模式依赖于ZooKeeper,不同于原始snowflake算法也主要是在workId的生成上,Leaf中workId是基于ZooKeeper的顺序Id来生成的,每个应用在使用Leaf-snowflake时,启动时都会都在Zookeeper中生成一个顺序Id,相当于一台机器对应一个顺序节点,也就是一个workId。
Leaf的号段模式是对直接用数据库自增ID充当分布式ID的一种优化,减少对数据库的频率操作。相当于从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,业务服务将号段在本地生成1~1000的自增ID并加载到内存。
特性:
1)全局唯一,绝对不会出现重复的ID,且ID整体趋势递增。
2)高可用,服务完全基于分布式架构,即使MySQL宕机,也能容忍一段时间的数据库不可用。
3)高并发低延时,在CentOS 4C8G的虚拟机上,远程调用QPS可达5W+,TP99在1ms内。
4)接入简单,直接通过公司RPC服务或者HTTP调用即可接入。
Leaf采用双buffer的方式,它的服务内部有两个号段缓存区segment。当前号段已消耗10%时,还没能拿到下一个号段,则会另启一个更新线程去更新下一个号段。
简而言之就是Leaf保证了总是会多缓存两个号段,即便哪一时刻数据库挂了,也会保证发号服务可以正常工作一段时间。
5.3、滴滴(TinyID)
由滴滴开发,开源项目链接:https://github.com/didi/tinyid
Tinyid是在美团(Leaf)的leaf-segment算法基础上升级而来,不仅支持了数据库多主节点模式,还提供了tinyid-client客户端的接入方式,使用起来更加方便。但和美团(Leaf)不同的是,Tinyid只支持号段一种模式不支持雪花模式。Tinyid提供了两种调用方式,一种基于Tinyid-server提供的http方式,另一种Tinyid-client客户端方式。每个服务获取一个号段(1000,2000]、(2000,3000]、(3000,4000]
特性:
1)全局唯一的long型ID
2)趋势递增的id
3)提供 http 和 java-client 方式接入
4)支持批量获取ID
5)支持生成1,3,5,7,9...序列的ID
6)支持多个db的配置
适用场景:只关心ID是数字,趋势递增的系统,可以容忍ID不连续,可以容忍ID的浪费
不适用场景:像类似于订单ID的业务,因生成的ID大部分是连续的,容易被扫库、或者推算出订单量等信息