跳过导航

如何设计一个可演进的分布式 ID 服务?从雪花算法到号段模式

约 5 分钟...次浏览
专栏分布式稳定性与工程实践第 3 篇

分布式 ID 的要求常被写成“全局唯一”,真实系统还关心吞吐、可用性、是否趋势递增、长度、信息泄露、跨机房和故障恢复。没有一种算法在所有维度最优。

常见方案

数据库自增简单且有序,但中心库成为容量和可用性依赖;多步长分片扩容困难。随机 UUID 本地生成、去中心化,但 128 位随机键索引大且容易导致 B+ 树页分裂,字符串表示更浪费空间。RFC 9562 定义的 UUIDv7 具有时间有序前缀,通常比 UUIDv4 更适合数据库索引,但仍要使用成熟实现、验证同毫秒单调性,并优先以 16 字节二进制存储。号段模式从数据库批量领取区间,在内存发号,数据库短暂不可用时仍可工作。雪花算法组合时间戳、节点号和序列号,吞吐高且趋势递增,但依赖时钟和节点身份。

典型 64 位布局:

0 | 41-bit timestamp | 10-bit worker | 12-bit sequence

位数必须由寿命、节点数和单毫秒峰值推导,不能照搬。时间戳纪元一旦发布就属于协议,修改会造成碰撞或排序异常。

经典 41 位毫秒时间戳只能覆盖约 69.7 年,12 位序列意味着单节点理论上每毫秒 4096 个 ID;真实上限还受锁竞争、缓存行、批量接口和时钟策略影响。趋势递增也不等于全局严格按生成时间排序:不同节点时钟和网络到达顺序都会改变观测次序。

时钟回拨

NTP 校时、虚拟机迁移可能使当前时间小于上次发号时间。直接继续生成会重复。可选策略:小幅回拨时等待;使用逻辑时钟;切换备用 worker 位;大幅回拨时停止发号并告警。任何策略都要明确最大阻塞时间,不能静默无限等待。

if (now < lastTimestamp) {
    throw new ClockMovedBackwardsException(lastTimestamp - now);
}

仅抛异常保证不重复,却降低可用性;生产通常结合容忍窗口、逻辑时间和实例摘流。System.nanoTime() 适合测量本进程经过时间,却不能直接替代可跨重启、跨节点编码进 ID 的墙上时间;进程重启后的最后时间戳与节点租约仍需持久化或由协调协议约束。

节点号分配

用 IP 末位或随机数作为 workerId 不能保证唯一,容器重建和跨网段会碰撞。应由协调存储租约分配,并用进程实例身份、租约代次(fencing token)和 TTL 防止复用;或者由部署平台注入稳定且唯一的编号。节点必须在租约到期前留出安全窗口停止发号,协调端也不能在旧持有者仍可能运行时过早复用编号。仅写一句“网络分区时停止”并不足以证明安全,因为被分区节点未必知道自己已失去租约。

号段的双缓冲

当前号段使用到一定比例时异步加载下一个号段,切换时不阻塞请求。数据库通过乐观更新原子地推进最大值:

UPDATE id_segment SET max_id=max_id+step,version=version+1
WHERE biz_tag=? AND version=?;

号段会因实例崩溃产生空洞,因此 ID 只能承诺唯一与大致有序,不能承诺连续。财务票据若要求法定连续编号,应使用独立业务规则。

可演进协议

ID 中若编码机房、业务类型,会方便排障却泄露规模并固化拓扑。可预留版本位,或将解析仅作为诊断能力,不让业务依赖位含义。Java long 是有符号 64 位;若 ID 保持最高位为 0,数据库通常直接用有符号 BIGINT 最省事。使用 MySQL BIGINT UNSIGNED 会超过 Long.MAX_VALUE,必须明确 JDBC、ORM、JSON 和 Java 类型的映射策略。UUID 优先使用 BINARY(16),不要默认存成带连字符字符串。

生产检查清单

  • 峰值是否按单实例、单毫秒测算并留余量?
  • workerId 是否有租约和冲突检测?
  • worker 租约是否有 fencing token、停止安全窗口和延迟复用策略?
  • 时钟回拨策略是否演练过?
  • 监控是否包含序列耗尽、时钟偏差、号段余量和生成延迟?
  • 下游是否错误依赖 ID 连续性或严格时间顺序?
  • 算法升级是否有版本兼容和碰撞验证?

ID 服务真正困难的不是位运算,而是故障条件下仍然守住唯一性,并让协议能够跨越多年演进。

分享:
文章作者:狼码纪
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。文章可能参考了其他优秀文章,如有侵权请联系删除。