订单号、消息 ID、用户 ID——这些一长串数字背后,很多用的是雪花算法(Snowflake)。它最早由 Twitter 开源,用一个 64 位整数同时解决了”分布式唯一”和”大致有序”两个难题。理解它的位结构和那个最容易翻车的时钟回拨问题,才能用好、也能看懂别人的 ID。
为什么需要雪花 ID
先看两个常见方案的短板:
- 数据库自增主键:依赖单点发号,分库分表后无法全局唯一,高并发下是瓶颈,ID 还会泄露业务量。
- UUID:能本地生成、全局唯一,但完全无序,做数据库主键会让 B+ 树频繁页分裂、写入变慢。
雪花 ID 取两者之长:本地生成、全局唯一、又大致按时间递增,对索引友好。
64 位怎么拆
经典 Twitter 布局,从高位到低位:
| 段 | 位数 | 作用 |
|---|---|---|
| 符号位 | 1 | 固定 0,保证是正数 |
| 时间戳 | 41 | 毫秒级,相对”起始纪元”的偏移,约够 69 年 |
| 机器位 | 10 | 常拆成 5 位数据中心 + 5 位机器,支持 1024 节点 |
| 序列号 | 12 | 同一毫秒内自增,每节点每毫秒最多 4096 个 |
把这四段拼成一个 64 位整数,就是一个雪花 ID。
为什么”大致有序”
因为时间戳在高位。时间靠前生成的 ID,高位更小,所以整体趋势是递增的——这正是它对数据库索引友好的原因(顺序写入,少页分裂)。
但要注意:同一毫秒内、不同机器之间的先后由机器位和序列决定,不代表真实生成时刻的严格顺序。所以雪花 ID 是”趋势递增”,不能当成精确排序的依据。
起始纪元(epoch)的意义
41 位毫秒时间戳如果从 1970 年(Unix 纪元)算起,很快就会逼近上限。各家于是设一个自定义起始纪元——比如 Twitter 用 2010-11-04——时间戳只记”距离这个纪元过了多少毫秒”,把 69 年的可用窗口推到上线之后。这也是为什么解析时必须选对纪元,否则时间全错。
时钟回拨:最致命的坑
雪花 ID 的唯一性建立在一个假设上:时间只会前进。
可现实里,服务器时间会被 NTP 校正、被运维手动调整。一旦时间往回拨,就可能生成和过去重复的时间戳,进而产生重复 ID——这对一个”唯一 ID 生成器”是灾难。
生产级发号器必须处理回拨,常见策略:
- 拒绝发号:检测到当前时间 < 上次时间,直接抛错并告警,等时间追平。
- 等待追平:阻塞到时钟追上上次记录的时间再发。
- 借位容忍:用扩展位或备用序列吸收小幅回拨。
自研发号器上线前,务必拿真实 ID 验证位布局,并明确回拨策略。
各平台布局不一样
| 平台 | 起始纪元 | 时间戳 | 机器/其他 | 序列 |
|---|---|---|---|---|
| Twitter / 通用 | 2010-11-04 | 41 bit | 5+5 bit | 12 bit |
| Discord | 2015-01-01 | 42 bit | 5+5 bit | 12 bit |
| Mastodon | Unix 纪元 | 48 bit | — | 16 bit |
| Sonyflake | 2014-09-01 | 39 bit(×10ms) | 16 bit | 8 bit |
解析时选错预设,时间和机器位就全错——时间明显不合理(1970 年或几百年后),先怀疑纪元/位布局选错了。
两个实用技巧
- 按时间分页:某时刻对应的”最小 ID”可由
(t − 纪元)左移机器位与序列位推出,用id > 最小ID就能查该时刻之后的数据,省掉created_at索引(Discord 的游标分页即如此)。 - 前端解析用 BigInt:64 位超过 JS Number 的安全整数范围(2^53),直接当数字会丢精度,必须用 BigInt 或字符串处理。
看懂了位结构和纪元,一串数字 ID 里藏着的生成时间、机器、序列就一目了然了。