目录
MySQL的逻辑架构
后台线程
缓冲池
InnoDB逻辑存储结构
Redo Log
Undo Log
InnoDB事务特性
Atomicity(原子性)
Consistency(一致性)
Isolation(隔离性)
Durability(持久性)
事务特性实现原理
原子性实现原理
持久性实现原理
隔离性实现原理
行锁与表锁
并发事务读操作可能存在的三类问题
事务隔离级别
MVCC
ReadView
一致性实现原理
由于InnoDB在MySQL数据库中占有十分重要的地位,并且其实现事务的过程也十分复杂,涉及的面比较广,如果没有一定的知识背景很难理解它的设计思路。因此要想理清InnoDB事务实现的原理,我们很有必要了解一下MySQL的相关知识。
从图1可以看出MySQL有以下几部分组成
连接池组件
管理服务和工具组件
SQL接口组件
查询分析器组件
优化器组件
缓冲组件
插件式组件
物理文件
MySQL数据库区别于其他数据库的最重要的一个特点就是它的插件式存储引擎。MySQL插件式的存储引擎提供了一系列标准的管理和服务支持,这些标准与存储引擎本身无关。需要特别注意的是,存储引擎是基于表的,而不是数据库。
InnoDB存储引擎支持事务,其设计目标主要面向在线事务处理(OLTP)的应用。它的特点是行锁设计,支持外键,并支持非锁定读,即默认读取操作不会产生锁。从MySQL数据库5.5.8版本开始,InnoDB存储引擎是MySQL默认的存储引擎。
InnoDB存储引擎将数据放在一个逻辑的表空间中,这个表空间由InnoDB存储引擎自身进行管理。它可以将每个InnoDB存储引擎的表单独存放到一个独立的ibd文件中,因此它也支持裸设备用来建立它的表空间。
InnoDB通过使用多版本并发控制(MVCC)来获得高并发性,并且实现了SQL标准的4种隔离级别,默认是Repeatable Read级别。同时,使用一种被称为next-key locking的策略来避免幻读(phantom)现象的产生。除此之外,InnoDB存储引擎还提供了插入缓存(insert buffer),二次写(double wrote),自适应哈希索引(adaptive hash index),预读(read ahead)等高性能高可用的功能。
对于表中数据的存储,InnoDB存储引擎采用了聚集(clustered)的方式,因此每张表的存储引擎会为每一行生成6字节的ROWID,并以此作为主键。
InnoDB体系架构
从图2中可以简单看出InnoDB存储引擎的体系结构,InnoDB有多个内存块,可以认为这些内存块组成了一个内存池,负责如下工作:
维护所有进程/线程需要访问的多个内部数据结构。
缓存磁盘上的数据,方便快速地读取,同时在对磁盘文件的数据修改之前在这里缓存。
重做日志(redo log)缓冲
... ...
后台线程的主要作用是负责刷新内存池中的数据,保证缓冲池中的内存缓存是最近的数据,此外将已经修改的数据文件刷新到磁盘文件,同时保证在数据库发生异常的情况下InnoDB能恢复到正常运行状态。
InnoDB存储引擎是多线程模型,因此其后台有多个不同的线程,负责处理不同的任务。
Master Thread:是一个非常核心的后台线程,主要负责将缓冲池中异步数据刷新到磁盘,保证数据一致性,包括脏页的刷新,合并插入缓存(insert buffer),undo页的回收。它具有最高的线程优先级别,其内部有多个循环(loop)组成:主循环(loop),后台循环(background loop),刷新循环(flsuh loop),暂停循环(suspend loop)。Master Thread会根据数据库运行状态在这几个循环进行切换。
Loop被称为主循环,因为大多数操作是在这个循环中,其中有两个操作,伪代码如下:
void master_thread{
goto loop;
loop;
for(int i = 0; i < 10; i++){
thread_sleep(1)
do log buffer flush to disk
if(last_one_second_ios < 5)//前一秒IO次数<5
do merge at most 5 insert buffer
//当前缓冲池中脏页的比例是否超过了配置文件中的阈值,默认90%
if(buf_get_modified_ratio_pct > innodb_max_dirty_page_pct)
do buffer pool flush 100 dirty page//将100个脏页写入磁盘
if(no user activity)
goto background loop
}
if(last_ten_seconds_ios < 200)//判断过去10秒的IO操作是否小于200
do buffer pool flush 100 dirty page//小于200InnoDB认为当前有足够的磁盘IO操作能力
do merge at most 5 insert buffer
do log buffer flush to disk
do full purge//删除无用的undo页
if(buf_get_modify_ratio_pct > 70%)//如果有超过70%的脏页比例
do buffer pool flush 100 dirty page //刷新100个脏页到磁盘
else
buffer pool flush 10 dirty page//小于70%,刷新10个脏页到磁盘
goto loop:
background loop:
do full purge//删除无用的undo页
do merge 20 insert buffer
if not idle:
goto loop:
else:
goto flush loop
flush loop:
do buffer pool flush 100 dirty page
if(buf_get_modified_ratio_pct > innodb_max_dirty_pages_pct)
goto flush loop
goto suspend loop
suspend loop:
suspend_thread()
waiting event
goto loop;
}
从代码中可以看到,loop循环是通过thread sleep来实现的,这意味着所谓每秒一次或每10秒一次的操作是不精确的。在负载很大的情况会有延迟,只能说大概在这个频率。
每秒一次的操作包括:
日志缓冲刷新到磁盘,即使这个事务还没有提交(总是);
合并插入缓存(可能);
之多刷新100个InnoDB的缓冲池中的脏页到磁盘;
如果当前没有用户活动,则切换到background loop(可能)。
每10秒的操作包括:
合并至多5个插入缓存(总是)
将日志缓存刷新到磁盘(总是)
删除无用的Undo页(总是)
刷新100个或者10个脏页到磁盘(总是)
background loop在当前没有用户活动(数据库空闲时)或者数据库关闭时,就会切换到这个循环,它会执行以下操作:
删除无用的undo页(总是);
合并20个插入循环(总是);
跳回到主循环(总是);
不断刷新100个页直到符合条件(可能,跳转到flush loop 中完成)。
若flush loop中也没有什么事情做,InnoDB就会切换到suspend_loop,将Master Thread挂起,等待事件的发生。若用户启动了InnoDB,却没有使用任何InnoDB存储引擎的表,那么Master Thread 总是处于挂起状态。
IO Thread:在InnoDB中使用了大量的AIO(Async IO)来处理写IO请求,这样可以极大提高数据库的性能。而IO Thread的工作主要负责这些IO请求的回调(call back)处理。
Purge Thread:事务被提交之后,其所使用的undo log可能不再需要,因此需要Purge Thread来回收已经使用并分配的undo页。在InnoDB1.1版本之前,purge操作仅在InnoDB存储引擎的Master Thread中完成。而从1.1版本开始,purge操作可以独立到单独的线程中进行,以此来减轻Master Thread的工作,从而提高CPU的使用率以及提升存储引擎的性能。
Purge Cleaner Thread:是在1.2.x版本中引入的。其作用是将之前版本中脏页的刷新操作都放到单独的线程中完成。其目的显而易见是为了减轻原Master Thread的工作及对于用户查询线程的阻塞,进一步提高存储引擎的性能。
InnoDB存储引擎是基于磁盘存储的,并将其中的记录按照页的方式进行管理。因此可将其视为基于磁盘的数据库系统。在数据库系统中由于CPU速度与磁盘速度之间的鸿沟,基于磁盘的数据库系统通常使用缓冲池技术来提高数据库的整体性能。在数据库中进行读取页的操作,首先将从磁盘读到的页存放在缓冲池中,这个过程称为将页“FIX”在缓冲池中。下一次再读取相同的页时,首先判断页是否存在缓冲池中。若在缓冲池中,称该页在缓冲池中被命中,直接读取该页。否则读取磁盘上的页。对于数据库中页的修改操作,则首先修改在缓冲池中的页,然后再以一定的频率刷新到磁盘上。这里需要注意的是,页从缓冲池刷回磁盘的操作并不是每次页发生更新时刷新,而是通过一种称为Checkpoint的机制刷新回磁盘。同样,这也是为了提高数据库的整体性能。图3可以很好地展示InnoDB存储引擎中内存的结构情况。
从InnoDB存储引擎的逻辑结构看,所有数据都被逻辑地存放在一个空间中,称之为表空间(table space)。表空间又由段(segment)区(extent)页(page)组成。页在一些文档中有时也被称为块(block)。InnoDB存储引擎的逻辑存储结构大致如图4所示
表空间可以看做是InnoDB存储引擎的最高层,所有的数据都存放在表空间中。
常见的段有数据段,索引段,回滚段等。
区是由连续页组成的空间,在任何情况下每个区的大小都是1MB。为了保证区中页的连续性,InnoDB存储引擎一次从磁盘申请4-5个区,在默认情况下,InnoDB存储引擎页的大小为16KB,即一个区中一共有64(1024KB/16KB)个连续的页。
InnoDB中也有页(也可以称之为块)的概念,它是InnoDB存储引擎管理的最小单位。默认大小为16KB。在InnoDB1.2.x中可以通过参数innodb_page_size将页的大小设置为4KB,8KB,16KB。若设置完成,则所有的页大小都为innodb_page_size,不可以对其再次进行修改。除非通过mysqldump导入和导出操作来产生新的库。
在InnoDB存储引擎中,常见的页类型有:
数据页(B-tree Node)
undo页(undo log page)
系统页(System Page)
事务数据页(Transaction System Page)
插入缓存位图页(Insert Buffer Bitmap)
插入缓存空闲列表页(Insert Buffer Free List)
未压缩的二进制大对象页(Uncompressed BLOB Page)
压缩的二进制大对象页(Compressed BLOB Page)
InnoDB存储引擎是面向列的(row-oriented),也就是说数据是按行进行存放的。每个页存放的行记录也是有硬性定义的,最多允许16KB/2-200行,即7992(8192-200)行记录。为什么是7992?
redo log即重做日志,用来实现事务的持久性,即事务ACID的D。它由两部分组成:一是内存中的重做日志缓存(redo log buffer),它是易失的;二是重做日志文件,它是持久的。
InnoDB是事务的存储引擎,其通过Force Log at Commit机制实现事务的持久性,即当事务提交时,必须先将该事务的所有日志写入到重做日志进行持久化,待事务的Commit操作完成才算完成。这里的日志包括两部分,即redo log和undo log。redo log用来保证事务的持久性,undo log用来帮助事务回滚及MVCC功能。redo log基本上是顺序写的,在数据库运行时不需要对redo log的文件进行读取操作。而undo log是需要进行随机读写的;
为了确保每次日志都写入重做日志文件,在每次将重做日志缓存写入重做日志文件后,InnoDB存储引擎都需要调用一次fsync操作。由于重做日志文件打开并没有使用O_DIRECT选项,因此重做日志缓冲先写入文件系统缓存。为了确保重做日志写入磁盘,必须进行一次fsync操作。由于fsync的效率取决于磁盘的性能,因此磁盘的性能决定了事务的提交性能,也就是数据库的性能。
InnoDB存储引擎允许用户手工设置非持久化的情况发生,以此来提高数据库的性能。即当事务提交时,日志不写入重做日志文件,而是等待一个时间周期后再执行fsync操作。由于并非强制在事务提交时进行一次fsync操作,显然这可以显著提高数据库的性能,但是数据库发生宕机时,由于部分日志未刷新到磁盘,因此会丢失最后一段时间的数据;
参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略。该参数的默认值出为1,表示事务提交时必须调用一次fsync操作。还可以设置该参数的值为0和2。0表示事务提交时不进行写入重做日志操作,这个操作仅在Master Thread中完成,而Master Thread 中每1秒会进行一次重做日志文件的fsync操作。2表示事务提交时将重做日志写入重做日志文件,但仅写文件系统的缓存中,不进行fsync操作。在这个设置下,当MySQL数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失。而当操作系统宕机时,重启数据库后会丢失未从文件系统刷新到重做日志那部分事务。虽然用户可以通过设置参数innodb_flush_log_trx_commit为0或2来提高事务提交的性能,但是需要牢记的是,这种方法丧失了事务的ACID特性。
log block:在InnoDB存储引擎中,重做日志都是以512字节进行存储的。这意味着重做日志缓存,重做日志文件都是以块(block)的方式进行保存的,称为重做日志块(redo log block),每块的大小为512字节。若一个页中产生的重做日志数量大于512字节,那么需要分割为多个重做日志块进行存储。此外,由于重做日志块的大小和磁盘扇区大小一样,都是512字节,因此重做日志写入可以保证原子性,不需要doublewrite技术。重做日志块除了日志本身外,还由日志块头(log block header)及日志块尾(log block tailer)两部分组成。重做日志头一共占用12字节,重做日志块尾占用8字节。故每个重做日志块实际可以存储的大小为492字节(512-12-8)。
redo log记录了事务的行为,可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作,这时就需要undo。因此对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果用户执行的事务或语句由于某种原因失败了,又或者用户用一条ROLLBACK语句请求回滚,就可以利用这些undo信息将数据回滚到修改之前的状态。它主要实现两个功能:实现事务回滚;实现MVCC。
redo存放在重做日志文件中,与redo不同undo存放在数据库内部的一个特殊段(segment)中,这个段是undo段(undo segment)。undo segment位于共享空间表内。我们通常对undo有这样的误解,undo用于将数据库物理地恢复到执行语句或事务之前的样子,但事实并非如此。undo是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子,所有修改都被逻辑的取消了,但是数据结构和页本身在回滚之后可能不大相同,因为在多用户并发系统中,可能会有数十数百甚至数千个并发事务。数据库的主要任务就是协调对数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页中另几条数据进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。
当InndoDB存储引擎进行回滚时,它实际上做的是与先前相反的工作。对于每个INSERT,InnoDB存储引擎会完成一个DELETE;对于每个DELETE,InnoDB存储引擎会执行一个INSERT;对于每个UPDATE,InnoDB存储引擎会执行一个相反的UPDATE,将修改的行返回去。
了解了以上内容对理解InnoDB事务的实现有一定帮助。
值得注意的是MySQL中事务是由存储引擎实现的而非MySQL本身。
原子性是指整个数据库事务是不可分割的工作单位,只有使事务中所有的数据库操作都指向了成功,才算事务成功.事务中任何一条SQL语句执行失败,已经执行成功的的SQL语句也必须撤销,数据库的状态应该退回到执行事务前的状态;
一致性是指事务将数据库从一种状态转变成另一种一致的状态.在事务开始之前和事务结束之后,数据库的完整性约束没有被破坏.如果事务中某个动作失败了,系统可以自动撤销事务,返回到初始化状态;
隔离性还有其他称呼,如并发控制(Concurrency Control),可串行化(Serializability),锁(Locking)等.事务的隔离性要求每个读写事务的对象对其他事物的操作对象能互相分离,即事务提交前对其他事务都不可见,通常使用锁来实现.当前数据库系统中都提供了一种粒度锁(Granular Lock)的策略,允许事务仅锁住一个实体对象的子集,以此来提高事务之间的并发度;
事务一旦提交,其结果是永久性的.即使发生宕机等故障,数据库也能将数据恢复.需要注意的是,只能从事务本身的角度保证结果的永久性;持久性保证事务的高可靠性(High Reliability),而不是高可用(High Availability).对于高可用性的实现,事物本身并不能保证,需要一些系统共同配合来完成;
按照严格的标准,只有同时满足ACID特性才是事务;但是在各大数据库厂商的实现中,真正满足ACID的事务少之又少。例如MySQL的NDB Cluster事务不满足持久性和隔离性;InnoDB默认事务隔离级别是可重复读,不满足隔离性;Oracle默认的事务隔离级别为READ COMMITTED,不满足隔离性……因此与其说ACID是事务必须满足的条件,不如说它们是衡量事务的四个维度。
实现原子性的关键,是当事务回滚时能够撤销所有已经成功执行的sql语句。InnoDB实现回滚,靠的是undo log:当事务对数据库进行修改时,InnoDB会生成对应的undo log;如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。
当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。
既然redo log也需要在事务提交时将日志写入磁盘,为什么它比直接将Buffer Pool中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:
刷脏是随机IO,因为每次修改的数据位置随机,但写redo log是追加操作属于顺序IO;
刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而redo log中只包含真正需要写入的部分,无效IO大大减少。
与原子性,持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。
隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们主要考虑最简单的读操作和写操作(加锁读等特殊读操作会特殊说明),那么隔离性的探讨,主要可以分为两个方面:
(一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性;
(一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性。
首先来看两个事务的写操作之间的相互影响。隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB通过锁机制来保证这一点。
锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁;获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。
按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。表锁在操作数据时会锁定整张表,并发性能较差;行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源(获得锁、检查锁、释放锁等都需要消耗资源),因此在锁定数据较多情况下使用表锁可以节省大量资源。MySQL中不同的存储引擎支持的锁是不一样的,例如MyIsam只支持表锁,而InnoDB同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。
脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。举例如下(以账户余额表为例):
不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。举例如下:
幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。举例如下:
SQL标准中定义了四种隔离级别,并规定了每种隔离级别下上述几个问题是否存在。一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。隔离级别与读问题的关系如下:
在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。因此在大多数数据库系统中,默认的隔离级别是读已提交(如Oracle)或可重复读(后文简称RR。需要注意的是,在SQL标准中,RR是无法避免幻读问题的,但是InnoDB实现的RR避免了幻读问题。
RR解决脏读、不可重复读、幻读等问题,使用的是MVCC:MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。下面的例子很好的体现了MVCC的特点:在同一时刻,不同的事务读取到的数据可能是不同的(即多版本)——在T5时刻,事务A和事务C可以读取到不同版本的数据。
MVCC最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:
隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等;
基于undo log的版本链:前面说到每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链;
通过隐藏列和版本链,MySQL可以将数据恢复到指定版本;但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。
trx_sys中的主要内容,以及判断可见性的方法如下:
下面以RR隔离级别为例,结合前文提到的几个问题分别说明。
脏读:
当事务A在T3时刻读取zhangsan的余额前,会生成ReadView,由于此时事务B没有提交仍然活跃,因此其事务id一定在ReadView的rw_trx_ids中,因此根据前面介绍的规则,事务B的修改对ReadView不可见。接下来,事务A根据指针指向的undo log查询上一版本的数据,得到zhangsan的余额为100。这样事务A就避免了脏读。
不可重复读:
当事务A在T2时刻读取zhangsan的余额前,会生成ReadView。此时事务B分两种情况讨论,一种是如图中所示,事务已经开始但没有提交,此时其事务id在ReadView的rw_trx_ids中;一种是事务B还没有开始,此时其事务id大于等于ReadView的low_limit_id。无论是哪种情况,根据前面介绍的规则,事务B的修改对ReadView都不可见。
当事务A在T5时刻再次读取zhangsan的余额时,会根据T2时刻生成的ReadView对数据的可见性进行判断,从而判断出事务B的修改不可见;因此事务A根据指针指向的undo log查询上一版本的数据,得到zhangsan的余额为100,从而避免了不可重复读。
幻读:
MVCC避免幻读的机制与避免不可重复读非常类似。
当事务A在T2时刻读取0 当事务A在T5时刻再次读取0 可以说,一致性是事务追求的最终目标:前面提到的原子性、持久性和隔离性,都是为了保证数据库状态的一致性。此外,除了数据库层面的保障,一致性的实现也需要应用层面进行保障。 实现一致性的措施包括: 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证; 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等; 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致。 参考文献 《MySQL技术内幕:InnoDB存储引擎》 https://zhuanlan.zhihu.com/p/48327345 http://mysql.taobao.org/monthly/2018/03/01/ 一致性实现原理