🏺 第13章 数据库控制
📄 一句话概括

死锁是两个事务各拿一把锁、又各要对方那把,互相等成一个环。 Oracle 会自己发现,回滚其中一个,报 ORA-00060。 预防只要一招:所有事务按同一个顺序访问资源。 这一招零成本,能消掉绝大多数死锁。

📝 本章和前面几章的关系

13.2 讲「锁让人排队」。这一篇讲「排队排成了环」。 死锁不是错误,是并发系统的必然产物——只要有两个人同时抢两把以上的锁,就有形成环的可能。 所以重点不是「消灭死锁」,是「让它少发生、发生了能自动恢复」。

一、环是怎么形成的

四步,每一步单独看都完全合法: ① A 改 1 号行,锁住 1 号;② B 改 2 号行,锁住 2 号;③ A 想改 2 号,等 B; ④ B 想改 1 号,等 A。 到第四步,A 等 B 放 2 号,B 等 A 放 1 号,谁也不肯先动。 这不是「某一句写错了」,是「顺序错了」。 把两条语句的顺序换一下,同样的代码就永远不死锁。 所以死锁的根因永远可以归结成一句话:两个事务访问同一批资源,但顺序不一样。 记住这句话,后面所有的预防手段都是它的推论。

二、Oracle 怎么处理:检测、挑一个、报错

Oracle 不是等下去,它会主动查。 一个会话等锁等了一段时间之后,就会被判成「可能在死锁」,系统顺着等待链走一圈, 走回自己就是环。 这个检测不是实时的,要等几秒——所以现象是「卡一下,然后突然报错」。 检测到环之后,Oracle 回滚【一条语句】,不是整个事务。 这一点常被说错:报 ORA-00060 之后事务其实还开着,你必须自己 ROLLBACK 或者 COMMIT。 而剩下那一半没提交的改动,如果直接 COMMIT,就只提交了一半的操作。 所以应用层的正确做法只有一个:整个事务回滚,然后重做。 报错的同时,Oracle 会写一个 trace 文件,里面把两个会话的完整等待链列出来。 19c 里在诊断目录下($ORACLE_BASE/diag/rdbms/<库名>/<实例名>/trace), 告警日志里也会留一条。 这个 trace 是排查死锁最有用的东西,它直接告诉你「是哪两条 SQL 撞上了」。

三、三个常见来源

第一,顺序不一致。 最常见,也最好改。 典型是「批量脚本按 ID 顺序改,前台程序按用户操作顺序改」,两种顺序一撞就死。 第二,外键列没建索引。 这一条很反直觉:父表删一行,Oracle 要去子表确认没有引用—— 子表那个外键列如果没有索引,就只能全表扫,扫到一行锁一行。 于是「删父表一行」实际锁住了整张子表,和另一个方向的操作一撞,就是死锁。 所以外键列一律建索引,理由不只是快。 第三,位图索引用在 OLTP 表上。 位图索引的锁粒度是「一段位图」,一段覆盖很多行。 两个事务改不同行,只要落进同一段,就互相锁住。 位图索引只适合数据仓库那种「只读、批量导入」的场景,OLTP 表上用它是自找麻烦。

四、预防:三条,按性价比排

一、同一顺序访问。 零成本,收益最大。 做法很土:改多行之前先 ORDER BY,改多表按固定的表名顺序写。 下一篇的 Demo 会实测这一招的效果——按顺序的那一组,死锁次数是 0。 二、事务写短。 别在事务里等用户点确认、别在事务里调外部接口、别在事务里跑大查询。 事务越长,持有锁的时间越长,形成环的机会越多。 三、该加的索引加上。 前面说过:没索引的 UPDATE 会锁住扫过的每一行。 锁的范围一大,死锁概率跟着涨。 所以「加索引」有三个好处:快、少锁、少死锁。 还有一条工程上的兜底:把「死锁」当成可重试的错误。 捕获 ORA-00060,等一小会儿(比如 100 毫秒),整个事务重来。 绝大多数死锁重试一次就过了——因为撞车的那个瞬间已经过去了。

五、真出现了怎么查

第一步看告警日志:搜 ORA-00060,旁边会有 trace 文件名。 第二步打开那个 trace,找 Deadlock graph 那一段—— 它会画出环,标出每个会话在等什么、持有什么。 trace 里最该看的是两样:waiter(s) 和 possible deadlock。 前者是「谁在等」,后者直接告诉你「等的是哪一行、哪个对象」。 看到两条 UPDATE 打在同一张表、条件不同,基本就实锤了「顺序不一致」。 第三步才是改代码。 改的方向永远是「让顺序一致」,不是「加锁更小心」——加锁加得再小心,顺序不一致照样死。

六、动手 Demo:交叉更新、牺牲一个、按顺序改的实测对比

死锁要两个会话才能复现,而 SQLite 的库级锁根本走不到「互相等」那一步。 所以这一篇的 Demo 用一个手写的锁管理器:记下谁持有哪一行、谁在等哪一行,顺着等待链走一圈, 走回自己就是死锁。 这个模型能真跑,而且能跑上千轮做统计—— 「按顺序改」到底能省掉多少死锁,这里给的是实测数字,不是感觉。

# 死锁:A 持 1 等 2,B 持 2 等 1
owner, waiting = {}, {}
def acquire(sess, key):
    who = owner.get(key)
    if who is None or who == sess:
        owner[key] = sess
        waiting.pop(sess, None)
        return "OK"
    waiting[sess] = key          # 记下「我在等谁」
    # 顺着等待链往下走,走回自己就是环
    s, seen = sess, []
    while s is not None and s not in seen:
        seen.append(s)
        k = waiting.get(s)
        s = None if k is None else owner.get(k)
    if s is not None:             # 有环
        for k in [k for k, v in owner.items() if v == sess]:
            del owner[k]
        waiting.pop(sess, None)
        return "ORA-00060"
    return "WAIT"

print(acquire("A", 1))   # OK
print(acquire("B", 2))   # OK
print(acquire("A", 2))   # WAIT
print(acquire("B", 1))   # ORA-00060
print(acquire("A", 2))   # OK:B 被牺牲后放锁了

实跑输出:

--------------------------------------------------------------------
第六十课:死锁——互相等,Oracle 挑一个牺牲
--------------------------------------------------------------------

  **死锁不是「锁坏了」,是两个事务各拿一把、又各要对方那把。**
  **这一课先把环画出来,再用一个能真跑的锁模型复现。**
  **最后实测「按同一顺序改表」到底能省掉多少死锁。**

  一、环是怎么形成的
  --------------------------------------------------------------------
  时刻 1      A 改 1 号行,锁住 1 号
  时刻 2      B 改 2 号行,锁住 2 号
  时刻 3      A 想改 2 号,等 B
  时刻 4      B 想改 1 号,等 A
  **到第四步,两个人都在等对方放锁,谁也不肯先放,程序就这么停住了。**
  **注意每一步单独看都合法**——没有哪一句写错了,错的是「顺序」。

  二、真跑一次交叉更新
  --------------------------------------------------------------------
  A 改 1 号行:OK
  B 改 2 号行:OK
  A 想改 2 号行:WAIT
  B 想改 1 号行:ORA-00060
  **B 拿到 `ORA-00060`,事务被回滚。**
  **Oracle 的做法是:一检测到环,立刻挑一个(走完环的那个)回滚掉,让另一个继续。**
  **B 被牺牲之后,A 再试一次就通了:OK**
  **所以死锁在 Oracle 里不是「数据库挂了」,是「有一个事务被杀了」。**
  **报 `ORA-00060` 的那个就是被选中的。**
  **应用层看到这个错,正确做法是【整个事务重做】,不是只重做失败的那一条语句。**

  三、实测:改表顺序能省掉多少死锁
  --------------------------------------------------------------------
  **10 条会话同时开事务、10 行数据、每条改两行,跑 300 轮:**
  **一组「两行随机顺序」,一组「先改行号小的」。**
  两行随机顺序        死锁 70 次,出现在 68 轮里
  先改行号小的        死锁 0 次
  **顺序一致的这一组,300 轮一次死锁都没有。**
  **这不是巧合:所有事务都按同一个顺序申请锁,就不可能形成环**——
  **环要求「有人先拿了大的、再要小的」。**
  **落成一条规矩:批量脚本里改多张表、多行,先排序再改。**
  **这一条不花任何成本,能省掉一大类半夜的告警。**

  四、SQLite 这边:只有超时,没有死锁检测
  --------------------------------------------------------------------
  **B 报的是 `database is locked`,不是 `ORA-00060`。**
  **两边根本走不到「互相等」这一步:A 一开始就拿的是库级写锁,B 连第一把都拿不到,直接超时退出。**
  **所以「死锁」在 SQLite 上几乎遇不到,它不是更安全,是并发度太低,压根没机会形成环。**

  五、预防的三条,按性价比排
  --------------------------------------------------------------------
  1. 同一顺序访问     零成本,收益最大,就是上面实测的那一招
  2. 事务写短         别在事务里等用户输入、调外部接口
  3. 该加的索引加上   没索引的 UPDATE 会锁住扫过的每一行
  **第三条容易被忽略:一条没走索引的 `UPDATE ... WHERE`,会顺次扫过整张表,扫到一行锁一行。**
  **本来只该锁 1 行,实际锁了几十万行,死锁概率和等待时间一起暴涨。**
  **所以「加索引」不只是为了快,也是为了少锁。**

第一段:A 拿 1 号、B 拿 2 号,都成功;A 要 2 号,WAIT;B 要 1 号,ORA-00060。 注意是【后动手的那个人】被牺牲——因为环是在他这里闭合的,系统顺着链一走走回了他自己。 第二段:B 被牺牲之后,A 再试就通了。 这就是「牺牲一个、放另一个过去」的实际效果。 模型里把被选中的事务整个退出了,这是简化—— 真实的 Oracle 只回滚那条语句,事务还开着,剩下的改动要你自己 ROLLBACK。 第三段是这一篇最值钱的数字:300 轮里,随机顺序 70 次死锁,按行号顺序 0 次。 这个 0 不是运气,是数学:所有事务都从小到大申请锁,就不可能有人「先拿大的、再要小的」, 环根本闭合不了。 第四段:SQLite 那边报的是 database is locked,不是 ORA-00060。 因为它压根走不到「互相等」——A 一开始就拿的是库级写锁,B 连第一把都拿不到。 所以「在 SQLite 上没遇到死锁」不能说明它更安全,只能说明它并发度低。 第五段三条预防,第一条就是上面实测的那一招。 后两条里,「该加的索引加上」最容易被当成性能优化,其实它也在防死锁。

30 秒实验

  1. 把 trial 里的会话数从 10 改成 20,重跑。 随机顺序的死锁次数会明显涨——参与的人越多,撞出环的机会越多。
  2. 把「先改行号小的」改成「先改行号大的」(统一降序),重跑。 死锁还是 0。 顺序一致就行,方向无所谓。
  3. 让每条会话改三行(再多要一把锁),看随机顺序的死锁次数怎么变。 环的可能性随「每人要几把锁」快速上升,这就是为什么「事务里少碰几张表」很值。

常见误区

  1. 「死锁是数据库的 bug」。 不是。 它是并发系统的必然产物,任何支持并发写的库都会遇到,区别只在「怎么处理」。
  2. 「报 ORA-00060 之后接着跑就行」。 不行。 被回滚的只是那条语句,事务还开着,剩下的改动悬在那里。 必须 ROLLBACK 整个事务再重做。
  3. 「加重试没用,该锁还是锁」。 有用。 死锁是瞬时的,重试一次,撞车的那个时刻已经过去了。 标准做法就是「捕获、等一下、重做」。
  4. 「把事务隔离级别调高能避免死锁」。 反了。 隔离级别越高,锁越严,死锁越多。 SERIALIZABLE 下死锁比 READ COMMITTED 常见得多。
自测三问(点开对答案) 1. 死锁的根因能用一句话说完吗? —— **两个事务访问同一批资源,但顺序不一样。** 所有预防手段都是这句话的推论。 2. Oracle 发现死锁后回滚了什么? —— **回滚触发检测的那条语句**,报 `ORA-00060`,**事务还开着,要自己回滚。** 3. 为什么外键列没索引会引发死锁? —— **父表删一行要去子表确认引用,没索引就全表扫,扫一行锁一行**,**锁的范围被放大到整张表。** 4. 预防死锁最省事的一招是什么? —— **所有事务按同一个顺序访问资源。** 零成本,效果最好。

第 13 章到此结束。事务、锁、死锁,三件事连起来就是完整的并发控制。 下一篇进第 14 章:Oracle 系统调优——从「别出错」转向「跑得快」,先讲初始化参数和 SGA。

相关笔记