引言
auto_increment 还是 UUID?UUID v4 还是 v7?ULID、雪花、KSUID 又是什么?——主键设计是每个表的第一行代码,选错会影响索引、分库分表、多机房甚至泄密。本文把标识符选型讲透:先讲自增与 UUID 的根本权衡(单调性 vs 不可预测性 vs 索引友好),再逐一拆 UUID 的版本(为什么 v4 在主键里是"反面教材"、v7 为什么被推荐),接着讲追求排序 + 紧凑 + 分布式的 ULID/雪花/KSUID,最后给一张完整的选型表与分布式 ID 生成器的设计要点。
前置:/others-binary-encoding-tools/(Base32/Base62 编码与可读性)、/others-data-compression-guide/(紧凑性视角)。数据库索引与分片见 [[database]]、[[distributed-systems]]。
目录
- 1. 主键的三重职责:索引、分片与隐私
- 2. 自增 vs UUID:根本权衡
- 3. UUID 版本族谱:v1/v4/v5/v7
- 4. 为什么 v4 不适合做主键
- 5. UUID v7:时间排序的时代答案
- 6. ULID、KSUID 与雪花:排序 + 紧凑的家族
- 7. 长度与可读性:Base62、短 ID 与泄密风险
- 8. 分布式 ID 生成器:雪花方案的工程细节
- 9. 选型决策表与迁移
- 10. 速查表与一句话记忆
- 延伸阅读
1. 主键的三重职责:索引、分片与隐私
主键不只是"唯一标识",它在三个层面同时起作用:
| 职责 | 影响 |
|---|---|
| 索引 | 聚簇索引按主键物理排序 → 主键形态决定插入与范围查询效率 |
| 分片 | 分库分表路由键常是主键 → 主键特征决定分布均匀性 |
| 隐私 | 可猜测的 ID 泄露业务量 → 可枚举即信息泄露 |
关键约束冲突:
单调递增 → 索引友好、范围查询友好,但"可猜测、泄露量"
随机不可预测 → 防枚举、隐私好,但"索引碎片化、缓存不友好"
两者天然对立 → 选择是权衡,不是"哪个对"
范围查询 vs 精确查询:日志/时序数据常按 ID 或时间范围查 → 希望 ID 带时间序;对外暴露的资源常怕被遍历 → 希望 ID 不可枚举。同一套 ID 很难同时满足——所以要么拆两套(内部自增 + 对外不可猜),要么接受某一侧损失。
记忆:主键同时是索引键、分片键、隐私门面——先问清楚’谁来用、怎么查、能不能猜’,再谈格式。
2. 自增 vs UUID:根本权衡
自增(auto_increment / identity):
| 优点 | 缺点 |
|---|---|
| 索引完美(单调、紧凑) | 多节点/多机房生成冲突 |
| 空间最小(bigint 8B) | 可枚举 → 泄露业务量 |
| 范围查询高效 | 合并/迁移要处理冲突 |
| 人类可读、易调试 | 分库分表要改路由策略 |
UUID(通用唯一):
| 优点 | 缺点 |
|---|---|
| 全局唯一、无需协调 | 长度大(128 bit = 16B,文本 36 字符) |
| 客户端可预生成 | 随机版(v4)索引碎片化严重 |
| 不可猜测(v4) | 无法排序、范围查询差 |
| 多机/离线可用 | 调试/日志里难记难读 |
核心场景划分:
单机、内部、无泄露顾虑 → 自增足够,别过度设计
多机/多机房、需客户端生成 → UUID 系
要排序 + 分布式 + 紧凑 → ULID/雪花系
对外暴露、防枚举 → 额外加"不可猜"标识(或加密混淆)
一个工程现实:很多团队把"自增够不够"问成"UUID 好不好"——先用自增,等真的出现多节点需求再迁,比一上来就背 UUID 的索引代价划算。
记忆:自增赢在索引与体积、UUID 赢在全局与隐私——选型先问’要不要多机、要不要防猜、要不要排序’。
3. UUID 版本族谱:v1/v4/v5/v7
UUID 是 128 位的标准格式 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx,不同版本差异在那 128 位怎么产生:
| 版本 | 生成方式 | 排序性 | 可猜测 | 典型用途 |
|---|---|---|---|---|
| v1 | 时间戳 + MAC + 时钟序列 | 弱(同机内单调) | 可(含 MAC/时间) | 遗留系统 |
| v3/v5 | 命名空间 + 名字的 MD5/SHA1 | 无 | 无(确定性) | 同一实体的稳定 ID |
| v4 | 全随机(122 位随机) | 无 | 强 | 会话、匿名、临时对象 |
| v7 | 时间戳 + 随机 | 强(随时间单调) | 中 | 通用主键(新推荐) |
| v8 | 自定义保留 | — | — | 未来 |
v4 的现状:绝大多数 UUID() 默认是 v4——适合"只需唯一、不需顺序"的场景(session、event id、临时资源)。
v5 的价值:UUID v5("namespace", "user:42") 永远是同一个值 → 用来做稳定的外部引用(同一资源跨系统映射),而不是主键。
v7 的新人设:RFC 9562(2024 年标准化)把 v7 定为"时间戳 + 随机后缀",明确为主键设计——既唯一又按时间排序,兼顾索引与并发生成。
记忆:v4 是通用唯一、v5 是稳定映射、v7 是主键答案——‘为什么 UUID 不排序’说的其实是被滥用的 v4。
4. 为什么 v4 不适合做主键
v4 作为主键的三个真实代价:
① 索引碎片化(B-tree 随机插入):
聚簇索引按主键排序存储 → 随机 v4 每次都插入"页中间"
→ 页分裂、写放大、缓存命中率低
→ 大表随机插入性能明显劣于单调 ID
② 空间膨胀(索引 + 外键 + 存储):
bigint 8 字节 vs UUID 文本 36 字节(二进制 16 字节)
一张表 1 亿行:UUID 文本形态比 bigint 多占用 2-3GB+
外键/关联表把膨胀再放大
③ 无法按 ID 排序:
"按创建时间倒序" 用 v4 做不到 → 必须额外加 created_at 列并建索引
v7/ULID/雪花则"ID 即时间序" → 少一个索引、多一个免费排序
v4 何时仍然正确:主键不是聚簇(如 PostgreSQL heap 表 + 二级索引兜底)、表小、纯精确查询、多机生成、对外防猜——在这些前提下 v4 的代价可忽略。问题在于"默认无脑 v4"。
-- 对照:MySQL 下 v4 主键的典型副作用
-- 大量页分裂 → 建议 InnoDB 改自增主键 + 独立业务键
CREATE TABLE t (
id BINARY(16) PRIMARY KEY, -- 随机 → 频繁页分裂
...
);
记忆:v4 的代价是索引碎片、空间膨胀、无法排序——‘表小/非聚簇/纯精确查询’时才无脑用 v4。
5. UUID v7:时间排序的时代答案
UUID v7 的位布局:
48 bit 时间戳(Unix 毫秒)
4 bit 版本号(7)
12 bit 随机
2 bit 变体(10)
62 bit 随机
→ 前 48 位是时间 → 整体随创建时间单调递增
→ 后 80 位是随机 → 多机并发不冲突
v7 的优势:
- 索引友好:B-tree 插入基本追加到尾部(除非同毫秒并发)
- 免费时间序:ORDER BY id 即 ORDER BY 创建时间
- 全局唯一:随机后缀保证多机唯一
- 标准统一:RFC 9562,生态自动支持
v7 的局限:
- 时间可猜:暴露创建顺序(隐私场景要小心)
- 同毫秒并发:时间前缀相同 → 仍靠随机排序,索引插入略随机
- 比雪花大:128 bit vs 雪花 64 bit
为什么不用自增但要 v7:需要多机唯一 + 时间排序 + 不可简单枚举(v7 后缀随机,猜不出后续 ID),自增做不到前者、v4 做不到后两者——v7 是这三者的平衡点。
# Python 侧 v7(示意,需 uuid6 库)
import uuid6
uid = uuid6.uuid7() # 2026-09-28T... 前缀 + 随机后缀
记忆:v7 = 时间前缀 + 随机后缀——索引友好 + 免费时间序 + 全局唯一,是现代主键的默认推荐。
6. ULID、KSUID 与雪花:排序 + 紧凑的家族
ULID(Universally Unique Lexicographically Sortable ID)——26 字符、Base32、可排序:
Crockford Base32 × 26 字符 = 128 bit
前 48 bit 毫秒时间戳 + 后 80 bit 随机
"01GQ2GXQY6..." → 字典序即时间序 → 可排序、可读、URL-safe
KSUID(K-Sortable Unique ID)——20 字节、含时间戳 + 随机:
时间戳(4B) + 负载(16B) → 可排序
Base62 27 字符 → 比 UUID 文本短
雪花(Snowflake)——Twitter 的 64 位方案,分布式协调 + 紧凑的经典:
1 bit 0(符号)
41 bit 毫秒时间戳(≈69 年)
10 bit 机器 ID(数据中心 5 + 机器 5)
12 bit 同毫秒序列号 → 每毫秒 4096 个
→ 64 位:比 UUID 小一半,带机器信息,严格单调
三者对比:
| 方案 | 位数 | 排序 | 依赖协调 | 长度形态 |
|---|---|---|---|---|
| UUID v7 | 128 | ✓ | 无 | 36 字符 |
| ULID | 128 | ✓ | 无 | 26 字符(Base32) |
| KSUID | 160 | ✓ | 无 | 27 字符(Base62) |
| 雪花 | 64 | ✓ | 机器 ID 分配 | 19 位数字 |
选型核心差异:要不要"严格单调 + 机器协调"——纯 UUID 系零协调但长;雪花短且严格单调但要管机器 ID(时钟回拨要处理)。
记忆:都想要’排序 + 紧凑’——零协调选 ULID/v7、要 64 位紧凑选雪花(代价是管机器 ID)。
7. 长度与可读性:Base62、短 ID 与泄密风险
ID 该多长:完全随机 128 位对大多数场景是"过度唯一"——62 位随机就足够扛碰撞。
短 ID 的做法:用 Base62(0-9A-Za-z)/ Base64url 压缩 ID:
UUID 二进制 16B → Base62 约 22 字符
随机 64 位 → Base62 约 11 字符(如 T8x4lK2Q)
import base64, uuid
raw = uuid.uuid4().bytes # 16 字节
short = base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
# 22 字符,URL-safe
可读性 vs 泄密:
短随机 ID(62 位) → 不可枚举但也不是不可破(1024^... 巨大,安全)
自增 + 编码混淆 → 可猜出总量(信息泄露)
时间序 ID → 泄露"何时创建、频率多少"
外露 ID 的常见做法:
1. 内部主键自增/雪花 → 对外映射随机短 ID(两张表的映射 or 加密编码)
2. 直接对外 v4/v7 → 用随机后缀(不可猜后续),接受长度
3. 公开资源(文章/商品)→ 常直接自增(知乎/淘宝文章 ID 可遍历)→ 权衡后接受
工程原则:“防枚举"不是"防泄露"的充分条件——对外 ID 是否可猜要按业务泄密风险评估,而不是默认做短 ID 就算安全。
记忆:短 ID 用 Base62 压缩 64 位随机;对外 ID 要按业务算’可猜 = 泄密什么’——短 ≠ 安全,长 ≠ 一定安全。
8. 分布式 ID 生成器:雪花方案的工程细节
真要做"高吞吐分布式 ID 服务”,雪花方案的三个工程坑:
① 时钟回拨:NTP 校时导致时间回退 → 可能生成重复 ID。
解法:
- 回拨小(< 阈值):等待/用最后时间+1
- 回拨大:拒绝服务/切换备用节点
- 纯随机后缀(v7 路线):天然免疫回拨
② 机器 ID 分配:数据中心 + 机器 10 位 → 最多 1024 台;注册中心 / 配置下发。
③ 同毫秒并发:12 位序列号每毫秒 4096 → 超出要"等下一毫秒"。
一套可落地的服务:
方案 A:Redis INCR / 数据库序列表(简单,但引入 Redis 依赖)
方案 B:雪花本地生成(零请求、高性能,但要管机器 ID + 回拨)
方案 C:UUID v7 客户端生成(零协调、免疫回拨、最长)
方案 D:分段发号(预取一段 ID 段给客户端本地消耗)
# 雪花生成核心(示意,时钟回拨检查略)
class Snowflake:
def __init__(self, worker, epoch=1_700_000_000_000):
self.worker = worker
self.epoch = epoch
self.seq = 0
self.last = 0
def next(self):
now = int(time.time() * 1000)
if now == self.last:
self.seq = (self.seq + 1) & 4095
if self.seq == 0: # 本毫秒耗尽 → 等下一毫秒
while now <= self.last:
now = int(time.time() * 1000)
else:
self.seq = 0
self.last = now
return ((now - self.epoch) << 22) | (self.worker << 12) | self.seq
记忆:分布式 ID 的核心难题是’协调 + 时钟’——雪花短但管机器 ID 与回拨,v7/ULID 零协调但长;要高吞吐就本地生成、要简单就客户端生成。
9. 选型决策表与迁移
一张表选型:
| 场景 | 推荐 | 理由 |
|---|---|---|
| 单机内部小表 | 自增 bigint | 索引完美、体积最小 |
| 多机/微服务、无需对外 | UUID v7 | 全局唯一 + 时间序 |
| 多机 + 追求紧凑/严格单调 | 雪花/ULID | 短、排序、可控 |
| 对外暴露、防枚举 | 随机短 ID(Base62 64 位) | 不可猜、可读 |
| 同一实体稳定映射 | UUID v5 | 确定性、跨系统一致 |
| 高并发写、要求低延迟 | 本地雪花/v7 | 免远程协调 |
| 遗留系统 | 维持现状 + 独立业务键 | 别为迁移冒险 |
迁移自增 → UUID 的注意:
- 分片键已按自增取模?→ 换 UUID 哈希路由,数据要重分布
- 外键/关联表全要改类型(bigint → char(36)/binary(16))
- 业务里"ID 递增"假设要清理('取最后一个 ID'会错)
- 建议:加新业务键列过渡,老数据保持旧主键
一次健康的决策流程:
□ 谁生成?(客户端/服务端/多机?)
□ 谁查询?(精确/范围/按时间排序?)
□ 谁看到?(内部/外部/可猜会怎样?)
□ 规模多大?(表大小决定空间与索引代价)
□ 有没有既有约束?(分片、外键、协议格式)
记忆:选型不是’UUID vs 自增’二选一——单机用自增、多机用 v7、紧凑要雪花/ULID、对外要不可猜、稳定映射用 v5;迁移前先盘’生成者、查询者、观者、规模、约束’五问。
10. 速查表与一句话记忆
全篇速查:
| 方案 | 位数 | 排序 | 协调 | 适用 |
|---|---|---|---|---|
| 自增 bigint | 64 | ✓ | ✗(单机) | 单机内部 |
| UUID v4 | 128 | ✗ | ✗ | 会话/临时对象 |
| UUID v5 | 128 | ✗ | ✗ | 稳定映射 |
| UUID v7 | 128 | ✓ | ✗ | 通用主键(推荐) |
| ULID | 128 | ✓ | ✗ | 排序 + 紧凑 + 零协调 |
| KSUID | 160 | ✓ | ✗ | 同上(含时间戳) |
| 雪花 | 64 | ✓ | 机器 ID | 高吞吐 64 位 |
一句话记忆:主键同时是索引键、分片键、隐私门面——单机小表自增够用、多机要唯一选 UUID v7(时间序 + 零协调)、要 64 位紧凑与严格单调用雪花(代价是管机器 ID 与时钟回拨)、ULID/KSUID 是零协调的排序紧凑替代、对外防枚举用 Base62 短随机 ID、同一实体稳定引用用 v5;分布式发号四方案(Redis/本地雪花/客户端 v7/分段发号)按协调与延迟权衡——先回答’谁生成、谁查询、谁看到、多大、什么约束’,主键就选得明白。
延伸阅读
- /others-binary-encoding-tools/ — Base32/Base62 编码与可读性
- /others-data-compression-guide/ — ID 文本形态的紧凑性视角
- /time-timezone-handling/ — 时间戳与时钟回拨的底层
- [[database]] — 聚簇索引与主键物理存储
- [[distributed-systems]] — 分布式 ID 与一致性
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。