一、痛点:分库分表之后,自增 ID 第一个倒下
单体时代我们用 MySQL 自增主键:简单、有序、性能好。但电商中台一旦分库分表,自增 ID 立刻出现三个问题:
- 全局唯一性失效:两个库各自从 1 开始自增,订单号必然撞车;
- 可猜测性:订单号连续递增,攻击者可以遍历接口枚举业务数据,配合越权漏洞就是灾难;
- 无业务区分度:订单号、支付流水号、券码混在一起,排查问题全靠查表。
于是分布式 ID 成了中台的基础设施。本文以「一个典型的电商中台」为例,讲透两种主流方案:雪花算法与号段模式,以及我在生产落地时踩过的五个坑。
二、方案选型:先看你的 ID 要满足什么
选型之前先列需求,大多数团队的共识是:
- 全局唯一、趋势递增(对 B+ 树索引友好,避免随机写导致的页分裂);
- 高可用、低延迟(下单链路要求在毫秒级拿到 ID);
- 不含敏感业务信息,又不能纯随机(纯随机 UUID 会让索引碎片化严重)。
| 方案 | 优点 | 缺点 |
|---|---|---|
| UUID | 本地生成、无网络开销 | 36 位字符串、无序、索引性能差 |
| DB 自增/号段 | 绝对递增、实现简单 | 依赖数据库、有单点和性能上限 |
| 雪花算法 | 本地生成、趋势递增、高性能 | 依赖机器时钟,时钟回拨会重复 |
我的实践结论:核心交易链路(订单、支付)用雪花算法,流水号、券码等低频业务用号段模式,两者不冲突,反而互补。
三、雪花算法:位运算里挤出的 64 位
雪花 ID 是 64 位 long,按位分配:1 位符号位 + 41 位时间戳 + 10 位机器位 + 12 位序列号。41 位毫秒时间戳约可用 69 年,10 位机器位支持 1024 个节点,12 位序列号单机单毫秒可生成 4096 个 ID。
下面是去掉框架、可直接理解的 Java 实现:
public class SnowflakeIdGenerator {
// 位分配:1 符号位 + 41 时间戳 + 10 机器位 + 12 序列号
private static final long START_EPOCH = 1700000000000L; // 自定义起始时间戳
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) - 1;
private static final long SEQUENCE_MASK = (1L << SEQUENCE_BITS) - 1;
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 sequence = 0L;
private long lastTimestamp = -1L;
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 <= 5) {
timestamp = waitUntil(lastTimestamp); // 轻微回拨,等时钟追上
} else {
throw new IllegalStateException("时钟回拨过大: " + offset + "ms");
}
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & SEQUENCE_MASK;
if (sequence == 0) {
timestamp = waitUntil(lastTimestamp); // 同毫秒序列号耗尽
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp - START_EPOCH) << TIMESTAMP_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitUntil(long lastTimestamp) {
long ts = System.currentTimeMillis();
while (ts <= lastTimestamp) {
ts = System.currentTimeMillis();
}
return ts;
}
}
三个关键点:一是 synchronized 保证线程安全,二是 位运算必须用 long 而不是 int(int 移位会溢出导致负数),三是 起始时间戳要取一个固定的过去时间,不要用 0,否则 ID 会特别长。
四、坑一:时钟回拨——雪花算法最经典的翻车现场
NTP 校时、虚拟机迁移、物理机时钟漂移,都可能让 System.currentTimeMillis() 往回跳。回拨期间生成的 ID 与历史 ID 冲突,而数据库主键冲突的后果是直接写入失败。
我的处理策略分三档:
- 回拨 ≤ 5ms:自旋等待,等系统时钟追上 lastTimestamp 再生成(见上文代码);
- 回拨 5ms ~ 1s:拒绝生成,抛异常让上层走降级,同时打告警;
- 回拨 > 1s:放弃本地生成,临时切换备用 ID 服务(号段模式兜底),并标记该节点需要人工校准。
另外要监控:把每次检测到的回拨偏移量上报到指标系统,回拨是机房硬件故障的前兆信号,不能只靠异常兜底。
五、坑二:workerId 怎么分配?写死在配置里必出事
10 位机器位一共 1024 个,容器化部署后节点是动态伸缩的,写死在 application.yml 里必然出现两个实例 workerId 相同,同毫秒生成重复 ID。我的做法:
- 启动时向 Redis 申请:
SET worker:order lock 1 NX EX 60,按自增序号分配,实例退出后释放; - 或用 ZooKeeper 临时节点,节点序号即 workerId,会话断开自动回收;
- 最次也要用 机器 IP 的最后一段 + 进程号 做哈希,保证同一台机器上多个实例不冲突。
六、坑三:雪花 ID 到了前端就变样——JS 精度丢失
这是最容易忽略的坑。雪花 ID 是 19 位数字,超过 JavaScript 安全整数上限 2^53-1。前端用 number 接收,最后几位会被截断成 0,导致订单详情查不到、跳转错单。
后端必须在序列化时把 Long 转成字符串:
@JsonSerialize(using = ToStringSerializer.class)
private Long orderId;
如果项目里大量实体都有 Long 主键,更省事的做法是全局配置 ObjectMapper,把所有 Long 类型统一序列化为字符串。前端拿到字符串后,不要主动转 number,直接作为字符串传给接口即可。
七、号段模式:一次取一批,数据库不再是瓶颈
号段模式的思路是:数据库只负责发号,每次取 step 个,应用内存里慢慢用。表结构如下:
CREATE TABLE id_segment (
biz_tag VARCHAR(32) PRIMARY KEY COMMENT '业务标识,如 order_id',
max_id BIGINT NOT NULL COMMENT '当前已分配的最大 ID',
step INT NOT NULL COMMENT '每次取号步长',
version INT NOT NULL COMMENT '乐观锁版本号',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
取号用乐观锁,防止多个实例并发更新:
UPDATE id_segment
SET max_id = max_id + step, version = version + 1
WHERE biz_tag = 'order_id' AND version = #{version};
更新成功即拿到 [旧 max_id + 1, 新 max_id] 这一段号,应用进程内用 AtomicLong 分配。step 取多少要看峰值:峰值 QPS × 峰持续秒数 × 冗余系数,比如峰值 2000/s、持续 30s,step 取 10 万左右,够用又不至于浪费。
进阶做法是双 buffer:内存里始终维护「当前段 + 下一段」两个区间,当前段用到 50% 就异步去数据库预取下一段,彻底消除取号阻塞,把数据库压力降到「几分钟一次 update」。这也是业界开源发号器(如 Leaf)的核心思想。
八、坑四与坑五:号段模式的单点与主从延迟
坑四:单点。号段模式把压力集中到一张表,必须保证这张表高可用。建议独立部署(不跟业务表混库),主从复制 + 双写切换,或者直接上分布式数据库。
坑五:主从延迟导致的重复发号。如果取号走从库,主从延迟期间两个实例可能读到同一个 max_id,各发一段互相重复的号。所以取号必须强制走主库,从库只承担查询。
九、最佳实践清单
- ID 不承载业务含义:不要在 ID 里编码店铺、渠道等业务信息,业务属性放独立字段,否则后续扩展会哭;
- 核心链路雪花 + 低频业务号段:组合使用,别迷信单一方案;
- 对外暴露时注意防枚举:雪花 ID 趋势递增意味着可被遍历,所有按 ID 查询的接口都要做权限校验;
- 序列化统一 Long 转 String:前后端一体约定,从根上消灭精度丢失;
- 回拨监控 + 降级预案:时钟回拨不是「会不会发生」,而是「什么时候发生」。
分布式 ID 看起来只是「一个发号器」,但它决定了订单链路的可用性上限。把选型、实现、降级想清楚,比上线后救火划算得多。