标识符设计:UUID v4/v7、ULID、雪花算法与工程权衡

系统讲解主键标识符设计:自增 ID 与 UUID 的根本权衡、UUID 各版本(v1/v4/v5/v7)的语义、UUID 作主键的索引与存储代价、ULID/KSUID/雪花算法的排序性、单调性要求、长度与可读性权衡(Base62/Short UUID),以及分布式多机房下的 ID 生成器设计。

引言

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. 主键的三重职责:索引、分片与隐私

主键不只是"唯一标识",它在三个层面同时起作用:

职责影响
索引聚簇索引按主键物理排序 → 主键形态决定插入与范围查询效率
分片分库分表路由键常是主键 → 主键特征决定分布均匀性
隐私可猜测的 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 v7128✓无36 字符
ULID128✓无26 字符(Base32)
KSUID160✓无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. 速查表与一句话记忆

全篇速查:

方案位数排序协调适用
自增 bigint64✓✗(单机)单机内部
UUID v4128✗✗会话/临时对象
UUID v5128✗✗稳定映射
UUID v7128✓✗通用主键(推荐)
ULID128✓✗排序 + 紧凑 + 零协调
KSUID160✓✗同上(含时间戳)
雪花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 与一致性

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. 通配符与 Glob 匹配:与正则的分野与落地
  2. 算法复杂度速查:Big-O、空间复杂度与工程直觉
  3. 正则表达式深层解析:引擎、回溯与灾难性回溯