本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
引言
在前面的《微服务设计篇》中,曾反复强调过要尽量避免使用分布式/微服务架构,尽管使用这些架构能够带来不少好处,但它们并非设计时的银弹。一旦当你使用分布式架构,就等价于走上了一条不归路,所需要面临的技术挑战可以说是无穷无尽,而这些问题将需要耗费大量时间和精力去解决。
咱们今天来看分布式架构中的第一个核心问题:分布式ID
,所谓的分布式ID
,就是针对整个系统而言,任何时刻获取一个ID
,无论系统处于何种情况,该值不会与之前产生的值重复,之后获取分布式ID
时,也不会再获取到与其相同的值,它是一个绝对意义上的全局唯一值。
在分布式系统中,我们如何实现上面所说的分布式ID
呢?这就是本文要讨论的话题,方案有很多,每种方案都有各自的优劣,哪种方案最好,哪种方式与自身业务最贴合?这些问题在看完后心里都会得到答案,下面Let's go
!
PS:个人编写的《技术人求职指南》小册已完结,其中从技术总结开始,到制定期望、技术突击、简历优化、面试准备、面试技巧、谈薪技巧、面试复盘、选
Offer
方法、新人入职、进阶提升、职业规划、技术管理、涨薪跳槽、仲裁赔偿、副业兼职……,为大家打造了一套“从求职到跳槽”的一条龙服务,同时也为诸位准备了七折优惠码:3DoleNaE
,近期需要找工作的小伙伴可以点击:s.juejin.cn/ds/USoa2R3/了解详情!
一、分布式ID概述
前面给出了分布式ID
的定义,可什么时候需要它呢?有人会回答分布式系统需要,可真的需要吗?并不一定,不是所有分布式系统都需要,回想以前单体架构时代,ID
通常是作为数据的唯一标识,比如用户会有用户ID
、订单会有订单ID
……,这些ID
在对应的业务模块中都是唯一的,通常依靠数据库自增序列来实现。
换到分布式系统里,尽管内部的技术架构天翻地覆,可是外层的业务却始终如一,因此,业务数据并不会随着技术架构的演进而消失。以用户数据为例,从单体架构转到分布式架构时,需要将用户ID
从数据库自增ID
换成分布式ID
吗?显然不用。
那么,究竟什么情况下需要用到分布式ID
呢?最经典的场景是分库分表,还是以用户数据来举例子,之前只有一张用户表,所以设置表ID
自增后,每新增一条数据都会自增ID
值,从而确保了ID
永远不会重复。
此刻用户表被分成了十张,如果再依靠数据库本身的自增机制来分配ID
,显然会导致ID
重复,这时分布式ID
就派上了用场。除开分库分表外,通常还会用到分布式ID
的场景有:
- 链路
ID
:分布式链路中,需要通过全局唯一的traceId
来串联所有日志; - 请求
ID
:幂等性处理时,需要通过唯一的ID
来判断是否为重复请求; - 消息标识:
MQ
需要基于唯一的msgID
来区分数据,确保数据不重复或丢失; - 短链码:生成短链接时,需要获取一个全局唯一的值作为
Code
避免重复; - ……
因此,并非分布式架构就一定要用到分布式ID
,只有强制要求全局唯一的场景才会需要。
PS:普通表自增的
ID
,也是一种另类的分布式ID
,只要自增出来的值不会重复即可。
1.1、分布式ID的特性
理解什么场景下需要分布式ID
后,下面我们一起来看一些分布式ID
应该需要具备的特性:
- 唯一性:每个
ID
必须全局唯一,避免因ID
重复导致的数据冲突或错误; - 顺序性:在某些场景中,
ID
需要具备单调递增的顺序性,方便排序与记忆; - 业务性:某些场景的
ID
要具备业务特性,如前缀XX
开头、包含时间信息等; - 精简性:某些场景下的
ID
不宜过长,所以对位数/长度有所限制,如16
位;
所以,当咱们设计一个生成分布式ID
的方案时,就必须将这几条考虑在内。除此之外,分布式ID
生成方案还得考虑如下几点:
- 分布式支持:D生成方案要能够在多节点、多数据中心等分布式环境下正常工作;
- 高可用性:ID生成不能成为系统瓶颈,必须解决单点故障问题,具有高可用性;
- 高性能:生成ID速率要非常高效,从而满足高并发场景下的海量ID需求;
- 拓展性:ID生成方案能随着业务发展,平滑的进行水平拓展,并兼容已有的ID。
OK,综上所述,一个合格的分布式ID
生成方案,需要综合考虑全局唯一性、趋势递增/有序性、高可用性、性能高效、易于扩展、业务性、长度等多方面要素,通过合理的设计和实现,方能确保生成的ID
满足业务需求并具有良好的性能表现。
二、分布式ID方案
前面了解分布式ID的概念及特性后,接着来看看生成分布式ID
的几种方案,包括它们的工作原理、优劣势分析,以及对环境依赖性。
2.1、无序随机ID
随机ID方案里的代表是UUID
(Universally Unique Identifier
)策略,它是一组由32
个十六进制数字组成的字符串,总共分为五段,每段之间用连字符“-”隔开,包括四个连字符在内,总计三十六个字符,而在Java中获取UUID
的方式也格外简单:
String uuid = UUID.randomUUID().toString();
System.out.println(uuid);
/*
* 输出结果:
* b2c2ec5d-efb9-44c7-b2c8-9cef367c8b3f
* */
通过JDK提供的UUID
工具类,一行代码就能生成一个UUID
,并且得到的UUID
不会重复,怎么保障的呢?UUID
的底层,会基于硬件地址(MAC地址)、时间戳和随机因子来生成ID
。
世界上没有两片完全相同的叶子,者如这句话一般,世界上也没有两台完全相同的机器,这时硬件地址自然不同,再加上正常情况下不可逆转的时间戳,以及一定范围的随机数,就能确保产生的UUID
,其全球唯一性。
PS:有没有可能出现重复的
UUID
呢?答案是特别难,除非两个应用部署在同一台机器,并且同一时刻生成UUID
(纳秒级精度),并且得到的随机数也完全相同,这时才有可能出现重复的UUID
。
但在实际应用中无需考虑UUID
的重复问题,因为UUID
出现重复的概率比你徒手搓出核弹都难,硬件地址+时间戳+随机数的组合,它几乎可以被视为唯一。这种设计确保了即使在全球范围内,不同系统生成的UUID
之间,发生重复的概率也极其微小,几乎可以忽略不计。
2.1.1、UUID版本说明
其实UUID
存在不同的版本,不同版本的生成策略也不一样,目前UUID
主要有五个版本,下面说明下各自的差异。
UUID版本1:基于时间的UUID
48bit
主机的硬件地址(Mac
地址);60bit
时间戳 (14bit
作为时间序列);
最基础的版本,基于当前纳秒级时间戳和机器MAC
地址生成,极端环境下可能造成重复值,但除非MAC
地址+纳秒级冲突,否则得到的ID
全局唯一。
UUID版本2:DCE安全的UUID
48bit
主机的硬件地址(Mac
地址);40bit
自定义标识符;28bit
时间戳(6bit
作为时间序列);
在第一个版本的基础上,增加了对DCE
(分布式计算环境)安全的支持。也就是允许自定义一个标识符,来区分部署在同一机器上的不同节点,但这个版本几乎很少使用。
UUID版本3:MD5散列的UUID
这种版本没有固定格式,会基于指定的命名空间、名称进行MD5
散列计算,从而生成一个唯一的UUID
。给定值不变的情况下,得到的UUID
不会改变。
UUID版本4:随机生成的UUID
6bit
标记版本;122bit
随机数;
这是JDK1.8
中默认使用的版本,这个版本完全基于特殊的随机数算法生成,每秒大概能产生十亿个ID
,因此具有很高的随机性和唯一性,是目前使用最广泛的版本。
UUID版本5:SHA1哈希的UUID
这个版本和第三个版本类似,只不过将算法从MD5
换成了SHA1
,用的也比较少,Java中可以基于UUID.nameUUIDFromBytes()
方法去实现,一般也很少用。
2.1.2、UUID优劣势分析
UUID
不需要依赖任何第三方资源,完全基于本地生成,所以生成UUID
速度非常快,常用的UUID
版本四,每秒大约能产生10E
个不重复的值。
从上面的描述能得知,UUID
能很好的满足唯一性、高性能、高可用等条件,不过UUID
也有几个问题:
- ①最终产生的值比较长,足足有
36
个字符; - ②生成的
ID
完全随机,ID
之间没有任何顺序; - ③可读性较差,得到的
ID
不具备任何业务含义。
因此,由于这几点问题的存在,对于某些特殊场景来说UUID
并不合适,比如数据分库分表后,需要获取分布式ID
来作为主键,如果这时使用UUID
,因为它的无序性,就会造成索引树的频繁分裂,最终影响写入性能。
2.2、ID生成器
为了保证有序性,也保证全局唯一性,还有一种方案就是搭建独立的ID
生成中心,这种方案实现起来并不难,核心就是:设计一个独立于所有业务应用之外的ID
生成器,由它来负责递增生成全局ID
。
2.2.1、自定义ID生成器
既然是独立的ID生成器应用,那如何设计呢?可以先定义一个获取全局ID
的接口:
@GetMapping("/getId")
public Long getGlobalId(String businessTag) {
return dentifierService.genAndGetGlobalId(businessTag);
}
接着来实现这个生成并返回全局ID
的genAndGetGlobalId()
方法,如下:
private static ConcurrentMap<String, AtomicLong> IdentifierMap = new ConcurrentHashMap<>();
public Long genAndGetGlobalId(String businessTag) {
AtomicLong IdGenerator = IdentifierMap.get(businessTag);
// 双重锁单例
if (null == IdGenerator) {
synchronized (businessTag.intern()) {
if (null == IdGenerator) {
IdGenerator = new AtomicLong(1);
IdentifierMap.put(businessTag, IdGenerator);
return IdGenerator.get();
}
}
}
return IdGenerator.incrementAndGet();
}
这就是一个最基本的ID生成器应用,看起来十分简陋,不过基本功能已经实现了,来解读一下流程:
- ①该应用内部有一个全局的
IdentifierMap
容器,用来维护不同业务的ID
序列; - ②当业务应用调用获取全局
ID
的/getId
接口时,会传入一个业务标识来区分; - ③生成器先根据业务标识去找对应的
ID
序列,没有则用DCL
初始化一个,返回1
; - ④如果找到了对应业务的生成器,则在原有值的基础上自增一次,并将自增值返回。
这便是完整的流程,虽然简陋,但也考虑到了并发安全问题,用到了并发容器、双重锁单例、原子类来解决。
不过这种做法不太专业,一方面是性能存在问题,另一方面是可靠性较差,一旦这个应用发生重启,之前维护的序列就会丢失,而常规的做法是什么呢?依靠第三方中间件来生成,如Redis、DB、Zookeeper
等等。
2.2.2、数据库版ID生成器
所谓数据库版的ID生成器,就是使用数据库的表自增特性,从而实现全局ID
。即为不同业务模块都建立一张自增表,从而维护递增序列:
CREATE TABLE `user_global_id` (
`user_id` bigint NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`last_time` datetime DEFAULT NULL COMMENT '最近获取时间',
PRIMARY KEY (`user_id`) USING BTREE
)
ENGINE=InnoDB AUTO_INCREMENT=1
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
ROW_FORMAT=DYNAMIC COMMENT='用户全局ID表';
基于这样的表结构,如果用户服务要获取一个分布式ID
,那么就直接往user_global_id
表里插入一条数据,并将插入后生成的自增ID
返回,这时就算重启生成器应用,原本维护的自增序列也不会丢失。
这种方式简单易用,也不需要我们考虑并发冲突问题,一切都依靠数据库自身提供的并发安全机制。不过缺点嘛,也十分明显,因为依赖数据库的自增机制,如果数据增长非常快,数据库会称为性能瓶颈。
2.2.3、Redis版ID生成器
由于数据库来实现生成器存在性能问题,所以业界也提出了一种基于Redis
的分布式ID
生成策略,其实也十分简单,即:为不同的业务创建不同的Key
,然后通过INCR
命令,来对相应的Key
自增,从而得到全局唯一并且有序的ID,伪代码如下:
// 根据业务标写入一个Key
set user:global:id 0
// 自增对应业务标的值并返回
incr user:global:id
这跟咱们一开始自己实现的ID
生成器很类似,只不过是将Map
换成了Redis
而已。
相较于数据库,Redis
号称支持每秒10W
的读写,这个性能显然搞出N
个量级,并且Redis
内部使用单线程去处理网络层请求,这也无需担心并发场景下的安全问题。
不过基于Redis
实现的生成器,缺点同样很明显,Redis
虽然有持久性机制,可是不太靠谱,毕竟它是先写内存,然后异步刷磁盘,遇到极端情况,就有可能导致自增序列的部分信息丢失。比如原本自增到888
做了一次持久化,后续自增到899
时,还没来得及持久化就宕机了,这时889~899
这段值就会丢失,重启后又会从888
开始递增。
2.2.4、ID生成器方案小结
当然,使用zookeeper
或其他中间件来实现的步骤也类似,这里就不做重复说明了,下面来聊聊ID
生成器方案的通病。
首先,ID
生成依赖各种各样的外部组件,这会增加系统复杂度以及风险。比如基于Redis
来实现生成器,一旦Redis
发生故障,就会将故障传播到业务应用,导致业务系统不可用。
其次,业务应用的分布式ID
依赖ID生成器应用,这也会增加出错风险。这一条与前面的有些类似,但总体上又有所区别,前面是生成器应用依赖的中间件故障会牵连业务,而这里是ID生成器本身故障,同样会将错误蔓延至业务系统。不过这个问题很好解决,即业务系统跳过ID生成器应用、直接基于中间件生成ID即可。
最后,以性能不错的Redis
举例,尽管它号称能够达到10W/s
的性能,可性能还是不够。10W/s
代表每秒钟最多只能产生十万个ID
,对于一些大型分布式系统来说显然不够用。
2.3、交错递增
所谓的交错递增,这是针对分库场景出现的一种特殊方案,比如用户数据分成了两个库,这时用户表再通过自增器保证ID
唯一性就会出问题,来看:
DB1:1、2、3、4、5、6……
DB2:1、2、3、4、5、6……
因为两个数据库都会保持这个递增顺序,那么最终就会出现重复的ID
,怎么解决呢?可以通过设置数据库自增步长的方式来防重,比如将两个节点的自增步长设为2
,然后为两个节点分配自增初始值1、2
,最终会出现如下效果:
DB1:1、3、5、7、9、11、13、15、17.......
DB2:2、4、6、8、10、12、14、16、18......
通过这种交错递增的形式,就能使得两个库递增的ID
不会重复。这种方案几乎没有改造成本,只要设置下数据库的参数值即可。同样,这种方案简单,但劣势也格外明显。
第一个问题就是拓展性问题,目前只有两个库,所以将自增步长设置为2
、自增初始值设为1、2
,从而能够实现交错自增,可如果后面再次扩容呢?比如变成三个库,这又怎么办?重新设置为3
的话,历史数据怎么处理?因此,这种方案除非一开始就确认不会扩容,否则后期很难维护。
第二个问题是局限性,因为大部分数据库,如MySQL
,并不支持改变单表的自增步长,只能改变整个库的自增步长。这时,如果你没有分库,只是在一个库里将用户表拆成了多张表,这种方案也不适用。
2.4、雪花算法
前面看过了几种分布式ID
生成方案,貌似都会有各自的问题,那究竟该怎么办?
正在大家为这点困扰的时候,Twitter
开源了内部使用的分布式ID
生成算法:雪花(Snowflake)算法,一经开源受到诸多好评,目前也称为分布式ID
的标准选择,包括后续其他的分布式ID
算法,也大多数基于它做了拓展实现,下面一起来看看。
2.4.1、Snowflake雪花算法原理
雪花算法生成的分布式ID
,在Java
中会使用Long
类型来承载,Long
类型占位8bytes
,也就正好对应上述这张图的64
个比特位,这64bit
会被分为四部分:
- 符号位(
1bit
):永远为零,表示生成的分布式ID
为正数。 - 时间戳位(
2~42bit
):会将当前系统的时间戳插入到这段位置。 - 工作进程位(
43~53bit
):在集群环境下,每个进程唯一的工作ID
。 - 序列号位(
54~64bit
):该序列是用来在同一个毫秒内生成不同的序列号。
当需要生成一个分布式ID
时,Sharding-Sphere
首先会获取当前系统毫秒级的时间戳,放入到第2~42bit
,总共占位41
个比特,一年365
天中,一共会存在365*24*60*60*1000
个不同的毫秒时间戳,此时可以做组计算:
Math.pow(2, 41) / (365*24*60*60*1000) ≈ 69.73年
也就是41bit
的空间,可以存下大概69.73
年生成的毫秒时间戳,Sharding-Sphere
雪花算法的时间纪元是从2016.11.01
日开始的,这也就代表着使用Sharding-Sphere
雪花算法生成的分布式ID
,在未来近70
年内无需担心出现时间戳存不下的问题。
有人也许会纠结,万一我的系统会在线上运行百年之久呢?这种情况下,获取到的时间戳,就无法使用
41bit
存储下了怎么办呢?这实际上很简单,把存储ID
的Long
类型改为容量更大的引用类型即可,也就是用更大的比特位来存放时间戳。
OK~,想明白上面的问题后,接着再聊聊分布式ID
的重复问题,如果系统的并发较高,导致同一毫秒内需要生成多个ID
怎么办呢?也就是时间戳位重复的情况下该怎么确保ID
唯一性呢?其实在最后12bit
上会存放一个顺序递增的序列值,2
的12
次幂为4096
,也就意味着同一毫秒内可以生成4096
个不同的ID
值。
但似乎又出现了一个问题:当系统每毫秒的并发
ID
需求超出4096
怎么办呢?Sharding-Sphere
的做法是留到下个毫秒时间戳时再生成ID
,基本只要你的业务不是持续性的超出4096
这个阈值,Sharding-Sphere
的雪花算法都是够用的,毕竟一秒409.6w
并发量,相信能够从容应对各类业务。
但一般分布式系统中,都会采用集群的模式部署核心业务,如果使用雪花算法的节点存在多个,并且部署在不同的机器上,这会导致同一个毫秒时间戳内,出现不同的并发需求,之前说到的解决方案,由于自增序列是基于堆中的对象实现,不同机器存在多个堆空间,也就是每个节点之间都维护着各自的自增序列,因此集群环境下依旧会产生重复的分布式ID
。
为了解决这个问题,雪花算法生成分布式
ID
中,第43~53bit
会用来存储工作进程ID
,当一个服务采用了集群方案部署时,不同的节点配置不同的worker-id
即可。因为worker-id
不同,所以就算毫秒时间戳、自增序列号完全一致,依旧不会导致ID
出现冲突,从而确保分布式ID
的全局唯一性。
上述这个过程便是雪花算法的实现原理,基本上能够确保任何时刻的ID
不会出现重复,而且是基于时间戳+自增序列实现的原因,因此也能够确保ID
呈现趋势增长,从而避免索引树的频繁分裂。
2.4.2、雪花算法的时钟回拨问题
雪花算法的时钟回拨问题,是指在使用雪花算法生成全局唯一ID
的过程中,由于系统时钟被调整到过去某个时间点而导致的问题。因为雪花算法依赖于系统时间戳来确保生成的ID
,其唯一性和递增性,一旦系统时钟发生回拨,就可能生成的ID
重复和乱序。
那为什么系统时钟会被回拨到过去呢?主要有下述几点原有:
- 系统时钟同步:在分布式系统中,如果某节点的系统时钟通过
NTP
(网络时间协议)等机制进行同步,一旦某个节点的时钟领先于标准时间,这时就会而被回拨矫正; - 手动调整系统时钟:系统管理员在进行系统维护时可能会手动调整系统时钟;
- 系统重启或故障:在某些情况下,系统重启或硬件故障可能导致系统时钟出现错误。
综上,一旦系统时钟被回拨到过去,系统时间比最后一次生成ID
的时间还早,雪花生成算法依赖的时间戳就会重复,从而引发ID
重复或生成比上一次还小的ID
,而前者则是系统不可容忍的致命错误。
既然有问题,那就一定有解决方案,对于时钟回拨问题,通常有几种解决策略:
- 阻塞等待:在检测到时钟回拨时,阻塞
ID
生成过程,直到系统时间追上最后一次记录的时间; - 使用备用时间戳:维护一个逻辑时钟,当检测到物理时钟回拨时,使用逻辑时钟(例如最后一次生成ID的时间加一毫秒)作为时间戳;
- 异常处理:在发现时钟回拨时,直接抛出异常,停止ID生成,避免重复的
ID
导致数据冲突; - 设计容错机制:增加自增序号的长度,以提供更大的回拨容忍空间,尽量减少回拨带来的影响。
总之,解决时钟回拨对雪花算法带来的影响,其实存在多种策略,大家可以根据自身系统要求和业务场景,选用最适合自己的方案即可。
2.4.3、Java版雪花算法实现
好了,前面大概知道了雪花算法的构成和原理,那么在Java
中如何实现呢?如下:
/*
* 雪花算法实现类
* */
public class Snowflake implements Serializable {
private static final long serialVersionUID = 1L;
// 雪花算法的起始时间纪元
public static long DEFAULT_TWEPOCH = 1288834974657L;
public static long DEFAULT_TIME_OFFSET = 2000L;
// 机器标识所占的位数
private static final long WORKER_ID_BITS = 5L;
// 数据中心标识所占的位数
private static final long DATA_CENTER_ID_BITS = 5L;
// 毫秒内的自增位数
private static final long SEQUENCE_BITS = 12L;
// 机器ID最大值
private static final long MAX_WORKER_ID = 31L;
// 数据中心ID最大值
private static final long MAX_DATA_CENTER_ID = 31L;
// 机器ID左移12位
private static final long WORKER_ID_SHIFT = 12L;
// 数据中心左移17位
private static final long DATA_CENTER_ID_SHIFT = 17L;
// 毫秒时间戳左移22位
private static final long TIMESTAMP_LEFT_SHIFT = 22L;
private static final long SEQUENCE_MASK = 4095L;
// 雪花ID相关组成部分的定义
private final long twepoch;
private final long workerId;
private final long dataCenterId;
private final boolean useSystemClock;
private final long timeOffset;
private final long randomSequenceLimit;
private long sequence;
// 最近一次生产ID的时间戳
private long lastTimestamp;
public Snowflake() {
this(IdUtil.getWorkerId(IdUtil.getDataCenterId(31L), 31L));
}
public Snowflake(long workerId) {
this(workerId, IdUtil.getDataCenterId(31L));
}
public Snowflake(long workerId, long dataCenterId) {
this(workerId, dataCenterId, false);
}
public Snowflake(long workerId, long dataCenterId, boolean isUseSystemClock) {
this((Date)null, workerId, dataCenterId, isUseSystemClock);
}
public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock) {
this(epochDate, workerId, dataCenterId, isUseSystemClock, DEFAULT_TIME_OFFSET);
}
public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock, long timeOffset) {
this(epochDate, workerId, dataCenterId, isUseSystemClock, timeOffset, 0L);
}
public Snowflake(Date epochDate, long workerId, long dataCenterId, boolean isUseSystemClock, long timeOffset, long randomSequenceLimit) {
this.sequence = 0L;
this.lastTimestamp = -1L;
this.twepoch = null != epochDate ? epochDate.getTime() : DEFAULT_TWEPOCH;
this.workerId = Assert.checkBetween(workerId, 0L, 31L);
this.dataCenterId = Assert.checkBetween(dataCenterId, 0L, 31L);
this.useSystemClock = isUseSystemClock;
this.timeOffset = timeOffset;
this.randomSequenceLimit = Assert.checkBetween(randomSequenceLimit, 0L, 4095L);
}
public long getWorkerId(long id) {
return id >> 12 & 31L;
}
public long getDataCenterId(long id) {
return id >> 17 & 31L;
}
public long getGenerateDateTime(long id) {
return (id >> 22 & 2199023255551L) + this.twepoch;
}
public synchronized long nextId() {
long timestamp = this.genTime();
// 解决时钟回拨问题
if (timestamp < this.lastTimestamp) {
if (this.lastTimestamp - timestamp >= this.timeOffset) {
throw new IllegalStateException(StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", new Object[]{this.lastTimestamp - timestamp}));
}
timestamp = this.lastTimestamp;
}
if (timestamp == this.lastTimestamp) {
long sequence = this.sequence + 1L & 4095L;
if (sequence == 0L) {
timestamp = this.tilNextMillis(this.lastTimestamp);
}
this.sequence = sequence;
} else if (this.randomSequenceLimit > 1L) {
this.sequence = RandomUtil.randomLong(this.randomSequenceLimit);
} else {
this.sequence = 0L;
}
this.lastTimestamp = timestamp;
return timestamp - this.twepoch << 22 | this.dataCenterId << 17 | this.workerId << 12 | this.sequence;
}
public String nextIdStr() {
return Long.toString(this.nextId());
}
private long tilNextMillis(long lastTimestamp) {
long timestamp;
for(timestamp = this.genTime(); timestamp == lastTimestamp; timestamp = this.genTime()) {
}
if (timestamp < lastTimestamp) {
throw new IllegalStateException(StrUtil.format("Clock moved backwards. Refusing to generate id for {}ms", new Object[]{lastTimestamp - timestamp}));
} else {
return timestamp;
}
}
private long genTime() {
return this.useSystemClock ? SystemClock.now() : System.currentTimeMillis();
}
}
好了,看到这个雪花算法的实现,其实这个代码并不是我写的,哪儿来的呢?我随手从Hutool
里复制的,毕竟现在许多开源类库就自带了雪花算法的实现,不止Hutool
,包括MyBatis-Plus、Sharing-JDBC……
都有默认的雪花算法实现,咱们也没必要费牛劲去自己手撸一个,使用方式如下:
// 将工作机器标识指定为1,数据中心标识指定为2
Snowflake snowflake = new Snowflake(1, 2);
long snowflakeId = snowflake.nextId();
System.out.println(snowflakeId);
不过一般不会这么直接使用,而是配置成全局的Bean
,通过SQL
拦截器在数据插入时自动注入雪花ID
。
由于原始版雪花算法存在的问题,出现了许多改良版算法,但其核心还是前面聊到的雪花算法,比如百度的Uid-Generator算法、美团的Leaf算法-雪花模式、阿里的Seata框架里的雪花算法实现等等,感兴趣的小伙伴可以自行翻阅源码。
2.5、号段模式
号段(Segment
)模式是一种预分配ID
的策略,特别适用于需要大量、快速生成唯一ID
的场景。这种模式在获取ID
时,会预分配一定范围的ID
号段给各个业务服务,各节点在拿到号段后,可以先缓存在自身内存中,后续可以直接在本地生成ID
。只有当号段用尽时,再向分布式ID
组件申请新的号段。
那么该如何实现号段模式呢?整体的流程如下:
- 中心存储:设置一个中心化的
ID
生成服务,基于数据库或其他组件管理和分配ID
号段; - 号段分配:
ID
生成服务按照一定规则(如每次固定数量)分配号段给请求的业务节点; - 本地生成ID:业务节点在获得一个号段后,可以独立地在此号段内生成
ID
,直到号段用尽; - 号段耗尽:一旦本地号段接近耗尽,业务节点可以提前向
ID
生成服务请求下一个号段。
这种模式相较于之前的分布式ID
生成方案,所具备的优势如下:
- 高性能:由于
ID
生成操作在本地完成,减少了网络请求,提升了ID
生成的效率; - 可用性高:即使短暂的与
ID
生成服务断开通信,业务节点仍可继续已有号段,增强了容错性; - 易于维护:
ID
生成服务只负责号段的管理和分配,逻辑相对简单,易于维护和扩展。
了解这些优势之后,那么再来看看号段模式的劣势:
- 号段分配依赖于
ID
生成服务,一旦已有号段用尽,此服务故障就会导致无法生成新ID
; - 号段范围难以评估,太大会造成
ID
不连贯出现间隙,太小则会引发频率请求新号段; - 号段最终依赖第三方组件存储,例如数据库,除开要保障服务自身高可用,还需关心外部组件。
所以,这些问题都是在实现号段模式的过程中,需要考虑的细节问题。当然,号段模式如何实现呢?同样可以直接参考优秀的开源方案,如:
好了,如果你数据量增长格外迅猛,并且对ID
生成有着极高的追求,那么就可以选择这种号段模式,但对于一般的系统来说,改良后的雪花算法就足以使用啦~
三、分布式ID总结
综上所述,我们将市面上主流的分布式ID生成方案都进行了讲述,每种方案各有优缺点,有的无序、有的性能差、有的风险性高、有的存在特殊问题,实际中我们该如何抉择呢?大家可以结合具体的业务场景及需求来权衡,选择一种最适合自身系统的方案。
OK,分布式ID
只是分布式系统中存在的一个问题,掌握本篇的知识,也相当于把这个坎越了过去,可开头就讲过,使用分布式架构需要面临的问题可谓无穷无尽。越过一个坎,翻过一座山,会发现后面还有更多的技术难关等待你去攻克,不过在如今这个分布式、微服务生态繁荣的时代,大多数问题早已有了成熟方案可以开箱即用,具体的内容就在后续篇章进行展开啦~
所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~