☰
MySQL死锁排查与解决:Java后端面试实战指南
2026/10/12 13:17:19 网站建设 项目流程

很多年没更新博客了,后台经常收到读者私信问一些面试题和线上问题排查思路。最近一位读者问了个很有意思的问题:“线上数据库死锁了,运维直接把服务重启了,这样对吗?如果面试官问‘MySQL 死锁怎么办’,每次回答‘重启服务’会被认为没经验吗?”

说实话,这个问题在 Java 后端面试中几乎是必问的,而且非常能区分候选人的实战水平。如果只是机械地回答“重启服务”,面试官基本可以判断你没有真正处理过线上数据库问题。数据库死锁不是不可恢复的故障,而是 InnoDB 存储引擎在并发事务冲突时的一种“自我仲裁”机制。真正的问题在于:你知不知道死锁是怎么产生的?能不能快速定位到死锁的 SQL?能不能通过合理的索引设计、事务拆分、锁顺序管理来避免死锁?

本文围绕“Java 面试 + MySQL 死锁排查与解决”这条主线,从底层原理、真实场景、排查命令、Java 侧代码优化几个维度展开。全文都是基于实际项目和常见线上案例整理的完整笔记,新手可以理解事务与锁的关系,有经验的开发者可以直接参考排查 SQL 和优化方案。内容包括:四种死锁场景复现、SHOW ENGINE INNODB STATUS日志怎么看、information_schema 三张表如何定位锁、Java 代码如何通过超时控制和锁顺序来兜底。

1. 死锁是什么?先搞懂 InnoDB 锁机制

很多 Java 开发同学对“死锁”的理解停留在大学操作系统课本上的概念:“两个线程各持有一把锁,互相等待对方释放锁,导致程序永远卡住。”这个理解是对的,但不够具体。MySQL 里的死锁远比教科书例子复杂,因为它涉及行锁、间隙锁、插入意向锁、自增锁、唯一键冲突等一堆概念。

1.1 InnoDB 锁的类型

MySQL 默认存储引擎是 InnoDB,它支持事务和行级锁。行级锁听起来很细粒度、很好用,但也正因为粒度细,才容易出现多个事务在“不同行”上加锁之后互相等待。

从锁模式上看,InnoDB 有三类锁:

锁模式含义兼容性
共享锁(S锁)允许持有事务读取一行数据S 与 S 兼容,S 与 X 互斥
排他锁(X锁)允许持有事务更新或删除一行数据X 与 S、X 都与 X 互斥
意向锁(IS/IX)表级标记,表示事务准备在表中某行加 S/X 锁意向锁之间互相兼容

如果面试官问“MySQL 的锁有哪些”,你只说出共享锁和排他锁是不够的。还要补充意向锁的存在意义:意向锁是为了快速判断表级操作(如LOCK TABLES、ALTER TABLE)和行级锁是否冲突,避免逐行扫描检查。

从锁的粒度来看,还要区分记录锁、间隙锁、临键锁:

  • 记录锁(Record Lock):锁住索引记录本身。
  • 间隙锁(Gap Lock):锁住索引记录之间的间隙,防止其他事务在该间隙插入数据。
  • 临键锁(Next-Key Lock):记录锁和间隙锁的组合,锁住一个范围及其左开右闭区间。

这里需要特别提醒:如果 InnoDB 表的查询没有走索引,行锁会升级为表锁,死锁和锁等待的概率会急剧上升。

1.2 死锁产生的四个必要条件

回到最基础的概念,死锁产生需要同时满足四个条件:

  1. 互斥:资源同一时刻只能被一个事务持有。
  2. 持有并等待:事务已经持有一个锁,同时在等待另一个锁。
  3. 不可剥夺:已持有的锁不能被其他事务强制拿走。
  4. 循环等待:两个或多个事务形成闭环等待链。

MySQL 的 InnoDB 引擎会自动检测死锁。当检测到死锁时,会回滚其中一个事务(通常是代价较小的事务),并返回错误码1213和错误信息Deadlock found when trying to get lock; try restarting transaction。

所以,“死锁”在 MySQL 里并不等同于“卡死”或“服务不可用”。InnoDB 通过锁等待超时和死锁检测机制,保证数据库不会因为死锁而彻底崩溃。真正让系统变慢的,往往不是死锁本身,而是大量等待中的事务堆积、连接池耗尽、慢 SQL 放大效应。

1.3 为什么“重启服务”不能解决死锁

重启服务在极少数情况下能临时缓解现象,因为服务重启后,数据库连接池中断,未提交的事务会被回滚,锁自然释放。但问题在于:

  • 线上服务重启会导致请求中断,业务受损。
  • 死锁发生的根因(SQL 设计、索引缺失、事务过长、锁顺序不一致)完全没有被解决。
  • 重启后同一个流量模型下,死锁会再次出现。
  • 如果是多个应用实例连接同一个数据库,重启一个服务实例并不能释放其他实例持有的事务锁。

面试时你直接回答“重启服务”,等于向面试官传递两个信号:一是你没有系统性排查过数据库锁问题;二是你对事务、锁、死锁检测这些机制理解不深。正确的思路应该是:先定位死锁 SQL → 分析事务加锁顺序 → 设计合理的索引和事务边界 → 通过代码兜底重试机制 → 监控和预警。

2. 环境准备与常用排查命令

在正式分析死锁场景之前,先准备一套可以复现的实验环境。下面的环境和配置适用于本地开发测试,生产环境的版本需要根据实际项目确认,本文重点是排查思路,不绑定特定版本。

2.1 推荐实验环境

建议使用以下组合:

  • MySQL 8.0 或 5.7 版本均可,本文示例 SQL 在这两个版本通用。
  • 客户端工具:命令行mysql客户端,或 MySQL Workbench、Navicat。
  • Java 侧示例基于 Spring Boot 2.x / 3.x,JDBC 驱动mysql-connector-j版本需要与 MySQL 服务端版本匹配。
  • 为了观察锁等待,可以将事务隔离级别设置为REPEATABLE READ,这是 MySQL 默认隔离级别。

创建一张简单的用户账户表,用于演示常见的行锁死锁场景。

-- 示例表:用户账户 CREATE TABLE `t_user_account` ( `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', `user_code` VARCHAR(32) NOT NULL COMMENT '用户编号', `balance` DECIMAL(10,2) NOT NULL DEFAULT '0.00' COMMENT '账户余额', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '账户状态 1-正常 2-冻结', `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_code` (`user_code`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户账户表';

插入几条测试数据。

INSERT INTO `t_user_account` (`id`, `user_code`, `balance`) VALUES (1, 'U1001', 100.00), (2, 'U1002', 100.00), (3, 'U1003', 100.00);

2.2 查看死锁的核心命令

排查 MySQL 死锁,第一个想到的命令是:

SHOW ENGINE INNODB STATUS;

这个命令会返回 InnoDB 引擎的完整状态信息,其中LATEST DETECTED DEADLOCK部分记录了最近一次死锁的详情,包括:

  • 死锁发生的时间。
  • 涉及的事务 ID。
  • 每条事务当前持有和等待的锁。
  • 执行的 SQL 语句。
  • 被回滚的事务。

执行结果中死锁部分大致长这样:

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2024-01-15 10:23:45 *** (1) TRANSACTION: TRANSACTION 10086, ACTIVE 3 sec starting index read mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index uk_user_code of table `test`.`t_user_account` *** (2) TRANSACTION: TRANSACTION 10087, ACTIVE 2 sec starting index read mysql tables in use 1, locked 1 2 lock struct(s), heap size 1136, 1 row lock(s) *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 28 page no 4 n bits 72 index uk_user_code of table `test`.`t_user_account` *** WE ROLL BACK TRANSACTION (2)

另外两个非常有用的元数据表在information_schema库中:

-- 查看当前所有事务 SELECT * FROM information_schema.INNODB_TRX; -- 查看当前锁等待 SELECT * FROM information_schema.INNODB_LOCK_WAITS;

当线上出现锁等待或死锁时,可以综合查询事务和锁等待关系,快速确认哪个事务阻塞了哪个事务。不过随着 MySQL 版本升级,部分字段名会有调整,执行前可以先DESC一下表结构。

-- 更直观地查询阻塞关系 SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.INNODB_TRX WHERE trx_state = 'RUNNING';

3. 线上最常见的死锁场景与复现

死锁不是凭空产生的,它背后一定有一条具体的业务链路。下面从 Java 后端开发最常见的四个场景出发,逐个复现并分析加锁顺序。

3.1 场景一:两个事务以不同顺序更新同一批数据

这是最经典、最典型的一个死锁场景。假设有两条业务逻辑:

  • 业务 A:先更新用户 U1001 的余额,再更新用户 U1002 的余额。
  • 业务 B:先更新用户 U1002 的余额,再更新用户 U1001 的余额。

当业务 A 和业务 B 并发执行时,事务 A 持有 U1001 的行锁,等待 U1002 的行锁;事务 B 持有 U1002 的行锁,等待 U1001 的行锁。循环等待形成死锁。

打开两个终端窗口,模拟两个事务:

事务 1:

BEGIN; UPDATE t_user_account SET balance = balance - 10 WHERE user_code = 'U1001'; -- 模拟停顿,不要执行 COMMIT -- 此时事务 1 持有 user_code = 'U1001' 这一行的 X 锁 -- 然后尝试更新 U1002 UPDATE t_user_account SET balance = balance - 10 WHERE user_code = 'U1002'; COMMIT;

事务 2:

BEGIN; UPDATE t_user_account SET balance = balance - 10 WHERE user_code = 'U1002'; -- 模拟停顿 -- 此时事务 2 持有 user_code = 'U1002' 这一行的 X 锁 -- 然后尝试更新 U1001 UPDATE t_user_account SET balance = balance - 10 WHERE user_code = 'U1001'; COMMIT;

需要按顺序执行:先开事务 1,执行第一条 UPDATE;再开事务 2,执行第一条 UPDATE;此时两个事务各持有一行锁。然后事务 1 尝试更新 U1002,事务 2 尝试更新 U1001。InnoDB 死锁检测机制会在片刻之后检测到循环等待,一方被回滚。

根因分析:两个事务对多个资源的加锁顺序不一致,导致循环等待。

解决办法:

  • 在业务代码中约定统一的数据更新顺序。例如,先按user_code升序排序,再按顺序更新。所有线程都按同一个顺序加锁,就不会形成闭环。
  • 如果批量更新数据,可以在 Java 中先List.sort(),再逐条更新,能显著降低死锁概率。

3.2 场景二:唯一键冲突引发的死锁

唯一键冲突导致的死锁非常隐蔽,而且经常被人忽视。很多 Java 开发在insert时没有做幂等校验,而是直接插入,然后捕获 DuplicateKeyException 做后续处理。这种写法在并发场景下会引发死锁。

复现步骤:

  1. 事务 1 尝试插入一条user_code = 'U1004'的记录。
  2. 事务 2 尝试插入同一条user_code = 'U1004'的记录。
  3. 第一个插入请求会获取唯一索引的排他锁并插入成功(或处于未提交状态)。
  4. 第二个插入请求检测到唯一键冲突,会尝试获取该唯一索引上已存在记录的锁,以便判断是“真的冲突”还是“唯一键被并发事务占用了”。
  5. 此时形成等待。

在 InnoDB 中,唯一键冲突时,后插入的事务需要等待第一个事务提交或回滚,才能确定冲突是否真正存在。如果存在其他行锁交叉等待,就可能进入死锁。

解决办法:

  • 使用INSERT ... ON DUPLICATE KEY UPDATE代替先查再插。
  • 在 Java 业务中,如果无法使用数据库原生 upsert,应该用分布式锁或 Redis 锁对其他并发插入做单机/全局串行化。
  • 捕获DuplicateKeyException后不要立即继续写库,而应该做事务回滚并重新读取状态。

3.3 场景三:Gap Lock 与插入意向锁冲突

这是 MySQLREPEATABLE READ隔离级别下非常典型的问题。Gap Lock 是区间锁,锁的是“记录之间的区间”。例如,WHERE balance > 50不走唯一索引的情况下,InnoDB 会对balance索引范围内的记录和间隙都加锁,防止其他事务在这个范围内插入新数据。

当两个事务同时对一个范围加 Gap Lock,并且各自想要插入数据到对方锁定的间隙中时,就发生死锁。

举例:

表中有balance值 100、200、300 三条记录,一个范围(100, 300)。

事务 1 执行:

BEGIN; SELECT * FROM t_user_account WHERE balance BETWEEN 100 AND 300 FOR UPDATE;

此时事务 1 对 balance 范围内已有的记录和间隙加锁。

事务 2 执行同样的查询:

BEGIN; SELECT * FROM t_user_account WHERE balance BETWEEN 100 AND 300 FOR UPDATE;

在REPEATABLE READ下,这种查询用的是当前读,也会对同样的范围加 Gap Lock。此时两个事务的 Gap Lock 其实是兼容的(这个细节取决于版本和索引情况),但真正冲突发生在其中一方尝试插入新数据时。如果事务 1 插入一条 balance=150 的记录,事务 2 也插入一条 balance=150 的记录,两者分别等待对方释放 Gap Lock,就会死锁。

解决办法:

  • 如果业务对范围查询不是严格要求严谨的“可重复读快照”,可以降低隔离级别到READ COMMITTED。这个级别下 InnoDB 会禁用 Gap Lock(仅保留外键检查和唯一键检查时的必要锁)。
  • 为范围查询条件建立合适的联合索引,尽量使用精确匹配,减少区间扫描范围。
  • 如果确实需要 Gap Lock 保证安全性,那就需要通过代码将并发度串行化,或者用LOCK IN SHARE MODE的语义重新设计业务流程。

3.4 场景四:单个 SQL 语句内部锁顺序交错

有些死锁不是发生在两个事务之间,而是发生在一条 SQL 内部。例如,一条UPDATE语句使用多表关联更新,或者使用子查询更新,InnoDB 在执行过程中可能按某个顺序获取锁,而另一条 SQL 以相反顺序获取同一批行的锁。

这种案例相对少见,但排查难度最大。因为死锁日志里的 SQL 只有一条,光看 SQL 本身看不出两个事务各自的加锁轨迹。

排查建议:

  • 查看死锁日志中的TRANSACTION区块,找到每个事务持有的lock struct和等待的RECORD LOCKS。
  • 配合EXPLAIN查看 SQL 的执行计划,判断哪些索引会被用到。
  • 注意:通过EXPLAIN看到的是当前优化器选择的执行计划,实际执行时锁定的范围可能更大,特别是有范围条件时。

4. 死锁的完整排查流程

当线上真的出现死锁告警,不能慌,也别急着重启服务。下面是一套标准排查流程,可以做成 SOP 交给团队。

4.1 第一步:保存死锁现场

发生死锁时,第一时间执行:

SHOW ENGINE INNODB STATUS\G

不同的 MySQL 版本和框架版本在锁信息和字段名上存在少量差异,但这不影响整体排查思路。把输出保存到本地文件,重点找LATEST DETECTED DEADLOCK。

如果死锁频繁发生,一次一次去线上执行SHOW ENGINE INNODB STATUS效率太低。建议开启 MySQL 错误日志,并定期巡检错误日志中的死锁信息。

在 MySQL 配置文件中可以开启相关日志:

[mysqld] # 开启通用日志或者错误日志 log_error = /var/log/mysql/error.log # 慢查询日志 slow_query_log = ON slow_query_log_file = /var/log/mysql/slow-query.log long_query_time = 2

注意:通用日志和慢查询日志在生产环境要谨慎开启,并配合日志清理策略,避免磁盘被写满。

4.2 第二步:查看当前运行中的事务

SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query, trx_rows_locked, trx_rows_modified FROM information_schema.INNODB_TRX;

这个查询能帮你看到当前数据库里所有活动事务。重点观察:

  • trx_started:事务开始时间,如果很长,说明事务可能没有及时提交。
  • trx_query:事务当前正在执行的 SQL,如果为 NULL,说明事务空闲。
  • trx_rows_locked:锁定的行数,过多就要警惕。

4.3 第三步:查看锁等待关系

SELECT waiting_trx_id, waiting_lock_id, waiting_pid, blocking_trx_id, blocking_lock_id, blocking_pid FROM information_schema.INNODB_LOCK_WAITS;

在 MySQL 8.0 中,这张表可能被新的性能字典表替代,如果查询报错,可以使用:

SELECT w.BLOCKING_PID, w.BLOCKING_TRX_ID, w.BLOCKING_LOCK_ID, w.WAITING_PID, w.WAITING_TRX_ID, w.WAITING_LOCK_ID FROM performance_schema.data_lock_waits w;

根据等待关系可以找到锁的“根事务”。如果是类似“事务 A 等待事务 B,事务 B 等待事务 A”的闭环,就是死锁;如果是链式等待,说明事务 A 阻塞了后面一串事务,需要优先排查事务 A 为什么长时间不提交。

4.4 第四步:定位具体阻塞的 SQL

找到阻塞事务的trx_mysql_thread_id就是数据库连接线程 ID,通过SHOW PROCESSLIST或SELECT * FROM performance_schema.threads可以找到对应的连接信息,包括连接的客户端 IP、连接时间、当前执行状态。

SHOW FULL PROCESSLIST;

如果阻塞事务的当前 SQL 为 NULL,说明事务处于空闲状态,但事务并未提交,仍然持有锁。这在很多 Java 项目中非常常见:业务代码里一个@Transactional方法内调用了外部 HTTP 接口,网络延迟导致事务长时间不提交,行锁一直被持有,后面所有请求全部堆积。

4.5 第五步:结合日志和代码分析

数据库层面的信息只是“现象”,最终的根因往往埋在业务代码里。排查时要把以下信息放在一起:

  • 死锁日志中的 SQL 和锁等待记录。
  • 应用系统的日志时间线,看看哪些请求在同一时间点并发执行。
  • 业务代码中对应方法的事务边界、方法调用链。

重点检查代码中的三个环节:

  1. @Transactional注解的范围是否过大。
  2. 事务方法里是否有外部 RPC 调用、网络请求、文件操作。
  3. 多个业务方法是否以不同顺序更新了同一批数据。

5. Java 项目的死锁根治方案

排查清楚死锁原因后,要从代码层面彻底解决。这个环节是 Java 面试中真正的加分项,也是线上问题处理能力的最好体现。

5.1 统一锁顺序

最直接、最有效的方案是“全局约定加锁顺序”。例如,所有涉及多行更新的操作,都先按user_code排序:

/** * 批量更新用户余额,统一按 userCode 排序避免死锁 */ public void batchUpdateBalances(List<UserAccount> accounts) { // 核心:所有线程都按同样的顺序更新 accounts.sort(Comparator.comparing(UserAccount::getUserCode)); for (UserAccount account : accounts) { userAccountMapper.updateBalance(account.getUserCode(), account.getBalance()); } }

这段代码的精髓在于Comparator.comparing(UserAccount::getUserCode)。多个线程并发执行批量更新时,无论传入的用户列表顺序如何,最终都会先更新user_code较小的数据,再更新较大的数据。这样就不会形成循环等待。

5.2 缩小事务边界

在 Spring Boot 项目中,@Transactional使用不当是锁等待和死锁的重要诱因。很多开发同学在方法上直接加注解,导致整个方法内部的所有操作都在一个事务里,包括耗时最长的网络请求。

看下面的坏例子:

@Transactional public void transferWithBadDesign(String from, String to, BigDecimal amount) { UserAccount fromAccount = userAccountMapper.selectByCodeForUpdate(from); UserAccount toAccount = userAccountMapper.selectByCodeForUpdate(to); // 模拟外部接口调用,比如风控校验、短信通知 riskControlService.check(fromAccount, toAccount); boolean success = riskControlService.sendNotify(); if (!success) { throw new BusinessException("通知失败"); } userAccountMapper.decreaseBalance(from, amount); userAccountMapper.increaseBalance(to, amount); }

这段代码有两个明显问题:

  1. 加锁顺序依赖参数传入顺序。如果两个请求的方向相反(A 转 B、B 转 A),很容易死锁。
  2. 外部接口调用放在事务内,导致事务时间被无限拉长,锁被持有几十毫秒甚至几秒。

改进后的写法:

@Transactional public void transfer(String from, String to, BigDecimal amount) { // 先排序,再按顺序加锁 String[] ordered = sortKeys(from, to); UserAccount fromAccount = userAccountMapper.selectByCodeForUpdate(ordered[0]); UserAccount toAccount = userAccountMapper.selectByCodeForUpdate(ordered[1]); // 只做数据库操作 userAccountMapper.decreaseBalance(from, amount); userAccountMapper.increaseBalance(to, amount); }

外部调用、风控校验、日志记录、消息发送,全部放到事务外的其他方法中,最好通过 Spring 的事务传播机制拆分。

5.3 利用数据库层兜底:锁等待超时

即使代码层面做了大量优化,仍然无法保证 100% 避免死锁。此时可以在数据库层配置锁等待超时,让锁等待不会无限持续。

[mysqld] # 锁等待超时时间,默认是 50 秒 innodb_lock_wait_timeout = 5 # 死锁检测,默认开启 innodb_deadlock_detect = ON

生产环境中将innodb_lock_wait_timeout设置得过小会导致正常业务频繁失败;设置得过大又会导致锁等待的事务长时间堆积。一般建议先设置为 5~10 秒,根据业务 SQL 的执行耗时动态调整。

5.4 Java 侧的重试机制

由于 MySQL 检测到死锁时会回滚其中一个事务,业务系统必须对死锁异常做重试处理。Spring Boot 场景下,可以通过 AOP 统一拦截死锁异常并重试。

@Component @Aspect public class RetryDeadLockAspect { private static final Logger log = LoggerFactory.getLogger(RetryDeadLockAspect.class); private static final int MAX_RETRY_TIMES = 3; /** * 拦截所有标注了 @RetryOnDeadLock 的方法,遇到死锁后重试 */ @Around("@annotation(com.example.demo.annotation.RetryOnDeadLock)") public Object retry(ProceedingJoinPoint joinPoint) throws Throwable { int retryCount = 0; while (retryCount < MAX_RETRY_TIMES) { try { return joinPoint.proceed(); } catch (DeadlockLoserDataAccessException e) { retryCount++; log.warn("方法执行遇到死锁,准备第 {} 次重试", retryCount, e); if (retryCount >= MAX_RETRY_TIMES) { throw e; } Thread.sleep(50); } } throw new RuntimeException("重试失败"); } }

在 Spring Boot 项目中,常见的死锁异常有:

  • org.springframework.dao.DeadlockLoserDataAccessException
  • org.springframework.dao.CannotAcquireLockException
  • org.mybatis.spring.MyBatisSystemException(底层 cause 是死锁错误码 1213)

重试的前提条件是:

  • 死锁导致被回滚的事务是当前事务。
  • 当前业务操作是幂等的,或者重试不会产生重复数据。
  • 重试次数不能太多,必须设置最大次数上限。
  • 重试之间要有短暂间隔,避免雪崩式地争抢锁。

5.5 使用乐观锁避免行锁争用

某些业务场景完全不需要使用悲观锁。例如库存扣减、账户余额更新,可以直接用乐观锁实现。乐观锁不会产生死锁,因为它没有“等待其他事务释放锁”的过程。

@Transactional public boolean deductStock(Long productId, BigDecimal deductCount) { ProductStock stock = productStockMapper.selectById(productId); // 更新时带上版本号 int rows = productStockMapper.deductWithVersion( productId, deductCount, stock.getVersion() ); // rows = 1 表示更新成功,rows = 0 表示版本冲突,需要重试 return rows > 0; }

对应的 SQL 是:

UPDATE product_stock SET stock = stock - #{deductCount}, version = version + 1 WHERE product_id = #{productId} AND version = #{version} AND stock >= #{deductCount};

乐观锁的适用条件是:并发冲突频率不高,或者冲突后重试代价较小。如果并发冲突非常激烈,乐观锁会导致大量无用的重试,倒不如优化为数据库原子操作:

UPDATE product_stock SET stock = stock - #{deductCount} WHERE product_id = #{productId} AND stock >= #{deductCount};

这种原子更新在单行上执行,不涉及多行加锁,也不存在死锁问题。这也是很多高并发秒杀系统常用的扣减方案。它不需要显式地开启事务、先查后更新,而是依赖单条 SQL 的原子性。

6. 高频问题排查汇总

实际项目中,死锁和慢 SQL、锁等待经常一起出现。下面把最常见的现象、原因和处理思路整理成一张表,方便对照排查。

问题现象常见原因解决思路
应用日志出现 Deadlock found,错误码 1213两个事务加锁顺序不一致统一加锁顺序,增加 Java 重试机制
大量请求堆积,接口 RT 飙升某个事务长时间持有锁未提交检查@Transactional范围内是否有外部调用
应用启动后连接池迅速耗尽数据库锁等待时间过长,连接被占满调大锁等待前先找阻塞事务,kill 阻塞连接
SHOW ENGINE INNODB STATUS看不到死锁死锁是历史发生,当前已回滚开启日志并提前配置监控告警
SELECT ... FOR UPDATE执行慢查询未走索引,行锁升级表锁用 EXPLAIN 分析执行计划,补索引
唯一键冲突后程序卡住唯一键冲突引发锁等待使用 upsert,避免先查再插
Gap Lock 导致的死锁隔离级别是 REPEATABLE READ,范围查询降级到 READ COMMITTED 或优化索引
死锁重试后仍然失败重试没有等待时间,或同一事务内重复锁冲突增加退避时间,分析事务边界

特别注意:如果线上出现严重锁等待,需要 kill 某个连接,一定要先确认该连接对应的事务分支,避免误杀核心业务。kill 语句示例:

-- 根据 information_schema.INNODB_TRX 查询到的 trx_mysql_thread_id KILL 12345;

KILL 操作需要数据库账号具备PROCESS和SUPER权限,并且要在业务低峰期操作,尽可能先通过应用系统优雅关闭连接,而不是直接在数据库层强制 kill。

7. 生产环境最佳实践

死锁问题不是一次修复就永远消失的。真正稳妥的做法是把“预防-检测-处理-复盘”形成闭环。

7.1 代码规范层面

  • 所有更新操作必须走索引,避免行锁升级为表锁。
  • 多个表的更新顺序在代码评审中要明确约定。
  • @Transactional事务方法中禁止出现外部 HTTP 调用、短信发送、消息队列发送等耗时操作。
  • 批量更新前先排序,排序规则统一用业务主键或唯一键。
  • 尽量使用数据库原子更新,减少“先查后更新”的悲观锁依赖。

7.2 监控与告警层面

数据库死锁和锁等待必须有监控指标,推荐关注:

  • InnoDB 死锁次数。
  • 当前活动事务数。
  • 锁等待次数和平均等待时长。
  • 连接池使用率。
  • 事务平均执行时长和最大执行时长。

监控平台可以选择 Prometheus + Grafana,也可以通过 MySQL 自带的状态变量观察:

SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_waits'; SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time_avg'; SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';

如果Innodb_deadlocks持续增长,说明应用层有系统性的死锁问题,需要尽快做代码优化。

7.3 应急预案层面

线上死锁告警后,处理步骤应该固化到团队的应急预案中,示例流程如下:

  1. 先保存SHOW ENGINE INNODB STATUS输出。
  2. 根据INNODB_TRX和INNODB_LOCK_WAITS找出锁等待关系。
  3. 如果阻塞事务属于某一个慢接口,优先考虑熔断或降级该接口。
  4. 确认高风险连接后,由 DBA 或高级开发执行 KILL。
  5. 分析死锁日志,确定根因。
  6. 修复代码发布,并在监控平台上验证死锁指标下降。

7.4 数据库配置层面

不要盲目修改数据库参数。死锁问题的核心在业务代码,配置只是兜底。推荐的几项配置如下:

[mysqld] # 锁等待超时,单位秒 innodb_lock_wait_timeout = 5 # 死锁检测默认是开启的,不要随意关闭 innodb_deadlock_detect = ON # 事务隔离级别,如果业务允许可以设置为 READ COMMITTED transaction_isolation = READ-COMMITTED

如果业务对事务隔离级别要求很高,必须使用REPEATABLE READ,那就更需要在代码层面严格控制锁的粒度和顺序。

8. 总结与 Java 面试学习建议

回到开头的面试题:线上 MySQL 死锁了怎么办?如果只是回答“重启服务”,在面试官眼里基本等于“我没有排查过这个问题”。

合理的面试回答应该分层展开:

  1. 先解释死锁的本质:两个或多个事务互相持有对方需要的锁,形成循环等待。InnoDB 死锁检测机制会回滚其中一个事务。
  2. 再说排查命令:使用SHOW ENGINE INNODB STATUS查看死锁日志,用INNODB_TRX和INNODB_LOCK_WAITS定位事务和锁等待关系。
  3. 然后说解决方案:统一加锁顺序、缩小事务边界、合理设计索引、避免外部调用在事务内、数据库层设置锁等待超时、Java 侧增加重试机制。
  4. 最后补充工程经验:监控死锁次数,建立应急预案,代码评审时检查@Transactional使用。

如果这篇文章对你有帮助,可以收藏备用。也欢迎在评论区聊聊你遇到过的 MySQL 死锁场景——是两条 UPDATE 互相等待,还是插入唯一键冲突,或者 Gap Lock 的“隐蔽死锁”?排查经验往往比面试八股文更有价值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询