事务隔离:为什么你改了我还看不见?

107 阅读9分钟

提到事务,你肯定会想到ACID(AtomicityConsistencyIsolationDurability,即原子性、一致性、隔离性、持久性),我们就来说说其中I,也就是“隔离性”。 当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,所以下面我们来说说隔离级别。 首先你要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。 SQL标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)、串行化(serializable)。

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到

  • 读提交指,一个事务提交之后,它做的变更才会被其他事务看到

  • 可重复读指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据时一致的。当然可重复读隔离级别下,未提交变更对其他事务也是不可见的

  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行

在MySQL中,实现了四种隔离级别,分别有可能产生的问题如下所示: ❌代表不可能发生,✅代表可能发生

在MySQL中可以通过一下命令改变事务的隔离级别:

-- 设置事务的隔离级别为 可重复读set session transaction isolation level repeatable read ;

其中“读提交”和“可重复读”比较难理解,所以我用一个例子说明这几种隔离级别。假设数据表T中只 有一列,其中一行的值为1,下面是按照时间顺序执行两个事务的行为。

create table T(c int) engine=InnoDB;​insert into T(c) values(1);

我们来看看在不同的隔离级别下,事务A会有哪些不同的返回结果,也就是图里面V1V2V3的返 回值分别是什么。

  • 若隔离级别是“读未提交”, 则V1的值就是2。这时候事务B虽然还没有提交,但是结果已经被A看 到了。因此,V2V3也都是2

  • 若隔离级别是“读提交”,则V11V2的值是2。事务B的更新在提交后才能被A看到。所以, V3 的值也是2

  • 若隔离级别是“可重复读”,则V1V21V32。之所以V2还是1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的

  • 若隔离级别是“串行化”,则在事务B执行“将1改成2”的时候,会被锁住。直到事务A提交后,事务 B才可以继续执行。所以从A的角度看, V1V2值是1V3的值是2

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。

  • 在“可重复读”隔离级别 下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图

  • 在“读提交”隔离级别下,这个视图是在每个SQL语句开始执行的时候创建的

  • 这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念

  • 而“串行化”隔离级别下直接用加锁的方式来避免并行访问

事务隔离的实现

理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明“可重复读”。 可重复读,当开启事务之后,在此次事务中读到的数据都不会变化(除开新增的数据(可重复读隔离级别不能解决幻读)),为什么可重复读隔离级别能做到这样呢? 这就不得不提MySQL的MVCC(Multi-Version Concurrency Control)多版本并发控制机制。对同一行记录的写-读操作不会通过加锁来互斥,而是通过MVCC版本控制实现快照读和当前读。可以了解一下这个图,后期会出文章介绍。

Mysql在读提交和可重复隔离级别下面都实现了MVCC机制。接下来我们主要讲讲MVCC是什么。

什么是MVCC和Read-View

MySQL8中文文档地址:www.deituicms.com/mysql8cn/cn…

MVCC机制保证了多个事务间的读写隔离,从而实现了事务的隔离性。MVCC机制主要是依靠undo log(回滚日志)版本链与一致性视图read view来实现的。 对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含必要的隐藏列:

  • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务id赋值给trx_id隐藏列。

undo日志版本链是指一条数据被多个事务依次修改过后,在每个事务修改完后,Mysql会保留修改前的数据undo回滚日志,通过事务ID(trx_id)和roll_pointe(指向上一条undo日志记录)把这些undo日志串联起来形成一个历史记录版本链。 begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个修改操作InnoDB表的语句, 事务才真正启动,才会向MySQL申请事务id,MySQL内部是严格按照事务的启动顺序来分配事务id的。

可重复读隔离级别,当事务开启时,执行第一条查询sql时会生成当前事务的一致性视图read-view注意并不是在开启事务的时候生成一致性视图,该视图在事务结束之前都不会变化(如果是读已提交隔离级别在每次执行查询sql时都会重新生成一致性视图),这个视图由执行查询时所有未提交事务ID数组(数组里最小的idmin_id)和已创建的最大事务IDmax_id)组成,事务里的任何sql查询结果需要从对应版本链里的最新数据开始逐条跟read-view做比对从而得到最终的快照结果。

如下图:

如果此时这些事务都没有提交,在这个时候我们进行查询语句的时候,在生成的一致性视图就是:[5、6、7],7 ,[5、6、7]查询时未提交的事务ID数组,执行此查询的时候已创建的最大事务ID。

Read-View中主要包含4个比较重要的内容:

  • m_ids:表示在生成Read-View时当前系统中活跃的读写事务的事务id列表。

  • min_trx_id:表示在生成Read-View时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。

  • max_trx_id:表示生成Read-View时系统中应该分配给下一个事务的id值。

  • creator_trx_id:表示生成该Read-View的事务的事务id。

Undo日志版本链和read view对比规则

read-view所解决的问题是使用读提交重复读隔离级别的事务中,不能读到未提交的记录,这需要判断一下版本链中的哪个版本是当前事务可见的。 从版本链依次开始比对:

  1. 如果版本链中记录的行的(rowtrx_id 小于视图中的未提交事务数组ID最小的值( trx_id<min_id ),表示这个版本是已提交的事务生成的,这个数据是可见的

  2. 如果 rowtrx_id 大于数组的最大ID( trx_id>max_id ),表示这个版本是由后来启动的事务生成的,是不可见的(若 row 的 trx_id 就是当前自己的事务是可见的)。

  3. rowtrx_id 在视图数组中(min_id <=trx_id< max_id),表示这个版本是由还没提交的事务生成的,不可见

  4. rowtrx_id 不在视图数组中,表示这个版本是已经提交了的事务生成的,可见

举个栗子:

开启事务,按下面sql的执行循序执行sql:

结合上面undo日志版本链,日志版本链和此表sql的顺序是一致的:

当我们执行查询 1 的第一条查询的时候生成一致性视图:[5、6],7 在可重复读隔离级别当前这次事务中的查询只会生成一次视图,不会再改变。开始执行比对规则。

版本链第一条数据trx_id为 5 ,命中比对规则 3 :在视图数组(未提交的ID数组)中,因此不可见;

继续比对trx_id为7,命中规则 4 ,那么则可见。

查询 1 剩下的两条的sql,因为在第一次执行查询已经生成了一致性视图,虽然在步骤 8 的时候事务 5 提交了,但是并不会改变查询 1 的一致性视图,所以查询 1 三条查询结果都是一致,这也就实现可重复读。

查询 2 的查询语句是在事务 5 提交之后执行的,因此它生成的一致性视图和查询 1 是不一样的,它的视图中未提交事务ID数组只有事务 6 ,因此它能够查询得到xiaohong55 事务 1 提交的结果。

如果是读已提交,那么就是每次执行查询语句都会生成新的一致性视图,试想查询 1 如果在已提交的隔离级别下面,那么执行最后一次的查询生成的一致性视图是和查询 2 一致的,就能读到事务 5 已提交的数据了。

对于删除,会将版本链上最新的数据复制一份,然后将trx_id修改成删除操作的 trx_id,同时在该条记录的头信息(record header)里的(deleted_flag)删除标记为 true,来表示当前记录已经被删除,在查询时按照上面的规则查到对应的记录如果delete_flag标记为true,意味着记录已被删除,则不返回数据。

在 InnoDB 多版本控制方案中,使用SQL语句删除行时,不会立即从数据库中物理删除该行。 InnoDB 只有在丢弃为删除写入的更新撤消日志记录时,才会物理删除相应的行及其索引记录。 此删除操作称为 清除,并且速度非常快,通常与执行删除的SQL语句的时间顺序相同。

小结

这篇文章里面,我介绍了MySQL的事务隔离级别的现象和实现原理。通过MVCC机制保证了多个事务间的读写隔离,从而实现了事务的隔离性。