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 就能插进去。

这一点很重要,它解释了两个现象:

顺带一个语义上的澄清:唯一约束判断的是值相等,而 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,误差在百毫秒量级。真实系统的并发窗口是微秒级的,但行为形状是一样的:约束把「检查-使用」之间的窗口消掉了,代价是冲突时会出现锁等待和重试。

替换到生产环境时要改三件事:

  1. 加约束的流程。直接 ALTER TABLE ... ADD CONSTRAINT 会持长锁。标准做法是:先 ADD CONSTRAINT ... NOT VALID(短锁),再在低峰期 VALIDATE CONSTRAINT(长扫描但不阻塞写),最后确认索引构建方式(CREATE INDEX CONCURRENTLY 不阻塞写,但会留下无效索引,失败后要手工清理)。
  2. 冲突重试策略。ON CONFLICT 把冲突变成正常路径后,「更新分支」也可能因为并发而失败(更新时再次冲突)。外层要有重试,且重试次数要有限——无限重试会把一次冲突放大成一次雪崩。
  3. 约束的监控。pg_constraint 里 convalidated = false 的约束是「只对新数据生效」的状态,这是运维要盯的遗留项。

上线后该盯的指标:pg_stat_database 的 conflict 计数、pg_stat_activity 里 wait_event = transactionid 的会话数(第 04 章会细看这个等待事件)、以及应用侧的错误率里 23505(唯一冲突)与 23503(外键冲突)的占比。最后一个指标特别有用:如果 23505 的比例持续偏高,说明应用层的判重和数据库的约束在打架,该回去看那段代码了。

动手

  1. 复现本课的四个报错:重复邮箱的 23505、负数的 CHECK 违反、删除父行的 23503、以及部分唯一索引的 23505。判断标准:你能不看文档说出每个报错里的 DETAIL 行分别对应哪张表、哪个约束。
  2. 把 t_user 的 DO NOTHING 改成 DO UPDATE,并加上 (xmax = 0) AS inserted。先插一条新邮箱、再插一次同邮箱,判断标准:两次返回的 inserted 分别是 t 和 f。
  3. 给 t_amount 加一条 CHECK (amount IS NULL OR amount <= 1000000),然后插入 NULL 和 -1 各一次。判断标准:NULL 能通过、-1 被 amount >= 0 那条拦住,你能说清两条 CHECK 各自管什么。
  4. 用一个 BEGIN 打开事务、插入一行但不提交,然后开第二个 psql 插同一行。判断标准:第二个会话卡住而不是立刻报错;回滚第一个事务后,第二个能插入成功。

自测

  1. 唯一约束在并发下会让第二个事务等待。请解释它在等什么,以及为什么「等待」而不是「立即报错」是更合理的实现。
  2. UNIQUE (email) 的列里为什么可以有多条 NULL?如果业务要求「邮箱不能为空且唯一」,该怎么写?
  3. INSERT ... ON CONFLICT DO NOTHING RETURNING id 在冲突时返回什么?为什么这个组合在生产上容易埋雷?
  4. 部分唯一索引 WHERE deleted_at IS NULL 为什么能实现「删除后编号可复用」?同一个索引对「查询已删除的行」有没有帮助?
  5. CHECK (amount >= 0) 拦不住 amount = NULL。结合第 00 章的 NOT IN 陷阱,说说这两件事的共同根源是什么。

↓ 下一步:02 章 · JSONB 的边界

进入 keel 阅读