死锁是两个事务各拿一把锁、又各要对方那把,互相等成一个环。
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 秒实验
- 把
trial里的会话数从 10 改成 20,重跑。 随机顺序的死锁次数会明显涨——参与的人越多,撞出环的机会越多。 - 把「先改行号小的」改成「先改行号大的」(统一降序),重跑。 死锁还是 0。 顺序一致就行,方向无所谓。
- 让每条会话改三行(再多要一把锁),看随机顺序的死锁次数怎么变。 环的可能性随「每人要几把锁」快速上升,这就是为什么「事务里少碰几张表」很值。
常见误区
- 「死锁是数据库的 bug」。 不是。 它是并发系统的必然产物,任何支持并发写的库都会遇到,区别只在「怎么处理」。
- 「报
ORA-00060之后接着跑就行」。 不行。 被回滚的只是那条语句,事务还开着,剩下的改动悬在那里。 必须ROLLBACK整个事务再重做。 - 「加重试没用,该锁还是锁」。 有用。 死锁是瞬时的,重试一次,撞车的那个时刻已经过去了。 标准做法就是「捕获、等一下、重做」。
- 「把事务隔离级别调高能避免死锁」。 反了。
隔离级别越高,锁越严,死锁越多。
SERIALIZABLE下死锁比READ COMMITTED常见得多。
自测三问(点开对答案)
1. 死锁的根因能用一句话说完吗? —— **两个事务访问同一批资源,但顺序不一样。** 所有预防手段都是这句话的推论。 2. Oracle 发现死锁后回滚了什么? —— **回滚触发检测的那条语句**,报 `ORA-00060`,**事务还开着,要自己回滚。** 3. 为什么外键列没索引会引发死锁? —— **父表删一行要去子表确认引用,没索引就全表扫,扫一行锁一行**,**锁的范围被放大到整张表。** 4. 预防死锁最省事的一招是什么? —— **所有事务按同一个顺序访问资源。** 零成本,效果最好。第 13 章到此结束。事务、锁、死锁,三件事连起来就是完整的并发控制。 下一篇进第 14 章:Oracle 系统调优——从「别出错」转向「跑得快」,先讲初始化参数和 SGA。
- ☐ (本篇完,下一篇 14.1 调整初始化参数)
相关笔记
- 用锁控制并发存取
- 索引对象
- PL/SQL 异常处理
-
- 🐬 MySQL必知必会/00-索引
- 🏺 Oracle从入门到精通 · 索引