InnoDB MVCC机制深度解析:undo log、版本链与ReadView的协作
InnoDB MVCC机制深度解析:undo log、版本链与ReadView的协作
适读人群:Java后端开发、DBA、对MySQL内核感兴趣的工程师 | 阅读时长:约25分钟
开篇故事
那是2021年双十一前夕,凌晨两点,我的手机嗡嗡直响。
生产告警:订单系统读取到了已取消订单的金额,导致财务对账差了将近300万。值班的小李在群里喊:「老张,数据库出问题了,事务提交了但查询还是旧数据!」
我从床上弹起来,登上服务器,第一反应是看binlog,数据写入是正常的。然后看应用日志,发现问题是一个统计报表查询,它在一个大事务里执行,事务开启时间是23:47,而取消订单的操作是23:52完成的,但报表查询在23:55还是读到了取消前的数据。
「这不是bug,这是MVCC在正常工作。」我在群里发了这句话,然后沉默了三秒,因为我知道接下来要解释的东西,很难用两句话说清楚。
这件事给了我一个很深的教训:MVCC是MySQL并发控制的基石,但如果你不真正理解它,就会把它的正常行为误判为bug,更危险的是,你不知道什么时候它会给你带来意外的数据不一致。
今天我就把InnoDB的MVCC机制从头到尾拆解一遍,不只讲理论,结合真实的生产案例,把undo log、版本链、ReadView之间的协作关系说清楚。
一、MVCC要解决的核心问题
1.1 没有MVCC时的世界
在没有MVCC的数据库里,实现读写隔离只有一种办法:加锁。
- 读操作加共享锁(S锁)
- 写操作加排他锁(X锁)
- 读写互斥,写写互斥
这种方式在并发量低的时候没问题,但在高并发场景下简直是灾难。想象一个电商首页,每秒几万次商品详情查询,如果每次读取都要等待写锁释放,系统基本上就瘫了。
1.2 MVCC的思路
MVCC(Multi-Version Concurrency Control,多版本并发控制)的核心思想是:不同的事务看到的是数据在不同时间点的快照。
- 读不阻塞写
- 写不阻塞读
- 每个事务在特定时间点看到一个一致的数据视图
这就是为什么InnoDB能在高并发下保持良好性能的核心原因。
1.3 InnoDB MVCC的三个核心组件
要理解MVCC,必须先搞清楚三个东西:
- 隐藏字段:每行数据背后藏着的事务ID和回滚指针
- undo log:数据修改前的历史版本存储
- ReadView:事务能看到哪些版本的「判断标准」
二、底层原理深度解析
2.1 InnoDB行记录的隐藏字段
InnoDB每行记录实际上包含三个隐藏字段,很多人知道两个,但第三个经常被忽略:
+------------------+------------------+------------------+
| DB_TRX_ID | DB_ROLL_PTR | DB_ROW_ID |
| (事务ID, 6字节) | (回滚指针, 7字节) | (行ID, 6字节) |
+------------------+------------------+------------------+- DB_TRX_ID:最近一次修改这行数据的事务ID。每个事务开启时,MySQL会分配一个全局递增的事务ID。
- DB_ROLL_PTR:回滚指针,指向这行数据在undo log中的上一个版本。这是版本链的关键。
- DB_ROW_ID:如果表没有主键,InnoDB会用这个字段生成一个隐式主键。
2.2 undo log的版本链
当一条数据被修改时,InnoDB不会直接覆盖旧数据,而是:
- 将旧数据写入undo log
- 用新数据覆盖原始位置
- 新数据的DB_ROLL_PTR指向undo log中的旧版本
多次修改后,就形成了一个版本链:
当前版本(内存/磁盘)
|
| DB_ROLL_PTR
v
undo log版本N(事务T3修改后的值)
|
| DB_ROLL_PTR
v
undo log版本N-1(事务T2修改后的值)
|
| DB_ROLL_PTR
v
undo log版本1(事务T1修改后的值,或初始插入值)
|
v
NULL(链尾)用一个具体例子来说明。假设有一张账户表:
CREATE TABLE account (
id INT PRIMARY KEY,
name VARCHAR(50),
balance DECIMAL(10,2)
);
INSERT INTO account VALUES (1, '张三', 1000.00);插入后,这行数据的隐藏字段状态:
id=1, name='张三', balance=1000.00
DB_TRX_ID = 100 (假设插入事务ID是100)
DB_ROLL_PTR = NULL (第一个版本,没有前序版本)现在事务T101执行更新:
-- 事务101
UPDATE account SET balance = 900.00 WHERE id = 1;执行后:
- undo log记录旧版本:
{id=1, balance=1000.00, DB_TRX_ID=100} - 当前行变为:
{id=1, balance=900.00, DB_TRX_ID=101, DB_ROLL_PTR -> undo_v1}
再来事务T102:
-- 事务102
UPDATE account SET balance = 800.00 WHERE id = 1;执行后:
- undo log记录:
{id=1, balance=900.00, DB_TRX_ID=101, DB_ROLL_PTR -> undo_v1} - 当前行变为:
{id=1, balance=800.00, DB_TRX_ID=102, DB_ROLL_PTR -> undo_v2}
版本链就是这样一层层串起来的。
2.3 ReadView:事务的可见性判断器
光有版本链不够,还需要知道:当前事务应该读哪个版本?
这就是ReadView的作用。ReadView包含四个核心字段:
ReadView {
m_ids: [当前活跃事务ID列表]
min_trx_id: m_ids中最小的事务ID
max_trx_id: 下一个将分配的事务ID(已分配最大ID + 1)
creator_trx_id: 创建这个ReadView的事务ID
}可见性判断规则(按顺序判断):
给定版本的 trx_id(即修改该版本的事务ID):
1. trx_id == creator_trx_id
→ 可见(自己修改的,当然能看到)
2. trx_id < min_trx_id
→ 可见(该事务在ReadView创建前已提交)
3. trx_id >= max_trx_id
→ 不可见(该事务在ReadView创建后才开启)
4. min_trx_id <= trx_id < max_trx_id
→ 如果 trx_id 在 m_ids 中:不可见(事务还未提交)
→ 如果 trx_id 不在 m_ids 中:可见(事务已提交)用Mermaid画出这个判断逻辑:
2.4 RC与RR隔离级别下ReadView的创建时机
这是很多人搞混的地方,也是开篇事故的根因:
READ COMMITTED(读已提交,RC):
- 每次执行SELECT都创建一个新的ReadView
- 因此能读到其他事务已提交的最新数据
- 这就是「不可重复读」产生的原因
REPEATABLE READ(可重复读,RR):
- 同一个事务内只在第一次SELECT时创建ReadView
- 后续SELECT复用同一个ReadView
- 因此在事务内多次读取同一行,结果一致
- 这就是开篇事故的原因:事务在23:47创建了ReadView,后续所有查询都基于这个快照
回顾开篇的事故:
23:47 - 报表事务开启,执行第一个SELECT,创建ReadView
ReadView.max_trx_id = 5000(假设)
23:52 - 另一个事务(trx_id=5001)取消订单,提交事务
23:55 - 报表事务继续执行后续SELECT
复用23:47的ReadView
ReadView.max_trx_id = 5000
trx_id=5001 >= max_trx_id=5000+1? → 不可见!
→ 继续读版本链,读到5001之前的版本(取消前的数据)这完全是MVCC的正常行为,不是bug。
2.5 undo log的两种类型
undo log按用途分为两种:
insert undo log:
- 只在事务回滚时使用
- 事务提交后立即可以删除
- 记录的是插入操作的逆操作(DELETE)
update undo log:
- 用于MVCC版本链和事务回滚
- 必须等到没有ReadView引用这个版本后才能被purge线程清理
- 这是为什么长事务会导致undo log膨胀的原因
2.6 完整的协作时序图
时间轴 →
事务T1(RR隔离): BEGIN → SELECT(创建ReadView) --------→ SELECT(复用ReadView)
事务T2: BEGIN → UPDATE → COMMIT
事务T3: BEGIN → UPDATE → COMMIT
ReadView(T1创建时):
m_ids = [T1]
min_trx_id = T1
max_trx_id = T2(还未开始)
T1第二次SELECT时:
T2已提交 → T2.trx_id < max_trx_id 且不在m_ids中 → 不可见(对RR而言)
因为max_trx_id在ReadView创建时就固定了
T3已提交 → T3.trx_id > max_trx_id → 不可见ASCII图表说明版本链遍历过程:
当前行: balance=800, trx_id=T3
↓ 不可见(T3在ReadView创建后开启)
undo_v2: balance=900, trx_id=T2
↓ 不可见(T2提交了,但在ReadView创建后开启)
undo_v1: balance=1000, trx_id=T1_init
↓ 可见!(T1_init < min_trx_id,已提交)
返回 balance=1000三、完整解决方案与代码
3.1 验证MVCC行为的Java测试代码
import java.sql.*;
import java.util.concurrent.*;
/**
* MVCC行为验证测试
* 模拟RR隔离级别下的快照读行为
*/
public class MVCCVerifyTest {
private static final String URL = "jdbc:mysql://localhost:3306/test"
+ "?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
private static final String USER = "root";
private static final String PWD = "password";
public static void main(String[] args) throws Exception {
// 准备测试数据
prepareData();
// 测试1: RR隔离级别下快照读不感知其他事务提交
testRRSnapshot();
// 测试2: RC隔离级别下每次读取最新已提交数据
testRCFreshRead();
}
static void prepareData() throws Exception {
try (Connection conn = getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("DROP TABLE IF EXISTS account");
stmt.execute("CREATE TABLE account (id INT PRIMARY KEY, "
+ "name VARCHAR(50), balance DECIMAL(10,2))");
stmt.execute("INSERT INTO account VALUES (1, '张三', 1000.00)");
System.out.println("[准备] 测试数据初始化完成,balance=1000.00");
}
}
static void testRRSnapshot() throws Exception {
System.out.println("\n=== 测试RR隔离级别快照读 ===");
CountDownLatch latch1 = new CountDownLatch(1); // T1已读取
CountDownLatch latch2 = new CountDownLatch(1); // T2已提交
// 事务T1:RR隔离级别,长事务
Thread t1 = new Thread(() -> {
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
// 第一次读
BigDecimal balance1 = queryBalance(conn, 1);
System.out.println("[T1-RR] 第一次读 balance=" + balance1); // 期望1000
latch1.countDown(); // 通知T2可以更新了
latch2.await(); // 等待T2提交
// 第二次读(复用ReadView)
BigDecimal balance2 = queryBalance(conn, 1);
System.out.println("[T1-RR] 第二次读 balance=" + balance2); // 期望还是1000(快照读)
System.out.println("[T1-RR] 两次读结果相同: " + balance1.equals(balance2));
conn.commit();
} catch (Exception e) {
e.printStackTrace();
}
});
// 事务T2:更新并提交
Thread t2 = new Thread(() -> {
try {
latch1.await(); // 等T1读取完
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
conn.createStatement().execute(
"UPDATE account SET balance = 500.00 WHERE id = 1");
conn.commit();
System.out.println("[T2] 已将balance更新为500.00并提交");
latch2.countDown();
}
} catch (Exception e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
static void testRCFreshRead() throws Exception {
System.out.println("\n=== 测试RC隔离级别每次读最新 ===");
// 重置数据
try (Connection conn = getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("UPDATE account SET balance = 1000.00 WHERE id = 1");
}
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
Thread t1 = new Thread(() -> {
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
BigDecimal balance1 = queryBalance(conn, 1);
System.out.println("[T1-RC] 第一次读 balance=" + balance1); // 期望1000
latch1.countDown();
latch2.await();
// RC:每次SELECT都生成新ReadView,能读到T2提交的数据
BigDecimal balance2 = queryBalance(conn, 1);
System.out.println("[T1-RC] 第二次读 balance=" + balance2); // 期望500(读已提交)
System.out.println("[T1-RC] 发生不可重复读: " + !balance1.equals(balance2));
conn.commit();
} catch (Exception e) {
e.printStackTrace();
}
});
Thread t2 = new Thread(() -> {
try {
latch1.await();
try (Connection conn = getConnection()) {
conn.setAutoCommit(false);
conn.createStatement().execute(
"UPDATE account SET balance = 500.00 WHERE id = 1");
conn.commit();
System.out.println("[T2-RC] 已将balance更新为500.00并提交");
latch2.countDown();
}
} catch (Exception e) {
e.printStackTrace();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
static BigDecimal queryBalance(Connection conn, int id) throws Exception {
try (PreparedStatement ps = conn.prepareStatement(
"SELECT balance FROM account WHERE id = ?")) {
ps.setInt(1, id);
ResultSet rs = ps.executeQuery();
return rs.next() ? rs.getBigDecimal("balance") : null;
}
}
static Connection getConnection() throws Exception {
return DriverManager.getConnection(URL, USER, PWD);
}
}预期输出:
[准备] 测试数据初始化完成,balance=1000.00
=== 测试RR隔离级别快照读 ===
[T1-RR] 第一次读 balance=1000.00
[T2] 已将balance更新为500.00并提交
[T1-RR] 第二次读 balance=1000.00 ← 快照读,看不到T2的提交
[T1-RR] 两次读结果相同: true
=== 测试RC隔离级别每次读最新 ===
[T1-RC] 第一次读 balance=1000.00
[T2-RC] 已将balance更新为500.00并提交
[T1-RC] 第二次读 balance=500.00 ← 读已提交,能看到T2
[T1-RC] 发生不可重复读: true3.2 用SQL验证MVCC内部状态
通过查询InnoDB的内部表,可以观察事务状态:
-- 查看当前活跃事务
SELECT
trx_id,
trx_state,
trx_started,
trx_isolation_level,
trx_query,
trx_rows_locked,
trx_rows_modified
FROM information_schema.INNODB_TRX
ORDER BY trx_started;
-- 查看undo log段使用情况
SELECT
NAME,
SUBSYSTEM,
COUNT,
MAX_COUNT,
CURRENT_COUNT,
STATUS
FROM information_schema.INNODB_METRICS
WHERE NAME LIKE 'trx_undo%'
ORDER BY NAME;
-- 查看锁等待情况
SELECT
r.trx_id waiting_trx_id,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_query blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
INNER JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;
-- 查看InnoDB状态(包含undo log详情)
SHOW ENGINE INNODB STATUS\G
-- 模拟长事务导致undo log膨胀
-- 开启一个事务但不提交
START TRANSACTION;
SELECT * FROM account WHERE id = 1;
-- 此时别的事务的undo log无法被清理
-- 用以下SQL观察undo log history长度
SELECT NAME, COUNT
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';3.3 生产环境长事务监控与告警
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.sql.*;
import java.time.Duration;
import java.util.*;
/**
* 长事务监控组件
* 超过指定时间的活跃事务发出告警
*/
@Component
public class LongTransactionMonitor {
private final DataSource dataSource;
private final AlertService alertService;
// 告警阈值:事务执行超过30秒即告警
private static final long ALERT_THRESHOLD_SECONDS = 30;
public LongTransactionMonitor(DataSource dataSource, AlertService alertService) {
this.dataSource = dataSource;
this.alertService = alertService;
}
@Scheduled(fixedDelay = 10_000) // 每10秒检查一次
public void checkLongTransactions() {
String sql = """
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_isolation_level,
trx_query,
trx_rows_locked,
trx_rows_modified
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > ?
ORDER BY running_seconds DESC
""";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, ALERT_THRESHOLD_SECONDS);
ResultSet rs = ps.executeQuery();
List<LongTrxInfo> longTrxList = new ArrayList<>();
while (rs.next()) {
LongTrxInfo info = new LongTrxInfo();
info.trxId = rs.getString("trx_id");
info.state = rs.getString("trx_state");
info.started = rs.getTimestamp("trx_started");
info.runningSeconds = rs.getLong("running_seconds");
info.isolationLevel = rs.getString("trx_isolation_level");
info.query = rs.getString("trx_query");
info.rowsLocked = rs.getLong("trx_rows_locked");
info.rowsModified = rs.getLong("trx_rows_modified");
longTrxList.add(info);
}
if (!longTrxList.isEmpty()) {
String message = formatAlertMessage(longTrxList);
alertService.sendAlert("长事务告警", message);
}
} catch (SQLException e) {
// 监控自身异常不应影响业务,记录日志即可
log.error("长事务检查失败", e);
}
}
private String formatAlertMessage(List<LongTrxInfo> list) {
StringBuilder sb = new StringBuilder();
sb.append("发现 ").append(list.size()).append(" 个长事务:\n");
for (LongTrxInfo info : list) {
sb.append(String.format(
" TrxId=%s, 已运行=%ds, 隔离级别=%s, 锁定行=%d, SQL=%s\n",
info.trxId, info.runningSeconds, info.isolationLevel,
info.rowsLocked,
info.query != null ? info.query.substring(0, Math.min(100, info.query.length())) : "NULL"
));
}
return sb.toString();
}
record LongTrxInfo(String trxId, String state, Timestamp started,
long runningSeconds, String isolationLevel,
String query, long rowsLocked, long rowsModified) {
LongTrxInfo() { this(null, null, null, 0, null, null, 0, 0); }
}
}四、踩坑实录
坑1:长事务导致undo log无限膨胀,磁盘撑爆
场景:一个报表导出功能,需要遍历1亿条订单数据,在一个事务里执行。
-- 问题SQL:一个事务里全表扫描1亿行
START TRANSACTION;
-- 这个查询跑了45分钟
SELECT order_id, amount, status
FROM orders
WHERE created_at BETWEEN '2021-01-01' AND '2021-12-31';
-- 在这45分钟内,orders表上发生了大量UPDATE操作
-- 这些UPDATE产生的undo log版本不能被清理
-- 因为这个长事务的ReadView持有对这些版本的引用
COMMIT;报错:
[ERROR] InnoDB: Page cleaner: 1000ms intended loop took 15189ms.
The settings might not be optimal.
[ERROR] /var/lib/mysql/ibdata1 grew to 45G, disk full!
ERROR 1114 (HY000): The table '/tmp/#sql_3f8a_2' is full根因:长事务持有ReadView,导致其他事务的update undo log无法被purge线程回收,undo log在ibdata1中无限增长。
解决方案:
// 错误做法:一个事务处理所有数据
@Transactional
public void exportOrders() {
List<Order> all = orderMapper.selectAllOrders(); // 1亿条!
// 处理...
}
// 正确做法:分批处理,每批一个短事务
@Service
public class OrderExportService {
@Autowired
private OrderMapper orderMapper;
// 注意:这个方法本身不加@Transactional
public void exportOrders() {
long lastId = 0;
int batchSize = 1000;
while (true) {
// 每批开启一个新事务,执行完立即提交
List<Order> batch = processOneBatch(lastId, batchSize);
if (batch.isEmpty()) break;
lastId = batch.get(batch.size() - 1).getId();
}
}
// 每个批次是独立的短事务
@Transactional(timeout = 30) // 30秒超时保护
public List<Order> processOneBatch(long lastId, int size) {
List<Order> batch = orderMapper.selectByIdGreaterThan(lastId, size);
// 处理batch中的数据...
return batch;
}
}对应SQL:
-- 分批查询,游标式翻页(比offset更高效)
SELECT order_id, amount, status
FROM orders
WHERE id > :lastId
ORDER BY id
LIMIT 1000;坑2:当前读(FOR UPDATE)穿透快照,导致逻辑错误
场景:秒杀库存扣减,用MVCC快照读判断库存,但用当前读扣减。
-- 线程A
START TRANSACTION;
-- 快照读:库存=10(读到的是快照,可能已经被其他事务扣减)
SELECT stock FROM product WHERE id = 1; -- 返回10,觉得可以扣
-- 线程B在A读完之后,抢先扣减并提交
START TRANSACTION;
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
COMMIT;
-- 线程A继续(此时真实库存已经是9了)
-- 这里的UPDATE是当前读(X锁),会读最新版本
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 实际上是把9改成8,这没问题
-- 但如果在SELECT快照读时判断了某些业务逻辑,就会出问题
COMMIT;更危险的场景:
-- 错误做法:混用快照读和当前读做业务判断
START TRANSACTION;
-- 快照读,读到库存=1
SELECT stock INTO @stock FROM product WHERE id = 1;
-- 基于快照读的值做判断
IF @stock > 0 THEN
-- 当前读(实际上其他事务已经把库存扣到0了)
UPDATE product SET stock = stock - 1 WHERE id = 1;
-- 此时stock已经变成-1!
END IF;
COMMIT;报错或数据异常:
-- 没有报错,但库存变成了负数
mysql> SELECT stock FROM product WHERE id = 1;
+-------+
| stock |
+-------+
| -1 |
+-------+正确做法:
-- 方案1:全程使用当前读(悲观锁)
START TRANSACTION;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 当前读+排他锁
-- 此时其他事务无法修改,安全判断
UPDATE product SET stock = stock - 1 WHERE id = 1 AND stock > 0;
-- 检查受影响行数,0行表示库存不足
COMMIT;
-- 方案2:乐观锁(CAS更新)
UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock > 0;
-- Java中检查affected rows == 1坑3:RC隔离级别下binlog格式必须用ROW,否则主从数据不一致
场景:公司线上用RC隔离级别(性能好,大厂常用),binlog配置了STATEMENT格式。
-- 主库执行(RC隔离级别)
-- 事务T1:
START TRANSACTION;
DELETE FROM orders WHERE status = 'CANCELLED';
-- 此时事务T2插入一条status='CANCELLED'的记录并提交
-- T1在RC下不会看到T2(T2在T1开始后提交)
-- 但T1的COMMIT时,STATEMENT binlog记录的是SQL语句本身
COMMIT;
-- binlog记录: DELETE FROM orders WHERE status = 'CANCELLED'从库回放这条binlog时:
主库看到:100条 status='CANCELLED' 记录被删除
从库回放:此时T2的记录也在从库上,从库删除了101条!报错(从库数据不一致检查):
pt-table-checksum输出:
TS ERRORS DIFFS ROWS DIFF_ROWS CHUNKS SKIPPED TIME TABLE
... 1 1000 1 10 0 5.234 test.orders
# 发现orders表主从差异!解决方案:
-- 永久修改binlog格式
-- 在my.cnf中设置:
binlog_format = ROW
binlog_row_image = FULL -- 记录完整的行数据,便于回滚
-- 或者针对特定session
SET SESSION binlog_format = ROW;
-- ROW格式下,binlog记录的是每行数据的变化
-- 从库回放时精确到行级别,不受隔离级别影响五、总结与延伸
今天把InnoDB MVCC的三个核心组件都拆解了一遍:
undo log:存储数据的历史版本,是版本链的物理基础。要注意undo log的生命周期,长事务会阻止undo log被清理。
版本链:通过每行数据的DB_ROLL_PTR串联起所有历史版本,是MVCC时间穿越的基础设施。
ReadView:决定当前事务能看到哪个版本的「快照滤镜」。RR隔离级别下,同一事务内复用同一个ReadView,这是可重复读的实现机制,也是开篇事故的根因。
几个值得延伸思考的问题:
MVCC只解决读-写并发问题,写-写并发还是要靠锁。这就是为什么你在高并发UPDATE同一行时还是会看到锁等待。
Purge线程定期清理无用的undo log版本,但它的清理速度受
innodb_purge_threads参数限制,高并发写入时可能跟不上,导致undo log积压。MySQL 8.0把undo log从ibdata1独立出来,放到undo tablespace,解决了ibdata1无限增长的历史问题。但undo tablespace本身的大小管理仍然需要关注。
开篇的事故最终解法是:将报表查询从长事务改为无事务(或极短事务),同时在应用层接受「报表数据存在几秒的延迟」的业务妥协。数据库的一致性和实时性,有时候是需要业务来做取舍的。
