2022 年我做一个电商的下单系统,下单时要在一个事务里挨个扣减购物车里每个商品的库存。第一版我做得很省事:拿到购物车的商品列表,就按列表里的顺序,一个个 UPDATE 过去。本地我下了几十单测了测——真不错:库存扣得准、订单也对得上。我心里很踏实:"事务嘛,把要改的几行包进一个 BEGIN…COMMIT,数据库自己会保证一致。"可等这个系统真正上线、扛起大促的并发下单,一串问题冒了出来。第一种最先把我打懵:后台日志里,时不时蹦出一行"Deadlock found when trying to get lock; try restarting transaction",对应的那一单,事务被数据库直接回滚了,用户看到的是"下单失败"。第二种:这种报错平时一天就几条,可一到流量高峰,它就成片成片地冒出来,失败率肉眼可见地往上窜。第三种最隐蔽:我加了个 catch,逮到死锁就重试一次,失败率确实降了下去——可我心里清楚,根上的东西一点没动,只是把错误从用户眼前藏了起来。第四种最致命:有一次重试的逻辑写得不对,一笔订单的库存被扣了两次。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"死锁是偶发的、随机的,是运气不好,加个重试就行了"。这句话把死锁当成了一种无法预防、只能事后补救的意外。可它不是。死锁不是运气问题,它有一个确定的、可以被讲清楚的成因:两个事务,各自持有了对方想要的锁,又都在等对方先放手——它们绕成了一个谁也走不出去的环。而这个环之所以会形成,几乎总是因为同一个原因:不同的事务,以不一致的顺序去获取同一组锁。真正治住死锁,核心不是"加个重试把错误藏起来",而是理解死锁是循环等待、根因是加锁乱序、用统一的加锁顺序去根治它、再用短事务和精确索引去缩小锁的范围。这篇文章就把数据库的死锁梳理一遍:为什么"加个重试就行"是错的、怎么读懂一次死锁、怎么用统一的加锁顺序根治它、怎么缩小锁的范围、怎么用乐观锁降低冲突,以及死锁监控、重试幂等、隔离级别这些把死锁真正治住要避开的坑。
问题背景
先把那串问题的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。
现象:把购物车按列表顺序逐个扣库存之后,上线冒出一串问题:并发下单时,日志里时不时蹦出 Deadlock 报错,对应的事务被回滚、用户看到下单失败;流量一高,死锁就成片出现,失败率飙升;加了重试后失败率降了,可根因没动,只是把错误藏了起来;还因为重试逻辑不当,出过库存被重复扣减的事故。
我当时的错误认知:"死锁是偶发的、随机的,是运气不好,加个 catch 重试一下就行了。"
真相:死锁(deadlock)不是一种随机的意外,而是一个有确定成因的、可以被预防的工程问题。它的本质是循环等待:事务 A 持有了资源 1 的锁、正在等资源 2 的锁;事务 B 持有了资源 2 的锁、正在等资源 1 的锁——两个事务各自攥着对方想要的东西,又都不肯先松手,就绕成了一个环。数据库(以 InnoDB 为例)会主动检测出这个环,然后挑一个事务回滚掉(通常选"代价较小"的那个),让另一个得以继续——你日志里看到的那行报错,就是被选中回滚的那个事务发出的。关键在于:这个环之所以会形成,几乎总是因为不同的事务以不一致的顺序去锁同一组资源。所以"加个重试"只是让被回滚的事务再撞一次运气,它没有消除那个环的成因——并发一高,死锁照样成片地来。
要把数据库的死锁治住,需要几块认知:
- 为什么"加个重试就行"是错的——死锁是循环等待,重试不除根因;
- 读懂一次死锁——用 InnoDB 状态看清两个事务卡在哪;
- 根治死锁——让所有事务按同一个顺序加锁,环就无法形成;
- 缩小锁的范围——事务要短、UPDATE 要精确命中索引;
- 乐观锁、热点拆分、重试幂等、监控这些工程坑怎么处理。
一、为什么"加个重试就行了"是错的
先把这件最根本的事钉死:死锁从来不是"运气不好"。它是一个纯粹由"加锁顺序"决定的、确定性的结果。你可以这样想:每个事务在执行过程中,会一把一把地去拿它需要的锁;只要存在两个事务,它们拿同一组锁的先后顺序是相反的,那么在并发足够高、时机足够巧的那一刻,它们就必然会一个拿到了 A 等着 B、另一个拿到了 B 等着 A——环就此闭合。本地测不出来,不是因为本地"运气好",而是因为本地并发低,两个事务"恰好交错"的那个时机没被撞上。并发一上来,这个时机每秒都在被反复制造。
下面这段代码,就是我那个"上线就成片死锁"的第一版:
# 反面教材:一次下单含多个商品,按购物车里的顺序逐个扣库存
def place_order(conn, cart_items):
"""cart_items:用户购物车里的商品 id 列表,顺序由用户决定"""
with conn.begin(): # 开启事务
for item_id in cart_items: # 直接按列表原始顺序遍历
conn.execute(
"UPDATE items SET stock = stock - 1 WHERE id = %s",
(item_id,))
# 破绽一:用户 A 的购物车是 [101, 102],用户 B 的是 [102, 101]。
# 破绽二:A 锁住 101 再等 102,B 锁住 102 再等 101 —— 绕成环,死锁。
# 破绽三:购物车顺序由用户决定,死锁迟早会撞上,根本不是运气。
这段代码在本地下几十单测试时表现完美,因为本地几乎没有并发,两个订单"同时各锁一半、再互相等"的时机压根没出现。它的问题不在代码本身,而在一个被忽略的前提:它默认"按什么顺序加锁都无所谓,反正最后都能锁到"。可一旦有并发,加锁顺序就是死锁的唯一开关。于是那串问题就有了解释:偶发死锁,是因为两个购物车顺序相反的订单恰好并发了;高峰成片死锁,是因为并发越高,这种"恰好"出现得越频繁;重试治标不治本,是因为重试只是让被回滚的事务再跑一遍,那个乱序加锁的环依然在那。问题的根子清楚了:治住死锁的工程量,全在"承认死锁是加锁顺序的确定后果"之后——你不去管加锁顺序,就只能眼看着死锁在高峰期成片地来。先从看清一次死锁到底长什么样说起。
二、读懂一次死锁:两个事务到底卡在哪
要治死锁,先得看清它的现场。死锁发生后,InnoDB 会把最近一次死锁的完整经过记下来,你可以立刻查出来:
-- 死锁发生后,在 MySQL 上立刻执行,查看最近一次死锁的全貌
SHOW ENGINE INNODB STATUS\G
-- 输出里的 LATEST DETECTED DEADLOCK 段落,是破案的关键:
-- *** (1) TRANSACTION: 事务 1 正在执行什么 SQL
-- *** (1) WAITING FOR THIS LOCK TO BE GRANTED: 事务 1 卡在等哪个锁
-- *** (2) TRANSACTION: 事务 2 正在执行什么 SQL
-- *** (2) HOLDS THE LOCK(S): 事务 2 握着事务 1 想要的锁
-- *** (2) WAITING FOR THIS LOCK TO BE GRANTED: 事务 2 又在等事务 1 的锁
-- *** WE ROLL BACK TRANSACTION (1): 数据库最终回滚了事务 1
这段输出要这样读:先看两个 TRANSACTION 各自在跑什么 SQL,再看一个"HOLDS"(握着什么)、一个"WAITING"(等什么)——你会发现,事务 1 等的,正是事务 2 握着的;事务 2 等的,正是事务 1 握着的。这个"互相等"的闭环,就是死锁的全部真相。把这两条 SQL 拎出来,对照代码,几乎一定能看到它们锁同一组行的顺序是反的。下面这张图,把一次死锁从形成到被打破的过程串起来:
这里的认知要点是:每一次死锁,日志里都白纸黑字写着"谁握着什么、谁在等什么"。它不是一团没法分析的随机噪声,而是一份完整的、可复盘的案发记录。你只要肯去读这份记录,就能定位到那两段以相反顺序加锁的代码——而那,就是你要去改的地方。现场看懂了,下一步就是动手根治:让环根本无法形成。
三、根治死锁:让所有事务按同一顺序加锁
死锁的环,需要"加锁顺序不一致"才能闭合。那么反过来——只要让所有事务,锁同一组资源时,都遵守一个完全相同的顺序,环就永远无法形成。因为如果大家都"先锁小号、再锁大号",那么任意两个事务之间的等待关系永远是单向的(等的人,一定在等一个号更大的锁),单向的等待,绕不成环。落到代码上,做法朴素到只有一行关键改动:加锁之前,先给要锁的 id 排序:
# 根治:扣库存前,先把商品 id 排序,统一所有事务的加锁顺序
def place_order_safe(conn, cart_items):
"""不管购物车原始顺序如何,一律按 id 升序逐个加锁。"""
# 关键的一行:排序 —— 给所有并发事务一个完全一致的加锁顺序
ordered = sorted(set(cart_items))
with conn.begin():
for item_id in ordered:
# 所有订单都按 id 升序加锁:等待关系永远单向,不会成环
conn.execute(
"UPDATE items SET stock = stock - 1 WHERE id = %s",
(item_id,))
# 用户 A 的 [101,102] 和 B 的 [102,101],排序后都变成 [101,102]
# 再也不会出现"一个先锁 101、一个先锁 102"的相反顺序
如果你是用 SELECT … FOR UPDATE 一次性锁多行,也要小心:用 IN 列表锁多行时,加锁的先后顺序并不保证,务必带上 ORDER BY,把顺序钉死:
-- 危险:IN 列表的加锁顺序不保证,两个事务可能反着锁
SELECT * FROM items WHERE id IN (102, 101) FOR UPDATE;
-- 正确:带 ORDER BY,让加锁顺序对所有事务都一致
SELECT * FROM items WHERE id IN (101, 102) ORDER BY id FOR UPDATE;
-- 更稳:逐行加锁,把"按固定顺序"这件事牢牢攥在应用代码里
SELECT * FROM items WHERE id = 101 FOR UPDATE;
SELECT * FROM items WHERE id = 102 FOR UPDATE;
这里的认知要点是:根治死锁的核心,不是什么高深的算法,而是一个朴素到极致的约定——所有事务,锁同一组资源时,都走同一个方向。"统一加锁顺序"之所以能彻底消灭死锁,是因为它从数学上保证了等待关系的单向性,而单向的等待,无论如何都绕不成环。这一个约定,胜过事后一万次重试。顺序统一之后,死锁的环没了;但锁本身还会带来等待和争抢——下一步是把锁的范围和持有时间,都压到最小。
四、缩小锁的范围:事务要短,索引要精准
统一加锁顺序消灭了死锁,但锁持有得越久、锁住的行越多,事务之间的冲突就越激烈(冲突虽不一定是死锁,却会变成漫长的锁等待,一样拖垮系统)。第一件事:让事务尽可能短。事务里只放"必须原子完成的写",那些调外部 API、发短信之类的慢操作,统统挪到事务提交之后:
# 反面:把耗时操作塞进事务里,锁被白白占住几百毫秒
def place_order_slow(conn, cart_items, user_id):
with conn.begin():
for item_id in sorted(cart_items):
conn.execute("UPDATE items SET stock = stock - 1 WHERE id = %s",
(item_id,))
notify_warehouse(cart_items) # 调外部仓储 API,几百毫秒
send_sms(user_id) # 发短信,又是几百毫秒
# 事务全程握着库存行的锁,干等这些慢操作 —— 冲突窗口被拉得极长
# 正确:事务里只做必须原子的写,慢操作挪到提交之后再做
def place_order_fast(conn, cart_items, user_id):
with conn.begin():
for item_id in sorted(cart_items):
conn.execute("UPDATE items SET stock = stock - 1 WHERE id = %s",
(item_id,))
# 事务已提交、行锁已释放,再去做这些不需要原子性的慢操作
notify_warehouse(cart_items)
send_sms(user_id)
第二件事:让 UPDATE 精确命中索引。这是一个极容易被忽略的死锁推手:如果 UPDATE 的 WHERE 条件没有命中索引,InnoDB 就只能逐行扫描,而它会把扫描过程中碰到的大量行都锁住——你以为只锁了一行,实际锁了一片:
-- 死锁的隐形推手:UPDATE 的 WHERE 没命中索引
-- 没有索引时,InnoDB 要逐行扫描,会锁住扫过的大量行
EXPLAIN UPDATE items SET stock = stock - 1 WHERE sku = 'A-12345';
-- 若看到 type=ALL、rows 很大 —— 全表扫描,锁的范围已经失控
-- 给 sku 建索引后,UPDATE 能精确定位,只锁那一行
CREATE INDEX idx_items_sku ON items(sku);
EXPLAIN UPDATE items SET stock = stock - 1 WHERE sku = 'A-12345';
-- 此时 type=ref、rows=1 —— 锁范围收缩到一行,冲突概率大降
这里的认知要点是:锁是一种"占用"——你占得越久、占得越宽,别人能并行的空间就越小。短事务,是在压缩"占用的时长";精确命中索引,是在压缩"占用的宽度"。这两件事都不直接消灭死锁,但它们让锁冲突的窗口又小又短,既减少了死锁的机会,也让系统在高并发下不至于被一片锁等待拖死。把锁压到又小又短之后,如果某些行依然冲突激烈,就该换一种思路:少用甚至不用锁。
五、降低锁冲突:乐观锁与热点行拆分
前面用的 SELECT … FOR UPDATE 是悲观锁:它假设冲突一定会发生,所以先把行锁死,再慢慢改。但如果冲突其实没那么频繁,先锁死就太浪费了。乐观锁是另一种思路:不加行锁,先改了再说;改的时候,用一个 version 字段检查"这行在我读到之后有没有被别人动过"——动过,这次更新就不算数,让调用方重试:
def deduct_stock_optimistic(conn, item_id, expect_version):
"""乐观锁:不加行锁,靠 version 字段在更新的瞬间检测冲突。"""
cur = conn.execute(
"UPDATE items SET stock = stock - 1, version = version + 1 "
"WHERE id = %s AND version = %s AND stock > 0",
(item_id, expect_version))
if cur.rowcount == 0:
# 一行都没更到:要么 version 已被别人改过,要么没库存了
raise ConflictError("库存已变化,请基于最新数据重试")
# 更新成功:全程没有长时间持锁,自然也不会和别人绕成环
还有一种更棘手的情况:某个爆款商品,所有订单都在抢它那一行库存——这一行成了热点,无论悲观还是乐观,大家都挤在同一行上死磕。解法是热点行拆分:把这一行库存,拆成 N 个"库存桶",每次下单随机挑一个桶扣减,把对单行的争抢摊到 N 行上:
import random
def deduct_hot_stock(conn, item_id, bucket_count=10):
"""热点行拆分:把一个爆款的库存,拆成 N 个子库存桶分散争抢。"""
bucket = random.randint(0, bucket_count - 1)
cur = conn.execute(
"UPDATE stock_buckets SET qty = qty - 1 "
"WHERE item_id = %s AND bucket = %s AND qty > 0",
(item_id, bucket))
if cur.rowcount == 0:
# 这个桶恰好空了,换一个桶再试(N 个桶的总量才是真实库存)
raise BucketEmptyError("当前库存桶已空,请重试其他桶")
# 1000 件库存拆成 10 个桶各 100 件,并发争抢被摊薄到原来的十分之一
这里的认知要点是:悲观锁、乐观锁、热点拆分,是面对"冲突"的三种不同态度。悲观锁说"冲突难免,先锁了再说",适合冲突激烈;乐观锁说"冲突不多,赌一把,错了再来",适合冲突稀疏;热点拆分则说"既然大家都挤在一个点上,那就把这个点摊开"。选哪种,取决于你对冲突频率的判断——但它们的共同目标,都是让事务少持锁、持短锁。设计层面的事说完了,最后是几个真正上规模后才会撞见的工程坑。
六、工程坑:重试要幂等、监控与隔离级别
前面把死锁的成因和根治讲透了,但还有几个工程坑,不处理就会让系统悄悄出错或失控。坑 1:死锁重试可以保留,但被重试的操作必须幂等。统一加锁顺序后,死锁会大幅减少,但不可能 100% 消灭(还有锁超时、外部因素),所以保留一层重试是合理的。但开头那个"库存被扣两次"的事故就是教训:如果被重试的操作不幂等,重试本身就会制造新的 bug。重试只该对死锁/锁超时这类错误生效,且被重试的整个操作必须能安全地重复执行:
import time
import functools
import pymysql
def retry_on_deadlock(max_retries=3, base_delay=0.1):
"""死锁重试:只对死锁/锁超时重试,且要求被装饰的操作是幂等的。"""
def decorator(fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
for attempt in range(max_retries + 1):
try:
return fn(*args, **kwargs)
except pymysql.err.OperationalError as e:
# 1213 = 死锁;1205 = 锁等待超时 —— 只对这两种重试
if e.args[0] not in (1213, 1205):
raise
if attempt == max_retries:
raise # 重试次数用尽,如实抛出
# 退避:每次重试前等得久一点,错开冲突时机
time.sleep(base_delay * (2 ** attempt))
return wrapper
return decorator
坑 2:死锁要持续监控,不能等用户来投诉。死锁回滚的是事务、对用户表现为偶发失败,很容易被淹没在日志里。要主动盯着它的发生频率,频率异常上涨就说明又有新的乱序加锁混进来了:
-- 持续监控死锁:这个计数器只增不减,定期采样看"增量"
SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks';
-- 打开这个开关,每一次死锁都会写进 MySQL 错误日志,便于逐个复盘
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 通过 performance_schema 看当前正在发生的锁等待,提前发现冲突热点
SELECT * FROM performance_schema.data_lock_waits;
坑 3:不同隔离级别,锁的行为不一样。MySQL InnoDB 默认是可重复读(REPEATABLE READ),它会用间隙锁(gap lock)锁住一段范围,这在范围更新时会让锁冲突的面积比你想象的更大、也更易死锁。如果业务不依赖可重复读,把隔离级别调成读已提交(READ COMMITTED)能明显减少间隙锁、降低死锁概率——但这是一个需要评估业务正确性的决定,不能盲调。坑 4:别在事务里"先查再改"留出时间差。"SELECT 查出库存够不够,再 UPDATE 扣减"——这两步之间的时间差,既是超卖的根源,也会拉长持锁时间。要么用 SELECT … FOR UPDATE 把查和改锁在一起,要么干脆用一条带条件的 UPDATE(WHERE stock > 0)把判断和扣减合成一个原子操作。坑 5:批量操作务必分批。一条 UPDATE 改动几十万行,会一次性锁住海量的行、并长时间持有,几乎必然和在线事务撞车。批量任务一定要拆成小批、每批几百行、批与批之间留间隙,让在线事务有机会插进来。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 死锁 | 两个事务各持对方想要的锁、又互相等待,绕成一个环 |
| 循环等待 | 死锁的本质,加锁顺序不一致时形成的等待闭环 |
| 统一加锁顺序 | 所有事务按同一顺序(如主键升序)加锁,根治死锁 |
| INNODB STATUS | SHOW ENGINE INNODB STATUS 可查最近一次死锁的全貌 |
| SELECT FOR UPDATE | 悲观行锁,一次锁多行时要带 ORDER BY 定死顺序 |
| 短事务 | 事务只做必须原子的写,慢操作挪到提交之后 |
| 索引与锁范围 | WHERE 没命中索引会锁住扫过的大量行,放大冲突 |
| 乐观锁 | 不加行锁,用 version 字段在更新瞬间检测冲突 |
| 热点行拆分 | 把爆款库存拆成多个桶,把对单行的争抢摊到多行 |
| 死锁重试 | 只对错误码 1213/1205 重试,且操作必须幂等 |
避坑清单
- 死锁不是运气,是加锁顺序不一致导致的循环等待,可预防。
- 加个重试只是把错误藏起来,根因不除高并发下会成片爆发。
- 多行加锁前先排序,让所有事务遵守同一个加锁顺序。
- 一次锁多行用 IN 时务必带 ORDER BY,否则加锁顺序不确定。
- 事务要短,外部 API、发短信这类慢操作挪到事务提交之后。
- UPDATE 的 WHERE 必须命中索引,否则会锁住扫过的大量行。
- 用 EXPLAIN 确认 UPDATE 的锁范围,type=ALL 是危险信号。
- 冲突稀疏时用乐观锁,爆款热点行要拆成多个库存桶。
- 死锁重试只对死锁错误生效,且被重试的操作必须幂等。
- 死锁要持续监控发生频率,批量操作务必拆小批、留间隙。
总结
回头看那串"偶发死锁、高峰成片、重试治标、重复扣减"的问题,以及我后来在死锁上接连踩的坑,最该记住的不是某一个错误码,而是我动手前那个想当然的判断——"死锁是偶发的、随机的,加个重试就行了"。这句话错在它把死锁当成了一种无法解释、无法预防的天灾。我以为死锁来无影去无踪,只能事后补救。可我忽略了一件事:死锁有一个清清楚楚的成因——两个事务以相反的顺序去锁同一组资源。它不是天灾,是我自己写的代码里,一个可以被指认、被改正的缺陷。给死锁加重试,不是解决了它,而是默许这个缺陷继续存在,只是用重试把它制造的错误从用户眼前擦掉了而已。
所以治住数据库的死锁,真正的工程量不在"写一个 catch 加重试"那几行代码上。那几行,谁都会写。真正的工程量,在于你要承认"死锁是加锁顺序的确定后果",并据此把整套加锁逻辑重新审视一遍:要锁多行,你就先排序、让所有事务走同一个方向;事务里有慢操作,你就把它挪到提交之后、让锁早点释放;UPDATE 的条件,你就确认它精确命中了索引、别锁了一整片;冲突稀疏的地方,你就换乐观锁;有爆款热点,你就把那一行拆成多个桶;重试可以留,但你要确保被重试的操作幂等。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"加个重试"为什么错,再讲怎么读懂死锁现场、怎么用统一顺序根治、怎么缩小锁的范围、怎么用乐观锁和热点拆分降低冲突,最后是重试幂等、监控、隔离级别这几个把死锁治扎实的工程细节。
你会发现,数据库的死锁,和现实里"两个厨师在厨房里抢厨具"完全相通。厨房里只有一把盐和一口锅,做菜两样都得用。一个不懂规矩的厨房会怎样?厨师 A 顺手抓起了盐、伸手去够锅;同一时刻,厨师 B 抢先端起了锅、回头来拿盐(这就是两个事务以相反顺序加锁)。结果两人各攥着对方要的东西,谁也不肯先放,就这么僵在原地(这就是循环等待),最后只能叫一个人把手里的放下、重来一遍(这就是数据库回滚一个事务)。而一个立了规矩的厨房怎么做?主厨定死一条铁律:不管做什么菜,都必须先拿盐、再拿锅(这就是统一加锁顺序)。从此,就算两个厨师同时开工,也只会是一个先拿到盐、另一个等着——等待永远是单向的,绝不会两人对着僵住。两个厨房,锅和盐都只有一份,可前者天天有人卡死,后者再忙也顺畅——差别不在厨具多少,只在那一条"先拿什么"的规矩。
最后想说,数据库的死锁治没治住,差距永远不会在"本地下几十单都正常"时暴露——本地你几乎没有并发,两个顺序相反的事务"恰好交错"的那个时机根本撞不上,你会觉得"加个重试兜底"已经是全部。它只在真实的、有高并发下单、有大促流量洪峰的线上环境里才显形。那时候它会用最伤用户的方式给你结账:做不好,你的系统会在高峰期成片地下单失败,你只能靠不断加重试硬撑,而重试一旦不幂等,还会把库存扣乱、把账算错;而做对了,你的事务会安静地各行其道:加锁永远朝同一个方向,锁持得又短又窄,死锁从成片爆发降到几乎绝迹,偶尔漏网的那一两个,也被一层幂等的重试稳稳兜住。所以别等"大促一来订单就成片失败"找上门,在你写下那个遍历购物车 UPDATE 的循环时就该想清楚:我面对的不是一个个孤立的事务,而是成千上万个会在同一批行上交错加锁的并发事务——它们锁这组行的顺序一致吗、锁持有得够短吗、UPDATE 锁的范围够窄吗,这一道道关,我是不是都替它们守住了?这些问题有了答案,你写出来的才不只是一套"本地能跑"的下单逻辑,而是一套真正扛得住高并发、经得起大促洪峰的可靠事务设计。
—— 别看了 · 2026