生成一个 ID 而已,怎么就变成了事故现场:分布式 ID 的五层坑

2026-10-28 12 0

生成一个 ID 而已,怎么就变成了事故现场

先说结论:接口报错、订单查不到、缓存击穿、数据被覆盖——这些让人抓狂的线上事故,有一类共同的源头,是那个看起来最不起眼的东西:ID。

ID 是什么?就是一串数字。它不参与业务逻辑,不承载业务语义,不值得一个后端工程师多看一眼。于是所有人都默认它是对的,直到某天它重复了。

这篇文章不讲"如何生成唯一 ID"。我讲的是:这个看起来两行代码就能搞定的需求,在真实系统里到底有几种翻车姿势,以及你到底该选哪一把刀。


一、自增 ID:省事,然后省掉你的数据库

任何一个正经的业务系统,第一反应都是 id BIGINT AUTO_INCREMENT。它有所有优点:单调递增、索引友好(顺序写入,页分裂少)、人眼可读、天然不重复。

然后它开始崩溃:

  • 分库分表:你要么引入全局发号器(又绕回来了),要么接受 ID 冲突。
  • 分库分表后的扩容:雪花算法写死在代码里的那 5 位机房号 + 5 位机器号,扩容时就是一场运维赌博。
  • 信息泄露:自增 ID 意味着"我们一共卖出了 1374 万单"是可以被推断出来的。相邻的两个订单号只差 1,爬虫只要遍历一遍,就能把你整个平台的订单详情、用户信息、地址、电话全部扒干净——这不是理论风险,是被反复验证过的真实攻击手法。

换句话说,自增 ID 既撑不住规模,又天生往外喊"我有多少用户"。


二、UUID:无序是一场慢性自杀

UUID v4 随机生成,分布式友好,不需要任何协调。但它有个致命问题:随机。

你的主键是 InnoDB 聚簇索引。随机 UUID 写进去,意味着每一行都可能落在 B+ 树的任意一页:

  • 页分裂激增,每次插入都可能把一个页劈成两个,随机 IO 剧增;
  • 页空间利用率掉到 50% 以下(分裂后一半空间浪费);
  • Buffer Pool 命中率下降,因为工作集被随机打散;
  • 写放大严重——1KB 的行,索引里可能占好几 KB。

实测很容易做:同一张表,主键用自增和用 UUID v4,同样的插入量,后者慢几倍不是玄学。

所以出现了 UUID v7:把 48 位毫秒时间戳放在最前面,后面跟随机位。它既保留了 UUID 的全球唯一性,又重新变成了"大致有序",B+ 树的痛点一下就缓解了。如果你今天要用 UUID,v7 是当前的最优解,没有之一。

一个反直觉的结论:在数据库主键这个场景下,"有序"的价值远大于"随机"的安全感。安全性应该靠对外做 ID 映射来解决,而不是靠把主键搞乱。


三、雪花算法:它不是不会重复,只是重复的条件比较刁钻

雪花算法把一个 64 位整数切成四段:

0 | 41 位时间戳 | 5 位数据中心 | 5 位机器 | 12 位序列号

毫秒级时间戳 41 位大约能用 69 年;每毫秒每台机器能生成 4096 个 ID。这个结构精巧得像瑞士手表,也脆得像瑞士手表——因为它的正确性依赖两个外部前提。

前提一:机器号不能重复

5 位机器号意味着同一数据中心最多 32 台机器。如果你把雪花算法部署到 40 个 Pod 上,要么共用机器号(然后序列号互相打架),要么就得靠 Zookeeper/etcd 抢号段分配(然后你就有了一个需要高可用的协调服务,只为了发 ID)。

更隐蔽的是:机器号分配的持久化。很多实现是从数据库读一个 max(worker_id)+1,用完就自增。这在测试环境没问题,在生产环境等于把"发号器"这个单点故障又请回来了——你只是把它藏得更深了。

前提二:时钟不能回拨

时间戳是雪花算法的地基。如果 NTP 把你的时间往回拨了 5 毫秒,同一台机器在"过去的 5 毫秒"里会生成出一批 ID,而这几个 ID 在未来的时间里会被再次生成一遍。重复就这么来了。

三种常见的处理方式,各有各的烂:

  • 直接抛异常:最诚实,但业务直接挂。时钟抖一下服务就不可用,这是在赌运维的胆量。
  • 等待时钟追上来:如果回拨只有几毫秒,等待是可接受的;如果回拨是几百毫秒甚至几秒(虚拟机迁移、宿主机 NTP 跳变都会),你的接口就出现几百毫秒的毛刺。
  • 切换备用实例:预留一个 worker_id 专门处理回拨窗口内的生成,用完为止。听着周全,其实你是在给"时钟"这个最不可控的组件打补丁——补丁本身也可能出 bug。

我的偏好是:回拨超过阈值就直接拒绝服务并报警,同时保留一个短窗口的等待兜底。系统宁可短暂不可用,也不要静默地发出重复 ID——后者会在下游的某个地方炸开,排查成本是前者的十倍。

前提三(隐藏的):前端会算错它

64 位整数,超过 JavaScript 的安全整数上限 2^53 - 1。雪花算法出来的 ID,末位序列号加上时间戳后段,很容易突破这个上限。然后:

// 后端返回:
{ "id": 1543265701927041024 }

// 前端 JSON.parse 之后:
1543265701927041000   // 精度丢了,末几位被抹平

// 用户点回来提交这个 ID:
1543265701927041000   // 数据库主键是 ...41024,查不到

这个 bug 的恶劣之处在于:它不在后端复现,也不在数据库复现,只在浏览器里发生,而且只在某些 ID 段发生(序列号接近 4095、时间戳余数靠近某个区间时)。你会收到"偶发的、无法复现的订单丢失"工单,然后怀疑人生。

解法不复杂:ID 永远以字符串形式传输。后端序列化成 "1543265701927041024",前端全程当字符串处理,绝不参与任何数值运算。这一条建议能救下你一个季度。


四、号段模式:把发号器做成"批量取号"

号段模式(美团 Leaf 的思路)换了个角度:不逐个申请,而是一次从数据库领一批。

-- 号段表
CREATE TABLE id_segment (
  biz_tag     VARCHAR(32) PRIMARY KEY,
  max_id      BIGINT NOT NULL,
  step        INT    NOT NULL,
  updated_at  TIMESTAMP
);

-- 取号段(应用层加锁或用数据库原子更新)
UPDATE id_segment
   SET max_id = max_id + 1000,
       updated_at = NOW()
 WHERE biz_tag = 'order'
 RETURNING max_id;
// 应用侧:双 buffer 切换
type Segment struct {
  currentID int64
  maxID     int64
  buf       []int64  // 预生成的 1000 个
}

func (s *Segment) Next() (int64, error) {
  if len(s.buf) == 0 {
    // buffer 用尽,去数据库领新号段
    max, err := s.loadSegment()
    if err != nil {
      return 0, err
    }
    s.currentID = max - int64(s.step)
    s.maxID = max
  }
  s.currentID++
  return s.currentID, nil
}

好处很直白:

  • 数据库压力大减——一次领 1000 个,QPS 直接除以一千;
  • ID 保持自增的递增性,B+ 树友好;
  • 数据库挂了不影响在途请求——本地 buffer 还能撑一段时间(这就是双 buffer 的意义:一个在用,一个在备);
  • 号段之间预留了跳跃空间,ID 不连续,天然降低了被遍历爬取的概率(虽然仍不解决安全问题)。

代价是号段用不完就浪费,以及号段表与内存状态的一致性要自己保证——这两点都很容易写错。

说句实在话:只要你的业务量还没到单表几千万,MySQL 自增主键都是最优解。别为了不存在的性能问题去造轮子。


五、你可能不想让别人知道的事

回到那个让人后背发凉的点:ID 本身是信息。

拆开一个雪花 ID:

  • 前 41 位是时间戳 → 别人能推算出你的服务器精确启动时间、业务上线时间、甚至你的日订单量增长曲线;
  • 中间的机器号 → 别人知道你用了多少台机器、机房规模多大;
  • 末尾序列号 → 如果是自增/连续号段,别人能遍历你所有的业务对象。

这不是理论洁癖。真实世界里,"订单号可枚举"直接导致的批量爬取、数据撞库、开源项目源码泄露,是反复发生的。

实操建议三条:

  1. 对外一律使用不透明 ID:内部用雪花/自增,对外做一层映射(数据库映射表,或者基于密钥的加密 ID,如 encrypt(id, key) 得到的密文做 ID)。
  2. 对外 ID 不暴露任何内部结构:别把时间戳、机器号编码进去。
  3. 给高频查询加校验:ID 可枚举的场景下,一定要用签名参数(timestamp + sign + nonce)做防重放和防遍历,光靠 ID 本身挡不住批量请求。

六、怎么选?一张表说清楚

  • 单体 / 低并发:数据库自增,没什么可犹豫的。
  • 分库分表但不想引依赖:号段模式(数据库领号),或第三方开源实现。
  • 多机房、极高并发、需要信息隐藏:雪花 + worker_id 外部分配(etcd/K8s StatefulSet 序号)+ 对外密文 ID。
  • 客户端/离线系统:UUID v7,别再用 v4 做主键。
  • 无论哪种:字符串传输、单独一张 ID 映射表对外、主键与业务编号分离。

写在最后

ID 设计的复杂度,几乎全部来自于一个残酷的事实:它是系统里唯一一个必须全局唯一、又必须长期稳定、还天天被外部用户看见的东西。

而多数人给 ID 分配的时间,是它还没有任何约束的那一刻。等它开始有约束了——开始有并发、开始有分库、开始有爬虫、开始有前端——你已经没有自由选择的余地了。

所以我现在的习惯是:新系统第一件事,不是设计表结构,而是先把 ID 的形态定下来。等它有了三百万行数据、三台服务器、两个前端团队的时候,你再想改,代价就是一次全量数据迁移和一个通宵。

而最容易被忽略的,永远是那条最朴素的建议:

ID 用字符串传。除非你能证明它是安全的。

就这一句,能省掉你未来某个季度的凌晨排障。

相关文章

别一上来就 Kafka:消息队列的五个真坑,和你必须算清的那笔一致性账
epoll 已经很能打了,Linux 干嘛还要再造一个 io_uring?
Rust 三大痛点,其实只有一个:所有权。读完你会想删掉所有 GC
NULL 不是空,是”未知”:一次凌晨两点的订单失踪,揪出 SQL 三值逻辑的真凶
TCP 被 QUIC 撕碎重写:HTTP/3 到底快在哪,又在哪儿翻车
你的 SQL 为什么这么慢?——从一条慢查询开始的性能破案

发布评论