数据库事务隔离级别完全指南:从一次"两个人同时下单、库存被卖成了负数"看懂脏读幻读

2023 年我做一个电商的库存扣减功能。逻辑看着特别简单用户下单我就查一下库存够不够够就扣减生成订单。第一版我做得很省事查库存判断够不够扣减三步我把它们包进了一个数据库事务里。本地一测毫无问题下单扣库存生成订单一气呵成。我心里很踏实把这几步包进一个事务里它就是原子的就安全了。可等它真正上线撞上大促的高并发一串问题冒了出来。第一种最离谱一个只有 1 件库存的商品居然卖出去了 3 单库存字段变成了负二。第二种对账时发现有些订单扣了款却没扣库存有些扣了库存却查不到订单。第三种偶尔会有请求报一个 deadlock 的错然后失败。第四种最让我崩溃这些问题本地怎么都复现不出来只在线上高并发时才出现。我盯着这一连串问题想了很久才彻底想明白第一版错在我以为把这几步操作包进一个事务里它就是原子的就安全了。可它不是。事务保证的是 ACID 而其中的隔离性根本不是一个非黑即白的开关它有四个隔离级别每个级别允许不同程度的并发异常。把操作包进事务只保证了它们要么全做要么全不做却没有阻止另一个事务在你查和写的缝隙里也来查也来写。真正用好事务核心不是把代码包进 begin 和 commit 里而是理解隔离级别各自允许什么异常什么时候该用更强的级别什么时候必须动用显式锁。本文从头梳理为什么包进事务不等于安全四个隔离级别和三种读异常分别是什么读已提交和可重复读到底差在哪幻读是怎么回事读后写的竞态该怎么用锁解决以及死锁长事务乐观锁这些把事务真正用对要避开的坑。

2023 年我做一个电商的库存扣减功能。逻辑看着特别简单:用户下单,我就查一下库存够不够,够就扣减、生成订单。第一版我做得很省事:"查库存 → 判断够不够 → 扣减"三步,我把它们包进了一个数据库事务里。本地一测——毫无问题:下单、扣库存、生成订单,一气呵成。我心里很踏实:"把这几步包进一个事务里,它就是原子的、就安全了。"可等它真正上线、撞上大促的高并发,一串问题冒了出来。第一种最离谱:一个只有 1 件库存的商品,居然卖出去了 3 单——库存字段变成了 -2。第二种:对账时发现,有些订单扣了款却没扣库存,有些扣了库存却查不到订单。第三种:偶尔会有请求报一个"deadlock"的错然后失败。第四种最让我崩溃:这些问题本地怎么都复现不出来,只在线上高并发时才出现。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"把这几步操作包进一个事务里,它就是原子的、就安全了"。这句话把"事务"这个词,理解成了一道能挡住一切并发问题的万能护盾。可它不是。事务保证的是 ACID,而其中的"隔离性"——也就是多个事务并发时彼此干不干扰——根本不是一个非黑即白的开关。它有四个隔离级别,每个级别允许不同程度的并发异常。把操作包进事务,只保证了它们"要么全做、要么全不做",却没有阻止另一个事务在你"查"和"写"的缝隙里也来查、也来写。真正用好事务,核心不是"把代码包进 begin 和 commit 里",而是理解隔离级别各自允许什么异常、什么时候该用更强的级别、什么时候必须动用显式锁。这篇文章就把事务隔离级别梳理一遍:为什么"包进事务"不等于"安全"、四个隔离级别和三种读异常分别是什么、读已提交和可重复读到底差在哪、幻读是怎么回事、"读后写"的竞态该怎么用锁解决,以及死锁、长事务、乐观锁这些把事务真正用对要避开的坑。

问题背景

先把那次超卖事故的现象和我的误判讲清楚,后面所有的设计都是冲着纠正这个误判去的。

现象:把"查库存、判断、扣减"三步包进一个事务,上线后高并发下冒出一串问题:1 件库存卖出 3 单、库存变成负数;扣款和扣库存对不上账;偶尔报 deadlock 错;这些问题本地无法复现,只在线上高并发时出现

我当时的错误认知:"把这几步操作包进一个事务里,它就是原子的、就安全了。"

真相:事务的"原子性"只保证一组操作要么全做要么全不做;而"隔离性"不是开关,是四个级别,每个级别放行不同的并发异常(脏读、不可重复读、幻读)。默认隔离级别不会自动帮你解决"读了之后再据此写"的竞态。事务真正的工程量,在于:看懂四个隔离级别各自的代价用 SELECT FOR UPDATE 等显式锁挡住读后写竞态用固定加锁顺序防死锁用乐观锁应对低冲突场景不写长事务。把代码包进事务只是开头,理解并发下它到底安不安全才是关键。

要把事务用对,需要几块认知:

  • 为什么"包进事务"不等于"安全"——事务不阻止并发读写竞态;
  • 四个隔离级别——读未提交、读已提交、可重复读、串行化;
  • 三种读异常——脏读、不可重复读、幻读分别是什么;
  • 显式锁——SELECT FOR UPDATE 怎么挡住"读后写"竞态;
  • 死锁、长事务、乐观锁这些工程坑怎么处理。

一、为什么"包进事务"不等于"安全"

先把这件最根本的事钉死:事务的原子性,保证的是"一组操作不可分割地全部完成或全部撤销";它保证不了的是"在你这组操作执行到一半时,别的事务不会插进来"。把"查库存、判断、扣减"包进一个事务,只是说这三步要么都成功、要么都回滚;它丝毫没有阻止——另一个并发的事务,在你"查完库存"和"还没扣减"的那个时间缝隙里,也跑来查了同一个库存。两个事务读到了同一个"还够"的库存,于是都通过了判断,然后都扣了减。超卖,就是这么发生的。

下面这段代码,就是我那个"上线就超卖"的第一版:

def deduct_stock(conn, product_id: int, qty: int) -> bool:
    """反面教材:以为把三步放进一个事务里就安全了。"""
    conn.begin()
    # 第一步:查出当前库存
    row = conn.execute(
        "SELECT stock FROM products WHERE id = %s", (product_id,)
    ).fetchone()
    # 第二步:在【应用代码里】判断够不够
    if row["stock"] < qty:
        conn.rollback()
        return False
    # 第三步:扣减
    conn.execute(
        "UPDATE products SET stock = stock - %s WHERE id = %s",
        (qty, product_id),
    )
    conn.commit()
    return True
    # 破绽:两个请求可能【同时】执行到第一步,都读到 stock=1,
    # 都通过了"够不够"的判断,然后各自扣 1 —— 库存变成 -1。
    # 事务保证了这三步"要么全做要么全不做",却【没有阻止】
    # 另一个事务在你"查"和"写"的缝隙里也来查、也来写。

这段代码本地测试永远是对的——因为本地没有并发,请求一个接一个地来,前一个扣完了后一个才查。它的问题不在代码本身,而在一个被忽略的前提:它默认"我查库存和我扣库存之间,世界是静止的,不会有别人插进来"。可在高并发下,这个前提彻底不成立。于是那串问题就有了解释:超卖,是因为多个事务在"查"和"写"之间互相穿插,都基于同一个旧库存做了判断;本地复现不了,正因为本地根本没有这种穿插。问题的根子清楚了:事务并不是一个"包起来就万事大吉"的护盾。要写对并发代码,你得先理解——多个事务并发时,它们到底能多大程度地"看见"和"干扰"彼此。这件事,就由"隔离级别"来定义。

二、四个隔离级别与三种读异常

ACID 里的 I,就是隔离性(Isolation)。它回答的问题是:当多个事务同时跑,一个事务能不能看到另一个事务"做了一半"的中间状态?SQL 标准定义了四个隔离级别,从弱到强排列——级别越弱,并发性能越好,但允许的"异常"越多;级别越强,越安全,但并发代价越大:

-- 查看当前会话的事务隔离级别
SELECT @@transaction_isolation;

-- 四个隔离级别,从弱到强:
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;  -- 读未提交
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;    -- 读已提交
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;   -- 可重复读
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;      -- 串行化

这四个级别,对应着三种会逐级被消除的"读异常"。最弱的 READ UNCOMMITTED(读未提交)允许一种最严重的异常——脏读:你读到了别的事务还没提交、随时可能被回滚的数据。下面用两个事务把脏读演示出来:

-- 脏读:READ UNCOMMITTED 下,会读到别人【还没提交】的数据

-- 事务 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 注意:此时 A 还没 COMMIT

-- 事务 B(隔离级别 = READ UNCOMMITTED)
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 读到了 A 未提交的扣款结果
-- 如果接下来 A 回滚了,B 读到的就是一个【从未真实存在过】的值

-- 事务 A
ROLLBACK;   -- A 撤销了,但 B 已经基于那个假数据做了决策

脏读的危害在于:B 基于一个"幻影数据"做了决策,可这个数据后来被 A 撤销了,等于从来没存在过。所以 READ UNCOMMITTED 在实践中几乎没人用——脏读的代价太大。从 READ COMMITTED(读已提交)开始,脏读被消除了:你只能读到别的事务已经提交的数据。这也是 Oracle、PostgreSQL、SQL Server 等大多数数据库的默认级别。但 READ COMMITTED 还不够——它还允许另外两种异常。它们是什么、怎么消除,是下一节的事。

三、读已提交 vs 可重复读:到底差在哪

READ COMMITTED 解决了脏读,但它还允许一种异常,叫不可重复读:在同一个事务内,你两次读同一行数据,结果却不一样——因为两次读之间,别的事务把那行改了并提交了。演示如下:

-- 不可重复读:同一事务内两次读同一行,结果【不一样】

-- 事务 A(隔离级别 = READ COMMITTED)
BEGIN;
SELECT price FROM products WHERE id = 1;   -- 第一次读:price = 100

-- 事务 B
BEGIN;
UPDATE products SET price = 200 WHERE id = 1;
COMMIT;                                     -- B 提交了

-- 事务 A
SELECT price FROM products WHERE id = 1;   -- 第二次读:price = 200(变了!)
COMMIT;
-- 在 READ COMMITTED 下这是被允许的,A 的两次读对不上

"同一个事务里,前后两次读到的数据不一致"——这在很多业务里是致命的。比如你先读一个价格做计算,中间又读一次校验,结果两次价格不一样,逻辑就乱了。REPEATABLE READ(可重复读)就是来解决这个的:它保证在同一个事务内,无论你读多少次同一行,结果永远一致——仿佛事务一开始,就给整个数据库拍了张快照,你整个事务期间看到的都是这张快照。这也是 MySQL InnoDB 的默认隔离级别。需要某个级别时,可以显式地为事务指定:

def transfer_with_rc(conn, from_id: int, to_id: int, amount: int):
    """显式用 READ COMMITTED:每条语句都看得到别人最新提交的数据。"""
    conn.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")
    conn.begin()
    try:
        conn.execute("UPDATE accounts SET balance = balance - %s "
                     "WHERE id = %s", (amount, from_id))
        conn.execute("UPDATE accounts SET balance = balance + %s "
                     "WHERE id = %s", (amount, to_id))
        conn.commit()
    except Exception:
        conn.rollback()
        raise

这里要建立一个关键的权衡意识:隔离级别不是越高越好。级别越高,数据库为了维持那个"一致的快照",要做的工作越多、加的锁越多、并发能力越受限READ COMMITTED 并发性能更好,代价是允许不可重复读;REPEATABLE READ 一致性更强,代价是并发开销更大。选哪个,取决于你的业务能不能容忍"同一事务内数据变化"。但即便到了 REPEATABLE READ,还有最后一种、也最隐蔽的异常没解决——幻读

四、幻读与 MySQL 的可重复读级别

不可重复读说的是"同一行的值变了";而幻读说的是"行的数量变了"。具体讲:在同一个事务内,你用同样的条件做两次范围查询,第二次却多出了(或少了)几行——因为别的事务在这期间,插入(或删除)了符合你条件的行。这些凭空多出来的行,就像幽灵一样,所以叫"幻读":

-- 幻读:同一事务内两次【范围查询】,行数因别人插入而变化

-- 事务 A
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 7;   -- 第一次:3 条

-- 事务 B
BEGIN;
INSERT INTO orders (user_id, amount) VALUES (7, 50);
COMMIT;

-- 事务 A
SELECT COUNT(*) FROM orders WHERE user_id = 7;   -- 再查:可能变成 4 条
COMMIT;

按 SQL 标准,只有最强的 SERIALIZABLE(串行化)级别才彻底消除幻读。SERIALIZABLE 会让事务像排队一样一个接一个地执行,并发性能最差,所以也很少用。这里有一个必须知道的细节:MySQL 的 InnoDB 引擎,在它默认的 REPEATABLE READ 级别下,通过一种叫"间隙锁(Next-Key Lock)"的机制,已经在很大程度上避免了幻读——它不仅锁住已存在的行,还锁住行与行之间的"间隙",不让别的事务往这些间隙里插入新行。这是 MySQL 比 SQL 标准"多做了一点"的地方,也是为什么 MySQL 选 RR 而非 RC 作默认级别。所以一条实用经验是:了解你用的数据库,它的默认级别到底是什么、那个级别的真实行为是什么——别想当然。隔离级别说清了,但它解决不了我开头那个超卖:那是一个"读了之后,要据此再写"的竞态,光靠调隔离级别不够,得动用显式的锁

五、用显式锁解决"读后写"竞态

回到开头那个超卖。它的本质是一个"读-改-写"竞态:你读出库存,基于读到的值做判断,再写回扣减后的值。问题就出在"读"和"写"之间有个缝,别的事务能挤进来。解决它,最直接的办法是 SELECT ... FOR UPDATE——在查的同时,就给这一行加上排他锁:

def deduct_stock_safe(conn, product_id: int, qty: int) -> bool:
    """正确做法:用 SELECT ... FOR UPDATE 给这一行加排他锁。"""
    conn.begin()
    # FOR UPDATE:查的同时就给这一行上锁,
    # 其他事务想读这行来做扣减,必须排队等我提交或回滚
    row = conn.execute(
        "SELECT stock FROM products WHERE id = %s FOR UPDATE",
        (product_id,),
    ).fetchone()
    if row["stock"] < qty:
        conn.rollback()
        return False
    conn.execute(
        "UPDATE products SET stock = stock - %s WHERE id = %s",
        (qty, product_id),
    )
    conn.commit()           # 提交后锁释放,排队的事务才能继续
    return True

FOR UPDATE 干的事是:从你查到这一行的那一刻起,就把它锁住。其他事务也想 FOR UPDATE 这一行的,必须排队等——等你 commitrollback 释放锁。这样,"读-改-写"这三步,对同一行而言就被强制串行化了:第二个事务拿到锁时,看到的已经是第一个事务扣减后的库存,自然不会再超卖。这种"先加锁、独占、再操作"的思路,叫悲观锁。不过,对付这个特定的库存场景,还有一个更简洁、性能更好的办法——干脆把"判断"和"扣减"压进同一条 UPDATE 语句,让数据库用一条原子的 SQL 同时完成判断和扣减:

-- 更优解:把"判断够不够"和"扣减"压进同一条原子 UPDATE
UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock >= 1;     -- 库存不足时,这条 UPDATE 影响 0 行

-- 应用层只需检查这条语句影响了几行:
--   affected_rows = 1  ->  扣减成功
--   affected_rows = 0  ->  库存不够(或商品不存在),扣减失败

这条 UPDATE 的精妙之处在于:它把"读-判断-写"三步,合并成了数据库内部的一个原子操作WHERE 里的 stock >= 1判断,SET stock = stock - 1扣减——数据库保证这条语句的执行是原子的,中间没有任何缝隙给别的事务。库存不够时,WHERE 条件不满足,这条语句就影响 0 行,应用层检查影响行数即可知道成败。这给出一条重要原则:凡是能用一条带条件的 UPDATE 搞定的,就不要拆成"先 SELECT 查、应用层判断、再 UPDATE 写"两步——两步之间的缝,就是并发 bug 的温床。下面这张图,把"读后写"该怎么选方案串起来:

六、工程坑:死锁、长事务与乐观锁

五块设计之外,还有几个工程坑,不处理就会在生产上出事。坑 1:加锁顺序不一致会死锁。我开头那个偶发的 deadlock 错,就是它。当两个事务各持有一把锁、又都在等对方手里那把,就互相死等。最经典的场景是转账:事务 A "A 转 B"先锁了账户 A、再去锁 B;事务 B "B 转 A"先锁了账户 B、再去锁 A——两个就锁死了。解法极其简单:永远按固定顺序加锁:

def transfer_no_deadlock(conn, acc_a: int, acc_b: int, amount: int):
    """避免死锁:永远按【固定顺序】(比如 id 从小到大)加锁。"""
    # 关键:不管是 A 转 B 还是 B 转 A,都先锁 id 小的那个。
    # 否则"A 转 B 锁 A 等 B"和"B 转 A 锁 B 等 A"会互相死等。
    first, second = sorted([acc_a, acc_b])
    conn.begin()
    conn.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
                 (first,))
    conn.execute("SELECT balance FROM accounts WHERE id = %s FOR UPDATE",
                 (second,))
    conn.execute("UPDATE accounts SET balance = balance - %s WHERE id = %s",
                 (amount, acc_a))
    conn.execute("UPDATE accounts SET balance = balance + %s WHERE id = %s",
                 (amount, acc_b))
    conn.commit()

坑 2:悲观锁不是唯一选择,冲突少的场景用乐观锁。FOR UPDATE 这种悲观锁,会让事务排队等,冲突多时还行,但如果冲突其实很少(比如多人编辑同一篇文档,但同时编辑同一篇的概率很低),悲观锁的排队等待就是纯浪费。这时候该用乐观锁:不加数据库锁,而是给数据加一个 version 字段,更新时检查"我读到之后,有没有人改过":

def update_with_optimistic_lock(conn, doc_id: int, new_text: str) -> bool:
    """乐观锁:不加数据库锁,靠 version 字段检测"读后被人改过"。"""
    row = conn.execute(
        "SELECT version, content FROM docs WHERE id = %s", (doc_id,)
    ).fetchone()
    old_version = row["version"]
    # 更新时,带上"我读到的那个 version"作为额外条件
    result = conn.execute(
        "UPDATE docs SET content = %s, version = version + 1 "
        "WHERE id = %s AND version = %s",
        (new_text, doc_id, old_version),
    )
    # 影响 0 行 = 在我"读"和"写"之间,别人已经改过了,version 对不上
    if result.rowcount == 0:
        return False        # 冲突,调用方需要重新读取后重试
    conn.commit()
    return True

乐观锁的思路是:乐观地假设"大概率没人和我冲突",先不加锁、直接干;到了写的时候,才用 version 检查一下"我读的时候那个版本还在不在"。在,说明没人动过,更新成功;不在(version 对不上,影响 0 行),说明读和写之间有人插了一脚,那就放弃这次、重读后重试冲突少时,乐观锁几乎没有等待开销,性能远好于悲观锁;冲突多时则相反,会因为反复重试而变慢。坑 3:绝对不要写长事务。一个事务从 begincommit 之间持续的时间越长,它持有的锁就占用得越久,别的事务等得越久,并发能力断崖式下跌。所以事务里千万别夹进慢操作——尤其是调用外部接口、发送网络请求、读大文件这类不可控的耗时操作。事务要短、快、只包真正需要原子性的数据库操作坑 4:应用层要能处理"事务失败后重试"。死锁、乐观锁冲突,数据库都会让其中一个事务失败。这是正常现象,不是 bug。你的应用层必须有重试逻辑:捕获到这类失败,重新读取最新数据、重新跑一遍事务,而不是直接把错误抛给用户。

关键概念速查

概念 / 手段 说明
原子性 一组操作要么全部完成要么全部撤销,不保证防并发
隔离性 多个事务并发时彼此的可见与干扰程度,分四个级别
脏读 读到别的事务尚未提交、随时可能被回滚的数据
不可重复读 同一事务内两次读同一行,值被别人改了
幻读 同一事务内两次范围查询,行数因别人插入删除而变化
READ COMMITTED 多数数据库默认,消除脏读,允许不可重复读
REPEATABLE READ MySQL 默认,同一事务内多次读结果一致
SELECT FOR UPDATE 悲观锁,查的同时给行加排他锁,其他事务排队
乐观锁 不加锁,用 version 字段检测读后是否被改,冲突再重试
死锁 两事务互相持锁等对方,按固定顺序加锁可避免

避坑清单

  1. 把几步操作放进一个事务只保证原子性,不会自动阻止并发竞态。
  2. 隔离性不是开关,有四个级别,每个级别允许不同的并发异常。
  3. 查库存判断够不够再扣减,在默认隔离级别下会超卖,必须加锁。
  4. 脏读读到的是可能被回滚的假数据,只有读未提交级别才允许它。
  5. 不可重复读是同一事务两次读同一行值变了,可重复读级别能避免。
  6. 幻读是范围查询行数变化,MySQL 的 RR 靠间隙锁已基本避免。
  7. 读后写的竞态用 SELECT FOR UPDATE 悲观锁,或把条件压进 UPDATE。
  8. 能用一条带 WHERE 条件的 UPDATE 搞定的,别拆成先查后写两步。
  9. 死锁多因加锁顺序不一致,永远按固定顺序如 id 升序加锁。
  10. 别写长事务,事务里别夹外部调用,死锁和冲突要在应用层重试。

总结

回头看那串"1 件库存卖出 3 单、库存变成负数、偶发 deadlock"的事故,以及我后来在事务上接连踩的坑,最该记住的不是某一段加锁代码,而是我动手前那个想当然的判断——"把这几步操作包进一个事务里,它就是原子的、就安全了"。这句话错在它把"事务"这个词,当成了一道能挡住一切并发问题的万能护盾。我以为"包进事务"和"线程安全"是一回事。可它根本不是。事务的"原子性"管的是"这一组操作会不会只做一半"——它保证不会;而"会不会有别人在我做的过程中插进来",那是"隔离性"管的事,而隔离性不是一个开关,是一个有四档的旋钮。我把整个旋钮的存在都忽略了,自然就栽了。

所以用对数据库事务,真正的工程量不在"写 begin 和 commit"那两行语句上。那两行,任何教程的第一页就教完了。真正的工程量,在于你要理解并发这件事的全部复杂度,并为它做出选择:你要知道四个隔离级别各自放行什么异常,从而为你的业务选一个对的级别;你要认出"读-改-写"这种竞态,知道它光靠隔离级别治不了,得上 SELECT FOR UPDATE 或者把条件压进 UPDATE;你要在悲观锁和乐观锁之间,根据冲突频率做权衡;你还要防住死锁、写短事务、在应用层准备好重试。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚"包进事务"为什么不等于安全,再把四个隔离级别和三种读异常逐一讲透,辨清读已提交和可重复读的差别、看懂幻读,然后用显式锁接住"读后写"竞态,最后是死锁、长事务、乐观锁这几个把事务用扎实的工程细节。

你会发现,事务并发控制的思路,和现实里"一群人共用一间会议室"完全相通。"把操作包进事务",就好比你规定"开会必须有始有终,不能开一半就散"(这是原子性)——这当然有用,但它压根没规定"同一时间能不能有两拨人都进这间会议室"。于是两拨人同时闯进来,各开各的会,吵成一团(这就是并发竞态、就是超卖)。一个会管理的人怎么做?他知道"能不能同时进"是另一套规则,而且这套规则有好几档(这是隔离级别):宽松一点的,允许你进去时瞥见别人会开了一半(脏读);严格一点的,你进去后看到的画面就固定了,别人改不了你眼前的东西(可重复读)。而对"我得先看看会议室空不空、空了我才进"这种事——这正是"读后写"——他知道光靠那几档规则不够,得有一把实实在在的门锁:你查看的同时就把门锁上(SELECT FOR UPDATE),别人只能在门外排队事务并发控制的成败,从来不在于你有没有把会"开完整",而在于你有没有想清楚:在我开会的时候,这扇门到底锁没锁。

最后想说,事务用没用对,差距永远不会在"功能测试"时暴露——测试时请求一个一个慢慢来,根本没有并发,你会觉得"包进事务"这几个字已经是全部。它只在真实的、成百上千个请求同时砸过来的高并发线上环境里才显形。那时候它会用最让人头疼的方式给你结账:做不好,你会像我一样,被一串本地死活复现不出的诡异数据折磨——库存莫名其妙变成负数,账目莫名其妙对不上,偶尔还蹦个 deadlock,你把每一行代码都看了八遍,逻辑全是对的,可数据就是错的;而做了,你的系统在大促那种极端并发下,每一笔库存扣减都精确无误,每一分钱的账都对得上,该排队的事务安静地排队、该重试的冲突悄悄地重试,用户那头毫无感知。所以别等"库存变负数"找上门,在你写下 begin 的那一刻就该想清楚:我这个事务,会不会和别的事务抢同一行数据?如果会,我是靠隔离级别、靠悲观锁、还是靠乐观锁来保证它的正确?这个问题有了答案,你的事务才不只是一对"看起来包起来了"的 begin/commit,而是一套真正扛得住高并发、数据怎么都不会错的可靠机制。

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

模型量化完全指南:从一次"量化完模型变小了却答得驴唇不对马嘴"看懂量化工程

2026-5-22 0:03:19

技术教程

知识蒸馏完全指南:从一次"小模型把大模型的答案背熟了却照样不会做题"看懂模型蒸馏

2026-5-22 0:15:52

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