前言

近期mysql事务使用过程中遇到过一些问题,本文是对这些问题及进而探究出来的一些知识点的梳理总结。

基本概念

首先阐述几个相关概念

事务特性 ACID

  1. Atomicity(原子性)。
    一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,不会结束在中间某个环节。事务在执行过程中发生错误,会被回滚到事务开始前的状态,就像这个事务从来没有执行过一样。
  2. Consistenty(一致性)。
    事务在开始之前和结束之后,数据库的完整性不会被破坏。这表示写入的数据必须完全符合预设的约束、触发器、级联回滚等。数据库总是会从一个一致性状态转换到另一个一致性状态。
  3. Isolation(隔离性)。
    数据库允许多个并发事务同时对其进行读写操作,可以防止多个事务并发执行时由于交叉执行而导致的数据不一致。具体分为读未提交、读提交、可重复读、串行化四个级别。
  4. Durability(持久性)。
    事务处理结束后,对数据的修改就是永久的,即使发生故障也不会丢失。

binlog的三种模式

  1. Row模式。
    会记录每行数据的修改,然后在slave端再对相同的数据执行相同的修改。

  2. Statement模式。
    记录相应的sql语句,然后再slave端执行相同的sql语句。

  3. Mixed模式。
    系统根据具体执行的每一个操作来选择Row或者Statement模式中的一种。

脏读、幻读、不可重复读

  1. 脏读。
    一个事务读到其他事务已经修改但尚未提交的内容。
  2. 幻读。
    同一事务中,前后两次完全相同的查询语句得到了不同的结果集(其他事务新插入了记录)。幻读仅发生在”当前读”,且仅专指”新插入的行”。
  3. 不可重复读。
    同一事务中,对同一份数据两次读到不同的值。

事务的四种隔离级别

  1. Read Uncommitted(读未提交)。
    最低的隔离级别,会出现”脏读”,事务可以看到其他事务尚未提交的内容。生产环境不会使用。
  2. Read Committed(读提交)。
    一个事务只能看见其他已提交事务的修改,会出现”不可重复读“现象。对选定对象的写锁一直保持到事务结束,但是读锁在select结束后马上释放。多配合binlog的row模式使用
  3. Repeatable Read(可重复读)。
    基于锁的并发控制对选定的对象的读锁和写锁都持续到事务结束。一个事务在执行过程中看到的数据,总是跟这个事务在启动时看到的是一致的。默认隔离级别。
  4. Serializable(串行化)。
    最高隔离级别,通过强制事务排序,使之不可能出现相互冲突,牺牲了系统的并发性。生产环境不会使用。

事务隔离(可重复读)是如何实现的

mysql常用存储引擎为InnoDB。InnoDB的默认隔离级别是可重复读(RR),具体实现为通过多版本并发控制(Multi Version Concurrency Control)来实现”可重复读”,通过间隙锁(Gap Lock)和Next-Key Lock来消除“幻读”。

MVCC

所谓的多版本即同一行数据可能存在多个”快照”。在RR级别下,事务启动时会对整个数据库”拍一个快照”。这个”快照”并非将整个数据库都拷贝一份。InnoDB里每个事务都有一个唯一的事务ID,即transaction id,它是事务开始时向InnoDB的事务系统申请的,且是严格递增的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把事务的transaction id 赋值给这个数据版本的事务ID,记为 row trx_id。同时旧的数据版本也要保留。基于这个机制,每个事务启动的时候申明:“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,我就认;如果是在我启动之后生成的,我就不认,我要找到它在我启动之前的版本”。当然,事务自身修改生成的新版本还是要认的

在继续之前先解释下这个”查找之前版本”的具体实现:数据的旧版本并非都是物理真实存在的,而是根据当前版本和undo log(回滚日志)计算出来的。一起来看一个场景:

时刻\事务 Session A Session B
T1 start transaction with consistent snapshot;
T2 update t set c=c+1 where id=5;
//执行100000次
T3 select * from t where id=5 lock in share mode;
T4 select * from t where id=5;

在T2时刻事务B执行了10万次修改,导致id为5的这行数据生成了10万个undo log(回滚日志)。而T4时刻Session A要读取id为5这一行的数据时就需要基于id未5这行数据的最新版本执行10万次计算来得到一个”认”的版本,显然,这个执行过程是会非常耗时的,这种读取方式成为”快照读”。而T3时刻的语句则会执行的非常快,原因是”lock in share mode”表示这次读取为”当前读”,即直接读取数据的当前版本(for update也是当前读)。

在事务启动时,InnoDB为每个事务创建了一个事务数组,用来存储当前时刻所有”活跃”的事务ID,其中“活跃”指的是已经创建但还没有提交。我们将这个数据中的最小事务ID称为”低水位”,将当前系统中已经创建过的事务中最大的事务ID称为”高水位”。这个数组和”高水位”就组成了事务的“一致性视图”,而数据版本的可见规则都是基于数据的row trx_id和这个”一致性视图”对比得到的:

  1. 如果数据的row trx_id小于”低水位”或者这个版本是自己生成的,就可见;
  2. 如果数据的row trx_id大于”高水位”,那这个版本是由本事务启动之后创建的事务生成的,不可见;
  3. 如果数据的row trx_id介于”低水位”和”高水位”之间,则又分为两种情况:
    a. 若row trx_id在数组中,则表示这个版本的数据是由还未提交的事务生成的,不可见;
    b. 若row trx_id不在数组中,则表示这个版本的数据是由已经提交了的事务生成的,可见;

基于上述这些,MVCC实现了”秒级创建整库快照”的功能。

消除幻读

在RR级别下,普通读都是”快照读”,不会看到其他事务新插入的行,因此,”幻读”仅发生在”当前读”。

时刻\事务 Session A Session B
T1 select * from t where d=5 for update;
result(5,5,5)
/update t set c=100 where d=5;/
T2 insert into t values(1,1,5);
/update t set c=5 where id=1;/
T3 select * from t where d=5 for update;
result(1,1,5),(5,5,5)
T4 commit;

那么幻读有什么危害呢?

  1. 首先是语义上的。T1时刻Session A的语句语义上是要锁住所有d=5的行,而由于(1,1,5)这行数据是T2时刻才插入的,因此T2时刻Session B在插入以后紧跟着执行update t set c=5 where id=1是可以执行成功的。这样就破坏了T1时刻事务要锁住所有d=5的行的加锁声明。
  2. 其次是会破坏数据的一致性。由于binlog默认是采用statement模式,会将事务的所有操作以sql语句的形式记录,然后再slave端执行相同的sql语句,且是在事务提交以后才将事务的所有语句一次性写入(防止crash导致日志不完整)。因此若在T1时刻Session A查询后执行了update t set c=100 where d=5只会修改id=5的这一行,但由于Session B没有显示开启事务,在执行结束后就会自动提交而Session A在T4时刻才提交,所以binlog中是Session B的语句在前Session A的语句在后,在slave端根据主库的binlog执行同步的结果是id=1和id=5的行c值都会被修改为100,导致主从库的数据不一致。

因此InnoDB加入了间隙锁(Gap Lock),顾名思义,就是锁住两个值之间的间隙,不允许在这个间隙内插入新数据。关于间隙锁的几个重点:

  1. 间隙锁彼此不会冲,即两个事务可以并发对同一间隙加间隙锁,都会成功;
  2. 间隙锁和行锁合称为Next-Key Lock,每个Next-Key Lock是一个前开后闭的区间;
  3. 索引上的等值查询:
    a. 如果索引是唯一索引,则Next-Key Lock会退化为行锁;
    b. 如果是普通索引,向右遍历到的最后一个值不满足等值条件的话,Next-Key Lock会退化为Gap Lock;
  4. 唯一索引上的范围查询,会访问到第一个不满足条件的值为止;
  5. 只有访问到的对象才会加锁,如果是覆盖索引,因为不需要再回表访问主键索引,因此不会在主键索引上加间隙锁,此时其他并发的事务如果根据主键id执行修改不会block住。这里要提一个lock in share mode和for update的区别:同样是覆盖索引,lock in share mode只会锁住覆盖索引,而for update认为接下来的语句要update,会顺便给主键索引加上锁。

通过引入间隙锁和Next-Key Lock,InnoDB解决了”幻读”的问题,但同时导致相同的语句锁住了更大的范围,降低了系统的并发度,举个简单的例子:

时刻\事务 Session A Session B
T1 begin;
select * from t where id=5 for update;
T2 begin;
select * from t where id=5 for update;
T3 insert into t values(5,5,5);
(blocked)
T4 insert into t values(5,5,5);
(ERROR, Dead lock found)

由于之前id=5的行并不存在,因此T1和T2时刻Session A和Session B都会加一个相同范围的间隙锁(假设为3-7),在T3时刻Session B的插入动作被Session A的间隙锁挡住了,进入等待,T4时刻Session A的插入会被Session B的间隙锁挡住,至此两个Session进入彼此等待状态,形成死锁。

此处再补充事务使用的两个建议:

  1. 事务要尽量的短;如非必要,尽量不要将事务拉的过长,因为事务越长,生成的间隙锁就会越多,造成死锁的概率就越大。
  2. 如果事务中要锁多个行,要把最可能造成锁冲突、最可能降低并发度的锁尽量往后放;举个例子:从仓库发料到供应商的现编仓,在同一个事务中要完成发料仓库和供应商现编仓两个仓库的库存变更,但多个供应商是从同一供料仓发料,显然对供料仓库存的修改更可能造成锁冲突,因此应当先执行现编仓的库存修改再执行供料仓的库存修改。