(十一)漫谈分布式之分布式ID篇:UUID、雪花算法、ID生成器、号段模式尽收囊中!

2,800 阅读27分钟

本文为稀土掘金技术社区首发签约文章,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方案里的代表是UUIDUniversally 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);
}

接着来实现这个生成并返回全局IDgenAndGetGlobalId()方法,如下:

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生成器

MySQL

所谓数据库版的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

由于数据库来实现生成器存在性能问题,所以业界也提出了一种基于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存储下了怎么办呢?这实际上很简单,把存储IDLong类型改为容量更大的引用类型即可,也就是用更大的比特位来存放时间戳。

OK~,想明白上面的问题后,接着再聊聊分布式ID的重复问题,如果系统的并发较高,导致同一毫秒内需要生成多个ID怎么办呢?也就是时间戳位重复的情况下该怎么确保ID唯一性呢?其实在最后12bit上会存放一个顺序递增的序列值,212次幂为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,而前者则是系统不可容忍的致命错误。

既然有问题,那就一定有解决方案,对于时钟回拨问题,通常有几种解决策略:

  1. 阻塞等待:在检测到时钟回拨时,阻塞ID生成过程,直到系统时间追上最后一次记录的时间;
  2. 使用备用时间戳:维护一个逻辑时钟,当检测到物理时钟回拨时,使用逻辑时钟(例如最后一次生成ID的时间加一毫秒)作为时间戳;
  3. 异常处理:在发现时钟回拨时,直接抛出异常,停止ID生成,避免重复的ID导致数据冲突;
  4. 设计容错机制:增加自增序号的长度,以提供更大的回拨容忍空间,尽量减少回拨带来的影响。

总之,解决时钟回拨对雪花算法带来的影响,其实存在多种策略,大家可以根据自身系统要求和业务场景,选用最适合自己的方案即可。

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组件申请新的号段。

那么该如何实现号段模式呢?整体的流程如下:

  1. 中心存储:设置一个中心化的ID生成服务,基于数据库或其他组件管理和分配ID号段;
  2. 号段分配ID生成服务按照一定规则(如每次固定数量)分配号段给请求的业务节点;
  3. 本地生成ID:业务节点在获得一个号段后,可以独立地在此号段内生成ID,直到号段用尽;
  4. 号段耗尽:一旦本地号段接近耗尽,业务节点可以提前向ID生成服务请求下一个号段。

这种模式相较于之前的分布式ID生成方案,所具备的优势如下:

  • 高性能:由于ID生成操作在本地完成,减少了网络请求,提升了ID生成的效率;
  • 可用性高:即使短暂的与ID生成服务断开通信,业务节点仍可继续已有号段,增强了容错性;
  • 易于维护ID生成服务只负责号段的管理和分配,逻辑相对简单,易于维护和扩展。

了解这些优势之后,那么再来看看号段模式的劣势:

  • 号段分配依赖于ID生成服务,一旦已有号段用尽,此服务故障就会导致无法生成新ID
  • 号段范围难以评估,太大会造成ID不连贯出现间隙,太小则会引发频率请求新号段;
  • 号段最终依赖第三方组件存储,例如数据库,除开要保障服务自身高可用,还需关心外部组件。

所以,这些问题都是在实现号段模式的过程中,需要考虑的细节问题。当然,号段模式如何实现呢?同样可以直接参考优秀的开源方案,如:

  • Tinyid算法:滴滴开源的分布式ID算法,完全基于号段模式实现;
  • Leaf算法:美团开源的分布式ID算法,支持基于数据库的号段模式。

好了,如果你数据量增长格外迅猛,并且对ID生成有着极高的追求,那么就可以选择这种号段模式,但对于一般的系统来说,改良后的雪花算法就足以使用啦~

三、分布式ID总结

综上所述,我们将市面上主流的分布式ID生成方案都进行了讲述,每种方案各有优缺点,有的无序、有的性能差、有的风险性高、有的存在特殊问题,实际中我们该如何抉择呢?大家可以结合具体的业务场景及需求来权衡,选择一种最适合自身系统的方案。

OK,分布式ID只是分布式系统中存在的一个问题,掌握本篇的知识,也相当于把这个坎越了过去,可开头就讲过,使用分布式架构需要面临的问题可谓无穷无尽。越过一个坎,翻过一座山,会发现后面还有更多的技术难关等待你去攻克,不过在如今这个分布式、微服务生态繁荣的时代,大多数问题早已有了成熟方案可以开箱即用,具体的内容就在后续篇章进行展开啦~

所有文章已开始陆续同步至微信公众号:竹子爱熊猫,想在手机上便捷阅读的小伙伴可搜索关注~