qinghuan
← 数据系统 / 全部笔记

单元 74 / 数据系统

P0 · 必会主线L2 · 进阶

MySQL 锁与死锁:为什么等待,失败后怎样重试

用两笔事务反向更新数据看懂死锁,再区分锁等待超时与死锁的回滚范围,决定怎样重新执行。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能复现两行交叉加锁、读懂等待关系,并设计有界且幂等的事务重试。

学习理由、准备要求与知识点

为什么学它

锁等待和死锁会直接影响接口可靠性,重试范围错误又会制造重复副作用。

先读这些内容

知识点清单

  • 记录锁
  • 间隙锁
  • 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["失败用例有界整笔重试"]

按顺序看,每一步做了什么

  1. 记录隔离级别、SQL与实际索引访问路径。
  2. 用两个连接建立持有和等待时间线。
  3. 收集同一窗口的等待关系与死锁报告。
  4. 区分死锁和等待超时及实际回滚范围。
  5. 调整访问顺序、缩短事务并设计有界整笔重试。

03对照实现代码

Connector/J将SQLState映射为异常类别 (新标签页)↗

MySQL Connector/J9.0.0 · SQLError#createSQLException

原创JDBC异常分类模型,非源码节选。只展示40类回滚与23类约束错误,省略驱动专用子类和其他分支;分类不代替服务器状态确认。

核心机制代码 · java
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 等框架是否已标记回滚,再决定结束事务和重新执行的位置。

代码先调用谁,再调用谁

  1. 服务器错误响应
  2. Connector/J取得SQLState与错误码
  3. SQLError.createSQLException
  4. 框架可能进一步翻译
  5. 外层恢复策略

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的闭环。

事务结束要同时移除它的出边和所有其他节点指向它的入边,否则会留下不存在的等待并误报。模型只处理给定快照,不决定谁应被回滚。

为什么这样写是对的返回非空列表时首尾为新增等待者,列表中第一条边是新增边,其余每条边都存在于添加前的图。

时间 单次添加O(V+E),结束事务O(V+E)额外空间 O(V+E)

BFS最多访问全部节点与边,前驱和队列占V;图保存E条边,移除入边需遍历邻接集。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 53 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能复现两行交叉加锁、读懂等待关系,并设计有界且幂等的事务重试。

本篇重点加练

锁定范围受访问路径影响,事务重试又会重新执行读取和副作用,必须共同审查。

  • 索引访问范围
  • 整笔事务重试

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    本地 InnoDB 表只有三条索引记录。画出等值和范围查询可能涉及的记录与区间,并说明需要哪些隔离及索引条件才能判断锁范围。

    提交内容
    索引示意与条件表
    怎样算完成
    • 区分记录、间隙和组合范围
    • 不脱离执行语句给固定结论

    练到的知识点记录锁 · 间隙锁 · next-key lock

  2. 作业 02代码推演

    两笔事务更新同一批账户,但一笔按余额条件扫描,另一笔按主键列表更新。分析访问路径与业务列表顺序是否必然一致。

    提交内容
    执行计划及锁获取假设
    怎样算完成
    • 注明索引和查询条件
    • 假设与观测分开记录

    练到的知识点索引访问范围 · 锁获取顺序

  3. 作业 03代码推演

    同一业务条件因新增索引而改用另一执行计划。列出判断锁影响变化应采集的计划、扫描范围和并发插入证据。

    提交内容
    前后计划与锁范围假设
    怎样算完成
    • 区分逻辑谓词和实际访问
    • 注明隔离与索引定义

    练到的知识点索引访问范围

  4. 作业 04原理问答

    事务重试时商品价格和用户等级已改变。定义重试是继续同一次业务承诺还是重新计算,并说明必须固定或重新读取的输入。

    提交内容
    输入快照契约
    怎样算完成
    • 区分技术重试和用户新请求
    • 包含价格有效期

    练到的知识点整笔事务重试

综合应用

  1. 作业 05面试场景

    业务日志只有“事务失败”和 8 秒耗时。列出判断死锁、锁等待超时或客户端超时所需的错误码及数据库证据。

    提交内容
    错误分类和取证清单
    怎样算完成
    • 区分客户端与数据库报告
    • 记录事务最终状态的确认方式

    练到的知识点死锁检测 · 锁等待超时

  2. 作业 06设计评审

    积分转移事务读取会员等级、更新两份余额并登记流水。设计失败重试的操作范围和尝试预算,说明外部通知应如何纳入验收。

    提交内容
    重试契约与状态图
    怎样算完成
    • 重试输入及业务身份稳定
    • 包含预算耗尽和结果未知

    练到的知识点整笔事务重试

  3. 作业 07排错实验

    仅在本地测试库构造范围更新与插入竞争,用两会话记录阻塞、解除和失败事件;另运行一次可控死锁并保留错误证据。

    提交内容
    SQL、会话轨迹与等待图
    怎样算完成
    • 每个会话有终止上限
    • 实验后核对并清理残留事务

    练到的知识点记录锁 · 间隙锁 · next-key lock · 索引访问范围 · 锁获取顺序 · 死锁检测 · 锁等待超时 · 整笔事务重试

  4. 作业 08设计评审

    多个任务以相同页大小扫描未处理记录,失败后从旧页码重试。评审数据移动、锁范围和重复处理之间的联系。

    提交内容
    批次状态与重试评审
    怎样算完成
    • 包含本批次提交后记录退出查询集合
    • 不把页码当作稳定任务身份

    练到的知识点索引访问范围 · 整笔事务重试

  5. 作业 09排错实验

    在独立本地 MySQL 为相同更新条件配置两种索引方案,执行有界并发事务并记录失败后的整笔重试输入,比较访问与处理覆盖。

    提交内容
    DDL、事务脚本与报告
    怎样算完成
    • 运行及等待均有限时
    • 分析正确性而非只统计异常数

    练到的知识点索引访问范围 · 整笔事务重试

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    多个后台任务同时扫描过期订单并更新状态,偶发互相等待。制定从执行计划、批次范围到重试行为的调查方案。

    提交内容
    诊断树与变更验收标准
    怎样算完成
    • 同时检查正确性和等待时间
    • 包含并行任务重叠边界

    练到的知识点索引访问范围 · 锁获取顺序 · 死锁检测 · 锁等待超时 · 整笔事务重试

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造新增索引改善查询耗时却未解决业务重复处理的案例,审查“性能优化已修复并发错误”的推理。

    提交内容
    反例及独立验收项
    怎样算完成
    • 保持业务数据一致
    • 区分访问成本和重试语义

    练到的知识点索引访问范围 · 整笔事务重试

  2. 作业 12思维拓展 · 条件变化

    如果事务包含只能执行一次的硬件动作,且数据库仍可能因死锁回滚,重新定义业务完成与自动重试边界。

    提交内容
    外部动作状态契约
    怎样算完成
    • 说明数据库不控制的资源
    • 包含结果不可判定的人工出口

    练到的知识点索引访问范围 · 整笔事务重试

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗