自增主键在分布式下的三个麻烦
单库单表时,AUTO_INCREMENT 简单可靠。一旦分库分表、或者多个服务都要生成订单号,问题就来了:不同库的自增会重复;ID 不连续会暴露业务量;按 ID 排序不等于按时间排序。更麻烦的是“先拿 ID 再插数据”的场景——很多业务需要在写库前就知道主键,自增做不到。
分布式 ID 方案很多,2025 年主流就两类:雪花算法(本地生成、趋势递增)和号段模式(数据库批量发号、绝对递增)。两者的取舍点在于:你能不能接受 ID 里带时间信息,以及愿不愿意为发号引入一次数据库交互。
雪花算法的 64 位结构
标准的雪花 ID 是一个 long,按位切成四段:
| 位数 | 含义 | 说明 |
|---|---|---|
| 1 bit | 符号位 | 恒为 0,保证正数 |
| 41 bit | 毫秒时间戳 | 相对自定义纪元的偏移,可用约 69 年 |
| 10 bit | 工作节点 ID | 最多 1024 个节点 |
| 12 bit | 序列号 | 同一毫秒内最多 4096 个 |
ID 整体趋势递增,按 ID 排序接近按时间排序,适合做 MySQL 主键,避免随机 UUID 带来的页分裂。代价是强依赖系统时钟,以及必须保证 workerId 不重复。
public final class SnowflakeIdGenerator {
private static final long EPOCH = 1704067200000L;
private static final long WORKER_ID_BITS = 10L;
private static final long SEQUENCE_BITS = 12L;
private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS);
private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS);
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
private final long workerId;
private long lastTimestamp = -1L;
private long sequence = 0L;
public SnowflakeIdGenerator(long workerId) {
if (workerId < 0 || workerId > MAX_WORKER_ID) {
throw new IllegalArgumentException("workerId 超出范围: " + workerId);
}
this.workerId = workerId;
}
public synchronized long nextId() {
long timestamp = System.currentTimeMillis();
if (timestamp < lastTimestamp) {
long offset = lastTimestamp - timestamp;
if (offset > 5L) {
throw new IllegalStateException("时钟回拨超过 5ms,拒绝发号");
}
timestamp = waitUntil(lastTimestamp);
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0L) {
timestamp = waitUntil(lastTimestamp + 1L);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - EPOCH) << TIMESTAMP_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitUntil(long target) {
long timestamp = System.currentTimeMillis();
while (timestamp < target) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
时钟回拨的处理要看业务容忍度:小幅回拨(几毫秒)可以等待追平;回拨较大时,直接抛异常让调用方重试,比发出重复 ID 安全。有些实现会切换备用 workerId 继续发号,但切换逻辑本身要保证新 workerId 没被占用。
号段模式:用一次数据库交互换一批 ID
号段模式维护一张发号表,每次从数据库取走一段连续区间,本地内存慢慢发,用完再取:
CREATE TABLE id_segment (
biz_tag VARCHAR(64) NOT NULL,
max_id BIGINT NOT NULL,
step INT NOT NULL,
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (biz_tag)
);
取号用一条原子更新完成,避免并发下拿到重复区间:
UPDATE id_segment SET max_id = max_id + step WHERE biz_tag = 'order';
SELECT max_id, step FROM id_segment WHERE biz_tag = 'order';
这两条要在同一个事务里执行,或者直接用 SELECT ... FOR UPDATE。步长 1000 到 10000 比较常见:太小会频繁访问数据库,太大则重启后浪费一大段 ID(对 long 来说无所谓,但会让 ID 看起来跳变)。
线上通常用双 buffer:当前号段用到 20% 时异步加载下一段,数据库短暂不可用时还能继续发号。号段模式的 ID 绝对递增、不依赖时钟,适合订单号、流水号这类要求可读性和顺序性的场景;代价是强依赖数据库,发号服务本身要考虑高可用。
常见坑
坑一:K8s 里 workerId 全靠随机。 多个 Pod 用 new Random().nextInt(1024),冲突概率随副本数上升,一旦撞上就是重复 ID。可以用 StatefulSet 的序号、启动时向 Nacos/Redis 注册租约,或者把 workerId 写进配置中心按实例下发。
坑二:时钟回拨。 NTP 校时、虚拟机迁移、容器所在宿主机时间跳变都会导致回拨,雪花算法对这类事件非常敏感。生产上应使用 chrony 的平滑校时,并对回拨次数做监控。
坑三:ID 传给前端精度丢失。 JavaScript 的 Number 最大安全整数是 2^53-1,19 位的雪花 ID 直接返回会给前端变成近似值。统一序列化成字符串,Jackson 可以用 ToStringSerializer,或者全局开启 WRITE_NUMBERS_AS_STRINGS。
坑四:把号段 ID 当时间用。 号段只保证递增,不保证连续,也不包含时间信息。别用它反推下单时间,更别用它做分表路由后还指望按时间范围扫描。
排查步骤
- 拿到重复 ID 先解码:右移 22 位加纪元得到时间戳,中间 10 位是 workerId,最后 12 位是序列号,能快速判断是同一节点同一毫秒发多了,还是两个节点撞了。
- 检查各实例的 workerId 分配记录,确认有没有重复下发。
- 在宿主机执行
chronyc tracking看时钟偏移和回拨事件。 - 查数据库唯一索引冲突日志,确认重复发生在哪个业务表、哪个时间段。
- 如果是号段模式,检查
id_segment的max_id是否被并发更新覆盖,以及双 buffer 切换逻辑有没有把旧号段重复发放。
评论