我刚工作的时候,设计订单表时给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-- 用户表有 id=1 的记录-- 订单表有 user_id=1 的记录-- 删除用户(报错,因为订单表有引用)DELETEFROMusersWHEREid=1;-- 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-- 删除用户(自动删除其订单)DELETEFROMusersWHEREid=1;-- 订单表 user_id=1 的记录也被自动删了适用场景:弱一致性(比如社交,删了用户,其帖子也自动删)。
坑:可能误删大量数据!比如删一个用户,自动删了 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 设为 NULL)DELETEFROMusersWHEREid=1;-- 订单表 user_id=1 的记录,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_id=999 不存在,应用层要自己查(可能漏掉)-- 用外键:数据库自动校验INSERTINTOorders(user_id,amount)VALUES(999,100.00);-- ERROR 1452: Cannot add or update a child row: a foreign key constraint fails好处:不会脏数据(比如订单表里有个user_id=999,但用户表里没这条记录)。
2. 级联操作方便
如果业务需要级联删除/更新,CASCADE很方便。
-- 删用户时,自动删其订单(不用应用层写逻辑)DELETEFROMusersWHEREid=1;-- 订单表 user_id=1 的记录也被自动删了好处:应用层不用写级联逻辑(代码更简洁)。
3. 数据库层约束(比应用层可靠)
应用层校验可能有 bug(比如并发场景,校验完插入前,数据被改了)。
// 应用层校验(有并发问题)publicvoidcreateOrder(intuserId,BigDecimalamount){// 校验用户是否存在Useruser=userDao.selectById(userId);if(user==null){thrownewException("用户不存在");}// 插入订单(这里可能有并发问题:校验完,插入前,用户被删了)orderDao.insert(userId,amount);}```**用外键**:数据库层自动加锁,不会并发问题。 ## 外键的缺点(重点!) ###1.性能差(锁竞争)**外键检查要加锁**(主表和从表都要加锁),并发性能差。 ```sql--会话A:插入订单(要检查用户表,可能锁住用户表的行)INSERTINTOorders(user_id,amount)VALUES(1,100.00);--会话B:删除用户(要等会话A释放锁)DELETEFROMusersWHEREid=1;--阻塞!问题:高并发场景下,外键检查会导致大量锁竞争,性能炸裂。
2. 增加数据库负担
每次插入/更新/删除,都要做外键检查,增加数据库负担。
如果不用外键,这些检查在应用层做(应用层可以水平扩展,数据库不行)。
3. 不支持分布式事务
如果是分库分表(分布式),外键没法跨库约束。
-- 用户表在 db1,订单表在 db2-- 外键没法跨库约束(只能单库内约束)CREATETABLEdb1.users(...);CREATETABLEdb2.orders(...,FOREIGNKEY(user_id)REFERENCESdb1.users(id)-- ❌ 不支持!);```**问题**:分库分表后,外键没用了(只能应用层保证一致性)。 ### 4. 部署/迁移麻烦 **如果用了外键**,部署/迁移时要**按依赖顺序**导入数据。 ```sql-- 要先导入主表(users),再导入从表(orders)-- 如果顺序反了,会报错(外键约束失败)问题:部署/迁移复杂(要写脚本保证顺序)。
5. 可能死锁
外键检查可能死锁(多个事务互相等待)。
-- 会话 A:更新用户(锁住用户表)UPDATEusersSETname='Alice'WHEREid=1;-- 会话 B:插入订单(要检查用户表,等会话 A 释放锁)INSERTINTOorders(user_id,amount)VALUES(1,100.00);-- 阻塞!-- 会话 A:删除订单(要检查用户表,等会话 B 释放锁,死锁!)DELETEFROMordersWHEREuser_id=1;-- 死锁!实战:要不要用外键?
场景 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){// 应用层校验Useruser=userDao.selectById(userId);if(user==null){ 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){// 应用层校验Useruser=userDao.selectById(userId);if(user==null){ throw new Exception("用户不存在");}// 插入订单orderDao.insert(userId,amount);}```**缺点**:有并发问题(校验完,插入前,数据可能被改了)。 **优化**:用**分布式锁**(比如 Redis 锁),保证校验和插入原子性。 ### 方案 2:数据库层校验(不用外键) **用存储过程/触发器**在数据库层校验(不用外键)。 ```sql-- 用触发器校验(不用外键)DELIMITER$$CREATETRIGGERorder_before_insert BEFOREINSERTONordersFOR EACH ROWBEGIN-- 校验 user_id 是否存在IFNOTEXISTS(SELECT1FROMusersWHEREid=NEW.user_id)THENSIGNAL SQLSTATE'45000'SETMESSAGE_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 保证最终一致性 - - **实战建议**:互联网应用别用外键、单体应用可以用、金融系统可以用、不用外键就用应用层校验 + 分布式锁 如果你能把外键的优缺点、四种动作、适用场景讲清楚,面试官绝对觉得你有实战经验。 --- **实战代码都在我本地跑过,你可以放心复制。** 如果有问题,欢迎评论区交流!