目录
一、两个事务都「检查通过」,约束还是被破坏了
线上有一个值班表,表里两行记录: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)。它说明了一件常被忽略的事:隔离级别不是「越高越安全」的四档开关,而是一组关于「允许哪些异常发生」的承诺。 两个事务各自的读是稳定的,各自的写也不冲突,问题出在「读到的集合」和「写入的行」之间那条没有被任何锁或版本检查覆盖的因果链上。
这篇文章想讲清三件事:
- SQL 标准里的四个隔离级别,到底各自允许什么异常;
- MVCC 是如何用「版本链 + 读视图」实现快照的,以及为什么同一份快照在「快照读」和「当前读」下表现不同;
- 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_id | m_ids 中的最小值 |
max_trx_id | 下一个将要分配的事务 ID |
creator_trx_id | 创建这个视图的事务自己的 ID |
判断某个版本是否可见,大致按这个顺序:
DB_TRX_ID == creator_trx_id:自己的修改,可见;DB_TRX_ID < min_trx_id:在视图创建前就已经提交,可见;DB_TRX_ID >= max_trx_id:在视图创建后才开始的事务,不可见;- 落在
[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 配合。
两者的对比可以压缩成一张表:
| 维度 | InnoDB | PostgreSQL |
|---|---|---|
| 旧版本存放 | undo log(回滚段) | 堆表中的旧元组 |
| 清理方式 | purge 线程 | VACUUM / autovacuum |
REPEATABLE READ 快照粒度 | 事务级 | 事务级 |
READ COMMITTED 快照粒度 | 语句级 | 语句级 |
| 同一行写写冲突 | 后到者阻塞,拿到锁后基于当前版本更新 | REPEATABLE READ 下后到者报 40001,READ COMMITTED 下等待并重新检查 |
| 防幻读手段 | 锁定读的 next-key lock | SERIALIZABLE 的 SSI |
SERIALIZABLE 实现 | 普通 SELECT 隐式加共享锁 | 快照隔离 + 序列化快照隔离(SSI) |
这张表里最重要的一行是「同一行写写冲突」。很多「隔离级别」的困惑,其实来自把读的可见性和写的冲突处理混成了一件事。
四、快照读与当前读:同一事务里有两套可见性
在 InnoDB 里,语句分成两类:
快照读(consistent read):普通的 SELECT。它不锁行,走 read view,看到的是事务开始时的快照。读和写互不阻塞,这是 MVCC 的主要收益。
当前读(current read):SELECT ... FOR UPDATE、SELECT ... FOR SHARE、UPDATE、DELETE。它不走快照,而是读最新已提交的版本,并加锁。当前读看到的东西可能比同一事务里的快照读更新,这不是 bug,而是两套机制的分工:快照读负责「读稳定」,当前读负责「基于最新状态做修改」。
-- 事务 T1,REPEATABLE READSELECT COUNT(*) FROM emp WHERE dept = 'RD'; -- 快照读,假设返回 0-- 事务 T2 插入一行 dept='RD' 并提交SELECT COUNT(*) FROM emp WHERE dept = 'RD'; -- 快照读,仍然返回 0UPDATE 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(SQLSTATE40001),要求应用重试;SELECT ... FOR UPDATE在REPEATABLE READ下同样可能因为并发更新而失败,而不是静默读到旧值。
五、用一段模拟器把三种现象跑出来
下面这段 Python 只模拟「版本链 + 读视图」,不模拟锁、阻塞和崩溃恢复。它的目的不是替代数据库,而是把可见性规则从 SQL 语法里剥出来,单独观察。为节省篇幅,这里只列引擎核心;三组场景都是按顺序调用 begin / snapshot / read / write / commit。
from dataclasses import dataclass
@dataclassclass 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 第二次=200REPEATABLE READ 第一次=100 第二次=100第二组:快照读看不到新插入的行,但当前读能看到;当前读之后,这行又以「自己的写」的身份出现在快照读里。
== B. 快照读 vs 当前读 ==T1 快照读 COUNT(RD) = 0T2 插入 emp:rd:3 并提交T1 再次快照读 COUNT(RD) = 0T1 执行 UPDATE ... WHERE dept='RD'(当前读),影响 1 行T1 之后快照读 COUNT(RD) = 1第三组:写偏斜。两个事务各自看到 2 人值班,各自把自己改成不值班,写的是不同的行,所以没有任何写写冲突;两个都提交,约束被破坏。
== C. 写偏斜(write skew) ==T1 看到值班人数 = 2T2 看到值班人数 = 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 - 100WHERE id = 1 AND balance >= 100;
-- 或者显式锁住要读的行SELECT balance FROM account WHERE id = 1 FOR UPDATE;写偏斜是最容易被隔离级别名字骗过去的。两个事务写的是不同的行,没有写写冲突,next-key lock 也锁不到对方要写的行;快照隔离允许它们同时提交。要阻止它,必须让「读到的集合」和「写入的行」之间产生某种冲突:
- 用
SERIALIZABLE,让数据库检测危险结构并 abort; - 用一把所有相关事务都要拿的锁(比如锁住「值班表」这个逻辑对象对应的一行);
- 用数据库约束把不变式写进去(唯一索引、
CHECK、排他约束); - 把「读-判断-写」合并成一条带条件的原子语句。
七、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 |
| 大批量导入/离线任务 | 不与在线事务互相阻塞 | 分批提交、控制事务大小,避免长事务 |
几条实践建议:
- 默认级别不是「安全级别」。 MySQL 默认 RR、PostgreSQL 默认 RC,都是各有取舍的工程选择,不是「谁更正确」。
- 不要用「把隔离级别调得更严」代替冲突处理。
SERIALIZABLE会把并发冲突变成 abort,应用必须能重试;没有重试的SERIALIZABLE只是把死锁换成了报错。 - 把不变式尽量下沉到数据库。 唯一索引、外键、
CHECK、排他约束能挡住的问题,不要留给「事务里先查再写」。 - 测试要跑并发。 顺序执行的单元测试永远不会暴露写偏斜。至少写一个两个连接交错执行的小测试,断言最终状态满足不变式。
- 监控要看冲突,不只看慢查询。 锁等待、死锁次数、
40001比例、长事务数量、purge/VACUUM 滞后,这些都是隔离级别选择的真实成本。
十、小结
- 隔离级别是一组异常承诺,不是四档开关。 标准只列了脏读、不可重复读、幻读;丢失更新、读偏斜、写偏斜都在表外。
- MVCC 用版本链 + 读视图实现快照。 InnoDB 把旧版本放在 undo log,PostgreSQL 把旧元组留在堆表里;读视图决定「哪些版本对我可见」。
- 快照读和当前读是两套可见性。 普通
SELECT走快照,UPDATE/DELETE/FOR UPDATE走最新已提交版本;混用时会出现「快照读后来看到了新行」的现象。 - 快照隔离能阻止不可重复读和快照读幻读,但不能阻止写偏斜。 写偏斜的两个事务写的是不同的行,没有写写冲突。
- InnoDB 的 RR 不是可串行化。 它靠事务级快照和 next-key lock 覆盖了大部分读异常,但快照读不加锁,跨行不变式仍然可能被破坏。
- PostgreSQL 的
SERIALIZABLE是 SSI。 它不靠间隙锁阻塞,而是检测危险结构后 abort;应用必须把40001当成正常路径来重试。 - 修丢失更新靠原子语句或显式锁,修写偏斜靠可串行化、公共锁或数据库约束。 调高隔离级别只是其中一种手段,而且通常是最贵的那种。
参考来源
- MySQL 8.4 Reference Manual: Consistent Nonlocking Reads(read view、快照读与事务级可见性)
- MySQL 8.4 Reference Manual: Locking Reads(
FOR UPDATE、FOR SHARE与当前读) - MySQL 8.4 Reference Manual: Transaction Isolation Levels(四个级别与 InnoDB 的实现差异)
- MySQL 8.4 Reference Manual: InnoDB Locking(record lock、gap lock、next-key lock 与插入意向锁)
- PostgreSQL Documentation: Transaction Isolation(RC、RR、Serializable 的可见性与冲突行为)
- Hal Berenson et al.: A Critique of ANSI SQL Isolation Levels(标准措辞的歧义、快照隔离与写偏斜)
- Jepsen: Consistency Models(线性一致、快照隔离、可串行化等模型的形式化定义与层级)
- Martin Kleppmann: Designing Data-Intensive Applications, Chapter 7(事务、弱隔离级别与写偏斜的工程视角)