ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MySQL 死锁怎么解决?底层原理与 Java 实战详解

MySQL 死锁怎么解决?底层原理与 Java 实战详解 面试考点分析死锁的本质能否说清死锁是事务之间对锁资源的循环等待并列出互斥、持有并等待、不可抢占、循环等待四个必要条件。InnoDB 锁机制是否了解记录锁、间隙锁、Next-Key Lock 的加锁范围以及它们如何影响死锁的产生。死锁检测与处理是否掌握 InnoDB 基于等待图的死锁检测机制以及死锁发生后自动回滚代价较小事务的策略。定位与排查能力是否会使用 SHOW ENGINE INNODB STATUS 分析 LATEST DETECTED DEADLOCK从中提取事务、SQL 与锁信息。工程化解决思路是否能给出固定加锁顺序、合理建索引、应用层重试等可落地的规避与兜底方案。一、标准回答总结MySQL InnoDB 中的死锁指的是两个或多个事务互相持有对方需要的锁资源形成循环等待导致所有相关事务都无法继续执行。InnoDB 会自动检测死锁并选择回滚其中代价较小的事务来打破死锁。作用死锁检测机制保证了在高并发事务场景下系统不会因为锁的循环等待而永久阻塞从而保障数据库的可用性。特点死锁检测默认开启由参数innodb_deadlock_detect控制当检测到死锁时InnoDB 会立即回滚其中一个事务并以错误码1213反馈给客户端。解决死锁通常从四个层面入手缩短事务减少事务持有锁的时间降低冲突概率。按固定顺序访问数据让所有事务以一致的顺序加锁从根源上避免循环等待。合理设计索引缩小加锁范围避免因全表扫描或间隙锁放大锁冲突。应用层兜底重试捕获死锁异常后按退避策略重试保证业务最终成功。二、核心原理2.1 死锁的四个必要条件死锁的产生必须同时满足以下四个条件缺一不可互斥条件同一资源同一时刻只能被一个事务持有例如排他锁。持有并等待事务已经持有资源又在等待其他资源。不可抢占事务持有的锁不能被其他事务强行剥夺只能由持有者自己释放。循环等待事务之间形成首尾相接的资源等待环。数据库解决死锁的思路本质上就是破坏其中至少一个条件。例如回滚事务相当于破坏“持有并等待”而固定加锁顺序则相当于破坏“循环等待”。2.2 InnoDB 的锁类型理解死锁先要理解 InnoDB 的锁。按锁的粒度可以划分为表级锁和行级锁锁粒度锁类型说明表级锁共享锁 S / 排他锁 X针对整张表加锁如 LOCK TABLES。意向锁 IS / IX标记事务是否准备对表中的行加锁用于提高表锁和行锁的兼容性判断效率。行级锁记录锁 Record Lock锁定单个索引记录。间隙锁 Gap Lock锁定索引记录之间的空隙防止幻读仅在 RR 隔离级别下使用。Next-Key Lock记录锁和间隙锁的组合锁定索引记录及其前面的空隙。插入意向锁 Insert Intention Lock插入数据前设置的特殊间隙锁相互之间通常不冲突但可能与间隙锁冲突。其中间隙锁和 Next-Key Lock 是 RR 隔离级别下产生死锁的常见来源因为它会把加锁范围从一个点扩大到一段区间。2.3 死锁检测机制InnoDB 通过维护事务之间的等待关系图wait-for graph来实时检测死锁当某个事务等待锁时InnoDB 在等待图中加入一条“等待方指向持有方”的边。如果图中出现了环说明发生了循环等待即判定为死锁。检测到死锁后InnoDB 选择回滚回滚代价较小的事务通常以 undo 日志量较少为标准。被回滚的事务收到1213错误信息提示客户端重启事务。该检测机制由参数innodb_deadlock_detect控制默认开启。关闭后不再实时检测死锁事务会一直等待直到超过innodb_lock_wait_timeout默认 50 秒才报1205锁超时错误。在官方文档InnoDB Deadlock Detection中特别提到死锁检测在并发线程特别多时可能成为性能瓶颈因为每次加锁等待都要在等待图中做遍历检查。2.4 如何查看死锁信息发生死锁后最直接的排查方式是查看 InnoDB 状态SHOW ENGINE INNODB STATUS\G在输出中定位LATEST DETECTED DEADLOCK部分可以看到死锁发生的时刻、参与死锁的两个事务、各自执行的 SQL以及各自持有和等待的锁类型这是定位死锁的第一手证据。三、应用场景3.1 日常开发场景账户转账两个账户之间互相转账多个事务以不同顺序更新同一组账户时容易死锁。库存扣减同时扣减多个商品的库存且不同请求的商品顺序不一致。批量数据更新定时任务和大促活动并发更新同一批记录。多表关联更新一个事务中同时更新订单表、库存表、账户表多表加锁顺序混乱。3.2 企业真实场景在电商订单系统中典型的死锁链路是下单服务锁定商品 A 的库存行然后准备锁定商品 B 的库存行。退款服务锁定商品 B 的库存行然后准备锁定商品 A 的库存行。两个事务互相等待最终触发 InnoDB 死锁检测其中一个被回滚。这种场景在高并发秒杀、组合商品促销、账务对账系统里非常常见。单纯靠提高数据库连接数无法解决必须从加锁顺序和事务设计上入手。四、使用方式下面以“账户转账”为例演示死锁的产生以及 Java 侧如何规避和兜底。先创建测试表CREATE TABLE account ( id BIGINT PRIMARY KEY, balance DECIMAL(10, 2) NOT NULL ) ENGINE InnoDB; INSERT INTO account (id, balance) VALUES (1, 1000.00), (2, 1000.00);4.1 死锁是怎么产生的在会话 A 中执行BEGIN; UPDATE account SET balance balance - 100 WHERE id 1; -- 先锁 id1 -- 不提交稍后执行下面的语句 UPDATE account SET balance balance 100 WHERE id 2; -- 等待 id2 上的锁在会话 B 中执行BEGIN; UPDATE account SET balance balance - 100 WHERE id 2; -- 先锁 id2 UPDATE account SET balance balance 100 WHERE id 1; -- 等待 id1 上的锁会话 A 持有 id1 等待 id2会话 B 持有 id2 等待 id1形成循环等待InnoDB 随即检测到死锁并回滚其中一个事务。4.2 Java 规避方案固定加锁顺序核心思想是让所有事务都按 id 从小到大依次加锁彻底消除循环等待。示例使用 Spring 和 MyBatisService public class AccountTransferService { private final AccountMapper accountMapper; public AccountTransferService(AccountMapper accountMapper) { this.accountMapper accountMapper; } Transactional(rollbackFor Exception.class) public void transfer(Long fromId, Long toId, BigDecimal amount) { // 关键固定加锁顺序按 id 从小到大依次加锁 Long firstId Math.min(fromId, toId); Long secondId Math.max(fromId, toId); Account first lockAndGet(firstId); if (first.getBalance().compareTo(amount) 0) { throw new BizException(余额不足); } debit(first, amount); Account second lockAndGet(secondId); credit(second, amount); } private Account lockAndGet(Long accountId) { return accountMapper.selectByIdForUpdate(accountId); } private void debit(Account account, BigDecimal amount) { account.setBalance(account.getBalance().subtract(amount)); accountMapper.updateBalance(account); } private void credit(Account account, BigDecimal amount) { account.setBalance(account.getBalance().add(amount)); accountMapper.updateBalance(account); } }对应的 MyBatis Mapperpublic interface AccountMapper { Select(SELECT * FROM account WHERE id #{id} FOR UPDATE) Account selectByIdForUpdate(Param(id) Long id); Update(UPDATE account SET balance #{balance} WHERE id #{id}) int updateBalance(Account account); }4.3 Java 兜底方案死锁重试即使固定加锁顺序能规避大部分死锁仍然建议在应用层加一层重试兜底。Spring 会把 MySQL 死锁错误封装为DeadlockLoserDataAccessExceptionService public class TransferFacade { private static final int MAX_RETRY 3; private final AccountTransferService transferService; public TransferFacade(AccountTransferService transferService) { this.transferService transferService; } public void transferWithRetry(Long fromId, Long toId, BigDecimal amount) { for (int attempt 1; attempt MAX_RETRY; attempt) { try { transferService.transfer(fromId, toId, amount); return; } catch (DeadlockLoserDataAccessException e) { log.warn(发生死锁第 {} 次重试, attempt); if (attempt MAX_RETRY) { throw new BizException(系统繁忙请稍后重试, e); } sleepBackoff(attempt); } } } private void sleepBackoff(int attempt) { try { Thread.sleep(attempt * 100L); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new BizException(线程中断, e); } } }4.4 执行流程与注意事项执行流程开启事务后先对 id 较小的账户执行SELECT ... FOR UPDATE加行锁。校验余额并完成扣款、更新。再对 id 较大的账户加锁并完成入账、更新最后提交事务。注意事项SELECT ... FOR UPDATE必须在事务内执行否则语句结束锁即释放无法起到保护作用。固定顺序要求所有涉及同一组数据的事务都遵守只要有一条路径加锁顺序不一致循环等待仍可能发生。重试必须有最大次数和退避时间避免死锁高发期产生雪崩。务必保证 update 语句走索引否则可能出现全表扫描或间隙锁导致锁范围被放大。五、扩展延伸5.1 死锁与锁等待的区别维度死锁锁等待定义事务互相持有对方需要的锁形成循环等待一个事务等待另一个事务持有的锁处理方式InnoDB 主动检测并回滚一方等待直到超时后报错错误码12131205本质循环依赖无法自行解开单向等待可自行解除5.2 死锁检测的优缺点优点能够快速发现并打破死锁避免事务无限期阻塞保障系统可用性。缺点检测本身存在性能开销。官方文档指出在高并发且大量事务互相等待的场景下等待图的遍历检测可能造成明显的 CPU 争用。此时可以考虑在极端场景下临时关闭innodb_deadlock_detect改用锁等待超时兜底但要接受更长的事务阻塞时间。5.3 实际开发注意事项尽量减少事务执行时间把非数据库操作挪到事务外。同一业务内尽量使用一致的表访问顺序例如统一按主键、按部门编号排序。RR 隔离级别下间隙锁更容易引发死锁业务允许时可评估降为 RCRead Committed隔离级别。为关键更新语句设计合适索引避免锁范围扩大。对可容忍短暂失败的业务增加重试机制并设置合理上限。六、面试追问追问 1死锁和锁等待有什么区别回答思路先说本质差异再补充处理机制、错误码和业务影响。参考回答死锁是事务之间形成循环等待谁都无法主动释放锁只能靠 InnoDB 检测并回滚一方错误码是 1213锁等待是单向等待只要持有锁的事务提交或回滚等待方就能继续若超过innodb_lock_wait_timeout仍未获得锁才报 1205。死锁是循环依赖锁等待是普通的排队阻塞。追问 2InnoDB 是怎么检测到死锁的为什么有时候要关闭死锁检测回答思路围绕等待图检测讲清原理再说明关闭检测的性能考量。参考回答InnoDB 维护事务之间的等待关系图当事务等待锁时加入一条等待边一旦图中形成环就判定为死锁然后回滚 undo 量较小的事务。之所以有时关闭innodb_deadlock_detect是因为高并发大量等待时等待图遍历检测会带来明显 CPU 开销这时可以用锁等待超时替代检测代价是单次等待时间变长。追问 3把隔离级别从 RR 降到 RC 能避免死锁吗回答思路先给出“不能完全避免但能降低概率”的结论再解释原因。参考回答不能完全避免。RC 隔离级别不使用间隙锁死锁的一种常见来源消失了因此死锁概率通常会下降但记录锁之间的循环等待仍然可能存在。降低隔离级别是优化手段之一真正要从根本上规避还是需要固定加锁顺序、缩短事务和合理使用索引。追问 4应用层捕获到死锁异常后直接重试就够了吗回答思路强调重试的必要条件说明直接无限重试的风险。参考回答不够。重试必须限定最大次数并采用退避策略否则高发期会造成雪崩同时要保证被回滚事务重试时重新从头执行不会基于已失效的中间状态继续。最稳妥的做法是让重试逻辑落在事务外层事务边界清晰失败后整体重跑。追问 5如何通过 SHOW ENGINE INNODB STATUS 快速定位死锁双方回答思路说明关注点不必逐字复述输出。参考回答重点查看LATEST DETECTED DEADLOCK中列出的两个事务分别确认每个事务执行到哪条 SQL、持有和等待哪些锁再看 “WE ROLL BACK TRANSACTION” 判断哪个事务被回滚。结合事务对应的业务代码就能判断加锁顺序不一致的路径并针对性修复。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进