Skip to content

隔离级别、Read View 与 undo:同一张表,为什么你我看见的不是同一个现在 ​

← 第 8 站纵览 | 练习答案 | 下一站:锁、死锁与并发复盘

InnoDB 的普通 SELECT 通常不是排队看一条最新记录,而是在多版本资料室里看一张自洽的照片。REPEATABLE READ 的照片更稳定,READ COMMITTED 的照片更新鲜;FOR UPDATE 则不是看照片,而是走到当前货架前挂上“此处施工”的牌子。

本篇任务 ​

  • 用两个 MySQL 连接亲手复现 RC、RR、快照读与当前读。
  • 解释 Read View、undo、redo 各管什么,不把它们熬成一锅“数据库魔法汤”。
  • 知道长事务为什么会让历史版本堆积,以及为何 FOR UPDATE 不是性能护身符。
  • 用系统表和性能视图定位仍在拖着事务不提交的“午夜访客”。

一句话心智模型 ​

一行 InnoDB 记录像一本会持续修订的账本:新写入留下版本信息,旧版本由 undo 支撑回溯。普通一致性读根据自己的 Read View 选择该读哪一版;锁定读和写操作需要面对当前版本并协调锁。redo 负责崩溃后把已确认的变更恢复回来,它不是用来保存历史快照的。

先搭一个可反复实验的小桌面 ​

下面语句适用于 MySQL 8.4、InnoDB。它不会删除现有数据库;建议用专门的本地实验库。连接两个终端前,先在任意一个 mysql 客户端执行:

sql
CREATE DATABASE IF NOT EXISTS mvcc_lab
  CHARACTER SET utf8mb4
  COLLATE utf8mb4_0900_ai_ci;

USE mvcc_lab;

CREATE TABLE IF NOT EXISTS sales_order (
  order_id BIGINT PRIMARY KEY,
  status ENUM('pending', 'paid', 'cancelled') NOT NULL,
  version_no INT NOT NULL,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP
) ENGINE = InnoDB;

INSERT INTO sales_order (order_id, status, version_no)
VALUES (1, 'pending', 1)
ON DUPLICATE KEY UPDATE
  status = VALUES(status),
  version_no = VALUES(version_no);

SELECT @@version, @@transaction_isolation, @@autocommit;
SELECT * FROM sales_order WHERE order_id = 1;

初始结果应含有 pending | 1。之后每次实验开始前都执行下面这条复位语句,并确保它在自动提交状态下执行:

sql
UPDATE sales_order
SET status = 'pending', version_no = 1
WHERE order_id = 1;

实验一:RR 的普通 SELECT 看的是同一张照片 ​

打开两个客户端,分别称为会话 A 和 B。两者都先执行 USE mvcc_lab;。

会话 A:

sql
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;

SELECT status, version_no
FROM sales_order
WHERE order_id = 1;
-- 预期:pending | 1

-- 现在停在这里,等 B 提交。

会话 B:

sql
UPDATE sales_order
SET status = 'paid', version_no = version_no + 1
WHERE order_id = 1;
-- 默认 autocommit = 1 时,这条语句已经提交;显式事务中再执行 COMMIT。

回到会话 A:

sql
SELECT status, version_no
FROM sales_order
WHERE order_id = 1;
-- 预期仍是:pending | 1

ROLLBACK;
时间线会话 A(RR)会话 B
T1START TRANSACTION
T2第一次普通 SELECT,建立一致性读视图
T3改成 `paid
T4第二次普通 SELECT,仍读 T2 的一致性视图

InnoDB 的默认隔离级别是 REPEATABLE READ。同一事务中,首次一致性读建立的视图通常会被后续普通 SELECT 复用;所以 A 的两次结果保持一致。这并不意味着全世界停了,B 的提交已经真实发生,只是不在 A 这张照片里。

实验二:RC 每次普通 SELECT 都会换一张新照片 ​

先把数据重置为 pending | 1,再用相同顺序操作,只把 A 的隔离级别改成 READ COMMITTED:

会话 A:

sql
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;

SELECT status, version_no FROM sales_order WHERE order_id = 1;
-- 预期:pending | 1

-- 等 B 执行和上一个实验相同的 UPDATE 并提交。

SELECT status, version_no FROM sales_order WHERE order_id = 1;
-- 预期:paid | 2
ROLLBACK;

READ COMMITTED 中每次一致性读通常建立自己的新视图,所以第二个 SELECT 能看见 B 已提交的版本。不要因此把 RC 简化成“没有隔离”:未提交的改动仍不可见,锁、当前读和业务不变量仍然存在,只是普通读取更倾向于新鲜数据。

实验三:FOR UPDATE 为什么会看见更“新”的值 ​

将隔离级别切回 RR 并重置数据。先按照实验一让 A 的普通 SELECT 读到 pending | 1,B 再提交 paid | 2。此时 A 继续执行:

sql
SELECT status, version_no
FROM sales_order
WHERE order_id = 1
FOR UPDATE;
-- 预期:paid | 2,并对该记录加锁。

ROLLBACK;

FOR UPDATE 是锁定读(也常被称为当前读),为了决定该锁什么,它面对的是可锁定的当前版本,而不是先前一致性读的旧照片。它还会在事务结束时才释放锁。

这个实验故意让同一个 RR 事务里出现“旧快照”和“当前读”两个视角,便于理解机制;它不是推荐的日常业务写法。若业务需要全程按当前状态做互斥决策,应从一开始就设计好锁和隔离协议。MySQL 官方也提醒不要在 RR 事务里随意混用锁定语句与非锁定 SELECT,因为两个视角会难以推理。

机制下潜:版本、视图和日志分别在哪儿上班 ​

角色主要工作它不负责什么
行记录的事务/回滚指针信息帮 InnoDB 判断当前版本与可回溯路径不是应用层的业务版本号
undo log支撑回滚,并让一致性读能重建较早版本不是永久审计日志
Read View为一次一致性读划定哪些事务的版本可见不是“数据库在某时刻的完整复制品”
redo log让已提交变更在崩溃恢复时可重放不为普通 SELECT 提供历史版本
binlog用于复制与恢复链路中的变更记录不直接实现 InnoDB 的 MVCC 可见性

一致性读大致遵循这样的判断:读取本事务自己的改动;读取在视图建立前已经提交的版本;对于之后提交或当时仍活跃事务留下的版本,顺着 undo 链找到对该视图可见的旧版本。实现细节由 InnoDB 管理,你不应依赖隐藏事务 ID 写业务逻辑;理解它的价值是解释“为什么旧读不会堵住新写”和“为什么长事务有成本”。

长事务:别让历史版本在仓库里租长约 ​

当一个旧 Read View 仍可能需要老版本时,purge 不能随意清理对应 undo 历史。长事务因此会增加历史链、undo 占用和恢复工作量。典型肇事者不是复杂算法,而是:

  • 在交互式客户端执行 START TRANSACTION 后去吃午饭;
  • Web 请求一开始就开事务,再在里面等待远程 HTTP、文件上传或用户输入;
  • 连接池归还前没有结束事务;
  • 批处理每处理一百万行才提交一次,却没有分批策略。

事务应包围一个短小的业务不变量,而不是包围整段“看起来有关”的程序。短不等于碎:将“扣减库存”和“写订单”拆成两个互不相干的事务,也可能把不变量切断。先写清必须一起成立的事实,再决定边界。

观察现场:谁还没提交,谁在等锁 ​

保持会话 A 的事务开启时,在第三个连接执行:

sql
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
       trx_rows_locked, trx_rows_modified, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

锁争用时,进一步检查:

sql
SELECT ENGINE_TRANSACTION_ID, THREAD_ID, OBJECT_SCHEMA, OBJECT_NAME,
       INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'mvcc_lab';

SELECT * FROM performance_schema.data_lock_waits;

SHOW ENGINE INNODB STATUS\G

这些视图的可见范围受权限和服务器配置影响;查不到不等于没有事务。排查生产长事务时,把 trx_mysql_thread_id 与应用连接、请求 ID、部署日志关联,先找所有者和业务影响,再决定是否终止。直接杀连接像拔电源,可能是必要的止血,不是日常保健。

边界与常见误解 ​

“我写了 FOR UPDATE,为什么完全没锁住?” ​

锁定读必须在有效事务内。START TRANSACTION 或关闭 autocommit 后才有可持续到提交/回滚的锁;若语句执行完就自动提交,锁也随之释放。索引和查询条件还会影响扫描范围及实际锁住的记录,下一站会专门拆解记录锁、间隙锁与 next-key lock。

“普通 SELECT 会被别人的行锁卡住吗?” ​

在 RC/RR 的默认一致性读中,普通 SELECT 通常从 Read View 读取可见版本,不会因为行上有锁就等待。需要当前值并参与互斥时才选择 FOR UPDATE 或 FOR SHARE。把每个查询都加锁会降低并发,还经常掩盖真正的业务边界。

“回滚会撤销我的 Python/HTTP/消息操作吗?” ​

MySQL 只能回滚本事务里的数据库改动。外部副作用需要 outbox、幂等键、补偿或状态查询;这与 Django 的 on_commit()、队列任务和支付回调的边界完全相关,但不是数据库单独能许下的承诺。

“DDL 也能一直看到旧表吗?” ​

不要把 MVCC 当作跨 DDL 的时光机。某些会创建临时表并替换原表的 ALTER TABLE 操作,可能让旧一致性读报 ER_TABLE_DEF_CHANGED 并要求重试。长事务期间规划 DDL,要把迁移策略和重试合同一并设计。

版本与旧系统提示 ​

  • 本篇以 MySQL 8.4 LTS、InnoDB 为主线;MySQL 9.x 的差异应在升级演练中单独核对,不能拿生产库做惊喜测试。
  • InnoDB 默认是 REPEATABLE READ;不要假设 PostgreSQL、SQLite 或 ORM 测试环境具有相同的 Read View 与锁语义。
  • LOCK IN SHARE MODE 是旧语法;新代码优先使用 FOR SHARE。NOWAIT 和 SKIP LOCKED 适合明确的抢占/队列语义,不适合把普通业务读取悄悄变成“不完整结果”。

练习:库存预留的两种证明方式 ​

创建 inventory_item(sku, available_qty),两个会话同时尝试预留同一个 SKU。完成:

  1. 分别在 RC、RR 下记录两次普通读取的时间线,并明确哪次读建立/使用什么视图。
  2. 设计一个使用条件 UPDATE ... WHERE available_qty >= :qty 的方案;再设计一个使用 SELECT ... FOR UPDATE 的方案,写明各自什么时候更合适。
  3. 故意保持一个事务不提交,用 innodb_trx 和 data_locks 找到它;记录连接 ID、持续时间和持锁对象。
  4. 为“预留成功但消息发送失败”写出一个幂等/outbox 方案草图;不要声称单个事务能覆盖消息系统。

完成标准: 双会话脚本可重复运行,预留数量绝不变成负数;你能清楚区分普通一致性读、当前读、锁等待与外部副作用四条边界。

完成后核对第 8 站答案。先画时间线,再看答案;MVCC 最怕只背名词不看现场。

官方延伸阅读 ​

  • MySQL 8.4:事务隔离级别(外部资料已收录于证据账本)
  • MySQL 8.4:一致性非锁定读(外部资料已收录于证据账本)
  • MySQL 8.4:锁定读(外部资料已收录于证据账本)
  • MySQL 8.4:死锁的处理与最小化(外部资料已收录于证据账本)

用心记录每一步成长