本文主要围绕是什么、为什么、怎么做三个方面来介绍分布式ID。
是什么?
数据库中的每条数据库通常需要唯一ID来标识,传统方式采用自增ID即可满足要求,但在分布式系统中往往需要对数据进行分库分表,这时候就需要一个全局唯一的ID来标识数据,这个全局唯一的ID就叫做分布式ID。
为什么?
分布式ID是分布式系统的全局唯一ID,分布式ID生成的好坏将直接影响到整个系统的性能。分布式ID需要满足如下条件:
全局唯一:必须保证ID全局性唯一
高性能:延时低、响应快,否则反倒可能成为性能瓶颈
高可用:作为数据的唯一标识,必须保持高可用
趋势递增:数值类型最优,能够趋势递增
易接入:在系统设计和实现上要尽可能的简单,开箱即可
怎么做?
本文主要分析以下9种分布式ID生成方式以及优缺点:
UUID
数据库自增ID
数据库多主模式
号段模式
Redis
雪花算法(SnowFlake)
美团(Leaf)
百度 (Uidgenerator)
滴滴出品(TinyID)
UUID
根据UUID生成分布式ID最容易想到的,UUID存在多个版本。
UUID Version 1:基于时间的UUID
基于时间的UUID通过计算当前时间戳、随机数和机器MAC地址得到。由于在算法中使用了MAC地址,这个版本的UUID可以保证在全球范围的唯一性。但与此同时,使用MAC地址会带来安全性问题,这就是这个版本UUID受到批评的地方。如果应用只是在局域网中使用,也可以使用退化的算法,以IP地址来代替MAC地址。
UUID Version 2:DCE安全的UUID
DCE(Distributed Computing Environment)安全的UUID和基于时间的UUID算法相同,但会把时间戳的前4位置换为POSIX的UID或GID。这个版本的UUID在实际中较少用到。
UUID Version 3:基于名字的UUID(MD5)
基于名字的UUID通过计算名字和名字空间的MD5散列值得到。这个版本的UUID保证了:相同名字空间中不同名字生成的UUID的唯一性;不同名字空间中的UUID的唯一性;相同名字空间中相同名字的UUID重复生成是相同的。
UUID Version 4:随机UUID
根据随机数,或者伪随机数生成UUID。这种UUID产生重复的概率是可以计算出来的,但随机的东西就像是买彩票:你指望它发财是不可能的,但狗屎运通常会在不经意中到来。
UUID Version 5:基于名字的UUID(SHA1)
和版本3的UUID算法类似,只是散列值计算使用SHA1(Secure Hash Algorithm 1)算法。
其中:
Version 1/2适合应用于分布式计算环境下,具有高度的唯一性;
Version 3/5适合于一定范围内名字唯一,且需要或可能会重复生成UUID的环境下;
至于Version 4,个人的建议是最好不用(虽然它是最简单最方便的)。
优点:
本机生成,无性能问题。V1版本理论上全球唯一。
缺点:
ID无序;字符串;长度过长;基于MAC生成可能造成MAC泄漏。
(对于数据库主键,最好的应该有序,短小,数值类型。尤其是无序会导致数据库聚簇索引结构频繁变动)
数据库自增ID
基于数据库的auto_increment自增ID完全可以充当分布式ID,具体实现:需要一个单独的MySQL实例用来生成ID,建表结构如下:
CREATE TABLE SEQUENCE_ID ( id bigint(20) unsigned NOT NULL auto_increment, value char(10) NOT NULL default '', PRIMARY KEY (id)) ENGINE=MyISAM;
当我们需要一个ID的时候,向表中插入一条记录返回主键ID,但这种方式有一个比较致命的缺点,访问量激增时MySQL本身就是系统的瓶颈,用它来实现分布式服务风险比较大,不推荐!
优点:
实现简单,ID单调自增,数值类型查询速度快。
缺点:
DB单点存在宕机风险,高并发场景下DB本身将成为性能瓶颈。
数据库多实例自增ID
这个方案就是为了解决MySQL单点问题,在auto_increment基本上面,设置step步长。
从上图可以看出,水平扩展的数据库集群,有利于解决数据库单点问题,同时为了ID生成特性,将自增步长按照机器数量来设置。
优点:
解决了单点问题。
缺点:
步长确定后无法扩容;高并发场景下DB本身仍将成为性能瓶颈。
号段模式
号段模式是主流分布式ID生成方式之一,号段模式可以理解为从数据库批量的获取自增ID,每次从数据库取出一个号段范围,例如 (1,1000] 代表1000个ID,具体的业务服务将本号段,生成1~1000的自增ID并加载到内存。表结构如下:
CREATE TABLE id_generator ( id int(10) NOT NULL, max_id bigint(20) NOT NULL COMMENT '当前最大id', step int(20) NOT NULL COMMENT '号段的布长', biz_typeint(20) NOT NULL COMMENT '业务类型', version int(20) NOT NULL COMMENT '版本号', PRIMARY KEY (`id`))
biz_type :代表不同业务类型
max_id :当前最大的可用id
step :代表号段的长度
version :乐观锁,每次都更新version,保证并发时数据的正确性
当一批号段ID用完,再次向数据库申请新号段,对max_id字段做一次update操作,update max_id= max_id + step,update成功则说明新号段获取成功,新的号段范围是(max_id ,max_id +step]。
update id_generator set max_id = #{max_id+step}, version = version + 1 where version = #{version} and biz_type = XXX
由于多业务端可能同时操作,所以采用版本号version乐观锁方式更新,这种分布式ID生成方式不强依赖于数据库,不会频繁的访问数据库,对数据库的压力小很多。
Redis
利用Redis的原子性操作自增,一般算法为:时间戳+ 自增Id, 补0
优点:
有序递增,可读性强。
缺点:
占用带宽,每次都要向Redis请求。
注意:
使用Redis需要考虑持久化问题,Redis有两种持久化方式RDB和AOF。
RDB会定时打一个快照进行持久化,假如连续自增但redis没及时持久化,而这会Redis挂掉了,重启Redis后会出现ID重复的情况。
AOF会对每条写命令进行持久化,即使Redis挂掉了也不会出现ID重复的情况,但由于incr命令的特殊性,会导致Redis重启恢复的数据时间过长。
雪花算法(SnowFlake)
雪花算法(Snowflake)是Twitter公司内部分布式项目采用的ID生成算法,开源后广受国内大厂的好评,在该算法影响下各大公司相继开发出各具特色的分布式生成器。
如上图所示:
① 1 bit:是不用的,为啥呢?
因为二进制里第一个 bit 为如果是 1,那么都是负数,但是我们生成的 ID 都是正数,所以第一个 bit 统一都是 0。
② 41 bit:表示的是时间戳,单位是毫秒。
41 bit 可以表示的数字多达 2^41 - 1,可以标识 2 ^ 41 - 1 个毫秒值,换算成年就是表示 69 年的时间。
③ 10 bit:记录工作机器 ID,代表的是这个服务最多可以部署在 2^10 台机器上,也就是 1024 台机器。
10 bit 里 5 个 bit 代表机房 id,5 个 bit 代表机器 ID。意思就是最多代表 2 ^ 5 个机房(32 个机房),每个机房里可以代表 2 ^ 5 个机器(32 台机器)。
④12 bit:这个是用来记录同一个毫秒内产生的不同 ID。
12 bit 可以代表的最大正整数是 2 ^ 12 - 1 = 4096,也就是说可以用这个 12 bit 代表的数字来区分同一个毫秒内的 4096 个不同的 ID。理论上snowflake方案的QPS约为409.6w/s,这种分配方式可以保证在任何一个IDC的任何一台机器在任意毫秒内生成的ID都是不同的。
根据这个算法,只需要将这个算法用Java语言实现出来,封装为一个工具方法,那么各个业务应用可以直接使用该工具方法来获取分布式ID,只需保证每个业务应用有自己的工作机器id即可,而不需要单独去搭建一个获取分布式ID的应用。
优点:
毫秒数在高位,自增序列在低位,ID趋势递增。
不依赖第三方应用,本地生成,生成性能高。
可以根据自身业务特性分配bit位,非常灵活。
缺点:
强依赖机器时钟,时钟回拨可能会导致ID重复。
美团(Leaf)
Leaf由美团开发,Leaf同时支持号段模式和Snowflake算法模式,可以切换使用。
Leaf-segment方案:
通过proxy server批量获取分布式ID,每次获取一个segment号段,用完之后再去数据库获取新的号段,大大的减轻数据库的压力;
各个业务不同的发号需求用biz_tag字段来区分,每个biz-tag的ID获取相互隔离,互不影响。如果以后有性能需求需要对数据库扩容,只需要对biz_tag分库分表就行。
建表语句如下:
DROP TABLE IF EXISTS `leaf_alloc`;CREATE TABLE `leaf_alloc` ( `biz_tag` varchar(128) NOT NULL DEFAULT '' COMMENT '业务key,用来区分业务', `max_id` bigint(20) NOT NULL DEFAULT '1' COMMENT '当前已经分配了的最大id', `step` int(11) NOT NULL COMMENT '初始步长,也是动态调整的最小步长', `description` varchar(256) DEFAULT NULL COMMENT '业务key的描述', `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '数据库维护的更新时间', PRIMARY KEY (`biz_tag`)) ENGINE=InnoDB;
大致架构如下图所示:
test_tag在第一台Leaf机器上是1~1000的号段,当这个号段用完时,会去加载另一个长度为step=1000的号段,假设另外两台号段都没有更新,这个时候第一台机器新加载的号段就应该是3001~4000。同时数据库对应的biz_tag这条数据的max_id会从3000被更新成4000,更新号段的SQL语句如下:
Begin UPDATEtableSETmax_id=max_id+step WHEREbiz_tag=xxx SELECTtag, max_id, step FROMtableWHEREbiz_tag=xxxCommit
优点:
支持线性扩展,性能能够支撑大多数业务场景。
ID是趋势递增的8byte的64位数字。
容灾性高:即使DB宕机,短时间内Leaf仍能正常对外提供服务。
自定义max_id大小,方便业务以原有ID迁移过来。
缺点:
ID不够随机,能够泄露发号数量的信息,不太安全。
TP999更新号段,数据库IO可能导致用户线程阻塞。
DB宕机会造成整个系统不可用。
双buffer
针对TP999采用双buffer的方式,Leaf服务内部有两个号段缓存区segment。当前号段已下发10%时,如果下一个号段未更新,则另启一个更新线程去更新下一个号段。当前号段全部下发完后,如果下个号段准备好了则切换到下个号段为当前segment接着下发,循环往复。
主要特性如下:
每个biz-tag都有消费速度监控,通常推荐segment长度设置为服务高峰期发号QPS的600倍(10分钟),这样即使DB宕机,Leaf仍能持续发号10-20分钟不受影响;
每次请求来临时都会判断下个号段的状态,从而更新此号段,所以偶尔的网络抖动不会影响下个号段的更新。
具体实现如下图所示:
Leaf-Snowflake方案:
鉴于Leaf-segment方案不适用于美团的订单号这种场景(Leaf-segment方案可以生成趋势递增的ID,同时ID号是可计算的,很容易被猜出美团每日的订单量这种商业秘密),所以Leaf-Snowflake方案就应运而生了。
Leaf-Snowflake方案完全沿用Snowflake方案的bit位设计。对于workerID的分配,当服务集群数量较小的情况下,完全可以手动配置。Leaf服务规模较大,动手配置成本太高。所以Leaf-Snowflake使用Zookeeper持久顺序节点的特性自动对Snowflake节点配置wokerID。Leaf-snowflake是按照下面几个步骤启动的:
启动Leaf-snowflake服务,连接Zookeeper,在leaf_forever父节点下检查自己是否已经注册过(是否有该顺序子节点)。
如果有注册过直接取回自己的workerID(zk顺序节点生成的int类型ID号),启动服务。
如果没有注册过,就在该父节点下面创建一个持久顺序节点,创建成功后取回顺序号当做自己的workerID号,启动服务。
弱依赖ZooKeeper
除了每次会去ZooKeeper拿数据以外,也会在本机文件系统上缓存一个workerID文件。当ZooKeeper出现问题,恰好机器出现问题需要重启时,能保证服务能够正常启动,这样做到对第三方组件的弱依赖。
解决时钟问题
因为这种方案依赖时间,如果机器的时钟发生了回拨,那么就会有可能生成重复的ID号,需要解决时钟回退的问题。
参见上图整个启动流程图,服务启动时首先检查自己是否写过ZooKeeper leaf_forever节点:
若写过,则用自身系统时间与leaf_forever/${self}节点记录时间做比较,若小于leaf_forever/${self}时间则认为机器时间发生了大步长回拨,服务启动失败并报警。
若未写过,证明是新服务节点,直接创建持久节点leaf_forever/${self}并写入自身系统时间,接下来综合对比其余Leaf节点的系统时间来判断自身系统时间是否准确,具体做法是取leaf_temporary下的所有临时节点(所有运行中的Leaf-snowflake节点)的服务IP:Port,然后通过RPC请求得到所有节点的系统时间,计算sum(time)/nodeSize。
若abs( 系统时间-sum(time)/nodeSize ) < 阈值,认为当前系统时间准确,正常启动服务,同时写临时节点leaf_temporary/${self} 维持租约。
否则认为本机系统时间发生大步长偏移,启动失败并报警。
每隔一段时间(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
百度(Uidgenerator)
uid-generator是百度技术部基于Snowflake算法开发的,与原始的Snowflake算法不同在于,uid-generator支持自定义时间戳、工作机器ID和序列号等各部分的位数。
由上图可知,uid-generator的时间部分长度只有28位,这就意味着uid-generator默认只能承受8.5年(2^28-1/86400/365)。当然可以根据业务的需求,uid-generator可以适当调整delta seconds、worker node id和sequence占用位数。
接下来分析百度uid-generator的实现。需要说明的是uid-generator有两种方式提供:DefaultUidGenerator和CachedUidGenerator。
DefaultUidGenerator
delta seconds
这个值是指当前时间与epoch时间的时间差,且单位为秒。epoch时间就是指集成uid-generator生成分布式ID服务第一次上线的时间,可配置。
worker id
搭建uid-generator的话,需要创建一个表:
DROP TABLE IF EXISTS WORKER_NODE;CREATE TABLE WORKER_NODE( ID BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY , HOST_NAME VARCHAR(64) NOT NULL COMMENT 'host name', PORT VARCHAR(64) NOT NULL COMMENT 'port', TYPE INT NOT NULL COMMENT 'node type: ACTUAL or CONTAINER', LAUNCH_DATE DATE NOT NULL COMMENT 'launch date', MODIFIED DATETIME NOT NULL COMMENT 'modified time', CREATED DATEIMTE NOT NULL COMMENT 'created time')COMMENT='DB WorkerID Assigner for UID Generator',ENGINE = INNODB;
uid-generator会在实例启动时,往WORKER_NODE表中插入一行数据,得到的id值就是workerId的值。由于workerId默认22位,限制集成uid-generator生成分布式ID的所有实例重启次数不允许超过4194303次(即2^22-1)。
sequence
sequence是DefaultUidGenerator的核心,它对时钟回拨的处理比较简单粗暴,具体实现如下:
// synchronized保证线程安全protected synchronized long nextId() { long currentSecond = getCurrentSecond(); if (currentSecond < lastSecond) { long refusedSeconds = lastSecond - currentSecond; // 如果时间有任何的回拨,那么直接抛出异常 throw new UidGenerateException ("Clock moved backwards. Refusing for %d seconds",refusedSeconds); } // 如果当前时间和上一次是同一秒时间,那么sequence自增 if (currentSecond == lastSecond) { sequence = (sequence + 1) & bitsAllocator.getMaxSequence(); // 如果同一秒内自增值超过2^13-1,那么就会自旋等待下一秒 if (sequence == 0) { currentSecond = getNextSecond(lastSecond); } } else { // 如果是新的一秒,那么sequence重新从0开始 sequence = 0L; } lastSecond = currentSecond; return bitsAllocator .allocate(currentSecond - epochSeconds, workerId, sequence);}
CachedUidGenerator
CachedUidGenerator是 uid-generator的重要改进实现。它的核心利用了RingBuffer,如下图所示,它本质上是一个数组,数组中每个项被称为slot。uid-generator设计了两个RingBuffer,一个保存唯一ID,一个保存flag。RingBuffer的尺寸是2^n,n必须是正整数:
RingBuffer Of UID
保存唯一ID的RingBuffer,它有两个指针,Tail指针和Cursor指针。Tail指针表示最后一个生成的唯一ID。如果这个指针追上了Cursor指针,意味着RingBuffer已经满了。这时候,不允许再继续生成ID了。Cursor指针表示最后一个已经给消费的唯一ID。如果Cursor指针追上了Tail指针,意味着RingBuffer已经空了。这时候,不允许再继续获取ID了。
RingBuffer Of Flag
这个RingBuffer的每个slot的值都是0或者1,0是CAN_PUT_FLAG的标志位,1是CAN_TAKE_FLAG的标识位。每个slot的状态要么是CAN_PUT,要么是CAN_TAKE。以某个slot的值为例,初始值为0,即CAN_PUT。接下来会初始化填满这个RingBuffer,这时候这个slot的值就是1,即CAN_TAKE。等获取分布式ID时取到这个slot的值后,这个slot的值又变为0,以此类推。
CachedUidGenerator在初始化RingBuffer,会根据boostPower的值确定RingBuffer的size。RingBuffer参数paddingFactor默认值是50,意指当RingBuffer中剩余可用ID数量少于50%的时候,将启动一个异步线程往RingBuffer中填充新的唯一ID,直到填满为止。
在满足填充新的唯一ID条件时,CachedUidGenerator通过时间值递增得到新的时间值,而不是System.currentTimeMillis()这种通过系统时钟的方式获取,从而脱离了对服务器时间的依赖,也就不会有时钟回拨的问题。而lastSecond是AtomicLong类型,所以线程也是安全的。
滴滴出品(TinyID)
Tinyid是用Java开发的一款分布式ID生成系统,基于数据库号段模式实现,关于这个算法与美团Leaf-segment如出一辙。Tinyid提供了java-client(sdk)使id生成本地化,获得了更好的性能与可用性。
TinyID的特性:
http方式访问,性能取决于http server的能力,网络传输速度。
java-client方式,本地生成,号段长度(step)越长,QPS越大。
总结
本文对分布式ID做了简要介绍,旨在指明一个学习方向,每种生成方式各有它的优缺点,具体如何使用还要看具体的业务需求。