转载至一口气说出9种分布式ID生成方式,面试官有点懵了

为什么要用分布式ID?

在说分布式ID的具体实现之前,我们来简单分析一下为什么用分布式ID?分布式ID应该满足哪些特征?

1、什么是分布式ID?

拿MySQL数据库举个栗子:
在我们业务数据量不大的时候,单库单表完全可以支撑现有业务,数据再大一点搞个MySQL主从同步读写分离也能对付。
但随着数据日渐增长,主从同步也扛不住了,就需要对数据库进行分库分表,但分库分表后需要有一个唯一ID来标识一条数据,数据库的自增ID显然不能满足需求;特别一点的如订单、优惠券也都需要有唯一ID做标识。此时一个能够生成全局唯一ID的系统是非常必要的。那么这个全局唯一ID就叫分布式ID。

2、那么分布式ID需要满足那些条件?

  • 全局唯一:必须保证ID是全局性唯一的,基本要求
  • 高性能:高可用低延时,ID生成响应要块,否则反倒会成为业务瓶颈
  • 高可用:100%的可用性是骗人的,但是也要无限接近于100%的可用性
  • 好接入:要秉着拿来即用的设计原则,在系统设计和实现上要尽可能的简单
  • 趋势递增:最好趋势递增,这个要求就得看具体业务场景了,一般不严格要求


image.png

1、基于UUID

在Java的世界里,想要得到一个具有唯一性的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的生成简单到只有一行代码,输出结果 c2b8c2b9e46c47e3b30dca3b0d447718,但UUID却并不适用于实际的业务需求。像用作订单号UUID这样的字符串没有丝毫的意义,看不出和订单相关的有用信息;而对于数据库来说用作业务主键ID,它不仅是太长还是字符串,存储性能差查询也很耗时,所以不推荐用作分布式ID。
优点:

  • 生成足够简单,本地生成无网络消耗,具有唯一性

缺点:

  • 无序的字符串,不具备趋势自增特性
  • 没有具体的业务含义
  • 长度过长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;
    insert into **SEQUENCE_ID(**value**)** VALUES **(**'values'**);**

当我们需要一个ID的时候,向表中插入一条记录返回主键ID,但这种方式有一个比较致命的缺点,访问量激增时MySQL本身就是系统的瓶颈,用它来实现分布式服务风险比较大,不推荐!
优点:

  • 实现简单,ID单调自增,数值类型查询速度快

缺点:

  • DB单点存在宕机风险,无法扛住高并发场景

    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扩容增加节点,这是一个比较麻烦的事。
image.png
从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点压力的问题,同时为了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,保证并发时数据的正确性
    image.png
    等这批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。
    select version from id_generator where biz_type=XXX;
    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的原子性自增。

  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

[

](https://pdai.tech/md/arch/arch-z-id.html)
但是单机存在性能瓶颈,无法满足高并发的业务需求,所以可以采用集群的方式来实现。集群的方式又会涉及到和数据库集群同样的问题,所以也需要设置分段和步长来实现。
为了避免长期自增后数字过大可以通过与当前时间戳组合起来使用,另外为了保证并发和业务多线程的问题可以采用 Redis + Lua的方式进行编码,保证安全。
Redis 实现分布式全局唯一ID,它的性能比较高,生成的数据是有序的,对排序业务有利,但是同样它依赖于redis,需要系统引进redis组件,增加了系统的配置复杂性
当然现在Redis的使用性很普遍,所以如果其他业务已经引进了Redis集群,则可以资源利用考虑使用Redis来实现。
另外用redis实现需要注意一点,要考虑到redis持久化的问题。redis有两种持久化方式RDB和AOF

  • RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
  • AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。

6.雪花算法-Snowflake

Snowflake,雪花算法是由Twitter开源的分布式ID生成算法,以划分命名空间的方式将 64-bit位分割成多个部分,每个部分代表不同的含义。而 Java中64bit的整数是Long类型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 来存储的。

  • 第1位占用1bit,其值始终是0,可看做是符号位不使用。
  • 第2位开始的41位是时间戳,41-bit位可表示2^41个数,每个数代表毫秒,那么雪花算法可用的时间年限是(1L<<41)/(1000L360024*365)=69 年的时间。
  • 中间的10-bit位可表示机器数,即2^10 = 1024台机器,但是一般情况下我们不会部署这么台机器。如果我们对IDC(互联网数据中心)有需求,还可以将 10-bit 分 5-bit 给 IDC,分5-bit给工作机器。这样

Snowflake,雪花算法是由Twitter开源的分布式ID生成算法,以划分命名空间的方式将 64-bit位分割成多个部分,每个部分代表不同的含义。而 Java中64bit的整数是Long类型,所以在 Java 中 SnowFlake 算法生成的 ID 就是 long 来存储的。

  • 第1位占用1bit,其值始终是0,可看做是符号位不使用。
  • 第2位开始的41位是时间戳,41-bit位可表示2^41个数,每个数代表毫秒,那么雪花算法可用的时间年限是(1L<<41)/(1000L360024*365)=69 年的时间。
  • 中间的10-bit位可表示机器数,即2^10 = 1024台机器,但是一般情况下我们不会部署这么台机器。如果我们对IDC(互联网数据中心)有需求,还可以将 10-bit 分 5-bit 给 IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,具体的划分可以根据自身需求定义。
  • 最后12-bit位是自增序列,可表示2^12 = 4096个数。

这样的划分之后相当于在一毫秒一个数据中心的一台机器上可产生4096个有序的不重复的ID。但是我们 IDC 和机器数肯定不止一个,所以毫秒内能生成的有序ID数是翻倍的。
分布式系统 - 全局唯一ID实现方案 - 图4分布式系统 - 全局唯一ID实现方案 - 图5

  1. package com.jajian.demo.distribute;
  2. /**
  3. * Twitter_Snowflake<br>
  4. * SnowFlake的结构如下(每部分用-分开):<br>
  5. * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
  6. * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>
  7. * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
  8. * 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
  9. * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>
  10. * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>
  11. * 加起来刚好64位,为一个Long型。<br>
  12. * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
  13. */
  14. public class SnowflakeDistributeId {
  15. // ==============================Fields===========================================
  16. /**
  17. * 开始时间截 (2015-01-01)
  18. */
  19. private final long twepoch = 1420041600000L;
  20. /**
  21. * 机器id所占的位数
  22. */
  23. private final long workerIdBits = 5L;
  24. /**
  25. * 数据标识id所占的位数
  26. */
  27. private final long datacenterIdBits = 5L;
  28. /**
  29. * 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
  30. */
  31. private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
  32. /**
  33. * 支持的最大数据标识id,结果是31
  34. */
  35. private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
  36. /**
  37. * 序列在id中占的位数
  38. */
  39. private final long sequenceBits = 12L;
  40. /**
  41. * 机器ID向左移12位
  42. */
  43. private final long workerIdShift = sequenceBits;
  44. /**
  45. * 数据标识id向左移17位(12+5)
  46. */
  47. private final long datacenterIdShift = sequenceBits + workerIdBits;
  48. /**
  49. * 时间截向左移22位(5+5+12)
  50. */
  51. private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
  52. /**
  53. * 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
  54. */
  55. private final long sequenceMask = -1L ^ (-1L << sequenceBits);
  56. /**
  57. * 工作机器ID(0~31)
  58. */
  59. private long workerId;
  60. /**
  61. * 数据中心ID(0~31)
  62. */
  63. private long datacenterId;
  64. /**
  65. * 毫秒内序列(0~4095)
  66. */
  67. private long sequence = 0L;
  68. /**
  69. * 上次生成ID的时间截
  70. */
  71. private long lastTimestamp = -1L;
  72. //==============================Constructors=====================================
  73. /**
  74. * 构造函数
  75. *
  76. * @param workerId 工作ID (0~31)
  77. * @param datacenterId 数据中心ID (0~31)
  78. */
  79. public SnowflakeDistributeId(long workerId, long datacenterId) {
  80. if (workerId > maxWorkerId || workerId < 0) {
  81. throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
  82. }
  83. if (datacenterId > maxDatacenterId || datacenterId < 0) {
  84. throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
  85. }
  86. this.workerId = workerId;
  87. this.datacenterId = datacenterId;
  88. }
  89. // ==============================Methods==========================================
  90. /**
  91. * 获得下一个ID (该方法是线程安全的)
  92. *
  93. * @return SnowflakeId
  94. */
  95. public synchronized long nextId() {
  96. long timestamp = timeGen();
  97. //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
  98. if (timestamp < lastTimestamp) {
  99. throw new RuntimeException(
  100. String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
  101. }
  102. //如果是同一时间生成的,则进行毫秒内序列
  103. if (lastTimestamp == timestamp) {
  104. sequence = (sequence + 1) & sequenceMask;
  105. //毫秒内序列溢出
  106. if (sequence == 0) {
  107. //阻塞到下一个毫秒,获得新的时间戳
  108. timestamp = tilNextMillis(lastTimestamp);
  109. }
  110. }
  111. //时间戳改变,毫秒内序列重置
  112. else {
  113. sequence = 0L;
  114. }
  115. //上次生成ID的时间截
  116. lastTimestamp = timestamp;
  117. //移位并通过或运算拼到一起组成64位的ID
  118. return ((timestamp - twepoch) << timestampLeftShift) //
  119. | (datacenterId << datacenterIdShift) //
  120. | (workerId << workerIdShift) //
  121. | sequence;
  122. }
  123. /**
  124. * 阻塞到下一个毫秒,直到获得新的时间戳
  125. *
  126. * @param lastTimestamp 上次生成ID的时间截
  127. * @return 当前时间戳
  128. */
  129. protected long tilNextMillis(long lastTimestamp) {
  130. long timestamp = timeGen();
  131. while (timestamp <= lastTimestamp) {
  132. timestamp = timeGen();
  133. }
  134. return timestamp;
  135. }
  136. /**
  137. * 返回以毫秒为单位的当前时间
  138. *
  139. * @return 当前时间(毫秒)
  140. */
  141. protected long timeGen() {
  142. return System.currentTimeMillis();
  143. }
  144. }
  1. public static void main(String[] args) {
  2. SnowflakeDistributeId idWorker = new SnowflakeDistributeId(0, 0);
  3. for (int i = 0; i < 1000; i++) {
  4. long id = idWorker.nextId();
  5. // System.out.println(Long.toBinaryString(id));
  6. System.out.println(id);
  7. }
  8. }

雪花算法提供了一个很好的设计思想,雪花算法生成的ID是趋势递增,不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的,而且可以根据自身业务特性分配bit位,非常灵活
但是雪花算法强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。如果恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。官方对于此并没有给出解决方案,而是简单的抛错处理,这样会造成在时间被追回之前的这段时间服务不可用。
很多其他类雪花算法也是在此思想上的设计然后改进规避它的缺陷,后面介绍的百度 UidGenerator 和 美团分布式ID生成系统 Leaf 中snowflake模式都是在 snowflake 的基础上演进出来的。

7.百度-UidGenerator

百度的 UidGenerator 是百度开源基于Java语言实现的唯一ID生成器,是在雪花算法 snowflake 的基础上做了一些改进。UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数和初始化策略,适用于docker等虚拟化环境下实例自动重启、漂移等场景。
在实现上,UidGenerator 提供了两种生成唯一ID方式,分别是 DefaultUidGenerator 和 CachedUidGenerator,官方建议如果有性能考虑的话使用 CachedUidGenerator 方式实现。
UidGenerator 依然是以划分命名空间的方式将 64-bit位分割成多个部分,只不过它的默认划分方式有别于雪花算法 snowflake。它默认是由 1-28-22-13 的格式进行划分。可根据你的业务的情况和特点,自己调整各个字段占用的位数。

  • 第1位仍然占用1bit,其值始终是0。
  • 第2位开始的28位是时间戳,28-bit位可表示2^28个数,这里不再是以毫秒而是以秒为单位,每个数代表秒则可用(1L<<28)/ (360024365) ≈ 8.51 年的时间。
  • 中间的 workId (数据中心+工作机器,可以其他组成方式)则由 22-bit位组成,可表示 2^22 = 4194304个工作ID。
  • 最后由13-bit位构成自增序列,可表示2^13 = 8192个数。

分布式系统 - 全局唯一ID实现方案 - 图6
其中 workId (机器 id),最多可支持约420w次机器启动。内置实现为在启动时由数据库分配(表名为 WORKER_NODE),默认分配策略为用后即弃,后续可提供复用策略

  1. DROP TABLE IF EXISTS WORKER_NODE;
  2. CREATE TABLE WORKER_NODE
  3. (
  4. ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id',
  5. HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name',
  6. PORT VARCHAR(64) NOT NULL COMMENT 'port',
  7. TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER',
  8. LAUNCH_DATE DATE NOT NULL COMMENT 'launch date',
  9. MODIFIED TIMESTAMP NOT NULL COMMENT 'modified time',
  10. CREATED TIMESTAMP NOT NULL COMMENT 'created time',
  11. PRIMARY KEY(ID)
  12. )
  13. COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;

DefaultUidGenerator 实现

DefaultUidGenerator 就是正常的根据时间戳和机器位还有序列号的生成方式,和雪花算法很相似,对于时钟回拨也只是抛异常处理。仅有一些不同,如以秒为为单位而不再是毫秒和支持Docker等虚拟化环境。

  1. protected synchronized long nextId() {
  2. long currentSecond = getCurrentSecond();
  3. // Clock moved backwards, refuse to generate uid
  4. if (currentSecond < lastSecond) {
  5. long refusedSeconds = lastSecond - currentSecond;
  6. throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);
  7. }
  8. // At the same second, increase sequence
  9. if (currentSecond == lastSecond) {
  10. sequence = (sequence + 1) & bitsAllocator.getMaxSequence();
  11. // Exceed the max sequence, we wait the next second to generate uid
  12. if (sequence == 0) {
  13. currentSecond = getNextSecond(lastSecond);
  14. }
  15. // At the different second, sequence restart from zero
  16. } else {
  17. sequence = 0L;
  18. }
  19. lastSecond = currentSecond;
  20. // Allocate bits for UID
  21. return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);
  22. }

如果你要使用 DefaultUidGenerator 的实现方式的话,以上划分的占用位数可通过 spring 进行参数配置。

  1. <bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false">
  2. <property name="workerIdAssigner" ref="disposableWorkerIdAssigner"/>
  3. <!-- Specified bits & epoch as your demand. No specified the default value will be used -->
  4. <property name="timeBits" value="29"/>
  5. <property name="workerBits" value="21"/>
  6. <property name="seqBits" value="13"/>
  7. <property name="epochStr" value="2016-09-20"/>
  8. </bean>

CachedUidGenerator 实现

而官方建议的性能较高的 CachedUidGenerator 生成方式,是使用 RingBuffer 缓存生成的id。数组每个元素成为一个slot。RingBuffer容量,默认为Snowflake算法中sequence最大值(2^13 = 8192)。可通过 boostPower 配置进行扩容,以提高 RingBuffer 读写吞吐量。
Tail指针、Cursor指针用于环形数组上读写slot:

  • Tail指针 表示Producer生产的最大序号(此序号从0开始,持续递增)。Tail不能超过Cursor,即生产者不能覆盖未消费的slot。当Tail已赶上curosr,此时可通过rejectedPutBufferHandler指定PutRejectPolicy
  • Cursor指针 表示Consumer消费到的最小序号(序号序列与Producer序列相同)。Cursor不能超过Tail,即不能消费未生产的slot。当Cursor已赶上tail,此时可通过rejectedTakeBufferHandler指定TakeRejectPolicy

CachedUidGenerator采用了双RingBuffer,Uid-RingBuffer用于存储Uid、Flag-RingBuffer用于存储Uid状态(是否可填充、是否可消费)。
由于数组元素在内存中是连续分配的,可最大程度利用CPU cache以提升性能。但同时会带来「伪共享」FalseSharing问题,为此在Tail、Cursor指针、Flag-RingBuffer中采用了CacheLine 补齐方式。
RingBuffer填充时机

  • 初始化预填充 RingBuffer初始化时,预先填充满整个RingBuffer。
  • 即时填充 Take消费时,即时检查剩余可用slot量(tail - cursor),如小于设定阈值,则补全空闲slots。阈值可通过paddingFactor来进行配置,请参考Quick Start中CachedUidGenerator配置。
  • 周期填充 通过Schedule线程,定时补全空闲slots。可通过scheduleInterval配置,以应用定时填充功能,并指定Schedule时间间隔。

    8.美团Leaf

    Leaf是美团基础研发平台推出的一个分布式ID生成服务,名字取自德国哲学家、数学家莱布尼茨的著名的一句话:“There are no two identical leaves in the world”,世间不可能存在两片相同的叶子。
    Leaf 也提供了两种ID生成的方式,分别是 Leaf-segment 数据库方案和 Leaf-snowflake 方案。

    Leaf-segment 数据库方案

    Leaf-segment 数据库方案,是在上文描述的在使用数据库的方案上,做了如下改变:

  • 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。

  • 各个业务不同的发号需求用 biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。

数据库表设计如下:

  1. CREATE TABLE `leaf_alloc` (
  2. `biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '业务key',
  3. `max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '当前已经分配了的最大id',
  4. `step` int(11) NOT NULL COMMENT '初始步长,也是动态调整的最小步长',
  5. `description` varchar(256) DEFAULT NULL COMMENT '业务key的描述',
  6. `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  7. PRIMARY KEY (`biz_tag`)
  8. ) ENGINE=InnoDB;

原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:
分布式系统 - 全局唯一ID实现方案 - 图7
同时Leaf-segment 为了解决 TP999(满足千分之九百九十九的网络请求所需要的最低耗时)数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,TP999 数据会出现偶尔的尖刺的问题,提供了双buffer优化。
简单的说就是,Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。
为了DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中,而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的 TP999 指标。详细实现如下图所示:
分布式系统 - 全局唯一ID实现方案 - 图8
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。

  • 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
  • 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。

对于这种方案依然存在一些问题,它仍然依赖 DB的稳定性,需要采用主从备份的方式提高 DB的可用性,还有 Leaf-segment方案生成的ID是趋势递增的,这样ID号是可被计算的,例如订单ID生成场景,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的

Leaf-snowflake方案

Leaf-snowflake方案完全沿用 snowflake 方案的bit位设计,对于workerID的分配引入了Zookeeper持久顺序节点的特性自动对snowflake节点配置 wokerID。避免了服务规模较大时,动手配置成本太高的问题。
Leaf-snowflake是按照下面几个步骤启动的:

  • 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
  • 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
  • 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。

分布式系统 - 全局唯一ID实现方案 - 图9
为了减少对 Zookeeper的依赖性,会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。
上文阐述过在类 snowflake算法上都存在时钟回拨的问题,Leaf-snowflake在解决时钟回拨的问题上是通过校验自身系统时间与 leaf_forever/${self}节点记录时间做比较然后启动报警的措施。
分布式系统 - 全局唯一ID实现方案 - 图10
美团官方建议是由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警。
在性能上官方提供的数据目前 Leaf 的性能在4C8G 的机器上QPS能压测到近5w/s,TP999 1ms。