
如果你跟着我写的“分布式ID生成方案”系列走到这一篇估计已经对号段、Redis、雪花这些主流手段有概念了。今天聊的是百度开源的UidGenerator重点不是“跑个Demo”而是我最近在一个SpringBoot2.X项目里的完整落地过程从为什么要选它、底层是怎么设计的到建表、依赖、配置、Bean装配、压测调参再到我实际踩进去的五个坑。这篇偏实战代码和配置可以直接抄但更希望你能看完原理部分这样后面调参时不会两眼一抹黑。UidGenerator在分布式ID圈子里属于“细粒度可调”的那一类同样基于雪花算法的位分配思路但把时间、机器、序号三个部分的位数全部开放出来还引入了一个内存RingBuffer做预生成。如果你所在的系统已经上了SpringBoot2.X又不想自己维护一套雪花算法里的时钟回拨逻辑这个方案值得认真看。1. 为什么把UidGenerator放进我的选型清单1.1 市面上方案那么多我关心的是什么分布式ID的方案我在前面文章里零零散散写过数据库自增ID简单但性能卡在单点写库上号段模式解决了数据库压力但需要额外维护号段表Redis的INCR快可一旦Redis重启持久化配置没做好就全乱普通雪花算法实现简单真到生产里要命的是时钟回拨、机器位分配、位数固定这几个问题。我这次面对的是一个内部订单中心项目SpringBoot2.XMySQL主从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位。默认分配规则是timeBits28、workerBits22、seqBits13三者之和正好63位。这里有个数学题你一定要自己算一遍timeBits的单位是秒28位时间戳能表示的最大值是2^28秒大概8.5年。如果你把epochStr设置成2023年1月1日那这个方案默认配置下只能用约8年多。时间一旦超出ID中的时间戳部分就会溢出。所以大厂里真正生产用的配置通常会把时间戳位数往上调比如29位或30位可用年限就变成17年、34年。但时间位调大了留给worker位和序列位的空间就会变小。比如timeBits30、workerBits22、seqBits11三者之和是63序列号只有2048个单节点每秒最多生成2048个ID一旦业务QPS超过这个数就会出现获取ID阻塞。这就是UidGenerator里的“位数分配本质上是资源权衡”不是越大越好。在实际业务中我一般先算清楚两件事一是这个系统打算用几年二是单个节点峰值每秒产生多少ID。对应到参数上时间位决定寿命序列位决定单节点并发上限worker位决定集群规模上限。三者加起来等于63一次算好别到上线后才发现某个维度的瓶颈。2.2 RingBuffer如何做到“预生产、零等待”UidGenerator性能强靠的不是每次都现场算ID而是内存里的RingBuffer提前把未来一段时间内的ID都生成好放在一个环形数组里。业务调用getUID()的时候只是从数组里取一个现成的值省去了每次计算的时间。RingBuffer默认的大小是2^seqBits也就是默认8192个槽位。如果每个时间单位秒恰好用完8192个IDRingBuffer会被填满如果某个时刻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支持。表结构是官方定好的直接执行下面这段DDLDROP 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) ) COMMENTDB 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 groupIdcom.baidu.fsg/groupId artifactIduid-generator/artifactId version1.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 version1.0 encodingUTF-8? beans xmlnshttp://www.springframework.org/schema/beans xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd bean iddisposableWorkerIdAssigner classcom.baidu.fsg.uid.worker.DisposableWorkerIdAssigner / bean idcachedUidGenerator classcom.baidu.fsg.uid.impl.CachedUidGenerator property nameworkerIdAssigner refdisposableWorkerIdAssigner / property nametimeBits value28 / property nameworkerBits value22 / property nameseqBits value13 / property nameepochStr value2023-01-01 / property nameboostPower value3 / /bean /beans然后在SpringBoot启动类或者配置类上导入这个XMLimport org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportResource; Configuration ImportResource(locations {classpath:uid-generator.xml}) public class UidGeneratorConfig { }注意timeBits、workerBits、seqBits这里用的是我最终调好的生产参数timeBits28意味着从epochStr开始只能覆盖约8.5年。如果你希望生命周期再长一点建议把timeBits上调到29同时把workerBits下调到21seqBits保持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在同一个秒内最多能生成多少个ID13位是8192个12位是4096个11位是2048个。如果目标业务单点峰值在每秒5000个ID左右那seqBits至少要有13位。此时剩余空间是50位分配成timeBits28、workerBits22是最常用的组合。如果这个系统要跑十五年timeBits就必须给到29workerBits变成21也是够的。我建议不要轻易压缩seqBits因为序列号位数直接决定单节点瞬时最大吞吐压缩到12位以下后高峰期很容易触发“获取ID等待”。4.3 boostPower和RingBuffer容量调整的边界boostPower是UidGenerator里一个很容易被忽视的参数。它控制RingBuffer扩容时一次扩展的倍数。默认值是2我最终调成了3。为什么这么调因为在压测时我发现默认配置下如果QPS突然暴涨RingBuffer会被消耗到很低的剩余量然后一次性填充较大批量这个填充过程有几十毫秒的停顿表现为接口耗时毛刺。调高boostPower后缓存预生成量更充足触底填充的频率明显降低。但boostPower不能无脑调大它本质是拿内存和GC换性能。RingBuffer里存的是long数组每个ID占8字节boostPower3时缓存容量是默认的数倍对象会常驻内存年轻代GC压力会变大。如果你的服务堆内存只有2G建议谨慎。我最后用的参数是timeBits28、workerBits22、seqBits13、boostPower3。这是结合系统吞吐目标和内存预算之后的折中结果。如果你的系统内存充足、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频率才定下boostPower3。5.5 时钟漂移是隐患不是家常便饭最后聊一下时钟回拨。官方文档说UidGenerator不直接依赖系统时钟做严格递增判断因为它的时间戳部分来自当前秒序列号部分来自RingBuffer。在秒级别内即使系统时钟发生微小漂移RingBuffer里已经预生成的ID仍能维持一段时间。但极端情况下如果宿主机时钟回拨超过一个时间单位就可能出现新生成的ID时间戳小于之前消费过的ID破坏单调递增性。我在生产环境对数据库、缓存、ID生成器所在的宿主机会统一做NTP时钟同步并加了监控如果系统时间发生大于500毫秒的回拨立刻告警。这个阈值是我结合RingBuffer缓存深度算出来的不同配置的容忍度不一样你可以根据缓存秒数和实际业务对ID有序性的要求自定义。好消息是UidGenerator的场景下短时间回拨不会立刻出问题但你一定要监控不要等到侥幸。最后分享我这两年做ID选型的一点体会分布式ID方案没有“用到底”一说UidGenerator适合的是那些想要开箱即用、又希望底层参数可控的团队。它在默认配置下就能跑得不错但真正让它跟业务匹配的是你花时间理解位分配和RingBuffer原理之后做的那几次调整。如果你也正在SpringBoot2.X项目里集成这个方案建议按我的步骤先搭一个最小可运行闭环再拿真实压测数据去调参别一上来就追求高吞吐。