支付模块上线:把正确性交给数据库
支付模块今天上线,payments 表由你来建。后端说:「别靠应用代码保证正确,数据库要能挡住脏数据。」
学 · 40 min
01五种约束:PK / FK / UNIQUE / CHECK / NOT NULL
场景后端说「数据库要能挡脏数据」--挡脏数据的就是这五个门卫。
各管一件事:PK = 唯一 + 非空(行的身份证);FK = 引用必须存在(明细指向的订单必须是真订单);UNIQUE = 列组合不重复;CHECK = 自定义规则(金额 > 0);NOT NULL = 必填。约束在写入时检查,违反即整条语句失败--脏数据根本进不了库。
create table payments ( id bigint generated always as identity primary key, order_id uuid not null references orders(id), method text not null, amount numeric(10,2) not null check (amount > 0), status text not null, paid_at timestamp, unique (order_id, method) -- 同一订单同渠道只有一笔 );易错约束挡的是「结构性错误」;「业务逻辑错」(把 100 元记成 1000 元)永远要靠流程和评审。
02外键的级联动作:CASCADE / SET NULL / RESTRICT
场景删一个用户,他的订单、明细怎么办?建表时就要回答。
on delete cascade:连带删光子行;set null:子行保留、外键置空(列必须可空);restrict(默认):有子行就拒绝删父行。选哪个是业务决策:审计要求高的库默认 restrict,别让一次 delete 引发雪崩。-- 实验:删用户,订单连带消失 alter table orders drop constraint orders_user_id_fkey, add constraint orders_user_id_fkey foreign key (user_id) references users(id) on delete cascade; delete from users where id = '...'; -- orders 里他的订单同时没了易错cascade 链会一路传导(订单没了 -> 明细也没了)且静默发生;生产上删数据前先数一下子行有多少。
03唯一约束 vs 唯一索引的关系
场景面试题:唯一约束和唯一索引是不是一回事?
在 PG 里功能等价:建唯一约束时 PG 自动建一个唯一索引来实现它。区别在意图层:约束是声明(表达业务规则),索引是实现(顺带加速)。写 DDL 用约束语义更清晰;已存在的唯一索引也被视作可用的冲突目标(on conflict 能用)。另外 PG 默认多个 NULL 不算重复。
-- 两种写法效果等价 alter table users add constraint uq_email unique (email); create unique index idx_email on users (email); select count(*) from (values (null),(null)) t(x); -- 两个 null 并存 -- unique 列同理:多个 NULL 行可以共存(PG 15+ 可用 nulls not distinct 改变)易错「唯一列可以有多个 NULL」常被当成 bug 上报--其实是 SQL 标准语义。
04「生产环境该不该用外键」的正反理由
场景技术评审会上最热闹的一题:后端嫌外键碍事,DBA 嫌没有外键心慌。
支持:数据库兜底正确性(应用有 bug 也挡得住)、自带文档作用、防止孤儿数据。反对:每次写入多一次引用检查(高频写场景有感)、锁竞争、分库分表后外键根本没法跨库。成熟观点不是站队,是分场景:单库中小规模用;超大规模 / 分库分表靠应用保证 + 定期跑一致性校验脚本。
易错面试回答这题要给出「在什么规模、什么前提下我会改变结论」--只喊口号不得分。
练 · 65 min
- 跑 seed.sql「W5」段灌入支付流水;按页面底部结构建 payments,PK / FK / NOT NULL 一次到位
参考答案
约束一次到位:PK、FK、NOT NULL、unique。check 留到第 3 题单独加--seed 埋的负金额订单会留下负流水,先过不了 check,得先把表建起来灌数。
create table payments ( id bigint generated always as identity primary key, order_id uuid not null references orders(id), method int not null, -- 1~4:支付渠道 amount numeric(10,2) not null, status int not null default 1, paid_at timestamp, unique (order_id, method) -- 同一订单同渠道只有一笔:幂等的地基 ); -- 然后跑 seed.sql 的 §E 段(一条 insert ... select) select count(*) from payments; -- ≈4 万:每个已支付订单一条 - 给前几周建的 7 张表补齐全部 PK 和 FK(还债日)
参考答案
加外键会扫一遍子表验证存量(order_items 12 万行,秒级)。加完之后每次写子表都多一次父表引用检查--这就是第 6 题「反方理由」的现场。
-- PK 建表时基本都有(\d 检查),缺的补:alter table 表名 add primary key (id); -- 这题的主菜是补外键: alter table orders add constraint orders_user_id_fkey foreign key (user_id) references users(id); alter table products add constraint products_category_id_fkey foreign key (category_id) references categories(id); alter table categories add constraint categories_parent_id_fkey foreign key (parent_id) references categories(id); alter table order_items add constraint order_items_order_id_fkey foreign key (order_id) references orders(id); alter table order_items add constraint order_items_product_id_fkey foreign key (product_id) references products(id); alter table user_logins add constraint user_logins_user_id_fkey foreign key (user_id) references users(id); -- 验收:列出全库外键 select conrelid::regclass as 子表, pg_get_constraintdef(oid) as 定义 from pg_constraint where contype = 'f' order by 1; - 加
CHECK (amount > 0),插一条负数验证报错参考答案
check 在写入时拦截、整条语句失败;「先有脏数据后加约束」必须先清存量--约束是入库的最后一道门,不是洗数据工具。
-- 约束对存量数据同样校验:负金额订单留下的负流水先清掉 delete from payments where amount < 0; alter table payments add constraint chk_payments_amount_positive check (amount > 0); -- 验证:插一条负数 insert into payments(order_id, method, amount, status, paid_at) values ((select id from orders where status = 2 limit 1), 1, -9.90, 1, now()); -- ERROR: new row for relation "payments" violates check constraint -- "chk_payments_amount_positive" - 用唯一约束防止「同一用户同一秒重复下单」
参考答案
这是 learn「唯一约束 vs 唯一索引」的落地:约束语法不支持表达式,唯一索引可以;反过来 PG 的唯一约束本来就是靠自动建唯一索引实现的。另外 PG 默认多个 NULL 不算重复。
-- 「同一用户同一秒」是表达式,约束语法写不了,只能用表达式唯一索引 create unique index uq_orders_user_second on orders (user_id, date_trunc('second', created_at)); -- 验证:原样复制一笔订单(同用户同时间戳) insert into orders(user_id, status, total_amount, created_at) select user_id, 1, 99.00, created_at from orders limit 1; -- ERROR: duplicate key value violates unique constraint "uq_orders_user_second" - 做
ON DELETE CASCADE实验:删一个用户,看订单是否连带消失参考答案
级联会静默传导:用户没了、订单没了、明细也没了。整个实验包在事务里 rollback--PG 连 DDL 都是事务性的(D33 正式讲)。生产删父行前先数一下子行有多少。
begin; create temp table victim as select user_id from orders group by 1 order by count(*) limit 1; -- 外键改成级联(order_items 不改的话 delete 会被它挡住--那就是 RESTRICT 的现场) alter table orders drop constraint orders_user_id_fkey, add constraint orders_user_id_fkey foreign key (user_id) references users(id) on delete cascade; alter table order_items drop constraint order_items_order_id_fkey, add constraint order_items_order_id_fkey foreign key (order_id) references orders(id) on delete cascade; delete from users where id = (select user_id from victim); select count(*) as 该用户剩余订单数 from orders where user_id = (select user_id from victim); -- 0:订单连带没了 rollback; -- 实验整体撤销,库不留痕 - 写下「生产用外键」的支持理由 3 条、反对理由 3 条
参考答案
支持:① 数据库兜底正确性,应用有 bug 也挡得住孤儿数据;② 自带文档作用,\d 就能看到表间关系;③ 优化器可借外键信息做 join 消除等优化。反对:① 写子表多一次引用检查,高频写场景有感;② 引入额外的锁竞争;③ 分库分表后外键无法跨库,形同虚设。结论模板:单库中小规模我默认用;到分库分表或极高写入量时,改为应用层保证 + 定期一致性校验脚本。