在 MySQL 里,不建议使用长事务的根因

引言

"不要使用长事务"是 MySQL 开发与运维中的黄金准则。然而,许多开发者仅将其视为性能建议,却未意识到其背后隐藏着系统级崩溃风险。本文将从 InnoDB 的底层机制出发,结合具体事务 ID(trx_id)、Undo Log 版本链、Read View 快照等核心组件,彻底剖析:

  • 为什么 REPEATABLE READ 隔离级别必须维护历史版本;
  • 为什么一个只包含 SELECT 的事务也能导致磁盘写满;
  • 为什么"事务中调用支付接口"这类看似合理的代码会引发雪崩。

只有理解了 MVCC 的完整工作流,才能真正明白:长事务的本质,是让整个数据库为你的快照背负历史包袱


一、可重复读(REPEATABLE READ)的实现原理

MySQL InnoDB 引擎在 REPEATABLE READ 隔离级别下,通过 MVCC(多版本并发控制) 实现一致性非锁定读。其核心依赖三个要素:

  1. 每行记录的隐藏字段

    • DB_TRX_ID:最后一次修改该行的事务 ID;
    • DB_ROLL_PTR:指向 Undo Log 中的历史版本指针。
  2. Undo Log:存储数据的历史版本,形成版本链(Version Chain)。

  3. Read View :事务执行第一个 SELECT 时创建的一致性视图,用于判断哪些版本可见。

1.1 版本链示例

假设初始插入由事务 trx_id = 100 完成:

sql 复制代码
INSERT INTO accounts (id, balance) VALUES (1, 100);

随后三次更新分别由 trx_id = 101, 102, 103 执行:

版本 balance DB_TRX_ID Undo 指向
V4 400 103 → V3
V3 300 102 → V2
V2 200 101 → V1
V1 100 100 NULL

物理上只保留最新版本 V4,其余通过 Undo Log 链式回溯。

1.2 Read View 是什么?------原理与机制

Read View(读视图)是 InnoDB 为实现 MVCC 而在内存中动态构建的一个一致性快照结构 。它的核心作用是:在不加锁的前提下,让事务看到一个"逻辑上一致"的数据库状态

关键特性:

  • 纯内存结构:Read View 不写入磁盘,不持久化,仅存在于事务执行期间的内存中。
  • 一次性创建 :在 REPEATABLE READ 隔离级别下,事务执行第一个 SELECT 语句时创建,之后全程复用,不再更新。
  • 事务私有:每个事务拥有自己的 Read View,彼此隔离。
  • 轻量但关键:虽然结构简单,但它决定了整个事务能看到哪些数据版本。

为什么需要 Read View?

因为 InnoDB 的行记录只保存最新版本,历史版本在 Undo Log 中。当一个事务读取数据时,它不能简单地"看到最新值"------那样会破坏隔离性。

Read View 提供了一套基于事务 ID 的可见性规则,让事务能沿着 Undo 链找到"它应该看到的那个版本"。

Read View 的内部字段

字段 含义
m_ids 创建 Read View 时,所有活跃(未提交)事务的 ID 列表。这些事务的修改对当前事务不可见。
m_up_limit_id m_ids 中的最小值。即 最小活跃事务 ID。小于该值的事务都已提交。
m_low_limit_id max(m_ids) + 1。即 下一个将要分配的事务 ID。大于等于该值的事务在 Read View 创建时尚未开始,属于"未来事务"。
m_creator_trx_id 当前事务自身的 trx_id。用于识别"自己修改的数据",即使未提交也可见。

📌 举例说明

假设事务 T(trx_id=150)创建 Read View 时,系统中只有它自己活跃,则:

  • m_ids = [150]
  • m_up_limit_id = min([150]) = 150
  • m_low_limit_id = max([150]) + 1 = 151
  • m_creator_trx_id = 150

1.3 可见性判断规则

基于上述字段,InnoDB 对某一行版本的 DB_TRX_ID 进行如下判断:

  1. 如果是自己修改的
    DB_TRX_ID == m_creator_trx_id → ✅ 可见

  2. 如果是未来事务产生的
    DB_TRX_ID >= m_low_limit_id → ❌ 不可见

  3. 如果是过去已提交事务产生的
    DB_TRX_ID < m_up_limit_id → ✅ 可见

  4. 如果是当时活跃但非自己的事务产生的
    DB_TRX_ID ∈ m_ids≠ m_creator_trx_id → ❌ 不可见

  5. 其他情况(如 DB_TRX_ID 在 [m_up_limit_id, m_low_limit_id) 区间但不在 m_ids 中)

    表示该事务在 Read View 创建前已提交 → ✅ 可见

  6. 若当前版本不可见,则沿 Undo 链向上查找,直到找到可见版本或链尾

⚠️ 关键点:Read View 一旦创建,在 REPEATABLE READ 下全程复用,直到事务结束


二、案例一:只读事务导致 Undo 日志无法清理

2.1 场景还原

事务 T1(trx_id = 150) 执行以下操作后忘记提交:

sql 复制代码
-- T=0
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;  -- ← 创建 Read View
-- 事务挂起 6 小时

根据上述规则,其 Read View 为:

  • m_ids = [150]
  • m_up_limit_id = 150
  • m_low_limit_id = 151
  • m_creator_trx_id = 150

这意味着:所有 DB_TRX_ID < 151 的版本都必须保留,因为它们可能被 T1 读取。

2.2 并发更新与 Undo 积压

与此同时,业务系统高频更新同一行(如用户积分),每秒一次,由连续递增的事务执行:

sql 复制代码
-- trx_id = 151, 152, 153, ..., 21750(6小时共21600次)
UPDATE accounts SET points = points + 1 WHERE id = 1;

每次 UPDATE 生成新版本和 Undo 记录。

为什么不能清理?

  • Purge 线程清理条件:所有活跃事务都不再需要该旧版本
  • 事务 150 的 Read View 要求:所有 DB_TRX_ID < 151 的版本必须保留(包括最初的 trx_id=100)。
  • Undo 是链式结构,只要最老版本(V1)不能删,整条链都必须保留
  • 因此,即使 trx_id=151~21750 的事务早已提交,它们的 Undo 仍因依赖 V1 而无法 purge。

2.3 故障后果

  • Undo 表空间从 500MB 膨胀至 8GB+;
  • ibdata1 文件写满,数据库进入只读模式;
  • 监控指标:
    • History list length > 200,000;
    • 简单查询延迟从 0.3ms 升至 50ms;
    • 磁盘 IO util 达 98%。

💥 结论 :即使没有 DML,一个未提交的 SELECT 也能拖垮整个数据库。


三、案例二:应用层"合理"长事务引发雪崩

3.1 典型下单流程代码

java 复制代码
@Transactional
public void placeOrder(Long userId, Long productId) {
    // 1. 查库存(SELECT)
    int stock = productMapper.selectStock(productId); // ← 创建 Read View!

    // 2. 调用第三方支付(网络 I/O,耗时 10~30 秒)
    paymentService.callRemoteAPI(...); // ⚠️ 事务挂起!

    // 3. 扣库存 + 保存订单
    productMapper.decreaseStock(productId);
    orderMapper.insert(new Order(...));
}

假设该事务分配到 trx_id = 22000

  • Read View:
    • m_ids = [22000]
    • m_up_limit_id = 22000
    • m_low_limit_id = 22001
    • m_creator_trx_id = 22000

这意味着:所有 DB_TRX_ID < 22001 的版本都必须保留

3.2 高频辅助更新放大危害

系统另有服务每秒更新商品浏览量 200 次,由 trx_id = 22001, 22002, ... 执行:

sql 复制代码
UPDATE products SET view_count = view_count + 1 WHERE id = 123;

在 20 秒内:

  • 产生 4,000 条 Undo 记录(trx_id 22001 ~ 26000);
  • 所有记录因事务 22000 的 Read View 而无法 purge(因为它们依赖更早版本)。

若同时有 50 个用户下单:

  • Undo 增长速率 = 200 × 50 × 20 = 200,000 条/分钟
  • Purge backlog 暴涨;
  • 主从复制延迟从 1 秒升至 15 分钟;
  • 应用超时率飙升。

3.3 根本原因

  • 问题不在新事务 ID 大 ,而在旧版本无法释放
  • Read View 冻结了历史视角,迫使 InnoDB 保留从 trx_id=100 到当前的所有中间状态;
  • Undo 日志增长速度 = 热点行更新频率 × 长事务数量 × 持续时间

四、长事务的四大系统级危害

危害类型 机制 后果
磁盘耗尽 Undo 表空间无法 purge ibdata1 或 undo tablespace 写满,数据库只读/宕机
查询性能暴跌 版本链过长,MVCC 回溯成本高 简单 SELECT 延迟从 ms 级升至百 ms 级
主从延迟 Binlog 积压 + Slave 回放慢 从库数据严重滞后,读写分离失效
锁冲突加剧 行锁持有时间过长 其他会话阻塞,死锁概率上升

结语


长事务的危害,源于 REPEATABLE READ 隔离级别下 Read View 与 Undo Log 的强耦合

一个未提交的事务,就像一个"时间锚点",将数据库的历史牢牢钉住,阻止系统轻装前行。

真正的稳定性,来自于对事务边界的敬畏:

让事务只做数据库该做的事,且越快越好。

唯有如此,Undo 日志才能及时回收,版本链才不会无限延长,数据库才能在高并发下稳健运行。

复制代码
相关推荐
q***3752 小时前
MySQL输入密码后闪退?
数据库·mysql·adb
杨DaB2 小时前
【MySQL】03 数据库的CRUD
数据库·mysql·adb
文心快码BaiduComate2 小时前
用文心快码写个「隐私优先」的本地会议助手
前端·后端·程序员
q***33372 小时前
mysql查看binlog日志
数据库·mysql
q***51892 小时前
MYSQL批量UPDATE的两种方式
数据库·mysql
q***96582 小时前
Spring Boot 集成 MyBatis 全面讲解
spring boot·后端·mybatis
m0_639817153 小时前
基于springboot教学资料管理系统【带源码和文档】
java·spring boot·后端
i***66503 小时前
SpringBoot实战(三十二)集成 ofdrw,实现 PDF 和 OFD 的转换、SM2 签署OFD
spring boot·后端·pdf
6***3493 小时前
MySQL项目
数据库·mysql