DeepSeek总结的时间旅行者的主键
来源:https://www.pgedge.com/blog/the-time-traveler-s-primary-key
时间旅行者的主键
Shaun Thomas | 2026年8月22日
每个表都需要一种方法来区分其行,而自增的代理键(surrogate key)几乎从数据库诞生之初就是首选的解决方案。它们简单、快速,也许最重要的是,它们是正确的。但分布式系统要求值在集群范围内唯一,最好无需某种共识模型或键服务器瓶颈。明智的选择总是算法生成。
于是 UUID 应运而生。自其首次亮相以来,该标准已经历了多次迭代,但以 128 位为代价,它几乎能保证算法上唯一的值。不幸的是,UUID 也倾向于将 B-Tree 索引视为特别耐打的皮纳塔(piñata)。
为什么如此方便的东西会带来这么多麻烦?有没有出路?很高兴你提出这个问题!
瞬息全宇宙
UUID 世界的主力军是第 4 版。它不是部分依赖于 MAC 地址或命名空间,而是随机生成的。Postgres 通过 gen_random_uuid() 函数免费提供了这一功能。
让我们调用几次看看:
SELECT gen_random_uuid() FROM generate_series(1, 4);
gen_random_uuid
--------------------------------------
5f8d2c1a-6b3e-4f9a-8c7d-1e2f3a4b5c6d
a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d
3e9f7a2b-1c4d-4e5f-9a8b-7c6d5e4f3a2b
c7d8e9f0-a1b2-4c3d-8e9f-0a1b2c3d4e5f
很漂亮。现在考虑一下,当这些值成为主键时,它们会去向何方:
CREATE TABLE customer (
id UUID NOT NULL PRIMARY KEY DEFAULT gen_random_uuid(),
full_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
默认情况下,Postgres 使用 B-tree 索引来支持主键。这类索引维护排序顺序以实现可预测的缓存行为和高效的查找。当我们插入一个有序的 BIGINT 自增键时,每个新值都比前一个大,因此它落在树的最右侧叶子页。该页几乎肯定已经在内存中,因为它是先前插入操作触及的同一页。我们填满它,它干净地分裂,我们继续。索引的热点区域是右边缘的一小部分。
随机 UUID 则恰恰相反。每个生成的值同样可能排在所有行之前或之后,因此每次插入都会进入一个不同的、不可预测的叶子页。我们需要的页很少是我们刚接触过的页,这意味着 Postgres 必须从文件系统缓存或更差的地方检索它。一个不知情的开发者可能会看着他们的插入吞吐量毫无缘由地下降。
这还不是最糟糕的部分。由于插入落在已经填满的页的中间,B-tree 必须分裂这些页以腾出空间。一遍又一遍。在整个索引的随机位置。这些分裂使得页面半空且高度碎片化,导致索引膨胀以容纳相同数量的键。这种稀疏的页面本身会使用更多 RAM,使得 shared_buffers 和文件系统缓存的利用效率降低。
事实证明,随处生成键的自由给我们带来了随处写入的诅咒。你到底该怎么办呢?
恰逢其时
128 位提供了足够的空间来消除歧义。不幸的是,UUID v4 将其塞满了无意义的噪声。当新值倾向于比旧值逐渐增大时,键的排序效果才好,就像普通序列那样。那么,如果我们保留 UUID 的全局唯一性,但安排其位随时间递增呢?
这正是 UUID 版本 7(根据 RFC 9562 标准化)的用武之地。其秘诀在于布局。高 48 位包含毫秒级的 Unix 时间戳,最高有效位在前。中间有四位用于版本和变体字段,以标记该值为 v7。其余位反映了与 v4 相关的随机性。
然而,PostgreSQL 的实现比简单地将随机值转储到最后的 70 多位中,对标准的执行更为充分。该标准允许在版本信息之后使用 12 位来“提供可选构造以保证额外的单调性”。实际上,Postgres 文档说:“时间戳是使用 UNIX 时间戳(毫秒精度)+ 亚毫秒时间戳 + 随机数计算的。”因此还有额外的 12 位亚毫秒时钟信息。
如果我们直接检查一个 v7 UUID,它看起来像这样:
019fced9-8586-7ec5-9b3c-dd610e1b431f
前两部分是 48 位的 UNIX 时间戳。第三部分以 UUID 版本号开头(这就是我们的 7),接下来的三个字符(本例中是 ‘ec5’)是额外的时间信息。这意味着前三个 UUID 部分有助于保证排序顺序,而剩余部分则体现了混乱的无序性。
在 B-tree 索引的上下文中,索引首先比较的是时间戳。因此,任何在本毫秒及亚毫秒之后生成的 UUID,无论后面跟着什么随机数,都会排在之前的条目之后。新键落在树的右边缘,正好是之前有序 BIGINT 插入的位置。这一改变恢复了 UUID v4 所破坏的一切:顺序局部性、热工作集(hot working-set)和干净的页面分裂。
同时,随机尾部仍在发挥作用。在任何一个毫秒内,数十个节点可以生成数十个值,并依靠剩余的 62 个完全随机的位保持全局唯一。这为各种活动提供了相当大的空间!时间和熵;两大美味,相辅相成。
要是有一种方便的方法能将其引入 Postgres 就好了……
完全封装
Postgres 作为扩展的拥护者,有一种简单的方法可以做到这一点。不可避免地,pg_uuidv7 扩展很早就拯救了局面。除此之外,你只能限制在应用程序端生成,或者使用像 postgres-uuidv7 这样的纯 SQL 实现。这很巧妙,但鉴于 Postgres 内置了 v4,为什么没有 v7 呢?
尽管笼罩在传说中,但有人说,“Hacking Postgres 101 - ULID function”播客集是最终在 Postgres 18 中引入的新的 UUID v7 功能的发源地。(如果你感兴趣,我强烈推荐观看完整内容。它只有一个小时,并提供了很多关于 Postgres 内部如何构建的见解。)
实际上有两个新函数:uuidv4() 和 uuidv7(),它们和 gen_random_uuid() 一样易于使用:
SELECT uuidv4() FROM generate_series(1, 4);
uuidv4
--------------------------------------
b2f8f000-bba7-432e-af41-910d55b50533
bdba26d1-42a2-468d-9d36-77a9ef473aaa
d35e35cf-e808-47e0-9bf0-fa5c654abd4c
9d57fa66-2e38-4ef4-ab68-c4e39655409a
SELECT uuidv7() FROM generate_series(1, 4);
uuidv7
--------------------------------------
019fced9-8586-7ec5-9b3c-dd610e1b431f
019fced9-8586-7f3d-9d77-28ee863c9457
019fced9-8586-7f48-a683-fdb6944b9f91
019fced9-8586-7f50-b967-0c3371637e18
还记得我关于前两部分的说法吗?由于是在同一毫秒生成的,所有四行的前两段在 v7 输出中都是相同的。第三段也大致按顺序排列,这一点也更加明显。现在我们有了更多的样本,剩余的两段则没有可辨别的模式。
甚至还有一个方便的函数可以提取 UUID 版本字符串:
SELECT uuid_extract_version(gen_random_uuid()) v4_rand,
uuid_extract_version(uuidv4()) v4,
uuid_extract_version(uuidv7()) v7;
v4_rand | v4 | v7
---------+----+----
4 | 4 | 7
或者你可以直接记住它是字符串表示中第三段的第一个字符。随你选择。
现在让我们测试一下新的 UUID 版本!
无处不在的膨胀
让我们创建两个仅在 UUID 版本上有所不同的表,并向每个表插入一百万行:
CREATE TABLE keys_random (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payload TEXT
);
CREATE TABLE keys_v7 (
id UUID PRIMARY KEY DEFAULT uuidv7(),
payload TEXT
);
INSERT INTO keys_random (payload)
SELECT 'row ' || g FROM generate_series(1, 1000000) g;
INSERT INTO keys_v7 (payload)
SELECT 'row ' || g FROM generate_series(1, 1000000) g;
相同的行数、相同的负载、一切相同。现在询问 Postgres 每个主键索引有多大,使用 pg_relation_size 针对约束为我们创建的索引:
SELECT pg_size_pretty(pg_relation_size('keys_random_pkey')) AS random_idx,
pg_size_pretty(pg_relation_size('keys_v7_pkey')) AS v7_idx;
random_idx | v7_idx
------------+----------
38 MB | 30 MB
相同的数据,而随机索引报告大约多出四分之一的开销。为了查看原因,我们可以依赖 pgstattuple 扩展,其 pgstatindex() 函数可以剖析 B-tree 的内部结构:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT 'random' AS tbl, avg_leaf_density, leaf_fragmentation
FROM pgstatindex('keys_random_pkey')
UNION ALL
SELECT 'v7', avg_leaf_density, leaf_fragmentation
FROM pgstatindex('keys_v7_pkey');
tbl | avg_leaf_density | leaf_fragmentation
--------+------------------+--------------------
random | 71.53 | 49.89
v7 | 89.98 | 0.00
哎呀!v7 索引将其叶子节点填充到大约 90% 的密度,并报告基本上零碎片化,因为每个键都是按顺序到达的,在开始下一个页面之前将每个页面填满。随机索引的密度约为 71%,近一半的叶子节点碎片化,这是插入随机位置并分裂已占用页面的磁盘空间成本。更密集的索引是更小的索引,并且紧凑的页面在共享缓冲区中工作得更好,无需额外的读取。
一旦时间戳开始发挥作用,一个诱人的问题就会出现:我们能把这个时钟读出来吗?
报时
这部分感觉就像在自动售货机里找到零钱。高位中的时间戳就放在那里,Postgres 18 给了我们 uuid_extract_timestamp() 来直接从键中提取它:
SELECT id, uuid_extract_timestamp(id) AS born_at
FROM keys_v7
ORDER BY id
LIMIT 3;
id | born_at
--------------------------------------+----------------------------
019f8a23-377c-725e-aa93-1c94b666dafe | 2026-07-22 14:03:11.612+00
019f8a23-377c-794c-9ec8-9b7a8a0ac984 | 2026-07-22 14:03:11.612+00
019f8a23-3781-7faa-bbc2-1812b24c01be | 2026-07-22 14:03:11.617+00
我们按主键排序,并按创建顺序接收行。用 v4 UUID 试试!哦等等,你不能!这意味着主键本身可以兼作 created_at 列,假设它意在表示行插入时间,而不是描述所存储的数据。
乐园里的麻烦?
当然,同样的增强多功能性也可能是一把双刃剑。可以检查任何外部可见的 UUID v7 内容以提取时间戳。这本身可能不是问题,但它确实意味着每个 v7 UUID 在技术上都会泄露潜在相关的元数据。
当值成组传输时,这种泄露会加剧。因为键按时间排序,相隔几秒创建的两条记录携带几乎相同的前缀。观察它们流的观察者可以测量任何时间间隔内的创建速率。系统在过去一小时内收到了多少订单?一夜之间有多少注册?这些都是有价值的业务遥测数据。相邻的键变得可能被枚举,而随机的键则不会。
而且具有讽刺意味的是,所有插入都落在索引的右边缘,这对于单个节点上的缓存局部性非常有利。但在某些分片或高度并行的写入拓扑中,同一个最右页面会成为该索引上每个活动写入器的争用点。而分散的 v4,尽管有膨胀,至少将其写入均匀分布。治愈我们缓存未命中的方法,在错误的集群中,可能会引发锁争用。当然,BIGINT 和其他此类类型已经具有此属性,因此这在很大程度上是一个被夸大的担忧。但这确实是两个版本之间的一个对比点。
那么 UUID v7 是最终选择吗?
每片雪花都是独一无二的
嗯……也许吧,也许不是。这里有一个隐含的假设,即分布式键生成需要 128 位,但事实并非如此。pgEdge snowflake 扩展重新利用了标准 BIGINT 中的所有位,以实现与 UUID v7 类似的效果。
以下是简要说明:
- 位 0-11 包含一个计数器,用于每毫秒 4096 个唯一 ID。
- 位 12-21 编码本地节点标识符以避免冲突,通过
snowflake.nodeGUC 设置。 - 位 22-62 是毫秒精度的时间戳。
- 位 63 未使用,用于符号位。
以下是在实际操作中的样子:
CREATE EXTENSION snowflake;
SELECT snowflake.nextval() FROM generate_series(1, 4);
nextval
--------------------
481092642763968512
481092642763968513
481092642763968514
481092642763968515
SELECT snowflake.format(snowflake.nextval());
format
-----------------------------------------------------------
{"id": 1, "ts": "2026-08-20 13:30:24.305+00", "count": 0}
任意整数值的膨胀有点不方便,但当你将 BIGINT 视为存储层时,就会发生这种情况。不管怎样,这意味着 Postgres 通常像处理标准序列一样处理 snowflake ID。我说“通常”是因为这里有一些细微之处。例如,在单个节点上,snowflake 列上的索引可能看起来像这样:
| key_type | heap | idx | avg_leaf_density | leaf_fragmentation |
|---|---|---|---|---|
| v4 random | 57 MB | 38 MB | 70.84 | 50.04 |
| uuid v7 | 57 MB | 30 MB | 89.98 | 0.00 |
| snowflake | 50 MB | 21 MB | 90.01 | 0.00 |
| bigint id | 50 MB | 21 MB | 90.01 | 0.00 |
乍一看,存储 snowflake ID 与普通的自增列似乎没有区别。但请考虑键中节点 ID 的位置:计数器 - 节点 - 时间戳。这意味着对于相同的时间戳,无论其计数器如何,来自节点 8 的生成值都排在来自节点 1 的值之后。
当多个节点共同产生的值足以压垮 Postgres 索引快速路径时会发生什么?没错:索引碎片化。碎片化的程度取决于集群的整体吞吐量,但它确实存在。我的测试表明,在集群范围内每秒至少需要 10 万次插入,效果才会显现。更高的速率意味着更多的页面分裂和更多的碎片化,尽管这似乎会像 UUID v4 一样达到一个平台期。
另一件需要考虑的事情是,像 UUID v7 一样,编码后的值包含元数据。对于 snowflake,不仅是时间戳(使用 snowflake.get_epoch()),节点 ID(使用 snowflake.get_node())也是可见的。如果 ID 值公开可见,这可能会暴露敏感的集群拓扑信息。因此,最好仅在内部使用它们。
无论如何,snowflake ID 证明了 UUID 不是分布式集群的唯一解决方案。然而,UUID 确实受益于由核心 Postgres 提供。
先生们,选择你们的键
谈到 UUID,我认识的大多数 DBA 都喜欢重新构建问题。首先要诚实地面对你是否真的需要 UUID。尽管呼声很高,但即使是与 BIGINT 相比,128 位的 UUID 也绝对是巨大的。以下是一些任何版本的 UUID 都不太适合的情况:
- 数据存在于单个节点上。
- 主实例本身分发每个键。
- 键不太可能在应用程序空间或面向公众的情况下流通。
在这些情况下,普通的 BIGINT GENERATED ALWAYS AS IDENTITY 往往仍然是正确的答案。它们天然排序完美,不泄露时间戳元数据,并且与 UUID v7 一样对索引页面保守。如果没有分布式生成的要求,UUID 的理由就微乎其微。
同时,UUID v4 正是因为其难以理解性而仍有其用武之地。有时键是打算公开暴露的。在创建时间、顺序或插入率等信息必须保持私密的情况下,gen_random_uuid() 的分散特性就成了一种特性而非缺陷。其代价是稀疏的索引页面和碎片化。我们过去不得不勉强支付这笔代价,但现在至少我们有了选择。
如果你已经在使用 UUID 并且可以访问 Postgres 18(19 即将推出!),试试新的 uuidv7() 函数。看看你喜不喜欢它。25% 的索引大小节省和固有的排序能力是对 v4 的真正改进,许多 DBA 都会欢迎这些改进。
更多推荐

所有评论(0)