3115 字
约 10 分钟
17
S17-A06 · 分布式 ID 与全局排序:方案与代价

分库之后第一个炸的不是事务,是 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. 自测

  1. 唯一、趋势递增、不可猜测、可读,为什么不可能四个同时满足?
  2. 号段模式为什么必然跳号?跳号在什么业务里是真问题?
  3. 时钟回拨的三种处理,各自把代价转移给了谁?
  4. "UUID 伤索引"这个结论依赖哪个前提?换成非聚簇表还成立吗?
  5. 为什么排序键里必须带 ID?它承担的是业务顺序还是别的角色?
  6. ID 里带时间戳,泄露的到底是什么信息?
S17-A06 · 分布式 ID 与全局排序:方案与代价
https://www.clxhxhhr.top/posts/6133/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。