楔子
上一篇文章我们介绍了 MySQL 的基本架构,这里再来回顾一下。
整个架构还是很好理解的,我们说 MySQL 分为 Server 层和存储引擎层。其中 Server 层包含了 MySQL 的大多数核心服务功能,而存储引擎层则负责提供数据的存储和读取,并且是插件式的,一个 Server 层支持不同的存储引擎层。
而本篇文章就来探讨一下存储引擎层,虽然 MySQL 的存储引擎可以有多种,但默认、也是最常用的则是 InnoDB,因此我们也只介绍 InnoDB。
Buffer Pool
InnoDB 有一个很重要的组件叫做 Buffer Pool,它是放在内存里面的,用于缓存数据。如果查询的数据在内存当中,那么直接返回,就不用再从磁盘读取了。
但这里存在一个问题,如果是查询数据的话很好理解,就是先查询 Buffer Pool,有的话直接返回;没有的话,则查询磁盘。可如果是更新数据的话该怎么办?比如对 id 等于 1 的数据进行更新。
其实是一样的道理,首先会查看 id = 1 这行数据在不在 Buffer Pool,如果不在,就将数据从磁盘加载到 Buffer Pool,然后更新。并且更新的时候要加独占锁,因为不允许多个连接同时更新同一条数据,关于锁的内容后续会详细介绍。
而更新完 Buffer Pool 之后,id = 1 的这行数据就变成了脏数据,因为此时内存数据和磁盘数据不一样了。那么之后要怎么做呢?这里先暂且按下不表,一会儿我们再聊,我们先来聊一下 undo log。
undo log
假设我们要执行这样一条 SQL 语句:
UPDATE t SET name = "古明地恋" WHERE id = 1;
之前 id = 1 这一行 name 字段的值为 "古明地觉",我们现在要将它更新为 "古明地恋",那么 MySQL 会先把 id = 1 和更新之前的值 "古明地觉" 写入到 undo 日志文件中。
之所以要这么做,就是为了能够回滚。update 语句如果在一个事务中,那么在事务提交之前,我们都是可以将数据进行回滚的,也就是将 name 的值从 "古明地恋" 回滚为 "古明地觉"。而为了实现这一点,就需要先将更新前的值写入到 undo log 中,所以这个日志的名字起得很形象,也可以叫 Ctrl+Z log(皮~)。
当然啦,我们不仅可以手动回滚数据,在事务执行出错时还会自动回滚。比如在一个事务中有三条 update 语句,前两条执行成功,但是执行第三条的时候报错了,那么前两个 update 修改的数据也会回滚为修改之前的值。而数据能够回滚,离不开 undo log。
更新 Buffer Pool 的缓存数据
当 MySQL 将磁盘数据加载到 Buffer Pool 并加上独占锁,同时将更新前的旧值写入 undo log 之后,就可以更新数据了。在内存里将 id = 1 这行数据的 name 字段的值更新为 "古明地恋",而更新成功之后,我们说这条数据就成为了脏数据,因为数据在磁盘上没有变化,但在内存里面已经被修改了。
那么此时问题就来了,如果 Buffer Pool 里的数据更新之后,MySQL 服务宕掉了,该怎么办?
因此这个时候,就必须把对内存、也就是 Buffer Pool 所做的修改,记录到 redo log buffer 里面。redo log buffer 也是内存的一个缓冲区,用来存放 redo log 日志的。这里又出现了一种新的日志:redo log,它是负责记录 MySQL 对内存数据都做了哪些修改,比如 "将 id = 1 这一行记录的 name 字段的值更新为 "古明地恋"。"
所以我们平时说写 redo log,并不是直接就写到 redo log 里面去,而是先写到 redo log buffer 里面,然后 redo log buffer 再将修改记录刷新到 redo log 里面,此时才算是落盘。所以 redo log 的作用就是在 MySQL 突然宕机的时候,恢复更新过的数据。
但就从上图来看,此时的修改还在内存里面。如果事务提交之前,MySQL 服务宕掉,那么无论是 Buffer Pool 还是 redo log buffer,所做的修改都将丢失,因为它们的操作都在内存当中,还没有涉及到磁盘。
但是此时会对数据产生影响吗?其实是不会的,虽然内存中更新的数据丢失了,但是磁盘中的数据还是老样子。MySQL 服务重启之后,数据没有任何变化,还是更新之前的状态。所以此时 MySQL 服务宕掉,没有任何影响,只是需要再更新一次罢了。
写入 redo log
如果 MySQL 服务一切正常,那么我们就要提交事务了,此时会把日志从 redo log buffer(内存)刷新到 redo log(磁盘)里面。而刷新策略则通过 innodb_flush_log_at_trx_commit 参数指定,它有以下几个可选项。
1)该参数设置为 0,会在事务提交时,不把日志从内存刷新到 redo log。那么此时即便提交事务,对数据所做的修改依旧可能丢失。因为目前既没有把 Buffer Pool 中更新的数据刷新到磁盘,也没有把日志刷新到 redo log,所以这个参数我们基本不会设置成 0。
2)该参数设置为 1,会在提交事务时,将日志从 redo log buffer 刷新到 redo log 中。换句话说,只要事务提交成功,那么日志一定成功写入 redo log。
redo log 记录的是 "对哪些数据做了什么修改",所以当前事务提交成功之后,redo log 里面就会有这样一条日志:"将 id = 1 这行数据的 name 字段的值更新为 "古明地恋"。"
所以即便此时 Buffer Pool 里的数据是脏数据也不要紧(数据还没有同步到磁盘),因为日志已经伴随着事务的提交从 redo log buffer 刷新到 redo log 里面了。即便 MySQL 宕掉,在重启之后我们也能将数据找回来,因为在 redo log 里面记录了我们将 id = 1 的 name 字段更新为 "古明地恋"。
所以 MySQL 重启之后,只需要根据 redo log 日志去恢复之前对数据所做的修改即可。
3)该参数还可以设置成 2,意思是事务提交时不将日志从 redo log buffer 刷新到 redo log(磁盘),而是写入到文件系统缓存(page cache)里面。所以此时 MySQL 宕掉了也没关系,因为数据还在 page cache 里面,但如果是 MySQL 所在节点宕掉了,仍然有可能造成事务提交之后数据丢失。
所以该参数设置为 0 和设置为 2 区别不大,一个是提交时什么也不做,另一个是提交时写入 page cache。而之所以说区别不大,是因为 InnoDB 有一个后台线程,每秒钟会定期将 redo log buffer 里的日志刷新到 page cache,然后调用 fsync 刷盘。另外当 redo log buffer 的大小达到参数 innodb_log_buffer_size 的一半时,后台线程也会刷盘。
但对于数据库这种软件来说,它的定位就是可靠存储,因此要严格保证事务提交之后,数据不丢失,所以生产环境中这个参数基本上都是 1(默认值),不会设置成 0 和 2。要保证只要事务提交,那么日志一定写入 redo log,即便 MySQL 宕机,也能在重启之后根据 redo log 进行恢复。
注意:MySQL 保证的是,在事务成功提交之后,对数据所做的修改是不丢失的。但如果在事务提交之前,MySQL 服务宕掉了,那就只能重启之后再提交一次了,但此时对数据是没有影响的。
binlog
前面介绍了 undo log 和 redo log,前者用于回滚,后者用于恢复更新之后的值。这里再来介绍一个新的日志:binlog,也被称为二进制日志或归档日志。
binlog 和 redo log 所做的事情有点类似,都是记录对数据进行了哪些修改,但 redo log 更偏向物理性,binlog 更偏向逻辑性。我们知道 MySQL 读取数据是以页为单位的,即使只查询或者更新一条数据,也会从硬盘加载一整页的数据(数据页)到 Buffer Pool。如果后续查询的数据命中 Buffer Pool,那么能够减少磁盘 IO,更新数据的时候也会先更新 Buffer Pool。
所以 redo log 记录了修改之后的内存页的数据,而 binlog 则是以事件的形式记录了除查询之外的 SQL 语句。binlog 有以下三种模式,通过参数 binlog_format 指定:
1)binlog_format=row
设置 binlog_format=row,那么 binlog 会记录每次操作后每一行记录的变化,它的优点是保持数据的绝对一致性,因为不管是什么 SQL,引用什么函数,它只记录执行后的效果。
但是缺点也很明显,采用 row 模式,binlog 会占用很大空间。假设我们执行了一个 update 语句,修改了 100 万行记录,那么 binlog 就会记录这 100 万行变化之后的结果,很明显这会导致 binlog 文件的膨胀。
2)binlog_format=statement
设置 binlog_format=statement,那么 binlog 会记录每次操作的 SQL 语句,假设还是执行 update,但不管这个 update 修改了多少行的记录,binlog 只会记录这对应的一条 update 语句。因此它的好处就是节省空间,而缺点则更加致命,因为它可能导致数据不一致。假设我们使用了 now()、uuid()、rand() 等函数,那么 binlog 记录的也只是这几个函数,但很明显从库在进行 binlog 回放时生成的结果就会和主库不一致。
3)binlog_format=mixed
混合级别,statement 的升级版,一定程度上解决了 statement 模式因为一些特殊情况而造成的数据不一致问题。
默认还是 statement,但是在某些特殊情况下,比如出现了重复执行生成的结果不一致的函数(uuid, rand, now 等)时、包含 AUTO_INCREMENT 字段的表被更新时、执行 INSERT DELAYED 语句时、使用 UDF 时,会按照 row 模式进行处理,也就是保存执行后的数据。
redo log 是 InnoDB 特有的,它记录的是修改之后的内存页的数据,并且事务的原子性和持久性就是基于 redo log 实现的,它确保了一个事务里面的操作要么全部成功、要么全部失败。而 binlog 则是 Server 层产生的,也就是说不管什么存储引擎,都会有 binlog 的产生,它以事件的形式记录了原始的 SQL 逻辑,另外主从复制就是采用的 binlog。
binlog 的写入
我们说在提交事务的时候,会把 redo 日志写入 redo log 文件中,但同时也会将 binlog 日志写入到 binlog 文件中。
在图中多了一个执行器,我们说它的作用就是不断调用存储引擎提供的接口,完成优化器生成的执行计划。实际上执行器非常重要,它负责加载磁盘数据到 Buffer Pool、写 undo log 日志、更新 Buffer Pool 里的数据、写 redo log buffer、将 redo log 刷新到磁盘、写 binlog 日志等等,总之执行器会和存储引擎配合完成一个 SQL 语句在内存与磁盘层面的全部更新操作。
然后我们看到图中总共有 6 个步骤,其中 1 2 3 4 是执行 SQL 语句时做的事情,而 5 6 则是提交事务时所做的事情。
另外补充一下,binlog 日志刷盘也是有策略的,由参数 sync_binlog 控制,默认值为 0。表示当写 binlog 日志的时候,并不直接写入 binlog 文件中,而是写入到 page cache。因此这和之前分析的一样,如果 MySQL 所在节点宕机,那么 binlog 会丢失。
如果 sync_binlog 设置为 1,那么在提交事务的时候会强制写入到 binlog 磁盘文件里面去。这样即便节点宕机,也不会丢失 binlog 日志。
基于 redo log 和 binlog 完成事务提交
当我们把 binlog 写入磁盘文件之后,接着就会完成最终的事务提交,此时会把本次更新对应的 binlog 文件名称和本次更新的 binlog 日志在文件里的位置,都写入到 redo log 文件里去,同时在 redo log 文件里写入一个 commit 标记。
所以在提交事务的时候,光写 redo log 是不够的,还要写 binlog,并且还要把 binlog 文件的名称、以及本次更新的 binlog 日志在 binlog 文件中的位置也写入到 redo log 文件里面。然后再往里面写一个 commit 标记,才算事务完成。
这里可能有人好奇,为什么最后要往 redo log 文件里面再写一个 commit 标记呢?答案很简单,为了保持 redo log 文件和 binlog 文件的一致性。
我们举个例子,在提交事务的时候,会经历以上 5 6 7 三个步骤,这三个步骤必须全部成功,事务才算提交成功。但是在刚完成步骤 5 的时候,MySQL 宕掉了,此时该怎么办呢?很简单,因为 redo log 文件没有 commit 标记,所以此时的事务判定为提交不成功。
因此事务提交成功,redo log 文件里面一定有日志(记录了内存页做了哪些修改),但redo log 里面有日志,并不代表事务就一定成功。只有等到 binlog 日志写入 binlog 文件、然后将 binlog 文件名以及日志的位置再写入 redo log 文件,并写入 commit 标记之后,事务才算提交成功。
问题又来了,如果是在刚完成步骤 6 的时候,MySQL 宕掉了,此时该怎么办呢?一样的道理,因为 redo log 文件中没有最终的 commit 标记,因此此时事务提交也是失败的。
必须是在 redo log 文件中写入最终的事务 commit 标记之后,事务才算提交成功。而且 redo log 文件里有本次更新对应的日志, binlog 文件里也有本次更新对应的日志 ,而这就是所谓的两阶段提交。
将内存的脏数据刷到磁盘
现在假设我们已经提交事务了,执行了以下更新语句:
UPDATE t SET name = "古明地恋" WHERE id = 1;
那么此时 Buffer Pool 中的数据会被更新,同时 redo log 日志和 binlog 日志都已写到磁盘,都记录了我们将 id = 1 的 name 字段修改为 "古明地恋"。只不过记录的方式不一样,前者记录的是修改之后的内存页,后者则以事件的形式记录了 SQL 语句。
但是 Buffer Pool 里的数据和磁盘数据还不一样,所以 MySQL 有一个后台的 IO 线程,会在某个时间随机地将 Buffer Pool 中的脏页刷到磁盘上。而在 IO 线程把脏页刷回磁盘之前,哪怕 MySQL 宕掉也没关系。因为重启之后,会在 Buffer Pool 里面根据 redo log 日志恢复之前提交事务时做过的修改,也就是将 id=1 的 name 字段修改为 "古明地恋"。然后等适当时机,IO 线程还是会把这个修改后的数据刷到磁盘的数据文件里。
小结
到目前为止,我们对 InnoDB 有了一个清楚的认识。InnoDB 存储引擎主要就是包含了一些 buffer pool, redo log buffer 等内存里的缓存数据,同时还包含了一些 undo log文件,redo log 文件等,同时 MySQL 的 Server 层还有自己的 binlog 文件。
执行更新的时候,每条 SQL 语句,都会对应:修改 Buffer Pool 里的缓存数据、写 undo 日志、写 redo log buffer 几个步骤。但是当提交事务的时候,一定会把 redo log 日志刷入磁盘,binlog 日志刷入磁盘,完成 redo log 文件中的事务 commit 标记。然后在某一时刻,由后台的IO线程随机地把 Buffer Pool 里的脏数据刷到磁盘里。
最后再思考一个问题,为什么 MySQL 要搞出这么多复杂的概念,执行 SQL 更新语句的时候,直接修改磁盘数据不就好了?其实不用想也知道原因,真要这么干的话,那速度绝对是相当慢,因为随便一个大文件的随机写操作都要几百毫秒。
所以在对数据做增删改查的时候,都是在 Buffer Pool 里进行的,但是为了避免数据丢失,MySQL 又引入了 undo log, redo log, binlog 等机制。但数据最终肯定是要落盘的,而这一步就交给后台 IO 线程去做了。
关于 undo log, redo log, binlog 这三种日志的更多细节,比如它们长什么样,到底是怎么写入的,我们还没有说。目前先了解它们是干什么的,更多内容我们后续慢慢再聊。