MySQL InnoDB存储引擎事务的ACID特性

一、前言

相信工做了一段时间的同窗确定都用过事务,也都据说过事务的4大特性ACID。ACID表示原子性、一致性、隔离性和持久性。一个很好的事务处理系统,必须具有这些标准特性:html

  • 原子性(Atomicity):一个事务必须被视为一个不可分割的最小工做单元,整个事务中的全部操做要么所有提交成功,要么所有失败回滚。
  • 一致性(consistency):数据库老是从一个一致性的状态转换到另外一个一致性的状态。(其实原子性和隔离性间接的保证了一致性)
  • 隔离性(isolation):一般来讲,一个事务所作的修改在最终提交之前,对其余事务是不可见的。
  • 持久性(durability):一旦事务提交,则其所作的修改就会永久保存到数据库中。

而咱们最常说的隔离性其实有对应的隔离级别,MySQL规定的隔离级别有4种,分别是:mysql

  • READ UNCOMMITTED(读未提交):在此级别里,事务的修改,即便没有提交,对其余事务也都是可见的。事务能够读取未提交的数据,也就是会产生脏读,在实际应用中通常不多使用。
  • READ COMMITTED(读已提交):大多数数据库系统的默认隔离级别都是它,可是MySQL不是。它可以避免脏读问题,可是在一个事务里对同一条数据的屡次查询可能会获得不一样的结果,也就是会产生不可重复读问题。
  • REPEATABLE READ(可重复读):该隔离级别是MySQL默认的隔离级别,看名字就知道它可以防止不可重复读问题,可是在一个事务里对一段数据的屡次读取可能会致使不一样的结果,也就是会有幻读的问题(注:这里说的没法解决是MySQL定义层面,对于InnoDB引擎则完美的解决了幻读的问题,若是你正在使用InnoDB引擎,可忽略)
  • SERIALIZABLE(可串行化):该隔离级别是级别最高的,它经过锁来强制事务串行执行,避免了前面说的全部问题。在高并发下,可能致使大量的超时和锁争用问题。实际应用中也不多用到这个隔离级别,由于RR级别解决了全部问题。

能够看到隔离级别里最重要的只有两个隔离级别:RC和RR。那么问题来了,咱们知道上面说的ACID以及隔离级别的实现原理吗?不管是平时工做仍是面试,这部分的问题都重中之重,接下来,我会抛出几个问题,你们能够带着问题来看此文:面试

ACID问题:sql

  • 为何InnoDB可以保证原子性?用的什么方式?
  • 为何InnoDB可以保证一致性?用的什么方式?
  • 为何InnoDB可以保证持久性?用的什么方式?

隔离性里隔离级别的问题:数据库

  • 为何RU级别会发生脏读,而其余的隔离级别可以避免?
  • 为何RC级别不能重复读,而RR级别可以避免?
  • 为何InnoDB的RR级别可以防止幻读?

解决这些问题以前,咱们要首先知道Redo log、Undo log以及MVCC都是什么。设计模式

二、Redo log

redo log(重作日志)用来实现事务的持久性,即事务ACID中的D。其由两部分组成,一是内存中的重作日志缓冲(redo log buffer),其实易失的。二是重作日志文件(redo log file),其是持久的。缓存

在一个事务中的每一次SQL操做以后都会写入一个redo log到buffer中,在最后COMMIT的时候,必须先将该事务的全部日志写入到redo log file进行持久化(这里的写入是顺序写的),待事务的COMMIT操做完成才算完成。数据结构

MySQL-Lock8

因为重作日志文件打开没有使用O_DIRECT选项,所以重作日志缓冲先写入文件系统缓存。为了确保重作日志写入磁盘,必须进行一次fsync操做。因为fsync的效率取决于磁盘的性能,所以磁盘的性能决定了事务提交的性能,也就是数据库的性能。由此咱们能够得出在进行批量操做的时候,不要for循环里面嵌套事务。并发

参数 innodb_flush_log_at_trx_commit 用来控制重作日志刷新到磁盘的策略,该参数有3个值:0、1和2。异步

  • 0:表示事务提交时不进行写redo log file的操做,这个操做仅在master thread中完成(master thread每隔1秒进行一次fsync操做)。
  • 1:默认值,表示每次事务提交时进行写redo log file的操做。
  • 2:表示事务提交时将redo log写入文件,不过仅写入文件系统的缓存中,不进行fsync操做。

咱们能够看到0和2的设置都比1的效率要高,可是破坏了数据库的ACID特性,不建议使用!

对比binlog

在MySQL数据库中还有一种二进制日志(binlog),从表面上来看它和redo log很类似,都是记录了对数据库操做的日志,可是,它们有着很是大的不一样。

首先,redo log是在MySQL的InnoDB引擎层产生,而binlog则是在MySQL的上层产生,它不只针对InnoDB引擎,其余任何引擎对于数据库的更改都会产生binlog。

其次,两种日志记录的内容形式不一样,binlog是一种逻辑日志,其记录的是对应的SQL语句。而redo log则是记录的物理格式日志,其记录的是对于每一个页的修改。

此外,两种日志记录写入磁盘的时间点不一样,binlog只在事务提交完成后一次性写入,而redo log在上面也说了是在事务进行中不断被写入,这表现为日志并非随事务提交的顺序进行写入的。

MySQL-Lock9

redo log block

在InnoDB引擎中,redo log都是以512字节进行存储的(和磁盘扇区的大小同样,所以redo log写入能够保证原子性,不须要double write),也就是重作日志缓存和文件都是以块的方式进行保存的,称为redo log block,每一个block占512字节。

重作日志除了日志自己以外,还由日志块头(log block header)及日志块尾(log block tailer)两部分组成。

MySQL-Lock10

下面我来解释一下组成Log Block header的4个部分各自的含义:

  • LOG_BLOCK_HDR_NO:它主要用来标记所处Redo Log Buffer中Log Block的位置。
  • LOG_BLOCK_HDR_DATA_LEN:它表示Log Block所占用的大小。当Log Block被写满时,该值为0x200,表示使用所有Log Block空间,即占用512字节。
  • LOG_BLOCK_FIRST_REC_GROUP:表示Log Block中第一个日志所在的偏移量,若是该值大小和LOG_BLOCK_HDR_DATA_LEN相同,则表示当前Log Block不包含新的日志,若是事务的日志大小超过一个Log Block的大小,剩余的将会接着保存到一个新的Log Block中。
  • LOG_BLOCK_CHECKPOINT_NO:表示该Log Block最后被写入时的检查点第4字节的值。

Log Block tailer只包含一个LOG_BLOCK_TRL_NO,它的值和LOG_BLOCK_HDR_NO相同,并在函数log_block_init中被初始化。

crash recovery

前面提到了redo log是用来实现ACID的持久性的,也就是只要事务提交成功后,事务内的全部修改都会保存到数据库,哪怕这时候数据库crash了,也要有办法来进行恢复。也就是Crash Recovery。

说到恢复,咱们先来了解一个概念:什么是LSN

LSN(log sequence number) 用于记录日志序号,它是一个不断递增的 unsigned long long 类型整数,占用8字节。它表明的含义有:

  • redo log写入的总量。
  • checkpoint的位置。
  • 页的版本,用来判断是否须要进行恢复操做。

checkpoint:它是redo log中的一个检查点,这个点以前的全部数据都已经刷新回磁盘,当DB crash后,经过对checkpoint以后的redo log进行恢复就能够了。

咱们能够经过命令show engine innodb status来观察LSN的状况:

---
LOG
---
Log sequence number 33646077360
Log flushed up to   33646077360
Last checkpoint at  33646077360
0 pending log writes, 0 pending chkp writes
49687445 log i/o's done, 1.25 log i/o's/second

  

Log sequence number表示当前的LSN,Log flushed up to表示刷新到redo log文件的LSN,Last checkpoint at表示刷新到磁盘的LSN。若是把它们三个简写为 A、B、C 的话,它们的值的大小确定为 A>=B>=C

InnoDB引擎在启动时无论上次数据库运行时是否正常关闭,都会进行恢复操做。由于重作日志记录的是物理日志,所以恢复的速度比逻辑日志,如二进制日志要快不少。恢复的时候只须要找到redo log的checkpoint进行恢复便可。

MySQL-Lock11

三、Undo log

重作日志记录了事务的行为,能够很好的经过其对页进行“重作”操做。可是事务有时候还须要进行回滚操做,也就是ACID中的A(原子性),这时就须要Undo log了。所以在数据库进行修改时,InnoDB存储引擎不但会产生Redo,还会产生必定量的Undo。这样若是用户执行的事务或语句因为某种缘由失败了,又或者用户一条ROLLBACK语句请求回滚,就能够利用这些Undo信息将数据库回滚到修改以前的样子。

Undo log是InnoDB MVCC事务特性的重要组成部分。当咱们对记录作了变动操做时就会产生Undo记录,Undo记录默认被记录到系统表空间(ibdata)中,但从5.6开始,也可使用独立的Undo 表空间。

Undo记录中存储的是老版本数据,当一个旧的事务须要读取数据时,为了能读取到老版本的数据,须要顺着undo链找到知足其可见性的记录。当版本链很长时,一般能够认为这是个比较耗时的操做。

基本文件结构

为了保证事务并发操做时,在写各自的undo log时不产生冲突,InnoDB采用回滚段(Rollback Segment,简称Rseg)的方式来维护undo log的并发写入和持久化。回滚段其实是一种 Undo 文件组织方式,每一个回滚段又有多个undo log slot。具体的文件组织方式以下图所示:

MySQL-Lock12

上图展现了基本的Undo回滚段布局结构,其中:

  • rseg0预留在系统表空间ibdata中。
  • rseg 1~rseg 32 这32个回滚段存放于临时表的系统表空间中,用于临时表的undo。
  • rseg33~rseg 128 则根据配置(InnoDB >= 1.1默认128,可经过参数 innodb_undo_logs 设置)存放到独立undo表空间中(若是没有打开独立Undo表空间,则存放于ibdata中,独立表空间能够经过参数 innodb_undo_directory 设置),用于普通事务的undo。

如图所示,每一个回滚段维护了一个段头页,在该page中又划分了1024个slot(TRX_RSEG_N_SLOTS),每一个slot又对应到一个undo log对象,所以理论上InnoDB最多支持 96 * 1024个普通事务。

Undo log的格式

在InnoDB引擎中,undo log分为:

  • insert undo log
  • update undo log

insert undo log是指在insert操做中产生的undo log,由于insert操做的记录,只对事务自己可见,对其余事务不可见(这是事务隔离性的要求),故该undo log能够在事务提交后直接删除,不须要进行purge操做。而update undo log记录的是delete和update操做产生的undo log。该undo log可能须要提供MVCC机制,所以不能在事务提交时就进行删除,提交时放入undo log链表,等待purge线程进行最后的删除。下面是两种undo log的结构图。

MySQL-Lock13

purge

对于一条delete语句 delete from t where a = 1,若是列a有汇集索引,则不会进行真正的删除,而只是在主键列等于1的记录delete flag设置为1,即记录仍是存在在B+树中。而对于update操做,不是直接对记录进行更新,而是标识旧记录为删除状态,而后新产生一条记录。那这些旧版本标识位删除的记录什么时候真正的删除?怎么删除?

其实InnoDB是经过undo日志来进行旧版本的删除操做的,在InnoDB内部,这个操做被称之为purge操做,原来在srv_master_thread主线程中完成,后来进行优化,开辟了purge线程进行purge操做,而且能够设置purge线程的数量。purge操做每10s进行一次。

为了节省存储空间,InnoDB存储引擎的undo log设计是这样的:一个页上容许多个事务的undo log存在。虽然这不表明事务在全局过程当中提交的顺序,可是后面的事务产生的undo log总在最后。此外,InnoDB存储引擎还有一个history列表,它根据事务提交的顺序,将undo log进行链接,以下面的一种状况:

MySQL-Lock14

在执行purge过程当中,InnoDB存储引擎首先从history list中找到第一个须要被清理的记录,这里为trx1,清理以后InnoDB存储引擎会在trx1所在的Undo page中继续寻找是否存在能够被清理的记录,这里会找到事务trx3,接着找到trx5,可是发现trx5被其余事务所引用而不能清理,故再去history list中取查找,发现最尾端的记录时trx2,接着找到trx2所在的Undo page,依次把trx六、trx4清理,因为Undo page2中全部的记录都被清理了,所以该Undo page能够进行重用。

InnoDB存储引擎这种先从history list中找undo log,而后再从Undo page中找undo log的设计模式是为了不大量随机读操做,从而提升purge的效率。

四、多版本控制MVCC

MVCC 多版本并发控制技术,用于多事务环境下,对数据读写在不加读写锁的状况下实现互不干扰,从而实现数据库的隔离性,在事务隔离级别为Read Commit 和 Repeatable Read中使用到,今天咱们就用最简单的方式,来分析下MVCC具体的原理,先解释几个概念。

InnoDB存储引擎的行结构

InnoDB表数据的组织方式为主键聚簇索引,二级索引中采用的是(索引键值, 主键键值)的组合来惟一肯定一条记录。

InnoDB表数据为主键聚簇索引,mysql默认为每一个索引行添加了4个隐藏的字段,分别是:

  • DB_ROW_ID:InnoDB引擎中一个表只能有一个主键,用于聚簇索引,若是表没有定义主键会选择第一个非Null的惟一索引做为主键,若是尚未,生成一个隐藏的DB_ROW_ID做为主键构造聚簇索引。
  • DB_TRX_ID:最近更改该行数据的事务ID。
  • DB_ROLL_PTR:undo log的指针,用于记录以前历史数据在undo log中的位置。
  • DELETE BIT:索引删除标志,若是DB删除了一条数据,是优先通知索引将该标志位设置为1,而后经过(purge)清除线程去异步删除真实的数据。

MySQL-Lock15

整个MVCC的机制都是经过DB_TRX_ID,DB_ROLL_PTR这2个隐藏字段来实现的。

事务链表

当一个事务开始的时候,会将当前数据库中正在活跃的全部事务(执行begin,可是尚未commit的事务)保存到一个叫trx_sys的事务链表中,事务链表中保存的都是未提交的事务,当事务提交以后会从其中删除。

MySQL-Lock16

ReadView

有了前面隐藏列和事务链表的基础,接下去就能够构造MySQL实现MVCC的关键——ReadView。

ReadView说白了就是一个数据结构,在事务开始的时候会根据上面的事务链表构造一个ReadView,初始化方法以下:

// readview 初始化
// m_low_limit_id = trx_sys->max_trx_id; 
// m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;
ReadView::ReadView()
    :
    m_low_limit_id(),
    m_up_limit_id(),
    m_creator_trx_id(),
    m_ids(),
    m_low_limit_no()
{
    ut_d(::memset(&m_view_list, 0x0, sizeof(m_view_list)));
}

  

总共作了如下几件事:

  1. 活跃事务链表(trx_sys)中事务id最大的值被赋值给m_low_limit_id
  2. 活跃事务链表中第一个值(也就是事务id最小)被赋值给m_up_limit_id
  3. m_ids 为事务链表。

MySQL-Lock17

经过该ReadView,新的事务能够根据查询到的全部活跃事务记录的事务ID来匹配可以看见该记录,从而实现数据库的事务隔离,主要逻辑以下:

  1. 经过聚簇索引的行结构中DB_TRX_ID隐藏字段能够知道最近被哪一个事务ID修改过。
  2. 一个新的事务开始时会根据事务链表构造一个ReadView。
  3. 当前事务根据ReadView中的数据去跟检索到的每一条数据去校验,看看当前事务是否是能看到这条数据。

那么问题来了,怎么来判断可见性呢?咱们来经过源码一探究竟:

// 判断数据对应的聚簇索引中的事务id在这个readview中是否可见
bool changes_visible(
        trx_id_t        id, // 记录的id
    const table_name_t& name) const
MY_ATTRIBUTE((warn_unused_result))
{
    ut_ad(id > 0);
    // 若是当前记录id < 事务链表的最小值或者等于建立该readview的id就是它本身,那么是可见的
    if (id < m_up_limit_id || id == m_creator_trx_id) {
        return(true);
    }

    check_trx_id_sanity(id, name);
    // 若是该记录的事务id大于事务链表中的最大值,那么不可见
    if (id >= m_low_limit_id) {
        return(false);
        // 若是事务链表是空的,那也是可见的
    } else if (m_ids.empty()) {
        return(true);
    }

    const ids_t::value_type*    p = m_ids.data();

    //判断是否在ReadView中,若是在说明在建立ReadView时 此条记录还处于活跃状态则不该该查询到,不然说明建立ReadView是此条记录已是不活跃状态则能够查询到
    return(!std::binary_search(p, p + m_ids.size(), id));
}

 

总结一下可见性判断逻辑:

  1. 当检索到的数据的事务ID小于事务链表中的最小值(数据行的DB_TRX_ID < m_up_limit_id)表示这个数据在当前事务开启前就已经被其余事务修改过了,因此是可见的。
  2. 当检索到的数据的事务ID表示的是当前事务本身修改的数据(数据行的DB_TRX_ID = m_creator_trx_id) 时,数据可见。
  3. 当检索到的数据的事务ID大于事务链表中的最大值(数据行的DB_TRX_ID >= m_low_limit_id) 表示这个数据在当前事务开启后到下一次查询之间又被其余的事务修改过,那么就是不可见的。
  4. 若是事务链表为空,那么也是可见的,也就是当前事务开始的时候,没有其余任意一个事务在执行。
  5. 当检索到的数据的事务ID在事务链表中的最小值和最大值之间,从m_low_limit_id到m_up_limit_id进行遍历,取出DB_ROLL_PTR指针所指向的回滚段的事务ID,把它赋值给 trx_id_current ,而后从步骤1从新开始判断,这样总能最后找到一个可用的记录。

RC和RR隔离级别ReadView的实现方式

咱们知道,RC隔离级别是能看到其余事务提交后的修改记录的,也就是不可重复读,可是RR隔离级别完美的避免了,可是它们都是使用的MVCC机制,那又为什么有两种大相径庭的结果呢?其实咱们看一下他们建立ReadView的区别就知道了。

  • 在RC事务隔离级别下,每次语句执行都关闭ReadView,而后从新建立一份ReadView。
  • 在RR下,事务开始后第一个读操做建立ReadView,一直到事务结束关闭。

上面的总结英文版为:With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTEDisolation level, the snapshot is reset to the time of each consistent read operation.

来源自MySQL官网:MySQL Glossary-glos_consistent_read

由于RC每次查询语句都建立一个新的ReadView,因此活跃的事务列表一直在变,也就致使若是事务B update提交了后事务A才进行查询,查询的结果就是最新的行,也就是不可重复读咯。而RR则一直用的事务开始时建立的ReadView。

五、总结

还记得开头提到的问题吗?如今应该可以所有解决了。

为何InnoDB可以保证原子性A?用的什么方式?

其实这个在上面Undo log中已经说起了。在事务里任何对数据的修改都会写一个Undo log,而后进行数据的修改,若是出现错误或者用户须要回滚的时候能够利用Undo log的备份数据恢复到事务开始以前的状态。

为何InnoDB可以保证持久性?用的什么方式?

这个在上面Redo log中已经说起了。在一个事务中的每一次SQL操做以后都会写入一个redo log到buffer中,在最后COMMIT的时候,必须先将该事务的全部日志写入到redo log file进行持久化(这里的写入是顺序写的),待事务的COMMIT操做完成才算完成。即便COMMIT后数据库有任何的问题,在下次重启后依然可以经过redo log的checkpoint进行恢复。也就是上面提到的crash recovery。

为何InnoDB可以保证一致性?用的什么方式?

在事务处理的ACID属性中,一致性是最基本的属性,其它的三个属性都为了保证一致性而存在的。

首先回顾一下一致性的定义。所谓一致性,指的是数据处于一种有意义的状态,这种状态是语义上的而不是语法上的。最多见的例子是转账。例如从账户A转一笔钱到账户B上,若是账户A上的钱减小了,而账户B上的钱却没有增长,那么咱们认为此时数据处于不一致的状态。

在数据库实现的场景中,一致性能够分为数据库外部的一致性和数据库内部的一致性。前者由外部应用的编码来保证,即某个应用在执行转账的数据库操做时,必须在同一个事务内部调用对账户A和账户B的操做。若是在这个层次出现错误,这不是数据库自己可以解决的,也不属于咱们须要讨论的范围。后者由数据库来保证,即在同一个事务内部的一组操做必须所有执行成功(或者所有失败)。这就是事务处理的原子性。(上面说过了是用Undo log来保证的)

可是,原子性并不能彻底保证一致性。在多个事务并行进行的状况下,即便保证了每个事务的原子性,仍然可能致使数据不一致的结果,好比丢失更新问题。

为了保证并发状况下的一致性,引入了隔离性,即保证每个事务可以看到的数据老是一致的,就好象其它并发事务并不存在同样。用术语来讲,就是多个事务并发执行后的状态,和它们串行执行后的状态是等价的。

为何RU级别会发生脏读,而其余的隔离级别可以避免?

RU级别的操做其实就是对事务内的每一条更新语句对应的行记录加上读写锁来操做,而不把一个事务当成一个总体来加锁,因此会致使脏读。可是RC和RR可以经过MVCC来保证记录只有在最后COMMIT后才会让别的事务看到。

为何RC级别不能重复读,而RR级别可以避免?

这个在上面的MVCC的最后说到了,在RC事务隔离级别下,每次语句执行都关闭ReadView,而后从新建立一份ReadView。而在RR下,事务开始后第一个读操做建立ReadView,一直到事务结束关闭。

为何InnoDB的RR级别可以防止幻读?

这个是由于RR隔离级别使用了Next-key Lock这么个东东,也就是Gap Lock+Record Lock的方式来进行间隙锁定,具体原理本章不深刻讨论,能够参考个人另外一篇文章。

相关文章
相关标签/搜索