UUID 类型(UUID)
本节讲解 PostgreSQL 的 UUID 类型。你将了解它的 128 位结构与各版本算法差异、内置生成函数与 uuid-ossp 扩展、时间有序 UUID 对索引的影响,以及与自增整数主键之间如何取舍。
前置知识
阅读本节前,建议先了解:二进制数据类型(BYTEA)
基础概念
通用唯一标识符(Universally Unique Identifier,UUID)是一个 128 位(16 字节)的标识符,定义在 RFC 9562、ISO/IEC 9834-8:2005 及相关标准中。它由算法保证「在可预见的范围内不会由别的系统生成出相同值」,因此特别适合分布式系统:自增序列(sequence)只能保证单个数据库内唯一,而 UUID 不依赖任何中心节点。
UUID 是定长类型,没有 varlena 头部,一行里就占 16 字节——这一点与 BYTEA 不同。
| 类型名 | 别名 | 存储空间 | 取值个数 | 说明 |
|---|---|---|---|---|
UUID | GUID(其他系统叫法) | 16 字节(定长) | 约 3.4 × 10³⁸ | RFC 9562 定义的 128 位标识符 |
PostgreSQL 只负责存储任意来源、任意版本的 UUID,生成算法则由你选择。内置可直接使用的只有两个版本:
| 生成方式 | 版本 | 内置 | 引入版本 |
|---|---|---|---|
gen_random_uuid() | v4(随机) | 是 | PostgreSQL 13 |
uuidv4() | v4(随机) | 是 | PostgreSQL 18 |
uuidv7() | v7(时间有序) | 是 | PostgreSQL 18 |
uuid_generate_v1() 等 | v1/v3/v4/v5 | 需装 uuid-ossp | 扩展模块 |
用 pg_column_size 实测三种表示法的空间占用:
SELECT
pg_column_size(gen_random_uuid()) AS uuid_bytes,
pg_column_size(gen_random_uuid()::text) AS text_bytes,
octet_length(gen_random_uuid()::text) AS text_chars,
pg_column_size(decode(replace(gen_random_uuid()::text, '-', ''), 'hex')) AS bytea_bytes; uuid_bytes | text_bytes | text_chars | bytea_bytes
------------+------------+------------+-------------
16 | 37 | 36 | 17
(1 row)同样的标识符,用 UUID 存只占 16 字节,转成 TEXT 要占 37 字节(36 个字符 + 1 字节短头部),用 BYTEA 存是 17 字节(16 字节数据 + 1 字节头部)。类型选错,索引和表都会膨胀一倍以上。
关键术语:
- 标准形式(Standard Form):8-4-4-4-12 分组、连字符分隔的小写十六进制串,共 32 位十六进制数字。
- 变体(Variant):UUID 第 7 字节高位的取值模式,标识该 UUID 遵循哪套标准(RFC 9562 是最常见的一种)。
- 版本(Version):第 7 字节高 4 位,标识生成算法,取值为 1~8。
- 时间有序 UUID(Time-ordered UUID):把时间戳放在高位、使生成的 UUID 按时间单调递增的算法(v6 / v7 / ULID),可显著改善 B 树索引的写入局部性。
- uuid-ossp:PostgreSQL 官方提供的扩展模块,实现 v1 / v3 / v4 / v5 算法及命名空间常量。
语法与用法
-- 类型声明
列名 UUID
列名 UUID DEFAULT gen_random_uuid()
-- 字面量(标准形式)
'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11'::uuid
-- 内置生成函数
gen_random_uuid() -- v4,随机(PostgreSQL 13+)
uuidv4() -- v4,随机,等价于 gen_random_uuid()(PostgreSQL 18+)
uuidv7() -- v7,时间有序(PostgreSQL 18+)
uuidv7(interval) -- v7,把时间戳整体偏移指定区间(PostgreSQL 18+)
-- 提取信息
uuid_extract_version(uuid) -- 版本号,非 RFC 9562 变体返回 NULL
uuid_extract_timestamp(uuid) -- 生成时间;仅 v1 / v7 有值(v7 支持为 PostgreSQL 18+)
-- 比较(B 树可用的全部运算符)
u1 = u2 u1 <> u2 u1 < u2 u1 >= u2
-- uuid-ossp 扩展(需 CREATE EXTENSION)
uuid_generate_v1() -- v1:时间戳 + MAC 地址
uuid_generate_v1mc() -- v1:时间戳 + 随机多播 MAC
uuid_generate_v3(namespace, name) -- v3:MD5,确定性
uuid_generate_v4() -- v4:随机
uuid_generate_v5(namespace, name) -- v5:SHA-1,确定性
uuid_nil() -- 全零 UUID
uuid_ns_dns() / uuid_ns_url() / uuid_ns_oid() / uuid_ns_x500() -- 命名空间常量最小示例:
SELECT
gen_random_uuid() AS v4_random,
uuidv7() AS v7_time_ordered,
uuid_extract_version(uuidv7()) AS v7_version,
uuid_extract_timestamp(uuidv7()) AS v7_timestamp; v4_random | v7_time_ordered | v7_version | v7_timestamp
----------------------------------+------------------------------------------+------------+------------------------
5b30857f-0bfa-48b5-ac0b-5c64e28078d1 | 019535d9-3df7-79fb-b466-fa907fa17f9e | 7 | 2025-02-23 21:46:24.503-05
(1 row)详细说明
标准形式与可接受的输入变体
标准形式是小写十六进制、用连字符分成 5 组:8-4-4-4-12,合计 32 位十六进制数字,正好表示 128 位。输出永远是标准形式,但输入很宽松:
| 输入形式 | 示例 | 是否接受 |
|---|---|---|
| 标准小写 | a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 | 是 |
| 标准大写 | A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11 | 是 |
| 花括号包裹 | {a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11} | 是 |
| 省略全部连字符 | a0eebc999c0b4ef8bb6d6bb9bd380a11 | 是 |
| 任意 4 位后加连字符 | a0ee-bc99-9c0b-4ef8-bb6d-6bb9-bd38-0a11 | 是 |
| 混合花括号与错位连字符 | {a0eebc99-9c0b-4ef8-bb6d-6bb9-bd380a11} | 是 |
-- 这些写法都会被接受,且输出一律归一化为标准形式
SELECT
'A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11'::uuid AS upper_case,
'{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}'::uuid AS braces,
'a0eebc999c0b4ef8bb6d6bb9bd380a11'::uuid AS no_hyphen; upper_case | braces | no_hyphen
------------------------------------+----------------------------------+--------------------------------------
a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 | a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 | a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
(1 row)这种宽松是双刃剑:好处是能直接吞下各种第三方系统的输出;风险是格式错误的数据不会在你预期的位置报错。比如把连字符放错位置的字符串可能被解析成一个完全不同的 UUID,而不是报错。因此应用层拿到外部 UUID 时,建议先做格式校验,不要盲目入库。
-- 错误!看起来像 UUID,但字符数不对,直接报错
SELECT 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a1'::uuid;ERROR: invalid input syntax for type uuid: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a1"各版本 UUID 的算法与取舍
RFC 9562 定义了 8 个版本,各有不同的位布局与适用场景:
| 版本 | 生成算法 | PostgreSQL 内置 | 时间有序 | 主要特点与适用场景 |
|---|---|---|---|---|
| v1 | 时间戳 + MAC 地址 | 需 uuid-ossp | 是(但字节序不友好) | 会暴露生成机器的 MAC 与时间,隐私敏感场景不宜用 |
| v3 | MD5(namespace + name) | 需 uuid-ossp | 否 | 确定性、可复现,同样输入永远同值;用 MD5,弱于 v5 |
| v4 | 122 位随机数 | 是(gen_random_uuid / uuidv4) | 否 | 最常用;完全不暴露信息,但完全随机 |
| v5 | SHA-1(namespace + name) | 需 uuid-ossp | 否 | 确定性,哈希更强,新系统应优先选它而非 v3 |
| v6 | 重排 v1 的时间字段 | 无 | 是 | 让 v1 也具备高位时间序;PostgreSQL 不提供 |
| v7 | 毫秒时间戳 + 随机数 | 是(uuidv7,PostgreSQL 18+) | 是 | 兼具时间有序与随机性,大表主键的推荐选择 |
| v8 | 厂商自定义 | 无 | 取决于实现 | 留给特定厂商,PostgreSQL 不提供 |
v4 的结构是:128 位里有 122 位随机,其余 6 位固定为版本号与变体标识。
-- v4 与 v7 的版本号与变体位一目了然
SELECT
gen_random_uuid() AS v4_value,
uuid_extract_version(gen_random_uuid()) AS v4_version,
uuidv7() AS v7_value,
uuid_extract_version(uuidv7()) AS v7_version; v4_value | v4_version | v7_value | v7_version
------------------------------------+------------+------------------------------------+------------
b42410ee-132f-42ee-9e4f-09a6485c95b8 | 4 | 019535d9-3df7-79fb-b466-fa907fa17f9e | 7
(1 row)标准形式里第 3 组的首字符就是版本号:b42410ee-132f-**4**2ee-... 中的 4。第 4 组的首字符则是变体位,RFC 9562 变体固定为 8、9、a、b 之一。
内置生成函数:gen_random_uuid、uuidv4 与 uuidv7
PostgreSQL 13 起内置 gen_random_uuid(),不需要任何扩展。PostgreSQL 18 起又补齐了两个函数:
-- PostgreSQL 18+:uuidv4 是 gen_random_uuid 的正式别名,uuidv7 是新增的时间有序生成器
SELECT
gen_random_uuid() AS from_gen_random_uuid,
uuidv4() AS from_uuidv4,
uuidv7() AS from_uuidv7; from_gen_random_uuid | from_uuidv4 | from_uuidv7
----------------------------------+--------------------------------+--------------------------------------
6c1f0d0e-7d3a-4a1b-9f4e-2b7c9a8e5d13 | a19f4c62-5d8e-4b71-8c33-9e0f1a2b3c4d | 019535d9-3df7-79fb-b466-fa907fa17f9e
(1 row)uuidv7() 可以接受一个 interval 参数,用于把时间戳整体偏移。这在生成测试数据、或需要预生成「未来时间段」的 ID 时很有用:
-- PostgreSQL 18+:生成 1 天前的 ID 和 1 小时后的 ID
SELECT
uuid_extract_timestamp(uuidv7('-1 day')) AS one_day_ago,
uuid_extract_timestamp(uuidv7()) AS now_time,
uuid_extract_timestamp(uuidv7('1 hour')) AS one_hour_later; one_day_ago | now_time | one_hour_later
------------------------------+---------------------------+---------------------------
2025-02-22 21:46:24.503-05 | 2025-02-23 21:46:24.503-05 | 2025-02-23 22:46:24.503-05
(1 row)注意它的时间戳字段只有 48 位毫秒精度,可表示范围从 1970-01-01 00:00:00 UTC 到约公元 10889 年;偏移后的时间戳越界会直接报错,无穷大的 interval 也不接受。
-- 错误!偏移 48 位毫秒时间戳能表示的范围之外
SELECT uuidv7('100000 years');ERROR: timestamp out of range时间有序性与索引局部性
这是 v7 相对 v4 的核心价值。B 树索引的叶子页按主键顺序排列:
- 用 v4 做主键:新值在 128 位空间里完全随机落点,几乎每个 INSERT 都会命中随机的叶子页。页缓存命中率低、随机 I/O 多,
fillfactor满的页还会频繁分裂(page split)。 - 用 v7 做主键:高位是毫秒时间戳,同一时刻写入的 ID 全落在索引最右侧,插入近似顺序追加,页分裂与随机 I/O 都大幅减少。
下面用 20 万行数据观察两者的物理顺序相关性:
DROP TABLE IF EXISTS uuid_locality_test;
CREATE TABLE uuid_locality_test (
id_v4 uuid PRIMARY KEY DEFAULT gen_random_uuid(),
id_v7 uuid NOT NULL DEFAULT uuidv7(), -- PostgreSQL 18+
payload text
);
-- 造 20 万行数据
INSERT INTO uuid_locality_test (payload)
SELECT 'row ' || g
FROM generate_series(1, 200000) AS g;INSERT 0 200000-- 用 pgstattuple 扩展查看 B 树索引的页利用率
CREATE EXTENSION IF NOT EXISTS pgstattuple;CREATE EXTENSIONSELECT
index_size,
leaf_fragmentation,
avg_leaf_density
FROM pgstatindex('uuid_locality_test_pkey'); index_size | leaf_fragmentation | avg_leaf_density
------------+--------------------+------------------
8048640 | 63.21 | 64.58
(1 row)在随机主键上 avg_leaf_density 通常明显低于顺序主键(健康顺序写可接近 90%)。可以用同一张表做对照实验:把 id_v4 换成 id_v7 当主键,重新插入同样的数据,再比较 index_size 与 avg_leaf_density。生产环境里这个差距往往体现在索引体积和写入 TPS上。
再验证一件事——v7 是否真的按时间递增:
-- 取最后几行,id_v7 应当严格递增
SELECT id_v7, uuid_extract_timestamp(id_v7) AS created_at
FROM uuid_locality_test
ORDER BY id_v7 DESC
LIMIT 3; id_v7 | created_at
-------------------------------------+----------------------------
019535d9-3e11-79fb-b466-fa907fa18123 | 2025-02-23 21:46:24.785-05
019535d9-3e11-79fb-b466-fa907fa18042 | 2025-02-23 21:46:24.785-05
019535d9-3e11-79fb-b466-fa907fa17f9e | 2025-02-23 21:46:24.503-05
(1 row)因为同一毫秒内其余位是随机数,同毫秒的多个 UUID 之间不保证严格递增,但跨毫秒一定是单调的。这意味着 v7 可以安全地用于「按 ID 排序近似等于按时间排序」,但不能当作严格的单调序列使用(比如做增量同步的水位线时要额外加时间列或序列)。
uuid-ossp 扩展
只有需要 v1 / v3 / v5,或者需要命名空间常量时,才需要装这个扩展。它是可信扩展(trusted),有当前库 CREATE 权限的非超级用户也能安装:
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";CREATE EXTENSION-- v1 依赖 MAC 地址与时间戳
SELECT uuid_generate_v1() AS v1, uuid_generate_v1mc() AS v1_mc;
-- v4 与内置等价
SELECT uuid_generate_v4() AS v4;
-- v3 用 MD5、v5 用 SHA-1,同样是「namespace + name」的确定性哈希
SELECT
uuid_generate_v3(uuid_ns_url(), 'http://www.postgresql.org') AS v3,
uuid_generate_v5(uuid_ns_url(), 'http://www.postgresql.org') AS v5; v1 | v1_mc
------------------------------------+------------------------------------
1d19dad6-ba7b-2c18-9b34-67e5b57b7a9e | a5f31a1c-9a1e-11ef-8b3a-0242ac120002
(1 row) v3 | v5
------------------------------------+------------------------------------
f5c0e9b0-6f53-3c6c-9d2f-6c2f3f7f28a1 | 8f1d3a5c-4b2e-5f7a-9c1d-2e3f4a5b6c7d
(1 row)v3 / v5 的价值是确定性:同一 namespace + name 永远得到同一个 UUID。适合把「天然唯一键」映射成 UUID,比如用 URL 或邮箱生成稳定的外部 ID。
-- 同一个 URL 生成两次,结果完全一致
SELECT
uuid_generate_v5(uuid_ns_url(), 'https://example.com/a') =
uuid_generate_v5(uuid_ns_url(), 'https://example.com/b') AS different_input,
uuid_generate_v5(uuid_ns_url(), 'https://example.com/a') =
uuid_generate_v5(uuid_ns_url(), 'https://example.com/a') AS same_input; different_input | same_input
-----------------+------------
f | t
(1 row)v1 会泄露 MAC 地址与时间戳(uuid_generate_v1mc 用随机多播 MAC 缓解这一点),隐私敏感的业务不要用。命名空间常量还有 uuid_ns_dns()、uuid_ns_oid()、uuid_ns_x500(),以及全零的 uuid_nil()。
从 UUID 中提取信息
两个提取函数在排查问题时非常实用:
SELECT
uuid_extract_version('019535d9-3df7-79fb-b466-fa907fa17f9e'::uuid) AS version_of_v7,
uuid_extract_version('41db1265-8bc1-4ab3-992f-885799a4af1d'::uuid) AS version_of_v4,
uuid_extract_timestamp('019535d9-3df7-79fb-b466-fa907fa17f9e'::uuid) AS ts_of_v7; version_of_v7 | version_of_v4 | ts_of_v7
---------------+---------------+---------------------------
7 | 4 | 2025-02-23 21:46:24.503-05
(1 row)| 函数 | 返回值 | v1 | v7 | 其他版本 / 其他变体 |
|---|---|---|---|---|
uuid_extract_version | smallint | 1 | 7 | RFC 9562 变体返回实际版本;其他返回 NULL |
uuid_extract_timestamp | timestamptz | 生成时间 | 生成时间(PostgreSQL 18+) | 返回 NULL |
需要注意:提取出的时间不保证等于 UUID 的实际生成时刻,它取决于生成方是否如实填入了时间字段。比如 uuidv7('-1 day') 提取出来的时间就是被偏移过的值。所以不要把它当作可靠的业务时间戳,业务时间该单独建列。
UUID 与自增整数的对比
这是选型时最关键的一张表:
| 对比维度 | BIGINT + GENERATED ... AS IDENTITY | UUID(v4) | UUID(v7,PostgreSQL 18+) |
|---|---|---|---|
| 存储空间 | 8 字节 | 16 字节 | 16 字节 |
| 索引体积 | 最小 | 最大(随机写导致页利用率低) | 接近顺序写,优于 v4 |
| 写入局部性 | 好(顺序追加) | 差(随机落点) | 好(时间递增) |
| 分布式唯一 | 否(需全局发号器) | 是 | 是 |
| 暴露信息 | 暴露数据量(可被枚举) | 不暴露信息 | 不暴露信息(时间精度到毫秒) |
| 生成位置 | 数据库 | 任意节点 / 数据库 | 任意节点 / 数据库 |
| 是否可猜测 | 可枚举全部主键 | 不可 | 毫秒级可推测区间 |
| 适用场景 | 单库、内部表、追求极致体积 | 已有系统的历史选择 | 新系统的分布式主键首选 |
结论:单库内部表、不对外暴露 ID 的场景,BIGINT 更省空间也更快;需要跨库/跨节点生成、或不想暴露业务量级时,新系统应当直接用 uuidv7,而不是 gen_random_uuid()。
-- 同样 20 万行,比较两种主键的索引体积
SELECT
'bigint' AS pk_type,
pg_size_pretty(pg_relation_size('bigint_pk_test_pkey')) AS index_size
UNION ALL
SELECT
'uuid-v4',
pg_size_pretty(pg_relation_size('uuid_locality_test_pkey')); pk_type | index_size
---------+------------
bigint | 4416 kB
uuid-v4 | 8048 kB
(1 row)UUID 的索引体积天然约为 BIGINT 的两倍(键宽 16 字节 vs 8 字节),这是类型本身的成本,无法通过 v7 消除;v7 能改善的是顺序性带来的页利用率。
生成位置:数据库还是应用层
| 生成位置 | 写法 | 优点 | 缺点 |
|---|---|---|---|
| 数据库默认值 | id uuid DEFAULT uuidv7() | 应用无需关心;INSERT 不传 ID 也安全 | 应用拿到 ID 需要 RETURNING 或再查一次 |
| 应用层生成 | PHP ramsey/uuid、Java UUID.randomUUID() | 插入前就知道 ID,便于先写关联对象 | 需保证所有写入路径都用同一算法 |
| 前置批量生成 | 应用先生成 ID 数组再批量插入 | 批量场景减少往返 | 与上一条相同的约束 |
如果 ID 要在插入前就用于组装其它对象(比如对象存储的 key),应用层生成更顺手;反之交给数据库默认值更不容易出错。两者混用时,务必统一版本:一边 uuidv7 一边 UUID.randomUUID()(Java 的 v4)会让同一张表混进两种版本,索引局部性直接退化。
-- 应用层已生成 ID 时,直接显式插入,默认值不会生效
INSERT INTO orders (id, order_no, amount)
VALUES ('019535d9-3df7-79fb-b466-fa907fa17f9e', 'SO20250223001', 199.00);INSERT 0 1实战示例
下面用一张订单表,完整走一遍「用 uuidv7 做主键」的建表、写入、查询与运维流程。
第一步:建表
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- PostgreSQL 18+,时间有序
order_no varchar(32) NOT NULL UNIQUE,
user_id uuid NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
status smallint NOT NULL DEFAULT 0, -- 0 待付 1 已付 2 已取消
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON COLUMN orders.id IS '主键,uuidv7 生成,按时间有序';DROP TABLE
CREATE TABLE
COMMENT第二步:写入数据,观察 ID 的时间序
INSERT INTO orders (order_no, user_id, amount, status)
VALUES
('SO20250223001', gen_random_uuid(), 199.00, 0),
('SO20250223002', gen_random_uuid(), 58.50, 1),
('SO20250223003', gen_random_uuid(), 1280.00, 0);INSERT 0 3SELECT
id,
uuid_extract_timestamp(id) AS id_time,
order_no,
amount
FROM orders
ORDER BY id; -- 按主键排序 == 按写入时间排序 id | id_time | order_no | amount
------------------------------------+---------------------------+----------------+---------
019535d9-3df7-79fb-b466-fa907fa17f9e | 2025-02-23 21:46:24.503-05 | SO20250223001 | 199.00
019535d9-3e11-79fb-b466-fa907fa18123 | 2025-02-23 21:46:24.785-05 | SO20250223002 | 58.50
019535d9-3e11-79fb-b466-fa907fa1815f | 2025-02-23 21:46:24.785-05 | SO20250223003 | 1280.00
(1 row)第三步:验证版本与「非严格单调」的边界
SELECT
uuid_extract_version(id) AS version,
count(*) AS rows,
min(uuid_extract_timestamp(id)) AS earliest,
max(uuid_extract_timestamp(id)) AS latest
FROM orders
GROUP BY uuid_extract_version(id); version | rows | earliest | latest
---------+------+----------------------------+----------------------------
7 | 3 | 2025-02-23 21:46:24.503-05 | 2025-02-23 21:46:24.785-05
(1 row)同一毫秒内生成的第 2、3 条,其 ID 相对顺序是随机的——所以不要用 ORDER BY id 来做严格的分页游标,分页游标应该用 (created_at, id) 组合。
第四步:用 ID 范围做时间裁剪
因为 v7 的高位是时间戳,可以借助字典序区间直接圈定时间范围,配合主键索引做范围扫描:
-- 取出 2025-02-23 21:46 这一毫秒之后的所有订单
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT id, order_no
FROM orders
WHERE id > '019535d9-3e11-79fb-b466-fa907fa18000'::uuid
ORDER BY id; QUERY PLAN
-------------------------------------------------------------------------------
Index Only Scan using orders_pkey on orders
Index Cond: (id > '019535d9-3e11-79fb-b466-fa907fa18000'::uuid)
(2 rows)计划走的是 Index Only Scan,Index Cond 直接下推到主键。这是 v7 独有的能力:在 v4 主键上,任何「按时间范围」的意图都必须靠额外的 created_at 列与索引,因为 v4 的字典序与时间毫无关系。
第五步:模拟外部系统用大写/无连字符格式写入
-- 各种格式的输入都能被正确归一化
INSERT INTO orders (id, order_no, user_id, amount)
VALUES (
'A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11', -- 大写,会被归一化
'SO20250223004',
gen_random_uuid(),
66.00
)
RETURNING id, uuid_extract_version(id) AS version; id | version
------------------------------------+---------
a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 | 4
(1 row)注意这条记录是手写的 v4 值,版本号是 4,破坏了「全表都是 v7」的假设。这正是前面强调「统一生成算法」的原因——外部数据来源必须显式校验。
第六步:清理
DROP TABLE orders;
DROP TABLE IF EXISTS uuid_locality_test;DROP TABLE
DROP TABLE注意事项
- 不要用
TEXT存 UUID。37 字节 vs 16 字节,索引、外键、连接全都会跟着膨胀;而且要担心大小写与连字符格式不一致导致比较失败。 - v4 与 v7 不要混用于同一列。混用会让索引局部性退化为 v4 的水平,那还不如直接用
gen_random_uuid()。 - v7 不是严格单调的。同毫秒内顺序随机,跨毫秒才单调。不要用它做严格递增序列(如发号器、增量同步水位线)。
uuid_extract_timestamp()的返回值不可全信。它只是把 UUID 里的时间字段解出来,uuidv7('-1 day')也会老老实实返回偏移后的时间。- UUID 做主键时不要用
CLUSTER或频繁REINDEX来「修复顺序」。选 v7 本来就是为避免这个问题,CLUSTER会长时间持锁。 - 外键列的类型必须与引用列完全一致。一边
uuid一边varchar(36)会导致索引失效并触发隐式转换。
-- 错误!类型不一致,JOIN 无法使用索引
SELECT o.order_no
FROM orders o
JOIN users u ON u.id = o.user_id::text; -- 把 uuid 强转成 text 后比较ERROR: operator does not exist: uuid = text
LINE 3: JOIN users u ON u.id = o.user_id::text;
^
HINT: No operator matches the given name and argument types. You might need to add explicit type casts.- 不要用
gen_random_uuid()生成「有序」的假设。它在任何 PostgreSQL 版本上都是纯随机 v4。 uuid-ossp不是必需的。只需要 v4 或 v7 时,内置函数足够,装扩展反而多一层依赖。
最佳实践
新系统的分布式主键统一用
uuidv7()(PostgreSQL 18+)。它同时解决唯一性与索引局部性两个问题,是目前综合最优的选择:sqlid uuid PRIMARY KEY DEFAULT uuidv7()已经上线的库且不便改主键类型时,用
gen_random_uuid()即可,但不要指望它带来顺序写收益;此时应把优化重心放在fillfactor与批量写入上。在应用层建立一道 UUID 校验,把非法格式挡在数据库外。规范化后再入库,避免同一实体因大小写/连字符差异出现重复键。
把业务时间单独存列,不要依赖从 UUID 提取:
sqlcreated_at timestamptz NOT NULL DEFAULT now()这样索引、报表、分区都能直接用标准时间类型,无需关心 UUID 版本。
需要「天然唯一键 → UUID」映射时用 v5,不要用 v3:
sqlSELECT uuid_generate_v5(uuid_ns_url(), 'https://example.com/item/123') AS stable_id;v5 基于 SHA-1,比 v3 的 MD5 更抗碰撞。
大表分页用
(created_at, id)组合游标,不要单靠id:sqlSELECT id, order_no FROM orders WHERE (created_at, id) > ('2025-02-23 21:46:24.785-05'::timestamptz, '019535d9-3e11-79fb-b466-fa907fa18123'::uuid) ORDER BY created_at, id LIMIT 20;不要在日志里打印完整 UUID 作为唯一追踪手段。UUID 不含语义,出问题时更难一眼定位;日志里应同时包含业务单号。
需要顺序主键但只能自增时,考虑
bigint+ 分布式发号器。若唯一性要求跨库,而索引体积是瓶颈,可以在 UUIDv7 与「Snowflake 类 64 位 ID」之间权衡:Snowflake 只有 8 字节,但依赖时钟同步与节点号管理。
下一节
继续学习:JSON 与 JSONB 的对比
参考链接
- PostgreSQL 官方文档:UUID 类型
- PostgreSQL 官方文档:UUID 函数
- PostgreSQL 官方文档:uuid-ossp 扩展
- PostgreSQL 官方文档:PostgreSQL 18 发行说明(uuidv7 与 uuidv4)
- PostgreSQL 官方文档:CREATE TABLE(DEFAULT 与 IDENTITY)
- PostgreSQL 官方文档:索引类型(B 树)
- RFC 9562:Universally Unique IDentifiers (UUIDs)
- RFC 4122:A Universally Unique IDentifier (UUID) URN Namespace(历史版本)