一个分布式主键的经典提问
给消息表或订单表选主键时,你大概遇到过这个两难:
UUID v4 : 550e8400-e29b-41d4-a716-446655440000 (随机,36 字符)
ULID : 01HZ3G7DZP5XA5Z4G5TQXW2Q7T (可排序,26 字符)
两者都全局唯一、都能离线生成,但一次排序测试就能看出分野:把按 UUID 排的 100 行按创建时间打乱,你会看到字典序和真实顺序毫无关联;换 ULID,字典序恰等于创建顺序。
这一条差异,决定了它们在数据库索引、日志聚合和可排查性上的不同表现。
ULID 的构造:把时间编码进前缀
ULID 只有 48 bit 时间戳 + 80 bit 随机数:
48 bit 80 bit
timestamp randomness
────────── ───────────────────────────── ──
01HZ3G7DZP 5XA5Z4G5TQXW2Q7T
- 前 48 位是自 Unix 纪元起的毫秒数,编码成 10 个 Base32 字符;
- 后 80 位是加密随机数,编码成 16 个字符;
- 总长 26 字符,字符集为排除
I/L/O/U的 Crockford Base32。
解码时间戳很简单:取出前 10 字符按 Base32 反解即得毫秒时间。这也是「可排序」的根基——先比时间,时间相同才比随机。
三种主流标识符对比
| 特性 | UUID v4 | ULID | Snowflake(类) |
|---|---|---|---|
| 长度 | 36 字符 | 26 字符 | 19 位十进制数 |
| 时间可排序 | 否 | 是(毫秒级) | 是(秒级+序列号) |
| 离线可生成 | 完全离线 | 完全离线 | 需节点 ID / 协调 |
| 跨节点强排序 | 否 | 否(受时钟漂移影响) | 依实现 |
| 可读性 | 中(连字符分组) | 高(紧凑无连字符) | 高(纯数字) |
Snowflake 系为了压到更短或对齐单一数据中心,常牺牲离线性(需要 worker ID);ULID 在多语言里都能纯函数离线生成,部署最简单。
单机/单节点如何保证「同一毫秒内递增」
很多实现提供 monotonic 模式:若同一毫秒内再次生成,就把随机位视作计数器递增,直到溢出或进入下一毫秒。对数据库顺序插入这点很关键——同一毫秒内乱序也会触发页分裂。
// JavaScript 示意:同一毫秒内递增随机位
let lastGen = null, lastRand = 0n
function ulid() {
const now = Date.now()
if (now === lastGen) lastRand++ // 单调递增
else { lastGen = now; lastRand = 0n }
return encode(now, lastRand)
}
为什么顺序主键对数据库更友好
InnoDB 这类聚簇索引表把主键当作物理排序依据。随机主键会让一行新数据插入到页面中部,触发页面分裂、回表和随机写放大;顺序主键则总是追加到页面尾部。数据量千万级时,两者在写入吞吐和缓存命中率上的差距会非常明显。
这也是每当你打开自己的工具站、用 ULID 生成器生成主键时的常见出发点:如果你要的是「全局唯一 + 大致时序」,ULID 常是比 UUID v4 更贴合数据库行为的默认选择。
隐私权衡:优点与代价要一起看清
编码进 ULID 的时间戳意味着:任何拿到 ID 的人都知道生成时刻。这既是日志排障的救命线索,也可能是泄露给用户的弱点。建议:
- 面向用户公开的标识符(订单号、邀请码)若需隐藏时间,用 UUID v4;
- 服务器内部、数据库主键、日志/事件 ID,优先 ULID;
- 合规敏感的创建时间,考虑把时间字段单独加密存储,而不用可排序 ID 间接外泄。
选型速查
选 ULID,当:关系库主键想要顺序写入、日志需按时间聚合、想离线简单生成且长度要短。
选 UUID v4,当:暴露给终端用户、严格不希望泄露时间、需要与外部系统按标准互操作。
选 Snowflake/数据库序列,当:跨节点硬性需要严格全局顺序、已有多数据中心且能提供 worker ID。
动手验证
拿一个你手头的 ULID 生成器或脚本,生成一批后按字典序排序,再对照内部实现的编码——你会发现它们天然按创建顺序排好。把同一个需求换成 UUID v4 重跑一次,排序就打乱了。这个小实验能让你把前面讲的特性直观留在记忆里。