数据库死锁完全指南:从一次"大促下单成片失败、库存还被扣两次"看懂死锁的根治

2022 年我做一个电商的下单系统下单时要在一个事务里挨个扣减购物车里每个商品的库存。第一版我做得很省事拿到购物车的商品列表就按列表里的顺序一个个 UPDATE 过去。本地我下了几十单测了测真不错库存扣得准订单也对得上。我心里很踏实事务嘛把要改的几行包进一个 BEGIN COMMIT 数据库自己会保证一致。可等这个系统真正上线扛起大促的并发下单一串问题冒了出来。第一种最先把我打懵后台日志里时不时蹦出一行 Deadlock found when trying to get lock 对应的那一单事务被数据库直接回滚了用户看到的是下单失败。第二种这种报错平时一天就几条可一到流量高峰它就成片成片地冒出来失败率肉眼可见地往上窜。第三种最隐蔽我加了个 catch 逮到死锁就重试一次失败率确实降了下去可我心里清楚根上的东西一点没动只是把错误从用户眼前藏了起来。第四种最致命有一次重试的逻辑写得不对一笔订单的库存被扣了两次。我盯着这一连串问题想了很久才彻底想明白第一版错在我以为死锁是偶发的随机的是运气不好加个重试就行了。可它不是。死锁不是运气问题它有一个确定的成因两个事务各自持有了对方想要的锁又都在等对方先放手绕成了一个谁也走不出去的环而这个环之所以会形成几乎总是因为不同的事务以不一致的顺序去获取同一组锁。真正治住死锁核心不是加个重试把错误藏起来而是理解死锁是循环等待根因是加锁乱序用统一的加锁顺序去根治它再用短事务和精确索引去缩小锁的范围。本文从头梳理为什么加个重试就行是错的怎么读懂一次死锁怎么用统一的加锁顺序根治它怎么缩小锁的范围怎么用乐观锁降低冲突以及死锁监控重试幂等隔离级别这些把死锁真正治住要避开的坑。

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 精确命中索引。这是一个极容易被忽略的死锁推手:如果 UPDATEWHERE 条件没有命中索引,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 重试,且操作必须幂等

避坑清单

  1. 死锁不是运气,是加锁顺序不一致导致的循环等待,可预防。
  2. 加个重试只是把错误藏起来,根因不除高并发下会成片爆发。
  3. 多行加锁前先排序,让所有事务遵守同一个加锁顺序。
  4. 一次锁多行用 IN 时务必带 ORDER BY,否则加锁顺序不确定。
  5. 事务要短,外部 API、发短信这类慢操作挪到事务提交之后。
  6. UPDATE 的 WHERE 必须命中索引,否则会锁住扫过的大量行。
  7. 用 EXPLAIN 确认 UPDATE 的锁范围,type=ALL 是危险信号。
  8. 冲突稀疏时用乐观锁,爆款热点行要拆成多个库存桶。
  9. 死锁重试只对死锁错误生效,且被重试的操作必须幂等。
  10. 死锁要持续监控发生频率,批量操作务必拆小批、留间隙。

总结

回头看那串"偶发死锁、高峰成片、重试治标、重复扣减"的问题,以及我后来在死锁上接连踩的坑,最该记住的不是某一个错误码,而是我动手前那个想当然的判断——"死锁是偶发的、随机的,加个重试就行了"。这句话错在它把死锁当成了一种无法解释、无法预防的天灾。我以为死锁来无影去无踪,只能事后补救。可我忽略了一件事:死锁有一个清清楚楚的成因——两个事务以相反的顺序去锁同一组资源它不是天灾,是我自己写的代码里,一个可以被指认、被改正的缺陷。给死锁加重试,不是解决了它,而是默许这个缺陷继续存在,只是用重试把它制造的错误从用户眼前擦掉了而已。

所以治住数据库的死锁,真正的工程量不在"写一个 catch 加重试"那几行代码上。那几行,谁都会写。真正的工程量,在于你要承认"死锁是加锁顺序的确定后果",并据此把整套加锁逻辑重新审视一遍:要锁多行,你就先排序、让所有事务走同一个方向;事务里有慢操作,你就把它挪到提交之后、让锁早点释放;UPDATE 的条件,你就确认它精确命中了索引、别锁了一整片;冲突稀疏的地方,你就换乐观锁;有爆款热点,你就把那一行拆成多个桶;重试可以留,但你要确保被重试的操作幂等。这篇文章的几节,其实就是顺着这条线展开的:先想清楚"加个重试"为什么错,再讲怎么读懂死锁现场、怎么用统一顺序根治、怎么缩小锁的范围、怎么用乐观锁和热点拆分降低冲突,最后是重试幂等、监控、隔离级别这几个把死锁治扎实的工程细节。

你会发现,数据库的死锁,和现实里"两个厨师在厨房里抢厨具"完全相通。厨房里只有一把盐和一口锅,做菜两样都得用。一个不懂规矩的厨房会怎样?厨师 A 顺手抓起了盐、伸手去够锅;同一时刻,厨师 B 抢先端起了锅、回头来拿盐(这就是两个事务以相反顺序加锁)。结果两人各攥着对方要的东西,谁也不肯先放,就这么僵在原地(这就是循环等待),最后只能叫一个人把手里的放下、重来一遍(这就是数据库回滚一个事务)。而一个立了规矩的厨房怎么做?主厨定死一条铁律:不管做什么菜,都必须先拿盐、再拿锅(这就是统一加锁顺序)。从此,就算两个厨师同时开工,也只会是一个先拿到盐、另一个等着——等待永远是单向的,绝不会两人对着僵住。两个厨房,锅和盐都只有一份,可前者天天有人卡死,后者再忙也顺畅——差别不在厨具多少,只在那一条"先拿什么"的规矩

最后想说,数据库的死锁治没治住,差距永远不会在"本地下几十单都正常"时暴露——本地你几乎没有并发,两个顺序相反的事务"恰好交错"的那个时机根本撞不上,你会觉得"加个重试兜底"已经是全部。它只在真实的、有高并发下单、有大促流量洪峰的线上环境里才显形。那时候它会用最伤用户的方式给你结账:做不好,你的系统会在高峰期成片地下单失败,你只能靠不断加重试硬撑,而重试一旦不幂等,还会把库存扣乱、把账算错;而做了,你的事务会安静地各行其道:加锁永远朝同一个方向,锁持得又短又窄,死锁从成片爆发降到几乎绝迹,偶尔漏网的那一两个,也被一层幂等的重试稳稳兜住。所以别等"大促一来订单就成片失败"找上门,在你写下那个遍历购物车 UPDATE 的循环时就该想清楚:我面对的不是一个个孤立的事务,而是成千上万个会在同一批行上交错加锁的并发事务——它们锁这组行的顺序一致吗、锁持有得够短吗、UPDATE 锁的范围够窄吗,这一道道关,我是不是都替它们守住了?这些问题有了答案,你写出来的才不只是一套"本地能跑"的下单逻辑,而是一套真正扛得住高并发、经得起大促洪峰的可靠事务设计

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

RAG 文档分块完全指南:从一次"知识库问答读到半句话、半张表格、答案没法溯源"看懂 Chunking 策略

2026-5-22 1:32:45

技术教程

RAG 检索重排序完全指南:从一次"知识库明明有答案、却死活检索不出来"看懂 Rerank

2026-5-22 1:46:32

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