雪花算法

噩梦开始:两台机器生成了相同的 ID 😱

部署了第二台服务器后,噩梦开始了。

我清楚地记得那个下午——监控报警疯狂闪烁,数据库报了一连串唯一键冲突错误。我打开日志一看,冷汗就下来了:

服务器 1 生成的 ID:1001 → 短码 "qU"
服务器 2 生成的 ID:1001 → 短码 "qU"  ← 💥 冲突了!

两台机器各自自增,竟然生成了相同的 ID!

单机时代,数据库的自增主键很好用。我把 url_mappings 表的 id 字段设为 AUTO_INCREMENT,一切都很完美。但现在,每台服务器都在独立生成 ID,根本不知道对方已经用过这个数字了。

我需要一个方案,让多台机器生成的 ID 也能保证全局唯一!

我冥思苦想了几种方案:

  • 中央发号器:搞一个独立服务专门分配 ID。但单点故障风险太大,而且会成为性能瓶颈。
  • 数据库号段模式:服务器 1 分配 1-1000,服务器 2 分配 1001-2000。但扩容麻烦,还可能浪费。
  • UUID:绝对唯一,但太长了(36 字符),而且无序,数据库索引性能差。

都不是很满意。直到我搜到了 Twitter 的 Snowflake 方案。

发现 Snowflake:像雪花一样独一无二 ❄️

2010 年,Twitter 开源了 Snowflake(雪花算法),专门解决分布式 ID 生成问题。这个名字很美——雪花每一片都是独一无二的,这正是我们需要的 ID 特性。

64 位 ID 的精妙结构

一个 Snowflake ID 是一个 64 位的整数,由四部分组成:

 0 | 0000...0000 | 00000 00000 | 0000...0000
 ↑       ↑              ↑             ↑
 │       │              │             │
符号位  时间戳(41位)   机器ID(10位)   序列号(12位)
(1位)                   │
                     ┌──┴──┐
                  数据中心  工作机器
                  (5位)    (5位)

我逐位分析了一下这个结构:

第 1 位:符号位

  • 永远是 0,保证 ID 是正数

第 2-42 位:时间戳(41 位)

  • 存储毫秒级时间戳(相对自定义纪元)
  • 41 位能表示的最大值是 2^41 = 2199023255552 毫秒
  • 换算成年:2199023255552 / 1000 / 60 / 60 / 24 / 365 ≈ 69 年
  • 够用了!我选择 2024-01-01 作为起始时间,能用到 2093 年

第 43-52 位:机器 ID(10 位)

  • 前 5 位是数据中心 ID(0-31),最多 32 个机房
  • 后 5 位是工作机器 ID(0-31),每个机房最多 32 台机器
  • 总共支持 32 × 32 = 1024 台机器

第 53-64 位:序列号(12 位)

  • 同一毫秒内的序列号
  • 12 位能表示 0-4095,也就是每毫秒最多生成 4096 个 ID
  • 换算成每秒:4096 × 1000 = 409.6 万个 ID

生成逻辑

Snowflake 的生成逻辑很清晰:

  1. 获取当前时间戳
  2. 如果是同一毫秒:序列号 +1(序列号满 4096 就等到下一毫秒)
  3. 如果是新的毫秒:序列号归零
  4. 组装 ID:时间戳 << 22 | 数据中心 ID << 17 | 机器 ID << 12 | 序列号

时间戳在高位,保证了 ID 趋势递增——这对数据库索引很友好。

我来实现一个 Snowflake

理解了原理,我开始动手实现。我把 Snowflake 拆成几个关键动作:

落地思路

  • 生成一个 ID 分三步:取当前毫秒时间戳、判断和上一次是不是同一毫秒、组装成 64 位整数。
  • 同一毫秒内序列号自增,超过 4095 就自旋等下一毫秒;换到新毫秒时序列号归零。
  • 组装公式是 时间戳 << 22 | 数据中心 ID << 17 | 机器 ID << 12 | 序列号,时间戳必须放在最高位。
  • 兜底是时钟倒退时不要接着发:这一条交给后面的回拨检测处理,宁可报错也不发出重复 ID。

运行一下:

生成 10 个雪花 ID:
  1847295608218624
  1847295608218625
  1847295608218626
  1847295608218627
  ...

ID 连续递增,很好!

解析 ID:验证正确性

为了验证我的实现是否正确,我写了一个解析函数,把 ID 还原成各部分:

落地思路

  • 解析就是反过来做一遍移位和掩码:时间戳 = id >> 22,序列号取最低 12 位,工作机器和数据中心各取自己那 5 位。
  • 解析出的时间戳再换算回绝对时间,和当前时间对一下,能验证纪元是不是用对了。
  • 拿一批连续生成的 ID 逐个解析,检查时间戳单调不减、序列号在同一毫秒内连续递增。
  • 兜底是解析函数只接受正数:符号位不为 0 说明这不是雪花 ID,直接报错,而不是解析出一个离谱的时间。

输出:

ID: 1847295608218624
生成时间: 2024-03-15 14:23:45.123
数据中心: 1
工作机器: 1
序列号: 0

完美!ID 确实包含了生成时间、数据中心、机器和序列号信息。

从雪花 ID 到短链接

拿到雪花 ID 后,我用 Base62 编码把它转成短链接:

落地思路

  • 雪花 ID 是 64 位整数,先转成字符串再做 Base62 编码,得到的就是可以直接放进 URL 的短码。
  • ID 是正整数,Base62 编码不会产生前导零字符,短码长度自然就是最短的那个。
  • 编码结果是变长的:ID 越大短码越长,本例里 19 位的 ID 编出来是 6 位短码。
  • 兜底是数据库那边的唯一索引仍然要留着:编码本身唯一,但存储层那道防线不能省。

输出:

雪花 ID: 1847295608218624
短链接码: 6HHb3G
短链接: https://s.url/6HHb3G

这就是我要的方案!

雪花算法的优缺点

实现了 Snowflake 后,我仔细思考了它的优缺点。

优点 ✅

优点说明
全局唯一机器 ID + 时间戳 + 序列号保证唯一
趋势递增时间戳在高位,ID 大致按时间递增,对数据库索引友好
高性能纯内存计算,无需网络请求,单机每秒 400 万+ ID
可反解可以从 ID 中提取生成时间和机器信息,方便排查问题
高可用不依赖外部服务(数据库、Redis),没有单点故障

我做了个性能测试:

落地思路

  • 压测就是在单个进程里循环调用生成函数,跑够 100 万次,统计耗时和唯一性。
  • 唯一性用集合校验:生成的所有 ID 去重后数量不变,说明没有重复。
  • 性能看的是纯计算耗时,不含网络和数据库——Snowflake 的优势正是不依赖任何外部服务。
  • 期望值落在本章给出的量级上(每秒几十万到百万级),远高于业务实际需要。

输出:

生成 1000000 个 ID
耗时:1.23 秒
速度:813,008 IDs/秒
唯一性:True(1000000 个唯一 ID)

单机每秒 80 万个 ID,完全够用了!

缺点 ❌

缺点说明
时钟依赖依赖系统时钟,时钟回拨会出问题
机器 ID 分配需要额外机制分配唯一的机器 ID
ID 较长64 位整数,转 Base62 后约 6-13 位

其中,时钟回拨是最致命的问题。

时钟回拨:Snowflake 的阿喀琉斯之踵 ⏰

Snowflake 依赖系统时钟。如果时钟回拨(比如 NTP 时间同步、人工修改时间),就会生成重复 ID 甚至报错。

我补了一层简单的检测逻辑:

落地思路

  • 每次生成前把当前时间戳和上一次用掉的比较,发现比上次小就说明时钟被往回调了。
  • 回拨幅度直接决定处理方式:小幅回拨可以等追回,超过阈值就报警并拒绝发号。
  • 检测点放在生成的最前面,宁可这一次请求失败,也不能发出去一个可能重复的 ID。
  • 兜底是把回拨事件记下来:发生次数和幅度本身就是机器时钟是否健康的指标。

但这只是发现问题,还没解决问题。我改进了一下:

落地思路

  • 小幅回拨就等:sleep 到时间追上再继续发号,序列号可以接着用。
  • 中等回拨就借用未来时间:继续沿用上一次的时间戳往下递增,代价是这段时间生成的 ID 时间戳偏大。
  • 大幅回拨直接报错拒绝并触发告警,等运维把机器时间修正后再恢复。
  • 三档阈值要和机器的时钟同步机制配套:NTP 校正的幅度比人工改时间小得多。

应对时钟回拨的策略:

策略实现方式适用场景
等待追回sleep 等到时间追上小幅回拨(&lt;5ms)
借用未来时间使用上次的时间戳继续递增中等回拨
报警拒绝直接报错,运维介入大幅回拨

机器 ID 分配:谁是 1 号?

Snowflake 需要给每台机器分配唯一的 datacenter_id 和 worker_id。我用过几种方案:

方案一:配置文件

配置要点

  • 配置表达的是环境差异和运行参数,不是业务规则本身。
  • 多实例配置要保证编号、容量和故障恢复策略清晰。

简单但不灵活,扩容时需要手动修改配置。

方案二:ZooKeeper 分配

落地思路

  • 机器启动时到 ZooKeeper 上创建一个临时节点,节点路径里带上自己的身份信息。
  • 节点序号就是分配到的机器 ID:创建时按序号递增,序号直接用节点的下标。
  • 机器下线时临时节点自动删除,这个 ID 就被释放回池子,下一台机器可以复用。
  • 兜底是会话超时:机器断电时连接断开、临时节点随之消失,不会出现一个「活着却占着 ID」的幽灵。

机器上线时自动分配 ID,下线时自动释放(临时节点会自动删除)。

方案三:Redis 自增

落地思路

  • 启动时从 Redis 里 INCR 一个专门的计数器,拿到的那次值就是这台机器的机器 ID。
  • 计数器是全局的,多台机器依次自增,天然不会分到同一个号。
  • 代价是 Redis 挂了或者计数被重置时机器 ID 可能重复,风险要靠运维守住。
  • 兜底是给机器 ID 落一份持久化记录:重启时优先复用原来的 ID,而不是重新自增一个新号。

更简单,但需要依赖 Redis。

Snowflake 解决了问题,但是…

我最终用 Snowflake 解决了多机器 ID 冲突的问题。部署后,10 台服务器每秒能生成几千万个唯一 ID,再也没有唯一键冲突的错误了。

但 Snowflake 真的完美吗?

时钟回拨的风险一直悬在我心头。虽然我加了各种保护措施,但如果某台机器的时间真的乱了,还是可能出问题。而且,为了分配机器 ID,我还得维护一套 ZooKeeper 或 Redis。

有没有更简单的方案?

不用位运算,不用管时钟回拨,也不用单独分配机器 ID?

三种方案横向对比

到这里,我已经完整走过了三种分布式 ID 方案。把 Redis 计数器、号段缓存和 Snowflake 放在一起看,选型思路就清晰了:

方案对比三种分布式 ID 生成方案怎么选
方案原理优点缺点适用场景
Redis 计数器INCR 原子自增,每台机器从同一计数器取号实现简单、天然全局递增、无时钟问题依赖 Redis 可用性,单点会成为瓶颈;需要持久化保障中小规模、单机房、追求快速落地
号段缓存数据库批量分配号段(如 1001-2000),内存中自增低数据库压力、可中心化管理、断点续接号段耗尽时要重新取号,存在短暂的间隙中大规模、高创建吞吐、需中心化治理
Snowflake时间戳 + 机器 ID + 序列号本地生成 64 位 ID纯内存、性能最高、不依赖外部服务、趋势递增时钟回拨风险、需分配机器 ID、实现复杂大规模多机房、要求高性能、可接受实现成本

选型要点: 短链接的 ID 不要求连续,但必须唯一。 小规模选 Redis 快速落地,规模化后号段缓存更稳,追求性能和抗依赖时用 Snowflake。 无论选哪种,都要有监控、降级和容量预估。

带着这个疑问,我开始研究另一种方案——Redis 计数器。


👉 下一节:Redis 计数器 —— 用更简单的方式生成自增 ID。

发现内容有问题?

告诉我们哪里需要改进。