“数据库主键应该用自增整数、UUID,还是 CUID?”看起来是在比较三种字符串或数字,实际混合了至少四个问题:
- 什么属性代表现实中的实体:身份证号、邮箱、订单号,还是一个没有业务含义的代理值?
- 谁来生成 ID:数据库、中心发号器、每个服务实例,还是离线客户端?
- 数据库怎样存储和索引它:8 字节整数、16 字节原生 UUID,还是二十多个字符的文本?
- ID 是否暴露给外部:它只是内部连接键,还是 URL、日志、消息和开放 API 的资源标识?
如果不先拆开这四层,就很容易得到互相矛盾的经验:传统 MySQL 应用喜欢递增主键,Spanner 却明确建议避开递增主键;UUIDv7 为索引局部性而生,CUID2 则主动放弃按时间排序;有人说 UUID 太占空间,却把 16 字节 UUID 存成了 36 字节文本。
先给出本文的结论:
不存在脱离数据库引擎、部署架构和暴露边界的“最佳主键”。对普通单库业务,自增
BIGINT仍然是极强的内部主键;需要跨节点、离线生成和跨系统合并时,UUIDv7 正在成为最通用的现代默认值;需要完全隐藏时间且主要工作在 Web/JavaScript 生态时,可以考虑 CUID2 或 UUIDv4。原版 CUID 已经废弃,不应再用于新系统。
先把四种“ID”分开
一张用户表可能同时存在下面四个值:
id = 842731 -- 数据库内部代理主键
public_id = 019c8c8a-6d10-7b72-... -- API / URL 公开标识
email = user@example.com -- 业务自然键
reset_token_hash = sha256(...) -- 安全能力凭证的摘要
它们不是重复设计,而是在承担不同契约:
| 名称 | 主要职责 | 能否变化 | 是否必须不可猜 | 常见约束 |
|---|---|---|---|---|
| 自然键 / 业务键 | 表达业务世界中的唯一性 | 可能 | 通常不需要 | UNIQUE |
| 代理主键 | 稳定连接一行及其外键 | 不应变化 | 不一定 | PRIMARY KEY |
| 公开 ID | 跨 API、日志、消息和系统引用资源 | 不应变化 | 视威胁模型而定 | UNIQUE NOT NULL |
| 安全令牌 | 持有它就能执行敏感操作 | 可撤销、会过期 | 必须 | 高熵、过期、通常只存摘要 |
最危险的混淆是把“难猜的主键”当成授权。即使使用 CUID2 或 UUIDv4,请求 /users/{id} 时仍然必须验证调用者是否有权访问该用户。UUID 的现行标准 RFC 9562 §8也明确要求:不能假设 UUID 难以猜测,更不能把 UUID 本身当作“持有即授权”的 security capability。
第二个常见混淆是把 PRIMARY KEY 等同于“表的物理排列顺序”。主键首先是逻辑约束:唯一且非空。它通常会创建索引,但物理行为取决于引擎:
- MySQL InnoDB 用主键作为聚簇索引,叶子页里就是整行数据;
- PostgreSQL 的表是 heap,主键 B-tree 与表数据分开,
CLUSTER也只是一次性重排; - 分布式数据库可能按主键范围把数据分给不同机器,主键的高位因此会决定流量落到哪个节点。
所以,“这个 ID 唯一吗”和“这个 ID 适合做聚簇键吗”是两道题。
一条主线看懂半个多世纪的演变
主键方案的历史不是旧算法不断被新算法淘汰,而是系统边界不断扩大:从一台数据库,扩展到多库多机、浏览器和手机离线写入,再扩展到全球分布式存储。
关系模型:用值建立关系,而不是追逐物理指针
早期层次数据库和网状数据库依赖访问路径与记录指针。Codd 在 1970 年提出关系模型后,表之间可以根据共同值进行连接,应用不再需要知道一条记录在磁盘上的物理地址。IBM 对这段历史的概括是:关系模型用表和唯一键替代了树形导航指针,参见 IBM 的关系数据库历史。
最直观的键来自业务本身,例如:
CREATE TABLE countries (
iso_code CHAR(2) PRIMARY KEY,
name TEXT NOT NULL
);
这类自然键有真实语义,不需要额外列,并且能在数据库层直接阻止业务重复。组合自然键也经常准确表达关系:
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
line_no INTEGER NOT NULL,
sku TEXT NOT NULL,
PRIMARY KEY (order_id, line_no)
);
问题在于,现实世界的“唯一且永不改变”比设计阶段想象得少:邮箱会换,手机号会回收,证件规则会调整,供应商编码会合并,甚至国家代码也可能变化。自然键一旦成为几十张表的外键,修改就会扩散到整条关系链;长字符串和多列组合还会放大索引与连接成本。
代理键:让实体身份脱离可变业务属性
Hall、Owlett 与 Todd 在 1976 年的 Relations and Entities 中讨论了 surrogate;Codd 1979 年的 RM/T 论文进一步把 surrogate 描述成由系统生成、用于持久标识实体的值。IBM Research 保存了这篇论文的出版信息与摘要。
今天工程里的代理键通常就是:
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY
代理键并没有让业务唯一性消失。正确做法是保留它:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE
);
这里 id 解决“稳定引用哪一行”,email 上的唯一约束解决“业务上不能出现两个相同邮箱”。只加代理主键却删掉业务唯一约束,会让数据库接受业务重复数据,这是代理键最常见的误用。
自增整数与 Sequence:单一权威下的近乎完美方案
在单数据库时代,AUTO_INCREMENT、IDENTITY 和 SEQUENCE 几乎集齐了主键想要的优点:
- 4 或 8 字节,索引、外键和网络传输都很紧凑;
- 数据库原子分配,局部范围内确定不重复;
- 大体递增,对传统 B-tree 和聚簇表非常友好;
- 运维简单,所有语言都能处理整数。
它的缺点也来自同一个前提:唯一性依赖一个共同权威。
- 离线客户端不能预先知道最终 ID;
- 多个独立数据库合并数据时可能撞号;
- 按 ID 可估算数据量、增长速度,并可枚举相邻资源;
- 数据库或发号服务可能成为扩展与可用性边界;
- 删除、回滚、序列缓存和故障切换都会产生空洞,所以它不是“连续序号”。
最后一点很重要:主键序列只保证分配身份,不承诺没有间断,也不应该直接充当发票号码等受监管业务编号。
号段与 Hi/Lo:少访问中心,但仍由中心划分领地
当每次生成 ID 都访问数据库太慢时,一种自然改进是一次领取一段:
实例 A 领取 [1, 1000]
实例 B 领取 [1001, 2000]
实例 C 领取 [2001, 3000]
实例在内存中消费自己的区间,用完再向中心申请。Hibernate 的 Hi/Lo、数据库 sequence cache、号段服务都属于这个思路。美团 Leaf 的 segment 模式也是从数据库批量取得由 step 控制的号段,以降低数据库压力,参见美团 Leaf 设计。
优点是 ID 仍为紧凑整数,吞吐比逐个取 sequence 高得多,也不依赖机器时钟。代价是:
- 仍需要数据库或号段服务做区间协调;
- 实例崩溃会浪费尚未使用的号段;
- 不同实例并行消费号段,只能“趋势递增”,不能把数值顺序当成全局事件顺序;
- 连续或近似连续的值仍会泄露业务规模。
UUID / GUID:把唯一性生成权下放到每个节点
UUID 最早用于 Apollo Network Computing System,后来进入 OSF DCE,再进入 Windows 等平台。现行 RFC 9562在历史说明中确认了这条传承路径。它的关键创新不是字符串中那几道短横线,而是:任何节点都能在没有中心注册的情况下生成一个实际足够唯一的 128 位值。
2005 年 RFC 4122 统一了大家熟悉的 UUIDv1—v5;2024 年 RFC 9562 取代 RFC 4122,并正式加入 v6、v7、v8。至此 UUID 已经不只是“随机 UUIDv4”的同义词。
Snowflake:在 64 位整数里编码时间、节点与序列
2010 年,Twitter 为分片 MySQL 和 Cassandra 迁移公开 Snowflake。原始设计把一个近似有序的 64 位整数拆成时间戳、worker 编号和毫秒内序列,Twitter 的发布说明概括为:
1 bit 保留 | 41 bit 毫秒时间 | 10 bit 节点 | 12 bit 毫秒内序列
它解决了三个现实问题:Java long 能直接保存、多个节点不必逐次访问数据库、生成结果大体按时间增长。随后出现了 Instagram Sharding ID、Sonyflake、Leaf-snowflake 等大量变体。
但 Snowflake 不是一个复制代码就结束的算法,它把复杂度转移到了运维:worker ID 必须唯一;机器时钟回拨必须等待、拒绝服务或启用备用策略;位数一旦分配,支持年限、节点数和峰值吞吐就被固定。它适合已有成熟基础设施的大规模系统,不是每个 CRUD 服务都需要自建的组件。
2010s 的可排序 ID:同一个痛点,各自给出编码
随机 UUIDv4 适合分布式生成,却不适合所有传统索引工作负载。于是业界先后出现 CUID、Firebase Push ID、ULID、KSUID、ObjectID、各种 COMB GUID 和 Flake 变体。它们大多在做同一件事:把时间放在高位,把随机数、计数器或节点信息放在低位,得到 K-sortable,也就是值的大方向与创建时间一致。
RFC 9562 的更新动机列出了 16 种被分析的既有实现,其中就包括 ULID、Snowflake、KSUID、ObjectID 与 CUID。换句话说,UUIDv7 不是凭空发明的新潮格式,而是标准组织对十多年工程分叉的一次收敛。
UUID 不是一种算法:v1 到 v8 分别解决什么
UUID 固定为 128 位,其中版本与 variant 占用少量固定比特。常见文本形式为 36 个字符:
019c8c8a-6d10-7b72-a96a-0e36ce9f6251
版本不同,剩余比特的含义完全不同:
| 版本 | 核心组成 | 排序特性 | 主要用途 | 主要问题 |
|---|---|---|---|---|
| v1 | Gregorian 时间、时钟序列、node ID | 原始字段顺序不适合直接按时间排 | DCE / 遗留系统 | 可能暴露时间与节点,字节布局别扭 |
| v2 | DCE Security | — | 特定 DCE 场景 | RFC 9562 不定义其生成细节,极少用于通用业务 |
| v3 | namespace + name 的 MD5 | 确定性 | 同名输入生成同一 ID | MD5,现行标准建议优先 v5 |
| v4 | 122 位随机数据 | 无时间顺序 | 通用随机、隐藏创建时间 | 传统 B-tree 插入位置分散 |
| v5 | namespace + name 的 SHA-1 | 确定性 | DNS 名、外部稳定键映射、幂等导入 | 输入语义变化会改变 ID;不适合可变自然键 |
| v6 | 重排后的 v1 | 按字节时间有序 | 兼容 v1 的系统升级 | 仍继承 v1 的时间模型;无遗留包袱时应选 v7 |
| v7 | 48 位 Unix 毫秒时间 + 74 位随机/单调数据 | 按字节大体时间有序 | 现代数据库键、分布式生成 | 暴露大致创建时间,依赖时钟质量 |
| v8 | 自定义 122 位 | 由实现决定 | 实验或厂商特定格式 | 互操作与唯一性均不能自动假设 |
以上布局与建议来自 RFC 9562 §5。标准特别指出:v6 主要服务于已有 v1 的上下文,没有 v1 兼容负担的系统应使用 v7;v8 只提供自定义容器,不是“比 v7 更先进”。
UUIDv4:随机不是缺陷,而是一种取舍
合格的 UUIDv4 有 122 个随机位。若随机源均匀,生成 (n) 个 ID 的碰撞概率近似:
p ≈ 1 - exp(-n(n - 1) / (2 × 2^122))
即使生成 10 亿个,理想模型下的碰撞概率仍约为 9.4 × 10^-20。真实系统里如果频繁见到 UUIDv4 碰撞,更应该先怀疑:使用了 Math.random()、伪随机状态随虚拟机快照被克隆、进程 fork 后没有重新播种、截短了 UUID,或者根本没使用合格的 v4 实现。
“概率极低”仍不等于约束。数据库应保留 PRIMARY KEY / UNIQUE,生成端应把极端重复当作可重试冲突,而不是为了相信 UUID 就删掉唯一索引。
UUIDv4 的真正工程代价通常是没有写入局部性。新键可能落到 B-tree 任意叶子页,扩大活跃索引页集合;当索引超过缓存后,会带来更多随机 I/O、页分裂和缓存失效率。不过,若表不大、写入不密集、索引能放进内存,差异可能根本不值得增加系统复杂度。
UUIDv7:把时间放回高位,但不放弃分布式生成
UUIDv7 的布局可以简化为:
0 47 48 51 52 63 64 65 66 127
+---------------------+-------+-----------+-----+-------------------+
| Unix 毫秒时间 48 bit | ver=7 | rand_a 12 | var | rand_b 62 |
+---------------------+-------+-----------+-----+-------------------+
它保留 74 位给随机数、亚毫秒小数或单调计数器。高位时间让相邻时刻生成的值落在索引相近区域;低位熵让不同节点能独立生成。RFC 9562 明确说明 v6/v7 可以作为不透明字节直接排序,并能改善数据库索引局部性,参见 §6.11 Sorting。
但“可按时间排序”不等于以下任何一项:
- 它不保证多个主机的墙上时钟完全一致;
- 它不保证 ID 顺序等于事务提交顺序;
- 它不保证同一毫秒内不同生成器的全局单调;
- 它不应该替代明确的
created_at、日志序号或共识提交位置。
截至本文写作时,PostgreSQL 18 已原生提供 uuidv4() 与 uuidv7(),并用 16 字节 uuid 类型存储,参见 PostgreSQL 18 UUID 类型与UUID 函数。这类数据库原生支持会明显降低 UUIDv7 的采用成本。
CUID 到底是什么,为什么必须区分 CUID 与 CUID2
“UUID 和 CUID 的区别”现在不能只做两列表,因为 CUID 与 CUID2 是不兼容的两代设计。
原版 CUID:为浏览器与横向扩展补充多种碰撞防线
原版 CUID 的典型结构是:
c - timestamp - counter - client fingerprint - random
例如:
cjld2cjxh0000qzrmn831i7rn
前缀 c 让它能直接用于 HTML / JavaScript 标识符;时间和计数器提高同一进程连续生成时的区分度;fingerprint 试图区分主机;随机部分降低剩余碰撞概率。原项目的格式说明还强调了浏览器离线生成和大体递增。
随着容器、Cloud Worker 和相同镜像的大规模部署普及,原设计对 PID、hostname、浏览器环境差异的假设不再可靠;更重要的是,时间戳和部分主机指纹直接出现在结果里,存在隐私与可枚举性问题,长度也曾不是严格固定。项目维护者现已把原版 CUID 标记为 Deprecated due to security,并要求改用 CUID2。因此,新项目中的 cuid() 到底生成哪一代,必须检查 ORM 和库的明确版本,不能凭函数名猜测。
CUID2:把所有输入哈希成不透明的随机外观字符串
CUID2 仍组合时间、伪随机数、session counter 与 host fingerprint,但会先混合、哈希,再编码成只含小写字母和数字的 Base36 文本;首字符为字母,默认长度 24,也可配置长度。官方说明见 CUID2 README。
这个改变带来三个直接结果:
- 不能从结果中直接取出时间或 fingerprint;
- 不同节点可以离线、无协调地生成;
- 哈希打散了时间,因此 CUID2 不可按创建时间排序。
CUID2 官方也明确把 sequential IDs 和高性能 tight loop 列为不适用场景。它更像一个带多源熵混合的、URL 友好的不透明应用 ID,而不是 UUIDv7 的可排序替代品。
UUID 与 CUID2 的实质比较
“UUID”必须具体到版本。最有意义的是把 CUID2 与 UUIDv4、UUIDv7 分开比较:
| 维度 | UUIDv4 | UUIDv7 | CUID2 默认配置 |
|---|---|---|---|
| 标准化 | IETF RFC 9562 | IETF RFC 9562 | 项目规范 / 多语言实现 |
| 逻辑长度 | 128 bit,其中 122 bit 随机 | 128 bit,其中 48 bit 毫秒时间、74 bit 随机/单调数据 | 24 位 Base36,首位字母,约 123.6 bit 取值空间 |
| 常见文本 | 36 字符 | 36 字符 | 24 字符、小写字母数字 |
| 数据库紧凑存储 | 原生 uuid / 16-byte binary | 原生 uuid / 16-byte binary | 通常是 24-byte ASCII 文本 |
| 时间排序 | 否 | 大体可以 | 否 |
| 暴露创建时间 | 否 | 是,毫秒级高位可解析 | 否,输入经哈希隐藏 |
| 无中心 / 离线生成 | 是 | 是 | 是 |
| 生态与跨语言互操作 | 最广 | 快速增长,格式已标准化 | Web/JS、Prisma 生态更突出 |
| 传统 B-tree 写入局部性 | 差 | 好 | 与随机 ID 相似 |
| 适合场景 | 不想泄露时间、分布式随机键 | 数据库主键、事件与对象 ID 的现代通用默认 | URL 友好、不透明、客户端生成的应用 ID |
这里有两个容易被字符串外观误导的地方:
- UUID 的 36 字符只是十六进制展示形式。数据库有原生 UUID 时应存 16 字节,不应因为展示形式而声明它“比 24 字符 CUID2 更占空间”。RFC 9562 也建议数据库尽可能存底层 128 位值,参见 §6.13。
- CUID2 默认空间略大于 UUIDv4 的 122 个随机位,不等于 UUIDv4 在正常实现中“经常碰撞”。CUID2 的多源混合主要是在随机源实现失败、环境克隆等非理想条件下增加防御层;在合格 CSPRNG 的理想模型中,两者的碰撞风险都远低于绝大多数应用的其他故障风险。
如果数据库原生支持 UUID,而你的需求只是“跨节点生成且不暴露时间”,UUIDv4 通常更标准、更紧凑。选择 CUID2 的真正理由应该是它的小写字母数字文本、首字母特性、Web 生态与多源熵设计,而不是“UUID 不够唯一”。
现在还有哪些主流方案
UUID 与 CUID 之外,下面这些方案仍活跃在不同生态中:
| 方案 | 二进制 / 常见文本 | 生成模型 | 优点 | 缺点与适用边界 |
|---|---|---|---|---|
自增 BIGINT / Sequence | 8 B / 十进制 | 数据库集中分配 | 最紧凑、确定唯一、单库 B-tree 友好 | 跨库合并和离线生成困难;适合单库内部键 |
| 号段 / Hi-Lo / Leaf segment | 通常 8 B | 中心批量划段、本地消费 | 高吞吐、无时钟回拨、仍是整数 | 需协调与运维;号段浪费、顺序泄露 |
| Snowflake 家族 | 通常 8 B | 时间 + worker + sequence | 紧凑、吞吐高、趋势递增 | worker 分配、回拨、位宽和寿命是运维契约 |
| UUIDv4 | 16 B / 36 chars | CSPRNG | 标准、无协调、不泄露时间、生态最好 | 随机索引写;不按时间排 |
| UUIDv7 | 16 B / 36 chars | 毫秒时间 + 熵/计数 | 标准、无协调、传统索引局部性好 | 暴露时间;范围分片数据库可能形成热点 |
| ULID | 16 B / 26 chars | 48-bit 毫秒 + 80-bit random | 紧凑文本、字典序与时间一致、实现多 | 非 IETF UUID;同毫秒全局顺序仍依赖实现 |
| KSUID | 20 B / 27 chars | 32-bit 秒 + 128-bit random | 随机空间大、文本与二进制均可排序 | 比 UUID 更宽、秒级时间、原生数据库支持少 |
| MongoDB ObjectID | 12 B / 24 hex | 秒时间 + 进程随机 + counter | MongoDB 原生、紧凑、趋势有序 | 非 CSPRNG 安全令牌;格式与 MongoDB 生态绑定 |
| CUID2 | 默认约 24 B 文本 | 多源熵 + hash + Base36 | 不透明、URL 友好、离线生成 | 不可排序、原生类型少、默认比 binary UUID 宽 |
| Nano ID | 默认 21 chars | CSPRNG + 可定制 alphabet | 短、URL 友好、实现小 | 不可排序;缩短长度或改 alphabet 必须重算碰撞风险 |
| TypeID | prefix + 26-char UUIDv7 | 类型前缀 + Base32 UUIDv7 | 日志和 API 中一眼看出资源类型 | 数据库内部若存整个前缀会变宽;底层仍是 UUIDv7 |
| UUIDv5 / 内容哈希 | 16 B 或更长 | namespace/name 或内容的确定性 hash | 同输入同 ID,适合幂等导入、不可变内容去重 | 输入变化 ID 就变;不适合一般可变实体主键 |
ULID 规范定义 48 位毫秒时间、80 位 randomness 与 26 位 Crockford Base32 文本;同毫秒单调性是生成器可以提供的额外模式,而不是跨所有主机自动获得的全局顺序。KSUID则使用 20 字节:4 字节秒时间加 16 字节随机 payload,文本固定 27 位 Base62。
MongoDB 的 ObjectID 是很成功的数据库专用折中。现行规范为 4 字节 Unix 秒时间、5 字节进程唯一随机值和 3 字节计数器;随机部分不要求密码学安全,所以它适合作为 _id,不应当作安全令牌。
TypeID 则说明“算法”和“外部表示”可以分层:它把 UUIDv7 编成 26 位 Base32,并加上 user_、order_ 之类的类型前缀。数据库内部完全可以只存 16 字节 UUID,API 边界再编码为 user_...,不必为了可读性让每个二级索引重复存储前缀。参见 TypeID 规范项目。
为什么 B-tree 让大家如此在意“有序”
B-tree 索引由固定大小的页组成。插入一条记录时,数据库先沿树找到目标叶子页;目标页放不下就要分裂,并把新的分隔键写入父页,父页也可能继续分裂。PostgreSQL 的 B-tree 文档描述了这条向上级联的过程。
用极简模型看两类 ID:
递增 / UUIDv7:
旧页 [................] [..............→ 新值]
活跃写入集中在右侧少数页,缓存命中通常更好
随机 / UUIDv4 / CUID2:
旧页 [..→..] [→.....] [....→.] [..→...]
活跃写入散落在更多页,索引大于内存后代价上升
但是“越有序越好”只适用于某些物理架构。
单机 B-tree:时间有序通常改善局部性
在一台传统数据库里,递增整数和 UUIDv7 让新记录靠近索引右端,通常减少随机页访问。SQL Server 的 NEWSEQUENTIALID() 就是早期有序 GUID 方案,微软文档明确说明它可减少叶子级随机 I/O 和页分裂,同时警告其下一值可能被猜测,不适合有隐私要求的场景,参见 NEWSEQUENTIALID。
“右端写入”也可能在极高并发下产生最后一页锁存竞争,但现代数据库有各自的并发优化。究竟是随机写的缓存代价大,还是单点叶页竞争大,需要压测目标引擎和真实写入并发,不能只凭 ID 形状判断。
范围分片数据库:完全相反,有序键可能把一台机器打热
Spanner 等分布式数据库按 key range 分布数据。单调递增键意味着所有最新写入都进入最大的那个范围,在范围分裂和迁移完成前持续压到同一节点。Google 的 Spanner schema best practices因此建议使用 UUIDv4、hash 前缀或 bit-reversed sequence,避免时间戳和递增序列位于主键高位。
这解释了看似矛盾的经验:
集中式 B-tree: UUIDv7 / 递增键常带来更好的缓存局部性
范围分片存储: UUIDv7 / 递增键可能把最新写入集中成热点
如果分布式数据库已经提供自动 hash-sharding、散列分区或专用 unique_rowid,优先遵循该产品的主键指南,不要机械搬用 MySQL 时代的“必须递增”。
同一个 UUID,在 PostgreSQL 与 InnoDB 里的代价不同
PostgreSQL:主键索引有局部性问题,但 heap 不按主键排列
PostgreSQL 默认把行追加到 heap 的可用位置,主键只是另一棵指向 heap tuple 的索引。随机 UUID 会让主键索引的访问页更分散,却不会像 InnoDB 那样天然决定整行物理排列。PostgreSQL 的 CLUSTER可以按某个索引重排表,但文档明确说这是一项一次性操作,后续写入不会自动保持顺序。
因此,“UUIDv4 会让 PostgreSQL 表按随机顺序到处插行”并不准确;更准确的是它会影响索引页局部性,并可能间接影响缓存、WAL 与 I/O。
MySQL InnoDB:主键宽度与顺序会扩散到整张表
InnoDB 的聚簇索引叶子存放整行,每条二级索引记录还包含对应主键值。MySQL 官方的聚簇与二级索引说明因此直接建议使用较短主键。
这意味着把 UUID 存成 CHAR(36) 主键有双重代价:
- 聚簇数据页上的键从 8 或 16 字节膨胀到至少 36 字节文本;
- 每一个二级索引都再复制这段主键。
如果表有五个二级索引,主键多出的字节不是只付一次。InnoDB 使用 UUID 时应优先 BINARY(16);若使用 UUIDv7,应按 RFC 的字节顺序存储。MySQL 的 UUID_TO_BIN(uuid, 1) swap flag 是为 v1 交换 time-low 与 time-high 字段而设计,官方文档明确说它对非 v1 格式没有收益,不能不加判断地套到 v7,参见 UUID_TO_BIN。
存储格式经常比生成算法更影响数据库
下面只比较 payload,不包含行头、B-tree 指针、对齐、变长字段头和字符排序规则等额外开销:
| 值 | 紧凑存储 | 常见文本存储 |
|---|---|---|
BIGINT / Snowflake | 8 B | 最多约 19 位十进制字符 |
| MongoDB ObjectID | 12 B | 24 个十六进制字符 |
| UUIDv4 / UUIDv7 / ULID | 16 B | UUID 36 chars;ULID 26 chars |
| KSUID | 20 B | 27 chars |
| Nano ID 默认 | 无统一二进制格式 | 21 chars |
| CUID2 默认 | 无数据库通用原生格式 | 24 chars |
由此可见:
- “ULID 文本比 UUID 文本短”成立;“ULID 的信息本体比 UUID 小”不成立,它们都是 128 位;
- CUID2 默认文本确实比 canonical UUID 文本短,却仍可能比数据库原生 UUID 的 16 字节宽;
- 在字符列上比较 ID 还要考虑 charset 与 collation。只含 ASCII 的区分大小写 ID 应使用明确、稳定的二进制或 ASCII collation,避免不必要的 Unicode 排序成本和等价规则;
- JSON、日志与 URL 看见的是文本长度,B-tree 看见的是数据库实际类型,两边可以使用不同编码。
选型时真正应该问的九个问题
不要先问“哪个算法最新”,先写下约束:
- 唯一性范围是一张表、一个数据库,还是多个独立区域和合作方?
- ID 必须在写数据库前生成吗?浏览器和移动端需要离线创建对象吗?
- 数据最终会不会跨库合并、双写、同步或进入消息系统?
- 主数据库是单机 B-tree、InnoDB 聚簇表,还是按 key range 分片?
- 外键和二级索引有多少,主键宽度会被复制多少次?
- 外部看到创建时间、节点或业务规模是否有风险?
- 需要的是“趋势有序”,还是有严格语义的全局顺序?
- 团队是否有能力运营 worker ID、时钟监控和中心发号器?
- ID 要活多久?格式、epoch、位宽和库能否在十年后继续互操作?
把答案映射到方案,通常会得到下面的结果。
场景一:普通单体或单主关系数据库
默认选择:BIGINT IDENTITY / AUTO_INCREMENT。
它简单、紧凑、确定唯一。若不希望外部枚举内部行号,可另加 UUIDv4、UUIDv7 或 CUID2 作为 public_id UNIQUE。双 ID 会多一个唯一索引和一次查询映射,只有确实存在外部暴露、未来合并或安全审计边界时才值得付出。
场景二:多个服务或客户端必须独立生成,数据会合并
默认选择:UUIDv7;不希望泄露时间则 UUIDv4。
UUIDv7 同时具有标准化、16 字节原生存储、跨语言实现和传统数据库局部性。若底层是范围分片数据库,应重新评估高位时间热点,可能改用 UUIDv4、hash-sharded index 或产品提供的散列键。
场景三:浏览器优先,ID 经常出现在 URL、DOM、日志中
候选:CUID2、Nano ID、TypeID,或改变 UUID 的外部编码。
- 想隐藏时间、只用小写字母数字并支持离线生成:CUID2;
- 想要很短的随机 URL ID:按容量计算后的 Nano ID;
- 想让日志直接显示资源类型并保持时间局部性:TypeID;
- 想保留数据库原生 UUID:内部 16-byte UUID,边界层再做 Base32 / Base64url 编码。
不要因为“URL 短”就随意把 Nano ID 或 CUID2 截到 8 位。取值空间随每删一个字符成倍下降,而且攻击者猜中任意一个有效资源的风险与“自己生成两次碰撞”的生日问题不是同一个概率模型。
场景四:超大规模、必须 64 位、每秒生成数百万 ID
候选:Snowflake 家族或成熟号段服务。
选择条件不只是 QPS,而是团队愿意把 worker 分配、epoch、时钟回拨、容量上限、跨机房灾备和监控当成长期基础设施。没有这些约束时,128 位 UUIDv7 往往能用更低的组织复杂度解决问题。
场景五:多租户与明显的访问局部性
可以考虑组合主键:
PRIMARY KEY (tenant_id, local_id)
它能把同一租户的数据放在相邻 key range,并让外键携带租户隔离信息。但大租户可能成为热点,组合主键也会变宽;InnoDB 的每个二级索引都会复制整组主键。另一种折中是保留窄的全局代理主键,再对 (tenant_id, business_key) 建唯一索引。
场景六:同一输入必须得到同一 ID
使用 UUIDv5 或内容哈希,但只用于确定性映射、幂等导入或不可变内容寻址。不要把邮箱等可变自然属性先 hash 一遍就误以为得到了稳定代理键:输入一变,hash 也变;hash 只隐藏了原值,并没有移除原值的业务生命周期。
三套可落地的表设计
PostgreSQL 18:直接以 UUIDv7 为主键
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id uuid NOT NULL REFERENCES customers(id),
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
status text NOT NULL
);
即使 UUIDv7 包含时间,也保留 created_at。前者是身份格式的一部分,后者才是可查询、可修正、有业务语义的列。
单库内部效率优先:窄主键 + 公开 ID
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
public_id uuid NOT NULL DEFAULT uuidv4(),
email text NOT NULL,
UNIQUE (public_id),
UNIQUE (email)
);
这种设计适合内部大量外键连接、外部又不应看见连续行号的系统。它的代价是所有通过 public_id 的访问先走唯一索引,再取内部行;批量导出和消息事件还必须明确到底使用哪个 ID,避免两个身份体系漂移。
MySQL InnoDB:应用生成 UUIDv7,按 16 字节保存
CREATE TABLE orders (
id BINARY(16) NOT NULL,
customer_id BINARY(16) NOT NULL,
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
status VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
KEY idx_orders_customer (customer_id)
) ENGINE = InnoDB;
应用应把 UUIDv7 的 128 位按 RFC 网络字节序写入 BINARY(16),展示时再编码。不要先生成 36 字符 UUID 再把字符本身塞进 BINARY(36),也不要对 v7 使用只为 v1 设计的 swap flag。
若确实选择 CUID2,应固定长度和排序规则,例如:
public_id CHAR(24)
CHARACTER SET ascii
COLLATE ascii_bin
NOT NULL UNIQUE
并确认所有语言实现使用同一代 CUID2、同一长度与合法字符集。
几个经常导致事故的判断
“主键递增,所以可以按主键恢复真实时间顺序”
错误。sequence cache、号段并行消费、事务回滚、时钟偏移、异步消息和提交顺序都会打破这个推论。需要排序就保存有业务语义的时间或日志位置;需要严格全序,就使用数据库提交序列、消息系统 offset 或共识协议,而不是从普通 ID 猜。
“UUID / CUID 已经唯一,不用建唯一约束”
错误。概率型 ID 依赖实现和随机源;确定型分布式 ID 依赖 worker、时钟和状态配置。唯一索引是最后的完整性边界,也能把生成器故障从静默串数据变成可观测错误。
“不可猜 ID 就能防越权”
错误。不可猜只能降低盲扫效率,不能替代对象级授权。密码重置、邀请链接、会话等能力凭证应使用独立的 CSPRNG token、有效期、单次使用/撤销机制,并在数据库中考虑只保存摘要。
“时间有序 ID 可以省掉 created_at”
错误。UUIDv7、ULID、KSUID、ObjectID 的时间来自生成器,而记录可能晚些入库、重试、从离线端上传或被历史回填。RFC 9562 甚至不保证 UUID 时间戳必须与真实时钟非常接近。ID 可以帮助大体聚簇和排障,不能替代业务时间模型。
“选错了以后再改主键就行”
主键会渗入外键、缓存键、URL、消息、数据仓库和第三方集成,是最难迁移的字段之一。InnoDB 重建主键需要重组整张表;分布式系统还要处理双写和历史事件。真正迁移时通常要经历:新增列、回填、建立唯一索引、双写、迁移所有引用、切换读路径,最后才考虑替换旧键。能用稳定内部键与公开 ID 分层解决的,不要轻易在线重写全链路主键。
最终建议
可以把选型压缩成下面几条:
- 业务唯一性用
UNIQUE表达,代理主键不能替代它。 - 单库内部主键优先
BIGINT;需要写入前生成、跨库合并或统一公开身份时优先 UUIDv7。 - 不希望 ID 泄露时间时选 UUIDv4;偏 Web、要求小写字母数字和多源熵时再考虑 CUID2。
- 不要再为新系统选原版 CUID;库函数叫
cuid()时也要确认实际版本。 - UUID 用数据库原生
uuid或BINARY(16),不要因展示格式默认存成CHAR(36)。 - 传统 B-tree 喜欢局部性,范围分片数据库却可能被递增高位打出热点;以目标数据库官方建议和压测为准。
- 需要 64 位超高吞吐才认真评估 Snowflake / 号段,并把 worker、时钟与容灾视为系统的一部分。
- ID 负责身份,
created_at负责时间,授权系统负责权限,安全 token 负责能力;不要让一个字段兼职所有职责。
主键方案的演变,本质上是一连串“把协调成本放在哪里”的选择:自然键把成本交给业务稳定性,自增整数交给数据库,号段交给中心分配,Snowflake 交给时钟与 worker 管理,UUIDv4 交给随机空间,UUIDv7 用少量时间信息换索引局部性,CUID2 则用更宽的文本与哈希计算换取不透明和 Web 友好。
理解这条交换关系,比记住“某某 ID 最先进”更重要。因为真正决定答案的从来不是 ID 的名字,而是你的数据将在哪里生成、怎样写入、被谁看见,以及十年后还要被哪些系统继续引用。