两个窗口,两个结果
准备两个 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 时机。
排查步骤
遇到"数据不一致"时,先别改代码,按这个顺序确认:
SELECT @@transaction_isolation确认当前会话的隔离级别;- 查
information_schema.innodb_trx,看有没有长事务、未提交事务; - 确认读操作是快照读还是当前读,有没有混用;
- 看业务代码里事务的边界,是不是在事务里调用了远程接口导致长时间不提交;
- 必要时用两个客户端复现,观察两次查询的差异。
另外,SHOW ENGINE INNODB STATUS 里的 History list length 能反映 undo 的堆积程度,这个值持续增长,基本可以确定有长事务或者大量未提交的写操作。
评论