日志系统:一条SQL更新语句是如何执行的?
前面介绍了一个查询语句的执行流程:一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎。那一条更新语句的执行流程又是怎样的呢?
之前你可能经常听 DBA 同事说,MySQL 可以恢复到半个月内任意一秒的状态,惊叹的同时,你是不是心中也会不免会好奇,这是怎样做到的呢?
我们还是从一个表的一条更新语句说起,下面是这个表的创建语句,这个表有一个主键 ID 和一个整型字段 c:
mysql> create table T(ID int primary key, c int);
如果要将 ID=2 这一行的值加 1,SQL 语句就会这么写:
mysql> update T set c=c+1 where ID=2;
其实,查询语句的那一套流程,更新语句也会同样走一遍,下图是之前的架构图:
- 先通过连接器来连数据库
- 在一个表上有更新的时候,跟这个表有关的查询缓存会失效,所以这条语句就会把表 T 上所有缓存结果都清空
- 接下来,分析器会解析得知这是一条更新语句
- 优化器决定执行方案,决定使用 ID 这个索引
- 执行器负责具体执行,找到这一行,然后更新
与查询流程不一样的是,更新流程还涉及两个重要的日志模块:redo log(重做日志)和 binlog(归档日志),它俩是本文的主角。
# 1. 重要的日志模块:redo log
还记得《孔乙己》吗,酒店掌柜有一个粉板,专门用来记录客人的赊账记录。如果赊账的人不多,那么他可以把顾客名和账目写在板上。但如果赊账的人多了,粉板总会有记不下的时候,这个时候掌柜一定还有一个专门记录赊账的账本。
如果有人要赊账或者还账的话,掌柜一般有两种做法:
- 一种做法是直接把账本翻出来,更新账本;
- 另一种做法是先在粉板上记下账,等空闲再把账本翻出来核算。
平时生意忙的时候,掌柜一定会选后者,毕竟前者太麻烦了,这么大的账本去找一个人很费时间。相比之下,还是先在粉板上记一下方便。同样,在 MySQL 里也有这个问题,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就也采用了类似酒店掌柜的做法。
粉板和账本配合的整个过程,其实就是 MySQL 中常说的 WAL(预写日志)技术。WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,等空闲时再写磁盘。
具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log 里面,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。
注意,只要写入 redo log,那这个数据库就算做了这个事情,也就是做了相应的更新,尽管可能还没真的去做,但对外来看就是做了。
类似于掌柜的粉板会写满,InnoDB 的 redo log 也是固定大小的。比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么这块“粉板”总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示:
- write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。
- checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。
write pos 和 checkpoint 之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。
有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe。
要理解 crash-safe 这个概念,可以想想我们前面赊账记录的例子。只要赊账记录记在了粉板上或写在了账本上,之后即使掌柜忘记了,比如突然停业几天,恢复生意后依然可以通过账本和粉板上的数据明确赊账账目。
所以我们说了,只要往 redo log 写了,就算数据库做过这件事了。
# 2. 重要的日志模块:binlog
前面我们讲过,MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到的粉板 redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。
也许你会问:为什么会有两份日志呢?
因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。
redo log 与 binlog 有以下三点不同:
- redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
- redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
- redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
有了对这两个日志的概念性理解,我们再来看执行器和 InnoDB 引擎在执行这个简单的 update 语句时的内部流程:
- 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
- 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
- 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
- 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
- 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。
下面给出这个 update 语句的执行流程图,图中浅色框表示是在 InnoDB 内部执行的,深色框表示是在执行器中执行的。
你可能注意到了,最后三步看上去有点“绕”,将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是"两阶段提交"。
这与 DDIA 中在分布式事务的原子提交中才讲的“两阶段提交”有点区别,这里不是分布式事务的。
# 3. 两阶段提交
为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致。要说明这个问题,我们得从文章开头的那个问题说起:怎样让数据库恢复到半个月内任意一秒的状态?
# 3.1 数据库的恢复
前面我们说过了,binlog 会记录所有的逻辑操作,并且是采用“追加写”的形式。如果你的 DBA 承诺说半个月内可以恢复,那么备份系统中一定会保存最近半个月的所有 binlog,同时系统会定期做整库备份。这里的“定期”取决于系统的重要性,可以是一天一备,也可以是一周一备。
具体的备份频率,需要考虑 RTO(恢复时间目标)这个指标。太频繁会消耗太多存储空间,但太久会使得 RTO 太大。
当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做:
- 首先找到最近的一次全量备份
- 然后从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻
这样你的临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。
除了恢复误删的表,有时候还需要增加从节点副本来增加读性能,现在常见的做法也是用全量备份加上应用 binlog 来实现的。因此,这个操作十分重要。
# 3.2 为什么需要两阶段提交
说完了数据恢复过程,我们回来说说,为什么日志需要“两阶段提交”。
这里不妨用反证法来进行解释。由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,要么采用反过来的顺序。我们看看这两种方式会有什么问题:
- 先写 redo log 后写 binlog:假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,就算数据库做了这件事了,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
- 先写 binlog 后写 redo log:如果在 binlog 写完之后 crash,由于 redo log 还没写,所以数据库就算还没做这件事,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。
可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。在采用了两阶段提交之后,可以分成这么三个过程:1. prepare 阶段 2. 写 binlog 3. commit,那:
- 如果在 2 之前崩溃:重启恢复:恢复后发现没有 commit,事务回滚,备份恢复时没有 binlog,两者一致。
- 如果在 3 之前崩溃:虽然没 commit,但满足 prepare 和 binlog 完整,所以重启后会自动 commit,备份恢复时有 binlog,两者一致。
要理清这里的关系。当发生崩溃时,重启恢复是只依靠 redo log 来实现的安全。而当需要备份或恢复时,是需要 binlog 来实现,毕竟 binlog 的记录主要就是用于备份,它有完整的逻辑记录。我们要实现的目标是重启恢复和备份恢复两个状况恢复得到的库是一样的状态,这样就不用担心日志写错了。
如果不用两阶段提交,那崩溃恢复后的主库可能与备份出来的从库产生数据不一致现象。
简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。
# Summary
今天介绍了 MySQL 中最重要的两个日志:物理日志 redo log 和逻辑日志 binlog。
- redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数我建议你设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。
- binlog 有完整的逻辑记录,就可以用于备份恢复,sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。
两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案,即使你不做数据库内核开发,日常开发中也有可能会用到。
# 补充
InnoDB 使用 redo log 的原因:其内部有一个 Buffer Pool 的结构,是用于缓存 MySQL 磁盘数据的内存空间。此时会涉及到 Buffer Pool 中的数据还没来得及刷盘,内存中的数据因断电就已丢失的问题。故每次修改后的值记录到磁盘上的 redo log 中,可以保证突然断电,也可以根据 redo log 来恢复 Buffer Pool,这样用到了 Buffer Pool 的内存高效性,也保证了数据不会丢失。
都是磁盘操作,为什么不直接将修改的数据写到数据文件里面去呢? 因为 redo log 是磁盘顺序写,数据刷盘是磁盘随机写,磁盘的顺序写比随机写高效的多。