如果你跟着我写的“分布式ID生成方案”系列走到这一篇,估计已经对号段、Redis、雪花这些主流手段有概念了。今天聊的是百度开源的UidGenerator,重点不是“跑个Demo”,而是我最近在一个SpringBoot2.X项目里的完整落地过程:从为什么要选它、底层是怎么设计的,到建表、依赖、配置、Bean装配、压测调参,再到我实际踩进去的五个坑。这篇偏实战,代码和配置可以直接抄,但更希望你能看完原理部分,这样后面调参时不会两眼一抹黑。
UidGenerator在分布式ID圈子里属于“细粒度可调”的那一类:同样基于雪花算法的位分配思路,但把时间、机器、序号三个部分的位数全部开放出来,还引入了一个内存RingBuffer做预生成。如果你所在的系统已经上了SpringBoot2.X,又不想自己维护一套雪花算法里的时钟回拨逻辑,这个方案值得认真看。
1. 为什么把UidGenerator放进我的选型清单
1.1 市面上方案那么多,我关心的是什么
分布式ID的方案我在前面文章里零零散散写过:数据库自增ID简单,但性能卡在单点写库上;号段模式解决了数据库压力,但需要额外维护号段表;Redis的INCR快,可一旦Redis重启,持久化配置没做好就全乱;普通雪花算法实现简单,真到生产里要命的是时钟回拨、机器位分配、位数固定这几个问题。
我这次面对的是一个内部订单中心项目,SpringBoot2.X,MySQL主从,K8s里部署多个Pod,上线后预计每秒订单创建峰值在几千到几万之间。单看QPS,其实普通雪花算法也能扛,但我一直有个原则:在选型阶段就把“后续扩展时最容易出问题的点”提前堵住。时钟回拨、workerId重复、位数写死导致机器规模受限,这三个问题恰恰是自研雪花最容易翻车的地方。
UidGenerator进入视野,是因为它在官方文档里直接把“时钟回拨”和“workerId管理”当成了设计目标来写,而不是靠使用者自己去补丁。它默认用数据库表分配workerId,每个实例启动时去申请一个唯一编号;ID的位分配不是写死在代码里的,而是通过配置项决定。这两点正好命中我上面说的痛点。
1.2 UidGenerator在场景里的实际位置
如果你看过这个系列前面的方案,其实可以把它们理解成不同场景下的工具组合。UidGenerator更适合那种“单机QPS要求高、部署实例数可能动态变化、又不希望业务代码感知太多ID生成细节”的服务。
这次我的目标很明确:订单号、支付流水号、退款单号这些核心业务ID,都要求全局唯一且趋势递增。UidGenerator生成的ID是63位整型,结构上最高位固定为0,后续位依次是时间戳、workerId、序列号,所以它天然就是单调递增的。配合数据库存储使用long类型,索引性能也很好。
它不适合的场景也要说清楚:如果你需要“订单号里直接带上业务含义”,比如包含业务线编码、机房编号,那这种方案给你留下的位数空间有限,需要自己重新分配位段;如果你用的是非K8s的纯物理机集群,而且机器规模常年只有个位数,那选UidGenerator属于杀鸡用牛刀,简单雪花就能解决。选型这东西,从来不是找最好的,而是找最不拧巴的。
1.3 一句话讲清它与普通雪花策略的分工
普通雪花算法通常固定为“41位时间戳+10位机器号+12位序列号”,生成的ID总长度63位。UidGenerator在这套思路上的改进,概括成一句话就是:把位分配从“写死”变成“可配置”,再通过内存缓存把生成性能拉高几个量级。
具体来说,默认配置下它可以跑成28位时间戳(按秒)、22位workerId、13位序列号。22位workerId意味着支持四百万级别的实例编号,这个规模对绝大多数互联网业务绰绰有余;13位序列号意味着单个worker节点在同一个时间单位内可以生成8192个ID。如果QPS超过这个值,就需要调整seqBits或者依赖RingBuffer的多槽位缓存。
2. UidGenerator生成ID的底层逻辑:把时间、机器、序号切成三段
2.1 三个字段的位分配与可用时长/容量计算
UidGenerator生成的ID是一个long型,Java的long是64位,最高位为符号位固定为0,所以实际可分配的只有63位。默认分配规则是timeBits=28、workerBits=22、seqBits=13,三者之和正好63位。
这里有个数学题你一定要自己算一遍:timeBits的单位是秒,28位时间戳能表示的最大值是2^28秒,大概8.5年。如果你把epochStr设置成2023年1月1日,那这个方案默认配置下只能用约8年多。时间一旦超出,ID中的时间戳部分就会溢出。所以大厂里真正生产用的配置,通常会把时间戳位数往上调,比如29位或30位,可用年限就变成17年、34年。
但时间位调大了,留给worker位和序列位的空间就会变小。比如timeBits=30、workerBits=22、seqBits=11,三者之和是63,序列号只有2048个,单节点每秒最多生成2048个ID,一旦业务QPS超过这个数,就会出现获取ID阻塞。这就是UidGenerator里的“位数分配本质上是资源权衡”,不是越大越好。
在实际业务中,我一般先算清楚两件事:一是这个系统打算用几年,二是单个节点峰值每秒产生多少ID。对应到参数上,时间位决定寿命,序列位决定单节点并发上限,worker位决定集群规模上限。三者加起来等于63,一次算好,别到上线后才发现某个维度的瓶颈。
2.2 RingBuffer如何做到“预生产、零等待”
UidGenerator性能强,靠的不是每次都现场算ID,而是内存里的RingBuffer提前把未来一段时间内的ID都生成好,放在一个环形数组里。业务调用getUID()的时候,只是从数组里取一个现成的值,省去了每次计算的时间。
RingBuffer默认的大小是2^seqBits,也就是默认8192个槽位。如果每个时间单位(秒)恰好用完8192个ID,RingBuffer会被填满;如果某个时刻QPS很低,ID消耗慢,RingBuffer里的ID就会堆着;如果QPS突然上涨,消费速度快,生成器会触发同步填充,把后面几个时间单位的ID一起补进来。
这里有个重要概念叫boostPower,默认是2,官方定义是“RingBuffer扩容因子”。它的意思是,当需要扩容时,缓存不是一格一格往上加,而是以2的幂次往上涨。如果你把boostPower设为3,缓存容量就变成8192乘以3的某个倍数关系,实际填满比例达到阈值后会一次性扩展。调大boostPower可以减少高峰期重新填充的频率,但代价是内存占用和首次构建时的GC压力。
理解了这个机制,你就能解释一个现象:为什么UidGenerator在低QPS时表现一般,但高QPS时吞吐反而很稳。因为它的核心是用空间换时间,把后续几秒的ID都预先算好了放在内存里。
2.3 workerId如何从数据库里“一次申请、多处复用”
UidGenerator默认提供的workerId分配器叫DisposableWorkerIdAssigner,它的做法是往WORKER_NODE表里插入一条记录,带上当前实例的host、端口、类型、启动日期,然后利用数据库自增主键拿到ID,这个ID就作为workerId使用。
这个设计有两个隐含好处。第一,实例重启后,如果host、端口、类型没变,分配器会先去表里查一把,发现已经存在这条记录,就直接复用原来的workerId,不会因为重启导致workerId漂移。第二,多个实例同时启动时,由于主键自增是原子的,不会产生两个一模一样的workerId。
但代价也显而易见:每次实例冷启动都需要访问一次数据库。如果数据库连接池没配好、网络抖动,启动阶段就可能失败。另外,如果你把实例部署在容器里,而容器的host或端口每次都随机变化,那么WORKER_NODE表里会不断插入新记录,老记录又不会被自动清理,时间长了表会越来越大。关于这个问题怎么解,我放到最后面的踩坑部分详细讲。
3. SpringBoot2.X集成UidGenerator的实操记录
3.1 第一步:建好WORKER_NODE表,别指望框架自动建表
很多第一次接触UidGenerator的人,拿到代码后在本地跑不起来,原因不是依赖不对,而是忘了建表。UidGenerator本身不负责自动建表,默认的workerId分配器需要数据库表WORKER_NODE支持。表结构是官方定好的,直接执行下面这段DDL:
DROP TABLE IF EXISTS WORKER_NODE; CREATE TABLE WORKER_NODE ( ID BIGINT NOT NULL AUTO_INCREMENT COMMENT 'auto increment id', 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 TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'modified time', CREATED TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'created time', PRIMARY KEY (ID) ) COMMENT='DB WorkerID Assigner for UID Generator';我这里用的是MySQL语法,你如果是PostgreSQL或者其他数据库,需要转换成对应方言。字段中的HOST_NAME和PORT是分配workerId时的身份依据,TYPE字段用来区分物理机和容器。线上环境我建议把这张表放到独立的配置库里,和业务表隔离开,避免建表权限和业务变更混在一起。
3.2 第二步:源码依赖与构建,注意没有中央仓库坐标
UidGenerator并没有把发布包推到Maven中央仓库,所以你不能在pom.xml里直接写一个坐标就完事。常规做法是先从官方仓库把源码拉到本地,然后执行构建命令安装到本地或公司私服。
git clone <uid-generator源码仓库地址> cd uid-generator mvn install -DskipTests构建完成后,在你自己的SpringBoot2.X项目里引入依赖:
<dependency> <groupId>com.baidu.fsg</groupId> <artifactId>uid-generator</artifactId> <version>1.0.0-SNAPSHOT</version> </dependency>这里有个容易忽略的细节:uid-generator的官方依赖里通常会带一套日志框架,如果你项目里用的是logback、log4j2,需要对比一下依赖树,把多余的冲突排除掉。我这次就碰上过因为日志依赖版本不一致,启动时一直报“SLF4J绑定多个实现”警告,虽然当时不影响功能,但排查其他问题时干扰视线。建议在引入依赖后用mvn dependency:tree检查一遍。
3.3 第三步:写uid-generator.xml并挂到Spring容器
UidGenerator官方推荐的方式是通过Spring XML配置或者Java Config来装配Bean。我一开始试图直接用注解写@Bean,发现很多内部setter并不是完全开放的,最后还是回归到官方xml方式,反而最省心。
在src/main/resources下新建uid-generator.xml:
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd"> <bean id="disposableWorkerIdAssigner" class="com.baidu.fsg.uid.worker.DisposableWorkerIdAssigner" /> <bean id="cachedUidGenerator" class="com.baidu.fsg.uid.impl.CachedUidGenerator"> <property name="workerIdAssigner" ref="disposableWorkerIdAssigner" /> <property name="timeBits" value="28" /> <property name="workerBits" value="22" /> <property name="seqBits" value="13" /> <property name="epochStr" value="2023-01-01" /> <property name="boostPower" value="3" /> </bean> </beans>然后在SpringBoot启动类或者配置类上导入这个XML:
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportResource; @Configuration @ImportResource(locations = {"classpath:uid-generator.xml"}) public class UidGeneratorConfig { }注意,timeBits、workerBits、seqBits这里用的是我最终调好的生产参数:timeBits=28,意味着从epochStr开始只能覆盖约8.5年。如果你希望生命周期再长一点,建议把timeBits上调到29,同时把workerBits下调到21,seqBits保持13不变。这个取舍直接影响ID结构,后面压测部分我会专门算给你看。
3.4 第四步:在业务模块里注入生成器并调用
Bean装配好之后,使用非常简单,注入IdGenerator接口,调用getUID()即可。建议抽出独立的service层,不要散落在业务代码里:
import com.baidu.fsg.uid.IdGenerator; import org.springframework.stereotype.Service; import javax.annotation.Resource; @Service public class OrderIdService { @Resource(name = "cachedUidGenerator") private IdGenerator uidGenerator; public long generateOrderId() { long uid = uidGenerator.getUID(); return uid; } }这里有一个必须提醒的细节:UidGenerator生成的ID是long类型,长度轻松超过2^53,而JavaScript的Number类型能精确表达的最大整数就是2^53。如果你们的订单号要传给前端,或者通过JSON接口返回,直接放long字段会有精度丢失风险。解决方式有两个:一是接口层的DTO里把ID定义成String,拿到long值后转成字符串再返回;二是给前端的字段本来就是String类型,不要试图用JS去精确处理这个数字。
4. 压测数据与参数调整:不是默认配置就能直接上生产
4.1 用模拟订单系统跑出来的数据
我这次在测试环境搭了一个8核16G的模拟订单中心,SpringBoot2.X版本,MySQL放在同一台机器的Docker里,网络开销基本忽略。用JMeter和内部压测脚本分别打了一轮,16个线程并发循环调用生成接口,单机短时吞吐率稳定在每秒三十多万个ID。对比普通雪花算法自研实现(单线程逐位运算)大概每秒几万的水平,UidGenerator的“预生成+内存取号”优势确实明显。
但你要看清楚这个场景:压测时的QPS已经远超RingBuffer默认8192个槽位的单秒生成能力,它是靠缓存了后续多个时间单位的ID才撑住峰值的。这意味着,如果你的业务本身QPS一直很高,RingBuffer的扩容参数直接影响你是否会出现短期阻塞。
压测中我还观察到一个现象:系统启动后第一次调用getUID()耗时会比后续调用高不少。因为初始化阶段RingBuffer是空的,第一次获取ID时触发了填充动作。这个冷启动延迟虽然只有几毫秒,但在线上如果恰好遇到压测尖峰启动,会表现为“接口第一笔超时”。解决办法是应用启动完成后主动调一次getUID()做预热。
4.2 timeBits、workerBits、seqBits的取舍计算
调参之前,我先把公式摆出来:timeBits + workerBits + seqBits = 63。任意一个维度变大,其他两个就要变小。
时间位常用配置大概是28位、29位、30位。28位秒级时间戳的数值范围是2^28秒,约8.5年;29位是17年;30位是34年。workerBits决定集群理论上最多能容纳多少实例,22位是419万个,21位是209万个,对通常的微服务集群来说完全够用。seqBits决定单个worker在同一个秒内最多能生成多少个ID,13位是8192个,12位是4096个,11位是2048个。
如果目标业务单点峰值在每秒5000个ID左右,那seqBits至少要有13位。此时剩余空间是50位,分配成timeBits=28、workerBits=22是最常用的组合。如果这个系统要跑十五年,timeBits就必须给到29,workerBits变成21也是够的。我建议不要轻易压缩seqBits,因为序列号位数直接决定单节点瞬时最大吞吐,压缩到12位以下后,高峰期很容易触发“获取ID等待”。
4.3 boostPower和RingBuffer容量调整的边界
boostPower是UidGenerator里一个很容易被忽视的参数。它控制RingBuffer扩容时一次扩展的倍数。默认值是2,我最终调成了3。
为什么这么调?因为在压测时我发现,默认配置下如果QPS突然暴涨,RingBuffer会被消耗到很低的剩余量,然后一次性填充较大批量,这个填充过程有几十毫秒的停顿,表现为接口耗时毛刺。调高boostPower后,缓存预生成量更充足,触底填充的频率明显降低。但boostPower不能无脑调大,它本质是拿内存和GC换性能。RingBuffer里存的是long数组,每个ID占8字节,boostPower=3时缓存容量是默认的数倍,对象会常驻内存,年轻代GC压力会变大。如果你的服务堆内存只有2G,建议谨慎。
我最后用的参数是timeBits=28、workerBits=22、seqBits=13、boostPower=3。这是结合系统吞吐目标和内存预算之后的折中结果。如果你的系统内存充足、QPS又极高,可以尝试把boostPower继续往上调,但一定要用压测数据说话,不要一拍脑袋。
5. 集成过程中遇到的五个坑,按排查顺序记录
5.1 启动即报错:workerIdAssigner没有实例
第一次启动,控制台直接抛出异常,提示找不到workerIdAssigner。我检查了uid-generator.xml,明明已经配置了disposableWorkerIdAssigner,后来才发现是@ImportResource的路径写错了。我把xml文件放到了src/main/java下的某个包里,系统资源路径自然扫描不到,classpath下只有默认的resources目录会被识别。
解决方案是把uid-generator.xml放到src/main/resources根目录下,或者放到META-INF目录里,然后重启。还有一个低级错误:SpringBoot的启动类上如果同时加了@SpringBootApplication和@Configuration两种注解,@ImportResource加错了位置,也会导致xml根本没有被加载。这个坑排查起来的特征就是“Bean定义存在但容器里没有”。
5.2 Long传到前端变成尾数不对
这个坑不是UidGenerator特有的,但它的ID长度让问题更容易暴露。上线前联调时,前端同事反馈订单号最后几位总是变成0。我当时第一反应是后端生成错了,后来排查发现是JSON序列化把long变成了JS Number,精度丢失。
解决方案我在前面提过:对外接口统一把long类型转成String返回。如果你的项目用了Fastjson或Jackson,可以通过配置全局把Long序列化成String;但更稳妥的是在DTO上直接定义String类型字段,业务层手动toString。这会牺牲一点内存,但能彻底避免前端精度问题。另外,如果订单号会作为Map的key传给前端,更要小心,Map key是Long类型时同样有精度问题。
5.3 容器多实例模式下workerId重复
测试环境一切正常,上线到K8s后偶尔出现ID重复。排查后发现是容器网络模式下,每个Pod对外的HOST_NAME看起来一样,但实例端口是动态分配的,DisposableWorkerIdAssigner在判断是否复用workerId时,会结合host和port一起查,端口一变就认为是新节点,于是表里新增记录,自增ID自然也不一样。
问题在于,有几次Pod重启后,分配的端口可能跟之前某条记录一模一样,导致查出了旧的workerId,与其他正在运行的实例发生冲突。这个场景下,UidGenerator默认的分配逻辑不够用了。我的处理方式是在每个Pod环境的WORKER_NODE记录里固定一个节点标识,或者改用自定义的WorkerIdAssigner,让workerId从环境变量里显式指定,而不是依赖数据库判断。自定义方案不复杂:实现WorkerIdAssigner接口,从Spring的Environment里读取一个配置项,甚至可以从Consul或Nacos注册中心获取分布式统一编号。
5.4 首次调用延迟与内存GC
我在压测环节提到过首次调用getUID会有额外耗时,实际线上还有一个变种:如果服务刚启动就到高峰流量,RingBuffer的前几次填充会有明显耗时,单看平均值不明显,但TP99会很难看。我在启动类里做了预热处理:
@Component public class UidGeneratorPreload { @Resource(name = "cachedUidGenerator") private IdGenerator uidGenerator; @PostConstruct public void preload() { for (int i = 0; i < 100; i++) { uidGenerator.getUID(); } } }顺便提一下GC问题:RingBuffer里的ID是常驻内存的,压测时堆内存明显有波动。如果你的服务是低堆内存部署,建议优先把boostPower调回默认值2,再根据实际吞吐决定要不要扩大。内存和性能的平衡点,每个系统不一样,我最后是结合线上监控里GC频率才定下boostPower=3。
5.5 时钟漂移是隐患不是家常便饭
最后聊一下时钟回拨。官方文档说UidGenerator不直接依赖系统时钟做严格递增判断,因为它的时间戳部分来自当前秒,序列号部分来自RingBuffer。在秒级别内,即使系统时钟发生微小漂移,RingBuffer里已经预生成的ID仍能维持一段时间。但极端情况下,如果宿主机时钟回拨超过一个时间单位,就可能出现新生成的ID时间戳小于之前消费过的ID,破坏单调递增性。
我在生产环境对数据库、缓存、ID生成器所在的宿主机会统一做NTP时钟同步,并加了监控:如果系统时间发生大于500毫秒的回拨,立刻告警。这个阈值是我结合RingBuffer缓存深度算出来的,不同配置的容忍度不一样,你可以根据缓存秒数和实际业务对ID有序性的要求自定义。好消息是,UidGenerator的场景下,短时间回拨不会立刻出问题,但你一定要监控,不要等到侥幸。
最后分享我这两年做ID选型的一点体会:分布式ID方案没有“用到底”一说,UidGenerator适合的是那些想要开箱即用、又希望底层参数可控的团队。它在默认配置下就能跑得不错,但真正让它跟业务匹配的,是你花时间理解位分配和RingBuffer原理之后做的那几次调整。如果你也正在SpringBoot2.X项目里集成这个方案,建议按我的步骤先搭一个最小可运行闭环,再拿真实压测数据去调参,别一上来就追求高吞吐。