KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
01 · 约束是数据库兜底 — keel 龙骨
## 现场:同一个邮箱注册了两次
现场:同一个邮箱注册了两次
注册接口的代码大概是这样:
if not repo.exists_by_email(email):
repo.insert_user(email)
上线三个月没出过问题。直到某次活动期间,同一个邮箱出现了两条记录,两条的时间戳相差 40 毫秒。
两个请求几乎同时到达,都在对方提交之前跑完了 exists_by_email,都拿到 False,都执行了插入。这不是并发 bug,这是检查与执行之间存在时间窗口——只要检查和使用不是原子的,中间就一定有一个窗口。
先猜一下:如果这时数据库上有一句 UNIQUE (email),第二个请求会看到什么?
直觉模型:最后一道闸
应用层的判重是「进门之前问一声」,数据库的约束是「门口装的那道闸」。
问一声有用,能挡掉绝大多数重复,还能给出友好的错误提示。但它挡不住并发——因为它是两个动作(问、进),中间有窗口。闸不一样,它是物理的:不管多少个人同时冲过来,只有第一个能过。
所以约束的定位不是「比应用层判重更好的判重」,而是在任何并发交错下都成立的最后一道保证。应用层的判重负责体验(少一次报错、给一句人话提示),数据库的约束负责正确性。两者不是二选一,是分工。
唯一约束在并发下做了什么
先看单会话下的行为。建表并插一条,再插一条相同的邮箱:
CREATE TABLE t_user(id bigserial PRIMARY KEY, email text NOT NULL UNIQUE, nickname text);
INSERT INTO t_user(email, nickname) VALUES ('a@x.com', 'alice');
INSERT INTO t_user(email, nickname) VALUES ('a@x.com', 'alice2');
ERROR: duplicate key value violates unique constraint "t_user_email_key"
DETAIL: Key (email)=(a@x.com) already exists.
t_user_email_key 这个名字是 PG 自动生成的,格式是 表名_列名_key。你可以查约束的真实定义:
SELECT conname, contype, pg_get_constraintdef(oid) AS def
FROM pg_constraint WHERE conrelid = 't_user'::regclass;
conname | contype | def
------------------+---------+------------------
t_user_pkey | p | PRIMARY KEY (id)
t_user_email_key | u | UNIQUE (email)
contype 的 u 表示唯一约束。这里有个容易被忽略的事实:PG 的唯一约束是靠一个唯一索引实现的。建约束时自动建了同名索引,删约束时索引跟着走。所以「加唯一约束」和「建唯一索引」在性能上没有区别,区别只在语义上——约束可以被外键引用,索引不能。
现在换成并发。两个会话同时插入同一个邮箱,A 先 BEGIN 插入但停住不提交,B 在 1 秒后也来插:
-- 会话 B 此刻尝试插入同一 email,会阻塞到 A 提交,然后失败:
ERROR: duplicate key value violates unique constraint "t_race_email_key"
DETAIL: Key (email)=(race@x.com) already exists.
-- 冲突发生后表里只剩一行:
rows_in_table
---------------
1
注意 B 的行为:它不是立刻报错,而是等——等 A 那个未提交的事务给出结果。A 提交了,B 才报冲突;如果 A 回滚了,B 就能插进去。
这一点很重要,它解释了两个现象:
- 高并发下,重复键检查会让事务排队。如果 A 的事务里除了插入还干了别的慢活,B 会一直等,等的是整个事务的结束,不是那条插入的结束。压测时看到「莫名其妙的慢」,这是原因之一。
- 报错的时机取决于对方事务的生命周期,所以「为什么这个错在日志里的时间和请求时间对不上」也有了答案。
顺带一个语义上的澄清:唯一约束判断的是值相等,而 NULL 在 SQL 里不等于任何东西,包括它自己。所以 UNIQUE (email) 的列里可以存在任意多条 email IS NULL 的记录。如果你需要「不允许为空且唯一」,必须同时写 NOT NULL。PG 15 起提供了 UNIQUE NULLS NOT DISTINCT 来改变这个默认行为,但绝大多数场景下,加 NOT NULL 才是你要的。
ON CONFLICT:把冲突变成一条正常路径
光是报错没什么用,业务要的是「重复了就按重复的处理」。这就是 ON CONFLICT。
它有两条分支,行为差别很大。先看 DO NOTHING:
INSERT INTO t_user(email, nickname) VALUES ('a@x.com', 'dup')
ON CONFLICT (email) DO NOTHING RETURNING id, email;
id | email
----+-------
(0 rows)
返回 0 行。这一条的坑在于:很多代码用 RETURNING id 拿新记录的 id,写成了 DO NOTHING。冲突时没有行返回,id 是空——如果这段代码后面拿 id 去建关联记录,就会得到一条 NULL 外键,或者直接崩在空指针上。
正确的做法是用 DO UPDATE 把冲突变成一次更新:
INSERT INTO t_user(email, nickname) VALUES ('a@x.com', 'alice-updated')
ON CONFLICT (email) DO UPDATE SET nickname = EXCLUDED.nickname
RETURNING id, email, nickname, (xmax = 0) AS inserted;
id | email | nickname | inserted
----+---------+---------------+----------
1 | a@x.com | alice-updated | f
(1 row)
这次有行了。EXCLUDED 是 PG 给「本次想插入但因冲突没能插入的那一行」起的名,你可以把它理解成「被拒的那份数据」。
最后那个 (xmax = 0) AS inserted 是一个常用技巧:在 INSERT ... ON CONFLICT DO UPDATE 的 RETURNING 里,如果是新插入的行,它的事务 id xmax 是 0;如果是走到了更新分支,xmax 非 0。这样一条语句就能同时告诉你「插了」还是「更新了」,不用再查一次。它是实现细节而不是标准 SQL,但足够稳定,被广泛使用。
RETURNING 能拿回什么
RETURNING 只能看到这一行最终的样子。有一点容易被误以为:RETURNING 拿到的是插入/更新之后的值,不是之前的值。如果你想在同一个语句里知道旧值,PG 不提供——需要先查一次,或者用触发器。这在做审计日志时会遇到,通常的做法是另开一张变更表 + 触发器,而不是指望 RETURNING。
部分唯一索引:让「软删除」后编号可复用
业务里常见一种需求:订单号唯一,但订单被删掉(通常是标记删除)之后,同一个编号要能再用。
普通的 UNIQUE (code) 做不到,因为被删的那条还占着编号。解法是部分唯一索引——只对满足条件的行做唯一约束:
CREATE TABLE t_order(id bigserial PRIMARY KEY, code text NOT NULL, deleted_at timestamptz);
CREATE UNIQUE INDEX uq_order_code_alive ON t_order(code) WHERE deleted_at IS NULL;
INSERT INTO t_order(code) VALUES ('SO-1');
INSERT INTO t_order(code) VALUES ('SO-1'); -- 报错
UPDATE t_order SET deleted_at = now() WHERE code = 'SO-1';
INSERT INTO t_order(code) VALUES ('SO-1'); -- 通过
ERROR: duplicate key value violates unique constraint "uq_order_code_alive"
DETAIL: Key (code)=(SO-1) already exists.
id | code
----+------
3 | SO-1
(1 row)
WHERE deleted_at IS NULL 这个条件让索引只包含「活着」的行。被删的行不在索引里,自然不参与唯一性判断。
有一处代价要知道:这个索引只对完全匹配 WHERE 条件的查询有效。如果你的查询是 WHERE code = 'SO-1' AND deleted_at IS NOT NULL(专门找已删除的),这个索引帮不上忙。部分索引是一个「用索引换约束」的工具,不是通用的查询优化手段。
顺带说,这个模式也有个反面:如果业务上删除是高频操作,索引里活着的行会越来越少,而你自己得记得每一条查询都带上 deleted_at IS NULL。忘了带一次,就会查出已删除的数据。ORM 的软删除插件做的事就是自动帮你补这个条件——好用,但它也意味着任何绕过 ORM 的手写 SQL 都会漏掉这个条件。
CHECK:把范围约束写在表上
唯一约束管「不重复」,NOT NULL 管「不能空」,还有一类更常见的业务约束是「范围」:
CREATE TABLE t_amount(id int, amount numeric(12,2) CHECK (amount >= 0));
INSERT INTO t_amount VALUES (2, -1.00);
ERROR: new row for relation "t_amount" violates check constraint "t_amount_amount_check"
DETAIL: Failing row contains (2, -1.00).
报错里直接给出了失败行的内容,排查时不用再去捞数据。
CHECK 的语义是「这个表达式必须为真或未知」。注意「或未知」——如果 amount 是 NULL,NULL >= 0 的结果是 NULL,这个 CHECK 不会拦下来。所以 CHECK (amount >= 0) 表达的是「非空时不能为负」,如果你想表达「必须有值且非负」,得写成 CHECK (amount IS NOT NULL AND amount >= 0),或者干脆把列设成 NOT NULL。
这个细节在迁移时会咬人:给已有表加 CHECK 时,用 ALTER TABLE ... ADD CONSTRAINT ... CHECK (...) NOT VALID 可以只对新数据生效,之后再选一个低峰期用 VALIDATE CONSTRAINT 完成对存量数据的校验。这是大表加约束的标准流程,NOT VALID 那一步只需要一个短锁。
外键:语义价值与代价
外键保证「子表引用的父行一定存在」:
CREATE TABLE t_parent(id int PRIMARY KEY);
CREATE TABLE t_child(id int PRIMARY KEY, pid int REFERENCES t_parent(id));
INSERT INTO t_parent VALUES (1);
INSERT INTO t_child VALUES (10, 1);
DELETE FROM t_parent WHERE id = 1;
ERROR: update or delete on table "t_parent" violates foreign key constraint "t_child_pid_fkey" on table "t_child"
DETAIL: Key (id)=(1) is still referenced from table "t_child".
报错把三样东西都告诉你了:子表名、约束名、以及那个还被引用的键值。这类错误的信息量比应用层「删除失败,请检查关联数据」大得多。
代价有两个。父表侧的删除和主键更新会需要检查子表,在被引用的数据量大时这是一次索引查找的开销,同时会在相关行上持锁;子表侧的插入和更新需要确认父行存在,同样要走一次索引。这两处开销在写密集的表上不可忽略,也是很多团队在大表上「只保留逻辑外键、不建约束」的原因。
本机实验台没有取到外键锁等待的现场取证,所以这一段的机制描述依据的是 PostgreSQL 官方文档 DDL-Constraints 章节,而不是本次的实测输出。要实测需要一个足够长的持有事务和精确的时序控制,本次没能稳定复现。
那到底该不该用外键?判断依据是这张表的数据有多少条入口。如果一张表的写入路径只有一处(一个服务、一个 DAO),应用层的校验是可控的,外键的收益主要是「防止将来出现第二条写入路径时出错」。如果有多处入口(批量导入、别的服务直连、运维手工修数据),外键的收益就明显更大——因为跨团队的口头约定迟早会失效。
约束决策表
| 需求 | 用什么 | 注意 |
|---|---|---|
| 某个值不能为空 | NOT NULL |
NULL 不参与任何比较,CHECK 也拦不住它 |
| 某个值唯一 | UNIQUE 约束 |
靠唯一索引实现;NULL 可以重复出现 |
| 只有一部分行需要唯一 | 部分唯一索引 | 需 WHERE 条件与索引定义完全匹配才走索引 |
| 值的范围 | CHECK |
用 NOT VALID + VALIDATE 给大表加分步流程 |
| 引用别的表 | 外键 | 写路径多就用;写密集大表要算开销 |
| 并发下的插入或更新 | ON CONFLICT |
DO NOTHING 不返回行,别用它拿 id |
生产边界
本课实验用的是本地 PostgreSQL 16.15,时序控制靠 pg_sleep,误差在百毫秒量级。真实系统的并发窗口是微秒级的,但行为形状是一样的:约束把「检查-使用」之间的窗口消掉了,代价是冲突时会出现锁等待和重试。
替换到生产环境时要改三件事:
- 加约束的流程。直接
ALTER TABLE ... ADD CONSTRAINT会持长锁。标准做法是:先ADD CONSTRAINT ... NOT VALID(短锁),再在低峰期VALIDATE CONSTRAINT(长扫描但不阻塞写),最后确认索引构建方式(CREATE INDEX CONCURRENTLY不阻塞写,但会留下无效索引,失败后要手工清理)。 - 冲突重试策略。
ON CONFLICT把冲突变成正常路径后,「更新分支」也可能因为并发而失败(更新时再次冲突)。外层要有重试,且重试次数要有限——无限重试会把一次冲突放大成一次雪崩。 - 约束的监控。
pg_constraint里convalidated = false的约束是「只对新数据生效」的状态,这是运维要盯的遗留项。
上线后该盯的指标:pg_stat_database 的 conflict 计数、pg_stat_activity 里 wait_event = transactionid 的会话数(第 04 章会细看这个等待事件)、以及应用侧的错误率里 23505(唯一冲突)与 23503(外键冲突)的占比。最后一个指标特别有用:如果 23505 的比例持续偏高,说明应用层的判重和数据库的约束在打架,该回去看那段代码了。
动手
- 复现本课的四个报错:重复邮箱的
23505、负数的CHECK违反、删除父行的23503、以及部分唯一索引的23505。判断标准:你能不看文档说出每个报错里的DETAIL行分别对应哪张表、哪个约束。 - 把
t_user的DO NOTHING改成DO UPDATE,并加上(xmax = 0) AS inserted。先插一条新邮箱、再插一次同邮箱,判断标准:两次返回的inserted分别是t和f。 - 给
t_amount加一条CHECK (amount IS NULL OR amount <= 1000000),然后插入NULL和-1各一次。判断标准:NULL能通过、-1被amount >= 0那条拦住,你能说清两条CHECK各自管什么。 - 用一个
BEGIN打开事务、插入一行但不提交,然后开第二个psql插同一行。判断标准:第二个会话卡住而不是立刻报错;回滚第一个事务后,第二个能插入成功。
自测
- 唯一约束在并发下会让第二个事务等待。请解释它在等什么,以及为什么「等待」而不是「立即报错」是更合理的实现。
UNIQUE (email)的列里为什么可以有多条NULL?如果业务要求「邮箱不能为空且唯一」,该怎么写?INSERT ... ON CONFLICT DO NOTHING RETURNING id在冲突时返回什么?为什么这个组合在生产上容易埋雷?- 部分唯一索引
WHERE deleted_at IS NULL为什么能实现「删除后编号可复用」?同一个索引对「查询已删除的行」有没有帮助? CHECK (amount >= 0)拦不住amount = NULL。结合第 00 章的NOT IN陷阱,说说这两件事的共同根源是什么。
↓ 下一步:02 章 · JSONB 的边界