ElIauk

MySQL 事务隔离级别与 MVCC

两个窗口,两个结果 准备两个 MySQL 客户端窗口,做一个小实验: -- 窗口 A BEGIN; SELECT balance FROM account WHERE id = 1; -- 假设查到 1000 -- 窗口 B UPDATE account SET balance = 900 WH

评论
MySQL 事务隔离级别与 MVCC

两个窗口,两个结果

准备两个 MySQL 客户端窗口,做一个小实验:

-- 窗口 A
BEGIN;
SELECT balance FROM account WHERE id = 1;  -- 假设查到 1000

-- 窗口 B
UPDATE account SET balance = 900 WHERE id = 1;
COMMIT;

-- 窗口 A 再查一次
SELECT balance FROM account WHERE id = 1;

在 MySQL 默认配置下,窗口 A 第二次查到的还是 1000。如果换成 READ COMMITTED,第二次就能看到 900。同一个事务里两次查询结果不同,这就是隔离级别和 MVCC 在起作用。

面试里常问"MySQL 默认隔离级别是什么""RR 能不能解决幻读",实际开发中更常遇到的是:为什么事务里读不到刚提交的数据?为什么长事务会让数据库越来越慢?这篇把隔离级别和 MVCC 串起来讲。

四种隔离级别

隔离级别 脏读 不可重复读 幻读 说明
READ UNCOMMITTED 可能 可能 可能 几乎不用
READ COMMITTED 不可能 可能 可能 Oracle、PostgreSQL 默认
REPEATABLE READ 不可能 不可能 部分场景可能 MySQL 默认
SERIALIZABLE 不可能 不可能 不可能 串行执行,性能最差

MySQL 的默认级别是 REPEATABLE READ,可以通过下面的语句确认和修改:

SELECT @@transaction_isolation;  -- MySQL 8.0
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

SET SESSION 只影响当前连接。项目里用连接池时,连接会被复用,某个请求改了会话隔离级别,下一个请求可能继承这个设置,所以更推荐在连接池或启动参数里统一配置。

MVCC 的三样东西

MVCC(多版本并发控制)让"读"和"写"不互相阻塞,核心是三样东西。

第一,隐藏列。 InnoDB 的每一行都有两个隐藏字段:DB_TRX_ID 记录最后修改这行的事务 ID,DB_ROLL_PTR 指向 undo log 里的旧版本。

第二,undo log 版本链。 每次更新都会把旧值写进 undo log,通过 DB_ROLL_PTR 串成一条链。当前行读不到时,就顺着链往回找历史版本。

第三,ReadView。 事务执行快照读时生成一个"可见性视图",里面记录了当前活跃事务 ID 列表(m_ids)、最小活跃事务 ID、下一个要分配的事务 ID 等。拿到一行的 DB_TRX_ID 后,按规则判断这个版本对自己是否可见:不可见就顺着 undo 链找上一个版本。

关键差异在 ReadView 的生成时机:

  • READ COMMITTED:每条 SELECT 都生成新的 ReadView,所以能看到别人刚提交的数据;
  • REPEATABLE READ:事务里第一次快照读时生成 ReadView,之后一直复用,所以整个事务看到的是同一个快照。

这也解释了开头实验的结果:窗口 A 的 ReadView 在第一次查询时就固定了,窗口 B 后来提交的修改不在这个视图里。

快照读与当前读

MVCC 只作用于"快照读",也就是普通的 SELECT。下面这些语句是"当前读",读的是最新已提交版本,并且会加锁:

SELECT * FROM account WHERE id = 1 FOR UPDATE;
SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE;
UPDATE account SET balance = balance - 100 WHERE id = 1;
DELETE FROM account WHERE id = 1;

当前读在 REPEATABLE READ 下使用记录锁、间隙锁、临键锁来防止其他事务插入或修改。所谓"RR 解决了幻读",指的是快照读不会看到新插入的行;但当前读仍然可能看到"多出来"的数据。

一个经典的困惑场景是:事务 A 先快照读,发现没有 id=10 的记录;事务 B 插入 id=10 并提交;事务 A 执行 update ... where id = 10,居然更新成功了一行;再快照读,又看不见这行。这不是 bug,而是快照读和当前读用了不同的读视图。

常见坑

1. 以为 RR 完全没有幻读

RR 只保证快照读的可重复性。涉及 update、delete、select ... for update 时,读的是最新数据,幻读现象仍然可能出现。业务上如果需要强一致,得靠锁或者唯一约束,而不是只依赖隔离级别。

2. 混用快照读和当前读

先 select 判断"库存足够",再 update 扣减,这两步之间别的请求可能已经把库存改掉。正确做法是用 update ... where stock >= n 这类原子条件,或者 select ... for update 锁定记录。

3. 长事务拖垮数据库

RR 下事务一直不提交,ReadView 就不能释放,undo log 要保留所有旧版本。长事务越多,undo 表空间越大,查询回滚版本链也越慢。监控 information_schema.innodb_trx 里的 trx_started,超过几分钟的事务都应该排查。

4. 连接池里的隔离级别残留

前面说过,SET SESSION 会影响后续复用这条连接的请求。排查"为什么这个接口看到了未提交的数据"时,先确认连接池有没有被改过会话级别。

5. 降低隔离级别后忽略间隙锁变化

READ COMMITTED 下间隙锁基本被关闭,并发写入冲突会减少,但幻读风险上升。从 RR 改 RC 是个需要评估的决定,不是"性能优化开关"。

6. 把"读不到"当成"没写入"

事务里写入后,另一个事务读不到,可能只是隔离级别和提交时机的问题,不代表数据丢了。先确认写事务是否提交、读事务的隔离级别和 ReadView 时机。

排查步骤

遇到"数据不一致"时,先别改代码,按这个顺序确认:

  1. SELECT @@transaction_isolation 确认当前会话的隔离级别;
  2. 查 information_schema.innodb_trx,看有没有长事务、未提交事务;
  3. 确认读操作是快照读还是当前读,有没有混用;
  4. 看业务代码里事务的边界,是不是在事务里调用了远程接口导致长时间不提交;
  5. 必要时用两个客户端复现,观察两次查询的差异。

另外,SHOW ENGINE INNODB STATUS 里的 History list length 能反映 undo 的堆积程度,这个值持续增长,基本可以确定有长事务或者大量未提交的写操作。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者