提到事务,你肯定不陌生,和数据库打交道的时候,我们总是会用到事务。简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。我们知道MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
隔离性与隔离级别
ACID
提到事务,我们一般都会想到ACID
-
Atomicity 原子性
-
Consistency 一致性
-
Isolation 隔离性
-
Durability 持久性
多个事务同时执行出现的问题
-
脏读(dirty read):当数据库中一个事务A正在修改一个数据但是还未提交或者回滚,另一个事务B 来读取了修改后的内容并且使用了, 之后事务A提交了,此时就引起了脏读。(仅出现在读未提交的隔离下)
-
不可重复读(non-repeatable read):在一个事务A中多次操作数据,在事务操作过程中(未最终提交),事务B也来做了处理,并且该值发生了改变,这时候就会导致A在事务操作的时候,发现数据与第一次不一样。就是不可重复读。(出现在读未提交以及读提交的隔离级别下)
-
幻读(phantom read):事务A首先根据条件索引得到N条数据,然后事务B改变了这N条数据之外的M条或者增添了M条符合事务A搜索条件的数据,导致事务A再次搜索发现有N+M条数据了,就产生了幻读。
-
不可重复读针对的是update或delete,幻读针对的是insert。
解决办法
为了解决上面出现的问题,就有了“隔离级别”的概念。在谈隔离级别之前,我们首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。
隔离级别
-
读未提交(read uncommitted):一个事务还没提交时,它做的变更就能被别的事务看到
-
读提交(read committed):一个事务提交之后,它做的变更才会被其他事务看到
-
可重复读(repeatable read):一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的
-
串行化(serializable):对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行
读提交与可重复读
我们来看下面这一个例子
mysql> create table T(c int) engine=InnoDB; insert into T(c) values(1);
我们假设数据表 T 中只有一列,其中一行的值为 1,下面的图是按照时间顺序执行两个事务的行为。
不同的隔离级别下,事务 A 会有不同的返回结果,也就是图里面 V1、V2、V3 的返回值。
-
若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
-
若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
-
若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
-
若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。
对于“读未提交”以及“串行化”我们都比较好理解,但是对于“读提交”以及“可重复读”就没那么容易理解。下面我们来简单的解释一下
视图
实际上实现事务的隔离,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准
-
在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。(这也就解释了为什么V2是1)
-
在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。
-
理解了视图,我们实际也就理解了上面的每一个值
注意:
-
“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;
-
“串行化”隔离级别下直接用加锁的方式来避免并行访问。
隔离级别的设置与查看
设置隔离级别
mysql> set session transaction isolation level xxxx xxxx (repeatable read.....);
查询隔离级别
mysql> show variables like 'tx_isolation'; +---------------+-----------------+ | Variable_name | Value | +---------------+-----------------+ | tx_isolation | REPEATABLE-READ | +---------------+-----------------+ 1 row in set, 1 warning (0.00 sec)
什么时候需要“可重复读”?
我们可以举个例子
-
假设我们在管理一个个人银行账户表。一个表存了账户余额,一个表存了账单明细。到了月底你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。
事务隔离的实现
MVC(多版本并发控制)
我们这里拿“可重复读”来理解事务隔离的实现
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
我们假设将一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似如图的记录。(这里可能会不好理解,注意这里记录的是回滚操作,也就是(将2改成1这些)不是第二篇文章说的redo log)。
虽然我们的当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的视图(read-view)。
如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是A:1、B:2、C:4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。
对于 视图A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 视图A、B、C 对应的事务是不会冲突的。
对于回滚日志我们可能会有以下几个问题
回滚日志什么时候删除?
答:在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。
什么时候才不需要了呢?
答:当系统里没有比这个回滚日志更早的视图的时候。
可以这样理解?当没有比这个视图更早的视图,也就是说如果我当前事务已经提交了,虽然当前事务不需要用到当前的视图,但是我现在不能删除回滚日志,因为我前面还有一个事务需要用到另一个视图。只有等到前面那个事务提交了,不需要用到视图了,那才能将回滚日志删除掉。
为什么建议你尽量不要使用长事务。
答:通过以上两个问题,我们或许知道为什么尽量不要使用长事务了。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。长事务除了对回滚段的影响,还占用锁资源,也可能拖垮整个库,这个我们后面文章讲锁的时候展开。
事务的启动方式
MySQL 的事务启动方式有以下几种
-
显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
-
set autocommit = 0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。
相关建议:总是使用 set autocommit=1, 通过显式语句的方式来启动事务
“多一次交互”的问题
-
对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。但是有可能导致长事务。
-
解决办法:使用 commit work and chain 语法。在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。
-
同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。
我们可以在information_schema 库的 innodb_trx这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
总结
多事务同时执行可能出现的问题:脏读、不可重复读、幻读
解决上面的问题:不同的事务隔离级别(读未提交、读提交、可重复读、串行化)
隔离的实现:通过视图来实现
隔离的启动以及建议:总是使用 set autocommit=1, 通过显式语句的方式来启动事务
问题
我们知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,会有什么方案来避免出现或者处理这种情况呢?
答案
首先,从应用开发端来看
-
确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
-
确认是否有不必要的只读事务。
-
业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)
其次,从数据库端
-
监控information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
-
Percona 的 pt-kill 这个工具不错,推荐使用;
-
在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
-
如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。