一次 MySQL 死锁排查复盘:从 updateById 异常到缺失 ID 索引

一、先明确已经发现了什么

排查起点很普通:一次更新请求报错,随后预期出现的日志没有打印。继续查看异常堆栈,发现底层错误是 MySQL 死锁。

最终形成的证据链如下:

发现能支持的结论
应用抛出 MySQL 1213 错误某个事务因死锁被回滚,不能单靠它确定另一个事务
InnoDB 报告列出双方持有和等待的锁可以确认循环等待涉及的具体索引记录
表结构中 id 没有任何索引按 id 更新不能依靠该字段的索引精确定位
请求时间线出现不同入口的重叠需要跨入口排查,不能只盯着同一请求的重试
一条路径先按关联键更新,再按 id 更新同一事务中的不同访问路径可能扩大交叉等待机会
缺少完整执行计划和连续锁快照不能断言逐条记录的扫描顺序与每把锁的取得原因

最重要的认识是:最终只匹配一行,不代表查找过程中只访问和锁定一行。

不过,“缺少索引”不等于“已经证明锁了整张表”。证据能说明到哪里,结论就写到哪里。

二、发现日志:从业务提示追到数据库异常

2.1 先看最底层异常

下面是经过示例化的应用异常:

ERROR traceId=trace-b instance=node-b operation=updateRecord

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException:
Deadlock found when trying to get lock; try restarting transaction

### The error may involve
example.persistence.SampleRecordMapper.updateById-Inline

### SQL:
UPDATE sample_record
SET value = ?, group_id = ?, bucket_id = ?
WHERE id = ? AND is_deleted = 0 AND tenant_id = ?

上层日志可能只写“请求处理失败”,甚至误写成“外部调用失败”。真正的出错位置要沿异常 cause、Mapper 方法和 SQL 查找,不能直接把 catch 中的文字当成故障来源。

随后没有打印的日志,只能证明执行链中断或路径发生变化。没有同一链路的证据时,不应将另一条日志缺失也归因于这次死锁。

2.2 区分死锁与普通锁等待超时

错误常见含义InnoDB 默认处理
1213检测到死锁回滚被选中的整个事务
1205锁等待超时通常回滚当前语句,受配置影响

上表描述的是存储引擎行为;异常继续传播后,应用框架也可能回滚整个外层事务。不能只据此决定应用层还能继续执行什么。[R5]

也不要用“异常文字包含 lock”作为统一重试依据。

三、排查第一步:先核对请求是否真的重叠

同一个请求多次失败、日志来自多个节点,很容易让人怀疑分布式锁失效。但先要核对时间。

以下是纯示例:

请求 R1:
+0.0s  开始
+1.0s  结束并记录异常

请求 R2:
+4.0s  开始
+7.0s  结束并记录异常

这段日志没有显示 R1、R2 同时执行,不能认定两者互相死锁。正确的下一步是查找与每次失败重叠的其他事务,而不是为了维持猜测强行解释分布式锁失效。

还应区分三个时间点:

  • SQL 执行结束:不意味着外层事务提交。
  • 方法体结束:在代理式事务中,提交可能发生在事务拦截器返回前。
  • 分布式锁释放:不天然等于数据库事务结束。

代理式 Spring 事务下,同类自调用不会额外经过事务代理;是否已有外层事务也要单独确认。[R7]

分布式锁通常只约束特定键、特定入口。即使它工作正常,不同入口、不同锁键仍可能访问相同数据库记录。

四、排查第二步:取得真正的死锁现场

4.1 先核对报告中的时间

常用命令:

SHOW ENGINE INNODB STATUS;

重点看 LATEST DETECTED DEADLOCK,而不是导出文件的创建时间。这个区域记录最近一次检测到的死锁,可能与最初看到的应用异常不是同一次事件。

索取应用日志时,要从报告中的死锁时间向前覆盖事务开始,再向后覆盖回滚与请求结束。例如一个事务已活跃约 30 秒,可以先获取前后各一分钟、覆盖相关实例的日志,再按需要扩展。

ACTIVE 表示事务活跃时间,不代表当前 SQL 全部耗时,也不代表持续等待锁的时间。

4.2 从报告中只提取必要的锁证据

下面是保留锁关系的教学示例。事务号、物理位置、表名均为虚构,省略了 SQL 和记录字段,不能作为生产原始日志引用:

*** (1) TRANSACTION:
TRANSACTION 10001, ACTIVE 30 sec fetching rows

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 10 page no 20 n bits 80
index idx_deleted_bucket of table `demo`.`sample_record`
trx id 10001 lock_mode X locks rec but not gap
Record lock, heap no 5

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 30 n bits 80
index GEN_CLUST_INDEX of table `demo`.`sample_record`
trx id 10001 lock_mode X locks rec but not gap waiting
Record lock, heap no 8

*** (2) TRANSACTION:
TRANSACTION 10002, ACTIVE 6 sec fetching rows

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 10 page no 30 n bits 80
index GEN_CLUST_INDEX of table `demo`.`sample_record`
trx id 10002 lock_mode X locks rec but not gap
Record lock, heap no 8

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 20 n bits 80
index idx_deleted_bucket of table `demo`.`sample_record`
trx id 10002 lock_mode X locks rec but not gap waiting
Record lock, heap no 5

*** WE ROLL BACK TRANSACTION (2)

将锁命名为:

  • S_A:行 A 在二级索引 idx_deleted_bucket 中的一条记录。
  • C_B:另一行 B 在隐藏聚簇索引 GEN_CLUST_INDEX 中的一条记录。
事务已持有正在等待
T1S_A 排他记录锁C_B 排他记录锁
T2C_B 排他记录锁S_A 排他记录锁

等待关系构成环:

T1 --等待 C_B--> T2
T2 --等待 S_A--> T1

实际分析时,需要结合物理记录中的聚簇定位信息判断 S_A、C_B 是否属于同一行,不能仅凭索引名称判断。本案例中二者对应不同记录;上面的示例为精简篇幅没有展示该字段。

锁报告还应这样读:

  • X 表示排他锁。
  • rec but not gap 表示这把锁针对记录,不包含前面的间隙。
  • 这不代表整个事务没有其他类型的锁。
  • 二级索引记录锁不等于锁住整棵索引。
  • lock struct(s) 是锁结构数量,不能直接换算成行数。
  • 报告中截断的 SQL 不能凭猜测补成“原文”。

五、排查第三步:找到重叠的两条更新路径

5.1 用相对时间表示请求关系

以下按原有排查结论重构时间关系,数值经过示例化,不含具体业务入口:

相对时间请求 A:trace-a请求 B:trace-b
+0.0s事务开始,执行按 id 更新的路径
+24.0s请求尚未结束事务开始
+25.0s 执行按 group_id 更新的路径
+29.7s 前一批更新返回,外层事务尚未提交
+30.0s仍在事务中随后的按 id 更新报死锁
+30.1s请求成功返回事务被回滚,异常向上传播

这说明应寻找不同请求之间的重叠,而不是只检查同一请求前后几次重试。

跨节点日志应按时间与 traceId 分组核对,不能把导出文件行序直接当成事件顺序。某个请求标识可能被重用,也不能仅凭一个字段归并事务。

应用 traceId 与 InnoDB 事务 ID 没有天然对应关系。若没有连接标识关联,应通过实例、时间、SQL 与参数交叉匹配,并在结论中保留归因的不确定性。

5.2 用纯数据库操作代替业务调用链

以下只是抽象结构,不包含业务方法:

事务 T1:
  按 id 更新 sample_record

事务 T2:
  按 group_id 更新 sample_record
  按 id 更新 sample_record
  提交

T2 的第一条 UPDATE 返回后,事务可能仍持有它取得的写锁;第二条 UPDATE 还会继续申请锁。

“同一张表”不等于“相同资源访问顺序”。两种 WHERE 条件可能让优化器选择不同访问路径。

但“存在 group_id 索引”也不等于“事故时肯定使用了该索引”。这仍需真实 SQL、绑定参数、表结构和执行计划支持。

六、排查第四步:检查真实 DDL,而不是相信 ORM 方法名

以下表结构是缩减后的通用示例,只保留解释问题所需的字段:

CREATE TABLE sample_record (
    id          BIGINT NOT NULL COMMENT '逻辑标识',
    group_id    BIGINT NOT NULL,
    bucket_id   BIGINT NOT NULL,
    tenant_id   BIGINT NOT NULL,
    is_deleted  TINYINT NOT NULL DEFAULT 0,
    value       INT NOT NULL DEFAULT 0,
    KEY idx_group_bucket (group_id, bucket_id),
    KEY idx_deleted_bucket (is_deleted, bucket_id)
) ENGINE = InnoDB;

检查命令:

SHOW CREATE TABLE sample_record;
SHOW INDEX FROM sample_record;

这里的关键缺陷是:id 既不是主键,也没有唯一索引或普通索引。

Java 属性叫 id、实体有 @TableId 注解、方法叫 updateById,都不能替代数据库中的主键约束。

InnoDB 没有显式主键、也没有可用的非空唯一索引时,会建立基于隐藏行 ID 的 GEN_CLUST_INDEX。二级索引携带聚簇定位信息,用来找到完整数据行。[R1]

因此,这个示例中的:

UPDATE sample_record
SET value = 1
WHERE id = 101
  AND is_deleted = 0
  AND tenant_id = 1;

不能依靠 id 索引精确定位。优化器可能利用 is_deleted 条件扫描候选范围,也可能选择其他路径;到底选什么,应查看执行计划。

SHOW INDEX 的 Cardinality 是估计值,也不能直接当作准确行数或扫描数量。

七、锁机制:扫描、加锁、更新是三个不同概念

7.1 行锁为什么会显示为索引锁

InnoDB 的记录锁作用于索引记录。聚簇索引保存完整行,二级索引保存有序键值及聚簇定位信息。R1

同一行 B 可以在不同索引中有不同条目:

名称含义
G_BB 在 idx_group_bucket 中的条目
D_BB 在 idx_deleted_bucket 中的条目
C_BB 在聚簇索引中的记录

因此,“行锁”和“索引记录锁”不是互斥概念。也不能据此认为每次更新都会锁住全部二级索引。

本案例的 S_A 属于另一行 A,不能将它直接当成 D_B。

7.2 索引里没有 id,为什么按 id 更新会锁它

如果 WHERE 同时包含 is_deleted=0,优化器可以先利用这个条件筛选候选记录,再回表判断 id 和 tenant_id。

对 UPDATE,查找与加锁过程会交织进行,并非总是在完整 WHERE 判断通过以后才申请锁。使用二级索引进行排他加锁时,还可能涉及对应的聚簇记录锁。[R3]

所以,不能说“不是最终目标的行,就绝不可能被锁”。

这里的扫描是逐步访问候选记录、逐步申请相应锁,不是存在一把名为“is_deleted=0”的锁并不断把它拉长。

7.3 非匹配记录的锁会不会提前释放

这取决于隔离级别和实际执行方式:

  • REPEATABLE READ 下,范围扫描可能保留更广的扫描锁。
  • READ COMMITTED 下,非匹配行的记录锁可以在条件判定后释放;UPDATE 还可能使用半一致读。
  • 过滤发生在哪一层、索引路径及数据库版本都会影响具体行为。[R4]

不能把“扫描可能加锁”扩大成“所有扫描记录一定锁到事务结束”。

也不能只根据 rec but not gap 推断当前连接一定使用 READ COMMITTED。

7.4 为什么开始更新下一张表时,不释放上一张表的锁

真正修改记录所持有的写锁通常保留到事务提交或回滚。上一条 UPDATE 返回、开始更新另一张表,不会自动释放这些写锁。R3

这是为了避免其他事务覆盖尚未提交的修改。它与扫描过程中释放非匹配记录的锁是两回事。

两个事务反向更新 A、B,就可能发生:

T1:已更新 A,等待 B
T2:已更新 B,等待 A

A、B 可以来自两张表,也可以来自同一张表。不能把这个跨资源示意直接当成本案例逐步扫描的事实。

如果没有显式多语句事务,也没有框架管理的外层事务,autocommit 模式下的独立语句通常各自提交,锁的存续范围就不同。[R13]

7.5 SET 中出现索引字段,不证明索引值真的改变

示例:

UPDATE sample_record
SET value = 1, bucket_id = 7
WHERE id = 101;

仅凭这条语句不能证明:

  1. bucket_id 的旧值不是 7。
  2. 已经成功执行到这条记录的更新。
  3. 某把二级索引锁就是由 bucket_id 变化产生的。

将整个实体传入 updateById,可能把本次没有主动改变的字段也放进 SET。赋值存在不等于索引键实际改变。

同样,不能凭死锁快照断言“T1 已经更新 A,然后又扫描到 B”。快照能确认当时的持有和等待关系,不记录全部历史步骤。

7.6 数据库为什么不自动让两个事务都成功

启用死锁检测时,InnoDB 会发现循环等待并选择一个事务回滚。报告中的 WE ROLL BACK TRANSACTION (2) 就是处理结果。[R14]

数据库解除的是无法推进的等待环;被回滚的请求仍然会失败。

Spring 的 @Transactional 管理事务边界,不自动提供安全的业务重放。需要重试时,应由应用明确设计范围、次数和幂等策略。[R7]

高并发增加事务交错机会,但锁等待不等于死锁。单向排队没有循环就不是死锁;三方也可以形成 T1 等 T2、T2 等 T3、T3 等 T1 的环。

八、修复过程:按证据设计,实施后再验收

本节提供通用修复步骤和示例,未执行数据库变更,也未得到修复后的测试数据。

8.1 记录环境和修复前基线

SELECT VERSION(), @@version_comment;
SELECT @@SESSION.transaction_isolation,
       @@GLOBAL.transaction_isolation;

SHOW CREATE TABLE sample_record;
SHOW INDEX FROM sample_record;

管理客户端的 SESSION 隔离级别只代表自己的连接。还要确认应用连接池、事务注解和实际 JDBC 会话配置。

对真实 UPDATE 使用相同 SQL 结构、参数类型和代表性参数查看计划。例如:

EXPLAIN FORMAT=JSON
UPDATE sample_record
SET value = 1
WHERE id = 101
  AND is_deleted = 0
  AND tenant_id = 1;

重点看索引、访问类型、估计扫描量与过滤条件。不能随意把真实更新简化成另一条 SELECT,再认定计划完全一样。

普通 EXPLAIN 和 EXPLAIN ANALYZE 不同,后者会实际执行受支持的语句。[R6]

事后执行计划反映当前结构、统计信息及参数,不自动证明事故发生时的计划。

8.2 检查数据是否满足主键条件

SELECT id, COUNT(*) AS duplicate_count
FROM sample_record
GROUP BY id
HAVING COUNT(*) > 1
LIMIT 20;

SELECT COUNT(*) AS null_count
FROM sample_record
WHERE id IS NULL;

检查覆盖全部数据,包括逻辑删除记录。LIMIT 只限制展示结果,不保证减少 GROUP BY 扫描成本。

如果 id 只在某个范围内唯一,应重新设计主键或唯一约束,不能直接加全表 id 主键。

8.3 为 id 建立符合语义的约束和访问路径

确认 id 全局唯一、非空,并完成迁移演练后,可评估:

-- 示例 DDL:仅在确认数据条件、资源与变更窗口后实施。
ALTER TABLE sample_record
    ADD PRIMARY KEY (id);

新增主键会改变聚簇数据组织,可能涉及表重建、额外磁盘占用、元数据锁与复制延迟。在线 DDL 不等于零阻塞,也不能未经核实就承诺 INSTANT。[R8]

若采用普通索引,只能改善访问路径,不能补上唯一性约束。主键、唯一索引、普通索引不是随意互换的选项。

恢复方案也不能只写一句 DROP PRIMARY KEY:删除主键同样可能有较大成本,并重新暴露访问路径问题。应提前设计备份恢复、切换和应用回退方案。

8.4 使用最小字段更新

下面是通用 MyBatis-Plus 示例,仅修改 value,不包含任何业务计算。假定 SampleRecord 映射 sample_record,提供 getId/getValue,sampleRecordMapper 是对应的 BaseMapper:

// 示例:仅写入调用方已确定的新值,避免回写整个旧实体。
sampleRecordMapper.update(
        null,
        Wrappers.<SampleRecord>lambdaUpdate()
                .eq(SampleRecord::getId, recordId)
                .set(SampleRecord::getValue, newValue)
);

需要进一步检查:

  • 最终 SQL 是否包含所需的租户与逻辑删除条件。
  • wrapper-only 更新能否按实际配置填充审计字段。
  • 是否需要版本号或条件更新,避免旧值覆盖。
  • 更新结果为 0 时的含义,以及驱动采用匹配行还是实际变更行计数。

最小字段更新可以减少无关字段回写,但 id 仍无索引时,它不能解决查找范围过大的问题。

8.5 缩短事务,统一资源访问顺序

多个入口应对相同资源采用一致的访问顺序。例如两个单行 UPDATE 都先处理 id=1,再处理 id=2,可以避免这两个操作构成反向顺序。

但 Java 集合排了序,不代表一条批量 SQL 的执行器一定按该顺序加锁,仍需核查真实执行方式。

将非必要远程调用、文件生成等耗时操作移出持锁阶段时,也要评估一致性与补偿,不能为了缩短事务破坏原子性。[R9]

8.6 在事务外编排有限重试

重试应包住完整事务,而不是只在失败位置重新执行一句 SQL:

入口不处于数据库事务中

最多尝试 3 次:
  开启一个全新的完整事务
  重新读取需要的数据
  重新计算并执行本次数据库操作
  提交成功则返回

  若执行失败:
    确认事务已退出并回滚
    不是明确可重试的死锁错误:直接抛出
    达到尝试上限:抛出并记录最终失败
    在事务外进行有上限、带随机抖动的退避
    开始下一次尝试

可以使用 TransactionTemplate 显式划分尝试边界。[R10] 默认传播行为可能加入外层事务,因此不能忽略“重试入口在事务外”的前提。

不要在已持锁的外层事务中随意用 REQUIRES_NEW 包一小段更新。被挂起的外层事务仍可能持有锁,还会改变原有原子性。

重试分类应核查底层 SQLException 的错误码,保留 cause,区分 1213、1205、重复键、连接故障和提交结果未知。不要仅凭字符串包含 Deadlock 判断,也不要将所有 SQLState 40001 视为相同业务场景。R5

8.7 外部副作用单独设计幂等

如果一次尝试已经成功调用外部服务,随后本地事务回滚,外部动作不会自动撤销。

通用设计方向是:在同一本地事务中保存数据修改与待投递记录,提交后由任务投递,并使用稳定操作标识去重。每次重试生成新的随机去重键,会破坏这种保障。

AFTER_COMMIT 可以约束事件处理时机,但本身不提供持久化可靠投递;提交后、发送前进程退出仍可能丢失动作。[R11]

这类设计应另行评审,不应为了“加重试”而默认业务已具备幂等能力。

九、用最小示例理解死锁

9.1 两个 SQL 会话即可演示循环等待

下面使用独立测试表,不涉及业务。它演示的是“两行反向更新”,不是缺失 id 索引案例的完整复现。示例未在本文整理过程中实际运行。

准备测试数据:

CREATE TABLE deadlock_demo (
    id INT NOT NULL PRIMARY KEY,
    value INT NOT NULL
) ENGINE = InnoDB;

INSERT INTO deadlock_demo (id, value) VALUES (1, 0), (2, 0);

使用两个独立连接,按以下顺序手动执行。每一步等待该语句返回,只有第 3 步应先保持等待,再到另一个连接执行第 4 步:

步骤会话 A会话 B
1BEGIN; UPDATE deadlock_demo SET value=value+1 WHERE id=1;
2 BEGIN; UPDATE deadlock_demo SET value=value+1 WHERE id=2;
3UPDATE deadlock_demo SET value=value+1 WHERE id=2; 此时等待
4 UPDATE deadlock_demo SET value=value+1 WHERE id=1; 形成循环

启用死锁检测且第 3 步尚未超时的前提下,预期其中一个事务收到 1213,另一个继续执行。不能预先保证哪一个被选中回滚。

演示结束后,在两个连接分别执行:

ROLLBACK;

被选中的事务已由 InnoDB 回滚;仍存活的事务需要显式结束。这里的 ROLLBACK 不会删除通过 CREATE TABLE 创建的测试表。

9.2 换成 MyBatis-Plus 与线程时的注意点

与上面等价的线程结构如下,属于伪代码:

线程 A:
  独立事务:更新 id=1
  通知“第一条更新完成”
  等待两个线程都完成第一条更新,超时则抛出异常回滚
  短暂 sleep
  更新 id=2

线程 B:
  独立事务:更新 id=2
  通知“第一条更新完成”
  等待两个线程都完成第一条更新,超时则抛出异常回滚
  短暂 sleep
  更新 id=1

每个线程需要自己的事务和数据库连接。CountDownLatch.await 的布尔返回值必须检查;线程中断应保留中断状态并退出;连接池也需要足够连接。

sleep 只能扩大交错窗口,不能保证执行顺序。屏障才用于确认双方已经拿到第一把锁。

若直接把这个方案套在 id 无索引的表上,第一条 UPDATE 就可能互相阻塞,双方无法到达屏障。应把“基本机制演示”和“真实访问路径复现”分开验证。

即使 SET id=id,也仍然是 UPDATE,不能当成只读操作随意在生产执行。

十、修复后的验证与运行时取证

10.1 修复验收表

检查项要验证的结果
DDL 与索引id 约束和索引实际存在,数据符合唯一性设计
真实 UPDATE 的计划访问路径与扫描范围符合预期
隔离级别使用实际应用连接核验
并发更新多轮运行并记录死锁率、等待时间和失败率
有限重试新事务执行,退避在事务外,次数和总时长有上限
字段一致性不回写无关字段,不丢失并发更新
外部副作用重试不会产生重复操作,失败可查询和恢复

没有运行这些验证,就不能写“修复后零死锁”或虚构性能提升比例。即便扫描范围得到改善,其他顺序冲突仍可能引起死锁。

10.2 查询当前锁等待

MySQL 8.0 可按版本和权限检查:

SELECT ENGINE_TRANSACTION_ID, THREAD_ID,
       INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'demo'
  AND OBJECT_NAME = 'sample_record';

SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
       w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,
       r.INDEX_NAME AS waiting_index,
       r.LOCK_MODE AS waiting_mode,
       r.LOCK_DATA AS waiting_data,
       b.INDEX_NAME AS blocking_index,
       b.LOCK_MODE AS blocking_mode,
       b.LOCK_DATA AS blocking_data
FROM performance_schema.data_lock_waits AS w
JOIN performance_schema.data_locks AS r
  ON r.ENGINE = w.ENGINE
 AND r.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
JOIN performance_schema.data_locks AS b
  ON b.ENGINE = w.ENGINE
 AND b.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID
WHERE r.OBJECT_SCHEMA = 'demo'
  AND r.OBJECT_NAME = 'sample_record';

demo 是示例库名。上述表展示当前锁等待,不是历史死锁录像。[R12]

死锁检测后事务通常迅速回滚,事后查询可能为空。持续排查时可评估启用 innodb_print_all_deadlocks,将死锁写入错误日志,并规划权限、日志容量和敏感字段处理。[R9]

十一、通用代码与评审规范

以下是从此类问题中提炼的通用建议,不引用任何公司的内部规范或工程流程。

11.1 数据库操作

  • 逻辑主键在数据库中建立对应约束,不用注释、命名或 ORM 注解代替。
  • 评审 UPDATE、DELETE 的最终 SQL 与访问计划,而不只看查询性能。
  • 更新明确指定本次所需字段,保留必要的租户、逻辑删除和审计规则。
  • 索引设计依据实际条件、唯一性和访问范围,不机械地为每列建索引。
  • 批量操作同时考虑性能、事务大小和真实锁顺序。
  • DDL 变更包含数据核验、演练、资源评估和恢复方案。

11.2 事务与代码组织

  • 接口校验、事务编排和持久化操作的职责清楚,避免隐藏事务边界。
  • 一个原子操作由明确的事务入口管理,不靠散落的注解猜测生效范围。
  • 检查代理调用、自调用、异步线程与外层事务传播。
  • 非必要耗时操作避免占用持锁时间;拆分事务时补齐一致性设计。
  • 异常包装保留 cause,不吞掉死锁后继续复用已失败事务。
  • 同一资源的多条更新路径应有一致的访问顺序约定。

11.3 重试与幂等

  • 精确分类可重试错误,完整事务重试。
  • 每次重新读数据和计算,避免沿用上一轮的可变实体。
  • 重试次数、退避和总耗时有上限。
  • 外部动作使用稳定操作标识和去重机制。
  • 最终失败可见、可告警,不用重试掩盖高频死锁。

11.4 日志与诊断

  • 记录错误码、SQLState、traceId、实例、必要参数及耗时。
  • 本地数据库失败和远程调用失败分别表达。
  • 区分 SQL 返回、事务结束和应用锁释放。
  • 在受控诊断中关联应用链路与 JDBC 连接,提高事务归因准确性。
  • 不输出凭据、个人信息、完整实体和无上限列表。
  • 发布案例时替换真实表名、类名、标识、实例地址和内部路径,并明确标注示例。

十二、排查中需要纠正的误解

容易形成的判断更准确的表达
同一个请求失败两次,所以两次互相死锁先核查时间重叠,再找到对应数据库事务
分布式锁正常就不会有数据库死锁不同入口或锁键仍可能访问交叉资源
updateById 必定按主键定位必须检查数据库实际约束与索引
索引没有 id,所以按 id 更新不会锁它其他过滤条件可形成候选扫描路径
非目标记录绝不会被锁UPDATE 查找中可能涉及非匹配记录锁
扫描到的记录一定锁到提交隔离级别与释放策略存在差异
SET 有索引字段,说明已改变索引键不能据此确定新旧值差异或执行进度
二级索引与聚簇索引互锁一定是同一行需要核对聚簇定位信息
开始更新下一张表就应释放前一张表的写锁修改记录的写锁通常保留到事务结束
MySQL 自动处理死锁,应用就不应失败数据库通过回滚一个事务解除等待环
Spring 会自动安全重试需要应用显式设计完整事务重试和幂等
加主键后永远不会死锁缩小访问范围,不排除其他循环等待

这次排查形成了可复用的方法:从底层异常出发,核对请求重叠,确定锁环,再结合 DDL、调用路径和执行计划解释机制。已确认的循环等待与尚未证实的逐行执行顺序必须分开描述。

参考资料

以下为通用机制参考,应按实际使用的 MySQL、Spring 版本核对。