一次 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 中的一条记录。
| 事务 | 已持有 | 正在等待 |
|---|---|---|
| T1 | S_A 排他记录锁 | C_B 排他记录锁 |
| T2 | C_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_B | B 在 idx_group_bucket 中的条目 |
| D_B | B 在 idx_deleted_bucket 中的条目 |
| C_B | B 在聚簇索引中的记录 |
因此,“行锁”和“索引记录锁”不是互斥概念。也不能据此认为每次更新都会锁住全部二级索引。
本案例的 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,等待 AA、B 可以来自两张表,也可以来自同一张表。不能把这个跨资源示意直接当成本案例逐步扫描的事实。
如果没有显式多语句事务,也没有框架管理的外层事务,autocommit 模式下的独立语句通常各自提交,锁的存续范围就不同。[R13]
7.5 SET 中出现索引字段,不证明索引值真的改变
示例:
UPDATE sample_record
SET value = 1, bucket_id = 7
WHERE id = 101;仅凭这条语句不能证明:
- bucket_id 的旧值不是 7。
- 已经成功执行到这条记录的更新。
- 某把二级索引锁就是由 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 |
|---|---|---|
| 1 | BEGIN; UPDATE deadlock_demo SET value=value+1 WHERE id=1; | |
| 2 | BEGIN; UPDATE deadlock_demo SET value=value+1 WHERE id=2; | |
| 3 | UPDATE 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 版本核对。
- [R1] MySQL:Clustered and Secondary Indexes
- [R2] MySQL:InnoDB Locking
- [R3] MySQL:Locks Set by Different SQL Statements
- [R4] MySQL:Transaction Isolation Levels
- [R5] MySQL:InnoDB Error Handling
- [R6] MySQL:EXPLAIN Statement
- [R7] Spring:Using @Transactional
- [R8] MySQL:Online DDL Operations
- [R9] MySQL:How to Minimize and Handle Deadlocks
- [R10] Spring:TransactionTemplate
- [R11] Spring:Transaction-bound Events
- [R12] MySQL:The data_lock_waits Table
- [R13] MySQL:autocommit, Commit, and Rollback
- [R14] MySQL:Deadlock Detection