
1. 外键的本质把一条业务规则焊进存储层夜里两点被电话叫醒说订单表里冒出来一批幽灵订单——下单用户的 ID 在用户表里根本查不到。这事儿在 MySQL 外键约束FOREIGN KEY缺席的系统里几乎是必然会发生的只要有第二个写入入口只要有一段异步补偿逻辑写错分支只要有人手工连上生产库跑了一条 DELETE参照完整性就会在某个没人注意的角落漏掉。外键约束干的事情非常朴素——它让数据库自己盯着子表里的这个值必须在父表里真实存在任何试图破坏这条规则的操作不管是应用代码、存储过程还是人肉 SQL统统被存储引擎拦下来。这篇内容适合三类人看刚学完建表语法、还没在生产环境被外键坑过的同学正在纠结到底要不要加外键、需要一套判断依据的后端开发以及接手了遗留库、需要给已有表补约束或者拆约束的维护者。我会把外键的检查机制、加锁行为、六种引用动作的实际表现、常见报错码的定位路径还有分库分表、逻辑删除这些真实场景下的冲突处理一条条拆开讲。1.1 从一条孤儿数据说起先建立一个最基础的模型一张t_user存用户一张t_order存订单订单里有个user_id指向用户。所谓参照完整性就是保证t_order.user_id里出现的每一个值都能在t_user.id里找到对应的那一行。听起来像废话但没有数据库层面的强制约束时这条废话失守的概率高得惊人。我见过最典型的三类失守场景。第一类是多写入入口主流程走应用服务但运营后台有一套独立的报表导入脚本开发者图省事直接INSERT字段名对上了逻辑校验漏了。第二类是异步与补偿用户注销删除主表记录消息队列里的下游消费者还没来得及处理中间有几秒到几分钟的窗口子表数据就成了孤儿。第三类是手工运维排查问题时连上生产库执行DELETE FROM t_user WHERE id 12345忘了还有关联数据。这三类问题的共同点是——应用层的校验是尽力而为的而数据库层的约束是一定发生的。你在 Service 层写一百行if (user null) throw只要有一条路径绕过了这段代码约束就失效而外键约束写在数据字典里任何 DML 语句执行前都要过这一关绕不过去。这就是外键最核心的价值它不是性能优化不是架构设计它是一条不会被代码分支绕过的数据底线。但这里有个认知需要纠正外键保证的是引用的行存在不是业务上合理。用户被标记为已注销、订单还在外键管不着订单金额为负数外键也管不着。它只解决存在性别指望它承担业务校验的职责否则你会写出一堆奇怪的约束然后被迫拆掉。1.2 数据库内部到底怎么检查这条约束理解检查机制才能理解后面的性能损耗和锁行为。外键的元数据在 MySQL 数据字典里有完整登记InnoDB 在建表时就把它编译成内部的约束对象。之后每一次涉及外键列的写入都要走一遍检查流程而这个流程比大多数人想象的查一下存不存在要重得多。往子表插入或更新外键列时InnoDB 需要确认父表里存在对应记录于是它在父表对应记录上加共享锁S 锁然后读取判断。注意这个共享锁是加在父表那一行上的不是加在子表上。这意味着什么意味着并发插入同一个用户的订单时多个事务会在父表同一行上竞争同一把 S 锁。S 锁之间本身兼容但一旦有别的事务要对这一行做UPDATE比如更新用户积分、登录时间X 锁和 S 锁就互斥了等待链一形成死锁的概率立刻上来。删除或更新父表记录时检查方向反过来InnoDB 要去子表里确认有没有引用这时候扫描的是子表外键列上的索引。如果外键列没有索引这个扫描就是全表扫代价随子表体积线性膨胀。好消息是 InnoDB 在建外键时会自动帮你补一个索引坏消息是这个自动补的索引名字很随意后期做索引优化时容易漏掉它或者误删它。还有一个容易被忽略的细节级联删除ON DELETE CASCADE执行时不会激活触发器。如果你在子表上挂了AFTER DELETE触发器做审计日志靠级联删掉的数据是不会留下日志的。这个坑在合规审计场景里特别致命我见过有团队据此被要求补全量日志。1.3 为什么很多团队最后把外键拆掉了说外键好不等于所有场景都该用。这几年不少团队在生产库上把外键统一清掉了原因基本集中在几个现实约束上值得逐条说清楚因为你未来要不要加外键答案就藏在这些约束里。分库分表的硬性限制。外键只能引用同一个 MySQL 实例内的表写db_name.table_name可以跨库但跨实例完全不行。一旦订单表按用户 ID 拆到 16 个分片用户表还在另一个实例外键就彻底失效了你只能在应用层或者中间件层做校验。这是架构演进带来的必然结果不是外键本身的错。逻辑删除的语义冲突。业务上删除用户通常是UPDATE t_user SET deleted 1物理行还在外键当然不报错。但如果你想做级联删除CASCADE 只认物理 DELETE对逻辑删除毫无反应。结果就是主表标记删除了子表数据依然活着外键在这套模型里什么都没保障到。批量导入和归档的性能损耗。往 500 万行的子表灌 100 万行历史数据每插一行都要去父表加锁校验一次这个开销非常可观。数据迁移、跨环境同步、报表库初始化几乎都要临时关掉检查。运维与变更成本。给存量大表加外键是一次全表校验锁和 IO 都不小调整表结构、改主键、换字符集时外键会让操作顺序变得复杂动不动就报被其他表引用。所以结论不是外键不好而是外键适合单库单实例、物理删除、写入路径集中的场景一旦踩到分片、逻辑删除、超高并发批量写它就会从帮手变成负担。2. 建表前必须对齐的三件事外键报错里有很大一部分根本不是逻辑问题而是建表时就埋下的类型和字符集不一致。这类问题最烦人的地方在于本地测试环境建表成功测试库建表成功到了预发环境执行同一个脚本却报errno 3780——因为两边的库默认字符集不一样。2.1 引擎与列类型必须严丝合缝第一条铁律父子表都必须是 InnoDB。MyISAM 引擎会接受外键语法但不做任何检查你写完CONSTRAINT ... FOREIGN KEY建表成功以为约束生效了实际上它只是被解析后丢弃了。如果你接手的是老库SHOW CREATE TABLE里看到ENGINEMyISAM却又有外键定义基本可以判定这个外键从来没起过作用。第二条整数类型的大小和符号必须完全一致。父表是INT UNSIGNED子表写INTMySQL 8.0 会直接拒绝报错信息大意是被引用的列与引用列不兼容。BIGINT和INT也不兼容。这个规则比很多人想的严格——MySQL 不做隐式转换不给我觉得差不多留余地。第三条字符串类型的长度可以不同但字符集和排序规则必须一致。VARCHAR(32)引用VARCHAR(64)是允许的这个灵活性挺有用但utf8mb4_0900_ai_ci引用utf8mb4_general_ci会失败。MySQL 8.0 把默认排序规则从utf8mb4_general_ci换成了utf8mb4_0900_ai_ci从 5.7 迁移过来的库最容易在这里翻车新库建的表默认 0900旧表是 general两边一关联就报错。建表时我习惯显式写全字符集和排序规则哪怕跟库默认值一样CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_name VARCHAR(64) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;显式声明的成本是几个字符收益是三个月后别人接手时不用猜。2.2 索引不是可选项而是前置条件父表被引用的那一列必须有索引。主键当然可以唯一索引可以普通索引理论上也行——但用普通索引做被引用列在语义上很怪因为父表可能存在两行同 ID 的记录引用关系就不唯一了。实际规范里被引用列要么是主键要么是唯一索引。子表的外键列如果建表时没有索引InnoDB 会自动创建一个。这看起来贴心其实是个隐形陷阱-- 你写的 CREATE TABLE t_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, PRIMARY KEY (id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user (id) ) ENGINEInnoDB; -- 实际产生的索引名字由系统生成 -- KEY fk_order_user (user_id)问题在于如果你的业务查询是WHERE user_id ? AND status ?真正需要的是联合索引(user_id, status)。系统自动建的那个单列索引会让优化器有得选但选不好而你在SHOW INDEX时如果只关注自己命名的索引很容易漏看这一条。我的做法是先手动建好复合索引再声明外键这样 InnoDB 检测到已有可用索引就不会再插一脚。还有一个反向的坑删除外键约束不会自动删除那个索引。时间久了表上会堆一堆没人认识的索引全是当年建外键时留下的白白占用写入性能。清理外键的时候要顺手检查一下。2.3 自引用与跨库引用这两种特殊结构树形结构的表部门、分类、评论回复会用到自引用外键就是parent_id指向本表的idCREATE TABLE t_category ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, parent_id BIGINT UNSIGNED DEFAULT NULL, name VARCHAR(64) NOT NULL, PRIMARY KEY (id), KEY idx_parent (parent_id), CONSTRAINT fk_category_parent FOREIGN KEY (parent_id) REFERENCES t_category (id) ON DELETE RESTRICT ) ENGINEInnoDB;这里parent_id允许为 NULL 是刻意的——根节点的父为空。要记住一个规则外键列值为 NULL 时约束检查直接跳过。这是很多人做可选关联时的取巧手段但如果你的业务语义是必须有关联那列定义就千万别写DEFAULT NULL否则等于给自己留了个后门。跨库引用在同一个实例内是支持的写全库名.表名即可但我不建议这么干一是备份恢复顺序会变得极其麻烦二是后来做实例拆分时这两个库大概率要分家外键就成了拦路虎。真要跨库关联宁可冗余字段。3. 一套订单模型的完整实操理论讲完直接上手。这部分我会从零建一套带外键的订单模型把六种引用动作逐个跑一遍然后演示怎么查、怎么改、怎么删。建议你在本地或者测试库跟着敲一遍外键的行为光看文档是记不住的。3.1 建表语句约束命名和字段顺序的讲究先建父表CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_name VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;再建子表把外键和索引分开写清楚CREATE TABLE t_order ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, order_no VARCHAR(32) NOT NULL, amount DECIMAL(12,2) NOT NULL DEFAULT 0.00, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user (id) ON DELETE RESTRICT ON UPDATE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;几个刻意的选择值得说明。约束名用fk_子表_父表_语义的格式。系统自动生成的名字形如t_order_ibfk_1一旦表多了完全看不出关联关系。手工命名后报错信息里直接能看到fk_order_user排查时省掉一次查字典的时间。先建idx_user_status复合索引再声明外键前面提过这样能避免系统多建一个单列索引。而且这个复合索引正好覆盖查某用户的有效订单这个最高频查询。ON DELETE RESTRICT而不是 CASCADE。订单是财务数据用户注销绝对不能把订单物理删掉这是合规红线。RESTRICT 让删除操作直接失败逼着业务走先处理关联数据的流程——这个失败恰恰是外键在帮你兜底。ON UPDATE CASCADE保留了。主键是自增 BIGINT理论上永远不会变这个 CASCADE 实际上是保险丝。如果哪天有人做数据订正改了主键至少子表能跟着走不至于产生孤儿数据。不过也有观点认为主键永不更新写RESTRICT更能暴露异常操作这两种取舍都成立看团队风格。3.2 六种引用动作逐个验证MySQL 支持的引用动作有 RESTRICT、NO ACTION、CASCADE、SET NULLInnoDB 不支持 SET DEFAULT。它们在父表删除/更新时的表现差异很大光看文字描述容易混淆跑一遍就清楚了。动作父表 DELETE 时的行为父表 UPDATE 主键时的行为适用场景RESTRICT存在子记录则报错拒绝删除存在子记录则报错财务、订单等不可丢的数据NO ACTION同 RESTRICT但检查时机略有差异同 RESTRICT与 RESTRICT 基本等价CASCADE级联删除所有子记录级联更新子记录的外键值强从属关系如订单明细SET NULL把子记录外键列置为 NULL同上可选关联列必须允许 NULLSET DEFAULTInnoDB 不支持建表即失败不支持不要用补充一句 RESTRICT 和 NO ACTION 的差别在 MySQL 里两者行为几乎一致都是立即检查标准 SQL 里 NO ACTION 允许延迟到事务提交时检查但 MySQL 没有实现延迟检查。所以写哪个都行团队统一就好我一般写 RESTRICT语义更直白。SET NULL有个前置条件极容易踩子表的外键列必须允许 NULL。如果列定义是NOT NULL建表时直接报错报错信息还不太直观。这个坑我在第一次用 SET NULL 时踩过盯着报错看了五分钟才反应过来。下面用订单明细表验证 CASCADE 的实际效果CREATE TABLE t_order_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, order_id BIGINT UNSIGNED NOT NULL, sku_code VARCHAR(32) NOT NULL, qty INT NOT NULL DEFAULT 1, PRIMARY KEY (id), KEY idx_order (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES t_order (id) ON DELETE CASCADE ) ENGINEInnoDB;插一条订单、两条明细然后删订单DELETE FROM t_order WHERE id 1; -- Query OK, 1 row affected SELECT COUNT(*) FROM t_order_item WHERE order_id 1; -- 0明细跟着没了。这就是 CASCADE 的真实效果简单直接但一定要想清楚数据丢了能不能找回。订单明细跟着订单删逻辑上没问题但如果是用户删除 → 订单 CASCADE 删除 → 明细 CASCADE 删除这条链一次误删就能把整条业务数据链端掉而且不会有任何提示。我的原则是级联链条最多两级超过两级就改用 RESTRICT 应用层显式处理。3.3 三组验证实验插入、更新、删除外键的检查发生在三个时机逐个验证一遍。场景一往子表插入不存在的父 ID。INSERT INTO t_order (user_id, order_no, amount) VALUES (99999, NO2024001, 100.00); -- ERROR 1452 (23000): Cannot add or update a child row: -- a foreign key constraint fails (demo.t_order, CONSTRAINT fk_order_user -- FOREIGN KEY (user_id) REFERENCES t_user (id) ON UPDATE CASCADE)报错信息里把库名、表名、约束名、引用关系全列出来了这就是手工命名的价值。看到 1452第一反应应该是子表要写的值在父表里没有而不是去怀疑语法。场景二删除被引用的父记录。DELETE FROM t_user WHERE id 1; -- ERROR 1451 (23000): Cannot delete or update a parent row: -- a foreign key constraint fails (demo.t_order, CONSTRAINT fk_order_user -- FOREIGN KEY (user_id) REFERENCES t_user (id) ON UPDATE CASCADE)1451 是父表被引用1452 是子表引用了不存在的值。这两个错误码方向相反是外键排查的第一分叉点记住它们的对应关系能省大量时间。场景三更新父表主键。UPDATE t_user SET id 100 WHERE id 1; SELECT user_id FROM t_order WHERE order_no NO2024001; -- 100ON UPDATE CASCADE生效子表的外键值跟着变了。反过来如果写的是 RESTRICT这条 UPDATE 会直接报 1451。还有一个隐蔽的检查时机父表的 UPDATE 即使没改主键也可能被阻塞。因为更新父表任意字段都需要对那一行加 X 锁而子表插入时持有的是 S 锁两者互斥。这个现象在高并发下表现为更新用户信息莫名变慢很容易被误判成慢查询实际上是外键锁在打架。3.4 查看、修改与删除已有外键外键建好之后怎么确认它真的生效了SHOW CREATE TABLE能看到定义但看不到状态细节。更完整的查询走information_schemaSELECT kcu.CONSTRAINT_NAME, kcu.TABLE_NAME AS child_table, kcu.COLUMN_NAME AS child_column, kcu.REFERENCED_TABLE_NAME AS parent_table, kcu.REFERENCED_COLUMN_NAME AS parent_column, rc.DELETE_RULE, rc.UPDATE_RULE FROM information_schema.KEY_COLUMN_USAGE kcu JOIN information_schema.REFERENTIAL_CONSTRAINTS rc ON rc.CONSTRAINT_SCHEMA kcu.CONSTRAINT_SCHEMA AND rc.CONSTRAINT_NAME kcu.CONSTRAINT_NAME WHERE kcu.CONSTRAINT_SCHEMA demo AND kcu.REFERENCED_TABLE_NAME IS NOT NULL;这条查询能一次性列出某个库里所有外键、方向、动作规则做库表梳理时非常好用。KEY_COLUMN_USAGE里REFERENCED_TABLE_NAME IS NOT NULL是区分普通索引和外键的关键条件。修改外键的规则必须先删后建没有ALTER CONSTRAINT这种语法ALTER TABLE t_order DROP FOREIGN KEY fk_order_user; ALTER TABLE t_order ADD CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user (id) ON DELETE RESTRICT ON UPDATE RESTRICT;注意DROP FOREIGN KEY后面跟的是约束名不是索引名也不是列名。虽然自动生成时两者常常同名但手工命名后就不一样了。删完之后记得检查索引是否还在——SHOW INDEX FROM t_order看一眼多余的索引要手动DROP INDEX。给存量表加外键时MySQL 8.0 支持在线 DDL但仍然会全表校验一遍已有数据。如果表里已经存在孤儿数据加外键会直接失败。正确顺序是先跑一条LEFT JOIN把脏数据捞出来清洗再加约束。-- 加外键前先体检 SELECT o.id, o.user_id FROM t_order o LEFT JOIN t_user u ON u.id o.user_id WHERE u.id IS NULL LIMIT 100;这个体检脚本我一般会存成固定 SQL每次加外键前跑一遍比加失败了再回头查高效得多。4. 报错与故障排查实录外键相关的问题有个共同特点报错信息看着吓人但定位路径其实很固定。这一节把常见的错误码、死锁场景、批量导入处理整理成可直接查的清单。4.1 错误码速查表错误码报错关键词根本原因定位与处理1451Cannot delete or update a parent row父记录被引用动作是 RESTRICT先处理子表关联数据或调整引用动作1452Cannot add or update a child row子表写入的值在父表不存在检查父表数据是否已写入、是否跨库查错1215Cannot add foreign key constraint建表时约束定义不合法查引擎、类型、索引、字符集四项3780Referencing column and referenced column are incompatible类型或字符集不一致用SHOW CREATE TABLE对比两边定义1822Failed to add the foreign key constraint. Missing index父表被引用列没有索引先给父表列加索引3730Cannot drop table referenced by a foreign key删父表时子表还在引用先删子表约束或子表1215 这个错误码最哑它只告诉你约束加不上不告诉你为什么。MySQL 会把详细原因写进SHOW ENGINE INNODB STATUS的LATEST FOREIGN KEY ERROR段这是排查 1215 的正确入口SHOW ENGINE INNODB STATUS\G -- 找 LATEST FOREIGN KEY ERROR 这一段那段输出会明确写出列类型不匹配还是缺少索引还是字符集不一致比错误码本身信息量大得多。我第一次遇到 1215 的时候不知道这个入口靠猜类型猜了半小时后来养成习惯只要见到 1215 先看 InnoDB 状态。3780 是 8.0 新增的码专门针对列不兼容报错里会直接说清楚是哪一边的字符集或类型对不上比 1215 友好很多。如果你的库还在 5.7就只能靠 1215 InnoDB 状态组合排查。4.2 死锁外键带来的隐形加锁这是外键最容易被低估的代价。举个真实结构t_order插入时会对t_user对应行加 S 锁同时业务代码里还有个更新用户积分的操作要对该行加 X 锁。两个事务如果顺序相反死锁就来了。-- 事务 A BEGIN; INSERT INTO t_order (user_id, order_no, amount) VALUES (1, A001, 10.00); -- 持有 t_user.id1 的 S 锁 -- 事务 B BEGIN; UPDATE t_user SET status 2 WHERE id 1; -- 请求 t_user.id1 的 X 锁等待 A -- 事务 A 继续 UPDATE t_user SET status 3 WHERE id 1; -- 请求 X 锁与 B 的请求冲突死锁形成MySQL 检测到后会回滚其中一个事务报Deadlock found when trying to get lock。这类死锁的特点是业务代码看起来毫无关联——一个下单、一个改用户状态怎么会死锁答案就在外键偷偷加的 S 锁上。应对办法有三条。统一加锁顺序涉及用户维度的操作永远先更新t_user再插入子表让所有事务的加锁顺序一致。缩短事务把非必要的查询移出事务减少持锁时间。降级为读已提交READ COMMITTED隔离级别下InnoDB 对某些间隙锁的处理更宽松能减少一部分冲突但外键的 S 锁依然存在。如果死锁频率高到影响业务那就得认真考虑拆外键了。4.3 批量导入、迁移与逻辑删除的处理批量导入。灌数据时把FOREIGN_KEY_CHECKS关掉是常见操作SET FOREIGN_KEY_CHECKS 0; -- 批量导入 SET FOREIGN_KEY_CHECKS 1;这个变量是会话级的只影响当前连接不影响别人这一点很重要。但它有两个必须知道的副作用关掉期间插入的脏数据不会在重新打开时被自动校验也就是说脏数据会一直留在表里另外 DDL 操作比如 DROP TABLE在FOREIGN_KEY_CHECKS0时不再检查引用关系如果你在这个窗口里误删了父表恢复起来很麻烦。所以我的用法是只在明确的导入脚本里临时关闭脚本跑完立刻打开绝不通过客户端手工操作。导入完成后必须补一次完整性校验就是前面那个LEFT JOIN体检脚本把加不上的数据捞出来处理。逻辑删除的冲突。前面提过CASCADE 对UPDATE ... SET deleted 1完全无感。如果你的表用逻辑删除外键就只能起到防止子表引用不存在的主键这一层作用级联语义得自己在应用层写。更麻烦的是主表逻辑删除后外键依然认为父记录存在子表照样能插入——业务上这是错的数据库上这是对的。处理方案有两个。一是放弃外键改用应用层 定时巡检写个每天跑的脚本扫出引用了已删除主记录的脏数据告警或自动修复。二是保留外键但加一层状态校验比如在父表上建一个生成列只对未删除记录生成唯一值子表引用这个生成列——这个方案比较绕实际项目里用得不多。跨库迁移。用mysqldump导出时会自动带上SET FOREIGN_KEY_CHECKS0导入完再打开所以常规备份恢复不用特别操心。但如果是自己写的迁移脚本务必手工加上这两行并且保证导入顺序是父表在前子表在后否则中途会报一堆 1452。5. 用还是不用一套可落地的判断方法这个问题在网上能吵十年因为双方说的根本不是同一个场景。与其争论外键好不好不如把判断条件列清楚逐条对照。5.1 场景判断矩阵场景特征建议理由单库单实例QPS 中等物理删除用外键收益最大代价可控单库单实例但父子表写入都极频繁谨慎使用锁竞争和死锁风险显著上升已分库分表或计划一年内分片不用跨实例无法生效早晚要拆全量逻辑删除不建议CASCADE 语义失效只剩检查作用财务、订单、账务类数据强烈建议数据完整性优先级高于性能日志、埋点、行为流水不用数据可丢可重建性能优先有多个写入入口后台、脚本、ETL强烈建议应用层约束覆盖不全子表数据量大且有频繁批量导入不用或用应用层导入性能损耗明显这张表的核心逻辑就一句外键的价值在于多入口写入时的强制一致性外键的代价在于加锁开销和架构耦合。哪边权重高答案就往哪边走。我的实际判断会更粗暴一点业务主数据用户、商品、订单、账务只要没分片一律加外键行为数据、日志数据、报表数据一律不加。这个划分在我们几个项目里没出过问题。5.2 不加外键时怎么把约束补回来决定不用外键不代表放任数据烂掉得有三层兜底。第一层是应用层显式校验。写入前查一次父记录是否存在用缓存挡掉大部分请求。注意这层校验有天然的时间窗口——查完到写完之间父记录可能被删了所以它不是强一致的。第二层是事务内的加锁校验。在同一个事务里先SELECT ... FOR SHARE锁住父记录再插子表这本质上是在模拟外键的 S 锁行为一致性有保障代价是锁竞争回来了。相当于你手工实现了一遍外键性能上并没有占便宜唯一的好处是可控、可降级。第三层是定时巡检 告警。每天凌晨跑一遍孤儿数据扫描把结果推到告警群数量超过阈值就介入处理。这一层是最后的兜底也是我强烈建议加的——不管用不用外键都应该有一套独立的数据体检机制因为外键也管不了业务语义错误。-- 孤儿数据巡检模板 SELECT order-user AS relation, COUNT(*) AS orphan_cnt FROM t_order o LEFT JOIN t_user u ON u.id o.user_id WHERE u.id IS NULL UNION ALL SELECT item-order, COUNT(*) FROM t_order_item i LEFT JOIN t_order o ON o.id i.order_id WHERE o.id IS NULL;这个脚本挂在定时任务里输出到监控比任何口头约定都靠谱。5.3 几条踩出来的硬规矩最后分享几条在外键上踩过坑之后定下来的规矩都是些文档里不会写的东西。规规矩矩给约束命名。系统自动生成的名字在报错时毫无信息量而报错现场往往就是你最没时间查字典的时候。级联链条不超过两级。A CASCADE B、B CASCADE C 这种结构一次误删能带走三层数据而且删的时候一路静默没有任何提示。真有强从属关系宁可拆成两次显式删除让每一步都有日志。加外键前必跑体检脚本。存量表加约束失败一次排查加修复至少半小时提前跑个LEFT JOIN只要三秒。删外键时顺手查索引。DROP FOREIGN KEY不删索引日积月累表上会有一堆僵尸索引白吃写入性能。我现在的习惯是删约束后立刻SHOW INDEX看一眼有没有多余的单列索引需要清理。生产环境不开FOREIGN_KEY_CHECKS0做手工操作。只在明确的导入脚本里用用完立刻恢复并且在脚本里写死恢复语句防止中途崩溃导致变量一直是关着的。给外键相关的表结构变更留足窗口。改主键、改类型、换字符集只要涉及被外键引用的列操作复杂度会翻好几倍别指望在业务高峰期顺手改完。我个人在实际操作中的体会是外键这东西属于用对了几乎感觉不到它的存在用错了处处是坑的典型。真正决定成败的不是用不用而是你是否清楚它在什么条件下帮你、在什么条件下害你。把它当成一把有刻度的尺子而不是万能钥匙或者洪水猛兽判断就简单多了。