<<Mysql是怎样运行的>>小记-3

第十八章 Mysql事务#

事务的特性#

我们在数据库上进行的操作许多都是现实操作的映射,可是生活中的有些操作不能正确的映射到计算机中.
数据库操作需要满足以下几点才能够满足:

  • 原子性(Automicily): 在一个事务里的所有操作是不可分割的,要么一起成功要么一起失败.
    比如一个转账操作A->B,不能是A帐上的钱成功扣减了,结果B帐上的钱却没有增加,A账户的扣减和B账户的增加必须是一起成功或是一起失败的.
  • 一致性(Consistency): 需要满足数据库操作和现实生活的一致性.
    比如身份证号不能重复,账户余额不能小于0等等,需要满足现实生活的正常逻辑,这一块需要业务代码和数据库共同去维护.
  • 隔离性(Isolation): 不同事务之间的操作不会互相影响到.
    比如A同时向B和C进行转账操作,如果没有隔离性的话,数据库可能会交替执行这俩个转账操作:
操作 步骤1 步骤2 步骤3 步骤4 步骤5
A转账B 读取A的账户余额为10 扣减A账户10-5 B账户增加金额
A转账C 读取A的账户余额为10 扣减A账户10-4 C账户增加金额

这个时候因为A账户的扣减操作比A转账C更晚执行,并且在读取余额的时候A转账B的这整个交易操作还未完成,导致获取到了错误的余额(这个时候另外的一个事务还未完成,而另一个事务读取到了同一条数据,这种情况我们也称之为脏读),导致最终A账户的余额是6,而不是我们想要的1
数据库操作的交替执行可能会导致其他的操作影响到本次操作的状态转换.隔离性就是杜绝这些不同事务之间的影响

  • 持久性(Durability): 在事务操作完成后的结果应该是持久的,在数据库里就是保存到磁盘的,不会因为其他事故所丢失影响.

事务的状态#

InnoDB设计者将事务给分成了以下几个状态:

  • 活动的: 当一个事务中的操作还在执行中,则我们视它为活动的
  • 部分提交的: 当一个事务的最后一个操作也执行完成,但是所造成的影响还未刷新到磁盘上的时候,视为部分提交的
  • 提交的: 当一个事务的操作执行完,并且事务的影响也被刷新到磁盘上,视为提交的
  • 失败的: 当一个事务在执行操作的时候报错了,或是手动被人停止事务了,或者是将事务的影响刷入到磁盘的时候,则会变为失败状态
  • 中止的: 当事务处于失败的状态,需要将这次事务中的操作所造成的影响撤回,恢复到执行事务之前的状态(也就是回滚)

事务的语法#

  • 事务的开启:

    • BEGIN [WORK]
    • START TRANSACTION [READ ONLY | READ WRITE] [WITH CONSISTENT SHAPSHOP]
      使用START TRANSACTIO能够提供更多的功能:
      READ ONLY指明该事务是只读事务
      READ WRITE指明该事务是读写事务
      WITH CONSISTENT SHAPSHOP是事务开启一致性读
  • 事务的提交

    • COMMIT [WORK]
  • 事务的回滚

    • ROLLBACK [WORK]

ROLLBACK是我们在业务上的某些时候要主动回滚事务的时候使用的,但是如果在数据库操作的时候遇到错误了,数据库会自动的回滚事务.

自动提交#

系统变量autocommit控制事务是否自动提交.
默认这个变量的值是ON,每一条语句都算是一个独立的事务.
如果我们显式的开启了事务,那么在事务提交前或是中止前都会关闭自动提交的功能.
或是我们设置该系统变量为OFF,那么输入的多个语句都算是同一个事务,直到我们主动的使用commit语句或是rollback语句结束掉这个事务.

隐式提交#

当我们开启事务或是autommit的值为OFF时,有时候即使我们没有进行主动提交事务,事务还是会被提交,我们称这种行为为隐式提交,下面有一些操作就会导致隐式提交:

  • 定义或修改DDL语句(数据库定义语言)
  • 隐式使用或是修改Mysql数据库里的表(系统表)
  • 事务控制或是锁定的语句,比如我们在开启一次事务后,又开启了一次事务,则会隐式提交前一次的事务,或是使用LOCK TABLES或是UNLOCK TABLES等语句也会隐式提交开启的事务
  • 加载数据的语句,使用LOAD DATA来进行数据导入的时候也会导致之前的语句隐式提交
  • 关于MYSQL复制的一些语句
  • 比如OPTIMIZE TABLE等等的一些语句

保存点#

在事务中,我们是不是有时候觉得只是想回滚事务中的一部分语句,而不是回滚到整个事务开始前?
我们就可以利用到保存点的特性
SAVEPOINT 保存节点名
然后我们可以通过:
ROLLBACK [WORK] TO [SAVEPOINT] 保存节点名
来回滚到对应的保存节点

同样我们也可以通过RELEASE SAVEPOINT 保存节点名来进行保存节点的删除

第十九章 Redo日志#

为什么需要redo日志?#

从前面BufferPool可以知道,如果想要对数据库的数据进行更新操作,需要将其数据页读取到内存的BufferPool中,然后对其缓存页进行更新操作,并放入到flush链表中等待后台线程同步到磁盘,但是这个更新操作实际上刷回磁盘的时间是不确定的.大多数时候是由后台线程异步进行同步的.
但是数据库事务有一个特性:持久性
持久性要求当事务提交之后,该事务造成的影响不会因为其他因素而消失,但是如果我们事务里修改了一个表的数值,事务刚刚提交,后台线程还未将数据页同步到磁盘,数据库就直接故障停机了,那么这个事务的影响就消失了.
这个问题对于事务的持久性是不可容忍的.

但是我们怎么解决这个问题呢?

  • 难道每次事务提交后都直接将对应的脏页修改到磁盘吗?
    磁盘IO那会大大的拖累用户线程的处理效率,而且可能一个数据页上这次事务只修改了一条数据,那也要将整个数据页刷回磁盘.这样的话有点浪费了.
    并且一个事务里很有可能不止修改一个表中的数据,或是修改很多不止在一个数据页上的数据,这又会产生很多随机IO.随机IO的效率又比顺序IO要慢.

那么怎么解决比较好呢?

  • 我们只是想要即使是事务提交后服务器故障,而这次事务的影响又不会消失.但是又不想每次同步到磁盘都要将整个数据页同步,尽量减少随机IO.
    设计者提出了一个解决办法:
    我们在事务提交的时候,并不强制将内存中的缓存页刷新到磁盘中.
    而是在事务提交的时候将这些修改的内容写到磁盘中.这样即使后面服务器崩溃了,也能够根据这些信息来将事务修改的内容同步到磁盘中,这样事务的持久性就得到了保证.
    我们称这些将修改的内容刷新到磁盘的文件为:redo log

使用redo日志的好处有:

  1. 因为redo log只存储修改的内容和表空间id,页号,日志类型等信息,所以占用空间很小
  2. 因为redo log是顺序写入磁盘的,是顺序IO,所以效率很高.

redo日志的格式#


type: redo log日志类型
space ID: 表空间id
page number: 页号
data: redo log的具体内容

简单的redo日志类型#

有时候我们只需要记录向某个数据页中写入一些内容,这类的redo log日志被称为物理日志.

比如我们前面说过,如果一个表没有定义主键或是唯一键,存储引擎就会自动为该表加上一个row_id隐藏列为主键

  • 服务器会在内存中维护一个全局变量,每当有隐藏列row_id的表插入一条数据的时候,就会用该全局变量的值充当row_id的值,并且该全局变量自增1.
  • 当该全局变量的值为256的倍数时,就会将该全局变量刷到系统表空间页号为7的数据页中的MAX ROW ID位置
  • 当服务器应用启动的时候,会将磁盘上的MAX ROW ID加上256加载到内存中的全局变量使用.
    当将全局变量的值刷新到磁盘上数据页的MAX ROW ID位置时的redo log,我们就称其为物理日志,该日志结构比较简单,只需要记录下在什么位置下修改了几个字节的值,还有具体的修改的内容即可.

属于物理日志的日志类型都有:

日志类型 type十进制 描述 结构
MLOG_1BYTE 1 在对应的偏移量写入1个字节的内容
MLOG_2BYTE 2 在对应的偏移量写入2个字节的内容
MLOG_4BYTE 4 在对应的偏移量写入4个字节的内容
MLOG_8BYTE 8 在对应的偏移量写入8个字节的内容
MLOG_WRITE_STRING 30 在对应的偏移量写入一串内容

复杂一些的redo日志类型#

虽然我们有简单的redo日志就可以将一些情况下的数据页内容的修改记录下来了.
但是有些时候,一条语句可能会更改许多数据页上的信息.或是在一个数据页上多处位置上进行修改.

  • 我们对一个有多个索引的表进行插入数据,那这个表有多少个索引,我们就要在多少个索引的数据页进行数据更新.
  • 当我们对一个索引B+树进行数据新增的时候,如果叶子节点的页面不够用,那又需要进行页分裂,进行页分裂了也有可能内节点的页面也不够用,又进一步需要内节点页分裂.
  • 在一个数据页里面插入数据后,这个数据页的其他位置:就比如Page Directory的槽信息,PageHeader中的页面统计信息,在页面中与这个新记录相邻记录的next_record属性.

对于这些情况来说使用物理日志可能会变得十分复杂,甚至出现插入操作其redo日志比其数据页的大小还大.

那要是我们对这些操作直接将其数据页整体存到redo日志中可行吗?这样就最多也就和数据页一样大了.这样也不可以,同样存在空间浪费(数据页里可能会有未更新的记录),并且这样和直接使用Buffer Pool刷回磁盘的空间差不多,也就少了用户线程处理随机IO的步骤,可是在处理这个redo日志的时候还是要刷回具体数据页磁盘的.得不偿失,还增高了复杂度.

正是因为物理日志无法解决这个问题,所以设计者又提出了一些日志类型来处理对于这些数据页的更新处理:

日志类型 type十进制 描述
MLOG_REC_INSERT 9 表示插入一条非紧凑行格式记录的日志类型
MLOG_COMP_REC_INSERT 38 表示插入一条紧凑行格式记录的日志类型
MLOG_COMP_PAGE_CREATE 58 表示创建一存储紧凑行格式记录的页面的日志类型
MLOG_COMP_REC_DELETE 42 表示删除一条紧凑行格式记录的日志类型
MLOG_COMP_LIST_START_DELETE 44 表示从给定记录开始删除页面中一系列记录的的日志类型
MLOG_COMP_LIST_END_DELETE 43 与 MLOG_COMP_LIST_START_DELETE 日志类型呼应,表示删除一系列记录直到 MLOG_COMP_LIST_END_DELETE 日志给定的记录
MLOG_ZIP_PAGE_COMPRESS 51 表示压缩一个数据页的日志类型

这些日志类型从物理层面来看,表示了对哪些表空间的哪些页面进行了更新.但是从逻辑层面来看,重启后直接靠这些日志直接进行修改对应的位置是不行的,需要依靠一些准备好的函数才能将系统恢复成崩溃前的样子,而redo日志就是执行这些函数所需的参数.

Mini-Transaction#

以组的方式写入redo日志#

为什么需要以组的方式写入redo日志呢?
我们在执行进行一些操作的时候,会生成多个redo日志,而我们希望这些redo日志在服务崩溃重启后,要么一起恢复,要么一条也不恢复.

比如我们在插入一条数据的时候,以页面内的空间是否足够为条件.

  • 乐观来看:该记录所插入的数据页中空闲的空间足够插入该记录,那么就只要记录一下MLOG_COMP_REC_INSERT类型的redo日志即可.
  • 悲观来看:该记录所插入的数据页中没有空闲的空间来插入该记录,那么又要进行页分裂操作,页分裂操作可能会导致内节点更新,还会需要更改许多段和区的统计信息等等.这又会产生许多redo日志.

实际上乐观情况下也有可能产生多条的redo日志的,具体就不细说了.

但是如果这些redo日志,只有一部分执行成功了(就比如我在记录一个悲观插入redo日志操作时,就只记录到了页分裂的日志操作,但是向新页面插入数据的日志没记录下来).结果在崩溃重启后,只根据存储下来的redo日志进行了页分裂操作.这样导致了我们的B+树恢复了一个不正确的状态.

所以设计者规定:这类需要保证原子性的操作的redo日志必须是以的方式来记录的,对于一个组的redo日志,要么都恢复,要么都不恢复.

那么我们怎么来实现这个目标呢?
设计者增加了一种日志类型:MLOG_MULTI_REC_END type值:31,该日志只有一个type字段,代表原子性操作的一组日志的结束.

当服务端进行重启恢复的时候,检测一组的redo日志时,只有检测到MLOG_MULTI_REC_END日志才会进行恢复,否则则放弃恢复前面解析的redo日志.
而对于那些一个redo日志的原子操作时,就比如更新max row id的物理日志,redo日志中的type的首位能够代表是否是单一日志支持的原子操作,这样就不用每条日志后跟随着一个MLOG_MULTI_REC_END日志了.

Mini-Transaction的概念#

我们上面说的,对于需要保证其原子性多个redo日志的操作,将其以组为单位来进行恢复,或是像是更新MAX ROW ID这类单一日志实现原子性的操作的日志.
我们称这一组/一个日志就为一个Mini-Transaction,简写mtr.

redo日志的写入方式#

InnoDB的设计者为了更好管理这些redo日志,将这些redo日志放入block(大小为512字节的页).以block为单位来管理这些redo日志(和页的概念差不多).

log block结构:#

所属部分 属性名 占用字节大小 描述
log block header LOG_BLOCK_HDR_NO 4 表示唯一代表该block的编号(该值大于0)
LOG_BLOCK_HDR_DATA_LEN 2 表示block中中已经使用的字节(初始值为12,即为block header所占用的长度,最大值为512)
LOG_BLOCK_FIRST_REC_GROUP 2 表示该block块中第一个MTR生成的redo日志记录组的偏移量(如果有一个mtr生成的redo日志占用了多个block的空间来存储,那么其使用的最后一个block记录的该属性就是这个MTR的redo日志结束的地方,下一个MTR生成的redo日志开始的地方)
LOG_BLOCK_CHECKPOINT_NO 4 checkpoint的序号
log block body 496 真正存储redo日志的地方
log block trailer LOG_BLOCK_CHECKSUM 4 表示该block的校验值,用于正确性校验

redo日志缓冲区#

当然,redo日志也不能直接写入到磁盘中,redo日志也像数据页一样有自己的buffer pool来解决磁盘同步速度慢的问题:redo log buffer (redo日志缓冲区)
redo log buffer在内存中被分成了多个连续的block
log buffer的大小通过innodb_log_buffer_size来指定

redo日志写入log buffer#

那么在redo日志写入log buffer的时候,我们该如何指导该插入到log buffer的什么位置呢?
设计者提供了一个变量buf_free,这个变量指明了接下来的redo日志在log buffer写入的位置.

前面说过我们需要保证所属一个MTR的redo日志的原子性.是一个不可分割的组.

属于一个mtr的日志写入并不是直接写入到内存中的,而是暂时先存到别的地方,当该mtr结束时,再将该组日志全部复制到logbuffer中

但是我们并不保证一个事务中的多个MTR的原子性.
所以俩个事务的MTR可能会交替来写入到log Buffer中.

比如有俩个事务:

  • 事务T1有俩个MTR:mtr_t1_1,mtr_t1_2
  • 事务T2有俩个MTR:mtr_t2_1,mtr_t2_2

    实际上的redo日志写入可能会是这样的:

redo日志文件#

redo日志刷盘时机#

既然redo日志是先刷入的log buffer之中的,那么总有什么时候需要将这些日志从内存刷新到磁盘中,那么会在什么时候进行刷盘呢?

  • log buffer 空间不足的时候
  • 事务提交的时候,为了保证事务的持久性,要保证事务提交的时候将其页面修改对应的redo日志刷新到磁盘,以便崩溃后恢复.
  • 将某个脏页刷新到磁盘前,会先将该脏页对应的redo日志刷新到磁盘中.(redo日志是顺序刷新的,所以也会将之前的其他脏页产生的redo日志刷新到磁盘中)
  • 后台线程会定时刷新log buffer中的redo日志到磁盘中(大约每秒一次)
  • 正常将服务器关闭时.
  • 做checkpoint时.

redo日志文件组#

数据目录下有名为:ib_logfile0ib_logfile1的俩个文件,这就是log buffer中刷新到磁盘的位置.

以下几个系统变量可以对redo日志文件做一些调整

  • innodb_log_group_home_dir:redo日志文件所在的目录
  • innodb_log_file_size:指定了每个redo日志文件的大小(默认48MB)
  • innodb_log_files_in_group:redo日志文件的个数

redo日志文件是顺序写入的,如果空间都写满了,它就会转到首个日志文件继续写.

redo日志文件格式#

redo日志文件中:

  • 前2048字节用于存储日志文件中的各类管理信息
  • 后面的空间用于存储block数据,就和log buffer中的block一样

redo日志前四个block文件格式#

我们接下来就来介绍一下这前2048字节(即前四个block)存储的数据

所属部分 属性名称 占用大小(字节) 描述
log file header LOG_HEADER_FORMAT 4 redo日志的版本(5.7.21的时候永远为1)
LOG_HEADER_PAD1 4 字节填充使用,没有实际意义
LOG_HEADER_START_LSN 8 标记本redo日志文件开始的LSN值,即本文件2048字节位置对应的LSN值
LOG_HEADER_CREATOR 32 标记本日志文件的创建者,正常运行为mysql版本号,使用mysqlbackup命令则是"ibbackup"和创建时间.
没用 460
LOG_BLOCK_CHECKSUM 4 该block的校验值
checkpoint1 LOG_CHECKPOINT_NO 8 服务器做checkpoint的编号,每做一次checkpoint操作则+1
LOG_CHECKPOINT_LSN 8 服务器做checkpoint结束时候的对应LSN值,系统崩溃恢复时从该值开始
LOG_CHECKPOINT_OFFSET 8 上个属性的LSN值在redo日志文件组中对应的偏移量
LOG_CHECKPOINT_LOG_BUF_SIZE 8 服务器在做checkpoint时对应的log buffer的大小
没用 476
LOG_BLOCK_CHECKSUM 4 block的校验值
未使用 512
checkpoint2 512 和checkpoint1结构一样

LOG Sequeue Number#

LOG Sequeue Number: lsn,日志序列号,随着redo日志的写入而增大(初始值为8704)

lsn值是按照实际上写入的日志占用字节数和对应的log block header和redo block trail来计算的.

初始状态 写入一个200字节的mtr日志组 写入一个1000字节的mtr日志组
8704是初始值,12是log block header的大小 因为这个时候该block已足够存放这组日志,所以lsn值只需要+200 这个mtr的日志大小过大,需要新增俩个block用来存储,所以还需要加上12×2和4×2

每一组由mtr生成的redo日志都有一个lsn号与它对应,可以认为lsn号越小的redo日志,其生成时间也越早.

flushed_to_disk_lsn#

因为log buffer中的redo日志会存在一部分已经刷新到磁盘上的,还有另一部分没刷新到磁盘上的.
所以设计者使用了一个全局变量来存储已刷入磁盘的偏移量位置:
buf_next_to_write:标记有哪些日志已经被刷新到了磁盘
buf_free:标记现在log buffer中已经使用的空间

而lsn也有一个对应全局变量用来表示已经刷入磁盘lsn编号值:
flushed_to_disk_lsn:用于标记现在已刷入磁盘的lsn值.

一开始该全局变量的值与lsn值一致,后面随着写入到logbuffer的redo日志越来越多,而没有立即将logbuffer上的日志刷新到磁盘上,lsn就会与flushed_to_disk_lsn的值拉开差距.如果俩者值相同,则说明logbuffer上的日志已经都刷入到磁盘中了.

lsn和redo日志文件偏移量#

因为lsn号代表系统已经写入的redo日志的字节总合(以及block的header和trail),我们可以通过lsn很快的计算出对应的文件偏移量.

flush链表中的lsn#

我们在写redo日志的时候,也不能忘记之前的Buffer Pool的flush链表,我们在mtr结束的时候,需要将mtr中可能修改的数据页放入到flush链表中.
而flush链表也需要与lsn值配合使用.
在flush链表的控制块中有这俩个属性来存放对应mtr的lsn值:
oldest_modification该数据页被加载到BufferPool后第一次被更新的mtr的开始的lsn值
newest_modification该数据页最后一次被更新的mtr结束的lsn值

我们来举个例子:

假设这个时候lsn值为初始值8704,加上header为8716.我们将oldest_modification简写为o_m,而newest_modification简写为n_m

操作 mtr_t1刷新了a数据页 mtr_t2刷新了a,b数据页 mtr_t3刷新了c,d数据页
mtr(lsn开始值,lsn结束值) mtr_t1(8716,8916) mtr_t2(8916,9948) mtr_t3(9948,10000)
数据页(o_m,n_m) a(8716,8916) a(8716,9948) b(8916,9948) c(9948,10000) d(9948,10000)
描述 mtr_t1中有a数据页的更新,当mtr_t1结束时,会将数据页a的控制块加入到lsn链表头部,因为数据页a之前未更新过,所以会将mtr_t1的lsn开始值写入到数据页a的控制块的o_m属性中,而结束值写入到n_m属性中 mtr_t2更新了a和b数据页,而a的数据页在之前被mtr_t1更新过,所以o_m不需要更新,而b数据页的o_m需要写入mtr_t2的lsn开始值8916,a和b数据页的n_m值同样写入为9948. mtr_t3更新了c和d数据页,这俩个数据页的o_m都写入9948,而n_m都写入为10000

FLUSH链表中的数据页按照oldest_modification的LSN值来进行排序(即越早修改则越靠近链表尾部,而多次修改并不会移动其位置或是重复加入链表),被多次更新的页不会重复加入到flush链表中,但是会更新其newest_modification值.

checkpoint#

我们在向redo日志文件组中不断地写入redo日志,终有一日文件容量会使用完,这个时候就从首个redo日志文件从头继续写入.
但是从首个redo日志文件从头继续写入,又会覆盖掉之前的redo日志,我们可以思考一下,哪些日志是可以直接被覆盖掉的并且不影响功能的?

  • redo日志是用来支持持久性的,为了在服务端崩溃后还能通过redo日志来恢复对应的数据.
    但是如果我们redo日志记录的更新已经刷新到了磁盘上,那么即使之后崩溃也不需要对该redo日志进行数据恢复.
    这些空间就可以被之后写入的redo日志来使用.

那么checkpoint操作具体是怎么做的呢?

  1. 计算当前系统中可以被覆盖的redo日志对应的lsn值最大是多少.
    去寻找flush链表的尾结点,比该尾结点的oldest_modification值所对应的lsn小的值的redo日志都是已经将更新的数据页刷新进磁盘的.

比如flush链表为:c->b->a,a页被刷新进磁盘,而a页mtr对应的redo日志组对应的更新都刷新到磁盘中了,所以就是可以直接进行覆盖的redo日志,flush链表变成了c->b,这个时候checkpoint操作取到了b页控制块中oldest_modification,而lsn值比b的oldest_modification小的,都是可以覆盖的redo日志.

  1. 根据上一步获取的lsn值,计算其redo日志文件组偏移量,将checkpoint_lsn、checkpoint_offeset、checkpoint_no写入redo日志文件组的管理信息中(但是要注意只会写到日志文件组中的第一个日志文件的管理信息中).
    checkpoint_lsn:上一步获取到的尾结点的lsn值,lsn值小于该值对应的redo日志都是可以被覆盖的(其对应的数据页更新已经刷新到磁盘中了).
    checkpoint_offset:该redo日志在日志文件组中的偏移量,可以通过lsn值方便的计算出.
    checkpoint_no:设计者提出的用于表示当前系统checkpoint次数的变量,每进行一次checkpoint,则该值+1
    当该次checkpoint_no是偶数时,则写入到checkpoint1中,是奇数则写入到checkpoint2中.

注意:checkpoint操作和脏页刷新到磁盘是俩回事,是由不同线程来处理的,可能会出现:刚刚进行完checkpoint操作,就将flush链表尾结点的脏页刷新到磁盘中的这种情况.这俩者的处理顺序是不确定的.

用户线程批量从flush链表中刷出脏页#

一般来说都是由后台线程来对LRU链表和flush链表来进行刷脏操作的,但是有可能会出现当前系统频繁修改页面,系统lsn值增长过快,而后台线程又无法快速刷出脏页,系统无法及时执行checkpoint,就有可能让用户线程将flush链表中最早修改的脏页同步刷新到磁盘(这会影响用户线程的处理速度,因为磁盘IO是比较慢的),使这些脏页的redo日志可以被覆盖,可以执行checkpoint操作了.

查看系统的lsn值#

通过SHOW ENGINE INNODB STATUS命令可以查看当前InnoDB存储引擎的各种lsn值情况.

innodb_flush_log_at_trx_commit#

为了保证事务的持久性,用户线程在事务提交的时候会强制将这次事务产生的redo日志刷新到磁盘中,如果我们想要牺牲一部分持久性,而提高数据库的性能,可以修改以下这个系统变量来选择不同的处理策略
innodb_flush_log_at_trx_commit:

  • 0 (在事务提交后不立即向磁盘同步redo日志,由后台线程处理)
  • 1 (默认值,在事务提交的时候将redo日志同步到磁盘)
  • 2 (事务提交的时候将redo日志同步到操作系统的缓冲区中,并不保证日志真正刷新到磁盘,如果只是数据库挂了,持久性还能得到保证,但是操作系统也挂了就无法保证持久性了)

崩溃恢复#

我们前面说了很多redo日志的设计,比如redo日志的格式是什么样的,存储redo日志的block格式又是什么样的等等,但是还没说过在崩溃后系统如何利用redo日志来进行恢复,接下来就稍微过一下恢复的过程.

确定恢复的起点和恢复的终点#

首先崩溃后要进行恢复首先要确定需要恢复的redo日志的起点和终点

  • 确定恢复的起点
    我们前面说过一个日志文件组的属性checkpoint_lsn,比该值小的lsn值都是其脏页已经被刷新到磁盘中的redo日志,所以这些redo日志不需要被恢复.当然,日志文件组中有checkpoint1和checkpoint2,要先计算哪一个checkpoint_lsn的值更大,更大的说明是最近的一次checkpoint,使用那个更大的checkpoint的checkpoint_lsn和checkpoint_offset.
  • 确定恢复的终点
    从起点的checkpoint_offset开始,向后遍历block,如果有block的LOG_BLOCK_HDR_DATA_LEN(当前block已使用的字节空间)值小于512,则说明该block是终点.

怎么通过对应的redo日志恢复#

  • 哈希表
    根据redo日志的space ID和page number创建出一个哈希表,格式为:
key value
space ID,page number redo1->redo2->redo3....->redon

这样可以减少页面的随机IO,将同一页面的更新放在一起进行恢复.
为了避免redo日志不同顺序恢复可能出现的错误(比如一条记录的新增->删除变成了删除->新增),value中的redo日志链表也是按照修改顺序来进行排序的.

  • 跳过已经刷新到磁盘的页面
    我们知道,刷新脏页到磁盘和进行checkpoint是俩回事,所以checkpoint中记录的lsn号之后的redo日志对应的脏页,仍然可能已经刷入到磁盘了.
    如果我们在崩溃后恢复仍然对这些redo日志进行恢复,重复进行操作,降低了恢复的速度,并且可能还会出现错误.
    那么我们如何判断哪些lsn值大于checkpoint_lsn的redo日志对应的脏页已经刷新到磁盘中了呢?
    • 有一个我们快要遗忘的属性,每一个数据页的FILEHEADER都有一个属性FIL_PAGE_LSN,该属性记录了该页面最近一次被修改页面对应的lsn值(也就是newest_modification值).
      如果有checkpoint操作之后,该页面被刷新到磁盘,那么该数据页的FIL_PAGE_LSN值一定会比checkpoint_lsn值要大,我们只要在进行数据恢复的时候,使用checkpoint_lsn值与该数据页的FIL_PAGE_LSN值进行比较,如果小于FIL_PAGE_LSN值则不需要再进行该日志的恢复了.

block的LOG_BLOCK_HDR_NO是如何计算的#

该属性代表block的唯一编号.在第一次使用该block的时候进行分配
计算公式为:((lsn/512) & 0x3FFFFFFF ) +1
可知:LOG_BLOCK_HDR_NO的取值范围为1~0x40000000,即为2的30次方=1G
不重复的LOG_BLOCK_HDR_NO有1G个.
而设计者规定redo日志文件组的所有文件大小的总和不能超过512GB,一个block文件512字节大小,刚刚好够用.

LOG_BLOCK_HDR_NO的首位被称为flush bit,如果某次log buffer将block刷新进磁盘中,第一个被刷入的block的LOG_BLOCK_HDR_NO首位即为1.

第二十章UNDO日志#

在上一章我们通过redo日志来保证的事物的持久性,让事务在提交之后的修改不会丢失.Redo日志只保证了MTR的原子性,并不保证一整个事务的原子性.
而这章要讲解的UNDO日志就是为了保证事务的原子性.
当事务中执行中可能出现各种错误,或是程序员在事务途中手动执行ROLLBACK语句进行回滚.
为了保证事务的原子性,这种情况我们就需要将事务之前修改的部分进行回滚,恢复成事务执行之前的模样.而undo日志就能帮助我们将需要回滚的信息进行记录.

对于这些事务中需要回滚的操作,我们主要分为:

  • insert : 在事务中插入了一条记录,至少需要记录下该条新增记录的主键值,在回滚的时候根据该条undo日志中记录的主键值进行删除即可.
  • delete : 在事务中删除了一条记录,需要记录下该条记录各列的值,在回滚之后将记录插入回表中.
  • update : 在事务中更新了一条记录,需要记录下该条记录更新之前各列的值,可能还需要根据是否有更新主键使用不同的策略.

而select语句不会对表中的数据进行更新,所以不需要对select语句记录undo日志.

事务id#

分配事务id的时机#

只有事务中对表的数据进行增、删、改操作的时候才会进行分配。
在事务章节的时候说过,我们可以通过START TRASACTION READ ONLY,来开始一个只读事务,使用该事务不能对普通的表进行增删改操作,故也不会对该事务分配事务id了。
但是只读事务对临时表是可以进行增删改操作的,如果一个只读事务对临时表进行了增删改操作,也会给他分配一个事务id。

总的来说,就是事务在第一次对表进行增删改操作的时候,就会为该事务分配一个唯一的事务id。

事务id的生成方式#

事务id的分配策略与之前说过的row_id类似

  • 服务器中会在内存中维护一个全局变量,每当需要分配一个事务id的时候,就会将该变量的值分配给该事务,并且该变量自增1.
  • 当这个变量为256的倍数的时候,就会将内存中的该变量刷新至磁盘中(系统表空间页号为5的页面中的一个Max Trx ID的属性,该属性占用八个字节).
  • 当系统下一次重新启动的时候,就会将磁盘中的Max Trx ID取出,将其加上256赋值给内存中的全局变量.

trx_id隐藏列#

事务id的生成方式也和row_id类似,其实事务id也在记录上有一个隐藏列来进行存储,而和这一章有关的就是trx_id和roll_pointer这俩个隐藏列.
记录中的trx_id记录了对该记录进行改动的语句所在的事务的事务id.

undo日志格式#

在一个事务里,每进行一个增删改操作的时候,就需要InnoDB存储引擎对其操作对应的undo日志存储下来,一般一次操作会对应一条或多条undo日志.
而这些undo日志也会根据生成的顺序从0开始编号,这些编号我们称为undo no

undo日志被存储在类型为FIL_PAGE_UNDO_LOG类型的页面中.
这些页面可以从系统表空间或是在一种专门存放undo日志的表空间(undo tablespance)分配

table_id: undo日志中会记录增删改操作对应的表id,我们想要指导对应的表id可以通过系统数据库的information_schema的innodb_sys_tables表来查看,比如:
select * from information_schema.innodb_sys_tables where name='表名'

我们接下来讲解各个操作所对应的undo日志用这个表来举例子:

CREATE TABLE undo_demo{
  id INT NOT NULL,
  key1 VARCHAR(100),
  col VARCHAR(100),
  PRIMARY KEY(id),
  KEY idx_key1(key1),
}Engine=InnoDB CHARSET=utf-8

假设该demo表的table_id为138

INSERT操作的UNDO日志#

INSERT操作所对应的UNDO日志类型为TRX_UNDO_INSERT_REC
其日志格式像是这样:

属性名称 描述
end of record 本条日志结束,下一条日志开始时在页面中的位置
undo type 日志类型,TRX_UNDO_INSERT_REC
undo no 本条undo日志对应的编号
table id 本条undo日志记录操作的数据库表
主键各列信息,<len,value>列表 主键每个列占用的存储空间大小和真实值
start of record 上一套undo日志结束,本条开始时候在页面中的地址

举个例子:
我们开始一个事务并且执行一条insert语句:

BEGIN;
insert into undo_demo(id,key1,col)
  values (1,'AWM','狙击枪'),(2,'M416','步枪');
  • 对于insert操作,存储引擎会对每一条新增的记录记录一条对应的undo日志.
  • (1,'AWM','狙击枪')这条记录,它是该事务中的第一个增删改操作,所以给其分配undo no为0,而主键INT数据类型,占用4个字节,真实值为1,我们上面提到过,这张表的table_id为138.
    所以这条记录对应的undo日志应该是这样的:
属性名称
end of record 本条日志结束的位置
undo type TRX_UNDO_INSERT_REC
undo no 0
table id 138
主键各列信息,<len,value>列表 <4,1>
start of record 本条日志开始的位置
  • 根据上面的思路,我们可以得知第二条(2,'M416','步枪')记录对应的undo日志应该是这样的:
属性名称
end of record 本条日志结束的位置
undo type TRX_UNDO_INSERT_REC
undo no 1
table id 138
主键各列信息,<len,value>列表 <4,2>
start of record 本条日志开始的位置

roll_pointer#

我们前面提到说,记录不止有row_id和trx_id隐藏列,还有一个roll_pointer的隐藏列,这个时候roll_pointer隐藏列就派上用场了,这个隐藏列是一个执行记录对应的undo日志的指针.我们需要针对聚簇索引对应的记录来记录undo日志的位置.

就像这样:

DELETE操作的undo日志#

这个我们可以回想一下数据页的一些知识点:

  • 页面中的记录会根据主键值的大小排序,使用记录next_record属性形成一个链表,我们可以称这个链表为正常记录链表.
  • 如果页面中的记录被删除了,并不会直接回收其空间或是直接重新分配空间,而是将其记录deleted_flag设置为1,表示该记录已被删除,这些被删除的记录也会被使用next_record属性来串成一个链表,我们称这个链表为垃圾链表,该数据页中Page Header会指向垃圾链表的头结点.这个链表中的记录可以在下次插入数据的时候进行利用.

所以我们可以将删除一个记录的操作分成俩个阶段:

  1. delete mark: 将被删除记录的deleted_flag设置为1,并且更新其trx_id和roll_pointer隐藏列的值.其他并不会修改(也就是说这个时候该记录还是在正常记录链表中的).
  2. purge:在其删除操作所在的事务提交了,会有专门的线程将其记录真正删除掉.也就是将其记录从正常记录链表移除,移动到垃圾链表的头结点处.然后调整一些数据页面的信息,比如页面用户记录数量,上次记录插入的位置,页目录的一些信息,页面的PAGE_FREE等等.

页面的Page Header中有一个属性PAGE_GARBAGE,该属性表示当前页面可重用空间的字节数,所以当记录被加入到垃圾链表的时候这个属性都要加上删除记录占用的字节大小.
在页面插入新记录的时候,会先去判断PAGE_FREE指向的记录(即垃圾链表的头结点)所占用的空间是否足够插入新记录,如果不够就会直接向页面给申请新的空间来存储这条记录,并不会去遍历垃圾链表.如果足够就会使用该被删除记录的空间,然后PAGE_FREE指向垃圾链表的下一个结点.
这样肯定会出现页面中碎片空间越来越多(头结点的记录存储空间会比新记录的存储空间大小大,不可避免的出现小部分空间无法利用),这些碎片空间也会被PAGE_GARBAGE属性统计在内,当页面快满的时候,插入了一条新纪录进来,发现PAGE_GARBAGE统计的空间足够插入该新记录,就会对对页面空间进行重新分配(开辟一个临时页面->将页面的记录依次插入进去->再把临时页面的内容复制到本页面).

delete undo日志格式#

回到undo日志,delete操作所对应的undo日志type为TRX_UNDO_DEL_MARK_REC,具体日志格式为:

属性名 描述
end of record 本条undo日志结束的位置
undo type TRX_UNDO_DEL_MARK_REC
undo no 本条undo日志的编号
table id 本条undo日志操作的表
info bits 记录头前4个比特的值
trx_id 旧记录的trx_id值
roll_pointer 旧记录的roll_pointer值
主键各列信息 <len,value>列表 主键每个列占用的空间大小和真实值
len of index_col_info 索引列各列信息部分和本部分占用的存储空间大小
索引列各列信息 <pos,len,value> 被索引的各列信息
start of record 本条undo日志开始的位置
  • trx_id和roll_pointer : 我们来看一下,为什么delete操作的undo日志需要存储trx_id和roll_pointer了呢?之前insert操作的undo日志都没有存储,这是因为一条insert的记录之前肯定是没有对该条记录有操作,比如我们不可能先对一条记录进行更新,再插入这条记录.所以不需要记录.
    但是在更新或是删除操作的时候,可能这条记录之前被更新过,所以需要记录下前一条操作的undo日志,这样就可以将该条记录有关的undo日志串起来成一个undo日志链表.我们称这个链表为版本链

  • 索引列各列信息 : 如果记录中有列被包含某个索引中,那么该列的位置、占用的存储空间、实际值就会被就会存储在该部分.该部分信息主要是在purge阶段去使用,用于将中间状态的记录真正的删除.

我们接下来继续举个例子来讲解delete操作的日志:

# 我们假设开启了一个trx_id为100的事务
BEGIN;
insert into undo_demo(id,key1,col)
  values (1,'AWM','狙击枪'),(2,'M416','步枪');

delete from undo_demo where id = 1;
  • 这条delete操作对应是这个事务中的第三条undo日志,所以他的undo no值为2.
  • 事务的trx_id为100,将(1,'AWM','狙击枪')记录中的trx_id取出放入到undo日志中,还有记录的隐藏列roll_pointer记录着insert操作对应的undo日志的位置,将其赋值给delete操作的undo日志,当然,对应记录的trx_id和roll_pointer也会更新.
  • 该表中有俩个索引(聚簇索引和普通二级索引idx_key1),只要被这俩个索引包含的列都需要被写到undo日志的索引列各列信息部分中.
    • 聚簇索引有一个列id,id列在表中的位置pos为0,INT为4个字节,实际值为1.
    • 二级索引idx_key1,有一个列key1,key1在表中的位置在id列、trx_id、roll_pointer列后,所以pos为3,'AWM'占3个字节,len为3,而value就是'AWM'.

所以索引各列信息的值应该是<0,4,1> , < 3,3,'AWM'>

  • len of index_col_info : 根据上一步我们已经得知了索引列各列信息所存储的信息,我们就可以计算'索引各列信息'部分和本部分占用的存储空间大小了.<0,4,1>占用1+1+4=6字节, < 3,3,'AWM'>占用1+1+3=5字节,6+5=11字节,而本部分占用2字节,2+11=13字节,len of index_col_info的值为13.

UPDATE操作对应的undo日志#

我们在上面提到过, update操作需要根据是否有更新主键来选择不同的处理策略,接下来我们就来具体说一说:

不更新主键#

不更新主键又细分成俩种情况:

  • 原地更新
    在更新记录的时候,如果更新后的每个列与更新前的每个列占用的空间大小完全一样(比原来小或是比原来大都是不可以的),那么就可以进行就地更新.

  • 先删除旧记录,再插入新记录
    不更新主键的情况,如果被更新的列和更新前的存储空间大小不一致,那么就需要先将这条记录从聚簇索引中删除,再根据更新后列的值创建一条新的记录插入到页面中.
    这里的删除操作并不是delete mark,而是真正的把这条记录给删除掉,将这条记录从正常记录链表加入到垃圾记录链表中,并且修改页面统计信息等等.并且这里的删除操作并不是purge时由专门的线程来进行处理,而是由用户线程来同步的进行真正的删除操作.
    如果更新后的记录占用的存储空间大小小于旧记录占用的存储空间大小,就可以直接复用旧记录的空间.

日志格式

对于不更新主键的更新操作这种情况,其undo type为TRX_UNDO_UPD_EXIST_REC

属性名 描述
end of record 本条日志结束的位置
undo type TRX_UNDO_UPD_EXIST_REC
undo no 本条undo日志对应的编号
table id 本条undo日志对应的记录所在表的table id
info bits 记录头信息的前4个比特的值
trx_id 旧记录的trx_id值
roll_pointer 旧记录的roll_pointer值
主键各列信息: <len,value>列表 主键每个列占用的存储空间大小和真实值
n_updated 这次操作有几个列被更新了
被更新的列更新前信息:<pos,old_len,old_value> 被更新的列更新前信息
len of index_col_info "索引列各列信息"部分和本部分占用的存储空间大小
索引列各列信息:<pos,len,value>列表 凡是被更新的索引列的各列信息
start of record 本条日志开始的位置

更新主键#

如果更新一条记录的时候将其主键值进行更新,也就说明其记录在聚簇索引的位置要进行改变.
对于这种情况,InnoDB在聚餐索引中分为了俩步来处理:

  1. 先将旧记录进行delete mark,在update语句所在的事务提交之后由专门的线程执行purge操作.
  2. 根据更新后各列的值创建一条新记录,并将其插入到聚簇索引中.

这种情况下,就会生成俩条undo日志,TRX_UNDO_DEL_MARK_REC和一条TRX_UNDO_INSERT_REC类型的undo日志.

还有一种TRX_UNDO_UPD_DEL_REC的undo日志类型, 书中没有介绍.

增删改操作对二级索引的影响#

在INSERT操作和DELETE操作的时候,在聚簇索引中执行造成的影响对于二级索引来说差不多,但是UPDATE操作有些不同.
如果UPDATE操作更新了二级索引所包含的列,那么就需要对其二级索引也进行更新.
这个时候就要对该二级索引数据页进行:
1.旧记录delete mark操作
2.新的记录在B+树中定位其位置,并进行insert操作

当我们对某个表进行插入一条数据的时候,实际上需要向聚簇索引和所有的二级索引都插入一条记录,不过我们在undo日志中不需要记录这些,只需要针对聚簇索引来记录一条undo日志即可.聚簇索引记录和二级索引记录是一一对应的.我们在回滚的时候也会将其聚簇索引和所有二级索引中对应的记录全部删除.DELETE和UPDATE的undo日志也是针对聚簇索引记录的改动来记录undo日志的.

通用链表结构#

我们上面说了undo日志的格式,和具体操作所对应的日志等等,接下来就来讲解下这些undo日志写入的位置,写入过程中有什么问题等等.

undo日志被存储在类型为FIL_PAGE_UNDO_LOG类型的页面中,为了更好的管理页面,我们在将这些页面串成一个链表,在页面中使用ListNode结构存储上一个页面的ListNode结点和下一个页面ListNode结点的位置.

再使用一个链表基结点ListBaseNode来更方便的管理这个链表:

具体ListNode和ListBaseNode示意图如下:

FIL_PAGE_UNDO_LOG页面#

页面部分名称 属性名称 大小 描述
File Header 38B 页面的通用结构
Undo Page Header TRX_UNDO_PAGE_TYPE 2B 本页面准备存储的undo日志的大类(1表示TRX_UNDO_INSERT,2表示TRX_UNDO_UPDATE,除了insert操作产生的undo日志一般都属于是TRX_UNDO_UPDATE类型)
TRX_UNDO_PAGE_START 2B 表示在当前页面中从什么位置开始存储undo日志
TRX_UNDO_PAGE_FREE 2B 表示当前页面最后一条日志结束时的位置
TRX_UNDO_PAGE_NODE 12B 代表一个链表结点
16320B 用于真正存放undo日志以及其他一些东西的位置
File Trailer 8B 页面的通用结构

为什么这里需要限制一个页面只能存储一个undo日志大类呢?

  • 主要是因为insert类型的undo日志在事务提交后可以直接删除掉,而其他类型的需要为MVCC服务, 所以需要区别对待.

Undo页面链表#

一个事务可能会包含多条语句,而一个语句可能对多条记录进行修改,而对这些记录进行改动前,都要记录为undo日志,可能会产生许多的undo日志,所以我们需要将多个Undo页面通过TRX_UNDO_PAGE_NODE属性串联在一起,连成一个链表.

而其链表的第一个Undo页面就称之为first undo page,其不仅承担着存储undo日志的责任,还需要负责存储一些其他的管理信息.其他的页面称为normal undo page.

我们前面在讲解Undo页面结构的时候又说过,一个Undo页面只能存放一个大类的undo日志,所以我们的一个Undo页面链表也只能存放一个大类的undo日志,而对于临时表和普通表的undo日志也需要分开存放,所以对于一个事务来说最多可能会分配4个Undo页面链表:

这些Undo页面链表并不是一个事务刚开启就分配给它的,而是类似于懒加载,当需要使用时才会进行分配.\

多个事务中的Undo页面链表#

为了提高写入Undo日志的效率,不同事务执行过程中会为各个事务分配单独的Undo页面链表.
所以不同的事务会有自己的数个Undo日志链表.就像这样:

当然,也会有Undo页面重用的情况,这种后面会说.

undo日志具体写入过程#

段的概念#

我们之前在将数据页的时候有说过段的概念,段是一个逻辑上的概念,一个段是由数个零散的页面或是区组成.
一个B+树就被划分为2个段,一个是非叶子节点段,一个是叶子节点段.而每一个段对应着一个INODE Entry结构,INODE结构里面描述了各种段的信息,比如段ID,段内的各种链表基结点,零散页面的页号等等.而我们又可以通过Segment Header的结构来定位INODE Entry.

通过表空间ID和页号以及页内偏移量就可以唯一定位一个INODE Entry结构的地址了.

Undo Log Segment Header#

而在Undo页面这,每一个Undo页面链表都对应着一个段.称为Undo Log Segment Header
而在first undo page中有一个部分Undo Log Segment Header,这个部分包含了该链表对应段的Segment Header信息.

Undo Log Segment Header结构

属性名称 大小 描述
TRX_UNDO_STATE 2B 本Undo链表所处于的状态
TRX_UNDO_LAST_LOG 2B 本Undo页面链表最后一个Undo Log Header的位置,注意哦,是Log Header不是我们上面说的Page Header
TRX_UNDO_FSEG_HEADER 10B 本Undo页面链表对应的段的Segment Header信息,就是上面的Segment Header结构
TRX_UNDO_PAGE_LIST 16B Undo页面链表的基结点,存储了链表头结点和尾结点的指针和该链表中结点的个数

TRX_UNDO_STATE这个属性主要的状态有:

  • TRX_UNDO_ACTIVE: 活跃状态,有一个活跃的事务正在向这个Undo页面链表中写入undo日志
  • TRX_UNDO_CACHED: 被缓存的状态,处于该状态的UNDO页面链表在等待之后被其他事务重用.具体重用的规则后面会说.
  • TRX_UNDO_TO_FREE: 等待被释放,我们之前说过,insert大类的日志在事务提交后可以被释放掉,如果其事务提交后,并且该链表不能被重用,就会处于这个状态.
  • TRX_UNDO_PURGE: 等待被purge的状态,如果update undo链表在事务提交后,并且链表不能被重用,就会处于这个状态.
  • TRX_UNDO_PREPARED: 这个状态的Undo页面链表用于存储处于PREPARE阶段的事务产生的日志(这个阶段分布式事务才会使用到)

Undo Log Header#

一个事务向Undo页面上写入undo日志是非常简单粗暴的,写完一条接着写另一条,各个undo日志之间紧密接触.一个页面写完了,再申请一个新的页面加入到Undo页面链表之中.
我们提过一嘴,就是Undo页面链表是存在重用的情况的,所以设计者提出了一个概念:同一个事务向一个Undo日志链表写入undo日志作为一个组.
所以没复用的情况下,一个Undo页面链表中只会有一个组.
而写入一个组的undo日志前要先记录关于这个组的一些属性.我们称这些存储这些属性的地方为Undo Log Header

Undo Log Header的结构如下:

属性名 大小(字节) 描述
TRX_UNDO_TRX_ID 8 生成本组undo日志的事务id
TRX_UNDO_TRX_NO 8 事务提交后生成的一个序号,序号越大事务提交越晚
TRX_UNDO_DEL_MARKS 2 标记本组日志是否宝行由delete mark操作产生的undo日志
TRX_UNDO_LOG_START 2 表示本组undo日志中第一条undo日志在页面的偏移量
TRX_UNDO_XID_EXISTS 1 本组undo日志是否有包含XID信息
TRX_UNDO_DICT_TRANS 1 标记本组undo日志是不是由DDL语句产生的
TRX_UNDO_TABLE_ID 8 如果上一个属性为真,则该属性值为DDL语句操作对象的表id
TRX_UNDO_NEXT_LOG 2 下一组undo日志在页面汇总开始的偏移量
TRX_UNDO_PREV_LOG 2 上一组undo日志在页面中开始的偏移量
TRX_UNDO_HISTORY_NODE 12
XID信息 140


可以看到 first undo page比其他页面多存储了Undo Log Segment Header和 Undo Log Header部分.

重用Undo页面#

前面说过,一般情况下每个事务都会被分配其单独的Undo页面链表.但是有可能某个事务只记录了几条undo日志,而为了这些几条undo日志就浪费了一个Undo日志链表(虽然这个链表也就只有一个Undo页面),所以设计者想了一些办法来复用这些Undo日志链表来减少空间的浪费.
在满足以下俩个条件时,事务提交后可以重用该事务的Undo页面链表:

  • 该链表中只包含一个Undo页面
    如果一个事务写undo日志时申请了许多的页面,提交后如果其他事务想要重用该Undo页面链表,新事务可能并没有写那么多undo日志,链表还是需要维护非常多的页面,而链表其他的页面得不到使用,这也造成了浪费.所以只有一个页面的Undo日志链表才能被重用.
  • 该Undo页面已经使用的空间小于整个页面空间的3/4
    如果这个Undo页面大部分空间都已经被使用了,也就没必要再重用其空间了(因为能够使用的空间不多).

满足了这俩个条件后,还会根据其Undo页面链表存储的undo日志大类来选择不同的重用策略.

  • insert undo 链表
    对于insert undo 链表,在事务提交后就没有用了,可以直接将其清除掉.所以重用的时候可以直接进行覆盖.
  • update undo 链表
    而对于update undo 链表,其undo日志就不能直接删除了,因为还要为MVCC服务.所以就需要末尾追加,并且添加新的Undo Log Header

在重用的时候也会对页面的Undo Page Header,Undo Log Header,Undo Log Segment Header,Undo Header中的一些属性进行调整

回滚段#

回滚段的概念#

一个事务最多会被分配到4个Undo日志链表,不同事务又拥有不同的Undo日志链表,同一时间系统中会存在许多的Undo日志链表,为了更好的管理这些链表,设计者设计了一个Rollback Segment Header的页面类型,这个页面中存放了各个Undo日志链表中的first undo page的页号,我们称这些页号是undo no.

Rollback Segment Header页面结构#

属性名称 占用大小(字节) 描述
File Header 38 页面的通用结构
TRX_RSEG_MAX_SIZE 4 该回滚段中所有Undo页面链表中的Undo页面数量之和的最大值,默认为0xFFFFFFFE,非常大
TRX_RSEG_HISTORY_SIZE 4 History链表占用的页面数量
TRX_RSEG_HISTORY 16 16
TRX_RSEG_FSEG_HEADER 10 这个回滚段对应的10字节大小的Segment Header结构
TRX_RSEG_UNDO_SLOTS 4096 各个Undo页面链表的first undo page的页号集合,undo slot集合,一个页号4个字节,4096能够存储1024个undo页面页号
没用的部分
File Trailer 8B 页面的通用结构

设计者认为每一个Rollback Segment Header页面都对应着一个段,我们称这个段为回滚段.当然这个段和我们上面每一个Undo页面链表对应的段不一样,一个回滚段能对应多个Undo页面链表的段.

这里所说的History 是下一章事务隔离和MVCC要说的,这里就不细说了

从回滚段中申请Undo页面链表#

一开始的时候并没有事务进行写undo日志,而Rollback Segment Header页面中的undo solt列表各项就都是一个特殊的值FIL_NULL(对应的十六进制值为0xFFFFFFFF)
FIL_FULL:代表该undo solt没指向任何页面.

这个时候,有事务需要开始提交undo日志:
首先从Rollback Segment Header页面的undo solt列表的第一项寻找起.

  • 如果该undo solt项值是FIL_NULL,则从表空间中新建一个段(Undo Log Segment),然后从段中申请一个页面作为Undo页面链表的first undo page,而后将undo solt指向的地址指向申请的这个页面(first undo page)的地址.
  • 如果该undo solt项指向了一个undo页面链表,则说明该项已经被分配给其他事务了,则继续向后寻找值为FIL_NULL的undo slot.

如果一个Rollback Segment Header页面中的1024个undo solt都名花有主了,这个时候客户端再尝试开启一个事务,就会报错:Too many active concurrent transactions

对于事务提交之后,我们对Rollback Segment Header页面也会对对应的undo solt进行处理:

  • 对于undo solt指向的是可以重用的undo页面链表,这时first undo page中的Undo Log Segment Header的TRX_UNDO_STATE会被设置为TRX_UNDO_CACHED,表明该页面链表可以被复用.

    • 如果undo solt指向的是处于TRX_UNDO_CACHED状态的undo页面链表,其undo solt会被放入一个链表,会根据存放的undo日志大类来选择存放到不同的链表,insert undo页面链表会存放进insert undo cached链表,而update undo页面链表会存放进update undo cached链表.

所以上面说的从回滚段分配页面,从undo solt第一项开始寻找前,应该前面还有一步:先从对应的undo cached链表中寻找是否有undo日志链表可以重用,没有的话再从回滚段中寻找空闲的undo solt.

  • 对于不可以重用的undo日志链表
    • insert undo日志链表的段会直接被释放掉,并且其undo solt值会设置成FIL_NULL.
    • update undo日志链表其的TRX_UNDO_SATE属性会被设置成TRX_UNDO_TO_PURGE,然后将undo solot的值设置成FIL_NULL,最后将本次事务写的一组日志写入到History链表之中.

多个回滚段#

我们上面说过一个回滚段有1024个undo solt,也就是能够存储1024个undo页面链表,但是要是数据库的一段时间内有1024个并发读写事务呢?那数据库就直接不能用了?

  • 所以设计者总共定义了128个回滚段,并且将这128个回滚段的地址都存入系统表空间页号为5的页面.
    128×1024=131072,这个数量总不用担心并发读写事务时,回滚段中的undo solt不够用了吧.

回滚段的分类#

回滚段根据是否在临时表空间来分类,我们将回滚段进行从0开始编号,有0~127号的回滚段:

  • 0和33~127编号的回滚段:都是在系统表空间或是在自己配置的undo表空间中(0号回滚段必须在系统表空间).
  • 1~32编号的回滚段:必须在临时表空间(对应数据目录中的ibtmpl文件)中.如果一个事务中有针对临时表的改动,必须要从这些回滚段中进行分配undo solt.

为什么要将临时表和普通表的回滚段进行分类呢?

  • 不要忘了,我们的UNDO页面也是普通的页面,对UNDO页面的写入也是需要记录其REDO日志的,对UNDO页面的有MLOG_UNDO_HDR_CREATEMLOG_UNDO_INSERTMLOG_UNDO_INIT等日志类型来记录其更新.
    但是对于临时表修改的UNDO日志不需要在崩溃后进行恢复,所以对于1~32编号回滚段中的UNDO日志在写入的时候就不记录其REDO日志.

为事务分配Undo页面链表的详细过程#

我们上面介绍了很多Undo日志格式是什么样的,Undo日志是写在哪里的,Undo页面的格式,以及回滚段等概念,接下来我们就从头开始过一个事务从开启到为其分配Undo页面链表的详细过程.

  1. 一开始事务中没有对表有更新操作的时候是不会对其分配回滚段和Undo页面链表的.
  2. 当事务执行中,对普通表进行了增删改操作,就会去系统表空间5号页面中分配一个回滚段(也就是获取一个Segment Rollback Header页面的地址).一但这个事务被分配了对应的回滚段,之后这个事务对普通表的修改都不会重复分配回滚段了,而是使用这个回滚段.
    当有多个事务需要分配回滚段的时候,分配的策略是round-robin(循环使用),像是轮询一样,即比如1号事务分配到了33号回滚段,则2号事务分配到的就是34号回滚段,接下来的事务也是一样,周而复始.
  3. 在事务分配到回滚段之后,会查看该回滚段中的俩个cached链表中是否有Undo页面链表可以进行重用,如果是insert大类的日志则去insert cached链表中寻找有没有缓存的undo solt,如果是update大类的日志则去update cached链表中寻找.如果有可用的缓存undo solt,则直接将这个undo solt分配给该事务.
  4. 如果没有可用的缓存undo solt,则需要从Segment Rollback Header页面中分配一个可用的undo solt给这个事务,从第0个undo solt开始寻找一个值为FIL_NULL的undo solt,如果有则分配给事务,没有则继续向后寻找,如果这1024个undo solt都被分配了(即值都不为FIL_NULL),就会报错.
  5. 如果事务获取到是cached链表中的undo solt,那么其Undo Log Segment已经分配了,否则就需要再申请一个Undo Log Segment,然后再从该Undo Log Segment申请一个Undo页面作为Undo页面链表的first undo page.
  6. 接下来事务就可以向上一步的Undo页面链表中写入Undo日志了.

以上说的是普通表写Undo日志的情况,如果是临时表也差不多,只是分配的回滚段编号为1~32了,如果一个事务对普通表进行了修改,又对临时表进行了修改,那么这个事务就会被分配俩个回滚段.并且临时表的Undo日志写入不需要写redo日志了.

事务执行遇到增删改->分配回滚段->从回滚段中获取一个undo solt->如果undo solot是新分配的,则为其分配Undo Log Segment,并且申请first undo page->向Undo日志链表中写入日志

回滚段相关配置#

回滚段的数量#

我们可以通过innodb_rollback_segments来配置回滚段的数量.可配置范围是1~128
但是这个系统变量有一个特点,就是并不会影响到临时回滚段的数量(怎么样配置临时回滚段数量都是32).
如果配置该项为1~33,都是会有1个普通表回滚段和32个临时回滚段.
只有在大于33时,普通表的回滚段数量就会是innodb_rollback_segments值-32

配置undo表空间#

我们现在的普通表回滚段都是在被分配到系统表空间的,我们如果想除0号回滚段之外的回滚段可以被分配到undo表空间,可以在在数据目录初始化的时候可以通过配置项来创建undo表空间.
相关的启动项:

  • innodb_undo_directory : undo表空间所在的目录,默认是数据目录
  • innodb_undo_tablespaces : undo表空间的数量,默认为0,则不创建任何的undo表空间

如果配置了innodb_undo_tablespaces,则33~127号回滚段会平均分配到各个undo表空间中,但是0号回滚段就不可使用了.

为什么我们undo日志在系统表空间好好的,还要提出一个undo表空间呢?

  • 在undo表空间中的文件大到一定程度,能够自动将其截断(truncate)成小文件,而系统表空间不能进行这样的操作,只能任其不断地增大.

二十一章 事务隔离级别和MVCC#

我们知道Mysql是一个B/S(客户端/服务端)架构,所以它需要具备同时处理来自多个客户端请求的能力,也就需要保证同时处理多个事务时的正确性.
我们视一次事务对应着现实生活的一次状态转换,而事务执行后的状态需要满足现实生活中的所有规则,这就是事务的一致性.

数据库中的事务需要满足四个特性:A(原子性)C(一致性)I(隔离性)D(持久性)

而同时处理多个事务,为了保证其事务执行后结果的一致性,就不能让一个事务的执行结果被另一个事务影响,所以我们就需要保证事务的隔离性.

那我们怎么实现事务的隔离性呢?
我们这里可以选择粗暴的将数据库中的事务进行串行执行,这样一个事务开始执行的时候,上一个事务已经执行完毕,就能够保证这些事务的隔离性了,但是这也导致并发性能会大幅度降低,我们自然不希望在什么时候一个数据库中只能有一个事务执行啦.

我们可以先归纳下,多个并发事务之间的操作关系一般有:

  • T1:读 T2:读
  • T1:读 T2:写
  • T1:写 T2:读
  • T1:写 T2:写

除了读-读情况下,其他三种情况的并发事务都可能造成一定的一致性问题.

事务并发执行时遇到的一致性问题#

为了接下来更方便的介绍并发事务可能会遇到的一致性问题,我们用w1[x]代表:事务1在x数据项处写入了数据,r1[x]表示事务1读取了x数据项的数据,而c1表示事务1进行提交,a1表示事务1进行了回滚.

脏写(写-写)#

如果一个事务修改了另一个未提交事务修改过的数据,这种情况我们就称为脏写

事务T1 事务T2
更新a记录的值
更新a记录的值
... ...
事务提交或是回滚
事务提交或是回滚

比如我们要求一个表的记录中,x字段和y字段的值必须保持一致,如果出现了以下情况的话会怎么样呢?
同时有俩个事务进行执行,一个更新a记录为x=1,y=1;而另一个更新a记录为x=2,y=2

事务T1 事务T2
a记录的x=1
a记录的x=2
a记录的y=2
事务提交
a记录的y=1
事务提交

这俩个事务并发执行之后,a记录变成了x=2,而y=1,变得并不一致了,也就不符合了一致性的要求.

而且脏写也可能会影响到事务的原子性和持久性:

事务T1 事务T2
a记录的x=1
a记录的x=2
a记录的y=1
事务提交
事务回滚

在这种情况下,事务T2已经提交了,但是事务T1进行了回滚.并且对T1进行回滚的话,那对事务T2修改的内容(只回滚x=2)就要进行部分回滚,这又破坏了原子性.但是如果队T2修改的内容进行全部回滚的话,那又破坏了持久性,明明事务T2已经提交了,但是修改的内容却丢失了.

脏读(写-读)#

如果一个事务读取到了另一个事务没有提交的数据,那么我们称这种情况为脏读

事务T1 事务T2
a记录x字段更新为1
读取a记录,其x=1
... ...
事务提交或回滚 事务提交或回滚

脏读也有可能导致一致性的问题,如果我们希望获取到的记录的x,y字段永远保持一致:

事务T1 事务T2
a记录x字段更新为1
读取a记录,其x=1,y=0
a记录y字段更新为1
... ...
事务提交或回滚 事务提交或回滚

这种情况就导致了事务T2获取到的a记录的x,y字段并不是一致的,虽然最终事务T1的状态还是一致的(最终一致性),但是在事务执行中的不一致的状态还是被暴露给了用户.

脏读也可能会导致查询到根本不存在的值

事务T1 事务T2
a记录的x字段更新为1
读取a记录,其x=1
事务回滚
事务提交

这种情况下事务T2读取到的x=1,在事务T1进行回滚后,x=1的值就不存在了.

不可重复读(读-写)#

如果一个事务修改了另一个未提交事务读取的数据,导致另一个未提交的事务后续再次进行读取的时候获取到的数据不一致.

事务T1 事务T2
读取到了a记录x=1
a记录的x字段更新为2
读取到了a记录x=2
事务提交 事务提交

事务T1进行了俩次读取,第一次获取到了x=1,而事务T2修改了事务T1读取的a记录,更新x为2,而第二次事务T1又进行了一次读取,读取到的却是x=2,这就导致了事务T1获取到的数据不一致.

幻读(读-写)#

当一个事务根据某些条件来进行查询一些数据,在该事务未提交的时候,另一个事务又写入(指insert,delete,update语句)了符合这些搜索条件的记录,就出现了幻读.

事务T1 事务T2
读取到a记录,b记录
插入c记录
读取到a记录,b记录,c记录
事务提交 事务提交

有没有发现,不可重复读和幻读出现的问题有点类似,都是读-写问题,并且都是读取到的本事务不存在的数据.我觉得可以认为,不可重复读主要强调的是事务读取到的同一条记录的数据不同(被其他事务所更新),而幻读主要强调事务读取到的记录比之前的记录多(被其他事务所插入,或是更新),并且能够解决幻读问题只能通过串行执行来真正解决.

SQL标准的四种隔离级别#

我们上面介绍了下并发事务执行时可能出现的问题,这些问题的严重性我们对其排个序:
脏写 > 脏读 > 不可重复读 > 幻读
之前说过,我们并不希望在所有情况下对并发事务串行执行来解决执行时的一致性问题,所以就有人设计了一些隔离标准,隔离标准越高,在并发事务执行时的出现的问题种类也就越少.

隔离级别 脏写 脏读 不可重复读 幻读
READ UNCOMMITTED (读未提交) 不可能 可能 可能 可能
READ COMMITTED (读已提交) 不可能 不可能 可能 可能
REPEATEABLE READ (可重复读) 不可能 不可能 不可能 可能
SERIALIZABLE (可串行化) 不可能 不可能 不可能 不可能

MySql中支持的4种隔离级别#

MySql默认的隔离级别为Reapeat Read(可重复读).并且MySql的Reapeat Read隔离级别能够很大程度上禁止幻读现象的发生.
我们可以通过以下这个命令来设置MySql的事务隔离级别:
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL level

而命令中level的可选值:REPEATEABLE READ,READ COMMITTED,READ UNCOMMITTED,SERIALIZABLE

当该命令中设置的是GLOBAL时,全局范围的事务都会遭受影响(当然,已存在的会话的事务隔离机制并不会更改)
当该命令中设置的是SESSION时,会话的事务都会遭受影响,当前会话之后的所有事务都会收到影响,当然,如果是在事务中执行的该语句,该事务是不会受到影响的,后一个事务才会.
当该命令中不设置范围时,仅仅对执行该语句的后一个事务造成影响.

如果我们想要设置服务端默认的事务隔离机制,那么可以通过启动时候设置系统变量transaction-isolation的值

想要查看当前会话默认的事务级别可以通过查看系统变量transaction_isolation的值
SHOW VARIABLE LIKE transaction_isolation``

MVCC原理#

版本链#

之前介绍过InnoDB存储引擎的表中,聚簇索引下的记录都有俩个隐藏列:

  • trx_id 有事务对聚簇索引的记录改动时,会将该事务的事务id赋值给该记录的trx_id隐藏列
  • roll_pointer 对聚簇索引进行改动时,会将旧版本的记录写入到undo日志之中,然后将指向该undo日志的指针赋值到该记录的roll_pointer隐藏列中,这样就可以通过它寻找到该记录之前的值了.

通过roll_pointer可以将该记录对应的数次修改的undo日志来串联起来,我们称这个链为版本链

当然,我们之前说过insert undo log在事务结束后就会进行回收,进行重用或者释放,所以roll_pointer指针的第一比特位代表undo日志的大类,如果为1则是insert undo日志.

为什么insert undo log不需要在MVCC时使用呢?可以想一想,MVCC是多版本并发控制,让各语句其查询的记录为其所在事务有权限看到的,而一个处于还未提交的事务中新插入的数据肯定是不能让其他事务对其进行操作的,会造成脏写.
而如果insert语句所处于的事务已经被提交,而其他事务是可以对其操作的,虽然可能会造成幻读问题,但是幻读问题主要靠加锁来解决,也不需要insert undo log来为其服务


当记录每次更改的时候,聚簇索引记录都会将roll_pointer和trx_id指向最新的undo日志(即本次更新操作所对应的undo日志),多个版本串连下来,就会像是这样:

我们可以看到上面图片中的trx_id从上到下是递减的,那么有没有可能就是trx_id不是递减的呢?

  • 我们可以来思考一下trx_id的生成方式:trx_id是一个事务对记录进行读写的时候才会生成的,而多个事务对同一条记录同时进行修改,会对事务进行加锁,让一个记录时间内只会被一个进行中的事务修改.而trx_id越小的事务也就越早进行修改,也就能占有锁,所以一般来说都会是递减的?
    那么有没有可能后生成的事务先占用了锁呢?我们可以看下这种情况:而要是俩个事务交叉修改俩条记录,就比如:
    事务trx_id:100的操作为:先修改id为1的记录,然后再修改id为2的记录
    事务trx_id:200的操作为:先修改id值为2的记录,然后再修改id值为1的记录
    这个时候事务事务200先获取到了id值为2的记录的锁,而100事务比200事务晚获取,而100事务先获取了id值为1记录的锁,由于这俩个事务都占有着锁,需要等待事务提交才能释放,所以死锁了.

ReadView#

我们上面提过四种隔离机制,而其中READ UNCOMMITTED(读未提交),可以读取到未提交事务进行修改的记录,所以不需要使用MVCC进行控制,直接读取最新的记录即可.
而SERIALIZABLE(可串行化)隔离机制的事务,直接以锁的方式来访问记录,也不需要使用MVCC进行控制.
所以我们接下来的重点就是MVCC如何实现READ COMMITTED(读已提交)REPEATABLE READ(可重复读)这俩个隔离机制了.

这俩个隔离机制的相同点就是,其必须读取到的是已提交事务的的记录,如果是其他未提交的事务修改的记录,是不能在该事务读取到其改动的.

而设计者提出了一个概念来解决这个问题:
ReadView,又称一致性视图,我们接下来看看它是怎么解决的:
ReadView中主要包括以下属性:

属性名称 描述
m_ids 在生成ReadView时,当前系统中活跃的读写事务的事务id列表
min_trx_id 在生成ReadView时,当前系统中活跃的读写事务id列表中的最小的事务id
max_trx_id 在生成ReadView时,系统应该为下一个事务分配的事务id
creator_trx_id 生成该ReadView的事务的事务id
事务no 当前系统中最大的事务no+1

只有在事务在进行增删改操作的时候,系统才会给该事务分配一个唯一的事务id,否则都会为一个默认值(可能是0,看Mysql版本)

接下来我们就可以利用ReadView中所存储的属性来判断版本链中的哪个记录的版本才是该事务可见的了:

  • 如果ReadView中的creator_trx_id 与该版本的trx_id与相同,则这个版本是来源于本事务的修改,则本事务可以访问该版本
  • 如果ReadView中的min_trx_id大于该版本的trx_id,则说明修改该版本所在的事务已经提交,则本事务也可以访问该版本
  • 如果ReadView中的max_trx_id小于该版本的trx_id,则说明修改该版本的事务在当前事务生成ReadView之后才开启,所以该版本也不能被本事务访问
  • 如果ReadView中的min_trx_id小于该版本的trx_id,而max_trx_id也大于该版本的trx_id,则会从ReadView中的m_ids中的当前活跃事务id列表中寻找是否有该版本的trx_id,如果有,说明该版本所属的事务在创建ReadView时,还是活跃的,则该版本不能被本事务访问,如果没有,则说明该版本所属的事务已经提交,可以被访问.
  • 如果某个版本的不能被本事务访问,则会继续顺着版本链,直到寻找到一条可以被访问的版本为止(按照上面的规则).如果最后一个版本也不能被访问,则查询结果不会包含这条记录.

按照以上思路,我们已经可以通过ReadView来判断记录的某个版本是否可以被本事务所访问了,那么如何来使用它来实现READ COMMTTED(读已提交)REPEATABLE READ(可重复读)呢?

  • 实现这俩个事务隔离机制,主要是ReadView的生成时机不同.
    • READ COMMTTED(读已提交) 在每次读取数据前都会生成一个ReadView
    • REPEATABLE READ(可重复读) 只在第一次读取数据的时候生成一个ReadView

通过MVCC(Module Version Concurrency Controller 多版本并发控制),可以让不同读-写,写-读事务之间并发执行,提高了系统的性能.

之前我们说过,delete操作或是update操作并不会立即将对应的记录(其聚簇索引记录和二级索引记录)从页面中完全删除,而是先打上一个delete mark标记,这就是为了其MVCC服务,如果开启了一个REAPEATABLE READ事务,而后另一个事务将这条记录给彻底删除了,那么该事务就会再也查询不到该条记录了.所以再删除操作或是更新操作时,就只是先打上delete mark标记,为了保证其记录对于其他事务的可见性.

purge#

我们已经知道了,被进行delete或是update操作的记录,并不会立即删除,而是先打上delete mark标记,而后在purge阶段才会真正进行原记录的删除.

其update undo日志也是,因为还需要支持MVCC,所以不能在事务提交之后立即删除,而insert undo日志在事务提交后就可以立即删除了.

update undo日志虽然需要为MVCC服务,不能在事务提交后就直接进行删除,但是这些日志并不是永远有用的,比如这些update undo日志所属的事务已经提交了,并且在该事务提交之前所生成的ReadView不再访问它们了,那么这些旧版本就属于是没有其他事务会访问到的了,这种时候我们就可以考虑删除.

  • TRX_UNDO_HISTORY_NODE:一个事务写的一组undo日志中,都有一个Undo Log Header部分,这个部分中又有一个名为TRX_UNDO_HISTORY_NODE的属性,这个属性表示了一个History链表的节点,当一个事务提交之后,就会把这个事务执行过程中产生的这一组update undo日志插入到History 链表的头部.

而每一个回滚段都对应着一个Rollback Segment Header的页面(一个回滚段对应最多1024个undo solt,而一个undo solt则对应一个undo页面链表),这个页面之中也包含着关于HISTORY链表的属性,这样回滚段就能更好的管理其下undo solt的History节点了:

  • TRX_RSEG_HISTORY:表示History链表的基结点
  • TRX_RSEG_HISTORY_SIZE:表示History链表占用的页面大小

每当一个事务在一个回滚段下写入的一组update undo日志,在该事务提交之后,就会加入到这个回滚段的History链表中,系统中同时可能会存在多个History链表(毕竟系统中可是有多个回滚段的嘛)

那么那些被打上delete mark标记的记录什么时候被真正删除呢?

  • TRX_UNDO_DEL_MARKS: 在Undo Log Header中的属性,用来标记本组undo日志中是否包含因delete mark操作而产生的undo日志.

而这个时候purge这个阶段的真正目的来了:

  • 为了节约空间,能够在合适的时候将这些update undo日志以及被打上delete mark标记的记录真正删除掉.

那么这个合适的时候是怎么判断的就非常重要了,要让这些update undo日志和被删除的记录不会影响被访问的记录.设计者是怎么去做的呢?

  • 我们可以认为,系统中最早的ReadView(并不是指系统启动以来,我认为是指当前系统中生成最早的)不再访问它们的时候,即它们所对应的事务已经提交时,这些数据就可以被清理了.
  • 在一个事务提交的时候,会为这个事务生成一个事务no的值,用于表示事务提交的顺序,该值越小,其事务提交的越早,该值越大,其事务提交的越晚.
  • 当事务提交之后,还会将事务no的值填充到其那组Undo日志的Undo Log Header中的TRX_UNDO_TRX_NO属性中,而回滚段所对应的History链表中的undo日志是按照事务no来进行排序的,事务no越小的排越前.
  • 而为了判断ReadView生成时,哪些事务已经提交,所以ReadView还有一个属性事务no,当生成ReadView的时候会将当前系统中最大的事务no+1赋值给这个事务no属性.
  • 同时设计者还将当前系统中所有的ReadView按照创建时间从小到大连成了一个链表.
  • 当专门的后台线程进行purge操作的时候,会取出这个系统中最早生成的ReadView,如果当前系统中不存在ReadView就直接新建一个(新建的这个ReadView的事务no会比当前已经提交的事务no大),
    然后从各个回滚段的History链表中取出事务no值较小的各组undo日志,将这个ReadView的事务no与这些undo日志的Undo Log Header中的TRX_UNDO_TRX_NO来进行比较,如果undo日志的TRX_UNDO_TRX_NO比ReadView的事务no小,说明这个undo日志已经没有用了,就会将其从History链表中移除,并且释放掉其占用的存储空间.如果该组undo日志的Undo Log Header中的TRX_UNDO_DEL_MARKS值为1,说明该组undo日志中包括被delete mark标记的记录,也会在这个时候将其记录给真正删除掉.

要注意一下,如果一个ReadView生成的比较早,并且一直都还在复用(就比如REPEATEABLE READ事务隔离机制的情况下),那么这个事务在提交之前,这个ReadView也不会释放,会导致系统中积累的update undo日志和被打了delete mark标记的记录无法被及时删除,也就越来越多.版本链也会越来越长,影响系统性能.

第二十二章 锁#

解决并发事务带来问题的俩种基本方式#

并发情况下的事务之间一般有以下几种关系:

  • 读-读: 并发事务相继读取相同的记录,而读取的操作并不会对记录造成影响,所以这种情况不会引发问题,允许这种情况的发生.
  • 写-写: 并发事务相继对相同的记录进行改动
  • 读-写和写-读: 一个事务进行读取操作,而另一个事务进行改动操作

写-写操作#

多个事务对相同的记录进行修改,都会造成脏写的问题,这个问题不管在任何隔离进制下都是不容忍受的,所以在多个未提交事务需要对相同的记录对相同的记录进行更改的时候,需要让它们来排队处理,而这个排队的过程是通过锁来实现的.
当一个事务对一条记录进行修改的时候,会在内存中查看是否有与该条记录相关联的锁,如果没有,就会在内存里为这条记录和当前事务生成对应的锁结构.
锁结构中有许多的属性,我们暂时先介绍俩个属性:

  • trx信息: 这个锁关联的事务信息
  • is_waiting: 当前事务是否在等待
    我们先来举一个加锁的流程例子:
  1. 有一个事务T1想要对某条记录进行改动,这个时候这个记录暂时没有被别的事务占用,也就没有对应的锁结构:
  2. 事务T1在内存中没有发现与这个记录有关联的锁结构,所以直接为该记录生成与事务关联的锁,其is_waiting属性为false,表示本事务获取锁成功,事务继续运行.
  3. 事务T2进来,同样对该记录进行改动,但是在内存中发现已有和该记录关联的锁结构了,所以只能生成与该记录关联的一个is_waiting属性为true的锁结构,表示本事务正在等待,获取锁失败.
  4. 当事务T1执行结束后,就会释放掉本事务关联的锁结构,然后检测一下是否有与该记录关联的锁结构,如果有,就会将其is_waiting设置为false, 然后将该事务的线程唤醒,继续处理.

读-写 和 写-读 情况#

在读-写 写-读的情况下回出现脏读,不可重复读,幻读的现象.
在MySql对REAPEATABLE READ隔离级别的实现下,能够很大程度的解决幻读的问题(还是有一定可能会出现).

解决这些问题主要有俩种办法:

  • 读操作使用MVCC,而写操作进行加锁
    在读操作的时候生成一个ReadView,使事务的读操作只能获取到在生成ReadView之前已提交事务的记录版本.在READ UNCOMMITTED隔离机制下,每次读操作都生成一个ReadView,使每次读操作都只能获取到已提交事务的版本,而REPEATABLE READ隔离机制下,只有第一次读操作生成ReadView,之后的每次读操作都复用这个ReadView,使之后的读操作都获取的是第一次读操作之前提交的事务修改的记录版本.也就避免了不可重复读和幻读.
  • 不论是读写操作都进行加锁
    即使是读操作我们也进行加锁,有时候我们的业务要求,在读取一个记录之后,就不希望其他事务对该记录进行修改,只有在等本事务提交之后才可以对该事务进行修改.
    比如一个银行存款的操作,我们需要将数据库中账户余额加上这次存入的金额,然后再存入到数据库中,我们肯定是不希望在我们读取账户余额后(修改余额前),有另外一个操作进来对账户余额的值进行修改,那这样可能会导致账户的余额错误.所以在读操作的时候就直接对记录加锁,这样只有在本事务完成之后其他事务才可以进来操作.

如果采用的是MVCC,这样就可以让读写操作并发执行,性能更高,而在特殊业务需求下我们需要对读操作也进行加锁,这就需要看具体场景了

一致性读#

事务利用MVCC进行读取的操作称为一致性读,或者一致性无锁读,快照读等.
所有的普通SELECT语句在READ COMMITTED,REPEATABLE READ隔离机制下的都算是一致性读.

锁定读#

我们前面说过可以通过给读操作也加锁来解决那些并发时可能会出现的问题.
但是我们只需要解决读-写,写-读,写-写之间的问题,而读-读时候并没有问题需要解决.
所以我们就想,能不能对于读-读之间并不进行堵塞,而对于读-写或是写-读,写-写进行堵塞呢?

  • 所以设计者给锁分了个类:
    • 共享锁(Shared Lock): S锁,当事务要读取一条记录的时候,需要先获取该记录的S锁
    • 独占锁(Exclusive Lock): X锁,排他锁,当事务要改动一条记录的时候,需要先获取该记录的X锁

S锁和S锁之间是兼容的,而X锁和其他X锁与S锁是不兼容的.
也就是说,如果事务T1对一个记录加了S锁,而事务T2进来也想获取到该记录的S锁,事务T1和T2同时持有该记录的S锁
而如果事务T1加了S或是X锁,而事务T2想要获取该记录的X锁,那么事务2就会被堵塞,直到事务T1提交并将锁释放掉.
而事务T1加了X锁,不管事务T2是加S锁还是X锁都会获取锁失败,堵塞等待

兼容性 X锁 S锁
X锁 不兼容 不兼容
S锁 不兼容 兼容

我们前面说过有时候也需要给读操作也进行加锁.
根据前面介绍的锁类型,自然加的锁也有俩种:

  • 对读取的记录加S锁
    SELECT ... LOCK IN SHARE MODE;
  • 对读取的记录加X锁
    SELECT ... FOR UPDATE;

写操作#

  • DELETE: 对于一条记录进行删除,实际上就是先在B+树中定位到对应的记录,然后获取这条记录的X锁,再对该记录进行delete mark操作.
    我们可以将从B+树中定位到该记录的位置,然后获取该记录的X锁这个操作看做一次获取X锁的锁定读
  • UPDATE: 对于一个UPDATE操作,就像是之前编写UNDO日志一样,分不同的情况:
    • 没更新键值并且更新之后的各列占用空间与更新前一致,那么会先在B+树中寻找到对应的位置,然后获取记录的X锁,直接在原本的位置进行修改.同样前面的定位和获取X锁的操作也可以视为一个获取X锁的锁定读.
    • 没更新键值,但是更新前后有列的占用空间与更新前不一致,那么就会先在B+树中定位到对应记录的位置,获取该记录的X锁,然后彻底删除掉该记录(不是delete mark操作,而是直接加入到垃圾链表中),然后再插入一条新纪录.这个在B+树中定位记录的位置,并且对该记录进行加锁,的操作也视为一个获取X锁的锁定读,并且在删除这个记录后,旧记录的锁会转移到新插入的记录上.
    • 更新了键值,就相当于在原记录上进行一次DELETE操作后又进行了INSERT操作,加锁操作就和DELETE操作和INSERT操作一样了.
  • INSERT: 一般情况下,新插入的记录受隐式锁保护,不需要再为其在内存中生成对应的锁结构.

多粒度锁#

我们前面说的锁都是针对记录粒度的锁,也可以称为行级锁或是行锁,但其实事务也可以直接为整张表加锁,我们称为针对表粒度的锁为表级锁或是表锁
同样,也可以对表加S锁或是X锁

  • 给表加S锁时
    如果一个事务给该表加了S锁:
    • 其他事务可以继续获取该表的S锁
    • 其他事务可以继续获取该表中记录的S锁
    • 其他事务不可以再继续获取该表的X锁
    • 其他事务不可以再获取该表中记录的X锁
  • 给表加X锁时
    如果一个事务给该表加了X锁:
    • 其他事务不可以继续获取该表的S锁
    • 其他事务不可以继续获取该表中记录的S锁
    • 其他事务不可以再继续获取该表的X锁
    • 其他事务不可以再获取该表中记录的X锁

不过我们给表加表锁的时候,需要注意该表中是否有记录被加了不兼容的行级锁(比如表中有记录被加了X锁,这个时候就不能给表加X锁了,需要等待),但是我们怎么知道表中的记录是否有被加锁呢?难道遍历记录区判断吗?

  • 所以设计者又提出了一种锁: 意向锁(Intention Lock) ,当然,意向锁也分为IS(意向共享锁) IX(意向独占锁)
    • IS锁: 当事务给表中的某些记录加行级S锁时, 先对表加IS锁.
    • IX锁: 当事务给表中的某些记录加行级X锁时, 先对表加IX锁.

有了意向锁,在之后加表级别的S锁和X锁,就可以迅速的判断表中是否有记录加锁了,不需要通过遍历手段来判断.
所以IS锁和IX锁是互相兼容的(毕竟它只是用于表示该表中有记录被加了S锁或是X锁嘛,比如一些记录被加了X锁并不影响另外一些记录被加S锁或是X锁)

表级锁的兼容性:#

兼容性 X IX S IS
X 不兼容 不兼容 不兼容 不兼容
IX 不兼容 兼容 不兼容 兼容
S 不兼容 不兼容 兼容 兼容
IX 不兼容 兼容 兼容 兼容

MySql中的行锁和表锁#

不同存储引擎对于表的支持也是不一样的,我们可以简单介绍一下一些存储引擎的锁

其他存储引擎中的锁#

MyISAM,MEMORY,MERGE这些存储引擎都只支持表级锁,并且这些存储引擎并不支持事务,所以使用这些存储引擎的锁一般都是针对当前会话而言的.
Session1对表进行SELECT 操作,就是要获取这个表的S锁,而Session2对表进行UPDATE操作,就是要获取这个表的X锁,如果Session1的SELECT操作没有执行完,Session2的UPDATE操作就需要等待Session1的SELECT操作完并释放掉该表的S锁,才可以继续执行.
这些存储引擎的表只支持在同一时刻被一个会话对表进行写操作.不过MYISAM支持一个特性:并发插入,可以在读取该表的时候同时插入记录,提高处理速度.

InnoDB存储引擎的锁#

InnoDB存储引擎支持多粒度的锁,表粒度的锁占用资源少,但是粒度过大,在我们只想锁住数条记录的时候,性能较差.行级锁粒度细,可以进行更精准的并发控制,不过占用资源较多,如果我们要对表中的许多记录加锁,就要为这些记录各自创建对应的锁结构(不过后面会介绍其实在符合某些条件下,一个锁结构是可以对应多条记录的).

InnoDB的表级锁#

表级别的S锁,X锁

在执行SELECT,UPDATE,DELETE,INSERT等操作的时候,是不会给表添加表级别的X锁或是S锁的.
在执行一些DDL语句,如ALTER TABLE,DROP TABLE等DDL语句的时候,其他事务对这个表并发执行的SELECT,UPDATE等语句会发生阻塞;某个事务对表进行SELECT,UPDATE做操作后,其他会话对表进行的DDL语句也会进行阻塞;但是这并不是通过对表加表级锁来实现的,而是通过在server层
使用一种称为MDL(元数据锁)来实现的.
InnoDB存储引擎提供的表级别的S锁和X锁只会在一些特殊情况下用到,比如系统崩溃恢复时,不过我们在系统变量autocommit=0并且innodb_table_locks=1时,可以通过命令手动获取S表锁或是X表锁:
LOCK TABLES table_name READ|WRITE

表级别的IS锁和IX锁

当访问InnoDB存储引擎的表,事务对表中的记录加S锁或是X锁前,会先对该表加IS锁或是X锁.具体的前面已经说过了.意向锁主要用于快速判断表中是否有记录被加锁了.

表级别的AUTO-INC锁

我们在为表定义对应的列时,可以对某些表设置AUTO_INCREMENT属性,这样在对这个表插入记录的时候,就可以不对该字段设置值,系统会自动为这个字段赋予递增的值.
那么系统是如何为这个字段进行递增赋值的呢?

  • 主要有俩个方式
    • 采用AUTO_INC锁,在插入的时候对表加上一个表级别的AUTO_INC锁,然后对每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值1,在该语句执行完后,再把AUTO_INC锁释放掉.一个事务在持有AUTO_INC锁时,其他事务对该表的插入语句都要被阻塞,以此来保证插入的递增值是连续的.
    • 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取这个锁,然后再生成本次插入语句需要用到的递增值之后就把该轻量级锁给释放掉,而不需要等待插入语句执行完毕.

一般来说,如果我们的插入语句在执行前就可以确认具体插入记录的条数,就会采用轻量级锁的方式来生成,但是如果不确定插入记录的条数,比如使用了INSERT...SELECT,REPLACE...SELECT或是LOAD DATA等插入语句,则是采用AUTO_INC锁来生成对应的值.

我们在使用的时候肯定是希望大多数时候使用轻量级锁的方式来生成对应的值,这样可以避免锁定表,提高插入性能.
innodb_automic_lock_mode系统变量(默认值1)可以控制系统使用哪种方式来生成递增值.0:AUTO_INC 1:混合使用 2:轻量级锁
一律使用轻量级锁的时候可能会造成不同事务中的递增值交叉(即重复),在有主从复制的场景下是不安全的.

InnoDB的行级锁#

实际上即使是行级锁,InnoDB也有不同类型的锁来支持.不同的行级锁有不同的作用.
我们接下来就来介绍一下这些行级锁:

Record Lock

我们之前提到的行级锁都是这种类型,只是将一条记录给锁上,我们可以称这种行级锁为正经记录锁,官方名为:LOCK_REC_NOT_GAP.
比如我们为"c曹操"这条记录加上正经记录锁,其加锁效果就如图示意:

正经记录锁也是有S锁和X锁之分的,其加锁兼容性也就和我们之前说的一样.

GAP LOCK

我们前面说过,MySQL支持的REPEATTABLE READ是可以解决幻读问题的,可以通过MVCC方式解决,也可以通过加锁的方式来解决,但是在事务进行时,我们并不知道后面其他事务更新的幻影记录,那么怎么为这些还不存在的记录加锁?
设计者提出了一种Gap Locks(间隙锁),可以在第一次读取记录后,为这些记录加上Gap Locks,这样在这些记录与其前一个记录之间的间隙被其他事务插入数据的时候,会被阻塞住,等待该事务提交后才会继续.

间隙锁的作用仅仅是为了防止幻影记录插入而已,可以看到我们为"c曹操"这条记录加了间隙锁,当有别的事务在number值为3和8之间的位置插入一条记录,寻找到了对应的插入位置,查看该位置的后一条记录,发现后一条记录加了Gap锁,则进行阻塞,等待该事务完成.

如果想要在聚簇索引的最后一个位置加上GAP锁该怎么加呢?
别忘了页面中还有一个Supremum记录,代表该页面最大的记录,可以为该记录加锁.

NEXT-KEY LOCK

NEXT-KEY LOCK是LOCK_REC_NOT_GAP(正经记录锁)GAP_LOCK(间隙锁)的结合,当为某个记录加NEXT-KEY LOCK锁之后,其他事务不仅获取该记录的锁会失败,也不能在该记录前插入新记录.

如图所示,为number为8的记录加了NEXT-KEY LOCK,其他事务也就没法获取成功number为8的这条记录的锁,也无法在number为3的记录与该记录前插入记录了.

Insert Intention Locks

Insert Intention Locks(插入意向锁),我们前面说过,在事务插入记录的时候,需要检查其插入位置的后一个记录是否有被加了GAP锁(NEXT-KEY LOCK或是GAP LOCK),如果有的话本事务需要阻塞,等待获取锁成功的事务执行完成本事务才能继续执行.我们介绍过,获取锁失败也是需要生成对应的锁结构(is_waiting为true).而在获取锁成功的事务执行完后就会释放锁,并唤醒插入意向锁的事务继续执行(插入意向锁并不会互相阻塞)
如果一个事务先在number为8的记录加上gap锁,这个时候其他事务在number为8的记录前插入俩条记录:

T2和T3事务就会各自创建一个锁结构,当事务T1执行完毕后,就会释放掉其事务所有的锁,然后将T2事务和T3事务的插入意向锁的is_waiting改为false,并唤醒其线程继续执行,插入意向锁并不会互相阻塞(比如事务T2获取了一个记录的插入意向锁,事务T3也是可以继续获取到这个记录的插入意向锁的).

隐式锁

我们前面说过,在插入一条记录的时候一般是不会加锁的,只有在插入的位置如果后一个记录被加了GAP锁,才会生成一个插入意向锁来与该记录(即加了GAP锁的那个记录)和本事务关联,当获取GAP锁成功的事务提交后,释放了锁进行唤醒本事务再继续进行插入操作.
那么我们来设想一个场景:

  • 如果一个事务插入了一条新记录,而这个时候另外一个事务:
    • 使用SELECT .... LOCK IN SHAPE MODE | FOR UPDATE来获取这条记录的S锁或是X锁呢?
      • 如果允许的话,就会产生幻读问题,其他事务获取到了还未提交的记录.(因为没有给这条事务加锁)
    • 如果其他事务想要修改这条记录呢?也就是想要获取这条记录的X锁.
      • 如果允许这种情况,就会造成脏写问题,其他事务修改了还未提交的记录.

主要的问题就是,一个事务插入后,如果没有对新插入的记录加锁,那么如何防范其他事务对该条记录的操作导致的并发问题呢?

为了应对这些情况,隐式锁就派上用场了,那么隐式锁具体指的是什么呢?

  • 我们知道,每条聚簇索引都有一个隐藏列trx_id,用于存放该记录最新被修改的事务id.当想要获取某条记录的锁时,先查看该记录trx_id列中的事务id,该事务id是否为当前系统中的活跃事务,如果是的话则为该活跃事务生成一个X锁结构(is_waiting属性为false),然后再为本事务生成一个锁结构(is_waiting属性为true),进入等待状态.
  • 那么二级索引中并没有存放trx_id列,又如何判断这个记录所对应的事务是否是活跃事务呢? 别忘了数据页中是有存放对本数据页改动最新的事务id的,在获取二级索引记录的时候,先获取该记录所在数据页的PAGE HEADER中的PAGE_MAX_TRX_ID属性,判断PAGE_MAX_TRX_ID值是否大于系统当前的最小活跃事务ID,如果大于则进行回表,重复上面聚簇索引的判断流程,小于则说明该记录对应的事务已提交,可以正常获取锁.

这样的话,通过事务id,我们就可以在事务插入记录的时候暂时不为其生成锁,而是当其他事务想要获取该事务的锁时,再为其和本事务生成对应的锁.我们称这种锁为:隐式锁 (毕竟不用显示的生成锁结构了)

InnoDB的锁内存结构#

进行对记录加锁就是为该记录在内存中生成与该事务关联的锁结构(无论获取锁是否成功).
我们前面的时候稍微有提到过点,在符合一些情况下,多个记录是可以共享一个锁结构的:

  • 这些记录是被同一个事务加锁的
  • 加锁的记录在同一数据页
  • 这些记录被加的是同种类的锁
  • 这些记录被加锁的等待状态是一样的

锁内存结构#

部分名称
锁所在的事务信息
索引信息
表锁/行锁信息
type_mode
其他信息
一堆比特位
  • 事务信息: 一个指向事务信息的指针,通过这个指针指向的地址我们可以获取这个锁关联的事务的事务id等等信息.

  • 索引信息: 当加锁的类型为行锁的时候,需要记录下进行加锁的记录属于的索引

  • 表锁/行锁信息: 会根据锁的类型来记录不同的信息

    • 表锁: 记载着该表的信息,以及其他的一些信息
    • 行锁: 记录着:Space ID(记录所在表空间),Page Number(记录所在页号),n_bits(一个比特位对应一个记录,这个数值就是用于表示使用了多少比特位,一般来说为了应对页面记录的增加,这个比特位会多分配些)
  • type_mode: 占用空间32位,这32位分别用来存放不同的信息,主要分为了三部分:

    • lock_mode: 锁的模式,可取值有这些
      • LOCK_IS (十进制为0): 共享意向锁
      • LOCK_IX (十进制为1): 独占意向锁
      • LOCK_S (十进制为2): 共享锁
      • LOCK_X (十进制为3): 独占锁
      • LOCK_AUTO_INC(十进制为4): AUTO_INC锁,为修饰为递增列赋值使用到的锁
    • lock_type: 锁的类型,表示是行锁还是表锁,占用5~8位,暂时只有使用第5位和第6位
      • LOCK_TABLE (十进制为16): 当第5位为1时,表示该锁为表锁
      • LOCK_REC (十进制为32): 当第6位为1时,表示该锁为行锁
    • rec_lock_type: 表示行锁的具体类型,使用剩余的位来表示.
      • LOCK_ORDINARY (十进制为0): 表示NEXT_KEY锁
      • LOCK_GAP (十进制为512): 表示间隙锁
      • LOCK_REC_NO_GAP (十进制为1024): 表示正经记录锁
      • LOCK_INSERT_INTENTION(十进制为2048): 表示插入意向锁
      • IS_WAITING(十进制为256): 表示当前锁是否等待也被放在type_mode这个部分来表示了,当第9位为0时,表示is_waiting为false,即获取锁成功,为1时,表示is_waiting为true,即获取锁失败.
  • 其他信息: 主要是为了运行时,方便对锁进行管理的各种哈希表和链表

  • 一堆比特位: 如果该锁为行锁,那么末尾还有一堆比特位(这一部分主要是为了让多个记录可以共用一个锁结构),这些比特位用于表示加锁的记录,一个比特位与一个heap_no对应,某个比特位值为1,就表示对其位置对应的heap_no其对应的记录加锁了.

heap_no是位于记录头的属性,用于表示该记录在数据页中的物理位置,0表示infimum记录 1表示supremum记录
看起来heap_no和比特位的映射顺序是不是挺怪的,为什么不是顺序映射?据说这样方便他们编码~

posted @   况况况  阅读(18)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· [翻译] 为什么 Tracebit 用 C# 开发
· Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版
· 2分钟学会 DeepSeek API,竟然比官方更好用!
· .NET 使用 DeepSeek R1 开发智能 AI 客户端
· 刚刚!百度搜索“换脑”引爆AI圈,正式接入DeepSeek R1满血版
点击右上角即可分享
微信分享提示
主题色彩