概述
事务(Transaction)是数据库区别于文件系统的重要特性之一。在文件系统中,如果正在写文件,但是操作系统崩溃了,这个文件就很有可能被破坏。当然,有一些机制可以把文件恢复到某个时间点。不过,如果需要保证两个文件同步,这些文件系统可能就显得无能为力了。例如,在需要更新两个文件时,更新完一个文档后,在更新完第二个文件之前系统重启了,就会有两个不同步的文件。
这正是数据库系统引入事务的主要目的:事务会把数据库从一种一致状态转换为另一种一致状态。在数据库提交工作时,可以确保要么所有修改都已经保存了,要么所有修改都不保存。
InnoDB存储引擎中的事务完全符合ACID的特性。ACID是以下4个词的缩写:
- 原子性(atomicity)
- 一致性(consistency)
- 隔离性(isolation)
- 持久性(durability)
认识事务
概述
事务可由一条SQL语句组成,也可以由一组复杂的SQL语句组成。事务是访问并更新数据库中各种数据项的一个程序执行单元。在事务中的操作,要么都做修改,要么都不做,这就是事务的目的,也是事务模型区别与文件系统的重要特性。
理论上说,事务有着极其严格的定义,它必须同时满足四个特性,即通常所说的事务的ACID特性。对于InnoDB存储引擎而言,其默认的事务隔离级别为READ REPETABLE,完全遵循和满足事务的ACID特性。这里,具体介绍事务的ACID特性,并给出相关概念。
A(atomicity),原子性。原子性指整个数据库事务是不可分割的工作单位。只有事务中所有的数据库操作都执行成功,才算整个事务成功。事务中任何一个SQL语句执行失败,已经执行成功的SQL语句也必须撤销,数据库状态应该退回到执行事务前的状态。如果事务中的操作都是只读的,要保持原子性是很简单的。一旦发生任何错误,要么重试,要么返回错误代码。因为只读操作不会改变系统中的任何相关部分。但是,当事务中的操作需要改变系统中的状态时,例如插入记录或者更新记录,那么情况可能就不像只读操作那么简单了。如果操作失败,很有可能引起状态的变化,因此必须要保护系统中并发用户访问受影响的部分数据。
C(consistency),一致性。一致性指事务将数据库从一种状态转变为下一种一致的状态。在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。例如,在表中有一个字段为姓名,为唯一约束,即在表中姓名不能重复。如果一个事务对姓名字段进行了修改,但是在事务提交或者事务操作发生回滚后,表中的姓名字段变得非唯一了,这就破坏了事务的一致性要求,即事务将数据库从一种状态变为了一种不一致的状态。因此,事务是一致性的单位,如果事务中某个动作失败了,系统可以自动撤销事务-返回初始化的状态。
I(Isolation),隔离性。隔离性还有其他的称呼,如并发控制(concurrency control)、可串行化(serializability)、锁(locking)。事务的隔离性要求每个读写事务的对象对其他事务的操作对象能相互分离,即该事务提交前对其他事务都不可见,通常这使用锁来实现。
D(durability),持久性。事务一旦提交,其结果就是永久性的。即使发生宕机等故障,数据库也能将数据恢复。需要注意的是,只能从事务本身的角度来保证结果的永久性。
分类
从事务理论的角度来说,可以把事务分为以下几种类型:
- 扁平事务(Flat Transaction)
- 带有保存点的扁平事务(Flat Transaction with Savepoints)
- 嵌套事务(Nested Transaction)
- 分布式事务(Distributed Transaction)
扁平事务(Flat Transaction)是事务类型中最简单的一种,但在实际生产环境中,这可能是使用最为频繁的事务。在扁平事务中,所有操作都处于同一层次,其由BEGIN WORK开始,由COMMIT WORK或ROLLBACK WORK结束,其间的操作是原子的,要么都执行,要么都回滚。因此扁平事务是应用程序成为原子操作的基本组成模块。
扁平事务的主要限制是不能提交或者回滚事务的一部分,或分几个步骤提交。
带有保存点的扁平事务(Flat Transaction with Savepoint),除了支持扁平事务支持的操作外,允许在事务执行过程中回滚到同一事务中较早的一个状态。这是因为某些事务可能在执行过程中出现的错误并不会导致所有的操作都无效,放弃整个事务不合乎要求,开销也太大。保存点(savepoint)用来通知系统应该记住事务当前的状态,以便当之后发生错误时,事务能回到保存点当时的状态。对于扁平的事务来说,其隐式地设置了一个保存点。然而在整个事务中,只有这一个保存点,因此,回滚只能回滚到事务开始时的状态。保存点用SAVE WORK函数来建立,通知系统记录当前的处理状态。当出现问题时,保存点能用作内部的重启动点,根据应用逻辑,决定是回到最近一个保存点还是其他更早的保存点。
链事务(Chained Transaction)可视为保存点模式的一种变种。带有保存点的扁平事务,当发生系统崩溃时,所有的保存点都将消失,因为其保存点是易失的(volatile),而非持久的(persistent)。这意味着当进行恢复时,事务需要从开始处重新执行,而不能从最近的一个保存点继续执行。链事务的思想是,在提交一个事务时,释放不需要的数据对象,将必要的处理上下文隐式地传给下一个要开始的事务。注意,提交事务操作和开始下一个事务操作将合并为一个原子操作。这意味着下一个事务看到上一个事务的结果,就好像在一个事务中进行的一样。链事务和带有保存点的扁平事务不同的是,带有保存点的扁平事务能回滚到任意正确的保存点。而链事务中的回滚仅限于当前事务,即只能恢复到最近一个的保存点。对于锁的处理,两者也不相同。链事务在执行COMMIT后即释放了当前事务所持有的锁,而带有保存点的扁平事务不影响迄今为止所持有的锁。
分布式事务(Distributed Transaction)通常是一个在分布式环境中运行的扁平事务,因此需要根据数据所在位置访问网络中的不同节点。
事务的实现
事务的隔离性由锁来实现的。原子性、一致性、持久性通过数据库的redo log和undo log来完成的。redo log称为重做日志,用来保证事务的原子性和持久性。undo log用来保证事务的一致性。
redo和undo的作用可以视为是一种恢复操作,redo恢复提交事务修改的页操作,而undo回滚行记录到某个特定的版本。因此两者记录的内容不同,redo通常是物理日志,记录的是页的物理修改操作。undo是逻辑日志,根据每行记录进行记录。
redo
基本概念
重做日志用来实现事务的持久性,即事务ACID中的D,其由两部分组成:一是内存中重做日志缓冲(redo log buffer),其是易失的;二是重做日志文件(redo log file),其是持久的。
InnoDB是事务的存储引擎,其通过Force Log at Commit机制实现事务的持久性,即当事务提交时,必须先将该事务的所有日志写入到重做日志文件进行持久化,带事务的COMMIT操作完成才算完成。这里的日志是指重做日志,在InnoDB存储引擎中,由两部分组成,即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数据库发生宕机而操作系统不发生宕机时,并不会导致事务的丢失。而当操作系统宕机时,重启数据库后丢失未从文件系统缓存刷新到重做日志文件那部分事务。
在MySQL数据库中还有一种二进制日志(binlog),其采用进行POINT-IN-TIME(PIT)的恢复及主从复制(Replication)环境的建立。从表面上来看其和重做日志非常相似,都是记录了对于数据库操作的日志。然而,从本质上来看,两者有着非常大的不同。
首先,重做日志是在InnoDB存储引擎层产生,而二进制日志是在MySQL数据库的上层产生的,并且二进制日志不仅仅针对于InnoDB存储引擎,MySQL数据库中任何存储引擎对于数据库的更改都会产生二进制日志。
其次,两种日志记录的内容形式不同。MySQL数据库上层的二进制日志是一种逻辑日志,其记录的是对应的SQL语句。而InnoDB存储引擎层面的重做日志是物理格式日志,其记录的是对于每个页的修改。
此外,两种日志记录写入磁盘的时间点不同,二进制日志只在事务提交完成后进行一次写入。而InnoDB存储引擎的重做日志在事务进行中不断被写入,这表现为日志并不是随事务提交的顺序进行写入的。
从上图可以看到,二进制日志仅在事务提交时记录,并且对于每一个事务,仅包含对应的事务的一个日志。而对于InnoDB存储引擎的重做日志,由于其记录的是物理操作日志,因此每个事务对应多个日志条目,并且事务的重做日志写入是并发的,并非在事务提交时写入,故其在文件中记录的顺序并非是事务开始的顺序。*T1、*T2、*T3表示的是事务提交时的日志。
log block
在InnoDB存储引擎中,重做日志都是以512字节进行存储的。这意味着重做日志缓存、重做日志文件都是以块(block)的方式进行保存的,称之为重做日志块(redo log block),每块的大小为512字节。
若一个页中产生的重做日志数量大于512字节,那么需要分割为多个重做日志块进行存储。此外,由于重做日志块的大小和磁盘扇区大小一样,都是512字节,因此重做日志的写入可以保证原子性,不需要doublewrite技术。
log group
log group为重做日志组,其中有多个重做日志文件。重做日志文件中存储的就是之前在log buffer中保存的log block,因此其也是根据块的方式进行物理存储的管理,每个块的大小与log block一样,同样为512字节。在InnoDB存储引擎运行过程中,log buffer根据一定的规则将内存中的log block刷新到磁盘。这个规则具体是:
- 事务提交时。
- 当log buffer中有一半的内存空间已经被使用时
- log checkpoint时
对于log block的写入追加(append)在redo log file的最后部分,当一个redo log file写满时,会接着写入下一个redo log file,其使用方式为round-robin。
LSN
LSN是Log Sequence Number的缩写,其代表的是日志序列号。在InnoDB存储引擎中,LSN占用8字节,并且单调递增。LSN表示的含义有:
- 重做日志写入的总量。
- checkpoint的位置。
- 页的版本。
LSN表示事务写入重做日志的字节总量。例如当前重做日志的LSN为1000,有一个事务T1写入了100字节的重做日志,那么LSN就变为了1100,若又有事务T2写入了200字节的重做日志,那么LSN就变为了1300,。可见LSN记录的是重做日志的总量,其单位为字节。
LSN不仅记录在重做日志中,还存在于每个页中。在每个页的头部,有一个值FIL_PAGE_LSN,记录了该页的LSN。在页中,LSN表示该页最后刷新时的LSN的大小。因为重做日志记录的是每个页的日志,因此页中的LSN用来判断页是否需要进行恢复操作。
恢复
InnoDB存储引擎在启动时不管上次数据库运行时是否正常关闭,都会尝试进行恢复操作。因为重做日志记录的是物理日志,因此恢复的速度比逻辑日志,如二进制日志要快得多。
由于checkpoint表示已经刷新到磁盘页上的LSN,因此在恢复过程中仅需恢复checkpoint开始的日志部分。当数据库在checkpoint的LSN为10000时发生宕机,恢复操作仅恢复LSN10000-13000范围内的日志。
InnoDB存储引擎的重做日志是屋里日志,因此其恢复速度较之二进制日志恢复快得多。
undo
基本概念
重做日志记录了事务的行为,可以很好地通过其对页进行重做操作。但是事务有时还需要进行回滚操作,这时就需要undo。因此在对数据库进行修改时,InnoDB存储引擎不但会产生redo,还会产生一定量的undo。这样如果用户执行的事务或语句由于某种原因失败了,又或用户用一条ROLLBACK语句请求回滚,就可以利用这些信息将数据回滚到修改之前的样子。
redo存放到重做日志文件中,与redo不同,undo存放的是数据库内部的一个特殊段(segment)中,这个段称为undo段(segment)。
用户通常对undo有这样的误解:undo用于将数据库物理地恢复到执行语句或事务之前的样子-但事实并非如此。undo是逻辑日志,因此只是将数据库逻辑地恢复到原来的样子。所有的修改都被逻辑地取消了,但是数据结构和页本身在回滚之后可能不大相同。这是因为在多用户并发系统中,可能有数十、数百甚至数千个并发事务。数据库的主要任务就是协调数据记录的并发访问。比如,一个事务在修改当前一个页中某几条记录,同时还有别的事务在对同一个页另几条记录进行修改。因此,不能将一个页回滚到事务开始的样子,因为这样会影响其他事务正在进行的工作。
除了回滚操作,undo的另一个作用是MVCC,即在InnoDB存储引擎中MVCC的实现是通过undo来完成的。当用户读取一行记录时,若该记录已经被其他事务占用,当前事务可以通过undo读取之前的行版本信息,以此实现非锁定读取。
最后也是最为重要的一点是,undo log会产生redo log,也就是undo log的产生会伴随着redo log的产生,这是因为undo log也需要持久性的保护。
MVCC的核心有两部分:1、undo log,undo log的实现了MVCC中的版本链。2、数据可见性的规则也就是快照。
2、undo存储管理
InnoDB存储引擎对undo的管理同样采用段的方式,但是这个段和之前介绍的段有所不同。首先InnoDB存储引擎有rollback segment,每个回滚段中记录了1024个undo log segment,而在每个undo log segment段中进行undo页的申请。
需要特别注意的是,事务在undo log segment分配页并写入undo log的这个过程同样需要写入重做日志。当事务提交时,InnoDB存储引擎会做以下两件事情:
- 将undo log放入列表中,以供之后的purge操作
- 判断undo log所在的页是否可以重用,若可以分配给下一个事务使用
事务提交后并不能马上删除undo log及undo log所在的页。这是因为可能还有其他事务需要通过undo log来得到行记录之前的版本。故事务提交时将undo log放入一个链表中,是否可以最终删除undo log及undo log所在的页由purge线程来判断。
此外,若每个事务分配一个单独的undo页会非常浪费存储空间,特别对于OLTP的应用类型。因为在事务提交时,可能不能马上释放页。假设某应用的删除和更新操作的TPS为(1000),为每个事务分配一个undo页,那么一分钟就需要1000*60个页,大约需要的存储空间为1GB。若每秒purge页的数量为20,这样的设计对磁盘空间有着相当高的要求。因此,在InnoDB存储引擎的设计中对undo页可以进行重用。具体来说,当事务提交时,首先将undo log放入链表中,然后判断undo页的使用空间是否小于3/4.若是则表示该undo页可以被重用,之后新的undo log记录在当前的undo log后面。由于存放undo log的列表是以记录进行组织的,而undo页可能存放着不同事务的undo log,因此purge操作需要涉及磁盘的离散读取操作,是一个比较缓慢的过程。
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线程进行最后的删除。
purge
delete和update操作可能并不直接删除原有的数据。例如:
DELETE FROM t WHERE a = 1;
表t上列a有聚集索引,列b上有辅助索引。对于上述的DELETE操作,通过前面关于undo log的介绍已经知道仅是将主键列等于1的记录delete flag设置为1,记录并没有被删除,即记录还是存放在B+树中。其次,对辅助索引上a等于1,b等于1的记录同样没有做任何处理,甚至没有产生undo log。而真正删除这行记录的操作其实被延时了,最终在purge操作中完成。
purge用于最终完成delete和update操作。这样的设计是因为InnoDB存储引擎支持MVCC,所以记录不能在事务提交时立即进行处理。这时其他事务可能正在引用这行,故InnoDB存储引擎需要保存记录之前的版本。而是否可以删除这条记录通过purge来进行判断,若该记录已不被任何其他事务引用,那么就可以进行真正的delete操作。可见,purge操作是清理之前的delete和update操作,将上述操作最终完成。而实际执行的操作为delete操作,清理之前行记录的版本。
在前一小节已经介绍过,为了节省存储空间,InnoDB存储引擎的undo log设计是这样的:一个页上允许有多个事务的undo log存在。虽然这不代表在全局过程中提交的顺序,但是后面的事务产生的undo log总在最后。此外,InnoDB存储引擎还有一个history列表,它根据事务提交的顺序,将undo log进行连接。如下面的一种情况:
在上图中,history list表示按照事务提交的顺序将undo log进行组织。在InnoDB存储引擎的设计中,先提交的事务总在尾端。undo page存放了undo log,由于可以重用,因此一个undo page中可能存放了多个不同事务的undo log。
在执行的过程中,InnoDB存储引擎首先从history list中找到第一个需要被清理的记录,这里为trx1,清理之后InnoDB存储引擎会在trx1的undo log页中继续寻找是否存在可以被清理的记录,这里会找到trx3,接着找到trx5,但是发现trx5被其他事务所引用而不能清理,故去再次去history list中查找,发现这时最尾端的记录为trx2,接着找到trx2所在的页,然后依次再把事务trx6、trx4的记录进行清理。由于undo page2中所有的页都被清理了,因此该undo page可以被重用。
InnoDB存储引擎这种先从history list中找undo log,然后再从undo page中找undo log的设计模式是为了避免大量的随机读取操作,从而提高purge的效率。
全局动态参数inodb_purge_batch_size用来设置每次purge需要清理的undo_page数量。在InnoDB1.2之前,该参数的默认值为20。而从1.2版本开始,该参数的默认值为300。通常来说,该参数设置得越大,每次回收的undo page也就越多,这样可供重用的undo page就越多,减少了磁盘存储空间与分配的开销。不过,该参数设置得太大,则每次需要purge处理更多的undo page,从而导致CPU和磁盘IO过于集中对undo log的处理,使性能下降。因此对该参数的调整需要由有经验的DBA来操作。
当InnoDB存储引擎的压力非常大时,并不能高效地进行purge操作。那么history list的长度会变得越来越长。全局动态参数innodb_max_purge_lag用来控制history list的长度,若长度大于该参数时,其会延缓DML的操作。该参数的默认值为0,表示不对history list做任何限制。当大于0时,就会延缓DML的操作,其延缓的算法为:
delay = ((length(history_list) - innodb_max_purge_lag) * 10) - 5
delay的单位是毫秒。此外,需要特别注意的是,delay的对象是行,而不是一个DML操作。
InnoDB1.2版本引入了新的全局动态参数innodb_max_purge_lag_delay,其用来控制delay的最大毫秒数。也就是当上述计算得到的delay值大于该参数时,将delay设置为innodb_max_purge_lag_delay,避免由于purge操作缓慢导致其他SQL线程出现无限制的等待。
group commit
若事务为非只读事务,则每次事务提交时需要进行一次fsync操作,以此保证重做日志都已经写入磁盘。当数据库发生宕机时,可以通过重做日志进行恢复。虽然固态硬盘的出现提高了磁盘的性能,然而磁盘的fsync性能是有限的。为了提高磁盘fsync的效率,当数据库都提供了group commit的功能,即一次fsync可以刷新确保多个事务日志被写入文件。对于InnoDB存储引擎来说,事务提交时会进行两个阶段的操作:
1、修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
2、调用fsync将确保日志都从重做日志缓冲写入磁盘。
步骤2相对于步骤1是一个缓慢的过程,这是因为存储引擎需要与磁盘打交道。但当有事务进行这个过程时,其他事务可以进行步骤1的操作,正在提交的事务完成提交操作后,再次进行步骤2时,可以将多个事务的重做日志通过一次fsync刷新到磁盘,这样就大大减少了磁盘的压力,从而提高了数据库的整体性能。对于写入或更新较为频繁的操作,group commit的效果尤为明显。
然而在InnoDB1.2版本之前,在开启二进制日志之后,InnoDB存储引擎的group commit功能会失效,从而导致性能的下降。并且在线环境多使用replication环境,因此二进制日志的选项基本都为开启状态,因此这个问题尤为显著。
导致这个问题的原因是在开启二进制日志后,为了保证存储引擎层中的事务和二进制日志的一致性,二者之间使用了两阶段事务,其步骤如下:
1、当事务提交时InnoDB存储引擎进行prepare操作。
2、MySQL数据库上层写入二进制日志
3、InnoDB存储引擎层将日志写入重做日志文件。
a、修改内存中事务对应的信息,并且将日志写入重做日志缓冲。
b、调用fsync将确保日志都从重做日志缓冲写入磁盘。
一旦步骤2完成,就确保了事务的提交,即使在执行步骤3时数据库发生了宕机。此外需要注意的是,每个步骤都需要进行一次fsync操作才能保证上下两层数据的一致性。步骤2的fsync由参数sync_binlog控制,步骤3的fsync由参数innodb_flush_log_at_trx_commit控制。
MySQL5.6BLGC(Binary Log Group Commit,BLGC)的实现方式是将事务提交的过程分为几个步骤来完成。如下图。
在MySQL数据库上层进行提交时首先按顺序将其放入一个队列中,队列中的第一个事务称为leader,其他事务称为follower,leader控制着follower 的行为。BLGC的步骤分为以下三个阶段:
1、Flush阶段,将每个事务的二进制日志写入内存中。
2、Sync阶段,将内存中的二进制日志刷新到磁盘,若队列中有多个事务,那么近一次fsync操作就完成了二进制日志的写入,这就是BLGC。
3、Commit阶段,leader根据顺序调用存储引擎层事务的提交,InnoDB存储引擎本就支持group commit,因此修复了原先由于锁prepare_commit_mutex导致group commit失效的问题。
当有一组事务再进行commit提交时,其他新事务可以进行flush阶段,从而使group commit不断生效。当然group commit的效果由队列中事务的数量决定,若每次队列中仅有一个事务,那么可能效果和之前差不多,甚至会更差。但当提交的事务越多时,group commit的效果越明显,数据库性能的提升也就越大。
参数binlog_max_flush_queue_time用来控制Flush阶段中等待的时间,即使之前的一组事务完成提交,当前一组的事务也不马上进入Sync阶段,而是至少需要等待一段时间。这样做的好处是group commit的事务数量更多,然而这也可能会导致事务的响应时间变慢。该参数的默认值为0,且推荐设置依然为0。除非用户的MySQL数据库中有着大量的连接。