MySQL: 为什么很多大厂禁用外键
大家好,我是HLAIA光子。
这篇文章聊一个数据库设计里争议很大的话题:外键。
你学数据库的时候,教程一定教过你建外键来保证数据一致性。但如果你去看大厂的生产环境,会发现一个反直觉的事实:很多大厂不仅不用外键,而且在开发规范里明确禁止使用。比如 GitHub 是这么说的:"We do not use foreign keys, ever, anywhere"。
看完这篇文章,你会知道大厂为什么这么做,背后有哪些真实的生产事故和性能数据支撑,以及不用外键之后用什么来替代。
先搞清楚一个前提。我们说的"禁用外键",禁的是物理外键,就是写在建表语句里的 FOREIGN KEY (work_id) REFERENCES works(id)。禁掉它不等于不要数据关联,而是把 work_id 当成一个普通列,加上索引保证查询性能,数据一致性交给应用层代码来校验。这种做法叫逻辑外键。
大厂的态度
禁用外键这事,不是某一家公司的偏门做法,而是一个相当广泛的行业共识。
GitHub 工程师在 gh-ost 的 Issue 里有一句著名的话:
At GitHub we do not use foreign keys, ever, anywhere.
这位工程师还补充说,他花了数年时间才做出这个决定,认为外键带来的麻烦远超收益。GitHub 自己的在线 DDL 工具 gh-ost 本身就不支持有外键的表。
Shopify 的情况更直接。他们开源的数据迁移工具 Ghostferry 官方文档写得很明确:不支持有外键约束的表。要在生产环境做 TB 级别的分库迁移,外键就是硬性障碍。
阿里巴巴这边,Java 开发手册嵩山版里有一条强制规定:"不得使用外键与级联,一切外键概念必须在应用层解决。"华山版还补充了理由:外键与级联更新适合单机低并发,不适合分布式、高并发集群;级联更新是强阻塞,存在数据库更新风暴的风险。
其他你能想到的大厂也差不多。PlanetScale 官方文档直接写"我们不推荐使用外键约束"。YouTube 用的数据库中间件 Vitess 发了专门的文章论证为什么不支持外键。前 Facebook 工程师在 Hacker News 上确认 Facebook 也不用外键,说"所有大型基于 MySQL 的公司都是如此"。GitLab 为了在拆分数据库时处理跨库关联,专门造了一套叫"Loose Foreign Keys"的系统——用定时任务每分钟跑一次,检测父表删除的记录,然后批量清理子表的关联数据。
PlanetScale 的官方态度也很明确:
说白了,不用物理外键在互联网行业是标配,不是异端。
性能开销
大厂不用外键的第一个原因:慢
Shayon Mukherjee 在 2023 年做了一组 PostgreSQL 测试,删除关联数据时,有外键要 490ms,没外键只要 96ms,差了整整 5 倍。
每次你往子表 INSERT 一行数据,数据库引擎必须额外做一次查询,确认外键指向的父表那行记录确实存在。在高频写入的场景下,这个"额外一次查询"的开销会被急剧放大,这是外键慢的一个原因。
2024 年还有一组更严谨的基准测试。用 PostgreSQL 16 + .NET 8 + BenchmarkDotNet,测了 100K 用户、250K 账户、每行 4 个外键的场景。批量插入 1000 行时,有外键比无外键慢 32.7%。即使是单行顺序插入,也要慢 5.5%。
Brent Ozar 在 SQL Server 上测了批量插入 100 万行的场景,有外键比无外键慢了 5.3%。更夸张的是一个生产案例:一张表上有 20 个外键约束,处理 40 万行数据时,禁用外键后性能提升了约 27 倍,从 55 分钟降到不到 2 分钟。
所以外键在高并发场景下就是性能瓶颈。
锁争用
外键能直接把你的服务搞挂
工程师 Carson Ip 记录过一次真实的生产事故。数据库里有父子两张表,子表通过外键引用父表。症状很诡异:CPU、磁盘 IO、内存一切正常,但 INSERT INTO child 和 UPDATE parent 比其他查询慢了一个数量级。最终拖垮了整个后端应用。
根因是这样,事务 A 对父表某行加了排他锁,事务 B 要往子表插入一行,外键校验需要对父表同一行加共享锁(S Lock),被事务 A 的 X Lock 阻塞。高并发场景下,多个事务同时争抢同一行父表的锁,锁等待、超时、死锁、连接池耗尽,最后服务宕机。
解决方案就是把外键去掉。去掉之后,死锁消失,查询恢复正常。Percona 论坛上也有类似的报告,MariaDB 10.1 的用户遇到了跟外键相关的死锁问题。
这种事故的可怕之处在于:它不是你代码写得不好,而是数据库的外键机制在高并发下的固有缺陷。你优化不了它,只能绕开它。
迁移噩梦
在线 Schema 变更工具的基本流程是:创建影子表,同步数据,然后原子切换。问题出在切换这一步。当你 ALTER 一个被外键引用的父表时,RENAME 之后,子表的外键会跟着旧表走,指向的不再是新表。
Vitess 的官方博文详细分析了这个问题。结论是:要迁移父表,必须同时迁移所有子表。子表通常比父表大得多,所有迁移必须原子完成,任何一个失败都无法回滚。这在工程上基本不可行。
PlanetScale 还发现了更深层的问题,就是子表上的 CASCADE 和 SET NULL 操作不会被记录到 MySQL 的 binlog 里。InnoDB 内部处理级联操作,但从不将其写入二进制日志。这意味着所有依赖 binlog 的变更数据捕获工具都会丢数据。PlanetScale 花了大约一年的工程时间才在 Vitess 层面实现了变通方案。
日常的 Flyway 或 Liquibase 迁移也会受影响。有外键时,删表必须严格按依赖顺序:先删最底层的子表,再删中间层,最后删父表。没有外键的话,随便先删哪个都行。在 CI/CD 环境里,这种严格的顺序依赖会让自动化迁移脚本变得非常脆弱。
微服务架构
如果你做的是微服务架构,那就真别用物理外键。
不同服务通常操作不同的数据库实例。MySQL 和 PostgreSQL 的外键约束只能在同一个数据库内生效。跨服务的数据一致性只能通过应用层来保证,比如 Saga 模式、事件驱动、最终一致性。
即使是单体应用,当数据量增长到需要分库分表时,外键同样失效。分片前 works 和 work_attachments 在同一个库,外键能工作。分片后它们可能落在不同的物理库上,外键就不可能了。
如果在设计之初就用了物理外键,后期拆分时改造成本巨大。不如一开始就不依赖它。
不用外键怎么做
不用外键,数据一致性的保证就需要在应用层做。
插入数据时手动校验引用是否存在。比如新增附件之前先查一下 work_id 对不对应得上,对不上就抛异常。删除数据时手动做级联清理,先删子表数据再删父表数据。再用一个定时任务,比如每天跑一次,扫描孤儿记录,发现异常就报警。
查性能靠索引保证。不建物理外键,但一定要在外键列上建索引。索引的作用是加速查询,不会在写入时强制校验,也不会阻塞写入。加索引在 MySQL 8.0+ 上是瞬时操作,维护成本很低。
// 应用层校验
@Transactional
public void addAttachment(Long workId, MultipartFile file) {
Work work = workRepository.findById(workId)
.orElseThrow(() -> new BusinessException("作品不存在: " + workId));
WorkAttachment attachment = new WorkAttachment();
attachment.setWorkId(workId);
attachment.setFileName(file.getOriginalFilename());
workAttachmentRepository.save(attachment);
}
// 应用层级联删除
@Transactional
public void deleteWork(Long workId) {
workAttachmentRepository.deleteByWorkId(workId);
workRepository.deleteById(workId);
}
代价就是要在应用层多写几行代码,换来的是更好的写入性能、更灵活的架构、更简单的运维、未来拆分服务时零改造成本。
写在最后
不用物理外键一个经过大规模生产验证的工程权衡,GitHub、Shopify、阿里巴巴、YouTube、Facebook,这些公司不约而同地做了同样的选择。不是因为外键这个功能不好,而是它在高并发、大规模、需要频繁变更的场景下,制造的问题比解决的问题多。
如果你觉得这篇文章有帮助,点赞关注,点点赞~