Skip to content

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 不同。

类型名别名存储空间取值个数说明
UUIDGUID(其他系统叫法)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 实测三种表示法的空间占用:

sql
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;
text
 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 算法及命名空间常量。

语法与用法 ​

text
-- 类型声明
列名 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()   -- 命名空间常量

最小示例:

sql
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;
text
             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}是
sql
-- 这些写法都会被接受,且输出一律归一化为标准形式
SELECT
  'A0EEBC99-9C0B-4EF8-BB6D-6BB9BD380A11'::uuid  AS upper_case,
  '{a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11}'::uuid AS braces,
  'a0eebc999c0b4ef8bb6d6bb9bd380a11'::uuid       AS no_hyphen;
text
             upper_case             |              braces              |             no_hyphen
------------------------------------+----------------------------------+--------------------------------------
 a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 | a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 | a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
(1 row)

这种宽松是双刃剑:好处是能直接吞下各种第三方系统的输出;风险是格式错误的数据不会在你预期的位置报错。比如把连字符放错位置的字符串可能被解析成一个完全不同的 UUID,而不是报错。因此应用层拿到外部 UUID 时,建议先做格式校验,不要盲目入库。

sql
-- 错误!看起来像 UUID,但字符数不对,直接报错
SELECT 'a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a1'::uuid;
text
ERROR:  invalid input syntax for type uuid: "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a1"

各版本 UUID 的算法与取舍 ​

RFC 9562 定义了 8 个版本,各有不同的位布局与适用场景:

版本生成算法PostgreSQL 内置时间有序主要特点与适用场景
v1时间戳 + MAC 地址需 uuid-ossp是(但字节序不友好)会暴露生成机器的 MAC 与时间,隐私敏感场景不宜用
v3MD5(namespace + name)需 uuid-ossp否确定性、可复现,同样输入永远同值;用 MD5,弱于 v5
v4122 位随机数是(gen_random_uuid / uuidv4)否最常用;完全不暴露信息,但完全随机
v5SHA-1(namespace + name)需 uuid-ossp否确定性,哈希更强,新系统应优先选它而非 v3
v6重排 v1 的时间字段无是让 v1 也具备高位时间序;PostgreSQL 不提供
v7毫秒时间戳 + 随机数是(uuidv7,PostgreSQL 18+)是兼具时间有序与随机性,大表主键的推荐选择
v8厂商自定义无取决于实现留给特定厂商,PostgreSQL 不提供

v4 的结构是:128 位里有 122 位随机,其余 6 位固定为版本号与变体标识。

sql
-- 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;
text
              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 起又补齐了两个函数:

sql
-- PostgreSQL 18+:uuidv4 是 gen_random_uuid 的正式别名,uuidv7 是新增的时间有序生成器
SELECT
  gen_random_uuid() AS from_gen_random_uuid,
  uuidv4()          AS from_uuidv4,
  uuidv7()          AS from_uuidv7;
text
       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 时很有用:

sql
-- 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;
text
         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 也不接受。

sql
-- 错误!偏移 48 位毫秒时间戳能表示的范围之外
SELECT uuidv7('100000 years');
text
ERROR:  timestamp out of range

时间有序性与索引局部性 ​

这是 v7 相对 v4 的核心价值。B 树索引的叶子页按主键顺序排列:

  • 用 v4 做主键:新值在 128 位空间里完全随机落点,几乎每个 INSERT 都会命中随机的叶子页。页缓存命中率低、随机 I/O 多,fillfactor 满的页还会频繁分裂(page split)。
  • 用 v7 做主键:高位是毫秒时间戳,同一时刻写入的 ID 全落在索引最右侧,插入近似顺序追加,页分裂与随机 I/O 都大幅减少。

下面用 20 万行数据观察两者的物理顺序相关性:

sql
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;
text
INSERT 0 200000
sql
-- 用 pgstattuple 扩展查看 B 树索引的页利用率
CREATE EXTENSION IF NOT EXISTS pgstattuple;
text
CREATE EXTENSION
sql
SELECT
  index_size,
  leaf_fragmentation,
  avg_leaf_density
FROM pgstatindex('uuid_locality_test_pkey');
text
 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 是否真的按时间递增:

sql
-- 取最后几行,id_v7 应当严格递增
SELECT id_v7, uuid_extract_timestamp(id_v7) AS created_at
FROM uuid_locality_test
ORDER BY id_v7 DESC
LIMIT 3;
text
                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 权限的非超级用户也能安装:

sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
text
CREATE EXTENSION
sql
-- 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;
text
                 v1                 |              v1_mc
------------------------------------+------------------------------------
 1d19dad6-ba7b-2c18-9b34-67e5b57b7a9e | a5f31a1c-9a1e-11ef-8b3a-0242ac120002
(1 row)
text
                 v3                 |                 v5
------------------------------------+------------------------------------
 f5c0e9b0-6f53-3c6c-9d2f-6c2f3f7f28a1 | 8f1d3a5c-4b2e-5f7a-9c1d-2e3f4a5b6c7d
(1 row)

v3 / v5 的价值是确定性:同一 namespace + name 永远得到同一个 UUID。适合把「天然唯一键」映射成 UUID,比如用 URL 或邮箱生成稳定的外部 ID。

sql
-- 同一个 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;
text
 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 中提取信息 ​

两个提取函数在排查问题时非常实用:

sql
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;
text
 version_of_v7 | version_of_v4 |          ts_of_v7
---------------+---------------+---------------------------
             7 |             4 | 2025-02-23 21:46:24.503-05
(1 row)
函数返回值v1v7其他版本 / 其他变体
uuid_extract_versionsmallint17RFC 9562 变体返回实际版本;其他返回 NULL
uuid_extract_timestamptimestamptz生成时间生成时间(PostgreSQL 18+)返回 NULL

需要注意:提取出的时间不保证等于 UUID 的实际生成时刻,它取决于生成方是否如实填入了时间字段。比如 uuidv7('-1 day') 提取出来的时间就是被偏移过的值。所以不要把它当作可靠的业务时间戳,业务时间该单独建列。

UUID 与自增整数的对比 ​

这是选型时最关键的一张表:

对比维度BIGINT + GENERATED ... AS IDENTITYUUID(v4)UUID(v7,PostgreSQL 18+)
存储空间8 字节16 字节16 字节
索引体积最小最大(随机写导致页利用率低)接近顺序写,优于 v4
写入局部性好(顺序追加)差(随机落点)好(时间递增)
分布式唯一否(需全局发号器)是是
暴露信息暴露数据量(可被枚举)不暴露信息不暴露信息(时间精度到毫秒)
生成位置数据库任意节点 / 数据库任意节点 / 数据库
是否可猜测可枚举全部主键不可毫秒级可推测区间
适用场景单库、内部表、追求极致体积已有系统的历史选择新系统的分布式主键首选

结论:单库内部表、不对外暴露 ID 的场景,BIGINT 更省空间也更快;需要跨库/跨节点生成、或不想暴露业务量级时,新系统应当直接用 uuidv7,而不是 gen_random_uuid()。

sql
-- 同样 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'));
text
 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)会让同一张表混进两种版本,索引局部性直接退化。

sql
-- 应用层已生成 ID 时,直接显式插入,默认值不会生效
INSERT INTO orders (id, order_no, amount)
VALUES ('019535d9-3df7-79fb-b466-fa907fa17f9e', 'SO20250223001', 199.00);
text
INSERT 0 1

实战示例 ​

下面用一张订单表,完整走一遍「用 uuidv7 做主键」的建表、写入、查询与运维流程。

第一步:建表

sql
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 生成,按时间有序';
text
DROP TABLE
CREATE TABLE
COMMENT

第二步:写入数据,观察 ID 的时间序

sql
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);
text
INSERT 0 3
sql
SELECT
  id,
  uuid_extract_timestamp(id) AS id_time,
  order_no,
  amount
FROM orders
ORDER BY id;                          -- 按主键排序 == 按写入时间排序
text
                 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)

第三步:验证版本与「非严格单调」的边界

sql
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);
text
 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 的高位是时间戳,可以借助字典序区间直接圈定时间范围,配合主键索引做范围扫描:

sql
-- 取出 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;
text
                                  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 的字典序与时间毫无关系。

第五步:模拟外部系统用大写/无连字符格式写入

sql
-- 各种格式的输入都能被正确归一化
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;
text
                 id                 | version
------------------------------------+---------
 a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11 |       4
(1 row)

注意这条记录是手写的 v4 值,版本号是 4,破坏了「全表都是 v7」的假设。这正是前面强调「统一生成算法」的原因——外部数据来源必须显式校验。

第六步:清理

sql
DROP TABLE orders;
DROP TABLE IF EXISTS uuid_locality_test;
text
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) 会导致索引失效并触发隐式转换。
sql
-- 错误!类型不一致,JOIN 无法使用索引
SELECT o.order_no
FROM orders o
JOIN users u ON u.id = o.user_id::text;      -- 把 uuid 强转成 text 后比较
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+)。它同时解决唯一性与索引局部性两个问题,是目前综合最优的选择:

    sql
    id uuid PRIMARY KEY DEFAULT uuidv7()
  • 已经上线的库且不便改主键类型时,用 gen_random_uuid() 即可,但不要指望它带来顺序写收益;此时应把优化重心放在 fillfactor 与批量写入上。

  • 在应用层建立一道 UUID 校验,把非法格式挡在数据库外。规范化后再入库,避免同一实体因大小写/连字符差异出现重复键。

  • 把业务时间单独存列,不要依赖从 UUID 提取:

    sql
    created_at timestamptz NOT NULL DEFAULT now()

    这样索引、报表、分区都能直接用标准时间类型,无需关心 UUID 版本。

  • 需要「天然唯一键 → UUID」映射时用 v5,不要用 v3:

    sql
    SELECT uuid_generate_v5(uuid_ns_url(), 'https://example.com/item/123') AS stable_id;

    v5 基于 SHA-1,比 v3 的 MD5 更抗碰撞。

  • 大表分页用 (created_at, id) 组合游标,不要单靠 id:

    sql
    SELECT 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 的对比

参考链接 ​