贱来 發表於 2025-7-8 12:06:00

MySQL 08 详解read view:事务到底是隔离的还是不隔离的?

<h3 id="场景引入">场景引入</h3>
<p>我们知道,在可重复读的隔离级别下,一个事务A启动的时候会创建一个read view,之后在这个事务A执行期间,即使其他事务修改数据,事务A看到的仍然和启动时相同。</p>
<p>考虑一个问题,假如该事务A想要对一行做更新,而此时这行的行锁被其他事务B持有,那么事务A会被锁住而等待行锁。当事务A获取到行锁想要<strong>查询</strong>或<strong>更新</strong>时,它读到的值是启动时看到的旧值还是被事务B更新后的新值呢?</p>
<p>我们以一个有两行数据<code>(id,k)=(1,1),(2,2)</code>的表为例。假设现在有三个事务A,B,C,其语句时间顺序如下:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708115539857-836184311.png" width="50%"></div>
<p>首先,需要注意事务启动时机:<code>begin/start transaction</code>并不会直接启动事务,而是执行到它们之后的第一个操作InnoDB表的语句,才会真正启动事务。如果想要马上启动一个事务,可以使用<code>start transaction with consistent snapshot</code>,就像事务A和事务B那样。而对于事务C没有显式使用语句,表示更新语句本身就是一个事务,会在语句完成时自动提交。</p>
<p>上面这个例子就是我们要考虑的问题的一个场景,事务B先启动了事务,而想要更新的行先被事务C更新,之后事务B自己更新并查询;事务A在事务B之后查询同一行。那么事务A和事务B查询结果是多少呢?</p>
<p>答案是:事务A得到的结果是<code>k=1</code>,事务B得到的结果是<code>k=3</code>。</p>
<p>如果答案和你想的不一样,那么可以继续往下读,相信最后能解答疑惑。</p>
<h3 id="快照在mvcc里如何工作">快照在MVCC里如何工作</h3>
<p>在可重复读的隔离级别下,事务在启动时就会有一个快照,这个快照是基于整个库的。</p>
<p>接下来首先来看快照如何实现:</p>
<p>InnoDB里每个事务都有一个唯一的事务ID称为transaction id,该ID在事务开始时向InnoDB的事务系统申请,按照申请顺序<strong>严格递增</strong>。</p>
<p>而每行数据有多个版本,每次有事务更新数据,都会生成一个新的数据版本,并且把事务的transaction id赋值给这个数据版本,记为row trx_id。同时,旧的数据版本依然会被保留,且可以在新数据版本中通过一定方法获取到旧数据版本。下图表示了一个记录被多个事务更新的过程:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708115624766-1279734890.png" width="40%"></div>
<p>图中,下方的矩形就代表了不同的数据版本。而<span class="math inline">\(U_i\)</span>实际就代表了undo log。只要获取了最新的数据版本和undo log,就能回滚出历史数据版本。</p>
<p>为了达到可重复读的定义,实际上是在一个事务启动的时候,<strong>允许其看见它自己创建的以及在它启动前已经生成的数据版本,而不允许看见在它启动时还未生成的数据版本</strong>。</p>
<p>在实现上,InnoDB为每个事务构造一个数组,用来保存在这个事务启动瞬间,当前正在活跃的事务ID。这里,活跃指的是<strong>启动但未提交</strong>的事务。同时,还会记录数组里面事务ID的最小值,以及当前系统里已经创建过的事务ID的最大值+1。</p>
<p>数组+最小值+(最大值+1)+当前事务ID,实际上就组成了当前事务的一致性视图read view。</p>
<p>而数据版本是否可见,就是基于read view和数据版本的row trx_id。read view的数组和字段会把row trx_id分为几种情况:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708115703736-977904316.png" width="40%"></div>
<p>对于当前启动的事务,一个数据版本的row trx_id,有如下可能:</p>
<ul>
<li>
<p>落在绿色部分,表示该版本在当前事务启动前已提交或是自己创建的,可见;</p>
</li>
<li>
<p>落在红色部分,表示该版本不是由所有已创建出来的事务启动的,不可见;</p>
</li>
<li>
<p>落在黄色部分</p>
<ul>
<li>
<p>若row trx_id在数组中,表示是活跃事务生成的,还未提交,不可见;</p>
</li>
<li>
<p>若row trx_id不在数组中,表示是已经提交的事务生成的,可见。</p>
</li>
</ul>
</li>
</ul>
<p>所以,由于所有数据都有多个版本,每个创建的事务都有对应的快照。</p>
<br>
<p>接下来分析“场景引入”里事务A的查询结果为什么是<code>k=1</code>:</p>
<p>这里先做几个假设。假设事务A开始前,系统里只有一个活跃事务ID为99,事务A,B,C的ID为100,101,102且当前系统只有四个事务;在三个事务启动前,(1,1)这一行的数据的row trx_id为90。</p>
<p>根据该假设,事务A的read view中的数组为,事务B的read view中的数组为,事务C的read view中的数组为。</p>
<p>我们分析事务A相关的操作:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708115755719-266661327.png" width="40%"></div>
<p>可以发现,尽管在事务A做查询时,数据已经改为了(1,3),但由于该版本的<code>row trx_id=101</code>,不存在于事务A的read view数组中,因此该版本对事务A不可见。事务A查询语句的流程应该是:</p>
<ul>
<li>
<p>找到(1,3),发现不可见;</p>
</li>
<li>
<p>找到上一个版本(1,2),发现不可见;</p>
</li>
<li>
<p>继续向前,找到(1,1),是一个可见的数据版本。</p>
</li>
</ul>
<p>通过以上分析,相信你已经理解为什么事务A的查询结果是<code>k=1</code>了。但若每次分析都像这样,未免有些麻烦,因此我们给出总结:一个数据版本,对于一个事务视图来说,除了自己的更新总是可见,有三种情况:</p>
<ul>
<li>
<p>版本未提交,不可见;</p>
</li>
<li>
<p>版本已提交,但是是在read view创建后提交的,不可见;</p>
</li>
<li>
<p>版本已提交,而且是在read view创建前提交的,可见。</p>
</li>
</ul>
<p>以上总结对比前面与row trx_id比较分析的方法,其实就是去掉了数字的对比,只用时间先后顺序判断。</p>
<h3 id="更新逻辑">更新逻辑</h3>
<p>分析事务B相关的操作:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708115834631-121143533.png" width="40%"></div>
<p>可以发现,如果我们像分析事务A那样去分析事务B,会认为事务B看不到(1,2)这个数据版本。</p>
<p>这个问题出在混淆了<strong>“快照读”</strong>和<strong>“当前读”</strong>。当前读指的是读最新版本的数据。由于更新数据都是先读后写,它用到的是当前读而不再是快照读。</p>
<p>知道了这个规则后,就比较好理解答案了,事务B在更新时能拿到数据(1,2),从而更新后生成了一个新的数据版本(1,3),且该版本的row trx_id为事务B的ID 101。那么之后事务B在查询时,能查到由自己更新的数据版本,得到结果为<code>k=3</code>。</p>
<p>当前读除了在update语句上会生效,如果使用<code>select … lock in share mode / for update</code>,也是当前读。因此,如果对事务A的查询语句加锁,它也能查询出<code>k=3</code>。</p>
<br>
<p>假设事务C不是马上提交的,而是变成了下面这样:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708115936999-535336511.png" width="50%"></div>
<p>此时,就需要考虑上一篇文章介绍的“两阶段锁协议”。由于事务C’ 没有提交,其在<code>id=1</code>这一行上加的写锁并不会释放。而事务B是当前读,必须加锁读最新版本,因此会被锁住,直到事务C’ 释放这个行锁。</p>
<h3 id="从可重复读到读已提交">从可重复读到读已提交</h3>
<p>到这里,我们可以归纳事务的可重复读的能力是如何实现的:核心是read view,而事务更新数据的时候,只能用当前读,如果读取行的行锁被其他事务占用,就需要进入锁等待。</p>
<p>可重复读和读已提交的区别:</p>
<ul>
<li>
<p>读已提交隔离级别下,每个语句执行前都会生成一个read view。</p>
</li>
<li>
<p>可重复读隔离级别下,只在事务开始时创建read view。</p>
</li>
</ul>
<p>最后我们再来分析一下在读已提交的隔离级别下,一开始的场景中事务A和事务B的读取结果。画出状态图:</p>
<div align="center"><img src="https://img2024.cnblogs.com/blog/3389949/202507/3389949-20250708120114876-225764614.png" width="40%"></div>
<p>对于事务B,答案依然是<code>k=3</code>。</p>
<p>对于事务A,其创建read view时已经能看到(1,2)的版本,但由于事务B还未提交,事务A并不能看到(1,3)的版本,因此事务A查询结果为<code>k=2</code>。</p><br><br>
来源:https://www.cnblogs.com/san-mu/p/18972739
頁: [1]
查看完整版本: MySQL 08 详解read view:事务到底是隔离的还是不隔离的?