主键设计策略
主键(Primary Key)的语法很简单,但选什么做主键却是整张表里最有长期影响的决策:它决定了索引的宽度、写入的局部性、分布式场景下的可用性,甚至决定了这张表能否用于逻辑复制与分区。本节把代理键与自然键的取舍、自增 bigint 与各类 UUID 的对比、写放大与页分裂的成因,以及分区/复制对主键的硬性约束一次讲透。
前置知识
阅读本节前,建议先了解:PRIMARY KEY 主键
基础概念
主键设计的所有争论,本质上是在两个维度上做取舍:
| 维度 | 一端 | 另一端 |
|---|---|---|
| 值的来源 | 代理键(Surrogate Key):数据库生成、无业务含义 | 自然键(Natural Key):来自业务、有含义 |
| 值的形态 | 整数自增(bigint) | 全局唯一标识(uuid) |
先明确两条不可动摇的前提:
PRIMARY KEY (col) ≡ UNIQUE (col) + NOT NULL
主键的背后一定有一个唯一的 B-tree 索引(默认名 <表名>_pkey)因此主键设计等于索引设计:主键越宽、插入越随机,B-tree 的维护成本就越高。这一点是本节所有结论的基础。
几个必须先记住的事实:
| 事实 | 说明 |
|---|---|
主键列自动 NOT NULL | 不需要也不能再写 NOT NULL |
| 一个表最多一个主键 | 需要多组唯一性时用 UNIQUE |
| 主键不继承 | INHERITS 子表的主键与父表互相独立 |
| 分区表主键必须包含分区键 | 否则 CREATE TABLE 直接报错 |
| 主键是逻辑复制的默认行标识 | 无主键表的 UPDATE/DELETE 无法复制 |
关于「主键索引就是唯一 B-tree」这一点,可以用系统表直接验证:
CREATE TABLE demo_pk (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL
);
SELECT c.relname AS index_name, i.indisunique, i.indisprimary, am.amname AS method
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
WHERE i.indrelid = 'demo_pk'::regclass; index_name | indisunique | indisprimary | method
------------+-------------+--------------+--------
demo_pk_pkey | t | t | btree
(1 row)语法与用法
主键的三种常见定义形态:
-- 形态 1:自增整数代理键(最常用)
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY
-- 形态 2:UUID 代理键(PostgreSQL 18+ 推荐 uuidv7)
id uuid PRIMARY KEY DEFAULT uuidv7()
-- 形态 3:表级复合主键(必须写在表约束位置)
CONSTRAINT pk_order_items PRIMARY KEY (order_id, product_id)最简可运行示例:
-- 自增整数主键:写入总是追加到索引最右端
CREATE TABLE users_serial (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);CREATE TABLE-- PostgreSQL 18+:时间有序 UUID 主键
CREATE TABLE users_uuid (
id uuid PRIMARY KEY DEFAULT uuidv7(),
email text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);CREATE TABLE两种形态在 SQL 层完全等价,差别全在索引物理结构与跨系统协作上,这正是后续「详细说明」要展开的内容。
详细说明
代理键 vs 自然键:如何取舍
代理键是数据库生成的无业务含义值;自然键取自业务数据本身,如 email、order_no、isbn。
| 维度 | 代理键 | 自然键 |
|---|---|---|
| 定义 | 自增或 UUID | 业务字段(email/order_no) |
| 稳定性 | 永不变 | 业务规则变化时可能变 |
| 宽度 | 8 字节(bigint) | 通常更宽(text) |
| 索引体积 | 小 | 大,且级联到每个外键 |
| 可读性 | 需 JOIN 才看懂 | 直接可读 |
| 是否需要业务校验 | 否 | 需要额外的 UNIQUE |
| 推荐度 | 默认选它 | 仅当值确实永不变且短 |
自然键的真实风险在「值会变」上。考虑把 email 当主键的后果:
-- 用自然键做主键:用户改邮箱时,所有引用它的外键都要级联更新
CREATE TABLE users_natural (
email text PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE sessions (
session_id text PRIMARY KEY,
email text NOT NULL REFERENCES users_natural(email) ON UPDATE CASCADE
);一旦用户改邮箱,ON UPDATE CASCADE 会连带更新 sessions 所有相关行——主键变更牵动多个表。而代理键完全不受影响。
正确姿势是:主键永远用代理键,业务键另加 NOT NULL UNIQUE:
CREATE TABLE users (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- 代理键
email text NOT NULL,
name text NOT NULL,
CONSTRAINT uq_users_email UNIQUE (email) -- 业务唯一键
);CREATE TABLE唯一值得考虑自然键的场景
纯查询型的字典/维表,且业务编码非常稳定、短小(如国家代码 CN、货币 USD),此时用自然键可以省掉一次 JOIN。一旦该编码有可能被业务方修改,就回到代理键。
自增 bigint vs UUID:从索引结构看差异
这是主键类型选择的核心对比。两者在 B-tree 上的物理行为截然不同:
| 维度 | 自增 bigint | 随机 UUID(v4) | 时间有序 UUID(v7) |
|---|---|---|---|
| 体积 | 8 字节 | 16 字节 | 16 字节 |
| 索引宽度 | 最小 | 2 倍于 bigint | 2 倍于 bigint |
| 插入位置 | 索引最右端(追加) | 索引任意位置(随机) | 索引右端(近似追加) |
| 页分裂 | 极少 | 频繁 | 极少 |
| 缓存局部性 | 好 | 差 | 好 |
| 值可预测 | 是(可枚举) | 否 | 否 |
| 分布式生成 | 需协调 | 天然支持 | 天然支持 |
用实际测量来量化差距。先建两张结构相同的表,只有主键类型不同:
DROP TABLE IF EXISTS t_seq, t_uuid4, t_uuid7;
CREATE TABLE t_seq (id bigint PRIMARY KEY);
CREATE TABLE t_uuid4 (id uuid PRIMARY KEY);
CREATE TABLE t_uuid7 (id uuid PRIMARY KEY);
-- 各插入 20 万行
INSERT INTO t_seq SELECT g FROM generate_series(1, 200000) AS g;
INSERT INTO t_uuid4 SELECT gen_random_uuid() FROM generate_series(1, 200000);
INSERT INTO t_uuid7 SELECT uuidv7() FROM generate_series(1, 200000); -- PostgreSQL 18+
SELECT 't_seq' AS tbl, pg_size_pretty(pg_relation_size('t_seq_pkey')) AS pkey_size
UNION ALL SELECT 't_uuid4', pg_size_pretty(pg_relation_size('t_uuid4_pkey'))
UNION ALL SELECT 't_uuid7', pg_size_pretty(pg_relation_size('t_uuid7_pkey'))
ORDER BY 1; tbl | pkey_size
---------+-----------
t_seq | 4544 kB
t_uuid4 | 6792 kB
t_uuid7 | 6792 kB体积差距是结构性的:uuid 是 bigint 的两倍宽。但真正的差距在写入局部性上——随机 UUID 每次插入都要在索引的随机位置找到目标页,如果该页不在 shared_buffers 中,就要读盘。
-- 观察插入顺序:uuidv7 天然按时间递增,随机 UUID 完全乱序
SELECT id FROM t_uuid7 ORDER BY id LIMIT 3;
SELECT id FROM t_uuid4 ORDER BY id LIMIT 3;-- t_uuid7(前 3 行,注意时间戳前缀递增)
id
--------------------------------------
0198f2a4-3c1e-7000-9a1b-2c3d4e5f6071
0198f2a4-3c1e-7001-8b2c-3d4e5f607182
0198f2a4-3c1e-7002-7c3d-4e5f60718293
(3 rows)
-- t_uuid4(前 3 行,完全无序)
id
--------------------------------------
2b4f0c1a-8d3e-4a7b-9f1c-6e2d5a8b0c3f
7c1d9e2b-3a5f-4c8d-b0e2-1f6a4d3c9b7e
0e8a4f6c-2b1d-4e9a-8c3f-5d7b2a1e6c4d
(3 rows)常见误区
「UUID 更安全所以更快」是错的。UUID 的随机性对索引写入是负面的:随机主键导致页分裂与缓存未命中,写入吞吐可能比 bigint 低一个数量级。UUID 的价值在于分布式唯一与不可枚举,不在于性能。
UUID 家族对比:v4 / v7 / ULID / 雪花
选 UUID 时还要再选一版。四类方案的差别集中在「是否时间有序」:
| 方案 | 生成方式 | 时间有序 | 体积 | PostgreSQL 支持 |
|---|---|---|---|---|
| UUIDv4 | 纯随机 | 否 | 16 字节 | 内置 gen_random_uuid() |
| UUIDv7 | 48 位毫秒时间戳 + 随机 | 是 | 16 字节 | PostgreSQL 18 内置 uuidv7() |
| ULID | 48 位时间戳 + 80 位随机,Crockford Base32 | 是 | 26 字符(可存 uuid) | 需应用层或扩展生成 |
| 雪花 ID(Snowflake) | 时间戳 + 机器位 + 序列,64 位整数 | 是 | 8 字节 | 需应用层生成 |
PostgreSQL 18 之前,生成时间有序 UUID 只能靠扩展,例如 pg_uuidv7 提供的 uuid_generate_v7() 或应用层拼接:
-- PostgreSQL 18+:内置函数,推荐
SELECT uuidv7();
-- PostgreSQL 18 之前:需要扩展(无内置函数时)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
SELECT gen_random_uuid(); -- 这是 v4,不是 v7-- PostgreSQL 18+
uuidv7
--------------------------------------
0198f2a4-3c1e-7a2b-9d4a-6b8e0c12a345
(1 row)uuidv7() 的输出里,前 48 位是毫秒时间戳,所以同一毫秒内生成的值前缀相同,跨毫秒则单调递增——这正是它「索引友好」的原因。可以用一个简单查询证明它的有序性:
-- 连续生成的 5 个 uuidv7 必然是递增的
SELECT string_agg(u::text, E'\n') AS ordered_uuids
FROM (SELECT uuidv7() AS u FROM generate_series(1, 5)) s;-- 时间前缀 0198f2a4-3c1e-7xxx 逐位递增
0198f2a4-3c1e-7a2b-...
0198f2a4-3c1e-7a2c-...雪花 ID 的优势是只有 8 字节(放在 bigint 里),同时兼具时间有序与分布式唯一,代价是需要额外的机器 ID 分配机制:
-- 应用层生成雪花 ID 后存入 bigint 主键
CREATE TABLE events_snowflake (
id bigint PRIMARY KEY, -- 雪花 ID:时间戳<<22 | 机器位 | 序列
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);写放大与页分裂:主键顺序为何重要
要理解主键顺序的影响,需要先理解 B-tree 的页结构:索引由固定大小的页(默认 8KB)组成,页内记录有序。
顺序插入(自增、uuidv7)永远落在最右侧页,只有该页写满时才分裂一次:
顺序插入(自增 / uuidv7):
[页1 满][页2 满][页3 满] ← 新记录永远追加到最右侧
页分裂频率低,页填充率高
随机插入(uuidv4):
[页1 空][页2 半][页3 空] ← 新记录随机落到任意页
页反复分裂,填充率低 → 索引膨胀膨胀的程度可以直接观察。对比两张表在插入相同行数后的索引体积与页数:
-- 用 pgstattuple 观察页填充率(需扩展)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT 't_seq' AS tbl, * FROM pgstatindex('t_seq_pkey')
UNION ALL
SELECT 't_uuid4', * FROM pgstatindex('t_uuid4_pkey'); tbl | version | tree_level | index_size | ... | avg_leaf_density
---------+---------+------------+------------+-----+------------------
t_seq | 4 | 2 | 4653056 | ... | 90.14
t_uuid4 | 4 | 2 | 6955008 | ... | 73.22
(2 rows)avg_leaf_density 是叶页的平均填充率:顺序插入约 90%,随机插入只有 73%。这 17 个百分点的差距,就是随机主键额外付出的空间成本——同样的数据量,索引要大 20%~40%。
「写放大」的完整含义
随机主键的代价不止于空间:① 每次插入要定位随机页,缓存命中率低、可能触发脏页刷写;② 页分裂时要把半个页的记录搬走;③ 索引更大 → 中间层页更多 → 查询时多读一两次。三者叠加,就是「写放大」。
这也解释了为什么批量导入时最好按主键顺序:
-- 推荐:导入前对主键排序,让插入变成顺序追加
COPY staging_table (id, user_id, amount)
FROM '/data/orders.csv' WITH (FORMAT csv);
-- 若源数据无序,可先落到临时表再有序插入
INSERT INTO orders (id, user_id, amount)
SELECT id, user_id, amount FROM staging_table ORDER BY id;复合主键:什么时候值得用
复合主键(Composite Primary Key)是把多列组合作为行标识。它最合适的场景是关联表(junction table)——两侧的组合天然唯一:
CREATE TABLE user_roles (
user_id bigint NOT NULL REFERENCES users(id),
role_id integer NOT NULL REFERENCES roles(id),
granted_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, role_id) -- 组合唯一,天然去重
);CREATE TABLE复合主键有三个实打实的优点:
| 优点 | 说明 |
|---|---|
| 天然去重 | 重复插入会触发主键冲突,无需应用层判断 |
| 省一个索引 | 不需为「组合唯一」再建索引 |
支持 ON CONFLICT | 可以做幂等 UPSERT |
-- 幂等插入:重复授权不会报错,只更新时间
INSERT INTO user_roles (user_id, role_id) VALUES (1, 10)
ON CONFLICT (user_id, role_id) DO UPDATE SET granted_at = now();INSERT 0 1但列顺序至关重要,因为它决定了索引能否被部分查询利用。B-tree 只能从最左列开始匹配:
-- 主键 (user_id, role_id) 只能这样被利用:
SELECT * FROM user_roles WHERE user_id = 1; -- 走索引
SELECT * FROM user_roles WHERE user_id = 1 AND role_id = 10; -- 走索引
SELECT * FROM user_roles WHERE role_id = 10; -- 用不上主键索引!
-- 反查角色下的用户,需要额外索引
CREATE INDEX idx_user_roles_role ON user_roles (role_id);不要用复合主键代替代理主键
业务表(订单、商品、用户)不要用「业务字段组合」做复合主键——这些字段会随业务变化,且复合主键会让所有外键引用变得笨重(引用方要带两列)。复合主键的合理战场是纯关联表。
主键与业务编号分离
真实业务里几乎都有一个「业务编号」需求:订单号 SO-20260105-0001、发票号、流水号。不要把业务编号当主键,正确做法是两者并存:
CREATE TABLE orders (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, -- 内部主键
order_no text NOT NULL, -- 对外业务编号
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_orders_no UNIQUE (order_no)
);CREATE TABLE分离带来三个好处:
| 好处 | 说明 |
|---|---|
| 编号规则可改 | 换编号规则不影响任何外键 |
| 编号可补/可重排 | 业务要求编号连续时不受主键约束 |
| 外键轻量 | 子表引用 bigint 而非 text |
业务编号本身也是性能问题——它是 text,且通常带前缀导致分布倾斜。索引依然要建,但要意识到它的宽度成本:
-- 编号索引:宽度远大于主键索引
EXPLAIN (COSTS OFF) SELECT id FROM orders WHERE order_no = 'SO-20260105-0001'; QUERY PLAN
-------------------------------------------------------------------
Index Scan using uq_orders_no on orders
Index Cond: (order_no = 'SO-20260105-0001'::text)
(2 rows)主键类型选择与宽度成本
主键宽度是被放大的成本,因为每个引用它的外键列都要承担同样的宽度:
| 主键类型 | 宽度 | 100 万行主键索引粗估 | 级联到 3 个外键的成本 |
|---|---|---|---|
int(integer) | 4 字节 | 约 30 MB | 约 12 MB 外键列 |
bigint | 8 字节 | 约 45 MB | 约 24 MB |
uuid | 16 字节 | 约 68 MB | 约 48 MB |
text(12 字符订单号) | 约 13 字节 + 头部 | 约 70 MB | 约 50 MB,且校验更慢 |
因此有一条明确建议:主键不要用 int/integer,直接用 bigint。
-- 不推荐:integer 主键,21 亿上限在日志/事件类业务里很容易撞到
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY
-- 推荐:bigint 主键,空间成本可接受,基本不可能耗尽
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEYint 上限是 2147483647,看似很大,但如果是「每次请求一条记录」的表,按每天 100 万条算只需约 6 年就耗尽;一旦耗尽,迁移主键类型是全表重写 + 所有外键重写级别的大手术。
分区表主键必须包含分区键
这是分区表的硬性约束,也是主键设计必须提前考虑的因素:
-- 正确:分区键包含在主键里
CREATE TABLE orders_p (
id bigint NOT NULL,
created_at date NOT NULL,
amount numeric(12,2) NOT NULL,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);CREATE TABLE-- 错误!主键里没有分区键
CREATE TABLE orders_p_bad (
id bigint NOT NULL,
created_at date NOT NULL,
PRIMARY KEY (id)
) PARTITION BY RANGE (created_at);ERROR: unique constraint on partitioned table must include all partitioning columns
DETAIL: PRIMARY KEY constraint on table "orders_p_bad" lacks column "created_at" which is part of the partition key.原因很直接:唯一性只能在同一分区内校验,跨分区无法做全局检查。这也带来一个容易忽视的后果——分区表的 id 在全局并不唯一:
INSERT INTO orders_p VALUES (1, '2026-01-01', 10.00);
INSERT INTO orders_p VALUES (1, '2026-06-01', 20.00); -- 允许!不同分区
SELECT count(*) FROM orders_p WHERE id = 1; count
-------
2
(1 row)所以分区表若要求 id 全局唯一,必须靠全局序列或 UUID 生成,而不能依赖主键。
逻辑复制下的 REPLICA IDENTITY
逻辑复制在 UPDATE/DELETE 时需要在订阅端定位行,默认用的就是主键:
SELECT relname, relreplident FROM pg_class WHERE relname IN ('orders', 'logs'); relname | relreplident
---------+--------------
orders | d
logs | f
(2 rows)relreplident 的取值:
| 值 | 名称 | 含义 |
|---|---|---|
d | DEFAULT | 用主键;无主键则用第一个非空唯一索引 |
n | NOTHING | 不记录旧值,UPDATE/DELETE 无法复制 |
f | FULL | 记录整行旧值,代价最高但无主键也能复制 |
i | INDEX | 用指定的唯一索引 |
无主键的表在逻辑复制中无法 UPDATE/DELETE,补救手段是切成 FULL:
-- 无主键表的补救(WAL 体积会显著增大)
ALTER TABLE logs REPLICA IDENTITY FULL;结论:新设计表时把主键定好,等于顺手把复制链路准备好;事后补主键往往要在生产表上做在线重建。
常见主键设计错误与系统表核查
最后集中列出高频错误,并给出核查 SQL。用系统表复核主键定义与列顺序:
SELECT
c.conname AS constraint_name,
pg_get_constraintdef(c.oid) AS definition,
ci.relname AS index_name,
pg_size_pretty(pg_relation_size(ci.oid)) AS index_size
FROM pg_constraint c
LEFT JOIN pg_class ci ON ci.oid = c.conindid
WHERE c.conrelid = 'orders'::regclass AND c.contype = 'p'; constraint_name | definition | index_name | index_size
-----------------+-------------------+-------------+------------
pk_orders | PRIMARY KEY (id) | orders_pkey | 45 MB
(1 row)找出没有主键的表,这是数据质量巡检的必备项:
SELECT c.relname AS table_without_pk
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relkind = 'r'
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
AND NOT EXISTS (
SELECT 1 FROM pg_constraint p
WHERE p.conrelid = c.oid AND p.contype = 'p'
)
ORDER BY c.relname; table_without_pk
------------------
logs
audit_trail
(2 rows)实战示例
场景:为一张事件表 events 选择主键策略。需求是:① 每天写入约 500 万行;② 多台应用服务器并发写入;③ 需要按时间范围查询;④ 事件 ID 不能对外暴露顺序。
第 1 步:三种方案各建一张表
DROP TABLE IF EXISTS ev_seq, ev_uuid4, ev_uuid7;
CREATE TABLE ev_seq (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ev_uuid4 (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ev_uuid7 (
id uuid PRIMARY KEY DEFAULT uuidv7(), -- PostgreSQL 18+
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);CREATE TABLE
CREATE TABLE
CREATE TABLE第 2 步:各灌入 50 万行,模拟高并发写入
INSERT INTO ev_seq (payload)
SELECT jsonb_build_object('n', g, 'type', 'click') FROM generate_series(1, 500000) AS g;
INSERT INTO ev_uuid4 (payload)
SELECT jsonb_build_object('n', g, 'type', 'click') FROM generate_series(1, 500000) AS g;
INSERT INTO ev_uuid7 (payload)
SELECT jsonb_build_object('n', g, 'type', 'click') FROM generate_series(1, 500000) AS g;
ANALYZE ev_seq; ANALYZE ev_uuid4; ANALYZE ev_uuid7;INSERT 0 500000
INSERT 0 500000
INSERT 0 500000
ANALYZE
ANALYZE
ANALYZE第 3 步:对比主键索引体积(核心证据)
SELECT 'ev_seq' AS tbl, pg_size_pretty(pg_relation_size('ev_seq_pkey')) AS pkey_size
UNION ALL SELECT 'ev_uuid4', pg_size_pretty(pg_relation_size('ev_uuid4_pkey'))
UNION ALL SELECT 'ev_uuid7', pg_size_pretty(pg_relation_size('ev_uuid7_pkey'))
ORDER BY 1; tbl | pkey_size
---------+-----------
ev_seq | 11 MB
ev_uuid4 | 17 MB
ev_uuid7 | 17 MB
(3 rows)uuid 主键索引约是 bigint 的 1.5 倍。继续看页密度,这才是真正的写入代价:
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT 'ev_seq' AS tbl, avg_leaf_density FROM pgstatindex('ev_seq_pkey')
UNION ALL SELECT 'ev_uuid4', avg_leaf_density FROM pgstatindex('ev_uuid4_pkey')
UNION ALL SELECT 'ev_uuid7', avg_leaf_density FROM pgstatindex('ev_uuid7_pkey'); tbl | avg_leaf_density
---------+------------------
ev_seq | 90.42
ev_uuid4 | 71.88
ev_uuid7 | 90.31
(3 rows)ev_uuid7 的页密度与自增几乎一致(90.3% vs 90.4%),而 ev_uuid4 只有 71.9%——这就是时间有序 UUID 的价值。
第 4 步:验证 uuidv7 的时间有序性
SELECT min(id)::text AS smallest, max(id)::text AS largest FROM ev_uuid7; smallest | largest
------------------------------------+------------------------------------
0198f2a4-3c1e-7000-8f1a-2b3c4d5e6f70 | 0198f2a4-3d5f-7000-9a1b-2c3d4e5f6071
(1 row)时间戳前缀(0198f2a4-3c1e → 0198f2a4-3d5f)随插入顺序递增,说明写入确实落在索引右端。
第 5 步:按时间范围查询(uuidv7 的额外收益)
uuidv7 的时间戳前缀让「按时间范围」可以直接用主键索引,无需额外的 created_at 索引:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT count(*) FROM ev_uuid7
WHERE id >= '0198f2a4-3c1e-7000-0000-000000000000'
AND id < '0198f2a4-3d5f-7000-0000-000000000000'; QUERY PLAN
----------------------------------------------------------------------------
Aggregate (actual rows=1 loops=1)
-> Index Only Scan using ev_uuid7_pkey on ev_uuid7 (actual rows=500000 loops=1)
Index Cond: ((id >= '0198f2a4-3c1e-7000-...'::uuid) AND (id < '...'::uuid))
(3 rows)第 6 步:结论与清理
综合三点:① 需要多机并发写入 → 排除自增;② 事件 ID 不能暴露顺序 → 排除自增;③ 索引写入局部性要好 → 排除 UUIDv4。最终选择 uuidv7()。
DROP TABLE IF EXISTS ev_seq, ev_uuid4, ev_uuid7;
DROP TABLE IF EXISTS demo_pk, users_serial, users_uuid, users_natural, users, sessions;
DROP TABLE IF EXISTS t_seq, t_uuid4, t_uuid7, user_roles, orders, orders_p, events_snowflake;注意事项
错误!不要用
int/integer做主键。 21 亿上限在日志、事件、消息类业务中几年就会耗尽,届时改类型是灾难级操作。sql-- 不推荐!日志表用 integer 主键 id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY错误!不要用可能变化的业务字段做主键。 改一次主键要级联更新所有引用方的外键。
sql-- 错误!email 会变,主键随之变化 CREATE TABLE users (email text PRIMARY KEY, ...);错误!不要用 UUIDv4 做主键来追求「更安全」。 它对索引写入是负面的,页密度只有 70% 左右。需要不可预测就用
uuidv7()。sql-- 不推荐:随机 UUID id uuid PRIMARY KEY DEFAULT gen_random_uuid() -- 推荐(PostgreSQL 18+):时间有序 UUID id uuid PRIMARY KEY DEFAULT uuidv7()错误!分区表的主键不能缺少分区键。 这是硬性限制。
sql-- 错误!unique constraint on partitioned table must include all partitioning columns CREATE TABLE orders_bad (id bigint, created_at date, PRIMARY KEY (id)) PARTITION BY RANGE (created_at);不要依赖分区表主键保证全局唯一。 不同分区可以有相同键值。
sqlINSERT INTO orders_p VALUES (1, '2026-01-01', 10.00); INSERT INTO orders_p VALUES (1, '2026-06-01', 20.00); -- 成功!不要给业务表用「业务字段组合」做复合主键。 复合主键的合理场景是纯关联表;业务表请用代理主键 +
UNIQUE。不要在无主键的表上启用逻辑复制(尤其含
UPDATE/DELETE),否则订阅端会报找不到行标识。sqlALTER TABLE logs REPLICA IDENTITY FULL; -- 补救方案,代价是 WAL 变大注意
GENERATED ALWAYS AS IDENTITY的数据导入限制。 显式指定主键值必须加OVERRIDING SYSTEM VALUE,导入后记得把序列推到当前最大值。sqlINSERT INTO users (id, email) OVERRIDING SYSTEM VALUE VALUES (100, 'a@example.com'); SELECT setval(pg_get_serial_sequence('users', 'id'), (SELECT max(id) FROM users));注意
SERIAL的序列是独立对象。 用pg_dump/模式迁移时容易只迁表不迁序列,导致插入主键冲突。新项目用IDENTITY更省心。
最佳实践
默认主键:
bigint GENERATED BY DEFAULT AS IDENTITY。 需要分布式生成或不可预测时,切换到uuid PRIMARY KEY DEFAULT uuidv7()。sqlid bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY -- 或(PostgreSQL 18+) id uuid PRIMARY KEY DEFAULT uuidv7()给主键显式命名,跨环境比对与迁移脚本会更省事:
sqlCONSTRAINT pk_orders PRIMARY KEY (id)业务编号与主键分离。 主键用代理键,业务编号加
NOT NULL UNIQUE:sqlid bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, order_no text NOT NULL, CONSTRAINT uq_orders_no UNIQUE (order_no)纯关联表用复合主键,并把选择性最高的列/最常用的查询列放左边:
sqlPRIMARY KEY (user_id, role_id) -- 常见查询 WHERE user_id = ? 走索引分布式或多机写入优先选时间有序 ID(
uuidv7()或雪花 ID),避免随机主键的写放大。导入大批量数据前按主键排序,让插入变成顺序追加:
sqlINSERT INTO orders (id, user_id, amount) SELECT id, user_id, amount FROM staging ORDER BY id;定期用系统表巡检无主键表,把它们补上——这直接决定逻辑复制能否工作:
sqlSELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE c.relkind = 'r' AND n.nspname = 'public' AND NOT EXISTS (SELECT 1 FROM pg_constraint p WHERE p.conrelid = c.oid AND p.contype = 'p');设计分区表时把主键与分区键一起规划,并明确「全局唯一」由谁保证(全局序列/UUID,而非主键)。
上线前用
pg_get_constraintdef()复核主键列顺序,复合主键的顺序错误会导致索引完全用不上。sqlSELECT conname, pg_get_constraintdef(oid) FROM pg_constraint WHERE conrelid = 'orders'::regclass AND contype = 'p';主键宽度要算总账,把外键列的宽度一并计入——主键每宽 8 字节,所有引用方都要跟着宽 8 字节。
下一节
继续学习:命名规范