在使用 PostgreSQL 数据库的过程中,有一组实践方式可以显著提升开发与协作体验。单个做法影响有限,但叠加起来效果十分明显。
UUID 确实存在一些缺点:
但 UUID 的优势远大于缺点:
CREATE TABLE person( id uuid not null default gen_random_uuid() primary key, name text not null )
虽然无法完整记录历史变更,但记录创建时间和最后更新时间,在排查问题时是非常有价值的线索。同时,这类信息一旦未记录,事后无法补救,只能通过预先记录获取。
因此,建议所有表统一包含 created at 与 updatedat 字段,并通过触发器自动维护 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();
注:每个数据表都需创建对应的触发器,但上述函数仅需创建一次。
该设置可避免删除被引用行时导致的数据丢失,若尝试删除被引用的行,系统会直接抛出错误。存储空间成本低廉,而数据恢复过程则极为繁琐,因此抛出错误比级联删除更合理。
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 );
默认情况下,所有表都会创建在 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 字段比直接删除更安全:
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)存在两个问题:
应对该问题的方式是创建状态日志表,每行记录代表某一时间点的实体状态。不应复用 created at 或 updatedat 字段,需新增显式的 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 布尔字段,配合唯一索引和触发器,确保仅有 validat 最新的行标记为最新状态:
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 字段进行标识,并对其创建唯一索引。多个 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;
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
2026 年 4 月 27-28 日,由 IvorySQL 社区联合 PGEU(欧洲 PG 社区)、PGAsia(亚洲 PG 社区)共同打造的 HOW 2026(IvorySQL & PostgreSQL 技术峰会) 将再度落地济南。届时,PostgreSQL 联合创始人 Bruce Momjian 等顶 级大师将亲临现场。
自开启征集以来,HOW 2026 筹备组已感受到来自全球 PostgreSQL 爱好者的澎湃热情。为了确保大会议题的深度与广度,我们诚邀您在 2026 年 2 月 27 日截止日期前,提交您的技术见解。
投递链接: https://jsj.top/f/uebqBc
来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/70043484/viewspace-3109725/ ,如需转载,请注明出处,否则将追究法律责任。