“数据库主键应该用自增整数、UUID,还是 CUID?”看起来是在比较三种字符串或数字,实际混合了至少四个问题:

  1. 什么属性代表现实中的实体:身份证号、邮箱、订单号,还是一个没有业务含义的代理值?
  2. 谁来生成 ID:数据库、中心发号器、每个服务实例,还是离线客户端?
  3. 数据库怎样存储和索引它:8 字节整数、16 字节原生 UUID,还是二十多个字符的文本?
  4. 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 适合做聚簇键吗”是两道题。

一条主线看懂半个多世纪的演变

主键方案的历史不是旧算法不断被新算法淘汰,而是系统边界不断扩大:从一台数据库,扩展到多库多机、浏览器和手机离线写入,再扩展到全球分布式存储。

流程图 1流程图 1

关系模型:用值建立关系,而不是追逐物理指针

早期层次数据库和网状数据库依赖访问路径与记录指针。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_INCREMENTIDENTITYSEQUENCE 几乎集齐了主键想要的优点:

  • 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

版本不同,剩余比特的含义完全不同:

版本核心组成排序特性主要用途主要问题
v1Gregorian 时间、时钟序列、node ID原始字段顺序不适合直接按时间排DCE / 遗留系统可能暴露时间与节点,字节布局别扭
v2DCE Security特定 DCE 场景RFC 9562 不定义其生成细节,极少用于通用业务
v3namespace + name 的 MD5确定性同名输入生成同一 IDMD5,现行标准建议优先 v5
v4122 位随机数据无时间顺序通用随机、隐藏创建时间传统 B-tree 插入位置分散
v5namespace + name 的 SHA-1确定性DNS 名、外部稳定键映射、幂等导入输入语义变化会改变 ID;不适合可变自然键
v6重排后的 v1按字节时间有序兼容 v1 的系统升级仍继承 v1 的时间模型;无遗留包袱时应选 v7
v748 位 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

这个改变带来三个直接结果:

  1. 不能从结果中直接取出时间或 fingerprint;
  2. 不同节点可以离线、无协调地生成;
  3. 哈希打散了时间,因此 CUID2 不可按创建时间排序

CUID2 官方也明确把 sequential IDs 和高性能 tight loop 列为不适用场景。它更像一个带多源熵混合的、URL 友好的不透明应用 ID,而不是 UUIDv7 的可排序替代品。

UUID 与 CUID2 的实质比较

“UUID”必须具体到版本。最有意义的是把 CUID2 与 UUIDv4、UUIDv7 分开比较:

维度UUIDv4UUIDv7CUID2 默认配置
标准化IETF RFC 9562IETF 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 / Sequence8 B / 十进制数据库集中分配最紧凑、确定唯一、单库 B-tree 友好跨库合并和离线生成困难;适合单库内部键
号段 / Hi-Lo / Leaf segment通常 8 B中心批量划段、本地消费高吞吐、无时钟回拨、仍是整数需协调与运维;号段浪费、顺序泄露
Snowflake 家族通常 8 B时间 + worker + sequence紧凑、吞吐高、趋势递增worker 分配、回拨、位宽和寿命是运维契约
UUIDv416 B / 36 charsCSPRNG标准、无协调、不泄露时间、生态最好随机索引写;不按时间排
UUIDv716 B / 36 chars毫秒时间 + 熵/计数标准、无协调、传统索引局部性好暴露时间;范围分片数据库可能形成热点
ULID16 B / 26 chars48-bit 毫秒 + 80-bit random紧凑文本、字典序与时间一致、实现多非 IETF UUID;同毫秒全局顺序仍依赖实现
KSUID20 B / 27 chars32-bit 秒 + 128-bit random随机空间大、文本与二进制均可排序比 UUID 更宽、秒级时间、原生数据库支持少
MongoDB ObjectID12 B / 24 hex秒时间 + 进程随机 + counterMongoDB 原生、紧凑、趋势有序非 CSPRNG 安全令牌;格式与 MongoDB 生态绑定
CUID2默认约 24 B 文本多源熵 + hash + Base36不透明、URL 友好、离线生成不可排序、原生类型少、默认比 binary UUID 宽
Nano ID默认 21 charsCSPRNG + 可定制 alphabet短、URL 友好、实现小不可排序;缩短长度或改 alphabet 必须重算碰撞风险
TypeIDprefix + 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) 主键有双重代价:

  1. 聚簇数据页上的键从 8 或 16 字节膨胀到至少 36 字节文本;
  2. 每一个二级索引都再复制这段主键。

如果表有五个二级索引,主键多出的字节不是只付一次。InnoDB 使用 UUID 时应优先 BINARY(16);若使用 UUIDv7,应按 RFC 的字节顺序存储。MySQL 的 UUID_TO_BIN(uuid, 1) swap flag 是为 v1 交换 time-lowtime-high 字段而设计,官方文档明确说它对非 v1 格式没有收益,不能不加判断地套到 v7,参见 UUID_TO_BIN

存储格式经常比生成算法更影响数据库

下面只比较 payload,不包含行头、B-tree 指针、对齐、变长字段头和字符排序规则等额外开销:

紧凑存储常见文本存储
BIGINT / Snowflake8 B最多约 19 位十进制字符
MongoDB ObjectID12 B24 个十六进制字符
UUIDv4 / UUIDv7 / ULID16 BUUID 36 chars;ULID 26 chars
KSUID20 B27 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 看见的是数据库实际类型,两边可以使用不同编码。

选型时真正应该问的九个问题

不要先问“哪个算法最新”,先写下约束:

  1. 唯一性范围是一张表、一个数据库,还是多个独立区域和合作方?
  2. ID 必须在写数据库前生成吗?浏览器和移动端需要离线创建对象吗?
  3. 数据最终会不会跨库合并、双写、同步或进入消息系统?
  4. 主数据库是单机 B-tree、InnoDB 聚簇表,还是按 key range 分片?
  5. 外键和二级索引有多少,主键宽度会被复制多少次?
  6. 外部看到创建时间、节点或业务规模是否有风险?
  7. 需要的是“趋势有序”,还是有严格语义的全局顺序?
  8. 团队是否有能力运营 worker ID、时钟监控和中心发号器?
  9. 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 分层解决的,不要轻易在线重写全链路主键。

最终建议

可以把选型压缩成下面几条:

  1. 业务唯一性用 UNIQUE 表达,代理主键不能替代它。
  2. 单库内部主键优先 BIGINT;需要写入前生成、跨库合并或统一公开身份时优先 UUIDv7。
  3. 不希望 ID 泄露时间时选 UUIDv4;偏 Web、要求小写字母数字和多源熵时再考虑 CUID2。
  4. 不要再为新系统选原版 CUID;库函数叫 cuid() 时也要确认实际版本。
  5. UUID 用数据库原生 uuidBINARY(16),不要因展示格式默认存成 CHAR(36)
  6. 传统 B-tree 喜欢局部性,范围分片数据库却可能被递增高位打出热点;以目标数据库官方建议和压测为准。
  7. 需要 64 位超高吞吐才认真评估 Snowflake / 号段,并把 worker、时钟与容灾视为系统的一部分。
  8. ID 负责身份,created_at 负责时间,授权系统负责权限,安全 token 负责能力;不要让一个字段兼职所有职责。

主键方案的演变,本质上是一连串“把协调成本放在哪里”的选择:自然键把成本交给业务稳定性,自增整数交给数据库,号段交给中心分配,Snowflake 交给时钟与 worker 管理,UUIDv4 交给随机空间,UUIDv7 用少量时间信息换索引局部性,CUID2 则用更宽的文本与哈希计算换取不透明和 Web 友好。

理解这条交换关系,比记住“某某 ID 最先进”更重要。因为真正决定答案的从来不是 ID 的名字,而是你的数据将在哪里生成、怎样写入、被谁看见,以及十年后还要被哪些系统继续引用。