数据库自增列足以支撑单库业务。系统拆成多个写入节点后,ID 生成突然变成一项基础设施能力:它要避免碰撞,不能拖慢写路径,还要经受扩容、时钟异常、数据库切换、配置漂移和跨地域网络分区。

这类系统最容易从算法名称开始讨论:UUID、Snowflake、数据库序列、号段。算法当然重要,但架构并不是从算法列表里挑出来的。它来自一组可验证的约束:唯一到什么范围,顺序要强到什么程度,故障时允许停多久,ID 可以暴露什么信息,以及团队能维护多少协调状态。

先定义 ID 的合同

“生成一个全局唯一且递增的 ID”看似明确,实际混合了几种不同语义。若不拆开,评审阶段达成的共识很可能只是每个人对同一句话的不同理解。

唯一性有作用域

唯一性至少要说明三个边界:

  • 命名空间:全公司、单业务、单租户、单表,还是单个分片。
  • 时间范围:进程存活期间、数据保留期,还是系统整个生命周期。
  • 保证方式:确定性不重叠,还是碰撞概率低到工程上可接受。

数据库序列和合理分配的 Snowflake 节点空间可以给出确定性的不重叠保证。UUIDv4 依靠足够大的随机空间降低碰撞概率。两者都常被称为“全局唯一”,但证明路径并不相同。

命名空间是一个容易被漏掉的参数。同一套发号集群可以按业务键隔离序列;不同系统也可以用固定前缀、版本位或独立数据库避免相撞。只说“全局”而不写清全局的边界,无法形成可测试的契约。

顺序不是单一属性

分布式 ID 常见的顺序要求可以分成四级:

语义 含义 典型用途
单节点单调 同一生成器后发的 ID 更大 日志排序、局部游标
时间可排序 大部分情况下,较晚生成的 ID 排在后面 B+Tree 写入局部性、粗略时间排序
全局严格单调 任意两个成功请求都能得到统一先后关系 少数强序号场景
提交顺序 事务先对外完成,排序键就一定更小 审计、跨地域一致读

Snowflake 和 UUIDv7 可以提供时间可排序的标识,但不能自动提供事务提交顺序。客户端 A 先拿到 ID 100,客户端 B 后拿到 101,B 完全可能先提交。此时按 ID 排序会得到“取号顺序”,而不是“业务生效顺序”。

若业务真正需要提交顺序,序号必须在事务提交边界产生或由事务系统提供提交时间戳。把更复杂的位布局塞进 ID,无法修复这个语义缺口。

空洞、复用和持久化边界

高可用发号通常会产生空洞。数据库事务回滚、节点预取一段号码后宕机、进程重启放弃本地缓存,都会让部分 ID 永远不被使用。

空洞和重复是两类问题:

  • 空洞通常只影响观感和容量利用率。
  • 重复会破坏主键、幂等键和数据关联,属于正确性故障。

因此,发号系统可以承诺“不复用”,很少应该承诺“无空洞”。PostgreSQL 的序列文档也明确说明,序列值不会随事务回滚,无空洞序列需要代价更高的锁与串行化。发票号、票据号等受监管编号应单独建模,不能直接复用通用分布式 ID 的可用性假设。

表示形式也属于架构

位宽决定容量、寿命和协议兼容性。64 位整数适合数据库主键和多数后端语言,但 JavaScript 的安全整数只有 53 位,跨端 API 通常应使用字符串承载。128 位 UUID 有标准格式和更大的空间,代价是索引、更宽的网络字段与存储占用。

ID 是否可枚举也要提前决定。连续整数会泄露业务规模,含时间字段的 ID 会暴露大致创建时间,某些节点字段还可能泄露拓扑。随机性可以降低猜测难度,却不能替代权限校验。OWASP 将对象级授权缺失列为独立风险,无论对象 ID 是整数、UUID 还是字符串,服务端都必须验证调用者是否有权访问该对象。

对外业务编号和内部主键也应分开。{biz}{date}{sequence} 适合作为订单号、工单号等展示字段;如果序列按天归零,日期就必须参与唯一性范围。它不应顺带承担数据库主键、事务顺序和权限校验。跨端传输时仍宜使用字符串,避免前导零或大整数精度丢失。

从约束走向方案

方案选择可以压缩成一棵决策树。关键不是寻找功能最多的算法,而是停止引入不需要的协调。

flowchart TD
    A[定义唯一性作用域与寿命] --> B{需要严格提交顺序吗}
    B -- 是 --> C[在事务或共识层生成提交序]
    B -- 否 --> D{必须使用 64 位整数吗}
    D -- 否 --> E{需要时间可排序吗}
    E -- 否 --> F[UUIDv4]
    E -- 是 --> G[UUIDv7]
    D -- 是 --> H{可以可靠管理节点身份与时钟吗}
    H -- 是 --> I[Snowflake 类方案]
    H -- 否 --> J{可接受每次访问中心存储吗}
    J -- 是 --> K[数据库序列或 Redis INCR]
    J -- 否 --> L{可依赖数据库且允许空洞吗}
    L -- 是 --> M[号段模式]
    L -- 否 --> N[重新协商位宽、顺序或可用性]

集中计数器:最直接的序列化点

数据库序列或 Redis INCR 都能把“下一个号码”收敛到一个中心状态。每次请求都访问中心存储,语义直观,适合流量适中、已经依赖该存储且能够接受中心故障影响发号的系统。

Redis INCR 会把整数值原子加一;key 不存在时按零处理。INCRBY 可以一次推进较大的步长,因此也能用于预留号段。后一种用法不再让每个业务请求访问 Redis,而是由节点批量取号,再从本地内存发放。

把 Redis 当作持久发号源时,唯一性证明不能停在“命令是原子的”。Redis 默认异步复制,故障切换可能丢失主节点已经确认、但尚未复制的写入;若计数器因此回退,恢复后的序号可能与已落库 ID 重复。RDB、AOF 及其刷盘策略决定可恢复到哪个位置,却不会自动证明高水位永不倒退。无法接受重复时,应在恢复后校准到已知最大值之外,或者改用具备可靠持久化边界的数据库序列、号段服务。

按日期拆 key 可以分散生命周期并方便运营查询,例如 id:order:20260904。但每日序列会重复,最终编号必须包含日期或其他不重叠前缀。ORD202609040000001 是业务编号,不是“一个递增数字就天然全局唯一”。

UUID:省掉发号服务

如果业务只需要低碰撞概率的标识,UUIDv4 往往已经够用。它不依赖数据库、注册中心或节点编号,调用方可以在本地生成。RFC 9562 定义的 UUIDv4 有 122 位随机或伪随机数据,适合不要求数值递增的场景。

UUIDv7 把 48 位 Unix 毫秒时间放在高位,并为剩余部分保留随机数或单调构造空间。它改善了按字节排序和数据库索引局部性,同时保留了无中心生成的优势。

UUIDv7 仍需处理本地时钟回退和同一毫秒内的生成顺序。实现可以等待时钟追平、维护单调计数器或拒绝继续发号。RFC 提供了构造规则,却不会替部署环境消除时钟故障。

适合 UUID 的条件很清楚:

  • 接受 128 位或字符串表示。
  • 不需要确定性的全局严格单调。
  • 希望调用方本地生成,减少运行中的基础设施依赖。
  • 可以接受随机碰撞概率模型,或通过数据库唯一约束做最后防线。

Snowflake:用节点空间换取本地吞吐

原始 Twitter Snowflake 把 64 位整数拆成时间、数据中心、节点和同毫秒序列。经典布局是 41 位时间、5 位数据中心、5 位节点、12 位序列;最高位保持为 0,使结果落在有符号 64 位正整数范围内。

生成路径只读取本地时间和进程内计数器,因此延迟低、吞吐高。协调被移出了每次请求,转移到两个低频问题:谁有权使用某个节点编号,以及时钟异常时谁负责阻止重复。

位布局不是标准答案。它是一份容量预算:

1
2
3
单时间片单节点容量 = 2 ^ sequence_bits
可并行节点数 = 2 ^ worker_bits
时间字段寿命 = 2 ^ time_bits × 时间单位

给节点位多分一位,就会从时间或序列中少一位。将时间单位从毫秒改成 10 毫秒可以延长寿命,但会压缩单时间片内的吞吐余量。不同衍生实现采用不同布局,不能把某个实现的容量数字当成 Snowflake 的固有属性。

Snowflake 的真正难点在控制面:

  • 节点编号必须在有效期内唯一,进程重启和容器漂移不能造成重用。
  • 节点租约过期后,需要纪元或隔离期阻止旧进程继续发号。
  • 时钟回退必须被检测,策略可以是等待、拒绝、使用逻辑时间或切换纪元。
  • 序列溢出后只能等待下一个时间片,延迟指标必须能看见这类停顿。

原始实现遇到时钟回退会直接拒绝发号。这个选择保护了唯一性,却把时钟可靠性变成可用性前提。如果业务要求时钟异常期间继续服务,就要增加纪元位、持久化逻辑时钟或外部时间协调,并承担更多状态管理成本。

号段:把数据库协调摊薄

号段模式适合不愿把唯一性绑定到机器时钟,又已经有可靠关系数据库的系统。数据库不为每个请求分配一个 ID,而是原子地把某个业务序列的高水位推进一大段。应用节点取得这段号码后,在内存中逐个发放。

从 Ticket Server 到号段预留

Flickr 的 Ticket Server 是集中计数器的经典实现:专用 MySQL 表只保存发号状态,通过 REPLACE INTO 推进自增值,再用 LAST_INSERT_ID() 取回号码。它把发号从业务分库中剥离出来,以一个明确的序列化点换取跨库唯一。

两台 Ticket Server 可以把 auto-increment-increment 都设为 2,并分别使用偏移 1 和 2,从而只生成奇数或偶数。两套空间天然不相交,不需要依赖主从复制来判定下一个号码。这是静态分区,不是可随意扩缩容的多主复制;增加或移除节点会改变模数与偏移,必须先划出新的不相交空间,不能直接原地改配置。

号段模式沿着同一思路减少了中心协调频率:数据库不再为每个请求发一个号,而是一次预留一段,节点随后在本地发放。唯一性仍由持久化高水位保证,热路径则不再承担一次数据库往返。

原子预留一个号段

一种通用的分配语义是:

1
2
3
UPDATE id_alloc
SET max_id = LAST_INSERT_ID(max_id + :step)
WHERE namespace = :namespace;

同一数据库连接随后读取 LAST_INSERT_ID(),得到新的高水位 new_max,本次独占区间就是 (new_max - step, new_max]。MySQL 文档说明,LAST_INSERT_ID(expr) 保存的是当前连接状态,因此更新与读取必须在同一连接上完成。连接池封装如果把两条语句拆到不同物理连接,这个协议就失效。

这套协议把数据库请求率近似降低为:

1
数据库补段 QPS ≈ ID 生成 QPS / 每段长度

节点还可以维护双缓冲:当前号段消费到某个阈值时,异步预取下一段;当前段耗尽后直接切换。双缓冲降低了数据库抖动传到调用方的概率,但没有创造无限可用性。当前段和下一段同时不可用时,发号仍然必须失败或限流。

号段长度需要从负载和故障窗口推导:

1
建议段长 ≥ 峰值 QPS × 目标补段间隔 × 安全系数

段太短会提高数据库压力,使一次抖动更快暴露给调用方;段太长会增加节点宕机时浪费的号码,也会拉大不同节点 ID 的跨度。自适应步长可以根据上一段的消费时间调整,但必须设置上下界,避免瞬时流量把段长永久推高。

号段模式的持久化边界是数据库高水位。数据库从旧快照恢复、跨集群迁移遗漏最新值,都会让高水位倒退。恢复后不能直接开放发号,应先将每个命名空间的水位推进到已知最大值之外,或切换到一个不相交的新纪元。

强顺序:把协调放回提交边界

若需求是“先完成的事务一定有更小的排序键”,独立发号服务无法单独兑现承诺。取号与提交之间存在任意长的业务处理、网络延迟和重试,提交顺序可以随时反转。

强顺序需要一个能够观察提交边界的序列化点,例如单主日志、共识协议决定的日志位置,或具备外部一致性语义的分布式事务系统。Google Spanner 使用 TrueTime 表达时钟不确定区间,并在提交协议中等待,从而让提交时间戳符合外部可观察的事务顺序。

这类能力会增加跨节点协调、尾延迟和分区期间的可用性成本。若用途只是主键、分库路由或粗略排序,引入强顺序属于过度设计。若用途是审计或跨地域事务可见顺序,则应明确支付这笔成本,不应由 Snowflake 的时间高位冒充。

架构由数据面和控制面组成

算法选定后,还需要把职责放到正确的运行路径。低延迟发号属于数据面;节点租约、配置、纪元、迁移和水位修复属于控制面。

flowchart LR
    Client[业务客户端] --> API[ID API 或 SDK]
    API --> FastPath[本地快速路径]
    FastPath --> Clock[时间与序列状态]
    FastPath --> Segment[当前号段]

    Control[控制面] --> Lease[节点租约与纪元]
    Control --> Config[命名空间与位布局版本]
    Control --> Guard[冲突检测与停发开关]

    Store[(持久化分配状态)] --> Segment
    Store --> Control
    Metrics[指标与审计] <-->|状态| API
    Metrics <-->|状态| Control

不是每种实现都需要图中的全部组件。UUIDv4 可以只保留 SDK 和数据库唯一约束;简单号段服务不需要时钟状态;单机房且节点由静态配置管理的 Snowflake 可以暂时不建设复杂租约系统。组件是否存在,取决于它对应的风险是否真实存在。

API 合同

发号接口至少要固定这些字段:

  • namespace:隔离业务序列和容量策略。
  • format_version:让位布局或编码方式可以演进。
  • 返回类型:明确是有符号 64 位整数、无符号整数还是字符串。
  • 错误语义:区分过载、时钟回退、租约失效、补段失败和配置不一致。
  • 批量能力:批量取号要说明返回值是否连续,以及失败时是否整批作废。

失败时返回随机数作为“兜底”会破坏系统最重要的不变量。正确的降级通常是拒绝、限流、切换到预先设计且不相交的备用空间,或者让上游进入可重试状态。

配置也是唯一性证明的一部分

位布局、时间纪元、节点编号范围、数据中心编号和号段步长不能只被视作调优参数。前几项直接参与唯一性证明,配置漂移等价于让两个生成器采用不同的数学规则。

启动时应校验配置版本和取值范围。运行中若发现本地版本落后、节点租约失效或纪元冲突,应停止发号,而不是继续使用缓存配置。对正确性参数采用 fail closed,会牺牲局部可用性,却避免更难修复的重复 ID。

故障模型决定保护机制

正常路径的基准测试只能证明速度,无法证明系统在生产环境中仍然唯一。设计评审应逐项回答故障发生后是否会重复、停多久、如何恢复。

故障 直接风险 建议机制
时钟回退 回到已经使用过的时间片 等待、拒绝、逻辑时间或切换纪元
节点编号重复 不同进程生成相同位组合 租约、唯一约束、纪元与旧进程隔离
序列耗尽 同一时间片无可用编号 等待下一时间片并记录尾延迟
数据库补段失败 当前段耗尽后不可用 双缓冲、提前补段、余量告警和限流
数据库恢复旧快照 高水位倒退导致区间重发 停发、对账、推进水位或切换新纪元
配置漂移 位布局或空间边界不一致 版本校验、集中发布、异常时停发
网络分区 多地域同时占用同一空间 静态拆分空间或使用共识分配
进程重启 本地缓存号段丢失 放弃剩余区间,永不复用

最危险的故障往往不是依赖完全宕机,而是系统仍能运行,持有的状态却已经过期。典型情况包括失去租约的旧进程、恢复到旧水位的数据库,以及拿着旧位布局继续发号的实例。健康检查若只看进程和端口,发现不了这些问题。

跨地域设计先选择一致性边界

跨地域部署有两条主要路径。

第一条是静态拆分 ID 空间。地域、单元或纪元占用不相交的前缀,每个故障域在本地独立发号。网络分区时仍可用,代价是不能得到全局严格递增,容量也要提前切分。

第二条是所有地域共享一个强一致分配域。共识组决定节点租约、高水位或全局顺序。它简化了唯一性证明,却把跨地域网络延迟和多数派可用性带入热路径或补段路径。

异步复制的多主计数器不能同时提供这两条路径的优点。分区期间两个主节点可能从相同水位继续分配,恢复复制后再处理冲突已经太晚,因为重复 ID 可能早已进入业务表。若要多主独立工作,必须事先切出不相交的空间。

容量规划要留下可观测的余量

Snowflake 类方案要计算三个上限:

  • 每个时间片的序列容量和溢出等待概率。
  • 节点编号容量,以及灰度、扩容和临时实例占用的余量。
  • 时间字段寿命,以及纪元结束前的格式迁移窗口。

号段方案要计算另外三个上限:

  • 峰值流量下,一段号码能支撑多久。
  • 数据库最长抖动期间,本地两段缓存能否继续服务。
  • 一次节点故障最多浪费多少号码,业务是否接受。

容量告警不应只盯 CPU 和 QPS。更有价值的指标包括:

指标 说明
每时间片序列使用率 提前发现 Snowflake 序列接近耗尽
时钟回退次数与最大幅度 判断时间源和虚拟化环境是否稳定
节点租约冲突、失效次数 发现 worker 空间被重复占用
当前号段余量与可用秒数 预测数据库故障何时传导到调用方
补段延迟及失败率 发现数据库抖动和连接池问题
拒绝发号次数 判断保护机制是否正在影响业务

监控还需要按命名空间区分。总量正常不能说明某个热点业务没有耗尽序列或号段。

迁移比初次上线更容易破坏唯一性

位布局一旦进入生产数据,就成为长期协议。改变节点位数、时间纪元或编码格式,会影响数据库类型、分片路由、排序和下游解析。

安全迁移通常包含四个阶段:

  1. 给新格式分配明确版本或不相交的数值空间。
  2. 让消费者先具备同时读取新旧格式的能力。
  3. 灰度切换生成端,并监控碰撞、排序和容量指标。
  4. 等旧生成器、离线任务和缓存全部退出后,再结束兼容窗口。

号段系统迁移数据库时,还要携带每个命名空间的高水位。只复制表结构和静态配置不够。Snowflake 系统迁移注册中心时,要防止新旧控制面同时向不同进程授予同一个节点编号。

回滚也必须提前设计。如果新旧格式共享同一空间,简单回滚二进制可能重新使用已经分配过的编号。版本位、纪元或双写水位的价值,主要体现在这种不顺利的路径上。

选型表

需求组合 优先方案 需要接受的边界
无中心、本地生成、不要求整数 UUIDv4 概率唯一、索引随机写入
无中心、128 位、希望按时间排序 UUIDv7 暴露时间,单调性依赖实现
64 位、本地高吞吐、时间大致有序 Snowflake 类 管理节点身份和时钟异常
64 位、流量适中、可接受每次访问中心存储 数据库序列或 Redis INCR 中心存储进入热路径,恢复语义必须可靠
64 位、不信任时钟、已有可靠数据库 号段模式 允许空洞,维护高水位和补段路径
跨地域严格提交顺序 事务/共识系统的提交序 更高协调成本和尾延迟
受监管的连续业务编号 独立业务序列 锁、串行化和较低可用性

一个系统也可以组合多种方案。数据库主键使用 Snowflake,外部资源 ID 使用随机 UUID;订单表使用普通分布式 ID,发票号使用受事务约束的独立序列。不同语义用不同标识承载,通常比让一个 ID 同时负责排序、安全、路由和业务编号更稳妥。

设计评审清单

上线前可以用以下问题检查方案是否闭环:

  • 唯一性的命名空间、寿命和保证方式能否写成可测试条件?
  • ID 顺序究竟代表生成时间、单节点顺序,还是事务提交顺序?
  • 时钟回退、节点租约失效和数据库水位倒退时,系统是否会停发?
  • 计算出的节点数、时间寿命、单时间片容量或号段故障窗口是否覆盖峰值?
  • 配置和格式升级是否有版本、灰度、回滚与旧实例隔离方案?
  • ID 是否暴露时间、规模或拓扑;对象权限是否独立校验?
  • 指标能否在号码耗尽或正确性保护触发前给出告警?

可复用的推导方法

分布式 ID 设计中有几条能迁移到其他基础设施的规律。

先定义语义,再选择机制

“递增”拆成局部单调、时间可排序、全局顺序和提交顺序后,很多争论会自然消失。队列 offset、版本号、时间戳服务也适用同样的方法:先写出观察者能验证的语义,再讨论实现。

把协调移到低频路径

集中计数器在每次取号时协调,结构简单,却会让中心存储进入热路径。Snowflake 只在节点编号分配时协调,号段模式只在批量预留时协调,正常发号留在本地。这种设计用少量预分配和状态租约换取热路径性能,常见于令牌、配额和分片路由系统。

用不相交空间隔离故障域

地域前缀、worker 位、号段和纪元的共同作用,是提前划分不会相撞的空间。只要边界不重叠,各故障域可以独立运行;代价是空间利用率下降以及全局顺序变弱。

不让标识承担授权和事务语义

ID 可以帮助定位对象,不负责证明调用者有权读取对象;时间高位可以帮助粗略排序,不负责证明事务先提交。职责分离能防止看似便利的编码格式演变成安全或一致性漏洞。

一个可维护的分布式 ID 系统最终应交付四项结果:语义合同、不重叠空间证明、异常停发与恢复规则、容量和正确性指标。算法只是其中的生成函数,架构价值主要体现在函数之外。

参考资料