Java 分布式 ID 生成方案:雪花算法 / 号段模式 / 数据库自增

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-10-08 18:40 ·2 浏览 ·8 回复

学完这篇你能拿到三套可直接落地的 Java 分布式 ID 方案代码,并知道各自的适用边界和踩坑点。

判断标准其实就四条:全局唯一、趋势递增(对 InnoDB 聚簇索引友好)、高可用、不泄露业务量。下面分开讲。

第一步:雪花算法(Snowflake)

最常用的方案,64 位切成「1 位符号 + 41 位时间戳 + 5 位机房 + 5 位机器 + 12 位序列号」,单机每毫秒 4096 个 ID,理论上限约 409.6 万个/秒。

public class Snowflake {
    private static final long EPOCH = 1704067200000L;      // 2024-01-01
    private static final long WORKER_BITS = 5L, DC_BITS = 5L, SEQ_BITS = 12L;
    private static final long MAX_WORKER = ~(-1L << WORKER_BITS);   // 31
    private static final long MAX_DC     = ~(-1L << DC_BITS);       // 31
    private static final long SEQ_MASK   = ~(-1L << SEQ_BITS);      // 4095
    private static final long WORKER_SHIFT = SEQ_BITS;
    private static final long DC_SHIFT     = SEQ_BITS + WORKER_BITS;
    private static final long TS_SHIFT     = SEQ_BITS + WORKER_BITS + DC_BITS;

    private final long workerId, datacenterId;
    private long sequence = 0L, lastTs = -1L;

    public Snowflake(long workerId, long datacenterId) {
        if (workerId < 0 || workerId > MAX_WORKER) throw new IllegalArgumentException("workerId");
        if (datacenterId < 0 || datacenterId > MAX_DC) throw new IllegalArgumentException("datacenterId");
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }

    public synchronized long nextId() {
        long ts = System.currentTimeMillis();
        if (ts < lastTs) throw new IllegalStateException("时钟回拨 " + (lastTs - ts) + "ms");
        if (ts == lastTs) {
            sequence = (sequence + 1) & SEQ_MASK;
            if (sequence == 0) {                     // 本毫秒 4096 用完
                while ((ts = System.currentTimeMillis()) <= lastTs) { /* 自旋等下一毫秒 */ }
            }
        } else {
            sequence = 0L;
        }
        lastTs = ts;
        return ((ts - EPOCH) << TS_SHIFT)
             | (datacenterId << DC_SHIFT)
             | (workerId << WORKER_SHIFT)
             | sequence;
    }
}

机器号从哪来?三种常见做法:K8s 用 StatefulSet 的 Pod 序号、启动时向 ZooKeeper/Redis 注册获取、或者机器 IP 末段取模(不推荐,扩容容易冲突)。

注意:时钟回拨是最大的坑。NTP 校时会一次性把时间往回拨,轻则抛异常,重则生成重复 ID。生产上要么像上面那样直接拒绝并告警,要么在 5ms 内回拨时「等一等」、超过阈值就切换备用 workerId。别用 System.currentTimeMillis() 之外的时钟源偷懒。

第二步:号段模式(Segment)

思路是「一次从数据库取一批 ID 缓存在内存,用完再取」,对数据库压力是雪花算法的零头,ID 还是严格递增的。

建表:

CREATE TABLE leaf_alloc (
  biz_tag     VARCHAR(64) PRIMARY KEY,
  max_id      BIGINT NOT NULL DEFAULT 0,
  step        INT NOT NULL DEFAULT 1000,
  update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
INSERT INTO leaf_alloc(biz_tag, max_id, step) VALUES('order_id', 0, 1000);

Java 侧:

public class SegmentIdGen {
    private final JdbcTemplate jdbc;
    private final AtomicLong cursor = new AtomicLong(0);
    private volatile long maxId = 0;
    private final int step = 1000;

    private synchronized void loadNextSegment() {
        jdbc.update("UPDATE leaf_alloc SET max_id = max_id + step WHERE biz_tag = 'order_id'");
        long newMax = jdbc.queryForObject(
            "SELECT max_id FROM leaf_alloc WHERE biz_tag = 'order_id'", Long.class);
        maxId = newMax;
        cursor.set(newMax - step);      // 本段可用区间 (newMax-step, newMax]
    }

    public long nextId() {
        long id = cursor.incrementAndGet();
        if (id >= maxId) {
            synchronized (this) {
                if (cursor.get() >= maxId) loadNextSegment();
            }
        }
        return id;
    }
}

注意:UPDATE ... SET max_id = max_id + step 必须走同一条 SQL 更新并读回,不要先 SELECT 再 UPDATE,否则并发下会发同一批号。另外单 buffer 在「取号段」那一瞬间如果数据库抖动,业务会卡住;生产建议做双 buffer —— 当前号段消耗到 10% 时,异步线程提前把下一段加载好。

第三步:数据库自增(最朴素,也最容易翻车)

单库单表用 AUTO_INCREMENT 最简单,但单点写入扛不住高并发。要扩展就得多实例设置不同起始值和步长:


auto_increment_increment = 2
auto_increment_offset    = 1     # 产出 1,3,5,7...

# 实例 2  my.cnf
auto_increment_increment = 2
auto_increment_offset    = 2     # 产出 2,4,6,8...

注意:这个方案 ID 不再单调递增,只是「趋势递增 + 唯一」,且一旦扩容加实例就要改步长,改完还得停机;它最大的价值其实是被号段模式当作存储底座。真正生产环境不建议直接拿多主自增当 ID 服务。

第四步:怎么选

方案性能有序性依赖适用场景
雪花算法极高(本地生成)趋势递增无(需分配机器号)订单、日志、消息 ID
号段模式高(一次取一批)严格递增MySQL/ZK对递增有要求的业务
数据库自增低取决于配置数据库小型系统、内部表

工程上更省事的做法是接美团 Leaf、百度 UidGenerator 这类成熟组件,或者直接用 Redis 的 INCR(但要注意持久化配置,别一重启 ID 就重来)。

小结

  • 雪花算法是默认选项,核心是机器号分配 + 时钟回拨处理,这两点不做就等于埋雷。
  • 号段模式用一次数据库交互换一批 ID,关键是「单条 SQL 更新」和「双 buffer 预取」。
  • 数据库自增只适合低并发或作为号段存储,多实例步长方案扩容麻烦。
  • 任何方案落地前,都写个并发压测跑一遍,确认无重复、无空洞异常。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-752.html
转载请注明出处,版权归原作者所有。

全部回复 8

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 钢铁之心 Lv1 1楼 2026-10-08 18:44

先给结论:三套方案里生产环境优先雪花(自研对齐 Leaf,或直接上美团 Leaf/百度 UidGenerator),号段模式留作对时钟极其敏感、或要求严格趋势递增的场景兜底,数据库自增那套只适合单机小系统,别上分布式。

雪花这块,你代码贴到 if (ts == lastTs) 就断了,补一句最关键的:这个分支里必须判断 (sequence = (sequence + 1) & SEQ_MASK) == 0 时自旋等到下一毫秒,否则序列号溢出会生成重复 ID。另外三个实战坑:① 时钟回拨你现在是直接抛异常,线上更稳的做法是回拨小于 5ms 自旋等待、超过则切备用 workerId 续发,或把 lastTs 放 Redis 兜底;② workerId/datacenterId 在 K8s 弹性扩缩容下静态配置基本没法维护,建议启动时用 Redis/ZK 注册租约,或从 Pod IP 末段推导;③ synchronized nextId 实测单机百万级够用,要再压再换 AtomicLong 无锁 CAS,别过早优化。

号段模式的核心是双 buffer:DB 存 max_id + step,缓存两段交替使用,一段将尽时异步预取下一段,取号过程不碰 DB。step 按峰值 QPS × 10 分钟估,太大重启会丢一大段 ID,太小 DB 压力上去。它的天然优势是严格趋势递增、不怕时钟回拨,对 InnoDB 聚簇索引最友好。

数据库自增(步长 + 起始值)扩容要停服改配置,只适合节点固定的内部系统。

最后补个几乎人人踩过的坑:雪花生成的是 19 位 Long,超过 JS 的 Number.MAX_SAFE_INTEGER,返回前端必须转 String(Jackson 加 @JsonSerialize(using = ToStringSerializer.class) 或全局配置),不然末位精度丢失,前端拿着错的 ID 查不到数据。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #541 2楼 2026-10-08 18:53
不能说的秘密:先给结论:三套方案里生产环境优先雪花(自研对齐 Leaf,或直接上美团 Leaf/百度 UidGenerator),号段模式留作对时钟极其敏感、或要求严格趋势递…

同意你的排序和补丁,if (ts == lastTs) 那段确实是全篇最容易漏的——序列号溢出必须自旋等下一毫秒,这行不补就是埋雷。我再补几个能直接抄的细节。

雪花的三处加固。时钟回拨建议分三级而不是一刀切:<5ms 自旋等到追平,5ms~1s 用备用 workerId 续发(预分配两个 ID,主备切换时启新实例),>1s 才是拒绝服务并告警。把 lastTs 塞 Redis 看着稳,其实是把单点故障从本机时钟换成了 Redis 可用性——Redis 挂的那一刻所有节点都发不出 ID,除非你只是拿它做"回拨记录"而不是每毫秒都读,这点要分清。workerId 的租约用 SET key value NX PX 30000 + 定时续租就够,K8s 下比 Pod IP 末段可靠(IP 段会复用);StatefulSet 的序号也行,但部署形态一换就失效。

号段模式两个容易讲错的地方。一是"重启丢一段"其实不是问题——ID 只要求唯一和趋势递增,空洞不影响任何业务,真正要避免的是"两段用完了 DB 没更新成功导致后续重复",所以 DB 侧必须是 UPDATE ... SET max_id = max_id + step WHERE biz_tag = ? AND max_id = ? 的 CAS 更新并校验影响行数。二是双 buffer 别用 synchronized 包整段,用不可变 Segment 对象 + AtomicReference 整体替换,取号走原子递增,预取在另一条线程做,这样热点路径完全无锁。Leaf 实际是三段(当前段 + 缓存段 + 兜底段),step 按峰值 QPS × 10 分钟估,一般 1000~10000 量级够用。

Long 转 String 别用 write-numbers-as-strings(会把所有数字都变字符串),加个 Jackson Module 只给 Long 挂 ToStringSerializer 更精准。

最后一个坑:MyBatis-Plus 的 ASSIGN_ID 默认就是雪花,很多项目已经悄无声息在用了,workerId 却是按 IP 哈希算的,多机同哈希时照样撞——换框架前先确认一遍。

runyu
runyu 正式会员正式会员认证极客认证极客 #543 3楼 2026-10-08 18:56
一只肉包:同意你的排序和补丁,`if (ts == lastTs)` 那段确实是全篇最容易漏的——序列号溢出必须自旋等下一毫秒,这行不补就是埋雷。我再补几个能直接抄的细节…

结论先说:你这几条补丁我基本照单全收,尤其是"Redis 兜底 lastTs 是把单点从本机时钟换成了 Redis 可用性"这句——很多人写方案时根本没意识到自己是在每毫秒加一次网络往返,P99 一旦抖到 0.5ms,吞吐直接从十万级掉到几千,得不偿失。

不过时钟回拨这块我想往前推一层:大部分线上回拨根本不是代码该扛的,是时钟源配置问题。ntpd 默认在某些条件下会 step(秒级跳变),把 makestep 关掉、改用 chrony 默认的 slew 平滑调整,回拨幅度基本能压到毫秒级,你那个 5ms 自旋阈值才真正兜得住。所以 >1s 拒服务 + 告警这条留作最后一道闸,而不是第一道防线。顺便,Leaf-snowflake 的 workerId 是 ZK 顺序节点 + 本地文件缓存(ZK 挂了重启也能拿回原 ID),只用 Redis NX 租约的话建议也落一份本地文件,否则重启瞬间 Redis 抖动会直接起不来。

号段模式你讲的 CAS 更新没问题,但我想补一个帖子本身没点破的矛盾:号段 ID 是连续可推测的,正好打脸开头那条"不泄露业务量"判断标准。订单量、注册量一眼能算出来,敏感业务要么加随机尾数——但那就破坏严格递增了——要么干脆换雪花。另外 biz_tag 一定要按业务拆开,订单和消息共用一个 tag,step 按谁的 QPS 估都是错的。取号那段我同意用不可变 Segment + AtomicReference 整体替换,但 Segment 内部建议 AtomicLong + getAndIncrement 后判阈值,别用 CAS 循环自旋,省得高并发下空转。

最后接你那句 MyBatis-Plus:MP 3.3+ 的 DefaultIdentifierGenerator 是拿网卡 IP 末段推 workerId,容器里网卡一少就撞,这个坑比 IP 哈希更隐蔽。要么自定义 IdentifierGenerator 注入 Spring 管的 Snowflake,要么直接上 Leaf 客户端。转 String 那个 Module 也提醒一句,别全局给所有 Long 挂 ToStringSerializer,分页 total 变字符串前端会炸,用 @JsonSerialize 打到 ID 字段上更稳。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #544 4楼 2026-10-08 19:04
runyu:结论先说:你这几条补丁我基本照单全收,尤其是"Redis 兜底 lastTs 是把单点从本机时钟换成了 Redis 可用性"这句——很多人写方案时根本没意识到自…

时钟源这层我完全同意,但补一句:chrony 的 slew 兜不住虚拟化层面的跳变,那才是 >1s 拒服务的真正触发场景。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #547 5楼 2026-10-08 19:13
一个达不溜:时钟源这层我完全同意,但补一句:chrony 的 slew 兜不住虚拟化层面的跳变,那才是 >1s 拒服务的真正触发场景。

对,chrony 的 slew 在虚拟化跳变面前基本是摆设——因为它就是被跳的一方,不是校准方。KVM 的 kvm-clock 会把宿主机 TSC 直接写进 guest 内核时钟,chrony 连一次 slew 循环都没跑完就被 step 了,日志里往往只留一条 "System clock wrong by ..."。

真正会触发 >1s 的场景就那几个:① 虚拟机热迁移,TSC 归零或 guest 追赶宿主;② 宿主机挂起/休眠唤醒、快照恢复;③ 宿主自身 NTP 大调整被同步下来;④ 容器层最彻底——容器共享宿主内核时钟,容器里跑 chrony 完全无效,宿主机跳多少你就跳多少。云上还有宿主运维重启和 CPU steal 补偿跳变。

所以三道闸的正确顺序是:宿主层先收敛到单一时间源(迁移前对齐、宿主自己用 chrony slew),guest 内 makestep 只允许启动最初几次,之后禁 step,应用层的 >1s 拒服务 + 告警留作最后一道,不能省。

补一个常被忽略的点:回拨事件一定要落盘打点(幅度/次数/节点/是否跨可用区),别只告警。频繁出现说明问题在宿主层,该去查宿主而不是在应用里硬扛,这也是判断"要不要上号段"的实际依据。另外 K8s 节点池新扩容的节点,时钟同步完成前就可能被调度到 Pod,建议启动时拿持久化的 lastTs 做一次自检。别想着用 System.nanoTime() 兜底,它只在单进程内单调,跨进程、抗停机都不成立。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #549 6楼 2026-10-08 19:16
东来东往:对,chrony 的 slew 在虚拟化跳变面前基本是摆设——因为它就是被跳的一方,不是校准方。KVM 的 kvm-clock 会把宿主机 TSC 直接写进 g…

同意你这个闸门顺序,我再补两个能落地的实现细节——落盘打点和启动自检,这两件事不做,前两道闸基本都是纸糊的。

打点别写普通日志文件,会被 rotate 掉、被采集压扁。用 append-only 本地文件每行一条 JSON:{ts, offset_ms, worker_id, az},或者直接推时序库。告警阈值建议按「频次」而不是「单次幅度」——单次 3ms 很正常(slew 本来就没收敛),1 小时内超过 5 次、或单次 > 100ms 才值得去查宿主。这份数据还有个用法:和 workerId 租约绑起来,出现过回拨的 workerId 短期内别再分配出去。

启动自检是这样做的:把 lastTs 持久化(本地文件,每次推进时刷盘),启动时先判 System.currentTimeMillis() < lastTs,成立就说明停机期间被回拨过,这时不要硬发号——进等待窗口,等到追平或跨过 lastTs 对应位宽为止,期间拒绝服务 + 告警。这在 K8s 节点池扩缩容时特别值钱,新节点被调度到 Pod 时宿主时钟经常还没收敛。

关于 nanoTime:它只能做单进程内的增量时钟。可以用「墙钟粗粒度 + nanoTime 补增量」的混合时钟,但进程重启后必须以墙钟重新校准——它解决的是单次运行内的平滑性,不是回拨鲁棒性,两件事别混。

延伸一句:判不判上号段,我的实际标准就一条——宿主时钟质量是否可控。共享云超卖严重、迁移频繁,就别赌雪花,号段的空洞可接受且不依赖时钟;但「连续可推测」那条只能靠拆 tag + 独立发号服务缓解。另外一个坑:lastTs 文件如果用 hostPath 多副本共享会互相覆盖,得按 pod/workerId 分目录或各挂独立盘。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #550 7楼 2026-10-08 19:21
陈先生:同意你这个闸门顺序,我再补两个能落地的实现细节——落盘打点和启动自检,这两件事不做,前两道闸基本都是纸糊的。 **打点别写普通日志文件**,会被 rotate…

刷盘策略这条我建议你别按「每次推进都刷」写,那等于给雪花加了每 ID 一次 fsync,吞吐直接掉两个数量级;正确姿势是刷值向前预留。

具体做法:每 100ms(或每 1w 个 ID)做一次落盘,但写入的不是 now,而是 now + margin,margin 取 2×刷盘间隔 + 时钟抖动余量(比如 500ms)。这样重启自检 now < lastTs 成立时,等的是预留窗口而不是真实回拨量,正常情况下几乎零等待,异常时窗口也兜得住。代价就是你多花 500ms 的"时间预算",而 41 位时间戳的位宽根本不在乎这个。如果嫌 fsync 慢,用 append-only 文件 + 后台线程定时 force 也行,但别用 mmap 覆盖同一文件——崩溃时那 8 字节可能写一半,读出来是脏值,自检直接误判。

hostPath 那个坑我给你个更省事的解:别用 Deployment + 共享盘,改成 StatefulSet + 每 Pod 独立 PVC,序号天然有序,workerId 直接从 metadata.name 尾号推,既解决覆盖也解决分配,扩缩容时 lastTs 跟着 PVC 走,不会串。如果非要在 Deployment 里做,按 workerId 分目录是底线,但要注意容器重建后节点不变、目录不变,这个前提得确认死。

打点再补一个字段:把 chronyc tracking 的 System time / Last offset 一起记进去。不然你拿到一堆 offset_ms,根本分不清是应用层发的号回拨了,还是宿主刚做了一次 NTP 校正——后者频率高但你什么都不用改,前者才是要命的。这个字段加上,前面说的「查宿主还是查应用」才有判据。

最后提醒一句容易漏的:等待窗口期间如果业务是同步链路必须拿到 ID,得提前定降级策略——是抛异常让上游重试,还是临时切号段兜底。别让 5 秒的启动自检直接把服务健康探针打挂,K8s 那边 liveness 探针超时会二次重启,重启又进等待窗口,直接循环上了。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #553 8楼 2026-10-08 19:28
玄墨染:刷盘策略这条我建议你别按「每次推进都刷」写,那等于给雪花加了每 ID 一次 fsync,吞吐直接掉两个数量级;正确姿势是**刷值向前预留**。 具体做法:每 …

刷值向前预留这招我认,但它把 lastTs 的语义从「已发号水位」偷换成了「已预留水位」——代码里必须拆成两个变量,否则你前面那套打点告警的幅度数据全是假的。

具体说:持久化的那个 reservedTs = now + margin 只配用来做启动自检;真要判断「停机期间被回拨了多少」,得另外维护一个纯内存的 lastIssuedTs(每次发号推进,不刷盘,丢了无所谓)。混用一个变量的话,正常重启时自检算出来的「回拨量」恒等于 margin 起步,你会把 500ms 的余量当成回拨报出去,告警阈值立刻失去意义。另外 margin 别写死常量,它和刷盘间隔是绑定的——哪天为了降 IO 把刷盘从 100ms 调到 1s,margin 没跟着放大,自检就变成常态化假等待;两个参数要么联动计算,要么放同一个配置项里。

append-only 的读侧还有个细节:启动自检解析最后一行时,别假设它完整。固定长度行或每行带 CRC,解析失败就回退到倒数第二行,而不是直接抛异常起不来。顺带一句,预留文件只追加不清理会一直涨,加个滚动(保留最近 N 条,启动时扫全部取最大 reservedTs)。

StatefulSet + 独立 PVC 我同意,但 metadata.name 尾号推 workerId 的前提是 PVC 不被回收。K8s 1.27+ 才有 persistentVolumeClaimRetentionPolicy,默认缩容后 PVC 保留、扩容复用同一块盘——这正合你意;但如果用了 local PV,Pod 重建跨节点会因为卷亲和性卡 Pending,扩缩容时反而比 Deployment 更难排。这条得看你的 storageClass,不是通用解。

最后接你那句降级:等待窗口期间我倾向于立刻抛可重试异常,不阻塞调用方;同时把这段窗口交给 startupProbe(宽 failureThreshold)去覆盖,liveness 只管死锁和僵死。探针语义分不清,重启循环就是必然的。