MySQL实战45讲

00开篇词 这一次,让我们一起来搞懂MySQL

你好,我是林晓斌,网名“丁奇”,欢迎加入我的专栏,和我一起开始 MySQL 学习之旅。我曾先后在百度和阿里任职,从事 MySQL 数据库方面的工作,一步步地从一个数据库小白成为 MySQL 内核开发人员。回想起来,从我第一次带着疑问翻 MySQL 的源码查到答案至今,已经有十个年头了。在这个过程中,走了不少弯路,但同时也收获了很多的知识和思考,希望能在这个专栏里分享给你。

记得刚开始接触 MySQL,是我在百度贴吧做权限系统的时候。我们遇到了一个奇怪的问题,一个正常 10 毫秒就能完成的 SQL 查询请求偶尔要执行 100 多毫秒才结束。当时主管问我是什么原因,我其实也搞不清楚,就上网查答案,但怎么找都找不到,又脸皮薄不想说自己不知道,只好硬着头皮翻源码。后来遇到了越来越多的问题,也是类似的情景,所以我逐步养成了通过分析源码理解原理的习惯。

当时,我自己的感觉是,即使我只是一个开发工程师,只是 MySQL 的用户,在了解了一个个系统模块的原理后,再来使用它,感觉是完全不一样的。当在代码里写下一行数据库命令的时候,我就能想到它在数据库端将怎么执行,它的性能是怎么样的,怎样写能让我的应用程序访问数据库的性能最高。进一步,哪些数据处理让数据库系统来做性能会更好,哪些数据处理在缓存里做性能会更好,我心里也会更清楚。在建表和建索引的时候,我也会更有意识地为将来的查询优化做综合考虑,比如确定是否使用递增主键、主键的列怎样选择,等等。

但随后我又有了一个新的困惑,我觉得自己了解的 MySQL 知识点是零散的,没有形成网络。于是解决完一个问题后,很容易忘记。再碰到类似的问题,我又得再翻一次代码。

所幸在阿里工作的时候,我参与了阿里云关系型数据库服务内核的开发,并且负责开发开源分支 AliSQL,让我对 MySQL 内核和源码有了更深层次的研究和理解。在服务内部客户和公有云客户的过程中,我有机会面对和解决足够多的问题,再通过手册进行系统的学习,算是比较坎坷地将 MySQL 的知识网络补了起来。

所以,在回顾这个过程的时候,我的第一个感受是,如果一开始就有一些从理论到实战的系统性指导,那该多好啊,也许我可以学习得更快些。

在极客时间团队跟我联系策划这个专栏的时候,我还是持怀疑态度的。为什么呢?现在不比当年了,犹记得十余年前,你使用 MySQL 的过程中碰到问题的话,基本上都只能到代码里去找答案,因为那时网上的资料太少了。

而近十年来,MySQL 在中国广泛普及,技术分享文章可以说是浩如烟海。所以,现在要系统地介绍一遍 MySQL 的话,恐怕里面提及的大多数知识点,都可以在社区文章中找到。那么我们做这个专栏的意义在哪里,而它又凭什么可以收费呢?

直到收到极客时间团队的答复,我才开始对这个专栏“想做和可以做”的事情感觉清晰起来。数据库是一个综合系统,其背后是发展了几十年的数据库理论。同时,数据库系统也是一个应用系统,可能一个业务开发人员用了两三年 MySQL,还未必清楚那些自己一直在用的“最佳实践”为什么是最佳的。

于是,我希望这个专栏能够帮助这样的一些开发者:他们正在使用 MySQL,知道如何写出逻辑正确的 SQL 语句来实现业务目标,却不确定这个语句是不是最优的;他们听说了一些使用数据库的最佳实践,但是更想了解为什么这么做;他们使用的数据库偶尔会出问题,亟需了解如何更快速、更准确地定位问题,甚至自己解决问题……

在过去的七年里,我带过十几个应届毕业生,看着他们成长,要求他们原理先行,再实践验证。几年下来,他们的成长速度都很快,其中好几个毕业没两年就成为团队的骨干力量了。我也在社招的时候面试过很多有着不错的运维实践经验和能力的候选人,但都因为对数据库原理仅有一知半解的了解,而最终遗憾地没有通过面试。

因此,我希望这个专栏能够激发开发者对数据库原理的探索欲,从而更好地理解工作中遇到的问题,更能知道背后的为什么。所以我会选那些平时使用数据库时高频出现的知识,如事务、索引、锁等内容构成专栏的主线。这些主线上是一个个的知识点。每个点就是一个概念、一个机制或者一个原理说明。在每个说明之后,我会和你讨论一个实践相关的问题。

希望能以这样的方式,让你对 MySQL 的几条主线有一个整体的认识,并且了解基本概念。在之后的实践篇中,我会引用到这些主线的知识背景,并着力说明它们是怎样指导实践的。这样,你可以从点到线,再到面,形成自己的 MySQL 知识网络。

在这里,有一份目录,你也可以先了解下整个专栏的知识结构。

img

如前面说的,这几条主线上的每个知识点几乎都不是最新的,有些甚至十年前就这样,并没有改过。但我希望针对这些点的说明,可以让你在使用 MySQL 时心里更有底,知道怎么做选择,并且明白为什么。了解了原理,才能在实践中不断创新,提升个人的价值和工作输出。

从这里开始,跟我一起搞懂 MySQL!

01 基础架构:一条SQL查询语句是如何执行的?

你好,我是林晓斌。

这是专栏的第一篇文章,我想来跟你聊聊 MySQL 的基础架构。我们经常说,看一个事儿千万不要直接陷入细节里,你应该先鸟瞰其全貌,这样能够帮助你从高维度理解问题。同样,对于 MySQL 的学习也是这样。平时我们使用数据库,看到的通常都是一个整体。比如,你有个最简单的表,表里只有一个 ID 字段,在执行下面这个查询语句时:

mysql> select * from T where ID=10;

我们看到的只是输入一条语句,返回一个结果,却不知道这条语句在 MySQL 内部的执行过程。

所以今天我想和你一起把 MySQL 拆解一下,看看里面都有哪些“零件”,希望借由这个拆解过程,让你对 MySQL 有更深入的理解。这样当我们碰到 MySQL 的一些异常或者问题时,就能够直戳本质,更为快速地定位并解决问题。

下面我给出的是 MySQL 的基本架构示意图,从中你可以清楚地看到 SQL 语句在 MySQL 的各个功能模块中的执行过程。

img

MySQL 的逻辑架构图

大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。

Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。

而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。

也就是说,你执行 create table 建表的时候,如果不指定引擎类型,默认使用的就是 InnoDB。不过,你也可以通过指定存储引擎的类型来选择别的引擎,比如在 create table 语句中使用 engine=memory, 来指定使用内存引擎创建表。不同存储引擎的表数据存取方式不同,支持的功能也不同,在后面的文章中,我们会讨论到引擎的选择。

从图中不难看出,不同的存储引擎共用一个Server 层,也就是从连接器到执行器的部分。你可以先对每个组件的名字有个印象,接下来我会结合开头提到的那条 SQL 语句,带你走一遍整个执行流程,依次看下每个组件的作用。

连接器

第一步,你会先连接到这个数据库上,这时候接待你的就是连接器。连接器负责跟客户端建立连接、获取权限、维持和管理连接。连接命令一般是这么写的:

mysql -h$ip -P$port -u$user -p

输完命令之后,你就需要在交互对话里面输入密码。虽然密码也可以直接跟在 -p 后面写在命令行中,但这样可能会导致你的密码泄露。如果你连的是生产服务器,强烈建议你不要这么做。

连接命令中的 mysql 是客户端工具,用来跟服务端建立连接。在完成经典的 TCP 握手后,连接器就要开始认证你的身份,这个时候用的就是你输入的用户名和密码。

  • 如果用户名或密码不对,你就会收到一个”Access denied for user”的错误,然后客户端程序结束执行。
  • 如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。

这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。

连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它。文本中这个图是 show processlist 的结果,其中的 Command 列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接。

img客户端如果太长时间没动静,连接器就会自动将它断开。这个时间是由参数 wait_timeout 控制的,默认值是 8 小时。

如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。

数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。

建立连接的过程通常是比较复杂的,所以我建议你在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。

但是全部使用长连接后,你可能会发现,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。

怎么解决这个问题呢?你可以考虑以下两种方案。

  1. 定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
  2. 如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。

查询缓存

连接建立完成后,你就可以执行 select 语句了。执行逻辑就会来到第二步:查询缓存。

MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。

如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。

但是大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利。

查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。

好在 MySQL 也提供了这种“按需使用”的方式。你可以将参数 query_cache_type 设置成 DEMAND,这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定,像下面这个语句一样:

mysql> select SQL_CACHE * from T where ID=10;

需要注意的是,MySQL 8.0 版本直接将查询缓存的整块功能删掉了,也就是说 8.0 开始彻底没有这个功能了。

分析器

如果没有命中查询缓存,就要开始真正执行语句了。首先,MySQL 需要知道你要做什么,因此需要对 SQL 语句做解析。

分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。

MySQL 从你输入的”select”这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。

做完了这些识别以后,就要做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法。

如果你的语句不对,就会收到“You have an error in your SQL syntax”的错误提醒,比如下面这个语句 select 少打了开头的字母“s”。

mysql> elect * from t where ID=1; 
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'elect * from t where ID=1' at line 1

一般语法错误会提示第一个出现错误的位置,所以你要关注的是紧接“use near”的内容。

优化器

经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。

优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。比如你执行下面这样的语句,这个语句是执行两个表的 join:

mysql> select * from t1 join t2 using(ID)  where t1.c=10 and t2.d=20;
  • 既可以先从表 t1 里面取出 c=10 的记录的 ID 值,再根据 ID 值关联到表 t2,再判断 t2 里面 d 的值是否等于 20。
  • 也可以先从表 t2 里面取出 d=20 的记录的 ID 值,再根据 ID 值关联到 t1,再判断 t1 里面 c 的值是否等于 10。

这两种执行方法的逻辑结果是一样的,但是执行的效率会有不同,而优化器的作用就是决定选择使用哪一个方案。

优化器阶段完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段。如果你还有一些疑问,比如优化器是怎么选择索引的,有没有可能选择错等等,没关系,我会在后面的文章中单独展开说明优化器的内容。

执行器

MySQL 通过分析器知道了你要做什么,通过优化器知道了该怎么做,于是就进入了执行器阶段,开始执行语句。

开始执行的时候,要先判断一下你对这个表 T 有没有执行查询的权限,如果没有,就会返回没有权限的错误,如下所示 (在工程实现上,如果命中查询缓存,会在查询缓存返回结果的时候,做权限验证。查询也会在优化器之前调用 precheck 验证权限)。

mysql> select * from T where ID=10; 
ERROR 1142 (42000): SELECT command denied to user 'b'@'localhost' for table 'T'

如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。

比如我们这个例子中的表 T 中,ID 字段没有索引,那么执行器的执行流程是这样的:

  1. 调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;
  2. 调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
  3. 执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。

至此,这个语句就执行完成了。

对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。

你会在数据库的慢查询日志中看到一个 rows_examined 的字段,表示这个语句执行过程中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。

在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟 rows_examined 并不是完全相同的。我们后面会专门有一篇文章来讲存储引擎的内部机制,里面会有详细的说明。

小结

今天我给你介绍了 MySQL 的逻辑架构,希望你对一个 SQL 语句完整执行流程的各个阶段有了一个初步的印象。由于篇幅的限制,我只是用一个查询的例子将各个环节过了一遍。如果你还对每个环节的展开细节存有疑问,也不用担心,后续在实战章节中我还会再提到它们。

我给你留一个问题吧,如果表 T 中没有字段 k,而你执行了这个语句 select * from T where k=1, 那肯定是会报“不存在这个列”的错误: “Unknown column ‘k’ in ‘where clause’”。你觉得这个错误是在我们上面提到的哪个阶段报出来的呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

02 日志系统:一条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;

前面我有跟你介绍过 SQL 语句基本的执行链路,这里我再把那张图拿过来,你也可以先简单看看这个图回顾下。首先,可以确定的说,查询语句的那一套流程,更新语句也是同样会走一遍。

img

MySQL 的逻辑架构图

你执行语句前要先连接数据库,这是连接器的工作。

前面我们说过,在一个表上有更新的时候,跟这个表有关的查询缓存会失效,所以这条语句就会把表 T 上所有缓存结果都清空。这也就是我们一般不建议使用查询缓存的原因。

接下来,分析器会通过词法和语法解析知道这是一条更新语句。优化器决定要使用 ID 这个索引。然后,执行器负责具体执行,找到这一行,然后更新。

与查询流程不一样的是,更新流程还涉及两个重要的日志模块,它们正是我们今天要讨论的主角:redo log(重做日志)和 binlog(归档日志)。如果接触 MySQL,那这两个词肯定是绕不过的,我后面的内容里也会不断地和你强调。不过话说回来,redo log 和 binlog 在设计上有很多有意思的地方,这些设计思路也可以用到你自己的程序里。

重要的日志模块:redo log

不知道你还记不记得《孔乙己》这篇文章,酒店掌柜有一个粉板,专门用来记录客人的赊账记录。如果赊账的人不多,那么他可以把顾客名和账目写在板上。但如果赊账的人多了,粉板总会有记不下的时候,这个时候掌柜一定还有一个专门记录赊账的账本。

如果有人要赊账或者还账的话,掌柜一般有两种做法:

  • 一种做法是直接把账本翻出来,把这次赊的账加上去或者扣除掉;
  • 另一种做法是先在粉板上记下这次的账,等打烊以后再把账本翻出来核算。

在生意红火柜台很忙时,掌柜一定会选择后者,因为前者操作实在是太麻烦了。首先,你得找到这个人的赊账总额那条记录。你想想,密密麻麻几十页,掌柜要找到那个名字,可能还得带上老花镜慢慢找,找到之后再拿出算盘计算,最后再将结果写回到账本上。

这整个过程想想都麻烦。相比之下,还是先在粉板上记一下方便。你想想,如果掌柜没有粉板的帮助,每次记账都得翻账本,效率是不是低得让人难以忍受?

同样,在 MySQL 里也有这个问题,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就用了类似酒店掌柜粉板的思路来提升更新效率。

而粉板和账本配合的整个过程,其实就是 MySQL 里经常说到的 WAL 技术,WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。

具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log(粉板)里面,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做,这就像打烊以后掌柜做的事。

如果今天赊账的不多,掌柜可以等打烊后再整理。但如果某天赊账的特别多,粉板写满了,又怎么办呢?这个时候掌柜只好放下手中的活儿,把粉板中的一部分赊账记录更新到账本中,然后把这些记录从粉板上擦掉,为记新账腾出空间。

与此类似,InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么这块“粉板”总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。

img

write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。

write pos 和 checkpoint 之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。

有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为crash-safe

要理解 crash-safe 这个概念,可以想想我们前面赊账记录的例子。只要赊账记录记在了粉板上或写在了账本上,之后即使掌柜忘记了,比如突然停业几天,恢复生意后依然可以通过账本和粉板上的数据明确赊账账目。

重要的日志模块: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 能力。

这两种日志有以下三点不同。

  1. redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
  3. redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。

有了对这两个日志的概念性理解,我们再来看执行器和 InnoDB 引擎在执行这个简单的 update 语句时的内部流程。

  1. 执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

这里我给出这个 update 语句的执行流程图,图中浅色框表示是在 InnoDB 内部执行的,深色框表示是在执行器中执行的。

img

update 语句执行流程

你可能注意到了,最后三步看上去有点“绕”,将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是”两阶段提交”。

两阶段提交

为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致。要说明这个问题,我们得从文章开头的那个问题说起:怎样让数据库恢复到半个月内任意一秒的状态?

前面我们说过了,binlog 会记录所有的逻辑操作,并且是采用“追加写”的形式。如果你的 DBA 承诺说半个月内可以恢复,那么备份系统中一定会保存最近半个月的所有 binlog,同时系统会定期做整库备份。这里的“定期”取决于系统的重要性,可以是一天一备,也可以是一周一备。

当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做:

  • 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;
  • 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。

这样你的临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。

好了,说完了数据恢复过程,我们回来说说,为什么日志需要“两阶段提交”。这里不妨用反证法来进行解释。

由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。

仍然用前面的 update 语句来做例子。假设当前 ID=2 的行,字段 c 的值是 0,再假设执行 update 语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了 crash,会出现什么情况呢?

  1. 先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。 但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。 然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
  2. 先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。

你可能会说,这个概率是不是很低,平时也没有什么动不动就需要恢复临时库的场景呀?

其实不是的,不只是误操作后需要用这个过程来恢复数据。当你需要扩容的时候,也就是需要再多搭建一些备库来增加系统的读能力的时候,现在常见的做法也是用全量备份加上应用 binlog 来实现的,这个“不一致”就会导致你的线上出现主从数据库不一致的情况。

简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

小结

今天,我介绍了 MySQL 里面最重要的两个日志,即物理日志 redo log 和逻辑日志 binlog。

redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数我建议你设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。

sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。

我还跟你介绍了与 MySQL 日志系统密切相关的“两阶段提交”。两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案,即使你不做数据库内核开发,日常开发中也有可能会用到。

文章的最后,我给你留一个思考题吧。前面我说到定期全量备份的周期“取决于系统重要性,有的是一天一备,有的是一周一备”。那么在什么场景下,一天一备会比一周一备更有优势呢?或者说,它影响了这个数据库系统的哪个指标?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的答案。

感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

03 事务隔离:为什么你改了我还看不见?

提到事务,你肯定不陌生,和数据库打交道的时候,我们总是会用到事务。最经典的例子就是转账,你要给朋友小王转 100 块钱,而此时你的银行卡只有 100 块钱。

转账过程具体到程序里会有一系列的操作,比如查询余额、做加减法、更新余额等,这些操作必须保证是一体的,不然等程序查完之后,还没做减法之前,你这 100 块钱,完全可以借着这个时间差再查一次,然后再给另外一个朋友转账,如果银行这么整,不就乱了么?这时就要用到“事务”这个概念了。

简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。你现在知道,MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。

今天的文章里,我将会以 InnoDB 为例,剖析 MySQL 在事务支持方面的特定实现,并基于原理给出相应的实践建议,希望这些案例能加深你对 MySQL 事务原理的理解。

隔离性与隔离级别

提到事务,你肯定会想到 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),今天我们就来说说其中 I,也就是“隔离性”。

当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。

在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。下面我逐一为你解释:

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

其中“读提交”和“可重复读”比较难理解,所以我用一个例子说明这几种隔离级别。假设数据表 T 中只有一列,其中一行的值为 1,下面是按照时间顺序执行两个事务的行为。

mysql> create table T(c int) engine=InnoDB;
insert into T(c) values(1);

img我们来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2、V3 的返回值分别是什么。

  • 若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
  • 若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
  • 若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
  • 若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。

在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

我们可以看到在不同的隔离级别下,数据库行为是有所不同的。Oracle 数据库的默认隔离级别其实就是“读提交”,因此对于一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,你一定要记得将 MySQL 的隔离级别设置为“读提交”。

配置的方式是,将启动参数 transaction-isolation 的值设置成 READ-COMMITTED。你可以用 show variables 来查看当前的值。

mysql> show variables like 'transaction_isolation'; 
+-----------------------+----------------+ 
| Variable_name | Value | 
+-----------------------+----------------+ 
| transaction_isolation | READ-COMMITTED | 
+-----------------------+----------------+

总结来说,存在即合理,哪个隔离级别都有它自己的使用场景,你要根据自己的业务情况来定。我想你可能会问那什么时候需要“可重复读”的场景呢?我们来看一个数据校对逻辑的案例。

假设你在管理一个个人银行账户表。一个表存了每个月月底的余额,一个表存了账单明细。这时候你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。

这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。

事务隔离的实现

理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明“可重复读”。

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。

假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。

img当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。

同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。

你一定会问,回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。

什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。

基于上面的说明,我们来讨论一下为什么建议你尽量不要使用长事务。

长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。

在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有 20GB,而回滚段有 200GB 的库。最终只好为了清理回滚段,重建整个库。

除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库,这个我们会在后面讲锁的时候展开。

事务的启动方式

如前面所述,长事务有这些潜在风险,我当然是建议你尽量避免。其实很多时候业务开发同学并不是有意使用长事务,通常是由于误用所致。MySQL 的事务启动方式有以下几种:

  1. 显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
  2. set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。

有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。

因此,我会建议你总是使用 set autocommit=1, 通过显式语句的方式来启动事务。

但是有的开发同学会纠结“多一次交互”的问题。对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。如果你也有这个顾虑,我建议你使用 commit work and chain 语法。

在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。

你可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。

select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60

小结

这篇文章里面,我介绍了 MySQL 的事务隔离级别的现象和实现,根据实现原理分析了长事务存在的风险,以及如何用正确的方式避免长事务。希望我举的例子能够帮助你理解事务,并更好地使用 MySQL 的事务特性。

我给你留一个问题吧。你现在知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,你会有什么方案来避免出现或者处理这种情况呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

在上期文章的最后,我给你留下的问题是一天一备跟一周一备的对比。

好处是“最长恢复时间”更短。

在一天一备的模式里,最坏情况下需要应用一天的 binlog。比如,你每天 0 点做一次全量备份,而要恢复出一个到昨天晚上 23 点的备份。

一周一备最坏情况就要应用一周的 binlog 了。

系统的对应指标就是 @尼古拉斯·赵四 @慕塔 提到的 RTO(恢复目标时间)。

当然这个是有成本的,因为更频繁全量备份需要消耗更多存储空间,所以这个 RTO 是成本换来的,就需要你根据业务重要性来评估了。

同时也感谢 @super blue cat、@高枕、@Jason 留下了高质量的评论。

04 深入浅出索引(上)

提到数据库索引,我想你并不陌生,在日常工作中会经常接触到。比如某一个 SQL 查询比较慢,分析完原因之后,你可能就会说“给某个字段加个索引吧”之类的解决方案。但到底什么是索引,索引又是如何工作的呢?今天就让我们一起来聊聊这个话题吧。

数据库索引的内容比较多,我分成了上下两篇文章。索引是数据库系统里面最重要的概念之一,所以我希望你能够耐心看完。在后面的实战文章中,我也会经常引用这两篇文章中提到的知识点,加深你对数据库索引的理解。

一句话简单来说,索引的出现其实就是为了提高数据查询的效率,就像书的目录一样。一本 500 页的书,如果你想快速找到其中的某一个知识点,在不借助目录的情况下,那我估计你可得找一会儿。同样,对于数据库的表而言,索引其实就是它的“目录”。

索引的常见模型

索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。可以用于提高读写效率的数据结构很多,这里我先给你介绍三种常见、也比较简单的数据结构,它们分别是哈希表、有序数组和搜索树。

下面我主要从使用的角度,为你简单分析一下这三种模型的区别。

哈希表是一种以键 - 值(key-value)存储数据的结构,我们只要输入待查找的值即 key,就可以找到其对应的值即 Value。哈希的思路很简单,把值放在数组里,用一个哈希函数把 key 换算成一个确定的位置,然后把 value 放在数组的这个位置。

不可避免地,多个 key 值经过哈希函数的换算,会出现同一个值的情况。处理这种情况的一种方法是,拉出一个链表。

假设,你现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示:

img

图 1 哈希表示意图

图中,User2 和 User4 根据身份证号算出来的值都是 N,但没关系,后面还跟了一个链表。假设,这时候你要查 ID_card_n2 对应的名字是什么,处理步骤就是:首先,将 ID_card_n2 通过哈希函数算出 N;然后,按顺序遍历,找到 User2。

需要注意的是,图中四个 ID_card_n 的值并不是递增的,这样做的好处是增加新的 User 时速度会很快,只需要往后追加。但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的。

你可以设想下,如果你现在要找身份证号在 [ID_card_X, ID_card_Y] 这个区间的所有用户,就必须全部扫描一遍了。

所以,哈希表这种结构适用于只有等值查询的场景,比如 Memcached 及其他一些 NoSQL 引擎。

有序数组在等值查询和范围查询场景中的性能就都非常优秀。还是上面这个根据身份证号查名字的例子,如果我们使用有序数组来实现的话,示意图如下所示:

img

图 2 有序数组示意图

这里我们假设身份证号没有重复,这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查 ID_card_n2 对应的名字,用二分法就可以快速得到,这个时间复杂度是 O(log(N))。

同时很显然,这个索引结构支持范围查询。你要查身份证号在 [ID_card_X, ID_card_Y] 区间的 User,可以先用二分法找到 ID_card_X(如果不存在 ID_card_X,就找到大于 ID_card_X 的第一个 User),然后向右遍历,直到查到第一个大于 ID_card_Y 的身份证号,退出循环。

如果仅仅看查询效率,有序数组就是最好的数据结构了。但是,在需要更新数据的时候就麻烦了,你往中间插入一个记录就必须得挪动后面所有的记录,成本太高。

所以,有序数组索引只适用于静态存储引擎,比如你要保存的是 2017 年某个城市的所有人口信息,这类不会再修改的数据。

二叉搜索树也是课本里的经典数据结构了。还是上面根据身份证号查名字的例子,如果我们用二叉搜索树来实现的话,示意图如下所示:

img

图 3 二叉搜索树示意图

二叉搜索树的特点是:每个节点的左儿子小于父节点,父节点又小于右儿子。这样如果你要查 ID_card_n2 的话,按照图中的搜索顺序就是按照 UserA -> UserC -> UserF -> User2 这个路径得到。这个时间复杂度是 O(log(N))。

当然为了维持 O(log(N)) 的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(log(N))。

树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。

你可以想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。

为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用“N 叉”树。这里,“N 叉”树中的“N”取决于数据块的大小。

以 InnoDB 的一个整数字段索引为例,这个 N 差不多是 1200。这棵树高是 4 的时候,就可以存 1200 的 3 次方个值,这已经 17 亿了。考虑到树根的数据块总是在内存中的,一个 10 亿行的表上一个整数字段的索引,查找一个值最多只需要访问 3 次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。

N 叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。

不管是哈希还是有序数组,或者 N 叉树,它们都是不断迭代、不断优化的产物或者解决方案。数据库技术发展到今天,跳表、LSM 树等数据结构也被用于引擎设计中,这里我就不再一一展开了。

你心里要有个概念,数据库底层存储的核心就是基于这些数据模型的。每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。

截止到这里,我用了半篇文章的篇幅和你介绍了不同的数据结构,以及它们的适用场景,你可能会觉得有些枯燥。但是,我建议你还是要多花一些时间来理解这部分内容,毕竟这是数据库处理数据的核心概念之一,在分析问题的时候会经常用到。当你理解了索引的模型后,就会发现在分析问题的时候会有一个更清晰的视角,体会到引擎设计的精妙之处。

现在,我们一起进入相对偏实战的内容吧。

在 MySQL 中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。而即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。由于 InnoDB 存储引擎在 MySQL 数据库中使用最为广泛,所以下面我就以 InnoDB 为例,和你分析一下其中的索引模型。

InnoDB 的索引模型

在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为前面我们提到的,InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。

每一个索引在 InnoDB 里面对应一棵 B+ 树。

假设,我们有一个主键列为 ID 的表,表中有字段 k,并且在 k 上有索引。

这个表的建表语句是:

mysql> create table T(
id int primary key, 
k int not null, 
name varchar(16),
index (k))engine=InnoDB;

表中 R1~R5 的 (ID,k) 值分别为 (100,1)、(200,2)、(300,3)、(500,5) 和 (600,6),两棵树的示例示意图如下。

img

图 4 InnoDB 的索引组织结构

从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。

主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。

非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。

根据上面的索引结构说明,我们来讨论一个问题:基于主键索引和普通索引的查询有什么区别?

  • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
  • 如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。

也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。

索引维护

B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行 ID 值为 700,则只需要在 R5 的记录后面插入一个新记录。如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。

而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。

除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。

当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

基于上面的索引维护过程说明,我们来讨论一个案例:

你可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,我们来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。

自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。

插入新记录的时候可以不指定 ID 的值,系统会获取当前 ID 最大值加 1 作为下一条记录的 ID 值。

也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。

而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。

除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?

由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。

显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。

所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。

有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:

  1. 只有一个索引;
  2. 该索引必须是唯一索引。

你一定看出来了,这就是典型的 KV 场景。

由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。

这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

小结

今天,我跟你分析了数据库引擎可用的数据结构,介绍了 InnoDB 采用的 B+ 树结构,以及为什么 InnoDB 要这么选择。B+ 树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。

由于 InnoDB 是索引组织表,一般情况下我会建议你创建一个自增主键,这样非主键索引占用的空间最小。但事无绝对,我也跟你讨论了使用业务逻辑字段做主键的应用场景。

最后,我给你留下一个问题吧。对于上面例子中的 InnoDB 表 T,如果你要重建索引 k,你的两个 SQL 语句可以这么写:

alter table T drop index k;
alter table T add index(k);

如果你要重建主键索引,也可以这么写:

alter table T drop primary key;
alter table T add primary key(id);

我的问题是,对于上面这两个重建索引的作法,说出你的理解。如果有不合适的,为什么,更好的方法是什么?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章末尾给你留下的问题是:如何避免长事务对业务的影响?

这个问题,我们可以从应用开发端和数据库端来看。

首先,从应用开发端来看:

  1. 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
  2. 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
  3. 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)

其次,从数据库端来看:

  1. 监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
  2. Percona 的 pt-kill 这个工具不错,推荐使用;
  3. 在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
  4. 如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。

感谢 @壹笙☞漂泊 @王凯 @易翔 留下的高质量评论。

05 深入浅出索引(下)

在上一篇文章中,我和你介绍了 InnoDB 索引的数据结构模型,今天我们再继续聊聊跟 MySQL 索引有关的概念。

在开始这篇文章之前,我们先来看一下这个问题:

在下面这个表 T 中,如果我执行 select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行?

下面是这个表的初始化语句。

mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0, 
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB; 
insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

img

图 1 InnoDB 的索引组织结构

现在,我们一起来看看这条 SQL 查询语句的执行流程:

  1. 在 k 索引树上找到 k=3 的记录,取得 ID = 300;
  2. 再到 ID 索引树查到 ID=300 对应的 R3;
  3. 在 k 索引树取下一个值 k=5,取得 ID=500;
  4. 再回到 ID 索引树查到 ID=500 对应的 R4;
  5. 在 k 索引树取下一个值 k=6,不满足条件,循环结束。

在这个过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。

在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,有没有可能经过索引优化,避免回表过程呢?

覆盖索引

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引。

由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

需要注意的是,在引擎内部使用覆盖索引在索引 k 上其实读了三个记录,R3~R5(对应的索引 k 上的记录项),但是对于 MySQL 的 Server 层来说,它就是找引擎拿到了两条记录,因此 MySQL 认为扫描行数是 2。

备注:关于如何查看扫描行数的问题,我将会在第 16 文章《如何正确地显示随机消息?》中,和你详细讨论。

基于上面覆盖索引的说明,我们来讨论一个问题:在一个市民信息表上,是否有必要将身份证号和名字建立联合索引?

假设这个市民表的定义是这样的:

CREATE TABLE `tuser` (
  `id` int(11) NOT NULL,
  `id_card` varchar(32) DEFAULT NULL,
  `name` varchar(32) DEFAULT NULL,
  `age` int(11) DEFAULT NULL,
  `ismale` tinyint(1) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `id_card` (`id_card`),
  KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB

我们知道,身份证号是市民的唯一标识。也就是说,如果有根据身份证号查询市民信息的需求,我们只要在身份证号字段上建立索引就够了。而再建立一个(身份证号、姓名)的联合索引,是不是浪费空间?

如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。

当然,索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑了。这正是业务 DBA,或者称为业务数据架构师的工作。

最左前缀原则

看到这里你一定有一个疑问,如果为每一种查询都设计一个索引,索引是不是太多了。如果我现在要按照市民的身份证号去查他的家庭地址呢?虽然这个查询需求在业务中出现的概率不高,但总不能让它走全表扫描吧?反过来说,单独为一个不频繁的请求创建一个(身份证号,地址)的索引又感觉有点浪费。应该怎么做呢?

这里,我先和你说结论吧。B+ 树这种索引结构,可以利用索引的“最左前缀”,来定位记录。

为了直观地说明这个概念,我们用(name,age)这个联合索引来分析。

img

图 2 (name,age)索引示意图

可以看到,索引项是按照索引定义里面出现的字段顺序排序的。

当你的逻辑需求是查到所有名字是“张三”的人时,可以快速定位到 ID4,然后向后遍历得到所有需要的结果。

如果你要查的是所有名字第一个字是“张”的人,你的 SQL 语句的条件是”where name like ‘张 %’”。这时,你也能够用上这个索引,查找到第一个符合条件的记录是 ID3,然后向后遍历,直到不满足条件为止。

可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左 N 个字段,也可以是字符串索引的最左 M 个字符。

基于上面对最左前缀索引的说明,我们来讨论一个问题:在建立联合索引的时候,如何安排索引内的字段顺序。

这里我们的评估标准是,索引的复用能力。因为可以支持最左前缀,所以当已经有了 (a,b) 这个联合索引后,一般就不需要单独在 a 上建立索引了。因此,第一原则是,如果通过调整顺序,可以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。

所以现在你知道了,这段开头的问题里,我们要为高频请求创建 (身份证号,姓名)这个联合索引,并用这个索引支持“根据身份证号查询地址”的需求。

那么,如果既有联合查询,又有基于 a、b 各自的查询呢?查询条件里面只有 b 的语句,是无法使用 (a,b) 这个联合索引的,这时候你不得不维护另外一个索引,也就是说你需要同时维护 (a,b)、(b) 这两个索引。

这时候,我们要考虑的原则就是空间了。比如上面这个市民表的情况,name 字段是比 age 字段大的 ,那我就建议你创建一个(name,age) 的联合索引和一个 (age) 的单字段索引。

索引下推

上一段我们说到满足最左前缀原则的时候,最左前缀可以用于在索引中定位记录。这时,你可能要问,那些不符合最左前缀的部分,会怎么样呢?

我们还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一个字是张,而且年龄是 10 岁的所有男孩”。那么,SQL 语句是这么写的:

mysql> select * from tuser where name like '张 %' and age=10 and ismale=1;

你已经知道了前缀索引规则,所以这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足条件的记录 ID3。当然,这还不错,总比全表扫描要好。

然后呢?

当然是判断其他条件是否满足。

在 MySQL 5.6 之前,只能从 ID3 开始一个个回表。到主键索引上找出数据行,再对比字段值。

而 MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。

图 3 和图 4,是这两个过程的执行流程图。

img

图 3 无索引下推执行流程

img

图 4 索引下推执行流程

在图 3 和 4 这两个图里面,每一个虚线箭头表示回表一次。

图 3 中,在 (name,age) 索引里面我特意去掉了 age 的值,这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。因此,需要回表 4 次。

图 4 跟图 3 的区别是,InnoDB 在 (name,age) 索引内部就判断了 age 是否等于 10,对于不等于 10 的记录,直接判断并跳过。在我们的这个例子中,只需要对 ID4、ID5 这两条记录回表取数据判断,就只需要回表 2 次。

小结

今天这篇文章,我和你继续讨论了数据库索引的概念,包括了覆盖索引、前缀索引、索引下推。你可以看到,在满足语句需求的情况下, 尽量少地访问资源是数据库设计的重要原则之一。我们在使用数据库的时候,尤其是在设计表结构时,也要以减少资源消耗作为目标。

接下来我给你留下一个问题吧。

实际上主键索引也是可以使用多个字段的。DBA 小吕在入职新公司的时候,就发现自己接手维护的库里面,有这么一个表,表结构定义类似这样的:

CREATE TABLE `geek` (
  `a` int(11) NOT NULL,
  `b` int(11) NOT NULL,
  `c` int(11) NOT NULL,
  `d` int(11) NOT NULL,
  PRIMARY KEY (`a`,`b`),
  KEY `c` (`c`),
  KEY `ca` (`c`,`a`),
  KEY `cb` (`c`,`b`)
) ENGINE=InnoDB;

公司的同事告诉他说,由于历史原因,这个表需要 a、b 做联合主键,这个小吕理解了。

但是,学过本章内容的小吕又纳闷了,既然主键包含了 a、b 这两个字段,那意味着单独在字段 c 上创建一个索引,就已经包含了三个字段了呀,为什么要创建“ca”“cb”这两个索引?

同事告诉他,是因为他们的业务里面有这样的两种语句:

select * from geek where c=N order by a limit 1;
select * from geek where c=N order by b limit 1;

我给你的问题是,这位同事的解释对吗,为了这两个查询模式,这两个索引是否都是必须的?为什么呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,通过两个 alter 语句重建索引 k,以及通过两个 alter 语句重建主键索引是否合理。

在评论区,有同学问到为什么要重建索引。我们文章里面有提到,索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。

这道题目,我给你的“参考答案”是:

重建索引 k 的做法是合理的,可以达到省空间的目的。但是,重建主键的过程不合理。不论是删除主键还是创建主键,都会将整个表重建。所以连着执行这两个语句的话,第一个语句就白做了。这两个语句,你可以用这个语句代替 : alter table T engine=InnoDB。在专栏的第 12 篇文章《为什么表数据删掉一半,表文件大小不变?》中,我会和你分析这条语句的执行流程。

评论区留言中, @壹笙☞漂泊 做了很详细的笔记,@高枕 帮同学解答了问题,@约书亚 提了一个很不错的面试问题。在这里,我要和你们道一声感谢。

PS:如果你在面试中,曾有过被 MySQL 相关问题难住的经历,也可以把这个问题发到评论区,我们一起来讨论。如果解答这个问题,需要的篇幅会很长的话,我可以放到答疑文章展开。

06 全局锁和表锁 :给表加个字段怎么有这么多阻碍?

今天我要跟你聊聊 MySQL 的锁。数据库锁设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁三类。今天这篇文章,我会和你分享全局锁和表级锁。而关于行锁的内容,我会留着在下一篇文章中再和你详细介绍。

这里需要说明的是,锁的设计比较复杂,这两篇文章不会涉及锁的具体实现细节,主要介绍的是碰到锁时的现象和其背后的原理。

全局锁

顾名思义,全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。

以前有一种做法,是通过 FTWRL 确保不会有其他线程对数据库做更新,然后对整个库做备份。注意,在备份过程中整个库完全处于只读状态。

但是让整库都只读,听上去就很危险:

  • 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
  • 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟。

看来加全局锁不太好。但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问题。

假设你现在要维护“极客时间”的购买系统,关注的是用户账户余额表和用户课程表。

现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。

如果时间顺序上是先备份账户余额表 (u_account),然后用户购买,然后备份用户课程表 (u_course),会怎么样呢?你可以看一下这个图:

img

图 1 业务和备份状态图

可以看到,这个备份结果里,用户 A 的数据状态是“账户余额没扣,但是用户课程表里面已经多了一门课”。如果后面用这个备份来恢复数据的话,用户 A 就发现,自己赚了。

作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,又可能会出现什么结果?

也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致的。

说到视图你肯定想起来了,我们在前面讲事务隔离的时候,其实是有一个方法能够拿到一致性视图的,对吧?

是的,就是在可重复读隔离级别下开启一个事务。

备注:如果你对事务隔离级别的概念不是很清晰的话,可以再回顾一下第 3 篇文章[《事务隔离:为什么你改了我还看不见?》]中的相关内容。

官方自带的逻辑备份工具是 mysqldump。当 mysqldump 使用参数–single-transaction 的时候,导数据之前就会启动一个事务,来确保拿到一致性视图。而由于 MVCC 的支持,这个过程中数据是可以正常更新的。

你一定在疑惑,有了这个功能,为什么还需要 FTWRL 呢?一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于 MyISAM 这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用 FTWRL 命令了。

所以,single-transaction 方法只适用于所有的表使用事务引擎的库。如果有的表使用了不支持事务的引擎,那么备份就只能通过 FTWRL 方法。这往往是 DBA 要求业务开发人员使用 InnoDB 替代 MyISAM 的原因之一。

你也许会问,既然要全库只读,为什么不使用 set global readonly=true 的方式呢?确实 readonly 方式也可以让全库进入只读状态,但我还是会建议你用 FTWRL 方式,主要有两个原因:

  • 一是,在有些系统中,readonly 的值会被用来做其他逻辑,比如用来判断一个库是主库还是备库。因此,修改 global 变量的方式影响面更大,我不建议你使用。
  • 二是,在异常处理机制上有差异。如果执行 FTWRL 命令之后由于客户端发生异常断开,那么 MySQL 会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly 之后,如果客户端发生异常,则数据库就会一直保持 readonly 状态,这样会导致整个库长时间处于不可写状态,风险较高。

业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被锁住的。

但是,即使没有被全局锁住,加字段也不是就能一帆风顺的,因为你还会碰到接下来我们要介绍的表级锁。

表级锁

MySQL 里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。

表锁的语法是 lock tables … read/write。与 FTWRL 类似,可以用 unlock tables 主动释放锁,也可以在客户端断开的时候自动释放。需要注意,lock tables 语法除了会限制别的线程的读写外,也限定了本线程接下来的操作对象。

举个例子, 如果在某个线程 A 中执行 lock tables t1 read, t2 write; 这个语句,则其他线程写 t1、读写 t2 的语句都会被阻塞。同时,线程 A 在执行 unlock tables 之前,也只能执行读 t1、读写 t2 的操作。连写 t1 都不允许,自然也不能访问其他表。

在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于 InnoDB 这种支持行锁的引擎,一般不使用 lock tables 命令来控制并发,毕竟锁住整个表的影响面还是太大。

另一类表级的锁是 MDL(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。

因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁。

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
  • 读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。

虽然 MDL 锁是系统默认会加的,但却是你不能忽略的一个机制。比如下面这个例子,我经常看到有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。

你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。我们来看一下下面的操作序列,假设表 t 是一个小表。

备注:这里的实验环境是 MySQL 5.6。

img

我们可以看到 session A 先启动,这时候会对表 t 加一个 MDL 读锁。由于 session B 需要的也是 MDL 读锁,因此可以正常执行。

之后 session C 会被 blocked,是因为 session A 的 MDL 读锁还没有释放,而 session C 需要 MDL 写锁,因此只能被阻塞。

如果只有 session C 自己被阻塞还没什么关系,但是之后所有要在表 t 上新申请 MDL 读锁的请求也会被 session C 阻塞。前面我们说了,所有对表的增删改查操作都需要先申请 MDL 读锁,就都被锁住,等于这个表现在完全不可读写了。

如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新 session 再请求的话,这个库的线程很快就会爆满。

你现在应该知道了,事务中的 MDL 锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。

基于上面的分析,我们来讨论一个问题,如何安全地给小表加字段?

首先我们要解决长事务,事务不提交,就会一直占着 MDL 锁。在 MySQL 的 information_schema 库的 innodb_trx 表中,你可以查到当前执行中的事务。如果你要做 DDL 变更的表刚好有长事务在执行,要考虑先暂停 DDL,或者 kill 掉这个长事务。

但考虑一下这个场景。如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?

这时候 kill 可能未必管用,因为新的请求马上就来了。比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者 DBA 再通过重试命令重复这个过程。

MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT n 这个语法。

ALTER TABLE tbl_name NOWAIT add column ...
ALTER TABLE tbl_name WAIT N add column ... 

小结

今天,我跟你介绍了 MySQL 的全局锁和表级锁。

全局锁主要用在逻辑备份过程中。对于全部是 InnoDB 引擎的库,我建议你选择使用–single-transaction 参数,对应用会更友好。

表锁一般是在数据库引擎不支持行锁的时候才会被用到的。如果你发现你的应用程序里有 lock tables 这样的语句,你需要追查一下,比较可能的情况是:

  • 要么是你的系统现在还在用 MyISAM 这类不支持事务的引擎,那要安排升级换引擎;
  • 要么是你的引擎升级了,但是代码还没升级。我见过这样的情况,最后业务开发就是把 lock tables 和 unlock tables 改成 begin 和 commit,问题就解决了。

MDL 会直到事务提交才释放,在做表结构变更的时候,你一定要小心不要导致锁住线上查询和更新。

最后,我给你留一个问题吧。备份一般都会在备库上执行,你在用–single-transaction 方法做逻辑备份的过程中,如果主库上的一个小表做了一个 DDL,比如给一个表上加了一列。这时候,从备库上会看到什么现象呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

说明:这篇文章没有介绍到物理备份,物理备份会有一篇单独的文章。

上期问题时间

上期的问题是关于对联合主键索引和 InnoDB 索引组织表的理解。

我直接贴 @老杨同志 的回复略作修改如下(我修改的部分用橙色标出):

表记录 –a–|–b–|–c–|–d– 1 2 3 d 1 3 2 d 1 4 3 d 2 1 3 d 2 2 2 d 2 3 4 d 主键 a,b 的聚簇索引组织顺序相当于 order by a,b ,也就是先按 a 排序,再按 b 排序,c 无序。

索引 ca 的组织是先按 c 排序,再按 a 排序,同时记录主键 –c–|–a–|–主键部分b– (注意,这里不是 ab,而是只有 b) 2 1 3 2 2 2 3 1 2 3 1 4 3 2 1 4 2 3 这个跟索引 c 的数据是一模一样的。

索引 cb 的组织是先按 c 排序,在按 b 排序,同时记录主键 –c–|–b–|–主键部分a– (同上) 2 2 2 2 3 1 3 1 2 3 2 1 3 4 1 4 3 2

所以,结论是 ca 可以去掉,cb 需要保留。

评论区留言点赞:

@浪里白条 帮大家总结了复习要点; @约书亚 的问题里提到了 MRR 优化; @HwangZHen 留言言简意赅。

07 行锁功过:怎么减少行锁对性能的影响?

在上一篇文章中,我跟你介绍了 MySQL 的全局锁和表级锁,今天我们就来讲讲 MySQL 的行锁。

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

我们今天就主要来聊聊 InnoDB 的行锁,以及如何通过减少锁冲突来提升业务并发度。

顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。

当然,数据库中还有一些没那么一目了然的概念和设计,这些概念如果理解和使用不当,容易导致程序出现非预期行为,比如两阶段锁。

从两阶段锁说起

我先给你举个例子。在下面的操作序列中,事务 B 的 update 语句执行时会是什么现象呢?假设字段 id 是表 t 的主键。img

这个问题的结论取决于事务 A 在执行完两条 update 语句后,持有哪些锁,以及在什么时候释放。你可以验证一下:实际上事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。

知道了这个答案,你一定知道了事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。

也就是说,在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。

知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。我给你举个例子。

假设你负责实现一个电影票在线交易业务,顾客 A 要在影院 B 购买电影票。我们简化一点,这个业务需要涉及到以下操作:

  1. 从顾客 A 账户余额中扣除电影票价;
  2. 给影院 B 的账户余额增加这张电影票价;
  3. 记录一条交易日志。

也就是说,要完成这个交易,我们需要 update 两条记录,并 insert 一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的顺序呢?

试想如果同时有另外一个顾客 C 要在影院 B 买票,那么这两个事务冲突的部分就是语句 2 了。因为它们要更新同一个影院账户的余额,需要修改同一行数据。

根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把语句 2 安排在最后,比如按照 3、1、2 这样的顺序,那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。

好了,现在由于你的正确设计,影院余额这一行的行锁在一个事务中不会停留很长时间。但是,这并没有完全解决你的困扰。

如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在活动时间开始的时候,你的 MySQL 就挂了。你登上服务器一看,CPU 消耗接近 100%,但整个数据库每秒就执行不到 100 个事务。这是什么原因呢?

这里,我就要说到死锁和死锁检测了。

死锁和死锁检测

当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。img

这时候,事务 A 在等待事务 B 释放 id=2 的行锁,而事务 B 在等待事务 A 释放 id=1 的行锁。 事务 A 和事务 B 在互相等待对方的资源释放,就是进入了死锁状态。当出现死锁以后,有两种策略:

  • 一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
  • 另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。

在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。

但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。

所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且 innodb_deadlock_detect 的默认值本身就是 on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。

你可以想象一下这个过程:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。

那如果是我们上面说到的所有事务都要更新同一行的场景呢?

每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,这是一个时间复杂度是 O(n) 的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

根据上面的分析,我们来讨论一下,怎么解决由这种热点行更新导致的性能问题呢?问题的症结在于,死锁检测要耗费大量的 CPU 资源。

一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁,可以临时把死锁检测关掉。但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误,毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的。而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的。

另一个思路是控制并发度。根据上面的分析,你会发现如果并发能够控制住,比如同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端做并发控制。但是,你会很快发现这个方法不太可行,因为客户端很多。我见过一个应用,有 600 个客户端,这样即使每个客户端控制到只有 5 个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到 3000。

因此,这个并发控制要做在数据库服务端。如果你有中间件,可以考虑在中间件实现;如果你的团队有能修改 MySQL 源码的人,也可以做在 MySQL 里面。基本思路就是,对于相同行的更新,在进入引擎之前排队。这样在 InnoDB 内部就不会有大量的死锁检测工作了。

可能你会问,如果团队里暂时没有数据库方面的专家,不能实现这样的方案,能不能从设计上优化这个问题呢?

你可以考虑通过将一行改成逻辑上的多行来减少锁冲突。还是以影院账户为例,可以考虑放在多条记录上,比如 10 个记录,影院的账户总额等于这 10 个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的 1/10,可以减少锁等待个数,也就减少了死锁检测的 CPU 消耗。

这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成 0 的时候,代码要有特殊处理。

小结

今天,我和你介绍了 MySQL 的行锁,涉及了两阶段锁协议、死锁和死锁检测这两大部分内容。

其中,我以两阶段协议为起点,和你一起讨论了在开发的时候如何安排正确的事务语句。这里的原则 / 我给你的建议是:如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁的申请时机尽量往后放。

但是,调整语句顺序并不能完全避免死锁。所以我们引入了死锁和死锁检测的概念,以及提供了三个方案,来减少死锁对数据库的影响。减少死锁的主要方向,就是控制访问相同资源的并发事务量。

最后,我给你留下一个问题吧。如果你要删除一个表里面的前 10000 行数据,有以下三种方法可以做到:

  • 第一种,直接执行 delete from T limit 10000;
  • 第二种,在一个连接中循环执行 20 次 delete from T limit 500;
  • 第三种,在 20 个连接中同时执行 delete from T limit 500。

你会选择哪一种方法呢?为什么呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期我给你留的问题是:当备库用–single-transaction 做逻辑备份的时候,如果从主库的 binlog 传来一个 DDL 语句会怎么样?

假设这个 DDL 是针对表 t1 的, 这里我把备份过程中几个关键的语句列出来:

Q1:SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
Q2:START TRANSACTION  WITH CONSISTENT SNAPSHOT;
/* other tables */
Q3:SAVEPOINT sp;
/* 时刻 1 */
Q4:show create table `t1`;
/* 时刻 2 */
Q5:SELECT * FROM `t1`;
/* 时刻 3 */
Q6:ROLLBACK TO SAVEPOINT sp;
/* 时刻 4 */
/* other tables */

在备份开始的时候,为了确保 RR(可重复读)隔离级别,再设置一次 RR 隔离级别 (Q1);

启动事务,这里用 WITH CONSISTENT SNAPSHOT 确保这个语句执行完就可以得到一个一致性视图(Q2);

设置一个保存点,这个很重要(Q3);

show create 是为了拿到表结构 (Q4),然后正式导数据 (Q5),回滚到 SAVEPOINT sp,在这里的作用是释放 t1 的 MDL 锁 (Q6)。当然这部分属于“超纲”,上文正文里面都没提到。

DDL 从主库传过来的时间按照效果不同,我打了四个时刻。题目设定为小表,我们假定到达后,如果开始执行,则很快能够执行完成。

参考答案如下:

  1. 如果在 Q4 语句执行之前到达,现象:没有影响,备份拿到的是 DDL 后的表结构。
  2. 如果在“时刻 2”到达,则表结构被改过,Q5 执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump 终止;
  3. 如果在“时刻 2”和“时刻 3”之间到达,mysqldump 占着 t1 的 MDL 读锁,binlog 被阻塞,现象:主从延迟,直到 Q6 执行完成。
  4. 从“时刻 4”开始,mysqldump 释放了 MDL 读锁,现象:没有影响,备份拿到的是 DDL 前的表结构。

评论区留言点赞板:

@Aurora 给了最接近的答案; @echo_陈 问了一个好问题; @壹笙☞漂泊 做了很好的总结。

08 事务到底是隔离的还是不隔离的?

我在第 3 篇文章和你讲事务隔离级别的时候提到过,如果是可重复读隔离级别,事务 T 启动的时候会创建一个视图 read-view,之后事务 T 执行期间,即使有其他事务修改了数据,事务 T 看到的仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下执行的事务,好像与世无争,不受外界影响。

但是,我在上一篇文章中,和你分享行锁的时候又提到,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?

我给你举一个例子吧。下面是一个只有两行的表的初始化语句。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `k` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);

img

图 1 事务 A、B、C 的执行流程

这里,我们需要注意的是事务的启动时机。

begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句(第一个快照读语句),事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令。

还需要注意的是,在整个专栏里面,我们的例子中如果没有特别说明,都是默认 autocommit=1。

在这个例子中,事务 C 没有显式地使用 begin/commit,表示这个 update 语句本身就是一个事务,语句完成的时候会自动提交。事务 B 在更新了行之后查询 ; 事务 A 在一个只读事务中查询,并且时间顺序上是在事务 B 的查询之后。

这时,如果我告诉你事务 B 查到的 k 的值是 3,而事务 A 查到的 k 的值是 1,你是不是感觉有点晕呢?

所以,今天这篇文章,我其实就是想和你说明白这个问题,希望借由把这个疑惑解开的过程,能够帮助你对 InnoDB 的事务和锁有更进一步的理解。

在 MySQL 里,有两个“视图”的概念:

  • 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
  • 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。

它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。

在第 3 篇文章[《事务隔离:为什么你改了我还看不见?》]中,我跟你解释过一遍 MVCC 的实现逻辑。今天为了说明查询和更新的区别,我换一个方式来说明,把 read view 拆开。你可以结合这两篇文章的说明来更深一步地理解 MVCC。

“快照”在 MVCC 里是怎么工作的?

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。

这时,你会说这看上去不太现实啊。如果一个库有 100G,那么我启动一个事务,MySQL 就要拷贝 100G 的数据出来,这个过程得多慢啊。可是,我平时的事务执行起来很快啊。

实际上,我们并不需要拷贝出这 100G 的数据。我们先来看看这个快照是怎么实现的。

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。

也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。

如图 2 所示,就是一个记录被多个事务连续更新后的状态。

img

图 2 行状态变更图

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。

你可能会问,前面的文章不是说,语句更新会生成 undo log(回滚日志)吗?那么,undo log 在哪呢?

实际上,图 2 中的三个虚线箭头,就是 undo log;而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。

明白了多版本和 row trx_id 的概念后,我们再来想一下,InnoDB 是怎么定义那个“100G”的快照的。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。

因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。

当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。

数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。

这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。

这个视图数组把所有的 row trx_id 分成了几种不同的情况。

img

图 3 数据版本可见性规则

这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
  3. 如果落在黄色部分,那就包括两种情况 a. 若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见; b. 若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

比如,对于图 2 中的数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。

你看,有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢?因为之后的更新,生成的版本一定属于上面的 2 或者 3(a) 的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。

所以你现在知道了,InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。

接下来,我们继续看一下图 1 中的三个事务,分析下事务 A 的语句返回的结果,为什么是 k=1。

这里,我们不妨做如下假设:

  1. 事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
  2. 事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
  3. 三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。

这样,事务 A 的视图数组就是 [99,100], 事务 B 的视图数组是 [99,100,101], 事务 C 的视图数组是 [99,100,101,102]。

为了简化分析,我先把其他干扰语句去掉,只画出跟事务 A 查询逻辑有关的操作:

img

图 4 事务 A 查询数据逻辑图

从图中可以看到,第一个有效更新是事务 C,把数据从 (1,1) 改成了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。

第二个有效更新是事务 B,把数据从 (1,2) 改成了 (1,3)。这时候,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。

你可能注意到了,在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1,3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,否则就变成脏读了。

好,现在事务 A 要来读数据了,它的视图数组是 [99,100]。当然了,读数据都是从当前版本读起的。所以,事务 A 查询语句的读数据流程是这样的:

  • 找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见;
  • 接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见;
  • 再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。

这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。

这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。

所以,我来给你翻译一下。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:

  1. 版本未提交,不可见;
  2. 版本已提交,但是是在视图创建后提交的,不可见;
  3. 版本已提交,而且是在视图创建前提交的,可见。

现在,我们用这个规则来判断图 4 中的查询结果,事务 A 的查询语句的视图数组是在事务 A 启动的时候生成的,这时候:

  • (1,3) 还没提交,属于情况 1,不可见;
  • (1,2) 虽然提交了,但是是在视图数组创建之后提交的,属于情况 2,不可见;
  • (1,1) 是在视图数组创建之前提交的,可见。

你看,去掉数字对比后,只用时间先后顺序来判断,分析起来是不是轻松多了。所以,后面我们就都用这个规则来分析。

更新逻辑

细心的同学可能有疑问了:事务 B 的 update 语句,如果按照一致性读,好像结果不对哦?

你看图 5 中,事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?

img

图 5 事务 B 更新逻辑图

是的,如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1。

但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了。因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作。

所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。

因此,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),这个新版本的 row trx_id 是 101。

所以,在执行事务 B 查询语句的时候,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。

这里我们提到了一个概念,叫作当前读。其实,除了 update 语句外,select 语句如果加锁,也是当前读。

所以,如果把事务 A 的查询语句 select * from t where id=1 修改一下,加上 lock in share mode 或 for update,也都可以读到版本号是 101 的数据,返回的 k 的值是 3。下面这两个 select 语句,就是分别加了读锁(S 锁,共享锁)和写锁(X 锁,排他锁)。

mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;

再往前一步,假设事务 C 不是马上提交的,而是变成了下面的事务 C’,会怎么样呢?

img

图 6 事务 A、B、C’的执行流程

事务 C’的不同是,更新后并没有马上提交,在它提交前,事务 B 的更新语句先发起了。前面说过了,虽然事务 C’还没提交,但是 (1,2) 这个版本也已经生成了,并且是当前的最新版本。那么,事务 B 的更新语句会怎么处理呢?

这时候,我们在上一篇文章中提到的“两阶段锁协议”就要上场了。事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被锁住了,必须等到事务 C’释放这个锁,才能继续它的当前读。

img

图 7 事务 B 更新逻辑图(配合事务 C’)

到这里,我们把一致性读、当前读和行锁就串起来了。

现在,我们再回到文章开头的问题:事务的可重复读的能力是怎么实现的?

可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。

而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:

  • 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
  • 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。

那么,我们再看一下,在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢?

这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于普通的 start transaction。

下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的 read view 框。(注意:这里,我们用的还是事务 C 的逻辑直接提交,而不是事务 C’)

img

图 8 读提交隔离级别下的事务状态图

这时,事务 A 的查询语句的视图数组是在执行这个语句的时候创建的,时序上 (1,2)、(1,3) 的生成时间都在创建这个视图数组的时刻之前。但是,在这个时刻:

  • (1,3) 还没提交,属于情况 1,不可见;
  • (1,2) 提交了,属于情况 3,可见。

所以,这时候事务 A 查询语句返回的是 k=2。

显然地,事务 B 查询结果 k=3。

小结

InnoDB 的行数据有多个版本,每个数据版本有自己的 row trx_id,每个事务或者语句有自己的一致性视图。普通查询语句是一致性读,一致性读会根据 row trx_id 和一致性视图确定数据版本的可见性。

  • 对于可重复读,查询只承认在事务启动前就已经提交完成的数据;
  • 对于读提交,查询只承认在语句启动前就已经提交完成的数据;

而当前读,总是读取已经提交完成的最新版本。

你也可以想一下,为什么表结构不支持“可重复读”?这是因为表结构没有对应的行数据,也没有 row trx_id,因此只能遵循当前读的逻辑。

当然,MySQL 8.0 已经可以把表结构放在 InnoDB 字典里了,也许以后会支持表结构的可重复读。

又到思考题时间了。我用下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读。现在,我要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是却发现了一个“诡异”的、改不掉的情况。请你构造出这种情况,并说明其原理。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);

img复现出来以后,请你再思考一下,在实际的业务开发中有没有可能碰到这种情况?你的应用代码会不会掉进这个“坑”里,你又是怎么解决的呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章最后,留给你的问题是:怎么删除表的前 10000 行。比较多的留言都选择了第二种方式,即:在一个连接中循环执行 20 次 delete from T limit 500。

确实是这样的,第二种方式是相对较好的。

第一种方式(即:直接执行 delete from T limit 10000)里面,单个语句占用时间长,锁的时间也比较长;而且大事务还会导致主从延迟。

第三种方式(即:在 20 个连接中同时执行 delete from T limit 500),会人为造成锁冲突。

评论区留言点赞板:

@Tony Du 的评论,详细而且准确。 @Knight²º¹⁸ 提到了如果可以加上特定条件,将这 10000 行天然分开,可以考虑第三种。是的,实际上在操作的时候我也建议你尽量拿到 ID 再删除。 @荒漠甘泉 提了一个不错的问题,大家需要区分行锁、MDL 锁和表锁的区别。对 InnoDB 表更新一行,可能过了 MDL 关,却被挡在行锁阶段。

09 普通索引和唯一索引,应该怎么选择?

今天的正文开始前,我要特意感谢一下评论区几位留下高质量留言的同学。

用户名是 @某、人 的同学,对文章的知识点做了梳理,然后提了关于事务可见性的问题,就是先启动但是后提交的事务,对数据可见性的影响。@夏日雨同学也提到了这个问题,我在置顶评论中回复了,今天的文章末尾也会再展开说明。@Justin 和 @倪大人两位同学提了两个好问题。

对于能够引发更深一步思考的问题,我会在回复的内容中写上“好问题”三个字,方便你搜索,你也可以去看看他们的留言。

非常感谢大家很细致地看文章,并且留下了那么多和很高质量的留言。知道文章有给大家带来一些新理解,对我来说是一个很好的鼓励。同时,也让其他认真看评论区的同学,有机会发现一些自己还没有意识到的、但可能还不清晰的知识点,这也在总体上提高了整个专栏的质量。再次谢谢你们。

好了,现在就回到我们今天的正文内容。

在前面的基础篇文章中,我给你介绍过索引的基本概念,相信你已经了解了唯一索引和普通索引的区别。今天我们就继续来谈谈,在不同的业务场景下,应该选择普通索引,还是唯一索引?

假设你在维护一个市民系统,每个人都有一个唯一的身份证号,而且业务代码已经保证了不会写入两个重复的身份证号。如果市民系统需要按照身份证号查姓名,就会执行类似这样的 SQL 语句:

select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz';

所以,你一定会考虑在 id_card 字段上建索引。

由于身份证号字段比较大,我不建议你把身份证号当做主键,那么现在你有两个选择,要么给 id_card 字段创建唯一索引,要么创建一个普通索引。如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的。

现在我要问你的是,从性能的角度考虑,你选择唯一索引还是普通索引呢?选择的依据是什么呢?

简单起见,我们还是用第 4 篇文章[《深入浅出索引(上)》]中的例子来说明,假设字段 k 上的值都不重复。

img

图 1 InnoDB 的索引组织结构

接下来,我们就从这两种索引对查询语句和更新语句的性能影响来进行分析。

查询过程

假设,执行查询的语句是 select id from T where k=5。这个查询语句在索引树上查找的过程,先是通过 B+ 树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认为数据页内部通过二分法来定位记录。

  • 对于普通索引来说,查找到满足条件的第一个记录 (5,500) 后,需要查找下一个记录,直到碰到第一个不满足 k=5 条件的记录。
  • 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。

那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。

你知道的,InnoDB 的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在 InnoDB 中,每个数据页的大小默认是 16KB。

因为引擎是按页读写的,所以说,当找到 k=5 的记录的时候,它所在的数据页就都在内存里了。那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。

当然,如果 k=5 这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。

但是,我们之前计算过,对于整型字段,一个数据页可以放近千个 key,因此出现这种情况的概率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的 CPU 来说可以忽略不计。

更新过程

为了说明普通索引和唯一索引对更新语句性能的影响这个问题,我需要先跟你介绍一下 change buffer。

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InooDB 会将这些更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后执行 change buffer 中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正确性。

需要说明的是,虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上。

将 change buffer 中的操作应用到原数据页,得到最新结果的过程称为 merge。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

显然,如果能够将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率。

那么,什么条件下可以使用 change buffer 呢?

对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入 (4,400) 这个记录,就要先判断现在表中是否已经存在 k=4 的记录,而这必须要将数据页读入内存才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用 change buffer 了。

因此,唯一索引的更新就不能使用 change buffer,实际上也只有普通索引可以使用。

change buffer 用的是 buffer pool 里的内存,因此不能无限增大。change buffer 的大小,可以通过参数 innodb_change_buffer_max_size 来动态设置。这个参数设置为 50 的时候,表示 change buffer 的大小最多只能占用 buffer pool 的 50%。

现在,你已经理解了 change buffer 的机制,那么我们再一起来看看如果要在这张表中插入一个新记录 (4,400) 的话,InnoDB 的处理流程是怎样的。

第一种情况是,这个记录要更新的目标页在内存中。这时,InnoDB 的处理流程如下:

  • 对于唯一索引来说,找到 3 和 5 之间的位置,判断到没有冲突,插入这个值,语句执行结束;
  • 对于普通索引来说,找到 3 和 5 之间的位置,插入这个值,语句执行结束。

这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的 CPU 时间。

但,这不是我们关注的重点。

第二种情况是,这个记录要更新的目标页不在内存中。这时,InnoDB 的处理流程如下:

  • 对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束;
  • 对于普通索引来说,则是将更新记录在 change buffer,语句执行就结束了。

将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。

之前我就碰到过一件事儿,有个 DBA 的同学跟我反馈说,他负责的某个业务的库内存命中率突然从 99% 降低到了 75%,整个系统处于阻塞状态,更新语句全部堵住。而探究其原因后,我发现这个业务有大量插入数据的操作,而他在前一天把其中的某个普通索引改成了唯一索引。

change buffer 的使用场景

通过上面的分析,你已经清楚了使用 change buffer 对更新过程的加速作用,也清楚了 change buffer 只限于用在普通索引的场景下,而不适用于唯一索引。那么,现在有一个问题就是:普通索引的所有场景,使用 change buffer 都可以起到加速作用吗?

因为 merge 的时候是真正进行数据更新的时刻,而 change buffer 的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做 merge 之前,change buffer 记录的变更越多(也就是这个页面上要更新的次数越多),收益就越大。

因此,对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时 change buffer 的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。

反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在 change buffer,但之后由于马上要访问这个数据页,会立即触发 merge 过程。这样随机访问 IO 的次数不会减少,反而增加了 change buffer 的维护代价。所以,对于这种业务模式来说,change buffer 反而起到了副作用。

索引选择和实践

回到我们文章开头的问题,普通索引和唯一索引应该怎么选择。其实,这两类索引在查询能力上是没差别的,主要考虑的是对更新性能的影响。所以,我建议你尽量选择普通索引。

如果所有的更新后面,都马上伴随着对这个记录的查询,那么你应该关闭 change buffer。而在其他情况下,change buffer 都能提升更新性能。

在实际使用中,你会发现,普通索引和 change buffer 的配合使用,对于数据量大的表的更新优化还是很明显的。

特别地,在使用机械硬盘时,change buffer 这个机制的收效是非常显著的。所以,当你有一个类似“历史数据”的库,并且出于成本考虑用的是机械硬盘时,那你应该特别关注这些表里的索引,尽量使用普通索引,然后把 change buffer 尽量开大,以确保这个“历史数据”表的数据写入速度。

change buffer 和 redo log

理解了 change buffer 的原理,你可能会联想到我在前面文章中和你介绍过的 redo log 和 WAL。

在前面文章的评论中,我发现有同学混淆了 redo log 和 change buffer。WAL 提升性能的核心机制,也的确是尽量减少随机读写,这两个概念确实容易混淆。所以,这里我把它们放到了同一个流程里来说明,便于你区分这两个概念。

备注:这里,你可以再回顾下第 2 篇文章[《日志系统:一条 SQL 更新语句是如何执行的?》]中的相关内容。

现在,我们要在表上执行这个插入语句:

mysql> insert into t(id,k) values(id1,k1),(id2,k2);

这里,我们假设当前 k 索引树的状态,查找到位置后,k1 所在的数据页在内存 (InnoDB buffer pool) 中,k2 所在的数据页不在内存中。如图 2 所示是带 change buffer 的更新状态图。

img

图 2 带 change buffer 的更新过程

分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间(t.ibd)、系统表空间(ibdata1)。

这条更新语句做了如下的操作(按照图中的数字顺序):

  1. Page 1 在内存中,直接更新内存;
  2. Page 2 没有在内存中,就在内存的 change buffer 区域,记录下“我要往 Page 2 插入一行”这个信息
  3. 将上述两个动作记入 redo log 中(图中 3 和 4)。

做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。

同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。

那在这之后的读请求,要怎么处理呢?

比如,我们现在要执行 select * from t where k in (k1, k2)。这里,我画了这两个读请求的流程图。

如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,我在图中就没画出这两部分。

img

图 3 带 change buffer 的读过程

从图中可以看到:

  1. 读 Page 1 的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL 之后如果读数据,是不是一定要读盘,是不是一定要从 redo log 里面把数据更新以后才可以返回?其实是不用的。你可以看一下图 3 的这个状态,虽然磁盘上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
  2. 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后应用 change buffer 里面的操作日志,生成一个正确的版本并返回结果。

可以看到,直到需要读 Page 2 的时候,这个数据页才会被读入内存。

所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随机写磁盘的 IO 消耗(转成顺序写),而 change buffer 主要节省的则是随机读磁盘的 IO 消耗。

小结

今天,我从普通索引和唯一索引的选择开始,和你分享了数据的查询和更新过程,然后说明了 change buffer 的机制以及应用场景,最后讲到了索引选择的实践。

由于唯一索引用不上 change buffer 的优化机制,因此如果业务可以接受,从性能角度出发我建议你优先考虑非唯一索引。

最后,又到了思考题时间。

通过图 2 你可以看到,change buffer 一开始是写内存的,那么如果这个时候机器掉电重启,会不会导致 change buffer 丢失呢?change buffer 丢失可不是小事儿,再从磁盘读入数据可就没有了 merge 过程,就等于是数据丢失了。会不会出现这种情况呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

补充: 评论区大家对“是否使用唯一索引”有比较多的讨论,主要是纠结在“业务可能无法确保”的情况。这里,我再说明一下:

  • 首先,业务正确性优先。咱们这篇文章的前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。这种情况下,本篇文章的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,可以给你多提供一个排查思路。
  • 然后,在一些“归档库”的场景,你是可以考虑使用唯一索引的。比如,线上数据只需要保留半年,然后历史数据保存在归档库。这时候,归档数据已经是确保没有唯一键冲突了。要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。

上期问题时间

上期的问题是:如何构造一个“数据无法修改”的场景。评论区里已经有不少同学给出了正确答案,这里我再描述一下。

img这样,session A 看到的就是我截图的效果了。

其实,还有另外一种场景,同学们在留言区都还没有提到。

img

这个操作序列跑出来,session A 看的内容也是能够复现我截图的效果的。这个 session B’启动的事务比 A 要早,其实是上期我们描述事务版本的可见性规则时留的彩蛋,因为规则里还有一个“活跃事务的判断”,我是准备留到这里再补充的。

当我试图在这里讲述完整规则的时候,发现第 8 篇文章[《事务到底是隔离的还是不隔离的?》]中的解释引入了太多的概念,以致于分析起来非常复杂。

因此,我重写了第 8 篇,这样我们人工去判断可见性的时候,才会更方便。【看到这里,我建议你能够再重新打开第 8 篇文章并认真学习一次。如果学习的过程中,有任何问题,也欢迎你给我留言】

用新的方式来分析 session B’的更新为什么对 session A 不可见就是:在 session A 视图数组创建的瞬间,session B’是活跃的,属于“版本未提交,不可见”这种情况。

业务中如果要绕过这类问题,@约书亚提供了一个“乐观锁”的解法,大家可以去上一篇的留言区看一下。

评论区留言点赞板:

@某、人、@夏日雨、@周巘、@李金刚 等同学提了一个很好的问题,就是我们今天答案的 session B’ 的情况; @justin 提到了提交和未提交版本的区别对待,@倪大人 提到了读提交和当前读的区别,都是经过了思考后提出的好问题,大家可以去留言区看看。

10 MySQL为什么有时候会选错索引?

前面我们介绍过索引,你已经知道了在 MySQL 中一张表其实是可以支持多个索引的。但是,你写 SQL 语句的时候,并没有主动指定使用哪个索引。也就是说,使用哪个索引是由 MySQL 来确定的。

不知道你有没有碰到过这种情况,一条本来可以执行得很快的语句,却由于 MySQL 选错了索引,而导致执行速度变得很慢?

我们一起来看一个例子吧。

我们先建一个简单的表,表里有 a、b 两个字段,并分别建上索引:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `b` (`b`)
) ENGINE=InnoDB;

然后,我们往表 t 中插入 10 万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到 (100000,100000,100000)。

我是用存储过程来插入数据的,这里我贴出来方便你复现:

delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=100000)do
    insert into t values(i, i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

接下来,我们分析一条 SQL 语句:

mysql> select * from t where a between 10000 and 20000;

你一定会说,这个语句还用分析吗,很简单呀,a 上有索引,肯定是要使用索引 a 的。

你说得没错,图 1 显示的就是使用 explain 命令看到的这条语句的执行情况。

img

图 1 使用 explain 命令查看语句执行情况

从图 1 看上去,这条查询语句的执行也确实符合预期,key 这个字段值是’a’,表示优化器选择了索引 a。

不过别急,这个案例不会这么简单。在我们已经准备好的包含了 10 万行数据的表上,我们再做如下操作。

img

图 2 session A 和 session B 的执行流程

这里,session A 的操作你已经很熟悉了,它就是开启了一个事务。随后,session B 把数据都删除后,又调用了 idata 这个存储过程,插入了 10 万行数据。

这时候,session B 的查询语句 select * from t where a between 10000 and 20000 就不会再选择索引 a 了。我们可以通过慢查询日志(slow log)来查看一下具体的执行情况。

为了说明优化器选择的结果是否正确,我增加了一个对照,即:使用 force index(a) 来让优化器强制使用索引 a(这部分内容,我还会在这篇文章的后半部分中提到)。

下面的三条 SQL 语句,就是这个实验过程。

set long_query_time=0;
select * from t where a between 10000 and 20000; /*Q1*/
select * from t force index(a) where a between 10000 and 20000;/*Q2*/
  • 第一句,是将慢查询日志的阈值设置为 0,表示这个线程接下来的语句都会被记录入慢查询日志中;
  • 第二句,Q1 是 session B 原来的查询;
  • 第三句,Q2 是加了 force index(a) 来和 session B 原来的查询语句执行情况对比。

如图 3 所示是这三条 SQL 语句执行完成后的慢查询日志。

img

图 3 slow log 结果

可以看到,Q1 扫描了 10 万行,显然是走了全表扫描,执行时间是 40 毫秒。Q2 扫描了 10001 行,执行了 21 毫秒。也就是说,我们在没有使用 force index 的时候,MySQL 用错了索引,导致了更长的执行时间。

这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。这时,MySQL 竟然会选错索引,是不是有点奇怪呢?今天,我们就从这个奇怪的结果说起吧。

优化器的逻辑

在第一篇文章中,我们就提到过,选择索引是优化器的工作。

而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越少,消耗的 CPU 资源越少。

当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。

我们这个简单的查询语句并没有涉及到临时表和排序,所以 MySQL 选错索引肯定是在判断扫描行数的时候出问题了。

那么,问题就是:扫描行数是怎么判断的?

MySQL 在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数。

这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

我们可以使用 show index 方法,看到一个索引的基数。如图 4 所示,就是表 t 的 show index 的结果 。虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且其实都不准确。

img

图 4 表 t 的 show index 结果

那么,MySQL 是怎样得到索引的基数的呢?这里,我给你简单介绍一下 MySQL 采样统计的方法。

为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。

采样统计的时候,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。

而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过 1/M 的时候,会自动触发重新做一次索引统计。

在 MySQL 中,有两种存储索引统计的方式,可以通过设置参数 innodb_stats_persistent 的值来选择:

  • 设置为 on 的时候,表示统计信息会持久化存储。这时,默认的 N 是 20,M 是 10。
  • 设置为 off 的时候,表示统计信息只存储在内存中。这时,默认的 N 是 8,M 是 16。

由于是采样统计,所以不管 N 是 20 还是 8,这个基数都是很容易不准的。

但,这还不是全部。

你可以从图 4 中看到,这次的索引统计值(cardinality 列)虽然不够精确,但大体上还是差不多的,选错索引一定还有别的原因。

其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。

接下来,我们再一起看看优化器预估的,这两个语句的扫描行数是多少。

img

图 5 意外的 explain 结果

rows 这个字段表示的是预计扫描行数。

其中,Q1 的结果还是符合预期的,rows 的值是 104620;但是 Q2 的 rows 值是 37116,偏差就大了。而图 1 中我们用 explain 命令看到的 rows 是只有 10001 行,是这个偏差误导了优化器的判断。

到这里,可能你的第一个疑问不是为什么不准,而是优化器为什么放着扫描 37000 行的执行计划不用,却选择了扫描行数是 100000 的执行计划呢?

这是因为,如果使用索引 a,每次从索引 a 上拿到一个值,都要回到主键索引上查出整行数据,这个代价优化器也要算进去的。

而如果选择扫描 10 万行,是直接在主键索引上扫描的,没有额外的代价。

优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。

使用普通索引需要把回表的代价算进去,在图 1 执行 explain 的时候,也考虑了这个策略的代价 ,但图 1 的选择是对的。也就是说,这个策略并没有问题。

所以冤有头债有主,MySQL 选错索引,这件事儿还得归咎到没能准确地判断出扫描行数。至于为什么会得到错误的扫描行数,这个原因就作为课后问题,留给你去分析了。

既然是统计信息不对,那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看一下执行效果。

img

图 6 执行 analyze table t 命令恢复的 explain 结果

这回对了。

所以在实践中,如果你发现 explain 的结果预估的 rows 值跟实际情况差距比较大,可以采用这个方法来处理。

其实,如果只是索引统计不准确,通过 analyze 命令可以解决很多问题,但是前面我们说了,优化器可不止是看扫描行数。

依然是基于这个表 t,我们看看另外一个语句:

mysql> select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 1;

从条件上看,这个查询没有符合条件的记录,因此会返回空集合。

在开始执行这条语句之前,你可以先设想一下,如果你来选择索引,会选择哪一个呢?

为了便于分析,我们先来看一下 a、b 这两个索引的结构图。

img

图 7 a、b 索引的结构图

如果使用索引 a 进行查询,那么就是扫描索引 a 的前 1000 个值,然后取到对应的 id,再到主键索引上去查出每一行,然后根据字段 b 来过滤。显然这样需要扫描 1000 行。

如果使用索引 b 进行查询,那么就是扫描索引 b 的最后 50001 个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描 50001 行。

所以你一定会想,如果使用索引 a 的话,执行速度明显会快很多。那么,下面我们就来看看到底是不是这么一回事儿。

图 8 是执行 explain 的结果。

mysql> explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;

img

图 8 使用 explain 方法查看执行计划 2

可以看到,返回结果中 key 字段显示,这次优化器选择了索引 b,而 rows 字段显示需要扫描的行数是 50198。

从这个结果中,你可以得到两个结论:

  1. 扫描行数的估计值依然不准确;
  2. 这个例子里 MySQL 又选错了索引。

索引选择异常和处理

其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况:原本可以执行得很快的 SQL 语句,执行速度却比你预期的慢很多,你应该怎么办呢?

一种方法是,像我们第一个例子一样,采用 force index 强行选择一个索引。MySQL 会根据词法解析的结果分析出可能可以使用的索引作为候选项,然后在候选列表中依次判断每个索引需要扫描多少行。如果 force index 指定的索引在候选索引列表中,就直接选择这个索引,不再评估其他索引的执行代价。

我们来看看第二个例子。刚开始分析时,我们认为选择索引 a 会更好。现在,我们就来看看执行效果:

img

图 9 使用不同索引的语句执行耗时

可以看到,原本语句需要执行 2.23 秒,而当你使用 force index(a) 的时候,只用了 0.05 秒,比优化器的选择快了 40 多倍。

也就是说,优化器没有选择正确的索引,force index 起到了“矫正”的作用。

不过很多程序员不喜欢使用 force index,一来这么写不优美,二来如果索引改了名字,这个语句也得改,显得很麻烦。而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。

但其实使用 force index 最主要的问题还是变更的及时性。因为选错索引的情况还是比较少出现的,所以开发的时候通常不会先写上 force index。而是等到线上出现问题的时候,你才会再去修改 SQL 语句、加上 force index。但是修改之后还要测试和发布,对于生产系统来说,这个过程不够敏捷。

所以,数据库的问题最好还是在数据库内部来解决。那么,在数据库里面该怎样解决呢?

既然优化器放弃了使用索引 a,说明 a 还不够合适,所以第二种方法就是,我们可以考虑修改语句,引导 MySQL 使用我们期望的索引。比如,在这个例子里,显然把“order by b limit 1” 改成 “order by b,a limit 1” ,语义的逻辑是相同的。

我们来看看改之后的效果:

img

图 10 order by b,a limit 1 执行结果

之前优化器选择使用索引 b,是因为它认为使用索引 b 可以避免排序(b 本身是索引,已经是有序的了,如果选择索引 b 的话,不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为代价更小。

现在 order by b,a 这种写法,要求按照 b,a 排序,就意味着使用这两个索引都需要排序。因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描 1000 行的索引 a。

当然,这种修改并不是通用的优化手段,只是刚好在这个语句里面有 limit 1,因此如果有满足条件的记录, order by b limit 1 和 order by b,a limit 1 都会返回 b 是最小的那一行,逻辑上一致,才可以这么做。

如果你觉得修改语义这件事儿不太好,这里还有一种改法,图 11 是执行效果。

mysql> select * from  (select * from t where (a between 1 and 1000)  and (b between 50000 and 100000) order by b limit 100)alias limit 1;

img

图 11 改写 SQL 的 explain

在这个例子里,我们用 limit 100 让优化器意识到,使用 b 索引代价是很高的。其实是我们根据数据特征诱导了一下优化器,也不具备通用性。

第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选择,或删掉误用的索引。

不过,在这个例子中,我没有找到通过新增索引来改变优化器行为的方法。这种情况其实比较少,尤其是经过 DBA 索引优化过的库,再碰到这个 bug,找到一个更合适的索引一般比较难。

如果我说还有一个方法是删掉索引 b,你可能会觉得好笑。但实际上我碰到过两次这样的例子,最终是 DBA 跟业务开发沟通后,发现这个优化器错误选择的索引其实根本没有必要存在,于是就删掉了这个索引,优化器也就重新选择到了正确的索引。

小结

今天我们一起聊了聊索引统计的更新机制,并提到了优化器存在选错索引的可能性。

对于由于索引统计信息不准确导致的问题,你可以用 analyze table 来解决。

而对于其他优化器误判的情况,你可以在应用端用 force index 来强行指定索引,也可以通过修改语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。

你可能会说,今天这篇文章后面的几个例子,怎么都没有展开说明其原理。我要告诉你的是,今天的话题,我们面对的是 MySQL 的 bug,每一个展开都必须深入到一行行代码去量化,实在不是我们在这里应该做的事情。

所以,我把我用过的解决方法跟你分享,希望你在碰到类似情况的时候,能够有一些思路。

你平时在处理 MySQL 优化器 bug 的时候有什么别的方法,也发到评论区分享一下吧。

最后,我给你留下一个思考题。前面我们在构造第一个例子的过程中,通过 session A 的配合,让 session B 删除数据后又重新插入了一遍数据,然后就发现 explain 结果中,rows 字段从 10001 变成 37000 多。

而如果没有 session A 的配合,只是单独执行 delete from t 、call idata()、explain 这三句话,会看到 rows 字段其实还是 10000 左右。你可以自己验证一下这个结果。

这是什么原因呢?也请你分析一下吧。

你可以把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章最后留给你的问题是,如果某次写入使用了 change buffer 机制,之后主机异常重启,是否会丢失 change buffer 和数据。

这个问题的答案是不会丢失,留言区的很多同学都回答对了。虽然是只更新内存,但是在事务提交的时候,我们把 change buffer 的操作也记录到 redo log 里了,所以崩溃恢复的时候,change buffer 也能找回来。

在评论区有同学问到,merge 的过程是否会把数据直接写回磁盘,这是个好问题。这里,我再为你分析一下。

merge 的执行流程是这样的:

  1. 从磁盘读入数据页到内存(老版本的数据页);
  2. 从 change buffer 里找出这个数据页的 change buffer 记录 (可能有多个),依次应用,得到新版数据页;
  3. 写 redo log。这个 redo log 包含了数据的变更和 change buffer 的变更。

到这里 merge 过程就结束了。这时候,数据页和内存中 change buffer 对应的磁盘位置都还没有修改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。

评论区留言点赞板:

@某、人 把 02 篇的 redo log 更新细节和 change buffer 的更新串了起来; @Ivan 回复了其他同学的问题,并联系到 Checkpoint 机制; @约书亚 问到了 merge 和 redolog 的关系。

11 怎么给字符串字段加索引?

现在,几乎所有的系统都支持邮箱登录,如何在邮箱这样的字段上建立合理的索引,是我们今天要讨论的问题。

假设,你现在维护一个支持邮箱登录的系统,用户表是这么定义的:

mysql> create table SUser(
ID bigint unsigned primary key,
email varchar(64), 
... 
)engine=innodb; 

由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句:

mysql> select f1, f2 from SUser where email='xxx';

从第 4 和第 5 篇讲解索引的文章中,我们可以知道,如果 email 这个字段上没有索引,那么这个语句就只能做全表扫描。

同时,MySQL 是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。

比如,这两个在 email 字段上创建索引的语句:

mysql> alter table SUser add index index1(email);
或
mysql> alter table SUser add index index2(email(6));

第一个语句创建的 index1 索引里面,包含了每个记录的整个字符串;而第二个语句创建的 index2 索引里面,对于每个记录都是只取前 6 个字节。

那么,这两种不同的定义在数据结构和存储上有什么区别呢?如图 2 和 3 所示,就是这两个索引的示意图。

img

图 1 email 索引结构

img

图 2 email(6) 索引结构

从图中你可以看到,由于 email(6) 这个索引结构中每个邮箱字段都只取前 6 个字节(即:zhangs),所以占用的空间会更小,这就是使用前缀索引的优势。

但,这同时带来的损失是,可能会增加额外的记录扫描次数。

接下来,我们再看看下面这个语句,在这两个索引定义下分别是怎么执行的。

select id,name,email from SUser where email='[email protected]';

如果使用的是 index1(即 email 整个字符串的索引结构),执行顺序是这样的:

  1. 从 index1 索引树找到满足索引值是’[email protected]’的这条记录,取得 ID2 的值;
  2. 到主键上查到主键值是 ID2 的行,判断 email 的值是正确的,将这行记录加入结果集;
  3. 取 index1 索引树上刚刚查到的位置的下一条记录,发现已经不满足 email=‘[email protected]’的条件了,循环结束。

这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。

如果使用的是 index2(即 email(6) 索引结构),执行顺序是这样的:

  1. 从 index2 索引树找到满足索引值是’zhangs’的记录,找到的第一个是 ID1;
  2. 到主键上查到主键值是 ID1 的行,判断出 email 的值不是’[email protected]’,这行记录丢弃;
  3. 取 index2 上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出 ID2,再到 ID 索引上取整行然后判断,这次值对了,将这行记录加入结果集;
  4. 重复上一步,直到在 idxe2 上取到的值不是’zhangs’时,循环结束。

在这个过程中,要回主键索引取 4 次数据,也就是扫描了 4 行。

通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。

但是,对于这个查询语句来说,如果你定义的 index2 不是 email(6) 而是 email(7),也就是说取 email 字段的前 7 个字节来构建索引的话,即满足前缀’zhangss’的记录只有一个,也能够直接查到 ID2,只扫描一行就结束了。

也就是说使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

于是,你就有个问题:当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?

实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

首先,你可以使用下面这个语句,算出这个列上有多少个不同的值:

mysql> select count(distinct email) as L from SUser;

然后,依次选取不同长度的前缀来看这个值,比如我们要看一下 4~7 个字节的前缀索引,可以用这个语句:

mysql> select 
  count(distinct left(email,4))as L4,
  count(distinct left(email,5))as L5,
  count(distinct left(email,6))as L6,
  count(distinct left(email,7))as L7,
from SUser;

当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的 L4~L7 中,找出不小于 L * 95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6。

前缀索引对覆盖索引的影响

前面我们说了使用前缀索引可能会增加扫描行数,这会影响到性能。其实,前缀索引的影响不止如此,我们再看一下另外一个场景。

你先来看看这个 SQL 语句:

select id,email from SUser where email='[email protected]';

与前面例子中的 SQL 语句

select id,name,email from SUser where email='[email protected]';

相比,这个语句只要求返回 id 和 email 字段。

所以,如果使用 index1(即 email 整个字符串的索引结构)的话,可以利用覆盖索引,从 index1 查到结果后直接就返回了,不需要回到 ID 索引再去查一次。而如果使用 index2(即 email(6) 索引结构)的话,就不得不回到 ID 索引再去判断 email 字段的值。

即使你将 index2 的定义修改为 email(18) 的前缀索引,这时候虽然 index2 已经包含了所有的信息,但 InnoDB 还是要回到 id 索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。

也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。

其他方式

对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。但是,遇到前缀的区分度不够好的情况时,我们要怎么办呢?

比如,我们国家的身份证号,一共 18 位,其中前 6 位是地址码,所以同一个县的人的身份证号前 6 位一般会是相同的。

假设你维护的数据库是一个市的公民信息系统,这时候如果对身份证号做长度为 6 的前缀索引的话,这个索引的区分度就非常低了。

按照我们前面说的方法,可能你需要创建长度为 12 以上的前缀索引,才能够满足区分度要求。

但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。

那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理方法呢?这种方法,既可以占用更小的空间,也能达到相同的查询效率。

答案是,有的。

第一种方式是使用倒序存储。如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:

mysql> select field_list from t where id_card = reverse('input_id_card_string');

由于身份证号的最后 6 位没有地址码这样的重复逻辑,所以最后这 6 位很可能就提供了足够的区分度。当然了,实践中你不要忘记使用 count(distinct) 方法去做个验证。

第二种方式是使用 hash 字段。你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。

mysql> alter table t add id_card_crc int unsigned, add index(id_card_crc);

然后每次插入新记录的时候,都同时用 crc32() 这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证号通过 crc32() 函数得到的结果可能是相同的,所以你的查询语句 where 部分要判断 id_card 的值是否精确相同。

mysql> select field_list from t where id_card_crc=crc32('input_id_card_string') and id_card='input_id_card_string'

这样,索引的长度变成了 4 个字节,比原来小了很多。

接下来,我们再一起看看使用倒序存储和使用 hash 字段这两种方法的异同点。

首先,它们的相同点是,都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的方式排序的,已经没有办法利用索引方式查出身份证号码在 [ID_X, ID_Y] 的所有市民了。同样地,hash 字段的方式也只能支持等值查询。

它们的区别,主要体现在以下三个方面:

  1. 从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而 hash 字段方法需要增加一个字段。当然,倒序存储方式使用 4 个字节的前缀长度应该是不够的,如果再长一点,这个消耗跟额外这个 hash 字段也差不多抵消了。
  2. 在 CPU 消耗方面,倒序方式每次写和读的时候,都需要额外调用一次 reverse 函数,而 hash 字段的方式需要额外调用一次 crc32() 函数。如果只从这两个函数的计算复杂度来看的话,reverse 函数额外消耗的 CPU 资源会更小些。
  3. 从查询效率上看,使用 hash 字段方式的查询性能相对更稳定一些。因为 crc32 算出来的值虽然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近 1。而倒序存储方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。

小结

在今天这篇文章中,我跟你聊了聊字符串字段创建索引的场景。我们来回顾一下,你可以使用的方式有:

  1. 直接创建完整索引,这样可能比较占用空间;
  2. 创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
  3. 倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
  4. 创建 hash 字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支持范围扫描。

在实际应用中,你要根据业务字段的特点选择使用哪种方式。

好了,又到了最后的问题时间。

如果你在维护一个学校的学生信息数据库,学生登录名的统一格式是”学号 @gmail.com”, 而学号的规则是:十五位的数字,其中前三位是所在城市编号、第四到第六位是学校编号、第七位到第十位是入学年份、最后五位是顺序编号。

系统登录的时候都需要学生输入登录名和密码,验证正确后才能继续使用系统。就只考虑登录验证这个行为的话,你会怎么设计这个登录名的索引呢?

你可以把你的分析思路和设计结果写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上篇文章中的第一个例子,评论区有几位同学说没有复现,大家要检查一下隔离级别是不是 RR(Repeatable Read,可重复读),创建的表 t 是不是 InnoDB 引擎。我把复现过程做成了一个视频,供你参考。

在上一篇文章最后,我给你留的问题是,为什么经过这个操作序列,explain 的结果就不对了?这里,我来为你分析一下原因。

delete 语句删掉了所有的数据,然后再通过 call idata() 插入了 10 万行数据,看上去是覆盖了原来的 10 万行。

但是,session A 开启了事务并没有提交,所以之前插入的 10 万行数据是不能删除的。这样,之前的数据每一行数据都有两个版本,旧版本是 delete 之前的数据,新版本是标记为 deleted 的数据。

这样,索引 a 上的数据其实就有两份。

然后你会说,不对啊,主键上的数据也不能删,那没有使用 force index 的语句,使用 explain 命令看到的扫描行数为什么还是 100000 左右?(潜台词,如果这个也翻倍,也许优化器还会认为选字段 a 作为索引更合适)

是的,不过这个是主键,主键是直接按照表的行数来估计的。而表的行数,优化器直接用的是 show table status 的值。

这个值的计算方法,我会在后面有文章为你详细讲解。

img评论区留言点赞板:

@斜面镜子 Bill 的评论最接近答案; @某、人 做了两个很不错的对照试验; @ye7zi 等几位同学很认真的验证,赞态度。大家的机器如果 IO 能力比较差的话,做这个验证的时候,可以把 innodb_flush_log_at_trx_commit 和 sync_binlog 都设置成 0。

12 为什么我的MySQL会“抖”一下?

平时的工作中,不知道你有没有遇到过这样的场景,一条 SQL 语句,正常执行的时候特别快,但是有时也不知道怎么回事,它就会变得特别慢,并且这样的场景很难复现,它不只随机,而且持续时间还很短。

看上去,这就像是数据库“抖”了一下。今天,我们就一起来看一看这是什么原因。

你的 SQL 语句为什么变“慢”了

在前面第 2 篇文章[《日志系统:一条 SQL 更新语句是如何执行的?》]中,我为你介绍了 WAL 机制。现在你知道了,InnoDB 在处理更新语句的时候,只做了写日志这一个磁盘操作。这个日志叫作 redo log(重做日志),也就是《孔乙己》里咸亨酒店掌柜用来记账的粉板,在更新内存写完 redo log 后,就返回给客户端,本次更新成功。

做下类比的话,掌柜记账的账本是数据文件,记账用的粉板是日志文件(redo log),掌柜的记忆就是内存。

掌柜总要找时间把账本更新一下,这对应的就是把内存里的数据写入磁盘的过程,术语就是 flush。在这个 flush 操作执行之前,孔乙己的赊账总额,其实跟掌柜手中账本里面的记录是不一致的。因为孔乙己今天的赊账金额还只在粉板上,而账本里的记录是老的,还没把今天的赊账算进去。

当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”

不论是脏页还是干净页,都在内存中。在这个例子里,内存对应的就是掌柜的记忆。

接下来,我们用一个示意图来展示一下“孔乙己赊账”的整个操作过程。假设原来孔乙己欠账 10 文,这次又要赊 9 文。

img

图 1 “孔乙己赊账”更新和 flush 过程

回到文章开头的问题,你不难想象,平时执行很快的更新操作,其实就是在写内存和日志,而 MySQL 偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。

那么,什么情况会引发数据库的 flush 过程呢?

我们还是继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上?

  • 第一种场景是,粉板满了,记不下了。这时候如果再有人来赊账,掌柜就只得放下手里的活儿,将粉板上的记录擦掉一些,留出空位以便继续记账。当然在擦掉之前,他必须先将正确的账目记录到账本中才行。 这个场景,对应的就是 InnoDB 的 redo log 写满了。这时候系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。我在第二讲画了一个 redo log 的示意图,这里我改成环形,便于大家理解。

img

图 2 redo log 状态图

checkpoint 可不是随便往前修改一下位置就可以的。比如图 2 中,把 checkpoint 位置从 CP 推进到 CP’,就需要将两个点之间的日志(浅绿色部分),对应的所有脏页都 flush 到磁盘上。之后,图中从 write pos 到 CP’之间就是可以再写入的 redo log 的区域。

  • 第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。 这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。 你一定会说,这时候难道不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿 redo log 出来应用不就行了?这里其实是从性能考虑的。如果刷脏页一定会写盘,就保证了每个数据页有两种状态:
    • 一种是内存里存在,内存里就肯定是正确的结果,直接返回;
    • 另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。 这样的效率最高。
  • 第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。 这种场景,对应的就是 MySQL 认为系统“空闲”的时候。当然,MySQL“这家酒店”的生意好起来可是会很快就能把粉板记满的,所以“掌柜”要合理地安排时间,即使是“生意好”的时候,也要见缝插针地找时间,只要有机会就刷一点“脏页”。
  • 第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。 这种场景,对应的就是 MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。

接下来,你可以分析一下上面四种场景对性能的影响。

其中,第三种情况是属于 MySQL 空闲时的操作,这时系统没什么压力,而第四种场景是数据库本来就要关闭了。这两种情况下,你不会太关注“性能”问题。所以这里,我们主要来分析一下前两种场景下的性能问题。

第一种是“redo log 写满了,要 flush 脏页”,这种情况是 InnoDB 要尽量避免的。因为出现这种情况的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更新数会跌为 0。

第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。InnoDB 用缓冲池(buffer pool)管理内存,缓冲池中的内存页有三种状态:

  • 第一种是,还没有使用的;
  • 第二种是,使用了并且是干净页;
  • 第三种是,使用了并且是脏页。

InnoDB 的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。

而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。

所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:

  1. 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
  2. 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的。

所以,InnoDB 需要有控制脏页比例的机制,来尽量避免上面的这两种情况。

InnoDB 刷脏页的控制策略

接下来,我就来和你说说 InnoDB 脏页的控制策略,以及和这些策略相关的参数。

首先,你要正确地告诉 InnoDB 所在主机的 IO 能力,这样 InnoDB 才能知道需要全力刷脏页的时候,可以刷多快。

这就要用到 innodb_io_capacity 这个参数了,它会告诉 InnoDB 你的磁盘能力。这个值我建议你设置成磁盘的 IOPS。磁盘的 IOPS 可以通过 fio 这个工具来测试,下面的语句是我用来测试磁盘随机读写的命令:

 fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest 

其实,因为没能正确地设置 innodb_io_capacity 参数,而导致的性能问题也比比皆是。之前,就曾有其他公司的开发负责人找我看一个库的性能问题,说 MySQL 的写入速度很慢,TPS 很低,但是数据库主机的 IO 压力并不大。经过一番排查,发现罪魁祸首就是这个参数的设置出了问题。

他的主机磁盘用的是 SSD,但是 innodb_io_capacity 的值设置的是 300。于是,InnoDB 认为这个系统的能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页累积,影响了查询和更新性能。

虽然我们现在已经定义了“全力刷脏页”的行为,但平时总不能一直是全力刷吧?毕竟磁盘能力不能只用来刷脏页,还需要服务用户请求。所以接下来,我们就一起看看 InnoDB 怎么控制引擎按照“全力”的百分比来刷脏页。

根据我前面提到的知识点,试想一下,如果你来设计策略控制刷脏页的速度,会参考哪些因素呢?

这个问题可以这么想,如果刷太慢,会出现什么情况?首先是内存脏页太多,其次是 redo log 写满。

所以,InnoDB 的刷盘速度就是要参考这两个因素:一个是脏页比例,一个是 redo log 写盘速度。

InnoDB 会根据这两个因素先单独算出两个数字。

参数 innodb_max_dirty_pages_pct 是脏页比例上限,默认值是 75%。InnoDB 会根据当前的脏页比例(假设为 M),算出一个范围在 0 到 100 之间的数字,计算这个数字的伪代码类似这样:

F1(M)
{
  if M>=innodb_max_dirty_pages_pct then
      return 100;
  return 100*M/innodb_max_dirty_pages_pct;
}

InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,我们假设为 N。InnoDB 会根据这个 N 算出一个范围在 0 到 100 之间的数字,这个计算公式可以记为 F2(N)。F2(N) 算法比较复杂,你只要知道 N 越大,算出来的值越大就好了。

然后,根据上述算得的 F1(M) 和 F2(N) 两个值,取其中较大的值记为 R,之后引擎就可以按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度。

上述的计算流程比较抽象,不容易理解,所以我画了一个简单的流程图。图中的 F1、F2 就是上面我们通过脏页比例和 redo log 写入速度算出来的两个值。

img

图 3 InnoDB 刷脏页速度策略

现在你知道了,InnoDB 会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。所以,无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用 IO 资源并可能影响到了你的更新语句,都可能是造成你从业务端感知到 MySQL“抖”了一下的原因。

要尽量避免这种情况,你就要合理地设置 innodb_io_capacity 的值,并且平时要多关注脏页比例,不要让它经常接近 75%

其中,脏页比例是通过 Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total 得到的,具体的命令参考下面的代码:

mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';
select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';
select @a/@b;

接下来,我们再看一个有趣的策略。

一旦一个查询请求需要在执行过程中先 flush 掉一个脏页时,这个查询就可能要比平时慢了。而 MySQL 中的一个机制,可能让你的查询会更慢:在准备刷一个脏页的时候,如果这个数据页旁边的数据页刚好是脏页,就会把这个“邻居”也带着一起刷掉;而且这个把“邻居”拖下水的逻辑还可以继续蔓延,也就是对于每个邻居数据页,如果跟它相邻的数据页也还是脏页的话,也会被放到一起刷。

在 InnoDB 中,innodb_flush_neighbors 参数就是用来控制这个行为的,值为 1 的时候会有上述的“连坐”机制,值为 0 时表示不找邻居,自己刷自己的。

找“邻居”这个优化在机械硬盘时代是很有意义的,可以减少很多随机 IO。机械硬盘的随机 IOPS 一般只有几百,相同的逻辑操作减少随机 IO 就意味着系统性能的大幅度提升。

而如果使用的是 SSD 这类 IOPS 比较高的设备的话,我就建议你把 innodb_flush_neighbors 的值设置成 0。因为这时候 IOPS 往往不是瓶颈,而“只刷自己”,就能更快地执行完必要的刷脏页操作,减少 SQL 语句响应时间。

在 MySQL 8.0 中,innodb_flush_neighbors 参数的默认值已经是 0 了。

小结

今天这篇文章,我延续第 2 篇中介绍的 WAL 的概念,和你解释了这个机制后续需要的刷脏页操作和执行时机。利用 WAL 技术,数据库将随机写转换成了顺序写,大大提升了数据库的性能。

但是,由此也带来了内存脏页的问题。脏页会被后台线程自动 flush,也会由于数据页淘汰而触发 flush,而刷脏页的过程由于会占用资源,可能会让你的更新和查询语句的响应时间长一些。在文章里,我也给你介绍了控制刷脏页的方法和对应的监控方式。

文章最后,我给你留下一个思考题吧。

一个内存配置为 128GB、innodb_io_capacity 设置为 20000 的大规格实例,正常会建议你将 redo log 设置成 4 个 1GB 的文件。

但如果你在配置的时候不慎将 redo log 设置成了 1 个 100M 的文件,会发生什么情况呢?又为什么会出现这样的情况呢?

你可以把你的分析结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期我留给你的问题是,给一个学号字段创建索引,有哪些方法。

由于这个学号的规则,无论是正向还是反向的前缀索引,重复度都比较高。因为维护的只是一个学校的,因此前面 6 位(其中,前三位是所在城市编号、第四到第六位是学校编号)其实是固定的,邮箱后缀都是 @gamil.com,因此可以只存入学年份加顺序编号,它们的长度是 9 位。

而其实在此基础上,可以用数字类型来存这 9 位数字。比如 201100001,这样只需要占 4 个字节。其实这个就是一种 hash,只是它用了最简单的转换规则:字符串转数字的规则,而刚好我们设定的这个背景,可以保证这个转换后结果的唯一性。

评论区中,也有其他一些很不错的见解。

评论用户 @封建的风 说,一个学校的总人数这种数据量,50 年才 100 万学生,这个表肯定是小表。为了业务简单,直接存原来的字符串。这个答复里面包含了“优化成本和收益”的思想,我觉得值得 at 出来。

@小潘 同学提了另外一个极致的方向。如果碰到表数据量特别大的场景,通过这种方式的收益是很不错的。

评论区留言点赞板:

@lttzzlll ,提到了用整型存“四位年份 + 五位编号”的方法; 由于整个学号的值超过了 int 上限,@老杨同志 也提到了用 8 个字节的 bigint 来存的方法。

13 为什么表数据删掉一半,表文件大小不变?

经常会有同学来问我,我的数据库占用空间太大,我把一个最大的表删掉了一半的数据,怎么表文件的大小还是没变?

那么今天,我就和你聊聊数据库表的空间回收,看看如何解决这个问题。

这里,我们还是针对 MySQL 中应用最广泛的 InnoDB 引擎展开讨论。一个 InnoDB 表包含两部分,即:表结构定义和数据。在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里。而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。

接下来,我会先和你说明为什么简单地删除表数据达不到表空间回收的效果,然后再和你介绍正确回收空间的方法。

参数 innodb_file_per_table

表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:

  1. 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
  2. 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。

从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。

我建议你不论使用 MySQL 的哪个版本,都将这个值设置为 ON。因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

所以,将 innodb_file_per_table 设置为 ON,是推荐做法,我们接下来的讨论都是基于这个设置展开的。

我们在删除整个表的时候,可以使用 drop table 命令回收表空间。但是,我们遇到的更多的删除数据的场景是删除某些行,这时就遇到了我们文章开头的问题:表中的数据被删除了,但是表空间却没有被回收。

我们要彻底搞明白这个问题的话,就要从数据删除流程说起了。

数据删除流程

我们先再来看一下 InnoDB 中一个索引的示意图。在前面[第 4]和[第 5]篇文章中,我和你介绍索引时曾经提到过,InnoDB 里的数据都是用 B+ 树的结构组织的。

img

图 1 B+ 树索引示意图

假设,我们要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

现在,你已经知道了 InnoDB 的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记录,会怎么样?

答案是,整个数据页就可以被复用了。

但是,数据页的复用跟记录的复用是不同的。

记录的复用,只限于符合范围条件的数据。比如上面的这个例子,R4 这条记录被删除后,如果插入一个 ID 是 400 的行,可以直接复用这个空间。但如果插入的是一个 ID 是 800 的行,就不能复用这个位置了。

而当整个页从 B+ 树里面摘掉以后,可以复用到任何位置。以图 1 为例,如果将数据页 page A 上的所有记录删除以后,page A 会被标记为可复用。这时候如果要插入一条 ID=50 的记录需要使用新页的时候,page A 是可以被复用的。

如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用。

进一步地,如果我们用 delete 命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。

你现在知道了,delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

实际上,不止是删除数据会造成空洞,插入数据也会。

如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。

假设图 1 中 page A 已经满了,这时我要再插入一行数据,会怎样呢?

img

图 2 插入数据导致页分裂

可以看到,由于 page A 满了,再插入一个 ID 是 550 的数据时,就不得不再申请一个新的页面 page B 来保存数据了。页分裂完成后,page A 的末尾就留下了空洞(注意:实际上,可能不止 1 个记录的位置是空洞)。

另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值。不难理解,这也是会造成空洞的。

也就是说,经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。

而重建表,就可以达到这样的目的。

重建表

试想一下,如果你现在有一个表 A,需要做空间收缩,为了把表中存在的空洞去掉,你可以怎么做呢?

你可以新建一个与表 A 结构相同的表 B,然后按照主键 ID 递增的顺序,把数据一行一行地从表 A 里读出来再插入到表 B 中。

由于表 B 是新建的表,所以表 A 主键索引上的空洞,在表 B 中就都不存在了。显然地,表 B 的主键索引更紧凑,数据页的利用率也更高。如果我们把表 B 作为临时表,数据从表 A 导入表 B 的操作完成后,用表 B 替换 A,从效果上看,就起到了收缩表 A 空间的作用。

这里,你可以使用 alter table A engine=InnoDB 命令来重建表。在 MySQL 5.5 版本之前,这个命令的执行流程跟我们前面描述的差不多,区别只是这个临时表 B 不需要你自己创建,MySQL 会自动完成转存数据、交换表名、删除旧表的操作。

img

图 3 改锁表 DDL

显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL 过程中,表 A 中不能有更新。也就是说,这个 DDL 不是 Online 的。

而在MySQL 5.6 版本开始引入的 Online DDL,对这个操作流程做了优化。

我给你简单描述一下引入了 Online DDL 之后,重建表的流程:

  1. 建立一个临时文件,扫描表 A 主键的所有数据页;
  2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
  3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;
  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;
  5. 用临时文件替换表 A 的数据文件。

img

图 4 Online DDL

可以看到,与图 3 过程的不同之处在于,由于日志文件记录和重放操作这个功能的存在,这个方案在重建表的过程中,允许对表 A 做增删改操作。这也就是 Online DDL 名字的来源。

我记得有同学在第 6 篇讲表锁的文章[《全局锁和表锁 :给表加个字段怎么索这么多阻碍?》]的评论区留言说,DDL 之前是要拿 MDL 写锁的,这样还能叫 Online DDL 吗?

确实,图 4 的流程中,alter 语句在启动的时候需要获取 MDL 写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。

为什么要退化呢?为了实现 Online,MDL 读锁不会阻塞增删改操作。

那为什么不干脆直接解锁呢?为了保护自己,禁止其他线程对这个表同时做 DDL。

而对于一个大表来说,Online DDL 最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个 DDL 过程来说,锁的时间非常短。对业务来说,就可以认为是 Online 的。

需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用 GitHub 开源的 gh-ost 来做。

Online 和 inplace

说到 Online,我还要再和你澄清一下它和另一个跟 DDL 有关的、容易混淆的概念 inplace 的区别。

你可能注意到了,在图 3 中,我们把表 A 中的数据导出来的存放位置叫作 tmp_table。这是一个临时表,是在 server 层创建的。

在图 4 中,根据表 A 重建出来的数据是放在“tmp_file”里的,这个临时文件是 InnoDB 在内部创建出来的。整个 DDL 过程都在 InnoDB 内部完成。对于 server 层来说,没有把数据挪动到临时表,是一个“原地”操作,这就是“inplace”名称的来源。

所以,我现在问你,如果你有一个 1TB 的表,现在磁盘间是 1.2TB,能不能做一个 inplace 的 DDL 呢?

答案是不能。因为,tmp_file 也是要占用临时空间的。

我们重建表的这个语句 alter table t engine=InnoDB,其实隐含的意思是:

alter table t engine=innodb,ALGORITHM=inplace;

跟 inplace 对应的就是拷贝表的方式了,用法是:

alter table t engine=innodb,ALGORITHM=copy;

当你使用 ALGORITHM=copy 的时候,表示的是强制拷贝表,对应的流程就是图 3 的操作过程。

但我这样说你可能会觉得,inplace 跟 Online 是不是就是一个意思?

其实不是的,只是在重建表这个逻辑中刚好是这样而已。

比如,如果我要给 InnoDB 表的一个字段加全文索引,写法是:

alter table t add FULLTEXT(field_name);

这个过程是 inplace 的,但会阻塞增删改操作,是非 Online 的。

如果说这两个逻辑之间的关系是什么的话,可以概括为:

  1. DDL 过程如果是 Online 的,就一定是 inplace 的;
  2. 反过来未必,也就是说 inplace 的 DDL,有可能不是 Online 的。截止到 MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引 (SPATIAL index) 就属于这种情况。

最后,我们再延伸一下。

在第 10 篇文章[《MySQL 为什么有时候会选错索引》]的评论区中,有同学问到使用 optimize table、analyze table 和 alter table 这三种方式重建表的区别。这里,我顺便再简单和你解释一下。

  • 从 MySQL 5.6 版本开始,alter table t engine = InnoDB(也就是 recreate)默认的就是上面图 4 的流程了;
  • analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程中加了 MDL 读锁;
  • optimize table t 等于 recreate+analyze。

小结

今天这篇文章,我和你讨论了数据库中收缩表空间的方法。

现在你已经知道了,如果要收缩一个表,只是 delete 掉表里面不用的数据的话,表文件的大小是不会变的,你还要通过 alter table 命令重建表,才能达到表文件变小的目的。我跟你介绍了重建表的两种实现方式,Online DDL 的方式是可以考虑在业务低峰期使用的,而 MySQL 5.5 及之前的版本,这个命令是会阻塞 DML 的,这个你需要特别小心。

最后,又到了我们的课后问题时间。

假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的:

  1. 一个表 t 文件大小为 1TB;
  2. 对这个表执行 alter table t engine=InnoDB;
  3. 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了 1.01TB。

你觉得可能是什么原因呢 ?

你可以把你觉得可能的原因写在留言区里,我会在下一篇文章的末尾把大家描述的合理的原因都列出来,以后其他同学就不用掉到这样的坑里了。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

在上期文章最后,我留给你的问题是,如果一个高配的机器,redo log 设置太小,会发生什么情况。

每次事务提交都要写 redo log,如果设置太小,很快就会被写满,也就是下面这个图的状态,这个“环”将很快被写满,write pos 一直追着 CP。

img

这时候系统不得不停止所有更新,去推进 checkpoint。

这时,你看到的现象就是磁盘压力很小,但是数据库出现间歇性的性能下跌。

评论区留言点赞板:

@某、人 给了一个形象的描述,而且提到了,在这种情况下,连 change buffer 的优化也失效了。因为 checkpoint 一直要往前推,这个操作就会触发 merge 操作,然后又进一步地触发刷脏页操作; 有几个同学提到了内存淘汰脏页,对应的 redo log 的操作,这个我们会在后面的文章中展开,大家可以先看一下 @melon 同学的描述了解一下; @算不出流源 提到了“动态平衡”,其实只要出现了这种“平衡”,意味着本应该后台的操作,就已经影响了业务应用,属于有损失的平衡。

14 count()这么慢,我该怎么办?

在开发系统的时候,你可能经常需要计算一个表的行数,比如一个交易系统的所有变更记录总数。这时候你可能会想,一条 select count(*) from t 语句不就解决了吗?

但是,你会发现随着系统中记录数越来越多,这条语句执行得也会越来越慢。然后你可能就想了,MySQL 怎么这么笨啊,记个总数,每次要查的时候直接读出来,不就好了吗。

那么今天,我们就来聊聊 count(*) 语句到底是怎样实现的,以及 MySQL 为什么会这么实现。然后,我会再和你说说,如果应用中有这种频繁变更并需要统计表行数的需求,业务设计上可以怎么做。

count(*) 的实现方式

你首先要明确的是,在不同的 MySQL 引擎中,count(*) 有不同的实现方式。

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高;
  • 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。

这里需要注意的是,我们在这篇文章里讨论的是没有过滤条件的 count(*),如果加了 where 条件的话,MyISAM 表也是不能返回得这么快的。

在前面的文章中,我们一起分析了为什么要使用 InnoDB,因为不论是在事务支持、并发能力还是在数据安全方面,InnoDB 都优于 MyISAM。我猜你的表也一定是用了 InnoDB 引擎。这就是当你的记录数越来越多的时候,计算一个表的总行数会越来越慢的原因。

为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢?

这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。这里,我用一个算 count(*) 的例子来为你解释一下。

假设表 t 中现在有 10000 条记录,我们设计了三个用户并行的会话。

  • 会话 A 先启动事务并查询一次表的总行数;
  • 会话 B 启动事务,插入一行后记录后,查询表的总行数;
  • 会话 C 先启动一个单独的语句,插入一行记录后,查询表的总行数。

我们假设从上到下是按照时间顺序执行的,同一行语句是在同一时刻执行的。

img

图 1 会话 A、B、C 的执行流程

你会看到,在最后一个时刻,三个会话 A、B、C 会同时查询表 t 的总行数,但拿到的结果却不同。

这和 InnoDB 的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发控制,也就是 MVCC 来实现的。每一行记录都要判断自己是否对这个会话可见,因此对于 count(*) 请求来说,InnoDB 只好把数据一行一行地读出依次判断,可见的行才能够用于计算“基于这个查询”的表的总行数。

备注:如果你对 MVCC 记忆模糊了,可以再回顾下第 3 篇文章[《事务隔离:为什么你改了我还看不见?》]和第 8 篇文章[《事务到底是隔离的还是不隔离的?》]中的相关内容。

当然,现在这个看上去笨笨的 MySQL,在执行 count(*) 操作的时候还是做了优化的。

你知道的,InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。

如果你用过 show table status 命令的话,就会发现这个命令的输出结果里面也有一个 TABLE_ROWS 用于显示这个表当前有多少行,这个命令执行挺快的,那这个 TABLE_ROWS 能代替 count(*) 吗?

你可能还记得在第 10 篇文章[《 MySQL 为什么有时候会选错索引?》]中我提到过,索引统计的值是通过采样来估算的。实际上,TABLE_ROWS 就是从这个采样估算得来的,因此它也很不准。有多不准呢,官方文档说误差可能达到 40% 到 50%。所以,show table status 命令显示的行数也不能直接使用。

到这里我们小结一下:

  • MyISAM 表虽然 count(*) 很快,但是不支持事务;
  • show table status 命令虽然返回很快,但是不准确;
  • InnoDB 表直接 count(*) 会遍历全表,虽然结果准确,但会导致性能问题。

那么,回到文章开头的问题,如果你现在有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,我们只能自己计数。

接下来,我们讨论一下,看看自己计数有哪些方法,以及每种方法的优缺点有哪些。

这里,我先和你说一下这些方法的基本思路:你需要自己找一个地方,把操作记录表的行数存起来。

用缓存系统保存计数

对于更新很频繁的库来说,你可能会第一时间想到,用缓存系统来支持。

你可以用一个 Redis 服务来保存这个表的总行数。这个表每被插入一行 Redis 计数就加 1,每被删除一行 Redis 计数就减 1。这种方式下,读和更新操作都很快,但你再想一下这种方式存在什么问题吗?

没错,缓存系统可能会丢失更新。

Redis 的数据不能永久地留在内存里,所以你会找一个地方把这个值定期地持久化存储起来。但即使这样,仍然可能丢失更新。试想如果刚刚在数据表中插入了一行,Redis 中保存的值也加了 1,然后 Redis 异常重启了,重启后你要从存储 redis 数据的地方把这个值读回来,而刚刚加 1 的这个计数操作却丢失了。

当然了,这还是有解的。比如,Redis 异常重启以后,到数据库里面单独执行一次 count(*) 获取真实的行数,再把这个值写回到 Redis 里就可以了。异常重启毕竟不是经常出现的情况,这一次全表扫描的成本,还是可以接受的。

但实际上,将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。

你可以设想一下有这么一个页面,要显示操作记录的总数,同时还要显示最近操作的 100 条记录。那么,这个页面的逻辑就需要先到 Redis 里面取出计数,再到数据表里面取数据记录。

我们是这么定义不精确的:

  1. 一种是,查到的 100 行结果里面有最新插入记录,而 Redis 的计数里还没加 1;
  2. 另一种是,查到的 100 行结果里没有最新插入的记录,而 Redis 的计数里已经加了 1。

这两种情况,都是逻辑不一致的。

我们一起来看看这个时序图。

img

图 2 会话 A、B 执行时序图

图 2 中,会话 A 是一个插入交易记录的逻辑,往数据表里插入一行 R,然后 Redis 计数加 1;会话 B 就是查询页面显示时需要的数据。

在图 2 的这个时序里,在 T3 时刻会话 B 来查询的时候,会显示出新插入的 R 这个记录,但是 Redis 的计数还没加 1。这时候,就会出现我们说的数据不一致。

你一定会说,这是因为我们执行新增记录逻辑时候,是先写数据表,再改 Redis 计数。而读的时候是先读 Redis,再读数据表,这个顺序是相反的。那么,如果保持顺序一样的话,是不是就没问题了?我们现在把会话 A 的更新顺序换一下,再看看执行结果。

img

图 3 调整顺序后,会话 A、B 的执行时序图

你会发现,这时候反过来了,会话 B 在 T3 时刻查询的时候,Redis 计数加了 1 了,但还查不到新插入的 R 这一行,也是数据不一致的情况。

在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,所以,我们说即使 Redis 正常工作,这个计数值还是逻辑上不精确的。

在数据库保存计数

根据上面的分析,用缓存系统保存计数有丢失数据和计数不精确的问题。那么,如果我们把这个计数直接放到数据库里单独的一张计数表 C 中,又会怎么样呢?

首先,这解决了崩溃丢失的问题,InnoDB 是支持崩溃恢复不丢数据的。

备注:关于 InnoDB 的崩溃恢复,你可以再回顾一下第 2 篇文章[《日志系统:一条 SQL 更新语句是如何执行的?》]中的相关内容。

然后,我们再看看能不能解决计数不精确的问题。

你会说,这不一样吗?无非就是把图 3 中对 Redis 的操作,改成了对计数表 C 的操作。只要出现图 3 的这种执行序列,这个问题还是无解的吧?

这个问题还真不是无解的。

我们这篇文章要解决的问题,都是由于 InnoDB 要支持事务,从而导致 InnoDB 表不能把 count(*) 直接存起来,然后查询的时候直接返回形成的。

所谓以子之矛攻子之盾,现在我们就利用“事务”这个特性,把问题解决掉。

img

图 4 会话 A、B 的执行时序图

我们来看下现在的执行结果。虽然会话 B 的读操作仍然是在 T3 执行的,但是因为这时候更新事务还没有提交,所以计数值加 1 这个操作对会话 B 还不可见。

因此,会话 B 看到的结果里, 查计数值和“最近 100 条记录”看到的结果,逻辑上就是一致的。

不同的 count 用法

在前面文章的评论区,有同学留言问到:在 select count(?) from t 这样的查询语句里面,count()、count(主键 id)、count(字段) 和 count(1) 等不同用法的性能,有哪些差别。今天谈到了 count() 的性能问题,我就借此机会和你详细说明一下这几种用法的性能差别。

需要注意的是,下面的讨论还是基于 InnoDB 引擎的。

这里,首先你要弄清楚 count() 的语义。count() 是一个聚合函数,对于返回的结果集,一行行地判断,如果 count 函数的参数不是 NULL,累计值就加 1,否则不加。最后返回累计值。

所以,count(*)、count(主键 id) 和 count(1) 都表示返回满足条件的结果集的总行数;而 count(字段),则表示返回满足条件的数据行里面,参数“字段”不为 NULL 的总个数。

至于分析性能差别的时候,你可以记住这么几个原则:

  1. server 层要什么就给什么;
  2. InnoDB 只给必要的值;
  3. 现在的优化器只优化了 count(*) 的语义为“取行数”,其他“显而易见”的优化并没有做。

这是什么意思呢?接下来,我们就一个个地来看看。

对于 count(主键 id) 来说,InnoDB 引擎会遍历整张表,把每一行的 id 值都取出来,返回给 server 层。server 层拿到 id 后,判断是不可能为空的,就按行累加。

对于 count(1) 来说,InnoDB 引擎遍历整张表,但不取值。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。

单看这两个用法的差别的话,你能对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。

对于 count(字段) 来说

  1. 如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
  2. 如果这个“字段”定义允许为 null,那么执行的时候,判断到有可能是 null,还要把值取出来再判断一下,不是 null 才累加。

也就是前面的第一条原则,server 层要什么字段,InnoDB 就返回什么字段。

但是 count(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 肯定不是 null,按行累加。

看到这里,你一定会说,优化器就不能自己判断一下吗,主键 id 肯定非空啊,为什么不能按照 count(*) 来处理,多么简单的优化啊。

当然,MySQL 专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且 MySQL 已经优化过 count(*) 了,你直接使用这种用法就可以了。

所以结论是:按照效率排序的话,count(字段)),所以我建议你,尽量使用 count()。

小结

今天,我和你聊了聊 MySQL 中获得表行数的两种方法。我们提到了在不同引擎中 count(*) 的实现方式是不一样的,也分析了用缓存系统来存储计数值存在的问题。

其实,把计数放在 Redis 里面,不能够保证计数和 MySQL 表里的数据精确一致的原因,是这两个不同的存储构成的系统,不支持分布式事务,无法拿到精确一致的视图。而把计数值也放在 MySQL 中,就解决了一致性视图的问题。

InnoDB 引擎支持事务,我们利用好事务的原子性和隔离性,就可以简化在业务开发时的逻辑。这也是 InnoDB 引擎备受青睐的原因之一。

最后,又到了今天的思考题时间了。

在刚刚讨论的方案中,我们用了事务来确保计数准确。由于事务可以保证中间结果不被别的事务读到,因此修改计数值和插入新记录的顺序是不影响逻辑结果的。但是,从并发系统性能的角度考虑,你觉得在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期我给你留的问题是,什么时候使用 alter table t engine=InnoDB 会让一个表占用的空间反而变大。

在这篇文章的评论区里面,大家都提到了一个点,就是这个表,本身就已经没有空洞的了,比如说刚刚做过一次重建表操作。

在 DDL 期间,如果刚好有外部的 DML 在执行,这期间可能会引入一些新的空洞。

@飞翔 提到了一个更深刻的机制,是我们在文章中没说的。在重建表的时候,InnoDB 不会把整张表占满,每个页留了 116 给后续的更新用。也就是说,其实重建表之后不是“最”紧凑的。

假如是这么一个过程:

  1. 将表 t 重建一次;
  2. 插入一部分数据,但是插入的这些数据,用掉了一部分的预留空间;
  3. 这种情况下,再重建一次表 t,就可能会出现问题中的现象。

评论区留言点赞板:

@W_T 等同学提到了数据表本身紧凑的情况; @undifined 提了一个好问题, @帆帆帆帆帆帆帆帆 同学回答了这个问题; @陈飞 @郜 @wang chen wen 都提了很不错的问题,大家可以去看看。

15 答疑文章(一):日志和索引相关问题

在今天这篇答疑文章更新前,MySQL 实战这个专栏已经更新了 14 篇。在这些文章中,大家在评论区留下了很多高质量的留言。现在,每篇文章的评论区都有热心的同学帮忙总结文章知识点,也有不少同学提出了很多高质量的问题,更有一些同学帮忙解答其他同学提出的问题。

在浏览这些留言并回复的过程中,我倍受鼓舞,也尽我所知地帮助你解决问题、和你讨论。可以说,你们的留言活跃了整个专栏的氛围、提升了整个专栏的质量,谢谢你们。

评论区的大多数留言我都直接回复了,对于需要展开说明的问题,我都拿出小本子记了下来。这些被记下来的问题,就是我们今天这篇答疑文章的素材了。

到目前为止,我已经收集了 47 个问题,很难通过今天这一篇文章全部展开。所以,我就先从中找了几个联系非常紧密的问题,串了起来,希望可以帮你解决关于日志和索引的一些疑惑。而其他问题,我们就留着后面慢慢展开吧。

日志相关问题

我在第 2 篇文章[《日志系统:一条 SQL 更新语句是如何执行的?》]中,和你讲到 binlog(归档日志)和 redo log(重做日志)配合崩溃恢复的时候,用的是反证法,说明了如果没有两阶段提交,会导致 MySQL 出现主备数据不一致等问题。

在这篇文章下面,很多同学在问,在两阶段提交的不同瞬间,MySQL 如果发生异常重启,是怎么保证数据完整性的?

现在,我们就从这个问题开始吧。

我再放一次两阶段提交的图,方便你学习下面的内容。

img

图 1 两阶段提交示意图

这里,我要先和你解释一个误会式的问题。有同学在评论区问到,这个图不是一个 update 语句的执行流程吗,怎么还会调用 commit 语句?

他产生这个疑问的原因,是把两个“commit”的概念混淆了:

  • 他说的“commit 语句”,是指 MySQL 语法中,用于提交一个事务的命令。一般跟 begin/start transaction 配对使用。
  • 而我们图中用到的这个“commit 步骤”,指的是事务提交过程中的一个小步骤,也是最后一步。当这个步骤执行完成后,这个事务就提交完成了。
  • “commit 语句”执行的时候,会包含“commit 步骤”。

而我们这个例子里面,没有显式地开启事务,因此这个 update 语句自己就是一个事务,在执行完成后提交事务时,就会用到这个“commit 步骤“。

接下来,我们就一起分析一下在两阶段提交的不同时刻,MySQL 异常重启会出现什么现象。

如果在图中时刻 A 的地方,也就是写入 redo log 处于 prepare 阶段之后、写 binlog 之前,发生了崩溃(crash),由于此时 binlog 还没写,redo log 也还没提交,所以崩溃恢复的时候,这个事务会回滚。这时候,binlog 还没写,所以也不会传到备库。到这里,大家都可以理解。

大家出现问题的地方,主要集中在时刻 B,也就是 binlog 写完,redo log 还没 commit 前发生 crash,那崩溃恢复的时候 MySQL 会怎么处理?

我们先来看一下崩溃恢复时的判断规则。

  1. 如果 redo log 里面的事务是完整的,也就是已经有了 commit 标识,则直接提交;
  2. 如果 redo log 里面的事务只有完整的 prepare,则判断对应的事务 binlog 是否存在并完整: a. 如果是,则提交事务; b. 否则,回滚事务。

这里,时刻 B 发生 crash 对应的就是 2(a) 的情况,崩溃恢复过程中事务会被提交。

现在,我们继续延展一下这个问题。

追问 1:MySQL 怎么知道 binlog 是完整的?

回答:一个事务的 binlog 是有完整格式的:

  • statement 格式的 binlog,最后会有 COMMIT;
  • row 格式的 binlog,最后会有一个 XID event。

另外,在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用来验证 binlog 内容的正确性。对于 binlog 日志由于磁盘原因,可能会在日志中间出错的情况,MySQL 可以通过校验 checksum 的结果来发现。所以,MySQL 还是有办法验证事务 binlog 的完整性的。

追问 2:redo log 和 binlog 是怎么关联起来的?

回答:它们有一个共同的数据字段,叫 XID。崩溃恢复的时候,会按顺序扫描 redo log:

  • 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
  • 如果碰到只有 parepare、而没有 commit 的 redo log,就拿着 XID 去 binlog 找对应的事务。

追问 3:处于 prepare 阶段的 redo log 加上完整 binlog,重启就能恢复,MySQL 为什么要这么设计?

回答:其实,这个问题还是跟我们在反证法中说到的数据与备份的一致性有关。在时刻 B,也就是 binlog 写完以后 MySQL 发生崩溃,这时候 binlog 已经写入了,之后就会被从库(或者用这个 binlog 恢复出来的库)使用。

所以,在主库上也要提交这个事务。采用这个策略,主库和备库的数据就保证了一致性。

追问 4:如果这样的话,为什么还要两阶段提交呢?干脆先 redo log 写完,再写 binlog。崩溃恢复的时候,必须得两个日志都完整才可以。是不是一样的逻辑?

回答:其实,两阶段提交是经典的分布式系统问题,并不是 MySQL 独有的。

如果必须要举一个场景,来说明这么做的必要性的话,那就是事务的持久性问题。

对于 InnoDB 引擎来说,如果 redo log 提交完成了,事务就不能回滚(如果这还允许回滚,就可能覆盖掉别的事务的更新)。而如果 redo log 直接提交,然后 binlog 写入的时候失败,InnoDB 又回滚不了,数据和 binlog 日志又不一致了。

两阶段提交就是为了给所有人一个机会,当每个人都说“我 ok”的时候,再一起提交。

追问 5:不引入两个日志,也就没有两阶段提交的必要了。只用 binlog 来支持崩溃恢复,又能支持归档,不就可以了?

回答:这位同学的意思是,只保留 binlog,然后可以把提交流程改成这样:… -> “数据更新到内存” -> “写 binlog” -> “提交事务”,是不是也可以提供崩溃恢复的能力?

答案是不可以。

如果说历史原因的话,那就是 InnoDB 并不是 MySQL 的原生存储引擎。MySQL 的原生引擎是 MyISAM,设计之初就有没有支持崩溃恢复。

InnoDB 在作为 MySQL 的插件加入 MySQL 引擎家族之前,就已经是一个提供了崩溃恢复和事务支持的引擎了。

InnoDB 接入了 MySQL 后,发现既然 binlog 没有崩溃恢复的能力,那就用 InnoDB 原有的 redo log 好了。

而如果说实现上的原因的话,就有很多了。就按照问题中说的,只用 binlog 来实现崩溃恢复的流程,我画了一张示意图,这里就没有 redo log 了。

img

图 2 只用 binlog 支持崩溃恢复

这样的流程下,binlog 还是不能支持崩溃恢复的。我说一个不支持的点吧:binlog 没有能力恢复“数据页”。

如果在图中标的位置,也就是 binlog2 写完了,但是整个事务还没有 commit 的时候,MySQL 发生了 crash。

重启后,引擎内部事务 2 会回滚,然后应用 binlog2 可以补回来;但是对于事务 1 来说,系统已经认为提交完成了,不会再应用一次 binlog1。

但是,InnoDB 引擎使用的是 WAL 技术,执行事务的时候,写完内存和日志,事务就算完成了。如果之后崩溃,要依赖于日志来恢复数据页。

也就是说在图中这个位置发生崩溃的话,事务 1 也是可能丢失了的,而且是数据页级的丢失。此时,binlog 里面并没有记录数据页的更新细节,是补不回来的。

你如果要说,那我优化一下 binlog 的内容,让它来记录数据页的更改可以吗?但,这其实就是又做了一个 redo log 出来。

所以,至少现在的 binlog 能力,还不能支持崩溃恢复。

追问 6:那能不能反过来,只用 redo log,不要 binlog?

回答:如果只从崩溃恢复的角度来讲是可以的。你可以把 binlog 关掉,这样就没有两阶段提交了,但系统依然是 crash-safe 的。

但是,如果你了解一下业界各个公司的使用场景的话,就会发现在正式的生产库上,binlog 都是开着的。因为 binlog 有着 redo log 无法替代的功能。

一个是归档。redo log 是循环写,写到末尾是要回到开头继续写的。这样历史日志没法保留,redo log 也就起不到归档的作用。

一个就是 MySQL 系统依赖于 binlog。binlog 作为 MySQL 一开始就有的功能,被用在了很多地方。其中,MySQL 系统高可用的基础,就是 binlog 复制。

还有很多公司有异构系统(比如一些数据分析系统),这些系统就靠消费 MySQL 的 binlog 来更新自己的数据。关掉 binlog 的话,这些下游系统就没法输入了。

总之,由于现在包括 MySQL 高可用在内的很多系统机制都依赖于 binlog,所以“鸠占鹊巢”redo log 还做不到。你看,发展生态是多么重要。

追问 7:redo log 一般设置多大?

回答:redo log 太小的话,会导致很快就被写满,然后不得不强行刷 redo log,这样 WAL 机制的能力就发挥不出来了。

所以,如果是现在常见的几个 TB 的磁盘的话,就不要太小气了,直接将 redo log 设置为 4 个文件、每个文件 1GB 吧。

追问 8:正常运行中的实例,数据写入后的最终落盘,是从 redo log 更新过来的还是从 buffer pool 更新过来的呢?

回答:这个问题其实问得非常好。这里涉及到了,“redo log 里面到底是什么”的问题。

实际上,redo log 并没有记录数据页的完整数据,所以它并没有能力自己去更新磁盘数据页,也就不存在“数据最终落盘,是由 redo log 更新过去”的情况。

  1. 如果是正常运行的实例的话,数据页被修改以后,跟磁盘的数据页不一致,称为脏页。最终数据落盘,就是把内存中的数据页写盘。这个过程,甚至与 redo log 毫无关系。
  2. 在崩溃恢复场景中,InnoDB 如果判断到一个数据页可能在崩溃恢复的时候丢失了更新,就会将它读到内存,然后让 redo log 更新内存内容。更新完成后,内存页变成脏页,就回到了第一种情况的状态。

追问 9:redo log buffer 是什么?是先修改内存,还是先写 redo log 文件?

回答:这两个问题可以一起回答。

在一个事务的更新过程中,日志是要写多次的。比如下面这个事务:

begin;
insert into t1 ...
insert into t2 ...
commit;

这个事务要往两个表中插入记录,插入数据的过程中,生成的日志都得先保存起来,但又不能在还没 commit 的时候就直接写到 redo log 文件里。

所以,redo log buffer 就是一块内存,用来先存 redo 日志的。也就是说,在执行第一个 insert 的时候,数据的内存被修改了,redo log buffer 也写入了日志。

但是,真正把日志写到 redo log 文件(文件名是 ib_logfile+ 数字),是在执行 commit 语句的时候做的。

(这里说的是事务执行过程中不会“主动去刷盘”,以减少不必要的 IO 消耗。但是可能会出现“被动写入磁盘”,比如内存不够、其他事务提交等情况。这个问题我们会在后面第 22 篇文章《MySQL 有哪些“饮鸩止渴”的提高性能的方法?》中再详细展开)。

单独执行一个更新语句的时候,InnoDB 会自己启动一个事务,在语句执行完成的时候提交。过程跟上面是一样的,只不过是“压缩”到了一个语句里面完成。

以上这些问题,就是把大家提过的关于 redo log 和 binlog 的问题串起来,做的一次集中回答。如果你还有问题,可以在评论区继续留言补充。

业务设计问题

接下来,我再和你分享 @ithunter 同学在第 8 篇文章[《事务到底是隔离的还是不隔离的?》]的评论区提到的跟索引相关的一个问题。我觉得这个问题挺有趣、也挺实用的,其他同学也可能会碰上这样的场景,在这里解答和分享一下。

问题是这样的(我文字上稍微做了点修改,方便大家理解):

业务上有这样的需求,A、B 两个用户,如果互相关注,则成为好友。设计上是有两张表,一个是 like 表,一个是 friend 表,like 表有 user_id、liker_id 两个字段,我设置为复合唯一索引即 uk_user_id_liker_id。语句执行逻辑是这样的:

以 A 关注 B 为例: 第一步,先查询对方有没有关注自己(B 有没有关注 A) select * from like where user_id = B and liker_id = A;

如果有,则成为好友 insert into friend;

没有,则只是单向关注关系 insert into like;

但是如果 A、B 同时关注对方,会出现不会成为好友的情况。因为上面第 1 步,双方都没关注对方。第 1 步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。请问这种情况,在 MySQL 锁层面有没有办法处理?

首先,我要先赞一下这样的提问方式。虽然极客时间现在的评论区还不能追加评论,但如果大家能够一次留言就把问题讲清楚的话,其实影响也不大。所以,我希望你在留言提问的时候,也能借鉴这种方式。

接下来,我把 @ithunter 同学说的表模拟出来,方便我们讨论。

CREATE TABLE `like` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `liker_id` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_id_liker_id` (`user_id`,`liker_id`)
) ENGINE=InnoDB; 
CREATE TABLE `friend` (
  id` int(11) NOT NULL AUTO_INCREMENT,
  `friend_1_id` int(11) NOT NULL,
  `firned_2_id` int(11) NOT NULL,
  UNIQUE KEY `uk_friend` (`friend_1_id`,`firned_2_id`)
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

虽然这个题干中,并没有说到 friend 表的索引结构。但我猜测 friend_1_id 和 friend_2_id 也有索引,为便于描述,我给加上唯一索引。

顺便说明一下,“like”是关键字,我一般不建议使用关键字作为库名、表名、字段名或索引名。

我把他的疑问翻译一下,在并发场景下,同时有两个人,设置为关注对方,就可能导致无法成功加为朋友关系。

现在,我用你已经熟悉的时刻顺序表的形式,把这两个事务的执行语句列出来:img

图 3 并发“喜欢”逻辑操作顺序

由于一开始 A 和 B 之间没有关注关系,所以两个事务里面的 select 语句查出来的结果都是空。

因此,session 1 的逻辑就是“既然 B 没有关注 A,那就只插入一个单向关注关系”。session 2 也同样是这个逻辑。

这个结果对业务来说就是 bug 了。因为在业务设定里面,这两个逻辑都执行完成以后,是应该在 friend 表里面插入一行记录的。

如提问里面说的,“第 1 步即使使用了排他锁也不行,因为记录不存在,行锁无法生效”。不过,我想到了另外一个方法,来解决这个问题。

首先,要给“like”表增加一个字段,比如叫作 relation_ship,并设为整型,取值 1、2、3。

值是 1 的时候,表示 user_id 关注 liker_id; 值是 2 的时候,表示 liker_id 关注 user_id; 值是 3 的时候,表示互相关注。

然后,当 A 关注 B 的时候,逻辑改成如下所示的样子:

应用代码里面,比较 A 和 B 的大小,如果 A,就执行下面的逻辑

mysql> begin; /* 启动事务 */
insert into `like`(user_id, liker_id, relation_ship) values(A, B, 1) on duplicate key update relation_ship=relation_ship | 1;
select relation_ship from `like` where user_id=A and liker_id=B;
/* 代码中判断返回的 relation_ship,
  如果是 1,事务结束,执行 commit
  如果是 3,则执行下面这两个语句:
  */
insert ignore into friend(friend_1_id, friend_2_id) values(A,B);
commit;

如果 A>B,则执行下面的逻辑

mysql> begin; /* 启动事务 */
insert into `like`(user_id, liker_id, relation_ship) values(B, A, 2) on duplicate key update relation_ship=relation_ship | 2;
select relation_ship from `like` where user_id=B and liker_id=A;
/* 代码中判断返回的 relation_ship,
  如果是 2,事务结束,执行 commit
  如果是 3,则执行下面这两个语句:
*/
insert ignore into friend(friend_1_id, friend_2_id) values(B,A);
commit;

这个设计里,让“like”表里的数据保证 user_id < liker_id,这样不论是 A 关注 B,还是 B 关注 A,在操作“like”表的时候,如果反向的关系已经存在,就会出现行锁冲突。

然后,insert … on duplicate 语句,确保了在事务内部,执行了这个 SQL 语句后,就强行占住了这个行锁,之后的 select 判断 relation_ship 这个逻辑时就确保了是在行锁保护下的读操作。

操作符 “|” 是按位或,连同最后一句 insert 语句里的 ignore,是为了保证重复调用时的幂等性。

这样,即使在双方“同时”执行关注操作,最终数据库里的结果,也是 like 表里面有一条关于 A 和 B 的记录,而且 relation_ship 的值是 3, 并且 friend 表里面也有了 A 和 B 的这条记录。

不知道你会不会吐槽:之前明明还说尽量不要使用唯一索引,结果这个例子一上来我就创建了两个。这里我要再和你说明一下,之前文章我们讨论的,是在“业务开发保证不会插入重复记录”的情况下,着重要解决性能问题的时候,才建议尽量使用普通索引。

而像这个例子里,按照这个设计,业务根本就是保证“我一定会插入重复数据,数据库一定要要有唯一性约束”,这时就没啥好说的了,唯一索引建起来吧。

小结

这是专栏的第一篇答疑文章。

我针对前 14 篇文章,大家在评论区中的留言,从中摘取了关于日志和索引的相关问题,串成了今天这篇文章。这里我也要再和你说一声,有些我答应在答疑文章中进行扩展的话题,今天这篇文章没来得及扩展,后续我会再找机会为你解答。所以,篇幅所限,评论区见吧。

最后,虽然这篇是答疑文章,但课后问题还是要有的。

我们创建了一个简单的表 t,并插入一行,然后对这一行做修改。

mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL primary key auto_increment,
`a` int(11) DEFAULT NULL
) ENGINE=InnoDB;
insert into t values(1,2);

这时候,表 t 里有唯一的一行数据 (1,2)。假设,我现在要执行:

mysql> update t set a=2 where id=1;

你会看到这样的结果:

img结果显示,匹配 (rows matched) 了一行,修改 (Changed) 了 0 行。

仅从现象上看,MySQL 内部在处理这个命令的时候,可以有以下三种选择:

  1. 更新都是先读后写的,MySQL 读出数据,发现 a 的值本来就是 2,不更新,直接返回,执行结束;
  2. MySQL 调用了 InnoDB 引擎提供的“修改为 (1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回;
  3. InnoDB 认真执行了“把这个值修改成 (1,2)“这个操作,该加锁的加锁,该更新的更新。

你觉得实际情况会是以上哪种呢?你可否用构造实验的方式,来证明你的结论?进一步地,可以思考一下,MySQL 为什么要选择这种策略呢?

你可以把你的验证方法和思考写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,用一个计数表记录一个业务表的总行数,在往业务表插入数据的时候,需要给计数值加 1。

逻辑实现上是启动一个事务,执行两个语句:

  1. insert into 数据表;
  2. update 计数表,计数值加 1。

从系统并发能力的角度考虑,怎么安排这两个语句的顺序。

这里,我直接复制 @阿建 的回答过来供你参考:

并发系统性能的角度考虑,应该先插入操作记录,再更新计数表。 知识点在[《行锁功过:怎么减少行锁对性能的影响?》] 因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少事务之间的锁等待,提升并发度。

评论区有同学说,应该把 update 计数表放后面,因为这个计数表可能保存了多个业务表的计数值。如果把 update 计数表放到事务的第一个语句,多个业务表同时插入数据的话,等待时间会更长。

这个答案的结论是对的,但是理解不太正确。即使我们用一个计数表记录多个业务表的行数,也肯定会给表名字段加唯一索引。类似于下面这样的表结构:

CREATE TABLE `rows_stat` (
  `table_name` varchar(64) NOT NULL,
  `row_count` int(10) unsigned NOT NULL,
  PRIMARY KEY (`table_name`)
) ENGINE=InnoDB;

在更新计数表的时候,一定会传入 where table_name=$table_name,使用主键索引,更新加行锁只会锁在一行上。

而在不同业务表插入数据,是更新不同的行,不会有行锁。

评论区留言点赞板:

@北天魔狼、@斜面镜子 Bil 和 @Bin 等同学,都给出了正确答案; @果然如此 同学提了一个好问题,虽然引入事务,避免看到”业务上还没提交的更新”,但是 Redis 的计数被提前看到了。核心原因还是两个系统,不支持一致性视图; @ 帆帆帆帆帆帆帆帆 同学的问题提醒了大家,count(id) 也是可以走普通索引得到的。

16 “order by”是怎么工作的?

在你开发应用的时候,一定会经常碰到需要根据指定的字段排序来显示结果的需求。还是以我们前面举例用过的市民表为例,假设你要查询城市是“杭州”的所有人名字,并且按照姓名排序返回前 1000 个人的姓名、年龄。

假设这个表的部分定义是这样的:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `city` varchar(16) NOT NULL,
  `name` varchar(16) NOT NULL,
  `age` int(11) NOT NULL,
  `addr` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `city` (`city`)
) ENGINE=InnoDB;

这时,你的 SQL 语句可以这么写:

select city,name,age from t where city='杭州' order by name limit 1000  ;

这个语句看上去逻辑很清晰,但是你了解它的执行流程吗?今天,我就和你聊聊这个语句是怎么执行的,以及有什么参数会影响执行的行为。

全字段排序

前面我们介绍过索引,所以你现在就很清楚了,为避免全表扫描,我们需要在 city 字段加上索引。

在 city 字段上创建索引之后,我们用 explain 命令来看看这个语句的执行情况。

img

图 1 使用 explain 命令查看语句的执行情况

Extra 这个字段中的“Using filesort”表示的就是需要排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer。

为了说明这个 SQL 查询语句的执行过程,我们先来看一下 city 这个索引的示意图。

img

图 2 city 字段的索引示意图

从图中可以看到,满足 city=‘杭州’条件的行,是从 IDX 到 ID(X+N) 的这些记录。

通常情况下,这个语句执行流程如下所示 :

  1. 初始化 sort_buffer,确定放入 name、city、age 这三个字段;
  2. 从索引 city 找到第一个满足 city=‘杭州’条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到 city 的值不满足查询条件为止,对应的主键 id 也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 做快速排序;
  7. 按照排序结果取前 1000 行返回给客户端。

我们暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还会用到这个排序。

img

图 3 全字段排序

图中“按 name 排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需的内存和参数 sort_buffer_size。

sort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

你可以用下面介绍的方法,来确定一个排序语句是否使用了临时文件。

/* 打开 optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';  
/* @a 保存 Innodb_rows_read 的初始值 */
select VARIABLE_VALUE into @a from  performance_schema.session_status where variable_name = 'Innodb_rows_read'; 
/* 执行语句 */
select city, name,age from t where city='杭州' order by name limit 1000;  
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G 
/* @b 保存 Innodb_rows_read 的当前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read'; 
/* 计算 Innodb_rows_read 差值 */
select @b-@a;

这个方法是通过查看 OPTIMIZER_TRACE 的结果来确认的,你可以从 number_of_tmp_files 中看到是否使用了临时文件。

img

图 4 全排序的 OPTIMIZER_TRACE 部分结果

number_of_tmp_files 表示的是,排序过程中使用的临时文件数。你一定奇怪,为什么需要 12 个文件?内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,MySQL 将需要排序的数据分成 12 份,每一份单独排序后存在这些临时文件中。然后把这 12 个有序文件再合并成一个有序的大文件。

如果 sort_buffer_size 超过了需要排序的数据量的大小,number_of_tmp_files 就是 0,表示排序可以直接在内存中完成。

否则就需要放在临时文件中排序。sort_buffer_size 越小,需要分成的份数越多,number_of_tmp_files 的值就越大。

接下来,我再和你解释一下图 4 中其他两个值的意思。

我们的示例表中有 4000 条满足 city=‘杭州’的记录,所以你可以看到 examined_rows=4000,表示参与排序的行数是 4000 行。

sort_mode 里面的 packed_additional_fields 的意思是,排序过程对字符串做了“紧凑”处理。即使 name 字段的定义是 varchar(16),在排序过程中还是要按照实际长度来分配空间的。

同时,最后一个查询语句 select @b-@a 的返回结果是 4000,表示整个执行过程只扫描了 4000 行。

这里需要注意的是,为了避免对结论造成干扰,我把 internal_tmp_disk_storage_engine 设置成 MyISAM。否则,select @b-@a 的结果会显示为 4001。

这是因为查询 OPTIMIZER_TRACE 这个表时,需要用到临时表,而 internal_tmp_disk_storage_engine 的默认值是 InnoDB。如果使用的是 InnoDB 引擎的话,把数据从临时表取出来的时候,会让 Innodb_rows_read 的值加 1。

rowid 排序

在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在 sort_buffer 和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么 sort_buffer 里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。

所以如果单行很大,这个方法效率不够好。

那么,如果 MySQL 认为排序的单行长度太大会怎么做呢?

接下来,我来修改一个参数,让 MySQL 采用另外一种算法。

SET max_length_for_sort_data = 16;

max_length_for_sort_data,是 MySQL 中专门控制用于排序的行数据的长度的一个参数。它的意思是,如果单行的长度超过这个值,MySQL 就认为单行太大,要换一个算法。

city、name、age 这三个字段的定义总长度是 36,我把 max_length_for_sort_data 设置为 16,我们再来看看计算过程有什么改变。

新的算法放入 sort_buffer 的字段,只有要排序的列(即 name 字段)和主键 id。

但这时,排序的结果就因为少了 city 和 age 字段的值,不能直接返回了,整个执行流程就变成如下所示的样子:

  1. 初始化 sort_buffer,确定放入两个字段,即 name 和 id;
  2. 从索引 city 找到第一个满足 city=‘杭州’条件的主键 id,也就是图中的 ID_X;
  3. 到主键 id 索引取出整行,取 name、id 这两个字段,存入 sort_buffer 中;
  4. 从索引 city 取下一个记录的主键 id;
  5. 重复步骤 3、4 直到不满足 city=‘杭州’条件为止,也就是图中的 ID_Y;
  6. 对 sort_buffer 中的数据按照字段 name 进行排序;
  7. 遍历排序结果,取前 1000 行,并按照 id 的值回到原表中取出 city、name 和 age 三个字段返回给客户端。

这个执行流程的示意图如下,我把它称为 rowid 排序。

img

图 5 rowid 排序

对比图 3 的全字段排序流程图你会发现,rowid 排序多访问了一次表 t 的主键索引,就是步骤 7。

需要说明的是,最后的“结果集”是一个逻辑概念,实际上 MySQL 服务端从排序后的 sort_buffer 中依次取出 id,然后到原表查到 city、name 和 age 这三个字段的结果,不需要在服务端再耗费内存存储结果,是直接返回给客户端的。

根据这个说明过程和图示,你可以想一下,这个时候执行 select @b-@a,结果会是多少呢?

现在,我们就来看看结果有什么不同。

首先,图中的 examined_rows 的值还是 4000,表示用于排序的数据是 4000 行。但是 select @b-@a 这个语句的值变成 5000 了。

因为这时候除了排序过程外,在排序完成后,还要根据 id 去原表取值。由于语句是 limit 1000,因此会多读 1000 行。

img

图 6 rowid 排序的 OPTIMIZER_TRACE 部分输出

从 OPTIMIZER_TRACE 的结果中,你还能看到另外两个信息也变了。

  • sort_mode 变成了 ,表示参与排序的只有 name 和 id 这两个字段。
  • number_of_tmp_files 变成 10 了,是因为这时候参与排序的行数虽然仍然是 4000 行,但是每一行都变小了,因此需要排序的总数据量就变小了,需要的临时文件也相应地变少了。

全字段排序 VS rowid 排序

我们来分析一下,从这两个执行流程里,还能得出什么结论。

如果 MySQL 实在是担心排序内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。

如果 MySQL 认为内存足够大,会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。

这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问。

对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读,因此不会被优先选择。

这个结论看上去有点废话的感觉,但是你要记住它,下一篇文章我们就会用到。

看到这里,你就了解了,MySQL 做排序是一个成本比较高的操作。那么你会问,是不是所有的 order by 都需要排序操作呢?如果不排序就能得到正确的结果,那对系统的消耗会小很多,语句的执行时间也会变得更短。

其实,并不是所有的 order by 语句,都需要排序操作的。从上面分析的执行过程,我们可以看到,MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。

你可以设想下,如果能够保证从 city 这个索引上取出来的行,天然就是按照 name 递增排序的话,是不是就可以不用再排序了呢?

确实是这样的。

所以,我们可以在这个市民表上创建一个 city 和 name 的联合索引,对应的 SQL 语句是:

alter table t add index city_user(city, name);

作为与 city 索引的对比,我们来看看这个索引的示意图。

img

图 7 city 和 name 联合索引示意图

在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足 city=‘杭州’的记录,并且额外确保了,接下来按顺序取“下一条记录”的遍历过程中,只要 city 的值是杭州,name 的值就一定是有序的。

这样整个查询过程的流程就变成了:

  1. 从索引 (city,name) 找到第一个满足 city=‘杭州’条件的主键 id;
  2. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,作为结果集的一部分直接返回;
  3. 从索引 (city,name) 取下一个记录主键 id;
  4. 重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city=‘杭州’条件时循环结束。

img

图 8 引入 (city,name) 联合索引后,查询语句的执行计划

可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用 explain 的结果来印证一下。

img

图 9 引入 (city,name) 联合索引后,查询语句的执行计划

从图中可以看到,Extra 字段中没有 Using filesort 了,也就是不需要排序了。而且由于 (city,name) 这个联合索引本身有序,所以这个查询也不用把 4000 行全都读一遍,只要找到满足条件的前 1000 条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描 1000 次。

既然说到这里了,我们再往前讨论,这个语句的执行流程有没有可能进一步简化呢?不知道你还记不记得,我在第 5 篇文章[《 深入浅出索引(下)》]中,和你介绍的覆盖索引。

这里我们可以再稍微复习一下。覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。

按照覆盖索引的概念,我们可以再优化一下这个查询语句的执行流程。

针对这个查询,我们可以创建一个 city、name 和 age 的联合索引,对应的 SQL 语句就是:

alter table t add index city_user_age(city, name, age);

这时,对于 city 字段的值相同的行来说,还是按照 name 字段的值递增排序的,此时的查询语句也就不再需要排序了。这样整个查询语句的执行流程就变成了:

  1. 从索引 (city,name,age) 找到第一个满足 city=‘杭州’条件的记录,取出其中的 city、name 和 age 这三个字段的值,作为结果集的一部分直接返回;
  2. 从索引 (city,name,age) 取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
  3. 重复执行步骤 2,直到查到第 1000 条记录,或者是不满足 city=‘杭州’条件时循环结束。

img

图 10 引入 (city,name,age) 联合索引后,查询语句的执行流程

然后,我们再来看看 explain 的结果。

img

图 11 引入 (city,name,age) 联合索引后,查询语句的执行计划

可以看到,Extra 字段里面多了“Using index”,表示的就是使用了覆盖索引,性能上会快很多。

当然,这里并不是说要为了每个查询能用上覆盖索引,就要把语句中涉及的字段都建上联合索引,毕竟索引还是有维护代价的。这是一个需要权衡的决定。

小结

今天这篇文章,我和你介绍了 MySQL 里面 order by 语句的几种算法流程。

在开发系统的时候,你总是不可避免地会使用到 order by 语句。你心里要清楚每个语句的排序逻辑是怎么实现的,还要能够分析出在最坏情况下,每个语句的执行对系统资源的消耗,这样才能做到下笔如有神,不犯低级错误。

最后,我给你留下一个思考题吧。

假设你的表里面已经有了 city_name(city, name) 这个联合索引,然后你要查杭州和苏州两个城市中所有的市民的姓名,并且按名字排序,显示前 100 条记录。如果 SQL 查询语句是这么写的 :

mysql> select * from t where city in ('杭州'," 苏州 ") order by name limit 100;

那么,这个语句执行的时候会有排序过程吗,为什么?

如果业务端代码由你来开发,需要实现一个在数据库端不需要排序的方案,你会怎么实现呢?

进一步地,如果有分页需求,要显示第 101 页,也就是说语句最后要改成 “limit 10000,100”, 你的实现方法又会是什么呢?

你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,当 MySQL 去更新一行,但是要修改的值跟原来的值是相同的,这时候 MySQL 会真的去执行一次修改吗?还是看到值相同就直接返回呢?

这是第一次我们课后问题的三个选项都有同学选的,所以我要和你需要详细说明一下。

第一个选项是,MySQL 读出数据,发现值与原来相同,不更新,直接返回,执行结束。这里我们可以用一个锁实验来确认。

假设,当前表 t 里的值是 (1,2)。

img

图 12 锁验证方式

session B 的 update 语句被 blocked 了,加锁这个动作是 InnoDB 才能做的,所以排除选项 1。

第二个选项是,MySQL 调用了 InnoDB 引擎提供的接口,但是引擎发现值与原来相同,不更新,直接返回。有没有这种可能呢?这里我用一个可见性实验来确认。

假设当前表里的值是 (1,2)。

img

图 13 可见性验证方式

session A 的第二个 select 语句是一致性读(快照读),它是不能看见 session B 的更新的。

现在它返回的是 (1,3),表示它看见了某个新的版本,这个版本只能是 session A 自己的 update 语句做更新的时候生成。(如果你对这个逻辑有疑惑的话,可以回顾下第 8 篇文章[《事务到底是隔离的还是不隔离的?》]中的相关内容)

所以,我们上期思考题的答案应该是选项 3,即:InnoDB 认真执行了“把这个值修改成 (1,2)“这个操作,该加锁的加锁,该更新的更新。

然后你会说,MySQL 怎么这么笨,就不会更新前判断一下值是不是相同吗?如果判断一下,不就不用浪费 InnoDB 操作,多去更新一次了?

其实 MySQL 是确认了的。只是在这个语句里面,MySQL 认为读出来的值,只有一个确定的 (id=1), 而要写的是 (a=3),只从这两个信息是看不出来“不需要修改”的。

作为验证,你可以看一下下面这个例子。

img

图 14 可见性验证方式 – 对照

补充说明:

上面我们的验证结果都是在 binlog_format=statement 格式下进行的。

@didiren 补充了一个 case, 如果是 binlog_format=row 并且 binlog_row_image=FULL 的时候,由于 MySQL 需要在 binlog 里面记录所有的字段,所以在读数据的时候就会把所有数据都读出来了。

根据上面说的规则,“既然读了数据,就会判断”, 因此在这时候,select * from t where id=1,结果就是“返回 (1,2)”。

同理,如果是 binlog_row_image=NOBLOB, 会读出除 blob 外的所有字段,在我们这个例子里,结果还是“返回 (1,2)”。

对应的代码如图 15 所示。这是 MySQL 5.6 版本引入的,在此之前我没有看过。所以,特此说明。

img

图 15 binlog_row_image=FULL 读字段逻辑

类似的,@mahonebags 同学提到了 timestamp 字段的问题。结论是:如果表中有 timestamp 字段而且设置了自动更新的话,那么更新“别的字段”的时候,MySQL 会读入所有涉及的字段,这样通过判断,就会发现不需要修改。

这两个点我会在后面讲更新性能的文章中再展开。

评论区留言点赞板:

@Gavin 、@melon、@阿建 等同学提到了锁验证法; @郭江伟 同学提到了两个点,都非常好,有去实际验证。结论是这样的: 第一,hexdump 看出来没改应该是 WAL 机制生效了,要过一会儿,或者把库 shutdown 看看。 第二,binlog 没写是 MySQL Server 层知道行的值没变,所以故意不写的,这个是在 row 格式下的策略。你可以把 binlog_format 改成 statement 再验证下。

17 如何正确地显示随机消息?

我在上一篇文章,为你讲解完 order by 语句的几种执行模式后,就想到了之前一个做英语学习 App 的朋友碰到过的一个性能问题。今天这篇文章,我就从这个性能问题说起,和你说说 MySQL 中的另外一种排序需求,希望能够加深你对 MySQL 排序逻辑的理解。

这个英语学习 App 首页有一个随机显示单词的功能,也就是根据每个用户的级别有一个单词表,然后这个用户每次访问首页的时候,都会随机滚动显示三个单词。他们发现随着单词表变大,选单词这个逻辑变得越来越慢,甚至影响到了首页的打开速度。

现在,如果让你来设计这个 SQL 语句,你会怎么写呢?

为了便于理解,我对这个例子进行了简化:去掉每个级别的用户都有一个对应的单词表这个逻辑,直接就是从一个单词表中随机选出三个单词。这个表的建表语句和初始数据的命令如下:

mysql> CREATE TABLE `words` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `word` varchar(64) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=0;
  while i<10000 do
    insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
    set i=i+1;
  end while;
end;;
delimiter ; 
call idata();

为了便于量化说明,我在这个表里面插入了 10000 行记录。接下来,我们就一起看看要随机选择 3 个单词,有什么方法实现,存在什么问题以及如何改进。

内存临时表

首先,你会想到用 order by rand() 来实现这个逻辑。

mysql> select word from words order by rand() limit 3;

这个语句的意思很直白,随机排序取前 3 个。虽然这个 SQL 语句写法很简单,但执行流程却有点复杂的。

我们先用 explain 命令来看看这个语句的执行情况。

img

图 1 使用 explain 命令查看语句的执行情况

Extra 字段显示 Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。

因此这个 Extra 的意思就是,需要临时表,并且需要在临时表上排序。

这里,你可以先回顾一下[上一篇文章]中全字段排序和 rowid 排序的内容。我把上一篇文章的两个流程图贴过来,方便你复习。

img

图 2 全字段排序

img

图 3 rowid 排序

然后,我再问你一个问题,你觉得对于临时内存表的排序来说,它会选择哪一种算法呢?回顾一下上一篇文章的一个结论:对于 InnoDB 表来说,执行全字段排序会减少磁盘访问,因此会被优先选择。

我强调了“InnoDB 表”,你肯定想到了,对于内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,根本不会导致多访问磁盘。优化器没有了这一层顾虑,那么它会优先考虑的,就是用于排序的行越少越好了,所以,MySQL 这时就会选择 rowid 排序。

理解了这个算法选择的逻辑,我们再来看看语句的执行流程。同时,通过今天的这个例子,我们来尝试分析一下语句的扫描行数。

这条语句的执行流程是这样的:

  1. 创建一个临时表。这个临时表使用的是 memory 引擎,表里有两个字段,第一个字段是 double 类型,为了后面描述方便,记为字段 R,第二个字段是 varchar(64) 类型,记为字段 W。并且,这个表没有建索引。
  2. 从 words 表中,按主键顺序取出所有的 word 值。对于每一个 word 值,调用 rand() 函数生成一个大于 0 小于 1 的随机小数,并把这个随机小数和 word 分别存入临时表的 R 和 W 字段中,到此,扫描行数是 10000。
  3. 现在临时表有 10000 行数据了,接下来你要在这个没有索引的内存临时表上,按照字段 R 排序。
  4. 初始化 sort_buffer。sort_buffer 中有两个字段,一个是 double 类型,另一个是整型。
  5. 从内存临时表中一行一行地取出 R 值和位置信息(我后面会和你解释这里为什么是“位置信息”),分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000。
  6. 在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。
  7. 排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出 word 值,返回给客户端。这个过程中,访问了表的三行数据,总扫描行数变成了 20003。

接下来,我们通过慢查询日志(slow log)来验证一下我们分析得到的扫描行数是否正确。

## Query_time: 0.900376  Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003
SET timestamp=1541402277;
select word from words order by rand() limit 3;

其中,Rows_examined:20003 就表示这个语句执行过程中扫描了 20003 行,也就验证了我们分析得出的结论。

这里插一句题外话,在平时学习概念的过程中,你可以经常这样做,先通过原理分析算出扫描行数,然后再通过查看慢查询日志,来验证自己的结论。我自己就是经常这么做,这个过程很有趣,分析对了开心,分析错了但是弄清楚了也很开心。

现在,我来把完整的排序执行流程图画出来。

img

图 4 随机排序完整流程图 1

图中的 pos 就是位置信息,你可能会觉得奇怪,这里的“位置信息”是个什么概念?在上一篇文章中,我们对 InnoDB 表排序的时候,明明用的还是 ID 字段。

这时候,我们就要回到一个基本概念:MySQL 的表是用什么方法来定位“一行数据”的。

在前面[第 4]和[第 5]篇介绍索引的文章中,有几位同学问到,如果把一个 InnoDB 表的主键删掉,是不是就没有主键,就没办法回表了?

其实不是的。如果你创建的表没有主键,或者把一个表的主键删掉了,那么 InnoDB 会自己生成一个长度为 6 字节的 rowid 来作为主键。

这也就是排序模式里面,rowid 名字的来历。实际上它表示的是:每个引擎用来唯一标识数据行的信息。

  • 对于有主键的 InnoDB 表来说,这个 rowid 就是主键 ID;
  • 对于没有主键的 InnoDB 表来说,这个 rowid 就是由系统生成的;
  • MEMORY 引擎不是索引组织表。在这个例子里面,你可以认为它就是一个数组。因此,这个 rowid 其实就是数组的下标。

到这里,我来稍微小结一下:order by rand() 使用了内存临时表,内存临时表排序的时候使用了 rowid 排序方法。

磁盘临时表

那么,是不是所有的临时表都是内存表呢?

其实不是的。tmp_table_size 这个配置限制了内存临时表的大小,默认值是 16M。如果临时表大小超过了 tmp_table_size,那么内存临时表就会转成磁盘临时表。

磁盘临时表使用的引擎默认是 InnoDB,是由参数 internal_tmp_disk_storage_engine 控制的。

当使用磁盘临时表的时候,对应的就是一个没有显式索引的 InnoDB 表的排序过程。

为了复现这个过程,我把 tmp_table_size 设置成 1024,把 sort_buffer_size 设置成 32768, 把 max_length_for_sort_data 设置成 16。

set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打开 optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on';  
/* 执行语句 */
select word from words order by rand() limit 3; 
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G

img

图 5 OPTIMIZER_TRACE 部分结果

然后,我们来看一下这次 OPTIMIZER_TRACE 的结果。

因为将 max_length_for_sort_data 设置成 16,小于 word 字段的长度定义,所以我们看到 sort_mode 里面显示的是 rowid 排序,这个是符合预期的,参与排序的是随机值 R 字段和 rowid 字段组成的行。

这时候你可能心算了一下,发现不对。R 字段存放的随机值就 8 个字节,rowid 是 6 个字节(至于为什么是 6 字节,就留给你课后思考吧),数据总行数是 10000,这样算出来就有 140000 字节,超过了 sort_buffer_size 定义的 32768 字节了。但是,number_of_tmp_files 的值居然是 0,难道不需要用临时文件吗?

这个 SQL 语句的排序确实没有用到临时文件,采用是 MySQL 5.6 版本引入的一个新的排序算法,即:优先队列排序算法。接下来,我们就看看为什么没有使用临时文件的算法,也就是归并排序算法,而是采用了优先队列排序算法。

其实,我们现在的 SQL 语句,只需要取 R 值最小的 3 个 rowid。但是,如果使用归并排序算法的话,虽然最终也能得到前 3 个值,但是这个算法结束后,已经将 10000 行数据都排好序了。

也就是说,后面的 9997 行也是有序的了。但,我们的查询并不需要这些数据是有序的。所以,想一下就明白了,这浪费了非常多的计算量。

而优先队列算法,就可以精确地只得到三个最小值,执行流程如下:

  1. 对于这 10000 个准备排序的 (R,rowid),先取前三行,构造成一个堆;

(对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组)

  1. 取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);
  2. 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。

这里我简单画了一个优先队列排序过程的示意图。

img

图 6 优先队列排序算法示例

图 6 是模拟 6 个 (R,rowid) 行,通过优先队列排序找到最小的三个 R 值的行的过程。整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。

图 5 的 OPTIMIZER_TRACE 结果中,filesort_priority_queue_optimization 这个部分的 chosen=true,就表示使用了优先队列排序算法,这个过程不需要临时文件,因此对应的 number_of_tmp_files 是 0。

这个流程结束后,我们构造的堆里面,就是这个 10000 行里面 R 值最小的三行。然后,依次把它们的 rowid 取出来,去临时表里面拿到 word 字段,这个过程就跟上一篇文章的 rowid 排序的过程一样了。

我们再看一下上面一篇文章的 SQL 查询语句:

select city,name,age from t where city='杭州' order by name limit 1000  ;

你可能会问,这里也用到了 limit,为什么没用优先队列排序算法呢?原因是,这条 SQL 语句是 limit 1000,如果使用优先队列算法的话,需要维护的堆的大小就是 1000 行的 (name,rowid),超过了我设置的 sort_buffer_size 大小,所以只能使用归并排序算法。

总之,不论是使用哪种类型的临时表,order by rand() 这种写法都会让计算过程非常复杂,需要大量的扫描行数,因此排序过程的资源消耗也会很大。

再回到我们文章开头的问题,怎么正确地随机排序呢?

随机排序方法

我们先把问题简化一下,如果只随机选择 1 个 word 值,可以怎么做呢?思路上是这样的:

  1. 取得这个表的主键 id 的最大值 M 和最小值 N;
  2. 用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
  3. 取不小于 X 的第一个 ID 的行。

我们把这个算法,暂时称作随机算法 1。这里,我直接给你贴一下执行语句的序列:

mysql> select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;

这个方法效率很高,因为取 max(id) 和 min(id) 都是不需要扫描索引的,而第三步的 select 也可以用索引快速定位,可以认为就只扫描了 3 行。但实际上,这个算法本身并不严格满足题目的随机要求,因为 ID 中间可能有空洞,因此选择不同行的概率不一样,不是真正的随机。

比如你有 4 个 id,分别是 1、2、4、5,如果按照上面的方法,那么取到 id=4 的这一行的概率是取得其他行概率的两倍。

如果这四行的 id 分别是 1、2、40000、40001 呢?这个算法基本就能当 bug 来看待了。

所以,为了得到严格随机的结果,你可以用下面这个流程:

  1. 取得整个表的行数,并记为 C。
  2. 取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
  3. 再用 limit Y,1 取得一行。

我们把这个算法,称为随机算法 2。下面这段代码,就是上面流程的执行语句的序列。

mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;

由于 limit 后面的参数不能直接跟变量,所以我在上面的代码中使用了 prepare+execute 的方法。你也可以把拼接 SQL 语句的方法写在应用程序中,会更简单些。

这个随机算法 2,解决了算法 1 里面明显的概率不均匀问题。

MySQL 处理 limit Y,1 的做法就是按顺序一个一个地读出来,丢掉前 Y 个,然后把下一个记录作为返回结果,因此这一步需要扫描 Y+1 行。再加上,第一步扫描的 C 行,总共需要扫描 C+Y+1 行,执行代价比随机算法 1 的代价要高。

当然,随机算法 2 跟直接 order by rand() 比起来,执行代价还是小很多的。

你可能问了,如果按照这个表有 10000 行来计算的话,C=10000,要是随机到比较大的 Y 值,那扫描行数也跟 20000 差不多了,接近 order by rand() 的扫描行数,为什么说随机算法 2 的代价要小很多呢?我就把这个问题留给你去课后思考吧。

现在,我们再看看,如果我们按照随机算法 2 的思路,要随机取 3 个 word 值呢?你可以这么做:

  1. 取得整个表的行数,记为 C;
  2. 根据相同的随机方法得到 Y1、Y2、Y3;
  3. 再执行三个 limit Y, 1 语句得到三行数据。

我们把这个算法,称作随机算法 3。下面这段代码,就是上面流程的执行语句的序列。

mysql> select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; // 在应用代码里面取 Y1、Y2、Y3 值,拼出 SQL 后执行
select * from t limit @Y2,1;
select * from t limit @Y3,1;

小结

今天这篇文章,我是借着随机排序的需求,跟你介绍了 MySQL 对临时表排序的执行过程。

如果你直接使用 order by rand(),这个语句需要 Using temporary 和 Using filesort,查询的执行代价往往是比较大的。所以,在设计的时候你要量避开这种写法。

今天的例子里面,我们不是仅仅在数据库内部解决问题,还会让应用代码配合拼接 SQL 语句。在实际应用的过程中,比较规范的用法就是:尽量将业务逻辑写在业务代码中,让数据库只做“读写数据”的事情。因此,这类方法的应用还是比较广泛的。

最后,我给你留下一个思考题吧。

上面的随机算法 3 的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),实际上它还是可以继续优化,来进一步减少扫描行数的。

我的问题是,如果你是这个需求的开发人员,你会怎么做,来减少扫描行数呢?说说你的方案,并说明你的方案需要的扫描行数。

你可以把你的设计和结论写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章最后留给你的问题是,select * from t where city in (“杭州”,” 苏州 “) order by name limit 100; 这个 SQL 语句是否需要排序?有什么方案可以避免排序?

虽然有 (city,name) 联合索引,对于单个 city 内部,name 是递增的。但是由于这条 SQL 语句不是要单独地查一个 city 的值,而是同时查了”杭州”和” 苏州 “两个城市,因此所有满足条件的 name 就不是递增的了。也就是说,这条 SQL 语句需要排序。

那怎么避免排序呢?

这里,我们要用到 (city,name) 联合索引的特性,把这一条语句拆成两条语句,执行流程如下:

  1. 执行 select * from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的,客户端用一个长度为 100 的内存数组 A 保存结果。
  2. 执行 select * from t where city=“苏州” order by name limit 100; 用相同的方法,假设结果被存进了内存数组 B。
  3. 现在 A 和 B 是两个有序数组,然后你可以用归并排序的思想,得到 name 最小的前 100 值,就是我们需要的结果了。

如果把这条 SQL 语句里“limit 100”改成“limit 10000,100”的话,处理方式其实也差不多,即:要把上面的两条语句改成写:

select * from t where city=" 杭州 " order by name limit 10100; 

 select * from t where city=" 苏州 " order by name limit 10100。

这时候数据量较大,可以同时起两个连接一行行读结果,用归并排序算法拿到这两个结果集里,按顺序取第 10001~10100 的 name 值,就是需要的结果了。

当然这个方案有一个明显的损失,就是从数据库返回给客户端的数据量变大了。

所以,如果数据的单行比较大的话,可以考虑把这两条 SQL 语句改成下面这种写法:

select id,name from t where city=" 杭州 " order by name limit 10100; 

select id,name from t where city=" 苏州 " order by name limit 10100。

然后,再用归并排序的方法取得按 name 顺序第 10001~10100 的 name、id 的值,然后拿着这 100 个 id 到数据库中去查出所有记录。

上面这些方法,需要你根据性能需求和开发的复杂度做出权衡。

评论区留言点赞板:

评论区很多同学都提到不能排序,说明各位对索引的存储都理解对了。 @峰 同学提到了归并排序,是我们这个问题解法的核心思想; @老杨同志 的回答中提到了“从业务上砍掉功能”,这个也确实是在业务设计中可以考虑的一个方向; @某、人 帮忙回答了 @发条橙子同学的问题,尤其是对问题一的回答,非常精彩。

18 为什么这些SQL语句逻辑相同,性能却差异巨大?

在 MySQL 中,有很多看上去逻辑相同,但性能却差异巨大的 SQL 语句。对这些语句使用不当的话,就会不经意间导致整个数据库的压力变大。

我今天挑选了三个这样的案例和你分享。希望再遇到相似的问题时,你可以做到举一反三、快速解决问题。

案例一:条件字段函数操作

假设你现在维护了一个交易系统,其中交易记录表 tradelog 包含交易流水号(tradeid)、交易员 id(operator)、交易时间(t_modified)等字段。为了便于描述,我们先忽略其他字段。这个表的建表语句如下:

mysql> CREATE TABLE `tradelog` (
  `id` int(11) NOT NULL,
  `tradeid` varchar(32) DEFAULT NULL,
  `operator` int(11) DEFAULT NULL,
  `t_modified` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `tradeid` (`tradeid`),
  KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

假设,现在已经记录了从 2016 年初到 2018 年底的所有数据,运营部门有一个需求是,要统计发生在所有年份中 7 月份的交易记录总数。这个逻辑看上去并不复杂,你的 SQL 语句可能会这么写:

mysql> select count(*) from tradelog where month(t_modified)=7;

由于 t_modified 字段上有索引,于是你就很放心地在生产库中执行了这条语句,但却发现执行了特别久,才返回了结果。

如果你问 DBA 同事为什么会出现这样的情况,他大概会告诉你:如果对字段做了函数计算,就用不上索引了,这是 MySQL 的规定。

现在你已经学过了 InnoDB 的索引结构了,可以再追问一句为什么?为什么条件是 where t_modified=‘2018-7-1’的时候可以用上索引,而改成 where month(t_modified)=7 的时候就不行了?

下面是这个 t_modified 索引的示意图。方框上面的数字就是 month() 函数对应的值。

img

图 1 t_modified 索引示意图

如果你的 SQL 语句条件用的是 where t_modified=‘2018-7-1’的话,引擎就会按照上面绿色箭头的路线,快速定位到 t_modified=‘2018-7-1’需要的结果。

实际上,B+ 树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。

但是,如果计算 month() 函数的话,你会看到传入 7 的时候,在树的第一层就不知道该怎么办了。

也就是说,对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

需要注意的是,优化器并不是要放弃使用这个索引。

在这个例子里,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引 t_modified,优化器对比索引大小后发现,索引 t_modified 更小,遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引 t_modified。

接下来,我们使用 explain 命令,查看一下这条 SQL 语句的执行结果。

img

图 2 explain 结果

key=“t_modified”表示的是,使用了 t_modified 这个索引;我在测试表数据中插入了 10 万行数据,rows=100335,说明这条语句扫描了整个索引的所有值;Extra 字段的 Using index,表示的是使用了覆盖索引。

也就是说,由于在 t_modified 字段加了 month() 函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把 SQL 语句改成基于字段本身的范围查询。按照下面这个写法,优化器就能按照我们预期的,用上 t_modified 索引的快速定位能力了。

mysql> select count(*) from tradelog where
    -> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
    -> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or 
    -> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');

当然,如果你的系统上线时间更早,或者后面又插入了之后年份的数据的话,你就需要再把其他年份补齐。

到这里我给你说明了,由于加了 month() 函数操作,MySQL 无法再使用索引快速定位功能,而只能使用全索引扫描。

不过优化器在个问题上确实有“偷懒”行为,即使是对于不改变有序性的函数,也不会考虑使用索引。比如,对于 select * from tradelog where id + 1 = 10000 这个 SQL 语句,这个加 1 操作并不会改变有序性,但是 MySQL 优化器还是不能用 id 索引快速定位到 9999 这一行。所以,需要你在写 SQL 语句的时候,手动改写成 where id = 10000 -1 才可以。

案例二:隐式类型转换

接下来我再跟你说一说,另一个经常让程序员掉坑里的例子。

我们一起看一下这条 SQL 语句:

mysql> select * from tradelog where tradeid=110717;

交易编号 tradeid 这个字段上,本来就有索引,但是 explain 的结果却显示,这条语句需要走全表扫描。你可能也发现了,tradeid 的字段类型是 varchar(32),而输入的参数却是整型,所以需要做类型转换。

那么,现在这里就有两个问题:

  1. 数据类型转换的规则是什么?
  2. 为什么有数据类型转换,就需要走全索引扫描?

先来看第一个问题,你可能会说,数据库里面类型这么多,这种数据类型转换规则更多,我记不住,应该怎么办呢?

这里有一个简单的方法,看 select “10” > 9 的结果:

  1. 如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是 1;
  2. 如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是 0。

验证结果如图 3 所示。

img

图 3 MySQL 中字符串和数字转换的效果示意图

从图中可知,select “10” > 9 返回的是 1,所以你就能确认 MySQL 里的转换规则了:在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字。

这时,你再看这个全表扫描的语句:

mysql> select * from tradelog where tradeid=110717;

就知道对于优化器来说,这个语句相当于:

mysql> select * from tradelog where  CAST(tradid AS signed int) = 110717;

也就是说,这条语句触发了我们上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜索功能。

现在,我留给你一个小问题,id 的类型是 int,如果执行下面这个语句,是否会导致全表扫描呢?

select * from tradelog where id="83126";

你可以先自己分析一下,再到数据库里面去验证确认。

接下来,我们再来看一个稍微复杂点的例子。

案例三:隐式字符编码转换

假设系统里还有另外一个表 trade_detail,用于记录交易的操作细节。为了便于量化分析和复现,我往交易日志表 tradelog 和交易详情表 trade_detail 这两个表里插入一些数据。

mysql> CREATE TABLE `trade_detail` (
  `id` int(11) NOT NULL,
  `tradeid` varchar(32) DEFAULT NULL,
  `trade_step` int(11) DEFAULT NULL, /* 操作步骤 */
  `step_info` varchar(32) DEFAULT NULL, /* 步骤信息 */
  PRIMARY KEY (`id`),
  KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8; 
insert into tradelog values(1, 'aaaaaaaa', 1000, now());
insert into tradelog values(2, 'aaaaaaab', 1000, now());
insert into tradelog values(3, 'aaaaaaac', 1000, now()); 
insert into trade_detail values(1, 'aaaaaaaa', 1, 'add');
insert into trade_detail values(2, 'aaaaaaaa', 2, 'update');
insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit');
insert into trade_detail values(4, 'aaaaaaab', 1, 'add');
insert into trade_detail values(5, 'aaaaaaab', 2, 'update');
insert into trade_detail values(6, 'aaaaaaab', 3, 'update again');
insert into trade_detail values(7, 'aaaaaaab', 4, 'commit');
insert into trade_detail values(8, 'aaaaaaac', 1, 'add');
insert into trade_detail values(9, 'aaaaaaac', 2, 'update');
insert into trade_detail values(10, 'aaaaaaac', 3, 'update again');
insert into trade_detail values(11, 'aaaaaaac', 4, 'commit');

这时候,如果要查询 id=2 的交易的所有操作步骤信息,SQL 语句可以这么写:

mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /* 语句 Q1*/

图 4 语句 Q1 的 explain 结果

我们一起来看下这个结果:

  1. 第一行显示优化器会先在交易记录表 tradelog 上查到 id=2 的行,这个步骤用上了主键索引,rows=1 表示只扫描一行;
  2. 第二行 key=NULL,表示没有用上交易详情表 trade_detail 上的 tradeid 索引,进行了全表扫描。

在这个执行计划里,是从 tradelog 表中取 tradeid 字段,再去 trade_detail 表里查询匹配字段。因此,我们把 tradelog 称为驱动表,把 trade_detail 称为被驱动表,把 tradeid 称为关联字段。

接下来,我们看下这个 explain 结果表示的执行流程:

img

图 5 语句 Q1 的执行过程

图中:

  • 第 1 步,是根据 id 在 tradelog 表里找到 L2 这一行;
  • 第 2 步,是从 L2 中取出 tradeid 字段的值;
  • 第 3 步,是根据 tradeid 值到 trade_detail 表中查找条件匹配的行。explain 的结果里面第二行的 key=NULL 表示的就是,这个过程是通过遍历主键索引的方式,一个一个地判断 tradeid 的值是否匹配。

进行到这里,你会发现第 3 步不符合我们的预期。因为表 trade_detail 里 tradeid 字段上是有索引的,我们本来是希望通过使用 tradeid 索引能够快速定位到等值的行。但,这里并没有。

如果你去问 DBA 同学,他们可能会告诉你,因为这两个表的字符集不同,一个是 utf8,一个是 utf8mb4,所以做表连接查询的时候用不上关联字段的索引。这个回答,也是通常你搜索这个问题时会得到的答案。

但是你应该再追问一下,为什么字符集不同就用不上索引呢?

我们说问题是出在执行步骤的第 3 步,如果单独把这一步改成 SQL 语句的话,那就是:

mysql> select * from trade_detail where tradeid=$L2.tradeid.value; 

其中,$L2.tradeid.value 的字符集是 utf8mb4。

参照前面的两个例子,你肯定就想到了,字符集 utf8mb4 是 utf8 的超集,所以当这两个类型的字符串在做比较的时候,MySQL 内部的操作是,先把 utf8 字符串转成 utf8mb4 字符集,再做比较。

这个设定很好理解,utf8mb4 是 utf8 的超集。类似地,在程序设计语言里面,做自动类型转换的时候,为了避免数据在转换过程中由于截断导致数据错误,也都是“按数据长度增加的方向”进行转换的。

因此, 在执行上面这个语句的时候,需要将被驱动数据表里的字段一个个地转换成 utf8mb4,再跟 L2 做比较。

也就是说,实际上这个语句等同于下面这个写法:

select * from trade_detail  where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value; 

CONVERT() 函数,在这里的意思是把输入的字符串转成 utf8mb4 字符集。

这就再次触发了我们上面说到的原则:对索引字段做函数操作,优化器会放弃走树搜索功能。

到这里,你终于明确了,字符集不同只是条件之一,连接过程中要求在被驱动表的索引字段上加函数操作,是直接导致对被驱动表做全表扫描的原因。

作为对比验证,我给你提另外一个需求,“查找 trade_detail 表里 id=4 的操作,对应的操作者是谁”,再来看下这个语句和它的执行计划。

mysql>select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;

img

图 6 explain 结果

这个语句里 trade_detail 表成了驱动表,但是 explain 结果的第二行显示,这次的查询操作用上了被驱动表 tradelog 里的索引 (tradeid),扫描行数是 1。

这也是两个 tradeid 字段的 join 操作,为什么这次能用上被驱动表的 tradeid 索引呢?我们来分析一下。

假设驱动表 trade_detail 里 id=4 的行记为 R4,那么在连接的时候(图 5 的第 3 步),被驱动表 tradelog 上执行的就是类似这样的 SQL 语句:

select operator from tradelog  where traideid =$R4.tradeid.value; 

这时候 $R4.tradeid.value 的字符集是 utf8, 按照字符集转换规则,要转成 utf8mb4,所以这个过程就被改写成:

select operator from tradelog  where traideid =CONVERT($R4.tradeid.value USING utf8mb4); 

你看,这里的 CONVERT 函数是加在输入参数上的,这样就可以用上被驱动表的 traideid 索引。

理解了原理以后,就可以用来指导操作了。如果要优化语句

select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;

的执行过程,有两种做法:

  • 比较常见的优化方法是,把 trade_detail 表上的 tradeid 字段的字符集也改成 utf8mb4,这样就没有字符集转换的问题了。
alter table trade_detail modify tradeid varchar(32) CHARACTER SET utf8mb4 default null;
  • 如果能够修改字段的字符集的话,是最好不过了。但如果数据量比较大, 或者业务上暂时不能做这个 DDL 的话,那就只能采用修改 SQL 语句的方法了。
mysql> select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2; 

img

图 7 SQL 语句优化后的 explain 结果

这里,我主动把 l.tradeid 转成 utf8,就避免了被驱动表上的字符编码转换,从 explain 结果可以看到,这次索引走对了。

小结

今天我给你举了三个例子,其实是在说同一件事儿,即:对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。

第二个例子是隐式类型转换,第三个例子是隐式字符编码转换,它们都跟第一个例子一样,因为要求在索引字段上做函数操作而导致了全索引扫描。

MySQL 的优化器确实有“偷懒”的嫌疑,即使简单地把 where id+1=1000 改写成 where id=1000-1 就能够用上索引快速查找,也不会主动做这个语句重写。

因此,每次你的业务代码升级时,把可能出现的、新的 SQL 语句 explain 一下,是一个很好的习惯。

最后,又到了思考题时间。

今天我留给你的课后问题是,你遇到过别的、类似今天我们提到的性能问题吗?你认为原因是什么,又是怎么解决的呢?

你可以把你经历和分析写在留言区里,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上篇文章的最后,留给你的问题是:我们文章中最后的一个方案是,通过三次 limit Y,1 来得到需要的数据,你觉得有没有进一步的优化方法。

这里我给出一种方法,取 Y1、Y2 和 Y3 里面最大的一个数,记为 M,最小的一个数记为 N,然后执行下面这条 SQL 语句:

mysql> select * from t limit N, M-N+1;

再加上取整个表总行数的 C 行,这个方案的扫描行数总共只需要 C+M+1 行。

当然也可以先取回 id 值,在应用中确定了三个 id 值以后,再执行三次 where id=X 的语句也是可以的。@倪大人 同学在评论区就提到了这个方法。

这次评论区出现了很多很棒的留言:

@老杨同志 提出了重新整理的方法、@雪中鼠 [悠闲] 提到了用 rowid 的方法,是类似的思路,就是让表里面保存一个无空洞的自增值,这样就可以用我们的随机算法 1 来实现; @吴宇晨 提到了拿到第一个值以后,用 id 迭代往下找的方案,利用了主键索引的有序性。

19 为什么我只查一行的语句,也执行这么慢?

一般情况下,如果我跟你说查询性能优化,你首先会想到一些复杂的语句,想到查询需要返回大量的数据。但有些情况下,“查一行”,也会执行得特别慢。今天,我就跟你聊聊这个有趣的话题,看看什么情况下,会出现这个现象。

需要说明的是,如果 MySQL 数据库本身就有很大的压力,导致数据库服务器 CPU 占用率很高或 ioutil(IO 利用率)很高,这种情况下所有语句的执行都有可能变慢,不属于我们今天的讨论范围。

为了便于描述,我还是构造一个表,基于这个表来说明今天的问题。这个表有两个字段 id 和 c,并且我在里面插入了 10 万行记录。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=100000)do
    insert into t values(i,i);
    set i=i+1;
  end while;
end;;
delimiter ; 
call idata();

接下来,我会用几个不同的场景来举例,有些是前面的文章中我们已经介绍过的知识点,你看看能不能一眼看穿,来检验一下吧。

第一类:查询长时间不返回

如图 1 所示,在表 t 执行下面的 SQL 语句:

mysql> select * from t where id=1;

查询结果长时间不返回。

img

图 1 查询长时间不返回

一般碰到这种情况的话,大概率是表 t 被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。

然后我们再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。

等 MDL 锁

如图 2 所示,就是使用 show processlist 命令查看 Waiting for table metadata lock 的示意图。

img

图 2 Waiting for table metadata lock 状态示意图

出现这个状态表示的是,现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住了。

在第 6 篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》]中,我给你介绍过一种复现方法。但需要说明的是,那个复现过程是基于 MySQL 5.6 版本的。而 MySQL 5.7 版本修改了 MDL 的加锁策略,所以就不能复现这个场景了。

不过,在 MySQL 5.7 版本下复现这个场景,也很容易。如图 3 所示,我给出了简单的复现步骤。

img

图 3 MySQL 5.7 中 Waiting for table metadata lock 的复现步骤

session A 通过 lock table 命令持有表 t 的 MDL 写锁,而 session B 的查询需要获取 MDL 读锁。所以,session B 进入等待状态。

这类问题的处理方式,就是找到谁持有 MDL 写锁,然后把它 kill 掉。

但是,由于在 show processlist 的结果里面,session A 的 Command 列是“Sleep”,导致查找起来很不方便。不过有了 performance_schema 和 sys 系统库以后,就方便多了。(MySQL 启动时需要设置 performance_schema=on,相比于设置为 off 会有 10% 左右的性能损失)

通过查询 sys.schema_table_lock_waits 这张表,我们就可以直接找出造成阻塞的 process id,把这个连接用 kill 命令断开即可。

img

图 4 查获加表锁的线程 id

等 flush

接下来,我给你举另外一种查询被堵住的情况。

我在表 t 上,执行下面的 SQL 语句:

mysql> select * from information_schema.processlist where id=1;

这里,我先卖个关子。

你可以看一下图 5。我查出来这个线程的状态是 Waiting for table flush,你可以设想一下这是什么原因。img

图 5 Waiting for table flush 状态示意图

这个状态表示的是,现在有一个线程正要对表 t 做 flush 操作。MySQL 里面对表做 flush 操作的用法,一般有以下两个:

flush tables t with read lock; 
flush tables with read lock;

这两个 flush 语句,如果指定表 t 的话,代表的是只关闭表 t;如果没有指定具体的表名,则表示关闭 MySQL 里所有打开的表。

但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。

所以,出现 Waiting for table flush 状态的可能情况是:有一个 flush tables 命令被别的语句堵住了,然后它又堵住了我们的 select 语句。

现在,我们一起来复现一下这种情况,复现步骤如图 6 所示:

img

图 6 Waiting for table flush 的复现步骤

在 session A 中,我故意每行都调用一次 sleep(1),这样这个语句默认要执行 10 万秒,在这期间表 t 一直是被 session A“打开”着。然后,session B 的 flush tables t 命令再要去关闭表 t,就需要等 session A 的查询结束。这样,session C 要再次查询的话,就会被 flush 命令堵住了。

图 7 是这个复现步骤的 show processlist 结果。这个例子的排查也很简单,你看到这个 show processlist 的结果,肯定就知道应该怎么做了。

img

图 7 Waiting for table flush 的 show processlist 结果

等行锁

现在,经过了表级锁的考验,我们的 select 语句终于来到引擎里了。

mysql> select * from t where id=1 lock in share mode; 

上面这条语句的用法你也很熟悉了,我们在第 8 篇[《事务到底是隔离的还是不隔离的?》]文章介绍当前读时提到过。

由于访问 id=1 这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的 select 语句就会被堵住。

复现步骤和现场如下:

img

图 8 行锁复现

img

图 9 行锁 show processlist 现场

显然,session A 启动了事务,占有写锁,还不提交,是导致 session B 被堵住的原因。

这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果你用的是 MySQL 5.7 版本,可以通过 sys.innodb_lock_waits 表查到。

查询方法是:

mysql> select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G

img

图 10 通过 sys.innodb_lock_waits 查行锁

可以看到,这个信息很全,4 号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式,就是 KILL QUERY 4 或 KILL 4。

不过,这里不应该显示“KILL QUERY 4”。这个命令表示停止 4 号线程当前正在执行的语句,而这个方法其实是没有用的。因为占有行锁的是 update 语句,这个语句已经是之前执行完成了的,现在执行 KILL QUERY,无法让这个事务去掉 id=1 上的行锁。

实际上,KILL 4 才有效,也就是说直接断开这个连接。这里隐含的一个逻辑就是,连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了 id=1 上的行锁。

第二类:查询慢

经过了重重封“锁”,我们再来看看一些查询慢的例子。

先来看一条你一定知道原因的 SQL 语句:

mysql> select * from t where c=50000 limit 1;

由于字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要扫描 5 万行。

作为确认,你可以看一下慢查询日志。注意,这里为了把所有语句记录到 slow log 里,我在连接后先执行了 set long_query_time=0,将慢查询日志的时间阈值设置为 0。

img

图 11 全表扫描 5 万行的 slow log

Rows_examined 显示扫描了 50000 行。你可能会说,不是很慢呀,11.5 毫秒就返回了,我们线上一般都配置超过 1 秒才算慢查询。但你要记住:坏查询不一定是慢查询。我们这个例子里面只有 10 万行记录,数据量大起来的话,执行时间就线性涨上去了。

扫描行数多,所以执行慢,这个很好理解。

但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。

如图 12 所示,是这个例子的 slow log。可以看到,执行的语句是

mysql> select * from t where id=1;

虽然扫描行数是 1,但执行时间却长达 800 毫秒。

img

图 12 扫描一行却执行得很慢

是不是有点奇怪呢,这些时间都花在哪里了?

如果我把这个 slow log 的截图再往下拉一点,你可以看到下一个语句,select * from t where id=1 lock in share mode,执行时扫描行数也是 1 行,执行时间是 0.2 毫秒。

img

图 13 加上 lock in share mode 的 slow log

看上去是不是更奇怪了?按理说 lock in share mode 还要加锁,时间应该更长才对啊。

可能有的同学已经有答案了。如果你还没有答案的话,我再给你一个提示信息,图 14 是这两个语句的执行输出结果。

img

图 14 两个语句的输出结果

第一个语句的查询结果里 c=1,带 lock in share mode 的语句返回的是 c=1000001。看到这里应该有更多的同学知道原因了。如果你还是没有头绪的话,也别着急。我先跟你说明一下复现步骤,再分析原因。

img

图 15 复现步骤

你看到了,session A 先用 start transaction with consistent snapshot 命令启动了一个事务,之后 session B 才开始执行 update 语句。

session B 执行完 100 万次 update 语句后,id=1 这一行处于什么状态呢?你可以从图 16 中找到答案。

img

图 16 id=1 的数据状态

session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)。

带 lock in share mode 的 SQL 语句,是当前读,因此会直接读到 1000001 这个结果,所以速度很快;而 select * from t where id=1 这个语句,是一致性读,因此需要从 1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。

注意,undo log 里记录的其实是“把 2 改成 1”,“把 3 改成 2”这样的操作逻辑,画成减 1 的目的是方便你看图。

小结

今天我给你举了在一个简单的表上,执行“查一行”,可能会出现的被锁住和执行慢的例子。这其中涉及到了表锁、行锁和一致性读的概念。

在实际使用中,碰到的场景会更复杂。但大同小异,你可以按照我在文章中介绍的定位方法,来定位并解决问题。

最后,我给你留一个问题吧。

我们在举例加锁读的时候,用的是这个语句,select * from t where id=1 lock in share mode。由于 id 上有索引,所以可以直接定位到 id=1 这一行,因此读锁也是只加在了这一行上。

但如果是下面的 SQL 语句,

begin;
select * from t where c=5 for update;
commit;

这个语句序列是怎么加锁的呢?加的锁又是什么时候释放呢?

你可以把你的观点和验证方法写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

在上一篇文章最后,我留给你的问题是,希望你可以分享一下之前碰到过的、与文章中类似的场景。

@封建的风 提到一个有趣的场景,值得一说。我把他的问题重写一下,表结构如下:

mysql> CREATE TABLE `table_a` (
  `id` int(11) NOT NULL,
  `b` varchar(10) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `b` (`b`)
) ENGINE=InnoDB;

假设现在表里面,有 100 万行数据,其中有 10 万行数据的 b 的值是’1234567890’, 假设现在执行语句是这么写的:

mysql> select * from table_a where b='1234567890abcd';

这时候,MySQL 会怎么执行呢?

最理想的情况是,MySQL 看到字段 b 定义的是 varchar(10),那肯定返回空呀。可惜,MySQL 并没有这么做。

那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也没能够快速判断出索引树 b 上并没有这个值,也很快就能返回空结果。

但实际上,MySQL 也不是这么做的。

这条 SQL 语句的执行很慢,流程是这样的:

  1. 在传给引擎执行的时候,做了字符截断。因为引擎里面这个行只定义了长度是 10,所以只截了前 10 个字节,就是’1234567890’进去做匹配;
  2. 这样满足条件的数据有 10 万行;
  3. 因为是 select *, 所以要做 10 万次回表;
  4. 但是每次回表以后查出整行,到 server 层一判断,b 的值都不是’1234567890abcd’;
  5. 返回结果是空。

这个例子,是我们文章内容的一个很好的补充。虽然执行过程中可能经过函数操作,但是最终在拿到结果后,server 层还是要做一轮判断的。

评论区留言点赞板:

@赖阿甘 提到了等号顺序问题,时间上 MySQL 优化器执行过程中,where 条件部分, a=b 和 b=a 的写法是一样的。 @沙漠里的骆驼 提到了一个常见的问题。相同的模板语句,但是匹配行数不同,语句执行时间相差很大。这种情况,在语句里面有 order by 这样的操作时会更明显。 @Justin 回答了我们正文中的问题,如果 id 的类型是整数,传入的参数类型是字符串的时候,可以用上索引。

20 幻读是什么,幻读有什么问题?

在上一篇文章最后,我给你留了一个关于加锁规则的问题。今天,我们就从这个问题说起吧。

为了便于说明问题,这一篇文章,我们就先使用一个小一点儿的表。建表和初始化语句如下(为了便于本期的例子说明,我把上篇文章中用到的表结构做了点儿修改):

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB; 
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);

这个表除了主键 id 外,还有一个索引 c,初始化语句在表中插入了 6 行数据。

上期我留给你的问题是,下面的语句序列,是怎么加锁的,加的锁又是什么时候释放的呢?

begin;
select * from t where d=5 for update;
commit;

比较好理解的是,这个语句会命中 d=5 的这一行,对应的主键 id=5,因此在 select 语句执行完成后,id=5 这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会在执行 commit 语句的时候释放。

由于字段 d 上没有索引,因此这条查询语句会做全表扫描。那么,其他被扫描到的,但是不满足条件的 5 行记录上,会不会被加锁呢?

我们知道,InnoDB 的默认事务隔离级别是可重复读,所以本文接下来没有特殊说明的部分,都是设定在可重复读隔离级别下。

幻读是什么?

现在,我们就来分析一下,如果只在 id=5 这一行加锁,而其他行的不加锁的话,会怎么样。

下面先来看一下这个场景(注意:这是我假设的一个场景):

img

图 1 假设只在 id=5 这一行加行锁

可以看到,session A 里执行了三次查询,分别是 Q1、Q2 和 Q3。它们的 SQL 语句相同,都是 select * from t where d=5 for update。这个语句的意思你应该很清楚了,查所有 d=5 的行,而且使用的是当前读,并且加上写锁。现在,我们来看一下这三条 SQL 语句,分别会返回什么结果。

  1. Q1 只返回 id=5 这一行;
  2. 在 T2 时刻,session B 把 id=0 这一行的 d 值改成了 5,因此 T3 时刻 Q2 查出来的是 id=0 和 id=5 这两行;
  3. 在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻 Q3 查出来的是 id=0、id=1 和 id=5 的这三行。

其中,Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。

这里,我需要对“幻读”做一个说明:

  1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
  2. 上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。

如果只从第 8 篇文章[《事务到底是隔离的还是不隔离的?》]我们学到的事务可见性规则来分析的话,上面这三条 SQL 语句的返回结果都没有问题。

因为这三个查询都是加了 for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值。并且,session B 和 sessionC 的两条语句,执行后就会提交,所以 Q2 和 Q3 就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。

但是,这是不是真的没问题呢?

不,这里还真就有问题。

幻读有什么问题?

首先是语义上的。session A 在 T1 时刻就声明了,“我要把所有 d=5 的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。

如果现在这样看感觉还不明显的话,我再往 session B 和 session C 里面分别加一条 SQL 语句,你再看看会出现什么现象。

img

图 2 假设只在 id=5 这一行加行锁 – 语义被破坏

session B 的第二条语句 update t set c=5 where id=0,语义是“我把 id=0、d=5 这一行的 c 值,改成了 5”。

由于在 T1 时刻,session A 还只是给 id=5 这一行加了行锁, 并没有给 id=0 这行加上锁。因此,session B 在 T2 时刻,是可以执行这两条 update 语句的。这样,就破坏了 session A 里 Q1 语句要锁住所有 d=5 的行的加锁声明。

session C 也是一样的道理,对 id=1 这一行的修改,也是破坏了 Q1 的加锁声明。

其次,是数据一致性的问题。

我们知道,锁的设计是为了保证数据的一致性。而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。

为了说明这个问题,我给 session A 在 T1 时刻再加一个更新语句,即:update t set d=100 where d=5。

img

图 3 假设只在 id=5 这一行加行锁 – 数据一致性问题

update 的加锁语义和 select …for update 是一致的,所以这时候加上这条 update 语句也很合理。session A 声明说“要给 d=5 的语句加上锁”,就是为了要更新数据,新加的这条 update 语句就是把它认为加上了锁的这一行的 d 值修改成了 100。

现在,我们来分析一下图 3 执行完成后,数据库里会是什么结果。

  1. 经过 T1 时刻,id=5 这一行变成 (5,5,100),当然这个结果最终是在 T6 时刻正式提交的 ;
  2. 经过 T2 时刻,id=0 这一行变成 (0,5,5);
  3. 经过 T4 时刻,表里面多了一行 (1,5,5);
  4. 其他行跟这个执行序列无关,保持不变。

这样看,这些数据也没啥问题,但是我们再来看看这时候 binlog 里面的内容。

  1. T2 时刻,session B 事务提交,写入了两条语句;
  2. T4 时刻,session C 事务提交,写入了两条语句;
  3. T6 时刻,session A 事务提交,写入了 update t set d=100 where d=5 这条语句。

我统一放到一起的话,就是这样的:

update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/ 
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/ 
update t set d=100 where d=5;/* 所有 d=5 的行,d 改成 100*/

好,你应该看出问题了。这个语句序列,不论是拿到备库去执行,还是以后用 binlog 来克隆一个库,这三行的结果,都变成了 (0,5,100)、(1,5,100) 和 (5,5,100)。

也就是说,id=0 和 id=1 这两行,发生了数据不一致。这个问题很严重,是不行的。

到这里,我们再回顾一下,这个数据不一致到底是怎么引入的?

我们分析一下可以知道,这是我们假设“select * from t where d=5 for update 这条语句只给 d=5 这一行,也就是 id=5 的这一行加锁”导致的。

所以我们认为,上面的设定不合理,要改。

那怎么改呢?我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。

img

图 4 假设扫描到的行都被加上了行锁

由于 session A 把所有的行都加了写锁,所以 session B 在执行第一个 update 语句的时候就被锁住了。需要等到 T6 时刻 session A 提交以后,session B 才能继续执行。

这样对于 id=0 这一行,在数据库里的最终结果还是 (0,5,5)。在 binlog 里面,执行序列是这样的:

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/ 
update t set d=100 where d=5;/* 所有 d=5 的行,d 改成 100*/ 
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

可以看到,按照日志顺序执行,id=0 这一行的最终结果也是 (0,5,5)。所以,id=0 这一行的问题解决了。

但同时你也可以看到,id=1 这一行,在数据库里面的结果是 (1,5,5),而根据 binlog 的执行结果是 (1,5,100),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了 id=1 这一行的插入和更新呢?

原因很简单。在 T3 时刻,我们给所有行加锁的时候,id=1 这一行还不存在,不存在也就加不上锁。

也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。

到这里,其实我们刚说明完文章的标题 :幻读的定义和幻读有什么问题。

接下来,我们再看看 InnoDB 怎么解决幻读的问题。

如何解决幻读?

现在你知道了,产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。

顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。

img

图 5 表 t 主键索引上的行锁和间隙锁

这样,当你执行 select * from t where d=5 for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。

也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。

现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。

比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。

img

图 6 两种行锁间的冲突关系

也就是说,跟行锁有冲突关系的是“另外一个行锁”。

但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。间隙锁之间都不存在冲突关系。

这句话不太好理解,我给你举个例子:

img

图 7 间隙锁之间不互锁

这里 session B 并不会被堵住。因为表 t 里并没有 c=7 这个记录,因此 session A 加的是间隙锁 (5,10)。而 session B 也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。

间隙锁和行锁合称 next-key lock,每个 next-key lock 是前开后闭区间。也就是说,我们的表 t 初始化以后,如果用 select * from t for update 要把整个表所有记录锁起来,就形成了 7 个 next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把 next-key lock 记为前开后闭区间。

你可能会问说,这个 supremum 从哪儿来的呢?

这是因为 +∞是开区间。实现上,InnoDB 给每个索引加了一个不存在的最大值 supremum,这样才符合我们前面说的“都是前开后闭区间”。

间隙锁和 next-key lock 的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。

在前面的文章中,就有同学提到了这个问题。我把他的问题转述一下,对应到我们这个例子的表来说,业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新它的数据,代码如下:

begin;
select * from t where id=N for update; 
/* 如果行不存在 */
insert into t values(N,N,N);
/* 如果行存在 */
update t set d=N set id=N; 
commit;

可能你会说,这个不是 insert … on duplicate key update 就能解决吗?但其实在有多个唯一键的时候,这个方法是不能满足这位提问同学的需求的。至于为什么,我会在后面的文章中再展开说明。

现在,我们就只讨论这个逻辑。

这个同学碰到的现象是,这个逻辑一旦有并发,就会碰到死锁。你一定也觉得奇怪,这个逻辑每次操作前用 for update 锁起来,已经是最严格的模式了,怎么还会有死锁呢?

这里,我用两个 session 来模拟并发,并假设 N=9。

img

图 8 间隙锁导致的死锁

你看到了,其实都不需要用到后面的 update 语句,就已经形成死锁了。我们按语句执行顺序来分析一下:

  1. session A 执行 select … for update 语句,由于 id=9 这一行并不存在,因此会加上间隙锁 (5,10);
  2. session B 执行 select … for update 语句,同样会加上间隙锁 (5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
  3. session B 试图插入一行 (9,9,9),被 session A 的间隙锁挡住了,只好进入等待;
  4. session A 试图插入一行 (9,9,9),被 session B 的间隙锁挡住了。

至此,两个 session 进入互相等待状态,形成死锁。当然,InnoDB 的死锁检测马上就发现了这对死锁关系,让 session A 的 insert 语句报错返回了。

你现在知道了,间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。其实,这还只是一个简单的例子,在下一篇文章中我们还会碰到更多、更复杂的例子。

你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法呢。

我在文章一开始就说过,如果没有特别说明,今天和你分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把 binlog 格式设置为 row。这,也是现在不少公司使用的配置组合。

前面文章的评论区有同学留言说,他们公司就使用的是读提交隔离级别加 binlog_format=row 的组合。他曾问他们公司的 DBA 说,你为什么要这么配置。DBA 直接答复说,因为大家都这么用呀。

所以,这个同学在评论区就问说,这个配置到底合不合理。

关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。

但其实我想说的是,配置是否合理,跟业务场景有关,需要具体问题具体分析。

但是,如果 DBA 认为之所以这么用的原因是“大家都这么用”,那就有问题了,或者说,迟早会出问题。

比如说,大家都用读提交,可是逻辑备份的时候,mysqldump 为什么要把备份线程设置成可重复读呢?(这个我在前面的文章中已经解释过了,你可以再回顾下第 6 篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》]的内容)

然后,在备份期间,备份线程用的是可重复读,而业务线程用的是读提交。同时存在两种事务隔离级别,会不会有问题?

进一步地,这两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的?

如果业务开发和运维团队这些问题都没有弄清楚,那么“没问题”这个结论,本身就是有问题的。

小结

今天我们从上一篇文章的课后问题说起,提到了全表扫描的加锁方式。我们发现即使给所有的行都加上行锁,仍然无法解决幻读问题,因此引入了间隙锁的概念。

我碰到过很多对数据库有一定了解的业务开发人员,他们在设计数据表结构和业务 SQL 语句的时候,对行锁有很准确的认识,但却很少考虑到间隙锁。最后的结果,就是生产库上会经常出现由于间隙锁导致的死锁现象。

行锁确实比较直观,判断规则也相对简单,间隙锁的引入会影响系统的并发度,也增加了锁分析的复杂度,但也有章可循。下一篇文章,我就会为你讲解 InnoDB 的加锁规则,帮你理顺这其中的“章法”。

作为对下一篇文章的预习,我给你留下一个思考题。

img

图 9 事务进入锁等待状态

如果你之前没有了解过本篇文章的相关内容,一定觉得这三个语句简直是风马牛不相及。但实际上,这里 session B 和 session C 的 insert 语句都会进入锁等待状态。

你可以试着分析一下,出现这种情况的原因是什么?

这里需要说明的是,这其实是我在下一篇文章介绍加锁规则后才能回答的问题,是留给你作为预习的,其中 session C 被锁住这个分析是有点难度的。如果你没有分析出来,也不要气馁,我会在下一篇文章和你详细说明。

你也可以说说,你的线上 MySQL 配置的是什么隔离级别,为什么会这么配置?你有没有碰到什么场景,是必须使用可重复读隔离级别的呢?

你可以把你的碰到的场景和分析写在留言区里,我会在下一篇文章选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我们在本文的开头回答了上期问题。有同学的回答中还说明了读提交隔离级别下,在语句执行完成后,是只有行锁的。而且语句执行完成后,InnoDB 就会把不满足条件的行行锁去掉。

当然了,c=5 这一行的行锁,还是会等到 commit 的时候才释放的。

评论区留言点赞板:

@薛畅 、@张永志同学给出了正确答案。而且提到了在读提交隔离级别下,是只有行锁的。 @帆帆帆帆帆帆帆帆、@欧阳成 对上期的例子做了验证,需要说明一下,需要在启动配置里面增加 performance_schema=on,才能用上这个功能,performance_schema 库里的表才有数据。

21 为什么我只改一行的语句,锁这么多?

在上一篇文章中,我和你介绍了间隙锁和 next-key lock 的概念,但是并没有说明加锁规则。间隙锁的概念理解起来确实有点儿难,尤其在配合上行锁以后,很容易在判断是否会出现锁等待的问题上犯错。

所以今天,我们就先从这个加锁规则开始吧。

首先说明一下,这些加锁规则我没在别的地方看到过有类似的总结,以前我自己判断的时候都是想着代码里面的实现来脑补的。这次为了总结成不看代码的同学也能理解的规则,是我又重新刷了代码临时总结出来的。所以,这个规则有以下两条前提说明:

  1. MySQL 后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。
  2. 如果大家在验证中有发现 bad case 的话,请提出来,我会再补充进这篇文章,使得一起学习本专栏的所有同学都能受益。

因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认是可重复读隔离级别。

我总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
  2. 原则 2:查找过程中访问到的对象才会加锁。
  3. 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  4. 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  5. 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

我还是以上篇文章的表 t 为例,和你解释一下这些规则。表 t 的建表语句和初始化语句如下。

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB; 
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);

接下来的例子基本都是配合着图片说明的,所以我建议你可以对照着文稿看,有些例子可能会“毁三观”,也建议你读完文章后亲手实践一下。

案例一:等值查询间隙锁

第一个例子是关于等值条件操作间隙:

img

图 1 等值查询的间隙锁

由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:

  1. 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10];
  2. 同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)。

所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。

案例二:非唯一索引等值锁

第二个例子是关于覆盖索引上的锁:

img

图 2 只加在非唯一索引上的锁

看到这个例子,你是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?我们来分析一下吧。

这里 session A 要给索引 c 上 c=5 的这一行加上读锁。

  1. 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5] 加上 next-key lock。
  2. 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10] 加 next-key lock。
  3. 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)。
  4. 根据原则 2 ,只有访问到的对象才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。

但 session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住。

需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。 执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁。

这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 session A 的查询语句改成 select d from t where c=5 lock in share mode。你可以自己验证一下效果。

案例三:主键索引范围锁

第三个例子是关于范围查询的。

举例之前,你可以先思考一下这个问题:对于我们这个表 t,下面这两条查询语句,加锁范围相同吗?

mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;

你可能会想,id 定义为 int 类型,这两个语句就是等价的吧?其实,它们并不完全等价。

在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让 session A 执行第二个查询语句,来看看加锁效果。

img

图 3 主键索引上范围查询的锁

现在我们就用前面提到的加锁规则,来分析一下 session A 会加什么锁呢?

  1. 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
  2. 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]。

所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。这样,session B 和 session C 的结果你就能理解了。

这里你需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。

案例四:非唯一索引范围锁

接下来,我们再看两个范围查询加锁的例子,你可以对照着案例三来看。

需要注意的是,与案例三不同的是,案例四中查询语句的 where 部分用的是字段 c。

img

图 4 非唯一索引范围锁

这次 session A 用字段 c 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10] 这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10] 和 (10,15] 这两个 next-key lock。

所以从结果上来看,sesson B 要插入(8,8,8) 的这个 insert 语句时就被堵住了。

这里需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后找了。

案例五:唯一索引范围锁 bug

前面的四个案例,我们已经用到了加锁规则中的两个原则和两个优化,接下来再看一个关于加锁规则中 bug 的案例。

img

图 5 唯一索引范围锁的 bug

session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15] 这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。

但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20] 这个 next-key lock 也会被锁上。

所以你看到了,session B 要更新 id=20 这一行,是会被锁住的。同样地,session C 要插入 id=16 的一行,也会被锁住。

照理说,这里锁住 id=20 这一行的行为,其实是没有必要的。因为扫描到 id=15,就可以确定不用往后再找了。但实现上还是这么做了,因此我认为这是个 bug。

我也曾找社区的专家讨论过,官方 bug 系统上也有提到,但是并未被 verified。所以,认为这是 bug 这个事儿,也只能算我的一家之言,如果你有其他见解的话,也欢迎你提出来。

案例六:非唯一索引上存在”等值”的例子

接下来的例子,是为了更好地说明“间隙”这个概念。这里,我给表 t 插入一条新记录。

mysql> insert into t values(30,10,30);

新插入的这一行 c=10,也就是说现在表里有两个 c=10 的行。那么,这时候索引 c 上的间隙是什么状态了呢?你要知道,由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。

img

图 6 非唯一索引等值的例子

可以看到,虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。

图中我画出了索引 c 上的主键 id。为了跟间隙锁的开区间形式进行区别,我用 (c=10,id=30) 这样的形式,来表示索引上的一行。

现在,我们来看一下案例六。

这次我们用 delete 语句来验证。注意,delete 语句加锁的逻辑,其实跟 select … for update 是类似的,也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。

img

图 7 delete 示例

这时,session A 在遍历的时候,先访问第一个 c=10 的记录。同样地,根据原则 1,这里加的是 (c=5,id=5) 到 (c=10,id=10) 这个 next-key lock。

然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10) 到 (c=15,id=15) 的间隙锁。

也就是说,这个 delete 语句在索引 c 上的加锁范围,就是下图中蓝色区域覆盖的部分。img

图 8 delete 加锁效果示例

这个蓝色区域左右两边都是虚线,表示开区间,即 (c=5,id=5) 和 (c=15,id=15) 这两行上都没有锁。

案例七:limit 语句加锁

例子 6 也有一个对照案例,场景如下所示:

img

图 9 limit 语句加锁

这个例子里,session A 的 delete 语句加了 limit 2。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。

这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。

因此,索引 c 上的加锁范围就变成了从(c=5,id=5) 到(c=10,id=30) 这个前开后闭区间,如下图所示:img

图 10 带 limit 2 的加锁效果

可以看到,(c=10,id=30)之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。

这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。

案例八:一个死锁的例子

前面的例子中,我们在分析的时候,是按照 next-key lock 的逻辑来分析的,因为这样分析比较方便。最后我们再看一个案例,目的是说明:next-key lock 实际上是间隙锁和行锁加起来的结果。

你一定会疑惑,这个概念不是一开始就说了吗?不要着急,我们先来看下面这个例子:

img

图 11 案例八的操作序列

现在,我们按时间顺序来分析一下为什么是这样的结果。

  1. session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-key lock(5,10] 和间隙锁 (10,15);
  2. session B 的 update 语句也要在索引 c 上加 next-key lock(5,10] ,进入锁等待;
  3. 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚。

你可能会问,session B 的 next-key lock 不是还没申请成功吗?

其实是这样的,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。

也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。

小结

这里我再次说明一下,我们上面的所有案例都是在可重复读隔离级别 (repeatable-read) 下验证的。同时,可重复读隔离级别遵守两阶段锁协议,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。

在最后的案例中,你可以清楚地知道 next-key lock 实际上是由间隙锁加行锁实现的。如果切换到读提交隔离级别 (read-committed) 的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。

其实读提交隔离级别在外键场景下还是有间隙锁,相对比较复杂,我们今天先不展开。

另外,在读提交隔离级别下还有一个优化,即:语句执行过程中加上的行锁,在语句执行完成后,就要把“不满足条件的行”上的行锁直接释放了,不需要等到事务提交。

也就是说,读提交隔离级别下,锁的范围更小,锁的时间更短,这也是不少业务都默认使用读提交隔离级别的原因。

不过,我希望你学过今天的课程以后,可以对 next-key lock 的概念有更清晰的认识,并且会用加锁规则去判断语句的加锁范围。

在业务需要使用可重复读隔离级别的时候,能够更细致地设计操作数据库的语句,解决幻读问题的同时,最大限度地提升系统并行处理事务的能力。

经过这篇文章的介绍,你再看一下上一篇文章最后的思考题,再来尝试分析一次。

我把题目重新描述和简化一下:还是我们在文章开头初始化的表 t,里面有 6 条记录,图 12 的语句序列中,为什么 session B 的 insert 操作,会被锁住呢?img

图 12 锁分析思考题

另外,如果你有兴趣多做一些实验的话,可以设计好语句序列,在执行之前先自己分析一下,然后实际地验证结果是否跟你的分析一致。

对于那些你自己无法解释的结果,可以发到评论区里,后面我争取挑一些有趣的案例在文章中分析。

你可以把你关于思考题的分析写在留言区,也可以分享你自己设计的锁验证方案,我会在下一篇文章的末尾选取有趣的评论跟大家分享。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题,我在本期继续作为了课后思考题,所以会在下篇文章再一起公布“答案”。

这里,我展开回答一下评论区几位同学的问题。

  • @令狐少侠 说,以前一直认为间隙锁只在二级索引上有。现在你知道了,有间隙的地方就可能有间隙锁。
  • @浪里白条 同学问,如果是 varchar 类型,加锁规则是什么样的。 回答:实际上在判断间隙的时候,varchar 和 int 是一样的,排好序以后,相邻两个值之间就有间隙。
  • 有几位同学提到说,上一篇文章自己验证的结果跟案例一不同,就是在 session A 执行完这两个语句:
begin;
select * from t where d=5 for update; /*Q1*/

以后,session B 的 update 和 session C 的 insert 都会被堵住。这是不是跟文章的结论矛盾?

其实不是的,这个例子用的是反证假设,就是假设不堵住,会出现问题;然后,推导出 session A 需要锁整个表所有的行和所有间隙。

评论区留言点赞板:

@ 某、人 、@郭江伟 两位同学尝试分析了上期问题,并给了有启发性的解答。

22 MySQL有哪些“饮鸩止渴”提高性能的方法?

不知道你在实际运维过程中有没有碰到这样的情景:业务高峰期,生产环境的 MySQL 压力太大,没法正常响应,需要短期内、临时性地提升一些性能。

我以前做业务护航的时候,就偶尔会碰上这种场景。用户的开发负责人说,不管你用什么方案,让业务先跑起来再说。

但,如果是无损方案的话,肯定不需要等到这个时候才上场。今天我们就来聊聊这些临时方案,并着重说一说它们可能存在的风险。

短连接风暴

正常的短连接模式就是连接到数据库后,执行很少的 SQL 语句就断开,下次需要的时候再重连。如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况。

我在第 1 篇文章[《基础架构:一条 SQL 查询语句是如何执行的?》]中说过,MySQL 建立连接的过程,成本是很高的。除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。

在数据库压力比较小的时候,这些额外的成本并不明显。

但是,短连接模型存在一个风险,就是一旦数据库处理得慢一些,连接数就会暴涨。max_connections 参数,用来控制一个 MySQL 实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。对于被拒绝连接的请求来说,从业务角度看就是数据库不可用。

在机器负载比较高的时候,处理现有请求的时间变长,每个连接保持的时间也更长。这时,再有新建连接的话,就可能会超过 max_connections 的限制。

碰到这种情况时,一个比较自然的想法,就是调高 max_connections 的值。但这样做是有风险的。因为设计 max_connections 这个参数的目的是想保护 MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到 CPU 资源去执行业务的 SQL 请求。

那么这种情况下,你还有没有别的建议呢?我这里还有两种方法,但要注意,这些方法都是有损的。

第一种方法:先处理掉那些占着连接但是不工作的线程。

max_connections 的计算,不是看谁在 running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过 kill connection 主动踢掉。这个行为跟事先设置 wait_timeout 的效果是一样的。设置 wait_timeout 参数表示的是,一个线程空闲 wait_timeout 这么多秒之后,就会被 MySQL 直接断开连接。

但是需要注意,在 show processlist 的结果里,踢掉显示为 sleep 的线程,可能是有损的。我们来看下面这个例子。

img

图 1 sleep 线程的两种状态

在上面这个例子里,如果断开 session A 的连接,因为这时候 session A 还没有提交,所以 MySQL 只能按照回滚事务来处理;而断开 session B 的连接,就没什么大影响。所以,如果按照优先级来说,你应该优先断开像 session B 这样的事务外空闲的连接。

但是,怎么判断哪些是事务外空闲的呢?session C 在 T 时刻之后的 30 秒执行 show processlist,看到的结果是这样的。

img

图 2 sleep 线程的两种状态,show processlist 结果

图中 id=4 和 id=5 的两个会话都是 Sleep 状态。而要看事务具体状态的话,你可以查 information_schema 库的 innodb_trx 表。

img

图 3 从 information_schema.innodb_trx 查询事务状态

这个结果里,trx_mysql_thread_id=4,表示 id=4 的线程还处在事务中。

因此,如果是连接数过多,你可以优先断开事务外空闲太久的连接;如果这样还不够,再考虑断开事务内空闲太久的连接。

从服务端断开连接使用的是 kill connection + id 的命令, 一个客户端处于 sleep 状态时,它的连接被服务端主动断开后,这个客户端并不会马上知道。直到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。

从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去,“MySQL 一直没恢复”。

你可能觉得这是一个冷笑话,但实际上我碰到过不下 10 次。

所以,如果你是一个支持业务的 DBA,不要假设所有的应用代码都会被正确地处理。即使只是一个断开连接的操作,也要确保通知到业务开发团队。

第二种方法:减少连接过程的消耗。

有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。

跳过权限验证的方法是:重启数据库,并使用–skip-grant-tables 参数启动。这样,整个 MySQL 会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。

但是,这种方法特别符合我们标题里说的“饮鸩止渴”,风险极高,是我特别不建议使用的方案。尤其你的库外网可访问的话,就更不能这么做了。

在 MySQL 8.0 版本里,如果你启用–skip-grant-tables 参数,MySQL 会默认把 –skip-networking 参数打开,表示这时候数据库只能被本地的客户端连接。可见,MySQL 官方对 skip-grant-tables 这个参数的安全问题也很重视。

除了短连接数暴增可能会带来性能问题外,实际上,我们在线上碰到更多的是查询或者更新语句导致的性能问题。其中,查询问题比较典型的有两类,一类是由新出现的慢查询导致的,一类是由 QPS(每秒查询数)突增导致的。而关于更新语句导致的性能问题,我会在下一篇文章和你展开说明。

慢查询性能问题

在 MySQL 中,会引发性能问题的慢查询,大体有以下三种可能:

  1. 索引没有设计好;
  2. SQL 语句没写好;
  3. MySQL 选错了索引。

接下来,我们就具体分析一下这三种可能,以及对应的解决方案。

导致慢查询的第一种可能是,索引没有设计好。

这种场景一般就是通过紧急创建索引来解决。MySQL 5.6 版本以后,创建索引都支持 Online DDL 了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行 alter table 语句。

比较理想的是能够在备库先执行。假设你现在的服务是一主一备,主库 A、备库 B,这个方案的大致流程是这样的:

  1. 在备库 B 上执行 set sql_log_bin=off,也就是不写 binlog,然后执行 alter table 语句加上索引;
  2. 执行主备切换;
  3. 这时候主库是 B,备库是 A。在 A 上执行 set sql_log_bin=off,然后执行 alter table 语句加上索引。

这是一个“古老”的 DDL 方案。平时在做变更的时候,你应该考虑类似 gh-ost 这样的方案,更加稳妥。但是在需要紧急处理时,上面这个方案的效率是最高的。

导致慢查询的第二种可能是,语句没写好。

比如,我们犯了在第 18 篇文章[《为什么这些 SQL 语句逻辑相同,性能却差异巨大?》]中提到的那些错误,导致语句没有使用上索引。

这时,我们可以通过改写 SQL 语句来处理。MySQL 5.7 提供了 query_rewrite 功能,可以把输入的一种语句改写成另外一种模式。

比如,语句被错误地写成了 select * from t where id + 1 = 10000,你可以通过下面的方式,增加一个语句改写规则。

mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1"); 
call query_rewrite.flush_rewrite_rules();

这里,call query_rewrite.flush_rewrite_rules() 这个存储过程,是让插入的新规则生效,也就是我们说的“查询重写”。你可以用图 4 中的方法来确认改写规则是否生效。

img

图 4 查询重写效果

导致慢查询的第三种可能,就是碰上了我们在第 10 篇文章[《MySQL 为什么有时候会选错索引?》]中提到的情况,MySQL 选错了索引。

这时候,应急方案就是给这个语句加上 force index。

同样地,使用查询重写功能,给原来的语句加上 force index,也可以解决这个问题。

上面我和你讨论的由慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,我们就可以预先发现问题。

  1. 上线前,在测试环境,把慢查询日志(slow log)打开,并且把 long_query_time 设置成 0,确保每个语句都会被记录入慢查询日志;
  2. 在测试表里插入模拟线上的数据,做一遍回归测试;
  3. 观察慢查询日志里每类语句的输出,特别留意 Rows_examined 字段是否与预期一致。(我们在前面文章中已经多次用到过 Rows_examined 方法了,相信你已经动手尝试过了。如果还有不明白的,欢迎给我留言,我们一起讨论)。

不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。

如果新增的 SQL 语句不多,手动跑一下就可以。而如果是新项目的话,或者是修改了原有项目的 表结构设计,全量回归测试都是必要的。这时候,你需要工具帮你检查所有的 SQL 语句的返回结果。比如,你可以使用开源工具 pt-query-digest(https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html)。

QPS 突增问题

有时候由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。

我之前碰到过一类情况,是由一个新功能的 bug 导致的。当然,最理想的情况是让业务把这个功能下掉,服务自然就会恢复。

而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。我这里再和你展开说明一下。

  1. 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
  2. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
  3. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成”select 1”返回。

当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用:

  1. 如果别的功能里面也用到了这个 SQL 语句模板,会有误伤;
  2. 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以 select 1 的结果返回的话,可能会导致后面的业务逻辑一起失败。

所以,方案 3 是用于止血的,跟前面提到的去掉权限验证一样,应该是你所有选项里优先级最低的一个方案。

同时你会发现,其实方案 1 和 2 都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。由此可见,更多的准备,往往意味着更稳定的系统。

小结

今天这篇文章,我以业务高峰期的性能问题为背景,和你介绍了一些紧急处理的手段。

这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法;既有临时的高危方案,也有未雨绸缪的、相对安全的预案。

在实际开发中,我们也要尽量避免一些低效的方法,比如避免大量地使用短连接。同时,如果你做业务开发的话,要知道,连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制。

DBA 虽然可以通过语句重写来暂时处理问题,但是这本身是一个风险高的操作,做好 SQL 审计可以减少需要这类操作的机会。

其实,你可以看得出来,在这篇文章中我提到的解决方法主要集中在 server 层。在下一篇文章中,我会继续和你讨论一些跟 InnoDB 有关的处理方法。

最后,又到了我们的思考题时间了。

今天,我留给你的课后问题是,你是否碰到过,在业务高峰期需要临时救火的场景?你又是怎么处理的呢?

你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

前两期我给你留的问题是,下面这个图的执行序列中,为什么 session B 的 insert 语句会被堵住。

img我们用上一篇的加锁规则来分析一下,看看 session A 的 select 语句加了哪些锁:

  1. 由于是 order by c desc,第一个要定位的是索引 c 上“最右边的”c=20 的行,所以会加上间隙锁 (20,25) 和 next-key lock (15,20]。
  2. 在索引 c 上向左遍历,要扫描到 c=10 才停下来,所以 next-key lock 会加到 (5,10],这正是阻塞 session B 的 insert 语句的原因。
  3. 在扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁。

因此,session A 的 select 语句锁的范围就是:

  1. 索引 c 上 (5, 25);
  2. 主键索引上 id=15、20 两个行锁。

这里,我再啰嗦下,你会发现我在文章中,每次加锁都会说明是加在“哪个索引上”的。因为,锁就是加在索引上的,这是 InnoDB 的一个基础设定,需要你在分析问题的时候要一直记得。

评论区留言点赞板:

@HuaMax 给出了正确的解释。

@Justin 同学提了个好问题,<= 到底是间隙锁还是行锁?其实,这个问题,你要跟“执行过程”配合起来分析。在 InnoDB 要去找“第一个值”的时候,是按照等值去找的,用的是等值判断的规则;找到第一个值以后,要在索引内找“下一个值”,对应于我们规则中说的范围查找。

@信信 提了一个不错的问题,要知道最终的加锁是根据实际执行情况来的。所以,如果一个 select * from … for update 语句,优化器决定使用全表扫描,那么就会把主键索引上 next-key lock 全加上。

@nero 同学的问题,提示我需要提醒大家注意,“有行”才会加行锁。如果查询条件没有命中行,那就加 next-key lock。当然,等值判断的时候,需要加上优化 2(即:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。)。

@小李子、@发条橙子同学,都提了很好的问题,这期高质量评论很多,你也都可以去看看。

最后,我要为元旦期间还坚持学习的同学们,点个赞 ^_^

23 MySQL是怎么保证数据不丢的?

今天这篇文章,我会继续和你介绍在业务高峰期临时提升性能的方法。从文章标题“MySQL 是怎么保证数据不丢的?”,你就可以看出来,今天我和你介绍的方法,跟数据的可靠性有关。

在专栏前面文章和答疑篇中,我都着重介绍了 WAL 机制(你可以再回顾下[第 2 篇]、[第 9 篇]、[第 12 篇]和[第 15 篇]文章中的相关内容),得到的结论是:只要 redo log 和 binlog 保证持久化到磁盘,就能确保 MySQL 异常重启后,数据可以恢复。

评论区有同学又继续追问,redo log 的写入流程是怎么样的,如何保证 redo log 真实地写入了磁盘。那么今天,我们就再一起看看 MySQL 写入 binlog 和 redo log 的流程。

binlog 的写入机制

其实,binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。

一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。状态如图 1 所示。

img

图 1 binlog 写盘状态

可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件。

  • 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
  • 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。

write 和 fsync 的时机,是由参数 sync_binlog 控制的:

  1. sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
  2. sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
  3. sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。

但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启,会丢失最近 N 个事务的 binlog 日志。

redo log 的写入机制

接下来,我们再说说 redo log 的写入机制。

在专栏的[第 15 篇答疑文章]中,我给你介绍了 redo log buffer。事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的。

然后就有同学问了,redo log buffer 里面的内容,是不是每次生成后都要直接持久化到磁盘呢?

答案是,不需要。

如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。

那么,另外一个问题是,事务还没提交的时候,redo log buffer 中的部分日志有没有可能被持久化到磁盘呢?

答案是,确实会有。

这个问题,要从 redo log 可能存在的三种状态说起。这三种状态,对应的就是图 2 中的三个颜色块。

img

图 2 MySQL redo log 存储状态

这三种状态分别是:

  1. 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
  2. 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
  3. 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。

日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。

为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:

  1. 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
  2. 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
  3. 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。

注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的 redo log,也是可能已经持久化到磁盘的。

实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的 redo log 写入到磁盘中。

  1. 一种是,redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
  2. 另一种是,并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。

这里需要说明的是,我们介绍两阶段提交的时候说过,时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。

如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。(如果你印象有点儿模糊了,可以再回顾下[第 15 篇文章]中的相关内容)。

每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 的时候就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。

通常我们说 MySQL 的“双 1”配置,指的就是 sync_binlog 和 innodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。

这时候,你可能有一个疑问,这意味着我从 MySQL 看到的 TPS 是每秒两万的话,每秒就会写四万次磁盘。但是,我用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的 TPS?

解释这个问题,就要用到组提交(group commit)机制了。

这里,我需要先和你介绍日志逻辑序列号(log sequence number,LSN)的概念。LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。

LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。关于 LSN 和 redo log、checkpoint 的关系,我会在后面的文章中详细展开。

如图 3 所示,是三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。

img

图 3 redo log 组提交

从图中可以看到,

  1. trx1 是第一个到达的,会被选为这组的 leader;
  2. 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
  3. trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
  4. 这时候 trx2 和 trx3 就可以直接返回了。

所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。

在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。

为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。在介绍两阶段提交的时候,我曾经给你画了一个图,现在我把它截过来。

img

图 4 两阶段提交

图中,我把“写 binlog”当成一个动作。但实际上,写 binlog 是分成两步的:

  1. 先把 binlog 从 binlog cache 中写到磁盘上的 binlog 文件;
  2. 调用 fsync 持久化。

MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:

img

图 5 两阶段提交细化

这么一来,binlog 也可以组提交了。在执行图 5 中第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。

不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。

如果你想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 来实现。

  1. binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
  2. binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

这两个条件是或的关系,也就是说只要有一个满足条件就会调用 fsync。

所以,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。

之前有同学在评论区问到,WAL 机制是减少磁盘写,可是每次提交事务都要写 redo log 和 binlog,这磁盘读写次数也没变少呀?

现在你就能理解了,WAL 机制主要得益于两个方面:

  1. redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
  2. 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。

分析到这里,我们再来回答这个问题:如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?

针对这个问题,可以考虑以下三种方法:

  1. 设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险。
  2. 将 sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
  3. 将 innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

我不建议你把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小。

小结

在专栏的[第 2 篇]和[第 15 篇]文章中,我和你分析了,如果 redo log 和 binlog 是完整的,MySQL 是如何保证 crash-safe 的。今天这篇文章,我着重和你介绍的是 MySQL 是“怎么保证 redo log 和 binlog 是完整的”。

希望这三篇文章串起来的内容,能够让你对 crash-safe 这个概念有更清晰的理解。

之前的第 15 篇答疑文章发布之后,有同学继续留言问到了一些跟日志相关的问题,这里为了方便你回顾、学习,我再集中回答一次这些问题。

问题 1:执行一个 update 语句以后,我再去执行 hexdump 命令直接查看 ibd 文件内容,为什么没有看到数据有改变呢?

回答:这可能是因为 WAL 机制的原因。update 语句执行完成后,InnoDB 只保证写完了 redo log、内存,可能还没来得及将数据写到磁盘。

问题 2:为什么 binlog cache 是每个线程自己维护的,而 redo log buffer 是全局共用的?

回答:MySQL 这么设计的主要原因是,binlog 是不能“被打断的”。一个事务的 binlog 必须连续写,因此要整个事务完成后,再一起写到文件里。

而 redo log 并没有这个要求,中间有生成的日志可以写到 redo log buffer 中。redo log buffer 中的内容还能“搭便车”,其他事务提交的时候可以被一起写到磁盘中。

问题 3:事务执行期间,还没到提交阶段,如果发生 crash 的话,redo log 肯定丢了,这会不会导致主备不一致呢?

回答:不会。因为这时候 binlog 也还在 binlog cache 里,没发给备库。crash 以后 redo log 和 binlog 都没有了,从业务角度看这个事务也没有提交,所以数据是一致的。

问题 4:如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?

回答:不是。

你可以设想一下更极端的情况,整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。这种也只能算是事务成功的,不能认为是 bug。

实际上数据库的 crash-safe 保证的是:

  1. 如果客户端收到事务成功的消息,事务就一定持久化了;
  2. 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
  3. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。

最后,又到了课后问题时间。

今天我留给你的思考题是:你的生产库设置的是“双 1”吗? 如果平时是的话,你有在什么场景下改成过“非双 1”吗?你的这个操作又是基于什么决定的?

另外,我们都知道这些设置可能有损,如果发生了异常,你的止损方案是什么?

你可以把你的理解或者经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上篇文章最后,想要你分享的是线上“救火”的经验。

@Long 同学,在留言中提到了几个很好的场景。

  • 其中第 3 个问题,“如果一个数据库是被客户端的压力打满导致无法响应的,重启数据库是没用的。”,说明他很好地思考了。 这个问题是因为重启之后,业务请求还会再发。而且由于是重启,buffer pool 被清空,可能会导致语句执行得更慢。
  • 他提到的第 4 个问题也很典型。有时候一个表上会出现多个单字段索引(而且往往这是因为运维工程师对索引原理不够清晰做的设计),这样就可能出现优化器选择索引合并算法的现象。但实际上,索引合并算法的效率并不好。而通过将其中的一个索引改成联合索引的方法,是一个很好的应对方案。

还有其他几个同学提到的问题场景,也很好,很值得你一看。

@Max 同学提到一个很好的例子:客户端程序的连接器,连接完成后会做一些诸如 show columns 的操作,在短连接模式下这个影响就非常大了。 这个提醒我们,在 review 项目的时候,不止要 review 我们自己业务的代码,也要 review 连接器的行为。一般做法就是在测试环境,把 general_log 打开,用业务行为触发连接,然后通过 general log 分析连接器的行为。

@Manjusaka 同学的留言中,第二点提得非常好:如果你的数据库请求模式直接对应于客户请求,这往往是一个危险的设计。因为客户行为不可控,可能突然因为你们公司的一个运营推广,压力暴增,这样很容易把数据库打挂。 在设计模型里面设计一层,专门负责管理请求和数据库服务资源,对于比较重要和大流量的业务,是一个好的设计方向。

@Vincent 同学提了一个好问题,用文中提到的 DDL 方案,会导致 binlog 里面少了这个 DDL 语句,后续影响备份恢复的功能。由于需要另一个知识点(主备同步协议),我放在后面的文章中说明。

24 MySQL是怎么保证主备一致的?

在前面的文章中,我不止一次地和你提到了 binlog,大家知道 binlog 可以用来归档,也可以用来做主备同步,但它的内容是什么样的呢?为什么备库执行了 binlog 就可以跟主库保持一致了呢?今天我就正式地和你介绍一下它。

毫不夸张地说,MySQL 能够成为现下最流行的开源数据库,binlog 功不可没。

在最开始,MySQL 是以容易学习和方便的高可用架构,被开发人员青睐的。而它的几乎所有的高可用架构,都直接依赖于 binlog。虽然这些高可用架构已经呈现出越来越复杂的趋势,但都是从最基本的一主一备演化过来的。

今天这篇文章我主要为你介绍主备的基本原理。理解了背后的设计原理,你也可以从业务开发的角度,来借鉴这些设计思想。

MySQL 主备的基本原理

如图 1 所示就是基本的主备切换流程。

img

图 1 MySQL 主备切换流程

在状态 1 中,客户端的读写都直接访问节点 A,而节点 B 是 A 的备库,只是将 A 的更新都同步过来,到本地执行。这样可以保持节点 B 和 A 的数据是相同的。

当需要切换的时候,就切成状态 2。这时候客户端读写访问的都是节点 B,而节点 A 是 B 的备库。

在状态 1 中,虽然节点 B 没有被直接访问,但是我依然建议你把节点 B(也就是备库)设置成只读(readonly)模式。这样做,有以下几个考虑:

  1. 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
  2. 防止切换逻辑有 bug,比如切换过程中出现双写,造成主备不一致;
  3. 可以用 readonly 状态,来判断节点的角色。

你可能会问,我把备库设置成只读了,还怎么跟主库保持同步更新呢?

这个问题,你不用担心。因为 readonly 设置对超级 (super) 权限用户是无效的,而用于同步更新的线程,就拥有超级权限。

接下来,我们再看看节点 A 到 B 这条线的内部流程是什么样的。图 2 中画出的就是一个 update 语句在节点 A 执行,然后同步到节点 B 的完整流程图。

img

图 2 主备流程图

图 2 中,包含了我在上一篇文章中讲到的 binlog 和 redo log 的写入机制相关的内容,可以看到:主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写 binlog。

备库 B 跟主库 A 之间维持了一个长连接。主库 A 内部有一个线程,专门用于服务备库 B 的这个长连接。一个事务日志同步的完整过程是这样的:

  1. 在备库 B 上通过 change master 命令,设置主库 A 的 IP、端口、用户名、密码,以及要从哪个位置开始请求 binlog,这个位置包含文件名和日志偏移量。
  2. 在备库 B 上执行 start slave 命令,这时候备库会启动两个线程,就是图中的 io_thread 和 sql_thread。其中 io_thread 负责与主库建立连接。
  3. 主库 A 校验完用户名、密码后,开始按照备库 B 传过来的位置,从本地读取 binlog,发给 B。
  4. 备库 B 拿到 binlog 后,写到本地文件,称为中转日志(relay log)。
  5. sql_thread 读取中转日志,解析出日志里的命令,并执行。

这里需要说明,后来由于多线程复制方案的引入,sql_thread 演化成为了多个线程,跟我们今天要介绍的原理没有直接关系,暂且不展开。

分析完了这个长连接的逻辑,我们再来看一个问题:binlog 里面到底是什么内容,为什么备库拿过去可以直接执行。

binlog 的三种格式对比

我在[第 15 篇答疑文章]中,和你提到过 binlog 有两种格式,一种是 statement,一种是 row。可能你在其他资料上还会看到有第三种格式,叫作 mixed,其实它就是前两种格式的混合。

为了便于描述 binlog 的这三种格式间的区别,我创建了一个表,并初始化几行数据。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `a` (`a`),
  KEY `t_modified`(`t_modified`)
) ENGINE=InnoDB; 
insert into t values(1,1,'2018-11-13');
insert into t values(2,2,'2018-11-12');
insert into t values(3,3,'2018-11-11');
insert into t values(4,4,'2018-11-10');
insert into t values(5,5,'2018-11-09');

如果要在表中删除一行数据的话,我们来看看这个 delete 语句的 binlog 是怎么记录的。

注意,下面这个语句包含注释,如果你用 MySQL 客户端来做这个实验的话,要记得加 -c 参数,否则客户端会自动去掉注释。

mysql> delete from t /*comment*/  where a>=4 and t_modified<='2018-11-10' limit 1;

当 binlog_format=statement 时,binlog 里面记录的就是 SQL 语句的原文。你可以用

mysql> show binlog events in 'master.000001';

命令看 binlog 中的内容。

img

图 3 statement 格式 binlog 示例

现在,我们来看一下图 3 的输出结果。

  • 第一行 SET @@SESSION.GTID_NEXT=‘ANONYMOUS’你可以先忽略,后面文章我们会在介绍主备切换的时候再提到;
  • 第二行是一个 BEGIN,跟第四行的 commit 对应,表示中间是一个事务;
  • 第三行就是真实执行的语句了。可以看到,在真实执行的 delete 命令之前,还有一个“use ‘test’”命令。这条命令不是我们主动执行的,而是 MySQL 根据当前要操作的表所在的数据库,自行添加的。这样做可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到 test 库的表 t。 use ‘test’命令之后的 delete 语句,就是我们输入的 SQL 原文了。可以看到,binlog“忠实”地记录了 SQL 命令,甚至连注释也一并记录了。
  • 最后一行是一个 COMMIT。你可以看到里面写着 xid=61。你还记得这个 XID 是做什么用的吗?如果记忆模糊了,可以再回顾一下[第 15 篇文章]中的相关内容。

为了说明 statement 和 row 格式的区别,我们来看一下这条 delete 命令的执行效果图:

img

图 4 delete 执行 warnings

可以看到,运行这条 delete 命令产生了一个 warning,原因是当前 binlog 设置的是 statement 格式,并且语句中有 limit,所以这个命令可能是 unsafe 的。

为什么这么说呢?这是因为 delete 带 limit,很可能会出现主备数据不一致的情况。比如上面这个例子:

  1. 如果 delete 语句使用的是索引 a,那么会根据索引 a 找到第一个满足条件的行,也就是说删除的是 a=4 这一行;
  2. 但如果使用的是索引 t_modified,那么删除的就是 t_modified=‘2018-11-09’也就是 a=5 这一行。

由于 statement 格式下,记录到 binlog 里的是语句原文,因此可能会出现这样一种情况:在主库执行这条 SQL 语句的时候,用的是索引 a;而在备库执行这条 SQL 语句的时候,却使用了索引 t_modified。因此,MySQL 认为这样写是有风险的。

那么,如果我把 binlog 的格式改为 binlog_format=‘row’, 是不是就没有这个问题了呢?我们先来看看这时候 binog 中的内容吧。

img

图 5 row 格式 binlog 示例

可以看到,与 statement 格式的 binlog 相比,前后的 BEGIN 和 COMMIT 是一样的。但是,row 格式的 binlog 里没有了 SQL 语句的原文,而是替换成了两个 event:Table_map 和 Delete_rows。

  1. Table_map event,用于说明接下来要操作的表是 test 库的表 t;
  2. Delete_rows event,用于定义删除的行为。

其实,我们通过图 5 是看不到详细信息的,还需要借助 mysqlbinlog 工具,用下面这个命令解析和查看 binlog 中的内容。因为图 5 中的信息显示,这个事务的 binlog 是从 8900 这个位置开始的,所以可以用 start-position 参数来指定从这个位置的日志开始解析。

mysqlbinlog  -vv data/master.000001 --start-position=8900;

img

图 6 row 格式 binlog 示例的详细信息

从这个图中,我们可以看到以下几个信息:

  • server id 1,表示这个事务是在 server_id=1 的这个库上执行的。
  • 每个 event 都有 CRC32 的值,这是因为我把参数 binlog_checksum 设置成了 CRC32。
  • Table_map event 跟在图 5 中看到的相同,显示了接下来要打开的表,map 到数字 226。现在我们这条 SQL 语句只操作了一张表,如果要操作多张表呢?每个表都有一个对应的 Table_map event、都会 map 到一个单独的数字,用于区分对不同表的操作。
  • 我们在 mysqlbinlog 的命令中,使用了 -vv 参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4、 @2=4 这些值)。
  • binlog_row_image 的默认配置是 FULL,因此 Delete_event 里面,包含了删掉的行的所有字段的值。如果把 binlog_row_image 设置为 MINIMAL,则只会记录必要的信息,在这个例子里,就是只会记录 id=4 这个信息。
  • 最后的 Xid event,用于表示事务被正确地提交了。

你可以看到,当 binlog_format 使用 row 格式的时候,binlog 里面记录了真实删除行的主键 id,这样 binlog 传到备库去的时候,就肯定会删除 id=4 的行,不会有主备删除不同行的问题。

为什么会有 mixed 格式的 binlog?

基于上面的信息,我们来讨论一个问题:为什么会有 mixed 这种 binlog 格式的存在场景?推论过程是这样的:

  • 因为有些 statement 格式的 binlog 可能会导致主备不一致,所以要使用 row 格式。
  • 但 row 格式的缺点是,很占空间。比如你用一个 delete 语句删掉 10 万行数据,用 statement 的话就是一个 SQL 语句被记录到 binlog 中,占用几十个字节的空间。但如果用 row 格式的 binlog,就要把这 10 万条记录都写到 binlog 中。这样做,不仅会占用更大的空间,同时写 binlog 也要耗费 IO 资源,影响执行速度。
  • 所以,MySQL 就取了个折中方案,也就是有了 mixed 格式的 binlog。mixed 格式的意思是,MySQL 自己会判断这条 SQL 语句是否可能引起主备不一致,如果有可能,就用 row 格式,否则就用 statement 格式。

也就是说,mixed 格式可以利用 statment 格式的优点,同时又避免了数据不一致的风险。

因此,如果你的线上 MySQL 设置的 binlog 格式是 statement 的话,那基本上就可以认为这是一个不合理的设置。你至少应该把 binlog 的格式设置为 mixed。

比如我们这个例子,设置为 mixed 后,就会记录为 row 格式;而如果执行的语句去掉 limit 1,就会记录为 statement 格式。

当然我要说的是,现在越来越多的场景要求把 MySQL 的 binlog 格式设置成 row。这么做的理由有很多,我来给你举一个可以直接看出来的好处:恢复数据

接下来,我们就分别从 delete、insert 和 update 这三种 SQL 语句的角度,来看看数据恢复的问题。

通过图 6 你可以看出来,即使我执行的是 delete 语句,row 格式的 binlog 也会把被删掉的行的整行信息保存起来。所以,如果你在执行完一条 delete 语句以后,发现删错数据了,可以直接把 binlog 中记录的 delete 语句转成 insert,把被错删的数据插入回去就可以恢复了。

如果你是执行错了 insert 语句呢?那就更直接了。row 格式下,insert 语句的 binlog 里会记录所有的字段信息,这些信息可以用来精确定位刚刚被插入的那一行。这时,你直接把 insert 语句转成 delete 语句,删除掉这被误插入的一行数据就可以了。

如果执行的是 update 语句的话,binlog 里面会记录修改前整行的数据和修改后的整行数据。所以,如果你误执行了 update 语句的话,只需要把这个 event 前后的两行信息对调一下,再去数据库里面执行,就能恢复这个更新操作了。

其实,由 delete、insert 或者 update 语句导致的数据操作错误,需要恢复到操作之前状态的情况,也时有发生。MariaDB 的Flashback工具就是基于上面介绍的原理来回滚数据的。

虽然 mixed 格式的 binlog 现在已经用得不多了,但这里我还是要再借用一下 mixed 格式来说明一个问题,来看一下这条 SQL 语句:

mysql> insert into t values(10,10, now());

如果我们把 binlog 格式设置为 mixed,你觉得 MySQL 会把它记录为 row 格式还是 statement 格式呢?

先不要着急说结果,我们一起来看一下这条语句执行的效果。

img

图 7 mixed 格式和 now()

可以看到,MySQL 用的居然是 statement 格式。你一定会奇怪,如果这个 binlog 过了 1 分钟才传给备库的话,那主备的数据不就不一致了吗?

接下来,我们再用 mysqlbinlog 工具来看看:

img

图 8 TIMESTAMP 命令

从图中的结果可以看到,原来 binlog 在记录 event 的时候,多记了一条命令:SET TIMESTAMP=1546103491。它用 SET TIMESTAMP 命令约定了接下来的 now() 函数的返回时间。

因此,不论这个 binlog 是 1 分钟之后被备库执行,还是 3 天后用来恢复这个库的备份,这个 insert 语句插入的行,值都是固定的。也就是说,通过这条 SET TIMESTAMP 命令,MySQL 就确保了主备数据的一致性。

我之前看过有人在重放 binlog 数据的时候,是这么做的:用 mysqlbinlog 解析出日志,然后把里面的 statement 语句直接拷贝出来执行。

你现在知道了,这个方法是有风险的。因为有些语句的执行结果是依赖于上下文命令的,直接执行的结果很可能是错误的。

所以,用 binlog 来恢复数据的标准做法是,用 mysqlbinlog 工具解析出来,然后把解析结果整个发给 MySQL 执行。类似下面的命令:

mysqlbinlog master.000001  --start-position=2738 --stop-position=2973 | mysql -h127.0.0.1 -P13000 -u$user -p$pwd;

这个命令的意思是,将 master.000001 文件里面从第 2738 字节到第 2973 字节中间这段内容解析出来,放到 MySQL 去执行。

循环复制问题

通过上面对 MySQL 中 binlog 基本内容的理解,你现在可以知道,binlog 的特性确保了在备库执行相同的 binlog,可以得到与主库相同的状态。

因此,我们可以认为正常情况下主备的数据是一致的。也就是说,图 1 中 A、B 两个节点的内容是一致的。其实,图 1 中我画的是 M-S 结构,但实际生产上使用比较多的是双 M 结构,也就是图 9 所示的主备切换流程。

img

图 9 MySQL 主备切换流程 – 双 M 结构

对比图 9 和图 1,你可以发现,双 M 结构和 M-S 结构,其实区别只是多了一条线,即:节点 A 和 B 之间总是互为主备关系。这样在切换的时候就不用再修改主备关系。

但是,双 M 结构还有一个问题需要解决。

业务逻辑在节点 A 上更新了一条语句,然后再把生成的 binlog 发给节点 B,节点 B 执行完这条更新语句后也会生成 binlog。(我建议你把参数 log_slave_updates 设置为 on,表示备库执行 relay log 后生成 binlog)。

那么,如果节点 A 同时是节点 B 的备库,相当于又把节点 B 新生成的 binlog 拿过来执行了一次,然后节点 A 和 B 间,会不断地循环执行这个更新语句,也就是循环复制了。这个要怎么解决呢?

从上面的图 6 中可以看到,MySQL 在 binlog 中记录了这个命令第一次执行时所在实例的 server id。因此,我们可以用下面的逻辑,来解决两个节点间的循环复制的问题:

  1. 规定两个库的 server id 必须不同,如果相同,则它们之间不能设定为主备关系;
  2. 一个备库接到 binlog 并在重放的过程中,生成与原 binlog 的 server id 相同的新的 binlog;
  3. 每个库在收到从自己的主库发过来的日志后,先判断 server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志。

按照这个逻辑,如果我们设置了双 M 结构,日志的执行流就会变成这样:

  1. 从节点 A 更新的事务,binlog 里面记的都是 A 的 server id;
  2. 传到节点 B 执行一次以后,节点 B 生成的 binlog 的 server id 也是 A 的 server id;
  3. 再传回给节点 A,A 判断到这个 server id 与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了。

小结

今天这篇文章,我给你介绍了 MySQL binlog 的格式和一些基本机制,是后面我要介绍的读写分离等系列文章的背景知识,希望你可以认真消化理解。

binlog 在 MySQL 的各种高可用方案上扮演了重要角色。今天介绍的可以说是所有 MySQL 高可用方案的基础。在这之上演化出了诸如多节点、半同步、MySQL group replication 等相对复杂的方案。

我也跟你介绍了 MySQL 不同格式 binlog 的优缺点,和设计者的思考。希望你在做系统开发时候,也能借鉴这些设计思想。

最后,我给你留下一个思考题吧。

说到循环复制问题的时候,我们说 MySQL 通过判断 server id 的方式,断掉死循环。但是,这个机制其实并不完备,在某些场景下,还是有可能出现死循环。

你能构造出一个这样的场景吗?又应该怎么解决呢?

你可以把你的设计和分析写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期我留给你的问题是,你在什么时候会把线上生产库设置成“非双 1”。我目前知道的场景,有以下这些:

  1. 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
  2. 备库延迟,为了让备库尽快赶上主库。@永恒记忆和 @Second Sight 提到了这个场景。
  3. 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似。
  4. 批量导入数据的时候。

一般情况下,把生产库改成“非双 1”配置,是设置 innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。

评论区留言点赞板:

@way 同学提到了一个有趣的现象,由于从库设置了 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count 导致一直延迟的情况。我们在主库设置这两个参数,是为了减少 binlog 的写盘压力。备库这么设置,尤其在“快要追上”的时候,就反而会受这两个参数的拖累。一般追主备就用“非双 1”(追上记得改回来)。

@一大只 同学验证了在 sync_binlog=0 的情况下,设置 sync_delay 和 sync_no_delay_count 的现象,点赞这种发现边界的意识和手动验证的好习惯。是这样的:sync_delay 和 sync_no_delay_count 的逻辑先走,因此该等还是会等。等到满足了这两个条件之一,就进入 sync_binlog 阶段。这时候如果判断 sync_binlog=0,就直接跳过,还是不调 fsync。

@锅子 同学提到,设置 sync_binlog=0 的时候,还是可以看到 binlog 文件马上做了修改。这个是对的,我们说“写到了 page cache”,就是文件系统的 page cache。而你用 ls 命令看到的就是文件系统返回的结果。

25 MySQL是怎么保证高可用的?

在上一篇文章中,我和你介绍了 binlog 的基本内容,在一个主备关系中,每个备库接收主库的 binlog 并执行。

正常情况下,只要主库执行更新生成的所有 binlog,都可以传到备库并被正确地执行,备库就能达到跟主库一致的状态,这就是最终一致性。

但是,MySQL 要提供高可用能力,只有最终一致性是不够的。为什么这么说呢?今天我就着重和你分析一下。

这里,我再放一次上一篇文章中讲到的双 M 结构的主备切换流程图。

img

图 1 MySQL 主备切换流程 – 双 M 结构

主备延迟

主备切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。

接下来,我们先一起看看主动切换的场景。

在介绍主动切换流程的详细步骤之前,我要先跟你说明一个概念,即“同步延迟”。与数据同步有关的时间点主要包括以下三个:

  1. 主库 A 执行完成一个事务,写入 binlog,我们把这个时刻记为 T1;
  2. 之后传给备库 B,我们把备库 B 接收完这个 binlog 的时刻记为 T2;
  3. 备库 B 执行完成这个事务,我们把这个时刻记为 T3。

所谓主备延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是 T3-T1。

你可以在备库上执行 show slave status 命令,它的返回结果里面会显示 seconds_behind_master,用于表示当前备库延迟了多少秒。

seconds_behind_master 的计算方法是这样的:

  1. 每个事务的 binlog 里面都有一个时间字段,用于记录主库上写入的时间;
  2. 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到 seconds_behind_master。

可以看到,其实 seconds_behind_master 这个参数计算的就是 T3-T1。所以,我们可以用 seconds_behind_master 来作为主备延迟的值,这个值的时间精度是秒。

你可能会问,如果主备库机器的系统时间设置不一致,会不会导致主备延迟的值不准?

其实不会的。因为,备库连接到主库的时候,会通过执行 SELECT UNIX_TIMESTAMP() 函数来获得当前主库的系统时间。如果这时候发现主库的系统时间与自己不一致,备库在执行 seconds_behind_master 计算的时候会自动扣掉这个差值。

需要说明的是,在网络正常的时候,日志从主库传给备库所需的时间是很短的,即 T2-T1 的值是非常小的。也就是说,网络正常情况下,主备延迟的主要来源是备库接收完 binlog 和执行完这个事务之间的时间差。

所以说,主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产 binlog 的速度要慢。接下来,我就和你一起分析下,这可能是由哪些原因导致的。

主备延迟的来源

首先,有些部署条件下,备库所在机器的性能要比主库所在的机器性能差。

一般情况下,有人这么部署时的想法是,反正备库没有请求,所以可以用差一点儿的机器。或者,他们会把 20 个主库放在 4 台机器上,而把备库集中在一台机器上。

其实我们都知道,更新请求对 IOPS 的压力,在主库和备库上是无差别的。所以,做这种部署时,一般都会将备库设置为“非双 1”的模式。

但实际上,更新过程中也会触发大量的读操作。所以,当备库主机上的多个备库都在争抢资源的时候,就可能会导致主备延迟了。

当然,这种部署现在比较少了。因为主备可能发生切换,备库随时可能变成主库,所以主备库选用相同规格的机器,并且做对称部署,是现在比较常见的情况。

追问 1:但是,做了对称部署以后,还可能会有延迟。这是为什么呢?

这就是第二种常见的可能了,即备库的压力大。一般的想法是,主库既然提供了写能力,那么备库可以提供一些读能力。或者一些运营后台需要的分析语句,不能影响正常业务,所以只能在备库上跑。

我真就见过不少这样的情况。由于主库直接影响业务,大家使用起来会比较克制,反而忽视了备库的压力控制。结果就是,备库上的查询耗费了大量的 CPU 资源,影响了同步速度,造成主备延迟。

这种情况,我们一般可以这么处理:

  1. 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
  2. 通过 binlog 输出到外部系统,比如 Hadoop 这类系统,让外部系统提供统计类查询的能力。

其中,一主多从的方式大都会被采用。因为作为数据库系统,还必须保证有定期全量备份的能力。而从库,就很适合用来做备份。

备注:这里需要说明一下,从库和备库在概念上其实差不多。在我们这个专栏里,为了方便描述,我把会在 HA 过程中被选成新主库的,称为备库,其他的称为从库。

追问 2:采用了一主多从,保证备库的压力不会超过主库,还有什么情况可能导致主备延迟吗?

这就是第三种可能了,即大事务。

大事务这种情况很好理解。因为主库上必须等事务执行完成才会写入 binlog,再传给备库。所以,如果一个主库上的语句执行 10 分钟,那这个事务很可能就会导致从库延迟 10 分钟。

不知道你所在公司的 DBA 有没有跟你这么说过:不要一次性地用 delete 语句删除太多数据。其实,这就是一个典型的大事务场景。

比如,一些归档类的数据,平时没有注意删除历史数据,等到空间快满了,业务开发人员要一次性地删掉大量历史数据。同时,又因为要避免在高峰期操作会影响业务(至少有这个意识还是很不错的),所以会在晚上执行这些大量数据的删除操作。

结果,负责的 DBA 同学半夜就会收到延迟报警。然后,DBA 团队就要求你后续再删除数据的时候,要控制每个事务删除的数据量,分成多次删除。

另一种典型的大事务场景,就是大表 DDL。这个场景,我在前面的文章中介绍过。处理方案就是,计划内的 DDL,建议使用 gh-ost 方案(这里,你可以再回顾下第 13 篇文章[《为什么表数据删掉一半,表文件大小不变?》]中的相关内容)。

追问 3:如果主库上也不做大事务了,还有什么原因会导致主备延迟吗?

造成主备延迟还有一个大方向的原因,就是备库的并行复制能力。这个话题,我会留在下一篇文章再和你详细介绍。

其实还是有不少其他情况会导致主备延迟,如果你还碰到过其他场景,欢迎你在评论区给我留言,我来和你一起分析、讨论。

由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。

可靠性优先策略

在图 1 的双 M 结构下,从状态 1 到状态 2 切换的详细过程是这样的:

  1. 判断备库 B 现在的 seconds_behind_master,如果小于某个值(比如 5 秒)继续下一步,否则持续重试这一步;
  2. 把主库 A 改成只读状态,即把 readonly 设置为 true;
  3. 判断备库 B 的 seconds_behind_master 的值,直到这个值变成 0 为止;
  4. 把备库 B 改成可读写状态,也就是把 readonly 设置为 false;
  5. 把业务请求切到备库 B。

这个切换流程,一般是由专门的 HA 系统来完成的,我们暂时称之为可靠性优先流程。

img

图 2 MySQL 可靠性优先主备切换流程

备注:图中的 SBM,是 seconds_behind_master 参数的简写。

可以看到,这个切换流程中是有不可用时间的。因为在步骤 2 之后,主库 A 和备库 B 都处于 readonly 状态,也就是说这时系统处于不可写状态,直到步骤 5 完成后才能恢复。

在这个不可用状态中,比较耗费时间的是步骤 3,可能需要耗费好几秒的时间。这也是为什么需要在步骤 1 先做判断,确保 seconds_behind_master 的值足够小。

试想如果一开始主备延迟就长达 30 分钟,而不先做判断直接切换的话,系统的不可用时间就会长达 30 分钟,这种情况一般业务都是不可接受的。

当然,系统的不可用时间,是由这个数据可靠性优先的策略决定的。你也可以选择可用性优先的策略,来把这个不可用时间几乎降为 0。

可用性优先策略

如果我强行把步骤 4、5 调整到最开始执行,也就是说不等主备数据同步,直接把连接切到备库 B,并且让备库 B 可以读写,那么系统几乎就没有不可用时间了。

我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。

接下来,我就和你分享一个可用性优先流程产生数据不一致的例子。假设有一个表 t:

mysql> CREATE TABLE `t` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `c` int(11) unsigned DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
insert into t(c) values(1),(2),(3);

这个表定义了一个自增主键 id,初始化数据后,主库和备库上都是 3 行数据。接下来,业务人员要继续在表 t 上执行两条插入语句的命令,依次是:

insert into t(c) values(4);
insert into t(c) values(5);

假设,现在主库上其他的数据表有大量的更新,导致主备延迟达到 5 秒。在插入一条 c=4 的语句后,发起了主备切换。

图 3 是可用性优先策略,且 binlog_format=mixed时的切换流程和数据结果。

img

图 3 可用性优先策略,且 binlog_format=mixed

现在,我们一起分析下这个切换流程:

  1. 步骤 2 中,主库 A 执行完 insert 语句,插入了一行数据(4,4),之后开始进行主备切换。
  2. 步骤 3 中,由于主备之间有 5 秒的延迟,所以备库 B 还没来得及应用“插入 c=4”这个中转日志,就开始接收客户端“插入 c=5”的命令。
  3. 步骤 4 中,备库 B 插入了一行数据(4,5),并且把这个 binlog 发给主库 A。
  4. 步骤 5 中,备库 B 执行“插入 c=4”这个中转日志,插入了一行数据(5,4)。而直接在备库 B 执行的“插入 c=5”这个语句,传到主库 A,就插入了一行新数据(5,5)。

最后的结果就是,主库 A 和备库 B 上出现了两行不一致的数据。可以看到,这个数据不一致,是由可用性优先流程导致的。

那么,如果我还是用可用性优先策略,但设置 binlog_format=row,情况又会怎样呢?

因为 row 格式在记录 binlog 的时候,会记录新插入的行的所有字段值,所以最后只会有一行不一致。而且,两边的主备同步的应用线程会报错 duplicate key error 并停止。也就是说,这种情况下,备库 B 的 (5,4) 和主库 A 的 (5,5) 这两行数据,都不会被对方执行。

图 4 中我画出了详细过程,你可以自己再分析一下。

img

图 4 可用性优先策略,且 binlog_format=row

从上面的分析中,你可以看到一些结论:

  1. 使用 row 格式的 binlog 时,数据不一致的问题更容易被发现。而使用 mixed 或者 statement 格式的 binlog 时,数据很可能悄悄地就不一致了。如果你过了很久才发现数据不一致的问题,很可能这时的数据不一致已经不可查,或者连带造成了更多的数据逻辑不一致。
  2. 主备切换的可用性优先策略会导致数据不一致。因此,大多数情况下,我都建议你使用可靠性优先策略。毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。

但事无绝对,有没有哪种情况数据的可用性优先级更高呢?

答案是,有的。

我曾经碰到过这样的一个场景:

  • 有一个库的作用是记录操作日志。这时候,如果数据不一致可以通过 binlog 来修补,而这个短暂的不一致也不会引发业务问题。
  • 同时,业务系统依赖于这个日志写入逻辑,如果这个库不可写,会导致线上的业务操作无法执行。

这时候,你可能就需要选择先强行切换,事后再补数据的策略。

当然,事后复盘的时候,我们想到了一个改进措施就是,让业务逻辑不要依赖于这类日志的写入。也就是说,日志写入这个逻辑模块应该可以降级,比如写到本地文件,或者写到另外一个临时库里面。

这样的话,这种场景就又可以使用可靠性优先策略了。

接下来我们再看看,按照可靠性优先的思路,异常切换会是什么效果?

假设,主库 A 和备库 B 间的主备延迟是 30 分钟,这时候主库 A 掉电了,HA 系统要切换 B 作为主库。我们在主动切换的时候,可以等到主备延迟小于 5 秒的时候再启动切换,但这时候已经别无选择了。

img

图 5 可靠性优先策略,主库不可用

采用可靠性优先策略的话,你就必须得等到备库 B 的 seconds_behind_master=0 之后,才能切换。但现在的情况比刚刚更严重,并不是系统只读、不可写的问题了,而是系统处于完全不可用的状态。因为,主库 A 掉电后,我们的连接还没有切到备库 B。

你可能会问,那能不能直接切换到备库 B,但是保持 B 只读呢?

这样也不行。

因为,这段时间内,中转日志还没有应用完成,如果直接发起主备切换,客户端查询看不到之前执行完成的事务,会认为有“数据丢失”。

虽然随着中转日志的继续应用,这些数据会恢复回来,但是对于一些业务来说,查询到“暂时丢失数据的状态”也是不能被接受的。

聊到这里你就知道了,在满足数据可靠性的前提下,MySQL 高可用系统的可用性,是依赖于主备延迟的。延迟的时间越小,在主库故障的时候,服务恢复需要的时间就越短,可用性就越高。

小结

今天这篇文章,我先和你介绍了 MySQL 高可用系统的基础,就是主备切换逻辑。紧接着,我又和你讨论了几种会导致主备延迟的情况,以及相应的改进方向。

然后,由于主备延迟的存在,切换策略就有不同的选择。所以,我又和你一起分析了可靠性优先和可用性优先策略的区别。

在实际的应用中,我更建议使用可靠性优先的策略。毕竟保证数据准确,应该是数据库服务的底线。在这个基础上,通过减少主备延迟,提升系统的可用性。

最后,我给你留下一个思考题吧。

一般现在的数据库运维系统都有备库延迟监控,其实就是在备库上执行 show slave status,采集 seconds_behind_master 的值。

假设,现在你看到你维护的一个备库,它的延迟监控的图像类似图 6,是一个 45°斜向上的线段,你觉得可能是什么原因导致呢?你又会怎么去确认这个原因呢?

img

图 6 备库延迟

你可以把你的分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期我留给你的问题是,什么情况下双 M 结构会出现循环复制。

一种场景是,在一个主库更新事务后,用命令 set global server_id=x 修改了 server_id。等日志再传回来的时候,发现 server_id 跟自己的 server_id 不同,就只能执行了。

另一种场景是,有三个节点的时候,如图 7 所示,trx1 是在节点 B 执行的,因此 binlog 上的 server_id 就是 B,binlog 传给节点 A,然后 A 和 A’搭建了双 M 结构,就会出现循环复制。

img

图 7 三节点循环复制

这种三节点复制的场景,做数据库迁移的时候会出现。

如果出现了循环复制,可以在 A 或者 A’上,执行如下命令:

stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=(server_id_of_B);
start slave;

这样这个节点收到日志后就不会再执行。过一段时间后,再执行下面的命令把这个值改回来。

stop slave;
CHANGE MASTER TO IGNORE_SERVER_IDS=();
start slave;

评论区留言点赞板:

@一大只、@HuaMax 同学提到了第一个复现方法;

@Jonh 同学提到了 IGNORE_SERVER_IDS 这个解决方法;

@React 提到,如果主备设置不同的步长,备库是不是可以设置为可读写。我的建议是,只要这个节点设计内就不会有业务直接在上面执行更新,就建议设置为 readonly。

26 备库为什么会延迟好几个小时?

在上一篇文章中,我和你介绍了几种可能导致备库延迟的原因。你会发现,这些场景里,不论是偶发性的查询压力,还是备份,对备库延迟的影响一般是分钟级的,而且在备库恢复正常以后都能够追上来。

但是,如果备库执行日志的速度持续低于主库生成日志的速度,那这个延迟就有可能成了小时级别。而且对于一个压力持续比较高的主库来说,备库很可能永远都追不上主库的节奏。

这就涉及到今天我要给你介绍的话题:备库并行复制能力。

为了便于你理解,我们再一起看一下第 24 篇文章[《MySQL 是怎么保证主备一致的?》]的主备流程图。

img

图 1 主备流程图

谈到主备的并行复制能力,我们要关注的是图中黑色的两个箭头。一个箭头代表了客户端写入主库,另一箭头代表的是备库上 sql_thread 执行中转日志(relay log)。如果用箭头的粗细来代表并行度的话,那么真实情况就如图 1 所示,第一个箭头要明显粗于第二个箭头。

在主库上,影响并发度的原因就是各种锁了。由于 InnoDB 引擎支持行锁,除了所有并发事务都在更新同一行(热点行)这种极端场景外,它对业务并发度的支持还是很友好的。所以,你在性能测试的时候会发现,并发压测线程 32 就比单线程时,总体吞吐量高。

而日志在备库上的执行,就是图中备库上 sql_thread 更新数据 (DATA) 的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。

在官方的 5.6 版本之前,MySQL 只支持单线程复制,由此在主库并发高、TPS 高时就会出现严重的主备延迟问题。

从单线程复制到最新版本的多线程复制,中间的演化经历了好几个版本。接下来,我就跟你说说 MySQL 多线程复制的演进过程。

其实说到底,所有的多线程复制机制,都是要把图 1 中只有一个线程的 sql_thread,拆成多个线程,也就是都符合下面的这个模型:

img

图 2 多线程模型

图 2 中,coordinator 就是原来的 sql_thread, 不过现在它不再直接更新数据了,只负责读取中转日志和分发事务。真正更新日志的,变成了 worker 线程。而 work 线程的个数,就是由参数 slave_parallel_workers 决定的。根据我的经验,把这个值设置为 8~16 之间最好(32 核物理机的情况),毕竟备库还有可能要提供读查询,不能把 CPU 都吃光了。

接下来,你需要先思考一个问题:事务能不能按照轮询的方式分发给各个 worker,也就是第一个事务分给 worker_1,第二个事务发给 worker_2 呢?

其实是不行的。因为,事务被分发给 worker 以后,不同的 worker 就独立执行了。但是,由于 CPU 的调度策略,很可能第二个事务最终比第一个事务先执行。而如果这时候刚好这两个事务更新的是同一行,也就意味着,同一行上的两个事务,在主库和备库上的执行顺序相反,会导致主备不一致的问题。

接下来,请你再设想一下另外一个问题:同一个事务的多个更新语句,能不能分给不同的 worker 来执行呢?

答案是,也不行。举个例子,一个事务更新了表 t1 和表 t2 中的各一行,如果这两条更新语句被分到不同 worker 的话,虽然最终的结果是主备一致的,但如果表 t1 执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性。

所以,coordinator 在分发的时候,需要满足以下这两个基本要求:

  1. 不能造成更新覆盖。这就要求更新同一行的两个事务,必须被分发到同一个 worker 中。
  2. 同一个事务不能被拆开,必须放到同一个 worker 中。

各个版本的多线程复制,都遵循了这两条基本原则。接下来,我们就看看各个版本的并行复制策略。

MySQL 5.5 版本的并行复制策略

官方 MySQL 5.5 版本是不支持并行复制的。但是,在 2012 年的时候,我自己服务的业务出现了严重的主备延迟,原因就是备库只有单线程复制。然后,我就先后写了两个版本的并行策略。

这里,我给你介绍一下这两个版本的并行策略,即按表分发策略和按行分发策略,以帮助你理解 MySQL 官方版本并行复制策略的迭代。

按表分发策略

按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行。因为数据是存储在表里的,所以按表分发,可以保证两个 worker 不会更新同一行。

当然,如果有跨表的事务,还是要把两张表放在一起考虑的。如图 3 所示,就是按表分发的规则。

img

图 3 按表并行复制程模型

可以看到,每个 worker 线程对应一个 hash 表,用于保存当前正在这个 worker 的“执行队列”里的事务所涉及的表。hash 表的 key 是“库名. 表名”,value 是一个数字,表示队列中有多少个事务修改这个表。

在有事务分配给 worker 时,事务里面涉及的表会被加到对应的 hash 表中。worker 执行完成后,这个表会被从 hash 表中去掉。

图 3 中,hash_table_1 表示,现在 worker_1 的“待执行事务队列”里,有 4 个事务涉及到 db1.t1 表,有 1 个事务涉及到 db2.t2 表;hash_table_2 表示,现在 worker_2 中有一个事务会更新到表 t3 的数据。

假设在图中的情况下,coordinator 从中转日志中读入一个新事务 T,这个事务修改的行涉及到表 t1 和 t3。

现在我们用事务 T 的分配流程,来看一下分配规则。

  1. 由于事务 T 中涉及修改表 t1,而 worker_1 队列中有事务在修改表 t1,事务 T 和队列中的某个事务要修改同一个表的数据,这种情况我们说事务 T 和 worker_1 是冲突的。
  2. 按照这个逻辑,顺序判断事务 T 和每个 worker 队列的冲突关系,会发现事务 T 跟 worker_2 也冲突。
  3. 事务 T 跟多于一个 worker 冲突,coordinator 线程就进入等待。
  4. 每个 worker 继续执行,同时修改 hash_table。假设 hash_table_2 里面涉及到修改表 t3 的事务先执行完成,就会从 hash_table_2 中把 db1.t3 这一项去掉。
  5. 这样 coordinator 会发现跟事务 T 冲突的 worker 只有 worker_1 了,因此就把它分配给 worker_1。
  6. coordinator 继续读下一个中转日志,继续分配事务。

也就是说,每个事务在分发的时候,跟所有 worker 的冲突关系包括以下三种情况:

  1. 如果跟所有 worker 都不冲突,coordinator 线程就会把这个事务分配给最空闲的 woker;
  2. 如果跟多于一个 worker 冲突,coordinator 线程就进入等待状态,直到和这个事务存在冲突关系的 worker 只剩下 1 个;
  3. 如果只跟一个 worker 冲突,coordinator 线程就会把这个事务分配给这个存在冲突关系的 worker。

这个按表分发的方案,在多个表负载均匀的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到某一个表的时候,所有事务都会被分配到同一个 worker 中,就变成单线程复制了。

按行分发策略

要解决热点表的并行复制问题,就需要一个按行并行复制的方案。按行复制的核心思路是:如果两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求 binlog 格式必须是 row。

这时候,我们判断一个事务 T 和 worker 是否冲突,用的就规则就不是“修改同一个表”,而是“修改同一行”。

按行复制和按表复制的数据结构差不多,也是为每个 worker,分配一个 hash 表。只是要实现按行分发,这时候的 key,就必须是“库名 + 表名 + 唯一键的值”。

但是,这个“唯一键”只有主键 id 还是不够的,我们还需要考虑下面这种场景,表 t1 中除了主键,还有唯一索引 a:

CREATE TABLE `t1` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `a` (`a`)
) ENGINE=InnoDB; 
insert into t1 values(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5);

假设,接下来我们要在主库执行这两个事务:

img

图 4 唯一键冲突示例

可以看到,这两个事务要更新的行的主键值不同,但是如果它们被分到不同的 worker,就有可能 session B 的语句先执行。这时候 id=1 的行的 a 的值还是 1,就会报唯一键冲突。

因此,基于行的策略,事务 hash 表中还需要考虑唯一键,即 key 应该是“库名 + 表名 + 索引 a 的名字 +a 的值”。

比如,在上面这个例子中,我要在表 t1 上执行 update t1 set a=1 where id=2 语句,在 binlog 里面记录了整行的数据修改前各个字段的值,和修改后各个字段的值。

因此,coordinator 在解析这个语句的 binlog 的时候,这个事务的 hash 表就有三个项:

  1. key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里 value=2 是因为修改前后的行 id 值不变,出现了两次。
  2. key=hash_func(db1+t1+“a”+2), value=1,表示会影响到这个表 a=2 的行。
  3. key=hash_func(db1+t1+“a”+1), value=1,表示会影响到这个表 a=1 的行。

可见,相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。你可能也发现了,这两个方案其实都有一些约束条件:

  1. 要能够从 binlog 里面解析出表名、主键值和唯一索引的值。也就是说,主库的 binlog 格式必须是 row;
  2. 表必须有主键;
  3. 不能有外键。表上如果有外键,级联更新的行不会记录在 binlog 中,这样冲突检测就不准确。

但,好在这三条约束规则,本来就是 DBA 之前要求业务开发人员必须遵守的线上使用规范,所以这两个并行复制策略在应用上也没有碰到什么麻烦。

对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高。不过,如果是要操作很多行的大事务的话,按行分发的策略有两个问题:

  1. 耗费内存。比如一个语句要删除 100 万行数据,这时候 hash 表就要记录 100 万个项。
  2. 耗费 CPU。解析 binlog,然后计算 hash 值,对于大事务,这个成本还是很高的。

所以,我在实现这个策略的时候会设置一个阈值,单个事务如果超过设置的行数阈值(比如,如果单个事务更新的行数超过 10 万行),就暂时退化为单线程模式,退化过程的逻辑大概是这样的:

  1. coordinator 暂时先 hold 住这个事务;
  2. 等待所有 worker 都执行完成,变成空队列;
  3. coordinator 直接执行这个事务;
  4. 恢复并行模式。

读到这里,你可能会感到奇怪,这两个策略又没有被合到官方,我为什么要介绍这么详细呢?其实,介绍这两个策略的目的是抛砖引玉,方便你理解后面要介绍的社区版本策略。

MySQL 5.6 版本的并行复制策略

官方 MySQL5.6 版本,支持了并行复制,只是支持的粒度是按库并行。理解了上面介绍的按表分发策略和按行分发策略,你就理解了,用于决定分发策略的 hash 表里,key 就是数据库名。

这个策略的并行效果,取决于压力模型。如果在主库上有多个 DB,并且各个 DB 的压力均衡,使用这个策略的效果会很好。

相比于按表和按行分发,这个策略有两个优势:

  1. 构造 hash 值的时候很快,只需要库名;而且一个实例上 DB 数也不会很多,不会出现需要构造 100 万个项这种情况。
  2. 不要求 binlog 的格式。因为 statement 格式的 binlog 也可以很容易拿到库名。

但是,如果你的主库上的表都放在同一个 DB 里面,这个策略就没有效果了;或者如果不同 DB 的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果。

理论上你可以创建不同的 DB,把相同热度的表均匀分到这些不同的 DB 中,强行使用这个策略。不过据我所知,由于需要特地移动数据,这个策略用得并不多。

MariaDB 的并行复制策略

在[第 23 篇文章]中,我给你介绍了 redo log 组提交 (group commit) 优化, 而 MariaDB 的并行复制策略利用的就是这个特性:

  1. 能够在同一组里提交的事务,一定不会修改同一行;
  2. 主库上可以并行执行的事务,备库上也一定是可以并行执行的。

在实现上,MariaDB 是这么做的:

  1. 在一组里面一起提交的事务,有一个相同的 commit_id,下一组就是 commit_id+1;
  2. commit_id 直接写到 binlog 里面;
  3. 传到备库应用的时候,相同 commit_id 的事务分发到多个 worker 执行;
  4. 这一组全部执行完成后,coordinator 再去取下一批。

当时,这个策略出来的时候是相当惊艳的。因为,之前业界的思路都是在“分析 binlog,并拆分到 worker”上。而 MariaDB 的这个策略,目标是“模拟主库的并行模式”。

但是,这个策略有一个问题,它并没有实现“真正的模拟主库并发度”这个目标。在主库上,一组事务在 commit 的时候,下一组事务是同时处于“执行中”状态的。

如图 5 所示,假设了三组事务在主库的执行情况,你可以看到在 trx1、trx2 和 trx3 提交的时候,trx4、trx5 和 trx6 是在执行的。这样,在第一组事务提交完成的时候,下一组事务很快就会进入 commit 状态。

img

图 5 主库并行事务

而按照 MariaDB 的并行复制策略,备库上的执行效果如图 6 所示。

img

图 6 MariaDB 并行复制,备库并行效果

可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。

另外,这个方案很容易被大事务拖后腿。假设 trx2 是一个超大事务,那么在备库应用的时候,trx1 和 trx3 执行完成后,就只能等 trx2 完全执行完成,下一组才能开始执行。这段时间,只有一个 worker 线程在工作,是对资源的浪费。

不过即使如此,这个策略仍然是一个很漂亮的创新。因为,它对原系统的改造非常少,实现也很优雅。

MySQL 5.7 的并行复制策略

在 MariaDB 并行复制实现之后,官方的 MySQL5.7 版本也提供了类似的功能,由参数 slave-parallel-type 来控制并行复制策略:

  1. 配置为 DATABASE,表示使用 MySQL 5.6 版本的按库并行策略;
  2. 配置为 LOGICAL_CLOCK,表示的就是类似 MariaDB 的策略。不过,MySQL 5.7 这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。

你可以先考虑这样一个问题:同时处于“执行状态”的所有事务,是不是可以并行?

答案是,不能。

因为,这里面可能有由于锁冲突而处于锁等待状态的事务。如果这些事务在备库上被分配到不同的 worker,就会出现备库跟主库不一致的情况。

而上面提到的 MariaDB 这个策略的核心,是“所有处于 commit”状态的事务可以并行。事务处于 commit 状态,表示已经通过了锁冲突的检验了。

这时候,你可以再回顾一下两阶段提交,我把前面[第 23 篇文章]中介绍过的两阶段提交过程图贴过来。

img

图 7 两阶段提交细化过程图

其实,不用等到 commit 阶段,只要能够到达 redo log prepare 阶段,就表示事务已经通过锁冲突的检验了。

因此,MySQL 5.7 并行复制策略的思想是:

  1. 同时处于 prepare 状态的事务,在备库执行时是可以并行的;
  2. 处于 prepare 状态的事务,与处于 commit 状态的事务之间,在备库执行时也是可以并行的。

我在第 23 篇文章,讲 binlog 的组提交的时候,介绍过两个参数:

  1. binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
  2. binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

这两个参数是用于故意拉长 binlog 从 write 到 fsync 的时间,以此减少 binlog 的写盘次数。在 MySQL 5.7 的并行复制策略里,它们可以用来制造更多的“同时处于 prepare 阶段的事务”。这样就增加了备库复制的并行度。

也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些。在 MySQL 5.7 处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。

MySQL 5.7.22 的并行复制策略

在 2018 年 4 月份发布的 MySQL 5.7.22 版本里,MySQL 增加了一个新的并行复制策略,基于 WRITESET 的并行复制。

相应地,新增了一个参数 binlog-transaction-dependency-tracking,用来控制是否启用这个新策略。这个参数的可选值有以下三种。

  1. COMMIT_ORDER,表示的就是前面介绍的,根据同时进入 prepare 和 commit 来判断是否可以并行的策略。
  2. WRITESET,表示的是对于事务涉及更新的每一行,计算出这一行的 hash 值,组成集合 writeset。如果两个事务没有操作相同的行,也就是说它们的 writeset 没有交集,就可以并行。
  3. WRITESET_SESSION,是在 WRITESET 的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序。

当然为了唯一标识,这个 hash 值是通过“库名 + 表名 + 索引名 + 值”计算出来的。如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert 语句对应的 writeset 就要多增加一个 hash 值。

你可能看出来了,这跟我们前面介绍的基于 MySQL 5.5 版本的按行分发的策略是差不多的。不过,MySQL 官方的这个实现还是有很大的优势:

  1. writeset 是在主库生成后直接写入到 binlog 里面的,这样在备库执行的时候,不需要解析 binlog 内容(event 里的行数据),节省了很多计算量;
  2. 不需要把整个事务的 binlog 都扫一遍才能决定分发到哪个 worker,更省内存;
  3. 由于备库的分发策略不依赖于 binlog 内容,所以 binlog 是 statement 格式也是可以的。

因此,MySQL 5.7.22 的并行复制策略在通用性上还是有保证的。

当然,对于“表上没主键”和“外键约束”的场景,WRITESET 策略也是没法并行的,也会暂时退化为单线程模型。

小结

在今天这篇文章中,我和你介绍了 MySQL 的各种多线程复制策略。

为什么要有多线程复制呢?这是因为单线程复制的能力全面低于多线程复制,对于更新压力较大的主库,备库是可能一直追不上主库的。从现象上看就是,备库上 seconds_behind_master 的值越来越大。

在介绍完每个并行复制策略后,我还和你分享了不同策略的优缺点:

  • 如果你是 DBA,就需要根据不同的业务场景,选择不同的策略;
  • 如果是你业务开发人员,也希望你能从中获取灵感用到平时的开发工作中。

从这些分析中,你也会发现大事务不仅会影响到主库,也是造成备库复制延迟的主要原因之一。因此,在平时的开发工作中,我建议你尽量减少大事务操作,把大事务拆成小事务。

官方 MySQL5.7 版本新增的备库并行策略,修改了 binlog 的内容,也就是说 binlog 协议并不是向上兼容的,在主备切换、版本升级的时候需要把这个因素也考虑进去。

最后,我给你留下一个思考题吧。

假设一个 MySQL 5.7.22 版本的主库,单线程插入了很多数据,过了 3 个小时后,我们要给这个主库搭建一个相同版本的备库。

这时候,你为了更快地让备库追上主库,要开并行复制。在 binlog-transaction-dependency-tracking 参数的 COMMIT_ORDER、WRITESET 和 WRITE_SESSION 这三个取值中,你会选择哪一个呢?

你选择的原因是什么?如果设置另外两个参数,你认为会出现什么现象呢?

你可以把你的答案和分析写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,什么情况下,备库的主备延迟会表现为一个 45 度的线段?评论区有不少同学的回复都说到了重点:备库的同步在这段时间完全被堵住了。

产生这种现象典型的场景主要包括两种:

  • 一种是大事务(包括大表 DDL、一个事务操作很多行);
  • 还有一种情况比较隐蔽,就是备库起了一个长事务,比如
begin; 
select * from t limit 1;

然后就不动了。

这时候主库对表 t 做了一个加字段操作,即使这个表很小,这个 DDL 在备库应用的时候也会被堵住,也不能看到这个现象。

评论区还有同学说是不是主库多线程、从库单线程,备库跟不上主库的更新节奏导致的?今天这篇文章,我们刚好讲的是并行复制。所以,你知道了,这种情况会导致主备延迟,但不会表现为这种标准的呈 45 度的直线。

评论区留言点赞板:

@易翔 、 @万勇、@老杨同志 等同学的回复都提到了我们上面说的场景;

@Max 同学提了一个很不错的问题。主备关系里面,备库主动连接,之后的 binlog 发送是主库主动推送的。之所以这么设计也是为了效率和实时性考虑,毕竟靠备库轮询,会有时间差。

27 主库出问题了,从库怎么办?

在前面的第[24]、[25]和[26]篇文章中,我和你介绍了 MySQL 主备复制的基础结构,但这些都是一主一备的结构。

大多数的互联网应用场景都是读多写少,因此你负责的业务,在发展过程中很可能先会遇到读性能的问题。而在数据库层解决读性能问题,就要涉及到接下来两篇文章要讨论的架构:一主多从。

今天这篇文章,我们就先聊聊一主多从的切换正确性。然后,我们在下一篇文章中再聊聊解决一主多从的查询逻辑正确性的方法。

如图 1 所示,就是一个基本的一主多从结构。

img

图 1 一主多从基本结构

图中,虚线箭头表示的是主备关系,也就是 A 和 A’互为主备, 从库 B、C、D 指向的是主库 A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

今天我们要讨论的就是,在一主多从架构下,主库故障后的主备切换问题。

如图 2 所示,就是主库发生故障,主备切换后的结果。

img

图 2 一主多从基本结构 – 主备切换

相比于一主一备的切换流程,一主多从结构在切换完成后,A’会成为新的主库,从库 B、C、D 也要改接到 A’。正是由于多了从库 B、C、D 重新指向的这个过程,所以主备切换的复杂性也相应增加了。

接下来,我们再一起看看一个切换系统会怎么完成一主多从的主备切换过程。

基于位点的主备切换

这里,我们需要先来回顾一个知识点。

当我们把节点 B 设置成节点 A’的从库的时候,需要执行一条 change master 命令:

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
MASTER_LOG_FILE=$master_log_name 
MASTER_LOG_POS=$master_log_pos  

这条命令有这么 6 个参数:

  • MASTER_HOST、MASTER_PORT、MASTER_USER 和 MASTER_PASSWORD 四个参数,分别代表了主库 A’的 IP、端口、用户名和密码。
  • 最后两个参数 MASTER_LOG_FILE 和 MASTER_LOG_POS 表示,要从主库的 master_log_name 文件的 master_log_pos 这个位置的日志继续同步。而这个位置就是我们所说的同步位点,也就是主库对应的文件名和日志偏移量。

那么,这里就有一个问题了,节点 B 要设置成 A’的从库,就要执行 change master 命令,就不可避免地要设置位点的这两个参数,但是这两个参数到底应该怎么设置呢?

原来节点 B 是 A 的从库,本地记录的也是 A 的位点。但是相同的日志,A 的位点和 A’的位点是不同的。因此,从库 B 要切换的时候,就需要先经过“找同步位点”这个逻辑。

这个位点很难精确取到,只能取一个大概位置。为什么这么说呢?

我来和你分析一下看看这个位点一般是怎么获取到的,你就清楚其中不精确的原因了。

考虑到切换过程中不能丢数据,所以我们找位点的时候,总是要找一个“稍微往前”的,然后再通过判断跳过那些在从库 B 上已经执行过的事务。

一种取同步位点的方法是这样的:

  1. 等待新主库 A’把中转日志(relay log)全部同步完成;
  2. 在 A’上执行 show master status 命令,得到当前 A’上最新的 File 和 Position;
  3. 取原主库 A 故障的时刻 T;
  4. 用 mysqlbinlog 工具解析 A’的 File,得到 T 时刻的位点。
mysqlbinlog File --stop-datetime=T --start-datetime=T

img

图 3 mysqlbinlog 部分输出结果

图中,end_log_pos 后面的值“123”,表示的就是 A’这个实例,在 T 时刻写入新的 binlog 的位置。然后,我们就可以把 123 这个值作为 $master_log_pos ,用在节点 B 的 change master 命令里。

当然这个值并不精确。为什么呢?

你可以设想有这么一种情况,假设在 T 这个时刻,主库 A 已经执行完成了一个 insert 语句插入了一行数据 R,并且已经将 binlog 传给了 A’和 B,然后在传完的瞬间主库 A 的主机就掉电了。

那么,这时候系统的状态是这样的:

  1. 在从库 B 上,由于同步了 binlog, R 这一行已经存在;
  2. 在新主库 A’上, R 这一行也已经存在,日志是写在 123 这个位置之后的;
  3. 我们在从库 B 上执行 change master 命令,指向 A’的 File 文件的 123 位置,就会把插入 R 这一行数据的 binlog 又同步到从库 B 去执行。

这时候,从库 B 的同步线程就会报告 Duplicate entry ‘id_of_R’ for key ‘PRIMARY’ 错误,提示出现了主键冲突,然后停止同步。

所以,通常情况下,我们在切换任务的时候,要先主动跳过这些错误,有两种常用的方法。

一种做法是,主动跳过一个事务。跳过命令的写法是:

set global sql_slave_skip_counter=1;
start slave;

因为切换过程中,可能会不止重复执行一个事务,所以我们需要在从库 B 刚开始接到新主库 A’时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务。

另外一种方式是,通过设置 slave_skip_errors 参数,直接设置跳过指定的错误。

在执行主备切换时,有这么两类错误,是经常会遇到的:

  • 1062 错误是插入数据时唯一键冲突;
  • 1032 错误是删除数据时找不到行。

因此,我们可以把 slave_skip_errors 设置为 “1032,1062”,这样中间碰到这两个错误时就直接跳过。

这里需要注意的是,这种直接跳过指定错误的方法,针对的是主备切换时,由于找不到精确的同步位点,所以只能采用这种方法来创建从库和新主库的主备关系。

这个背景是,我们很清楚在主备切换过程中,直接跳过 1032 和 1062 这两类错误是无损的,所以才可以这么设置 slave_skip_errors 参数。等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一致,也跳过了。

GTID

通过 sql_slave_skip_counter 跳过事务和通过 slave_skip_errors 忽略错误的方法,虽然都最终可以建立从库 B 和新主库 A’的主备关系,但这两种操作都很复杂,而且容易出错。所以,MySQL 5.6 版本引入了 GTID,彻底解决了这个困难。

那么,GTID 到底是什么意思,又是如何解决找同步位点这个问题呢?现在,我就和你简单介绍一下。

GTID 的全称是 Global Transaction Identifier,也就是全局事务 ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是:

GTID=server_uuid:gno

其中:

  • server_uuid 是一个实例第一次启动时自动生成的,是一个全局唯一的值;
  • gno 是一个整数,初始值是 1,每次提交事务的时候分配给这个事务,并加 1。

这里我需要和你说明一下,在 MySQL 的官方文档里,GTID 格式是这么定义的:

GTID=source_id:transaction_id

这里的 source_id 就是 server_uuid;而后面的这个 transaction_id,我觉得容易造成误导,所以我改成了 gno。为什么说使用 transaction_id 容易造成误解呢?

因为,在 MySQL 里面我们说 transaction_id 就是指事务 id,事务 id 是在事务执行过程中分配的,如果这个事务回滚了,事务 id 也会递增,而 gno 是在事务提交的时候才会分配。

从效果上看,GTID 往往是连续的,因此我们用 gno 来表示更容易理解。

GTID 模式的启动也很简单,我们只需要在启动一个 MySQL 实例的时候,加上参数 gtid_mode=on 和 enforce_gtid_consistency=on 就可以了。

在 GTID 模式下,每个事务都会跟一个 GTID 一一对应。这个 GTID 有两种生成方式,而使用哪种方式取决于 session 变量 gtid_next 的值。

  1. 如果 gtid_next=automatic,代表使用默认值。这时,MySQL 就会把 server_uuid:gno 分配给这个事务。 a. 记录 binlog 的时候,先记录一行 SET @@SESSION.GTID_NEXT=‘server_uuid:gno’; b. 把这个 GTID 加入本实例的 GTID 集合。
  2. 如果 gtid_next 是一个指定的 GTID 的值,比如通过 set gtid_next=‘current_gtid’指定为 current_gtid,那么就有两种可能: a. 如果 current_gtid 已经存在于实例的 GTID 集合中,接下来执行的这个事务会直接被系统忽略; b. 如果 current_gtid 没有存在于实例的 GTID 集合中,就将这个 current_gtid 分配给接下来要执行的事务,也就是说系统不需要给这个事务生成新的 GTID,因此 gno 也不用加 1。

注意,一个 current_gtid 只能给一个事务使用。这个事务提交后,如果要执行下一个事务,就要执行 set 命令,把 gtid_next 设置成另外一个 gtid 或者 automatic。

这样,每个 MySQL 实例都维护了一个 GTID 集合,用来对应“这个实例执行过的所有事务”。

这样看上去不太容易理解,接下来我就用一个简单的例子,来和你说明 GTID 的基本用法。

我们在实例 X 中创建一个表 t。

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
insert into t values(1,1);

img

图 4 初始化数据的 binlog

可以看到,事务的 BEGIN 之前有一条 SET @@SESSION.GTID_NEXT 命令。这时,如果实例 X 有从库,那么将 CREATE TABLE 和 insert 语句的 binlog 同步过去执行的话,执行事务之前就会先执行这两个 SET 命令, 这样被加入从库的 GTID 集合的,就是图中的这两个 GTID。

假设,现在这个实例 X 是另外一个实例 Y 的从库,并且此时在实例 Y 上执行了下面这条插入语句:

insert into t values(1,1);

并且,这条语句在实例 Y 上的 GTID 是 “aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”。

那么,实例 X 作为 Y 的从库,就要同步这个事务过来执行,显然会出现主键冲突,导致实例 X 的同步线程停止。这时,我们应该怎么处理呢?

处理方法就是,你可以执行下面的这个语句序列:

set gtid_next='aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10';
begin;
commit;
set gtid_next=automatic;
start slave;

其中,前三条语句的作用,是通过提交一个空事务,把这个 GTID 加到实例 X 的 GTID 集合中。如图 5 所示,就是执行完这个空事务之后的 show master status 的结果。

img

图 5 show master status 结果

可以看到实例 X 的 Executed_Gtid_set 里面,已经加入了这个 GTID。

这样,我再执行 start slave 命令让同步线程执行起来的时候,虽然实例 X 上还是会继续执行实例 Y 传过来的事务,但是由于“aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”已经存在于实例 X 的 GTID 集合中了,所以实例 X 就会直接跳过这个事务,也就不会再出现主键冲突的错误。

在上面的这个语句序列中,start slave 命令之前还有一句 set gtid_next=automatic。这句话的作用是“恢复 GTID 的默认分配行为”,也就是说如果之后有新的事务再执行,就还是按照原来的分配方式,继续分配 gno=3。

基于 GTID 的主备切换

现在,我们已经理解 GTID 的概念,再一起来看看基于 GTID 的主备复制的用法。

在 GTID 模式下,备库 B 要设置为新主库 A’的从库的语法如下:

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
master_auto_position=1 

其中,master_auto_position=1 就表示这个主备关系使用的是 GTID 协议。可以看到,前面让我们头疼不已的 MASTER_LOG_FILE 和 MASTER_LOG_POS 参数,已经不需要指定了。

我们把现在这个时刻,实例 A’的 GTID 集合记为 set_a,实例 B 的 GTID 集合记为 set_b。接下来,我们就看看现在的主备切换逻辑。

我们在实例 B 上执行 start slave 命令,取 binlog 的逻辑是这样的:

  1. 实例 B 指定主库 A’,基于主备协议建立连接。
  2. 实例 B 把 set_b 发给主库 A’。
  3. 实例 A’算出 set_a 与 set_b 的差集,也就是所有存在于 set_a,但是不存在于 set_b 的 GITD 的集合,判断 A’本地是否包含了这个差集需要的所有 binlog 事务。 a. 如果不包含,表示 A’已经把实例 B 需要的 binlog 给删掉了,直接返回错误; b. 如果确认全部包含,A’从自己的 binlog 文件里面,找出第一个不在 set_b 的事务,发给 B;
  4. 之后就从这个事务开始,往后读文件,按顺序取 binlog 发给 B 去执行。

其实,这个逻辑里面包含了一个设计思想:在基于 GTID 的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的。因此,如果实例 B 需要的日志已经不存在,A’就拒绝把日志发给 B。

这跟基于位点的主备协议不同。基于位点的协议,是由备库决定的,备库指定哪个位点,主库就发哪个位点,不做日志的完整性判断。

基于上面的介绍,我们再来看看引入 GTID 后,一主多从的切换场景下,主备切换是如何实现的。

由于不需要找位点了,所以从库 B、C、D 只需要分别执行 change master 命令指向实例 A’即可。

其实,严谨地说,主备切换不是不需要找位点了,而是找位点这个工作,在实例 A’内部就已经自动完成了。但由于这个工作是自动的,所以对 HA 系统的开发人员来说,非常友好。

之后这个系统就由新主库 A’写入,主库 A’的自己生成的 binlog 中的 GTID 集合格式是:server_uuid_of_A’:1-M。

如果之前从库 B 的 GTID 集合格式是 server_uuid_of_A:1-N, 那么切换之后 GTID 集合的格式就变成了 server_uuid_of_A:1-N, server_uuid_of_A’:1-M。

当然,主库 A’之前也是 A 的备库,因此主库 A’和从库 B 的 GTID 集合是一样的。这就达到了我们预期。

GTID 和在线 DDL

接下来,我再举个例子帮你理解 GTID。

之前在第 22 篇文章[《MySQL 有哪些“饮鸩止渴”提高性能的方法?》]中,我和你提到业务高峰期的慢查询性能问题时,分析到如果是由于索引缺失引起的性能问题,我们可以通过在线加索引来解决。但是,考虑到要避免新增索引对主库性能造成的影响,我们可以先在备库加索引,然后再切换。

当时我说,在双 M 结构下,备库执行的 DDL 语句也会传给主库,为了避免传回后对主库造成影响,要通过 set sql_log_bin=off 关掉 binlog。

评论区有位同学提出了一个问题:这样操作的话,数据库里面是加了索引,但是 binlog 并没有记录下这一个更新,是不是会导致数据和日志不一致?

这个问题提得非常好。当时,我在留言的回复中就引用了 GTID 来说明。今天,我再和你展开说明一下。

假设,这两个互为主备关系的库还是实例 X 和实例 Y,且当前主库是 X,并且都打开了 GTID 模式。这时的主备切换流程可以变成下面这样:

  • 在实例 X 上执行 stop slave。
  • 在实例 Y 上执行 DDL 语句。注意,这里并不需要关闭 binlog。
  • 执行完成后,查出这个 DDL 语句对应的 GTID,并记为 server_uuid_of_Y:gno。
  • 到实例 X 上执行以下语句序列:
set GTID_NEXT="server_uuid_of_Y:gno";
begin;
commit;
set gtid_next=automatic;
start slave;

这样做的目的在于,既可以让实例 Y 的更新有 binlog 记录,同时也可以确保不会在实例 X 上执行这条更新。

  • 接下来,执行完主备切换,然后照着上述流程再执行一遍即可。

小结

在今天这篇文章中,我先和你介绍了一主多从的主备切换流程。在这个过程中,从库找新主库的位点是一个痛点。由此,我们引出了 MySQL 5.6 版本引入的 GTID 模式,介绍了 GTID 的基本概念和用法。

可以看到,在 GTID 模式下,一主多从切换就非常方便了。

因此,如果你使用的 MySQL 版本支持 GTID 的话,我都建议你尽量使用 GTID 模式来做一主多从的切换。

在下一篇文章中,我们还能看到 GTID 模式在读写分离场景的应用。

最后,又到了我们的思考题时间。

你在 GTID 模式下设置主从关系的时候,从库执行 start slave 命令后,主库发现需要的 binlog 已经被删除掉了,导致主备创建不成功。这种情况下,你觉得可以怎么处理呢?

你可以把你的方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上一篇文章最后,我给你留的问题是,如果主库都是单线程压力模式,在从库追主库的过程中,binlog-transaction-dependency-tracking 应该选用什么参数?

这个问题的答案是,应该将这个参数设置为 WRITESET。

由于主库是单线程压力模式,所以每个事务的 commit_id 都不同,那么设置为 COMMIT_ORDER 模式的话,从库也只能单线程执行。

同样地,由于 WRITESET_SESSION 模式要求在备库应用日志的时候,同一个线程的日志必须与主库上执行的先后顺序相同,也会导致主库单线程压力模式下退化成单线程复制。

所以,应该将 binlog-transaction-dependency-tracking 设置为 WRITESET。

评论区留言点赞板:

@慧鑫 coming 问了一个好问题,对同一行作更新的几个事务,如果 commit_id 相同,是不是在备库并行执行的时候会导致数据不一致?这个问题的答案是更新同一行的事务是不可能同时进入 commit 状态的。 @老杨同志 对这个问题给出了更详细的回答,大家可以去看一下。

28 读写分离有哪些坑?

在上一篇文章中,我和你介绍了一主多从的结构以及切换流程。今天我们就继续聊聊一主多从架构的应用场景:读写分离,以及怎么处理主备延迟导致的读写分离问题。

我们在上一篇文章中提到的一主多从的结构,其实就是读写分离的基本结构了。这里,我再把这张图贴过来,方便你理解。

img

图 1 读写分离基本结构

读写分离的主要目标就是分摊主库的压力。图 1 中的结构是客户端(client)主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层。也就是说,由客户端来选择后端数据库进行查询。

还有一种架构是,在 MySQL 和客户端之间有一个中间代理层 proxy,客户端只连接 proxy, 由 proxy 根据请求类型和上下文决定请求的分发路由。

img

图 2 带 proxy 的读写分离架构

接下来,我们就看一下客户端直连和带 proxy 的读写分离架构,各有哪些特点。

  1. 客户端直连方案,因为少了一层 proxy 转发,所以查询性能稍微好一点儿,并且整体架构简单,排查问题更方便。但是这种方案,由于要了解后端部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。 你可能会觉得这样客户端也太麻烦了,信息大量冗余,架构很丑。其实也未必,一般采用这样的架构,一定会伴随一个负责管理后端的组件,比如 Zookeeper,尽量让业务端只专注于业务逻辑开发。
  2. 带 proxy 的架构,对客户端比较友好。客户端不需要关注后端细节,连接维护、后端信息维护等工作,都是由 proxy 完成的。但这样的话,对后端维护团队的要求会更高。而且,proxy 也需要有高可用架构。因此,带 proxy 架构的整体就相对比较复杂。

理解了这两种方案的优劣,具体选择哪个方案就取决于数据库团队提供的能力了。但目前看,趋势是往带 proxy 的架构方向发展的。

但是,不论使用哪种架构,你都会碰到我们今天要讨论的问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。

这种“在从库上会读到系统的一个过期状态”的现象,在这篇文章里,我们暂且称之为“过期读”。

前面我们说过了几种可能导致主备延迟的原因,以及对应的优化策略,但是主从延迟还是不能 100% 避免的。

不论哪种结构,客户端都希望查询从库的数据结果,跟查主库的数据结果是一样的。

接下来,我们就来讨论怎么处理过期读问题。

这里,我先把文章中涉及到的处理过期读的方案汇总在这里,以帮助你更好地理解和掌握全文的知识脉络。这些方案包括:

  • 强制走主库方案;
  • sleep 方案;
  • 判断主备无延迟方案;
  • 配合 semi-sync 方案;
  • 等主库位点方案;
  • 等 GTID 方案。

强制走主库方案

强制走主库方案其实就是,将查询请求做分类。通常情况下,我们可以将查询请求分为这么两类:

  1. 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。
  2. 对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。

你可能会说,这个方案是不是有点畏难和取巧的意思,但其实这个方案是用得最多的。

当然,这个方案最大的问题在于,有时候你会碰到“所有查询都不能是过期读”的需求,比如一些金融类的业务。这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性。

因此接下来,我们来讨论的话题是:可以支持读写分离的场景下,有哪些解决过期读的方案,并分析各个方案的优缺点。

Sleep 方案

主库更新后,读从库之前先 sleep 一下。具体的方案就是,类似于执行一条 select sleep(1) 命令。

这个方案的假设是,大多数情况下主备延迟在 1 秒之内,做一个 sleep 可以有很大概率拿到最新的数据。

这个方案给你的第一感觉,很可能是不靠谱儿,应该不会有人用吧?并且,你还可能会说,直接在发起查询时先执行一条 sleep 语句,用户体验很不友好啊。

但,这个思路确实可以在一定程度上解决问题。为了看起来更靠谱儿,我们可以换一种方式。

以卖家发布商品为例,商品发布后,用 Ajax(Asynchronous JavaScript + XML,异步 JavaScript 和 XML)直接把客户端输入的内容作为“新的商品”显示在页面上,而不是真正地去数据库做查询。

这样,卖家就可以通过这个显示,来确认产品已经发布成功了。等到卖家再刷新页面,去查看商品的时候,其实已经过了一段时间,也就达到了 sleep 的目的,进而也就解决了过期读的问题。

也就是说,这个 sleep 方案确实解决了类似场景下的过期读问题。但,从严格意义上来说,这个方案存在的问题就是不精确。这个不精确包含了两层意思:

  1. 如果这个查询请求本来 0.5 秒就可以在从库上拿到正确结果,也会等 1 秒;
  2. 如果延迟超过 1 秒,还是会出现过期读。

看到这里,你是不是有一种“你是不是在逗我”的感觉,这个改进方案虽然可以解决类似 Ajax 场景下的过期读问题,但还是怎么看都不靠谱儿。别着急,接下来我就和你介绍一些更准确的方案。

判断主备无延迟方案

要确保备库无延迟,通常有三种做法。

通过前面的[第 25 篇]文章,我们知道 show slave status 结果里的 seconds_behind_master 参数的值,可以用来衡量主备延迟时间的长短。

所以第一种确保主备无延迟的方法是,每次从库执行查询请求前,先判断 seconds_behind_master 是否已经等于 0。如果还不等于 0 ,那就必须等到这个参数变为 0 才能执行查询请求。

seconds_behind_master 的单位是秒,如果你觉得精度不够的话,还可以采用对比位点和 GTID 的方法来确保主备无延迟,也就是我们接下来要说的第二和第三种方法。

如图 3 所示,是一个 show slave status 结果的部分截图。

img

图 3 show slave status 结果

现在,我们就通过这个结果,来看看具体如何通过对比位点和 GTID 来确保主备无延迟。

第二种方法,对比位点确保主备无延迟:

  • Master_Log_File 和 Read_Master_Log_Pos,表示的是读到的主库的最新位点;
  • Relay_Master_Log_File 和 Exec_Master_Log_Pos,表示的是备库执行的最新位点。

如果 Master_Log_File 和 Relay_Master_Log_File、Read_Master_Log_Pos 和 Exec_Master_Log_Pos 这两组值完全相同,就表示接收到的日志已经同步完成。

第三种方法,对比 GTID 集合确保主备无延迟:

  • Auto_Position=1 ,表示这对主备关系使用了 GTID 协议。
  • Retrieved_Gtid_Set,是备库收到的所有日志的 GTID 集合;
  • Executed_Gtid_Set,是备库所有已经执行完成的 GTID 集合。

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

可见,对比位点和对比 GTID 这两种方法,都要比判断 seconds_behind_master 是否为 0 更准确。

在执行查询请求之前,先判断从库是否同步完成的方法,相比于 sleep 方案,准确度确实提升了不少,但还是没有达到“精确”的程度。为什么这么说呢?

我们现在一起来回顾下,一个事务的 binlog 在主备库之间的状态:

  1. 主库执行完成,写入 binlog,并反馈给客户端;
  2. binlog 被从主库发送给备库,备库收到;
  3. 在备库执行 binlog 完成。

我们上面判断主备无延迟的逻辑,是“备库收到的日志都执行完成了”。但是,从 binlog 在主备之间状态的分析中,不难看出还有一部分日志,处于客户端已经收到提交确认,而备库还没收到日志的状态。

如图 4 所示就是这样的一个状态。

img

图 4 备库还没收到 trx3

这时,主库上执行完成了三个事务 trx1、trx2 和 trx3,其中:

  1. trx1 和 trx2 已经传到从库,并且已经执行完成了;
  2. trx3 在主库执行完成,并且已经回复给客户端,但是还没有传到从库中。

如果这时候你在从库 B 上执行查询请求,按照我们上面的逻辑,从库认为已经没有同步延迟,但还是查不到 trx3 的。严格地说,就是出现了过期读。

那么,这个问题有没有办法解决呢?

配合 semi-sync

要解决这个问题,就要引入半同步复制,也就是 semi-sync replication。

semi-sync 做了这样的设计:

  1. 事务提交的时候,主库把 binlog 发给从库;
  2. 从库收到 binlog 以后,发回给主库一个 ack,表示收到了;
  3. 主库收到这个 ack 以后,才能给客户端返回“事务完成”的确认。

也就是说,如果启用了 semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。

在[第 25 篇文章]的评论区,有同学问到:如果主库掉电的时候,有些 binlog 还来不及发给从库,会不会导致系统数据丢失?

答案是,如果使用的是普通的异步复制模式,就可能会丢失,但 semi-sync 就可以解决这个问题。

这样,semi-sync 配合前面关于位点的判断,就能够确定在从库上执行的查询请求,可以避免过期读。

但是,semi-sync+ 位点判断的方案,只对一主一备的场景是成立的。在一主多从场景中,主库只要等到一个从库的 ack,就开始给客户端返回确认。这时,在从库上执行查询请求,就有两种情况:

  1. 如果查询是落在这个响应了 ack 的从库上,是能够确保读到最新数据;
  2. 但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。

其实,判断同步位点的方案还有另外一个潜在的问题,即:如果在业务更新的高峰期,主库的位点或者 GTID 集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况。

实际上,回到我们最初的业务逻辑里,当发起一个查询请求以后,我们要得到准确的结果,其实并不需要等到“主备完全同步”。

为什么这么说呢?我们来看一下这个时序图。

img

图 5 主备持续延迟一个事务

图 5 所示,就是等待位点方案的一个 bad case。图中备库 B 下的虚线框,分别表示 relaylog 和 binlog 中的事务。可以看到,图 5 中从状态 1 到状态 4,一直处于延迟一个事务的状态。

备库 B 一直到状态 4 都和主库 A 存在延迟,如果用上面必须等到无延迟才能查询的方案,select 语句直到状态 4 都不能被执行。

但是,其实客户端是在发完 trx1 更新后发起的 select 语句,我们只需要确保 trx1 已经执行完成就可以执行 select 语句了。也就是说,如果在状态 3 执行查询请求,得到的就是预期结果了。

到这里,我们小结一下,semi-sync 配合判断主备无延迟的方案,存在两个问题:

  1. 一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
  2. 在持续延迟的情况下,可能出现过度等待的问题。

接下来,我要和你介绍的等主库位点方案,就可以解决这两个问题。

等主库位点方案

要理解等主库位点方案,我需要先和你介绍一条命令:

select master_pos_wait(file, pos[, timeout]);

这条命令的逻辑如下:

  1. 它是在从库执行的;
  2. 参数 file 和 pos 指的是主库上的文件名和位置;
  3. timeout 可选,设置为正整数 N 表示这个函数最多等待 N 秒。

这个命令正常返回的结果是一个正整数 M,表示从命令开始执行,到应用完 file 和 pos 表示的 binlog 位置,执行了多少事务。

当然,除了正常返回一个正整数 M 外,这条命令还会返回一些其他结果,包括:

  1. 如果执行期间,备库同步线程发生异常,则返回 NULL;
  2. 如果等待超过 N 秒,就返回 -1;
  3. 如果刚开始执行的时候,就发现已经执行过这个位置了,则返回 0。

对于图 5 中先执行 trx1,再执行一个查询请求的逻辑,要保证能够查到正确的数据,我们可以使用这个逻辑:

  1. trx1 事务更新完成后,马上执行 show master status 得到当前主库执行到的 File 和 Position;
  2. 选定一个从库执行查询语句;
  3. 在从库上执行 select master_pos_wait(File, Position, 1);
  4. 如果返回值是 >=0 的正整数,则在这个从库执行查询语句;
  5. 否则,到主库执行查询语句。

我把上面这个流程画出来。

img

图 6 master_pos_wait 方案

这里我们假设,这条 select 查询最多在从库上等待 1 秒。那么,如果 1 秒内 master_pos_wait 返回一个大于等于 0 的整数,就确保了从库上执行的这个查询结果一定包含了 trx1 的数据。

步骤 5 到主库执行查询语句,是这类方案常用的退化机制。因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。

你可能会说,如果所有的从库都延迟超过 1 秒了,那查询压力不就都跑到主库上了吗?确实是这样。

但是,按照我们设定不允许过期读的要求,就只有两种选择,一种是超时放弃,一种是转到主库查询。具体怎么选择,就需要业务开发同学做好限流策略了。

GTID 方案

如果你的数据库开启了 GTID 模式,对应的也有等待 GTID 的方案。

MySQL 中同样提供了一个类似的命令:

 select wait_for_executed_gtid_set(gtid_set, 1);

这条命令的逻辑是:

  1. 等待,直到这个库执行的事务中包含传入的 gtid_set,返回 0;
  2. 超时返回 1。

在前面等位点的方案中,我们执行完事务后,还要主动去主库执行 show master status。而 MySQL 5.7.6 版本开始,允许在执行完更新类事务后,把这个事务的 GTID 返回给客户端,这样等 GTID 的方案就可以减少一次查询。

这时,等 GTID 的执行流程就变成了:

  1. trx1 事务更新完成后,从返回包直接获取这个事务的 GTID,记为 gtid1;
  2. 选定一个从库执行查询语句;
  3. 在从库上执行 select wait_for_executed_gtid_set(gtid1, 1);
  4. 如果返回值是 0,则在这个从库执行查询语句;
  5. 否则,到主库执行查询语句。

跟等主库位点的方案一样,等待超时后是否直接到主库查询,需要业务开发同学来做限流考虑。

我把这个流程图画出来。

img

图 7 wait_for_executed_gtid_set 方案

在上面的第一步中,trx1 事务更新完成后,从返回包直接获取这个事务的 GTID。问题是,怎么能够让 MySQL 在执行事务后,返回包中带上 GTID 呢?

你只需要将参数 session_track_gtids 设置为 OWN_GTID,然后通过 API 接口 mysql_session_track_get_first 从返回包解析出 GTID 的值即可。

在专栏的[第一篇文章]中,我介绍 mysql_reset_connection 的时候,评论区有同学留言问这类接口应该怎么使用。

这里我再回答一下。其实,MySQL 并没有提供这类接口的 SQL 用法,是提供给程序的 API(https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html)。

比如,为了让客户端在事务提交后,返回的 GITD 能够在客户端显示出来,我对 MySQL 客户端代码做了点修改,如下所示:

img

图 8 显示更新事务的 GTID– 代码

这样,就可以看到语句执行完成,显示出 GITD 的值。

img

图 9 显示更新事务的 GTID– 效果

当然了,这只是一个例子。你要使用这个方案的时候,还是应该在你的客户端代码中调用 mysql_session_track_get_first 这个函数。

小结

在今天这篇文章中,我跟你介绍了一主多从做读写分离时,可能碰到过期读的原因,以及几种应对的方案。

这几种方案中,有的方案看上去是做了妥协,有的方案看上去不那么靠谱儿,但都是有实际应用场景的,你需要根据业务需求选择。

即使是最后等待位点和等待 GTID 这两个方案,虽然看上去比较靠谱儿,但仍然存在需要权衡的情况。如果所有的从库都延迟,那么请求就会全部落到主库上,这时候会不会由于压力突然增大,把主库打挂了呢?

其实,在实际应用中,这几个方案是可以混合使用的。

比如,先在客户端对请求做分类,区分哪些请求可以接受过期读,而哪些请求完全不能接受过期读;然后,对于不能接受过期读的语句,再使用等 GTID 或等位点的方案。

但话说回来,过期读在本质上是由一写多读导致的。在实际应用中,可能会有别的不需要等待就可以水平扩展的数据库方案,但这往往是用牺牲写性能换来的,也就是需要在读性能和写性能中取权衡。

最后 ,我给你留下一个问题吧。

假设你的系统采用了我们文中介绍的最后一个方案,也就是等 GTID 的方案,现在你要对主库的一张大表做 DDL,可能会出现什么情况呢?为了避免这种情况,你会怎么做呢?

你可以把你的分析和方案设计写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期给你留的问题是,在 GTID 模式下,如果一个新的从库接上主库,但是需要的 binlog 已经没了,要怎么做?

@某、人同学给了很详细的分析,我把他的回答略做修改贴过来。

  1. 如果业务允许主从不一致的情况,那么可以在主库上先执行 show global variables like ‘gtid_purged’,得到主库已经删除的 GTID 集合,假设是 gtid_purged1;然后先在从库上执行 reset master,再执行 set global gtid_purged =‘gtid_purged1’;最后执行 start slave,就会从主库现存的 binlog 开始同步。binlog 缺失的那一部分,数据在从库上就可能会有丢失,造成主从不一致。
  2. 如果需要主从数据一致的话,最好还是通过重新搭建从库来做。
  3. 如果有其他的从库保留有全量的 binlog 的话,可以把新的从库先接到这个保留了全量 binlog 的从库,追上日志以后,如果有需要,再接回主库。
  4. 如果 binlog 有备份的情况,可以先在从库上应用缺失的 binlog,然后再执行 start slave。

评论区留言点赞板:

@悟空 同学级联实验,验证了 seconds_behind_master 的计算逻辑。

@_CountingStars 问了一个好问题:MySQL 是怎么快速定位 binlog 里面的某一个 GTID 位置的?答案是,在 binlog 文件头部的 Previous_gtids 可以解决这个问题。

@王朋飞 同学问了一个好问题,sql_slave_skip_counter 跳过的是一个 event,由于 MySQL 总不能执行一半的事务,所以既然跳过了一个 event,就会跳到这个事务的末尾,因此 set global sql_slave_skip_counter=1;start slave 是可以跳过整个事务的。

29 如何判断一个数据库是不是出问题了?

我在第[25]和[27]篇文章中,和你介绍了主备切换流程。通过这些内容的讲解,你应该已经很清楚了:在一主一备的双 M 架构里,主备切换只需要把客户端流量切到备库;而在一主多从架构里,主备切换除了要把客户端流量切到备库外,还需要把从库接到新主库上。

主备切换有两种场景,一种是主动切换,一种是被动切换。而其中被动切换,往往是因为主库出问题了,由 HA 系统发起的。

这也就引出了我们今天要讨论的问题:怎么判断一个主库出问题了?

你一定会说,这很简单啊,连上 MySQL,执行个 select 1 就好了。但是 select 1 成功返回了,就表示主库没问题吗?

select 1 判断

实际上,select 1 成功返回,只能说明这个库的进程还在,并不能说明主库没问题。现在,我们来看一下这个场景。

set global innodb_thread_concurrency=3; 
CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
 insert into t values(1,1)

img

图 1 查询 blocked

我们设置 innodb_thread_concurrency 参数的目的是,控制 InnoDB 的并发线程上限。也就是说,一旦并发线程数达到这个值,InnoDB 在接收到新请求的时候,就会进入等待状态,直到有线程退出。

这里,我把 innodb_thread_concurrency 设置成 3,表示 InnoDB 只允许 3 个线程并行执行。而在我们的例子中,前三个 session 中的 sleep(100),使得这三个语句都处于“执行”状态,以此来模拟大查询。

你看到了, session D 里面,select 1 是能执行成功的,但是查询表 t 的语句会被堵住。也就是说,如果这时候我们用 select 1 来检测实例是否正常的话,是检测不出问题的。

在 InnoDB 中,innodb_thread_concurrency 这个参数的默认值是 0,表示不限制并发线程数量。但是,不限制并发线程数肯定是不行的。因为,一个机器的 CPU 核数有限,线程全冲进来,上下文切换的成本就会太高。

所以,通常情况下,我们建议把 innodb_thread_concurrency 设置为 64~128 之间的值。这时,你一定会有疑问,并发线程上限数设置为 128 够干啥,线上的并发连接数动不动就上千了。

产生这个疑问的原因,是搞混了并发连接和并发查询。

并发连接和并发查询,并不是同一个概念。你在 show processlist 的结果里,看到的几千个连接,指的就是并发连接。而“当前正在执行”的语句,才是我们所说的并发查询。

并发连接数达到几千个影响并不大,就是多占一些内存而已。我们应该关注的是并发查询,因为并发查询太高才是 CPU 杀手。这也是为什么我们需要设置 innodb_thread_concurrency 参数的原因。

然后,你可能还会想起我们在[第 7 篇文章]中讲到的热点更新和死锁检测的时候,如果把 innodb_thread_concurrency 设置为 128 的话,那么出现同一行热点更新的问题时,是不是很快就把 128 消耗完了,这样整个系统是不是就挂了呢?

实际上,在线程进入锁等待以后,并发线程的计数会减一,也就是说等行锁(也包括间隙锁)的线程是不算在 128 里面的。

MySQL 这样设计是非常有意义的。因为,进入锁等待的线程已经不吃 CPU 了;更重要的是,必须这么设计,才能避免整个系统锁死。

为什么呢?假设处于锁等待的线程也占并发线程的计数,你可以设想一下这个场景:

  1. 线程 1 执行 begin; update t set c=c+1 where id=1, 启动了事务 trx1, 然后保持这个状态。这时候,线程处于空闲状态,不算在并发线程里面。
  2. 线程 2 到线程 129 都执行 update t set c=c+1 where id=1; 由于等行锁,进入等待状态。这样就有 128 个线程处于等待状态;
  3. 如果处于锁等待状态的线程计数不减一,InnoDB 就会认为线程数用满了,会阻止其他语句进入引擎执行,这样线程 1 不能提交事务。而另外的 128 个线程又处于锁等待状态,整个系统就堵住了。

下图 2 显示的就是这个状态。

img

图 2 系统锁死状态(假设等行锁的语句占用并发计数)

这时候 InnoDB 不能响应任何请求,整个系统被锁死。而且,由于所有线程都处于等待状态,此时占用的 CPU 却是 0,而这明显不合理。所以,我们说 InnoDB 在设计时,遇到进程进入锁等待的情况时,将并发线程的计数减 1 的设计,是合理而且是必要的。

虽然说等锁的线程不算在并发线程计数里,但如果它在真正地执行查询,就比如我们上面例子中前三个事务中的 select sleep(100) from t,还是要算进并发线程的计数的。

在这个例子中,同时在执行的语句超过了设置的 innodb_thread_concurrency 的值,这时候系统其实已经不行了,但是通过 select 1 来检测系统,会认为系统还是正常的。

因此,我们使用 select 1 的判断逻辑要修改一下。

查表判断

为了能够检测 InnoDB 并发线程数过多导致的系统不可用情况,我们需要找一个访问 InnoDB 的场景。一般的做法是,在系统库(mysql 库)里创建一个表,比如命名为 health_check,里面只放一行数据,然后定期执行:

mysql> select * from mysql.health_check; 

使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。

但是,我们马上还会碰到下一个问题,即:空间满了以后,这种方法又会变得不好使。

我们知道,更新事务要写 binlog,而一旦 binlog 所在磁盘的空间占用率达到 100%,那么所有的更新语句和事务提交的 commit 语句就都会被堵住。但是,系统这时候还是可以正常读数据的。

因此,我们还是把这条监控语句再改进一下。接下来,我们就看看把查询语句改成更新语句后的效果。

更新判断

既然要更新,就要放个有意义的字段,常见做法是放一个 timestamp 字段,用来表示最后一次执行检测的时间。这条更新语句类似于:

mysql> update mysql.health_check set t_modified=now();

节点可用性的检测都应该包含主库和备库。如果用更新来检测主库的话,那么备库也要进行更新检测。

但,备库的检测也是要写 binlog 的。由于我们一般会把数据库 A 和 B 的主备关系设计为双 M 结构,所以在备库 B 上执行的检测命令,也要发回给主库 A。

但是,如果主库 A 和备库 B 都用相同的更新命令,就可能出现行冲突,也就是可能会导致主备同步停止。所以,现在看来 mysql.health_check 这个表就不能只有一行数据了。

为了让主备之间的更新不产生冲突,我们可以在 mysql.health_check 表上存入多行数据,并用 A、B 的 server_id 做主键。

mysql> CREATE TABLE `health_check` (
  `id` int(11) NOT NULL,
  `t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
/* 检测命令 */
insert into mysql.health_check(id, t_modified) values (@@server_id, now()) on duplicate key update t_modified=now();

由于 MySQL 规定了主库和备库的 server_id 必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。

更新判断是一个相对比较常用的方案了,不过依然存在一些问题。其中,“判定慢”一直是让 DBA 头疼的问题。

你一定会疑惑,更新语句,如果失败或者超时,就可以发起主备切换了,为什么还会有判定慢的问题呢?

其实,这里涉及到的是服务器 IO 资源分配的问题。

首先,所有的检测逻辑都需要一个超时时间 N。执行一条 update 语句,超过 N 秒后还不返回,就认为系统不可用。

你可以设想一个日志盘的 IO 利用率已经是 100% 的场景。这时候,整个系统响应非常慢,已经需要做主备切换了。

但是你要知道,IO 利用率 100% 表示系统的 IO 是在工作的,每个请求都有机会获得 IO 资源,执行自己的任务。而我们的检测使用的 update 命令,需要的资源很少,所以可能在拿到 IO 资源的时候就可以提交成功,并且在超时时间 N 秒未到达之前就返回给了检测系统。

检测系统一看,update 命令没有超时,于是就得到了“系统正常”的结论。

也就是说,这时候在业务系统上正常的 SQL 语句已经执行得很慢了,但是 DBA 上去一看,HA 系统还在正常工作,并且认为主库现在处于可用状态。

之所以会出现这个现象,根本原因是我们上面说的所有方法,都是基于外部检测的。外部检测天然有一个问题,就是随机性。

因为,外部检测都需要定时轮询,所以系统可能已经出问题了,但是却需要等到下一个检测发起执行语句的时候,我们才有可能发现问题。而且,如果你的运气不够好的话,可能第一次轮询还不能发现,这就会导致切换慢的问题。

所以,接下来我要再和你介绍一种在 MySQL 内部发现数据库问题的方法。

内部统计

针对磁盘利用率这个问题,如果 MySQL 可以告诉我们,内部每一次 IO 请求的时间,那我们判断数据库是否出问题的方法就可靠得多了。

其实,MySQL 5.6 版本以后提供的 performance_schema 库,就在 file_summary_by_event_name 表里统计了每次 IO 请求的时间。

file_summary_by_event_name 表里有很多行数据,我们先来看看 event_name=‘wait/io/file/innodb/innodb_log_file’这一行。

img

图 3 performance_schema.file_summary_by_event_name 的一行

图中这一行表示统计的是 redo log 的写入时间,第一列 EVENT_NAME 表示统计的类型。

接下来的三组数据,显示的是 redo log 操作的时间统计。

第一组五列,是所有 IO 类型的统计。其中,COUNT_STAR 是所有 IO 的总次数,接下来四列是具体的统计项, 单位是皮秒;前缀 SUM、MIN、AVG、MAX,顾名思义指的就是总和、最小值、平均值和最大值。

第二组六列,是读操作的统计。最后一列 SUM_NUMBER_OF_BYTES_READ 统计的是,总共从 redo log 里读了多少个字节。

第三组六列,统计的是写操作。

最后的第四组数据,是对其他类型数据的统计。在 redo log 里,你可以认为它们就是对 fsync 的统计。

在 performance_schema 库的 file_summary_by_event_name 表里,binlog 对应的是 event_name = “wait/io/file/sql/binlog”这一行。各个字段的统计逻辑,与 redo log 的各个字段完全相同。这里,我就不再赘述了。

因为我们每一次操作数据库,performance_schema 都需要额外地统计这些信息,所以我们打开这个统计功能是有性能损耗的。

我的测试结果是,如果打开所有的 performance_schema 项,性能大概会下降 10% 左右。所以,我建议你只打开自己需要的项进行统计。你可以通过下面的方法打开或者关闭某个具体项的统计。

如果要打开 redo log 的时间监控,你可以执行这个语句:

mysql> update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innodb_log_file%';

假设,现在你已经开启了 redo log 和 binlog 这两个统计信息,那要怎么把这个信息用在实例状态诊断上呢?

很简单,你可以通过 MAX_TIMER 的值来判断数据库是否出问题了。比如,你可以设定阈值,单次 IO 请求时间超过 200 毫秒属于异常,然后使用类似下面这条语句作为检测逻辑。

mysql> select event_name,MAX_TIMER_WAIT  FROM performance_schema.file_summary_by_event_name where event_name in ('wait/io/file/innodb/innodb_log_file','wait/io/file/sql/binlog') and MAX_TIMER_WAIT>200*1000000000;

发现异常后,取到你需要的信息,再通过下面这条语句:

mysql> truncate table performance_schema.file_summary_by_event_name;

把之前的统计信息清空。这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。

小结

今天,我和你介绍了检测一个 MySQL 实例健康状态的几种方法,以及各种方法存在的问题和演进的逻辑。

你看完后可能会觉得,select 1 这样的方法是不是已经被淘汰了呢,但实际上使用非常广泛的 MHA(Master High Availability),默认使用的就是这个方法。

MHA 中的另一个可选方法是只做连接,就是 “如果连接成功就认为主库没问题”。不过据我所知,选择这个方法的很少。

其实,每个改进的方案,都会增加额外损耗,并不能用“对错”做直接判断,需要你根据业务实际情况去做权衡。

我个人比较倾向的方案,是优先考虑 update 系统表,然后再配合增加检测 performance_schema 的信息。

最后,又到了我们的思考题时间。

今天,我想问你的是:业务系统一般也有高可用的需求,在你开发和维护过的服务中,你是怎么判断服务有没有出问题的呢?

你可以把你用到的方法和分析写在留言区,我会在下一篇文章中选取有趣的方案一起来分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,如果使用 GTID 等位点的方案做读写分离,在对大表做 DDL 的时候会怎么样。

假设,这条语句在主库上要执行 10 分钟,提交后传到备库就要 10 分钟(典型的大事务)。那么,在主库 DDL 之后再提交的事务的 GTID,去备库查的时候,就会等 10 分钟才出现。

这样,这个读写分离机制在这 10 分钟之内都会超时,然后走主库。

这种预期内的操作,应该在业务低峰期的时候,确保主库能够支持所有业务查询,然后把读请求都切到主库,再在主库上做 DDL。等备库延迟追上以后,再把读请求切回备库。

通过这个思考题,我主要想让关注的是,大事务对等位点方案的影响。

当然了,使用 gh-ost 方案来解决这个问题也是不错的选择。

评论区留言点赞板:

@曾剑、@max 同学提到的备库先做,再切主库的方法也是可以的。

30 答疑文章(二):用动态的观点看加锁

在第[20]和[21]篇文章中,我和你介绍了 InnoDB 的间隙锁、next-key lock,以及加锁规则。在这两篇文章的评论区,出现了很多高质量的留言。我觉得通过分析这些问题,可以帮助你加深对加锁规则的理解。

所以,我就从中挑选了几个有代表性的问题,构成了今天这篇答疑文章的主题,即:用动态的观点看加锁。

为了方便你理解,我们再一起复习一下加锁规则。这个规则中,包含了两个“原则”、两个“优化”和一个“bug”:

  • 原则 1:加锁的基本单位是 next-key lock。希望你还记得,next-key lock 是前开后闭区间。
  • 原则 2:查找过程中访问到的对象才会加锁。
  • 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  • 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  • 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

接下来,我们的讨论还是基于下面这个表 t:

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `c` (`c`)
) ENGINE=InnoDB; 
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);

不等号条件里的等值查询

有同学对“等值查询”提出了疑问:等值查询和“遍历”有什么区别?为什么我们文章的例子里面,where 条件是不等号,这个过程里也有等值查询?

我们一起来看下这个例子,分析一下这条查询语句的加锁范围:

begin;
select * from t where id>9 and id<12 order by id desc for update;

利用上面的加锁规则,我们知道这个语句的加锁范围是主键索引上的 (0,5]、(5,10] 和 (10, 15)。也就是说,id=15 这一行,并没有被加上行锁。为什么呢?

我们说加锁单位是 next-key lock,都是前开后闭区间,但是这里用到了优化 2,即索引上的等值查询,向右遍历的时候 id=15 不满足条件,所以 next-key lock 退化为了间隙锁 (10, 15)。

但是,我们的查询语句中 where 条件是大于号和小于号,这里的“等值查询”又是从哪里来的呢?

要知道,加锁动作是发生在语句执行过程中的,所以你在分析加锁行为的时候,要从索引上的数据结构开始。这里,我再把这个过程拆解一下。

如图 1 所示,是这个表的索引 id 的示意图。

img

图 1 索引 id 示意图

  1. 首先这个查询语句的语义是 order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个 id<12 的值”。
  2. 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到 id=12 的这个值,只是最终没找到,但找到了 (10,15) 这个间隙。
  3. 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到 id=5 这一行,所以会加一个 next-key lock (0,5]。

也就是说,在执行过程中,通过树搜索的方式定位记录的时候,用的是“等值查询”的方法。

等值查询的过程

与上面这个例子对应的,是 @发条橙子同学提出的问题:下面这个语句的加锁范围是什么?

begin;
select id from t where c in(5,20,10) lock in share mode;

这条查询语句里用的是 in,我们先来看这条语句的 explain 结果。img

图 2 in 语句的 explain 结果

可以看到,这条 in 语句使用了索引 c 并且 rows=3,说明这三个值都是通过 B+ 树搜索定位的。

在查找 c=5 的时候,先锁住了 (0,5]。但是因为 c 不是唯一索引,为了确认还有没有别的记录 c=5,就要向右遍历,找到 c=10 才确认没有了,这个过程满足优化 2,所以加了间隙锁 (5,10)。

同样的,执行 c=10 这个逻辑的时候,加锁的范围是 (5,10] 和 (10,15);执行 c=20 这个逻辑的时候,加锁的范围是 (15,20] 和 (20,25)。

通过这个分析,我们可以知道,这条语句在索引 c 上加的三个记录锁的顺序是:先加 c=5 的记录锁,再加 c=10 的记录锁,最后加 c=20 的记录锁。

你可能会说,这个加锁范围,不就是从 (5,25) 中去掉 c=15 的行锁吗?为什么这么麻烦地分段说呢?

因为我要跟你强调这个过程:这些锁是“在执行过程中一个一个加的”,而不是一次性加上去的。

理解了这个加锁过程之后,我们就可以来分析下面例子中的死锁问题了。

如果同时有另外一个语句,是这么写的:

select id from t where c in(5,20,10) order by c desc for update;

此时的加锁范围,又是什么呢?

我们现在都知道间隙锁是不互锁的,但是这两条语句都会在索引 c 上的 c=5、10、20 这三行记录上加记录锁。

这里你需要注意一下,由于语句里面是 order by c desc, 这三个记录锁的加锁顺序,是先锁 c=20,然后 c=10,最后是 c=5。

也就是说,这两条语句要加锁相同的资源,但是加锁顺序相反。当这两条语句并发执行的时候,就可能出现死锁。

关于死锁的信息,MySQL 只保留了最后一个死锁的现场,但这个现场还是不完备的。

有同学在评论区留言到,希望我能展开一下怎么看死锁。现在,我就来简单分析一下上面这个例子的死锁现场。

怎么看死锁?

图 3 是在出现死锁后,执行 show engine innodb status 命令得到的部分输出。这个命令会输出很多信息,有一节 LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。img

图 3 死锁现场

我们来看看这图中的几个关键信息。

  1. 这个结果分成三部分:
    • (1) TRANSACTION,是第一个事务的信息;
    • (2) TRANSACTION,是第二个事务的信息;
    • WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。
  2. 第一个事务的信息中:
    • WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息;
    • index c of table test.t,说明在等的是表 t 的索引 c 上面的锁;
    • lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;
    • Record lock 说明这是一个记录锁;
    • n_fields 2 表示这个记录是两列,也就是字段 c 和主键字段 id;
    • 0: len 4; hex 0000000a; asc ;; 是第一个字段,也就是 c。值是十六进制 a,也就是 10;
    • 1: len 4; hex 0000000a; asc ;; 是第二个字段,也就是主键 id,值也是 10;
    • 这两行里面的 asc 表示的是,接下来要打印出值里面的“可打印字符”,但 10 不是可打印字符,因此就显示空格。
    • 第一个事务信息就只显示出了等锁的状态,在等待 (c=10,id=10) 这一行的锁。
    • 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
  3. 第二个事务显示的信息要多一些:
    • “ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;
    • index c of table test.t 表示锁是在表 t 的索引 c 上;
    • hex 0000000a 和 hex 00000014 表示这个事务持有 c=10 和 c=20 这两个记录锁;
    • WAITING FOR THIS LOCK TO BE GRANTED,表示在等 (c=5,id=5) 这个记录锁。

从上面这些信息中,我们就知道:

  1. “lock in share mode”的这条语句,持有 c=5 的记录锁,在等 c=10 的锁;
  2. “for update”这个语句,持有 c=20 和 c=10 的记录锁,在等 c=5 的记录锁。

因此导致了死锁。这里,我们可以得到两个结论:

  1. 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
  2. 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以 InnoDB 选择了回滚成本更小的 lock in share mode 语句,来回滚。

怎么看锁等待?

看完死锁,我们再来看一个锁等待的例子。

在第 21 篇文章的评论区,@Geek_9ca34e 同学做了一个有趣验证,我把复现步骤列出来:

img

图 4 delete 导致间隙变化

可以看到,由于 session A 并没有锁住 c=10 这个记录,所以 session B 删除 id=10 这一行是可以的。但是之后,session B 再想 insert id=10 这一行回去就不行了。

现在我们一起看一下此时 show engine innodb status 的结果,看看能不能给我们一些提示。锁信息是在这个命令输出结果的 TRANSACTIONS 这一节。你可以在文稿中看到这张图片img

图 5 锁等待信息

我们来看几个关键信息。

  1. index PRIMARY of table test.t ,表示这个语句被锁住是因为表 t 主键上的某个锁。
  2. lock_mode X locks gap before rec insert intention waiting 这里有几个信息:
    • insert intention 表示当前线程准备插入一个记录,这是一个插入意向锁。为了便于理解,你可以认为它就是这个插入动作本身。
    • gap before rec 表示这是一个间隙锁,而不是记录锁。
  3. 那么这个 gap 是在哪个记录之前的呢?接下来的 0~4 这 5 行的内容就是这个记录的信息。
  4. n_fields 5 也表示了,这一个记录有 5 列:
    • 0: len 4; hex 0000000f; asc ;; 第一列是主键 id 字段,十六进制 f 就是 id=15。所以,这时我们就知道了,这个间隙就是 id=15 之前的,因为 id=10 已经不存在了,它表示的就是 (5,15)。
    • 1: len 6; hex 000000000513; asc ;; 第二列是长度为 6 字节的事务 id,表示最后修改这一行的是 trx id 为 1299 的事务。
    • 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为 7 字节的回滚段信息。可以看到,这里的 acs 后面有显示内容 (% 和 4),这是因为刚好这个字节是可打印字符。
    • 后面两列是 c 和 d 的值,都是 15。

因此,我们就知道了,由于 delete 操作把 id=10 这一行删掉了,原来的两个间隙 (5,10)、(10,15)变成了一个 (5,15)。

说到这里,你可以联合起来再思考一下这两个现象之间的关联:

  1. session A 执行完 select 语句后,什么都没做,但它加锁的范围突然“变大”了;
  2. 第 21 篇文章的课后思考题,当我们执行 select * from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到 c=10 的时候,要把 (5, 10] 锁起来。

也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。

update 的例子

看过了 insert 和 delete 的加锁例子,我们再来看一个 update 语句的案例。在留言区中 @信信 同学做了这个试验:

img

图 6 update 的例子

你可以自己分析一下,session A 的加锁范围是索引 c 上的 (5,10]、(10,15]、(15,20]、(20,25] 和 (25,supremum]。

注意:根据 c>5 查到的第一个记录是 c=10,因此不会加 (0,5] 这个 next-key lock。

之后 session B 的第一个 update 语句,要把 c=5 改成 c=1,你可以理解为两步:

  1. 插入 (c=1, id=5) 这个记录;
  2. 删除 (c=5, id=5) 这个记录。

按照我们上一节说的,索引 c 上 (5,10) 间隙是由这个间隙右边的记录,也就是 c=10 定义的。所以通过这个操作,session A 的加锁范围变成了图 7 所示的样子:img

图 7 session B 修改后, session A 的加锁范围

好,接下来 session B 要执行 update t set c = 5 where c = 1 这个语句了,一样地可以拆成两步:

  1. 插入 (c=5, id=5) 这个记录;
  2. 删除 (c=1, id=5) 这个记录。

第一步试图在已经加了间隙锁的 (1,10) 中插入数据,所以就被堵住了。

小结

今天这篇文章,我用前面[第 20]和[第 21 篇]文章评论区的几个问题,再次跟你复习了加锁规则。并且,我和你重点说明了,分析加锁范围时,一定要配合语句执行逻辑来进行。

在我看来,每个想认真了解 MySQL 原理的同学,应该都要能够做到:通过 explain 的结果,就能够脑补出一个 SQL 语句的执行流程。达到这样的程度,才算是对索引组织表、索引、锁的概念有了比较清晰的认识。你同样也可以用这个方法,来验证自己对这些知识点的掌握程度。

在分析这些加锁规则的过程中,我也顺便跟你介绍了怎么看 show engine innodb status 输出结果中的事务信息和死锁信息,希望这些内容对你以后分析现场能有所帮助。

老规矩,即便是答疑文章,我也还是要留一个课后问题给你的。

上面我们提到一个很重要的点:所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。

那么,一个空表有间隙吗?这个间隙是由谁定义的?你怎么验证这个结论呢?

你可以把你关于分析和验证方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章最后留给的问题,是分享一下你关于业务监控的处理经验。

在这篇文章的评论区,很多同学都分享了不错的经验。这里,我就选择几个比较典型的留言,和你分享吧:

  • @老杨同志 回答得很详细。他的主要思路就是关于服务状态和服务质量的监控。其中,服务状态的监控,一般都可以用外部系统来实现;而服务的质量的监控,就要通过接口的响应时间来统计。
  • @Ryoma 同学,提到服务中使用了 healthCheck 来检测,其实跟我们文中提到的 select 1 的模式类似。
  • @强哥 同学,按照监控的对象,将监控分成了基础监控、服务监控和业务监控,并分享了每种监控需要关注的对象。

这些都是很好的经验,你也可以根据具体的业务场景借鉴适合自己的方案。

31 误删数据后除了跑路,还能怎么办?

今天我要和你讨论的是一个沉重的话题:误删数据。

在前面几篇文章中,我们介绍了 MySQL 的高可用架构。当然,传统的高可用架构是不能预防误删数据的,因为主库的一个 drop table 命令,会通过 binlog 传给所有从库和级联从库,进而导致整个集群的实例都会执行这个命令。

虽然我们之前遇到的大多数的数据被删,都是运维同学或者 DBA 背锅的。但实际上,只要有数据操作权限的同学,都有可能踩到误删数据这条线。

今天我们就来聊聊误删数据前后,我们可以做些什么,减少误删数据的风险,和由误删数据带来的损失。

为了找到解决误删数据的更高效的方法,我们需要先对和 MySQL 相关的误删数据,做下分类:

  1. 使用 delete 语句误删数据行;
  2. 使用 drop table 或者 truncate table 语句误删数据表;
  3. 使用 drop database 语句误删数据库;
  4. 使用 rm 命令误删整个 MySQL 实例。

误删行

在[第 24 篇文章]中,我们提到如果是使用 delete 语句误删了数据行,可以用 Flashback 工具通过闪回把数据恢复回来。

Flashback 恢复数据的原理,是修改 binlog 的内容,拿回原库重放。而能够使用这个方案的前提是,需要确保 binlog_format=row 和 binlog_row_image=FULL。

具体恢复数据时,对单个事务做如下处理:

  1. 对于 insert 语句,对应的 binlog event 类型是 Write_rows event,把它改成 Delete_rows event 即可;
  2. 同理,对于 delete 语句,也是将 Delete_rows event 改为 Write_rows event;
  3. 而如果是 Update_rows 的话,binlog 里面记录了数据行修改前和修改后的值,对调这两行的位置即可。

如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务:

(A)delete ...
(B)insert ...
(C)update ...

现在要把数据库恢复回这三个事务操作之前的状态,用 Flashback 工具解析 binlog 后,写回主库的命令是:

(reverse C)update ...
(reverse B)delete ...
(reverse A)insert ...

也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。

需要说明的是,我不建议你直接在主库上执行这些操作。

恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。

为什么要这么做呢?

这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏。

当然,我们不止要说误删数据的事后处理办法,更重要是要做到事前预防。我有以下两个建议:

  1. 把 sql_safe_updates 参数设置为 on。这样一来,如果我们忘记在 delete 或者 update 语句中写 where 条件,或者 where 条件里面没有包含索引字段的话,这条语句的执行就会报错。
  2. 代码上线前,必须经过 SQL 审计。

你可能会说,设置了 sql_safe_updates=on,如果我真的要把一个小表的数据全部删掉,应该怎么办呢?

如果你确定这个删除操作没问题的话,可以在 delete 语句中加上 where 条件,比如 where id>=0。

但是,delete 全表是很慢的,需要生成回滚日志、写 redo、写 binlog。所以,从性能角度考虑,你应该优先考虑使用 truncate table 或者 drop table 命令。

使用 delete 命令删除的数据,你还可以用 Flashback 来恢复。而使用 truncate /drop table 和 drop database 命令删除的数据,就没办法通过 Flashback 来恢复了。为什么呢?

这是因为,即使我们配置了 binlog_format=row,执行这三个命令时,记录的 binlog 还是 statement 格式。binlog 里面就只有一个 truncate/drop 语句,这些信息是恢复不出数据的。

那么,如果我们真的是使用这几条命令误删数据了,又该怎么办呢?

误删库 / 表

这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份 binlog。

在这两个条件都具备的情况下,假如有人中午 12 点误删了一个库,恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天 0 点;
  2. 用备份恢复出一个临时库;
  3. 从日志备份里面,取出凌晨 0 点之后的日志;
  4. 把这些日志,除了误删除数据的语句外,全部应用到临时库。

这个流程的示意图如下所示:

img

图 1 数据恢复流程 -mysqlbinlog 方法

关于这个过程,我需要和你说明如下几点:

  1. 为了加速数据恢复,如果这个临时库上有多个数据库,你可以在使用 mysqlbinlog 命令时,加上一个–database 参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要应用其他库日志的情况。
  2. 在应用日志的时候,需要跳过 12 点误操作的那个语句的 binlog:
    • 如果原实例没有使用 GTID 模式,只能在应用到包含 12 点的 binlog 文件的时候,先用–stop-position 参数执行到误操作之前的日志,然后再用–start-position 从误操作之后的日志继续执行;
    • 如果实例使用了 GTID 模式,就方便多了。假设误操作命令的 GTID 是 gtid1,那么只需要执行 set gtid_next=gtid1;begin;commit; 先把这个 GTID 加到临时实例的 GTID 集合,之后按顺序执行 binlog 的时候,就会自动跳过误操作的语句。

不过,即使这样,使用 mysqlbinlog 方法恢复数据还是不够快,主要原因有两个:

  1. 如果是误删表,最好就是只恢复出这张表,也就是只重放这张表的操作,但是 mysqlbinlog 工具并不能指定只解析一个表的日志;
  2. 用 mysqlbinlog 解析出日志应用,应用日志的过程就只能是单线程。我们在[第 26 篇文章]中介绍的那些并行复制的方法,在这里都用不上。

一种加速的方法是,在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:

  1. 在 start slave 之前,先通过执行 change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表;
  2. 这样做也可以用上并行复制技术,来加速整个数据恢复过程。

这个过程的示意图如下所示。

img

图 2 数据恢复流程 -master-slave 方法

可以看到,图中 binlog 备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上已经删除了临时实例需要的 binlog 的话,我们可以从 binlog 备份系统中找到需要的 binlog,再放回备库中。

假设,我们发现当前临时实例需要的 binlog 是从 master.000005 开始的,但是在备库上执行 show binlogs 显示的最小的 binlog 文件是 master.000007,意味着少了两个 binlog 文件。这时,我们就需要去 binlog 备份系统中找到这两个文件。

把之前删掉的 binlog 放回备库的操作步骤,是这样的:

  1. 从备份系统下载 master.000005 和 master.000006 这两个文件,放到备库的日志目录下;
  2. 打开日志目录下的 master.index 文件,在文件开头加入两行,内容分别是 “./master.000005”和“./master.000006”;
  3. 重启备库,目的是要让备库重新识别这两个日志文件;
  4. 现在这个备库上就有了临时库需要的所有 binlog 了,建立主备关系,就可以正常同步了。

不论是把 mysqlbinlog 工具解析出的 binlog 文件应用到临时库,还是把临时库接到备库上,这两个方案的共同点是:误删库或者表后,恢复数据的思路主要就是通过备份,再加上应用 binlog 的方式。

也就是说,这两个方案都要求备份系统定期备份全量日志,而且需要确保 binlog 在被从本地删除之前已经做了备份。

但是,一个系统不可能备份无限的日志,你还需要根据成本和磁盘空间资源,设定一个日志保留的天数。如果你的 DBA 团队告诉你,可以保证把某个实例恢复到半个月内的任意时间点,这就表示备份系统保留的日志时间就至少是半个月。

另外,我建议你不论使用上述哪种方式,都要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。为什么这么说呢?

这里的原因,主要包括两个方面:

  1. 虽然“发生这种事,大家都不想的”,但是万一出现了误删事件,能够快速恢复数据,将损失降到最小,也应该不用跑路了。
  2. 而如果临时再手忙脚乱地手动操作,最后又误操作了,对业务造成了二次伤害,那就说不过去了。

延迟复制备库

虽然我们可以通过利用并行复制来加速恢复数据的过程,但是这个方案仍然存在“恢复时间不可控”的问题。

如果一个库的备份特别大,或者误操作的时间距离上一个全量备份的时间较长,比如一周一备的实例,在备份之后的第 6 天发生误操作,那就需要恢复 6 天的日志,这个恢复时间可能是要按天来计算的。

那么,我们有什么方法可以缩短恢复数据需要的时间呢?

如果有非常核心的业务,不允许太长的恢复时间,我们可以考虑搭建延迟复制的备库。这个功能是 MySQL 5.6 版本引入的。

一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。

延迟复制的备库是一种特殊的备库,通过 CHANGE MASTER TO MASTER_DELAY = N 命令,可以指定这个备库持续保持跟主库有 N 秒的延迟。

比如你把 N 设置为 3600,这就代表了如果主库上有数据被误删了,并且在 1 小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行。这时候到这个备库上执行 stop slave,再通过之前介绍的方法,跳过误操作命令,就可以恢复出需要的数据。

这样的话,你就随时可以得到一个,只需要最多再追 1 小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间。

预防误删库 / 表的方法

虽然常在河边走,很难不湿鞋,但终究还是可以找到一些方法来避免的。所以这里,我也会给你一些减少误删操作风险的建议。

第一条建议是,账号分离。这样做的目的是,避免写错命令。比如:

  • 我们只给业务开发同学 DML 权限,而不给 truncate/drop 权限。而如果业务开发人员有 DDL 需求的话,也可以通过开发管理系统得到支持。
  • 即使是 DBA 团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号。

第二条建议是,制定操作规范。这样做的目的,是避免写错要删除的表名。比如:

  • 在删除数据表之前,必须先对表做改名操作。然后,观察一段时间,确保对业务无影响以后再删除这张表。
  • 改表名的时候,要求给表名加固定的后缀(比如加 _to_be_deleted),然后删除表的动作必须通过管理系统执行。并且,管理系删除表的时候,只能删除固定后缀的表。

rm 删除数据

其实,对于一个有高可用机制的 MySQL 集群来说,最不怕的就是 rm 删除数据了。只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA 系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作。

这时,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。

当然了,现在不止是 DBA 有自动化系统,SA(系统管理员)也有自动化系统,所以也许一个批量下线机器的操作,会让你整个 MySQL 集群的所有节点都全军覆没。

应对这种情况,我的建议只能是说尽量把你的备份跨机房,或者最好是跨城市保存。

小结

今天,我和你讨论了误删数据的几种可能,以及误删后的处理方法。

但,我要强调的是,预防远比处理的意义来得大。

另外,在 MySQL 的集群方案中,会时不时地用到备份来恢复实例,因此定期检查备份的有效性也很有必要。

如果你是业务开发同学,你可以用 show grants 命令查看账户的权限,如果权限过大,可以建议 DBA 同学给你分配权限低一些的账号;你也可以评估业务的重要性,和 DBA 商量备份的周期、是否有必要创建延迟复制的备库等等。

数据和服务的可靠性不止是运维团队的工作,最终是各个环节一起保障的结果。

今天的课后话题是,回忆下你亲身经历过的误删数据事件吧,你用了什么方法来恢复数据呢?你在这个过程中得到的经验又是什么呢?

你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起讨论。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章给你留的问题,是关于空表的间隙的定义。

一个空表就只有一个间隙。比如,在空表上执行:

begin;
select * from t where id>1 for update;

这个查询语句加锁的范围就是 next-key lock (-∞, supremum]。

验证方法的话,你可以使用下面的操作序列。你可以在图 4 中看到显示的结果。

img

图 3 复现空表的 next-key lock

img

图 4 show engine innodb status 部分结果

评论区留言点赞板:

@老杨同志 给出了正确的分析和 SQL 语句验证方法; @库淘淘 指出了 show engine innodb status 验证结论。

赞这些思考和反馈。

32 为什么还有kill不掉的语句?

在 MySQL 中有两个 kill 命令:一个是 kill query + 线程 id,表示终止这个线程中正在执行的语句;一个是 kill connection + 线程 id,这里 connection 可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。

不知道你在使用 MySQL 的时候,有没有遇到过这样的现象:使用了 kill 命令,却没能断开这个连接。再执行 show processlist 命令,看到这条语句的 Command 列显示的是 Killed。

你一定会奇怪,显示为 Killed 是什么意思,不是应该直接在 show processlist 的结果里看不到这个线程了吗?

今天,我们就来讨论一下这个问题。

其实大多数情况下,kill query/connection 命令是有效的。比如,执行一个查询的过程中,发现执行时间太久,要放弃继续查询,这时我们就可以用 kill query 命令,终止这条查询语句。

还有一种情况是,语句处于锁等待的时候,直接使用 kill 命令也是有效的。我们一起来看下这个例子:

img

图 1 kill query 成功的例子

可以看到,session C 执行 kill query 以后,session B 几乎同时就提示了语句被中断。这,就是我们预期的结果。

收到 kill 以后,线程做什么?

但是,这里你要停下来想一下:session B 是直接终止掉线程,什么都不管就直接退出吗?显然,这是不行的。

我在[第 6 篇文章]中讲过,当对一个表做增删改查操作时,会在表上加 MDL 读锁。所以,session B 虽然处于 blocked 状态,但还是拿着一个 MDL 读锁的。如果线程被 kill 的时候,就直接终止,那之后这个 MDL 读锁就没机会被释放了。

这样看来,kill 并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。

其实,这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。

实现上,当用户执行 kill query thread_id_B 时,MySQL 里处理 kill 命令的线程做了两件事:

  1. 把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
  2. 给 session B 的执行线程发一个信号。

为什么要发信号呢?

因为像图 1 的我们例子里面,session B 处于锁等待状态,如果只是把 session B 的线程状态设置 THD::KILL_QUERY,线程 B 并不知道这个状态变化,还是会继续等待。发一个信号的目的,就是让 session B 退出等待,来处理这个 THD::KILL_QUERY 状态。

上面的分析中,隐含了这么三层意思:

  1. 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是 THD::KILL_QUERY,才开始进入语句终止逻辑;
  2. 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;
  3. 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。

到这里你就知道了,原来不是“说停就停的”。

接下来,我们再看一个 kill 不掉的例子,也就是我们在前面[第 29 篇文章]中提到的 innodb_thread_concurrency 不够用的例子。

首先,执行 set global innodb_thread_concurrency=2,将 InnoDB 的并发线程上限数设置为 2;然后,执行下面的序列:

img

图 2 kill query 无效的例子

可以看到:

  1. sesssion C 执行的时候被堵住了;
  2. 但是 session D 执行的 kill query C 命令却没什么效果,
  3. 直到 session E 执行了 kill connection 命令,才断开了 session C 的连接,提示“Lost connection to MySQL server during query”,
  4. 但是这时候,如果在 session E 中执行 show processlist,你就能看到下面这个图。

img

图 3 kill connection 之后的效果

这时候,id=12 这个线程的 Commnad 列显示的是 Killed。也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。

为什么在执行 kill query 命令时,这条语句不像第一个例子的 update 语句一样退出呢?

在实现上,等行锁时,使用的是 pthread_cond_timedwait 函数,这个等待状态可以被唤醒。但是,在这个例子里,12 号线程的等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,就调用 nanosleep 函数进入 sleep 状态。

也就是说,虽然 12 号线程的状态已经被设置成了 KILL_QUERY,但是在这个等待进入 InnoDB 的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。

而当 session E 执行 kill connection 命令时,是这么做的,

  1. 把 12 号线程状态设置为 KILL_CONNECTION;
  2. 关掉 12 号线程的网络连接。因为有这个操作,所以你会看到,这时候 session C 收到了断开连接的提示。

那为什么执行 show processlist 的时候,会看到 Command 列显示为 killed 呢?其实,这就是因为在执行 show processlist 的时候,有一个特别的逻辑:

如果一个线程的状态是 KILL_CONNECTION,就把 Command 列显示成 Killed。

所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。那这个线程什么时候会退出呢?

答案是,只有等到满足进入 InnoDB 的条件后,session C 的查询语句继续执行,然后才有可能判断到线程状态已经变成了 KILL_QUERY 或者 KILL_CONNECTION,再进入终止逻辑阶段。

到这里,我们来小结一下。

这个例子是 kill 无效的第一类情况,即:线程没有执行到判断线程状态的逻辑。跟这种情况相同的,还有由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程的状态。

另一类情况是,终止逻辑耗时较长。这时候,从 show processlist 结果上看也是 Command=Killed,需要等到终止逻辑完成,语句才算真正完成。这类情况,比较常见的场景有以下几种:

  1. 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。
  2. 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
  3. DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。

之前有人问过我,如果直接在客户端通过 Ctrl+C 命令,是不是就可以直接终止线程呢?

答案是,不可以。

这里有一个误解,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。

而由于 MySQL 是停等协议,所以这个线程执行的语句还没有返回的时候,再往这个连接里面继续发命令也是没有用的。实际上,执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 kill query 命令。

所以,你可别以为在客户端执行完 Ctrl+C 就万事大吉了。因为,要 kill 掉一个线程,还涉及到后端的很多操作。

另外两个关于客户端的误解

在实际使用中,我也经常会碰到一些同学对客户端的使用有误解。接下来,我们就来看看两个最常见的误解。

第一个误解是:如果库里面的表特别多,连接就会很慢。

有些线上的库,会包含很多表(我见过最多的一个库里有 6 万个表)。这时候,你就会发现,每次用客户端连接都会卡在下面这个界面上。

img

图 4 连接等待

而如果 db1 这个库里表很少的话,连接起来就会很快,可以很快进入输入命令的状态。因此,有同学会认为是表的数目影响了连接性能。

从[第一篇文章]你就知道,每个客户端在和服务端建立连接的时候,需要做的事情就是 TCP 握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关。

但实际上,正如图中的文字提示所说的,当使用默认参数连接的时候,MySQL 客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:

  1. 执行 show databases;
  2. 切到 db1 库,执行 show tables;
  3. 把这两个命令的结果用于构建一个本地的哈希表。

在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。

也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。

图中的提示也说了,如果在连接命令中加上 -A,就可以关掉这个自动补全的功能,然后客户端就可以快速返回了。

这里自动补全的效果就是,你在输入库名或者表名的时候,输入前缀,可以使用 Tab 键自动补全表名或者显示提示。

实际使用中,如果你自动补全功能用得并不多,我建议你每次使用的时候都默认加 -A。

其实提示里面没有说,除了加 -A 以外,加–quick(或者简写为 -q) 参数,也可以跳过这个阶段。但是,这个–quick 是一个更容易引起误会的参数,也是关于客户端常见的一个误解。

你看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能。为什么这么说呢?

MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:

  1. 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用 API 开发,对应的就是 mysql_store_result 方法。
  2. 另一种是不缓存,读一个处理一个。如果你用 API 开发,对应的就是 mysql_use_result 方法。

MySQL 客户端默认采用第一种方式,而如果加上–quick 参数,就会使用第二种不缓存的方式。

采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。关于服务端的具体行为,我会在下一篇文章再和你展开说明。

那你会说,既然这样,为什么要给这个参数取名叫作 quick 呢?这是因为使用这个参数可以达到以下三点效果:

  • 第一点,就是前面提到的,跳过表名自动补全功能。
  • 第二点,mysql_store_result 需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;
  • 第三点,是不会把执行命令记录到本地的命令历史文件。

所以你看到了,–quick 参数的意思,是让客户端变得更快。

小结

在今天这篇文章中,我首先和你介绍了 MySQL 中,有些语句和连接“kill 不掉”的情况。

这些“kill 不掉”的情况,其实是因为发送 kill 命令的客户端,并没有强行停止目标线程的执行,而只是设置了个状态,并唤醒对应的线程。而被 kill 的线程,需要执行到判断状态的“埋点”,才会开始进入终止逻辑阶段。并且,终止逻辑本身也是需要耗费时间的。

所以,如果你发现一个线程处于 Killed 状态,你可以做的事情就是,通过影响系统环境,让这个 Killed 状态尽快结束。

比如,如果是第一个例子里 InnoDB 并发度的问题,你就可以临时调大 innodb_thread_concurrency 的值,或者停掉别的线程,让出位子给这个线程执行。

而如果是回滚逻辑由于受到 IO 资源限制执行得比较慢,就通过减少系统压力让它加速。

做完这些操作后,其实你已经没有办法再对它做什么了,只能等待流程自己完成。

最后,我给你留下一个思考题吧。

如果你碰到一个被 killed 的事务一直处于回滚状态,你认为是应该直接把 MySQL 进程强行重启,还是应该让它自己执行完成呢?为什么呢?

你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章末尾,给你留下的问题是,希望你分享一下误删数据的处理经验。

@苍茫 同学提到了一个例子,我觉得值得跟大家分享一下。运维的同学直接拷贝文本去执行,SQL 语句截断,导致数据库执行出错。

从浏览器拷贝文本执行,是一个非常不规范的操作。除了这个例子里面说的 SQL 语句截断问题,还可能存在乱码问题。

一般这种操作,如果脚本的开发和执行不是同一个人,需要开发同学把脚本放到 git 上,然后把 git 地址,以及文件的 md5 发给运维同学。

这样就要求运维同学在执行命令之前,确认要执行的文件的 md5,跟之前开发同学提供的 md5 相同才能继续执行。

另外,我要特别点赞一下 @苍茫 同学复现问题的思路和追查问题的态度。

@linhui0705 同学提到的“四个脚本”的方法,我非常推崇。这四个脚本分别是:备份脚本、执行脚本、验证脚本和回滚脚本。如果能够坚持做到,即使出现问题,也是可以很快恢复的,一定能降低出现故障的概率。

不过,这个方案最大的敌人是这样的思想:这是个小操作,不需要这么严格。

@Knight²º¹⁸ 给了一个保护文件的方法,我之前没有用过这种方法,不过这确实是一个不错的思路。

为了数据安全和服务稳定,多做点预防方案的设计讨论,总好过故障处理和事后复盘。方案设计讨论会和故障复盘会,这两种会议的会议室气氛完全不一样。经历过的同学一定懂的。

33 我查这么多数据,会不会把数据库内存打爆?

我经常会被问到这样一个问题:我的主机内存只有 100G,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光了?

这个问题确实值得担心,被系统 OOM(out of memory)可不是闹着玩的。但是,反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了?

所以说,对大表做全表扫描,看来应该是没问题的。但是,这个流程到底是怎么样的呢?

全表扫描对 server 层的影响

假设,我们现在要对一个 200G 的 InnoDB 表 db1. t,执行一个全表扫描。当然,你要把扫描结果保存在客户端,会使用类似这样的命令:

mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_file

你已经知道了,InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。

那么,这个“结果集”存在哪里呢?

实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:

  1. 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。
  2. 重复获取行,直到 net_buffer 写满,调用网络接口发出去。
  3. 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
  4. 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。

这个过程对应的流程图如下所示。

img

图 1 查询结果发送流程

从这个流程中,你可以看到:

  1. 一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,并不会达到 200G;
  2. socket send buffer 也不可能达到 200G(默认定义 /proc/sys/net/core/wmem_default),如果 socket send buffer 被写满,就会暂停读数据的流程。

也就是说,MySQL 是“边读边发的”,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。

比如下面这个状态,就是我故意让客户端不去读 socket receive buffer 中的内容,然后在服务端 show processlist 看到的结果。

img

图 2 服务端发送阻塞

如果你看到 State 的值一直处于“Sending to client”,就表示服务器端的网络栈写满了。

我在上一篇文章中曾提到,如果客户端使用–quick 参数,会使用 mysql_use_result 方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现如图 2 所示的这种情况。

因此,对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存。

当然前提是查询返回结果不多。在[第 30 篇文章]评论区,有同学说到自己因为执行了一个大查询导致客户端占用内存近 20G,这种情况下就需要改用 mysql_use_result 接口了。

另一方面,如果你在自己负责维护的 MySQL 里看到很多个线程都处于“Sending to client”这个状态,就意味着你要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。

而如果要快速减少处于这个状态的线程的话,将 net_buffer_length 参数设置为一个更大的值是一个可选方案。

与“Sending to client”长相很类似的一个状态是“Sending data”,这是一个经常被误会的问题。有同学问我说,在自己维护的实例上看到很多查询语句的状态是“Sending data”,但查看网络也没什么问题啊,为什么 Sending data 要这么久?

实际上,一个查询语句的状态变化是这样的(注意:这里,我略去了其他无关的状态):

  • MySQL 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
  • 然后,发送执行结果的列相关的信息(meta data) 给客户端;
  • 再继续执行语句的流程;
  • 执行完成后,把状态设置成空字符串。

也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。比如,你可以构造一个锁等待的场景,就能看到 Sending data 状态。

img

图 3 读全表被锁

img

图 4 Sending data 状态

可以看到,session B 明显是在等锁,状态显示为 Sending data。

也就是说,仅当一个线程处于“等待客户端接收结果”的状态,才会显示”Sending to client”;而如果显示成“Sending data”,它的意思只是“正在执行”。

现在你知道了,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。

在 server 层的处理逻辑我们都清楚了,在 InnoDB 引擎里面又是怎么处理的呢? 扫描全表会不会对引擎系统造成影响呢?

全表扫描对 InnoDB 的影响

在[第 2]和[第 15 篇]文章中,我介绍 WAL 机制的时候,和你分析了 InnoDB 内存的一个作用,是保存更新的结果,再配合 redo log,就避免了随机写盘。

内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询。

在第 2 篇文章的评论区有同学问道,由于有 WAL 机制,当事务提交的时候,磁盘上的数据页是旧的,那如果这时候马上有一个查询要来读这个数据页,是不是要马上把 redo log 应用到数据页呢?

答案是不需要。因为这时候内存数据页的结果是最新的,直接读内存页就可以了。你看,这时候查询根本不需要读磁盘,直接从内存拿结果,速度是很快的。所以说,Buffer Pool 还有加速查询的作用。

而 Buffer Pool 对查询的加速效果,依赖于一个重要的指标,即:内存命中率

你可以在 show engine innodb status 结果中,查看一个系统当前的 BP 命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。

执行 show engine innodb status ,可以看到“Buffer pool hit rate”字样,显示的就是当前的命中率。比如图 5 这个命中率,就是 99.0%。

img

图 5 show engine innodb status 显示内存命中率

如果所有查询需要的数据页都能够直接从内存得到,那是最好的,对应的命中率就是 100%。但,这在实际生产上是很难做到的。

InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成可用物理内存的 60%~80%。

在大约十年前,单机的数据量是上百个 G,而物理内存是几个 G;现在虽然很多服务器都能有 128G 甚至更高的内存,但是单机的数据量却达到了 T 级别。

所以,innodb_buffer_pool_size 小于磁盘的数据量是很常见的。如果一个 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。

InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。

下图是一个 LRU 算法的基本模型。

img

图 6 基本 LRU 算法

InnoDB 管理 Buffer Pool 的 LRU 算法,是用链表来实现的。

  1. 在图 6 的状态 1 里,链表头部是 P1,表示 P1 是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
  2. 这时候有一个读请求访问 P3,因此变成状态 2,P3 被移到最前面;
  3. 状态 3 表示,这次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 Pm 这个数据页的内存,存入 Px 的内容,然后放到链表头部。
  4. 从效果上看,就是最久没有被访问的数据页 Pm,被淘汰了。

这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,会不会有问题呢?

假设按照这个算法,我们要扫描一个 200G 的表,而这个表是一个历史数据表,平时没有业务访问它。

那么,按照这个算法扫描的话,就会把当前的 Buffer Pool 里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说 Buffer Pool 里面主要放的是这个历史数据表的数据。

对于一个正在做业务服务的库,这可不妙。你会看到,Buffer Pool 的内存命中率急剧下降,磁盘压力增加,SQL 语句响应变慢。

所以,InnoDB 不能直接使用这个 LRU 算法。实际上,InnoDB 对 LRU 算法做了改进。

img

图 7 改进的 LRU 算法

在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。图中 LRU_old 指向的就是 old 区域的第一个位置,是整个链表的 58 处。也就是说,靠近链表头部的 58 是 young 区域,靠近链表尾部的 38 是 old 区域。

改进后的 LRU 算法执行流程变成了下面这样。

  1. 图 7 中状态 1,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2。
  2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页 Pm,但是新插入的数据页 Px,是放在 LRU_old 处。
  3. 处于 old 区域的数据页,每次被访问的时候都要做下面这个判断:
    • 若这个数据页在 LRU 链表中存在的时间超过了 1 秒,就把它移动到链表头部;
    • 如果这个数据页在 LRU 链表中存在的时间短于 1 秒,位置保持不变。1 秒这个时间,是由参数 innodb_old_blocks_time 控制的。其默认值是 1000,单位毫秒。

这个策略,就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描 200G 的历史数据表为例,我们看看改进后的 LRU 算法的操作逻辑:

  1. 扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
  2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;
  3. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。

可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了 Buffer Pool,但是对 young 区域完全没有影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。

小结

今天,我用“大查询会不会把内存用光”这个问题,和你介绍了 MySQL 的查询结果,发送给客户端的过程。

由于 MySQL 采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在 server 端保存完整的结果集。所以,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。

而对于 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于 InnoDB 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。

当然,我们前面文章有说过,全表扫描还是比较耗费 IO 资源的,所以业务高峰期还是不能直接在线上主库执行全表扫描的。

最后,我给你留一个思考题吧。

我在文章中说到,如果由于客户端压力太大,迟迟不能接收结果,会导致 MySQL 无法发送结果而影响语句执行。但,这还不是最糟糕的情况。

你可以设想出由于客户端的性能问题,对数据库影响更严重的例子吗?或者你是否经历过这样的场景?你又是怎么优化的?

你可以把你的经验和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,如果一个事务被 kill 之后,持续处于回滚状态,从恢复速度的角度看,你是应该重启等它执行结束,还是应该强行重启整个 MySQL 进程。

因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束。

当然,如果这个语句可能会占用别的锁,或者由于占用 IO 资源过多,从而影响到了别的语句执行的话,就需要先做主备切换,切到新主库提供服务。

切换之后别的线程都断开了连接,自动停止执行。接下来还是等它自己执行完成。这个操作属于我们在文章中说到的,减少系统压力,加速终止逻辑。

评论区留言点赞板:

@HuaMax 的回答中提到了对其他线程的影响; @夹心面包 @Ryoma @曾剑 同学提到了重启后依然继续做回滚操作的逻辑。

34 到底可不可以使用join?

在实际生产中,关于 join 语句使用的问题,一般会集中在以下两类:

  1. 我们 DBA 不让使用 join,使用 join 有什么问题呢?
  2. 如果有两个大小不同的表做 join,应该用哪个表做驱动表呢?

今天这篇文章,我就先跟你说说 join 语句到底是怎么执行的,然后再来回答这两个问题。

为了便于量化分析,我还是创建两个表 t1 和 t2 来和你说明。

CREATE TABLE `t2` (
  `id` int(11) NOT NULL,
  `a` int(11) DEFAULT NULL,
  `b` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `a` (`a`)
) ENGINE=InnoDB; 
drop procedure idata;
delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=1000)do
    insert into t2 values(i, i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata(); 
create table t1 like t2;
insert into t1 (select * from t2 where id<=100)

可以看到,这两个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 往表 t2 里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。

Index Nested-Loop Join

我们来看一下这个语句:

select * from t1 straight_join t2 on (t1.a=t2.a);

如果直接使用 join 语句,MySQL 优化器可能会选择表 t1 或 t2 作为驱动表,这样会影响我们分析 SQL 语句的执行过程。所以,为了便于分析执行过程中的性能问题,我改用 straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。

现在,我们来看一下这条语句的 explain 结果。

img

图 1 使用索引字段 join 的 explain 结果

可以看到,在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了这个索引,因此这个语句的执行流程是这样的:

  1. 从表 t1 中读入一行数据 R;
  2. 从数据行 R 中,取出 a 字段到表 t2 里去查找;
  3. 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
  4. 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。

这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称 NLJ。

它对应的流程图如下所示:

img

图 2 Index Nested-Loop Join 算法的执行流程

在这个流程里:

  1. 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
  2. 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
  3. 所以,整个执行流程,总扫描行数是 200。

现在我们知道了这个过程,再试着回答一下文章开头的两个问题。

先看第一个问题:能不能使用 join?

假设不使用 join,那我们就只能用单表查询。我们看看上面这条语句的需求,用单表查询怎么实现。

  1. 执行select * from t1,查出表 t1 的所有数据,这里有 100 行;
  2. 循环遍历这 100 行数据:
    • 从每一行 R 取出字段 a 的值 $R.a;
    • 执行select * from t2 where a=$R.a
    • 把返回的结果和 R 构成结果集的一行。

可以看到,在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了 100 次交互。除此之外,客户端还要自己拼接 SQL 语句和结果。

显然,这么做还不如直接 join 好。

我们再来看看第二个问题:怎么选择驱动表?

在这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。

假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。

假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。

因此整个执行过程,近似复杂度是 N + N*2*log2M。

显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表。

如果你没觉得这个影响有那么“显然”, 可以这么理解:N 扩大 1000 倍的话,扫描行数就会扩大 1000 倍;而 M 扩大 1000 倍,扫描行数扩大不到 10 倍。

到这里小结一下,通过上面的分析我们得到了两个结论:

  1. 使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;
  2. 如果使用 join 语句的话,需要让小表做驱动表。

但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”。

接下来,我们再看看被驱动表用不上索引的情况。

Simple Nested-Loop Join

现在,我们把 SQL 语句改成这样:

select * from t1 straight_join t2 on (t1.a=t2.b);

由于表 t2 的字段 b 上没有索引,因此再用图 2 的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。

你可以先设想一下这个问题,继续使用图 2 的算法,是不是可以得到正确的结果呢?如果只看结果的话,这个算法是正确的,而且这个算法也有一个名字,叫做“Simple Nested-Loop Join”。

但是,这样算来,这个 SQL 请求就要扫描表 t2 多达 100 次,总共扫描 100*1000=10 万行。

这还只是两个小表,如果 t1 和 t2 都是 10 万行的表(当然了,这也还是属于小表的范围),就要扫描 100 亿行,这个算法看上去太“笨重”了。

当然,MySQL 也没有使用这个 Simple Nested-Loop Join 算法,而是使用了另一个叫作“Block Nested-Loop Join”的算法,简称 BNL。

Block Nested-Loop Join

这时候,被驱动表上没有可用的索引,算法的流程是这样的:

  1. 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
  2. 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。

这个过程的流程图如下:

img

图 3 Block Nested-Loop Join 算法的执行流程

对应地,这条 SQL 语句的 explain 结果如下所示:

img

图 4 不使用索引字段 join 的 explain 结果

可以看到,在这个过程中,对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。

前面我们说过,如果使用 Simple Nested-Loop Join 算法进行查询,扫描行数也是 10 万行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join 算法的这 10 万次判断是内存操作,速度上会快很多,性能也更好。

接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。

假设小表的行数是 N,大表的行数是 M,那么在这个算法里:

  1. 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
  2. 内存中的判断次数是 M*N。

可以看到,调换这两个算式中的 M 和 N 没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。

然后,你可能马上就会问了,这个例子里表 t1 才 100 行,要是表 t1 是一个大表,join_buffer 放不下怎么办呢?

join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t1 的所有数据话,策略很简单,就是分段放。我把 join_buffer_size 改成 1200,再执行:

select * from t1 straight_join t2 on (t1.a=t2.b);

执行过程就变成了:

  1. 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
  2. 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
  3. 清空 join_buffer;
  4. 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。

执行流程图也就变成这样:

img

图 5 Block Nested-Loop Join – 两段

图中的步骤 4 和 5,表示清空 join_buffer 再复用。

这个流程才体现出了这个算法名字中“Block”的由来,表示“分块去 join”。

可以看到,这时候由于表 t1 被分成了两次放入 join_buffer 中,导致表 t2 会被扫描两次。虽然分成两次放入 join_buffer,但是判断等值条件的次数还是不变的,依然是 (88+12)*1000=10 万次。

我们再来看下,在这种情况下驱动表的选择问题。

假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。

注意,这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然λ的取值范围是 (0,1)。

所以,在这个算法的执行过程中:

  1. 扫描行数是 N+λ*N*M;
  2. 内存判断 N*M 次。

显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 M 和 N 大小确定的情况下,N 小一些,整个算式的结果会更小。

所以结论是,应该让小表当驱动表。

当然,你会发现,在 N+λ*N*M 这个式子里,λ才是影响扫描行数的关键因素,这个值越小越好。

刚刚我们说了 N 越大,分段数 K 越大。那么,N 固定的时候,什么参数会影响 K 的大小呢?(也就是λ的大小)答案是 join_buffer_size。join_buffer_size 越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。

这就是为什么,你可能会看到一些建议告诉你,如果你的 join 语句很慢,就把 join_buffer_size 改大。

理解了 MySQL 执行 join 的两种算法,现在我们再来试着回答文章开头的两个问题

第一个问题:能不能使用 join 语句?

  1. 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;
  2. 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。

所以你在判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。

第二个问题是:如果要使用 join,应该选择大表做驱动表还是选择小表做驱动表?

  1. 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
  2. 如果是 Block Nested-Loop Join 算法:
    • 在 join_buffer_size 足够大的时候,是一样的;
    • 在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。

所以,这个问题的结论就是,总是应该使用小表做驱动表。

当然了,这里我需要说明下,什么叫作“小表”

我们前面的例子是没有加条件的。如果我在语句的 where 条件加上 t2.id<=50 这个限定条件,再来看下这两条语句:

select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;

注意,为了让两条语句的被驱动表都用不上索引,所以 join 字段都使用了没有索引的字段 b。

但如果是用第二个语句的话,join_buffer 只需要放入 t2 的前 50 行,显然是更好的。所以这里,“t2 的前 50 行”是那个相对小的表,也就是“小表”。

我们再来看另外一组例子:

select t1.b,t2.* from  t1  straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from  t2  straight_join t1 on (t1.b=t2.b) where t2.id<=100;

这个例子里,表 t1 和 t2 都是只有 100 行参加 join。但是,这两条语句每次查询放入 join_buffer 中的数据是不一样的:

  • 表 t1 只查字段 b,因此如果把 t1 放到 join_buffer 中,则 join_buffer 中只需要放入 b 的值;
  • 表 t2 需要查所有的字段,因此如果把表 t2 放到 join_buffer 中的话,就需要放入三个字段 id、a 和 b。

这里,我们应该选择表 t1 作为驱动表。也就是说在这个例子里,“只需要一列参与 join 的表 t1”是那个相对小的表。

所以,更准确地说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。

小结

今天,我和你介绍了 MySQL 执行 join 语句的两种可能算法,这两种算法是由能否使用被驱动表的索引决定的。而能否用上被驱动表的索引,对 join 语句的性能影响很大。

通过对 Index Nested-Loop Join 和 Block Nested-Loop Join 两个算法执行过程的分析,我们也得到了文章开头两个问题的答案:

  1. 如果可以使用被驱动表的索引,join 语句还是有其优势的;
  2. 不能使用被驱动表的索引,只能使用 Block Nested-Loop Join 算法,这样的语句就尽量不要使用;
  3. 在使用 join 的时候,应该让小表做驱动表。

最后,又到了今天的问题时间。

我们在上文说到,使用 Block Nested-Loop Join 算法,可能会因为 join_buffer 不够大,需要对被驱动表做多次全表扫描。

我的问题是,如果被驱动表是一个大表,并且是一个冷数据表,除了查询过程中可能会导致 IO 压力大以外,你觉得对这个 MySQL 服务还有什么更严重的影响吗?(这个问题需要结合上一篇文章的知识点)

你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上一篇文章最后留下的问题是,如果客户端由于压力过大,迟迟不能接收数据,会对服务端造成什么严重的影响。

这个问题的核心是,造成了“长事务”。

至于长事务的影响,就要结合我们前面文章中提到的锁、MVCC 的知识点了。

  • 如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住;
  • 当然读的事务也有问题,就是会导致 undo log 不能被回收,导致回滚段空间膨胀。

评论区留言点赞板:

@老杨同志 提到了更新之间会互相等锁的问题。同一个事务,更新之后要尽快提交,不要做没必要的查询,尤其是不要执行需要返回大量数据的查询; @长杰 同学提到了 undo 表空间变大,db 服务堵塞,服务端磁盘空间不足的例子。

35 join语句怎么优化?

在上一篇文章中,我和你介绍了 join 语句的两种算法,分别是 Index Nested-Loop Join(NLJ) 和 Block Nested-Loop Join(BNL)。

我们发现在使用 NLJ 算法的时候,其实效果还是不错的,比通过应用层拆分成多个语句然后再拼接查询结果更方便,而且性能也不会差。

但是,BNL 算法在大表 join 的时候性能就差多了,比较次数等于两个表参与 join 的行数的乘积,很消耗 CPU 资源。

当然了,这两个算法都还有继续优化的空间,我们今天就来聊聊这个话题。

为了便于分析,我还是创建两个表 t1、t2 来和你展开今天的问题。

create table t1(id int primary key, a int, b int, index(a));
create table t2 like t1;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=1000)do
    insert into t1 values(i, 1001-i, i);
    set i=i+1;
  end while;
  
  set i=1;
  while(i<=1000000)do
    insert into t2 values(i, i, i);
    set i=i+1;
  end while; 
end;;
delimiter ;
call idata();

为了便于后面量化说明,我在表 t1 里,插入了 1000 行数据,每一行的 a=1001-id 的值。也就是说,表 t1 中字段 a 是逆序的。同时,我在表 t2 中插入了 100 万行数据。

Multi-Range Read 优化

在介绍 join 语句的优化方案之前,我需要先和你介绍一个知识点,即:Multi-Range Read 优化 (MRR)。这个优化的主要目的是尽量使用顺序读盘。

在[第 4 篇文章]中,我和你介绍 InnoDB 的索引结构时,提到了“回表”的概念。我们先来回顾一下这个概念。回表是指,InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去查整行数据的过程。

然后,有同学在留言区问到,回表过程是一行行地查数据,还是批量地查数据?

我们先来看看这个问题。假设,我执行这个语句:

select * from t1 where a>=1 and a<=100;

主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图 1 所示。

img

图 1 基本回表流程

如果随着 a 的值递增顺序查询的话,id 的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。

因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。

这,就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:

  1. 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
  2. 将 read_rnd_buffer 中的 id 进行递增排序;
  3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。

另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置set optimizer_switch="mrr_cost_based=off"。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)

下面两幅图就是使用了 MRR 优化后的执行流程和 explain 结果。

img

图 2 MRR 执行流程

img

图 3 MRR 执行流程的 explain 结果

从图 3 的 explain 结果中,我们可以看到 Extra 字段多了 Using MRR,表示的是用上了 MRR 优化。而且,由于我们在 read_rnd_buffer 中按照 id 做了排序,所以最后得到的结果集也是按照主键 id 递增顺序的,也就是与图 1 结果集中行的顺序相反。

到这里,我们小结一下。

MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

Batched Key Access

理解了 MRR 性能提升的原理,我们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Acess(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化。

我们再来看看上一篇文章中用到的 NLJ 算法的流程图:

img

图 4 Index Nested-Loop Join 流程图

NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了。

那怎么才能一次性地多传些值给表 t2 呢?方法就是,从表 t1 里一次性地多拿些行出来,一起传给表 t2。

既然如此,我们就把表 t1 的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是 join_buffer。

通过上一篇文章,我们知道 join_buffer 在 BNL 算法里的作用,是暂存驱动表的数据。但是在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中。

如图 5 所示,是上面的 NLJ 算法优化后的 BKA 算法的流程。

img

图 5 Batched Key Acess 流程

图中,我在 join_buffer 中放入的数据是 P1~P100,表示的是只会取查询需要的字段。当然,如果 join buffer 放不下 P1~P100 的所有数据,就会把这 100 行数据分成多段执行上图的流程。

那么,这个 BKA 算法到底要怎么启用呢?

如果要使用 BKA 优化算法的话,你需要在执行 SQL 语句之前,先设置

set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';

其中,前两个参数的作用是要启用 MRR。这么做的原因是,BKA 算法的优化要依赖于 MRR。

BNL 算法的性能问题

说完了 NLJ 算法的优化,我们再来看 BNL 算法的优化。

我在上一篇文章末尾,给你留下的思考题是,使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致 IO 压力大以外,还会对系统有什么影响呢?

在[第 33 篇文章]中,我们说到 InnoDB 的 LRU 算法的时候提到,由于 InnoDB 对 Bufffer Pool 的 LRU 算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域。如果 1 秒之后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响就不大。

但是,如果一个使用 BNL 算法的 join 语句,多次扫描一个冷表,而且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部。

这种情况对应的,是冷表的数据量小于整个 Buffer Pool 的 3/8,能够完全放入 old 区域的情况。

如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入 young 区域。

由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页,很可能在 1 秒之内就被淘汰了。这样,就会导致这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。

也就是说,这两种情况都会影响 Buffer Pool 的正常运作。

大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。

为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。

也就是说,BNL 算法对系统的影响主要包括三个方面:

  1. 可能会多次扫描被驱动表,占用磁盘 IO 资源;
  2. 判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
  3. 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。

我们执行语句之前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL 算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法。

接下来,我们就具体看看,这个优化怎么做?

BNL 转 BKA

一些情况下,我们可以直接在被驱动表上建索引,这时就可以直接转成 BKA 算法了。

但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:

select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;

我们在文章开始的时候,在表 t2 中插入了 100 万行数据,但是经过 where 条件过滤后,需要参与 join 的只有 2000 行数据。如果这条语句同时是一个低频的 SQL 语句,那么再为这个语句在表 t2 的字段 b 上创建一个索引就很浪费了。

但是,如果使用 BNL 算法来 join 的话,这个语句的执行流程是这样的:

  1. 把表 t1 的所有字段取出来,存入 join_buffer 中。这个表只有 1000 行,join_buffer_size 默认值是 256k,可以完全存入。
  2. 扫描表 t2,取出每一行数据跟 join_buffer 中的数据进行对比,
    • 如果不满足 t1.b=t2.b,则跳过;
    • 如果满足 t1.b=t2.b, 再判断其他条件,也就是是否满足 t2.b 处于 [1,2000] 的条件,如果是,就作为结果集的一部分返回,否则跳过。

我在上一篇文章中说过,对于表 t2 的每一行,判断 join 是否满足的时候,都需要遍历 join_buffer 中的所有行。因此判断等值条件的次数是 1000*100 万 =10 亿次,这个判断的工作量很大。

img

图 6 explain 结果

img

图 7 语句执行时间

可以看到,explain 结果里 Extra 字段显示使用了 BNL 算法。在我的测试环境里,这条语句需要执行 1 分 11 秒。

在表 t2 的字段 b 上创建索引会浪费资源,但是不创建索引的话这个语句的等值条件要判断 10 亿次,想想也是浪费。那么,有没有两全其美的办法呢?

这时候,我们可以考虑使用临时表。使用临时表的大致思路是:

  1. 把表 t2 中满足条件的数据放在临时表 tmp_t 中;
  2. 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引;
  3. 让表 t1 和 tmp_t 做 join 操作。

此时,对应的 SQL 语句的写法如下:

create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

图 8 就是这个语句序列的执行效果。

img

图 8 使用临时表的执行效果

可以看到,整个过程 3 个语句执行时间的总和还不到 1 秒,相比于前面的 1 分 11 秒,性能得到了大幅提升。接下来,我们一起看一下这个过程的消耗:

  1. 执行 insert 语句构造 temp_t 表并插入数据的过程中,对表 t2 做了全表扫描,这里扫描行数是 100 万。
  2. 之后的 join 语句,扫描表 t1,这里的扫描行数是 1000;join 比较过程中,做了 1000 次带索引的查询。相比于优化前的 join 语句需要做 10 亿次条件判断来说,这个优化效果还是很明显的。

总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能。

扩展 -hash join

看到这里你可能发现了,其实上面计算 10 亿次那个操作,看上去有点儿傻。如果 join_buffer 里面维护的不是一个无序数组,而是一个哈希表的话,那么就不是 10 亿次判断,而是 100 万次 hash 查找。这样的话,整条语句的执行速度就快多了吧?

确实如此。

这,也正是 MySQL 的优化器和执行器一直被诟病的一个原因:不支持哈希 join。并且,MySQL 官方的 roadmap,也是迟迟没有把这个优化排上议程。

实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下:

  1. select * from t1;取得表 t1 的全部 1000 行数据,在业务端存入一个 hash 结构,比如 C++ 里的 set、PHP 的 dict 这样的数据结构。
  2. select * from t2 where b>=1 and b<=2000; 获取表 t2 中满足条件的 2000 行数据。
  3. 把这 2000 行数据,一行一行地取到业务端,到 hash 结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。

理论上,这个过程会比临时表方案的执行速度还要快一些。如果你感兴趣的话,可以自己验证一下。

小结

今天,我和你分享了 Index Nested-Loop Join(NLJ)和 Block Nested-Loop Join(BNL)的优化方法。

在这些优化方法中:

  1. BKA 优化是 MySQL 已经内置支持的,建议你默认使用;
  2. BNL 算法效率低,建议你都尽量转成 BKA 算法。优化的方向就是给被驱动表的关联字段加上索引;
  3. 基于临时表的改进方案,对于能够提前过滤出小数据的 join 语句来说,效果还是很好的;
  4. MySQL 目前的版本还不支持 hash join,但你可以配合应用端自己模拟出来,理论上效果要好于临时表的方案。

最后,我给你留下一道思考题吧。

我们在讲 join 语句的这两篇文章中,都只涉及到了两个表的 join。那么,现在有一个三个表 join 的需求,假设这三个表的表结构如下:

CREATE TABLE `t1` (
 `id` int(11) NOT NULL,
 `a` int(11) DEFAULT NULL,
 `b` int(11) DEFAULT NULL,
 `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB; 
create table t2 like t1;
create table t3 like t2;
insert into ... // 初始化三张表的数据

语句的需求实现如下的 join 逻辑:

select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;

现在为了得到最快的执行速度,如果让你来设计表 t1、t2、t3 上的索引,来支持这个 join 语句,你会加哪些索引呢?

同时,如果我希望你用 straight_join 来重写这个语句,配合你创建的索引,你就需要安排连接顺序,你主要考虑的因素是什么呢?

你可以把你的方案和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上篇文章最后留给你的问题,已经在本篇文章中解答了。

这里我再根据评论区留言的情况,简单总结下。根据数据量的大小,有这么两种情况:

  • @长杰 和 @老杨同志 提到了数据量小于 old 区域内存的情况;
  • @Zzz 同学,很认真地看了其他同学的评论,并且提了一个很深的问题。对被驱动表数据量大于 Buffer Pool 的场景,做了很细致的推演和分析。

给这些同学点赞,非常好的思考和讨论。

36 为什么临时表可以重名?

今天是大年三十,在开始我们今天的学习之前,我要先和你道一声春节快乐!

在上一篇文章中,我们在优化 join 查询的时候使用到了临时表。当时,我们是这么用的:

create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

你可能会有疑问,为什么要用临时表呢?直接用普通表是不是也可以呢?

今天我们就从这个问题说起:临时表有哪些特征,为什么它适合这个场景?

这里,我需要先帮你厘清一个容易误解的问题:有的人可能会认为,临时表就是内存表。但是,这两个概念可是完全不同的。

  • 内存表,指的是使用 Memory 引擎的表,建表语法是 create table … engine=memory。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表。
  • 而临时表,可以使用各种引擎类型 。如果是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,写数据的时候是写到磁盘上的。当然,临时表也可以使用 Memory 引擎。

弄清楚了内存表和临时表的区别以后,我们再来看看临时表有哪些特征。

临时表的特性

为了便于理解,我们来看下下面这个操作序列:

img

图 1 临时表特性示例

可以看到,临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …。
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名。
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
  5. show tables 命令不显示临时表。

由于临时表只能被创建它的 session 访问,所以在这个 session 结束的时候,会自动删除临时表。也正是由于这个特性,临时表就特别适合我们文章开头的 join 优化这种场景。为什么呢?

原因主要包括以下两个方面:

  1. 不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。
  2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。

临时表的应用

由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。

一般分库分表的场景,就是要把一个逻辑上的大表分散到不同的数据库实例上。比如。将一个大表 ht,按照字段 f,拆分成 1024 个分表,然后分布到 32 个数据库实例上。如下图所示:

img

图 2 分库分表简图

一般情况下,这种分库分表系统都有一个中间层 proxy。不过,也有一些方案会让客户端直接连接数据库,也就是没有 proxy 这一层。

在这个架构中,分区 key 的选择是以“减少跨库和跨表查询”为依据的。如果大部分的语句都会包含 f 的等值条件,那么就要用 f 做分区键。这样,在 proxy 这一层解析完 SQL 语句以后,就能确定将这条语句路由到哪个分表做查询。

比如下面这条语句:

select v from ht where f=N;

这时,我们就可以通过分表规则(比如,N%1024) 来确认需要的数据被放在了哪个分表上。这种语句只需要访问一个分表,是分库分表方案最欢迎的语句形式了。

但是,如果这个表上还有另外一个索引 k,并且查询语句是这样的:

select v from ht where k >= M order by t_modified desc limit 100;

这时候,由于查询条件里面没有用到分区字段 f,只能到所有的分区中去查找满足条件的所有行,然后统一做 order by 的操作。这种情况下,有两种比较常用的思路。

第一种思路是,在 proxy 层的进程代码中实现排序。

这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显:

  1. 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如 group by,甚至 join 这样的操作,对中间层的开发能力要求比较高;
  2. 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题。

另一种思路就是,把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作。

比如上面这条语句,执行流程可以类似这样:

  • 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 v、k、t_modified;
  • 在各个分库上执行
select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
  • 把分库执行的结果插入到 temp_ht 表中;
  • 执行
select v from temp_ht order by t_modified desc limit 100; 

得到结果。

这个过程对应的流程图如下所示:

img

图 3 跨库查询流程示意图

在实践中,我们往往会发现每个分库的计算量都不饱和,所以会直接把临时表 temp_ht 放到 32 个分库中的某一个上。这时的查询逻辑与图 3 类似,你可以自己再思考一下具体的流程。

为什么临时表可以重名?

你可能会问,不同线程可以创建同名的临时表,这是怎么做到的呢?

接下来,我们就看一下这个问题。

我们在执行

create temporary table temp_t(id int primary key)engine=innodb;

这个语句的时候,MySQL 要给这个 InnoDB 表创建一个 frm 文件保存表结构定义,还要有地方保存表数据。

这个 frm 文件放在临时文件目录下,文件名的后缀是.frm,前缀是“##sql{进程 id}{线程 id} 序列号”。你可以使用 select @@tmpdir 命令,来显示实例的临时文件目录。

而关于表中数据的存放方式,在不同的 MySQL 版本中有着不同的处理方式:

  • 在 5.6 以及之前的版本里,MySQL 会在临时文件目录下创建一个相同前缀、以.ibd 为后缀的文件,用来存放数据文件;
  • 而从 5.7 版本开始,MySQL 引入了一个临时文件表空间,专门用来存放临时文件的数据。因此,我们就不需要再创建 ibd 文件了。

从文件名的前缀规则,我们可以看到,其实创建一个叫作 t1 的 InnoDB 临时表,MySQL 在存储上认为我们创建的表名跟普通表 t1 是不同的,因此同一个库下面已经有普通表 t1 的情况下,还是可以再创建一个临时表 t1 的。

为了便于后面讨论,我先来举一个例子。

img

图 4 临时表的表名

这个进程的进程号是 1234,session A 的线程 id 是 4,session B 的线程 id 是 5。所以你看到了,session A 和 session B 创建的临时表,在磁盘上的文件不会重名。

MySQL 维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表,每个表都对应一个 table_def_key。

  • 一个普通表的 table_def_key 的值是由“库名 + 表名”得到的,所以如果你要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了。
  • 而对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”。

也就是说,session A 和 sessionB 创建的两个临时表 t1,它们的 table_def_key 不同,磁盘文件名也不同,因此可以并存。

在实现上,每个线程都维护了自己的临时表链表。这样每次 session 内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;在 session 结束的时候,对链表里的每个临时表,执行 “DROP TEMPORARY TABLE + 表名”操作。

这时候你会发现,binlog 中也记录了 DROP TEMPORARY TABLE 这条命令。你一定会觉得奇怪,临时表只在线程内自己可以访问,为什么需要写到 binlog 里面?

这,就需要说到主备复制了。

临时表和主备复制

既然写 binlog,就意味着备库需要。

你可以设想一下,在主库上执行下面这个语句序列:

create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/
create temporary table temp_t like t_normal;/*Q2*/
insert into temp_t values(1,1);/*Q3*/
insert into t_normal select * from temp_t;/*Q4*/

如果关于临时表的操作都不记录,那么在备库就只有 create table t_normal 表和 insert into t_normal select * from temp_t 这两个语句的 binlog 日志,备库在执行到 insert into t_normal 的时候,就会报错“表 temp_t 不存在”。

你可能会说,如果把 binlog 设置为 row 格式就好了吧?因为 binlog 是 row 格式时,在记录 insert into t_normal 的 binlog 时,记录的是这个操作的数据,即:write_row event 里面记录的逻辑是“插入一行数据(1,1)”。

确实是这样。如果当前的 binlog_format=row,那么跟临时表有关的语句,就不会记录到 binlog 里。也就是说,只在 binlog_format=statment/mixed 的时候,binlog 中才会记录临时表的操作。

这种情况下,创建临时表的语句会传到备库执行,因此备库的同步线程就会创建这个临时表。主库在线程退出的时候,会自动删除临时表,但是备库同步线程是持续在运行的。所以,这时候我们就需要在主库上再写一个 DROP TEMPORARY TABLE 传给备库执行。

之前有人问过我一个有趣的问题:MySQL 在记录 binlog 的时候,不论是 create table 还是 alter table 语句,都是原样记录,甚至于连空格都不变。但是如果执行 drop table t_normal,系统记录 binlog 就会写成:

DROP TABLE `t_normal` /* generated by server */

也就是改成了标准的格式。为什么要这么做呢 ?

现在你知道原因了,那就是:drop table 命令是可以一次删除多个表的。比如,在上面的例子中,设置 binlog_format=row,如果主库上执行 “drop table t_normal, temp_t”这个命令,那么 binlog 中就只能记录:

DROP TABLE `t_normal` /* generated by server */

因为备库上并没有表 temp_t,将这个命令重写后再传到备库执行,才不会导致备库同步线程停止。

所以,drop table 命令记录 binlog 的时候,就必须对语句做改写。“/* generated by server */”说明了这是一个被服务端改写过的命令。

说到主备复制,还有另外一个问题需要解决:主库上不同的线程创建同名的临时表是没关系的,但是传到备库执行是怎么处理的呢?

现在,我给你举个例子,下面的序列中实例 S 是 M 的备库。

img

图 5 主备关系中的临时表操作

主库 M 上的两个 session 创建了同名的临时表 t1,这两个 create temporary table t1 语句都会被传到备库 S 上。

但是,备库的应用日志线程是共用的,也就是说要在应用线程里面先后执行这个 create 语句两次。(即使开了多线程复制,也可能被分配到从库的同一个 worker 中执行)。那么,这会不会导致同步线程报错 ?

显然是不会的,否则临时表就是一个 bug 了。也就是说,备库线程在执行的时候,要把这两个 t1 表当做两个不同的临时表来处理。这,又是怎么实现的呢?

MySQL 在记录 binlog 的时候,会把主库执行这个语句的线程 id 写到 binlog 中。这样,在备库的应用线程就能够知道执行每个语句的主库线程 id,并利用这个线程 id 来构造临时表的 table_def_key:

  1. session A 的临时表 t1,在备库的 table_def_key 就是:库名 +t1+“M 的 serverid”+“session A 的 thread_id”;
  2. session B 的临时表 t1,在备库的 table_def_key 就是 :库名 +t1+“M 的 serverid”+“session B 的 thread_id”。

由于 table_def_key 不同,所以这两个表在备库的应用线程里面是不会冲突的。

小结

今天这篇文章,我和你介绍了临时表的用法和特性。

在实际应用中,临时表一般用于处理比较复杂的计算逻辑。由于临时表是每个线程自己可见的,所以不需要考虑多个线程执行同一个处理逻辑时,临时表的重名问题。在线程退出的时候,临时表也能自动删除,省去了收尾和异常处理的工作。

在 binlog_format=‘row’的时候,临时表的操作不记录到 binlog 中,也省去了不少麻烦,这也可以成为你选择 binlog_format 时的一个考虑因素。

需要注意的是,我们上面说到的这种临时表,是用户自己创建的 ,也可以称为用户临时表。与它相对应的,就是内部临时表,在[第 17 篇文章]中我已经和你介绍过。

最后,我给你留下一个思考题吧。

下面的语句序列是创建一个临时表,并将其改名:

img

图 6 关于临时表改名的思考题

可以看到,我们可以使用 alter table 语法修改临时表的表名,而不能使用 rename 语法。你知道这是什么原因吗?

你可以把你的分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,对于下面这个三个表的 join 语句,

select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;

如果改写成 straight_join,要怎么指定连接顺序,以及怎么给三个表创建索引。

第一原则是要尽量使用 BKA 算法。需要注意的是,使用 BKA 算法的时候,并不是“先计算两个表 join 的结果,再跟第三个表 join”,而是直接嵌套查询的。

具体实现是:在 t1.c>=X、t2.c>=Y、t3.c>=Z 这三个条件里,选择一个经过过滤以后,数据最少的那个表,作为第一个驱动表。此时,可能会出现如下两种情况。

第一种情况,如果选出来是表 t1 或者 t3,那剩下的部分就固定了。

  1. 如果驱动表是 t1,则连接顺序是 t1->t2->t3,要在被驱动表字段创建上索引,也就是 t2.a 和 t3.b 上创建索引;
  2. 如果驱动表是 t3,则连接顺序是 t3->t2->t1,需要在 t2.b 和 t1.a 上创建索引。

同时,我们还需要在第一个驱动表的字段 c 上创建索引。

第二种情况是,如果选出来的第一个驱动表是表 t2 的话,则需要评估另外两个条件的过滤效果。

总之,整体的思路就是,尽量让每一次参与 join 的驱动表的数据集,越小越好,因为这样我们的驱动表就会越小。

评论区留言点赞板:

@库淘淘 做了实验验证; @poppy 同学做了很不错的分析; @dzkk 同学在评论中介绍了 MariaDB 支持的 hash join,大家可以了解一下; @老杨同志提了一个好问题,如果语句使用了索引 a,结果还要对 a 排序,就不用 MRR 优化了,否则回表完还要增加额外的排序过程,得不偿失。

37 什么时候会使用内部临时表?

今天是大年初二,在开始我们今天的学习之前,我要先和你道一声春节快乐!

在[第 16]和[第 34]篇文章中,我分别和你介绍了 sort buffer、内存临时表和 join buffer。这三个数据结构都是用来存放语句执行过程中的中间数据,以辅助 SQL 语句的执行的。其中,我们在排序的时候用到了 sort buffer,在使用 join 语句的时候用到了 join buffer。

然后,你可能会有这样的疑问,MySQL 什么时候会使用内部临时表呢?

今天这篇文章,我就先给你举两个需要用到内部临时表的例子,来看看内部临时表是怎么工作的。然后,我们再来分析,什么情况下会使用内部临时表。

union 执行流程

为了便于量化分析,我用下面的表 t1 来举例。

create table t1(id int primary key, a int, b int, index(a));
delimiter ;;
create procedure idata()
begin
  declare i int; 
  set i=1;
  while(i<=1000)do
    insert into t1 values(i, i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

然后,我们执行下面这条语句:

(select 1000 as f) union (select id from t1 order by id desc limit 2);

这条语句用到了 union,它的语义是,取这两个子查询结果的并集。并集的意思就是这两个集合加起来,重复的行只保留一行。

下图是这个语句的 explain 结果。

img

图 1 union 语句 explain 结果

可以看到:

  • 第二行的 key=PRIMARY,说明第二个子句用到了索引 id。
  • 第三行的 Extra 字段,表示在对子查询的结果集做 union 的时候,使用了临时表 (Using temporary)。

这个语句的执行流程是这样的:

  1. 创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段。
  2. 执行第一个子查询,得到 1000 这个值,并存入临时表中。
  3. 执行第二个子查询:
    • 拿到第一行 id=1000,试图插入临时表中。但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行;
    • 取到第二行 id=999,插入临时表成功。
  4. 从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是 1000 和 999。

这个过程的流程图如下所示:

img

图 2 union 执行流程

可以看到,这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键 id 的唯一性约束,实现了 union 的语义。

顺便提一下,如果把上面这个语句中的 union 改成 union all 的话,就没有了“去重”的语义。这样执行的时候,就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端。因此也就不需要临时表了。

img

图 3 union all 的 explain 结果

可以看到,第二行的 Extra 字段显示的是 Using index,表示只使用了覆盖索引,没有用临时表了。

group by 执行流程

另外一个常见的使用临时表的例子是 group by,我们来看一下这个语句:

select id%10 as m, count(*) as c from t1 group by m;

这个语句的逻辑是把表 t1 里的数据,按照 id%10 进行分组统计,并按照 m 的结果排序后输出。它的 explain 结果如下:

img

图 4 group by 的 explain 结果

在 Extra 字段里面,我们可以看到三个信息:

  • Using index,表示这个语句使用了覆盖索引,选择了索引 a,不需要回表;
  • Using temporary,表示使用了临时表;
  • Using filesort,表示需要排序。

这个语句的执行流程是这样的:

  1. 创建内存临时表,表里有两个字段 m 和 c,主键是 m;
  2. 扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
    • 如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
    • 如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
  3. 遍历完成后,再根据字段 m 做排序,得到结果集返回给客户端。

这个流程的执行图如下:

img

图 5 group by 执行流程

图中最后一步,对内存临时表的排序,在[第 17 篇文章]中已经有过介绍,我把图贴过来,方便你回顾。

img

图 6 内存临时表排序流程

其中,临时表的排序过程就是图 6 中虚线框内的过程。

接下来,我们再看一下这条语句的执行结果:

img

图 7 group by 执行结果

如果你的需求并不需要对结果进行排序,那你可以在 SQL 语句末尾增加 order by null,也就是改成:

select id%10 as m, count(*) as c from t1 group by m order by null;

这样就跳过了最后排序的阶段,直接从临时表中取数据返回。返回的结果如图 8 所示。

img

图 8 group + order by null 的结果(内存临时表)

由于表 t1 中的 id 值是从 1 开始的,因此返回的结果集中第一行是 id=1;扫描到 id=10 的时候才插入 m=0 这一行,因此结果集里最后一行才是 m=0。

这个例子里由于临时表只有 10 行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,参数 tmp_table_size 就是控制这个内存大小的,默认是 16M。

如果我执行下面这个语句序列:

set tmp_table_size=1024;
select id%100 as m, count(*) as c from t1 group by m order by null limit 10;

把内存临时表的大小限制为最大 1024 字节,并把语句改成 id % 100,这样返回结果里有 100 行数据。但是,这时的内存临时表大小不够存下这 100 行数据,也就是说,执行过程中会发现内存临时表大小到达了上限(1024 字节)。

那么,这时候就会把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是 InnoDB。 这时,返回的结果如图 9 所示。

img

图 9 group + order by null 的结果(磁盘临时表)

如果这个表 t1 的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。

group by 优化方法 – 索引

可以看到,不论是使用内存临时表还是磁盘临时表,group by 逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的。如果表的数据量比较大,上面这个 group by 语句执行起来就会很慢,我们有什么优化的方法呢?

要解决 group by 语句的优化问题,你可以先想一下这个问题:执行 group by 语句为什么需要临时表?

group by 的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的 id%100 的结果是无序的,所以我们就需要有一个临时表,来记录并统计结果。

那么,如果扫描过程中可以保证出现的数据是有序的,是不是就简单了呢?

假设,现在有一个类似图 10 的这么一个数据结构,我们来看看 group by 可以怎么做。

img

图 10 group by 算法优化 - 有序输入

可以看到,如果可以确保输入的数据是有序的,那么计算 group by 的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程:

  • 当碰到第一个 1 的时候,已经知道累积了 X 个 0,结果集里的第一行就是 (0,X);
  • 当碰到第一个 2 的时候,已经知道累积了 Y 个 1,结果集里的第二行就是 (1,Y);

按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。

你一定想到了,InnoDB 的索引,就可以满足这个输入有序的条件。

在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新。你可以用下面的方法创建一个列 z,然后在 z 列上创建一个索引(如果是 MySQL 5.6 及之前的版本,你也可以创建普通列和索引,来解决这个问题)。

alter table t1 add column z int generated always as(id % 100), add index(z);

这样,索引 z 上的数据就是类似图 10 这样有序的了。上面的 group by 语句就可以改成:

select z, count(*) as c from t1 group by z;

优化后的 group by 语句的 explain 结果,如下图所示:

img

图 11 group by 优化的 explain 结果

从 Extra 字段可以看到,这个语句的执行不再需要临时表,也不需要排序了。

group by 优化方法 – 直接排序

所以,如果可以通过加索引来完成 group by 逻辑就再好不过了。但是,如果碰上不适合创建索引的场景,我们还是要老老实实做排序的。那么,这时候的 group by 要怎么优化呢?

如果我们明明知道,一个 group by 语句中需要放到临时表上的数据量特别大,却还是要按照“先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表”,看上去就有点儿傻。

那么,我们就会想了,MySQL 有没有让我们直接走磁盘临时表的方法呢?

答案是,有的。

在 group by 语句中加入 SQL_BIG_RESULT 这个提示(hint),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。

MySQL 的优化器一看,磁盘临时表是 B+ 树存储,存储效率不如数组来得高。所以,既然你告诉我数据量很大,那从磁盘空间考虑,还是直接用数组来存吧。

因此,下面这个语句

select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m;

的执行流程就是这样的:

  1. 初始化 sort_buffer,确定放入一个整型字段,记为 m;
  2. 扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
  3. 扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
  4. 排序完成后,就得到了一个有序数组。

根据有序数组,得到数组里面的不同值,以及每个值的出现次数。这一步的逻辑,你已经从前面的图 10 中了解过了。

下面两张图分别是执行流程图和执行 explain 命令得到的结果。

img

图 12 使用 SQL_BIG_RESULT 的执行流程图

img

图 13 使用 SQL_BIG_RESULT 的 explain 结果

从 Extra 字段可以看到,这个语句的执行没有再使用临时表,而是直接用了排序算法。

基于上面的 union、union all 和 group by 语句的执行过程的分析,我们来回答文章开头的问题:MySQL 什么时候会使用内部临时表?

  1. 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
  2. join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构;
  3. 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。

小结

通过今天这篇文章,我重点和你讲了 group by 的几种实现算法,从中可以总结一些使用的指导原则:

  1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
  2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
  3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
  4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

最后,我给你留下一个思考题吧。

文章中图 8 和图 9 都是 order by null,为什么图 8 的返回结果里面,0 是在结果集的最后一行,而图 9 的结果里面,0 是在结果集的第一行?

你可以把你的分析写在留言区里,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是:为什么不能用 rename 修改临时表的改名。

在实现上,执行 rename table 语句的时候,要求按照“库名 / 表名.frm”的规则去磁盘找文件,但是临时表在磁盘上的 frm 文件是放在 tmpdir 目录下的,并且文件名的规则是“##sql{进程 id}{线程 id} 序列号.frm”,因此会报“找不到文件名”的错误。

评论区留言点赞板:

@poppy 同学,通过执行语句的报错现象推测了这个实现过程。

38 都说InnoDB好,那还要不要使用Memory引擎?

我在上一篇文章末尾留给你的问题是:两个 group by 语句都用了 order by null,为什么使用内存临时表得到的语句结果里,0 这个值在最后一行;而使用磁盘临时表得到的结果里,0 这个值在第一行?

今天我们就来看看,出现这个问题的原因吧。

内存表的数据组织结构

为了便于分析,我来把这个问题简化一下,假设有以下的两张表 t1 和 t2,其中表 t1 使用 Memory 引擎, 表 t2 使用 InnoDB 引擎。

create table t1(id int primary key, c int) engine=Memory;
create table t2(id int primary key, c int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);
insert into t2 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);

然后,我分别执行 select * from t1 和 select * from t2。

img

图 1 两个查询结果 -0 的位置

可以看到,内存表 t1 的返回结果里面 0 在最后一行,而 InnoDB 表 t2 的返回结果里 0 在第一行。

出现这个区别的原因,要从这两个引擎的主键索引的组织方式说起。

表 t2 用的是 InnoDB 引擎,它的主键索引 id 的组织方式,你已经很熟悉了:InnoDB 表的数据就放在主键索引树上,主键索引是 B+ 树。所以表 t2 的数据组织方式如下图所示:

img

图 2 表 t2 的数据组织

主键索引上的值是有序存储的。在执行 select * 的时候,就会按照叶子节点从左到右扫描,所以得到的结果里,0 就出现在第一行。

与 InnoDB 引擎不同,Memory 引擎的数据和索引是分开的。我们来看一下表 t1 中的数据内容。

img

图 3 表 t1 的数据组织

可以看到,内存表的数据部分以数组的方式单独存放,而主键 id 索引里,存的是每个数据的位置。主键 id 是 hash 索引,可以看到索引上的 key 并不是有序的。

在内存表 t1 中,当我执行 select * 的时候,走的是全表扫描,也就是顺序扫描这个数组。因此,0 就是最后一个被读到,并放入结果集的数据。

可见,InnoDB 和 Memory 引擎的数据组织方式是不同的:

  • InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
  • 而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)。

从中我们可以看出,这两个引擎的一些典型不同:

  1. InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的;
  2. 当数据文件有空洞的时候,InnoDB 表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值;
  3. 数据位置发生变化的时候,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引;
  4. InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。
  5. InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。

由于内存表的这些特性,每个数据行被删除以后,空出的这个位置都可以被接下来要插入的数据复用。比如,如果要在表 t1 中执行:

delete from t1 where id=5;
insert into t1 values(10,10);
select * from t1;

就会看到返回结果里,id=10 这一行出现在 id=4 之后,也就是原来 id=5 这行数据的位置。

需要指出的是,表 t1 的这个主键索引是哈希索引,因此如果执行范围查询,比如

select * from t1 where id<5;

是用不上主键索引的,需要走全表扫描。你可以借此再回顾下[第 4 篇文章]的内容。那如果要让内存表支持范围扫描,应该怎么办呢 ?

hash 索引和 B-Tree 索引

实际上,内存表也是支 B-Tree 索引的。在 id 列上创建一个 B-Tree 索引,SQL 语句可以这么写:

alter table t1 add index a_btree_index using btree (id);

这时,表 t1 的数据组织形式就变成了这样:

img

图 4 表 t1 的数据组织 – 增加 B-Tree 索引

新增的这个 B-Tree 索引你看着就眼熟了,这跟 InnoDB 的 b+ 树索引组织形式类似。

作为对比,你可以看一下这下面这两个语句的输出:

img

图 5 使用 B-Tree 和 hash 索引查询返回结果对比

可以看到,执行 select * from t1 where id 的时候,优化器会选择 B-Tree 索引,所以返回结果是 0 到 4。 使用 force index 强行使用主键 id 这个索引,id=0 这一行就在结果集的最末尾了。

其实,一般在我们的印象中,内存表的优势是速度快,其中的一个原因就是 Memory 引擎支持 hash 索引。当然,更重要的原因是,内存表的所有数据都保存在内存,而内存的读写速度总是比磁盘快。

但是,接下来我要跟你说明,为什么我不建议你在生产环境上使用内存表。这里的原因主要包括两个方面:

  1. 锁粒度问题;
  2. 数据持久化问题。

内存表的锁

我们先来说说内存表的锁粒度问题。

内存表不支持行锁,只支持表锁。因此,一张表只要有更新,就会堵住其他所有在这个表上的读写操作。

需要注意的是,这里的表锁跟之前我们介绍过的 MDL 锁不同,但都是表级的锁。接下来,我通过下面这个场景,跟你模拟一下内存表的表级锁。

img

图 6 内存表的表锁 – 复现步骤

在这个执行序列里,session A 的 update 语句要执行 50 秒,在这个语句执行期间 session B 的查询会进入锁等待状态。session C 的 show processlist 结果输出如下:

img

图 7 内存表的表锁 – 结果

跟行锁比起来,表锁对并发访问的支持不够好。所以,内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好。

数据持久性问题

接下来,我们再看看数据持久性的问题。

数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。

你可能会说,如果数据库异常重启,内存表被清空也就清空了,不会有什么问题啊。但是,在高可用架构下,内存表的这个特点简直可以当做 bug 来看待了。为什么这么说呢?

我们先看看 M-S 架构下,使用内存表存在的问题。

img

图 8 M-S 基本架构

我们来看一下下面这个时序:

  1. 业务正常访问主库;
  2. 备库硬件升级,备库重启,内存表 t1 内容被清空;
  3. 备库重启后,客户端发送一条 update 语句,修改表 t1 的数据行,这时备库应用线程就会报错“找不到要更新的行”。

这样就会导致主备同步停止。当然,如果这时候发生主备切换的话,客户端会看到,表 t1 的数据“丢失”了。

在图 8 中这种有 proxy 的架构里,大家默认主备切换的逻辑是由数据库系统自己维护的。这样对客户端来说,就是“网络断开,重连之后,发现内存表数据丢失了”。

你可能说这还好啊,毕竟主备发生切换,连接会断开,业务端能够感知到异常。

但是,接下来内存表的这个特性就会让使用现象显得更“诡异”了。由于 MySQL 知道重启之后,内存表的数据会丢失。所以,担心主库重启之后,出现主备不一致,MySQL 在实现上做了这样一件事儿:在数据库重启之后,往 binlog 里面写入一行 DELETE FROM t1。

如果你使用是如图 9 所示的双 M 结构的话:

img

图 9 双 M 结构

在备库重启的时候,备库 binlog 里的 delete 语句就会传到主库,然后把主库内存表的内容删除。这样你在使用的时候就会发现,主库的内存表数据突然被清空了。

基于上面的分析,你可以看到,内存表并不适合在生产环境上作为普通数据表使用。

有同学会说,但是内存表执行速度快呀。这个问题,其实你可以这么分析:

  1. 如果你的表更新量大,那么并发度是一个很重要的参考指标,InnoDB 支持行锁,并发度比内存表好;
  2. 能放到内存表的数据量都不大。如果你考虑的是读的性能,一个读 QPS 很高并且数据量不大的表,即使是使用 InnoDB,数据也是都会缓存在 InnoDB Buffer Pool 里的。因此,使用 InnoDB 表的读性能也不会差。

所以,我建议你把普通内存表都用 InnoDB 表来代替。但是,有一个场景却是例外的。

这个场景就是,我们在第 35 和 36 篇说到的用户临时表。在数据量可控,不会耗费过多内存的情况下,你可以考虑使用内存表。

内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因:

  1. 临时表不会被其他线程访问,没有并发性的问题;
  2. 临时表重启后也是需要删除的,清空数据这个问题不存在;
  3. 备库的临时表也不会影响主库的用户线程。

现在,我们回过头再看一下第 35 篇 join 语句优化的例子,当时我建议的是创建一个 InnoDB 临时表,使用的语句序列是:

create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

了解了内存表的特性,你就知道了, 其实这里使用内存临时表的效果更好,原因有三个:

  1. 相比于 InnoDB 表,使用内存表不需要写磁盘,往表 temp_t 的写数据的速度更快;
  2. 索引 b 使用 hash 索引,查找的速度比 B-Tree 索引快;
  3. 临时表数据只有 2000 行,占用的内存有限。

因此,你可以对[第 35 篇文章]的语句序列做一个改写,将临时表 t1 改成内存临时表,并且在字段 b 上创建一个 hash 索引。

create temporary table temp_t(id int primary key, a int, b int, index (b))engine=memory;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

img

图 10 使用内存临时表的执行效果

可以看到,不论是导入数据的时间,还是执行 join 的时间,使用内存临时表的速度都比使用 InnoDB 临时表要更快一些。

小结

今天这篇文章,我从“要不要使用内存表”这个问题展开,和你介绍了 Memory 引擎的几个特性。

可以看到,由于重启会丢数据,如果一个备库重启,会导致主备同步线程停止;如果主库跟这个备库是双 M 架构,还可能导致主库的内存表数据被删掉。

因此,在生产上,我不建议你使用普通内存表。

如果你是 DBA,可以在建表的审核系统中增加这类规则,要求业务改用 InnoDB 表。我们在文中也分析了,其实 InnoDB 表性能还不错,而且数据安全也有保障。而内存表由于不支持行锁,更新语句会阻塞查询,性能也未必就如想象中那么好。

基于内存表的特性,我们还分析了它的一个适用场景,就是内存临时表。内存表支持 hash 索引,这个特性利用起来,对复杂查询的加速效果还是很不错的。

最后,我给你留一个问题吧。

假设你刚刚接手的一个数据库上,真的发现了一个内存表。备库重启之后肯定是会导致备库的内存表数据被清空,进而导致主备同步停止。这时,最好的做法是将它修改成 InnoDB 引擎表。

假设当时的业务场景暂时不允许你修改引擎,你可以加上什么自动化逻辑,来避免主备同步停止呢?

你可以把你的思考和分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

今天文章的正文内容,已经回答了我们上期的问题,这里就不再赘述了。

评论区留言点赞板:

@老杨同志、@poppy、@长杰 这三位同学给出了正确答案,春节期间还持续保持跟进学习,给你们点赞。

39 自增主键为什么不是连续的?

在[第 4 篇文章]中,我们提到过自增主键,由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。

之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。

今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”?

为了便于说明,我们创建一个表 t,其中 id 是自增主键字段、c 是唯一索引。

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;

自增值保存在哪儿?

在这个空表 t 里面执行 insert into t values(null, 1, 1); 插入一行数据,再执行 show create table 命令,就可以看到如下图所示的结果:

img

图 1 自动生成的 AUTO_INCREMENT 值

可以看到,表定义里面出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成 id=2。

其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。

不同的引擎对于自增值的保存策略不同。

  • MyISAM 引擎的自增值保存在数据文件中。
  • InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:
    • 在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。 举例来说,如果一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时候,我们删除 id=10 的行,AUTO_INCREMENT 还是 11。但如果马上重启实例,重启后这个表的 AUTO_INCREMENT 就会变成 10。 也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。
    • 在 MySQL 8.0 版本,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。

理解了 MySQL 对自增值的保存策略以后,我们再看看自增值修改机制。

自增值修改机制

在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:

  1. 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;
  2. 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。

根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是 X,当前的自增值是 Y。

  1. 如果 X,那么这个表的自增值不变;
  2. 如果 X≥Y,就需要把当前自增值修改为新的自增值。

新的自增值生成算法是:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值。

其中,auto_increment_offset 和 auto_increment_increment 是两个系统参数,分别用来表示自增的初始值和步长,默认值都是 1。

备注:在一些场景下,使用的就不全是默认值。比如,双 M 的主备结构里要求双写的时候,我们就可能会设置成 auto_increment_increment=2,让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数,避免两个库生成的主键发生冲突。

当 auto_increment_offset 和 auto_increment_increment 都是 1 的时候,新的自增值生成逻辑很简单,就是:

  1. 如果准备插入的值 >= 当前自增值,新的自增值就是“准备插入的值 +1”;
  2. 否则,自增值不变。

这就引入了我们文章开头提到的问题,在这两个参数都设置为 1 的时候,自增主键 id 却不能保证是连续的,这是什么原因呢?

自增值的修改时机

要回答这个问题,我们就要看一下自增值的修改时机。

假设,表 t 里面已经有了 (1,1,1) 这条记录,这时我再执行一条插入数据命令:

insert into t values(null, 1, 1); 

这个语句的执行流程就是:

  1. 执行器调用 InnoDB 引擎接口写入一行,传入的这一行的值是 (0,1,1);
  2. InnoDB 发现用户没有指定自增 id 的值,获取表 t 当前的自增值 2;
  3. 将传入的行的值改成 (2,1,1);
  4. 将表的自增值改成 3;
  5. 继续执行插入数据操作,由于已经存在 c=1 的记录,所以报 Duplicate key error,语句返回。

对应的执行流程图如下:

img

图 2 insert(null, 1,1) 唯一键冲突

可以看到,这个表的自增值改成 3,是在真正执行插入数据的操作之前。这个语句真正执行的时候,因为碰到唯一键 c 冲突,所以 id=2 这一行并没有插入成功,但也没有将自增值再改回去。

所以,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的情况。

如图 3 所示就是完整的演示结果。

img

图 3 一个自增主键 id 不连续的复现步骤

可以看到,这个操作序列复现了一个自增主键 id 不连续的现场 (没有 id=2 的行)。可见,唯一键冲突是导致自增主键 id 不连续的第一种原因。

同样地,事务回滚也会产生类似的现象,这就是第二种原因。

下面这个语句序列就可以构造不连续的自增 id,你可以自己验证一下。

insert into t values(null,1,1);
begin;
insert into t values(null,2,2);
rollback;
insert into t values(null,2,2);
// 插入的行是 (3,2,2)

你可能会问,为什么在出现唯一键冲突或者回滚的时候,MySQL 没有把表 t 的自增值改回去呢?如果把表 t 的当前自增值从 3 改回 2,再插入新数据的时候,不就可以生成 id=2 的一行数据了吗?

其实,MySQL 这么设计是为了提升性能。接下来,我就跟你分析一下这个设计思路,看看自增值为什么不能回退。

假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增 id,肯定要加锁,然后顺序申请。

  1. 假设事务 A 申请到了 id=2, 事务 B 申请到 id=3,那么这时候表 t 的自增值是 4,之后继续执行。
  2. 事务 B 正确提交了,但事务 A 出现了唯一键冲突。
  3. 如果允许事务 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现这样的情况:表里面已经有 id=3 的行,而当前的自增 id 值是 2。
  4. 接下来,继续执行的其他事务就会申请到 id=2,然后再申请到 id=3。这时,就会出现插入语句报错“主键冲突”。

而为了解决这个主键冲突,有两种方法:

  1. 每次申请 id 之前,先判断表里面是否已经存在这个 id。如果存在,就跳过这个 id。但是,这个方法的成本很高。因为,本来申请 id 是一个很快的操作,现在还要再去主键索引树上判断 id 是否存在。
  2. 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。

可见,这两个方法都会导致性能问题。造成这些麻烦的罪魁祸首,就是我们假设的这个“允许自增 id 回退”的前提导致的。

因此,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是因为这样,所以才只保证了自增 id 是递增的,但不保证是连续的。

自增锁的优化

可以看到,自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。其实,在 MySQL 5.1 版本之前,并不是这样的。

接下来,我会先给你介绍下自增锁设计的历史,这样有助于你分析接下来的一个问题。

在 MySQL 5.0 版本的时候,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。显然,这样设计会影响并发度。

MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1。

  1. 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;
  2. 这个参数的值被设置为 1 时:
    • 普通 insert 语句,自增锁在申请之后就马上释放;
    • 类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
  3. 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁。

你一定有两个疑问:为什么默认设置下,insert … select 要使用语句级的锁?为什么这个参数的默认值不是 2?

答案是,这么设计还是为了数据的一致性。

我们一起来看一下这个场景:

img

图 4 批量插入数据的自增锁

在这个例子里,我往表 t1 中插入了 4 行数据,然后创建了一个相同结构的表 t2,然后两个 session 同时执行向表 t2 中插入数据的操作。

你可以设想一下,如果 session B 是申请了自增值以后马上就释放自增锁,那么就可能出现这样的情况:

  • session B 先插入了两个记录,(1,1,1)、(2,2,2);
  • 然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5);
  • 之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。

你可能会说,这也没关系吧,毕竟 session B 的语义本身就没有要求表 t2 的所有行的数据都跟 session A 相同。

是的,从数据逻辑上看是对的。但是,如果我们现在的 binlog_format=statement,你可以设想下,binlog 会怎么记录呢?

由于两个 session 是同时执行插入数据命令的,所以 binlog 里面对表 t2 的更新日志只有两种情况:要么先记 session A 的,要么先记 session B 的。

但不论是哪一种,这个 binlog 拿去从库执行,或者用来恢复临时实例,备库和临时实例里面,session B 这个语句执行出来,生成的结果里面,id 都是连续的。这时,这个库就发生了数据不一致。

你可以分析一下,出现这个问题的原因是什么?

其实,这是因为原库 session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用 statement 格式的 binlog 来串行执行,是执行不出来的。

而要解决这个问题,有两种思路:

  1. 一种思路是,让原库的批量插入数据语句,固定生成连续的 id 值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。
  2. 另一种思路是,在 binlog 里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是 innodb_autoinc_lock_mode 设置为 2,同时 binlog_format 设置为 row。

因此,在生产上,尤其是有 insert … select 这种批量插入数据的场景时,从并发插入数据性能的角度考虑,我建议你这样设置:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row. 这样做,既能提升并发性,又不会出现数据一致性问题。

需要注意的是,我这里说的批量插入数据,包含的语句类型是 insert … select、replace … select 和 load data 语句。

但是,在普通的 insert 语句里面包含多个 value 值的情况下,即使 innodb_autoinc_lock_mode 设置为 1,也不会等语句执行完成才释放锁。因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,然后一次性申请,申请完成后锁就可以释放了。

也就是说,批量插入数据的语句,之所以需要这么设置,是因为“不知道要预先申请多少个 id”。

既然预先不知道要申请多少个自增 id,那么一种直接的想法就是需要一个时申请一个。但如果一个 select … insert 语句要插入 10 万行数据,按照这个逻辑的话就要申请 10 万次。显然,这种申请自增 id 的策略,在大批量插入数据的情况下,不但速度慢,还会影响并发插入的性能。

因此,对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:

  1. 语句执行过程中,第一次申请自增 id,会分配 1 个;
  2. 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
  3. 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
  4. 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。

举个例子,我们一起看看下面的这个语句序列:

insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t;
insert into t2(c,d) select c,d from t;
insert into t2 values(null, 5,5);

insert…select,实际上往表 t2 中插入了 4 行数据。但是,这四行数据是分三次申请的自增 id,第一次申请到了 id=1,第二次被分配了 id=2 和 id=3, 第三次被分配到 id=4 到 id=7。

由于这条语句实际只用上了 4 个 id,所以 id=5 到 id=7 就被浪费掉了。之后,再执行 insert into t2 values(null, 5,5),实际上插入的数据就是(8,5,5)。

这是主键 id 出现自增 id 不连续的第三种原因。

小结

今天,我们从“自增主键为什么会出现不连续的值”这个问题开始,首先讨论了自增值的存储。

在 MyISAM 引擎里面,自增值是被写在数据文件上的。而在 InnoDB 中,自增值是被记录在内存的。MySQL 直到 8.0 版本,才给 InnoDB 表的自增值加上了持久化的能力,确保重启前后一个表的自增值不变。

然后,我和你分享了在一个语句执行过程中,自增值改变的时机,分析了为什么 MySQL 在事务回滚的时候不能回收自增 id。

MySQL 5.1.22 版本开始引入的参数 innodb_autoinc_lock_mode,控制了自增值申请时的锁范围。从并发性能的角度考虑,我建议你将其设置为 2,同时将 binlog_format 设置为 row。我在前面的文章中其实多次提到,binlog_format 设置为 row,是很有必要的。今天的例子给这个结论多了一个理由。

最后,我给你留一个思考题吧。

在最后一个例子中,执行 insert into t2(c,d) select c,d from t; 这个语句的时候,如果隔离级别是可重复读(repeatable read),binlog_format=statement。这个语句会对表 t 的所有记录和间隙加锁。

你觉得为什么需要这么做呢?

你可以把你的思考和分析写在评论区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,如果你维护的 MySQL 系统里有内存表,怎么避免内存表突然丢数据,然后导致主备同步停止的情况。

我们假设的是主库暂时不能修改引擎,那么就把备库的内存表引擎先都改成 InnoDB。对于每个内存表,执行

set sql_log_bin=off;
alter table tbl_name engine=innodb;

这样就能避免备库重启的时候,数据丢失的问题。

由于主库重启后,会往 binlog 里面写“delete from tbl_name”,这个命令传到备库,备库的同名的表数据也会被清空。

因此,就不会出现主备同步停止的问题。

如果由于主库异常重启,触发了 HA,这时候我们之前修改过引擎的备库变成了主库。而原来的主库变成了新备库,在新备库上把所有的内存表(这时候表里没数据)都改成 InnoDB 表。

所以,如果我们不能直接修改主库上的表引擎,可以配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。

同时,跟业务开发同学约定好建表规则,避免创建新的内存表。

评论区留言点赞板:

大家在春节期间还坚持看专栏,并且深入地思考和回复,给大家点赞。 @长杰 同学提到的将数据保存到 InnoDB 表用来持久化,也是一个方法。不过,我还是建议釜底抽薪,直接修改备库的内存表的引擎。 @老杨同志 提到的是主库异常重启的场景,这时候是不会报主备不一致的,因为主库重启的时候写了 delete from tbl_name,主备的内存表都清空了。

40 insert语句的锁为什么这么多?

在上一篇文章中,我提到 MySQL 对自增主键锁做了优化,尽量在申请到自增 id 以后,就释放自增锁。

因此,insert 语句是一个很轻量的操作。不过,这个结论对于“普通的 insert 语句”才有效。也就是说,还有些 insert 语句是属于“特殊情况”的,在执行过程中需要给其他资源加锁,或者无法在申请到自增 id 以后就立马释放自增锁。

那么,今天这篇文章,我们就一起来聊聊这个话题。

insert … select 语句

我们先从昨天的问题说起吧。表 t 和 t2 的表结构、初始化数据语句如下,今天的例子我们还是针对这两个表展开。

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB; 
insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4); 
create table t2 like t

现在,我们一起来看看为什么在可重复读隔离级别下,binlog_format=statement 时执行:

insert into t2(c,d) select c,d from t;

这个语句时,需要对表 t 的所有行和间隙加锁呢?

其实,这个问题我们需要考虑的还是日志和数据的一致性。我们看下这个执行序列:

img

图 1 并发 insert 场景

实际的执行效果是,如果 session B 先执行,由于这个语句对表 t 主键索引加了 (-∞,1] 这个 next-key lock,会在语句执行完成后,才允许 session A 的 insert 语句执行。

但如果没有锁的话,就可能出现 session B 的 insert 语句先执行,但是后写入 binlog 的情况。于是,在 binlog_format=statement 的情况下,binlog 里面就记录了这样的语句序列:

insert into t values(-1,-1,-1);
insert into t2(c,d) select c,d from t;

这个语句到了备库执行,就会把 id=-1 这一行也写到表 t2 中,出现主备不一致。

insert 循环写入

当然了,执行 insert … select 的时候,对目标表也不是锁全表,而是只锁住需要访问的资源。

如果现在有这么一个需求:要往表 t2 中插入一行数据,这一行的 c 值是表 t 中 c 值的最大值加 1。

此时,我们可以这么写这条 SQL 语句 :

insert into t2(c,d)  (select c+1, d from t force index(c) order by c desc limit 1);

这个语句的加锁范围,就是表 t 索引 c 上的 (3,4] 和 (4,supremum] 这两个 next-key lock,以及主键索引上 id=4 这一行。

它的执行流程也比较简单,从表 t 中按照索引 c 倒序,扫描第一行,拿到结果写入到表 t2 中。

因此整条语句的扫描行数是 1。

这个语句执行的慢查询日志(slow log),如下图所示:

img

图 2 慢查询日志 – 将数据插入表 t2

通过这个慢查询日志,我们看到 Rows_examined=1,正好验证了执行这条语句的扫描行数为 1。

那么,如果我们是要把这样的一行数据插入到表 t 中的话:

insert into t(c,d)  (select c+1, d from t force index(c) order by c desc limit 1);

语句的执行流程是怎样的?扫描行数又是多少呢?

这时候,我们再看慢查询日志就会发现不对了。

img

图 3 慢查询日志 – 将数据插入表 t

可以看到,这时候的 Rows_examined 的值是 5。

我在前面的文章中提到过,希望你都能够学会用 explain 的结果来“脑补”整条语句的执行过程。今天,我们就来一起试试。

如图 4 所示就是这条语句的 explain 结果。

img

图 4 explain 结果

从 Extra 字段可以看到“Using temporary”字样,表示这个语句用到了临时表。也就是说,执行过程中,需要把表 t 的内容读出来,写入临时表。

图中 rows 显示的是 1,我们不妨先对这个语句的执行流程做一个猜测:如果说是把子查询的结果读出来(扫描 1 行),写入临时表,然后再从临时表读出来(扫描 1 行),写回表 t 中。那么,这个语句的扫描行数就应该是 2,而不是 5。

所以,这个猜测不对。实际上,Explain 结果里的 rows=1 是因为受到了 limit 1 的影响。

从另一个角度考虑的话,我们可以看看 InnoDB 扫描了多少行。如图 5 所示,是在执行这个语句前后查看 Innodb_rows_read 的结果。

img

图 5 查看 Innodb_rows_read 变化

可以看到,这个语句执行前后,Innodb_rows_read 的值增加了 4。因为默认临时表是使用 Memory 引擎的,所以这 4 行查的都是表 t,也就是说对表 t 做了全表扫描。

这样,我们就把整个执行过程理清楚了:

  1. 创建临时表,表里有两个字段 c 和 d。
  2. 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表。这时,Rows_examined=4。
  3. 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中。这时,Rows_examined 的值加 1,变成了 5。

也就是说,这个语句会导致在表 t 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据。

至于这个语句的执行为什么需要临时表,原因是这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符。

由于实现上这个语句没有在子查询中就直接使用 limit 1,从而导致了这个语句的执行需要遍历整个表 t。它的优化方法也比较简单,就是用前面介绍的方法,先 insert into 到临时表 temp_t,这样就只需要扫描一行;然后再从表 temp_t 里面取出这行数据插入表 t1。

当然,由于这个语句涉及的数据量很小,你可以考虑使用内存临时表来做这个优化。使用内存临时表优化时,语句序列的写法如下:

create temporary table temp_t(c int,d int) engine=memory;
insert into temp_t  (select c+1, d from t force index(c) order by c desc limit 1);
insert into t select * from temp_t;
drop table temp_t;

insert 唯一键冲突

前面的两个例子是使用 insert … select 的情况,接下来我要介绍的这个例子就是最常见的 insert 语句出现唯一键冲突的情况。

对于有唯一键的表,插入数据时出现唯一键冲突也是常见的情况了。我先给你举一个简单的唯一键冲突的例子。

img

图 6 唯一键冲突加锁

这个例子也是在可重复读(repeatable read)隔离级别下执行的。可以看到,session B 要执行的 insert 语句进入了锁等待状态。

也就是说,session A 执行的 insert 语句,发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁。我们前面说过,一个 next-key lock 就是由它右边界的值定义的。这时候,session A 持有索引 c 上的 (5,10] 共享 next-key lock(读锁)。

至于为什么要加这个读锁,其实我也没有找到合理的解释。从作用上来看,这样做可以避免这一行被别的事务删掉。

这里官方文档有一个描述错误,认为如果冲突的是主键索引,就加记录锁,唯一索引才加 next-key lock。但实际上,这两类索引冲突加的都是 next-key lock。

备注:这个 bug,是我在写这篇文章查阅文档时发现的,已经发给官方并被 verified 了。

有同学在前面文章的评论区问到,在有多个唯一索引的表中并发插入数据时,会出现死锁。但是,由于他没有提供复现方法或者现场,我也无法做分析。所以,我建议你在评论区发问题的时候,尽量同时附上复现方法,或者现场信息,这样我才好和你一起分析问题。

这里,我就先和你分享一个经典的死锁场景,如果你还遇到过其他唯一键冲突导致的死锁场景,也欢迎给我留言。

img

图 7 唯一键冲突 – 死锁

在 session A 执行 rollback 语句回滚的时候,session C 几乎同时发现死锁并返回。

这个死锁产生的逻辑是这样的:

  1. 在 T1 时刻,启动 session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁(如果你的印象模糊了,可以回顾下[第 21 篇文章]介绍的加锁规则)。
  2. 在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,加上读锁;同样地,session C 也在索引 c 上,c=5 这一个记录上,加了读锁。
  3. T3 时刻,session A 回滚。这时候,session B 和 session C 都试图继续执行插入操作,都要加上写锁。两个 session 都要等待对方的行锁,所以就出现了死锁。

这个流程的状态变化图如下所示。

img

图 8 状态变化图 – 死锁

insert into … on duplicate key update

上面这个例子是主键冲突后直接报错,如果是改写成

insert into t values(11,10,10) on duplicate key update d=100; 

的话,就会给索引 c 上 (5,10] 加一个排他的 next-key lock(写锁)。

insert into … on duplicate key update 这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。

注意,如果有多个列违反了唯一性约束,就会按照索引的顺序,修改跟第一个索引冲突的行。

现在表 t 里面已经有了 (1,1,1) 和 (2,2,2) 这两行,我们再来看看下面这个语句执行的效果:

img

图 9 两个唯一键同时冲突

可以看到,主键 id 是先判断的,MySQL 认为这个语句跟 id=2 这一行冲突,所以修改的是 id=2 的行。

需要注意的是,执行这条语句的 affected rows 返回的是 2,很容易造成误解。实际上,真正更新的只有一行,只是在代码实现上,insert 和 update 都认为自己成功了,update 计数加了 1, insert 计数也加了 1。

小结

今天这篇文章,我和你介绍了几种特殊情况下的 insert 语句。

insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下,这个语句会给 select 的表里扫描到的记录和间隙加读锁。

而如果 insert 和 select 的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。

insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。

最后,我给你留一个问题吧。

你平时在两个表之间拷贝数据用的是什么方法,有什么注意事项吗?在你的应用场景里,这个方法,相较于其他方法的优势是什么呢?

你可以把你的经验和分析写在评论区,我会在下一篇文章的末尾选取有趣的评论来和你一起分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我们已经在文章中回答了上期问题。

有同学提到,如果在 insert … select 执行期间有其他线程操作原表,会导致逻辑错误。其实,这是不会的,如果不加锁,就是快照读。

一条语句执行期间,它的一致性视图是不会修改的,所以即使有其他事务修改了原表的数据,也不会影响这条语句看到的数据。

评论区留言点赞板:

@长杰 同学回答得非常准确。

41 怎么最快地复制一张表?

我在上一篇文章最后,给你留下的问题是怎么在两张表中拷贝数据。如果可以控制对源表的扫描行数和加锁范围很小的话,我们简单地使用 insert … select 语句即可实现。

当然,为了避免对源表加读锁,更稳妥的方案是先将数据写到外部文本文件,然后再写回目标表。这时,有两种常用的方法。接下来的内容,我会和你详细展开一下这两种方法。

为了便于说明,我还是先创建一个表 db1.t,并插入 1000 行数据,同时创建一个相同结构的表 db2.t。

create database db1;
use db1; 
create table t(id int primary key, a int, b int, index(a))engine=innodb;
delimiter ;;
  create procedure idata()
  begin
    declare i int;
    set i=1;
    while(i<=1000)do
      insert into t values(i,i,i);
      set i=i+1;
    end while;
  end;;
delimiter ;
call idata(); 
create database db2;
create table db2.t like db1.t

假设,我们要把 db1.t 里面 a>900 的数据行导出来,插入到 db2.t 中。

mysqldump 方法

一种方法是,使用 mysqldump 命令将数据导出成一组 INSERT 语句。你可以使用下面的命令:

mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql

把结果输出到临时文件。

这条命令中,主要参数含义如下:

  1. –single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
  2. –add-locks 设置为 0,表示在输出的文件结果里,不增加” LOCK TABLES t WRITE;” ;
  3. –no-create-info 的意思是,不需要导出表结构;
  4. –set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
  5. –result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。

通过这条 mysqldump 命令生成的 t.sql 文件中就包含了如图 1 所示的 INSERT 语句。

img

图 1 mysqldump 输出文件的部分结果

可以看到,一条 INSERT 语句里面会包含多个 value 对,这是为了后续用这个文件来写入数据的时候,执行速度可以更快。

如果你希望生成的文件中一条 INSERT 语句只插入一行数据的话,可以在执行 mysqldump 命令时,加上参数–skip-extended-insert。

然后,你可以通过下面这条命令,将这些 INSERT 语句放到 db2 库里去执行。

mysql -h127.0.0.1 -P13000  -uroot db2 -e "source /client_tmp/t.sql"

需要说明的是,source 并不是一条 SQL 语句,而是一个客户端命令。mysql 客户端执行这个命令的流程是这样的:

  1. 打开文件,默认以分号为结尾读取一条条的 SQL 语句;
  2. 将 SQL 语句发送到服务端执行。

也就是说,服务端执行的并不是这个“source t.sql”语句,而是 INSERT 语句。所以,不论是在慢查询日志(slow log),还是在 binlog,记录的都是这些要被真正执行的 INSERT 语句。

导出 CSV 文件

另一种方法是直接将结果导出成.csv 文件。MySQL 提供了下面的语法,用来将查询结果导出到服务端本地目录:

select * from db1.t where a>900 into outfile '/server_tmp/t.csv';

我们在使用这条语句时,需要注意如下几点。

  1. 这条语句会将结果保存在服务端。如果你执行命令的客户端和 MySQL 服务端不在同一个机器上,客户端机器的临时目录下是不会生成 t.csv 文件的。
  2. into outfile 指定了文件的生成位置(/server_tmp/),这个位置必须受参数 secure_file_priv 的限制。参数 secure_file_priv 的可选值和作用分别是:
    • 如果设置为 empty,表示不限制文件生成的位置,这是不安全的设置;
    • 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
    • 如果设置为 NULL,就表示禁止在这个 MySQL 实例上执行 select … into outfile 操作。
  3. 这条命令不会帮你覆盖文件,因此你需要确保 /server_tmp/t.csv 这个文件不存在,否则执行语句时就会因为有同名文件的存在而报错。
  4. 这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。

得到.csv 导出文件后,你就可以用下面的 load data 命令将数据导入到目标表 db2.t 中。

load data infile '/server_tmp/t.csv' into table db2.t;

这条语句的执行流程如下所示。

  1. 打开文件 /server_tmp/t.csv,以制表符 (\t) 作为字段间的分隔符,以换行符(\n)作为记录之间的分隔符,进行数据读取;
  2. 启动事务。
  3. 判断每一行的字段数与表 db2.t 是否相同:
    • 若不相同,则直接报错,事务回滚;
    • 若相同,则构造成一行,调用 InnoDB 引擎接口,写入到表中。
  4. 重复步骤 3,直到 /server_tmp/t.csv 整个文件读入完成,提交事务。

你可能有一个疑问,如果 binlog_format=statement,这个 load 语句记录到 binlog 里以后,怎么在备库重放呢?

由于 /server_tmp/t.csv 文件只保存在主库所在的主机上,如果只是把这条语句原文写到 binlog 中,在备库执行的时候,备库的本地机器上没有这个文件,就会导致主备同步停止。

所以,这条语句执行的完整流程,其实是下面这样的。

  1. 主库执行完成后,将 /server_tmp/t.csv 文件的内容直接写到 binlog 文件中。
  2. 往 binlog 文件中写入语句 load data local infile ‘/tmp/SQL_LOAD_MB-1-0’ INTO TABLE db2.t
  3. 把这个 binlog 日志传到备库。
  4. 备库的 apply 线程在执行这个事务日志时: a. 先将 binlog 中 t.csv 文件的内容读出来,写入到本地临时目录 /tmp/SQL_LOAD_MB-1-0 中; b. 再执行 load data 语句,往备库的 db2.t 表中插入跟主库相同的数据。

执行流程如图 2 所示:

img

图 2 load data 的同步流程

注意,这里备库执行的 load data 语句里面,多了一个“local”。它的意思是“将执行这条命令的客户端所在机器的本地文件 /tmp/SQL_LOAD_MB-1-0 的内容,加载到目标表 db2.t 中”。

也就是说,load data 命令有两种用法

  1. 不加“local”,是读取服务端的文件,这个文件必须在 secure_file_priv 指定的目录或子目录下;
  2. 加上“local”,读取的是客户端的文件,只要 mysql 客户端有访问这个文件的权限即可。这时候,MySQL 客户端会先把本地文件传给服务端,然后执行上述的 load data 流程。

另外需要注意的是,select …into outfile 方法不会生成表结构文件, 所以我们导数据时还需要单独的命令得到表结构定义。mysqldump 提供了一个–tab 参数,可以同时导出表结构定义文件和 csv 数据文件。这条命令的使用方法如下:

mysqldump -h$host -P$port -u$user ---single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --tab=$secure_file_priv

这条命令会在 $secure_file_priv 定义的目录下,创建一个 t.sql 文件保存建表语句,同时创建一个 t.txt 文件保存 CSV 数据。

物理拷贝方法

前面我们提到的 mysqldump 方法和导出 CSV 文件的方法,都是逻辑导数据的方法,也就是将数据从表 db1.t 中读出来,生成文本,然后再写入目标表 db2.t 中。

你可能会问,有物理导数据的方法吗?比如,直接把 db1.t 表的.frm 文件和.ibd 文件拷贝到 db2 目录下,是否可行呢?

答案是不行的。

因为,一个 InnoDB 表,除了包含这两个物理文件外,还需要在数据字典中注册。直接拷贝这两个文件的话,因为数据字典中没有 db2.t 这个表,系统是不会识别和接受它们的。

不过,在 MySQL 5.6 版本引入了可传输表空间(transportable tablespace) 的方法,可以通过导出 + 导入表空间的方式,实现物理拷贝表的功能。

假设我们现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:

  1. 执行 create table r like t,创建一个相同表结构的空表;
  2. 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
  3. 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
  4. 在 db1 目录下执行 cp t.cfg r.cfg; cp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
  5. 执行 unlock tables,这时候 t.cfg 文件会被删除;
  6. 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。

至此,拷贝表数据的操作就完成了。这个流程的执行过程图如下:

img

图 3 物理拷贝表

关于拷贝表的这个流程,有以下几个注意点:

  1. 在第 3 步执行完 flsuh table 命令之后,db1.t 整个表处于只读状态,直到执行 unlock tables 命令后才释放读锁;
  2. 在执行 import tablespace 的时候,为了让文件里的表空间 id 和数据字典中的一致,会修改 r.ibd 的表空间 id。而这个表空间 id 存在于每一个数据页中。因此,如果是一个很大的文件(比如 TB 级别),每个数据页都需要修改,所以你会看到这个 import 语句的执行是需要一些时间的。当然,如果是相比于逻辑导入的方法,import 语句的耗时是非常短的。

小结

今天这篇文章,我和你介绍了三种将一个表的数据导入到另外一个表中的方法。

我们来对比一下这三种方法的优缺点。

  1. 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
    • 必须是全表拷贝,不能只拷贝部分数据;
    • 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;
    • 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用。
  2. 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用 join 这种比较复杂的 where 条件写法。
  3. 用 select … into outfile 的方法是最灵活的,支持所有的 SQL 写法。但,这个方法的缺点之一就是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。

后两种方式都是逻辑备份方式,是可以跨引擎使用的。

最后,我给你留下一个思考题吧。

我们前面介绍 binlog_format=statement 的时候,binlog 记录的 load data 命令是带 local 的。既然这条命令是发送到备库去执行的,那么备库执行的时候也是本地执行,为什么需要这个 local 呢?如果写到 binlog 中的命令不带 local,又会出现什么问题呢?

你可以把你的分析写在评论区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

我在上篇文章最后给你留下的思考题,已经在今天这篇文章的正文部分做了回答。

上篇文章的评论区有几个非常好的留言,我在这里和你分享一下。

@huolang 同学提了一个问题:如果 sessionA 拿到 c=5 的记录锁是写锁,那为什么 sessionB 和 sessionC 还能加 c=5 的读锁呢?

这是因为 next-key lock 是先加间隙锁,再加记录锁的。加间隙锁成功了,加记录锁就会被堵住。如果你对这个过程有疑问的话,可以再复习一下[第 30 篇文章]中的相关内容。

@一大只 同学做了一个实验,验证了主键冲突以后,insert 语句加间隙锁的效果。比我在上篇文章正文中提的那个回滚导致死锁的例子更直观,体现了他对这个知识点非常好的理解和思考,很赞。

@roaming 同学验证了在 MySQL 8.0 版本中,已经能够用临时表处理 insert … select 写入原表的语句了。

@老杨同志 的回答提到了我们本文中说到的几个方法。

42 grant之后要跟着flush privileges吗?

在 MySQL 里面,grant 语句是用来给用户赋权的。不知道你有没有见过一些操作文档里面提到,grant 之后要马上跟着执行一个 flush privileges 命令,才能使赋权语句生效。我最开始使用 MySQL 的时候,就是照着一个操作文档的说明按照这个顺序操作的。

那么,grant 之后真的需要执行 flush privileges 吗?如果没有执行这个 flush 命令的话,赋权语句真的不能生效吗?

接下来,我就先和你介绍一下 grant 语句和 flush privileges 语句分别做了什么事情,然后再一起来分析这个问题。

为了便于说明,我先创建一个用户:

create user 'ua'@'%' identified by 'pa';

这条语句的逻辑是创建一个用户’ua’@’%’,密码是 pa。注意,在 MySQL 里面,用户名 (user)+ 地址 (host) 才表示一个用户,因此 ua@ip1 和 ua@ip2 代表的是两个不同的用户。

这条命令做了两个动作:

  1. 磁盘上,往 mysql.user 表里插入一行,由于没有指定权限,所以这行数据上所有表示权限的字段的值都是 N;
  2. 内存里,往数组 acl_users 里插入一个 acl_user 对象,这个对象的 access 字段值为 0。

图 1 就是这个时刻用户 ua 在 user 表中的状态。

img

图 1 mysql.user 数据行

在 MySQL 中,用户权限是有不同的范围的。接下来,我就按照用户权限范围从大到小的顺序依次和你说明。

全局权限

全局权限,作用于整个 MySQL 实例,这些权限信息保存在 mysql 库的 user 表里。如果我要给用户 ua 赋一个最高权限的话,语句是这么写的:

grant all privileges on *.* to 'ua'@'%' with grant option;

这个 grant 命令做了两个动作:

  1. 磁盘上,将 mysql.user 表里,用户’ua’@’%‘这一行的所有表示权限的字段的值都修改为‘Y’;
  2. 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 值(权限位)修改为二进制的“全 1”。

在这个 grant 命令执行完成后,如果有新的客户端使用用户名 ua 登录成功,MySQL 会为新连接维护一个线程对象,然后从 acl_users 数组里查到这个用户的权限,并将权限值拷贝到这个线程对象中。之后在这个连接中执行的语句,所有关于全局权限的判断,都直接使用线程对象内部保存的权限位。

基于上面的分析我们可以知道:

  1. grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。
  2. 对于一个已经存在的连接,它的全局权限不受 grant 命令的影响。

需要说明的是,一般在生产环境上要合理控制用户权限的范围。我们上面用到的这个 grant 语句就是一个典型的错误示范。如果一个用户有所有权限,一般就不应该设置为所有 IP 地址都可以访问。

如果要回收上面的 grant 语句赋予的权限,你可以使用下面这条命令:

revoke all privileges on *.* from 'ua'@'%';

这条 revoke 命令的用法与 grant 类似,做了如下两个动作:

  1. 磁盘上,将 mysql.user 表里,用户’ua’@’%‘这一行的所有表示权限的字段的值都修改为“N”;
  2. 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 的值修改为 0。

db 权限

除了全局权限,MySQL 也支持库级别的权限定义。如果要让用户 ua 拥有库 db1 的所有权限,可以执行下面这条命令:

grant all privileges on db1.* to 'ua'@'%' with grant option;

基于库的权限记录保存在 mysql.db 表中,在内存里则保存在数组 acl_dbs 中。这条 grant 命令做了如下两个动作:

  1. 磁盘上,往 mysql.db 表中插入了一行记录,所有权限位字段设置为“Y”;
  2. 内存里,增加一个对象到数组 acl_dbs 中,这个对象的权限位为“全 1”。

图 2 就是这个时刻用户 ua 在 db 表中的状态。

img

图 2 mysql.db 数据行

每次需要判断一个用户对一个数据库读写权限的时候,都需要遍历一次 acl_dbs 数组,根据 user、host 和 db 找到匹配的对象,然后根据对象的权限位来判断。

也就是说,grant 修改 db 权限的时候,是同时对磁盘和内存生效的。

grant 操作对于已经存在的连接的影响,在全局权限和基于 db 的权限效果是不同的。接下来,我们做一个对照试验来分别看一下。

img

图 3 权限操作效果

需要说明的是,图中 set global sync_binlog 这个操作是需要 super 权限的。

可以看到,虽然用户 ua 的 super 权限在 T3 时刻已经通过 revoke 语句回收了,但是在 T4 时刻执行 set global 的时候,权限验证还是通过了。这是因为 super 是全局权限,这个权限信息在线程对象中,而 revoke 操作影响不到这个线程对象。

而在 T5 时刻去掉 ua 对 db1 库的所有权限后,在 T6 时刻 session B 再操作 db1 库的表,就会报错“权限不足”。这是因为 acl_dbs 是一个全局数组,所有线程判断 db 权限都用这个数组,这样 revoke 操作马上就会影响到 session B。

表权限和列权限

除了 db 级别的权限外,MySQL 支持更细粒度的表权限和列权限。其中,表权限定义存放在表 mysql.tables_priv 中,列权限定义存放在表 mysql.columns_priv 中。这两类权限,组合起来存放在内存的 hash 结构 column_priv_hash 中。

这两类权限的赋权命令如下:

create table db1.t1(id int, a int); 
grant all privileges on db1.t1 to 'ua'@'%' with grant option;
GRANT SELECT(id), INSERT (id,a) ON mydb.mytbl TO 'ua'@'%' with grant option;

跟 db 权限类似,这两个权限每次 grant 的时候都会修改数据表,也会同步修改内存中的 hash 结构。因此,对这两类权限的操作,也会马上影响到已经存在的连接。

看到这里,你一定会问,看来 grant 语句都是即时生效的,那这么看应该就不需要执行 flush privileges 语句了呀。

答案也确实是这样的。

flush privileges 命令会清空 acl_users 数组,然后从 mysql.user 表中读取数据重新加载,重新构造一个 acl_users 数组。也就是说,以数据表中的数据为准,会将全局权限内存数组重新加载一遍。

同样地,对于 db 权限、表权限和列权限,MySQL 也做了这样的处理。

也就是说,如果内存的权限数据和磁盘数据表相同的话,不需要执行 flush privileges。而如果我们都是用 grant/revoke 语句来执行的话,内存和数据表本来就是保持同步更新的。

因此,正常情况下,grant 命令之后,没有必要跟着执行 flush privileges 命令。

flush privileges 使用场景

那么,flush privileges 是在什么时候使用呢?显然,当数据表中的权限数据跟内存中的权限数据不一致的时候,flush privileges 语句可以用来重建内存数据,达到一致状态。

这种不一致往往是由不规范的操作导致的,比如直接用 DML 语句操作系统权限表。我们来看一下下面这个场景:

img

图 4 使用 flush privileges

可以看到,T3 时刻虽然已经用 delete 语句删除了用户 ua,但是在 T4 时刻,仍然可以用 ua 连接成功。原因就是,这时候内存中 acl_users 数组中还有这个用户,因此系统判断时认为用户还正常存在。

在 T5 时刻执行过 flush 命令后,内存更新,T6 时刻再要用 ua 来登录的话,就会报错“无法访问”了。

直接操作系统表是不规范的操作,这个不一致状态也会导致一些更“诡异”的现象发生。比如,前面这个通过 delete 语句删除用户的例子,就会出现下面的情况:

img

图 5 不规范权限操作导致的异常

可以看到,由于在 T3 时刻直接删除了数据表的记录,而内存的数据还存在。这就导致了:

  1. T4 时刻给用户 ua 赋权限失败,因为 mysql.user 表中找不到这行记录;
  2. 而 T5 时刻要重新创建这个用户也不行,因为在做内存判断的时候,会认为这个用户还存在。

小结

今天这篇文章,我和你介绍了 MySQL 用户权限在数据表和内存中的存在形式,以及 grant 和 revoke 命令的执行逻辑。

grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用 grant 和 revoke 语句,是不需要随后加上 flush privileges 语句的。

flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以我们尽量不要使用这类语句。

另外,在使用 grant 语句赋权时,你可能还会看到这样的写法:

grant super on *.* to 'ua'@'%' identified by 'pa';

这条命令加了 identified by ‘密码’, 语句的逻辑里面除了赋权外,还包含了:

  1. 如果用户’ua’@’%‘不存在,就创建这个用户,密码是 pa;
  2. 如果用户 ua 已经存在,就将密码修改成 pa。

这也是一种不建议的写法,因为这种写法很容易就会不慎把密码给改了。

“grant 之后随手加 flush privileges”,我自己是这么使用了两三年之后,在看代码的时候才发现其实并不需要这样做,那已经是 2011 年的事情了。

去年我看到一位小伙伴这么操作的时候,指出这个问题时,他也觉得很神奇。因为,他和我一样看的第一份文档就是这么写的,自己也一直是这么用的。

所以,今天的课后问题是,请你也来说一说,在使用数据库或者写代码的过程中,有没有遇到过类似的场景:误用了很长时间以后,由于一个契机发现“啊,原来我错了这么久”?

你可以把你的经历写在留言区,我会在下一篇文章的末尾选取有趣的评论和你分享。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

上期问题时间

上期的问题是,MySQL 解析 statement 格式的 binlog 的时候,对于 load data 命令,解析出来为什么用的是 load data local。

这样做的一个原因是,为了确保备库应用 binlog 正常。因为备库可能配置了 secure_file_priv=null,所以如果不用 local 的话,可能会导入失败,造成主备同步延迟。

另一种应用场景是使用 mysqlbinlog 工具解析 binlog 文件,并应用到目标库的情况。你可以使用下面这条命令 :

mysqlbinlog $binlog_file | mysql -h$host -P$port -u$user -p$pwd

把日志直接解析出来发给目标库执行。增加 local,就能让这个方法支持非本地的 $host。

评论区留言点赞板:

@poppy 、@库淘淘 两位同学提到了第一个场景; @王显伟 @lionetes 两位同学帮忙回答了 @undifined 同学的疑问,拷贝出来的文件要确保 MySQL 进程可以读。

直播回顾 林晓斌:我的 MySQL 心路历程

在专栏上线后的 11 月 21 日,我来到极客时间做了一场直播,主题就是“我的 MySQL 心路历程”。今天,我特意将这个直播的回顾文章,放在了专栏下面,希望你可以从我这些年和 MySQL 打交道的经历中,找到对你有所帮助的点。

这里,我先和你说一下,在这个直播中,我主要分享的内容:

  1. 我和 MySQL 打交道的经历;
  2. 你为什么要了解数据库原理;
  3. 我建议的 MySQL 学习路径;
  4. DBA 的修炼之道。

我的经历

以丰富的经历进入百度

我是福州大学毕业的,据我了解,那时候我们学校的应届生很难直接进入百度,都要考到浙江大学读个研究生才行。没想到的是,我投递了简历后居然进了面试。

入职以后,我跑去问当时的面试官,为什么我的简历可以通过筛选?他们说:“因为你的简历厚啊”。我在读书的时候,确实做了很多项目,也实习过不少公司,所以简历里面的经历就显得很丰富了。

在面试的时候,有个让我印象很深刻的事儿。面试官问我说,你有这么多实习经历,有没有什么比较好玩儿的事?我想了想答道,跟你说个数据量很大的事儿 ,在跟移动做日志分析的时候我碰到了几千万行的数据。他听完以后就笑了。

后来,我进了百度才知道,几千万行那都是小数据。

开始尝试看源码解决问题

加入百度后,我是在贴吧做后端程序,比如权限系统等等。其实很简单,就是写一个 C 语言程序,响应客户端请求,然后返回结果。

那个时候,我还仅仅是个 MySQL 的普通用户,使用了一段时间后就出现问题了:一个跑得很快的请求,偶尔会又跑得非常慢。老板问这是什么原因,而我又不好意思说不知道,于是就自己上网查资料。

但是,2008 年那会儿,网上资料很少,花了挺长时间也没查出个所以然。最终,我只好去看源码。翻到源码,我当时就觉得它还蛮有意思的。而且,源码真的可以帮我解决一些问题。

于是一发不可收拾,我从那时候就入了源码的“坑”。

混社区分享经验

2010 年的时候,阿里正好在招数据库的开发人员。虽然那时我还只是看得懂源码,没有什么开发经验,但还是抱着试试看的态度投了简历。然后顺利通过了面试,成功进入了阿里。之后,我就跟着褚霸(霸爷)干了 7 年多才离开了阿里。

在百度的时候,我基本上没有参加过社区活动。因为那时候百度可能更提倡内部分享,解决问题的经验基本上都是在内网分享。所以,去了阿里以后,我才建了博客、开了微博。我在阿里的花名叫丁奇,博客、微博、社区也因此都是用的这个名字。

为什么要了解数据库原理?

这里,我讲几个亲身经历的事情,和你聊聊为什么要了解数据库原理。

了解原理能帮你更好地定位问题

一次同学聚会,大家谈起了技术问题。一个在政府里的同学说,他们的系统很奇怪,每天早上都得重启一下应用程序,否则就提示连接数据库失败,他们都不知道该怎么办。

我分析说,按照这个错误提示,应该就是连接时间过长了,断开了连接。数据库默认的超时时间是 8 小时,而你们平时六点下班,下班之后系统就没有人用了,等到第二天早上九点甚至十点才上班,这中间的时间已经超过 10 个小时了,数据库的连接肯定就会断开了。

我当时说,估计这个系统程序写得比较差,连接失败也不会重连,仍然用原来断掉的连接,所以就报错了。然后,我让他回去把超时时间改得长一点。后来他跟我说,按照这个方法,问题已经解决了。

由此,我也更深刻地体会到,作为开发人员,即使我们只知道每个参数的意思,可能就可以给出一些问题的正确应对方法。

了解原理能让你更巧妙地解决问题

我在做贴吧系统的时候,每次访问页面都要请求一次权限。所以,这个请求权限的请求,访问概率会非常高,不可能每次都去数据库里查,怎么办呢?

我想了个简单的方案:在应用程序里面开了个很大的内存,启动的时候就把整张表全部 load 到内存里去。这样再有权限请求的时候,直接从内存里取就行了。

数据库重启时,我的进程也会跟着重启,接下来就会到数据表里面做全表扫描,把整个用户相关信息全部塞到内存里面去。

但是,后来我遇到了一个很郁闷的情况。有时候 MySQL 崩溃了,我的程序重新加载权限到内存里,结果这个 select 语句要执行 30 分钟左右。本来 MySQL 正常重启一下是很快的,进程重启也很快,正常加载权限的过程只需要两分钟就跑完了。但是,为什么异常重启的时候就要 30 分钟呢?

我没辙了,只好去看源码。然后,我发现 MySQL 有个机制,当它觉得系统空闲时会尽量去刷脏页。

具体到我们的例子里,MySQL 重启以后,会执行我的进程做全表扫描,但是因为这个时候权限数据还没有初始化完成,我的 Server 层不可能提供服务,于是 MySQL 里面就只有我那一个 select 全表扫描的请求,MySQL 就认为现在很闲,开始拼命地刷脏页,结果就吃掉了大量的磁盘资源,导致我的全表扫描也跑得很慢。

知道了这个机制以后,我就写了个脚本,每隔 0.5 秒发一个请求,执行一个简单的 SQL 查询,告诉数据库其实我现在很忙,脏页刷得慢一点。

脚本一发布使用,脏页果然刷得慢了,加载权限的扫描也跑得很快了。据说我离职两年多以后,这个脚本还在用。

你看,如果我们懂得一些参数,并可以理解这些参数,就可以做正确的设置了。而如果我们进一步地懂得一些原理,就可以更巧妙地解决问题了。

看得懂源码让你有更多的方法

2012 年的时候,阿里双十一业务的压力比较大。当时还没有这么多的 SSD,是机械硬盘的时代。

为了应对压力我们开始引入 SSD,但是不敢把 SSD 直接当存储用,而是作为二级缓存。当时,我们用了一个叫作 Flashcache 的开源系统(现在已经是老古董级别了,不知道你有没有听过这个系统)。

Flashcache 实现,把 SSD 当作物理盘的二级缓存,可以提升性能。但是,我们自己部署后发现性能提升的效果没有预想的那么好,甚至还不如纯机械盘。

于是,我跟霸爷就开始研究。霸爷负责分析 Flashcache 的源码,我负责分析 MySQL 源码。后来我们发现 Flashcache 是有脏页比例的,当脏页比例到了 80% 就会停下来强行刷盘。

一开始我们以为这个脏页比例是全部的 20%,看了源码才知道,原来它分了很多个桶,比如说一个桶 20M,这个桶如果用完 80%,它就认为脏页满了,就开始刷脏页。这也就意味着,如果你是顺序写的话,很容易就会把一个桶写满。

知道了这个原理以后,我就把日志之类顺序写的数据全都放到了机械硬盘,把随机写的数据放到了 Flashcache 上。这样修改以后,效果就好了。

你看,如果能看得懂源码,你的操作行为就会不一样。

MySQL 学习路径

说到 MySQL 的学习路径,其实我上面分享的这些内容,都可以归结为学习路径。

首先你要会用,要去了解每个参数的意义,这样你的运维行为(使用行为)就会不一样。千万不要从网上拿了一些使用建议,别人怎么用,你就怎么用,而不去想为什么。再往后,就要去了解每个参数的实现原理。一旦你了解了这些原理,你的操作行为就会不一样。 再进一步,如果看得懂源码,那么你对数据库的理解也会不一样。

再来讲讲我是怎么带应届生的。实践是很好的学习方式,所以我会让新人来了以后先搭主备,然后你就会发现每个人的自学能力都不一样。比如遇到有延迟,或者我们故意构造一个主备数据不一致的场景,让新人了解怎么分析问题,解决问题。

如果一定要总结出一条学习路径的话,那首先要会,然后可以发现问题

在专栏里面,我在每篇文章末尾,都会提出一个常见问题,作为思考题。这些问题都不会很难,是跟专栏文章挂钩、又是会经常遇到的,但又无法直接从文章里拿到答案。

我的建议是,你可以尝试先不看答案自己去思考,或者去数据库里面翻一翻,这将会是一个不错的过程。

再下一步就是实践。之后当你觉得开始有一些“线”的概念了,再去看 MySQL 的官方手册。在我的专栏里,有人曾问我要不要直接去看手册?

我的建议是,一开始千万不要着急看手册,这里面有 100 多万个英文单词,你就算再厉害,也是看了后面忘了前面。所以,你一定要自己先有脉络,然后有一个知识网络,再看手册去查漏补缺。

我自己就是这么一路走过来的。

另外,在专栏的留言区,很多用户都希望我能推荐一本书搭配专栏学习。如果只推荐一本的话,我建议你读一下《高性能 MySQL》这本书,它是 MySQL 这个领域的经典图书,已经出到第三版了,你可以想象一下它的流行度。

这本书的其中两位译者(彭立勋、翟卫祥)是我原团队的小伙伴,有着非常丰富的 MySQL 源码开发经验,他们对 MySQL 的深刻理解,让这本书保持了跟原作英文版同样高的质量。

极客时间的编辑说,他们已经和出版社沟通,为我们专栏的用户争取到了全网最低价,仅限 3 天,你可以直接点击链接购买。img

DBA 的修炼

DBA 和开发工程师有什么相同点?

我带过开发团队,也带过 DBA 团队,所以可以分享一下这两个岗位的交集。

其实,DBA 本身要有些开发底子,比如说做运维系统的开发。另外,自动化程度越高,DBA 的日常运维工作量就越少,DBA 得去了解开发业务逻辑,往业务架构师这个方向去做。

开发工程师也是一样,不能所有的问题都指望 DBA 来解决。因为,DBA 在每个公司都是很少的几个人。所以,开发也需要对数据库原理有一定的了解,这样向 DBA 请教问题时才能更专业,更高效地解决问题。

所以说,这两个岗位应该有一定程度的融合,即:开发要了解数据库原理,DBA 要了解业务和开发。

DBA 有前途吗?

这里我要强调的是,每个岗位都有前途,只需要根据时代变迁稍微调整一下方向。

像原来开玩笑说 DBA 要体力好,因为得搬服务器。后来 DBA 的核心技能成了会搭库、会主备切换,但是现在这些也不够用了,因为已经有了自动化系统。

所以,DBA 接下来一方面是要了解业务,做业务的架构师;另一方面,是要有前瞻性,做主动诊断系统,把每个业务的问题挑出来做成月报,让业务开发去优化,有不清楚的地方,开发同学会来找你咨询。你帮助他们做好了优化之后,可以把优化的指标呈现出来。这将很好地体现出你对于公司的价值。

有哪些比较好的习惯和提高 SQL 效率的方法?

这个方法,总结起来就是:要多写 SQL,培养自己对 SQL 语句执行效率的感觉。以后再写或者建索引的时候,知道这个语句执行下去大概的时间复杂度,是全表扫描还是索引扫描、是不是需要回表,在心里都有一个大概的概念。

这样每次写出来的 SQL 都会快一点,而且不容易犯低级错误。这也正式我开设这个专栏的目标。

看源码需要什么技术?

看源码的话,一是要掌握 C 和 C++;另外还要熟悉一些调试工具。因为代码是静态的,运行起来是动态的,看代码是单线程的,运行起来是多线程的,所以要会调试。

另外,我不建议你用可视化的工具。虽然可视化工具很方便,但你不知道这个操作点下去以后,实际上做了什么,所以我建议你自己手写代码和 SQL 语句,这样对一些底层原理你会更明白。

怎么学习 C、C++?

我在读研究生的时候,在 C 和 C++ 语言的学习上进步最大。

那时,我去给专科上 C 和 C++ 的课。我觉得自己已经会了,完全可以教得了。但去了之后,我才知道,自己会跟能够教别人完全是两码事儿。备课的时候,你不能只讲会用的部分,还得把原理讲清楚。这样,就会倒逼自己进行更深入更全面的学习。

有的人看完技术博客和专栏,会把这篇文章的提纲列一下,写写自己的问题和对这篇文章的理解。这个过程,是非常利于学习的。因为你听进来是一回事儿,讲出去则是另一回事儿。

学数据库要保持什么心态?

不只是数据库,所有多线程的服务,调试和追查问题的过程都是很枯燥的,遇到问题都会很麻烦。但是,你找出问题时的那一下会很爽。

我觉得你得找到这种感觉,它可以支持你度过接下来要枯燥很久的那段时光,这样你才能继续坚持下去。

当然,如果有更快乐的学习过程还是更好的,希望这个专栏能让你学习得轻松些。