分库之后第一个炸的不是事务,是 ID。两个库各自从 1 开始自增,第二天主键撞了,第三条开始连外键都对不上。
这类事故的教训常被总结成"要上分布式 ID",其实问题更具体:你需要的到底是"不重复",还是"能排序",还是"不被人顺着猜"。这三个诉求来自不同部门,代价互斥,选型会决定后面三年的写入性能与迁移成本。
1. 四个需求,不可能同时满足
| 需求 | 谁提的 | 满足代价 | 与谁冲突 |
|---|---|---|---|
| 唯一 | 存储层 | 低,几乎都能做到 | 无 |
| 趋势递增 | DBA、索引 | 中,需要时钟或协调 | 不可猜测、无中心 |
| 不可猜测 | 风控、对外接口 | 中,需要随机或加密 | 趋势递增 |
| 可读 | 运营、客服 | 低,但要拼字段 | 长度、不可猜测 |
唯一性是底线,剩下三个是在同一个 ID 里抢同一段比特。趋势递增要求高位近乎单调,不可猜测要求高位均匀随机;可读要求塞进日期与业务线,长度与不可猜测同时恶化。所以真正的选型不是挑方案,是决定牺牲哪一个。
一个可用的判断顺序:先问"这个 ID 会不会出现在 URL 里"。会出现,不可猜测就是硬约束,趋势递增只能靠"随机前缀 + 有序后缀"折中;不会出现(内部主键、消息 key),就大方地用趋势递增。
2. 五种方案对照
| 方案 | 趋势递增 | 性能 | 单点风险 | 主要失效模式 |
|---|---|---|---|---|
| 数据库自增 + 步长 | 是 | 中 | 低(多库分摊) | 扩容改步长要停写;步长与实例数绑死 |
| 号段模式 | 是 | 高(一次取一段) | 中(发号器) | 号段耗尽未续;重启浪费一段 |
| Snowflake 类 | 是 | 高(本地生成) | 无 | 时钟回拨;workerId 冲突 |
| UUID / ULID | UUID 否,ULID 是 | 高 | 无 | 索引碎片;字符串存储膨胀 |
| Redis INCR | 是 | 高 | 高(单点或集群槽) | 持久化丢失后重发;集群迁移跳号 |
号段模式是多数团队的最优起点:一次取 1000 个号放本地,用完再取,数据库压力降三个数量级。代价是"跳号"变成常态——服务重启会丢掉手里没用完的一段,ID 出现空洞。只要业务不要求"ID 连续"(连续与唯一是两件事),这不是问题。
-- 号段分配:一次 UPDATE 取走一段,靠乐观锁保证不重发
UPDATE id_segment
SET max_id = max_id + :step, updated_at = NOW()
WHERE biz_tag = :tag AND max_id = :expect;
-- affected_rows = 1 才拿到 [expect+1, expect+step]
// Snowflake 位分配:41 位毫秒 + 10 位机器 + 12 位序列
// 按具体实现核对:有的把机器位拆成 5 位机房 + 5 位机器
private static final long EPOCH = 1700000000000L; // 自定义纪元,决定可用年限
long id = ((ts - EPOCH) << 22) | (workerId << 12) | sequence;
41 位毫秒从自定义纪元起算约 69 年,把纪元定在项目启动年就能避开 2038 类问题;定得太早会提前耗尽。
3. 时钟回拨:必须显式选择一种行为
Snowflake 类方案唯一真正的敌人是 NTP 校时。回拨 5 毫秒很常见,回拨 5 分钟也发生过。三种处理各有代价:
| 策略 | 行为 | 代价 |
|---|---|---|
| 等待 | sleep 到时间追上 | 该实例写入短暂停顿;回拨大时等于挂起 |
| 借位 | 序列号继续用、或临时换 workerId | 实现复杂;换 workerId 有冲突风险 |
| 拒绝 | 直接抛异常,让上层重试或降级 | 短时不可用,但绝不产生重复 |
long now = System.currentTimeMillis();
if (now < lastTs) {
long back = lastTs - now;
if (back <= 5) Thread.sleep(back); // 小幅回拨:等
else throw new ClockMovedBackwardsException(back); // 大幅回拨:拒绝
}
关键不是选哪种,是在代码里显式选并配上告警。默认实现里 if (now < lastTs) now = lastTs 这种"悄悄抹平"最常见,它会在回拨期间持续发出重复 ID,而且没有任何日志。
4. 趋势递增为什么重要:B+ 树的写入位置
InnoDB 的主键是聚簇索引,数据行按主键顺序存放。ID 递增意味着新行总在最后一页追加;ID 随机意味着新行插进任意页,触发页分裂、半满页、以及 buffer pool 里更多的脏页。
| ID 形态 | 插入位置 | 页分裂 | 对写入的影响 |
|---|---|---|---|
| 自增 / 号段 / Snowflake | 尾部 | 几乎不发生 | 顺序写,页利用率高 |
| UUIDv4 | 均匀分散 | 频繁 | 随机写,页利用率下降,二级索引更大 |
| ULID / 有序 UUID | 近似尾部 | 少 | 接近自增 |
这条规律的适用范围要说清:它是聚簇索引 + B+ 树的性质,不是"UUID 天生慢"。如果表用非聚簇索引 + 代理主键(如 PostgreSQL 的 heap 表),随机主键的代价主要在索引大小与缓存命中,而不是页分裂。
5. 长度、精度与 JS 的 2^53
| 形态 | 存储 | 文本长度 | 备注 |
|---|---|---|---|
BIGINT 自增/号段 |
8 字节 | ≤ 19 位 | 最省 |
| Snowflake(long) | 8 字节 | 19 位 | 超出 JS 安全整数 |
| ULID(128 位) | 16 字节二进制 / 26 字符 | 26 | 可按字典序排 |
| UUIDv4 | 16 字节二进制 / 36 字符 | 36 | 索引与可读性都差 |
JavaScript 的安全整数上限是 2^53 - 1(9007199254740991),19 位的 Snowflake 早就越界。接口直接返回数字,前端会静默变成另一个数——不是报错,是"改了一个看起来对但不存在的主键"。
// 后端:统一序列化为字符串,别依赖调用方自觉
// Jackson: @JsonSerialize(using = ToStringSerializer.class)
JSON.parse('{"id": 1957203915486720001}').id // → 1957203915486720000
约定要写进接口规范:所有 64 位 ID 在 JSON 里是字符串,包括内部调用。只在对外接口改会造成两套形态并存,排查时极难分辨。
6. 业务排序不等于 ID 排序
ID 只保证趋势递增,不保证业务顺序。下单时间、支付时间、生效时间都可能与 ID 顺序相反:补偿单晚生成但归属更早,批量导入的 ID 是导入顺序不是业务顺序。
分页是最容易踩的地方。ORDER BY id LIMIT 20 OFFSET 10000 在深页上要扫描并丢弃一万行,而且新数据插入会让翻页出现重复与漏项。
-- 游标分页:拿上一页最后一条的排序键,而不是 offset
SELECT id, title FROM article
WHERE (created_at, id) < (:lastCreatedAt, :lastId)
ORDER BY created_at DESC, id DESC
LIMIT 20;
排序键必须包含 ID 作为决胜字段,否则同一时间戳的多行在翻页边界会抖动。这也是"ID 该参与排序"的唯一正当理由:做 tie-breaker,不做业务顺序。
7. ID 里带信息:收益与泄露
| 携带信息 | 收益 | 泄露风险 |
|---|---|---|
| 时间戳 | 按时间范围扫描、归档、定位问题单 | 暴露业务量(两个 ID 相减得订单增量) |
| 机器/分片 | 直接路由、排查定位 | 暴露集群规模与拓扑 |
| 业务线/类型 | 少一次查表、便于分库 | 暴露业务结构,便于定向枚举 |
| 自增序列 | 调试友好 | 暴露总量与增长速度 |
订单号是最典型的例子:20261109000123 这样的号既好读又好猜,竞对按天差两个号就能估出日单量。折中做法是内部 ID 保持信息量,对外 ID 另发一个映射:
internal: 1957203915486720001 (Snowflake,带时间与机器)
external: ord_7QK3M9XZ2A (随机 base32,唯一索引映射回内部)
代价是一次额外的映射查询(可缓存)和一张映射表。收益是内部 ID 可以随便改结构、可以分片、可以扩容,而对外契约不变。
8. 迁移:老数据双 ID 并存
存量表换 ID 是长尾工程,不能一把梭。可行的路径是四步,每一步都可回滚:
| 阶段 | 动作 | 验证点 |
|---|---|---|
| 1 | 新增 new_id 列(可空),写入时双写 |
新数据两列都有值 |
| 2 | 回填历史数据,分批 + 限速 | 校验无空值、无重复 |
| 3 | 读路径逐步切到 new_id,写路径继续双写 |
灰度对比两列映射一致 |
| 4 | 建唯一索引、切主键、下线 old_id |
回滚点保留一个发布周期 |
-- 回填按主键区间推进,避免长事务与全表锁
UPDATE orders SET new_id = :generated
WHERE id BETWEEN :from AND :to AND new_id IS NULL
LIMIT 2000;
回填期间要接受"两列不一致"的存在:外部系统可能按旧 ID 写入。所以第 4 步之前必须有一段只读校验期,用抽样比对确认没有新数据只写了旧列。
9. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 主键冲突 | 多库同起点自增、号段重发、workerId 重复 | 检查步长配置与号段分配的乐观锁;给 workerId 做集中注册 |
| ID 出现负值 | Snowflake 纪元设置晚于当前时间,或位移溢出 | 核对 EPOCH 与位数分配,别用有符号位 |
| 前端拿到的 ID 末位变了 | JSON number 精度丢失 | 全部 64 位 ID 序列化为字符串 |
| 回拨窗口内出现重复 | 静默抹平时间,未检测回拨 | 显式检测 + 告警 + 拒绝或等待 |
| 翻页出现重复/漏项 | OFFSET 分页 + 并发插入 |
改游标分页,排序键带 ID |
| 写入 TPS 上不去 | 随机主键导致页分裂、二级索引膨胀 | 换趋势递增,或改非聚簇表结构 |
10. 边界:什么时候不该上分布式 ID
| 场景 | 判据 | 该怎么做 |
|---|---|---|
| 单库单表 | 一张表能撑到可预见规模 | 就用自增,别提前抽象 |
| 量小但要分库 | 日增万级以内 | 号段模式最省事,一个 biz_tag 就够 |
| 强可读要求 | 运营要能口头报单号 | 接受非递增,用"日期 + 随机段" |
| 多租户对外暴露 | ID 会进 URL | 对外 ID 随机,内部随意 |
| 已有全局唯一业务键 | 订单号、身份证、SKU | 用业务键做唯一约束,ID 只做代理 |
反过来的信号很明确:写入量已经让单库自增成为瓶颈,或数据已经分片且需要跨片唯一——这时才值得引入发号器。提前上 Snowflake 的典型后果是把一个"没有的问题"换成了一个"时钟回拨的问题"。
11. 自测
- 唯一、趋势递增、不可猜测、可读,为什么不可能四个同时满足?
- 号段模式为什么必然跳号?跳号在什么业务里是真问题?
- 时钟回拨的三种处理,各自把代价转移给了谁?
- "UUID 伤索引"这个结论依赖哪个前提?换成非聚簇表还成立吗?
- 为什么排序键里必须带 ID?它承担的是业务顺序还是别的角色?
- ID 里带时间戳,泄露的到底是什么信息?