别拿 UUID v4 当数据库主键:一次写到崩的教训,和 v7 怎么救场
那张表上线半年都好好的。直到行数过了一千万,深夜的批量导入开始超时,写入 p99 从十几毫秒飙到大半秒。改的不是业务代码——是主键。我们把 UUID v4 换成了带时间前缀的 v7,写入曲线当天就平了。这篇说清楚中间到底发生了什么。
先把结论摆这儿,赶时间的可以只看这段:用随机 UUID(v4)做 InnoDB 主键,在表小的时候没事,表一大就会因为聚簇索引页分裂把写入性能拖垮。问题不在 UUID 本身,在于它随机。换成时间有序的 UUID v7、或者干脆用自增主键 + 单独的 UUID 业务列,都能绕开。
为什么"随机"这件事在主键上是致命的
得先讲一个很多人没意识到的事:InnoDB 的主键不是普通索引,它是聚簇索引。意思是整张表的数据行,物理上就是按主键顺序排在一棵 B+ 树的叶子节点里的。主键的值决定了一行数据被塞到磁盘的哪个位置。
自增主键插入时,新行永远追加到 B+ 树最右边那个页。写满了就开一个新页,老页再也不动。顺序、紧凑、缓存友好。
UUID v4 是 122 位随机数。新插入的主键值落在哪儿完全随机——可能要插到三个月前那批数据中间。那个页早就写满了,于是 InnoDB 得做页分裂(page split):把一个满页劈成两个半空的页,腾出位置。
页分裂的连锁反应有三层:① 一次插入变成多次页 I/O;② 页只填了一半,空间利用率掉到 ~60%,同样的数据占更多磁盘、吃更多内存;③ 被随机命中的老页不停被换进换出 buffer pool,命中率崩盘。表越大、buffer pool 装不下整棵树时,最后一条尤其要命。
表小的时候整棵索引树都在内存里,页分裂的代价被内存吃掉了,你根本看不出来。等数据量超过 buffer pool(innodb_buffer_pool_size),随机写开始频繁穿透到磁盘——这就是为什么问题总在"跑了大半年、突然有一天"爆发,而不是上线第一天。
到底慢多少
不想引用别人的跑分,就说我们自己那张表的观感:同样的批量导入脚本,自增主键时稳定在十几毫秒一批;换 UUID v4 主键、表过千万行之后,p99 摸到了五六百毫秒,而且抖动极大——取决于这一批的随机值是不是正好命中了冷页。磁盘 IOPS 被写放大顶满,CPU 倒还闲着。
这是随机写的典型画像:瓶颈不在算力,在于你逼着数据库到处乱跳着写。
还有个隐藏成本:你可能把它存成了字符串
UUID 长这样:550e8400-e29b-41d4-a716-446655440000。很多人图省事,直接拿 CHAR(36) 或 VARCHAR(36) 存。
-- ❌ 36 字节,还是个字符串比较
id CHAR(36) PRIMARY KEY
-- ✅ 16 字节二进制,比较更快
id BINARY(16) PRIMARY KEY
差的不止是 36 比 16 这点字节。InnoDB 的每一个二级索引都会把主键值带上——你建 5 个二级索引,这个臃肿的主键就被复制 5 份。主键从 16 字节涨到 36 字节,整库的索引体积跟着膨胀一大圈,内存里能缓存的索引项变少,又反过来加重前面说的换页问题。
想看一个 UUID 拆开后每一段是什么含义、或者快速生成几个测试用的 UUID,可以直接用 在线 UUID 生成器,v1/v4 都能出。
UUID v7:把时间戳放进前缀
2024 年 5 月,RFC 9562 正式发布,取代了用了二十年的 RFC 4122,新增了 v6、v7、v8 三个版本。其中真正解决问题的是 v7。
它的设计简单粗暴:把 128 位的前 48 位换成 Unix 毫秒时间戳,剩下的位填随机数和版本标记。
UUID v7 结构(128 位)
┌─────────────────────────┬──────┬────────────────────┐
│ 48 位 Unix 毫秒时间戳 │ 版本 │ 74 位随机 / 序列 │
└─────────────────────────┴──────┴────────────────────┘
0190f8a2-3b4c-7xxx-xxxx-xxxxxxxxxxxx
└── 时间在最高位 → 字典序≈时间序 → 插入接近顺序追加
因为时间戳在最高位,新生成的 UUID v7 在排序上总是比旧的大。插进 B+ 树时又回到了"往最右边追加"的模式——页分裂没了,写放大没了。同时它还保留了 UUID 的核心优点:客户端就能生成、全局唯一、不暴露自增 ID 那种"猜得到下一个"的顺序。
主流库都跟上了:PostgreSQL 18 内置 uuidv7() 函数;Java 用 java.util.UUID 之外的库或 Hibernate 6 的生成器;Node 生态有 uuidv7 包;Python 3.14 的 uuid 模块加了 uuid7()。生成不出 v7 的老环境,自己拼时间戳前缀也就几行代码。
// Node.js,零依赖手搓一个够用的 v7
import { randomBytes } from "crypto";
function uuidv7() {
const ts = Date.now(); // 48 位毫秒时间戳
const b = randomBytes(16);
b[0] = (ts / 2 ** 40) & 0xff;
b[1] = (ts / 2 ** 32) & 0xff;
b[2] = (ts / 2 ** 24) & 0xff;
b[3] = (ts / 2 ** 16) & 0xff;
b[4] = (ts / 2 ** 8) & 0xff;
b[5] = ts & 0xff;
b[6] = (b[6] & 0x0f) | 0x70; // version = 7
b[8] = (b[8] & 0x3f) | 0x80; // variant
const h = b.toString("hex");
return `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
}
那 v4 是不是就该扔了
不是。v4 没问题的地方多了去了——它只是不该当聚簇索引的主键而已。
- 对外暴露的 ID:v7 的前缀泄露了创建时间,竞品能从你的订单号推算出每天下了多少单。要防这个,对外用 v4,库里另存。
- 密码重置 token、一次性链接:要的就是不可预测,v4 的全随机正合适。
- 分布式 trace ID、幂等键:不进数据库主键,随机就好。
一句话区分:进 B+ 树当聚簇主键的,要有序(v7 或自增);要不可预测的,要随机(v4)。这俩需求天生打架,所以很多团队干脆两列分开——内部主键用自增 BIGINT,对外业务 ID 用 v4 单独建唯一索引。
别看见"v7 更好"就连夜给老表改主键。改聚簇主键 = 整张表数据物理重排 + 所有二级索引重建,大表上是个停机级操作。存量表更现实的做法:新表用 v7,老表维持现状,或者排个维护窗口慢慢迁。
如果你正在做技术选型
三选一,按场景:
| 方案 | 适合 | 代价 |
|---|---|---|
自增 BIGINT | 纯内部表、单库 | 分库分表会撞 ID、对外暴露顺序 |
UUID v7 + BINARY(16) | 要分布式生成又要写入快 | 前缀泄露时间 |
| 自增主键 + v4 业务列 | 既要写入快又要对外不可预测 | 两列、两个索引 |
大多数新项目我会直接选第二个:v7 存成二进制。够快,够唯一,客户端能生成,省掉了自增主键在分库时的那一堆破事。