常见的分布式ID生成方式,大致分类的话可以分为两类:一种是类DB型的,根据设置不同起始值和步长来实现趋势递增,需要考虑服务的容错性和可用性; 另一种是类snowflake型,这种就是将64位划分为不同的段,每段代表不同的涵义,基本就是时间戳、机器ID和序列数。这种方案就是需要考虑时钟回拨的问题以及做一些 buffer的缓冲设计提高性能。@pdai
1. 为什么需要全局唯一ID
传统的单体架构时,我们基本上是单库然后业务单表的结构,每个业务表的ID一般我们都是从1增,通过AUTO_INCREMENT=1
设置自增起始值,但是在分布式服务架构模式下分库分表的设计,使得多个库或者多个表存储相同的业务数据,这种情况根据数据库的自增ID就会产生相同的ID,不能保证主键的唯一性。
2. UUID
UUID(Universally Unique Identifier),通用唯一识别码的缩写,UUID是由一组32为的16进制的数字组成的,所以理论上UUID的总数为:16^32 = 2^128
,如果每纳秒产生1兆个UUID,要花完全部UUID需要100亿年。
生成的UUID是由8-4-4-4-12
格式的数据组成,其中32个字符和4个连接符’-‘,一般使用的时候会将连接符删除uuid.toString().replaceAll("-","")
。
目前UUID创建方式有5个版本,每个版本的算法不同,应用范围也不同。
版本1-基于时间的UUID: 这个一般是通过当前时间,随机数,和本地Mac地址来计算出来,可以通过 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()来使用或者其他包中工具。由于使用了MAC地址,因此能够确保唯一性,但是同时也暴露了MAC地址,私密性不够好
版本2-DCE安全的UUID: DCE(Distributed Computing Environment)安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX的UID或GID。这个版本的UUID在实际中较少用到
版本3-基于名字的UUID(MD5): 基于名字的UUID通过计算名字和名字空间的MD5散列值得到。这个版本的UUID保证了:相同名字空间中不同名字生成的UUID的唯一性;不同名字空间中的UUID的唯一性;相同名字空间中相同名字的UUID重复生成是相同的
版本4-随机UUID: 根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的,但是重复的可能性可以忽略不计,因此该版本也是被经常使用的版本。JDK中使用的就是这个版本
版本5-基于名字的UUID(SHA1): 和基于名字的UUID算法类似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法。
Java中JDK自带的UUID产生方式就是版本4根据随机数生成的UUID和版本3基于名字的UUID
1 | public static void main(String[] args) { |
输出结果:
1 | 59f51e7ea5ca453bbfaf2c1579f09f1d |
虽然UUID生成方便,本地也没有网络消耗,但是使用起来有一些缺点:
不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用
信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,暴露使用者的位置
对Mysql索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能
3. 数据库生成
由于分布式数据库的起始自增值一样所以才会有冲突的情况发生,那么我们将分布式系统中数据库的同一个业务表的自增ID设计成不一样的起始值,然后设置固定的步长,步长的值即为分库的数量或分表的数量。
以MySQL举例,利用给字段设置auto_increment_increment
和auto_increment_offset
来保证ID自增。
auto_increment_offset
:表示自增长字段从哪个数开始,他的取值范围是1 .. 65535auto_increment_increment
:表示自增长字段每次递增的量,其默认值是1,取值范围是1 .. 65535。
假设有三台机器,则DB1中order表的起始ID值为1,DB2中order表的起始值为2,DB3中order表的起始值为3,它们自增的步长都为3,则它们的ID生成范围如下图所示:
通过这种方式明显的优势就是依赖于数据库自身不需要其他资源,并且ID号单调自增,可以实现一些对ID有特殊要求的业务
但是缺点也很明显,首先它强依赖DB,当DB异常时整个系统不可用。虽然配置主从复制可以尽可能地增加可用性,但是数据一致性在特殊情况下难以保证。主从切换时的不一致可能会导致重复发号。还有就是ID发号性能瓶颈限制在单台MySQL的读写性能
4. 基于Redis
Redis实现分布式唯一ID主要是通过INCR
和INCRBY
这样的自增原子命令,由于Redis自身的单线程的特点所以能保证生成的ID可肯定是唯一有序的。
但是单机存在性能瓶颈,无法满足高并发的业务需求,所以可以采用集群的方式来实现。集群的方式又会涉及到和数据库集群同样的问题i,所以也需要设置分段和步长来实现。
为了避免长期自增后数字过大可以通过与当前时间戳组合使用,另外为了确保并发和业务多线程的问题可以采用Redis + LUA
的方式进行编码保证安全。
Redis 实现分布式全局唯一ID,它的性能比较高,生成的数据是有序的,对排序业务有利,但是同样它依赖于redis,需要系统引进redis组件,增加了系统的配置复杂性。
当然现在Redis的使用性很普遍,所以如果其他业务已经引进了Redis集群,则可以资源利用考虑使用Redis来实现。
5. 雪花算法-Snowflake
Snowflake雪花算法是由Twitter开源的分布式ID生成算法,以划分命名空间的方式将64-bit位分割成多个部分,每个部分代表不同的含义。而Java中64bit的整数是Long类型,所以Java中snowFlake算法生成的ID就是long来存储的。
一个SnowflakesID有64位:
- 第1位: Java中long的最高位是符号位,正数为0,负数是1,一般生成的ID都是正数,所以默认为0
- 第2-42位:时间戳,表示了自选定的时期以来的毫秒数
- 第43-52位:计算机ID,表示机器数,即2^10 = 1024台机器,防止冲突,如果我们对IDC(互联网数据中心)有需求,还可以将 10-bit 分 5-bit 给 IDC,分5-bit给工作机器
- 第53-64位:每台及其上生成ID的序列号,这允许在同一毫秒内创建多个Snowflake ID
这样的划分之后相当于在一毫秒一个数据中心的一台机器上可产生4096个有序的不重复的ID。但是我们 IDC 和机器数肯定不止一个,所以毫秒内能生成的有序ID数是翻倍的。
Snowflake 的Twitter官方原版是用Scala写的,对Scala语言有研究的同学可以去阅读下,以下是 Java 版本的写法。
1 | package com.jajian.demo.distribute; |
测试的代码如下:
1 | public static void main(String[] args) { |
雪花算法提供了一个很好的设计思想,雪花算法生成的ID是趋势递增,不依赖数据库等第三方系统,以服务的方式部署,稳定性更高,生成ID的性能也是非常高的,而且可以根据自身业务特性分配bit位,非常灵活。
但是雪花算法强依赖机器时钟,如果机器上时钟回拨,会导致发号重复或者服务会处于不可用状态。如果恰巧回退前生成过一些ID,而时间回退后,生成的ID就有可能重复。官方对于此并没有给出解决方案,而是简单的抛错处理,这样会造成在时间被追回之前的这段时间服务不可用。
6. 百度-UidGenerator
百度的
UidGenerator
是百度开源基于Java语言实现的唯一ID生成器,是在雪花算法 snowflake 的基础上做了一些改进。UidGenerator以组件形式工作在应用项目中, 支持自定义workerId位数和初始化策略,适用于docker等虚拟化环境下实例自动重启、漂移等场景。
在实现上,UidGenerator提供了两种生成唯一ID方式,分别是DefaultUidGenerator
和CachedUidGenerator
,官方建议如果有性能考虑的话使用CacheUidGeneraotr
方式实现。
UidGenerator
依赖是以划分命名空间的方式将64bit位分割成多个部分,只不过它的默认划分方式有别于雪花算法Snowflake。它默认是由1-28-22-13
的格式进行划分的,可以根据不同的业务自己调整各个字段占用的位数。
第1位仍然占用1bit,其值始终是0。
第2开始的28位是时间戳,可以表示2^28个数,这里不再是以毫秒而是以秒作为单位。
第29开始的22位是workId(数据中心+工作机器,可以是其他组成方式),可表示 2^22 = 4194304个工作ID。
最后13bit构成自增序列
其中 workId(机器 id),最多可支持约420w次机器启动。内置实现为在启动时由数据库分配(表名为 WORKER_NODE),默认分配策略为用后即弃,后续可提供复用策略。
1 | ROP TABLE IF EXISTS WORKER_NODE; |
6.1 DefaultUidGenerator实现
DefaultUidGenerator
就是正常的根据时间戳和机器位还有序列号的生成方式,和雪花算法很相似,对于时钟回拨也只是抛异常处理,仅有一些不同,如以秒位单位而不再是毫秒,且支持Docker等虚拟化环境。
1 | protected synchronized long nextId() { |
如果你要使用 DefaultUidGenerator 的实现方式的话,以上划分的占用位数可通过 spring 进行参数配置。
1 | <bean id="defaultUidGenerator" class="com.baidu.fsg.uid.impl.DefaultUidGenerator" lazy-init="false"> |
6.2 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时间间隔。#
7. 美团Leaf
Leaf是美团基础研发平台推出的一个分布式ID生成服务,名字取自德国哲学家、数学家莱布尼茨的著名的一句话:“There are no two identical leaves in the world”,世间不可能存在两片相同的叶子。
Leaf 也提供了两种ID生成的方式,分别是 Leaf-segment
数据库方案和 Leaf-snowflake
方案。
7.1 Leaf-segment 数据库方案
Leaf-segment 数据库方案,是在上文描述的在使用数据库的方案上,做了如下改变:
原方案每次获取ID都得读写一次数据库,造成数据库压力大。改为利用
proxy server
批量获取,每次获取一个segment(step决定大小)号段的值。用完之后再去数据库获取新的号段,可以大大的减轻数据库的压力。各个业务不同的发号需求用
biz_tag
字段来区分,每个biz-tag
的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,不需要上述描述的复杂的扩容操作,只需要对biz_tag分库分表就行。
数据库表设计如下:
1 | CREATE TABLE `leaf_alloc` ( |
原来获取ID每次都需要写数据库,现在只需要把step设置得足够大,比如1000。那么只有当1000个号被消耗完了之后才会去重新读写一次数据库。读写数据库的频率从1减小到了1/step,大致架构如下图所示:
同时Leaf-segment 为了解决 TP999(满足千分之九百九十九的网络请求所需要的最低耗时)数据波动大,当号段使用完之后还是会hang在更新数据库的I/O上,TP999 数据会出现偶尔的尖刺的问题,提供了双buffer优化。简单的说就是,Leaf 取号段的时机是在号段消耗完的时候进行的,也就意味着号段临界点的ID下发时间取决于下一次从DB取回号段的时间,并且在这期间进来的请求也会因为DB号段没有取回来,导致线程阻塞。如果请求DB的网络和DB的性能稳定,这种情况对系统的影响是不大的,但是假如取DB的时候网络发生抖动,或者DB发生慢查询就会导致整个系统的响应时间变慢。为了DB取号段的过程能够做到无阻塞,不需要在DB取号段的时候阻塞请求线程,即当号段消费到某个点时就异步的把下一个号段加载到内存中,而不需要等到号段用尽的时候才去更新号段。这样做就可以很大程度上的降低系统的 TP999 指标。详细实现如下图所示:
采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。
每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响。
每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。
对于这种方案依然存在一些问题,它仍然依赖 DB的稳定性,需要采用主从备份的方式提高 DB的可用性,还有 Leaf-segment方案生成的ID是趋势递增的,这样ID号是可被计算的,例如订单ID生成场景,通过订单id号相减就能大致计算出公司一天的订单量,这个是不能忍受的
7.2 Leaf-snowflake方案
Leaf-snowflake方案完全沿用 snowflake 方案的bit位设计,对于workerID的分配引入了Zookeeper持久顺序节点的特性自动对snowflake节点配置 wokerID。避免了服务规模较大时,动手配置成本太高的问题。
Leaf-snowflake是按照下面几个步骤启动的:
启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
为了减少对 Zookeeper的依赖性,会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动。
上文阐述过在类 snowflake算法上都存在时钟回拨的问题,Leaf-snowflake在解决时钟回拨的问题上是通过校验自身系统时间与 leaf_forever/${self}节点记录时间做比较然后启动报警的措施。
美团官方建议是由于强依赖时钟,对时间的要求比较敏感,在机器工作时NTP同步也会造成秒级别的回退,建议可以直接关闭NTP同步。要么在时钟回拨的时候直接不提供服务直接返回ERROR_CODE,等时钟追上即可。或者做一层重试,然后上报报警系统,更或者是发现有时钟回拨之后自动摘除本身节点并报警。
在性能上官方提供的数据目前 Leaf 的性能在4C8G 的机器上QPS能压测到近5w/s,TP999 1ms