mysql13

LRU 链表的管理
划分区域的 LRU 链表
  • 但是这种实现存在两种比较尴尬的情况:
情况一
  • InnoDB 提供了预读(英文名:read ahead)。所谓预读,就是 InnoDB 认为执行当前的请求可能之后会读取某些页面,就预先把它们加载到 Buffer Pool 中。根据触发方式的不同,预读又可以细分为下边两种:
线性预读
  • InnoDB 提供了一个系统变量 innodb_read_ahead_threshold,如果顺序访问了 某个区(extent)的页面超过这个系统变量的值(默认是56),就会触发一次异步读取下一个 区中全部的页面到 Buffer Pool 的请求
  • 这个 innodb_read_ahead_threshold 系统变量的值默认是 56,我们可以在服 务器启动时通过启动参数或者服务器运行过程中直接调整该系统变量的值
随机预读
  • 如果 Buffer Pool 中已经缓存了某个区的 13 个连续的页面,不论这些页面是 不是顺序读取的,都会触发一次异步读取本区中所有其他的页面到 Buffer Pool 的请求。InnoDB 同时提供了 innodb_random_read_ahead 系统变量,它的默认值 为 OFF。
总结
  • 如果预读到 Buffer Pool 中的页成功的被使用到,那就可以极大的提高语句执 行的效率。可是如果用不到呢?这些预读的页都会放到 LRU 链表的头部,但是如 果此时 Buffer Pool 的容量不太大而且很多预读的页面都没有用到的话,这就会导致处在 LRU 链表尾部的一些缓存页会很快的被淘汰掉,也就是所谓的劣币驱逐良币,会大大降低缓存命中率。
情况二
  • 应用程序可能会写一些需要扫描全表的查询语句(比如没有建立合 适的索引或者压根儿没有 WHERE 子句的查询)。

  • 扫描全表意味着什么?意味着将访问到该表所在的所有页!假设这个表中记 录非常多的话,那该表会占用特别多的页,当需要访问这些页时,会把它们统统 都加载到 Buffer Pool 中,这也就意味着 Buffer Pool 中的所有页都被换了一次血, 其他查询语句在执行时又得执行一次从磁盘加载到 Buffer Pool 的操作。而这种全 表扫描的语句执行的频率也不高,每次执行都要把 Buffer Pool 中的缓存页换一次 血,这严重的影响到其他查询对 Buffer Pool 的使用,从而大大降低了缓存命中 率。

  • 总结一下上边说的可能降低 Buffer Pool 的两种情况:

    • 加载到 Buffer Pool 中的页不一定被用到。
    • 如果非常多的使用频率偏低的页被同时加载到 Buffer Pool 时,可能会把那些 使用频率非常高的页从 Buffer Pool 中淘汰掉。
  • 因为有这两种情况的存在,所以 InnoDB 把这个 LRU 链表按照一定比例分成 两截,分别是:

LRU划分区域
  • 一部分存储使用频率非常高的缓存页,所以这一部分链表也叫做热数据,或 者称young 区域

  • 另一部分存储使用频率不是很高的缓存页,所以这一部分链表也叫做冷数据, 或者称 old 区域

  • 我们是按照某个比例将 LRU 链表分成两半的,不是某些节点固定是 young 区域的,某些节点固定是 old 区域的,随着程序的运行,某个节点所属的区域也 可能发生变化。那这个划分成两截的比例怎么确定呢?对于 InnoDB 存储引擎来 说,我们可以通过查看系统变量innodb_old_blocks_pct的值来确定old区域在LRU 链表中所占的比例,比方说这样:

    • SHOW VARIABLES LIKE ‘innodb_old_blocks_pct’;
    • 从结果可以看出来,默认情况下,old 区域在 LRU 链表中所占的比例是 37%,也 就是说 old 区域大约占 LRU 链表的 3/8。这个比例我们是可以设置的,我们可以 在启动时修改 innodb_old_blocks_pct 参数来控制 old 区域在 LRU 链表中所占的比 例。在服务器运行期间,我们也可以修改这个系统变量的值,不过需要注意的是, 这个系统变量属于全局变量。
  • 有了这个被划分成 young 和 old 区域的 LRU 链表之后,InnoDB 就可以针对 我们上边提到的两种可能降低缓存命中率的情况进行优化了:

  • 针对预读的页面可能不进行后续访问情况的优化:

    • InnoDB 规定,当磁盘上的某个页面在初次加载到 Buffer Pool 中的某个缓存页时,该预读的缓存页对应的控制块会被放到 old 区域的头部。这样针对预读到 Buffer Pool 却不进行后续访问的页面就会被逐渐从 old 区域逐出,而不会影响 young 区 域中被使用比较频繁的缓存页。
  • 针对全表扫描时,短时间内访问大量使用频率非常低的页面情况的优化:

    • 在进行全表扫描时,虽然首次被加载到 Buffer Pool 的页被放到了 old 区域的 头部,但是后续会被马上访问到,每次进行访问的时候又会把该页放到 young 区域的头部,这样仍然会把那些使用频率比较高的页面给顶下去。
  • 有同学会想:可不可以在第一次访问该页面时不将其从 old 区域移动到 young 区域的头部,后续访问时再将其移动到 young 区域的头部。回答是:行不 通!因为 InnoDB 规定每次去页面中读取一条记录时,都算是访问一次页面,而 一个页面中可能会包含很多条记录,也就是说读取完某个页面的记录就相当于访 问了这个页面好多次

  • 全表扫描有一个特点,那就是它的执行频率非常低,出现了全表扫描的语句 也是我们应该尽快优化的对象。而且在执行全表扫描的过程中,即使某个页面中 有很多条记录,也就是去多次访问这个页面所花费的时间也是非常少的。 所以在对某个处在old区域的缓存页进行第一次访问时就在它对应的控制块 中记录下来这个访问时间,如果后续的访问时间与第一次访问的时间在某个时间 间隔内,那么该页面就不会被从 old 区域移动到 young 区域的头部,否则将它移 动到 young 区域的头部。上述的这个间隔时间是由系统变量

    • innodb_old_blocks_time 控制的:
    • 这个 innodb_old_blocks_time 的默认值是 1000,它的单位是毫秒,也就意味着对 于从磁盘上被加载到 LRU 链表的 old 区域的某个页来说,如果第一次和最后一次 访问该页面的时间间隔小于 1s(很明显在一次全表扫描的过程中,多次访问一 个页面中的时间不会超过 1s),那么该页是不会被加入到 young 区域的, 当然, 像 innodb_old_blocks_pct 一样,我们也可以在服务器启动或运行时设置 innodb_old_blocks_time 的值,这里需要注意的是,如果我们把 innodb_old_blocks_time 的值设置为 0,那么每次我们访问一个页面时就会把该页 面放到 young 区域的头部。
  • 综上所述,正是因为将 LRU 链表划分为 young 和 old 区域这两个部分,又添 加了 innodb_old_blocks_time 这个系统变量,才使得预读机制和全表扫描造成的 缓存命中率降低的问题得到了遏制,因为用不到的预读页面以及全表扫描的页面 都只会被放到 old 区域,而不影响 young 区域中的缓存页。

更进一步优化 LRU 链表
  • 对于 young 区域的缓存页来说,我们每次访问一个缓存页就要把它移动到 LRU 链表的头部,这样开销是不是太大?
  • ==毕竟在 young 区域的缓存页都是热点数据,也就是可能被经常访问的,这样 频繁的对 LRU 链表进行节点移动操作也会拖慢速度?==为了解决这个问题,MySQL 中还有一些优化策略,比如只有被访问的缓存页位于 young 区域的 1/4 的后边, 才会被移动到 LRU 链表头部,这样就可以降低调整 LRU 链表的频率,从而提升 性能
  • 还有没有什么别的针对 LRU 链表的优化措施呢?当然还有,我们这里不继续 说了,更多的需要看 MySQL 的源码,但是不论怎么优化,出发点就是:尽量高 效的提高 Buffer Pool 的缓存命中率
其他的一些链表
  • 为了更好的管理 Buffer Pool 中的缓存页,除了我们上边提到的一些措施, InnoDB 们还引进了其他的一些链表,比如 unzip LRU 链表用于管理解压页,zip clean 链表用于管理没有被解压的压缩页,zip free 数组中每一个元素都代表一个 链表,它们组成所谓的伙伴系统来为压缩页提供内存空间等等。
刷新脏页到磁盘
  • 后台有专门的线程每隔一段时间负责把脏页刷新到磁盘,这样可以不影响用 户线程处理正常的请求。主要有两种刷新路径:

  • 1、从 LRU 链表的冷数据中刷新一部分页面到磁盘

    • 后台线程会定时从 LRU 链表尾部开始扫描一些页面,扫描的页面数量可以通 过系统变量 innodb_lru_scan_depth 来指定,如果从里边儿发现脏页,会把它们 刷新到磁盘。这种刷新页面的方式被称之为 BUF_FLUSH_LRU
  • 2、从 flush 链表中刷新一部分页面到磁盘

    • 后台线程也会定时从 flush 链表中刷新一部分页面到磁盘,刷新的速率取决 于当时系统是不是很繁忙。这种刷新页面的方式被称之为 BUF_FLUSH_LIST
  • 有时候后台线程刷新脏页的进度比较慢,导致用户线程在准备加载一个磁盘 页到 Buffer Pool 时没有可用的缓存页,这时就会尝试看看 LRU 链表尾部有没有 可以直接释放掉的未修改页面,如果没有的话会不得不将 LRU 链表尾部的一个脏 页同步刷新到磁盘(和磁盘交互是很慢的,这会降低处理用户请求的速度)。这 种刷新单个页面到磁盘中的刷新方式被称之为BUF_FLUSH_SINGLE_PAGE

  • 当然,有时候系统特别繁忙时,也可能出现用户线程批量的从 flush 链表中 刷新脏页的情况,很显然在处理用户请求过程中去刷新脏页是一种严重降低处理 速度的行为,这属于一种迫不得已的情况。

多个 Buffer Pool 实例
  • 我们上边说过,Buffer Pool 本质是 InnoDB 向操作系统申请的一块连续的内 存空间,在多线程环境下,访问 Buffer Pool 中的各种链表都需要加锁处理,在 Buffer Pool 特别大而且多线程并发访问特别高的情况下,单一的 Buffer Pool 可能 会影响请求的处理速度。所以在 Buffer Pool 特别大的时候,我们可以把它们拆分 成若干个小的 Buffer Pool,每个 Buffer Pool 都称为一个实例,它们都是独立的, 独立的去申请内存空间,独立的管理各种链表,所以在多线程并发访问时并不会 相互影响,从而提高并发处理能力。
    • 总结下来就是写热点分散
  • 我们可以在服务器启动的时候通过设置 innodb_buffer_pool_instances 的值来修改 Buffer Pool 实例的个数
  • 那每个 Buffer Pool 实例实际占多少内存空间呢?其实使用这个公式算出来 的:
    • innodb_buffer_pool_size/innodb_buffer_pool_instances
  • 也就是总共的大小除以实例的个数,结果就是每个 Buffer Pool 实例占用的大 小。
  • 不过也不是说 Buffer Pool 实例创建的越多越好,分别管理各个 Buffer Pool 也是需要性能开销的,InnoDB 规定:当 innodb_buffer_pool_size(默认 128M) 的值小于 1G 的时候设置多个实例是无效的,InnoDB 会默认把 innodb_buffer_pool_instances 的值修改为 1。所以Buffer Pool 大于或等于1G的时候才能设置多个Buffer Pool实例
innodb_buffer_pool_chunk_size
  • 在 MySQL 5.7.5 之前,Buffer Pool 的大小只能在服务器启动时通过配置 innodb_buffer_pool_size 启动参数来调整大小,在服务器运行过程中是不允许调 整该值的。不过 MySQL 在 5.7.5 以及之后的版本中支持了在服务器运行过程中调 整 Buffer Pool 大小的功能,
  • 但是有一个问题,就是每次当我们要重新调整 Buffer Pool 大小时,都需要重 新向操作系统申请一块连续的内存空间,然后将旧的 Buffer Pool 中的内容复制到这一块新空间,这是极其耗时的。所以 MySQL 决定不再一次性为某个 Buffer Pool 实例向操作系统申请一大片连续的内存空间,而是以一个所谓的 chunk 为单位向 操作系统申请空间。也就是说一个Buffer Pool实例其实是由若干个chunk组成的, 一个 chunk 就代表一片连续的内存空间,里边儿包含了若干缓存页与其对应的控 制块:
    • buffer pool由多个chunk组成
    • chunk又由一系列的控制块+内存碎片+一系列的缓存页组成
  • 正是因为发明了这个 chunk 的概念,我们在服务器运行期间调整 Buffer Pool 的大小时就是以 chunk 为单位增加或者删除内存空间,而不需要重新向操作系统 申请一片大的内存,然后进行缓存页的复制。这个所谓的 chunk 的大小是我们在 启动操作 MySQL 服务器时通过 innodb_buffer_pool_chunk_size 启动参数指定的, 它的默认值是 134217728,也就是 128M。不过需要注意的是, innodb_buffer_pool_chunk_size 的值只能在服务器启动时指定,在服务器运行过 程中是不可以修改的。
  • Buffer Pool 的缓存页除了用来缓存磁盘上的页面以外,还可以存储锁信息、 自适应哈希索引等信息。
查看 Buffer Pool 的状态信息
  • MySQL 给我们提供了 SHOW ENGINE INNODB STATUS 语句来查看关于 InnoDB 存储引擎运行过程中的一些状态信息,其中就包括 Buffer Pool 的一些信息,我们 看一下(为了突出重点,我们只把输出中关于 Buffer Pool 的部分提取了出来):

    • SHOW ENGINE INNODB STATUS\G
  • 这里边的每个值都代表什么意思如下,知道即可

    • Total memory allocated:代表 Buffer Pool 向操作系统申请的连续内存空间大小,包括全部控制块、缓存页、以及碎片的大小。
    • Dictionary memory allocated:为数据字典信息分配的内存空间大小,注意这 个内存空间和 Buffer Pool 没啥关系,不包括在 Total memory allocated 中。
    • Buffer pool size:代表该 Buffer Pool 可以容纳多少缓存页,注意,单位是页!
    • Free buffers:代表当前 Buffer Pool 还有多少空闲缓存页,也就是 free 链表 中还有多少个节点
    • Database pages:代表 LRU 链表中的页的数量,包含 young 和 old 两个区域 的节点数量。
    • Old database pages:代表 LRU 链表 old 区域的节点数量
    • Modified db pages:代表脏页数量,也就是flush 链表中节点的数量
    • Pending reads:正在 等待从磁盘上加载到 Buffer Pool 中的页面数量。当准备从磁盘中加载某个页面时,会先为这个页面在 Buffer Pool 中分配一个 缓存页以及它对应的控制块,然后把这个控制块添加到 LRU 的 old 区域的头部, 但是这个时候真正的磁盘页并没有被加载进来,Pending reads 的值会跟着加 1。
    • Pending writes LRU:即将从 LRU 链表中刷新到磁盘中的页面数量。
    • Pending writes flush list:即将从 flush 链表中刷新到磁盘中的页面数量。
    • Pending writes single page:即将以单个页面的形式刷新到磁盘中的页面数量。
    • Pages made young:代表 LRU 链表中曾经从 old 区域移动到 young 区域头部 的节点数量。
    • Page made not young:在将 innodb_old_blocks_time 设置的值大于 0 时,首 次访问或者后续访问某个处在old区域的节点时由于不符合时间间隔的限制而不 能将其移动到 young 区域头部时,Page made not young 的值会加 1。
    • youngs/s:代表每秒从 old 区域被移动到 young 区域头部的节点数量。
    • non-youngs/s:代表每秒由于不满足时间限制而不能从 old 区域移动到 young 区域头部的节点数量。
    • Pages read、created、written:代表读取,创建,写入了多少页。后边跟着 读取、创建、写入的速率。
    • Buffer pool hit rate:表示在过去某段时间,平均访问 1000 次页面,有多少 次该页面已经被缓存到 Buffer Pool 了。
    • young-making rate:表示在过去某段时间,平均访问 1000 次页面,有多少 次访问使页面移动到 young 区域的头部了。
    • not (young-making rate):表示在过去某段时间,平均访问 1000 次页面,有 多少次访问没有使页面移动到 young 区域的头部。
    • LRU len:代表 LRU 链表中节点的数量。
    • unzip_LRU:代表 unzip_LRU 链表中节点的数量。
    • I/O sum:最近 50s 读取磁盘页的总数。
    • I/O cur:现在正在读取的磁盘页数量。
    • I/O unzip sum:最近 50s 解压的页面数量。
    • I/O unzip cur:正在解压的页面数量。

InnoDB 的内存结构总结

  • InnoDB 的内存结构和磁盘存储结构图总结如下:
    • 内存结构
      • buffer pool
        • 索引页
        • 数据页
        • 锁信息
        • 自适应哈希
        • 数据字典信息
        • insert/change buffer
          • 针对二级索引的写入优化
        • double write buffer
      • redo log buffer
    • 磁盘结构
      • 系统表空间
        • innodb数据字典
        • double write buffer
        • insert/change buffer
        • 事务系统相关
        • undo相关
      • redo log
        • ib_logfile0
        • ib_logfile1
      • 独立表空间
      • 其他表空间
      • undo 空间
        • undo001
        • undo002
  • 其中的 Insert/Change Buffer 主要是用于对二级索引的写入优化,Undo 空间 则是 undo 日志一般放在系统表空间,但是通过参数配置后,也可以用独立表空间存放,所以用虚线表示。

事务的底层原理和 MVCC

  • 在事务的实现机制上,MySQL 采用的是 WAL(Write-ahead logging,预写式 日志)机制来实现的
  • 在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到 系统中。通常包含 redo 和 undo 两部分信息
  • 为什么需要使用 WAL,然后包含 redo 和 undo 信息呢?举个例子,如果一 个系统直接将变更应用到系统状态中,那么在机器掉电重启之后系统需要知道操 作是成功了,还是只有部分成功或者是失败了(为了恢复状态)。如果使用了 WAL,那么在重启之后系统可以通过比较日志和系统状态来决定是继续完成操作 还是撤销操作。
  • redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log, 这样当发生掉电之类的情况时系统可以在重启后继续操作
  • undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销 日志恢复到变更之间的状态
  • MySQL 中用 redo log 来在系统 Crash 重启之类的情况时修复数据(事务的持 久性),而 undo log 来保证事务的原子性。

redo 日志

redo 日志的作用

  • InnoDB 存储引擎是以页为单位来管理存储空间的,我们进行的增删改查操 作其实本质上都是在访问页面(包括读页面、写页面、创建新页面等操作)。在 Buffer Pool 的时候说过,在真正访问页面之前,需要把在磁盘上的页缓存到内存 中的 Buffer Pool 之后才可以访问。但是在事务的时候又强调过一个称之为持久性 的特性,就是说对于一个已经提交的事务,在事务提交后即使系统发生了崩溃, 这个事务对数据库中所做的更改也不能丢失
  • 如果我们只在内存的 Buffer Pool 中修改了页面,假设在事务提交后突然发生 了某个故障,导致内存中的数据都失效了,那么这个已经提交了的事务对数据库 中所做的更改也就跟着丢失了,这是我们所不能忍受的。那么如何保证这个持久 性呢?一个很简单的做法就是在事务提交完成之前把该事务所修改的所有页面 都刷新到磁盘,但是这个简单粗暴的做法有些问题
刷新一个完整的数据页太浪费了
  • 有时候我们仅仅修改了某个页面中的一个字节,但是我们知道在 InnoDB 中 是以页为单位来进行磁盘 IO 的,也就是说我们在该事务提交时不得不将一个完 整的页面从内存中刷新到磁盘,我们又知道一个页面默认是 16KB 大小,只修改 一个字节就要刷新 16KB 的数据到磁盘上显然是太浪费了
随机 IO 刷起来比较慢
  • 一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务 修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的 页面刷新到磁盘时,需要进行很多的随机 IO,随机 IO 比顺序 IO 要慢,尤其对于 传统的机械硬盘来说。
  • 怎么办呢?我们只是想让已经提交了的事务对数据库中数据所做的修改永 久生效,即使后来系统崩溃,在重启后也能把这种修改恢复出来。所以我们其实 没有必要在每次事务提交时就把该事务在内存中修改过的全部页面刷新到磁盘, 只需要把修改了哪些东西记录一下就好,比方说某个事务将系统表空间中的第 100 号页面中偏移量为 1000 处的那个字节的值 1 改成 2 我们只需要记录一下:
  • 将第 0 号表空间的 100 号页面的偏移量为 1000 处的值更新为 2
  • 这样我们在事务提交时,把上述内容刷新到磁盘中,即使之后系统崩溃了, 重启之后只要按照上述内容所记录的步骤重新更新一下数据页,那么该事务对数 据库中所做的修改又可以被恢复出来,也就意味着满足持久性的要求。因为在系 统崩溃重启时需要按照上述内容所记录的步骤重新更新数据页,所以上述内容也 被称之为重做日志,英文名为 redo log,也可以称之为 redo 日志。与在事务提交 时将所有修改过的内存中的页面刷新到磁盘中相比,只将该事务执行过程中产生 的 redo 日志刷新到磁盘的好处如下:
    • 1、redo 日志占用的空间非常小 存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的
    • 2、redo 日志是顺序写入磁盘的。在执行事务的过程中,每执行一条语句,就可能产生若干条 redo 日志,这 些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO。
      • kafka为什么支持百万级别,也是顺序写的

redo 日志格式

  • 通过上边的内容我们知道,redo 日志本质上只是记录了一下事务对数据库 做了哪些修改。 InnoDB 们针对事务对数据库的不同修改场景定义了多种类型的 redo 日志,但是绝大部分类型的 redo 日志都有下边这种通用的结构:
  • 各个部分的详细释义如下:
    • type:该条 redo 日志的类型,redo 日志设计大约有 53 种不同的类型日志
    • space ID:表空间 ID
    • page number:页号
    • data:该条 redo 日志的具体内容
简单的 redo 日志类型
  • 我们用一个简单的例子来说明最基本的 redo 日志类型。我们前边介绍 InnoDB 的记录行格式的时候说过,如果我们没有为某个表显式的定义主键,并 且表中也没有定义 Unique 键,那么 InnoDB 会自动的为表添加一个称之为 row_id 的隐藏列作为主键。为这个 row_id 隐藏列赋值的方式如下:
  • 服务器会在内存中维护一个全局变量每当向某个包含隐藏的 row_id 列的 表中插入一条记录时,就会把该变量的值当作新记录的 row_id 列的值,并且把 该变量自增 1
  • 每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的 页号为 7 的页面中一个称之为 Max Row ID 的属性处
  • 当系统启动时,会将上边提到的 Max Row ID 属性加载到内存中,将该值加 上 256 之后赋值给我们前边提到的全局变量。
  • 这个 Max Row ID 属性占用的存储空间是 8 个字节,当某个事务向某个包含 row_id 隐藏列的表插入一条记录,并且为该记录分配的 row_id 值为 256 的倍数 时,就会向系统表空间页号为 7 的页面的相应偏移量处写入 8 个字节的值。但是 我们要知道,这个写入实际上是在 Buffer Pool 中完成的,我们需要为这个页面的 修改记录一条 redo 日志,以便在系统崩溃后能将已经提交的该事务对该页面所 做的修改恢复出来。这种情况下对页面的修改是极其简单的,redo 日志中只需 要记录一下在某个页面的某个偏移量处修改了几个字节的值,具体被修改的内容 是啥就好了,InnoDB 把这种极其简单的 redo 日志称之为物理日志,并且根据在 页面中写入数据的多少划分了几种不同的 redo 日志类型:
    • MLOG_1BYTE(type 字段对应的十进制数字为 1):表示在页面的某个偏移 量处写入 1 个字节的 redo 日志类型。
    • MLOG_2BYTE(type 字段对应的十进制数字为 2):表示在页面的某个偏移 量处写入 2 个字节的 redo 日志类型。
    • MLOG_4BYTE(type 字段对应的十进制数字为 4):表示在页面的某个偏移 量处写入 4 个字节的 redo 日志类型。
    • MLOG_8BYTE(type 字段对应的十进制数字为 8):表示在页面的某个偏移 量处写入 8 个字节的 redo 日志类型。
    • MLOG_WRITE_STRING(type 字段对应的十进制数字为 30):表示在页面的 某个偏移量处写入一串数据。
  • 我们上边提到的 Max Row ID 属性实际占用 8 个字节的存储空间,所以在修 改页面中的该属性时,会记录一条类型为MLOG_8BYTE的redo日志,MLOG_8BYTE 的 redo 日志结构如下所示:
  • offset 代表在页面中的偏移量。
  • 其余 MLOG_1BYTE、MLOG_2BYTE、MLOG_4BYTE 类型的 redo 日志结构和 MLOG_8BYTE 的类似,只不过具体数据中包含对应个字节的数据罢了。 MLOG_WRITE_STRING 类型的 redo 日志表示写入一串数据,但是因为不能确定写 入的具体数据占用多少字节,所以需要在日志结构中还会多一个 len 字段。
复杂一些的 redo 日志类型
  • 有时候执行一条语句会修改非常多的页面,包括系统数据页面和用户数据页 面(用户数据指的就是聚簇索引和二级索引对应的 B+树)。以一条 INSERT 语句 为例,它除了要向 B+树的页面中插入数据,也可能更新系统数据 Max Row ID 的 值,不过对于我们用户来说,平时更关心的是语句对 B+树所做更新:

  • 表中包含多少个索引,一条 INSERT 语句就可能更新多少棵 B+树

  • 针对某一棵 B+树来说,既可能更新叶子节点页面,也可能更新非叶子节点 页面,也可能创建新的页面(在该记录插入的叶子节点的剩余空间比较少,不足 以存放该记录时,会进行页面的分裂,在非叶子节点页面中添加目录项记录)

  • 在语句执行过程中,INSERT 语句对所有页面的修改都得保存到 redo 日志中 去。实现起来是非常麻烦的,比方说将记录插入到聚簇索引中时,如果定位到的 叶子节点的剩余空间足够存储该记录时,那么只更新该叶子节点页面就好,那么 只记录一条 MLOG_WRITE_STRING 类型的 redo 日志,表明在页面的某个偏移量 处增加了哪些数据就好了么?

  • 别忘了一个数据页中除了存储实际的记录之后,还有什么 File Header、Page Header、Page Directory 等等部分,所以每往叶子节点代表的数据页里插入一条 记录时,还有其他很多地方会跟着更新,比如说:

  • 可能更新 Page Directory 中的槽信息、Page Header 中的各种页面统计信息, 比如槽数量可能会更改,还未使用的空间最小地址可能会更改,本页面中的记录 数量可能会更改,各种信息都可能会被修改,同时数据页里的记录是按照索引列从小到大的顺序组成一个单向链表的,每插入一条记录,还需要更新上一条记录 的记录头信息中的 next_record 属性来维护这个单向链表

  • 画一个简易的示意图就像是这样:

  • 其实说到底,把一条记录插入到一个页面时需要更改的地方非常多。这时我们如 果使用上边介绍的简单的物理 redo 日志来记录这些修改时,可以有两种解决方 案:

    • 方案一:在每个修改的地方都记录一条 redo 日志。 也就是如上图所示,有多少个加粗的块,就写多少条物理 redo 日志。这样 子记录 redo 日志的缺点是显而易见的,因为被修改的地方是在太多了,可能记 录的 redo 日志占用的空间都比整个页面占用的空间都多了。
    • 方案二:将整个页面的第一个被修改的字节到最后一个修改的字节之间所有 的数据当成是一条物理 redo 日志中的具体数据。
  • 从图中也可以看出来,第一个被修改的字节到最后一个修改的字节之间仍然 有许多没有修改过的数据,我们把这些没有修改的数据也加入到 redo 日志中去 依然很浪费。

  • 正因为上述两种使用物理 redo 日志的方式来记录某个页面中做了哪些修改 比较浪费,InnoDB 中就有非常多的 redo 日志类型来做记录

  • 这些类型的 redo 日志既包含物理层面的意思,也包含逻辑层面的意思,具 体指:

  • 物理层面看,这些日志都指明了对哪个表空间的哪个页进行了修改。

  • 逻辑层面看,在系统崩溃重启时,并不能直接根据这些日志里的记载,将页 面内的某个偏移量处恢复成某个数据,而是需要调用一些事先准备好的函数,执 行完这些函数后才可以将页面恢复成系统崩溃前的样子。

  • 简单来说,一个 redo 日志类型而只是把在本页面中变动(比如插入、修改) 一条记录所有必备的要素记了下来,之后系统崩溃重启时,服务器会调用相关向 某个页面变动(比如插入、修改)一条记录的那个函数,而 redo 日志中的那些 数据就可以被当成是调用这个函数所需的参数,在调用完该函数后,页面中的相 关值也就都被恢复到系统崩溃前的样子了。这就是所谓的逻辑日志的意思。

  • 当然,如果不是为了写一个解析 redo 日志的工具或者自己开发一套 redo 日 志系统的话,那就不需要去研究 InnoDB 中的 redo 日志具体格式。

  • 大家只要记住:redo 日志会把事务在执行过程中对数据库所做的所有修改 都记录下来,在之后系统崩溃重启后可以把事务所做的任何修改都恢复出来

Mini-Transaction

以组的形式写入 redo 日志
  • 语句在执行过程中可能修改若干个页面。比如我们前边说的一条 INSERT 语 句可能修改系统表空间页号为 7 的页面的 Max Row ID 属性(当然也可能更新别 的系统页面,只不过我们没有都列举出来而已),还会更新聚簇索引和二级索引 对应 B+树中的页面。由于对这些页面的更改都发生在 Buffer Pool 中,所以在修 改完页面之后,需要记录一下相应的 redo 日志。

  • 在这个执行语句的过程中产生的 redo 日志被 InnoDB 人为的划分成了若干个 不可分割的组每一个组称为一个mini-transaction,比如:

    • 1、更新 Max Row ID 属性时产生的 redo 日志是不可分割的。
    • 2、向聚簇索引对应 B+树的页面中插入一条记录时产生的 redo 日志是不可 分割的。
    • 3、向某个二级索引对应 B+树的页面中插入一条记录时产生的 redo 日志是 不可分割的。
    • 4、还有其他的一些对页面的访问操作时产生的 redo 日志是不可分割的….。
  • 怎么理解这个不可分割的意思呢?我们以向某个索引对应的 B+树插入一条 记录为例,在向 B+树中插入这条记录之前,需要先定位到这条记录应该被插入 到哪个叶子节点代表的数据页中,定位到具体的数据页之后,有两种可能的情况:

    • 情况一:该数据页的剩余的空闲空间充足,足够容纳这一条待插入记录,那 么事情很简单,直接把记录插入到这个数据页中,记录一条 redo 日志就好了, 我们把这种情况称之为乐观插入
    • 情况二:该数据页剩余的空闲空间不足,那么事情就很麻烦了,遇到这种情 况要进行所谓的页分裂操作:
      • 1、新建一个叶子节点;
      • 2、然后把原先数据页中的一部分记录复制到这个新的数据页中;
      • 3、然后再把记录插入进去,把这个叶子节点插入到叶子节点链表中;
      • 4、非叶子节点中添加一条目录项记录指向这个新创建的页面;
      • 5、非叶子节点空间不足,继续分裂。
  • 很显然,这个过程要对多个页面进行修改,也就意味着会产生很多条 redo 日志,我们把这种情况称之为悲观插入

  • 另外,这个过程中,由于需要新申请数据页,还需要改动一些系统页面,比 方说要修改各种段、区的统计信息信息,各种链表的统计信息,也会产生 redo 日志。

  • 当然在乐观插入时也可能产生多条 redo 日志。

  • InnoDB 认为向某个索引对应的 B+树中插入一条记录的这个过程必须是原子 的,不能说插了一半之后就停止了。比方说在悲观插入过程中,新的页面已经分 配好了,数据也复制过去了,新的记录也插入到页面中了,可是没有向非叶子节 点中插入一条目录项记录,这个插入过程就是不完整的,这样会形成一棵不正确 的 B+树。

  • 我们知道 redo 日志是为了在系统崩溃重启时恢复崩溃前的状态,如果在悲 观插入的过程中只记录了一部分 redo 日志,那么在系统崩溃重启时会将索引对 应的 B+树恢复成一种不正确的状态。

  • 所以规定在执行这些需要保证原子性的操作时必须以组的形式来记录的 redo 日志,在进行系统崩溃重启恢复时,针对某个组中的 redo 日志,要么把全 部的日志都恢复掉,要么一条也不恢复。在实现上,根据多个 redo 日志的不同, 使用了特殊的 redo 日志类型作为组的结尾,来表示一组完整的 redo 日志。

Mini-Transaction 的概念
  • 所以 MySQL 把对底层页面中的一次原子访问的过程称之为一个 Mini-Transaction,比如上边所说的修改一次 Max Row ID 的值算是一个 Mini-Transaction,向某个索引对应的 B+树中插入一条记录的过程也算是一个 Mini-Transaction。
  • 一个所谓的 Mini-Transaction 可以包含一组 redo 日志,在进行崩溃恢复时这 一组 redo 日志作为一个不可分割的整体
  • 一个事务可以包含若干条语句,每一条语句其实是由若干个 Mini-Transaction 组成,每一个 Mini-Transaction 又可以包含若干条 redo 日志,最终形成了一个树形结构。

redo 日志的写入过程

redo log block 和日志缓冲区
  • InnoDB 为了更好的进行系统崩溃恢复,把通过 Mini-Transaction 生成的 redo 日志都放在了大小为 512 字节的块(block)中
  • 我们前边说过,为了解决磁盘速度过慢的问题而引入了 Buffer Pool。同理, 写入 redo 日志时也不能直接直接写到磁盘上,实际上在服务器启动时就向操作 系统申请了一大片称之为 redo log buffer 的连续内存空间,翻译成中文就是 redo 日志缓冲区,我们也可以简称为 log buffer。这片内存空间被划分成若干个连续 的 redo log block,我们可以通过启动参数 innodb_log_buffer_size 来指定 log buffer 的大小,该启动参数的默认值为 16MB
  • 向 log buffer 中写入 redo 日志的过程是顺序的,也就是先往前边的 block 中 写,当该 block 的空闲空间用完之后再往下一个 block 中写。
  • 我们前边说过一个 Mini-Transaction 执行过程中可能产生若干条 redo 日志, 这些 redo 日志是一个不可分割的组,所以其实并不是每生成一条 redo 日志,就 将其插入到 log buffer 中,而是每个 Mini-Transaction 运行过程中产生的日志先暂时存到一个地方,当该 Mini-Transaction 结束的时候,将过程中产生的一组 redo 日志再全部复制到 log buffer 中。
redo 日志刷盘时机
  • 我们前边说 Mini-Transaction 运行过程中产生的一组 redo 日志在 Mini-Transaction 结束时会被复制到 log buffer 中,可是这些日志总在内存里呆着 也不是个办法,在一些情况下它们会被刷新到磁盘里,比如:
    • 1、log buffer 空间不足时,log buffer 的大小是有限的(通过系统变量 innodb_log_buffer_size 指定),如果不停的往这个有限大小的 log buffer 里塞入 日志,很快它就会被填满。InnoDB 认为如果当前写入 log buffer 的 redo 日志量已 经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上
    • 2、事务提交时(commit操作),我们前边说过之所以使用 redo 日志主要是因为它占用的空间少,还是顺序写,在事务提交时可以不把修改过的 Buffer Pool 页面刷新到磁盘, 但是为了保证持久性,必须要把修改这些页面对应的 redo 日志刷新到磁盘
    • 3、后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo 日志到磁盘
    • 4、正常关闭服务器时等等。
redo 日志文件组
  • MySQL 的数据目录(使用 SHOW VARIABLES LIKE 'datadir’查看)下默认有两 个名为 ib_logfile0 和 ib_logfile1 的文件,log buffer 中的日志默认情况下就是刷新 到这两个磁盘文件中。如果我们对默认的 redo 日志文件不满意,可以通过下边 几个启动参数来调节:
  • innodb_log_group_home_dir,该参数指定了 redo 日志文件所在的目录,默 认值就是当前的数据目录。
  • innodb_log_file_size,
  • 该参数指定了每个 redo 日志文件的大小,默认值为 48MB,
  • innodb_log_files_in_group,该参数指定 redo 日志文件的个数,默认值为 2, 最大值为 100。
  • 所以磁盘上的 redo 日志文件可以不只一个,而是以一个日志文件组的形式 出现的。这些文件以 ib_logfile[数字](数字可以是 0、1、2…)的形式进行命名。 在将 redo 日志写入日志文件组时,是从 ib_logfile0 开始写,如果 ib_logfile0 写满 了,就接着 ib_logfile1 写,同理,ib_logfile1 写满了就去写 ib_logfile2,依此类推。 如果写到最后一个文件该咋办?那就重新转到 ib_logfile0 继续写。
redo 日志文件格式
  • 我们前边说过 log buffer 本质上是一片连续的内存空间,被划分成了若干个 512 字节大小的 block。将 log buffer 中的 redo 日志刷新到磁盘的本质就是把 block 的镜像写入日志文件中,所以 redo 日志文件其实也是由若干个 512 字节大小的 block 组成
  • redo 日志文件组中的每个文件大小都一样,格式也一样,都是由两部分组 成:前 2048 个字节,也就是前 4 个 block 是用来存储一些管理信息的。 从第 2048 字节往后是用来存储 log buffer 中的 block 镜像的。

Log Sequence Number

  • 自系统开始运行,就不断的在修改页面,也就意味着会不断的生成 redo 日 志。redo 日志的量在不断的递增,就像人的年龄一样,自打出生起就不断递增, 永远不可能缩减了。
  • InnoDB 为记录已经写入的 redo 日志量,设计了一个称之为 Log Sequence Number 的全局变量,翻译过来就是:日志序列号,简称 LSN。规定初始的 lsn 值为 8704(也就是一条 redo 日志也没写入时,LSN 的值为 8704)。
  • 我们知道在向 log buffer 中写入 redo 日志时不是一条一条写入的,而是以一 个 Mini-Transaction 生成的一组 redo 日志为单位进行写入的。从上边的描述中可 以看出来,每一组由 Mini-Transaction 生成的 redo 日志都有一个唯一的 LSN 值与 其对应,LSN 值越小,说明 redo 日志产生的越早
flushed_to_disk_lsn
  • redo 日志是首先写到 log buffer 中,之后才会被刷新到磁盘上的 redo 日志文 件。InnoDB 中有一个称之为 buf_next_to_write 的全局变量,标记当前 log buffer 中已经有哪些日志被刷新到磁盘中了。

  • 我们前边说 lsn 是表示当前系统中写入的 redo 日志量,这包括了写到 log buffer 而没有刷新到磁盘的日志,相应的,InnoDB 也有一个表示刷新到磁盘中的 redo 日志量的全局变量,称之为 flushed_to_disk_lsn。系统第一次启动时,该变 量的值和初始的 lsn 值是相同的,都是 8704。随着系统的运行,redo 日志被不断 写入 log buffer,但是并不会立即刷新到磁盘,lsn 的值就和 flushed_to_disk_lsn 的值拉开了差距。我们演示一下:

  • 系统第一次启动后,向 log buffer 中写入了 mtr_1、mtr_2、mtr_3 这三个 mtr 产生的 redo 日志,假设这三个 mtr 开始和结束时对应的 lsn 值分别是:

    • mtr_1:8716 ~ 8916
    • mtr_2:8916 ~ 9948
    • mtr_3:9948 ~ 10000
  • 此时的 lsn 已经增长到了 10000,但是由于没有刷新操作,所以此时 flushed_to_disk_lsn 的值仍为 8704

  • 随后进行将 log buffer 中的 block 刷新到 redo 日志文件的操作,假设将 mtr_1 和 mtr_2 的日志刷新到磁盘,那么 flushed_to_disk_lsn 就应该增长 mtr_1 和 mtr_2 写入的日志量,所以 flushed_to_disk_lsn 的值增长到了 9948。

  • 综上所述,当有新的 redo 日志写入到 log buffer 时,首先 lsn 的值会增长, 但flushed_to_disk_lsn不变,随后随着不断有log buffer中的日志被刷新到磁盘上, flushed_to_disk_lsn 的值也跟着增长。如果两者的值相同时,说明 log buffer 中的 所有 redo 日志都已经刷新到磁盘中了

  • Tips:应用程序向磁盘写入文件时其实是先写到操作系统的缓冲区中去,如果某个写入操作要等到操作系统确认已经写到磁盘时才返回,那需要调用一下操作系统提供的 fsync 函数。其实只有当系统执行了 fsync 函数后, flushed_to_disk_lsn的值才会跟着增长当仅仅把log buffer中的日志写入到操作系统缓冲区却没有显式的刷新到磁盘时,另外的一个称之为write_lsn的值跟着增长。

  • 当然系统的LSN值远不止我们前面描述的lsn,还有很多。

查看系统中的各种 LSN
  • 我们可以使用 SHOW ENGINE INNODB STATUS 命令查看当前 InnoDB 存储引擎 中的各种 LSN 值的情况,比如:

    • SHOW ENGINE INNODB STATUS\G
  • 其中:

    • Log sequence number(lsn):代表系统中的 lsn 值,也就是当前系统已经写入的 redo 日志量,包括写入 log buffer 中的日志。
    • Log flushed up to(write_lsn):代表 flushed_to_disk_lsn 的值,也就是当前系统已经写入 磁盘的 redo 日志量。
    • Pages flushed up to:代表 flush 链表中被最早修改的那个页面对应的 oldest_modification 属性值。
    • Last checkpoint at:当前系统的 checkpoint_lsn 值。

innodb_flush_log_at_trx_commit 的用法

  • 我们前边说为了保证事务的持久性,用户线程在事务提交时需要将该事务执 行过程中产生的所有 redo 日志都刷新到磁盘上。会很明显的降低数据库性能。 如果对事务的持久性要求不是那么强烈的话,可以选择修改一个称为 innodb_flush_log_at_trx_commit 的系统变量的值,缺省值是1,该变量有 3 个可选的值:

    • 0:当该系统变量值为 0 时,表示在事务提交时不立即向磁盘中同步 redo 日 志,这个任务是交给后台线程做的。

      这样很明显会加快请求处理速度,但是如果事务提交后服务器挂了,后台线 程没有及时将 redo 日志刷新到磁盘,那么该事务对页面的修改会丢失。

    • 1:当该系统变量值为 1 时,表示在事务提交时需要将 redo 日志同步到磁盘, 可以保证事务的持久性。1 也是 innodb_flush_log_at_trx_commit 的默认值。

    • 2:当该系统变量值为 2 时,表示在事务提交时需要将 redo 日志写到操作系 统的缓冲区中,但并不需要保证将日志真正的刷新到磁盘。 这种情况下如果数据库挂了,操作系统没挂的话,事务的持久性还是可以保 证的,但是操作系统也挂了的话,那就不能保证持久性了。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值