1. 矛盾场景的复现
假设系统处于 可重复读 (RR) 隔离级别。
- T1 时刻 :表
t中有一行记录,age = 18。 - T2 时刻 :事务 A 开启,执行
SELECT age FROM t。根据我们刚刚总结的 ReadView 快照规则,它读到了age = 18。 - T3 时刻 :事务 B 开启,执行
UPDATE t SET age = 20并立刻提交 。此时物理磁盘和最新的 Undo Log 中,这行数据的最新真实值变成了20。 - T4 时刻 :事务 A 准备基于它看到的数据,给
age加 1。于是执行:UPDATE t SET age = age + 1。
此时,数据库内核面临着一个生死攸关的二选一困境(矛盾点爆发):
2. 矛盾的两极(无论怎么选,好像都错)
假设选项一:坚持快照逻辑(基于 18 去改)
如果数据库强制执行你总结的"快照读"逻辑,认为事务 A 只能看到 18。
- 执行计算 :18+1=1918 + 1 = 1918+1=19。
- 物理动作 :事务 A 将
age = 19写入磁盘,并覆盖掉当前记录。 - 灾难性后果 :丢失更新(Lost Update) 。事务 B 辛辛苦苦改的 20 凭空消失了!如果这是银行存款,用户的钱就彻底算错了。数据库的一致性底线彻底崩塌。
假设选项二:背叛快照逻辑(基于 20 去改)
如果数据库为了保护事务 B 的成果,让事务 A 去读取物理磁盘上最新的真实值。
- 执行计算 :20+1=2120 + 1 = 2120+1=21。
- 物理动作 :事务 A 将
age = 21写入磁盘。 - 灾难性后果 :逻辑精神分裂 。对于事务 A 来说,上一秒
SELECT查出来的明明是 18,执行了一句+ 1的操作,再次SELECT查看结果,竟然变成了 21?!说好的"可重复读(事务隔离)"呢?这直接打破了开发者的编程常识。
3. InnoDB 的终极解法:一套引擎,两套截然不同的读取规则
面对"坚持快照会导致更新丢失"与"放弃快照会导致隔离性破裂"这个死局,InnoDB 给出的破局之道是:在底层彻底切分"读"与"写"的物理执行路径。
在可重复读(RR)隔离级别下,只要你向数据库发起请求,InnoDB 的执行器就会自动根据你的 SQL 语句类型,将请求分流到以下两种完全不同的"读取模式"中:
模式 A:快照读(Snapshot Read)------ 追求极致并发的无锁查询
- 触发条件 :
所有普通的查询语句,例如SELECT * FROM table WHERE ...(不带任何加锁后缀)。 - 底层物理动作 :
- 依赖内存快照:执行器严格依赖当前事务在第一次查询时生成的 ReadView。
- 全程无锁:执行器在聚簇索引树上遍历数据时,不需要对任何数据页或记录加锁。
- 版本回溯 :当读取到 B+ 树叶子节点上的最新物理记录时,如果根据
trx_id判定该最新记录对当前 ReadView 不可见,它就会顺着该记录的roll_pointer指针进入 Undo Log。在历史版本链中逐个套用可见性算法,直到提取出那个符合当前快照规则的旧版本数据。
- 核心目的 :
实现读写互不阻塞。即使其他事务正在对该行数据进行高频修改,快照读也丝毫不受影响,因为它读取的是通过 Undo Log 拼装出来的历史安全版本。
模式 B:当前读(Current Read)------ 捍卫数据底线的加锁读取
- 触发条件 :
- 所有的写操作 :
UPDATE、DELETE、INSERT。 - 所有显式加锁的读操作 :
SELECT ... FOR UPDATE(加排他锁/X锁)、SELECT ... LOCK IN SHARE MODE(加共享锁/S锁)
- 所有的写操作 :
- 底层物理动作 :
- 强制撕破快照 :一旦触发当前读,执行器会彻底无视当前事务的 ReadView 状态。无论快照规则规定你应该看哪个历史版本,当前读一律跳过判定逻辑。
- 直击最新物理行 :执行器直接定位到聚簇索引 B+ 树的叶子节点,强制抓取该行数据在磁盘/内存中最新且已提交的真实物理版本。
- 瞬间加锁封锁 :在读取到最新版本的同时,执行器会立刻在聚簇索引的这行记录上加锁(如果条件允许,还会加上锁定前后范围的 Next-Key Lock)。如果是
UPDATE/DELETE或FOR UPDATE,加的是排他锁,其物理意义是:"在我基于这个最新值计算并提交之前,任何并发事务都必须在锁外排队,禁止触碰这行数据!"
- 核心目的 :
绝对禁止丢失更新(Lost Update)。因为数据的修改必须基于当前数据库最真实的物理状态,绝不能基于虚假的"历史快照"去盲目覆盖他人的提交成果。
4. 矛盾是如何被物理化解的?
回到我们最初的 T4 时刻,当事务 A 执行 UPDATE t SET age = age + 1 时,真实的底层动作是这样的:
- 引擎判断 :这是一条
UPDATE语句,必须走当前读通道。 - 撕破快照:事务 A 放弃使用 T2 时刻生成的 ReadView,直接去聚簇索引叶子节点抓取最新状态。
- 加锁获取真实值 :事务 A 发现最新的物理记录是事务 B 提交的
age = 20。于是 A 给这行记录上一把排他锁。 - 执行计算与更新 :引擎基于真实值进行计算:20+1=2120 + 1 = 2120+1=21。生成最新的版本,记录
trx_id = A,并将roll_pointer指向包含 20 的 Undo Log。
那么,这违反了"可重复读"吗?
- 从数据安全的角度:它拯救了事务 B 的更新,避免了丢失更新。
- 从开发者视角 :确实会产生疑惑(查出来是 18,加 1 变 21)。因此,在正规的业务开发中,如果你的业务逻辑是"先查出数据,再基于查出的数据做修改",强烈禁止使用普通 SELECT ,必须使用
SELECT ... FOR UPDATE强行把查询升级为"当前读",让事务 B 根本无法在中间插队修改。