本文由 简悦 SimpRead) 转码, 原文地址 mp.weixin.qq.com)

来自公众号:业余码农

目前数据库的类型主要分为两种,一种是关系型数据库,另一种是非关系型数据库(NoSQL)。而我们今天的主角 MySQL 就是关系型数据库中的一种。

MySQL 事务日志 - 图2

1 关系型数据库与 NoSQL

简单来说,关系型数据库的存储就是按照表格进行的。数据的存储实际上就是对一个或者多个表格的存储。通过对这些表格进行分类、合并、连接或者选取等运算来实现对数据库的管理。常见的关系型数据库有 MySQL、Oracle、DB2 和 SqlServer 等。

非关系型数据库(NoSQL)是相对于关系型数据库的一种泛指,它的特点是去掉了关系型数据库中的关系特性,从而可获得更好的扩展性。NoSQL 并没有严格的存储方式,但采用不同的存储结构都是为了获得更高的性能和更高的并发。

NoSQL 根据存储方式可分为四大类,键值存储数据库、列存储数据库、文档型数据库和图形数据库。这四种数据的存储原理不尽相同,因而在应用场景上也有些许的差异。一般常用的有作为数据缓存的 redis 和分布式系统的 HBase。目前常见的数据库排名可见网站:

https://db-engines.com/en/ranking

MySQL 事务日志 - 图3 四种 NoSQL 的特点比较

关系型数据库与非关系型数据库本质上的区别就在于存储的数据是否具有一定的逻辑关系,由此产生的两类数据库看的性能和优劣势上也有一定的区别。二者对比可见下图。
MySQL 事务日志 - 图4关系型数据库与 NoSQL 的优缺点对比

2 MySQL 简介

介绍

在关系型数据库中,MySQL 可以说是其中的王者。它是目前最流行的数据库之一,由瑞典 MySQL AB 公司开发,目前属于 Oracle 公司。MySQL 数据库具有以下几个方面的优势:

  • 体积小、速度快;
  • 代码开源,采用了 GPL 协议,可以修改源码来开发自己的 MySQL 系统;
  • 支持大型的数据库,可以处理拥有上千万条记录的大型数据库;
  • 使用标准的 SQL 数据语言形式,并采用优化的 SQL 查询算法,有效地提高查询速度;
  • 使用 C 和 C++ 编写,并使用多种编译器进行测试,保证源代码的可移植性;
  • 可运行在多个系统上,并且支持多种语言;
  • 核心程序采用完全的多线程编程,可以灵活地为用户提供服务,充分利用 CPU 资源。

逻辑架构

MySQL 的逻辑架构可分为四层,包括连接层、服务层、引擎层和存储层,各层的接口交互及作用如下图所示。需要注意的是,由于本文将主要讲解事务的实现原理,因此下文针对的都是 InnoDB 引擎下的情况。

连接层: 负责处理客户端的连接以及权限的认证。 服务层: 定义有许多不同的模块,包括权限判断,SQL 接口,SQL 解析,SQL 分析优化, 缓存查询的处理以及部分内置函数执行等。MySQL 的查询语句在服务层内进行解析、优化、缓存以及内置函数的实现和存储。 引擎层: 负责 MySQL 中数据的存储和提取。MySQL 中的服务器层不管理事务,事务是由存储引擎实现的。其中使用最为广泛的存储引擎为 InnoDB,其它的引擎都不支持事务。 存储层: 负责将数据存储与设备的文件系统中。

MySQL 事务日志 - 图5

MySQL 的逻辑架构

3 MySQL 事务

事务是 MySQL 区别于 NoSQL 的重要特征,是保证关系型数据库数据一致性的关键技术。事务可看作是对数据库操作的基本执行单元,可能包含一个或者多个 SQL 语句。这些语句在执行时,要么都执行,要么都不执行。

事务的执行主要包括两个操作,提交和回滚。

提交:commit,将事务执行结果写入数据库。

回滚:rollback,回滚所有已经执行的语句,返回修改之前的数据。

MySQL 事务包含四个特性,号称 ACID 四大天王。

原子性(Atomicity) :语句要么全执行,要么全不执行,是事务最核心的特性,事务本身就是以原子性来定义的;实现主要基于 undo log 日志实现的。 持久性(Durability :保证事务提交后不会因为宕机等原因导致数据丢失;实现主要基于 redo log 日志。 隔离性(Isolation) :保证事务执行尽可能不受其他事务影响;InnoDB 默认的隔离级别是 RR,RR 的实现主要基于锁机制、数据的隐藏列、undo log 和类 next-key lock 机制。 一致性(Consistency) :事务追求的最终目标,一致性的实现既需要数据库层面的保障,也需要应用层面的保障。

原子性

事务的原子性就如原子操作一般,表示事务不可再分,其中的操作要么都做,要么都不做;如果事务中一个 SQL 语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态。只有 0 和 1,没有其它值。

事务的原子性表明事务就是一个整体,当事务无法成功执行的时候,需要将事务中已经执行过的语句全部回滚,使得数据库回归到最初未开始事务的状态。

事务的原子性就是通过 undo log 日志进行实现的。当事务需要进行回滚时,InnoDB 引擎就会调用 undo log 日志进行 SQL 语句的撤销,实现数据的回滚。

持久性

事务的持久性是指当事务提交之后,数据库的改变就应该是永久性的,而不是暂时的。这也就是说,当事务提交之后,任何其它操作甚至是系统的宕机故障都不会对原来事务的执行结果产生影响。

事务的持久性是通过 InnoDB 存储引擎中的 redo log 日志来实现的,具体实现思路见下文。

隔离性

原子性和持久性是单个事务本身层面的性质,而隔离性是指事务之间应该保持的关系。隔离性要求不同事务之间的影响是互不干扰的,一个事务的操作与其它事务是相互隔离的。

由于事务可能并不只包含一条 SQL 语句,所以在事务的执行期间很有可能会有其它事务开始执行。因此多事务的并发性就要求事务之间的操作是相互隔离的。这一点跟多线程之间数据同步的概念有些类似。

一致性(Consistency)

事务追求的最终目标,一致性的实现既需要数据库层面的保障,也需要应用层面的保障。

锁机制

当一个事务需要对数据库中的某行数据进行修改时,需要先给数据加锁;加了锁的数据,其它事务是不运行操作的,只能等待当前事务提交或回滚将锁释放。

锁机制并不是一个陌生的概念,在许多场景中都会利用到不同实现的锁对数据进行保护和同步。而在 MySQL 中,根据不同的划分标准,还可将锁分为不同的种类。

按照粒度划分:行锁、表锁、页锁 按照使用方式划分:共享锁、排它锁 按照思想划分:悲观锁、乐观锁

锁机制的知识点很多,由于篇幅不好全部展开讲。这里对按照粒度划分的锁进行简单介绍。

粒度:指数据仓库的数据单位中保存数据的细化或综合程度的级别。细化程度越高,粒度级就越小;相反,细化程度越低,粒度级就越大。

MySQL 按照锁的粒度划分可以分为行锁、表锁和页锁。

行锁:粒度最小的锁,表示只针对当前操作的行进行加锁;

表锁:粒度最大的锁,表示当前的操作对整张表加锁;

页锁:粒度介于行级锁和表级锁中间的一种锁,表示对页进行加锁。

MySQL 事务日志 - 图6数据库的粒度划分

这三种锁是在不同层次上对数据进行锁定,由于粒度的不同,其带来的好处和劣势也不一而同。

表锁在操作数据时会锁定整张表,因而并发性能较差; 行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源 (获得锁、检查锁、释放锁等都需要消耗资源),因此在锁定数据较多情况下使用表锁可以节省大量资源。

MySQL 中不同的存储引擎能够支持的锁也是不一样的。MyIsam 只支持表锁,而 InnoDB 同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。

并发读写问题

在并发情况下,MySQL 的同时读写可能会导致三类问题,脏读、不可重复度和幻读。

(1)脏读:当前事务中读到其他事务未提交的数据,也就是脏数据。

MySQL 事务日志 - 图7

以上图为例,事务 A 在读取文章的阅读量时,读取到了事务 B 为提交的数据。如果事务 B 最后没有顺利提交,导致事务回滚,那么实际上阅读量并没有修改成功,而事务 A 却是读到的修改后的值,显然不合情理。

(2)不可重复读:在事务 A 中先后两次读取同一个数据,但是两次读取的结果不一样。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。

MySQL 事务日志 - 图8

以上图为例,事务 A 在先后读取文章阅读量的数据时,结果却不一样。说明事务 A 在执行的过程中,阅读量的值被其它事务给修改了。这样使得数据的查询结果不再可靠,同样也不合实际。

(3)幻读:在事务 A 中按照某个条件先后两次查询数据库,两次查询结果的行数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。

MySQL 事务日志 - 图9

以上图为例,当对 0 < 阅读量 < 100 的文章进行查询时,先查到了一个结果,后来查询到了两个结果。这表明同一个事务的查询结果数不一,行数不一致。这样的问题使得在根据某些条件对数据筛选的时候,前后筛选结果不具有可靠性。

隔离级别

根据上面这三种问题,产生了四种隔离级别,表明数据库不同程度的隔离性质。

MySQL 事务日志 - 图10

在实际的数据库设计中,隔离级别越高,导致数据库的并发效率会越低;而隔离级别太低,又会导致数据库在读写过程中会遇到各种乱七八糟的问题。

因此在大多数数据库系统中,默认的隔离级别时读已提交(如 Oracle)或者可重复读 RR(MySQL 的 InnoDB 引擎)。

MVCC

又是一个难嚼的大块头。MVCC 就是用来实现上面的第三个隔离级别,可重复读 RR。

MVCC:Multi-Version Concurrency Control,即多版本的并发控制协议。

MVCC 的特点就是在同一时刻,不同事务可以读取到不同版本的数据,从而可以解决脏读和不可重复读的问题。

MVCC 实际上就是通过数据的隐藏列和回滚日志(undo log),实现多个版本数据的共存。这样的好处是,使用 MVCC 进行读数据的时候,不用加锁,从而避免了同时读写的冲突。

在实现 MVCC 时,每一行的数据中会额外保存几个隐藏的列,比如当前行创建时的版本号和删除时间和指向 undo log 的回滚指针。这里的版本号并不是实际的时间值,而是系统版本号。每开始新的事务,系统版本号都会自动递增。事务开始时的系统版本号会作为事务的版本号,用来和查询每行记录的版本号进行比较。

每个事务又有自己的版本号,这样事务内执行数据操作时,就通过版本号的比较来达到数据版本控制的目的。

另外,InnoDB 实现的隔离级别 RR 时可以避免幻读现象的,这是通过next-key lock机制实现的。

next-key lock实际上就是行锁的一种,只不过它不只是会锁住当前行记录的本身,还会锁定一个范围。比如上面幻读的例子,开始查询 0 < 阅读量 < 100 的文章时,只查到了一个结果。next-key lock会将查询出的这一行进行锁定,同时还会对 0 < 阅读量 < 100 这个范围进行加锁,这实际上是一种间隙锁。间隙锁能够防止其他事务在这个间隙修改或者插入记录。这样一来,就保证了在 0 < 阅读量 < 100 这个间隙中,只存在原来的一行数据,从而避免了幻读。

间隙锁:封锁索引记录中的间隔

虽然 InnoDB 使用next-key lock能够避免幻读问题,但却并不是真正的可串行化隔离。再来看一个例子吧。

MySQL 事务日志 - 图11

首先提一个问题:

在 T6 时间,事务 A 提交事务之后,猜一猜文章 A 和文章 B 的阅读量为多少?

答案是,文章 AB 的阅读量都被修改成了 10000。这代表着事务 B 的提交实际上对事务 A 的执行产生了影响,表明两个事务之间并不是完全隔离的。虽然能够避免幻读现象,但是却没有达到可串行化的级别。

这还说明,避免脏读、不可重复读和幻读,是达到可串行化的隔离级别的必要不充分条件。可串行化是都能够避免脏读、不可重复读和幻读,但是避免脏读、不可重复读和幻读却不一定达到了可串行化。

一致性

一致性是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。

一致性是事务追求的最终目标,原子性、持久性和隔离性,实际上都是为了保证数据库状态的一致性而存在的。

这就不多说了吧。你细品。

4 MySQL 日志系统

了解完 MySQL 的基本架构,大体上能够对 MySQL 的执行流程有了比较清晰的认知。接下来我将为大家介绍一下日志系统。

MySQL 日志系统是数据库的重要组件,用于记录数据库的更新和修改。若数据库发生故障,可通过不同日志记录恢复数据库的原来数据。因此实际上日志系统直接决定着 MySQL 运行的鲁棒性和稳健性。

MySQL 的日志有很多种,如二进制日志(binlog)、错误日志、查询日志、慢查询日志等,此外 InnoDB 存储引擎还提供了两种日志:redo log(重做日志)和 undo log(回滚日志)。这里将重点针对 InnoDB 引擎,对重做日志、回滚日志和二进制日志这三种进行分析。

重做日志(redo log)

重做日志(redo log)是 InnoDB 引擎层的日志,用来记录事务操作引起数据的变化,记录的是数据页的物理修改。

重做日记的作用其实很好理解,我打个比方。数据库中数据的修改就好比你写的论文,万一哪天论文丢了怎么呢?以防这种不幸的发生,我们可以在写论文的时候,每一次修改都拿个小本本记录一下,记录什么时间对某一页进行了怎么样的修改。这就是重做日志。

InnoDB 引擎对数据的更新,是先将更新记录写入 redo log 日志,然后会在系统空闲的时候或者是按照设定的更新策略再将日志中的内容更新到磁盘之中。这就是所谓的预写式技术(Write Ahead logging)。这种技术可以大大减少 IO 操作的频率,提升数据刷新的效率。

脏数据刷盘

值得注意的是,redo log 日志的大小是固定的,为了能够持续不断的对更新记录进行写入,在 redo log 日志中设置了两个标志位置,checkpoint 和 write_pos,分别表示记录擦除的位置和记录写入的位置。redo log 日志的数据写入示意图可见下图。

MySQL 事务日志 - 图12

write_pos标志到了日志结尾时,会从结尾跳至日志头部进行重新循环写入。所以 redo log 的逻辑结构并不是线性的,而是可看作一个圆周运动。write_poscheckpoint中间的空间可用于写入新数据,写入和擦除都是往后推移,循环往复的。

MySQL 事务日志 - 图13

write_pos追上checkpoint时,表示 redo log 日志已经写满。这时不能继续执行新的数据库更新语句,需要停下来先删除一些记录,执行checkpoint规则腾出可写空间。

checkpoint 规则:checkpoint 触发后,将 buffer 中脏数据页和脏日志页都刷到磁盘。

脏数据:指内存中未刷到磁盘的数据。

redo log 中最重要的概念就是缓冲池buffer pool,这是在内存中分配的一个区域,包含了磁盘中部分数据页的映射,作为访问数据库的缓冲。

当请求读取数据时,会先判断是否在缓冲池命中,如果未命中才会在磁盘上进行检索后放入缓冲池;

当请求写入数据时,会先写入缓冲池,缓冲池中修改的数据会定期刷新到磁盘中。这一过程也被称之为刷脏

因此,当数据修改时,除了修改buffer pool中的数据,还会在 redo log 中记录这次操作;当事务提交时,会根据 redo log 的记录对数据进行刷盘。如果 MySQL 宕机,重启时可以读取 redo log 中的数据,对数据库进行恢复,从而保证了事务的持久性,使得数据库获得crash-safe能力。

脏日志刷盘

除了上面提到的对于脏数据的刷盘,实际上 redo log 日志在记录时,为了保证日志文件的持久化,也需要经历将日志记录从内存写入到磁盘的过程。redo log 日志可分为两个部分,一是存在易失性内存中的缓存日志redo log buff,二是保存在磁盘上的 redo log 日志文件redo log file

为了确保每次记录都能够写入到磁盘中的日志中,每次将redo log buffer中的日志写入redo log file的过程中都会调用一次操作系统的fsync操作。

fsync 函数:包含在 UNIX 系统头文件 #include 中,用于同步内存中所有已修改的文件数据到储存设备。

在写入的过程中,还需要经过操作系统内核空间的os buffer。redo log 日志的写入过程可见下图。

MySQL 事务日志 - 图14

redo log 日志刷盘流程

二进制日志(binlog)

二进制日志 binlog 是服务层的日志,还被称为归档日志。binlog 主要记录数据库的变化情况,内容包括数据库所有的更新操作。所有涉及数据变动的操作,都要记录进二进制日志中。因此有了 binlog 可以很方便的对数据进行复制和备份,因而也常用作主从库的同步。

这里 binlog 所存储的内容看起来似乎与 redo log 很相似,但是其实不然。redo log 是一种物理日志,记录的是实际上对某个数据进行了怎么样的修改;而 binlog 是逻辑日志,记录的是 SQL 语句的原始逻辑,比如” 给 ID=2 这一行的 a 字段加 1 “。binlog 日志中的内容是二进制的,根据日记格式参数的不同,可能基于 SQL 语句、基于数据本身或者二者的混合。一般常用记录的都是 SQL 语句。

这里的物理和逻辑的概念,我的个人理解是:

物理的日志可看作是实际数据库中数据页上的变化信息,只看重结果,而不在乎是通过 “何种途径” 导致了这种结果; 逻辑的日志可看作是通过了某一种方法或者操作手段导致数据发生了变化,存储的是逻辑性的操作。

同时,redo log 是基于crash recovery,保证 MySQL 宕机后的数据恢复;而 binlog 是基于point-in-time recovery,保证服务器可以基于时间点对数据进行恢复,或者对数据进行备份。

事实上最开始 MySQL 是没有 redo log 日志的。因为起先 MySQL 是没有 InnoDB 引擎的,自带的引擎是 MyISAM。binlog 是服务层的日志,因此所有引擎都能够使用。但是光靠 binlog 日志只能提供归档的作用,无法提供crash-safe能力,所以 InnoDB 引擎就采用了学自于 Oracle 的技术,也就是 redo log,这才拥有了crash-safe能力。这里对 redo log 日志和 binlog 日志的特点分别进行了对比:

MySQL 事务日志 - 图15redo log 与 binlog 的特点比较

在 MySQL 执行更新语句时,都会涉及到 redo log 日志和 binlog 日志的读写。一条更新语句的执行过程如下:

MySQL 事务日志 - 图16
MySQL 更新语句的执行过程

从上图可以看出,MySQL 在执行更新语句的时候,在服务层进行语句的解析和执行,在引擎层进行数据的提取和存储;同时在服务层对 binlog 进行写入,在 InnoDB 内进行 redo log 的写入。

不仅如此,在对 redo log 写入时有两个阶段的提交,一是 binlog 写入之前prepare状态的写入,二是 binlog 写入之后commit状态的写入。

之所以要安排这么一个两阶段提交,自然是有它的道理的。现在我们可以假设不采用两阶段提交的方式,而是采用 “单阶段” 进行提交,即要么先写入 redo log,后写入 binlog;要么先写入 binlog,后写入 redo log。这两种方式的提交都会导致原先数据库的状态和被恢复后的数据库的状态不一致。

先写入 redo log,后写入 binlog:

在写完 redo log 之后,数据此时具有crash-safe能力,因此系统崩溃,数据会恢复成事务开始之前的状态。但是,若在 redo log 写完时候,binlog 写入之前,系统发生了宕机。此时 binlog 没有对上面的更新语句进行保存,导致当使用 binlog 进行数据库的备份或者恢复时,就少了上述的更新语句。从而使得id=2这一行的数据没有被更新。

MySQL 事务日志 - 图17
先写 redo log 后写 binlog 的问题

先写入 binlog,后写入 redo log:

写完 binlog 之后,所有的语句都被保存,所以通过 binlog 复制或恢复出来的数据库中 id=2 这一行的数据会被更新为 a=1。但是如果在 redo log 写入之前,系统崩溃,那么 redo log 中记录的这个事务会无效,导致实际数据库中id=2这一行的数据并没有更新。

MySQL 事务日志 - 图18
先写 binlog 后写 redo log 的问题

由此可见,两阶段的提交就是为了避免上述的问题,使得 binlog 和 redo log 中保存的信息是一致的。

回滚日志(undo log)

回滚日志同样也是 InnoDB 引擎提供的日志,顾名思义,回滚日志的作用就是对数据进行回滚。当事务对数据库进行修改,InnoDB 引擎不仅会记录 redo log,还会生成对应的 undo log 日志;如果事务执行失败或调用了 rollback,导致事务需要回滚,就可以利用 undo log 中的信息将数据回滚到修改之前的样子。

但是 undo log 与 redo log 不一样,它属于逻辑日志。它对 SQL 语句执行相关的信息进行记录。当发生回滚时,InnoDB 引擎会根据 undo log 日志中的记录做与之前相反的工作。比如对于每个数据插入操作(insert),回滚时会执行数据删除操作(delete);对于每个数据删除操作(delete),回滚时会执行数据插入操作(insert);对于每个数据更新操作(update),回滚时会执行一个相反的数据更新操作(update),把数据改回去。undo log 由两个作用,一是提供回滚,二是实现 MVCC。

5 主从复制

主从复制的概念很简单,就是从原来的数据库复制一个完全一样的数据库,原来的数据库称作主数据库,复制的数据库称为从数据库。从数据库会与主数据库进行数据同步,保持二者的数据一致性。

主从复制的原理实际上就是通过 binlog 日志实现的。bin log 日志中保存了数据库中所有 SQL 语句,通过对 bin log 日志中 SQL 的复制,然后再进行语句的执行即可实现从数据库与主数据库的同步。

主从复制的过程主要是靠三个线程进行的,
一个运行在主服务器中的发送线程,用于发送 binlog 日志到从服务器。
两外两个运行在从服务器上的 I/O 线程和 SQL 线程。
I/O 线程用于读取主服务器发送过来的 binlog 日志内容,并拷贝到本地的中继日志中。
SQL 线程用于读取中继日志中关于数据更新的 SQL 语句并执行,从而实现主从库的数据一致。

MySQL 事务日志 - 图19
主从复制原理

之所以需要实现主从复制,实际上是由实际应用场景所决定的。主从复制能够带来的好处有:

  1. 通过复制实现数据的异地备份,当主数据库故障时,可切换从数据库,避免数据丢失。
  2. 可实现架构的扩展,当业务量越来越大,I/O 访问频率过高时,采用多库的存储,可以降低磁盘 I/O 访问的频率,提高单个机器的 I/O 性能。
  3. 可实现读写分离,使数据库能支持更大的并发。
  4. 实现服务器的负载均衡,通过在主服务器和从服务器之间切分处理客户查询的负荷。