数据库死锁完全指南:从一次"两条没问题的 SQL 放一起却死锁"看懂为什么加锁顺序才是根因

2022 年我做一个电商的下单功能用户下单要做两件互相关联的事扣掉商品的库存在用户账户上记一笔消费这两件事必须一起成功或一起失败我自然用一个数据库事务把它们包起来第一版我做得很顺手事务开始 UPDATE 商品表扣库存 UPDATE 用户表记消费提交本地一测下单流畅库存和账户都对得上我心里很笃定事务嘛把要一起成功的几步包进 BEGIN COMMIT 数据库自己保证它们要么全成要么全败这下单稳了可等真实流量上来一串问题冒了出来第一种最先把我打懵线上偶尔报一个错 Deadlock found 下单直接失败概率不高但每天稳定来那么几次而我在本地怎么点都复现不出来第二种最难缠我把报错的那两条 UPDATE 单独拎出来一条条跑毫无问题第三种最头疼我给下单加了失败就重试平时确实好了可一到促销高峰死锁报错突然密集到刷屏第四种最莫名其妙互相锁死的两个事务改的根本不是同一行却还是把对方锁死了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为死锁就是两个事务抢同一行数据可这个认知是错的本文从头梳理为什么两条没问题的 SQL 放一起会死锁死锁到底是什么最常见的加锁顺序不一致怎么根治间隙锁和索引锁带来的看不见的死锁死锁了到底该怎么办以及一些把防死锁做扎实要避开的工程坑

2022 年我做一个电商的下单功能——用户下单,要做两件互相关联的事:扣掉商品的库存、在用户账户上记一笔消费。这两件事必须一起成功或一起失败,我自然用一个数据库事务把它们包起来。第一版我做得很顺手:事务开始,UPDATE 商品表扣库存,UPDATE 用户表记消费,提交。本地一测,下单流畅,库存和账户都对得上,我心里很笃定:事务嘛,把要一起成功的几步包进 BEGIN/COMMIT,数据库自己保证它们要么全成、要么全败,这下单稳了。可等真实流量上来,一串问题冒了出来。第一种最先把我打懵:线上偶尔报一个错——"Deadlock found when trying to get lock; try restarting transaction",下单直接失败。概率不高,但每天稳定来那么几次,而我在本地怎么点都复现不出来。第二种最难缠:我把报错的那两条 UPDATE 单独拎出来,一条条跑,毫无问题;我把它们顺着写进一个事务跑,也毫无问题——它们只在"很多人同时下单"的时候才死锁。第三种最头疼:我给下单加了"失败就重试",平时确实好了,可一到促销高峰,死锁报错突然密集到刷屏,重试也追不上,大量订单卡死。第四种最莫名其妙:我抓到一次死锁日志,发现互相锁死的两个事务,改的根本不是同一个商品、同一个用户——它们碰都没碰同一行数据,却还是把对方锁死了。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为死锁就是"两个事务抢同一行数据,你不放我也不放"——所以只要两个事务改的不是同一行,就绝不会死锁;死锁顶多是个小概率的偶发故障,加个重试也就过去了。可这个认知是错的。死锁的本质,不是"抢同一行",而是两个事务各自已经持有了对方下一步想要的锁,谁也不肯先松手,形成了一个首尾相接的"循环等待"。它锁的也不只是"行"——还会锁住行与行之间的"间隙"、锁住索引。更要命的是,它根本不是运气问题:只要你的代码里存在"两个事务会以相反的顺序去获取多把锁"这个结构,并发一上来,死锁就是必然会被触发的——它是你加锁顺序里埋下的一个缺陷,不是偶然。要把事务和锁用扎实,根上要明白:死锁不能靠"撞大运式地躲开",只能靠"从设计上让循环等待无法形成"。本文从头梳理:为什么两条没问题的 SQL 放一起会死锁,死锁到底是什么,最常见的加锁顺序不一致怎么根治,间隙锁和索引锁带来的看不见的死锁,死锁了到底该怎么办,以及一些把防死锁做扎实要避开的工程坑。

问题背景

先把"锁"和"死锁"这两件事说清楚。数据库为了让并发的事务不互相干扰,会在事务修改数据时给数据加锁:一个事务锁住了某行,另一个事务想改这行,就得等前者提交、释放锁。死锁,是这种"等待"形成了一个闭环——事务 A 在等 B 释放锁,而 B 又在等 A 释放锁,两个人僵在原地,永远等不到。

错误认知是:死锁就是两个事务抢同一行,改的不是同一行就不会死锁,而且它是偶发的,重试一下就好。真相是:死锁的根源是"循环等待",它由加锁的顺序决定,而不是由"锁的是哪一行"决定;它是结构性的,并发下必然触发。把这一点摊开,第一版的几类问题就都能解释了:

  • 本地复现不了:死锁需要两个事务的加锁操作精确地交错,单机低并发下这种交错极难撞上,高并发的线上才会频繁出现。
  • 单条 SQL 没问题:死锁不是某一条 SQL 的错,它是两个事务"加锁顺序"配合出来的问题,拆开看永远看不见。
  • 高峰期重试也救不回:重试只是让死锁后的事务再试一次,没有消除产生死锁的结构,并发越高死锁越密,重试只会雪上加霜。
  • 没改同一行也死锁:死锁还会发生在间隙锁、索引锁上,两个事务可以锁住"不同的行",却争用同一段间隙或同一个索引。

所以让事务用对,核心不是"祈祷不要死锁",而是从设计上掐断循环等待:统一加锁顺序、缩小锁的范围、理解那些看不见的锁。下面六节,就从第一版"两条 SQL 都没问题"的想当然讲起。

一、为什么"两条没问题的 SQL"放一起会死锁

第一版的下单事务,逻辑朴素得很:扣库存、记消费,两条 UPDATE。问题出在,我的代码里其实有两条不同的业务路径,它们都要改这两张表,但改的顺序不一样。

-- 反面教材:两条业务路径,以相反的顺序锁两张表

-- 路径 A:普通下单 —— 先扣库存,再记账户
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 100;    -- 锁住 products 第 100 行
UPDATE accounts SET balance = balance - 50 WHERE id = 7; -- 锁住 accounts 第 7 行
COMMIT;

-- 路径 B:退款回滚 —— 先退账户,再加库存
BEGIN;
UPDATE accounts SET balance = balance + 50 WHERE id = 7; -- 锁住 accounts 第 7 行
UPDATE products SET stock = stock + 1 WHERE id = 100;    -- 锁住 products 第 100 行
COMMIT;

-- 单独看,两条事务都天经地义。
-- 但 A 的加锁顺序是 products 然后 accounts,
--    B 的加锁顺序是 accounts 然后 products —— 正好相反。

这两条事务,你单独审查任何一条,都挑不出毛病:下单先扣库存再记账,退款先退钱再补库存,顺序都符合各自的业务直觉。问题在它们凑在一起并发的时候。把它们撞上死锁的那条时间线摊开看,就清楚了。

死锁是怎么撞上的:一条精确交错的时间线

  时刻   事务 A(下单)              事务 B(退款)
  --------------------------------------------------------------
  t1    锁住 products[100]          ——
  t2    ——                          锁住 accounts[7]
  t3    想锁 accounts[7]             ——
        但 B 已持有,A 开始等待
  t4    ——                          想锁 products[100]
                                     但 A 已持有,B 开始等待
  --------------------------------------------------------------
  此刻:A 在等 B 释放 accounts[7],B 在等 A 释放 products[100]
        两个事务首尾相接,谁都不会先松手 —— 死锁形成

看这条时间线的关键,是 t3 和 t4:A 已经攥着 products[100] 不放、伸手要 accounts[7],而 accounts[7] 恰好在 B 手里;与此同时 B 攥着 accounts[7]、伸手要 products[100],而它在 A 手里。两个事务各持一把、各等一把,等待关系连成了一个环。

这一节要建立的认知是:死锁从来不是"某一条 SQL 写错了",而是两个事务的加锁顺序"对不上"——它是一个关于顺序的、需要把两段代码摆在一起才看得见的问题。第一版排查时我犯的错,是一条条 SQL 地看、一个个事务地看,这个视角天然就看不见死锁——因为死锁不存在于任何单个事务里,它存在于"事务 A 的加锁顺序"和"事务 B 的加锁顺序"之间的关系里。A 单独跑没问题,B 单独跑没问题,正是死锁问题最典型的样子:每一方都"自己没错",错的是它们俩凑在一起时,一个从左往右锁、一个从右往左锁。所以排查死锁,你要换一个视角:不要盯着"哪条 SQL 慢了、错了",而要把所有会碰到同一批数据的事务都找出来,排成一列,看它们各自是按什么顺序获取锁的。只要存在两个事务的加锁顺序相反,死锁的种子就埋下了。看清"死锁是顺序问题、是关系问题",是后面所有解法的前提。

二、死锁到底是什么:四个必要条件与循环等待

要根治死锁,得先看清它成立的条件。操作系统理论里,死锁的形成有四个必要条件,它们必须同时满足,缺一个死锁就形不成。把它们对到数据库事务上,是这样四条:

  • 互斥:一把锁同一时刻只能被一个事务持有,这是锁的基本性质。
  • 持有并等待:一个事务已经持有了一把锁,还想再去申请另一把锁。
  • 不可剥夺:一个事务持有的锁,不能被别的事务强行抢走,只能等它自己提交后释放。
  • 循环等待:存在一个事务的等待链条,首尾相接成了一个环——A 等 B、B 等 A。

这四条里,前三条是数据库锁机制天生就有的、你改不掉的。唯一能被你的代码动手破坏的,是第四条"循环等待"。而判断有没有循环等待,有一个非常具体的办法:把所有事务的"谁在等谁"画成一张图,看图里有没有环。

# 死锁的核心是"循环等待":把事务的等待关系画成一张图,图里有环就是死锁

def has_deadlock(wait_for):
    # wait_for: {事务: 它正在等待的事务},例如 {'A': 'B', 'B': 'A'}
    for start in wait_for:
        seen = set()
        cur = start
        while cur in wait_for:          # 顺着"等待"关系往下走
            if cur in seen:             # 走回了到过的事务 —— 出现环
                return True
            seen.add(cur)
            cur = wait_for[cur]
    return False

# A 等 B、B 等 A —— 等待关系成环,死锁
print(has_deadlock({'A': 'B', 'B': 'A'}))        # True
# A 等 B、B 等 C、C 不等任何人 —— 没有环,只是正常排队
print(has_deadlock({'A': 'B', 'B': 'C'}))        # False

这段 has_deadlock,本质上就是数据库内部"死锁检测"做的事:InnoDB 维护着一张事务的"等待图",每当有事务进入等待,它就检查这张图里有没有环——有环,就是死锁。

这一节的认知是:死锁的四个条件里,只有"循环等待"这一个是你能动手破坏的——所以一切防死锁的工程手段,本质上都是同一件事:不让事务之间的等待关系形成环。这是一个让人松一口气的结论。死锁听起来玄,但它能不能发生,卡在一个非常具体、非常可控的点上:等待关系里有没有环。你不需要去对抗锁的互斥性,也不需要发明什么精巧的算法,你只要保证"任意一组并发事务,它们的等待关系连起来永远是一条链、不会首尾相接成环"就够了。而要让它不成环,最直接、最有效的办法,就是让所有事务都按同一个顺序去获取锁——下一节讲的就是这件事。

三、最常见的死锁:加锁顺序不一致

讲清了"循环等待是唯一能破的条件",这一节就讲怎么破它。破循环等待最经典、最有效的办法,叫资源排序:给所有可能被锁的资源定一个全局的、固定的顺序,规定任何事务都必须按这个顺序去获取锁。

为什么这能消除环?道理很简单:如果每个事务都从"小"往"大"锁,那么一个事务持有的锁,一定都比它正在等待的那把锁要"小"。而环要形成,必须有一个事务既持有了某把大锁、又在等一把小锁——这恰恰违反了"按从小到大顺序加锁"这条规则。规则一立,环就无法闭合。

# 正解一:统一加锁顺序 —— 永远按 id 从小到大锁

def lock_accounts(conn, account_ids):
    # 不管业务上想先动哪个账户,先把要锁的 id 排序
    ordered = sorted(account_ids)
    cur = conn.cursor()
    cur.execute("BEGIN")
    for aid in ordered:
        # 所有事务都按 id 升序加锁 —— 顺序统一,环就无法形成
        cur.execute(
            "SELECT * FROM accounts WHERE id = %s FOR UPDATE", (aid,))
    # ……在这里执行真正的业务改动……
    cur.execute("COMMIT")

# 关键不在"升序"还是"降序",而在"所有事务都用同一个序"。
# 哪怕业务逻辑想先扣 7 号、再扣 3 号,加锁也必须 3 号在前。

上面是同一张表里多行的情况。如果一个事务要锁的资源横跨好几张表(比如第一版的 products 和 accounts),那"顺序"就要扩展到表的层面:你得为这些表定一个固定的先后,规定所有事务都先锁前面那张、再锁后面那张。

-- 正解二:跨多张表时,也要定一个固定的表顺序

-- 规定:任何事务,都先锁 products,再锁 accounts —— 永不调换

-- 路径 A:下单
BEGIN;
SELECT stock FROM products WHERE id = 100 FOR UPDATE;     -- 先 products
SELECT balance FROM accounts WHERE id = 7 FOR UPDATE;     -- 后 accounts
UPDATE products SET stock = stock - 1 WHERE id = 100;
UPDATE accounts SET balance = balance - 50 WHERE id = 7;
COMMIT;

-- 路径 B:退款 —— 业务上想先退钱,但加锁仍然 products 在前
BEGIN;
SELECT stock FROM products WHERE id = 100 FOR UPDATE;     -- 仍先 products
SELECT balance FROM accounts WHERE id = 7 FOR UPDATE;     -- 仍后 accounts
UPDATE accounts SET balance = balance + 50 WHERE id = 7;  -- 业务改动顺序随意
UPDATE products SET stock = stock + 1 WHERE id = 100;
COMMIT;

注意路径 B 的写法:它业务上想"先退钱",所以两条 UPDATE 仍是账户在前;但它在最开头,用两条 SELECT ... FOR UPDATE 按"products 先、accounts 后"的统一顺序,先把锁都拿齐了。加锁顺序和业务改动顺序,被拆成了两件事。

这一节的认知是:统一加锁顺序之所以能根治死锁,是因为它把"会不会死锁"这件事,从一个运行时的、依赖并发时序的概率问题,变成了一个写代码时就能静态确定的结构问题。第一版的死锁,本质上是把"会不会死锁"交给了运气——两个顺序相反的事务,撞上特定的交错就死、撞不上就活,你没法预测、也没法在测试里稳定复现。资源排序做的,是把这件事从运行时拽回到编码时:你只要在代码审查时确认"所有碰这批资源的事务,加锁顺序是一致的",死锁就在结构上被排除了,根本不需要运行起来才知道。这就是为什么资源排序是防死锁的第一手段——它不是"降低死锁概率",它是"让死锁不可能发生"。代价也很明确:你必须放弃"想先改谁就先锁谁"的随意,接受"业务顺序"和"加锁顺序"是两件分开的事——业务上你爱怎么排怎么排,但加锁,必须服从那个全局统一的序。

四、看不见的锁:间隙锁与索引锁带来的死锁

到这里,"两个事务改同一批行"的死锁已经能根治了。但第一版第四种问题还悬着:互相锁死的两个事务,改的根本不是同一行。这就要讲一类"看不见的锁"。

很多人以为数据库只锁"行",其实在 MySQL InnoDB 的可重复读(RR)隔离级别下,为了防止幻读,它还会锁"间隙"(gap)——也就是行与行之间的空隙。一条范围查询或范围更新,锁住的不是某一行,而是一整段区间。

-- 间隙锁:范围条件锁住的是"一段区间",不是某一行

-- 假设 orders 表里现有 id 为 10、20、30 的三行

-- 事务 A:
BEGIN;
-- 这条范围更新,在 RR 级别下会锁住 10 到 30 之间的整段间隙,
-- 既包括 id=20 这行,也包括 20 两侧的空隙
UPDATE orders SET status = 1 WHERE id > 10 AND id < 30;

-- 事务 B:想插入一行 id=25 的新订单
BEGIN;
-- id=25 落在 A 锁住的间隙里 —— B 必须等 A 提交
-- 哪怕 id=25 这一行此刻根本不存在,B 也插不进去
INSERT INTO orders (id, status) VALUES (25, 0);

看懂了间隙锁,"没改同一行也死锁"就有了解释:两个事务可以各自做一条范围更新,它们最终改到的行完全不同,但它们锁住的"间隙"重叠了——于是它们在争用同一段间隙,照样能互相锁死。

-- 间隙锁导致的死锁:两个事务锁的"行"不同,却争同一段间隙

-- 事务 A:把某用户所有未支付的订单标记为取消
UPDATE orders SET status = 9
WHERE user_id = 1 AND status = 0;        -- 锁住 user_id=1 的一段间隙

-- 事务 B:给同一个用户插入一条新的未支付订单
INSERT INTO orders (user_id, status) VALUES (1, 0);
-- B 要插入的位置落在 A 锁住的间隙里 —— 并发时极易和别的事务交错成死锁

-- 解法一:给 status、user_id 建好索引,让锁的范围尽量精确,
--          别因为没有索引,锁范围退化成锁住一大片甚至全表
-- 解法二:把大范围更新拆成两步 —— 先 SELECT 出要改的 id 列表,
--          再按 id 逐个精确更新,用主键等值锁替代模糊的间隙锁

这一节的认知是:你以为你在锁"几行数据",但数据库实际锁住的,可能是一段你从没意识到的"区间"——死锁波及的范围,往往比你写在 WHERE 后面的条件要大得多。第一版"没改同一行也死锁"的困惑,根子就在这里:我脑子里的模型是"UPDATE 改哪行就只锁哪行",所以两个事务只要 WHERE 条件不重叠,我就认定它们井水不犯河水。但这个模型是错的——在 RR 隔离级别下,一个范围条件锁住的是一整段间隙,两个 WHERE 条件"看起来不重叠"的事务,完全可能在同一段间隙上撞车。要驯服这种看不见的锁,有两个方向:一是让锁尽量精确——给查询条件建好索引,别让数据库因为找不到索引而扩大锁的范围;二是用等值的主键锁替代模糊的范围锁——能"先查出 id、再按 id 精确更新"的,就别直接甩一条大范围 UPDATE。说到底,你对"这条 SQL 到底锁了什么"了解得越清楚,死锁就越少在你意料之外的地方冒出来。

五、死锁了怎么办:重试,以及为什么不能只靠重试

前面四节都在讲"怎么让死锁不发生"。但工程上还得接受一个现实:再怎么设计,死锁的概率也压不到绝对的零——总有些边角场景会漏。所以你还需要一套"死锁真的发生了,怎么办"的应对。

好消息是,数据库自己会检测死锁:InnoDB 一旦发现循环等待,会立刻挑一个事务作为"牺牲品",把它回滚掉,让另一个事务得以继续。被回滚的那个事务,会收到那条 "Deadlock found" 的报错。所以应对死锁的第一步,是捕获这个特定的错误,然后重试。

# 死锁后重试:捕获死锁错误码,退避一小段时间后重试

import time, functools
import pymysql

def retry_on_deadlock(max_retries=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except pymysql.err.OperationalError as e:
                    # 1213 是 MySQL 死锁的错误码
                    if e.args[0] == 1213 and attempt < max_retries - 1:
                        # 退避一小段(随重试次数递增)时间,
                        # 避开和对手再次精确交错
                        time.sleep(0.05 * (attempt + 1))
                        continue
                    raise            # 不是死锁,或重试已用尽 —— 抛出去
        return wrapper
    return decorator

@retry_on_deadlock(max_retries=3)
def place_order(conn, product_id, account_id):
    # ……一个完整的下单事务……
    pass

重试能接住偶发的死锁,但它救不了"结构上就大量死锁"的系统——这正是第一版第三种问题的成因。除了重试,还要给锁等待设一个超时,别让事务无限期地干等下去。

-- 兜底:给锁等待设一个超时,别让事务无限期地干等

-- innodb_lock_wait_timeout:一个事务等锁最多等多少秒,
-- 超过就主动报错退出,避免它一直挂着、白占着连接
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';

SET innodb_lock_wait_timeout = 5;     -- 默认 50 秒,通常应调小

-- 注意区分两种很像、但根因不同的报错:
--   死锁(1213):InnoDB 检测到环,马上回滚一个事务
--   锁等待超时(1205):没有环,只是某个事务等太久了
-- 两者都要处理,但排查方向不同 —— 1213 查加锁顺序,1205 查长事务

这一节的认知是:重试是死锁的"急救",不是"治疗"——它能让单次撞上死锁的那个用户不至于直接失败,但它丝毫没有触碰产生死锁的那个结构,死锁该发生还是会发生。第一版促销高峰"重试也救不回来",就是把急救当成了治疗的恶果。这里有个反直觉的恶性循环:并发越高,死锁越密;死锁越密,重试越多;而重试本身又是在已经很拥挤的数据库上,再发起一轮事务、再抢一轮锁——它不缓解拥堵,它加剧拥堵。所以重试的正确定位,是给"已经从设计上基本消除了死锁、只剩极少数漏网场景"的系统兜底,而不是给"结构上就会大量死锁"的系统续命。正确的顺序永远是:先用资源排序、缩小事务、精确加锁这些手段,把死锁的发生率从设计上压到极低;然后再用带退避的重试,去接住那最后漏下来的一点点。把重试当成主力,你就会在高峰期被死锁淹没;把重试当成兜底,它才能真正帮到你。

把"线上报了死锁,该怎么排查和根治"的完整流程画出来,就是下面这张图:

[mermaid]
flowchart TD
A[线上报死锁错误 1213] --> B[抓死锁日志 SHOW ENGINE INNODB STATUS]
B --> C{两个事务锁的是同一批行吗}
C -->|是| D[检查两个事务的加锁顺序是否相反]
D --> E[用资源排序统一加锁顺序]
C -->|否| F[多半是间隙锁或索引锁在作怪]
F --> G[补索引让锁更精确 或改用主键等值更新]
E --> H[再加带退避的重试做兜底]
G --> H

六、把防死锁做扎实,要避开的工程坑

前面五节讲清了死锁的原理和根治办法。但要在生产里真正用好,还有几个工程坑得专门讲。第一个,也是最容易被忽略的:事务开得太大、拖得太长。事务持有锁的时间,是从它第一次加锁开始、一直到 COMMIT 才释放——事务里夹的东西越多、跑得越久,锁就被占得越久,别的事务等待、撞上死锁的窗口就越大。

# 坑一:事务里夹了慢操作,锁被白白占用很久

# 反面教材:事务里夹着外部接口调用
def place_order_bad(conn, order):
    cur = conn.cursor()
    cur.execute("BEGIN")
    cur.execute("UPDATE products SET stock = stock - 1 WHERE id = %s",
                (order.product_id,))
    send_sms(order.user_phone)          # 调短信接口,可能要几百毫秒
    call_risk_api(order)                # 调风控接口,又是几百毫秒
    cur.execute("INSERT INTO orders ...")
    cur.execute("COMMIT")               # 锁从第一条 UPDATE 一直占到这里

# 正解:事务里只放数据库操作,外部调用挪到事务外
def place_order_good(conn, order):
    if not call_risk_api(order):         # 风控在事务开始前就调用完
        return
    cur = conn.cursor()
    cur.execute("BEGIN")
    cur.execute("UPDATE products SET stock = stock - 1 WHERE id = %s",
                (order.product_id,))
    cur.execute("INSERT INTO orders ...")
    cur.execute("COMMIT")                # 事务尽可能短,锁尽快释放
    send_sms(order.user_phone)           # 通知类操作放到事务提交之后

第二个坑,是出了死锁却不去看日志,只能靠脑补猜。InnoDB 会把最近一次死锁的详细情况记录下来,这是排查死锁最可靠的第一手资料。

-- 坑二:出了死锁不去看日志,只能靠猜

-- InnoDB 会把最近一次死锁的详情记录下来,这是排查的第一手资料
SHOW ENGINE INNODB STATUS;
-- 在输出里找 LATEST DETECTED DEADLOCK 这一段,它会告诉你:
--   TRANSACTION 1:在等什么锁、已持有什么锁、执行的是哪条 SQL
--   TRANSACTION 2:同上
--   WE ROLL BACK TRANSACTION ... —— 哪个事务被选为牺牲品回滚了

-- 想长期留存每一次死锁,可以打开这个参数,
-- 让每次死锁的详情都写进 MySQL 的错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;

还有几个坑值得点一下。其一,隔离级别可以是一个选项:把可重复读(RR)换成读已提交(RC),会去掉大部分间隙锁,死锁会明显减少——但代价是放弃了对幻读的防护,要结合业务能否接受来权衡。其二,死锁回滚谁不是你能定的:InnoDB 倾向于回滚"改动行数较少"的那个事务,所以你的大事务未必能在死锁里幸存,这也是事务要短的另一个理由。其三,死锁率要监控起来:把每天的死锁次数做成一个指标,平时盯着它,促销前更要盯——死锁率悄悄上涨,往往是某次代码改动引入了新的顺序冲突。下面把死锁的三类成因和对策集中对照一下:

死锁的三类成因与对策对照

  成因                典型表现                    对策
  --------------------------------------------------------------
  加锁顺序不一致      两事务锁同批行 顺序相反      资源排序 统一加锁顺序
  间隙锁争用          没改同一行也互相锁死        补索引 缩小锁范围
  事务太大太长        高峰期死锁与超时齐飞        精简事务 外部调用挪出去

  原则:先从设计上掐断循环等待,再用重试兜底;
        重试是急救不是治疗,绝不能把它当主力。

这一节这几个坑,串起来是同一个意思:防死锁不只是"写对加锁顺序"这一个动作,它是一整套关于"如何持有锁"的纪律——锁要持有得尽量短、尽量精确,出了问题要看得见、查得到。事务大小、慢操作、隔离级别、监控,这些坑表面上五花八门,根子上指向同一件事:锁是一种"独占的、会阻塞别人的、用完才能还的"稀缺资源,你持有它的每一刻,都在给别的事务制造等待。而等待是死锁的原料——等待越多、越久,等待关系连成环的机会就越大。所以把事务砍短、把外部调用挪出去、把锁的范围用索引收窄、把隔离级别选得恰当,这些动作没有一个直接写着"防死锁",但它们都在做同一件事:减少锁的持有时间和持有范围,也就是减少死锁的原料。把"尽量少占锁、尽量短占锁"当成一条贯穿始终的纪律,再配上看得见的死锁日志和监控,你的系统才能在真实并发下长期稳住。

关键概念速查

概念 说明
死锁 两个事务互相持有对方所需的锁,形成循环等待,谁都无法继续
循环等待 死锁四个条件中唯一可被破坏的一条,事务的等待关系连成了环
资源排序 让所有事务按同一全局顺序加锁,从结构上消除循环等待
行锁 锁住具体某一行数据,等值条件命中索引时的常见锁
间隙锁 RR 隔离级别下锁住行与行之间的空隙,用来防止幻读
FOR UPDATE 在 SELECT 时主动给查到的行加排他锁,常用于统一加锁
死锁检测 InnoDB 发现循环等待后,主动回滚一个事务来打破僵局
错误码 1213 MySQL 死锁错误,需捕获后做退避重试
错误码 1205 锁等待超时错误,根因多是长事务而非死锁
innodb_lock_wait_timeout 事务等锁的最长时间,超时即报错退出

避坑清单

  1. 不要以为改的不是同一行就不会死锁:间隙锁会让它们争用同一段间隙。
  2. 不要让不同事务以相反顺序加锁:用资源排序统一所有事务的加锁顺序。
  3. 不要把业务顺序和加锁顺序混为一谈:业务随意,加锁必须服从全局序。
  4. 不要在事务里夹外部调用:短信、风控等慢操作挪到事务外面去。
  5. 不要让事务拖太久:锁从加锁起持有到 COMMIT,事务越短越好。
  6. 不要只靠重试解决死锁:重试是急救,根治要靠消除循环等待。
  7. 不要在高峰期盲目加重试:并发越高,重试越会加剧拥堵。
  8. 不要给范围更新省索引:没索引会让锁范围扩大,甚至锁住全表。
  9. 不要出了死锁不看日志:SHOW ENGINE INNODB STATUS 是第一手资料。
  10. 不要给锁等待设过长超时:超时调小,避免事务无限期地干等。

总结

回头看第一版那个"两条 UPDATE 包进事务就完事"的下单功能,它的错误很典型。它不在某一行代码,而在一个对死锁的根本误解:以为死锁是两个事务抢同一行、是个偶发的运气问题,加个重试就过去了。真相是,死锁的本质是"循环等待"——两个事务各自持有对方下一步想要的锁,首尾相接成环。它由加锁的顺序决定,而不是由锁的哪一行决定;它锁的也不只是行,还有看不见的间隙和索引;它更不是运气问题,只要代码里存在"加锁顺序相反"的结构,并发一上来就必然触发。

而把防死锁做对,工程量并不小。它不是"加个重试"那么简单,而是要理解死锁是循环等待、是顺序问题,要用资源排序让所有事务按同一顺序加锁,要看清间隙锁这种"看不见的锁"并用索引把它收窄,要把事务砍短、把外部调用挪出去,要用带退避的重试做兜底,还要会看死锁日志、会监控死锁频率。一套真正不会在高峰期崩掉的事务设计,是这些环节一个不少地拼起来的。

这件事其实很像两个工人去同一间仓库取工具。仓库里每件工具同一时刻只能一个人拿在手上(这就是锁的互斥)。工人 A 的活儿要用扳手和钳子,他习惯先拿扳手、再拿钳子;工人 B 的活儿也要这两样,但他习惯先拿钳子、再拿扳手。平时两人错峰干活,谁也碰不上谁。可某天他俩同时进了仓库:A 抓起扳手,B 抓起钳子,然后 A 伸手要钳子——钳子在 B 手里;B 伸手要扳手——扳手在 A 手里。两人各攥着一件、各等着对方那一件,僵在原地——这就是死锁。怎么根治?不是规定"谁拿不到就把手里的放下、重来一次"——这是重试,两人很可能下次又同时抓、又僵住。真正的根治,是车间立一条铁规矩:不管你的活儿想先用哪件,进仓库取工具,一律先取扳手、再取钳子。规矩一立,B 也得先去够扳手——扳手要么没人拿、他直接拿走,要么在 A 手里、他就空着手在门口等;而 A 手里只有扳手,取钳子畅通无阻,干完活把两件都还回来。一个统一的取用顺序,就让"互相攥着、互等对方"这件事从根上不可能发生。这就是资源排序。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你自己测,点一下下单、点一下退款,所有事务都是一个接一个串行跑的,死锁需要的那种"两个事务的加锁操作精确交错"的时序,单机手点根本撞不出来——你会觉得"用事务包起来"这套天衣无缝。真正会把死锁撑出来的,是上线后的真实并发:成千上万的下单、退款、改价在同一毫秒里交错,你那两条"顺序相反"的加锁路径,会被真实流量精准地、反复地踩中,促销高峰更是把死锁率成倍放大。所以如果你正在写一个有事务、有并发的功能,别等线上刷屏报 1213 才回头怀疑加锁顺序。在写下第一个事务的时候就想清楚:这个事务会锁哪些资源、它加锁的顺序是什么、项目里别的事务锁这批资源时顺序一致吗、我的事务是不是开得太大——把"用事务保证一起成功"和"让事务之间永不形成循环等待"当成两件必须分别去做的事,这是这篇文章最想留给你的一句话。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Few-shot 提示工程完全指南:从一次"加了几个例子分类反而更偏了"看懂示例为什么是双刃剑

2026-5-22 21:26:52

技术教程

LLM 成本优化完全指南:从一次"换了便宜模型账单却没降多少"看懂为什么 token 用量才是大头

2026-5-22 21:45:42

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索