Nikolay Samokhvalov:UUIDv4 与 UUIDv7 对比——随机主键在 Postgres 中的代价

Nikolay Samokhvalov:UUIDv4 与 UUIDv7 对比——随机主键在 Postgres 中的代价

💡 原文英文,约5300词,阅读约需20分钟。
📝

内容提要

文章对比了PostgreSQL中UUIDv4与UUIDv7作为主键的性能差异:当索引超出内存后,UUIDv4的插入速率仅为有序键的0.62–0.69倍,每行WAL写入量多1.5–1.9倍,索引体积大33%,原因是随机键使整个索引都成为工作集。建议新表使用int8或uuidv7(),并在服务端生成UUIDv7,以避免客户端时钟偏差。

🔎

延伸解读

性能拐点:索引超出内存后UUIDv4才显劣势

文章实测显示,当主键索引仍能放入shared_buffers时,UUIDv4与UUIDv7的插入速率几乎相同(0.95–1.10倍)。但索引超出内存后,UUIDv4的插入速率降至有序键的0.62–0.69倍。这意味着在数据量较小或内存充足时,UUIDv4的随机性代价并不明显;只有当索引工作集超出内存,随机写入导致整个索引成为工作集,每次插入都可能触发缓冲区换出,性能差距才显著扩大。

WAL放大:随机键增加检查点后的全页写入

UUIDv4的WAL开销主要来自全页写入(FPI)。每次检查点后,UUIDv4几乎每触碰一个叶子页就要写一次全页镜像,而UUIDv7和int8只反复修改最右侧叶子,只需一次镜像。在9000万至1亿行时,UUIDv4每行WAL是参考键的1.5–1.9倍。延长checkpoint_timeout可减少WAL(5分钟到30分钟降低39%),但不会改善插入速率,且会延长崩溃恢复时间。

索引膨胀与迁移陷阱:UUIDv4索引大33%,切换需重建

由于随机插入导致叶子页平均填充率仅68%(接近ln 2),UUIDv4主键索引比UUIDv7大33%。将现有表默认值改为uuidv7()虽只需一条ALTER语句,但旧UUIDv4值仍占主导,新UUIDv7键会插入到旧键范围的中间,导致新索引部分填充率更差(约50%),且不会自动整理。若在意索引大小,需定期REINDEX CONCURRENTLY或迁移到新表。

服务端生成与查询模式:避免时钟偏差和错误排序

客户端生成UUIDv7时,若各客户端时钟偏差达±60秒,索引叶子密度会降至69%,45%的叶子乱序,性能接近UUIDv4。因此应在服务端用uuidv7()生成。此外,UUIDv4表上order by id desc limit 100返回的是100个最大随机值而非最新行,应改用created_at排序。分区时,UUIDv7可按id范围分区,同时支持id点查和时间窗口裁剪,而UUIDv4通常按created_at分区,导致id查询扫描所有分区。

❓

Q&A

PostgreSQL 里用 UUIDv4 做主键,在数据量大了以后插入性能会下降多少?

当索引超出内存后,UUIDv4 的插入速率会明显下降。在 1 亿行、1 GiB shared_buffers 的测试中,UUIDv4 的插入速率仅为同一服务器上有序键(UUIDv7 或 int8)的 0.62–0.69 倍。下降大约从 2600 万行开始,因为此时主键索引超过了 shared_buffers。

为什么 UUIDv4 主键会导致 Postgres 插入变慢?

B-tree 索引按键值顺序存储。UUIDv4 是随机的,新键可能落在任何叶子节点上,导致整个索引都成为工作集,每次插入都可能需要读取一个缓冲区并驱逐一个脏页。而 UUIDv7 和 int8 是有序的,新键总是进入最右边的叶子节点,该节点常驻内存,因此插入几乎不产生额外的 I/O。

UUIDv4 和 UUIDv7 在 WAL 写入量上有多大差别?

在 9000 万到 1 亿行时,UUIDv4 每行写入的 WAL 量是同一服务器上有序键的 1.5 到 1.9 倍。原因是每次 checkpoint 后,UUIDv4 会为它触及的几乎每个叶子节点写入完整页映像(full-page image),而 UUIDv7 和 int8 只反复修改最右边的叶子,只需一个映像。

UUIDv4 和 UUIDv7 的主键索引大小差多少?

在 1 亿行时,UUIDv4 的主键索引比 UUIDv7 大约 33%。这是因为随机插入导致叶子节点平均只有 68% 满(接近随机 B-tree 插入的 ln 2),而 UUIDv7 和 int8 的叶子节点能填充到默认的 90%。REINDEX 可以暂时将 UUIDv4 索引压缩到与 UUIDv7 相同的大小,但新的随机插入会再次分裂叶子。

新表应该用 int8 还是 UUIDv7 做主键?

文章建议新表使用 int8(bigint generated always as identity),或者如果需要 UUID 则使用 uuidv7()。在超出内存后,UUIDv7 和 int8 的插入速率大致相同(比值在 0.95 到 1.01 之间),但 int8 的索引条目更小(20 字节 vs 28 字节),因此索引体积比 UUIDv7 小 29%。如果既需要紧凑主键又需要公开 UUID,可以同时使用 int8 主键和 uuidv7() 唯一列,但索引字节数会比单独使用 UUIDv7 多 35%。

如何将现有表的 UUIDv4 主键迁移到 UUIDv7?

由于类型仍是 uuid,只需修改默认值:set lock_timeout = '500ms'; alter table ... alter column id set default uuidv7(); 这不会重写表,但会短暂获取 ACCESS EXCLUSIVE 锁,因此要设置较短的 lock_timeout 并重试。注意:旧值仍是 UUIDv4,uuid_extract_timestamp() 对它们返回 NULL;新 UUIDv7 键会插入到旧 UUIDv4 键的中间范围,可能导致索引填充率变差,必要时可定期 REINDEX CONCURRENTLY 或迁移到新表。

UUIDv7 作为主键有什么缺点或风险?

UUIDv7 的前 48 位是毫秒时间戳,会泄露记录的创建时间(Postgres 18 还包含亚毫秒部分,分辨率约 0.25 微秒),因此不能用作秘密或不可猜测的令牌。此外,随机删除会导致任何追加有序索引膨胀(删除随机 30% 后重新填充,主键索引增大 1.30 倍),需要 REINDEX CONCURRENTLY 回收空间。如果由客户端生成 UUIDv7,时钟偏差会使索引变得像 UUIDv4 一样随机(测试中 ±60 秒偏差导致索引仅 69% 满,45% 叶子乱序),因此应在服务端生成。

在 UUIDv4 表上使用 order by id desc 获取最新记录有什么问题?

在 UUIDv4 表上,order by id desc limit 100 返回的是 100 个最大的随机值,而不是最新的行。它需要读取约 100 个分散的堆页面,耗时约 32 毫秒;而在 UUIDv7 或 int8 表上,同样的查询约 1 毫秒即可完成,因为最新的堆页面已在缓存中。要获取最新记录,应使用 order by created_at。

🏷️

标签

➡️

继续阅读