聊到分布式ID,很多团队的第一反应就是自己撸一个雪花算法变体。早期我也这么干过,但后来发现:真正写起来容易,后面要处理时钟回拨、workerId分配、机器上线下线、高并发下的性能瓶颈,全是坑。百度UidGenerator是业界最早一批把雪花算法做成可配置、带缓存预生成、带workerId自动分配的开源方案,在SpringBoot2.X下集成顺手,不用自己造轮子,也避开了大部分常见坑。这篇主要聊它的原理、参数设计和完整集成步骤,适合正在做基础组件选型、或者已经踩过自研雪花算法坑的开发同学,直接照着搭一套自己的分布式ID服务。
1. 为什么折腾一圈又选回百度UidGenerator
1.1 分布式ID方案现状盘点
在聊UidGenerator之前,先把市面上常见的方案摊开看一看。UUID最简单,但生成出来的ID没有业务含义,长度大,在MySQL里做InnoDB主键还会因为随机性引发页分裂,性能其实是负优化。数据库自增ID简单可靠,但一旦分库分表,多实例各自维护自增位置,要用步长或者号段才能做,还需要额外的分配表维护,后续扩容时要重新调整步长,麻烦。Redis自增解决了一部分问题,但Redis一挂,整套ID生成就跟着挂,而且要想在Redis里生成高效ID,还得用Lua脚本,维护成本也不低。
真正在互联网业务里用得多的,还是雪花算法这一族。它的核心思想很简单:用一个long型64位整数,分成时间戳段、机器ID段、序列号段,各段按位组合,拼出一个趋势递增、全局唯一的ID。本地生成、无网络依赖、性能高,这是它的最大优点。但原生雪花算法暴露的问题也很集中:机器ID得手工分配,扩容时运维要记录谁用了哪个ID;时间被回拨时生成的ID可能重复;而且很多实现把位数写死,工作节点多或者单节点并发高的时候,表现并不是很好。百度UidGenerator本质上就是针对这几个问题做了一轮工程化改造。
1.2 百度UidGenerator到底省了什么
百度UidGenerator提供了两种实现,一个是DefaultUidGenerator,基于标准雪花算法的位运算方案;另一个是CachedUidGenerator,它用RingBuffer缓存预生成的ID,批量取号、异步填充,极大降低了对时间的实时依赖,也大幅提高了并发吞吐。它把时间戳、workerId、序列号三段位数都做成可配置,适配不同规模的业务;workerId可以通过数据库表自动分配,节点启动后自动注册一个记录取ID,不用人肉去维护IP到workerId的映射。对于大多数中小团队来说,这几个点正好就是自研时最头疼的部分。
我见过太多团队从GitHub拉一个雪花算法工具类往项目里一放,第一版跑得很开心,半年之后节点扩容,运维在Excel里手工分配workerId,某次部署漏了一台机器,两个节点用了同一个ID,半夜日志里出现重复主键,排查了半天。UidGenerator的数据库自动分配机制解决的正是这个问题,这也是我后来坚持用它的一个很重要原因。
1.3 技术选型边界:何时适合它
不是所有场景都适合UidGenerator。如果你只有单库单表,数据库自增主键就够用;如果系统刚起步,并发要求不高,也能接受性能问题,UUID也无妨。但如果你的系统已经走向微服务化,多个服务多个实例,需要一个全局唯一的、趋势递增的、可用性高的顺序ID,而且不想引入额外中间件,那UidGenerator就是性价比很高的选择,成本就是一张worker node表和一个Jar包依赖。另外需要注意,它是用数据库表做workerId分配的,如果没有任何数据库环境,这套方案就不是最优解,不如直接上Redis或者让各节点用静态配置。
2. 核心原理与参数拆解
2.1 位运算生成器:一段看懂的ID结构
先看DefaultUidGenerator。它把64位long型ID拆成三段,除去最高位符号位,剩下63位由三个配置共同组成:timeBits、workerBits、seqBits。三个值的总和必须是63,这是整道题的约束前提。假设我配置timeBits=29、workerBits=21、seqBits=13,合成后ID的二进制大致长这样:最高位0占一位,接下来29位是相对某个固定起始时间的毫秒偏移量,再接下来21位是workerId,最后13位是同一个毫秒内的自增序列号。
这样拆完,29位的时间戳能表示2的29次方毫秒,约536,870,912毫秒,折算约17.1年。21位的workerId能支撑2,097,152个节点,对绝大多数公司来说绰绰有余。13位序列号意味着单个节点单毫秒能生成8192个ID,换算成每秒是820万的量级,配合非缓存模式也已经远超高并发业务的日常需求。计算一下shift就知道为什么它灵活:workerIdShift等于seqBits,timestampShift等于workerBits加seqBits。生成的时候,先取相对时间戳,左移timestampShift位,再与workerId左移workerIdShift后的值按位或,最后把序列号按位或进低位。
使用起来很简单:
long id = uidGenerator.nextId();这种模式适合业务量稳定、并发不算特别极端的场景,最大的优点是生成速度极快,没有锁竞争,完全本机运算。
2.2 缓存生成器:RingBuffer是性能关键
CachedUidGenerator才是精华。它的实现思路是:不再每一次调用都现场计算一个ID,而是利用一个环形数组把未来一段时间内会使用到的ID预先生成好,塞进缓冲区,调用方直接从缓冲区里取,取完一个,后台或者下一次取号时发现剩余量不足,再批量补一批进去。
这个环形数组内部用了tail和cursor两个指针,tail指向当前写位置,cursor指向当前消费位置。初始化时它会根据当前配置和并发参数算出需要的槽位大小,比如容纳未来若干秒产生的ID,再向上对齐到2的幂,避免取模运算的性能损耗。每次消费一个ID,cursor就往前走一格;当剩余可用槽位跌到一个阈值,它就以更大的一批数量重新填充,这个扩容的动作由boostPower控制。假设boostPower=3,一次扩容会准备8倍的基础槽位数,减少填充频率。paddingFactor则是那个触发阈值,默认是50,表示缓冲区剩余不足50%就开始补充。
还有scheduleInterval这个定时器配置,单位是秒。它是个兜底机制,就算没有任何消费动作触发补货,定时器也会周期性检查并主动填充,防止某个时段突然大流量打过来时缓冲区见底。这背后还带来了一个隐藏收益:因为RingBuffer里装的都是预生成好的未来ID,时间戳都已经固定了,所以对微小时刻的时钟回拨有天然的容忍能力,回拨范围没超过预生成窗口,取到的还是缓冲区里那批不重复的ID,不会立刻爆出重复主键。
2.3 WorkerId自动分配与数据库表的角色
UidGenerator里的workerId,不是靠配置文件写死的。它内置了一个WorkerIdAssigner接口,官方提供的实现是依靠一张WORKER_NODE表完成自动注册。每台应用节点启动时,把当前主机的hostName、port、节点类型、启动日期等信息插入WORKER_NODE表,表里的自增主键ID,就作为该节点的workerId。因为主键全局递增,只要节点注册时不冲突,拿到的workerId自然全局唯一。
这张表很轻量,跟业务表完全隔离,一般单独放一个库也完全没问题。它的存在保证了三个比较重要的场景:一是新节点扩容,不需要人工分配workerId,启动即自动获取;二是节点重启,如果host和port都相同,可以复用原有的workerId记录,避免每次重启都在表里插一条,导致workerId不断膨胀;三是多机部署时,只要hostName加port能区分开,就不会有重复分配的风险。需要注意的是,如果整个系统没有数据库连接,这套分配机制就无从谈起。所以用它的前提,是应用本身已经有依赖数据库的基础设施。
3. SpringBoot2.X集成实操
3.1 Maven与数据库准备
我已经基于SpringBoot2.5版本实践过整套流程,先把依赖准备好。UidGenerator本身没有发布到中央仓库的所有版本,不同版本情况不一,稳妥做法是把源码clone下来自己构建,然后安装到本地Maven仓库:
mvn clean install -DskipTests如果你的工程能直接从中央仓库拉到对应坐标,就省去这一步。否则在项目pom里引入本地仓库产物:
<dependency> <groupId>com.baidu.fsg</groupId> <artifactId>uid-generator</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.1.4</version> </dependency>数据库准备很简单,前提是有workernode这张表:
CREATE TABLE WORKER_NODE ( ID BIGINT NOT NULL AUTO_INCREMENT, HOST_NAME VARCHAR(64) NOT NULL, PORT VARCHAR(64) NOT NULL, TYPE INT NOT NULL, LAUNCH_DATE DATE NOT NULL, MODIFIED TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, CREATED TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (ID) ) ENGINE=INNODB COMMENT='WorkerId分配节点表';字段就是官方那套:ID自增主键即workerId,HOST_NAME和PORT用于唯一标识一个节点,LAUNCH_DATE记录启动日期,MODIFIED和CREATED是审计时间。这张表不需要任何外键,不需要额外索引,保持极简。
3.2 实体类与Mapper映射
先定义WorkerNode实体,字段跟表一一对应,Java侧偏好Long、String、Integer、Date的类型组合:
public class WorkerNode { private Long id; private String hostName; private String port; private Integer type; private Date launchDate; private Date modified; private Date created; // getter/setter省略 }Mapper接口直接用MyBatis注解方式,节省XML文件的路径配置。查询一个节点用hostName加port定位,插入节点需要返回自增主键,所以用@Options(useGeneratedKeys=true):
@Mapper public interface WorkerNodeMapper { @Select("SELECT ID, HOST_NAME, PORT, TYPE, LAUNCH_DATE, MODIFIED, CREATED " + "FROM WORKER_NODE WHERE HOST_NAME = #{hostName} AND PORT = #{port}") WorkerNode selectByHostAndPort(@Param("hostName") String hostName, @Param("port") String port); @Options(useGeneratedKeys = true, keyProperty = "id") @Insert("INSERT INTO WORKER_NODE(HOST_NAME, PORT, TYPE, LAUNCH_DATE, MODIFIED, CREATED) " + "VALUES(#{hostName}, #{port}, #{type}, #{launchDate}, NOW(), NOW())") int insert(WorkerNode workerNode); }扫描这个Mapper,注意SpringBoot启动类上要有@MapperScan,或者直接在接口上标注@Mapper。实际集成时,我习惯统一放到一个独立的dao包路径下,比如com.example.demo.dao,用@MapperScan(“com.example.demo.dao”)扫进去,避免和业务Mapper混在一起。
3.3 核心配置类与Bean注入
接下来最关键的是把生成器装配成Spring Bean。我建议不用官方那个过于内部的默认装配方式,而是自己实现一个WorkerIdAssigner,逻辑完全可控。
先实现自定义分配器:
@Component public class DatabaseWorkerIdAssigner implements WorkerIdAssigner { @Resource private WorkerNodeMapper workerNodeMapper; @Value("${server.port:8080}") private String port; @Override public long assignWorkerId() { String hostName = getHostName(); WorkerNode workerNode = workerNodeMapper.selectByHostAndPort(hostName, port); if (workerNode != null) { return workerNode.getId(); } WorkerNode insertNode = new WorkerNode(); insertNode.setHostName(hostName); insertNode.setPort(port); insertNode.setType(1); insertNode.setLaunchDate(new Date()); workerNodeMapper.insert(insertNode); return insertNode.getId(); } private String getHostName() { try { return InetAddress.getLocalHost().getHostAddress(); } catch (Exception e) { return System.getenv("HOSTNAME") == null ? "unknown" : System.getenv("HOSTNAME"); } } }注意一点:同一个host和port在表里已经存在记录时,直接复用原来那条的ID,不要重新插入。这个逻辑是保证重启环境变量不变时workerId稳定的关键。我遇到的不少重复ID问题,恰恰是有些人把这一步写成了无条件插入。
然后装配生成器,这里要绑定配置参数。SpringBoot里可以用@ConfigurationProperties简化:
@ConfigurationProperties(prefix = "uid") public class UidProperties { private int timeBits; private int workerBits; private int seqBits; private String epochStr; private Cache cache = new Cache(); public static class Cache { private int boostPower = 3; private int paddingFactor = 50; private int scheduleInterval = 60; // getter/setter省略 } // getter/setter省略 }装配Bean:
@Configuration @EnableConfigurationProperties(UidProperties.class) public class UidConfig { @Bean public UidGenerator uidGenerator(DatabaseWorkerIdAssigner assigner, UidProperties properties) { CachedUidGenerator generator = new CachedUidGenerator(); generator.setWorkerIdAssigner(assigner); generator.setTimeBits(properties.getTimeBits()); generator.setWorkerBits(properties.getWorkerBits()); generator.setSeqBits(properties.getSeqBits()); generator.setEpochStr(properties.getEpochStr()); generator.setBoostPower(properties.getCache().getBoostPower()); generator.setPaddingFactor(properties.getCache().getPaddingFactor()); generator.setScheduleInterval(properties.getCache().getScheduleInterval()); return generator; } }这里我用了CachedUidGenerator,而不是DefaultUidGenerator。原因很简单:它的性能上限更高,还带缓冲兜底,对时钟回拨的容忍度更好。Blog里如果业务并发量真的小到只有每秒几十个,其实DefaultUidGenerator也够用,但要切换也简单,代码里把实现类换成DefaultUidGenerator即可,其余配置一样。
3.4 application.yml参数与常见配置
参数配置我一般这样写:
uid: time-bits: 29 worker-bits: 21 seq-bits: 13 epoch-str: "2024-01-01" cache: boost-power: 3 padding-factor: 50 schedule-interval: 60三个位数加起来是63,这个是硬约束。epochStr字段是整个算法的时间起点,格式是yyyy-MM-dd,建议选一个接近系统上线的时间。这样时间戳位的偏移量会小,可用时间窗口更长。如果你选得太早,比如1990年,那29位毫秒数可能在某个时刻就溢出了,ID会直接跳到负数,这是我在实际项目里踩过一次的教训。
有人会问为什么workerBits要配21,其实完全取决于你的节点规模。如果只有几十台机器,配置10到12位足够;如果未来可能会有几万个容器实例,那就给高一点。但要注意,workerBits越大,timeBits或者seqBits就得压缩,因为总和固定是63。我这里是按中型团队设计的:支撑209万个节点、单节点每毫秒8192个ID、整体可用17年。实际业务基本都能套进去。
3.5 业务侧调用与验证
在业务服务里注入UidGenerator,调用nextId就行:
@Service public class OrderIdService { @Resource private UidGenerator uidGenerator; public Long nextOrderId() { return uidGenerator.nextId(); } }启动SpringBoot应用后,第一次调用会自动完成初始化并生成ID。我建议启动完成后写一个简单的自测接口,把生成的ID打出来,人工看一眼ID的长度和趋势。一个合法的UidGenerator生成的ID是19位左右的长整数,而且连续生成多次,高位会随时间推移缓慢递增。
如果想做初步压测,可以在单元测试或者临时接口里跑一轮百万次生成:
long start = System.currentTimeMillis(); for (int i = 0; i < 1_000_000; i++) { uidGenerator.nextId(); } long cost = System.currentTimeMillis() - start; System.out.println("cost: " + cost + " ms, qps: " + (1_000_000L * 1000 / cost));以我普通开发机的实测结果,CachedUidGenerator跑百万ID基本在几十毫秒级别,整体吞吐轻松过百万QPS。这并不是我故意夸大,而是缓存预生成模式下,取一个ID就是从数组里拿一个元素再移动一下指针,瓶颈根本不在算法本身。在正式环境里,真正需要关注的是初始化阶段预生成耗时,以及数据库分配workerId的短连接开销。
4. 调优经验与避坑清单
4.1 参数如何根据业务测算
先把三个核心参数的约束背下来:timeBits + workerBits + seqBits = 63。在这个前提下,每个参数都对应一个业务上限。
timeBits决定可用年限。计算方式是:2的timeBits次方毫秒数,除以86,400,000毫秒得到天数,再除以365得到年数。比如29位就是约17.1年,30位是约34.2年。如果业务注定活不过十年,配29就够;如果要更长时间,配30到31,但其他位要相应扣减。
workerBits决定节点上限。2的21次方是2,097,152,如果只算3年内的扩展规模,可能2的15次方即32768就足够,省下来的位数给seqBits或timeBits。
seqBits决定单节点并发上限。sequence位数是n,那么单毫秒内可以生成2的n次方个ID,每秒就是2的n次方乘1000。13位是每毫秒8192个,真实跑起来已经非常夸张。如果单机每秒只有几千的请求量,给10位甚至8位都够了。
实际调优案例:曾经有个项目预计未来5年内扩展到200台实例、单实例峰值TPS大约3万,那么workerBits选8位足够支撑256节点,seqBits选10位支撑每毫秒1024个即每秒约100万,此时timeBits可以拿45位,可用年限直接拉满到一万多年都没有溢出的忧虑。但因为总和限制是63位,45+8+10=63,完美。这个案例说明,不是越大越好,而是各位需要动态平衡。
4.2 我的踩坑记录:5个真实问题
问题一:启动直接报“Failed to configure a DataSource”。这是因为DatabasWorkerIdAssigner初始化需要数据库,而应用的数据源配置有问题。排查思路先确认数据源是否正常,再看Mapper扫描是否生效。
问题二:首次启动生成ID正常,重启后workerId变化。这是因为无条件插入逻辑导致每次重启都新建记录,新的自增ID顶上来了。复用workerId会让节点重启后的ID整体保持稳定。
问题三:某次大促后ID出现负数。原因是epochStr配得太早,timeBits不够用,时间戳部分溢出到符号位。复盘下来,上线时不应该随手配一个远古日期,而应该按上面公式反向计算出一个合理的epochStr。
问题四:CachedUidGenerator首次调用耗时很长。首次调用会完成RingBuffer初始化并预生成一批ID,如果机器性能差,这个耗时可能达到几百毫秒。解决方式是在应用启动阶段主动预热一次,把uidGenerator.nextId()在启动后立即调用一次,比如在ApplicationRunner里执行一个热身循环,避免第一个业务请求承担初始化开销。
问题五:多个服务共用同一个库存表导致workerId重复。如果一个库表被多套环境、多个独立生成器共用,它们的自增主键混在一起,分配逻辑会被打乱。必须保证一个WORKER_NODE表只服务一套UidGenerator集群。
4.3 测试与上线注意事项
上线之前,至少要过一遍三个检查。第一个检查是参数总和:三个bits加起来必须是63,不然运行期会直接抛异常。第二个检查是workerId上限:从数据库拿到的自增ID必须小于2的workerBits次方,否则位运算会直接吞掉高位,两个节点可能生成相同ID。如果你部署的节点数不多,这个问题概率很低,但如果你未来真的想用几千个实例,就一定要配置足够大的workerBits,并且在上线环节加上启动检查:
long maxWorkerId = ~(-1L << workerBits); if (workerId > maxWorkerId) { throw new IllegalStateException("workerId 超出配置上限"); }第三个检查是时钟同步。CachedUidGenerator能容忍轻微回拨,但你不能指望业务服务器时钟偏差很大还能绝对安全。分布式系统的标准姿势就是NTP时间同步,这一点无论用哪套ID生成器都逃不掉。我在运维侧会把时钟同步加入所有业务节点的初始化脚本,后面就再没遇到过因为时钟回拨导致的重复ID。
还有一个小细节,如果你要让生成器做到高可用,不要只部署单节点。UidGenerator自带数据库分配workerId,天然适合多节点部署,常态应该是两台以上同时提供服务,单台故障时流量打到另一台上,workerId不同,ID自然也不会冲突。这比单机自建UUID要高一个Level。
写在最后
这套方案我前前后后在好几个SpringBoot2.X项目里落地过,最大的体会是:不要被“百度UidGenerator”这个名字唬住,它本质就是一个把雪花算法做得更工程化的组件,引入成本很低、收益却很直观。最让我省心的就是workerId自动分配,再也不用在扩容的时候拿着表格分配ID,新机器启动就直接拿到自己的身份,几乎零运维。如果再让我选一次,小规模新项目我依然会先上CachedUidGenerator,而不是又去写一个雪花工具类。真到哪天并发量高到RBUFFER预生成都不够用了,再换其他专门的高性能方案也不迟,但到那个阶段,你已经把分布式ID这条路趟得明明白白了。