3.事务隔离:为什么你改了我还看不见
提到事务,大家肯定不陌生,事务相关最经典的一个例子就是银行转账问题:
比如,你的银行卡里有100块,现在你要转账给张三100块,而转账包括这样三个操作:查询余额、做减法、更新余额。
那么,在你转给张三100块时,在做减法之前,你又转给自己另一个账户100块,然后两个转账操作都成功了,我们没有花钱就转给了张三100块。
这就出现了不一致现象,和正常的结果不一致,银行肯定不会那么傻,所以,为了解决那样的问题,我们就要用到事务这个概念。
简单来说,事务就是保证一组数据库操作,要么全部成功,要么全部失败。
在MySQL中,事务支持是在引擎层实现的。
我们现在知道,引擎层支持多个引擎,而MyISAM引擎不支持事务,这也是它被InnoDB取代的原因。
下面的文章,会以InnoDB为例,来剖析事务支持方面的特定实现:
隔离性、隔离级别
提到事务,大家肯定先想到ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),本章就来讲讲其中的I,隔离性。
当数据库中有多个事务同时执行的时候,可能会出现脏读(读了未提交的数据,这个数据回滚就会出现问题)、不可重复读(事务中的两次读操作之间,数据被其他事务修改了)、幻读(两次读表之间,其他事务增加了表项)这些问题,为了解决这些问题,就有了“隔离级别”的概念。
隔离级别分为不同的等级,不同等级隔离的程度不同,下面的隔离级别依次提高:
- 读未提交:事务1可以读取还未提交的事务2变更的数据。这个相当于没有隔离,会造成脏读、不可重复读、幻读。
- 读已提交:只有事务2提交了,事务1才可以看到事务2变更的数据,并且读取变更后的数据;在事务2提交之前,只能读取到没有变更的数据。解决了脏读。相当于加了“写锁”。
- 可重复读:事务2执行过程中看到的数据,总是和启动时看到的数据时一致的,不会被更改(当然事务2未提交的变更,不会被事务1读取) ,解决了脏读、不可重复读。相当于加了“写锁”和“读锁”。
- 串行化,最高的隔离级别,使用“写锁”和“读锁”保证事务逻辑上是串行执行的,互不干扰。解决了脏读、不可重复读、幻读。
(加了写锁:数据不可以被读写;加了读锁:数据不可以被写)
下面来看一个例子,理解这几个隔离级别:
数据库表是这样的
mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);
此图来自于极客时间丁奇老师MySQL实战45讲
来看看不同隔离级别下,V1、V2、V3的值是多少:
-
读未提交:V1=2,因为事务A可以看到事务B还未提交的修改;V2=2;V3=2。
-
读提交:V1=1,因为事务B还未提交,所以不可以读取它的修改;V2=2;V3=2。
-
可重复读:V1=1;V2=1,因为事务A在执行期间,看到的数据前后必须是一致的;V3=2。
-
串行化:V1=1;V2=1;V3=2,在事务B执行到“将1修改为2”时,会被锁住,等事务A提交后才会继续执行。
事务隔离的实现
在实现上,数据库里会创建一个视图,访问的时候以视图的逻辑结果为准。
在“可重复读”隔离级别下,这个视图是在事务开始的时候创建的,整个事务期间都会使用这个视图。在“读提交”隔离级别下,视图是在每个sql语句开始执行的时候创建的。
“读未提交”隔离级别直接返回记录上的最新值,没有视图的概念;“串行化”隔离级别直接使用加锁的方式来避免并行访问。
可以看到,在不同隔离级别下,数据库有不同的行为,所以,在进行数据库迁移的时候,要保证数据库的隔离级别一致。
不同隔离级别有不同的应用场景
接下来我们讲一讲“可重复读”隔离级别的应用场景和实现:
应用场景:一个数据校对的案例
假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。
这时候,使用“可重复读”比较方便。事务启动时的视图可以认为是静态的,不受其他事务的影响。
那么下面来讲一下它的实现
“可重复读”事务隔离的实现
在MySQL中,每条记录在更新时都会同时记录一条回滚操作,记录上的最新值,通过这个回滚操作,可以得到前一个状态的值。
假设值1被按顺序改成了2,3,4,在回滚日志里面会有下面的记录:
此图来自于极客时间丁奇老师MySQL实战45讲
当前值是4,但在查询这条记录时,不同时刻启动的事务会有不同的read-view。在图中,视图A、B、C对应记录的值分别为1,2,4,每个视图其实就对应不同时刻启动的事务。
对于read-view A,它对应的值是1,可当前值是4,如果事务A想得到它对应视图A的值1,我们就必须执行所有的回滚操作得到值1。如果此时,还有另一个事务将值4变为5,这个事务和前面视图A、B、C对应的事务不会冲突。
所以,“可重复读”就是这样实现的,每个事务自己有一个视图,这个视图对应的值是不会改变的,当事务去读值,会回滚到它的视图所对应的值,保证了事务中每次读都和事务开始时的数据是一致的。
一条记录在系统中可以存在多个版本,这就是数据库的多版本并发控制(MVCC),这个到后面的章节再介绍。
那么,现在有一个问题:回滚日志不能一直存在吧,那什么时候删除呢?
答案:在不需要的时候删除,也就是没有事务再去使用这些回滚日志的时候,系统会去删除。
那什么时候才不需要了呢?
答案:当系统没有比这个回滚日志更早的read-view时。比如:事务A结束了,视图A可以删除了,所以将2改成1这条回滚日志可以删除。(这里如果没有理解不用怕,看看下面就理解了)
为什么尽量不要使用长事务?
在“可重复读”隔离级别的数据库中,每个事务都会有一个视图,如果这个事务是一个长事务的话,那么系统里会存在一个很老的事务视图。因为长事务随时都可能会读取任何数据,所以有必要保存长事务可能用到的所有回滚操作,这就导致占用大量的存储空间。
丁奇老师:在MySQL 5.5及以前的版本,回滚日志是跟数据字典一起放在ibdata文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有20GB,而回滚段有200GB的库。最终只好为了清理回滚段,重建整个库。
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库,这个会在后面讲锁的时候展开。
事务的启动方式
有些时候并不是我们有意使用长连接,而是我们的一些不好的操作就会导致出现了长连接。
事务启动的两种方式:
- 显示启动事务,不管autocommit为0还是1,开启事务使用begin或者start transaction,提交使用commit,回滚使用rollback。(当autocommit=1并且使用了begin启动事务,那么只有使用commit才会提交事务,每条语句不会被看作是一个事务)
- 隐示启动事务,set autocommit=0,这个命令会将这个线程的自动提交关闭,当执行到第一条语句的时候自动就开启了事务,只有执行commit或者rollback的时候事务才结束。
有些客户端连接框架在连接成功后会自动执行一条set autocommit=0语句,这就导致接下来的语句都在事务中,如果这个连接是一个长连接,那么就会意外导致了一个长事务。
因此,建议使用set autocommit=1,并通过显示语句的方式启动事务。如果嫌每次都要begin开启事务操作麻烦,那么可以使用commit work and chain,自动启动下一事务,并省去了执行begin语句的开销。
丁奇老师留的思考题:你现在知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,你会有什么方案来避免出现或者处理这种情况呢?
上期答案:一天一备和一周一备相比,好处就是‘“最长恢复时间”更短,一天一备最坏的情况需要一天的binlog日志,而一周一备最坏需要一周的binlog。但这种好处是用存储空间换来的,所以使用哪个就根据具体需求来了。