2020 年我做一个电商后台,建数据库表。users 表存用户,orders 表存订单,订单里有一个 user_id 字段记着"这单是谁下的"。要不要给 user_id 加外键约束?这件事我压根没多想。第一版我做得很顺手:orders 表的 user_id 就声明成一个普通的 BIGINT 字段,不加任何外键。我那时候听来的说法很笃定:"外键拖性能、还碍事——改表麻烦、删数据麻烦,数据完整性在应用层保证就行,代码里插订单前查一下用户在不在,不就完了?"建完表,跑一跑——真不错:订单插得进、查得出,关联用户的页面也都正常。我心里很踏实。可等这个系统跑了一年多、数据攒到几百万行,一串问题冒了出来。第一种最先把我打懵:财务跑月度报表,发现一批订单的 user_id 指向的用户根本不存在——按 user_id 去 users 表里查,查无此人,可订单白纸黑字躺在那里。第二种最难缠:这事不是所有订单都有,几百万订单里偶尔冒出来一个,毫无规律,我盯着代码怎么看都看不出哪段逻辑会插出这种数据。第三种最头疼:我加了应用层校验——插订单前先 SELECT 一下用户在不在——可上线之后,线上还是源源不断冒出新的孤儿订单。第四种最莫名其妙:我想把这些孤儿数据清理掉,可它们的 user_id 指向的用户早就没了,我根本无从知道这些订单当初到底属于谁——这数据坏得无法修复。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"数据完整性在应用层保证就行,外键约束只是个可有可无的文档,加了还拖性能"。这句话把"完整性"理解成了"某段代码记得去检查"。可它根本不是这么回事。我脑子里,数据库就是个存数据的仓库,它老老实实存我给的东西;至于"这条订单的 user_id 是不是真有这么个用户",那是我应用代码该操心的事,我代码里写好 SELECT 检查,数据就干净了。可这个想法,从根上就低估了一件事:你的应用代码,从来不是这张表唯一的写入者。线上同时跑着好几个应用实例,它们并发地写;有数据迁移脚本在写;有人深夜连上数据库手动执行 SQL 在写;有别的微服务、别的定时任务在写。你在某一段代码里写的那个"先查再插",只约束了那一段代码——它管不住另一个实例、管不住迁移脚本、管不住那条手敲的 DELETE。更要命的是,哪怕只有你这一段代码,"先 SELECT 查用户、再 INSERT 订单"本身就是两步,这两步之间有一个时间窗,就在这个窗口里,另一个事务完全可以把那个用户删掉——你查的时候他还在,你插的时候他已经没了。数据完整性,从来不是"某处代码记得检查"能保证的;它必须是这张表的一条物理铁律:任何一条数据,只要它进了这张表,就在物理上不可能违反这个关系——无论它是从哪个实例、哪个脚本、哪个人手里进来的。能立下这条铁律的,只有数据库自己,而立这条铁律的工具,就是外键约束。真正把数据完整性做扎实,核心不是"在应用代码里到处写检查",而是承认完整性是一条必须由数据库强制执行的不变量(invariant):外键约束让数据库在每一次写入的那一刻,原子地检查"被引用的行真的存在",违反就直接拒绝——它不依赖任何一段应用代码记得做什么,它对所有写入者一视同仁。这篇文章就把数据库外键约束这个坑梳理一遍:为什么"应用层保证完整性"一定会漏、外键约束到底强制保证了什么、ON DELETE 的几种级联行为、外键列为什么必须有索引、外键和死锁的关系,以及什么时候才真的可以不用外键这些把数据完整性做扎实要避开的坑。
问题背景
这个坑普遍,是因为"完整性在应用层保证"这个说法,听起来非常合理——代码逻辑清清楚楚,插之前查一下,有什么问题?它错得隐蔽,是因为第一版在很长时间里都是对的:只要并发不高、只有一个写入方、没人手动改库,孤儿数据可能几个月才冒一条,你甚至注意不到。它不报错、不告警,坏数据安安静静地躺在表里,直到某天一个跨表 JOIN、一张报表、一次数据导出,才把它兜出来。而那时,被引用的父行往往早已删除,孤儿数据已经无法溯源、无法修复。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:订单的
user_id指向不存在的用户;孤儿数据零星出现、毫无规律;加了应用层校验仍冒新孤儿;孤儿数据无法溯源、无法修复。 - 错误认知一:以为应用代码是表的唯一写入方,写好检查就够了。真相是表有多个写入方——多实例、迁移脚本、手工 SQL、别的服务,你的检查只管得住自己那段。
- 错误认知二:以为"先查再插"是可靠的。真相是查和插是两步,中间的时间窗里被引用的行可能被删,这是一个 TOCTOU 竞态。
- 错误认知三:以为外键只是"文档",拖性能、可有可无。真相是外键是数据库层强制执行的不变量,它在写入的原子瞬间检查,对所有写入者生效。
- 真相:数据完整性必须是数据库强制的物理约束。外键约束保证"被引用的行一定存在",不依赖任何应用代码,这才是真正兜得住的完整性。
一、为什么"应用层保证完整性"一定会漏
先把第一版那两张表摆出来。问题不在它写了什么,而在它没写什么。
-- 第一版:建表只声明字段,不加任何外键约束(反面教材)
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
status TINYINT DEFAULT 1
);
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL, -- 逻辑上指向 users.id,但没有外键约束
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL
);
-- user_id 只是一个普通的 BIGINT 字段,数据库完全不知道它"应该"指向 users
关键就在最后那行注释:在数据库眼里,orders.user_id 和 orders.amount 没有任何区别,都只是数字。数据库不知道 user_id 这个数字"本应"对应一个真实用户。于是下面这种事,数据库一声不吭就放行了:
-- 场景:用户注销,业务把 users 里的记录删了
DELETE FROM users WHERE id = 1001;
-- 但 orders 表里 user_id = 1001 的订单一条没动,数据库也不会拦
-- 此刻这些订单的 user_id 指向一个已不存在的用户 —— 它们成了孤儿数据
SELECT id, user_id, amount FROM orders WHERE user_id = 1001;
-- 仍然查得到一堆订单,只是它们的"主人"已经没了
我后来当然想到了"应用层保证":插订单前先查用户在不在。代码看着天衣无缝:
# 应用层"保证完整性":插订单前先查用户在不在(看着没问题,其实有缝)
def create_order(db, user_id, amount):
user = db.query("SELECT id FROM users WHERE id = %s", user_id)
if not user:
raise ValueError("用户不存在") # 第 1 步:检查
# ----- 危险的时间窗:就在这两步之间,别的事务可能把这个用户删了 -----
db.execute(
"INSERT INTO orders (user_id, amount) VALUES (%s, %s)",
user_id, amount,
) # 第 2 步:插入
这段代码漏在两个地方。一个是时间窗:"查"和"插"是两步,中间隔着网络、隔着别的事务,就在这一瞬,另一个事务完全可能把 user_id 对应的用户删掉——你查到的时候他在,你插进去的时候他没了。这是一个经典的 TOCTOU(检查时与使用时不一致)竞态。另一个、也是更致命的——这段代码只约束了走这段代码的写入。可线上还有别的实例在跑、有数据迁移脚本在 INSERT、有人深夜连上库手敲 SQL、有别的微服务直接写这张表。它们一个都不会执行你这个 create_order 函数。你的检查,对它们形同虚设。
这里要建立的第一个、也是最重要的认知是:"应用层保证完整性"这句话里,藏着一个几乎从不成立的隐含前提——它假设"应用层"是一个单一的、统一的、能管住所有写入的关口。可现实里,一张数据库表从来没有这样一个唯一关口。它的写入方是一群:线上有多个应用实例在并发地写,有上线时跑的数据迁移脚本在写,有运维和开发偶尔连上去手动执行的 SQL 在写,有其他团队的微服务在写,有定时任务、数据修复脚本在写。你在 create_order 里写的那个"先查再插",只是这群写入方里的一个,它再严密,也只管得住自己。完整性约束的本质要求是"对这张表的每一条写入都成立",而应用层的检查天然只能做到"对走了我这段代码的写入成立"——这两者之间的差距,就是孤儿数据钻进来的全部缝隙。这就解释了第二、第三个怪现象:孤儿数据零星而无规律,是因为它们来自那些你监控不到的写入路径;加了应用层校验还冒新孤儿,是因为新孤儿压根不是你那段代码插的。要堵住这个缝,你不能在"应用层"这个有无数个入口的地方设卡,你必须把卡设在所有写入的唯一必经之地——数据库本身。数据库不在乎写入来自哪个实例、哪个脚本、哪个人,它对每一条进来的数据都执行同一套规则。这,就是外键约束存在的全部理由。
二、外键约束到底强制保证了什么
外键约束做的事,一句话:它让数据库记住"orders.user_id 必须指向一个真实存在的 users.id",并在每一次写入时强制检查这条规则。建表时把它声明上去:
-- 正确:在 orders 上声明外键,把"user_id 必须指向真实用户"交给数据库强制
CREATE TABLE orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
created_at DATETIME NOT NULL,
-- 外键约束:user_id 的值,必须在 users.id 里真实存在
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users (id)
);
有了这条约束,数据库会在两个方向上替你把关。一个方向是插入/更新子表:你往 orders 插一条 user_id = 9999 的订单,如果 users 里没有 id = 9999,数据库直接拒绝这条 INSERT、报错。另一个方向是删除/更新父表:你想 DELETE 掉一个还有订单挂在名下的用户,数据库默认也会拒绝——它不允许你制造出孤儿数据。两个方向一堵,孤儿数据就在物理上没有了产生的可能。给一张已经有脏数据的旧表补外键,要分两步,因为存量孤儿数据会让 ADD CONSTRAINT 失败:
-- 给一张已有数据的表补外键:必须先处理掉存量的孤儿数据
-- 否则 ADD CONSTRAINT 会因为已存在违规数据而直接失败
-- 1) 先把孤儿订单挑出来(user_id 在 users 里找不到对应行)
SELECT o.id, o.user_id
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL;
-- 2) 妥善处理完这些孤儿行之后,再补上外键约束
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users (id);
这里要建立的认知是:外键约束和应用层检查,看起来都是"检查一下被引用的行在不在",本质却是两种完全不同的东西,差别在于"谁来保证"和"什么时候保证"。应用层检查是一段"代码逻辑",它只在那段代码被执行时生效,而且查和写是分离的两步;外键约束是一条"数据库声明",它是表结构的一部分,被数据库在每一次写入的那个原子瞬间强制执行,查和写在同一个动作里完成,中间没有任何时间窗。这个区别带来一个质变:有了外键,"orders 里不存在孤儿数据"就从一个"我希望成立、并努力用代码维护"的目标,变成了一个"在物理上必然成立"的事实——它不再依赖任何人记得做什么。你可以用任何方式往 orders 里写数据,用 ORM、用裸 SQL、用迁移脚本、用 DBA 手敲的命令,只要那条 user_id 不指向真实用户,数据库就会拒绝它,无一例外。这就是"不变量"这个词的分量:它不是一个被维护的状态,而是一个被保证的事实。一个系统的健壮,很大程度上就取决于你把多少关键规则,从"靠代码努力维护的状态"升级成了"靠机制强制保证的不变量"。外键,就是数据完整性这条规则的升级方式。
三、ON DELETE 的几种行为:删父行时子行怎么办
上一节说,默认情况下你删一个还有订单的用户,数据库会拒绝。但"拒绝"只是默认行为之一,外键允许你显式指定:当父表的一行被删除时,子表里那些引用它的行该怎么处理。这就是 ON DELETE 子句,常用的有三种:
-- ON DELETE 决定:父表(users)的一行被删除时,子表(orders)怎么办
-- RESTRICT(默认):只要还有订单指向这个用户,就禁止删除该用户
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT
-- CASCADE:删用户时,自动把他名下所有订单一起删掉(级联删除)
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
-- SET NULL:删用户时,把订单的 user_id 置为 NULL(该列必须允许 NULL)
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE SET NULL
删除父行时,数据库内部的决策是这样走的:
怎么选?要看业务语义。RESTRICT 最安全,它逼你"先处理子数据、再删父数据",适合订单这种绝不能随父数据消失的重要记录。CASCADE 很方便但必须慎用:删一个用户连他几千条订单一起没了,这往往不是你想要的——订单通常要保留做财务记录。CASCADE 适合那种"父没了子就彻底没意义"的从属数据,比如用户和他的"地址簿"。SET NULL 适合"父没了、子还想留着,只是不再归属任何人"的场景。真实项目里,删用户大多不是物理删除,而是把 status 改成"已注销"的软删除——这样订单的 user_id 永远指向一个仍然存在的行,外键和历史数据两全。
这里要建立的认知是:ON DELETE 这个子句,表面上是一个技术选项,实质上是在逼你回答一个一直被你含糊带过的业务问题——"当一个父实体消失时,那些依附于它的子实体,在业务上意味着什么"。在没有外键的第一版里,这个问题根本不会被问出来:你删用户就删用户,订单怎么样?不知道,也没人逼你想。于是订单就那么静静地变成了孤儿,业务语义上的一个大窟窿,被代码的沉默掩盖了。而外键的 ON DELETE,强迫你在建表的那一刻就把这个问题想清楚并明确表态:订单是 RESTRICT,意思是"用户要走可以,但他的订单是独立的财务记录,不能跟着消失,你得先妥善处理";地址簿是 CASCADE,意思是"地址离开了用户就毫无意义,用户没了它就该一起清掉"。你选哪个,就是在声明这段数据关系的业务性质。所以别把 ON DELETE 当成一个"填哪个都行、出问题再说"的配置项——它是你对数据关系的一次正式定义。把它想清楚、写明确,你的数据库 schema 就不只是一堆字段,而是一份精确记录了业务规则的、会被强制执行的契约。
四、外键列必须有索引,否则父表操作会很慢
外键有一个非常容易被忽略、却会实实在在拖垮性能的细节:子表里那个外键列(orders.user_id),必须有索引。想想数据库要做什么:你删除或更新 users 里的一行,数据库为了执行外键规则,必须回到 orders 表问一句"还有没有行的 user_id 等于它"。如果 orders.user_id 上没有索引,这个"问一句"就是一次全表扫描——几百万行的 orders 全扫一遍,而且扫描期间还会持有锁。
-- 关键:子表的外键列(orders.user_id)必须有索引
-- 父表 users 删除/更新一行时,数据库要回 orders 检查"还有行引用它吗"
-- orders.user_id 没索引,这个检查就是一次全表扫描,而且扫描时持锁
CREATE INDEX idx_orders_user_id ON orders (user_id);
-- 注:MySQL InnoDB 在建外键时,若该列上没有可用索引,会自动建一个
-- 但别依赖这个自动行为 —— 显式建索引、并确认它真的存在,才稳妥
这就解释了那个流传很广、却以讹传讹的说法——"外键拖性能"。外键本身的检查开销很小,真正拖慢的,是外键列没索引导致的全表扫描。MySQL 的 InnoDB 引擎其实会在你建外键时,如果发现该列没有可用索引,自动帮你补一个;但别依赖这个隐式行为——它在不同数据库、不同版本上表现不一,你应该显式地为每一个外键列建索引,并亲自确认它存在。把索引建好,"外键拖性能"这个顾虑,绝大多数情况下根本不成立。
这里要建立的认知是:很多被当成"某个特性天生就慢"的结论,真相往往是"这个特性被错误地使用了",而"外键拖性能"是其中最典型的一个。它的因果链是:外键的检查需要在子表上反查 → 反查需要外键列有索引才快 → 很多人建表时只顾着声明外键、忘了给外键列建索引 → 于是每次父表删改都触发子表全表扫描 → 慢 → 结论被归罪到"外键"头上。真正的元凶是那个缺失的索引,外键只是把这个缺失的后果放大了出来。这里有一个值得记住的通用经验:当你听到"X 很慢、别用 X"这种笼统的劝告时,别急着接受,先追问一句"X 在什么条件下慢、那个条件能不能消除"——很多时候,所谓的性能问题有一个明确的、廉价的解法,而那个笼统的劝告会让你白白放弃一个本该用的好工具。对外键来说,这个廉价解法就是一条 CREATE INDEX。记住这条铁律:声明了外键,就一定要确认外键列上有索引,这两件事永远成对出现。做到了这一点,你就可以放心地享受外键带来的完整性保证,而不必为那个被夸大的"性能"顾虑买单。
五、外键与锁、死锁:它确实会带来新的复杂度
话说回来,外键不是没有代价,把它说得完美无缺也不诚实。它真实的代价,藏在锁里。当你往子表 orders 插入一行时,数据库为了确保"被引用的那个用户在我插入的整个过程中不会被删掉",会对 users 里那一行加一个共享锁。这个锁通常很短、感知不到,但它确实存在,而一旦多个事务交叉操作父子表,就可能死锁:
-- 外键检查会加锁:子表插入时,会对它引用的父表那一行加共享锁
-- 两个事务以相反的顺序交叉操作父子表,就可能死锁
-- 事务 A:先改用户,再插订单
UPDATE users SET status = 2 WHERE id = 1001; -- 锁住 users 行 1001
INSERT INTO orders (user_id, amount) VALUES (1001, 50);
-- 事务 B:先插订单,再改用户(顺序与 A 相反)
INSERT INTO orders (user_id, amount) VALUES (1001, 80); -- 要对 users 1001 加共享锁
UPDATE users SET status = 3 WHERE id = 1001; -- 与 A 互相等待,造成死锁
-- 解法:统一所有事务访问父子表的顺序、缩短事务、并对死锁做重试
除了死锁,外键还带来另外两类工程上的麻烦,必须心里有数。一是大表加外键的 ALTER TABLE 会很重:它要校验全表已有数据是否都满足约束,大表上这可能是一个长时间持锁的操作,得放在低峰期、或借助在线 DDL 工具。二是外键让"表的删除顺序"有了硬性依赖:你想 DROP 或清空 users,必须先处理 orders,这在某些数据清理、测试环境重建的场景下会让人觉得"碍事"。这些代价是真实的,但请把它们和它换来的东西放在一起称——它换来的是"孤儿数据在物理上不可能存在"。
这里要建立的认知是:一个成熟的工程判断,从来不是"这个东西好不好",而是"这个东西的代价和收益,在我这个具体场景下怎么权衡"。外键不是免费的:它带来额外的锁、带来死锁的可能、让大表的结构变更变重、给表之间的操作顺序加上了硬约束。第一版那个"外键碍事"的判断,错的不是它看到了代价——这些代价真实存在——错的是它只看了代价这一边的秤盘,完全没有往另一边放上"它防住了什么"。而另一边该放的东西分量极重:孤儿数据,是一种"沉默地产生、长期地潜伏、最终无法修复"的数据腐烂,它不报错、不告警,等你发现时,被引用的父行往往已经删除,坏数据连溯源都做不到。把这两边放上秤:一边是"事务设计要更小心、要处理死锁重试、大表变更要挑时机",这些都是有明确解法、可以工程化应对的麻烦;另一边是"数据可能不可逆地腐烂"。对绝大多数业务系统来说,这杆秤会毫不犹豫地倒向"用外键"。真正的工程能力,不是记住"外键好"或"外键坏"这种结论,而是养成这种"两个秤盘都要看、都要称"的习惯——下一节我们就来看,在哪些场景下,这杆秤真的会倒向另一边。
六、什么时候才真的可以不用外键
外键不是任何场景都该用。确实存在它用不了、或不值得用的场景,但理由必须是真实的,而不是"听说它拖性能"。最主要的一个真实场景是分库分表:当数据量大到 users 和 orders 被拆分、落在了不同的物理数据库上,外键在技术上就无法建立了——外键约束只能在同一个数据库实例内部生效,它管不了跨库的引用关系。这也是为什么很多超大规模的互联网系统不用外键:不是因为外键不好,而是因为它们的数据早已不在一个库里。
但"不用外键"绝不等于"不要完整性"。在这种场景下,完整性的保证从"数据库自动做",变成了"你必须自己显式地、用别的手段补上"。最关键的补救手段是一个独立的对账任务——它定期扫描,做的事正是外键本该自动做的检查:
-- 分库分表后,users 和 orders 可能落在不同物理库,无法建外键
-- 替代方案:应用层保证 + 一个独立的对账任务定期扫描
-- 对账任务的核心:找出 user_id 已指向不存在用户的孤儿订单
-- (实际中 users 与 orders 跨库,这个比对要在应用层或数仓里做)
SELECT o.id, o.user_id
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL;
-- 发现孤儿数据就立即告警,并走人工或自动的修复流程
哪怕你因为种种原因暂时无法给一张同库的表加外键,也至少要把上面这种对账查询接进监控,让 orphan_count 一旦大于 0 就告警。它远不如外键——外键是事前阻止孤儿数据产生,对账只是事后发现已经产生的——但有总比没有强。最坏的情况,就是第一版那样:既没有外键事前拦,也没有对账事后查,孤儿数据在黑暗里默默累积,直到无可挽回。
这里要建立的认知是:决定"用不用外键",真正的分界线不是"外键好不好用",而是"数据库还能不能帮你执行这个约束"。在单库环境里,被引用的父表和子表在同一个数据库实例内,数据库有能力在每次写入时做这个检查,那你就该把完整性这件事交给它——这是它的本职,它做得又快又可靠。而一旦数据分库,父表和子表跨了物理库,数据库就丧失了执行这个检查的能力,这时不用外键不是一种"选择",而是一种"现实",你别无他法。但要分清这里发生的事:你失去的只是"外键这个实现方式",你没有、也不能失去"完整性这个需求"。需求还在,只是保证它的责任,从数据库被迫移交回了你的手上——你必须用应用层的严格约束、用独立的对账任务、用监控告警,把数据库原本免费提供的那个保证,一块一块地手工重建出来。所以一个清醒的判断链是这样的:默认就用外键,让数据库替你扛完整性;只有当分库分表这种硬约束让外键在技术上不可能时,才放弃它——而放弃的同时,必须立刻把对账和监控这套替代方案补上。绝不要因为一句没有根据的"外键拖性能"就主动放弃它,那等于白白丢掉了数据库免费送你的一道最坚固的防线。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 外键约束 | 声明子表某列必须指向父表真实存在的行 | 由数据库在每次写入时原子强制执行 |
| 孤儿数据 | 子表行的外键值指向已不存在的父行 | 沉默产生 长期潜伏 父行删除后无法溯源 |
| 应用层检查的缺口 | 先查再插是两步 且只约束自己那段代码 | 有时间窗竞态 管不住多实例与手工 SQL |
| 不变量 invariant | 在物理上必然成立 不依赖代码维护的事实 | 外键把完整性从被维护的状态升级为铁律 |
| ON DELETE RESTRICT | 父行有子行引用时禁止删除 是默认行为 | 最安全 适合订单等必须独立保留的数据 |
| ON DELETE CASCADE | 删父行时自动删除引用它的子行 | 慎用 适合父没了子就无意义的从属数据 |
| ON DELETE SET NULL | 删父行时把子行外键列置为 NULL | 子行保留但不再归属 该列须允许 NULL |
| 外键列索引 | 子表外键列必须有索引 | 没索引会让父表删改触发子表全表扫描 |
| 外键与死锁 | 子表写入会对被引用的父行加共享锁 | 交叉操作父子表可能死锁 须统一访问顺序 |
| 对账任务 | 用 LEFT JOIN 定期扫描孤儿数据 | 分库分表无法建外键时的事后补救手段 |
避坑清单
- 不要以为"应用层保证完整性"就够了。表有多个写入方,你的检查只管得住自己那段代码。
- 不要依赖"先查再插"。查和插之间有时间窗,被引用的行可能在窗口里被删,这是 TOCTOU 竞态。
- 默认就给关联列加外键约束,把完整性交给数据库在写入时原子强制执行。
- 给已有数据的表补外键前,先用 LEFT JOIN 清掉存量孤儿数据,否则 ADD CONSTRAINT 会失败。
- 建表时就想清楚 ON DELETE 选哪种。订单类用 RESTRICT,从属数据才用 CASCADE,它是对业务关系的正式定义。
- 每一个外键列都必须显式建索引。否则父表删改会触发子表全表扫描,这才是"外键拖性能"的真凶。
- 统一各事务访问父子表的顺序,缩短事务,并对死锁做重试,外键的锁开销才不会变成死锁。
- 大表加外键的 ALTER TABLE 要挑低峰期或用在线 DDL 工具,它会校验全表、长时间持锁。
- 删用户优先用软删除(改状态而非物理删除),订单的外键永远指向存在的行,历史数据也保住了。
- 分库分表确实无法建外键时,必须补对账任务和监控,让孤儿数据数大于 0 就告警,绝不能两手空空。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为数据完整性是"应用代码记得检查"就能保证的事,外键约束只是个拖性能的可有可无的文档。可完整性根本不是"某处代码记得做什么"——你的应用代码从来不是表的唯一写入方,多实例、迁移脚本、手工 SQL、别的服务都在写,你的检查管不住它们;哪怕只有你这段代码,"先查再插"这两步之间也有一个能被别的事务钻空子的时间窗。问题从来不在"我哪段检查写漏了",而在我把完整性建在了一个有无数入口、根本守不住的"应用层"上。
真正把数据完整性做扎实,工作量不在"在代码里到处补检查",而在一次观念的转变:承认完整性必须是一条由数据库强制的物理铁律,而不是一个靠代码努力维护的状态。一旦接受这一点,该做的事就都浮现出来了——给关联列声明外键、想清楚 ON DELETE 的业务语义、给每个外键列建索引、统一事务顺序避免死锁、分库时用对账任务补位。每一步都不复杂,难的是先承认:你管不住所有写入这张表的人和程序,但数据库可以;你能做的最负责任的事,就是把这条规则交给数据库,让它对所有人一视同仁地执行。
我后来常拿图书馆借书登记来想这件事。每一条借书记录上,都写着一个书号,表示"借出去的是这本书"。第一版的做法,等于借书登记本上的书号可以随便填——管理员手一抖填错一个、或者填了一个早已报废下架的旧书号,登记本照单全收,没人拦。等那本书真的从馆藏目录里被清掉,这条借书记录就永远指着一个"查无此书"的号码,你再也说不清它当初到底借的是哪本。外键约束,就是图书馆系统立的一条铁规矩:借书登记里的每一个书号,在写下去的那一刻,系统就去馆藏目录里核对,核不上就当场拒绝;而且只要还有未还的借阅记录指着某本书,这本书就不许从馆藏目录里删除。这条规矩不靠任何一个管理员的细心,它对每一次登记都生效——这才是"登记本里不会有假书号"这件事的真正保证。
这类问题最咬人的地方,在于它在系统年轻、数据量小、写入方单一的时候,几乎永远是"对"的:并发不高、没人手动改库,孤儿数据可能几个月都不冒一条,你甚至意识不到隐患的存在。它只在系统长大——多实例部署了、迁移脚本跑过了、有人深夜连库改过数据了——之后,才开始悄悄累积坏数据,而且不报错、不告警,等你某天跑报表发现时,被引用的父行早已删除,孤儿数据连修复的可能都没有了。所以别等数据腐烂到无法挽回才想起完整性:建第一张关联表的那一刻,就该把外键约束声明上去——它不该是一个"以后有空再加"的优化项,而该是和字段类型同等重要的、表结构的一部分。把这条铁律在写第一行 CREATE TABLE 时就立好,你才算真正跳出了那个人人都听过"外键拖性能"、却让人人在某天对着一堆无主孤儿数据发呆的坑。
—— 别看了 · 2026