读完这篇,你能做什么
能复现两行交叉加锁、读懂等待关系,并设计有界且幂等的事务重试。
学习理由、准备要求与知识点
为什么学它
锁等待和死锁会直接影响接口可靠性,重试范围错误又会制造重复副作用。
先读这些内容
知识点清单
- 记录锁
- 间隙锁
- next-key lock
- 索引访问范围
- 锁获取顺序
- 死锁检测
- 锁等待超时
- 整笔事务重试
运行前先确认MySQL8.4、InnoDB与Java17;Java异常映射源码固定Connector/J9.0.0。等待图算法为单线程教学检测,不实现InnoDB锁管理、受害者选择或真实事务回滚。
01先弄懂它是什么
锁等待和死锁不是同一件事。需要的数据暂时被别人锁住,等对方结束后可能继续,这叫锁等待;A 等 B、B 又等 A,双方都无法自行推进,才形成死锁。两个事务反着顺序修改两行就可能出现这种情况。实际还可能涉及索引记录、数据范围和多个语句,不能只看修改了几张业务表。
InnoDB 的记录锁保护索引中的记录,间隙锁限制在某个区间插入,next-key 锁则同时涉及记录和前面的间隙。真正锁住多大范围,要看隔离级别、所用索引以及等值还是范围查找。完整使用唯一索引做等值查询等情况可能缩小范围,所以“行锁”不等于每条 SQL 都只锁最终返回的那一行。
数据库检测到死锁后,通常会选一个事务整体回滚,让其他事务继续;需要重试时,应重新执行整个业务事务。锁等待超时却不一定如此:InnoDB 默认通常只撤销当前这条语句,innodb_rollback_on_timeout 等设置会改变范围。Spring 等框架还可能另外把事务标记为需要回滚。因此要同时确认数据库撤销了什么、框架准备怎样处理,不能只看异常名称就认定之前的修改全没了。
文中这几个词,先认一认
- 等待边A→B
- A 需要的资源被 B 持有,所以 A 正在等 B;箭头方向表示谁等谁。
- 死锁受害者
- 数据库为打破死锁而选择回滚的事务,应用不能假定总是固定的那一方。
- 整笔重试
- 结束已经失败的事务,再从读取、检查到修改重新执行整个业务过程。
02看看它怎么工作
在隔离InnoDB表准备id1和id2。连接A开始事务并更新1;连接B开始事务并更新2;A更新2开始等待;B再更新1可形成环。记录两个会话错误和SHOW ENGINE INNODB STATUS的最近死锁信息,再完成各自事务收尾。该实验只说明此锁顺序可死锁,不能推断真实系统中所有死锁都由相同原因产生。
等待图把每个事务作为节点,阻塞关系作为有向边。如果要增加A→B,且已存在B到A的路径,就形成环。检测只回答是否存在环;选择回滚谁还要考虑事务大小等引擎策略与实现限制。锁图会不断变化,诊断快照需要同一时间窗口,不能把不同时刻的边随意拼成假环。
重试应放在事务外层:结束失败的事务,重新创建事务,再从读取、检查到修改完整执行。业务本身要能安全重做,或用幂等规则避免重复效果。只重跑最后一条 UPDATE,可能漏掉之前已经被回滚的修改。尝试次数、两次尝试的间隔和总耗时都应有限;已经发出的邮件、外部支付,不会被数据库一并撤销,需要移到合适的事务外流程,或用可靠事件和幂等规则衔接。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
A["事务A持有记录1"] -->|"等待记录2"| B["事务B持有记录2"]
B -->|"等待记录1"| A
C["检测等待环"] --> D["回滚一个事务"]
D --> E["其他事务继续"]
D --> F["失败用例有界整笔重试"]按顺序看,每一步做了什么
- 记录隔离级别、SQL与实际索引访问路径。
- 用两个连接建立持有和等待时间线。
- 收集同一窗口的等待关系与死锁报告。
- 区分死锁和等待超时及实际回滚范围。
- 调整访问顺序、缩短事务并设计有界整笔重试。
03对照实现代码
MySQL Connector/J9.0.0 · SQLError#createSQLException
原创JDBC异常分类模型,非源码节选。只展示40类回滚与23类约束错误,省略驱动专用子类和其他分支;分类不代替服务器状态确认。
import java.sql.*;
class SourceModel {
static SQLException translate(String state,int vendorCode) {
// SQLState类别是诊断信息之一,还要保存厂商错误码。
if (state!=null&&state.startsWith("40"))
return new SQLTransactionRollbackException("transaction",state,vendorCode);
if (state!=null&&state.startsWith("23"))
return new SQLIntegrityConstraintViolationException("constraint",state,vendorCode);
// 不能把所有异常都标成可重试事务错误。
return new SQLException("database",state,vendorCode);
}
// 异常包装本身不执行数据库回滚或业务重试。
}真实createSQLException依据SQLState前缀选择异常类别,并保留厂商错误码;40分支使用驱动事务回滚异常。
约束错误、语法错误、连接错误等走不同分支,不能对所有SQLException使用同一种盲重试。
框架上层可能再次翻译异常,诊断应保留cause、SQLState和vendor code,而非只匹配中文消息文本。
驱动把异常分了类,不表示它自己检测了全部锁关系,也不证明当前连接还能接着用。下一步应结合数据库到底回滚了哪些内容,以及 Spring 等框架是否已标记回滚,再决定结束事务和重新执行的位置。
代码先调用谁,再调用谁
- 服务器错误响应
- Connector
/J取得SQLState与错误码 SQLError.createSQLException- 框架可能进一步翻译
- 外层恢复策略
04动手写一个例子
新增等待边时返回最短闭环证据
维护事务等待图。添加waiter→holder前,查找holder到waiter的最短路径;若存在,返回新增边构成的闭环,否则返回空列表。边仍登记供诊断,结束事务时移除其全部相关边。
- 输入 1
已有A→B、B→C,新增C→A- 输出 1
[C,A,B,C]
- 输入 2
只有A→B- 输出 2
无环
若新增边构成一个新环,该环必然包含这条边,去掉它后就是holder到waiter的已有路径。因此不需要每次完整枚举所有环,只搜索这个可达性问题。
BFS按边数找到最短证据路径,前驱映射用于重建。将waiter放在路径前面,就得到新增waiter→holder再沿旧路径回到waiter的闭环。
事务结束要同时移除它的出边和所有其他节点指向它的入边,否则会留下不存在的等待并误报。模型只处理给定快照,不决定谁应被回滚。
为什么这样写是对的返回非空列表时首尾为新增等待者,列表中第一条边是新增边,其余每条边都存在于添加前的图。
BFS最多访问全部节点与边,前驱和队列占V;图保存E条边,移除入边需遍历邻接集。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
public class Main {
static class WaitGraph {
final Map<String,Set<String>> edges=new HashMap<>();
List<String> add(String waiter,String holder) {
Objects.requireNonNull(waiter); Objects.requireNonNull(holder);
Map<String,String> parent=new HashMap<>();
Deque<String> queue=new ArrayDeque<>();
parent.put(holder,null); queue.add(holder);
while (!queue.isEmpty()&&!parent.containsKey(waiter)) {
String node=queue.remove();
for (String next:edges.getOrDefault(node,Set.of())) {
if (parent.containsKey(next)) continue;
// 首次访问前驱构成最短路径树。
parent.put(next,node); queue.add(next);
}
}
List<String> cycle=new ArrayList<>();
if (parent.containsKey(waiter)) {
List<String> path=new ArrayList<>();
for (String n=waiter;n!=null;n=parent.get(n)) path.add(n);
Collections.reverse(path);
cycle.add(waiter); cycle.addAll(path); // 新增边接上已有路径。
}
edges.computeIfAbsent(waiter,k->new LinkedHashSet<>()).add(holder);
edges.computeIfAbsent(holder,k->new LinkedHashSet<>());
return List.copyOf(cycle);
}
void finish(String transaction) {
edges.remove(transaction);
// 释放后还要清除别人指向它的等待关系。
for (Set<String> holders:edges.values()) holders.remove(transaction);
}
}
static void eq(Object expected,Object actual) {
if (!Objects.equals(expected,actual)) throw new AssertionError(actual);
}
public static void main(String[] args) {
WaitGraph g=new WaitGraph();
eq(List.of(),g.add("A","B"));
eq(List.of(),g.add("B","C"));
eq(List.of("C","A","B","C"),g.add("C","A"));
g.finish("B");
eq(false,g.edges.get("A").contains("B"));
eq(List.of(),g.add("A","D"));
eq(List.of("D","C","A","D"),g.add("D","C"));
WaitGraph self=new WaitGraph();
eq(List.of("X","X"),self.add("X","X"));
WaitGraph independent=new WaitGraph();
independent.add("P","Q"); eq(List.of(),independent.add("R","S"));
System.out.println("PASS 8 wait graph tests");
}
}这些特殊情况也要试一试
- 自等待形成长度一的边环;独立分量不互相误判。
- 结束事务同时清理入边出边,避免幽灵阻塞。
- 已存在环时模型仍能报告包含新边的证据,但不是全部环枚举器。
进阶选读 · 从死锁证据到重试预算
场景高并发下频繁死锁,增加重试后吞吐反而下降。
做法先按业务键和 SQL 类型归类死锁环,看看是否总是同一批记录被反向访问。让相关代码按统一顺序拿锁,缩短每次事务持锁的时间;热点竞争很强时,再限制同时处理它的请求数。需要重试时,间隔稍微错开并加入随机变化,避免大家一起再撞上,同时始终受整个请求的最终截止时间约束。
复杂度图检测成本与等待规模相关,重试增加系统实际工作量,需用每成功请求尝试数评估。
好处与代价更严格串行化降低竞争却牺牲并行度,应以业务延迟、成功率与资源消耗比较。
05项目里怎么用
死锁不是简单服务器坏了,它可能是正常并发竞争的可恢复结果,但频繁发生说明访问路径值得优化。尽量统一同类事务访问记录的顺序,保持事务短,并确保过滤条件有合适索引以减少锁范围。唯一等值和范围查询的锁行为不能用同一口诀代替。
采用整笔业务重试时,锁等待超时后要先明确回滚并结束当前事务,避免新一轮仍带着之前的修改和锁。InnoDB 默认通常只回滚超时语句;如果选择只重试这条语句,必须确认事务仍可继续、之前的业务判断也仍有效。数据库选项与 Spring 的事务标记会影响处理方式,应测试实际组合。提交时通信断开则可能让提交结果未知,不能当成已经明确回滚的死锁。
日志保存事务相关操作、错误码、等待对象和用例身份,避免泄露完整参数。重试必须有幂等业务设计或保证外部副作用尚未发生;在持锁期间调用慢HTTP会扩大等待窗口,应调整编排或采用可靠事件。
容易踩的坑
- 把锁等待与死锁混为一谈。
- 以为行锁总是只保护最终返回行。
- 死锁后只重试最后一条SQL。
- 对所有数据库错误无限重试,放大过载。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 统一访问顺序 | 减少循环等待机会。 | 需要所有相关路径遵守。 | 多行同类更新。 |
| 乐观条件更新 | 低冲突时减少长持锁读改写。 | 冲突需重新读取并重试。 | 可用版本或条件裁决的状态。 |
| 热点串行化 | 降低冲突与重试浪费。 | 限制并发吞吐。 | 高竞争且单键工作短的业务。 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01等待很久一定是死锁吗?
展开参考答案与推导
不是,可能只是持锁事务很慢而没有循环。
02超时默认一定回滚整笔事务吗?
展开参考答案与推导
不一定,需看InnoDB设置和框架事务状态,常见默认只回滚当前语句。
03异常映射类负责检测锁环吗?
展开参考答案与推导
不负责,它转换服务器错误,锁管理和检测在服务端。
换个情况,还会处理吗
01两个事务按相反主键顺序更新怎么办?
展开参考答案与推导
评估统一加锁访问顺序并缩短事务,保留有界重试。
02重试后出现重复外部通知怎么办?
展开参考答案与推导
外部副作用不受本地回滚保护,应移动到可靠事件或加入稳定业务幂等。
03超时后继续执行提交是否安全?
展开参考答案与推导
不能直接假定,检查已执行修改和事务状态,通常明确回滚当前用例再按策略重试更可控。
写代码试一试
01为何只搜holder到waiter?
- 输入 / 条件
新增A→B- 输出 / 预期
已有B到A路径才能构成新环
提示 新环必含新增边。
展开参考答案与推导
去掉新增边,剩余环就是从B回到A的路径。
02如何验证结束事务清理入边?
- 输入 / 条件
A等待B,B结束- 输出 / 预期
A不再指向B
提示 不能只删B邻接表。
展开参考答案与推导
遍历所有邻接集合移除B,并断言旧等待消失。
03怎样输出更易读的数据库证据?
- 输入 / 条件
事务节点和等待边- 输出 / 预期
带SQL与索引对象的闭环
提示 边上保存原因。
展开参考答案与推导
为每条边保存被等待锁、SQL摘要和采样时刻,重建路径时一起输出。
读完之后,独立完成
课后作业:自己做一遍
本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能复现两行交叉加锁、读懂等待关系,并设计有界且幂等的事务重试。
本篇重点加练
锁定范围受访问路径影响,事务重试又会重新执行读取和副作用,必须共同审查。
- 索引访问范围
- 整笔事务重试
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
本地
InnoDB表只有三条索引记录。画出等值和范围查询可能涉及的记录与区间,并说明需要哪些隔离及索引条件才能判断锁范围。- 提交内容
- 索引示意与条件表
- 怎样算完成
- 区分记录、间隙和组合范围
- 不脱离执行语句给固定结论
练到的知识点记录锁 · 间隙锁 · next-key lock
- 作业 02代码推演
两笔事务更新同一批账户,但一笔按余额条件扫描,另一笔按主键列表更新。分析访问路径与业务列表顺序是否必然一致。
- 提交内容
- 执行计划及锁获取假设
- 怎样算完成
- 注明索引和查询条件
- 假设与观测分开记录
练到的知识点索引访问范围 · 锁获取顺序
- 作业 03代码推演
同一业务条件因新增索引而改用另一执行计划。列出判断锁影响变化应采集的计划、扫描范围和并发插入证据。
- 提交内容
- 前后计划与锁范围假设
- 怎样算完成
- 区分逻辑谓词和实际访问
- 注明隔离与索引定义
练到的知识点索引访问范围
- 作业 04原理问答
事务重试时商品价格和用户等级已改变。定义重试是继续同一次业务承诺还是重新计算,并说明必须固定或重新读取的输入。
- 提交内容
- 输入快照契约
- 怎样算完成
- 区分技术重试和用户新请求
- 包含价格有效期
练到的知识点整笔事务重试
综合应用
- 作业 05面试场景
业务日志只有“事务失败”和 8 秒耗时。列出判断死锁、锁等待超时或客户端超时所需的错误码及数据库证据。
- 提交内容
- 错误分类和取证清单
- 怎样算完成
- 区分客户端与数据库报告
- 记录事务最终状态的确认方式
练到的知识点死锁检测 · 锁等待超时
- 作业 06设计评审
积分转移事务读取会员等级、更新两份余额并登记流水。设计失败重试的操作范围和尝试预算,说明外部通知应如何纳入验收。
- 提交内容
- 重试契约与状态图
- 怎样算完成
- 重试输入及业务身份稳定
- 包含预算耗尽和结果未知
练到的知识点整笔事务重试
- 作业 07排错实验
仅在本地测试库构造范围更新与插入竞争,用两会话记录阻塞、解除和失败事件;另运行一次可控死锁并保留错误证据。
- 提交内容
- SQL、会话轨迹与等待图
- 怎样算完成
- 每个会话有终止上限
- 实验后核对并清理残留事务
练到的知识点记录锁 · 间隙锁 · next-key lock · 索引访问范围 · 锁获取顺序 · 死锁检测 · 锁等待超时 · 整笔事务重试
- 作业 08设计评审
多个任务以相同页大小扫描未处理记录,失败后从旧页码重试。评审数据移动、锁范围和重复处理之间的联系。
- 提交内容
- 批次状态与重试评审
- 怎样算完成
- 包含本批次提交后记录退出查询集合
- 不把页码当作稳定任务身份
练到的知识点索引访问范围 · 整笔事务重试
- 作业 09排错实验
在独立本地
MySQL为相同更新条件配置两种索引方案,执行有界并发事务并记录失败后的整笔重试输入,比较访问与处理覆盖。- 提交内容
- DDL、事务脚本与报告
- 怎样算完成
- 运行及等待均有限时
- 分析正确性而非只统计异常数
练到的知识点索引访问范围 · 整笔事务重试
进阶挑战 · 掌握前置后选做
- 作业 10设计评审
多个后台任务同时扫描过期订单并更新状态,偶发互相等待。制定从执行计划、批次范围到重试行为的调查方案。
- 提交内容
- 诊断树与变更验收标准
- 怎样算完成
- 同时检查正确性和等待时间
- 包含并行任务重叠边界
练到的知识点索引访问范围 · 锁获取顺序 · 死锁检测 · 锁等待超时 · 整笔事务重试
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 11思维拓展 · 反例构造
构造新增索引改善查询耗时却未解决业务重复处理的案例,审查“性能优化已修复并发错误”的推理。
- 提交内容
- 反例及独立验收项
- 怎样算完成
- 保持业务数据一致
- 区分访问成本和重试语义
练到的知识点索引访问范围 · 整笔事务重试
- 作业 12思维拓展 · 条件变化
如果事务包含只能执行一次的硬件动作,且数据库仍可能因死锁回滚,重新定义业务完成与自动重试边界。
- 提交内容
- 外部动作状态契约
- 怎样算完成
- 说明数据库不控制的资源
- 包含结果不可判定的人工出口
练到的知识点索引访问范围 · 整笔事务重试
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
MySQL 8.4:InnoDB 锁 (新标签页)↗
阅读记录锁、间隙锁与 next-key lock,解释索引范围如何影响阻塞。
- 实践实践指南英文
MySQL 8.4:处理死锁 (新标签页)↗
阅读锁顺序、短事务与检测输出,设计可重试的事务边界。
- 深入参考文档英文
MySQL 8.4:InnoDB 错误处理 (新标签页)↗
核对死锁与锁等待超时的回滚范围,避免只重试半个业务动作。
- 深入参考文档英文
Connector/J SQLError (新标签页)↗
保留SQLState、错误码与异常类别,不靠消息猜测。