改变工作方式的 PostgreSQL 实用模式
在使用 PostgreSQL 数据库的过程中,有一组实践方式可以显著提升开发与协作体验。单个做法影响有限,但叠加起来效果十分明显。
使用 UUID 作为主键
UUID 确实存在一些缺点:
- 完全随机的 UUID 无法自然排序,对索引有一定影响
- 相比自增 ID 占用更多存储空间(而存储通常是成本最低的资源)
但 UUID 的优势远大于缺点:
- 生成 UUID 无需与数据库协调
- 可以安全地对外公开和传递
CREATE TABLE person(
id uuid not null default gen_random_uuid() primary key,
name text not null
)
为所有表添加 created_at 和 updated_at 字段
虽然无法完整记录历史变更,但记录创建时间和最后更新时间,在排查问题时是非常有价值的线索。同时,这类信息一旦未记录,事后无法补救,只能通过预先记录获取。
因此,建议所有表统一包含 created_at 与 updated_at 字段,并通过触发器自动维护 updated_at 字段。
CREATE TABLE person(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
name text not null
);
CREATE FUNCTION set_current_timestamp_updated_at()
RETURNS TRIGGER AS $$
DECLARE
_new record;
BEGIN
_new := NEW;
_new."updated_at" = now();
RETURN _new;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_person_updated_at
BEFORE UPDATE ON person
FOR EACH ROW
EXECUTE PROCEDURE set_current_timestamp_updated_at();
注:每个数据表都需创建对应的触发器,但上述函数仅需创建一次。
外键约束设置 ON UPDATE RESTRICT 和 ON DELETE RESTRICT
该设置可避免删除被引用行时导致的数据丢失,若尝试删除被引用的行,系统会直接抛出错误。存储空间成本低廉,而数据恢复过程则极为繁琐,因此抛出错误比级联删除更合理。
CREATE TABLE person(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
name text not null
);
CREATE TABLE pet(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
name text not null,
owner_id uuid not null references person(id)
on update restrict
on delete restrict
);
使用 Schema 进行逻辑分区
默认情况下,所有表都会创建在 public schema 中。该方式虽可行,但未利用自定义模式的能力会造成功能浪费。
Schema 可作为表的逻辑命名空间,适用于中大型应用。跨 schema 的关联与查询完全可行,几乎没有额外成本。
CREATE SCHEMA vet;
CREATE TABLE vet.person(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
name text not null
);
CREATE TABLE vet.pet(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
name text not null,
owner_id uuid not null references vet.person(id)
on update restrict
on delete restrict
);
使用“枚举表”而非枚举类型
SQL 中定义枚举的方式很多,例如枚举类型或 CHECK 约束。一个更灵活的做法是使用“枚举表”。
即:使用一张表存放允许的取值,其他表通过外键引用。
CREATE TABLE vet.pet_kind(
value text not null primary key
);
INSERT INTO vet.pet_kind(value)
VALUES ('dog'), ('cat'), ('bird');
CREATE TABLE vet.pet(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
owner_id uuid not null references vet.person(id)
on update restrict
on delete restrict,
kind text not null references vet.pet_kind(value)
on update restrict
on delete restrict
);
这样不仅可以随时扩展取值,还可以为每个值附加说明等元数据:
CREATE TABLE vet.pet_kind(
value text not null primary key,
comment text not null default ''
);
INSERT INTO vet.pet_kind(value, comment)
VALUES
('dog', 'A Canine'),
('cat', 'A Feline'),
('bird', 'A 50 Year Commitment');
CREATE TABLE vet.pet(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
owner_id uuid not null references vet.person(id)
on update restrict
on delete restrict,
kind text not null references vet.pet_kind(value)
on update restrict
on delete restrict
);
数据表命名使用单数形式
表名建议统一使用名词单数形式。虽然 SELECT * FROM pets 看起来更自然,但在复杂查询中,实际操作的是“单行数据”。
SELECT *
FROM pet
-- It's a cruel coincidence that in english an "s"
-- suffix can sometimes work both as a plural
-- and a possessive, but notice how the where clause
-- is asserting a condition about a single row.
WHERE pet.name = 'sally'
使用复数形式命名数据表会引发诸多边缘问题,数据表名称应与表中单行数据所代表的实体保持一致。
关联表采用机械化命名规则
用于建立数据多对多关系的 "连接表" 有时可使用语义化名称,但多数情况下无合适的语义化名称,此时可直接拼接所关联表的名称作为连接表名。
CREATE TABLE vet.person(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
CREATE TABLE vet.pet(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- pet_owner would work in this context, but
-- I just want to demonstrate the table_a_table_b naming scheme
CREATE TABLE vet.person_pet(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
person_id uuid not null references vet.person(id)
on update restrict
on delete restrict,
pet_id uuid not null references vet.pet(id)
on update restrict
on delete restrict
);
CREATE UNIQUE INDEX ON vet.person_pet(person_id, pet_id);
优先使用软删除
再次强调:存储便宜,数据恢复困难。
如需标记数据失效,使用可为空的 timestamptz 字段比直接删除更安全:
- 有时间戳:表示删除或失效时间
- 为 NULL:表示仍然有效
CREATE TABLE vet.prescription(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
pet_id uuid not null references vet.pet(id)
on update restrict
on delete restrict,
issued_at timestamptz not null,
-- Instead of deleting a prescription,
-- explicitly mark when it was revoked
revoked_at timestamptz
);
相比布尔值,时间戳通常更有价值,因为不仅表示“是否发生”,还能表示“何时发生”。
将状态表示为日志形式
将状态表示为单一字段(如 submitted → approved)存在两个问题:
- 无法准确记录状态发生的时间或来源
- 状态更新可能以乱序形式接收(例如 Webhook 场景)
应对该问题的方式是创建状态日志表,每行记录代表某一时间点的实体状态。不应复用 created_at 或 updated_at 字段,需新增显式的 valid_at 字段标记状态生效时间。
CREATE TABLE vet.adoption_approval_status(
value text not null primary key
);
INSERT INTO vet.adoption_approval_status(value)
VALUES ('submitted'), ('in_review'), ('rejected'), ('approved');
CREATE TABLE vet.adoption_approval(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
person_id uuid not null references vet.person(id)
on update restrict
on delete restrict,
status text not null references vet.adoption_approval_status(value)
on update restrict
on delete restrict,
valid_at timestamptz not null
);
CREATE INDEX ON vet.adoption_approval(person_id, valid_at DESC);
仅对 valid_at 字段建立索引在短期内有效,但查询性能最终会下降。最优解决方案是新增 latest 布尔字段,配合唯一索引和触发器,确保仅有 valid_at 最新的行标记为最新状态:
CREATE TABLE vet.adoption_approval(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
person_id uuid not null references vet.person(id)
on update restrict
on delete restrict,
status text not null references vet.adoption_approval_status(value)
on update restrict
on delete restrict,
valid_at timestamptz not null,
latest boolean default false
);
CREATE INDEX ON vet.adoption_approval(person_id, valid_at DESC);
-- Conditional unique index makes sure we only have one latest
CREATE UNIQUE INDEX ON vet.adoption_approval(person_id, latest)
WHERE latest = true;
-- Then a trigger to keep latest up to date
CREATE OR REPLACE FUNCTION vet.set_adoption_approval_latest()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE vet.adoption_approval
SET latest = false
WHERE latest = true and person_id = NEW.person_id;
UPDATE vet.adoption_approval
SET latest = true
WHERE id = (
SELECT id
FROM vet.adoption_approval
WHERE person_id = NEW.person_id
ORDER BY valid_at DESC
LIMIT 1
);
RETURN null;
END;
$function$;
CREATE TRIGGER adoption_approval_insert_trigger
AFTER INSERT ON vet.adoption_approval
FOR EACH ROW
EXECUTE FUNCTION vet.set_adoption_approval_latest();
为特殊行标记 system_id
系统中常存在“特殊行”,例如用于系统行为配置或固定逻辑依赖的记录。
可通过 system_id 字段进行标识,并对其创建唯一索引。多个 NULL 不会冲突,因此对普通数据无影响。
CREATE TABLE vet.contact_info(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
person_id uuid references vet.person(id)
on update restrict
on delete restrict,
mailing_address text not null,
system_id text
);
CREATE UNIQUE INDEX ON vet.contact_info(system_id);
-- Not hard to imagine wanting to build functionality that
-- automatically contacts the CDC for cases of rabies or similar,
-- but maybe every other bit of contact_info in the system is
-- for more "normal" purposes
INSERT INTO vet.contact_info(system_id, mailing_address)
VALUES ('cdc', '4770 Buford Highway, NE');
谨慎使用视图
视图在封装复杂查询时非常有用,但也存在明显问题:
- 删除字段需要重建视图
- 视图嵌套会迅速失控
- 查询规划器对视图的优化能力有限
建议仅在必要时使用,并避免“视图套视图”。
CREATE TABLE vet.prescription(
id uuid not null default gen_random_uuid() primary key,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
pet_id uuid not null references vet.pet(id)
on update restrict
on delete restrict,
issued_at timestamptz not null,
-- Instead of deleting a prescription,
-- explicitly mark when it was revoked
revoked_at timestamptz
);
CREATE INDEX ON vet.prescription(revoked_at);
-- There are pros and cons to having this view
CREATE VIEW vet.active_prescription AS
SELECT
vet.prescription.id,
vet.prescription.created_at,
vet.prescription.updated_at,
vet.prescription.pet_id,
vet.prescription.issued_at
FROM
vet.prescription
WHERE
vet.prescription.revoked_at IS NULL;
使用 JSON 查询
PostgreSQL 对 JSON 的支持不仅体现在存储,更体现在查询结果构造上。
将 JSON 作为查询结果格式能发挥更大价值。该方式虽存在缺点(丢失类型信息、需一次性获取结果、JSON 序列化存在性能开销),但核心优势是可通过单次数据库请求获取所需全部信息,避免笛卡尔积问题和 N+1 查询问题。
SELECT jsonb_build_object(
'id', vet.person.id,
'name', vet.person.name,
'pets', array(
SELECT jsonb_build_object(
'id', vet.pet.id,
'name', vet.pet.name,
'prescriptions', array(
SELECT jsonb_build_object(
'issued_at', vet.prescription.issued_at
)
FROM vet.prescription
WHERE vet.prescription.pet_id = vet.pet.id
)
)
FROM vet.person_pet
LEFT JOIN vet.pet
ON vet.pet.id = vet.person_pet.pet_id
WHERE vet.person_pet.person_id = vet.person.id
),
'contact_infos', array(
SELECT jsonb_build_object(
'mailing_address', vet.contact_info.mailing_address
)
FROM vet.contact_info
WHERE vet.contact_info.person_id = vet.person.id
)
)
FROM vet.person
WHERE id = '29168a93-cd14-478f-8c70-a2b7a782c714';
上述查询可返回如下格式的结果:
{
"id": "29168a93-cd14-478f-8c70-a2b7a782c714",
"name": "Jeff Computers",
"pets": [
{
"id": "3e5557c0-c628-44ef-b4d1-86012c5f48bf",
"name": "Rhodie",
"prescriptions": [
{
"issued_at": "2025-03-11T23:46:18.345146+00:00"
}
]
},
{
"id": "ed63ca7d-3368-4353-9747-6b6b2fa6657a",
"name": "Jenny",
"prescriptions": []
}
],
"contact_infos": [
{
"mailing_address": "123 Sesame St."
}
]
}
结语
综合来看,这些 PostgreSQL 设计模式并不追求“炫技”,而是围绕真实业务场景中反复踩过的坑给出的务实解法。它们关注长期维护、数据安全与系统演进成本,强调在一开始就做出对未来友好的选择。随着业务规模扩大,这些看似细微的设计习惯,往往会成为系统稳定性与开发效率的分水岭。
原文链接:https://mccue.dev/pages/3-11-25-life-altering-postgresql-patterns
作者:Ethan McCue
HOW 2026 议题招募中
2026 年 4 月 27-28 日,由 IvorySQL 社区联合 PGEU(欧洲 PG 社区)、PGAsia(亚洲 PG 社区)共同打造的 HOW 2026(IvorySQL & PostgreSQL 技术峰会) 将再度落地济南。届时,PostgreSQL 联合创始人 Bruce Momjian 等顶级大师将亲临现场。
自开启征集以来,HOW 2026 筹备组已感受到来自全球 PostgreSQL 爱好者的澎湃热情。为了确保大会议题的深度与广度,我们诚邀您在 2026 年 2 月 27 日截止日期前,提交您的技术见解。

浙公网安备 33010602011771号