数据库事务隔离级别完全指南:从一次"开了事务库存还是被超卖"看懂脏读幻读到底怎么挡

2023 年我做一个电商系统的库存扣减功能用户下单系统从商品库存里减掉相应的数量这件事我没多想就有了方案开个事务把查库存判断够不够扣库存这几步包进去第一版我做得很顺手我在代码里 BEGIN 先 SELECT 出当前库存在程序里判断库存够再 UPDATE 把库存减掉最后 COMMIT 本地点几下下单扣库存数字一分不差我心里很笃定并发安全嘛把这几步包进一个事务里数据库自然就帮我管好了可等大促一来并发一上一串问题冒了出来第一种最先把我打懵同一件商品库存明明只剩 1 件却被两个用户同时下单成功了库存扣成了负数第二种最难缠我做了个对账功能在一个事务里前后两次查同一个商品的库存两次的数字竟然不一样第三种最头疼我在事务里 COUNT 某个状态的订单过了一会儿再 COUNT 一次行数多了几行第四种最莫名其妙有一次两个转账操作互相卡住数据库直接报错把其中一个事务回滚了我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为只要把操作包进一个事务里数据库就会自动保证并发安全可事务的隔离性根本不是一个非黑即白的开关数据库提供的是好几个隔离级别像一组从松到紧的档位每一个档位只挡住特定的几类并发异常放过另外几类本文从头梳理为什么开了事务不等于并发安全脏读不可重复读幻读这三类异常各是什么隔离级别是怎么一级一级把它们挡掉的级别越高的代价是什么以及一些把它做扎实要避开的工程坑

2023 年我做一个电商系统的库存扣减功能——用户下单,系统从商品库存里减掉相应的数量。这件事我没多想,就有了方案:开个事务,把"查库存、判断够不够、扣库存"这几步包进去。第一版我做得很顺手——我在代码里 BEGIN,先 SELECT 出当前库存,在程序里判断"库存够",再 UPDATE 把库存减掉,最后 COMMIT。本地点几下,下单、扣库存,数字一分不差,我心里很笃定:并发安全嘛,把这几步包进一个事务里,数据库自然就帮我管好了,这库存稳了。可等大促一来、并发一上,一串问题冒了出来。第一种最先把我打懵:同一件商品,库存明明只剩 1 件,却被两个用户同时下单成功了,库存扣成了负数。第二种最难缠:我做了个对账功能,在一个事务里前后两次查同一个商品的库存,两次的数字竟然不一样,中间没有任何我自己的操作。第三种最头疼:我做了个统计,在事务里 COUNT 某个状态的订单,过了一会儿同一个事务里再 COUNT 一次,行数多了几行,凭空多出来的。第四种最莫名其妙:有一次两个转账操作互相卡住,数据库直接报了个错把其中一个事务回滚了。我盯着这一连串问题想了很久,才彻底想明白:第一版错在一个根本的认知上。我以为只要把操作包进一个事务里,数据库就会自动保证并发安全,所有并发问题它都替我兜住了。可事务的"隔离性"根本不是一个非黑即白的开关——数据库提供的是好几个"隔离级别",像一组从松到紧的档位。每一个档位,只挡住特定的几类并发异常,放过另外几类。你不指定,数据库就按它的默认档位运行,而默认档位下,依然有并发异常会漏过来。要把并发数据做扎实,根上要明白:事务不是"开了就安全",你必须知道你用的是哪个隔离级别、这个级别挡住了什么、又放过了什么,然后按你的业务,要么选对级别、要么自己加锁补上。本文从头梳理:为什么"开了事务"不等于"并发安全",脏读、不可重复读、幻读这三类异常各是什么,隔离级别是怎么一级一级把它们挡掉的,级别越高的代价是什么,以及一些把它做扎实要避开的工程坑。

问题背景

先把事务的四个特性说清楚,也就是常说的 ACID:原子性(一组操作要么全成功要么全失败)、一致性(数据从一个合法状态到另一个合法状态)、隔离性(并发的事务之间互不干扰)、持久性(提交后的数据不丢)。第一版的错,集中在对"隔离性"这一条的误解上。

错误认知是:隔离性是一个布尔开关,开了事务,并发的事务之间就彻底隔离、互不可见了。真相是:完全的隔离(每个事务都像独占数据库一样运行)代价极高,几乎没法支撑并发。所以数据库提供的是分级的隔离——四个隔离级别,从松到紧依次挡住越来越多的并发异常,但越紧的级别,并发性能越低。把这一点摊开,第一版的几类问题就都能解释了:

  • 库存被超卖:两个事务同时读到库存为 1,各自判断"够",各自扣减,这是典型的并发写冲突,只靠"开事务"挡不住。
  • 同一查询两次结果不同:这叫不可重复读,事务读过的某一行,被另一个已提交的事务改了,本事务再读就变了。
  • COUNT 出来行数变多:这叫幻读,事务按某个范围查询,另一个事务在这个范围里插入了新行,本事务再查就"多"出来了。
  • 转账互相卡死被回滚:这是死锁,两个事务各持有对方需要的锁、互相等待,数据库检测到后会牺牲一个。

所以让并发数据做对,核心不是"记得开事务",而是理解隔离级别这组档位,知道每一档挡什么、放什么,按业务把档位选对、把缺口补上。下面六节,就从第一版"开了事务还超卖"讲起。

一、为什么"开了事务"不等于"并发就安全"

第一版的库存扣减,逻辑看起来无懈可击:开事务、查库存、判断、扣减、提交,五步都在一个事务里。但它有一个致命的盲点——它把"事务"和"并发安全"划了等号。事务保证的是"我这一组操作,要么全做、要么全不做"(原子性),它并不天然保证"我这组操作执行的时候,别人不会插进来"。两个事务完全可以"同时"走到"查库存"这一步。

-- 反面教材:开了事务,库存照样被超卖

-- 事务 A 和事务 B 几乎同时执行下面这段

BEGIN;

-- 第 1 步:查当前库存,两个事务都读到 stock = 1
SELECT stock FROM products WHERE id = 100;

-- 第 2 步:在程序里判断 stock >= 1,两个事务都判断"够"

-- 第 3 步:各自把库存减 1
UPDATE products SET stock = stock - 1 WHERE id = 100;

COMMIT;

-- 结果:库存只有 1,却卖出了 2 件,stock 变成了 -1
-- 事务保证了"这几步要么全做要么全不做",
-- 但它没保证"我做的时候别人不能同时做同样的事"

问题出在"查库存"和"扣库存"是分开的两步,中间有个时间窗口。事务 A 查到库存是 1,还没来得及扣,事务 B 也查到了库存是 1。两个事务都觉得"还有货",于是都扣了。事务的原子性,只保证 A 自己的"查—判断—扣"这一组不会做一半,它管不着 B 在 A 的中间窗口里干了什么。这就是并发——多个事务的操作在时间上交错执行。

这一节要建立的认知是:事务的原子性,管的是"一个事务内部的多个操作不可分割";而并发安全,管的是"多个事务之间互相不要干扰"——这是两件不同的事。第一版的错,是默认了"原子"就等于"独占"。其实不是。一个事务在执行时,数据库默认是允许别的事务同时执行的,否则数据库就退化成一次只能跑一个事务的串行系统,根本扛不住并发。"多个事务能不能同时跑、跑的时候能看见对方多少"——这件事,正是由隔离级别来控制的。所以遇到并发数据出错,第一个要问的不是"我开事务了吗",而是"我用的是哪个隔离级别,它挡得住我这个场景的并发吗"。后面三节,就把隔离级别要挡的三类异常,一个一个讲清楚。

二、脏读:读到了别人还没提交的数据

三类并发异常里,最严重的一类叫脏读。它指的是:一个事务,读到了另一个事务"修改了、但还没提交"的数据。为什么说"没提交"是关键?因为没提交的数据,随时可能被回滚掉——一旦回滚,那个值就从来没有真正存在过。你基于一个"从未真正存在过的值"做了判断、做了计算,你的逻辑就建立在了流沙上。

-- 脏读:读到了对方还没提交、随后又回滚掉的数据

-- 事务 B:给账户加了一笔钱,但还没提交
BEGIN;
UPDATE accounts SET balance = balance + 1000 WHERE id = 7;
-- 注意:此时 B 还没 COMMIT

-- 事务 A:在 B 提交之前就读到了这个 +1000
BEGIN;
SELECT balance FROM accounts WHERE id = 7;
-- 在"读未提交"级别下,A 读到的是已经加了 1000 的余额
-- A 以为账户很有钱,放行了一笔大额操作

-- 事务 B 此时出错,回滚了
ROLLBACK;
-- 那 1000 块从来没真正存在过,但 A 已经基于它做了决定
-- A 读到的,就是一笔"脏"数据

挡住脏读的办法,其实很朴素:规定一个事务只能读到"已经提交"的数据,别人没提交的修改,对我一律不可见。这就是隔离级别里的"读已提交"(Read Committed)。它和最松的那个级别"读未提交"(Read Uncommitted)的唯一区别,就是后者允许脏读、前者不允许。绝大多数数据库的默认级别,至少是"读已提交"——也就是说,脏读这个最严重的异常,默认就已经被挡掉了。

-- 隔离级别可以按事务设置,也可以设会话级默认

-- 查看当前会话的隔离级别(MySQL)
SELECT @@transaction_isolation;

-- 把当前会话设为"读已提交",此后脏读被挡住
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 也可以只为下一个事务单独设置
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 这个事务里的所有 SELECT,都只看得见已提交的数据
SELECT balance FROM accounts WHERE id = 7;
COMMIT;

这一节的认知是:脏读之所以是最严重的异常,是因为它让你读到的数据"可能根本不曾真实存在过"——它建立在别人一个还没尘埃落定的修改上。已提交的数据,无论后面发生什么,它"曾经是合法状态"这件事是确定的;而未提交的数据,它的命运还悬而未决,可能提交、也可能回滚。基于前者做判断,最坏是数据旧了点;基于后者做判断,你可能基于一个虚构的事实采取了无法挽回的行动。所以"读已提交"这条线,是几乎所有正经业务的底线——它的含义朴素得很:我只相信那些已经板上钉钉的数据。理解了脏读,你就明白隔离级别的第一格,挡的是"虚假的数据"。

三、不可重复读:同一个查询两次结果不一样

脏读被"读已提交"挡掉了,但"读已提交"自己又带来一个新问题。第一版那个对账功能的怪事——同一个事务里,前后两次查同一行,数字不一样——根子就在这。这类异常叫不可重复读:在一个事务内,你读了某一行;之后另一个事务把这一行改了、并且提交了;你在同一个事务里再读这一行,值就变了。

注意,这里读到的"变化后的值",是对方已经提交了的——所以它不是脏读,它是合法的数据。但它依然是个问题:在"读已提交"级别下,事务每次 SELECT,看到的都是"那一刻最新的已提交数据"。这意味着,在同一个事务里,你没法保证两次读到的是同一个东西。对账、做一组前后依赖的判断时,这种"脚下的数据在动"的感觉,是致命的。

-- 不可重复读:同一事务里两次读同一行,结果不同

-- 事务 A:在"读已提交"级别下做对账
BEGIN;
SELECT balance FROM accounts WHERE id = 7;   -- 读到 5000

-- 此时事务 B 介入,改了这一行并提交
--   BEGIN;
--   UPDATE accounts SET balance = 8000 WHERE id = 7;
--   COMMIT;

SELECT balance FROM accounts WHERE id = 7;   -- 再读,变成了 8000
-- A 还在同一个事务里,什么都没做,余额却从 5000 变成了 8000
COMMIT;

挡住不可重复读的办法,是再收紧一档:规定一个事务从开始到结束,看到的数据是一个"固定的快照"——事务启动的那一刻,数据是什么样,这个事务整个生命周期里看到的就一直是那个样,别人提交的修改,本事务一概看不见。这个级别叫"可重复读"(Repeatable Read)。在这个级别下,事务 A 那两次 SELECT,会稳稳地都读到 5000。

-- "可重复读"级别:整个事务看到的是一份固定的快照

SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;

BEGIN;
-- 事务一开始,就确定了一份数据快照
SELECT balance FROM accounts WHERE id = 7;   -- 读到 5000

-- 即使此刻别的事务改了这行并提交了……

SELECT balance FROM accounts WHERE id = 7;   -- 依然是 5000
-- 同一事务内,多次读同一行,结果稳定一致
COMMIT;
-- 提交之后再开新事务来读,才会看到最新的值

这一节的认知是:"可重复读"换来的核心保证,是"一致性视图"——在一个事务的眼里,世界是静止的,它看到的是事务开始那一刻的一张快照,而不是一个不断被别人改动的实时画面。"读已提交"和"可重复读"的差别,本质是"快照的粒度"不同:前者每条 SELECT 语句各看各的最新快照,后者整个事务共用一张快照。对于"读一个值、做点判断、再读、再判断"这种有前后依赖的逻辑,你需要的就是后者那种"脚下的地是稳的"的感觉。理解了这一点,你选隔离级别时就有了依据:你的事务里,是不是依赖"多次读到的东西必须一致"?是,就至少要"可重复读"。

四、幻读:同一个范围查询行数变了

"可重复读"保证了同一行多次读结果一致。但还有一个更隐蔽的异常,它不针对"某一行",而针对"某个范围"。第一版那个 COUNT 越数越多的怪事,就是它——幻读。幻读指的是:一个事务按某个条件做范围查询(比如"所有 status 为 pending 的订单"),之后另一个事务在这个范围里插入了新行并提交;本事务再用同样的条件查,就发现凭空多了几行,像见了鬼一样。

幻读和不可重复读的区别要分清:不可重复读,是你读过的某一行的"值"变了;幻读,是符合你查询条件的"行数"变了——多出来或少掉了行。前者是"已有的东西被改了",后者是"凭空冒出了新东西"。一个事务即便对每一行都用了快照,新插入的行如果不被快照机制覆盖,照样会"幻"出来。

-- 幻读:同一个范围查询,两次查到的行数不一样

BEGIN;
-- 第一次:统计待处理订单
SELECT COUNT(*) FROM orders WHERE status = 'pending';   -- 得到 10

-- 此时另一个事务插入了一条新的待处理订单并提交
--   BEGIN;
--   INSERT INTO orders (status) VALUES ('pending');
--   COMMIT;

-- 第二次:同一个事务、同样的条件再统计
SELECT COUNT(*) FROM orders WHERE status = 'pending';   -- 得到 11
-- 凭空多出来一行,这一行就是"幻影"
COMMIT;

挡住幻读,要用到最严的隔离级别"串行化"(Serializable),或者在"可重复读"级别下对查询范围显式加锁。串行化的做法相当于:让有冲突的事务彻底排队、一个一个来,结果等价于它们是串行执行的——既然是串行,自然不会有任何并发异常。代价也很直接:并发能力大幅下降。更常用的折中,是在需要的地方手动加范围锁。

-- 用显式加锁锁住一个范围,挡住幻读

-- 在"可重复读"级别下,对查询范围加锁
BEGIN;
-- FOR UPDATE 会锁住符合条件的行,以及它们之间的间隙,
-- 别的事务想往这个范围里 INSERT 会被阻塞
SELECT * FROM orders WHERE status = 'pending' FOR UPDATE;

-- 在锁的保护下做统计、做处理,期间别人插不进来
UPDATE orders SET status = 'processing' WHERE status = 'pending';
COMMIT;
-- 提交后锁释放,别的事务才能继续

这一节的认知是:幻读提醒我们,并发异常不只发生在"单行的值"上,也发生在"一个范围的成员资格"上——谁属于这个查询结果,这件事本身也会被并发修改。很多人理解隔离,只想到"我读的这行别被改",却忽略了"符合我条件的行集合"也是一个会变的东西。一个范围,它的边界是确定的,但边界之内有几行、是哪几行,取决于别人有没有往里插、往外删。串行化用"彻底排队"一刀切地解决所有这类问题,代价是并发性;范围锁则是外科手术式地只锁住你真正在乎的那个范围。理解了"成员资格也会被并发改变",你就会在做范围统计、范围处理时多一根弦——光有快照不够,可能还得锁住范围。

把这三类异常和四个隔离级别的对应关系画出来,就是下面这张图:

[mermaid]
flowchart TD
A[一个并发场景] --> B{能容忍读到未提交数据吗}
B -->|能 几乎没有| C[读未提交]
B -->|不能| D{同一行多次读需要一致吗}
D -->|不需要| E[读已提交 挡住脏读]
D -->|需要| F{范围查询的行数需要稳定吗}
F -->|不需要| G[可重复读 再挡不可重复读]
F -->|需要| H[串行化或范围加锁 再挡幻读]

五、隔离级别越高越安全,但代价是并发性能

到这里,四个隔离级别都出场了:读未提交、读已提交、可重复读、串行化,从松到紧。一个很自然的念头是:那我直接全用最严的"串行化"不就一劳永逸了?这个念头,正是要在这一节破掉的。隔离级别越高,确实越安全,但它不是免费的——它用"并发性能"来换"安全"。级别越高,事务之间需要的等待、加的锁就越多,系统能同时处理的事务就越少。

-- 不同隔离级别的并发代价,直观地感受一下

-- 串行化级别下,普通的 SELECT 也会加锁
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT * FROM products WHERE id = 100;
-- 此时别的事务想 UPDATE 这一行,会被阻塞,直到本事务结束
-- 安全是安全了,但并发吞吐被压低了
COMMIT;

-- 读已提交级别下,SELECT 走快照、基本不阻塞写
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM products WHERE id = 100;   -- 不挡别人的写
COMMIT;
-- 并发吞吐高,但要自己留意不可重复读、幻读

正确的做法,不是无脑拉到最高,而是按业务场景选。绝大多数普通的读写业务,数据库的默认级别(MySQL 的 InnoDB 默认是"可重复读",PostgreSQL、Oracle、SQL Server 默认是"读已提交")就够用了。只有对一致性要求极高、又能接受较低并发的关键场景(比如某些金融结算),才值得上"串行化"。而对于像库存扣减这种"高并发 + 强一致"的场景,更常见的不是拔高隔离级别,而是在默认级别上,对关键的那几行做显式加锁。

-- 库存扣减的正解:不是拔高隔离级别,而是对那一行加锁

BEGIN;
-- FOR UPDATE 给这一行加上排他锁,
-- 别的事务执行到同样的 FOR UPDATE 会被阻塞、排队
SELECT stock FROM products WHERE id = 100 FOR UPDATE;

-- 拿到锁之后,这中间别的事务进不来,
-- "查—判断—扣"这一组就成了真正的独占操作
UPDATE products SET stock = stock - 1
WHERE id = 100 AND stock >= 1;

COMMIT;   -- 提交后锁释放,下一个排队的事务才进来

这一节的认知是:隔离级别是一根"安全"和"并发性能"之间的滑杆,你不是要把它推到底,而是要根据业务,推到那个"安全足够、性能也还撑得住"的位置。"全用串行化最保险"这个想法,错在它只看到了安全这一端,没看到另一端的代价。安全和性能是一对此消彼长的量,没有哪个位置是绝对最优的,只有"对这个业务最合适的"。而且,提高隔离级别不是唯一的安全手段——显式加锁(FOR UPDATE)是一种更精准的工具:它不改变整个事务的隔离级别,只对你真正在乎的那几行、那个范围,做精确的并发控制。会用这把手术刀,你就能在低隔离级别的高并发下,依然守住关键数据的强一致。隔离级别管的是"默认的、全局的"并发行为,显式锁管的是"特定的、局部的"并发行为,两者要配合用。

六、把事务并发做扎实,要避开的工程坑

前面五节讲清了隔离级别和锁。但要在生产里真正把并发数据做扎实,还有几个坑得专门讲。第一个,也是最容易踩的:别想当然地以为你的数据库默认级别是某个值。第一版我就吃了这个亏——我以为是"读已提交",其实 MySQL InnoDB 默认是"可重复读"。不同数据库默认级别不同,上线前务必亲自查一遍。

-- 坑一:不同数据库默认隔离级别不同,务必亲自确认

-- MySQL:查全局默认 + 当前会话
SELECT @@global.transaction_isolation, @@transaction_isolation;
-- InnoDB 默认是 REPEATABLE-READ

-- PostgreSQL:查当前隔离级别
SHOW default_transaction_isolation;
-- 默认是 read committed

-- 别凭印象,代码里依赖的级别,要在目标库上实测一遍

第二个坑,是长事务。一个事务开着不提交,它持有的锁就一直不释放,它撑起的那份快照也一直不能回收。别的事务要么被它的锁卡住、要么因为它而让数据库积累大量旧版本数据。事务要"短"——尽快提交或回滚,尤其不要在事务里夹着网络请求、文件 IO 这种慢操作。

# 坑二:别在事务里夹慢操作,事务要尽量短

# 反面写法:事务里夹了一个慢吞吞的外部调用
def bad_place_order(conn, user_id, item_id):
    with conn.begin():                       # 事务开始
        conn.execute("UPDATE products SET stock = stock - 1 ...")
        call_payment_gateway(user_id)        # 网络请求,可能几秒
        conn.execute("INSERT INTO orders ...")
    # 这几秒里,products 那一行的锁一直被攥着,别人全在排队

# 正解:把慢操作挪到事务外,事务里只留必要的数据库操作
def good_place_order(conn, user_id, item_id):
    pay_result = call_payment_gateway(user_id)   # 先在事务外完成
    with conn.begin():                           # 再开事务,快进快出
        conn.execute("UPDATE products SET stock = stock - 1 ...")
        conn.execute("INSERT INTO orders ...", pay_result)

还有几个坑值得点一下。其一,死锁是并发系统的常态,不是 bug——两个事务以不同顺序锁多行就可能死锁,数据库会检测到并回滚其中一个;你的代码要能捕获死锁错误并重试,同时尽量让所有事务"以相同的顺序"访问多行来减少死锁。其二,SELECT ... FOR UPDATE 要加在事务里才有意义,不在事务里(自动提交模式下)锁会立刻释放,等于没加。其三,隔离级别只解决"数据库内部"的并发,如果你的判断逻辑跨了多个请求、多个服务,数据库事务管不到,那要用分布式锁或乐观锁。下面把四个隔离级别集中对照一下:

四个隔离级别对照

  级别            脏读   不可重复读   幻读     并发性能
  --------------------------------------------------------------
  读未提交        会     会           会       最高 几乎不用
  读已提交        挡住   会           会       高 多数库默认
  可重复读        挡住   挡住         看实现   中 MySQL默认
  串行化          挡住   挡住         挡住     最低 关键场景才用

  口诀:级别从上到下越来越严,挡住的异常越来越多,
        并发性能越来越低;按业务选,不是越高越好。
  补充:范围内的强一致,常用 FOR UPDATE 显式加锁来补,
        而不是把整个事务的隔离级别拔到串行化。

这一节这几个坑,串起来是同一个意思:隔离级别不是一个"设一次就万事大吉"的全局参数,事务并发是一个需要持续关注的工程面。默认级别要亲自确认,因为不同库不一样;事务要写得短,因为长事务会把锁和快照拖垮整个系统;死锁要主动处理,因为它是并发下的必然现象;FOR UPDATE 要用对地方,因为它依赖事务上下文;跨服务的并发,数据库事务根本管不着,要另想办法。把事务并发当成一个要设计、要测试、要监控的对象,而不是"包进 BEGIN/COMMIT 就放心了"——这样你的数据,才能在真实的高并发下依然正确。

关键概念速查

概念 说明
ACID 事务的四个特性:原子性、一致性、隔离性、持久性
隔离级别 控制并发事务互相可见程度的分级档位,共四级
脏读 读到了另一个事务尚未提交、可能被回滚的数据
不可重复读 同一事务内两次读同一行,因别人已提交的修改而结果不同
幻读 同一事务内范围查询,因别人插入新行而行数变化
读已提交 只读已提交数据,挡住脏读,多数数据库的默认级别
可重复读 整个事务共用一份快照,再挡住不可重复读,MySQL 默认
串行化 最严级别,事务等价于串行执行,挡住全部三类异常
FOR UPDATE 对查询到的行加排他锁,在低隔离级别下保护关键数据
死锁 两事务互持对方所需的锁而僵持,数据库回滚其一来打破

避坑清单

  1. 不要以为"开了事务"就并发安全:事务保证原子性,不天然保证事务之间不互相干扰。
  2. 不要凭印象认定默认隔离级别:不同数据库默认值不同,上线前在目标库实测确认。
  3. 不要忽视脏读的严重性:它让你基于一个可能被回滚、从未真实存在的值做决定。
  4. 不要在有前后依赖的事务里用过低级别:需要多次读结果一致,至少用可重复读。
  5. 不要把不可重复读和幻读混为一谈:前者是行的值变了,后者是范围内的行数变了。
  6. 不要无脑全用串行化:它用并发性能换安全,只在关键强一致场景才值得。
  7. 不要在事务里夹网络请求、文件 IO:长事务长时间持锁,会拖垮整个系统的并发。
  8. 不要把死锁当成 bug:它是并发常态,代码要能捕获死锁错误并重试。
  9. 不要在自动提交模式下用 FOR UPDATE:不在事务里,锁瞬间释放,等于没加。
  10. 不要指望数据库事务管跨服务的并发:跨请求、跨服务要用分布式锁或乐观锁。

总结

回头看第一版那个"包进事务就放心"的库存扣减,它的错误很典型。它不在某一行代码,而在一个对事务的根本误解:以为事务是一个并发安全的总开关,包进 BEGIN 和 COMMIT,数据库就替你把所有并发问题兜住了。真相是,事务的原子性只保证"一个事务内部的操作不可分割",而事务与事务之间能互相看见多少、干扰多少,是由隔离级别这组从松到紧的档位决定的。你不指定,它就跑在默认档位上,而默认档位之下,依然有并发异常会漏过来。

而把事务并发做对,工程量并不小。它不是写一对 BEGIN/COMMIT 那么简单,而是要弄清脏读、不可重复读、幻读这三类异常各是什么,要知道四个隔离级别各挡住其中哪几类、又放过哪几类,要按业务在"安全"和"并发性能"之间选对档位,要会用 FOR UPDATE 这把手术刀对关键数据精确加锁,还要把事务写短、要处理死锁、要确认默认级别。一套真正可靠的并发方案,是这些环节一个不少地拼起来的。

这件事其实很像一间共用的会议室。事务,就是你"预定了这间会议室"——预定保证了你这场会要么开成、要么不开,不会开到一半被踢出去,这是原子性。但预定本身,并不保证别人不会也以为这间屋子空着、推门就进来。隔离级别,就是这间会议室的门锁规格:最松的级别,屋子根本没锁,谁都能随时推门进来看一眼你写在白板上、还没定稿的草稿(脏读);严一点的级别,你进去前别人的草稿会先被擦掉,但你开会期间别人还能进来改动白板(不可重复读);更严的级别,你一进屋,白板内容就被拍照定格,你这场会看到的永远是那张照片(可重复读);最严的级别,屋子彻底独占,别人只能在门外排队(串行化)。锁得越严,你的会越不受打扰,但排队的人也越多。而 FOR UPDATE,就是你在最松的屋子里,自己给白板上某一块区域单独上了一把锁——不必把整间屋子都锁死,只锁住你真正在写的那部分。

这类问题还有一个共同的麻烦:它在开发和测试时几乎暴露不出来。你一个人在本地点点点,所有事务都是一个接一个串行执行的,中间根本没有别的事务来插队,脏读、不可重复读、幻读、死锁,一个都不会出现——你会觉得"包进事务"这套天衣无缝。真正会把隔离级别的缺口撑开的,是上线后的真实并发:成百上千个事务在同一瞬间交错执行,那个"查库存和扣库存之间的时间窗口"会被无数次精准地踩中,超卖、对账错乱、死锁就全冒出来了。所以如果你正在写一个会被并发访问的数据操作,别等大促时库存扣成负数、用户投诉上门,才回头怀疑事务。在写下 BEGIN 的那一刻就想清楚:我用的是哪个隔离级别、它挡得住我这个场景的并发吗、关键的那几行要不要 FOR UPDATE——把"开了事务"和"并发安全"当成两件必须分别确认的事,这是这篇文章最想留给你的一句话。

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

LLM 思维链完全指南:从一次"让模型直接报答案它算错了多步应用题"看懂 Chain of Thought 为什么有效

2026-5-22 20:34:17

技术教程

LLM 流式输出完全指南:从一次"用户点了发送对着空白屏幕等十几秒"看懂为什么 AI 对话必须用流式

2026-5-22 20:44:15

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