来源: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.node GUC 设置。
  • 位 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 都会欢迎这些改进。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐