![6cb20e670e5c347222cee19adaff5c80.png](https://img-blog.csdnimg.cn/img_convert/6cb20e670e5c347222cee19adaff5c80.png)
详细欢迎关注公众号: 戏说码农职场
- 背景
为什么需要分布式ID呢?
因为分布式架构下,唯一序列号生成是我们在设计一个系统,尤其是数据库使用分库分表的时候常常会遇见的问题。当分成若干个sharding表后,如何能够快速拿到一个唯一序列号,是经常遇到的问题。总而言之, 是因为高并发, 我们引入了分库分表, 所以需要分布式唯一ID的生产.
- 特性
- 全局唯一,这是基本要求,不能出现重复。
- 数字类型,趋势递增,后面的ID必须比前面的大,这是从MySQL存储引擎来考虑的,需要保证写入数据的性能。
- 长度短,能够提高查询效率,这也是从MySQL数据库规范出发的,尤其是ID作为主键时。
- 信息安全,如果ID连续生成,势必会泄露业务信息,甚至可能被猜出,所以需要无规则不规则。
- 高可用低延时,ID生成快,能够扛住高并发,延时足够低不至于成为业务瓶颈。
- 如何做
基于UUID
优点:
- 简单方便, 全剧唯一
缺点:
- 因为使用唯一ID 是用来查询的, 对于单库来说, 不是按照顺序排序, 造成索引B+数的创建调整麻烦
- 长度太长, 存储空间较大
- 查询效率低, 顺序存储, 查询高效
![ab7e9459716057707ad70d943849e939.png](https://img-blog.csdnimg.cn/img_convert/ab7e9459716057707ad70d943849e939.png)
基于变种UUID
针对缺点, 可以通过方法, 将时间放在前面<忽略>
基于数据库多实例主键自增
需要关注步长Step 和 实例个数, 如下图所示:
![1ac550947f8728f383c4b8739416f904.png](https://img-blog.csdnimg.cn/img_convert/1ac550947f8728f383c4b8739416f904.png)
从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了ID生成特性,将自增步长按照机器数量来设置,但是,这里有个缺点就是不能再扩容了,如果再扩容,ID就没法儿生成了,步长都用光了,那如果你要解决新增机器带来的问题,你或许可以将第三台机器的ID起始生成位置设定离现在的ID比较远的位置,同时把新的步长设置进去,同时修改旧机器上ID生成的步长,但必须在ID还没有增长到新增机器设置的开始自增ID值,否则就要出现重复了。
优点
- 解决了ID生成的单点问题,同时平衡了负载。
缺点
- 一定确定好步长,将对后续的扩容带来困难,而且单个数据库本身的压力还是大,无法满足高并发。
适用场景
- 数据量不大,数据库不需要扩容的场景。
这种方案,除了难以适应大规模分布式和高并发的场景,普通的业务规模还是能够胜任的,所以这种方案还是值得积累。
基于Redis生成ID
当使用数据库来生成ID性能不够要求的时候,我们可以尝试使用Redis来生成ID。这主要依赖于Redis是单线程的,所以也可以用生成全局唯一的ID。可以用Redis的原子操作 INCR和INCRBY来实现。
同样需要预定义步长和初始化。假如一个集群中有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性能较好
缺点:
需要编码, 增加工作量, 依赖redis, 需要保证可靠性.
雪花算法
snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号(意味着每个节点在每毫秒可以产生 4096 个 ID),最后还有一个符号位,永远是0。具体实现的代码可以参看https://github.com/twitter/snowflake。下面是生成规则:
![ea3ec157af55d02c7ccbc102524fa383.png](https://img-blog.csdnimg.cn/img_convert/ea3ec157af55d02c7ccbc102524fa383.png)
其中,1位标识符,不使用且标记为0;41位时间戳,用来存储时间戳的差值;10位机器码,可以标识1024个机器节点,如果机器分机房部署(IDC),这10位还可以拆分,比如5位表示机房ID,5位表示机器ID,这样就有32*32种组合,一般来说是足够了;最后的12位随即序列,用来记录毫秒内的计数,一个节点就能够生成4096个ID序号。所以综上所述,综合计算下来,理论上Snowflake算法方案的QPS大约为409.6w/s,性能足够强悍了,而且这种方式,能够确保集群中每个节点生成的ID都是不同的,且区间内递增。
优点
- 每秒能够生成百万个不同的ID,性能佳。
- 时间戳值在高位,中间是固定的机器码,自增的序列在地位,整个ID是趋势递增的。
- 能够根据业务场景数据库节点布置灵活挑战bit位划分,灵活度高。
缺点
- 强依赖于机器时钟,如果时钟回拨,会导致重复的ID生成,所以一般基于此的算法发现时钟回拨,都会抛异常处理,阻止ID生成,这可能导致服务不可用。
实现代码:
public class IdWorker {
//因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 id 都是正数,所以第一个 bit 统一都是 0。
//机器ID 2进制5位 32位减掉1位 31个
private long workerId;
//机房ID 2进制5位 32位减掉1位 31个
private long datacenterId;
//代表一毫秒内生成的多个id的最新序号 12位 4096 -1 = 4095 个
private long sequence;
//设置一个时间初始值 2^41 - 1 差不多可以用69年
private long twepoch = 1585644268888L;
//5位的机器id
private long workerIdBits = 5L;
//5位的机房id
private long datacenterIdBits = 5L;
//每毫秒内产生的id数 2 的 12次方
private long sequenceBits = 12L;
// 这个是二进制运算,就是5 bit最多只能有31个数字,也就是说机器id最多只能是32以内
private long maxWorkerId = -1L ^ (-1L << workerIdBits);
// 这个是一个意思,就是5 bit最多只能有31个数字,机房id最多只能是32以内
private long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
private long workerIdShift = sequenceBits;
private long datacenterIdShift = sequenceBits + workerIdBits;
private long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
private long sequenceMask = -1L ^ (-1L << sequenceBits);
//记录产生时间毫秒数,判断是否是同1毫秒
private long lastTimestamp = -1L;
public long getWorkerId(){
return workerId;
}
public long getDatacenterId() {
return datacenterId;
}
public long getTimestamp() {
return System.currentTimeMillis();
}
public IdWorker(long workerId, long datacenterId, long sequence) {
// 检查机房id和机器id是否超过31 不能小于0
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));
}
this.workerId = workerId;
this.datacenterId = datacenterId;
this.sequence = sequence;
}
// 这个是核心方法,通过调用nextId()方法,让当前这台机器上的snowflake算法程序生成一个全局唯一的id
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));
}
// 下面是说假设在同一个毫秒内,又发送了一个请求生成一个id
// 这个时候就得把seqence序号给递增1,最多就是4096
if (lastTimestamp == timestamp) {
// 这个意思是说一个毫秒内最多只能有4096个数字,无论你传递多少进来,
//这个位运算保证始终就是在4096这个范围内,避免你自己传递个sequence超过了4096这个范围
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型
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;
}
//获取当前时间戳
private long timeGen(){
return System.currentTimeMillis();
}
/**
* main 测试类
* @param args
*/
public static void main(String[] args) {
System.out.println(1&4596);
System.out.println(2&4596);
System.out.println(6&4596);
System.out.println(6&4596);
System.out.println(6&4596);
System.out.println(6&4596);
// IdWorker worker = new IdWorker(1,1,1);
// for (int i = 0; i < 22; i++) {
// System.out.println(worker.nextId());
// }
}
}
基于美团的Leaf方案
从上面的几种分布式ID方案可以看出,能够解决一定问题,但是都有明显缺陷,为此,美团在数据库的方案基础上做了一个优化,提出了一个叫做Leaf-segment的数据库方案。原方案我们每次获取ID都需要去读取一次数据库,这在高并发和大数据量的情况下很容易造成数据库的压力,那能不能一次性获取一批ID呢,这样就无需频繁的造访数据库了。Leaf-segment的方案就是采用每次获取一个ID区间段的方式来解决,区间段用完之后再去数据库获取新的号段,这样一来可以大大减轻数据库的压力,那怎么做呢?很简单,我们设计一张表如下:
+-------------+--------------+------+-----+-------------------+-----------------------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+--------------+------+-----+-------------------+-----------------------------+
| biz_tag | varchar(128) | NO | PRI | | |
| max_id | bigint(20) | NO | | 1 | |
| step | int(11) | NO | | NULL | |
| desc | varchar(256) | YES | | NULL | |
| update_time | timestamp | NO | | CURRENT_TIMESTAMP | on update CURRENT_TIMESTAMP |
+-------------+--------------+------+-----+-------------------+-----------------------------+
其中biz_tag用来区分业务,max_id表示该biz_tag目前所被分配的ID号段的最大值,step表示每次分配的号段长度,后面的desc和update_time分别表示业务描述和上一次更新号段的时间。原来每次获取ID都要访问数据库,现在只需要把Step设置的足够合理如1000,那么现在可以在1000个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;
- 这个用户服务可以用[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的起点,可以自定义步长,非常灵活易于扩容,于此同时,这种方式也很好的解决了数据库压力问题,而且ID号段是存储在JVM中的,性能获得极大的保障,可用性也过得去,即时数据库宕机了,因为JVM缓存的号段,系统也能够因此撑住一段时间。
参考:
分布式系统ID生成办法 - Captain&D - 博客园www.cnblogs.com![e7159053dfb4f83868c2c2fe09d79d84.png](https://img-blog.csdnimg.cn/img_convert/e7159053dfb4f83868c2c2fe09d79d84.png)