学完这篇你能拿到三套可直接落地的 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 预取」。
- 数据库自增只适合低并发或作为号段存储,多实例步长方案扩容麻烦。
- 任何方案落地前,都写个并发压测跑一遍,确认无重复、无空洞异常。