20251217从百度的雪花id到tinyid二开-压测-源代码拆解

37 阅读14分钟

20251217从百度的雪花id到tinyid二开-压测-源代码拆解

1前言 / 背景说明

当前系统统一使用的全局 ID 生成组件基于百度开源的 uid-generator。
该方案本质上是 Snowflake 算法的变种,通过 timeBits + workerBits + seqBits 拼接形成 64 bit long。我们在落地时引入了数据库表 sy_worker_node,通过自增主键作为 workerId,并由 DisposableWorkerIdAssigner 在应用启动时自动插入一条记录、分配一个新的 workerId。

随着业务规模和部署形态的演进,这一套设计逐渐暴露出明显问题:

workerId 空间存在「用完」风险:

1、在容器化和弹性伸缩场景下,每次实例重启 / 扩容都会插入新的 sy_worker_node 记录并消耗一个全新的 workerId。
2、这些 workerId 在实例销毁后无法安全回收,如果回收重新使用,则存在与历史时间窗口叠加时产生重复 ID 的风险;如果不回收,则会持续消耗有限的 workerBits 空间,长期运行后存在“workerId 见底”的可能。

workerId 生命周期难管理且严重浪费

从实现上看,DisposableWorkerIdAssigner 每次都是“只增不减”(来自于百度的策略):
1、只要实例启动,就插入一条新记录,拿一个新的自增 ID。
2、不区分短命容器、长期节点,也不考虑回收策略。
3、这意味着大部分 workerId 实际上只是被用了一小段时间,然后永久“作废”,对应的位空间被硬生生浪费掉,这里也是核心最需要。

ID 分布在容器化场景下更加离散,不利于按 ID 分段管理数据
1、在 Snowflake 模式下,ID 的高位包含时间,中位是 workerId,不同 workerId 的号段在数轴上交错分布。
2、在容器频繁重启、workerId 不断自增的情况下,同一业务生成的数据会被切割到非常分散的 ID 段中,不利于基于「ID 连续区间」做分区、归档或增量同步等操作,这里是最核心需要。

雪花方案依赖 DB,却没有解决「中心化 worker 管理」的根问题

1、为了分配 workerId,我们引入了 sy_worker_node 表、对应的 Mapper 和 Service,这事实上已经让整个方案变成“依赖中心 DB 的 Snowflake”。
2、与其维护复杂的节点表、处理回收和幂等,不如直接引入一套更加契合容器化场景的发号服务。

在上述背景下,现有 ID 生成工具已经出现「workerId 空间被大量浪费、长期看有耗尽风险」的倾向;同时,复杂的 worker 节点管理逻辑给部署和运维带来了额外负担。因此,我们决定 逐步从百度 UidGenerator 体系迁移到滴滴 TinyId 号段服务:
1、上层仍通过统一的 UidUtils.genId() 获取全局 ID,业务代码无需感知变更;
2、底层将原先依赖 sy_worker_node + Snowflake 的本地生成逻辑,替换为基于 TinyId 的 按 bizType 号段发号;

以服务名(spring.application.name)作为 bizType 维度,规避容器实例级别的 workerId 管理问题,将“节点状态”收敛到 TinyId 服务侧统一治理。

新方案从本质上是「从本地 Snowflake + workerId 管理」迁移到「远程号段服务 + bizType 配置」:

我们不再尝试在应用侧精细管理 workerId 生命周期,而是将 ID 空间的分配和回收抽象为按业务线(bizType)分配号段,由发号服务统一承担。这一调整可以更好地适配当前大规模容器化部署的运行环境,也为后续的分库分表、增量同步和数据归档提供更稳定、可预期的 ID 形态。

2旧方案设计回顾:Snowflake + sy_worker_node

2.1 组件结构与依赖关系

旧方案基于百度 uid-generator,在本项目中的落地形态如下:
1、对上层:

统一由 UidUtils.genId() 暴露:

public synchronized static long genId(){
    UidGenerator uidGenerator = SpringUtil.getBean("cachedUidGenerator");
    return uidGenerator.getUID();
}

业务层只感知一个 long 型全局 ID。
核心 Bean:CachedUidGenerator =>配置类 IdGeneratorConfiguration 中定义:

@Bean
public CachedUidGenerator cachedUidGenerator() {
    CachedUidGenerator cachedUidGenerator = new CachedUidGenerator();
    cachedUidGenerator.setWorkerIdAssigner(disposableWorkerIdAssigner());
    cachedUidGenerator.setWorkerBits(20);
    cachedUidGenerator.setSeqBits(12);
    cachedUidGenerator.setTimeBits(31);
    cachedUidGenerator.setEpochStr("2024-11-21");   // 其实这个epoch Snowflake的41位时间戳部分存储的不是绝对时间,而是当前时间与epoch time的差值, 这里就是当前时间和2024-11-21的差值
    return cachedUidGenerator;
}

使用百度提供的 CachedUidGenerator,在本地内存维护队列,降低实时计算开销。

workerId 分配组件:DisposableWorkerIdAssigner, 来自百度的一个策略:

实现 WorkerIdAssigner 接口,核心职责是:
1、通过 WorkNodeService 操作 sy_worker_node 表;
2、插入一条新节点记录,取自增主键 work_node_id 作为 workerId。

持久层依赖:
表结构 sy_worker_node:

CREATE TABLE `sy_worker_node` (
  `work_node_id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
  `host_name` varchar(64NOT NULL COMMENT '主机名称',
  `port` varchar(64NOT NULL COMMENT '端口',
  `type` int NOT NULL COMMENT '类型(ACTUAL or CONTAINER)',
  `launch_date` date NOT NULL COMMENT '年月日',
  `modeified` datetime NOT NULL COMMENT '修改时间',
  `created` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`work_node_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

WorkNodeService + WorkerNodeMapper 负责插入和查询。
整体依赖链路可以简化为:

业务代码 → UidUtils → CachedUidGenerator → DisposableWorkerIdAssigner → DB: sy_worker_node → workerId → Snowflake 拼接 ID

DisposableWorkerIdAssigner 主要负责机械码workerId的相关策略的处理

2.2 Snowflake 原理与百度实现要点

经典 Snowflake 的 64bit 布局一般是:
1、高位:时间戳(相对某个 epoch 的偏移)
2、中位:workerId(机器/节点标识)
3、低位:序列号(同一毫秒内的自增计数)

在百度 uid-generator 中:
当前项目配置了 timeBits=31, workerBits=20, seqBits=12:
1、时间维度:允许约 2³¹ 时间片(结合 epoch 使用)
2、workerId:最多约 1,048,576 个 worker
3、每毫秒序列号:最多 4096 个 ID

百度实现里,DefaultUidGenerator 有类似如下的流程(伪代码):

long currentMillis = ...;
if (currentMillis < lastTimestamp) {
    // 处理时钟回拨 
}
if (currentMillis == lastTimestamp) {
    // 同一毫秒内,自增 seq
    sequence = (sequence + 1) & seqMask;
    if (sequence == 0) {
        // 当前毫秒用完,等待下一毫秒
        currentMillis = waitNextMillis(lastTimestamp);
    }
} else {
    sequence = 0;
}

lastTimestamp = currentMillis;

// 拼接 ID
return ( (currentMillis - epoch) << (workerBits + seqBits) )
       | (workerId << seqBits)
       | sequence;   // 同个这个拼接id的策略 更好的看出来了这个雪花id的结构构成

CachedUidGenerator 在此基础上再加一个 RingBuffer,把生成的 ID 预填到队列中,业务线程从队列里“拿号”,降低高并发场景下的同步开销。有点类似段号里面的缓存拿号的逻辑

关键点在于:
整个 64bit ID 的中间部分 workerBits 完全由 workerId 决定,而 workerId 又来自 DB 表 sy_worker_node 的自增值。因此,一旦 workerId 分配策略设计不当,就会直接影响 ID 空间的利用率和连续性。
对于当前的项目, workerId 的分配策略有很大的问题

2.3 sy_worker_node + DisposableWorkerIdAssigner 的实现逻辑
DisposableWorkerIdAssigner 的核心逻辑可以概括为:

1、构建 WorkNode 对象

若运行于容器,这里百度的id生成器还巧妙的处理了容器之间的策略关系:
type = CONTAINER
host_name = Docker Host
port = Docker Port

否则:
type = ACTUAL
host_name = 本机 IP
port = 当前时间戳 + 随机数
写入 sy_worker_node

调用 workNodeService.addWorkerNode(workNode) 执行 insert;

依赖数据库自增生成 work_node_id,这点需要注意, 所以其实这个work_node_id的唯一性是能显著的保证的

2、返回 workerId,使用了上述构建的 WorkNode 对象

workNode.getWork_node_id() 作为当前实例的 workerId 返回给生成器。

在真实实现中,它并不会检查“本机+端口是否已经存在并重用旧节点”,而是倾向于每次都插入一条新纪录。这就带来几个非常现实的问题:
1、容器场景下的 workerId 雪崩式膨胀
2、每次重启 / 发布,相当于占用一个新的 workerId;
3、旧 workerId 实际上不会再被使用,但也不能安全回收;
4、长期运行后,work_node_id 会无限增长,直至耗尽 workerBits 空间。

workerId 回收几乎不可行,原因如下:

假设我们删除旧记录,回收自增 ID 或重新占用,会导致 workerId 与历史某个时间窗口重叠,一旦时间回拨或系统时间不严格单调,就有概率生成重复 ID,这与全局唯一性不符合了
这类回收方案本质上是在用「高复杂度运维」换「极低但存在的重号风险」,几乎不可接受。

ID 分布在数轴上高度离散,这对分块数据同步,大数据处理特别不友好

同一业务的不同实例对应不同 workerId;随着实例频繁重启,workerId 不断增大,导致同一业务的数据 ID 被切割到大量不同的 worker 区间;按 ID 范围分库、做增量同步或归档时,扫描区间会非常碎,容易拖慢批量任务。

总结来说,百度这套方案本身并非“设计错误”,在物理机 + 少量固定节点的时代是合理的。但在容器化 + 高弹性部署环境下,基于「自增表 + 一次性 workerId」的设计,已经很难跟上实际处理的诉求。

3新方案设计:TinyId 号段发号

3.1 TinyId 的基本原理

TinyId 采用的是号段发号(Segment)算法,核心思想是:
1、为每一个 bizType 在数据库中维护一个“当前最大 ID”和“步长 step”;
2、Server 端每次从 DB 中“申请一段号段”,例如 [maxId + 1, maxId + step];
3、Server 把这段号段缓存到内存中,用 AtomicLong 本地自增发号;
4、号段用完后,再向 DB 申请下一段。

典型的表结构(概念上)包含字段:
1、biz_type:业务类型(我们计划用 spring.application.name 作为 bizType)
2、max_id:当前已经被分配出去的最大 ID
3、step:每次申请的号段大小
4、version:乐观锁版本号,用于并发控制

简化后的核心 SQL 流程:

  1. 1. 查询当前记录
SELECT max_id, step, version FROM tiny_id_info WHERE biz_type = ? FOR UPDATE;
  1. 2. 计算新号段
new_max_id = old_max_id + step;
  1. 3. 更新行,version + 1,防止并发
UPDATE tiny_id_info
SET max_id = new_max_id, version = version + 1
WHERE biz_type = ? AND version = old_version;

Server 成功更新后,向 Client 返回 [old_max_id + 1, new_max_id] 这一整段号段;Client 在本地用 AtomicLong 从 segment.start 自增到 segment.end 即可。

3.2 本项目中的 TinyId 接入方式与核心代码

在本项目中,我们没有直接在业务代码里依赖 TinyId 的 API,而是做了一层适配:
统一接口层:UidGenerator

public interface UidGenerator {
    long getUID();
    String parseUID(long uid);
}

TinyId 实现:TinyIdUidGenerator

public class TinyIdUidGenerator implements UidGenerator {
    private final String bizType;

    public TinyIdUidGenerator(String bizType) {
        this.bizType = bizType;
    }

    @Override
    public long getUID() {
        try {
             工厂调用初始化客户端代码省略
         } catch (Exception e) {
            throw new IllegalStateException("tinyid 客户端不可用", e);
        }
    }

    @Override
    public String parseUID(long uid) {
        return String.valueOf(uid);
    }
}

这里通过反射避免直接强绑 TinyId 的接口,方便后续替换或二开;

bizType 由配置注入,当前方案使用 spring.application.name;Spring Boot 配置:按应用名映射 bizType

@Configuration
public class IdGeneratorConfiguration {

    @Bean
    public UidGenerator cachedUidGenerator(Environment environment) {
        String appName = environment.getProperty("spring.application.name");
        if (StrUtil.isBlank(appName)) {
            throw new IllegalStateException("缺少配置 spring.application.name");
        }
        return new TinyIdUidGenerator(appName);
    }
}

这里要做一个配置检测

这样,上层业务仍然调用:

public synchronized static long genId(){
    UidGenerator uidGenerator = SpringUtil.getBean("cachedUidGenerator");
    return uidGenerator.getUID();
}

只是底层的实现从「本地 Snowflake + workerId」变成了「TinyId 号段服务 + bizType」。

3.3 TinyId 方案的优势与 tradeoff

相对旧方案的优势:
1、天然适配容器化、弹性伸缩
2、以 bizType 作为维度发号,不关心单实例的生命周期;
3、容器扩容、缩容、重启不会造成“workerId 空间的额外消耗”;
4、节点状态、号段分配与回收都集中在 TinyId Server,统一治理。

简化本地组件依赖,统一使用http服务来进行访问,
不再需要 sy_worker_node 表、WorkerNodeService/Mapper 这一整套;
应用侧只需要 TinyId 客户端和少量配置,即可完成发号。

可控的跳号与 ID 形态

1、号段预申请 + 应用重启 → ID 天然会“跳号”,但模式简单可控;
2、在一个固定 bizType 上,ID 大致呈趋势递增,更利于「按 ID 范围做数据增量同步」;

可通过 step 策略平衡 DB 压力与浪费,这里的 step 管理抽离出来了 workerId构成雪花id浪费的问题

1、step 越大:DB 压力越低,但重启频繁时浪费越多;
2、step 越小:浪费越少,但 DB 访问越频繁;

这是一种「可调节」的 tradeoff,而不是 Snowflake 中 workerId 的“硬浪费”,调控手段粒度更细

需要接受的事实:
不保证全局严格单调递增,也不保证无跳号;多实例 + 多 bizType 场景中,ID 分布依然会有一定程度的“打散”,只是模式比 Snowflake + workerId 更可预测、更便于按业务线分段。

3.4 TinyId 二次开发计划(Server / Client 改造点)

TinyId 的原始实现偏老,需要结合公司技术栈做一定二开调整。
Server 侧:
1、数据库兼容 MySQL 8+

需要:
升级 JDBC 驱动;检查并修正 SQL 语法(特别是默认排序规则、timestamp 默认值(这里最好使用datetime而不是timestamp这种被废弃的类型)等)。

2、Spring Boot 升级至 2.x+
原项目基于 Spring Boot 1.x,需要整体升级;这里需要注意最好能使用异步的tomcat, 这样多个http请求的时候可以充分利用异步http能力来提高并发访问的性能, 避免被同一时段多次的请求打崩

3、连接池替换为 HikariCP
Spring Boot 2.x 默认连接池即 HikariCP;相比 Tomcat 自带连接池有更好的性能和资源利用率;
需要在配置中删除旧的连接池配置,改用标准的 spring.datasource.hikari.*。

Client 侧:
4、配置加载方式改造
原版本硬编码读取 classpath:tinyid_client.properties,对多环境和 Spring 配置中心不友好;目前项目里面使用的nacos,对于相关配置可以引入nacos-client来进行改造;然后,保留 tinyid_client.properties 作为兜底/兼容方案。

4迁移策略:从 Snowflake 逐步切换到 TinyId

考虑到历史数据和线上稳定性,迁移不可能“一刀切”,建议采取分阶段、可回滚的策略。

4.1 双轨兼容期:两套 ID 生成并存

灰度切流:
可以按服务、按环境、按集群逐步切:

1、先在测试环境 / 预发环境启用 TinyId;
2、在线上某一小部分实例开启 TinyId,其余仍使用 Baidu 方案,监控 ID 生成延迟和下游链路;
3、无异常后再逐步扩大 TinyId 的比例,最终全量切换。

4.2 标记 ID 来源,便于排查与回滚

在迁移期可以考虑:
在关键数据表中增加一个“ID 生成策略来源”字段(例如 id_source:BAIDU / TINYID),或者在日志中统一打点;一旦发现 TinyId 有问题,能够快速定位受影响数据范围,并根据 id_source 做针对性处理。

4.3 回滚预案

配置级别回滚:
保持 Baidu 实现的代码和配置不删除;
一旦 TinyId 出现严重问题,只需切换 id.generator.type=baidu,重启实例即可回滚。

数据级兜底:
ID 本质只是主键和关联键,只要不在 ID 中编码业务含义(我们目前也没有),即使切换回 Baidu,已有数据不会失效;
数据同步、分区策略如果依赖了 ID 的大小关系,在回滚方案设计时需要单独评估(例如:在 TinyId 期间生成的数据是否需要特殊处理)。

4.4 清理旧组件与表

在 TinyId 方案稳定运行一段时间后,可以考虑:
逐步下线:
com.xfvape.uid 依赖;CachedUidGenerator、DisposableWorkerIdAssigner、WorkNodeService、WorkerNodeMapper;
评估并清理 sy_worker_node 表:若不再使用,可以保留一段时间只读观测;
确认不会再有读写后再做归档或删除。

.preview-wrapper pre::before { position: absolute; top: 0; right: 0; color: #ccc; text-align: center; font-size: 0.8em; padding: 5px 10px 0; line-height: 15px; height: 15px; font-weight: 600; } .hljs.code__pre > .mac-sign { display: flex; } .code__pre { padding: 0 !important; } .hljs.code__pre code { display: -webkit-box; padding: 0.5em 1em 1em; overflow-x: auto; text-indent: 0; } h2 strong { color: inherit !important; }

本文使用 文章同步助手 同步