ID生成器手册

编程

ID生成器手册

在复杂分布式系统中,往往需要对大量的数据和消息进行唯一标识。此时一个能够生成全局唯一ID的系统是非常必要的。概括下来,那业务系统对ID号的要求有哪些呢?

需求

  1. 全局唯一性:不能出现重复的ID号,既然是唯一标识,这是最基本的要求。

  2. 趋势递增:在MySQL InnoDB引擎中使用的是聚集索引,由于多数RDBMS使用B-tree的数据结构来存储索引数据,在主键的选择上面我们应该尽量使用有序的主键保证写入性能。

  3. 单调递增:保证下一个ID一定大于上一个ID,例如事务版本号、IM增量消息、排序等特殊需求。

  4. 信息安全:如果ID是连续的,恶意用户的扒取工作就非常容易做了,直接按照顺序下载指定URL即可;如果是订单号就更危险了,竞对可以直接知道我们一天的单量。所以在一些应用场景下,会需要ID无规则、不规则。

上述123对应三类不同的场景,3和4需求还是互斥的,无法使用同一个方案满足。

同时除了对ID号码自身的要求,业务还对ID号生成系统的可用性要求极高,想象一下,如果ID生成系统瘫痪,整个美团点评支付、优惠券发券、骑手派单等关键动作都无法执行,这就会带来一场灾难。

由此总结下一个ID生成系统应该做到如下几点:

  1. 平均延迟和TP999延迟都要尽可能低;

  2. 可用性5个9;

  3. 高QPS。

实现

UUID

UUID(Universally Unique Identifier)的标准型式包含32个16进制数字,以连字号分为五段,形式为8-4-4-4-12的36个字符,示例:550e8400-e29b-41d4-a716-446655440000,到目前为止业界一共有5种方式生成UUID,详情见IETF发布的UUID规范 A Universally Unique IDentifier (UUID) URN Namespace。

优点:

  • 性能非常高:本地生成,没有网络消耗。

缺点:

  • 不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用。

  • 信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,这个漏洞曾被用于寻找梅丽莎病毒的制作者位置。

  • ID作为主键时在特定的环境会存在一些问题,比如做DB主键的场景下,UUID就非常不适用:

    ① MySQL官方有明确的建议主键要尽量越短越好[4],36个字符长度的UUID不符合要求。

    All indexes other than the clustered index are known as secondary indexes. In InnoDB, each record in a secondary index contains the primary key columns for the row, as well as the columns specified for the secondary index. InnoDB uses this primary key value to search for the row in the clustered index.* If the primary key is long, the secondary indexes use more space, so it is advantageous to have a short primary key*.

② 对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。

示例代码

package com.demo.idgen;

import java.util.UUID;

public class UuidGen {

   public static void main(String[] args) {

       String uuid=UUID.randomUUID().toString();

       System.out.println(uuid);

  }

}

生成结果

96e2937e-5748-475e-a63e-6b0e8232c124

类snowflake方案

这种方案大致来说是一种以划分命名空间(UUID也算,由于比较常见,所以单独分析)来生成ID的一种算法,这种方案把64-bit分别划分成多段,分开来标示机器、时间等,比如在snowflake中的64-bit分别表示如下图(图片来自网络)所示:

image

 

41-bit的时间可以表示(1L<<41)/(1000L360024*365)=69年的时间,10-bit机器可以分别表示1024台机器。如果我们对IDC划分有需求,还可以将10-bit分5-bit给IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,可以根据自身需求定义。12个自增序列号可以表示2^12个ID,理论上snowflake方案的QPS约为409.6w/s,这种分配方式可以保证在任何一个IDC的任何一台机器在任意毫秒内生成的ID都是不同的。

这种方式的优缺点是:

优点:

  • 毫秒数在高位,自增序列在低位,整个ID都是趋势递增的。

  • 不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的。

  • 可以根据自身业务特性分配bit位,非常灵活。

缺点:

  • 强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。

应用举例Mongdb objectID

MongoDB官方文档 ObjectID可以算作是和snowflake类似方法,通过“时间+机器码+pid+inc”共12个字节,通过4+3+2+3的方式最终标识成一个24长度的十六进制字符

示例代码

package com.demo.idgen;

/**

* Twitter_Snowflake<br>

* SnowFlake的结构如下(每部分用-分开):<br>

* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>

* 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0<br>

* 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)

* 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>

* 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId<br>

* 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号<br>

* 加起来刚好64位,为一个Long型。<br>

* SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。

*/

public class Snowflake {

   // ==============================Fields===========================================

   /** 开始时间截 (2015-01-01) */

   private final long twepoch = 1420041600000L;

   /** 机器id所占的位数 */

   private final long workerIdBits = 5L;

   /** 数据标识id所占的位数 */

   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) */

   private long workerId;

   /** 数据中心ID(0~31) */

   private long datacenterId;

   /** 毫秒内序列(0~4095) */

   private long sequence = 0L;

   /** 上次生成ID的时间截 */

   private long lastTimestamp = -1L;

   //==============================Constructors=====================================

   /**

    * 构造函数

    * @param workerId 工作ID (0~31)

    * @param datacenterId 数据中心ID (0~31)

    */

   public Snowflake(long workerId, long datacenterId) {

       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;

  }

   // ==============================Methods==========================================

   /**

    * 获得下一个ID (该方法是线程安全的)

    * @return SnowflakeId

    */

   public synchronized long nextId() {

       long timestamp = timeGen();

       //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常

       if (timestamp < lastTimestamp) {

           throw new RuntimeException(

                   String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));

      }

       //如果是同一时间生成的,则进行毫秒内序列

       if (lastTimestamp == timestamp) {

           sequence = (sequence + 1) & sequenceMask;

           //毫秒内序列溢出

           if (sequence == 0) {

               //阻塞到下一个毫秒,获得新的时间戳

               timestamp = tilNextMillis(lastTimestamp);

          }

      }

       //时间戳改变,毫秒内序列重置

       else {

           sequence = 0L;

      }

       //上次生成ID的时间截

       lastTimestamp = timestamp;

       //移位并通过或运算拼到一起组成64位的ID

       return ((timestamp - twepoch) << timestampLeftShift) //

               | (datacenterId << datacenterIdShift) //

               | (workerId << workerIdShift) //

               | sequence;

  }

   /**

    * 阻塞到下一个毫秒,直到获得新的时间戳

    * @param lastTimestamp 上次生成ID的时间截

    * @return 当前时间戳

    */

   protected long tilNextMillis(long lastTimestamp) {

       long timestamp = timeGen();

       while (timestamp <= lastTimestamp) {

           timestamp = timeGen();

      }

       return timestamp;

  }

   /**

    * 返回以毫秒为单位的当前时间

    * @return 当前时间(毫秒)

    */

   protected long timeGen() {

       return System.currentTimeMillis();

  }

}

结果如下

660125991150026752

数据库生成

以MySQL举例,利用给字段设置auto_increment_incrementauto_increment_offset来保证ID自增,每次业务使用下列SQL读写MySQL得到ID号。

begin;

REPLACE INTO Tickets64 (stub) VALUES ("a");

SELECT LAST_INSERT_ID();

commit;

这种方案的优缺点如下:

优点:

  • 非常简单,利用现有数据库系统的功能实现,成本小,有DBA专业维护。

  • ID号单调自增,可以实现一些对ID有特殊要求的业务。

缺点:

  • 强依赖DB,当DB异常时整个系统不可用,属于致命问题。配置主从复制可以尽可能的增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。

  • ID发号性能瓶颈限制在单台MySQL的读写性能。

Redis生成

Redis的所有命令操作都是单线程的,本身提供像incr和increby这样的自增原子命令,所以能保证生成的ID肯定是唯一有序的。

这里额外再介绍一下使用Redis来生成分布式ID,其实和利用Mysql自增ID类似,可以利用Redis中的incr命令来实现原子性的自增与返回,比如:

127.0.0.1:6379> set seq_id 1 // 初始化自增ID为1

OK

127.0.0.1:6379> incr seq_id // 增加1,并返回

(integer) 2

127.0.0.1:6379> incr seq_id // 增加1,并返回

(integer) 3

优点

· 不依赖于数据库,灵活方便,且性能优于数据库。

· 数字ID天然排序,对分页或者需要排序的结果很有帮助。

· 使用集群可以防止单点故障问题。

缺点

· 如果系统中没有Redis,还需要引入新的组件,增加系统复杂度。

· 需要编码和配置的工作量比较大。

· 步长、初始值需提前确定好且不易于扩展。

使用redis的效率是非常高的,但是要考虑持久化的问题。Redis支持RDB和AOF两种持久化的方式。

RDB持久化相当于定时打一个快照进行持久化,如果打完快照后,连续自增了几次,还没来得及做下一次快照持久化,这个时候Redis挂掉了,重启Redis后会出现ID重复。

AOF持久化相当于对每条写命令进行持久化,如果Redis挂掉了,不会出现ID重复的现象,但是会由于incr命令过得,导致重启恢复数据时间过长

 

开源方案

美团

Leaf这个名字是来自德国哲学家、数学家莱布尼茨的一句话: >There are no two identical leaves in the world > “世界上没有两片相同的树叶”

综合对比上述几种方案,每种方案都不完全符合我们的要求。所以Leaf分别在上述第二种和第三种方案上做了相应的优化,实现了Leaf-segment和Leaf-snowflake方案。

Leaf-segment数据库方案

第一种Leaf-segment方案,在使用数据库的方案上,做了如下改变: - 原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用proxy server批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。 - 各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。

数据库表设计如下:

+-------------+--------------+------+-----+-------------------+-----------------------------+

| 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表示每次分配的号段长度。原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:

image

 

test_tag在第一台Leaf机器上是1~1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:

Begin

UPDATE table SET max_id=max_id+step WHERE biz_tag=xxx

SELECT tag, max_id, step FROM table WHERE biz_tag=xxx

Commit

这种模式有以下优缺点:

优点:

  • Leaf服务可以很方便的线性扩展,性能完全能够支撑大多数业务场景。

  • ID号码是趋势递增的8byte的64位数字,满足上述数据库存储的主键要求。

  • 容灾性高:Leaf服务内部有号段缓存,即使DB宕机,短时间内Leaf仍能正常对外提供服务。

  • 可以自定义max_id的大小,非常方便业务从原有的ID方式上迁移过来。

缺点:

  • ID号码不够随机,能够泄露发号数量的信息,不太安全。

  • TP999数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,tg999数据会出现偶尔的尖刺。

  • DB宕机会造成整个系统不可用。

双buffer优化

对于第二个缺点,Leaf-segment做了一些优化,简单的说就是:

Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。

为此,我们希望DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中。而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的TP999指标。详细实现如下图所示:

image

 

采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。

  • 每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。

  • 每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。

Leaf高可用容灾

对于第三点“DB可用性”问题,我们目前采用一主两从的方式,同时分机房部署,Master和Slave之间采用半同步方式[5]同步数据。同时使用公司Atlas数据库中间件(已开源,改名为DBProxy)做主从切换。当然这种方案在一些情况会退化成异步模式,甚至在非常极端情况下仍然会造成数据不一致的情况,但是出现的概率非常小。如果你的系统要保证100%的数据强一致,可以选择使用“类Paxos算法”实现的强一致MySQL方案,如MySQL 5.7前段时间刚刚GA的MySQL Group Replication。但是运维成本和精力都会相应的增加,根据实际情况选型即可。

image

 

同时Leaf服务分IDC部署,内部的服务化框架是“MTthrift RPC”。服务调用的时候,根据负载均衡算法会优先调用同机房的Leaf服务。在该IDC内Leaf服务不可用的时候才会选择其他机房的Leaf服务。同时服务治理平台OCTO还提供了针对服务的过载保护、一键截流、动态流量分配等对服务的保护措施。

Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,不适用于订单ID生成场景,比如竞对在两天中午12点分别下单,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的。面对这一问题,我们提供了 Leaf-snowflake方案。

image

 

Leaf-snowflake方案完全沿用snowflake方案的bit位设计,即是“1+41+10+12”的方式组装ID号。对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以使用Zookeeper持久顺序节点的特性自动对snowflake节点配置wokerID。Leaf-snowflake是按照下面几个步骤启动的:

  1. 启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。

  2. 如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。

  3. 如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。

image

 

弱依赖ZooKeeper

除了每次会去ZK拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。这样做到了对三方组件的弱依赖。一定程度上提高了SLA

解决时钟问题

因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。

image

 

参见上图整个启动流程图,服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:

  1. 若写过,则用自身系统时间与leaf_forever/节点记录时间做比较,若小于​{self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警。

  2. 若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize。

  3. 若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约。

  4. 否则认为本机系统时间发生大步长偏移,启动失败并报警。

  5. 每隔一段时间(3s)上报自身系统时间写入leaf_forever/${self}。

由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警,如下:

//发生了回拨,此刻时间小于上次发号时间

if (timestamp < lastTimestamp) {

 

           long offset = lastTimestamp - timestamp;

           if (offset <= 5) {

               try {

              //时间偏差大小小于5ms,则等待两倍时间

                   wait(offset << 1);//wait

                   timestamp = timeGen();

                   if (timestamp < lastTimestamp) {

                      //还是小于,抛异常并上报

                       throwClockBackwardsEx(timestamp);

                    }    

              } catch (InterruptedException e) {  

                  throw  e;

              }

          } else {

               //throw

               throwClockBackwardsEx(timestamp);

          }

      }

//分配ID      

       

从上线情况来看,在2017年闰秒出现那一次出现过部分机器回拨,由于Leaf-snowflake的策略保证,成功避免了对业务造成的影响。

宜信

s4_gear_idgen

先看下数据库表设计:

字段说明:

· id:数据库主键,无实际含义。

· key_name :用来区分业务,不同的业务使用不同的。

· key_name,每个key_name的ID相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对 biz_tag 分库分表就行。

· key_value:表示该key_name目前所被分配的ID号段的最大值。

· key_length:生成ID的长度。

· key_cache:表示每次分配的号段长度。原来获取ID每次都需要写数据库,现在只需要把key_cache设置得足够大,比如1000。那么只有当 1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step。

· key_prefix:生成ID的前缀,可配置自定义前缀+日期部分 如: ​{date14}

· ID前缀日期部分支持以下几种日期格式:

· key_suffix=:生成ID的后缀,可配置亦可不配置

· key_digit:ID 进制数,支持10进制36进制62进制 。

· Version:每条记录对应的版本号,用户更新记录。

优点

· 很方便的线性扩展,能够支撑大多数业务场景。

· 生成ID规则多样,可配置且支持10进制、36进制、62进制。

· 业务之间ID相互隔离,互不影响。

· 获取ID不用频繁操作数据库,快消耗完号段内ID时才会操作数据库,减轻了数据库的压力。

· 提前初始化号段内的ID,保证在每个号段内ID使用完之前初始化完成,避免业务使用完ID后才初始化带来的影响。

· 可以自定义key_value的大小,非常方便业务从原有的ID方式上迁移过来。

· 容灾性高:服务内部有号段缓存,即使 DB 宕机,短时间内服务仍能正常对外提供服务。

三、 功能介绍

该ID生成器是基于NS4框架实现的,支持分布式部署,同时生成的ID长度、前缀、后缀、步长,进制也可根据自己的业务自由的配置。

其功能可分为以下几个部分:

· 获取单个 Long 类型的 ID 如 66310

· 获取批量 String 类型的 ID:19011123221266312, 19011123221266313, 19011123221266314, 19011123221266315

百度

源码实现分析

与原始的snowflake算法不同,uid-generator支持自定义时间戳、工作机器id和序列号等各部分的位数,以应用于不同场景。默认分配方式如下。

  • sign(1bit) 固定1bit符号标识,即生成的UID为正数。

  • delta seconds (28 bits) 当前时间,相对于时间基点"2016-05-20"的增量值,单位:秒,最多可支持约8.7年(注意:1. 这里的单位是秒,而不是毫秒! 2.注意这里的用词,是“最多”可支持8.7年,为什么是“最多”,后面会讲)

  • worker id (22 bits) 机器id,最多可支持约420w次机器启动。内置实现为在启动时由数据库分配,默认分配策略为用后即弃,后续可提供复用策略。

  • sequence (13 bits) 每秒下的并发序列,13 bits可支持每秒8192个并发。(注意下这个地方,默认支持qps最大为8192个)

DefaultUidGenerator

DefaultUidGenerator的产生id的方法与基本上就是常见的snowflake算法实现,仅有一些不同,如以秒为为单位而不是毫秒。

DefaultUidGenerator的产生id的方法如下。

   protected synchronized long nextId() {

      long currentSecond = getCurrentSecond();

      // Clock moved backwards, refuse to generate uid

      if (currentSecond < lastSecond) {

          long refusedSeconds = lastSecond - currentSecond;

          throw new UidGenerateException("Clock moved backwards. Refusing for %d seconds", refusedSeconds);

      }

      // At the same second, increase sequence

      if (currentSecond == lastSecond) {

          sequence = (sequence + 1) & bitsAllocator.getMaxSequence();

          // Exceed the max sequence, we wait the next second to generate uid

          if (sequence == 0) {

              currentSecond = getNextSecond(lastSecond);

          }

      // At the different second, sequence restart from zero

      } else {

          sequence = 0L;

      }

      lastSecond = currentSecond;

      // Allocate bits for UID

      return bitsAllocator.allocate(currentSecond - epochSeconds, workerId, sequence);

  }

CachedUidGenerator

CachedUidGenerator支持缓存生成的id。

基本实现原理

关于CachedUidGenerator,文档上是这样介绍的。

在实现上, UidGenerator通过借用未来时间来解决sequence天然存在的并发限制; 采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费, 同时对CacheLine补齐,避免了由RingBuffer带来的硬件级「伪共享」问题. 最终单机QPS可达600万。

【采用RingBuffer来缓存已生成的UID, 并行化UID的生产和消费】

使用RingBuffer缓存生成的id。RingBuffer是个环形数组,默认大小为8192个,里面缓存着生成的id。

获取id

会从ringbuffer中拿一个id,支持并发获取

填充id

RingBuffer填充时机

  • 程序启动时,将RingBuffer填充满,缓存着8192个id

  • 在调用getUID()获取id时,检测到RingBuffer中的剩余id个数小于总个数的50%,将RingBuffer填充满,使其缓存8192个id

  • 定时填充(可配置是否使用以及定时任务的周期)

【UidGenerator通过借用未来时间来解决sequence天然存在的并发限制】

因为delta seconds部分是以秒为单位的,所以1个worker 1秒内最多生成的id书为8192个(2的13次方)。

从上可知,支持的最大qps为8192,所以通过缓存id来提高吞吐量。

为什么叫借助未来时间?

因为每秒最多生成8192个id,当1秒获取id数多于8192时,RingBuffer中的id很快消耗完毕,在填充RingBuffer时,生成的id的delta seconds 部分只能使用未来的时间。

(因为使用了未来的时间来生成id,所以上面说的是,【最多】可支持约8.7年)

  @Override

  public long getUID() {

      try {

          return ringBuffer.take();

      } catch (Exception e) {

          LOGGER.error("Generate unique id exception. ", e);

          throw new UidGenerateException(e);

      }

  }

RingBuffer缓存已生成的id

(注意:这里的RingBuffer不是Disruptor框架中的RingBuffer,但是借助了很多Disruptor中RingBuffer的设计思想,比如使用缓存行填充解决伪共享问题)

RingBuffer为环形数组,默认容量为sequence可容纳的最大值(8192个),可以通过boostPower参数设置大小。

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 补齐方式。

public class RingBuffer {

  private static final Logger LOGGER = LoggerFactory.getLogger(RingBuffer.class);

  /** Constants */

  private static final int START_POINT = -1;

  private static final long CAN_PUT_FLAG = 0L; //用于标记当前slot的状态,表示可以put一个id进去

  private static final long CAN_TAKE_FLAG = 1L; //用于标记当前slot的状态,表示可以take一个id

  public static final int DEFAULT_PADDING_PERCENT = 50; //用于控制何时填充slots的默认阈值:当剩余的可用的slot的个数,小于bufferSize的50%时,需要生成id将slots填满

  /** The size of RingBuffer"s slots, each slot hold a UID */

  private final int bufferSize; //slots的大小,默认为sequence可容量的最大值,即8192个

  private final long indexMask;

 

  private final long[] slots; //slots用于缓存已经生成的id

  private final PaddedAtomicLong[] flags; //flags用于存储id的状态(是否可填充、是否可消费)

  /** Tail: last position sequence to produce */

  //Tail指针

  //表示Producer生产的最大序号(此序号从0开始,持续递增)。Tail不能超过Cursor,即生产者不能覆盖未消费的slot。当Tail已赶上curosr,此时可通过rejectedPutBufferHandler指定PutRejectPolicy

  private final AtomicLong tail = new PaddedAtomicLong(START_POINT); //

  /** Cursor: current position sequence to consume */

  //表示Consumer消费到的最小序号(序号序列与Producer序列相同)。Cursor不能超过Tail,即不能消费未生产的slot。当Cursor已赶上tail,此时可通过rejectedTakeBufferHandler指定TakeRejectPolicy

  private final AtomicLong cursor = new PaddedAtomicLong(START_POINT);

  /** Threshold for trigger padding buffer*/

  private final int paddingThreshold; //用于控制何时填充slots的阈值

   

  /** Reject put/take buffer handle policy */

  //当slots满了,无法继续put时的处理策略。默认实现:无法进行put,仅记录日志

  private RejectedPutBufferHandler rejectedPutHandler = this::discardPutBuffer;

  //当slots空了,无法继续take时的处理策略。默认实现:仅抛出异常

  private RejectedTakeBufferHandler rejectedTakeHandler = this::exceptionRejectedTakeBuffer;

   

  /** Executor of padding buffer */

  //用于运行【生成id将slots填满】任务

  private BufferPaddingExecutor bufferPaddingExecutor;

RingBuffer填充时机

  • 程序启动时,将RingBuffer填充满,缓存着8192个id

  • 在调用getUID()获取id时,检测到RingBuffer中的剩余id个数小于总个数的50%,将RingBuffer填充满,使其缓存8192个id

  • 定时填充(可配置是否使用以及定时任务的周期)

填充RingBuffer

   /**

    * Padding buffer fill the slots until to catch the cursor

    */

  public void paddingBuffer() {

      LOGGER.info("Ready to padding buffer lastSecond:{}. {}", lastSecond.get(), ringBuffer);

      // is still running

      if (!running.compareAndSet(false, true)) {

          LOGGER.info("Padding buffer is still running. {}", ringBuffer);

          return;

      }

      // fill the rest slots until to catch the cursor

      boolean isFullRingBuffer = false;

      while (!isFullRingBuffer) {

          //获取生成的id,放到RingBuffer中。

          List<Long> uidList = uidProvider.provide(lastSecond.incrementAndGet());

          for (Long uid : uidList) {

              isFullRingBuffer = !ringBuffer.put(uid);

              if (isFullRingBuffer) {

                  break;

              }

          }

      }

      // not running now

      running.compareAndSet(true, false);

      LOGGER.info("End to padding buffer lastSecond:{}. {}", lastSecond.get(), ringBuffer);

  }

生成id(上面代码中的uidProvider.provide调用的就是这个方法)

   /**

    * Get the UIDs in the same specified second under the max sequence

    *

    * @param currentSecond

    * @return UID list, size of {@link BitsAllocator#getMaxSequence()} + 1

    */

  protected List<Long> nextIdsForOneSecond(long currentSecond) {

      // Initialize result list size of (max sequence + 1)

      int listSize = (int) bitsAllocator.getMaxSequence() + 1;

      List<Long> uidList = new ArrayList<>(listSize);

      // Allocate the first sequence of the second, the others can be calculated with the offset

      //这里的实现很取巧

      //因为1秒内生成的id是连续的,所以利用第1个id来生成后面的id,而不用频繁调用snowflake算法

      long firstSeqUid = bitsAllocator.allocate(currentSecond - epochSeconds, workerId, 0L);

      for (int offset = 0; offset < listSize; offset++) {

          uidList.add(firstSeqUid + offset);

      }

      return uidList;

  }

填充缓存行解决“伪共享”

   //数组在物理上是连续存储的,flags数组用来保存id的状态(是否可消费、是否可填充),在填入id和消费id时,会被频繁的修改。

  //如果不进行缓存行填充,会导致频繁的缓存行失效,直接从内存中读数据。

  private final PaddedAtomicLong[] flags;

  //tail和cursor都使用缓存行填充,是为了避免tail和cursor落到同一个缓存行上。

  /** Tail: last position sequence to produce */

  private final AtomicLong tail = new PaddedAtomicLong(START_POINT);

  /** Cursor: current position sequence to consume */

  private final AtomicLong cursor = new PaddedAtomicLong(START_POINT)

/**

* Represents a padded {@link AtomicLong} to prevent the FalseSharing problem<p>

*

* The CPU cache line commonly be 64 bytes, here is a sample of cache line after padding:<br>

* 64 bytes = 8 bytes (object reference) + 6 * 8 bytes (padded long) + 8 bytes (a long value)

* @author yutianbao

*/

public class PaddedAtomicLong extends AtomicLong {

  private static final long serialVersionUID = -3415778863941386253L;

  /** Padded 6 long (48 bytes) */

  public volatile long p1, p2, p3, p4, p5, p6 = 7L;

  /**

    * Constructors from {@link AtomicLong}

    */

  public PaddedAtomicLong() {

      super();

  }

  public PaddedAtomicLong(long initialValue) {

      super(initialValue);

  }

  /**

    * To prevent GC optimizations for cleaning unused padded references

    */

  public long sumPaddingToPreventOptimization() {

      return p1 + p2 + p3 + p4 + p5 + p6;

  }

}

阿里

阿里的订单生成策略: 年月日+随机数+时间戳+应用名和线程id

附录

开源的分布式ID生成器

美团

Leaf——美团点评分布式ID生成系统

博客地址: https://tech.meituan.com/2017/04/21/mt-leaf.html

代码地址: https://github.com/Meituan-Dianping/Leaf

宜信

ns4_gear_idgen ——宜信分布式ID生成系统

博客地址: https://www.jianshu.com/p/dad4d6a064bc

代码地址: https://github.com/newsettle/ns4gearidgen

百度

uid-generator ——百度分布式ID生成系统

博客地址 : https://www.cnblogs.com/yeyang/p/10226284.html

代码地址 : https://github.com/baidu/uid-generator

其他

居于美团leaf、百度UidGenerator、原生snowflake 进行整合的 唯一ID生成器

代码地址 : https://github.com/linhuaichuan/ecp-uid

以上是 ID生成器手册 的全部内容, 来源链接: utcz.com/z/512108.html

回到顶部