2437 字
约 8 分钟
17
S08-A22 · 外键:该不该用,以及不用时你欠了什么

一次"删用户"的操作失败了,报错信息是"违反外键约束"。查下来是三年前有人给"订单表"加了 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. 自测

  1. 外键给了什么保证?为什么"隐式索引"常被忽略?
  2. 四种代价里,哪一种是最实际的性能问题?它为什么会让"父表热点行"成为瓶颈?
  3. 不用外键时"欠了什么"?哪一项最容易被漏?
  4. "混用"的判据是什么?为什么"订单用、日志不用"是合理的?
  5. 加外键前的三件事分别为什么?ON DELETE CASCADE 的危险在哪?
  6. 判断"该不该用外键"的那个问题是什么?
S08-A22 · 外键:该不该用,以及不用时你欠了什么
https://www.clxhxhhr.top/posts/6608/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。