深夜的环形停车场里,四辆货车 T1、T2、T3、T4 各自占住一个卸货位,又都想再顺路停进下一个车位:T1 占了 1 号位想进 2 号位,T2 占了 2 号位想进 3 号位……四辆车首尾相咬,谁都不肯退让,整个停车场彻底堵死。这就是数据库世界里最常见的并发事故——死锁(Deadlock)。停车场的值班管理员有两件事要做:第一,及时发现“四辆车堵成环”的事实(死锁检测);第二,从四辆车里选一辆损失最小的请它倒车出去,让其他车先走(死锁处理)。本文就用这座环形停车场的故事,为你拆解死锁检测与处理的完整思路:从等待图检测环路,到超时兜底,再到受害者选择与不同数据库的实践差异。

岗位关联

掌握死锁的检测原理与处理策略,是数据库性能调优与高并发架构设计的基本功,也是面试和实战中避开“数据库卡死”事故的关键能力。

目录
  1. 可视化演示
  2. 死锁成因剖析
  3. 等待图检测法
  4. 等待超时策略
  5. 受害者选择
  6. 主动避免策略
  7. 数据库实现对比
  8. 性能调优实践
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示等待图环路检测死锁、超时检测机制触发回滚及牺牲者选择策略,交互切换理解数据库死锁检测与处理核心流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

等待图WFG环路检测 事务等待关系构建有向图 图中存在环即判定死锁 WFG Cycle 等待图是死锁检测的核心数据结构

死锁成因剖析

深夜环形停车场,T1 占了 1 号位、还想进 2 号位;T2 占了 2 号位、还想要 3 号位;T3 占了 3 号位、盯上 4 号位;T4 占了 4 号位、偏偏要回 1 号位。四辆车首尾相接,每辆车的路都被下一辆堵住。停车场管理员跑过去一看就明白了:这不是谁技术差,而是大家互相攥着对方要的东西不放——这就是死锁的雏形。

数据库里,事务就是司机,行锁就是车位。事务 A 更新了第 1 行(拿着 1 号位),还想更新第 2 行;事务 B 更新了第 2 行还想更新第 3 行……一旦形成一个“你等我、我等他”的圆圈,谁都无法前进。专业定义是:两个及以上事务在并发执行中,各自持有一个资源,又都在等待对方持有的资源,导致所有事务永久阻塞,称为死锁。

死锁的发生有四个必要条件:互斥、持有并等待、不可抢占、循环等待。四者同时满足才会死锁,因此所有检测与处理策略的本质,都是围绕打破其中之一做文章。比如“主动避免策略”就是打破循环等待。公式如下:

所以管理者心里有本账:只要让四辆车别“无限期攥着车位不放”,或者别让它们形成圆圈,就能从根上防住死锁。后文讲的检测、超时、回滚、排序,都是从这个思路长出来的。

死锁⟹互斥∧持有并等待∧不可抢占∧循环等待\displaystyle \text{死锁} \Longrightarrow \text{互斥} \land \text{持有并等待} \land \text{不可抢占} \land \text{循环等待}
这个公式告诉我们:死锁一旦发生,四个条件必然全部成立;反过来,只要想办法让其中一个条件不成立,死锁就不会发生。
  1. 互斥条件车位一次只能停一辆货车,行锁一次只能被一个事务持有,这是资源本身的性质。
    互斥\text{互斥}
  2. 持有并等待条件货车占着已有车位还等下一个车位,事务拿着已有锁还申请新锁——这是形成僵局的前提。
    持有并等待\text{持有并等待}
  3. 不可抢占条件占着的车位不能被别人强行挪走,数据库里的锁也不能被其他事务强行夺走,只能等持有者主动释放。
    不可抢占\text{不可抢占}
  4. 循环等待条件四辆车像串珠一样互相等待,形成闭环;没有环就没有死锁。
    循环等待\text{循环等待}
  5. 蕴含关系箭头表示“一旦死锁成立,左边成立则右边必然同时成立”,四个条件缺一不可。
    ⟹\Longrightarrow

等待图检测法

与急脾气的管理员不同,MySQL InnoDB 的管理员很精细——他在监控室挂一块白板,每当一辆车说“我等 X 车位”,就画一条从这辆车指向车位持有者的箭头。这块白板,就是等待图。箭头越多,白板上的关系越复杂,但只要箭头构成一个圈,管理员立刻就能判断:出事了。

数据库的等待图是有向图 $G_t=(V,E)$,其中 $V$ 是所有活跃事务,$E$ 是等待关系:$T_i \to T_j$ 表示 $T_i$ 在等 $T_j$ 持有的锁。事务申请锁时加一条边,获取锁或回滚时删一条边。只要途中出现有向环,就必然存在死锁。InnoDB 的做法是:每次产生新的等待边时,增量检查一次图中有没有环,属于“实时检测”。

环检测用深度优先搜索(DFS):从任意顶点出发递归遍历,如果当前顶点的某个邻居已经在“本次递归路径栈”里,说明绕了一圈又回到自己,即找到环。等待图的数学定义与判定条件如下:

管理员盯着白板,箭头一旦绕成一圈,他就立刻抓起喇叭喊:“死锁!” 这种检测方式不依赖猜测,只要图里有环就一抓一个准,这也是主流数据库的核心检测思路。

Gt=(V,E),E={(Ti,Tj)∣Ti 等待 Tj}\displaystyle G_t = (V, E), \quad E = \{ (T_i, T_j) \mid T_i \text{ 等待 } T_j \}
  1. 顶点集合 V图中每个节点是一个活跃事务,相当于白板上每辆车的名字。
    V={T1,T2,…,Tn}V = \{ T_1, T_2, \ldots, T_n \}
  2. 边集合 E每条有向边是一次“等待”记录,从等待者出发指向被等待者。
    E={(Ti,Tj)}E = \{ (T_i, T_j) \}
  3. 有向边含义$(T_i, T_j)$ 读作“$T_i$ 在等 $T_j$”,箭头方向不能画反。
    Ti→TjT_i \to T_j
  4. DFS 回溯判定深度优先遍历时,若遇到一个已经在递归栈中的顶点,说明路径绕成环,死锁成立。
    ∃ v∈递归栈,再次访问  ⇒  存在死锁环\exists\, v \in \text{递归栈,再次访问} \;\Rightarrow\; \text{存在死锁环}

等待超时策略

有些简单的停车场,管理员不看白板,只发计时小票:每辆车进入等待状态就掐表,等待超过一定秒数,不管是不是真死锁,直接请出场。这就是等待超时策略。它的逻辑非常直白——僵持太久的一定有问题,先赶走再说。

数据库里,innodb_lock_wait_timeout 就是锁等待超时参数,默认 50 秒。一个事务等待某个行锁超过该时长,数据库就让这个事务报错并回滚。优点是实现简单,不依赖任何图结构;缺点也很明显:正常的长时间事务(比如跑大报表、批量更新几百万行)可能只是排队等锁等得久,却被误判成死锁杀掉,这就是“误杀长事务”。

超时判定的公式可以写成:

急脾气管理员虽然果断,但容易误伤老实车。所以现代数据库通常把等待超时当成“兜底保险”:先用等待图精准检测,万一检测机制出问题或事务卡住,再由超时机制断后。

twait(Ti)⩾τ⇒回滚 Ti\displaystyle t_{\text{wait}}(T_i) \geqslant \tau \quad \Rightarrow \quad \text{回滚 } T_i
这条公式表示:当事务 $T_i$ 的累计等待时间超过阈值 $\tau$ 时,系统强制选择 $T_i$ 作为回滚对象,无论它是不是死锁环中的一员。
  1. 等待时间 $t_{\text{wait}}$事务从开始等某一个锁到当前时刻的累计时长,相当于小票上的秒数。
    twait(Ti)t_{\text{wait}}(T_i)
  2. 阈值 $\tau$管理员设定的等待上限,在 MySQL 中就是 innodb_lock_wait_timeout 的值,默认 50 秒。
    τ=50 秒\tau = 50\,\text{秒}
  3. 比较符号 $\geqslant$等待时间一旦达到或超过阈值,条件即触发。
    twait⩾τt_{\text{wait}} \geqslant \tau
  4. 回滚动作触发后数据库直接回滚 $T_i$,释放它持有的所有锁,让其他事务继续。
    回滚 Ti\text{回滚 } T_i

受害者选择

管理员确认死锁后,必须请一辆车退出去。请谁呢?T1 已经卸了半车货,T4 才刚停进去——当然是请 T4 退,损失最小。这个“损失最小”就是受害者选择的直觉:不是惩罚谁,而是让整个停车场付出的代价最低。

数据库里,InnoDB 实际会选择“undo log 行数最少”的事务回滚,近似理解为“已完成工作最少、回滚最便宜”的事务。更通用的代价函数可以综合三个因素:事务已运行时长 $A$、已完成的更新行数 $W$、已持有的锁数量 $R$,再给它们配上不同的权重系数。计算公式如下:

选定受害者之后,还要决定回滚到什么程度。默认是“全车倒出”——整个事务回滚,所有修改作废;有些数据库支持保存点(Savepoint),可以只回滚到保存点,保留之前的工作,这就是“回滚粒度”的差别。管理员专挑损失最小的车请走,整体代价最低,这就是死锁处理的核心智慧。

C(T)=α⋅A(T)+β⋅W(T)+γ⋅R(T)\displaystyle C(T) = \alpha \cdot A(T) + \beta \cdot W(T) + \gamma \cdot R(T)
这个公式是“回滚代价”的量化模型:哪个事务的代价函数值最小,就优先选它当受害者,因为它回滚起来最划算。
  1. 运行时长 $A(T)$事务已经执行了多久,时间越长说明它可能已经做了很多事,回滚越可惜。
    A(T)A(T)
  2. 已完成工作量 $W(T)$事务已经更新了多少行数据,行数越多,回滚需要撤销的修改越多。
    W(T)W(T)
  3. 持锁数量 $R(T)$事务当前握着多少把锁,锁越多,回滚时要释放的资源越多,影响面越大。
    R(T)R(T)
  4. 权重系数 $\alpha, \beta, \gamma$对不同因素的重视程度,比如把 $W$ 的权重调高,就更倾向于回滚“干活少”的事务。
    α,β,γ\alpha, \beta, \gamma
  5. 总代价 $C(T)$加权求和后得到该事务的回滚代价,数据库挑选 $C$ 最小者作为受害者。
    C(T)=α⋅A(T)+β⋅W(T)+γ⋅R(T)C(T) = \alpha \cdot A(T) + \beta \cdot W(T) + \gamma \cdot R(T)

主动避免策略

如果管理员更负责,他会规定所有车进入停车场后必须按“车位号从小到大”的顺序停靠:先找 1 号位、再去 2 号位、最后 3 号位。这样一来,四辆车都朝着同一个方向行驶,永远不可能绕成环。与其等死锁发生后再手忙脚乱地处理,不如从规则上让它根本发生不了。

数据库里的对应做法是“资源统一排序”(Lock Ordering):所有事务严格按照同一全局顺序获取资源。比如在代码里约定,操作多条记录时一律按主键 ID 升序加锁,先锁 id=1 再锁 id=2,而不允许某个事务先锁 id=2 再锁 id=1。这个规则直接打破了“循环等待”这一必要条件。公式化表达为:

除了资源排序,还有一种“一次性获取”的思路:把多条记录的加锁合并到一条 SELECT ... FOR UPDATE 或 UPDATE 语句中,让数据库在一瞬间同时拿到所有需要的锁,而不是逐个申请。再配合锁超时兜底,即使有事务违背约定,也不会无限期卡死。规矩定好了,环自然断。

R1≺R2≺⋯≺Rn⇒所有事务按编号升序加锁\displaystyle R_1 \prec R_2 \prec \cdots \prec R_n \quad \Rightarrow \quad \text{所有事务按编号升序加锁}
这条公式给出了一个全局的资源全序关系:只要所有事务都遵守同一顺序加锁,等待关系就只能“从小到大”单向延伸,永远画不出环。
  1. 资源全序 $\prec$给所有资源(车位、行锁)排一个全局顺序,定下“谁在前谁在后”。
    R1≺R2R_1 \prec R_2
  2. 传递链条资源按 $R_1, R_2, \ldots, R_n$ 排成一条链,不允许多叉。
    R1≺R2≺⋯≺RnR_1 \prec R_2 \prec \cdots \prec R_n
  3. 事务约束任何事务获取多个资源时,只能按这个顺序从前往后拿,不能跳序、不能逆序。
    先获取 Ri,再获取 Rj,i<j\text{先获取 } R_i \text{,再获取 } R_j, \quad i < j
  4. 反证无环假设存在循环等待 $R_i \succ R_j \succ \cdots \succ R_i$,则意味着顺序上 $R_i$ 既小于又大于自己,矛盾,所以环不可能出现。
    Ri≻Rj≻⋯≻Ri⇒矛盾R_i \succ R_j \succ \cdots \succ R_i \Rightarrow \text{矛盾}

数据库实现对比

不同停车场的规矩各不相同。MySQL InnoDB 的管理员眼疾手快,白板上一出现环就立刻处理;PostgreSQL 的管理员按固定节奏巡场,每隔一小段时间检查一次,发现死锁就向司机报错;SQL Server 管理员则每几秒做一次全面扫描,还会给每辆车标一个“牺牲优先级”,决定先请谁出场。

技术层面看:MySQL 的 InnoDB 在等待边发生变化时即时增量检测,回滚 undo 最少的事务,可用 innodb_deadlock_detect 参数开关检测功能;PostgreSQL 的锁管理器周期性检查进程间的等待关系,检测到死锁后终止其中一个事务,并向客户端返回 "deadlock detected" 错误;SQL Server 的死锁监视器周期扫描锁等待,支持用 SET DEADLOCK_PRIORITY 指定事务的牺牲优先级,并将死锁图写入错误日志供分析。

检测频率越高,发现越及时,但 CPU 开销也越大。单次环检测的复杂度是 $O(V+E)$,$V$ 是等待图的顶点数,$E$ 是边数;假设每秒检测 $f$ 次,总开销就是两者的乘积:

选哪种方案完全看业务:高并发电商系统更看重快速解除僵局,愿意为实时检测付出 CPU 成本;离线分析系统死锁极少,用低频率周期检测就足够省心。

总开销=f⋅O(V+E),f=1Δt\displaystyle \text{总开销} = f \cdot O(V + E), \quad f = \frac{1}{\Delta t}
这条公式揭示了检测频率与开销的权衡:间隔 $\Delta t$ 越短,$f$ 越大,死锁发现得越快,但消耗的计算资源也越多。
  1. 单次检测复杂度 $O(V+E)$对等待图做一次 DFS 环检测,需要遍历所有顶点和边,计算量与它们之和成正比。
    O(V+E)O(V + E)
  2. 检测频率 $f$每秒钟做几次检测,等于检测间隔 $\Delta t$ 的倒数。
    f=1Δtf = \frac{1}{\Delta t}
  3. 总开销公式频率越高,每秒被重复执行的图遍历次数越多,累计 CPU 开销线性增长。
    f⋅O(V+E)f \cdot O(V + E)
  4. 权衡启示追求实时性就调小 $\Delta t$,追求低开销就调大 $\Delta t$,没有免费午餐。
    Δt↓⇒发现更快,开销更大\Delta t \downarrow \Rightarrow \text{发现更快},\text{开销更大}

性能调优实践

管理员最终的考核标准是运营报告:停车场每晚堵死几次?每次回滚了几辆车?他需要一套指标和工具来持续监控。数据库运维也是同样道理,光会处理死锁不够,还要会量化、会调优。

常见的指标是死锁率(Deadlock Rate),定义为一个时间段内死锁次数与总事务数的比值。优化手段包括:一是缩短事务——事务越短,持锁时间越短,死锁概率越低;二是统一加锁顺序,避免应用层代码各行其是;三是减小锁范围,让 SQL 走索引、少扫行;四是在 MySQL 中调整 innodb_deadlock_detect 和 innodb_lock_wait_timeout 参数;五是查看 SHOW ENGINE INNODB STATUS 中的最近死锁日志,分析等待环后调整 SQL。死锁率公式如下:

管理者心中有指标、手里有工具,停车场就不会再彻夜堵车。从“出事了再抓环”到“让环根本形成不了”,死锁检测与处理这门功夫,最终都落在“让数据库在高并发下依然稳定可用”这一个目标上。

DLR=NdeadlockNcommit+Nrollback\displaystyle DLR = \frac{N_{\text{deadlock}}}{N_{\text{commit}} + N_{\text{rollback}}}
这是死锁率的量化指标:分子是死锁发生次数,分母是全部已结束事务数,比值越低说明系统并发健康度越高。
  1. 死锁次数 $N_{\text{deadlock}}$从错误日志或状态变量中统计出的死锁发生次数。
    NdeadlockN_{\text{deadlock}}
  2. 提交事务数 $N_{\text{commit}}$统计周期内成功提交的事务总数,代表正常完成的工作量。
    NcommitN_{\text{commit}}
  3. 回滚事务数 $N_{\text{rollback}}$统计周期内回滚的事务总数,包含死锁回滚与业务主动回滚。
    NrollbackN_{\text{rollback}}
  4. 死锁率 $DLR$两者相除得到死锁占比,是衡量并发健康度的重要指标,调优目标就是让它尽量趋近于零。
    DLR=NdeadlockNcommit+NrollbackDLR = \frac{N_{\text{deadlock}}}{N_{\text{commit}} + N_{\text{rollback}}}

AI 应用场景

电商秒杀扣库存

阶段:应用层事务设计 + 数据库死锁恢复

解决的问题:秒杀场景下,大量订单并发扣减同一批热门商品的库存。传统写法是“先扣库存、再插订单、再更新商品热度”,多个事务获取锁的顺序不一致,很容易形成环形等待,导致订单接口大面积超时。数据库的死锁检测会自动回滚其中一个事务,应用层捕获死锁异常后重试有限次数,让其他请求继续成功。

具体案例:某电商大促期间,同一商品行被 1000 个并发订单更新,InnoDB 实时检测到等待环后,自动回滚 undo 量最小的那个事务,应用捕获 1213 死锁错误并自动重试 3 次,最终只有约 0.3% 的请求需要重试,整体下单成功率保持在 99.5% 以上。

金融系统双向转账

阶段:事务回滚 + 受害者选择

解决的问题:账户 A 向 B 转账、B 向 A 转账同时发生时,两个事务分别先锁住对方要用的账户行,形成互相等待。数据库死锁检测会选择回滚代价最小(已更新行数少)的事务,让另一个事务顺利完成,避免两个账户的资金操作互相卡死。

具体案例:某支付平台在压测“用户间转账”接口时发现死锁率约 2%,定位后发现是两台应用服务器上的事务加锁顺序不一致。团队统一约定“按账户 ID 升序加锁”后,死锁率降至 0.01%,同时打开死锁日志持续监控,问题彻底闭环。

DBA 死锁监控与优化

阶段:日志分析 + 等待图复盘

解决的问题:生产环境偶尔出现“事务等待超时”告警,但不知道是谁和谁在互相等待。通过 SHOW ENGINE INNODB STATUS 查看最近一次死锁的等待环,把每条 SQL 涉及的加锁表与行数画出来,就能定位到“事务先锁 A 表再锁 B 表,另一个事务先锁 B 表再锁 A 表”的顺序冲突。

具体案例:某数据中台团队在每周巡检中,通过死锁日志发现两张宽表之间的关联更新经常成环,随后在代码规范中增加“统一先锁主表、后锁明细表”的要求,并给异常检测追加了死锁率趋势告警,三个月内死锁从每周 20 次降到 0。

Python 演示

python82 行
class DeadlockDetector:    """使用等待图检测死锁,并选择最小代价事务回滚。"""     def __init__(self):        self.wait_graph = {}      # 等待图: txn_id -> set of holder_ids        self.transactions = {}    # 事务元数据: txn_id -> {"age","work","locks"}     def add_txn(self, txn_id, age, work, locks):        self.transactions[txn_id] = {"age": age, "work": work, "locks": locks}        self.wait_graph.setdefault(txn_id, set())     def add_wait(self, waiter, holder):        self.wait_graph.setdefault(waiter, set()).add(holder)     def remove_wait(self, waiter, holder):        self.wait_graph.get(waiter, set()).discard(holder)     def detect_cycle(self):        visited = set()        path = []         def dfs(node):            visited.add(node)            path.append(node)            for nxt in self.wait_graph.get(node, ()):                if nxt not in visited:                    if dfs(nxt):                        return True                elif nxt in path:                    idx = path.index(nxt)                    self.cycle = path[idx:] + [nxt]                    return True            path.pop()            return False         for txn in list(self.wait_graph):            if txn not in visited:                if dfs(txn):                    return self.cycle        return None     def choose_victim(self, cycle):        # 回滚代价 = 已完成工作量 + 已持锁数量,越小越划算        def cost(txn_id):            t = self.transactions[txn_id]            return t["work"] + t["locks"]        return min(cycle, key=cost)     def resolve(self):        cycle = self.detect_cycle()        if not cycle:            return None        victim = self.choose_victim(cycle)        # 回滚受害者:释放它持有的资源,并删除与它相关的等待边        del self.transactions[victim]        self.wait_graph.pop(victim, None)        for holder_set in self.wait_graph.values():            holder_set.discard(victim)        return victim  def simulate():    d = DeadlockDetector()    # age, work, locks 分别代表运行时长、已更新行数、已持锁数量    for i, age in enumerate([10, 20, 30, 5], start=1):        d.add_txn("T%d" % i, age, work=i * 2, locks=1)     # 构成环: T1->T2->T3->T4->T1    d.add_wait("T1", "T2")    d.add_wait("T2", "T3")    d.add_wait("T3", "T4")    d.add_wait("T4", "T1")     cycle = d.detect_cycle()    print("检测到死锁环:", " -> ".join(cycle) + " -> " + cycle[0])    victim = d.resolve()    print("选择回滚受害者:", victim)    print("回滚后等待边:", {k: sorted(v) for k, v in d.wait_graph.items() if v})  if __name__ == "__main__":    simulate()
目的:用 Python 模拟数据库的等待图死锁检测与受害者选择——四辆“货车”互相占位等待,DFS 找到环后,选择工作量最小的 T2 回滚,死锁解除。
  1. self.wait_graph.setdefault(waiter, set()).add(holder)记录一条等待边:等待者指向持有者,对应白板上新增一个箭头。
  2. elif nxt in path:DFS 时发现邻居已经在当前递归路径里,说明绕回了自己,这就是环。
  3. self.cycle = path[idx:] + [nxt]从递归路径中截取从环起点到终点的一段,得到完整的死锁环。
  4. return min(cycle, key=cost)在所有环内事务里挑代价最小者,对应停车场管理员请“损失最小”的车退场。
  5. holder_set.discard(victim)受害者回滚后,从所有其他事务的等待集合中移除它,等待图不再指向它,死锁环随即断裂。

常见误区

误区事务卡住了就是死锁,SQL 执行慢、一直等锁、报锁等待超时,我都叫它死锁。

正解普通锁等待是一个事务在等另一个事务释放锁,只要对方释放就能继续,是“排队”;死锁是多个事务互相持有对方需要的锁,形成环形等待,靠排队永远等不到。停车场里一辆车挡路是阻塞,四辆车围着互相挡路才是死锁。

为何常见:两者的表象都是“卡住不动 + 锁等待超时”,初学阶段很难从现象区分本质。

误区等待超时实现简单又不用画图,是不是比死锁检测更高效?很多数据库默认用超时不就够了?

正解等待超时的最大问题是误杀——一个正常的大事务可能只是排队等得久,却被当成死锁回滚。等待图检测能精准识别“环”这个死锁标志,不会冤枉好人,所以被 InnoDB、PostgreSQL、SQL Server 等主流数据库用作主检测手段,超时只作兜底。

为何常见:超时机制直观“好想”,而等待图需要理解图和 DFS,初学者容易低估误杀的代价。

误区死锁影响这么大,那我把检测间隔调到最小,每秒检测几千次,不是能第一时间发现死锁吗?

正解每次环检测都要遍历等待图的顶点和边,总开销是 $f \cdot O(V+E)$。检测频率过高会占用大量 CPU,在高并发下反而拖垮数据库吞吐。正确的做法是根据死锁发生频率动态权衡,必要时甚至关闭实时检测,依赖锁等待超时兜底。

为何常见:只看到死锁的破坏性,忽略了检测本身也有成本,忘了“监控不能影响业务”这个运维常识。

误区死锁发生后,数据库回滚的只是报错的那条 SQL,我之前做的其他修改应该还保留着吧?

正解数据库通常以事务为原子单位,死锁引发的回滚大多是整个事务回滚,意味着这个事务之前所有已执行的修改全部撤销。只有显式设置保存点(Savepoint)并回滚到保存点的场景,才能保留部分工作。

为何常见:受“单条 SQL 报错只影响该语句”的经验影响,忽略了事务的原子性边界。

误区既然数据库能检测死锁、自动回滚,那我在代码里什么都不用做,等它处理完不就行了?

正解数据库只负责“回滚其中一个事务”来解除死锁,但被回滚事务的业务逻辑并不会自动重试。应用层必须捕获死锁异常(如 MySQL 的 1213 错误),做有限次重试,否则用户会看到“操作失败”。另外,死锁率过高时,优化事务的加锁顺序才是治本。

为何常见:把“数据库能自动回滚”误解为“数据库能自动完成业务”,忽略了应用与数据库的分工边界。

面试题

Q1. 死锁的四个必要条件中,“占着自己的资源,还等着别人的资源”描述的是哪一条?

单选题 · easy

  • A.互斥条件
  • B.持有并等待条件← 正确答案
  • C.不可抢占条件
  • D.循环等待条件

答案:B。持有并等待是指事务已经持有一个资源,又在申请另一个资源。互斥指资源同一时刻只能被一个事务占有;不可抢占指资源不能被强行夺走;循环等待指多个事务形成环形等待链。题目描述的是“占着 + 等着”,正是持有并等待条件,故选 B。

Q2. 等待图检测死锁时,判断死锁成立的核心标志是什么?

单选题 · easy

  • A.图中存在一条边
  • B.图中存在有向环← 正确答案
  • C.图中顶点数量超过四个
  • D.图中出现双向箭头

答案:B。等待图中有向环意味着事务们互相等待、形成闭环,谁也无法推进,这就是死锁的充要判据。而一条边只是单次等待关系,顶点多或双向箭头都不等于死锁,故选 B。

Q3. MySQL InnoDB 默认的死锁处理方式是?

单选题 · mid

  • A.双方都回滚
  • B.回滚最小事务← 正确答案
  • C.重启死锁线程
  • D.等待超时解决

答案:B。InnoDB 默认开启死锁检测(innodb_deadlock_detect=ON),检测到环后选择回滚 undo log 最少的事务,即“回滚代价最小”的事务,而不是双方都回滚。等待超时只是兜底机制,故选 B。

Q4. 使用等待超时策略时,最大的缺点是?

单选题 · mid

  • A.检测速度过慢
  • B.容易误杀长事务← 正确答案
  • C.消耗额外内存
  • D.无法解除死锁

答案:B。等待超时只看“等了多久”,不看是否真的成环。一个正常的长事务可能因为排队等锁超过阈值就被强制回滚,造成无辜误杀。它本身能解除僵局,也不消耗额外内存,缺点就是误判成本高,故选 B。

Q5. 选择死锁回滚受害者时,通常优先回滚哪类事务?

单选题 · mid

  • A.持锁数量最多的事务
  • B.回滚代价最小的事务← 正确答案
  • C.启动时间最早的事务
  • D.连接序号最小的事务

答案:B。死锁处理的目标是让整体损失最小,所以选择回滚代价最小的事务——通常是已完成工作最少、undo 最少的事务。持锁最多、启动最早、序号最小都不是数据库选择受害者的依据,故选 B。

Q6. 关于回滚粒度,以下说法正确的是?

单选题 · hard

  • A.所有数据库死锁都只回滚出错语句
  • B.回滚到保存点保留之前工作← 正确答案
  • C.部分回滚会释放全部锁资源
  • D.保存点用于加速死锁检测

答案:B。保存点(Savepoint)允许事务回滚到某个中间位置,保留该位置之前的工作,这是“部分回滚”的价值所在。数据库死锁默认回滚整个事务而非单条语句;部分回滚只释放保存点之后的锁,不会释放全部资源;保存点与死锁检测速度无关,故选 B。

Q7. 设等待图顶点数为 V、边数为 E,用 DFS 做一次完整环检测的时间复杂度是?

单选题 · extreme

  • A.O(V)
  • B.O(E)
  • C.O(V+E)← 正确答案
  • D.O(V×E)

答案:C。深度优先搜索遍历整个有向图时,每个顶点访问一次、每条边检查一次,因此单次环检测的复杂度是 O(V+E)。O(V) 只算了顶点忽略边,O(E) 只算了边忽略顶点,O(V×E) 是错误的高估,故选 C。

Q8. 下列哪些措施可以有效减少应用中的死锁?

多选题 · mid

  • A.所有事务按相同顺序访问资源← 正确答案
  • B.尽量缩短事务执行时间← 正确答案
  • C.提高隔离级别到串行化
  • D.将大事务拆分为多个小事务← 正确答案

答案:ABD。统一资源访问顺序打破循环等待,缩短事务减少持锁窗口,拆分大事务降低多资源冲突概率,三者都能降死锁。而将隔离级别提高到串行化相当于全表互斥,只会增加阻塞和死锁风险,不能减少死锁,故选 A、B、D。

Q9. 关于等待图死锁检测机制,下列说法正确的有?

多选题 · mid

  • A.能精准发现环形等待← 正确答案
  • B.需要维护等待关系边← 正确答案
  • C.检测本身有 CPU 开销← 正确答案
  • D.可以完全避免死锁发生

答案:ABC。等待图检测在事务请求锁时加边、释放锁时删边,通过找环精准识别死锁;检测需要遍历图结构,自然消耗 CPU。但它只是“发现者”而非“预防者”,死锁仍可能发生且需要回滚处理,不能完全避免,故选 A、B、C。

Q10. 关于死锁与活锁的区别,以下说法正确的有?

多选题 · hard

  • A.死锁形成循环等待环← 正确答案
  • B.活锁是让出锁导致饥饿← 正确答案
  • C.死锁需要干预才能解除← 正确答案
  • D.活锁也总是形成闭环

答案:ABC。死锁是环形等待、互不相让,必须通过回滚干预;活锁是事务反复被让出资源,永远轮不到执行,表现为“饥饿”但并没有形成闭环。活锁通常由调度策略或锁升级机制引发,比如不断被高优先级事务抢占,故 D 错误,选 A、B、C。