事务的定义
事务的基本要素(ACID)
原子性:Atomicity,整个数据库事务是不可分割的工作单位
一致性:Consistency,事务将数据库从一种状态转变为下一种一致的状态
隔离性:Isolation,每个读写事务的对象对其他事务的操作对象能相互分离
持久性:Durability,事务一旦提交,其结果是永久性的
事务的并发问题
脏读:事务A读取了事务B更新的数据,然后B回滚操作,那么A读取到的数据是脏数据
不可重复读:事务 A 多次读取同一数据,期间事务 B对数据作了更新并提交,导致事务A多次读取同一数据时,结果 不一致。
幻读:事务 A 多次同一条件查询,期间事务B删除或插入了满足条件的数据,导致事务A多次读取的结果集不一致。
SQL标准定义的隔离级别为:
事务隔离级别 | 描述 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|---|
READ UNCOMMITTED | 一个事务会读到另一个未提交事务修改过的数据。 | 是 | 是 | 是 |
READ COMMITTED | 一个事务只能读到另一个已经提交的事务修改过的数据。 | 否 | 是 | 是 |
REPEATABLE READ | 一个事务只能读到另一个已经提交的事务修改过的数据,而且该事务第一次读过某条记录后,即使其他事务修改了该记录的值并且提交,该事务之后再读该条记录时,读到的仍是第一次读到的值。 | 否 | 否 | 是 |
SERIALIZABLE | 事务串行化执行 | 否 | 否 | 否 |
InnoDB默认支持REPEATABLE READ,但与标准SQL不同,InnoDB在REPEATABLE READ事务隔离级别下,使用Next-Key Lock算法,避免了幻读问题。
事务的实现
redo log
redo log称为重做日志,保证事务的原子性,持久性
当前事务数据库系统普遍采用Write Ahred Log策略(WAL,预写式日志),即当事务提交时,先写重做日志,再修改磁盘页数据。如果宕机,则通过重做日志来完成数据的恢复。这也是事务ACID中D(Durability持久性)的要求。
实际上是最先操作是修改内存池中的页数据。先写重做日志是相对于修改磁盘数据而言。
任何对Innodb表的更新,Innodb都会将更新操作转化为redo log并写入磁盘,redo log中记录了修改的详细信息。
redo log是物理日志,记录页的偏移量和字节等数据。
重做redo log,实际是重做提交事务修改的页物理操作。
innodb_flush_log_at_trx_commit参数控制重做日志缓存刷新到磁盘的策略。
1:默认,事务提交必须调用一次fsync操作
0:事务提交时不进行写入重做日志操作,由master thread每1秒进行一次fsync操作
2:事务提交时仅将重做日志写入文件系统缓存,不进行fsync操作,当mysql宕机而操作系统不宕机时,不会导致事务丢失。
除了事务提交时,刷新重做日志到磁盘还有如下场景
redo log buffer已经使用一半内存空间
Async/Sync Flush Checkpoint
log block
重做日志缓冲,重做日志文件都是以块(block)的方法进行保存的,称为重做日志块(redo log block),每块的大小为512字节。重做日志块与磁盘扇区大小一样,因此重做日志的写入可以保证原子性,不需要doublewrite技术。
日志块由三部分组成,依次为日志块头(log block header),日志内容(log body),日志块尾log block tailer
日志块头内容以下
变量 | 字节 | 描述 |
---|---|---|
LOG_BLOCK_HDR_NO | 4 | log buffer由log block组成,在内部log buffer就好似一个由log block数组,LOG_BLOCK_HDR_NO用于标记这个数组的下标,递增并且循环使用。第一位用做flush bit,最大值为2G |
LOG_BLOCK_HDR_DATA_LEN | 2 | 表示log block占用的大小。最大为0x200,表示log block512字节 |
LOG_BLOCK_FIRST_REC_GROUP | 2 | 新事务第一个日志偏移量。如果log block中存储了事务L1后还有空余空间,还会存储下一个事务L2的内容,LOG_BLOCK_FIRST_REC_GROUP记录事务L2的偏移量。 |
若LOG_BLOCK_FIRST_REC_GROUP与LOG_BLOCK_HDR_DATA_LEN相同,表示当前log block不含新的日志
LOG_BLOCK_HDR_NO是通过lsn计算得到的,因此InnoDB也可以通过lsn定位到具体的redo log。
日志块尾只有LOG_BLOCK_TRL_NO,4字节,与LOG_BLOCK_CHECKPOINT_NO保持一致
log group
重做日志组是一个逻辑概念,由多个重做日志文件redo log file组成,每个log group中的日志大小是相同的。默认重做日志组由ib_logfile0,ib_logfile1组成。
InnoDB 1.2 版本前,重做日志组的总大小要小于4GB,InnoDB 1.2 版本开始,提高到512GB
对log block的写入追加在redo log file的最后部分,当一个redo log file被写满时,会接着写入下一个redo log file,使用方式为round-robin。
在架构篇说过,当页被Checkpoint刷新到磁盘后,对应的重做日志就不需要了 ,其空间可以被覆盖重用。
每个redo group的第一个redo log file中前2kb不保存log block的信息,而是保存log file header,checkpoint信息。
redo log使用顺序写,速度很快,但checkpoint后,需要更新第一个日志文件的头部checkpoint标记,这时并不是顺序写。
redo group非第一个redo log file中前2KB内容并不存储信息,预留为空。
redo log file前2k内容,依次存放以下数据
属性 | 字节 |
---|---|
LOG FILE HEADER | 512 |
CHECKPOINT BLOCK1 | 512 |
空 | 512 |
CHECKPOINT BLOCK2 | 512 |
重点看一下CHECKPOINT BLOCK的内容
属性 | 字节 | 解析 |
---|---|---|
LOG_CHECKPOINT_NO | 8 | 单调递增的值,每次checkpoint操作完成后进行自增操作 |
LOG_CHECKPOINT_LSN | 8 | checkpoint的值 |
LOG_CHECKPOINT_OFFSET | 4 | checkpoint的值对应的在重做日志的偏移量 |
LOG_CHECKPOINT_LSN表示checkpoint的值,LSN小于该值的页都已经被写入到磁盘。CHECKPOINT BLOCK有两个,InnoDB会交替进行checkpoint值的更新。
这样即使某次checkpoint block写失败了,那么崩溃恢复的时候从上一次记录的checkpoint开始恢复,也能正确的恢复数据库事务。
恢复时,InnoDB需要读取这两个CHECKPOINT BLOCK,取其中较大的LOG_CHECKPOINT_LSN,恢复大于该值的重做日志。
LSN
LSN 即日志序列号( Log Sequence Number ),它是每个redo log的序号。
LSN存在多处,代表不同含义
1. 代表重做日志写入总量
LSN是单调递增的,保存在log_sys中(InnoDB运行时会维护一个对象,负责管理 Redo Log Buffer,启动时由log_init()函数负责初始化)
每写入一个redo log时,LSN就会递增该 Redo Log 写入的字节数,例如新增一个log长度是len,则log_sys->lsn += len。
2. 代表checkpoint最新位置,见上面的CHECKPOINT BLOCK中的LOG_CHECKPOINT_LSN。
当InnoDB正常shutdown,在flush redo log和脏页后,会做一次完全同步的checkpoint,并将checkpoint的LSN写到共享表空间的FSP HEADER PAGE的FIL_PAGE_FILE_FLUSH_LSN变量中。
(由于已经完全checkpoint,下次启动时,lsn可以被重新赋予常量初始值LOG_START_LSN)
Mysql启动时,会读取共享表空间中的FIL_PAGE_FILE_FLUSH_LSN,以及CHECKPOINT BLOCK中的较大的LOG_CHECKPOINT_LSN,如果两者相同,则说明正常关闭;否则,就需要进行故障恢复。
通过LOG_CHECKPOINT_LSN找到对应的redo log,扫描其后的redo log执行恢复操作即可。
3. 代表页最后刷新位置。每个页的头部都有一个FIL_PAGE_LSN,记录该页最后刷新时LSN的大小,可用于判断页是否需要进行恢复操作。
参数:innodb_fast_shutdown,控制数据库关闭操作
0:关闭时,需要完成所有full purge和merge insert buffer,并将所有脏页刷新到磁盘
1:默认值,只是将脏页刷新到磁盘
2:只保证日志都写入到日志文件,下次启动,会进行恢复操作
参数:innodb_force_recovery,控制数据库恢复操作
默认0,表示需要恢复时,进行所有的恢复操作。
其他配置值不一一列出。
undo
undo log保证事务的原子性, 帮助事务回滚以及MVCC功能。
undo是逻辑日志,对每行数据进行记录,记录的是每个操作的逆操作。
回滚操作,实际做的是先前相反的工作,对于insert,做一个delete,对于delete,做一个insert,对于update,做一个相反的update。
undo的存储
InnoDB将undo log看做数据,通过Page保存undo log。
回滚段
回滚段也是一个段对象,保存在页(0,6)处(共享表空间第6页),内容如下
变量 | 字节 | 描述 |
---|---|---|
TRX_RSEG_MAX_SIZE | 4 | 未使用 |
TRX_RSEG_HISTORY_SZIE | 4 | HISTORY链表中UNDO页的数量 |
TRX_RSGE_HISTORY | 16 | 已提交的undo日志链表,可被purge回收 |
TRX_RSEG_FSEG_HEADER | 10 | 回滚段的SEGMENT HEADER |
TRX_RSEG_UNDO_SLOTS | 4*1024 | 指向UNDO段SEGMENT HEADER所在页的偏移量 |
一个UNDO段可以管理一个事务,一个回滚段可以管理1024个UNDO段。
InnoDB1.1之前,只有一个回滚段,支持最大并发事务为1026。
InnoDB1.1开始,最大支持128个回滚段。
位于(0,5)的FIL_PAGE_TYPE_SYS,记录了所有回滚段所在页。
UNDO段
UNDO段是真正存储undo log的地方。它实际上是一个UNDO页链表。链表第一个UNDO页由以下部分组成:
UNDO LOG PAGE HEADER
UNDO LOG SEGMENT HEADER
UNDO日志
UNDO LOG PAGE HEADER内容如下
变量 | 字节 | 描述 |
---|---|---|
TRX_UNDO_PAGE_TYPE | 2 | undo日志的类型,TRX_UNDO_INSERT或TRX_UNDO_UPDATE |
TRX_UNOD_PAGE_STARE | 2 | UNDO页最新一个事务undo日志所在位置 |
TRX_UNDO_PAGE_FREE | 2 | UNDO页空闲的偏移量 |
TRX_UNDO_PAGE_NODE | 12 | UNDO页的链表节点 |
关于TRX_UNDO_PAGE_NODE,可以参考存储篇的链表结构
UNDO LOG SEGMENT HEADER内容如下
变量 | 字节 | 描述 |
---|---|---|
TRX_UNDO_STATE | 2 | UNDO段的状态 |
TRX_UNDO_LAST_LOG | 2 | 最近一个undo log header在页中的偏移量位置 |
TRX_UNDO_FSEG_HEADER | 10 | UNDO段的segment header |
TRX_UNDO_PAGE_LIST | 16 | UNDO页的链表头 |
TRX_UNDO_STATE的有效值有TRX_UNDO_ACTIVE,TRX_UNDO_CACHEd,TRX_UNDO_TO_FREE,TRX_UNDO_TO_PURGE。
UNDO LOG SEGMENT HEADER仅保存在UNDO页链表的第一个UNDO页中,其他UNDO页中对应位置保留为空
undo记录结构
每个undo记录由两部分组成
UNDO LOG HEADER
UNDO LOG RECORD
undo log record有update undo log record和insert undo log record两种类型,通常insert操作产生insert undo log record,其他DML操作产生update undo log record。
通过TRX_UNDO_PAGE_TYPE可以看出,一个UNDO段只能存储一种类型的undo,insert undo log或update undo log。如果一个事务同时有INSERT,UPDATE操作,则需要每种类型分配单独的UNDO段,这样也会导致InnoDB支持最大并发事务数下降。
UNDO LOG HEADER内容如下
变量 | 字节 | 描述 |
---|---|---|
TRX_UNDO_TRX_ID | 8 | 产生undo日志的事务id |
TRX_UNDO_TRX_NO | 8 | 标识事务提交顺序的序号 |
TRX_UNDO_DEL_MARKS | 2 | 标记本组 undo 日志中是否包含delete mark 产生的 undo 日志 |
TRX_UNDO_LOG_START | 2 | 表示本组 undo 日志中第一条 undo 日志的在页面中的偏移量 |
TRX_UNDO_DICT_OPERATION | 2 | 是否为DDL操作 |
TRX_UNDO_TABLE_ID | 8 | 若是DDL操作,操作表的id |
TRX_UNDO_NEXT_LOG | 2 | 下一个UNDO LOG HEADER位置 |
TRX_UNDO_PREV_LOG | 2 | 上一个UNDO LOG HEADER位置 |
TRX_UNDO_HISTORY_NODE | 12 | HISTORY链表节点 |
由于purge可能移除一些undo log record,TRX_UNDO_LOG_START不一定等于UNDO LOG HEADER结束位置偏移量。
事务开启时,会分配一个唯一的严格递增的事务ID以及UNDO段,并设置其TRX_UNDO_STATE变量为TRX_UNDO_ACTIVE。
注意:
InnoDB将undo log看做数据,UNDO页与普通的数据页一起管理,会依据LRU规则刷新出内存,后续再从磁盘读取。
同样,对undo log的操作也需要记录到redo log中。
如对于一个insert操作,redo log不仅要记录insert操作,还需要记录一个生成undo insert的操作。
进行恢复时,InnoDB会重做所有事务,包括未提交的事务和回滚了的事务。然后通过undo log回滚那些未提交的事务。
参数:
innodb_undo_directory:指定UNDO独立表空间位置
innodb_undo_logs:设置rollback segment个数,默认为128(一个rollback segment支持1024并发),在InnoDB 1.2,该参数替换之前版本的innodb_rollback_segments
innodb_undo_tablespaces:组成undo表空间文件个数
innodb_undo_log_truncate: MySQL 自动收缩 Undo 表空间,防止磁盘占用过大,默认开启(Mysql5.7.5之后提供)
innodb_max_undo_log_size:超过该阀值将被自动收缩
UNDO页复用
当事务提交时,需要处理UNDO页:
如果当前的undo log只占一个page,且占用的header page大小使用不足其3/4时(TRX_UNDO_PAGE_REUSE_LIMIT),则状态设置为TRX_UNDO_CACHED,表示该UNDO页可以复用,之后新的undo log记录在当前undo log的后面。
如果是Insert_undo(undo类型为TRX_UNDO_INSERT),则状态设置为TRX_UNDO_TO_FREE,该undo log可被删除
如上不满足,则表明该undo log可能需要Purge线程去执行清理操作,状态设置为TRX_UNDO_TO_PURGE,将undo log加入到回滚段的TRX_RSGE_HISTORY中,由purge回收。
purge操作
purge用于最终完成delete和update操作,这样设计是因为InnoDB支持MVCC,所以记录不能在事务提交时立即进行处理,其他事务可能正在引用这行数据。
(delete操作将记录的delete flag设置为1)
前面说过,回滚段TRX_RSGE_HISTORY列表,会根据事务提交的顺序,将undo log链接起来。
执行purge过程中,InnoDB从TRX_RSGE_HISTORY列表中找到第一个需要被清理的记录trx1,清理后InnoDB会在trx1所在undo log页继续查找是否存在可以被清理的记录,直到该UNDO页没有可以清理的记录,再回到history list中查找下一个需要被清理的记录。
由于可以重用,一个undo log可能存放了不同事务的undo log。因此purge操作需要涉及磁盘的离散读取操作,是一个比较缓慢的过程。
MVCC原理
隐藏列
在存储篇说过,行数据中有两个隐藏列用于实现MVCC
TransactionID:DB_TRX_ID,记录操作该数据事务的事务ID
RollPointer:DB_ROLL_PTR,指向上一个版本数据在undo log 里的位置指针
事务修改行数据时,会将修改前的数据放入undo log中,并修改TransactionID为当前事务ID,RollPointer指向上一个版本数据位置。
例如将行数据的一个字段从A -> B -> C,TransactionID,RollPointer变化如下
注意:这里通过RollPointer组织成一条undo log链。
快照
在RR级别下,事务在begin/start transaction之后的第一条select读操作后, 会创建一个快照(read view),将当前系统中活跃的其他事务记录记录起来。
在RC级别下,事务中每条select语句都会创建一个快照。
可见性判断
设要读取的行的最后提交事务id为 trx_id_current,
当前事务创建的快照read view 中最早的事务id为up_limit_id, 最迟的事务id为low_limit_id
1. trx_id_current < up_limit_id, 当前事务在读取该行记录时, 该行记录的最新事务ID是小于当前系统所有活跃的事务,所以当前行数据可见。
2. trx_id_current > low_limit_id, 当前事务开启后,该行记录被修改并提交,数据不可见。
3. up_limit_id <= trx_id_current <= low_limit_id,该行记录最新事务处于活动状态,
这时需要判断 trx_id_current 在不在 快照的活跃事务ID列表中。
若不在,数据可见。若在,不可见,需要查找undo log 链得到上一个版本再进行可见性判断。
group commit
对于InnoDB,事务提交进行两个操作:
修改内存中事务对应的信息,并将日志写入重做日志缓冲
调用fsync将重做日志缓冲写入磁盘
group commit,组提交,即将多个事务的重做日志缓冲通过一次fsync刷新到磁盘
开启binlog后,为了保证存储引擎中的事务和binlog的一致性,InnoDB使用两阶段事务。
注意:重做日志是innodb产生,物理格式日志,记录对每个页的修改,在事务进行中不断写入。
而binlog是mysql上层产生的,是一种逻辑日志,在事务提交完成时一次写入。
两阶段事务步骤如下
Prepare 阶段:SQL 已经成功执行并生成 redo 和 undo 的内存日志;InnoDB 将回滚段设置为 prepare 状态;
binlog提交阶段:binlog 内存日志数据写入文件系统缓存并通过fsync() 写入磁盘;
Commit 阶段:fsync() 将 binlog 文件系统缓存日志数据永久写入磁盘;
恢复操作
在 prepare 阶段前崩溃,该事务直接回滚;
在 binlog 已经 fsync() ,但 InnoDB 未 commit 时崩溃;恢复时,将会从 binlog 中获取事务信息,重做该事务并提交,使 InnoDB 和 binlog 始终保持一致。
InnoDB需要保证 binlog 的写入顺序和 InnoDB 事务提交顺序一致。
我们使用on-line backup下来的备份文件进行恢复或者主备同步,因为InnoDB检测最新的事务T3已经Commit,不需要进行恢复,结果导致事务T1数据丢失。
InnoDB1.2版本前 ,使用 prepare_commit_mutex 保证顺序,只有当上一个事务 commit 后释放锁,下个事务才可以进行 prepare 操作。但导致开启二进制日志后,group commit功能失效,性能较差。
InnoDB1.2版本后进行了优化,
prepare 阶段不变,
binlog提交阶段和commit 阶段拆分为三个过程,每个阶段都会去维护一个队列,第一个进入该队列的作为 leader 线程,其他作为 follower 线程;leader 线程会收集 follower 的事务,并负责做 sync,follower 线程等待 leader 通知操作完成。
Flush阶段,将队列中每个事务的binlog都写入内存
Sync阶段,将内存队列中的binlog刷新到磁盘,若队列中有多个事务,仅一次fsync操作就完成日志的写入。
Commit阶段,leader根据队列顺序调用InnoDB事务的提交,这时就可以使用group commit功能。
由于三个阶段都是根据队列顺序执行操作,所以保证 binlog 的写入顺序和 InnoDB 事务提交顺序一致。
当有一组事物在进行Commit阶段时,其他新事物可以进行Flush阶段,从而使用group commit不断生效。