MySQL的可重复读隔离级别实现原理分析
MySQL 的 可重复读(Repeatable Read, RR) 隔离级别主要通过 多版本并发控制(Multi-Version Concurrency Control, MVCC) 和 锁机制(特别是间隙锁) 来实现的。其核心目标是:在一个事务内,无论读取多少次相同的行,看到的数据都是一致的(即第一次读取时建立的“快照”),并且防止了“不可重复读”问题(同一个事务内两次读取同一行得到不同值)。同时,在 InnoDB 的 RR 级别下,还通过间隙锁机制(Next-Key Locks)极大地防止了“幻读”问题(同一个事务内两次范围查询得到不同的行集)。
以下是 RR 隔离级别实现的核心机制:
1. 多版本并发控制 (MVCC) - 实现快照读 (Snapshot Read)
MVCC 是 RR 级别实现“可重复读”特性的基石。
- 隐式字段: InnoDB 在每个聚簇索引记录(数据行)中隐藏了 3 个关键字段:
DB_TRX_ID
(6 Bytes):记录创建/最后修改该行的事务ID。 当一个事务开始修改某行时,会把自己的事务 ID 写入这个字段。DB_ROLL_PTR
(7 Bytes):指向该行在 undo log 中的回滚段指针。 这个指针指向该行之前版本(旧版本)在 undo log 中的位置,形成一个版本链。DB_ROW_ID
(6 Bytes):单调递增的行 ID(如果表没有定义主键,InnoDB 会自动生成这个作为聚簇索引)。
- Undo Log (回滚日志): 当事务修改数据时:
- 会先将数据行的旧版本复制到 undo log 中。
- 然后修改数据行(更新
DB_TRX_ID
为当前事务 ID,更新DB_ROLL_PTR
指向刚写入 undo log 的旧版本记录)。 - 这样,每个被修改的行都通过
DB_ROLL_PTR
链接成一个历史版本链(链表)。
- ReadView (一致性视图): 这是 MVCC 的关键数据结构,决定了事务能看到哪些版本的数据。当事务执行第一个
SELECT
语句(或显式开启只读事务)时,InnoDB 会为该事务生成一个 ReadView。一个 ReadView 主要包含:m_ids
: 生成 ReadView 时,系统中活跃(未提交)的事务 ID 列表。min_trx_id
:m_ids
中的最小值。max_trx_id
: 生成 ReadView 时,系统应该分配给下一个新事务的 ID(即当前最大事务 ID + 1)。creator_trx_id
: 创建该 ReadView 的当前事务自己的 ID(只读事务为 0)。
- 可见性规则: 当事务需要读取一行时,它沿着该行的版本链(通过
DB_ROLL_PTR
回溯)查找第一个满足以下条件的版本:- 版本对应的
DB_TRX_ID
<min_trx_id
:该版本是在 ReadView 创建之前就已提交的事务修改的。可见。 - 版本对应的
DB_TRX_ID
在m_ids
中:该版本是由 ReadView 创建时还活跃(未提交) 的事务修改的。不可见。继续查找更旧的版本。 - 版本对应的
DB_TRX_ID
>=max_trx_id
:该版本是由 ReadView 创建之后才开启的事务修改的。不可见。继续查找更旧的版本。 - 版本对应的
DB_TRX_ID
=creator_trx_id
:该版本是由当前事务自身修改的。可见。
- 如果找到链头(最初的版本)仍不可见,则认为该行对该事务不可见(如同不存在)。
- 版本对应的
- RR 级别的关键点:
- 在 RR 级别下,一个事务只在第一次执行
SELECT
时生成一个 ReadView。 - 后续在该事务内的所有 普通
SELECT
语句(快照读) 都复用这个最初的 ReadView。 - 因此,无论之后其他事务如何修改、提交或回滚,该事务看到的数据始终是第一次
SELECT
时那个“快照”版本的数据。这就保证了“可重复读”。
- 在 RR 级别下,一个事务只在第一次执行
2. 锁机制 (Locking) - 处理当前读 (Current Read) 和防止幻读
MVCC 主要解决了快照读(普通 SELECT
) 的可重复读问题。但对于当前读(如 SELECT ... FOR UPDATE
, SELECT ... LOCK IN SHARE MODE
, UPDATE
, DELETE
, INSERT
),以及需要防止幻读,就需要用到锁机制,特别是 Next-Key Locks。
- 行锁 (Record Locks): 锁定索引记录本身。
- 间隙锁 (Gap Locks): 锁定索引记录之间的间隙(一个开区间),防止其他事务在这个间隙中插入新记录。
- 临键锁 (Next-Key Locks): 行锁 + 间隙锁的组合。锁定索引记录本身以及该记录之前的间隙(一个左开右闭区间)。这是 InnoDB 在 RR 级别下默认使用的锁类型。
- RR 级别下如何防止幻读:
- 当一个事务执行当前读(例如
SELECT * FROM t WHERE id > 100 FOR UPDATE
)时:- InnoDB 不仅会锁住所有满足条件
id > 100
的现有行(行锁)。 - 还会在这些现有行的索引记录范围之后(以及可能的间隙之间)加上间隙锁 (Gap Locks) 或 临键锁 (Next-Key Locks)。
- InnoDB 不仅会锁住所有满足条件
- 例如,如果现有 id 是 101, 105, 110。那么
id > 100
的查询可能会锁定:- 现有行:101, 105, 110(行锁)
- 间隙:(100, 101), (101, 105), (105, 110)(间隙锁)
- 以及最大值之后的间隙:(110, +∞)(间隙锁)
- 这些间隙锁会阻止其他事务在这些被锁定的间隙中插入任何新的记录(例如插入 id=102, 106, 115 等)。
- 因此,在该事务提交之前,其他事务无法插入满足其查询条件(
id > 100
)的新行。当该事务再次执行相同的范围查询时,就不会看到新插入的行(幻行),从而防止了幻读。
- 当一个事务执行当前读(例如
- 为什么 RR 级别能“极大防止”而非“绝对防止”幻读?
- 快照读 (
SELECT
): 基于 MVCC 的 ReadView,它看到的是固定快照,本身就不会看到其他事务新提交的数据(包括幻行)。所以快照读天然不会发生幻读。 - 当前读 (
SELECT ... FOR UPDATE
等): 通过 Next-Key Locks 锁住索引记录和间隙,阻止了其他事务在锁定范围内插入新行,从而防止了当前读的幻读。 - “极大防止”的细微点: 如果一个事务 T1 只使用快照读 (
SELECT
),它看不到其他事务插入的幻行(因为 MVCC)。但是,如果 T1 之后在同一个事务内执行了一个写操作(如UPDATE
或DELETE
),而这个写操作恰好影响到了其他事务新插入并提交的行(这些行对 T1 的快照读是不可见的),那么 T1 的写操作会“看到”这些新行(写操作需要当前读并可能加锁)。如果这个写操作修改了这些新行,那么 T1 后续的读操作(即使是快照读)再读取这些被自己修改的行时,就会看到它们了。这种情况理论上构成了一种特殊的幻读现象(写操作发现了之前读操作没看到的行)。不过这种情况相对罕见,且很多业务场景下可以接受或规避。因此,通常说 InnoDB 的 RR 级别通过 Next-Key Locks “极大防止”了幻读。
- 快照读 (
总结 MySQL RR 隔离级别的实现
- MVCC (核心):
- 利用隐藏字段 (
DB_TRX_ID
,DB_ROLL_PTR
) 和 Undo Log 构建行数据的历史版本链。 - 事务在第一次执行快照读 (
SELECT
) 时生成一个 ReadView。 - 所有后续的快照读都复用这个 ReadView,通过版本链的可见性规则访问历史快照数据,保证可重复读。
- 利用隐藏字段 (
- Next-Key Locks (关键补充):
- 默认使用临键锁(行锁 + 间隙锁)。
- 在当前读 (
SELECT ... FOR UPDATE/LOCK IN SHARE MODE
,UPDATE
,DELETE
,INSERT
) 时锁定索引记录及其前后的间隙。 - 阻止其他事务在锁定范围内插入新记录,从而极大防止了幻读的发生。
- Undo Log 的清理:
- 不再被任何活动事务的 ReadView 引用的旧版本数据(在 Undo Log 中)会被 Purge 线程清理掉。
因此,InnoDB 通过巧妙地结合 MVCC 的快照机制(处理读) 和 Next-Key Locks 的锁定机制(处理写和范围控制),高效且强有力地实现了可重复读(RR)隔离级别的语义要求。