目录
5751 字
29 分钟
事务隔离级别不是四档开关:MVCC 的读视图、当前读与幻读的边界

一、两个事务都「检查通过」,约束还是被破坏了#

线上有一个值班表,表里两行记录:Alice 值班,Bob 值班。业务规则只有一条:任何时刻至少有一人值班。

Alice 想请假,事务 T1 执行:

SELECT COUNT(*) FROM duty WHERE on_call = true; -- 返回 2,可以走
UPDATE duty SET on_call = false WHERE name = 'Alice';

同一时刻,Bob 也想请假,事务 T2 执行了同样的逻辑,只是更新的是自己那一行:

SELECT COUNT(*) FROM duty WHERE on_call = true; -- 也返回 2,也可以走
UPDATE duty SET on_call = false WHERE name = 'Bob';

两个事务操作的是不同的行,谁也没有覆盖谁。在 MySQL 的 REPEATABLE READ 下,两条 UPDATE 都能成功,两个事务都能提交。提交之后再查:

SELECT COUNT(*) FROM duty WHERE on_call = true;
-- 0

没有脏读,没有不可重复读,没有同一行上的丢失更新,隔离级别还是「比较高的那个」。但业务约束被破坏了。

这类异常叫写偏斜(write skew)。它说明了一件常被忽略的事:隔离级别不是「越高越安全」的四档开关,而是一组关于「允许哪些异常发生」的承诺。 两个事务各自的读是稳定的,各自的写也不冲突,问题出在「读到的集合」和「写入的行」之间那条没有被任何锁或版本检查覆盖的因果链上。

这篇文章想讲清三件事:

  1. SQL 标准里的四个隔离级别,到底各自允许什么异常;
  2. MVCC 是如何用「版本链 + 读视图」实现快照的,以及为什么同一份快照在「快照读」和「当前读」下表现不同;
  3. MySQL 的 REPEATABLE READ 和 PostgreSQL 的 SERIALIZABLE 分别补上了哪一块,代价是什么。

二、隔离级别定义的是「异常集合」,不是「档位」#

SQL 标准(SQL:1999)用三种现象来定义隔离级别:

异常现象
脏读(P1)读到了另一个事务尚未提交的写
不可重复读(P2)同一事务内两次读同一行,结果不同
幻读(P3)同一事务内两次执行同一范围查询,返回的行集合不同

对应的级别是:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED不可能可能可能
REPEATABLE READ不可能不可能可能
SERIALIZABLE不可能不可能不可能

这张表只列了三种现象。工程上真正让人加班的,往往是表外的几种:

异常一句话描述标准表里管不管
丢失更新(lost update)两个事务读同一个值,各自算完写回,后写的覆盖先写的不管
读偏斜(read skew)一个事务内先读 A 再读 B,中间另一事务提交了 A、B 的一组相关更新,读到半新半旧不管
写偏斜(write skew)两个事务读同一组行,各自更新不同的行,局部检查都通过,全局约束被破坏不管
序列化异常并发执行的结果无法对应到任何串行执行顺序只有 SERIALIZABLE 直接定义它

1995 年 Berenson 等人的《A Critique of ANSI SQL Isolation Levels》指出,标准对 P1/P2/P3 的措辞本身就有歧义:它描述的是「现象」,不是「事务之间的依赖关系」。例如「不可重复读」既可以理解为「读到了被改过的行」,也可以理解为「读到了新插入的行」。同一个级别名,在不同数据库里的实现可能相差很远。

所以更准确的理解是:

  • READ COMMITTED、REPEATABLE READ 是按禁止现象列举出来的级别;
  • SERIALIZABLE 是按语义定义的级别:并发执行的结果必须等价于某个串行执行顺序;
  • 中间那些没有名字的异常(丢失更新、写偏斜、读偏斜),标准没有替你排除,需要具体实现或业务自己处理。

三、MVCC 怎么让「读不阻塞写」:版本链与读视图#

MVCC(多版本并发控制)的核心思路很朴素:更新不覆盖旧值,而是生成一个新版本;每个事务带着一个「读视图」,只挑对自己可见的版本。

InnoDB:undo log 里的旧版本#

InnoDB 的聚簇索引行上有两个隐藏列:

  • DB_TRX_ID:最后修改这一行的事务 ID;
  • DB_ROLL_PTR:指向 undo log 中上一个版本的指针。

一次更新不会原地抹掉旧值,而是把旧值写进 undo log,再在行上写入新值。多个版本通过 DB_ROLL_PTR 串成一条链。

事务第一次执行普通 SELECT 时,InnoDB 创建一个 read view,里面至少有四个关键字段:

字段含义
m_ids创建视图时,所有活跃(未提交)的读写事务 ID 集合
min_trx_idm_ids 中的最小值
max_trx_id下一个将要分配的事务 ID
creator_trx_id创建这个视图的事务自己的 ID

判断某个版本是否可见,大致按这个顺序:

  1. DB_TRX_ID == creator_trx_id:自己的修改,可见;
  2. DB_TRX_ID < min_trx_id:在视图创建前就已经提交,可见;
  3. DB_TRX_ID >= max_trx_id:在视图创建后才开始的事务,不可见;
  4. 落在 [min_trx_id, max_trx_id) 之间:如果 DB_TRX_ID 在 m_ids 里,说明视图创建时它还没提交,不可见;否则可见。

如果当前版本不可见,就沿着 DB_ROLL_PTR 往 undo log 里找更老的版本,直到找到一个可见的版本,或者链到头(说明这行在视图看来不存在)。

关键点是:read view 在 REPEATABLE READ 下是事务级的一次性快照,第一次读时建立,之后一直复用。这就是「同一事务两次读结果相同」的来源。到了 READ COMMITTED,每条语句都会重新建一个 read view,所以能看到别人刚提交的修改。

PostgreSQL:堆表里的元组版本#

PostgreSQL 走的是另一条路。它不把旧版本放进单独的 undo 区域,而是直接在堆表里保留多个元组版本。每个元组头上有:

  • xmin:创建这个版本的事务 ID;
  • xmax:删除(或锁定)这个版本的事务 ID;
  • cmin / cmax:命令 ID,用来区分同一事务内不同语句的可见性。

快照由 xmin、xmax 和 xip(活跃事务列表)组成。可见性判断和 InnoDB 的思路相似,但实现细节不同。旧版本不会立刻消失,而是等 VACUUM 确认没有事务还需要它们之后才回收;索引项指向堆中的元组,可见性在访问堆时检查,所以 index-only scan 还需要 visibility map 配合。

两者的对比可以压缩成一张表:

维度InnoDBPostgreSQL
旧版本存放undo log(回滚段)堆表中的旧元组
清理方式purge 线程VACUUM / autovacuum
REPEATABLE READ 快照粒度事务级事务级
READ COMMITTED 快照粒度语句级语句级
同一行写写冲突后到者阻塞,拿到锁后基于当前版本更新REPEATABLE READ 下后到者报 40001,READ COMMITTED 下等待并重新检查
防幻读手段锁定读的 next-key lockSERIALIZABLE 的 SSI
SERIALIZABLE 实现普通 SELECT 隐式加共享锁快照隔离 + 序列化快照隔离(SSI)

这张表里最重要的一行是「同一行写写冲突」。很多「隔离级别」的困惑,其实来自把读的可见性和写的冲突处理混成了一件事。

四、快照读与当前读:同一事务里有两套可见性#

在 InnoDB 里,语句分成两类:

快照读(consistent read):普通的 SELECT。它不锁行,走 read view,看到的是事务开始时的快照。读和写互不阻塞,这是 MVCC 的主要收益。

当前读(current read):SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE。它不走快照,而是读最新已提交的版本,并加锁。当前读看到的东西可能比同一事务里的快照读更新,这不是 bug,而是两套机制的分工:快照读负责「读稳定」,当前读负责「基于最新状态做修改」。

-- 事务 T1,REPEATABLE READ
SELECT COUNT(*) FROM emp WHERE dept = 'RD'; -- 快照读,假设返回 0
-- 事务 T2 插入一行 dept='RD' 并提交
SELECT COUNT(*) FROM emp WHERE dept = 'RD'; -- 快照读,仍然返回 0
UPDATE emp SET status = 'checked' WHERE dept = 'RD'; -- 当前读,能看到 T2 插入的那行
SELECT COUNT(*) FROM emp WHERE dept = 'RD'; -- 快照读,这次返回 1

最后一条为什么变成 1?因为 UPDATE 把 T2 插入的那行改成了 T1 自己的版本,而「自己的修改永远可见」。从快照读的角度看,T1 没有读到别人的新行;从结果看,同一事务里的 COUNT(*) 确实变了。这就是很多人说的「InnoDB 在 REPEATABLE READ 下仍然会幻读」——更准确的说法是:快照读本身不会产生幻读;但当前读会把快照外的行拉进事务,之后这些行就以「自己的写」的身份出现在快照读里。

PostgreSQL 没有「当前读」这个叫法,但语义上也有类似的分叉:

  • READ COMMITTED 下,每条语句取新快照;UPDATE/DELETE 找到目标行后,如果该行已被并发事务修改,会等待对方提交,然后基于新版本重新检查 WHERE(EvalPlanQual),必要时再更新;
  • REPEATABLE READ 下,事务使用同一个快照;如果目标行在快照之后被改过,直接报 could not serialize access due to concurrent update(SQLSTATE 40001),要求应用重试;
  • SELECT ... FOR UPDATE 在 REPEATABLE READ 下同样可能因为并发更新而失败,而不是静默读到旧值。

五、用一段模拟器把三种现象跑出来#

下面这段 Python 只模拟「版本链 + 读视图」,不模拟锁、阻塞和崩溃恢复。它的目的不是替代数据库,而是把可见性规则从 SQL 语法里剥出来,单独观察。为节省篇幅,这里只列引擎核心;三组场景都是按顺序调用 begin / snapshot / read / write / commit。

from dataclasses import dataclass
@dataclass
class Version:
value: str
created_by: int
deleted_by: int | None = None
class DB:
def __init__(self):
self.versions: dict[str, list[Version]] = {}
self.committed: set[int] = set()
self.active: set[int] = set()
self.next_txid = 1
def begin(self) -> int:
txid = self.next_txid
self.next_txid += 1
self.active.add(txid)
return txid
def commit(self, txid: int) -> None:
self.active.discard(txid)
self.committed.add(txid)
def snapshot(self) -> frozenset[int]:
return frozenset(self.committed)
def _visible(self, v: Version, txid: int, snap: frozenset[int]) -> bool:
if v.deleted_by == txid:
return False
if v.created_by == txid:
return True
if v.created_by not in snap:
return False
if v.deleted_by is not None and v.deleted_by in snap:
return False
return True
def read(self, key: str, txid: int, snap: frozenset[int]) -> str | None:
for v in reversed(self.versions.get(key, [])):
if self._visible(v, txid, snap):
return v.value
return None
def write(self, key: str, value: str, txid: int) -> None:
chain = self.versions.setdefault(key, [])
for v in reversed(chain):
if v.deleted_by is None:
v.deleted_by = txid
break
chain.append(Version(value, txid))
def current_rows(self, txid: int) -> dict[str, str]:
out = {}
for key, chain in self.versions.items():
for v in reversed(chain):
if v.deleted_by is None and (v.created_by in self.committed or v.created_by == txid):
out[key] = v.value
break
return out
def update_where(self, prefix: str, old: str, new: str, txid: int) -> int:
changed = 0
for key, value in self.current_rows(txid).items():
if key.startswith(prefix) and value == old:
self.write(key, new, txid)
changed += 1
return changed

第一组实验:同一个事务读两次,中间另一个事务提交了修改。READ COMMITTED 每次都取新快照,REPEATABLE READ 复用第一次的快照。

== A. 同一事务读两次,别人中间改了 ==
READ COMMITTED 第一次=100 第二次=200
REPEATABLE READ 第一次=100 第二次=100

第二组:快照读看不到新插入的行,但当前读能看到;当前读之后,这行又以「自己的写」的身份出现在快照读里。

== B. 快照读 vs 当前读 ==
T1 快照读 COUNT(RD) = 0
T2 插入 emp:rd:3 并提交
T1 再次快照读 COUNT(RD) = 0
T1 执行 UPDATE ... WHERE dept='RD'(当前读),影响 1 行
T1 之后快照读 COUNT(RD) = 1

第三组:写偏斜。两个事务各自看到 2 人值班,各自把自己改成不值班,写的是不同的行,所以没有任何写写冲突;两个都提交,约束被破坏。

== C. 写偏斜(write skew) ==
T1 看到值班人数 = 2
T2 看到值班人数 = 2
两个事务都提交后,值班人数 = 0

这三组输出对应三种不同的失败方式:

  • A 是读稳定性的失败:READ COMMITTED 不承诺同一事务内两次读一致;
  • B 是机制切换的副作用:快照读和当前读各有各的可见性,混用时会出现「看起来像幻读」的现象;
  • C 是写偏斜:快照隔离能保证「读到的集合是稳定的」,但不能保证「基于这个集合做的写入集合仍然满足全局约束」。

六、四种异常的分界线:不可重复读、幻读、丢失更新、写偏斜#

这四个词经常被混着用,但它们的成因和修法完全不同。

异常读的对象写是否冲突快照隔离能否阻止典型修法
不可重复读同一行不涉及能事务级快照(RR)、行锁
幻读(快照读)同一范围不涉及能事务级快照(RR)
幻读(锁定读)同一范围插入与范围锁冲突取决于实现next-key lock、范围锁、SSI
丢失更新同一行是通常能,但实现差异大SELECT ... FOR UPDATE、原子更新、版本号
写偏斜同一组行否(写不同行)不能SERIALIZABLE、物化冲突、业务约束

「快照隔离能否阻止」这一列值得展开。

不可重复读和快照读下的幻读,本质都是「读到了不同时间点的状态」。事务级快照直接解决了:所有普通读都看同一个 read view,别人提交什么都与它无关。

锁定读下的幻读是另一回事。SELECT ... FOR UPDATE 要读最新数据,还要防止别人往查询范围里插入新行。InnoDB 的做法是 next-key lock:锁住索引记录本身,再锁住记录之间的间隙(gap)。这样别的插入就落不进这个范围。PostgreSQL 不玩间隙锁,它在 SERIALIZABLE 下用 SSI 检测「读-写依赖」形成的危险结构,发现可能违反可串行化就 abort 其中一个事务。一个靠阻塞,一个靠检测后回滚。

丢失更新的关键在于「读」和「写」之间有没有隔开。如果应用先 SELECT 出余额,在内存里算好,再 UPDATE ... SET balance = 110,那么无论隔离级别多高,只要两个事务都基于旧值计算,后写的就会覆盖先写的。InnoDB 的 UPDATE 是当前读,它会阻塞到前一个事务提交,然后基于最新行执行 SET;但如果 SET 里写的是应用算好的字面量,最新行也会被覆盖。PostgreSQL 在 REPEATABLE READ 下会直接让后到者报 40001,把问题变成一次可重试的失败。修法不是「调高隔离级别」,而是:

-- 原子更新:把判断和修改放进同一条语句
UPDATE account SET balance = balance - 100
WHERE id = 1 AND balance >= 100;
-- 或者显式锁住要读的行
SELECT balance FROM account WHERE id = 1 FOR UPDATE;

写偏斜是最容易被隔离级别名字骗过去的。两个事务写的是不同的行,没有写写冲突,next-key lock 也锁不到对方要写的行;快照隔离允许它们同时提交。要阻止它,必须让「读到的集合」和「写入的行」之间产生某种冲突:

  1. 用 SERIALIZABLE,让数据库检测危险结构并 abort;
  2. 用一把所有相关事务都要拿的锁(比如锁住「值班表」这个逻辑对象对应的一行);
  3. 用数据库约束把不变式写进去(唯一索引、CHECK、排他约束);
  4. 把「读-判断-写」合并成一条带条件的原子语句。

七、MySQL 的 REPEATABLE READ 不是 SERIALIZABLE#

MySQL InnoDB 的默认隔离级别是 REPEATABLE READ。它同时用了两套机制:

  • 普通 SELECT 走事务级快照,解决不可重复读和快照读下的幻读;
  • UPDATE、DELETE、SELECT ... FOR UPDATE 走当前读,配合 next-key lock 解决锁定读下的幻读。

这两套机制合起来,让「RR 能防幻读」这句话在锁定读的语境下成立。但它不等于可串行化,原因至少有四个:

第一,快照读不加锁。 T1 读了一个范围,T2 仍然可以往这个范围里插入并提交;T1 之后再基于快照做判断,看到的还是旧集合。写偏斜就是这样发生的。

第二,next-key lock 依赖索引。 如果查询条件用不上索引,InnoDB 会扫描并锁住大量记录,甚至接近全表;如果用的是唯一索引的等值查询且行存在,通常只加记录锁,不加间隙锁。锁的范围取决于执行计划,而不是 SQL 的字面意思。同一个 WHERE 换个索引,并发行为就可能不同。

第三,间隙锁会带来死锁。 两个事务分别往同一个间隙里插入不同的值,各自持有间隙锁,再互相等待对方释放,就会死锁。间隙锁的粒度越粗,死锁概率越高。它不是免费的。

第四,SERIALIZABLE 的实现方式不同。 InnoDB 在 SERIALIZABLE 下会把普通 SELECT 隐式转换成加共享锁的读,让所有读都参与锁协议。这能减少异常,但代价是读也阻塞写、写也阻塞读,死锁和等待都会上升。它不是一个「打开就安全」的开关。

所以更准确的表述是:

InnoDB 的 REPEATABLE READ = 事务级快照 + 锁定读的 next-key lock。它阻止了不可重复读和锁定读幻读,但没有阻止写偏斜,也不是可串行化。

八、PostgreSQL 的选择:快照隔离 + SSI,失败要重试#

PostgreSQL 的级别映射更贴近「实现语义」:

级别实现
READ COMMITTED(默认)每条语句取新快照;并发更新时等待并重新检查
REPEATABLE READ事务级快照,等价于快照隔离;写写冲突时报 40001
SERIALIZABLE快照隔离 + SSI,检测序列化冲突并 abort

SSI(Serializable Snapshot Isolation)的思路不是给读加锁,而是记录读写依赖:每个事务读了哪些范围、写了哪些行,数据库据此判断是否存在「写偏斜环」或其他不可串行化的结构。发现危险结构时,abort 其中一个事务,返回 40001。

这带来两个工程后果:

第一,SERIALIZABLE 下必须写重试。 不是「可能失败」,而是「失败是正常路径的一部分」。没有重试逻辑的应用,打开 SERIALIZABLE 之后会把并发冲突直接暴露成用户可见的错误。

for attempt in range(3):
try:
with conn.transaction(isolation_level="SERIALIZABLE"):
transfer(...)
break
except SerializationFailure: # SQLSTATE 40001
if attempt == 2:
raise

第二,REPEATABLE READ 也不是可串行化。 PostgreSQL 文档明确写了:REPEATABLE READ 允许写偏斜,需要真正的不变式保护时要用 SERIALIZABLE。这一点和 MySQL 的 REPEATABLE READ 类似,但两者的失败方式不同:PostgreSQL 倾向于 abort,InnoDB 倾向于阻塞后继续。

还有一个容易被忽略的成本:长事务会拖住清理。 PostgreSQL 的快照会阻止 VACUUM 回收旧元组,长事务期间表会膨胀;InnoDB 的长事务会让 undo log 和 history list 增长,purge 跟不上时同样会拖慢系统。隔离级别选得再对,一个跑了几十分钟的只读事务也能把数据库拖进麻烦。

九、工程上怎么选:先写清楚需要哪种保证#

与其记「哪个级别更高」,不如按操作类型列一张表:

场景需要的保证推荐做法
报表、列表、详情页同一事务内读一致即可REPEATABLE READ 或 READ COMMITTED;只读事务显式声明
余额、库存的读改写同一行不丢更新UPDATE ... SET x = x - ? WHERE x >= ?,或 SELECT ... FOR UPDATE,或版本号 CAS
唯一性(用户名、订单号)不允许重复唯一索引,不要靠隔离级别
跨行不变式(值班人数、账户总额)可串行化SERIALIZABLE + 重试;或锁一个公共的「守卫行」;或落成数据库约束
任务队列取任务不重复领取SELECT ... FOR UPDATE SKIP LOCKED
大批量导入/离线任务不与在线事务互相阻塞分批提交、控制事务大小,避免长事务

几条实践建议:

  1. 默认级别不是「安全级别」。 MySQL 默认 RR、PostgreSQL 默认 RC,都是各有取舍的工程选择,不是「谁更正确」。
  2. 不要用「把隔离级别调得更严」代替冲突处理。 SERIALIZABLE 会把并发冲突变成 abort,应用必须能重试;没有重试的 SERIALIZABLE 只是把死锁换成了报错。
  3. 把不变式尽量下沉到数据库。 唯一索引、外键、CHECK、排他约束能挡住的问题,不要留给「事务里先查再写」。
  4. 测试要跑并发。 顺序执行的单元测试永远不会暴露写偏斜。至少写一个两个连接交错执行的小测试,断言最终状态满足不变式。
  5. 监控要看冲突,不只看慢查询。 锁等待、死锁次数、40001 比例、长事务数量、purge/VACUUM 滞后,这些都是隔离级别选择的真实成本。

十、小结#

  • 隔离级别是一组异常承诺,不是四档开关。 标准只列了脏读、不可重复读、幻读;丢失更新、读偏斜、写偏斜都在表外。
  • MVCC 用版本链 + 读视图实现快照。 InnoDB 把旧版本放在 undo log,PostgreSQL 把旧元组留在堆表里;读视图决定「哪些版本对我可见」。
  • 快照读和当前读是两套可见性。 普通 SELECT 走快照,UPDATE/DELETE/FOR UPDATE 走最新已提交版本;混用时会出现「快照读后来看到了新行」的现象。
  • 快照隔离能阻止不可重复读和快照读幻读,但不能阻止写偏斜。 写偏斜的两个事务写的是不同的行,没有写写冲突。
  • InnoDB 的 RR 不是可串行化。 它靠事务级快照和 next-key lock 覆盖了大部分读异常,但快照读不加锁,跨行不变式仍然可能被破坏。
  • PostgreSQL 的 SERIALIZABLE 是 SSI。 它不靠间隙锁阻塞,而是检测危险结构后 abort;应用必须把 40001 当成正常路径来重试。
  • 修丢失更新靠原子语句或显式锁,修写偏斜靠可串行化、公共锁或数据库约束。 调高隔离级别只是其中一种手段,而且通常是最贵的那种。

参考来源#

事务隔离级别不是四档开关:MVCC 的读视图、当前读与幻读的边界
https://www.hehonglei.cn/posts/transaction-isolation-mvcc-read-view/
作者
Honglei He
发布于
2026-10-28
许可协议
CC BY-NC-SA 4.0