Innodb事务学习
1 事务的实现
事务的隔离性是由锁来实现的。原子性、一致性、持久性是通过数据库的redo log和undo log来实现的。redo log称为重做日志,用来保证事务的持久性。undo log用来保证事务的一致性。
这里不要认为undo log 是redo log的逆过程,这种理解是不对的。 redo 中记录的是恢复提交事务修改的页的操作,也就说page no ,offset 修改成了啥,而undo回滚行记录到某个特定版本。因此两者记录的内容不同, redo通常是物理日志, 记录的是页的物理修改操作。 undo是逻辑日志, 根据每行记录进行记录 。
1.1 redo
1.1.1 概念
重做日志用来实现事务的持久性,即事务ACID中的D。其由两部分组成:一是内存中的重做日志缓冲,可能丢失;二是重做文件,它是持久到磁盘的。
InnoDB通过日志先行的机制(Force Log at Commit)来实现事务的持久性,即当事务提交时,必须先将该事务的所有日志写到重做日志文件进行持久化,持久化完成才视为事务提交成功。redo log基本上都是顺序写的, 在数据库运行时不需要对redo log的文件进行读取操作。
为了确保每次事务提交日志都写入重做日志文件, 在每次将重做日志缓冲写入重做日志文件后, InnoDB存储引擎都需要调用一次fsync操作。 由于重做日志文件打开并没有使用O_DIRECT选项, 因此重做日志缓冲先写入文件系统缓存。 为了确保重做日志写入磁盘, 必须进行一次fsync操作。 由于fsync的效率取决于磁盘的性能, 因此磁盘的性能决定了事务提交的性能, 也就是数据库的性能。
参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略。 该参数的默认值为1, 表示事务提交时必须调用一次fsync操作。 还可以设置该参数的值为0和2。 0表示事务提交时不进行写入重做日志操作, 这个操作仅在master thread中完成, 而在master thread中每1秒会进行一次重做日志文件的fsync操作。 2表示事务提交时将重做日志写入重做日志文件, 但仅写入文件系统的缓存中, 不进行fsync操作。 在这个设置下, 当MySQL数据库发生宕机而操作系统不发生宕机时, 并不会导致事务的丢失。 而当操作系统宕机时, 重启数据库后会丢失未从文件系统缓存刷新到重做日志文件那部分事务 。
在MySQL数据库中还有一种二进制日志(binlog) , 其用来进行POINTIN-TIME(PIT) 的恢复及主从复制(Replication) 环境的建立。 从表面上看其和重做日志非常相似, 都是记录了对于数据库操作的日志。 然而, 从本质上来看, 两者有着非常大的不同。首先, 重做日志是在InnoDB存储引擎层产生, 而二进制日志是在
MySQL数据库的上层产生的, 并且二进制日志不仅仅针对InnoDB存储引擎, MySQL数据库中的任何存储引擎对于数据库的更改都会产生二进制日志。其次, 两种日志记录的内容形式不同。 MySQL数据库上层的二进制日志是一种逻辑日志, 其记录的是对应的SQL语句。 而InnoDB存储引擎层面的重做日志是物理格式日志, 其记录的是对于每个页的修改。
此外, 两种日志记录写入磁盘的时间点不同。 二进制日志只在事务提交完成后进行一次写入。 而InnoDB存储引擎的重做日志在事务进行中不断地被写入, 这表现为日志并不是随事务提交的顺序进行写入的。
二进制日志仅在事务提交时记录, 并且对于每一个事务, 仅包含对应事务的一个日志。 而对于InnoDB存储引擎的重做日志, 由于其记录的是物理操作日志, 因此每个事务对应多个日志条目, 并且事务的重做日志写入是并发的, 并非在事务提交时写入, 故其在文件中记录的顺序并非是事务开始的顺序。
1.1.2 日志块
InnoDB中,重做日志都是以512字节进行存储的。不管是重做日志缓存,重做日志文件都是以块的方式进行保存的,称之为重做日志块,每块的大小为512字节。
若一个页(默认16KB)中产生的重做日志数量大于512字节(B),那就需要分割为多个重做日志块进行存储。此外, 由于重做日志块的大小和磁盘扇区大小一样, 都是512字节, 因此重做日志的写入可以保证原子性, 不需要doublewrite技术。
重做日志块除了日志本身之外,还由日志头和日志尾组成。日志头记录了日志块在重做日志缓存list中的编码、日志占用空间大小、第一个日志所在的偏移量(因为一个块可能存在好几个事务的日志)。日志尾中也记录了日志块在重做日志缓存list中的编码。
注意:这里的块是指重做日志缓存中的数据结构
1.1.3 日志组
log group 为重做日志组,它是一个逻辑概念。 它由多个重做日志文件组成。而重做日志文件中存储的就是之前在logbuffer中保存的log block, 因此重做日志文件也是根据块的方式进行物理存储管理的。在InnoDB存储引擎运行过程中, log buffer根据一
定的规则将内存中的log block刷新到磁盘。 这个规则具体是:
- 事务提交时
- 当log buffer中有一半的内存空间已经被使用
- log checkpoint时
对于log block的写入追加都是在redo log file的最后部分,但是并不意味着redo log file的写入都是顺序的。因为redo log file 不只只保存了log block中的重做日志内容,还保存了一共占用2KB大小的log file header和checkpoint点信息。所以在log block的写入过程中,还需要更改这2KB大小的信息,更改就不完全是顺序写了。
故log group与redo log file 之间的关系如下图所示:
1.1.4 重做日志格式
InnoDB存储引擎的存储管理是基于页的,故其重做日志格式也是基于页的。通用的头部格式如下图所示:
- redo_log_type:重做日志的类型。
- space: 表空间的ID。
- page_no: 页的偏移量
之后redo log body的部分, 根据重做日志类型的不同, 会有不同的存储内容。InnoDB1.2版本时,一共有51种类型。
1.1.5 LSN
LSN是Log Sequence Number的缩写,其代表的是日志序列号。在InnoDB存储引擎中, LSN占用8字节,并且单调递增。 LSN表示的含义有:
- 重做日志写入的总量
- checkpoint的位置
- 页的版本
LSN不仅记录在重做日志中,还存在于每个页(InnoDB管理的数据页)中。在每个页的头部(File header)中有一个值FIL_PAGE_LSN,记录了该页的LSN。在页中,LSN表示该页最后刷新时LSN的大小。所以当重做日志记录中的LSN和对应页中的LSN不一样的时候,就需要触发恢复操作来保持一致。举个例子:页P1的LSN为1000,数据库启动时发现p1页的重做日志LSN为1300,并且该事务已经提交,那么数据库就要根据重做日志1000~1300的日志记录修改页的数据。对于页中LSN比重做日志中的LSN大的情况,不用特殊处理,因为我们的最终的目的就是把页维持到最新的状态。
数据库中存在三个key,Log sequence number ,Log flushed up to 和Last checkpoint at ,分别代表引擎重做日志缓存的LSN,重做日志文件LSN和刷新到磁盘的LSN。
1.1.6 恢复
InnoDB存储引擎在启动时不管上次数据库运行时是否正常关闭, 都会尝试进行恢复操作。 因为重做日志记录的是物理日志, 因此恢复的速度比逻辑日志, 如二进制日志, 要快很多。
此外,在读取重做日志恢复的时候,应用了顺序读和并行读取,提高了数据库恢复的速度。
由于checkpoint表示已经刷新到磁盘页上的LSN, 因此在恢复过程中仅需恢复checkpoint开始的日志部分。
1.2 undo
1.2.1 基本概念
重做日志记录了事务的行为, 可以很好地通过其对页进行“重做”操作。但是事务有时还需要进行回滚操作, 这时就需要undo。 因此在对数据库进行修改时, InnoDB存储引擎不但会产生redo, 还会产生一定量的undo。 这样如果用户执行的事务或语句由于某种原因失败了, 又或者用户用一条ROLLBACK语句请求回滚, 就可以利用这些undo信息将数据回滚到修改之前的样子。
redo存放在重做日志文件中, 与redo不同, undo存放在数据库内部的一个特殊段(segment) 中, 这个段称为undo段(undo segment) 。 undo段位于共享表空间内。 可以通过py_innodb_page_info.py工具来查看当前共享表空间中undo的数量。
用户通常对undo有这样的误解: undo用于将数据库物理地恢复到执行语句或事务之前的样子——但事实并非如此。 undo是逻辑日志, 因此只是将数据库逻辑地恢复到原来的样子。 所有修改都被逻辑地取消了, 但是数据结构和页本身在回滚之后可能大不相同。 这是因为在多用户并发系统中, 可能会有数十、 数百甚至数千个并发事务。 数据库的主要任务就是协调对数据记录的并发访问。 比如, 一个事务在修改当前一个页中某条记录, 同时还有别的事务在对同一个页中另几条记录进行修改。 因此, 不能将一个页回滚到事务开始的样子, 因为这样会影响其他事务正在进行的工作 。比如:用户执行了一个Insert语句,引擎在回回滚的时候就会执行一个delete操作。其余类型的语句类似。
除了回滚操作, undo的另一个作用是MVCC, 即在InnoDB存储引擎中MVCC的实现是通过undo来完成。 当用户读取一行记录时, 若该记录已经被其他事务占用, 当前事务可以通过undo读取之前的行版本信息, 以此实现非锁定读取。最后也是最为重要的一点是, undo log会产生redo log, 也就是undo log的产生会伴随着redo log的产生, 这是因为undo log也需要持久性的保护。
undo也是页的结构,所以保证页中数据不丢失的任务都给了redo log
1.2.2 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的应用类型。
因此, 在InnoDB存储引擎的设计中对undo页可以进行重用。 具体来说, 当事务提交时, 首先将undo log放入链表中(Histroy list length), 然后判断undo页的使用空间是否小于3/4, 若是则表示该undo页可以被重用, 之后新的undo log记录在当前undo log的后面。 由于存放
undo log的列表是以记录进行组织的, 而undo页可能存放着不同事务的undo log, 因此purge操作需要涉及磁盘的离散读取操作, 是一个比较缓慢的过程。
这里的离散读表示清理现场可能为了清理同一个事务的undo log,离散读不同的undo log页去找这个事务的uodo log日志。
1.2.3 undo log 格式
在InnoDB存储引擎中,undo log分为:
- insert undo log
- update undo log
insert undo log是指在insert操作中产生的undo log。 因为insert操作的记录, 只对事务本身可见, 对其他事务不可见(这是事务隔离性的要求) , 故该undo log可以在事务提交后直接删除。
insert undo log中开始得前两个字节next记录了下一个undo log得位置,通过这个next和结尾的start判断这个undo log的大小。undo no记录事务的ID, table_id记录undo log所对应的表对象。还记录了所有主键的列和值。这样在进行rollback操作时,根据这些值就可以进行删除操作的回滚。
update undo log记录的是对delete和update操作产生的undo log。 该undo log可能需要提供MVCC机制, 因此不能在事务提交时就进行删除。 提交时放入undo log链表, 等待purge线程进行最后的删除。 相对于insert undo log,其type_cmpl字段有三个值:
- 12 更新non-delete-mark的记录(对应update) 非索引更新
- 13 将delete的记录标记为not delete(un delete)
- 14将记录标记为delete(delete) 索引更新
除此之外还多了update_vector信息,表示操作发生改变的列。这里还可能多对索引的列。
更新主键时,会产生两条undo log,分别是13 和 insert类型。
1.3 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操作中完成。
这样设计是因为InnoDB存储引擎支持MVCC, 所以记录不能在事务提交时立即进行处理。 这时其他事物可能正在引用这行, 故InnoDB存储引擎需要保存记录之前的版本。而是否可以删除该条记录通过purge来进行判断。 若该行记录已不被任何其他事务引用, 那么就可以进行真正的delete操作。 可见, purge操作是清理之前的delete和update操作, 将上述操作“最终”完成。 而实际执行的操作为delete操作, 清理之前行记录的版本。
InnoDB存储引擎得undo log 设计是这样的:一个页上允许多个事务的undo log存在。这样就可能发生清理一个事务对应的undo log发生随机读的低效行为。那InnoDB是怎样解决的呢? InnoDB中有一个history列表,它根据事务提交的顺序,将undo log进行链接。
在执行purge的过程中吗,InnoDB存储引擎首先从history list中找到第一个需要被清理的记录(undo log),记为1,清理1之后InnoDB存储引擎会在1所在的页(undo page)中继续寻找是否存在可以被清理的记录,而不是把这个页释放,继续读其它的页。清理完当前页之后,也可以重用,也可以释放,然后继续在history list中找下一个带清理的页。
InnoDB存储引擎这种先从history list中找undo log, 然后再从undo page中找undo log的设计模式是为了避免大量的随机读取操作, 从而提高purge的效率。
1.4 group commit
若事务为非只读事务, 则每次事务提交时需要进行一次fsync操作, 以此保证重做日志都已经写入磁盘。 当数据库发生宕机时, 可以通过重做日志进行恢复。 虽然固态硬盘的出现提高了磁盘的性能, 然而磁盘的fsync性能是有限的。 为了提高磁盘fsync的效率, 当前数据库都提供了group commit的功能, 即一次fsync可以刷新确保多个事务日志被写入文件。
对于InnoDB存储引擎来说, 事务提交时会进行两个阶段的操作:
1) 修改内存中事务对应的信息, 并且将日志写入重做日志缓冲。
2) 调用fsync将确保日志都从重做日志缓冲写入磁盘。
步骤2) 相对步骤1) 是一个较慢的过程, 这是因为存储引擎需要与磁盘打交道。 但当有事务进行这个过程时, 其他事务可以进行步骤1) 的操作, 正在提交的事物完成提交操作后, 再次进行步骤2) 时, 可以将多个事务的重做日志通过一次fsync刷新到磁盘, 这样就大大地减少了磁盘的压力, 从而提高了数据库的整体性能。 对于写入或更新较为频繁的操作, group commit的效果尤为明显。
1.5 事务的隔离级别
SQL标准自定义的四个隔离级别为:
- READ UNCOMMITED
- READ COMMITED
- REPEATABLE READ
- SERIALIZABLE
READ UNCOMMITTED称为浏览访问(browse access) , 仅仅针对事务而言的。 READ COMMITTED称为游标稳定(cursor stability) 。REPEATABLE READ是2.9999°的隔离, 没有幻读的保护。SERIALIZABLE称为隔离, 或3°的隔离。 SQL和SQL2标准的默认事务隔离级别是SERIALIZABLE。
InnoDB存储引擎默认支持的隔离级别是REPEATABLE READ, 但是与标准SQL不同的是, InnoDB存储引擎在REPEATABLE READ事务隔离级别下, 使用Next-Key Lock锁的算法, 因此避免幻读的产生。 这与其他数据库系统(如Microsoft SQL Server数据库) 是不同的。 所以说,InnoDB存储引擎在默认的REPEATABLE READ的事务隔离级别下已经能完全保证事务的隔离性要求, 即达到SQL标准的SERIALIZABLE隔离级别。
有的人可能认为read commited的隔离级别性能要比repeatable read级别要高很多,但是其实两个级别差不多。所以尽量都采用repeatable read的隔离级别。
在SERIALIABLE的事务隔离级别, InnoDB存储引擎会对每个SELECT语句后自动加上LOCK IN SHARE MODE, 即为每个读取操作加一个共享锁。 因此在这个事务隔离级别下, 读占用了锁, 对一致性的非锁定读不再予以支持。
因为InnoDB存储引擎在REPEATABLE READ隔离级别下就可以达到3°
的隔离, 因此一般不在本地事务中使用SERIALIABLE的隔离级别。
SERIALIABLE的事务隔离级别主要用于InnoDB存储引擎的分布式事
务。
1.6 分布式事务
1.6.1 MySQL数据库分布式事务
InnoDB存储引擎提供了对XA事务的支持, 并通过XA事务来支持分布式事务的实现。 分布式事务指的是允许多个独立的事务资源(transactional resources) 参与到一个全局的事务中。 事务资源通常是关系型数据库系统, 但也可以是其他类型的资源。 全局事务要求在其中的所有参与的事务要么都提交, 要么都回滚, 这对于事务原有的ACID要求又有了提高。 另外, 在使用分布式事务时, InnoDB存储引擎的事务隔离级别必须设置为SERIALIZABLE。
XA事务允许不同数据库之间的分布式事务, 如一台服务器是MySQL数据库的, 另一台是Oracle数据库的, 又可能还有一台服务器是SQL Server数据库的, 只要参与在全局事务中的每个节点都支持XA事务。
XA事务由一个或多个资源管理器(Resource Managers) 、 一个事务管理器(Transaction Manager) 以及一个应用程序(Application Program)组成。
- 资源管理器:提供访问事务资源的方法。 通常一个数据库就是一个资源管理器。
- 事务管理器:协调参与全局事务中的各个事务。 需要和参与全局事务的所有资源管理器进行通信。
- 应用程序:定义事务的边界, 指定全局事务中的操作。
在MySQL数据库的分布式事务中, 资源管理器就是MySQL数据库, 事务管理器为连接MySQL服务器的客户端。 下图显示了一个分布式事务的模型。
分布式事务使用两段式提交(two-phase commit) 的方式。 在第一阶段, 所有参与全局事务的节点都开始准备(PREPARE) , 告诉事务管理器它们准备好提交了。 在第二阶段, 事务管理器告诉资源管理器执行ROLLBACK还是COMMIT。 如果任何一个节点显示不能提交, 则所有的节点都被告知需要回滚。 可见与本地事务不同的是, 分布式事务需要多一次的PREPARE操作, 待收到所有节点的同意信息后, 再进行COMMIT或是ROLLBACK操作。
XA 协议是由 X/Open 组织提出的分布式事务处理规范,主要定义了事务管理器 TM 和局部资源管理器 RM 之间的接口。
1.6.2 内部XA事务
之前讨论的分布式事务是外部事务, 即资源管理器是MySQL数据库本身。 在MySQL数据库中还存在另外一种分布式事务, 其在存储引擎与插件之间, 又或者在存储引擎与存储引擎之间, 称之为内部XA事务。
最为常见的内部XA事务存在于binlog与InnoDB存储引擎之间。 由于复制的需要, 因此目前绝大多数的数据库都开启了binlog功能。 在事务提交时, 先写二进制日志, 再写InnoDB存储引擎的重做日志。 对上述两个操作的要求也是原子的, 即二进制日志和重做日志必须同时写入。 若二进制日志先写了, 而在写入InnoDB存储引擎时发生了宕机, 那么slave可能会接收到master传过去的二进制日志并执行, 最终导致了主从不一致的情况。 如下图所示。
在上图中,如果执行完1、2后在步骤3之前MySQL数据库发生了宕机,则会发生主从不一致的情况。
为了解决这个问题, MySQL数据库在binlog与InnoDB存储引擎之间采用XA事务。 当事务提交时, InnoDB存储引擎会先做一个PREPARE操作, 将事务的xid写入, 接着进行二进制日志的写入, 如下图所示。 如果在InnoDB存储引擎提交前,MySQL数据库宕机了, 那么MySQL数据库在重启后会先检查准备的UXID事务是否已经提交, 若没有, 则在存储引擎层再进行一次提交操作。
MySQL数据库在binlog与InnoDB存储引擎之间采用XA事务。 当事务提交时, InnoDB存储引擎会先做一个PREPARE操作, 将事务的xid写入, 接着进行二进制日志的写入, 如下图所示。 如果在InnoDB存储引擎提交前,MySQL数据库宕机了, 那么MySQL数据库在重启后会先检查准备的UXID事务是否已经提交, 若没有, 则在存储引擎层再进行一次提交操作。