分布式id生成的常用方法
一、为什么要用分布式ID?
在说分布式ID的具体实现之前,我们来简单分析一下为什么用分布式ID?分布式ID应该满足哪些特征?
1、什么是分布式ID?
对mysql来说,在我们业务数据量不大的时候,单库单表完全可以支撑现有业务,数据再大一点搞个MySQL主从同步读写分离也能对付。
但随着数据日渐增长,主从同步也扛不住住了,就需要对数据库进行分库分表,但分库分表后需要有一个唯一ID来标识一条数据,数据库的自增ID显然不能满足需求;特别一点的如订单、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的。那么这个全局唯一ID就叫分布式ID。
2、全局唯一id特点
全局唯一性
:整个分布式服务系统中不能出现重复的ID。单调递增
:保证生成的下一个ID一定大于上一个ID,单调递增会顺序添加到数据索引的节点的后续位置,当一页写满,就会自动开辟一个新的页,不需要移动数据重构索引树,减少碎片。趋势递增
:在一段时间内,生成的ID是递增的趋势,提高写入性能。如:在一段时间内生成的ID在【0,1000】之间,过段时间生成的ID在【1000,2000】之间。但在【0-1000】区间内的时候,ID生成有可能第一次是12,第二次是10,第三次是14。信息安全
:如果 ID 是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞争对手可以直接知道我们一天的单量。所以在一些应用场景下,会需要无规则、不规则的 ID。
第2、4两个需求是互斥的,无法同时满足。 同时,在大型分布式网站架构中,除了需要满足ID生成自身的需求外,还需要ID生成系统可用性极高。因此,做一个全局唯一id生成系统必须满足以下特点:
- 高可用:高可用低延时,ID生成响应要块,否则反倒会成为业务瓶颈
- 高QPS。
二、分布式ID的实现技术
常见的分布式ID的生成如下
- UUID
- 数据库自增ID
- 数据库多主模式
- 号段模式
- Redis
- 雪花算法(SnowFlake)
1、基于UUID
想要得到一个具有唯一性的ID,首先被想到可能就是UUID,毕竟它有着全球唯一的特性。那么UUID可以做分布式ID吗?答案是可以的,但是并不推荐!
1 public static void main(String[] args) { 2 String uuid = UUID.randomUUID().toString().replaceAll("-",""); 3 System.out.println(uuid); 4 }
UUID的生成简单到只有一行代码,核心思想是使用「机器的网卡、当地时间、一个随机数」来生成UUID。
优点:
- 生成足够简单,本地生成无网络消耗,具有唯一性
缺点:
- 无序的字符串,不具备趋势自增特性
- 没有具体的业务含义
- 长度过长16 字节128位,36位长度的字符串,存储以及查询对MySQL的性能消耗较大,MySQL官方明确建议主键要尽量越短越好,作为数据库主键
UUID
的无序性会导致数据位置频繁变动,严重影响性能。
2、基于数据库自增ID
基于数据库的auto_increment自增ID完全可以充当分布式ID,具体实现:需要一个单独的MySQL实例用来生成ID,建表结构如下:
1 CREATE DATABASE `SEQ_ID`; 2 CREATE TABLE SEQID.SEQUENCE_ID ( 3 id bigint(20) unsigned NOT NULL auto_increment, 4 value char(10) NOT NULL default '', 5 PRIMARY KEY (id), 6 ) ENGINE=MyISAM;
1 insert into SEQUENCE_ID(value) VALUES ('values');
当我们需要一个ID的时候,向表中插入一条记录返回主键ID。不推荐。
优点:
- 实现简单,ID单调自增,数值类型查询速度快
缺点:
- DB单点存在宕机风险,访问量激增时MySQL本身就是系统的瓶颈,无法扛住高并发场景
3、基于数据库集群模式
前边说了单点数据库方式不可取,那对上边的方式做一些高可用优化,换成主从模式集群。害怕一个主节点挂掉没法用,那就做双主模式集群,也就是两个Mysql实例都能单独的生产自增ID。
那这样还会有个问题,两个MySQL实例的自增ID都从1开始,会生成重复的ID怎么办?
解决方案:设置起始值和自增步长
MySQL_1 配置:
1 set @@auto_increment_offset = 1; -- 起始值 2 set @@auto_increment_increment = 2; -- 步长
MySQL_2 配置:
1 set @@auto_increment_offset = 2; -- 起始值 2 set @@auto_increment_increment = 2; -- 步长
这样两个MySQL实例的自增ID分别就是:
1、3、5、7、9
2、4、6、8、10
那如果集群后的性能还是扛不住高并发咋办?就要进行MySQL扩容增加节点,这是一个比较麻烦的事。
从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置。
增加第三台MySQL实例需要人工修改一、二两台MySQL实例的起始值和步长,把第三台机器的ID起始生成位置设定在比现有最大自增ID的位置远一些,但必须在一、二两台MySQL实例ID还没有增长到第三台MySQL实例的起始ID值的时候,否则自增ID就要出现重复了,必要时可能还需要停机修改。
优点:
- 解决DB单点问题
缺点:
- 不利于后续扩容,而且实际上单个数据库自身压力还是大,依旧无法满足高并发场景。
4、基于数据库的号段模式
号段模式是当下分布式ID生成器的主流实现方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存。表结构如下:
1 CREATE TABLE id_generator ( 2 id int(10) NOT NULL, 3 max_id bigint(20) NOT NULL COMMENT '当前最大id', 4 step int(20) NOT NULL COMMENT '号段的步长', 5 biz_type int(20) NOT NULL COMMENT '业务类型', 6 version int(20) NOT NULL COMMENT '版本号', 7 PRIMARY KEY (`id`) 8 )
biz_type :代表不同业务类型
max_id :当前最大的可用id
step :代表号段的长度
version :是一个乐观锁,每次都更新version,保证并发时数据的正确性
等这批号段ID用完,再次向数据库申请新号段,对max_id
字段做一次update
操作,
1 update max_id= max_id + step
update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]
。
1 update id_generator set max_id = #{max_id+step}, version = version + 1 where version = # {version} and biz_type = XXX
由于多业务端可能同时操作,所以采用版本号Version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。
5、基于Redis模式
Redis也同样可以实现,原理就是利用redis的 incr命令实现ID的原子性自增。
INCR 命令主要有以下2个特征:
- Redis的INCR命令具备了"INCR AND GET"的原子操作,即增加并返回结果的原子操作。这个原子性很方便我们实现获取ID.
- Redis是单进程单线程架构,INCR命令不会出现id重复。
基于以上2个特性,我们可以采用INCR命令来实现分布式全局ID生成。
1 127.0.0.1:6379> set seq_id 1 // 初始化自增ID为1 2 OK 3 127.0.0.1:6379> incr seq_id // 增加1,并返回递增后的数值 4 (integer) 2
用redis实现需要注意一点,要考虑到redis持久化的问题。redis有两种持久化方式RDB和AOF
- RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
- AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。
6、基于雪花算法(Snowflake)模式
雪花算法(Snowflake)是twitter公司内部分布式项目采用的ID生成算法,开源后广受国内大厂的好评,在该算法影响下各大公司相继开发出各具特色的分布式生成器。
Snowflake生成的是Long类型的ID,一个Long类型占8个字节,每个字节占8比特,也就是说一个Long类型占64个比特。
Snowflake ID组成结构:正数位(占1比特)+ 时间戳(占41比特)+ 机器ID(占5比特)+ 数据中心(占5比特)+ 自增值(占12比特),总共64比特组成的一个Long类型。
-
1bit,不用,因为二进制中最高位是符号位,1表示负数,0表示正数。生成的id一般都是用整数,所以最高位固定为0。
-
41bit-时间戳,用来记录时间戳,毫秒级。
- 41位可以表示 241-1 个数字,
- 如果只用来表示正整数(计算机中正数包含0),可以表示的数值范围是:0 至 241-1 ,减1是因为可表示的数值范围是从0开始算的,而不是1。
- 也就是说41位可以表示 241-1 个毫秒的值,转化成单位年则是 ( 241-1 ) / (1000*60*60*24*365) = 69年
-
10bit-工作机器id,用来记录工作机器id。
- 可以部署在210 = 1024个节点,包括5位 datacenterId 和5位 workerId
- 5位(bit)可以表示的最大正整数是25 - 1 = 31,即可以用0、1、2、3、....31这32个数字,来表示不同的datecenterId或workerId -
12bit-序列号,序列号,用来记录同毫秒内产生的不同id。
- 12位(bit)可以表示的最大正整数是212 - 1 = 4095,即可以用0、1、2、3、....4094这4095个数字,来表示同一机器同一时间截(毫秒)内产生的4095个ID序号。
根据这个算法的逻辑,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。
Twitter官方给出的算法实现 是用Scala写的,这里不做分析,可自行查看。
Java版算法实现:搬运自煲煲菜的博客
1 package com.study.uuid; 2 3 public class IdWorker { 4 5 //下面两个每个5位,加起来就是10位的工作机器id 6 private long workerId; //工作id 7 private long datacenterId; //数据id 8 //12位的序列号 9 private long sequence; 10 11 public IdWorker(long workerId, long datacenterId, long sequence) { 12 // sanity check for workerId 13 if (workerId > maxWorkerId || workerId < 0) { 14 throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId)); 15 } 16 if (datacenterId > maxDatacenterId || datacenterId < 0) { 17 throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId)); 18 } 19 System.out.printf("worker starting. timestamp left shift %d, datacenter id bits %d, worker id bits %d, sequence bits %d, workerid %d", 20 timestampLeftShift, datacenterIdBits, workerIdBits, sequenceBits, workerId); 21 22 this.workerId = workerId; 23 this.datacenterId = datacenterId; 24 this.sequence = sequence; 25 } 26 27 //初始时间戳 28 private long twepoch = 1288834974657L; 29 30 //长度为5位 31 private long workerIdBits = 5L; 32 private long datacenterIdBits = 5L; 33 //最大值 34 private long maxWorkerId = -1L ^ (-1L << workerIdBits); 35 private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits); 36 //序列号id长度 37 private long sequenceBits = 12L; 38 //序列号最大值 39 private long sequenceMask = -1L ^ (-1L << sequenceBits); 40 41 //工作id需要左移的位数,12位 42 private long workerIdShift = sequenceBits; 43 //数据id需要左移位数 12+5=17位 44 private long datacenterIdShift = sequenceBits + workerIdBits; 45 //时间戳需要左移位数 12+5+5=22位 46 private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits; 47 48 //上次时间戳,初始值为负数 49 private long lastTimestamp = -1L; 50 51 public long getWorkerId() { 52 return workerId; 53 } 54 55 public long getDatacenterId() { 56 return datacenterId; 57 } 58 59 public long getTimestamp() { 60 return System.currentTimeMillis(); 61 } 62 63 //下一个ID生成算法 64 public synchronized long nextId() { 65 long timestamp = timeGen(); 66 67 //获取当前时间戳如果小于上次时间戳,则表示时间戳获取出现异常 68 if (timestamp < lastTimestamp) { 69 System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp); 70 throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", 71 lastTimestamp - timestamp)); 72 } 73 74 //获取当前时间戳如果等于上次时间戳(同一毫秒内),则在序列号加一;否则序列号赋值为0,从0开始。 75 if (lastTimestamp == timestamp) { 76 sequence = (sequence + 1) & sequenceMask; 77 if (sequence == 0) { 78 timestamp = tilNextMillis(lastTimestamp); 79 } 80 } else { 81 sequence = 0; 82 } 83 84 //将上次时间戳值刷新 85 lastTimestamp = timestamp; 86 87 /** 88 * 返回结果: 89 * (timestamp - twepoch) << timestampLeftShift) 表示将时间戳减去初始时间戳,再左移相应位数 90 * (datacenterId << datacenterIdShift) 表示将数据id左移相应位数 91 * (workerId << workerIdShift) 表示将工作id左移相应位数 92 * | 是按位或运算符,例如:x | y,只有当x,y都为0的时候结果才为0,其它情况结果都为1。 93 * 因为个部分只有相应位上的值有意义,其它位上都是0,所以将各部分的值进行 | 运算就能得到最终拼接好的id 94 */ 95 return ((timestamp - twepoch) << timestampLeftShift) | 96 (datacenterId << datacenterIdShift) | 97 (workerId << workerIdShift) | 98 sequence; 99 } 100 101 //获取时间戳,并与上次时间戳比较 102 private long tilNextMillis(long lastTimestamp) { 103 long timestamp = timeGen(); 104 while (timestamp <= lastTimestamp) { 105 timestamp = timeGen(); 106 } 107 return timestamp; 108 } 109 110 //获取系统时间戳 111 private long timeGen() { 112 return System.currentTimeMillis(); 113 } 114 115 //---------------测试--------------- 116 public static void main(String[] args) { 117 IdWorker worker = new IdWorker(1, 1, 1); 118 for (int i = 0; i < 30; i++) { 119 System.out.println(worker.nextId()); 120 } 121 } 122 123 }
三、其他方法
除了上述的几种方法,国内很多大厂也基于上述一些算法实现了自己的分布式ID生成算法,
如:
- 百度(uid-generator)项目地址: GitHub - baidu/uid-generator: UniqueID generator
-
滴滴(Tinyid)由滴滴开发,Github地址:tinyid/README.md at master · didi/tinyid (github.com)
- 美团(Leaf) 由美团开发,github地址:Meituan-Dianping/Leaf: Distributed ID Generate Service (github.com)
感兴趣的可以自行查看。
总结
本文只是简单介绍一下每种分布式ID生成器,旨在给大家做一个简单的了解,每种生成方式都有它自己的优缺点,具体如何使用还要看具体的业务需求。