对于 MySQL 事务,可能我们大家都不陌生,事务是对数据库中数据操作保证数据逻辑一致的最小操作单位,一个事务中可能包含多条语句,但这些语句作为一个整体,由事务来保证这个整体的操作要么都成功,要么都失败,不存在部分成功部分失败的情况。
同时事务还具备 ACID 的特性,分别是原子性(Atomicity),一致性(Consistency),隔离性(Isolation),持久性(Durability)。
原子性: 事务作为一个最小操作单位,事务中的所有 SQL 要么都执行,要么都不执行
一致性: 每个事务都满足数据库的完整性约束,也就是说如果从 A 账户向 B 账户转账,只要 A 账户里面减少了 100 元,那 B 账户里面就一定会增加 100 元。
隔离性: 并发进行的多个事务,各个事务之间的操作对其他事务是相互隔离的。
持久性: 事务在提交之后,对数据的修改是永久的,即时数据库发生故障也不会丢失。
其中隔离性由于多个并发执行的事务同时操作同一条数据时,会带来数据的不一致性,包括脏读,不可重复读,幻读,因此有了不同的隔离级别来针对性的解决这些问题,分别是读未提交,读提交,可重复读,串行化。
读未提交: 一个事物产生的修改,还没提交就已经被其他事物读取到,存在的问题是脏读,不可重复读,幻读。
读提交: 一个事务产生的修改,还没提交时对其他事务不可见,只有提交之后对其他事务才可见,存在的问题是不可重复读,幻读。
可重复读: 事务开启之后,其他事务产生的修改对该事务都不可见,包括未提交和已提交的事务,当然事务本身的修改在当前事务内还是可见的,存在的问题是幻读,不过对于 MySQL InnoDB 引擎引进的行锁和间隙锁,已经可以解决幻读的问题了。
串行化: 数据库中基本上所有事务都是按顺序执行的,只有读和读之间不影响,读和写,写和写都是互斥的,这时后面的事务需要等前面的事务提交之后才能执行。
以上四种隔离级别从低到高,隔离级别越高,意味着性能越低,但数据越安全,所以在日常使用中需要综合各方面需求来选择其中一种,对于 MySQL 默认的隔离级别是可重复读,查看当前隔离级别设置的方式如下:
1 | show variables like 'transaction_isolation'; |
接下来我们看在特定场景,不同的隔离级别设置下,同一个事务看到的结果是怎样的,这里主要还是看前三种隔离级别,最后一种都串行化了也就不存在并行的事务了。还是以之前写 MySQL 文章最开始的那张表为例,我再贴下表结构和初始化数据:
1 | create table users |
假如说目前有下面两个事务在操作修改数据,然后事务之间的执行顺序如下:
事务一 | 事务二 |
---|---|
① begin; select * from users where id = 1; |
① begin; select * from users where id = 1; |
② update users set age = 28 where id = 1; | |
② select * from users where id = 1;# T1 | |
③ commit; | |
③ select * from users where id = 1;# T2 | |
④ commit; | |
⑤ select * from users where id = 1;# T3 |
从表格中可以看到,事务一和事务二开启之后都分别查询了 id = 1 的数据,这时查询出来的是张三的那条数据,并且年龄都是 27,接下来事务一将张三的年龄改成 28,这时还没提交,然后事务二再次查询了这条数据,此时记为 T1,紧接着事务一提交,事务二又查询了 id = 1 的数据,此时记为 T2,最后事务二自己提交事务,提交后又查询了一次 id = 1 的数据,此时记为 T3,那么在不同的隔离级别下,事务二在 T1,T2,T3 这三个时刻查询出来张三的年龄分别是多少呢,我们一个一个来分析:
读未提交(Read Uncommitted):
读未提交,顾名思义,一个事务可以读到其他事务未提交的数据。上面事务二的 T1,T2,T3 时刻都是在事务一更新数据之后查询的,因此 T1,T2,T3 时刻查出来张三的年龄都是 28,不管事务一的更新所在事务是否已经提交。
读提交(Read Committed)
读提交,意思也很明白了,一个事务只能读到其他事务已经提交的数据。上面事务二 T1 时刻事务一还没提交,它对张三年龄的更新对因此 T1 是不可见的,T1 时刻查出来张三的年龄还是 27,T2 和 T3 时刻事务一已经提交,因此 T2 和 T3 时刻事务二查询出来张三的年龄是 28。
可重复读(Repeatable Read)
事务开启之后,在整个事务内读到的数据都是一样的,其他事物的修改对当前事务不影响,T1 时刻事务一还没提交,肯定不可见,这时查出来张三的年龄还是 27, T2 时刻事务一已经提交,但其他事物的修改对当前事务不影响,这里查出来张三的年龄也是 27,T3时刻事务一已经提交,同时事务二本身也已经提交了,相当于再启动另一个事务继续查询,这时是可以看到事务一的修改,因此 T3 时刻查出来张三的年龄是 28。
也许你对上面这些东西已经比较熟悉了,无非就是事务的 ACID 特性以及隔离性中的不同隔离级别,上面我们也用一个实际的例子来说明了三种隔离级别的不同情况,这时如果我问你事务中的隔离性是如何实现的呢?怎么做到在不同隔离级别下事务之间数据的可见性也不同?
其实是在事务开启时,会为整个事务创建一个一致性读视图,这个视图持续到事务的结束,在整个事务的执行期间所看到的数据都依赖于事务开启时创建的一致性读视图(consisitent read view)。当然一致性读视图并不是实际存在的物理结构,它只是用来定义事务执行期间能看到哪些数据。如何定义呢?
这里需要先提到一个日志 undo log,我们之前说在更新的时候会写入 redo log 和 binlog,而 undo log 你可以认为是在写入 redo log 的同时记录的一个日志。redo log 是重做日志,记录的是数据页的物理修改,也就是只能将数据恢复到最后一次提交的状态,并且是循环写的;undo log 则是回滚日志,一般是逻辑日志,根据每行记录进行记录日志,主要用来回滚行记录到之前的版本。
比如说在三个不同事务里依次将张三的年龄依次更新成 28,29,30,SQL 如下:
1 | update users set age = 28 where name = '张三'; #① 事务一 |
上面这三个更新事务分别对应着数据库中三个数据版本,下面图中详细展示了三个事务更新后记录的 undo log。
从上面图中可以看出,三个事务更新后都会记录相应的 undo log 用来回滚到上一个版本,那么在查询张三的年龄的时候,不同时刻启动的事务查询得到的年龄是不一样的,但数据库中数据肯定已经是最新值了,只不过根据事务的隔离级别不同,需要通过 undo log 回滚到对应的可见版本,这里举几个例子:
从图中可以看到刚开始张三的那条数据年龄是 27,对应的更新事务是 X,目前最新的数据年龄值是 30,对应的更新事务是事务三:
如果是在事务一启动之前开启事务 A 来查询,在事务 A 启动的时刻创建了一个一致性读视图,后面的更改对我都不可见,我只认我创建的视图中能看到的数据,后面在事务中查询看到的数据都依赖于视图中能看到的数据,虽然读到最新的值是 age=30,但是不可见,通过 undo log 回滚到上一个版本 age=29,同样不可见,直到回滚到 age = 27,可见。
如果是在事务一启动之后事务二启动之前开启事务 B 来查询,同样启动时创建一致性读视图,接下来在事务中查询读到数据库中最新的值是 age=30,不可见,通过 undo log 回滚到上一个版本 age=29,同样不可见,再往前回滚到上一个版本 age = 28,可见。
对于在事务二和事务三之间启动事务 C 查询,流程也是一样的,而在事务三之后启动事务 D,那么一致性读视图里面的最新值就是数据库中的最新值,不用回滚,直接可见。
从上面例子可以看出,一条记录在数据库中看起来就存在多个版本一样,也就是我们经常听到的多版本并发控制(MVCC),不同时刻启动的事务看到的是不同版本的数据,但要注意,并不是说这多个版本的数据是物理结构上存在的,它们都是需要拿到当前的最新值再通过 undo log 进行回滚到相应的版本,那么就会引出一个问题了,对于那种长事务,往往数据已经更新了很多个版本了,但在这个事务里面依然需要通过 undo log 一步一步回滚到可见的那个版本,也就是说在事务提交前这些 undo log 就没办法进行删除,需要一直保留,直到没有对应的事务再需要这些日志的时候才能够删除,如果存在很多的长事务,就可能导致这些 undo log 越来越大,因此在我们日常的开发中,我们需要尽量的去避免使用长事务,长事务不仅会造成回滚日志会越来越大,还会导致锁资源的占用问题,这个后面在分析锁的时候再说。
上面也说了事务的很多东西了,事务的基本定义,ACID 特性,以及隔离性是如何实现的,然后引出了 undo log 这个新的日志,但 undo log 只是我们实现隔离性的一个辅助工具,能够让我们回滚到之前的数据版本,那 MySQL 是怎么去判定具体要回滚到哪个版本呢?
接下来我们再引出一个事务 id 的概念,实际上数据库在每个事务启动时都会向服务申请一个唯一的事务 id,而且这个事务 id 是顺序递增的,每次事务更新数据的时候会生成一个新的数据版本,并且把当前事务 id 赋值给这个数据版本,记为 trx_id,同时旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它,因此在新的数据版本中除了包含本次数据的值和当前更新的事务 id 外,还有一个引用(指向上一个数据版本),通过 undo log 进行回滚时需要用到。上面的几个事务更新在加上事务 id 之后,更新流程图如下:
从图中可以看到最开始更新之前张三这条数据是由事务 id=100 的事务 X 所更新的,接下来依次被事务一事务二事务三所更新,对应的事务 id 分别是 105,110,115,而在不同时刻启动的事务会创建不同的一致性读视图,这个一致性读视图其实也就是给整个库打了个快照,注意并不是说事务启动的时候将整个库的数据拷贝一份出来,要是这样做的话就太费内存,太费时间了。
它的实现上是将目前还处于活跃事务的事务 id 放到一个数组里面,活跃事务是指事务开启了但还没提交的事务,数组中最小值记为低水位,当前系统中已经创建的事务 id 的最大值 +1 记为高水位,然后依据高低水位来判断数据版本可不可见,注意理解这句话,说明数组中包含的是活跃事务 id,中间已经提交了的事务是不在里面的,也就是说数组中的值不一定是连续的。
有了这个数组之后,就可以利用这个数组来判断数据可不可见了,判断规则如下:
1.数据版本对应的事务 id 比最低水位小,说明是已经提交了的事务,该数据版本可见
2.数据版本对应的事务 id 比最高水位大,说明是在当前事务之后启动的事务,不管提没提交,该数据版本都不可见
3.数据版本对应的事务 id 大于等于最低水位小于等于最高水位,那存在两种情况:
- 数组中包含该事务 id,说明是还未提交的事务,该数据版本不可见
- 数组中不包含该事务 id,说明是已经提交的事务,该数据版本可见
接下来我们来模拟几个事务的 SQL 执行场景,然后我们用上面的规则来分析可见性,看看是否和我们之前理解的数据可见性是一致的。下面的场景将会涵盖上面三种情况,SQL 执行的顺序就是按照表格从上到下的顺序,事务隔离级别没有特殊说明都是可重复读:
上面表格中没有显式开启事务的都是自动提交,表头描述了每个事务的名称以及对应的事务 id,表格内容从上到下可以看到首先是事务一将 id = 1 的 age 字段更新成 28 并且自动提交,接下来启动事务二但没提交,紧接着事务三将 id = 1 的 age 字段更新成 30 并且自动提交,然后事务二再将 id = 1 的 age 字段更新成 29 依然没提交,再接着事务 A 启动,随后对事务二进行提交,然后事务四将 id = 1 的 age 字段更新成 31 并且自动提交,这时再回到事务 A 查询 id = 1 的那条数据,这时看到的 id = 1 的那条数据的 age 等于多少呢?
上面描述了这个场景的整个过程,我们用上面的规则来进行分析,其实主要就是分析事务 A 中的查询语句看到的 age 字段是多少。事务 A 启动时将当前库活跃事务的事务 id 放到一个数组里面,我们假设数据库目前只有这几个事务在操作数据库,那么事务 A 启动时处于活跃的事务就只有事务二是还没提交的,同时事务 A 启动时系统中已经创建的事务 id 的最大值是 103,高水位就是 104,那么事务 A 启动时对应的数组就是 [102, 104],低水位就是 102,然后在事务 A 中进行查询 id = 1 的那条记录,该记录目前最新版本 age 的最新值是 31,对应的事务 id 是 105,105 比高水位大,对应上面规则中的第二种情况,那么该数据版本不可见,然后根据 undo log 回滚到前一个版本,前一个版本是事务二进行更新的,事务二的事务 id 是 102,等于最低水位并且数组中包含该事务 id,对应着上面规则中的第三种情况里面的第一种,数据版本依然不可见,继续根据 undo log 回滚到前一个版本,前一个版本是事务三进行更新的,事务三的事务 id 是 103,大于最低水位小于最高水位,但数组中没有包含该事务 id,对应着上面规则中的第三种情况里面的第二种,数据版本可见,所以在事务 A 中看到的数据 age = 30。而其实如果还需要再回滚的话,那就回滚到了事务一更新的版本,事务一的事务 id 是 101,比最低水位小,对应着上面规则中的第一种情况,该数据版本也是可见的。
上面的场景实际执行结果也是一样的,实际操作如下所示:
上面开了五个终端,每个终端里面都和 MySQL 建立连接,各个连接中的 SQL 执行顺序对应着表格中从上到下的顺序,可以看到最后查询出来的值也是我们上面按照高低水位的判断规则分析出来的结果 age = 30。
上面其实更多的是从底层实现层面来分析得到这个结果,如果说每次事务间的数据可见性都按照这个去分析就有点太麻烦了,所以也就有了下面更容易理解的判断方式,首先以事务启动的那一刻为准,
1.该事物开启时,对于还没提交的事务更新都是不可见的
2.该事务开启时,对于已经提交的事务更新都是可见的
再回到上面表格中模拟的场景,事务 A 开启时,事务一和事务三是已经提交了的,并且事务三在事务一之后提交,事务二还没提交,事务四在事务 A 开启时还没创建,那么在事务 A 中的查询,看到的就是事务三的更新 age = 30,这样去分析是不是就方便多了。从上面的分析也可以看到,事务开启后,不管在什么时候查询,前后看到的数据都是一致的,因为数据版本的可见性只取决于事务启动的那一刻,这个就是所谓的一致性读。
可能你注意到了上面表格中事务 A 中的查询语句后面有两个划线了的 SQL 语句,一个是更新,一个是查询,假如我们把这两个 SQL 语句放开执行,那么后面的这个查询语句查询到的数据是多少?如果按照上面一致性读的说法,执行更新语句时看到的张三的年龄应该是 30,age = 30 + 1,那紧接着的查询语句查询出来的张三的年龄应该就是 31。如果是这样的话看起来好像不太对,因为事务四的更新看起来就丢失了,因为在事务 A 中进行更新前, 事务四已经将张三的年龄更新成 31 了。
事实上,我们真正去执行的时候,事务 A 中查询得到的值是 age = 32,也就是事务 A 里面是认事务四的那个更新的,当然也必须得认,不然就都乱套了,但这样就又和我们上面的一致性读说法好像不一致了,这里就需要引出另一个概念,对于查询是按照一致性读的原则,而对于更新则是按照当前读的原则,也就是说更新都是从最新版本的数据上开始更新的,所以这里就是直接从事务四对应的版本的数据上进行更新的,age = 31 + 1,紧接着的查询语句一看最新版本的数据是 32,对应的事务 id 是 104,和自己的事务 id 相等,这个版本是自己的更新,那也是要认的,因此查询出来的数据就是 age = 32,下面我单独将事务 A 加上更新语句之后的执行截图贴出来:
其实除了更新语句,对于加读锁或者写锁的查询也是当前读,我们可以简单的模拟一下这种场景,先在一个连接中开启事务,然后在另一个连接中更新一条数据,再回到第一个连接中加读锁或者写锁去查询刚才更新的那条数据。
上面截图中就是先在上面的窗口查询当前 id = 1 的那条记录,age = 35,然后开启事务,紧接着在下面的窗口将 id = 1 的记录 age 更新成 40,再回到上面的窗口中去查询,可以看到在加读锁或者写锁的时候都是当前读,查询到的是当前最新版本的数据,其中在查询语句后面加上 lock in share mode,表示加读锁,查询语句后面加上 for update,表示加写锁,读读不冲突,读写和写写是冲突的,这个后面提到锁的时候再具体细说。
上面我们就已经描述了在可重复读的隔离级别下,事务开启时会创建一个一致性视图,接下来的数据查询都依赖于事务开启时创建的一致性视图,而如果是读提交的隔离级别,不妨先来实际操作下,先将事务的隔离级别修改成读提交:
1 | set global transaction isolation level read committed; |
注意执行完上面的 SQL 之后将连接断开,重新连接之后才会生效,这个操作最好不要在生产库上做测试。
修改好了之后,同样用刚才的方式做测试,在一个连接里面开启事务,另一个连接里面更新,再回到第一个连接里面去查询,结果如下:
从结果可以看到,第一个窗口中查到的是已经提交的最新版本的值,这个其实和可重复读的隔离级别的区别就是,读提交是在每个 SQL 执行前都会重新创建一个一致性读视图,而可重复读则是在事务正式开启时创建,所以读提交的隔离级别下,每次都查询得到已经提交的最新版本的值。而对于读未提交的隔离级别那就是每次直接将当前数据的最新版本返回就可以了,也就没有视图的概念了。
上面我们可能多次提到一致性读视图,这里可能需要进行说明下,它和我们在查询时通过 create view 的方式创建的视图是不一样的,这里的一致性视图是静态的,当前视图对应的数据是由字段上的当前值通过 undo log 回滚计算得到的,它是用来辅助实现可重复读和读提交的隔离级别。而通过 create view 的方式创建的视图则是利用查询语句定义的一张虚拟的表,调用的时候执行相应的查询语句来生成查询结果。
还有一点你可能也注意到了,我在上面的实践中开启事务都是用的 start transaciton with consistent snapshot; 语句,和我熟悉的 begin 或者 start transaction 好像有点不一样,这里我们来了解下他们之间的区别:
begin 或者 start transaction: 一致性读的视图不会马上创建,而是在执行 begin 或者 start transaction 后面的第一个 SQL 语句时生成,这个SQL可以是 select,update,delete,insert 其中的任意一种,事务 id 也是此时才被分配,当然这其实也是能理解,这样做可以最大程度的支持事务之间并发。
start transaciton with consistent snapshot: 该语句执行后,会马上创建一个一致性读的视图,同时事务 id 也是立即被分配。
注意: 一致性读视图是基于整库的,在可重复读的隔离级别下,全库快照秒级实现,这正是 InnoDB 利用了“所有数据都有多个版本”的这个特性来实现的。