2026 年 6 月 5 日
在数据库中,用随机 UUID 作主键很常见。不过,随机 UUID(如 UUID4)有个已知缺点,其无序性会让聚集索引产生大量额外分页操作,因为是随机将行插入 B 树,还得重新平衡。本文能帮我们直观了解这些额外分页操作带来的性能成本。虽说本文主要针对 SQLite,但随机 UUID 的问题在其他用聚集索引的数据库中同样存在。
什么是聚集索引?
聚集索引决定了表中数据行的物理存储顺序。表的数据行存于索引的叶子页,按索引键排序。所以:每个表只能有一个聚集索引(行只能按一种方式物理排序);聚集索引就是表本身,叶子节点含完整的行数据;相比之下,非聚集索引只存索引列及指向实际行数据的指针,实际行数据存于其他地方。
Rowid
每个普通的 SQLite 表都有个隐式的 64 位整数主键,叫 rowid。表的数据存于按 rowid 排序的 B 树中,这其实就是 SQLite 的聚集索引。行的物理存储顺序遵循 rowid 序列。
无 Rowid 表
SQLite 也支持无 Rowid 表。这些表没有隐式的 rowid,声明的主键会成为聚集索引。
基准测试
我们以常规的 rowid 整数主键建立性能基准,分批次插入 1000 万行数据,每次插 100 万行。
(d/q writer ["CREATE TABLE IF NOT EXISTS event(id INT PRIMARY KEY, data BLOB)"]) (dotimes [_ 100] (time (d/with-write-tx [db writer] (dotimes [_ 1000000] (d/q db ["INSERT INTO event (data) values (?)", data])))))结果如下:
| 总行数 | 时间(毫秒) |
|---|---|
| 10000000 | 1208 |
| 20000000 | 1102 |
| 30000000 | 1177 |
| 40000000 | 1138 |
| 50000000 | 1086 |
| 60000000 | 1101 |
| 70000000 | 1070 |
| 80000000 | 1069 |
| 90000000 | 1079 |
| 100000000 | 1081 |
大约每秒插入 100 万行。
UUID4
现在试试用 UUID4。
(d/q writer ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"]) (dotimes [_ 10] (time (d/with-write-tx [db writer] (dotimes [_ 1000000] (d/q db ["INSERT INTO event (id, data) values (?, ?)", (random-uuid4-bytes), data])))))结果如下:
| 总行数 | 时间(毫秒) |
|---|---|
| 10000000 | 2649 |
| 20000000 | 5644 |
| 30000000 | 7137 |
| 40000000 | 8352 |
| 50000000 | 9359 |
| 60000000 | 9817 |
| 70000000 | 10490 |
| 80000000 | 11130 |
| 90000000 | 11668 |
| 100000000 | 12586 |
哦不!速度慢了 10 - 12 倍!
性能分析
差距不小。不过,我们能进行性能分析,而非凭空猜测。下面是归一化的差异图(diffgraph)。差异图用于比较两个性能分析快照(本例中是整数主键和 UUID4 主键),并以火焰图结构显示差异。与显示绝对变化的常规差异图不同,归一化视图会调整两个比较配置文件的样本总数,使其相同。这意味着我们能以百分比形式看到相对差异,这很重要,因为配置文件运行时间不同。
颜色表示变化方向:蓝色框表示在第二个配置文件(UUID4)中,该函数花费时间比第一个(整数主键)少;红色框表示在第二个配置文件中花费时间更多。颜色强度表示该框样本数量的相对变化(自身时间增量)。从差异图能看到,在平衡树、读写操作上花费时间更多。这是因为 UUID4 无序,是随机排序的,迫使 SQLite 不断重新平衡 B 树。
UUID7
理论上,可用 UUID7 解决问题,因为 UUID7 按时间排序,消除了 UUID4 的排序问题。看看是否能改善情况。
(d/q writer ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"]) (dotimes [_ 10] (time (d/with-write-tx [db writer] (dotimes [_ 1000000] (d/q db ["INSERT INTO event (id, data) values (?, ?)", (random-uuid7-bytes), data])))))结果如下:
| 总行数 | 时间(毫秒) |
|---|---|
| 10000000 | 1372 |
| 20000000 | 1280 |
| 30000000 | 1365 |
| 40000000 | 1250 |
| 50000000 | 1256 |
| 60000000 | 1270 |
| 70000000 | 1246 |
| 80000000 | 1257 |
| 90000000 | 1245 |
| 100000000 | 1258 |
速度回到较合理水平,比基准测试稍慢。这是因为 UUID 二进制主键为 16 字节,而整数主键为 8 字节。
结论
希望本文能帮你了解 SQLite 中使用 UUID 主键的陷阱及应对办法。
完整的基准测试代码可[点击此处](https://github.com/andersmurphy/clj-cookbook/tree/master/sqlite-perils-of-uuid)查看。如果你喜欢这篇文章,可能也会对[《使用 SQLite 实现 100000 TPS》](https://andersmurphy.com/2025/12/02/100000-tps-over-a-billion-rows-the-unreasonable-effectiveness-of-sqlite.html)感兴趣。
延伸阅读
- 聚集索引
- 聚集索引与无 Rowid 优化
- clj-async-profiler
- 探索火焰图
- 差异图
感谢 [Datastar Discord](https://discord.gg/bnRNgZjgPh) 上阅读本文草稿并提供反馈的每一个人。