一次"删用户"的操作失败了,报错信息是"违反外键约束"。查下来是三年前有人给"订单表"加了 user_id 的外键——于是"删用户"必须先"删订单",而业务上"用户注销"要保留订单(合规要求)。
外键的争议通常是"性能 vs 完整性"。而真正的分界是"这条关系由谁保证":数据库保证(外键),还是应用保证(校验 + 对账)。"不清不楚"的形态是"以为有外键、其实没有"(或反之):于是"有人依赖完整性保护",而"保护并不存在"——这类问题要等"看到孤儿数据"才被发现。
两者都有代价,而"不清不楚"最贵。
1. 外键给了什么
| 保证 | 说明 |
|---|---|
| 引用完整性 | 子行必须有父行 |
删除行为(ON DELETE) |
级联/置空/拒绝 |
更新行为(ON UPDATE) |
同上 |
| 隐式索引 | 子表外键列会自动建索引 |
第四项常被忽略:外键会自动给"子表的外键列"建索引第四项的"机制"是"外键需要一个索引来定位子行",于是数据库自动建——而"手动加外键但没建索引"的数据库会自己补一个,于是"看起来没建却查得快"。
——于是"加上外键"顺便解决了"按 user_id 查订单"的性能问题。
2. 外键的四种代价
| 代价 | 机制 |
|---|---|
| 写放大 | 每次写要检查父行(加锁) |
| 父表锁争用 | 高并发插入子行时争用父行 |
| 变更困难 | 分库分表后跨库外键不可用 |
| 运维复杂 | 数据迁移/导入顺序受限 |
第二项的"热点"很容易量化:如果某个父行的子行插入占比超过 10%,它就可能是锁热点——而这类"热点"在业务上往往对应"大客户"或"默认分类"。
第二项是最实际的性能问题:"插入子行"要"读父行并加共享锁",于是"父表热点行"会成为瓶颈。
第三项是"架构"层面的:分库后跨库外键不支持第三项的"代价时机"很晚:它是"决定分库"那一刻才出现的问题,而那时"改外键"要和"数据迁移"一起做——成本翻倍。
——于是"现在用外键"会在"将来分库"时变成迁移工作。
3. 不用外键时"欠了什么"
| 欠的 | 补救 |
|---|---|
| 引用完整性 | 应用校验 + 定期对账 |
| 级联删除 | 应用层实现(且要事务) |
| 隐式索引 | 手动建索引(易漏) |
| 删除保护 | 应用层检查"有没有子行" |
第四项的"检测"只能靠"对账脚本":"孤儿数量"是一个可测的指标,而"有孤儿"就意味着"某条路径漏了校验"——它比"靠评审发现"可靠。
第四项最容易被漏:"删父行前先查子行"这件事,只在"记得查的路径"上生效——于是"另一个入口"就会删出孤儿数据。
-- 不用外键时,"欠的账"要靠对账脚本补
-- 1 找孤儿:子行指向不存在的父行
SELECT o.id FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL;
-- 2 找"该有索引但没有":按 user_id 查会不会全扫
EXPLAIN SELECT * FROM orders WHERE user_id = 42; -- 看 key 是否为 NULL
-- 3 定期跑(每天):孤儿数量应当为 0,非 0 即"有入口漏了校验"
4. 三种"折中"的做法
| 做法 | 适用 |
|---|---|
| 保留外键(单库) | 单库、写压力不大 |
| 不用外键 + 应用校验 + 对账 | 高并发/分库预备 |
| 混用(核心表用、日志表不用) | 现实中最常见 |
第三项的判据是"这张表的数据能不能接受孤儿第三项的"判断"很实际:"孤儿"会怎样——算错钱(不能接受)、查不到(不能接受)、只是少一条日志(可接受)。三类后果决定"要不要外键"。
":订单不能(要能算钱),日志可以(孤儿日志不影响业务)。
// 混用的分工(同一系统里)
users ← orders(外键:金额相关,孤儿会算错)
users ← login_log(无外键:孤儿日志可接受,且写入极高频)
// 判据:这条关系的"孤儿"会造成什么后果
"外键与否"的决策可以写成一个三行的记录,附在表结构注释里:
// 表结构注释里的决策记录(三行)
orders.user_id → users.id:保留外键(金额相关,孤儿会算错;写压力可接受)
login_log.user_id → users.id:不用外键(孤儿可接受;写入极高频)
// 判据:每个"关系"都写一行,于是"为什么没有外键"是可查的
5. 加外键"之前"要做的三件事
| 步骤 | 原因 |
|---|---|
| 1 清孤儿数据 | 否则加不上(约束校验失败) |
| 2 评估写压力 | 高并发插入会争用父行 |
3 选 ON DELETE 行为 |
级联删除很危险 |
第三项的"安全替代"是 RESTRICT + 应用层"显式删除子行":它把"删多少"变成"代码里能看见的决定",而不是"数据库自动扩散"。
第三项是"最危险"的选择:ON DELETE CASCADE 会让"删一行"变成"删一片"——而它在"误删"时无法撤销。
-- 加外键前先清孤儿(否则报 1452)
DELETE o FROM orders o LEFT JOIN users u ON u.id = o.user_id WHERE u.id IS NULL;
-- 再评估:父表该行的"子行插入频率"
SELECT user_id, COUNT(*) c FROM orders WHERE create_time > NOW()-INTERVAL 1 DAY
GROUP BY user_id ORDER BY c DESC LIMIT 5;
-- 若某个 user_id 一小时插入上千行 → 该行会成为锁热点
"孤儿数据"的对账要"覆盖所有指向该父表的子表",否则会漏:
-- 从元数据里找出"所有指向 users 的外键候选列"(按命名约定)
SELECT table_name, column_name FROM information_schema.columns
WHERE table_schema = DATABASE() AND column_name IN ('user_id','uid');
-- 对每一对跑"孤儿查询",结果汇总;shell 里循环即可
-- 收益:新增表若沿用命名,会被自动覆盖到
6. 排错:五种"外键相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 删父行失败 | 有子行/RESTRICT |
先处理子行或改行为 |
| 插入子行变慢 | 父行锁争用 | 评估去外键 |
| 分库后不可用 | 跨库外键不支持 | 改应用校验 |
| 误删一片数据 | ON DELETE CASCADE |
改 RESTRICT + 应用层 |
| 孤儿数据 | 无外键且漏校验 | 对账 + 补入口 |
第四项是"不可逆"的:级联删除在"误删父行"时会连带删掉全部子行第四项的"识别"是"看 DDL 里有没有 ON DELETE CASCADE"——而它常是"为了省事"加上的,之后没人再检查。
,而它不触发"应用层的检查"。
最后一个"删除语义"的视角:外键把"删除"的语义"从代码搬到了 DDL",于是"读代码看不出会删什么"。
// 三种删除语义(按"看得见"程度)
应用层删除子行:代码里看得见(推荐,尤其"删一片"时)
ON DELETE CASCADE:DDL 里看得见(要读 schema)
ON DELETE SET NULL:DDL 里看得见(父消失,子变"无主")
// 判据:这个删除"会不会影响其它业务",会 → 放应用层
7. 边界:什么情况不必纠结外键
| 场景 | 为什么 | 做法 |
|---|---|---|
| 单库、写压力低 | 争用不明显 | 用外键(省事) |
| 日志/事件类表 | 孤儿可接受 | 不用 |
| 已确定要分库 | 跨库不支持 | 不用 + 应用校验 |
| 数据导入为主(批量) | 顺序可控制 | 导入期禁用,完成后加回 |
第四项的"风险"在于"忘了校验":禁用检查后导入不会报错,而"孤儿"已经写进去了——所以"导完立刻跑对账"是这条技巧的一部分。
第四项是"运维技巧":大批量导入时先禁用外键检查,导完再校验——而"校验"这一步不能省。
判断口径一句话:"这条关系的孤儿会造成什么后果"。最后一条与"演进"有关的边界:"现在单库"不等于"将来单库"。所以"外键"这个决定要按"三年后的架构"评估,而不是"今天跑得快不快"。
会造成"算错钱/查不到",就必须有保证(外键或严格校验 + 对账);不会(日志、统计),不必背外键的成本。
8. 自测
- 外键给了什么保证?为什么"隐式索引"常被忽略?
- 四种代价里,哪一种是最实际的性能问题?它为什么会让"父表热点行"成为瓶颈?
- 不用外键时"欠了什么"?哪一项最容易被漏?
- "混用"的判据是什么?为什么"订单用、日志不用"是合理的?
- 加外键前的三件事分别为什么?
ON DELETE CASCADE的危险在哪? - 判断"该不该用外键"的那个问题是什么?