民主万岁 發表於 2025-12-23 09:59:15

在MySQL中不建议使用长事务的根因详析

<div id="navCategory"><h5 class="catalogue">目录</h5><ul class="first_class_ul"><li><a href="#_label0">引言</a></li><li><a href="#_label1">一、可重复读(REPEATABLE READ)的实现原理</a></li><ul class="second_class_ul"><li><a href="#_lab2_1_0">1.1 版本链示例</a></li><li><a href="#_lab2_1_1">1.2 Read View 是什么?&mdash;&mdash;原理与机制</a></li><ul class="third_class_ul"><li><a href="#_label3_1_1_0">关键特性:</a></li><li><a href="#_label3_1_1_1">为什么需要 Read View?</a></li><li><a href="#_label3_1_1_2">Read View 的内部字段</a></li></ul><li><a href="#_lab2_1_2">1.3 可见性判断规则</a></li><ul class="third_class_ul"></ul></ul><li><a href="#_label2">二、案例一:只读事务导致 Undo 日志无法清理</a></li><ul class="second_class_ul"><li><a href="#_lab2_2_3">2.1 场景还原</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_2_4">2.2 并发更新与 Undo 积压</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_2_5">2.3 故障后果</a></li><ul class="third_class_ul"></ul></ul><li><a href="#_label3">三、案例二:应用层&ldquo;合理&rdquo;长事务引发雪崩</a></li><ul class="second_class_ul"><li><a href="#_lab2_3_6">3.1 典型下单流程代码</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_3_7">3.2 高频辅助更新放大危害</a></li><ul class="third_class_ul"></ul><li><a href="#_lab2_3_8">3.3 根本原因</a></li><ul class="third_class_ul"></ul></ul><li><a href="#_label4">四、长事务的四大系统级危害</a></li><ul class="second_class_ul"></ul><li><a href="#_label5">结语</a></li><ul class="second_class_ul"></ul></ul></div><p class="maodian"><a name="_label0"></a></p><h2>引言</h2>
<p>&ldquo;不要使用长事务&rdquo;是 MySQL 开发与运维中的黄金准则。然而,许多开发者仅将其视为性能建议,却未意识到其背后隐藏着<strong>系统级崩溃风险</strong>。本文将从 InnoDB 的底层机制出发,结合具体事务 ID(trx_id)、Undo Log 版本链、Read View 快照等核心组件,彻底剖析:</p>
<ul><li>为什么 <strong>REPEATABLE READ</strong> 隔离级别必须维护历史版本;</li><li>为什么一个只包含 <code>SELECT</code> 的事务也能导致磁盘写满;</li><li>为什么&ldquo;事务中调用支付接口&rdquo;这类看似合理的代码会引发雪崩。</li></ul>
<p>只有理解了 MVCC 的完整工作流,才能真正明白:<strong>长事务的本质,是让整个数据库为你的快照背负历史包袱</strong>。</p>
<p class="maodian"><a name="_label1"></a></p><h2>一、可重复读(REPEATABLE READ)的实现原理</h2>
<p>MySQL InnoDB 引擎在 <code>REPEATABLE READ</code> 隔离级别下,通过 <strong>MVCC(多版本并发控制)</strong> 实现一致性非锁定读。其核心依赖三个要素:</p>
<ol><li><p><strong>每行记录的隐藏字段</strong>:</p>
<ul><li><code>DB_TRX_ID</code>:最后一次修改该行的事务 ID;</li><li><code>DB_ROLL_PTR</code>:指向 Undo Log 中的历史版本指针。</li></ul></li><li><p><strong>Undo Log</strong>:存储数据的历史版本,形成版本链(Version Chain)。</p></li><li><p><strong>Read View</strong>:事务执行第一个 <code>SELECT</code> 时创建的一致性视图,用于判断哪些版本可见。</p></li></ol>
<p class="maodian"><a name="_lab2_1_0"></a></p><h3>1.1 版本链示例</h3>
<p>假设初始插入由事务 <strong>trx_id = 100</strong> 完成:</p>
<div class="jb51code"><pre class="brush:sql;">INSERT INTO accounts (id, balance) VALUES (1, 100);
</pre></div>
<p>随后三次更新分别由 <strong>trx_id = 101, 102, 103</strong> 执行:</p>
<table><thead><tr><th>版本</th><th>balance</th><th>DB_TRX_ID</th><th>Undo 指向</th></tr></thead><tbody><tr><td>V4</td><td>400</td><td>103</td><td>&rarr; V3</td></tr><tr><td>V3</td><td>300</td><td>102</td><td>&rarr; V2</td></tr><tr><td>V2</td><td>200</td><td>101</td><td>&rarr; V1</td></tr><tr><td>V1</td><td>100</td><td>100</td><td>NULL</td></tr></tbody></table>
<p>物理上只保留最新版本 V4,其余通过 Undo Log 链式回溯。</p>
<p class="maodian"><a name="_lab2_1_1"></a></p><h3>1.2 Read View 是什么?&mdash;&mdash;原理与机制</h3>
<p><strong>Read View(读视图)是 InnoDB 为实现 MVCC 而在内存中动态构建的一个一致性快照结构</strong>。它的核心作用是:<strong>在不加锁的前提下,让事务看到一个&ldquo;逻辑上一致&rdquo;的数据库状态</strong>。</p>
<p class="maodian"><a name="_label3_1_1_0"></a></p><h4>关键特性:</h4>
<ul><li>✅ <strong>纯内存结构</strong>:Read View 不写入磁盘,不持久化,仅存在于事务执行期间的内存中。</li><li>✅ <strong>一次性创建</strong>:在 <code>REPEATABLE READ</code> 隔离级别下,事务执行<strong>第一个 <code>SELECT</code> 语句时创建</strong>,之后全程复用,不再更新。</li><li>✅ <strong>事务私有</strong>:每个事务拥有自己的 Read View,彼此隔离。</li><li>✅ <strong>轻量但关键</strong>:虽然结构简单,但它决定了整个事务能看到哪些数据版本。</li></ul>
<p class="maodian"><a name="_label3_1_1_1"></a></p><h4>为什么需要 Read View?</h4>
<p>因为 InnoDB 的行记录只保存最新版本,历史版本在 Undo Log 中。当一个事务读取数据时,它不能简单地&ldquo;看到最新值&rdquo;&mdash;&mdash;那样会破坏隔离性。<br />Read View 提供了一套<strong>基于事务 ID 的可见性规则</strong>,让事务能沿着 Undo 链找到&ldquo;它应该看到的那个版本&rdquo;。</p>
<p class="maodian"><a name="_label3_1_1_2"></a></p><h4>Read View 的内部字段</h4>
<table><thead><tr><th>字段</th><th>含义</th></tr></thead><tbody><tr><td><code>m_ids</code></td><td>创建 Read View 时,<strong>所有活跃(未提交)事务的 ID 列表</strong>。这些事务的修改对当前事务不可见。</td></tr><tr><td><code>m_up_limit_id</code></td><td><code>m_ids</code> 中的最小值。即 <strong>最小活跃事务 ID</strong>。小于该值的事务都已提交。</td></tr><tr><td><code>m_low_limit_id</code></td><td><code>max(m_ids) + 1</code>。即 <strong>下一个将要分配的事务 ID</strong>。大于等于该值的事务在 Read View 创建时尚未开始,属于&ldquo;未来事务&rdquo;。</td></tr><tr><td><code>m_creator_trx_id</code></td><td>当前事务自身的 trx_id。用于识别&ldquo;自己修改的数据&rdquo;,即使未提交也可见。</td></tr></tbody></table>
<blockquote><p>📌 <strong>举例说明</strong>:<br />假设事务 T(trx_id=150)创建 Read View 时,系统中只有它自己活跃,则:</p>
<ul><li><code>m_ids = </code></li><li><code>m_up_limit_id = min() = 150</code></li><li><code>m_low_limit_id = max() + 1 = 151</code></li><li><code>m_creator_trx_id = 150</code></li></ul></blockquote>
<p class="maodian"><a name="_lab2_1_2"></a></p><h3>1.3 可见性判断规则</h3>
<p>基于上述字段,InnoDB 对某一行版本的 <code>DB_TRX_ID</code> 进行如下判断:</p>
<ol><li><p><strong>如果是自己修改的</strong>:<br /><code>DB_TRX_ID == m_creator_trx_id</code> &rarr; ✅ <strong>可见</strong>。</p></li><li><p><strong>如果是未来事务产生的</strong>:<br /><code>DB_TRX_ID &gt;= m_low_limit_id</code> &rarr; ❌ <strong>不可见</strong>。</p></li><li><p><strong>如果是过去已提交事务产生的</strong>:<br /><code>DB_TRX_ID &lt; m_up_limit_id</code> &rarr; ✅ <strong>可见</strong>。</p></li><li><p><strong>如果是当时活跃但非自己的事务产生的</strong>:<br /><code>DB_TRX_ID &isin; m_ids</code> 且 <code>&ne; m_creator_trx_id</code> &rarr; ❌ <strong>不可见</strong>。</p></li><li><p><strong>其他情况(如 DB_TRX_ID 在 [m_up_limit_id, m_low_limit_id) 区间但不在 m_ids 中)</strong>:<br />表示该事务在 Read View 创建前已提交 &rarr; ✅ <strong>可见</strong>。</p></li><li><p><strong>若当前版本不可见,则沿 Undo 链向上查找,直到找到可见版本或链尾</strong>。</p></li></ol>
<blockquote><p>⚠️ 关键点:<strong>Read View 一旦创建,在 REPEATABLE READ 下全程复用,直到事务结束</strong>。</p></blockquote>
<p class="maodian"><a name="_label2"></a></p><h2>二、案例一:只读事务导致 Undo 日志无法清理</h2>
<p class="maodian"><a name="_lab2_2_3"></a></p><h3>2.1 场景还原</h3>
<p>事务 <strong>T1(trx_id = 150)</strong> 执行以下操作后忘记提交:</p>
<div class="jb51code"><pre class="brush:sql;">-- T=0
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;-- ← 创建 Read View
-- 事务挂起 6 小时
</pre></div>
<p>根据上述规则,其 Read View 为:</p>
<ul><li><code>m_ids = </code></li><li><code>m_up_limit_id = 150</code></li><li><code>m_low_limit_id = 151</code></li><li><code>m_creator_trx_id = 150</code></li></ul>
<p>这意味着:<strong>所有 DB_TRX_ID &lt; 151 的版本都必须保留</strong>,因为它们可能被 T1 读取。</p>
<p class="maodian"><a name="_lab2_2_4"></a></p><h3>2.2 并发更新与 Undo 积压</h3>
<p>与此同时,业务系统高频更新同一行(如用户积分),每秒一次,由连续递增的事务执行:</p>
<div class="jb51code"><pre class="brush:sql;">-- trx_id = 151, 152, 153, ..., 21750(6小时共21600次)
UPDATE accounts SET points = points + 1 WHERE id = 1;
</pre></div>
<p>每次 UPDATE 生成新版本和 Undo 记录。</p>
<p><strong>为什么不能清理?</strong></p>
<ul><li>Purge 线程清理条件:<strong>所有活跃事务都不再需要该旧版本</strong>。</li><li>事务 150 的 Read View 要求:所有 <code>DB_TRX_ID &lt; 151</code> 的版本必须保留(包括最初的 trx_id=100)。</li><li>Undo 是链式结构,<strong>只要最老版本(V1)不能删,整条链都必须保留</strong>。</li><li>因此,即使 trx_id=151~21750 的事务早已提交,它们的 Undo 仍因依赖 V1 而无法 purge。</li></ul>
<p class="maodian"><a name="_lab2_2_5"></a></p><h3>2.3 故障后果</h3>
<ul><li>Undo 表空间从 500MB 膨胀至 8GB+;</li><li><code>ibdata1</code> 文件写满,数据库进入只读模式;</li><li>监控指标:<ul><li><code>History list length</code> &gt; 200,000;</li><li>简单查询延迟从 0.3ms 升至 50ms;</li><li>磁盘 IO util 达 98%。</li></ul></li></ul>
<blockquote><p>💥 <strong>结论</strong>:即使没有 DML,一个未提交的 <code>SELECT</code> 也能拖垮整个数据库。</p></blockquote>
<p class="maodian"><a name="_label3"></a></p><h2>三、案例二:应用层&ldquo;合理&rdquo;长事务引发雪崩</h2>
<p class="maodian"><a name="_lab2_3_6"></a></p><h3>3.1 典型下单流程代码</h3>
<div class="jb51code"><pre class="brush: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(...));
}
</pre></div>
<p>假设该事务分配到 <strong>trx_id = 22000</strong>。</p>
<ul><li>Read View:<ul><li><code>m_ids = </code></li><li><code>m_up_limit_id = 22000</code></li><li><code>m_low_limit_id = 22001</code></li><li><code>m_creator_trx_id = 22000</code></li></ul></li></ul>
<p>这意味着:<strong>所有 DB_TRX_ID &lt; 22001 的版本都必须保留</strong>。</p>
<p class="maodian"><a name="_lab2_3_7"></a></p><h3>3.2 高频辅助更新放大危害</h3>
<p>系统另有服务每秒更新商品浏览量 200 次,由 trx_id = 22001, 22002, &hellip; 执行:</p>
<div class="jb51code"><pre class="brush:sql;">UPDATE products SET view_count = view_count + 1 WHERE id = 123;
</pre></div>
<p>在 20 秒内:</p>
<ul><li>产生 4,000 条 Undo 记录(trx_id 22001 ~ 26000);</li><li>所有记录因事务 22000 的 Read View 而<strong>无法 purge</strong>(因为它们依赖更早版本)。</li></ul>
<p>若同时有 50 个用户下单:</p>
<ul><li>Undo 增长速率 = 200 &times; 50 &times; 20 = <strong>200,000 条/分钟</strong>;</li><li>Purge backlog 暴涨;</li><li>主从复制延迟从 1 秒升至 15 分钟;</li><li>应用超时率飙升。</li></ul>
<p class="maodian"><a name="_lab2_3_8"></a></p><h3>3.3 根本原因</h3>
<ul><li><strong>问题不在新事务 ID 大</strong>,而在<strong>旧版本无法释放</strong>;</li><li><strong>Read View 冻结了历史视角</strong>,迫使 InnoDB 保留从 trx_id=100 到当前的所有中间状态;</li><li><strong>Undo 日志增长速度 = 热点行更新频率 &times; 长事务数量 &times; 持续时间</strong>。</li></ul>
<p class="maodian"><a name="_label4"></a></p><h2>四、长事务的四大系统级危害</h2>
<table><thead><tr><th>危害类型</th><th>机制</th><th>后果</th></tr></thead><tbody><tr><td><strong>磁盘耗尽</strong></td><td>Undo 表空间无法 purge</td><td><code>ibdata1</code> 或 undo tablespace 写满,数据库只读/宕机</td></tr><tr><td><strong>查询性能暴跌</strong></td><td>版本链过长,MVCC 回溯成本高</td><td>简单 SELECT 延迟从 ms 级升至百 ms 级</td></tr><tr><td><strong>主从延迟</strong></td><td>Binlog 积压 + Slave 回放慢</td><td>从库数据严重滞后,读写分离失效</td></tr><tr><td><strong>锁冲突加剧</strong></td><td>行锁持有时间过长</td><td>其他会话阻塞,死锁概率上升</td></tr></tbody></table>
<p class="maodian"><a name="_label5"></a></p><h2>结语</h2>
<p>长事务的危害,源于 <strong>REPEATABLE READ 隔离级别下 Read View 与 Undo Log 的强耦合</strong>。<br />一个未提交的事务,就像一个&ldquo;时间锚点&rdquo;,将数据库的历史牢牢钉住,阻止系统轻装前行。</p>
<p>真正的稳定性,来自于对事务边界的敬畏:</p>
<blockquote><p><strong>让事务只做数据库该做的事,且越快越好。</strong></p></blockquote>
<p>唯有如此,Undo 日志才能及时回收,版本链才不会无限延长,数据库才能在高并发下稳健运行。</p>
頁: [1]
查看完整版本: 在MySQL中不建议使用长事务的根因详析