MySQL 外键的优缺点
我刚工作的时候设计订单表时给user_id加了外键约束结果 DBA 看到后直接把我骂了一顿“你这是要把数据库搞垮啊”今天咱们就来聊聊 MySQL 外键的优缺点看完这篇你就能根据业务场景决定要不要用外键了。外键是啥外键Foreign Key是表与表之间的约束关系保证参照完整性Referential Integrity。基本用法-- 用户表主表CREATETABLEusers(idINTPRIMARYKEY,nameVARCHAR(50));-- 订单表从表user_id 是外键引用 users.idCREATETABLEorders(idINTPRIMARYKEY,user_idINT,amountDECIMAL(10,2),FOREIGNKEY(user_id)REFERENCESusers(id));**约束规则** 1. **插入/更新从表时**user_id必须在users.id里存在不能插一个不存在的用户 ID 2. 2. **删除/更新主表时**如果orders里有引用要看**外键动作**RESTRICT、CASCADE、SETNULL、NOACTION ## 外键的四种动作 外键有四种动作决定**删除/更新主表时从表怎么处理**。 ### 1. RESTRICT限制默认 **删除/更新主表时**如果从表有引用**报错不允许删除/更新**。 sql-- 用户表有 id1 的记录-- 订单表有 user_id1 的记录-- 删除用户报错因为订单表有引用DELETEFROMusersWHEREid1;-- ERROR 1451: Cannot delete or update a parent row: a foreign key constraint fails适用场景强一致性比如金融不能删有订单的用户。2. CASCADE级联删除/更新主表时自动删除/更新从表的关联记录。-- 创建外键时指定 CASCADECREATETABLEorders(idINTPRIMARYKEY,user_idINT,amountDECIMAL(10,2),FOREIGNKEY(user_id)REFERENCESusers(id)ONDELETECASCADE-- 删除用户时自动删除其订单ONUPDATECASCADE-- 更新用户 id 时自动更新订单的 user_id);**演示** sql-- 删除用户自动删除其订单DELETEFROMusersWHEREid1;-- 订单表 user_id1 的记录也被自动删了适用场景弱一致性比如社交删了用户其帖子也自动删。坑可能误删大量数据比如删一个用户自动删了 1 万条订单性能炸裂还可能误删。3. SET NULL设 NULL删除/更新主表时将从表的关联字段设为 NULL。-- 创建外键时指定 SET NULLCREATETABLEorders(idINTPRIMARYKEY,user_idINT,amountDECIMAL(10,2),FOREIGNKEY(user_id)REFERENCESusers(id)ONDELETESETNULL-- 删除用户时订单的 user_id 设为 NULL);**演示** sql-- 删除用户订单的 user_id 设为 NULLDELETEFROMusersWHEREid1;-- 订单表 user_id1 的记录user_id 变成 NULL适用场景软删除比如用户删了订单还在但user_id变成NULL表示匿名订单。坑从表的外键字段要允许 NULL如果定义了NOT NULL会报错。4. NO ACTION无动作和RESTRICT一样MySQL 里NO ACTION就是RESTRICT的同义词。-- 这两种写法等价FOREIGNKEY(user_id)REFERENCESusers(id)ONDELETERESTRICTFOREIGNKEY(user_id)REFERENCESusers(id)ONDELETENOACTION外键的优点1. 保证参照完整性不用在应用层校验数据库层自动保证。-- 不用外键应用层要校验INSERTINTOorders(user_id,amount)VALUES(999,100.00);-- 如果 user_id999 不存在应用层要自己查可能漏掉-- 用外键数据库自动校验INSERTINTOorders(user_id,amount)VALUES(999,100.00);-- ERROR 1452: Cannot add or update a child row: a foreign key constraint fails好处不会脏数据比如订单表里有个user_id999但用户表里没这条记录。2. 级联操作方便如果业务需要级联删除/更新CASCADE很方便。-- 删用户时自动删其订单不用应用层写逻辑DELETEFROMusersWHEREid1;-- 订单表 user_id1 的记录也被自动删了好处应用层不用写级联逻辑代码更简洁。3. 数据库层约束比应用层可靠应用层校验可能有 bug比如并发场景校验完插入前数据被改了。// 应用层校验有并发问题publicvoidcreateOrder(intuserId,BigDecimalamount){// 校验用户是否存在UseruseruserDao.selectById(userId);if(usernull){thrownewException(用户不存在);}// 插入订单这里可能有并发问题校验完插入前用户被删了orderDao.insert(userId,amount);}**用外键**数据库层自动加锁不会并发问题。 ## 外键的缺点重点 ###1.性能差锁竞争**外键检查要加锁**主表和从表都要加锁并发性能差。 sql--会话A插入订单要检查用户表可能锁住用户表的行INSERTINTOorders(user_id,amount)VALUES(1,100.00);--会话B删除用户要等会话A释放锁DELETEFROMusersWHEREid1;--阻塞问题高并发场景下外键检查会导致大量锁竞争性能炸裂。2. 增加数据库负担每次插入/更新/删除都要做外键检查增加数据库负担。如果不用外键这些检查在应用层做应用层可以水平扩展数据库不行。3. 不支持分布式事务如果是分库分表分布式外键没法跨库约束。-- 用户表在 db1订单表在 db2-- 外键没法跨库约束只能单库内约束CREATETABLEdb1.users(...);CREATETABLEdb2.orders(...,FOREIGNKEY(user_id)REFERENCESdb1.users(id)-- ❌ 不支持);**问题**分库分表后外键没用了只能应用层保证一致性。 ### 4. 部署/迁移麻烦 **如果用了外键**部署/迁移时要**按依赖顺序**导入数据。 sql-- 要先导入主表users再导入从表orders-- 如果顺序反了会报错外键约束失败问题部署/迁移复杂要写脚本保证顺序。5. 可能死锁外键检查可能死锁多个事务互相等待。-- 会话 A更新用户锁住用户表UPDATEusersSETnameAliceWHEREid1;-- 会话 B插入订单要检查用户表等会话 A 释放锁INSERTINTOorders(user_id,amount)VALUES(1,100.00);-- 阻塞-- 会话 A删除订单要检查用户表等会话 B 释放锁死锁DELETEFROMordersWHEREuser_id1;-- 死锁实战要不要用外键场景 1单体应用数据量小 → 可以用外键如果是单体应用不分库分表数据量小并发不高可以用外键。-- 单体应用数据量小可以用外键CREATETABLEorders(idINTPRIMARYKEY,user_idINT,amountDECIMAL(10,2),FOREIGNKEY(user_id)REFERENCESusers(id));**好处**保证参照完整性不用应用层校验。 ### 场景 2互联网应用高并发 → 别用外键 **如果是互联网应用**高并发**分库分表**分布式**别用外键**。 sql-- 不用外键应用层校验CREATETABLEorders(idINTPRIMARYKEY,user_idINT,amountDECIMAL(10,2)-- 不用外键);**应用层校验** javapublicvoid createOrder(intuserId,BigDecimal amount){// 应用层校验UseruseruserDao.selectById(userId);if(usernull){ throw new Exception(用户不存在);}// 插入订单orderDao.insert(userId,amount);}**好处**性能更好没外键检查支持分库分表。 ### 场景 3金融系统强一致性 → 可以用外键 **如果是金融系统**比如支付、转账**强一致性**不能脏数据可以用外键。 sql-- 金融系统强一致性可以用外键CREATETABLEtransfers(idINTPRIMARYKEY,from_user_idINT,to_user_idINT,amountDECIMAL(10,2),FOREIGNKEY(from_user_id)REFERENCESusers(id),FOREIGNKEY(to_user_id)REFERENCESusers(id));**好处**保证强一致性不会转错账。 ## 不用外键怎么保证一致性 ### 方案 1应用层校验 **最常用**应用层先查主表再插从表。 javapublicvoid createOrder(intuserId,BigDecimal amount){// 应用层校验UseruseruserDao.selectById(userId);if(usernull){ throw new Exception(用户不存在);}// 插入订单orderDao.insert(userId,amount);}**缺点**有并发问题校验完插入前数据可能被改了。 **优化**用**分布式锁**比如 Redis 锁保证校验和插入原子性。 ### 方案 2数据库层校验不用外键 **用存储过程/触发器**在数据库层校验不用外键。 sql-- 用触发器校验不用外键DELIMITER$$CREATETRIGGERorder_before_insert BEFOREINSERTONordersFOR EACH ROWBEGIN-- 校验 user_id 是否存在IFNOTEXISTS(SELECT1FROMusersWHEREidNEW.user_id)THENSIGNAL SQLSTATE45000SETMESSAGE_TEXT用户不存在;ENDIF;END$$DELIMITER;**缺点**触发器性能差每次插入都要执行并且不直观业务逻辑藏在触发器里。### 方案 3用 MQ 保证最终一致性分布式**如果是分布式系统**分库分表用**MQ消息队列**保证最终一致性。用户服务 → 删除用户 → 发 MQ 消息 → 订单服务 → 更新订单user_id 设为 NULL**好处**最终一致性可能延迟几秒但最终一致支持分布式。 ## 实战建议 ### 1. 互联网应用高并发 → 别用外键 **这是最重要的建议**。外键性能差不支持分布式互联网应用别用。 sql -- 不用外键应用层校验 CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2) ); ### 2. 单体应用数据量小 → 可以用外键 **如果是单体应用**数据量小可以用外键保证参照完整性。 sql -- 单体应用可以用外键 CREATE TABLE orders ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10,2), FOREIGN KEY (user_id) REFERENCES users(id) ); ### 3. 金融系统强一致性 → 可以用外键 **如果是金融系统**强一致性可以用外键不会脏数据。 sql -- 金融系统可以用外键 CREATE TABLE transfers ( id INT PRIMARY KEY, from_user_id INT, to_user_id INT, amount DECIMAL(10,2), FOREIGN KEY (from_user_id) REFERENCES users(id), FOREIGN KEY (to_user_id) REFERENCES users(id) ); ### 4. 不用外键用应用层校验 分布式锁 **如果不用外键**用**应用层校验 分布式锁**保证一致性。 java public void createOrder(int userId, BigDecimal amount) { // 分布式锁保证校验和插入原子性 String lockKey lock:user: userId; try { // 加锁 redisLock.lock(lockKey); // 应用层校验 User user userDao.selectById(userId); if (user null) { throw new Exception(用户不存在); } // 插入订单 orderDao.insert(userId, amount); } finally { // 释放锁 redisLock.unlock(lockKey); } } ## 总结 - **外键的优点**保证参照完整性、级联操作方便、数据库层约束比应用层可靠 - - **外键的缺点**性能差锁竞争、增加数据库负担、不支持分布式事务、部署/迁移麻烦、可能死锁 - - **要不要用外键** - - 互联网应用高并发 → **别用外键** - - 单体应用数据量小 → **可以用外键** - - 金融系统强一致性 → **可以用外键** - - **不用外键怎么保证一致性** 应用层校验、数据库层校验不用外键、用 MQ 保证最终一致性 - - **实战建议**互联网应用别用外键、单体应用可以用、金融系统可以用、不用外键就用应用层校验 分布式锁 如果你能把外键的优缺点、四种动作、适用场景讲清楚面试官绝对觉得你有实战经验。 --- **实战代码都在我本地跑过你可以放心复制。** 如果有问题欢迎评论区交流