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 这一行的,必须排队等——等你 commit 或 rollback 释放锁。这样,"读-改-写"这三步,对同一行而言就被强制串行化了:第二个事务拿到锁时,看到的已经是第一个事务扣减后的库存,自然不会再超卖。这种"先加锁、独占、再操作"的思路,叫悲观锁。不过,对付这个特定的库存场景,还有一个更简洁、性能更好的办法——干脆把"判断"和"扣减"压进同一条 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:绝对不要写长事务。一个事务从 begin 到 commit 之间持续的时间越长,它持有的锁就占用得越久,别的事务等得越久,并发能力断崖式下跌。所以事务里千万别夹进慢操作——尤其是调用外部接口、发送网络请求、读大文件这类不可控的耗时操作。事务要短、快、只包真正需要原子性的数据库操作。坑 4:应用层要能处理"事务失败后重试"。死锁、乐观锁冲突,数据库都会让其中一个事务失败。这是正常现象,不是 bug。你的应用层必须有重试逻辑:捕获到这类失败,重新读取最新数据、重新跑一遍事务,而不是直接把错误抛给用户。
关键概念速查
| 概念 / 手段 | 说明 |
|---|---|
| 原子性 | 一组操作要么全部完成要么全部撤销,不保证防并发 |
| 隔离性 | 多个事务并发时彼此的可见与干扰程度,分四个级别 |
| 脏读 | 读到别的事务尚未提交、随时可能被回滚的数据 |
| 不可重复读 | 同一事务内两次读同一行,值被别人改了 |
| 幻读 | 同一事务内两次范围查询,行数因别人插入删除而变化 |
| READ COMMITTED | 多数数据库默认,消除脏读,允许不可重复读 |
| REPEATABLE READ | MySQL 默认,同一事务内多次读结果一致 |
| SELECT FOR UPDATE | 悲观锁,查的同时给行加排他锁,其他事务排队 |
| 乐观锁 | 不加锁,用 version 字段检测读后是否被改,冲突再重试 |
| 死锁 | 两事务互相持锁等对方,按固定顺序加锁可避免 |
避坑清单
- 把几步操作放进一个事务只保证原子性,不会自动阻止并发竞态。
- 隔离性不是开关,有四个级别,每个级别允许不同的并发异常。
- 查库存判断够不够再扣减,在默认隔离级别下会超卖,必须加锁。
- 脏读读到的是可能被回滚的假数据,只有读未提交级别才允许它。
- 不可重复读是同一事务两次读同一行值变了,可重复读级别能避免。
- 幻读是范围查询行数变化,MySQL 的 RR 靠间隙锁已基本避免。
- 读后写的竞态用 SELECT FOR UPDATE 悲观锁,或把条件压进 UPDATE。
- 能用一条带 WHERE 条件的 UPDATE 搞定的,别拆成先查后写两步。
- 死锁多因加锁顺序不一致,永远按固定顺序如 id 升序加锁。
- 别写长事务,事务里别夹外部调用,死锁和冲突要在应用层重试。
总结
回头看那串"1 件库存卖出 3 单、库存变成负数、偶发 deadlock"的事故,以及我后来在事务上接连踩的坑,最该记住的不是某一段加锁代码,而是我动手前那个想当然的判断——"把这几步操作包进一个事务里,它就是原子的、就安全了"。这句话错在它把"事务"这个词,当成了一道能挡住一切并发问题的万能护盾。我以为"包进事务"和"线程安全"是一回事。可它根本不是。事务的"原子性"管的是"这一组操作会不会只做一半"——它保证不会;而"会不会有别人在我做的过程中插进来",那是"隔离性"管的事,而隔离性不是一个开关,是一个有四档的旋钮。我把整个旋钮的存在都忽略了,自然就栽了。
所以用对数据库事务,真正的工程量不在"写 begin 和 commit"那两行语句上。那两行,任何教程的第一页就教完了。真正的工程量,在于你要理解并发这件事的全部复杂度,并为它做出选择:你要知道四个隔离级别各自放行什么异常,从而为你的业务选一个对的级别;你要认出"读-改-写"这种竞态,知道它光靠隔离级别治不了,得上 SELECT FOR UPDATE 或者把条件压进 UPDATE;你要在悲观锁和乐观锁之间,根据冲突频率做权衡;你还要防住死锁、写短事务、在应用层准备好重试。这篇文章的几节,其实就是顺着这条思路展开的:先想清楚"包进事务"为什么不等于安全,再把四个隔离级别和三种读异常逐一讲透,辨清读已提交和可重复读的差别、看懂幻读,然后用显式锁接住"读后写"竞态,最后是死锁、长事务、乐观锁这几个把事务用扎实的工程细节。
你会发现,事务并发控制的思路,和现实里"一群人共用一间会议室"完全相通。"把操作包进事务",就好比你规定"开会必须有始有终,不能开一半就散"(这是原子性)——这当然有用,但它压根没规定"同一时间能不能有两拨人都进这间会议室"。于是两拨人同时闯进来,各开各的会,吵成一团(这就是并发竞态、就是超卖)。一个会管理的人怎么做?他知道"能不能同时进"是另一套规则,而且这套规则有好几档(这是隔离级别):宽松一点的,允许你进去时瞥见别人会开了一半(脏读);严格一点的,你进去后看到的画面就固定了,别人改不了你眼前的东西(可重复读)。而对"我得先看看会议室空不空、空了我才进"这种事——这正是"读后写"——他知道光靠那几档规则不够,得有一把实实在在的门锁:你查看的同时就把门锁上(SELECT FOR UPDATE),别人只能在门外排队。事务并发控制的成败,从来不在于你有没有把会"开完整",而在于你有没有想清楚:在我开会的时候,这扇门到底锁没锁。
最后想说,事务用没用对,差距永远不会在"功能测试"时暴露——测试时请求一个一个慢慢来,根本没有并发,你会觉得"包进事务"这几个字已经是全部。它只在真实的、成百上千个请求同时砸过来的高并发线上环境里才显形。那时候它会用最让人头疼的方式给你结账:做不好,你会像我一样,被一串本地死活复现不出的诡异数据折磨——库存莫名其妙变成负数,账目莫名其妙对不上,偶尔还蹦个 deadlock,你把每一行代码都看了八遍,逻辑全是对的,可数据就是错的;而做对了,你的系统在大促那种极端并发下,每一笔库存扣减都精确无误,每一分钱的账都对得上,该排队的事务安静地排队、该重试的冲突悄悄地重试,用户那头毫无感知。所以别等"库存变负数"找上门,在你写下 begin 的那一刻就该想清楚:我这个事务,会不会和别的事务抢同一行数据?如果会,我是靠隔离级别、靠悲观锁、还是靠乐观锁来保证它的正确?这个问题有了答案,你的事务才不只是一对"看起来包起来了"的 begin/commit,而是一套真正扛得住高并发、数据怎么都不会错的可靠机制。
—— 别看了 · 2026