跳到正文

MVCC 多版本并发控制 ​

一句话:MVCC 用隐藏列、Undo 版本链和 ReadView 让读操作走历史快照、写操作走最新版本,换来读写不互斥,代价是 Undo 膨胀、版本链变长和长事务拖垮 Purge。

适用版本:MySQL 8.0(InnoDB)。RC/RR 的行为在 8.4 未变;ReadView 是内部结构,没有 SQL 视图可直接查看。

为什么需要 ​

如果读和写靠同一把锁互斥,一个长查询就会阻塞所有写,一个长写事务也会阻塞所有读。多数业务是读多写少,这种互斥的代价不可接受。

MVCC 的思路是:写不覆盖旧版本,而是生成一个新版本;读按自己的快照挑一个可见版本。这样读不需要等写,写也不需要等读。

核心机制 ​

三个隐藏列 ​

InnoDB 每行有两个必备隐藏列,外加一个可选隐藏主键:

列大小作用
DB_TRX_ID6 字节最后修改该行的事务 ID
DB_ROLL_PTR7 字节指向 Undo 中的上一个版本
DB_ROW_ID6 字节仅当表没有主键且没有非空唯一键时生成

Undo 版本链 ​

每次 UPDATE/DELETE 把旧值写进 Undo,DB_ROLL_PTR 把当前行指向上一个版本,形成一条从新到旧的链。读操作从链头开始,按 ReadView 判断每个版本是否可见,直到找到第一个可见版本。

INSERT 的 Undo 只记录新行主键,提交后即可回收;UPDATE/DELETE 的 Undo 要等没有读事务需要这个旧版本时才能被 Purge 回收。

ReadView 与可见性 ​

ReadView 在创建时记录四个值:

字段含义
m_ids创建时仍活跃(未提交)的事务 ID 集合
min_trx_idm_ids 的最小值
max_trx_id下一个待分配的事务 ID
creator_trx_id创建该 ReadView 的事务 ID

判断一个版本是否可见,按顺序看:

  1. DB_TRX_ID == creator_trx_id:自己改的,可见。
  2. DB_TRX_ID < min_trx_id:创建 ReadView 前已提交,可见。
  3. DB_TRX_ID >= max_trx_id:创建 ReadView 后才开始的事务,不可见。
  4. 否则看 DB_TRX_ID 是否在 m_ids 中:在 = 未提交,不可见;不在 = 已提交,可见。

不可见就沿 DB_ROLL_PTR 找上一个版本,重复判断。

RC 与 RR 的差别只在创建时机 ​

隔离级别ReadView 创建时机结果
READ COMMITTED每条 SELECT 新建每次读到最新已提交版本,可能不可重复读
REPEATABLE READ事务首次一致性读时创建,之后复用事务内看到同一快照

注意:快照不是 BEGIN 时创建的,而是第一次一致性读(普通 SELECT)时创建的。如果另一个事务在 BEGIN 之后、首次 SELECT 之前提交,这个事务能看到对方的修改——这不是快照失效,而是快照还没建立。

代价 ​

  • Undo 不能随意回收,长事务会让整个实例的 Purge 卡住。
  • 版本链过长时,每次可见性判断要沿链走多步,查询变慢。
  • 二级索引没有版本信息,回表后仍要按聚簇索引的版本判断。

实战场景 ​

一、一个没人提交的长事务,把 undo 撑到磁盘告警 ​

现象:主库磁盘使用率一周内从 55% 涨到 92%,information_schema.INNODB_METRICS 里 trx_rseg_history_len(历史链表长度)从几千涨到 900 万+;同一时段几条普通更新从 5ms 涨到 300ms。最后定位到某个运维脚本开了事务、执行了 UPDATE,但一直没提交也没回滚(客户端断连后连接残留)。

排查:MVCC 的回收依赖“没有读事务需要这个旧版本”。

定位层取证据判定
谁开着长事务SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.INNODB_TRX ORDER BY trx_startedtrx_started 是几小时前即命中
历史堆积量SHOW ENGINE INNODB STATUS 的 History list length持续增长说明 Purge 追不上
版本链长度热点行的 UPDATE 次数与读取延迟判断可见性要沿链走多步,读变慢的直接原因
连接来源processlist 里的 host/user + 应用侧连接池区分脚本、ORM 泄漏与从库延迟
磁盘构成undo 表空间文件大小确认是 undo 而不是业务数据涨
sql
-- 定位长事务与它持有的锁
SELECT t.trx_id, t.trx_started, TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS run_s,
       t.trx_mysql_thread_id, p.user, p.host, t.trx_query
  FROM information_schema.INNODB_TRX t
  JOIN information_schema.PROCESSLIST p ON p.id = t.trx_mysql_thread_id
 WHERE TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) > 60
 ORDER BY run_s DESC;

对症做法:

sql
-- 1. 立即止血:确认业务允许后终止该连接(回滚会花时间,但比继续膨胀好)
KILL <trx_mysql_thread_id>;

-- 2. 给空闲事务设上限,避免“客户端断了事务还挂着”
SET GLOBAL innodb_lock_wait_timeout = 10;          -- 默认 50s,按业务收紧
-- 连接池侧:maxLifetime 要小于服务端 wait_timeout,防止拿到已断连接的旧事务

-- 3. 大批量改动分段提交,每段 1000~5000 行(经验值,需按业务与 binlog 容量校准)
UPDATE t SET status = 1 WHERE id BETWEEN ? AND ? AND status = 0;   -- 循环 + commit
text
4. 应用侧把“长事务”当指标管:
   连接池里事务存活时间 P99 超过 30s 即告警,而不是等磁盘告警反推。
5. Purge 线程数(innodb_purge_threads)能加速回收,但治不了长事务本身——
   只要有一个读事务需要旧版本,历史链表就降不下来。

复盘要点:判据是“History list length 是否回落”和“是否存在超过 60s 的未提交事务”,后者应做成常驻告警。这次事故的本质与隔离级别无关:MVCC 的代价就是旧版本要活到没人需要它为止,把事务开长等于让全实例为它买单。

二、快照读 + 当前读混用,余额被覆盖 ​

现象:账户扣减接口用这样的写法:先 SELECT balance(普通查询)判断余额够不够,再 UPDATE account SET balance = ? 写入新值。并发压测时出现“两笔各扣 80,余额 100 却都成功”,日均 3 笔(经验值,需校准)。数据库是 RR,日志里也没有锁等待。

排查:两条语句读的是不同版本。

定位层取证据判定
读的类型SELECT * FROM account WHERE id=1 是快照读,走 ReadView看到的是旧版本,不含别人未提交的改动
写的类型UPDATE 是当前读,一定基于最新已提交版本计算依据与写入依据来自两个时点
是否加锁有没有 FOR UPDATE 或行锁保护判断无锁即存在读-改-写竞态
事务边界两条语句是否在同一事务内同事务也不等于安全,快照读不加锁
更新方式SET balance = ?(绝对值)还是 SET balance = balance - ?(相对值)绝对值写法会覆盖别人的修改

RR 的快照只保证“同一事务内看到同一快照”,它不提供对写入的互斥;把判断建立在快照读上、把写入当绝对值,就等价于丢失更新。

对症做法:

sql
-- 方案 1(首选):把判断交给数据库的原子条件,不读回来再算
UPDATE account SET balance = balance - 80
 WHERE id = ? AND balance >= 80;
-- affected rows = 0 就是余额不足,不需要先 SELECT

-- 方案 2:确实要“读出来算”,就用当前读锁住这一行
START TRANSACTION;
SELECT balance FROM account WHERE id = ? FOR UPDATE;    -- 行锁 + 最新已提交版本
-- 基于该值计算
UPDATE account SET balance = ? WHERE id = ?;
COMMIT;
java
// 方案 3:乐观锁版本判定,适合冲突不频繁的场景
int updated = mapper.deduct(id, amount, expectedVersion);
// UPDATE account SET balance=balance-?, version=version+1 WHERE id=? AND version=?
if (updated == 0) throw new RetryableConflictException(id);
text
1. 团队约定:凡“先查后改”的写路径,判断依据必须来自当前读或原子条件,
   不允许用普通 SELECT 的结果直接算出绝对值写回。
2. 需要读最新值但不想加行锁时,把隔离级别降到 RC 并接受业务侧自行处理幻读,
   这比在 RR 里混用两种读更安全——前提是判断逻辑本身用原子 UPDATE。
3. 二级索引上的读仍要回表按聚簇索引版本判断,
   别指望“覆盖索引”能绕过可见性检查。

复盘要点:判据是把这类写法挡在 review 与静态检查里——搜索代码里 SELECT 之后紧跟以绝对值 SET 的同一实体,命中即要求改成条件更新或 FOR UPDATE。另外要有兜底:余额类字段加“变更流水 + 日终对账”,丢失更新能被对账发现而不是等客户投诉。

三、“设了 RR 为什么还读到别人新提交的数据” ​

现象:开发把事务隔离级别确认是 REPEATABLE READ,代码里 @Transactional 包住“BEGIN → 睡 2 秒 → SELECT”,另一会话在这 2 秒内提交了一行数据;结果这条 SELECT 读到了新提交的数据,于是判断“RR 没生效/配置没生效”。

排查:快照不是 BEGIN 时创建的。

定位层取证据判定
ReadView 建立时机RR 在事务首次一致性读时创建并复用BEGIN 后没人读过,就没有快照
事务内多次读第二次 SELECT 是否仍看到第一次的快照是 → RR 正常
是否混入当前读中间有无 FOR UPDATE/UPDATE/DDL当前读永远看最新已提交版本
实际生效级别SELECT @@transaction_isolation, @@global.transaction_isolation + 会话是否被覆盖连接池/框架可能设成 RC
连接级设置JDBC URL 的 sessionVariables=transaction_isolation=...、代理层默认全局 RR 被会话 RC 顶掉
从库延迟读请求是否落在从库那是复制问题,不是隔离级别问题
sql
-- 验证:第一次读之后才有别人的提交
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT c FROM t WHERE id = 1;            -- 此刻建立 ReadView,读到 version=A
-- 另一会话把 version 改成 B 并提交
SELECT c FROM t WHERE id = 1;            -- 仍是 A(复用快照)
SELECT c FROM t WHERE id = 1 FOR UPDATE; -- 立刻是 B(当前读)
COMMIT;

对症做法:

text
1. 测试写法要覆盖真实时序:
   “BEGIN 之后别人先提交、自己再第一次读”这种情况下看到新值是正确的,
   不能作为“隔离级别失效”的证据;要证明失效,必须构造“两次读之间别人提交”。
2. 业务需要“事务内绝对同一快照”时,在事务开始就做一次一致性读锚定快照:
   START TRANSACTION WITH CONSISTENT SNAPSHOT;      -- 立即建立 ReadView
3. 会话级别可能被连接池或代理改写,
   排查时先看 @@transaction_isolation 的实际会话值,再看配置文件。
4. 需要“读到自己判断的最新值并加锁”时,RR 也帮不了:那必须用当前读。
java
// Spring 里显式声明级别,避免继承环境默认值
@Transactional(isolation = Isolation.REPEATABLE_READ, rollbackFor = Exception.class)
public void settle(long accountId) { ... }

复盘要点:把“快照读 / 当前读”的区分写进代码规范:判断依据要来自当前读或原子条件,展示与统计才用快照读。这一类误判在排查上很费时间,因为“读到新数据”看起来像 bug,实际是 ReadView 建立时机与读类型两件事;相关锁行为见 锁,Purge 与回滚段见 MySQL 生产踩坑。

最小上手 ​

sql
-- 会话 A
START TRANSACTION;
SELECT * FROM t WHERE id = 1;   -- RR:这里创建 ReadView

-- 会话 B
UPDATE t SET name = 'B' WHERE id = 1;
COMMIT;

-- 会话 A
SELECT * FROM t WHERE id = 1;   -- 仍是旧值
COMMIT;

-- 把隔离级别改成 RC 再重复:两次 SELECT 之间能看到 B
SET SESSION transaction_isolation = 'READ-COMMITTED';

生产动作 ​

长事务监控(8.0):

sql
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS seconds_open,
       trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING'
ORDER BY trx_started;

-- 版本链长度(History list length)
SHOW GLOBAL STATUS LIKE 'Innodb_history_list_length';
SHOW ENGINE INNODB STATUS\G
-- 看 TRANSACTIONS 段的 History list length;持续增长说明 Purge 追不上
  • 告警线按业务定,常见做法是 seconds_open > 60 就告警,> 600 就人工介入。
  • 找到长事务后先看 trx_query 和 trx_mysql_thread_id;确认是业务事务再 KILL,不要直接杀正在做 DDL 的会话。
  • SHOW ENGINE INNODB STATUS 里 History list length 持续上升,说明有老 ReadView 或 Purge 被阻塞,先查长事务,再查 innodb_purge_threads(8.0 默认 4)是否被 IO 拖住。
  • Undo 表空间文件不会因为 kill 长事务就自动缩小;空间先变为可复用,确需缩容要重建表空间或重建实例,按官方文档操作。

边界 ​

  • 只作用于快照读:SELECT ... FOR UPDATE、UPDATE、DELETE 是当前读,必须看最新版本,走锁而不是 MVCC。
  • 只有 RC/RR 有意义:READ UNCOMMITTED 直接读最新值,SERIALIZABLE 把普通读也加锁。
  • 不解决写写冲突:两个事务改同一行,仍然靠行锁串行化。
  • 不是免费的:读多写少才划算;写极多、更新同一行的场景,版本链和 Purge 成本会很明显。

讲清楚 ​

MVCC 解决的是读写互斥。InnoDB 在每行存最后修改它的事务 ID 和指向 Undo 的指针,每次更新把旧值写进 Undo,串成版本链。读的时候创建一个 ReadView,记录当时哪些事务还活跃,然后从链头往旧版本走,找到第一个可见版本。RC 和 RR 的引擎是同一套,差别只在 ReadView 什么时候建:RC 每条 SELECT 建一次,RR 第一次一致性读建一次然后复用。代价是 Undo 不能及时回收,长事务会把版本链和 Purge 一起拖住,所以生产上监控长事务和 History list length 比背可见性算法更有用。

把一门技术讲透到既懂也会用