PostgreSQL 20 合入了一个小而实用的补齐:uuid 类型将支持 min() 和 max() 聚合。这个变化不算炫目,但会减少很多查询里的绕路写法。因为 uuid 本来已经有完整比较操作符,也有 btree operator class,换句话说,它在 PostgreSQL 里早就是可排序的;缺的只是常用聚合函数这块拼图。
为什么 UUID 可以有“最小”和“最大”
很多人下意识觉得 UUID 是随机标识,不该谈大小。但数据库里的“大小”不是业务含义,而是类型的排序规则。
PostgreSQL 的 uuid 类型已经支持比较操作,例如:
SELECT '00000000-0000-0000-0000-000000000001'::uuid
< 'ffffffff-ffff-ffff-ffff-ffffffffffff'::uuid AS is_smaller;
它也能建 btree 索引,说明 PostgreSQL 已经知道如何对 uuid 排序:
CREATE TABLE events (
id uuid PRIMARY KEY,
payload text NOT NULL
);
CREATE INDEX events_id_btree_idx ON events USING btree (id);
既然能比较、能排序、能走 btree,min(id) 和 max(id) 缺席就显得不自然。PostgreSQL 20 的补丁补上了这点,并加入了内部用于比较的 uuid_larger() 和 uuid_smaller()。
以前怎么写,之后怎么写
在 PostgreSQL 20 之前,如果你想拿一列 UUID 的最小值或最大值,常见做法是通过 ORDER BY ... LIMIT 1:
SELECT id
FROM events
ORDER BY id ASC
LIMIT 1;
SELECT id
FROM events
ORDER BY id DESC
LIMIT 1;
这当然能工作,而且在有合适索引时也很高效。但它不是聚合表达式,放进分组、窗口、报表 SQL 时会变得别扭。
PostgreSQL 20 之后,可以直接写成:
SELECT min(id) AS first_uuid,
max(id) AS last_uuid
FROM events;
分组场景也更自然:
SELECT tenant_id,
min(id) AS min_event_id,
max(id) AS max_event_id,
count(*) AS event_count
FROM events
GROUP BY tenant_id;
这类查询读起来更像“按租户统计 ID 范围”,而不是“每组再找排序后的第一条和最后一条”。
可以这样实践:用 SQL 观察差异
下面的例子可以在 PostgreSQL 20 或更新版本上直接运行。若你还在 PostgreSQL 19 或更早版本,最后的 min(id) / max(id) 查询会报错,这正好能说明这个变化解决了什么。
DROP TABLE IF EXISTS demo_uuid_events;
CREATE TABLE demo_uuid_events (
tenant_id text NOT NULL,
id uuid NOT NULL,
payload text NOT NULL
);
INSERT INTO demo_uuid_events (tenant_id, id, payload) VALUES
('acme', '00000000-0000-0000-0000-000000000010', 'created'),
('acme', '00000000-0000-0000-0000-000000000003', 'updated'),
('acme', 'ffffffff-ffff-ffff-ffff-ffffffffffff', 'closed'),
('beta', '11111111-1111-1111-1111-111111111111', 'created'),
('beta', '22222222-2222-2222-2222-222222222222', 'updated');
SELECT tenant_id,
min(id) AS min_id,
max(id) AS max_id,
count(*) AS rows
FROM demo_uuid_events
GROUP BY tenant_id
ORDER BY tenant_id;
预期结果会类似:
tenant_id | min_id | max_id | rows
-----------+--------------------------------------+--------------------------------------+------
acme | 00000000-0000-0000-0000-000000000003 | ffffffff-ffff-ffff-ffff-ffffffffffff | 3
beta | 11111111-1111-1111-1111-111111111111 | 22222222-2222-2222-2222-222222222222 | 2
如果你想顺手确认索引排序能力,可以加一个 btree 索引,然后看 ORDER BY ... LIMIT 1 的计划:
CREATE INDEX demo_uuid_events_id_idx ON demo_uuid_events (id);
EXPLAIN
SELECT id
FROM demo_uuid_events
ORDER BY id
LIMIT 1;
注意:这个例子只是说明 uuid 本身有确定排序规则,不等于承诺 min(uuid) 在所有数据量和统计信息状态下都会选择同一种执行计划。实际计划仍然取决于表大小、索引、统计信息和查询形状。
这不是“按时间排序”的替代品
min(uuid) 和 max(uuid) 表示 UUID 排序规则下的边界值,不表示最早创建或最新创建。
这点尤其重要。很多系统用 UUID v4 作为随机主键,此时最大 UUID 没有“最新”的含义。即使使用带时间信息的 UUID 变体,也要确认数据库排序规则是否与你想表达的时间顺序一致。业务上要查最新事件,仍然应该用明确的时间列:
SELECT tenant_id,
max(created_at) AS latest_event_time
FROM events
GROUP BY tenant_id;
如果你需要“每个租户最新事件的 UUID”,可以把时间排序写清楚:
SELECT DISTINCT ON (tenant_id)
tenant_id,
id,
created_at
FROM events
ORDER BY tenant_id, created_at DESC, id DESC;
UUID 聚合解决的是类型能力和 SQL 表达一致性,不会替你推断业务语义。
升级时怎么用
这项变化适合逐步吸收,不需要为了它大改 SQL。
可以优先改这些地方:
- 报表 SQL 里已经按实体分组,并且需要展示 UUID 边界值。
- 应用代码里为了规避
min(uuid)缺失而写了子查询或ORDER BY ... LIMIT 1。 - 通用查询生成器对不同可排序类型统一生成
min()/max(),但之前必须给uuid加例外分支。
需要保留边界意识:
- 如果代码必须兼容 PostgreSQL 19 或更早版本,不要直接生成
min(uuid)/max(uuid)。 - 不要把 UUID 最大值当作“最新记录”。
- 性能判断仍要看
EXPLAIN,尤其是大表、分区表和复杂分组查询。
这是一个典型的 PostgreSQL 小步增强:不改变你的数据模型,却让类型系统和 SQL 体验更完整。uuid 已经能排序,现在终于也能像其他常见可排序类型一样参与 min() 和 max() 了。