侧边栏壁纸
  • 累计撰写 247 篇文章
  • 累计创建 16 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

通过 redo 日志保证数据库事务的持久性

kaixindeken
2021-07-22 / 0 评论 / 0 点赞 / 91 阅读 / 5,108 字

可以看到数据库事务主要是围绕写入操作(包括插入、更新和删除)展开的,因为查询操作其实对数据库表数据没有任何副作用,而写入操作则不然,有更多问题需要考虑,比如多个连接同时执行写入操作带来的并发安全问题(隔离性)、写入操作如何在即使出现宕机等异常问题依然可以保证数据不丢失(持久性)、写入操作的回滚机制底层如何实现(原子性)、如何在数据库集群中进行不同数据库之间的数据同步,等等。这样来看,查询优化的思路反而简单一些,只需要充分利用索引树即可。

接下来,我们将围绕数据库事务中的写入操作探究 InnoDB 引擎底层是如何保证 ACID 特性的,先来看如何保证数据库事务的持久性。

前情回顾

我们探究过 MySQL 服务端数据写入的流程,不过那里止步于具体的写入数据工作交给了更底层的存储引擎,并且在那里我们提到了数据更新的时候,MySQL 会记录两个日志,一个是 binlog 日志,一个是 redo 日志。binlog 日志是 MySQL Server 层的归档日志,与存储引擎无关,我们可以通过它实现数据备份和恢复,事实上,读写分离的数据库集群正是通过 binlog 实现不同数据库主机数据同步的,不过 binlog 并不能在数据库崩溃重启后实现数据的恢复,这个数据恢复是什么意思呢?

我们知道为了保证数据库的性能,不可能每次写入数据就立即持久化到磁盘(磁盘 IO 性能太差),而是将更新写入到内存中的缓存页,这一点我们在 Buffer Pool 那篇教程中已经介绍过,然后 MySQL 通过单独的进程基于指定更新策略异步将这些更新同步到磁盘。

这里就可能出现数据库崩溃后的数据恢复问题:如果 MySQL 服务器在某个时间节点出现异常宕机,但是数据库事务中已经提交的更新记录可能还没有从缓存页同步到磁盘,在服务器重启后,这些数据记录是需要进行恢复的,否则就不满足数据库事务的持久性特性了(已提交事务的操作结果会永久性保存下来),通过 binlog 日志无法恢复这些数据,因此在 InnoDB 存储引擎中专门提供了 redo 日志功能来实现这个功能。

注:MyISAM 引擎不支持数据库事务,所以在 MyISAM 中并没有 redo 日志功能。

redo 日志简介

关于 redo 日志和 binlog 日志如何协同确保数据库数据一致性的底层流程在 SQL 更新语句的执行流程与日志写入 已经介绍过,这里单独介绍 redo 日志及其底层实现,以及如何确保数据库崩溃后的数据恢复。

redo 直译过来就是重做,所以 redo 日志也称作重做日志,或者直接使用英文名 redo log,为了确保数据库事务的持久性,又不能将事务提交后更改的缓存页(脏页)立即同步到磁盘中,于是 MySQL 的设计者在 InnoDB 引擎中提供了 redo 日志功能,作为折中方案,我们会将数据库某条记录的更改以 redo 日志的方式持久化到磁盘,之后需要进行数据恢复时,通过对应的 redo 日志来恢复数据即可。

与同步完整的缓存页相比(一个数据库事务可能涉及到多个缓存页的更新),redo 日志占用的空间非常小,并且 redo 日志是顺序写入磁盘的,不存在随机磁盘 IO(对于一个数据库事务而言,可能涉及多张表、多条记录的更改,如果同步到磁盘,会涉及随机磁盘 IO,与顺序磁盘 IO 相比,随机磁盘 IO 性能差很多)。

那么 redo 日志小到什么程度呢?我们简单来看下它的内存结构:

1.jpeg

其中 type 字段表示 redo 日志的类型,MySQL 会为不同类型的操作设置不同的 redo 日志类型,space ID 表示表空间 ID,page number 表示页号,通过表空间 ID 和页号(数据页编号)就可以确定更新记录所在的数据页了,最后 data 表示具体修改的数据。可以看到,比起可能涉及到的多个完整数据页(每个大小 16 KB),redo 日志的结构非常简单。

关于 redo 日志底层细节不必深入了解,你只需要知道 redo 日志会把数据库事务在执行过程中对数据库所做的所有修改都记录下来,然后在系统崩溃重启后可以把事务所做的所有修改都恢复出来。

redo 日志写入

事务与 redo 日志的关联

需要注意的是,对于数据库事务中的某条更新语句而言,可能对应多个 redo 日志。

MySQL 是如何进行拆分的呢?以插入语句(INSERT)为例,一条插入记录可能涉及多个数据页的更改:

  • 首先是自增主键 ID 的更新;
  • 其次是主键索引(聚簇索引)的更新,会在所属 B+ 树叶子节点对应的数据页中插入一条新记录(如果原有数据页剩余空间已经存放不下新记录,还会进行页分裂等更复杂的操作);
  • 如果插入数据表还维护了多个二级索引,那么每个二级索引所属 B+ 对应的数据页中也要插入一条新记录(同样存在页分裂的可能)。
    以上所有的更新都对应一组 redo 日志。

在 MySQL 底层,将数据页的一次原子访问的过程称之为一个 Mini-Transaction,所以显然,每个 Mini-Transaction 都会维护一组 redo 日志。

redo 日志内存缓冲区

redo 日志底层写入机制和数据页的更新机制很像(数据页是 MySQL 维护数据库表数据读取和写入的基本单位),实际上,redo 日志也不是立即同步到磁盘的,因为一条 SQL 更新语句可能对应多个 redo 日志,这样的磁盘 IO 成本也很高。MySQL 会在内存中通过 redo log block(类似 Buffer Pool 中的缓存页)来存放 redo 日志,每个 block 的大小是 512,和缓存页存放在 Buffer Pool 类似,这些 redo log block 存放在 MySQL 服务器启动时申请的 redo log buffer 这片连续的内存空间中(默认大小是 16MB)。

1.jpeg

与 Buffer Pool 相比,redo log buffer 的维护要简单很多,所有的 redo 日志都是顺序写入的,写满一个 redo log block 写下一个,依次类推,只需要维护一个指向当前写入位置的全局变量指针即可,不需要通过额外的链表来维护。

一条 SQL 语句可能包含多个 Mini-Transaction,一个 Mini-Transaction 对应一组 redo 日志,因此,一条 SQL 语句可能对应多个 redo 日志,这些 redo 日志可能会顺序占用多个 redo log block。

当然,redo log buffer 大小是有限的,需要将内存中的 redo 日志刷新到磁盘才能持久化存储,为了保证数据库事务的持久性,也要及时刷新(总不能比缓存页刷新频率还低)。那么,这些存储在 redo log buffer 中的 redo 日志何时会被同步到磁盘?主要有以下几种策略:

  • redo log buffer 空间不足时,一般在超过其空间容量一半时刷新;
  • 事务提交时,这样一来,系统崩溃后,即便缓存页数据没有同步到磁盘,也可以通过 redo 日志恢复,从而确保事务的持久性;
  • 后台有一个线程,大约每秒都会刷新一次;
  • 正常关闭 MySQL 服务器时。

redo 日志磁盘文件

我们在安装 MySQL 并初始化数据库后,可以在MySQL 所在的磁盘目录下看到 ib_logfile0 和 ib_logfile1 文件,它们就是 redo 日志对应的磁盘文件:

1.jpeg

需要注意的是,和数据文件不同,redo 日志文件总容量是有限制的,每个 redo 日志大小默认上限是 48 MB,每组 redo 日志的个数是 2:

1.jpeg

这里每组的含义是 redo 日志不会无限新建,而是根据这个上限值循环覆盖,比如默认值是 2,那么会从 ib_logfile0 开始记录 redo 日志,ib_logfile0 写满之后接着通过 ib_logfile1 记录 redo 日志,如果 ib_logfile1 写满之后则开始覆盖 ib_logfile0,循环往复。。。你可以根据需要调整这个值的上限。

显然,redo 日志文件大小上限其实就是 innodb_log_file_size × innodb_log_files_in_group。

既然 redo 日志大小有上限,并且存在循环覆盖机制,所以这里又要设计一套刷新 redo 日志的机制了。在此之前,我们需要明确的是 redo 日志是被设计来恢复系统崩溃重启后未持久化到磁盘的表数据的,因此如果 redo 日志中对应的数据更新记录已经持久化到对应的数据表磁盘文件,那么这些 redo 日志就没有意义了,可以被覆盖了。

checkpoint

redo 日志底层通过 checkpoint 机制来处理循环覆盖问题。

MySQL 设计了一个 LSN 来标识 redo 日志的序列号(类似主键 ID,不断增长),并且通过全局变量 checkpoint_lsn 来标识当前系统中可以被覆盖的 redo 日志总量是多少,如果 Buffer Pool 中的某个脏页被刷新到磁盘,则相关的 redo 日志就可以被覆盖了,所以可以进行一个增加 checkpoint_lsn 的操作,我们把这个过程称之为做一次 checkpoint。

做一次 checkpoint 其实可以分为两个步骤:

  • 计算一下当前系统中可以被覆盖的 redo 日志对应的 LSN 值最大是多少,并将其赋值给 checkpoint_lsn;
  • 将 checkpoint_lsn 和对应的 redo 日志文件组偏移量(比如 0、1,对应 ib_logfile 后面的值)以及此次 checkpint 的编号写到 redo 日志文件的管理信息中。

记录完 checkpoint 的信息之后,就可以在需要循环覆盖时覆盖掉 checkpoint_lsn 之前的 redo 日志了。

如何通过 redo 日志恢复数据

在 redo 日志中,由于 checkpoint_lsn 之前的数据已经同步到磁盘,所以在系统崩溃重启恢复数据时不需要关心,需要从 checkpoint_lsn 之后的位置开始读取 redo 日志恢复数据页即可。

在恢复过程中,MySQL 底层还提供了一些性能优化策略:

  • 基于 redo 日志的 space ID 和 page number 属性计算出哈希值作为 key,将对应 redo 日志作为 value 构建哈希表。这样一来,表空间 ID 和页号相同的 redo 日志就可以被放到哈希表的同一个槽里,如果有多个这样的 redo 日志,那么它们之间使用链表连接起来(按照 redo 日志生成时间进行排序,这样才能正常恢复)。这样做的好处是恢复数据页时,可以一次恢复一个数据页上的所有条记录,从而尽可能避免随机磁盘 IO;
  • 每个数据页都有一个 File Header 结构,里面有一个称之为 FIL_PAGE_LSN 的属性,该属性记载了最近一次修改页面时对应的 lsn 值,如果在做了某次 checkpoint 之后有脏页被刷新到磁盘中,那么该页对应的 FIL_PAGE_LSN 代表的 lsn 值肯定大于 checkpoint_lsn 的值,我们可以跳过这样的已同步页面加快数据恢复速度。
0

评论区