This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,173 @@
<audio id="audio" title="01 | 基础架构一条SQL查询语句是如何执行的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9d/0a/9d235bdc1e75fed6877b3da98190220a.mp3"></audio>
你好,我是林晓斌。
这是专栏的第一篇文章我想来跟你聊聊MySQL的基础架构。我们经常说看一个事儿千万不要直接陷入细节里你应该先鸟瞰其全貌这样能够帮助你从高维度理解问题。同样对于MySQL的学习也是这样。平时我们使用数据库看到的通常都是一个整体。比如你有个最简单的表表里只有一个ID字段在执行下面这个查询语句时
```
mysql&gt; select * from T where ID=10
```
我们看到的只是输入一条语句返回一个结果却不知道这条语句在MySQL内部的执行过程。
所以今天我想和你一起把MySQL拆解一下看看里面都有哪些“零件”希望借由这个拆解过程让你对MySQL有更深入的理解。这样当我们碰到MySQL的一些异常或者问题时就能够直戳本质更为快速地定位并解决问题。
下面我给出的是MySQL的基本架构示意图从中你可以清楚地看到SQL语句在MySQL的各个功能模块中的执行过程。
<img src="https://static001.geekbang.org/resource/image/0d/d9/0d2070e8f84c4801adbfa03bda1f98d9.png" alt="">
大体来说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 src="https://static001.geekbang.org/resource/image/f2/ed/f2da4aa3a672d48ec05df97b9f992fed.png" alt=""><br>
客户端如果太长时间没动静连接器就会自动将它断开。这个时间是由参数wait_timeout控制的默认值是8小时。
如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续就需要重连然后再执行请求了。
数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。
建立连接的过程通常是比较复杂的,所以我建议你在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。
但是全部使用长连接后你可能会发现有些时候MySQL占用内存涨得特别快这是因为MySQL在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来可能导致内存占用太大被系统强行杀掉OOM从现象看就是MySQL异常重启了。
怎么解决这个问题呢?你可以考虑以下两种方案。
<li>
定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
</li>
<li>
如果你用的是MySQL 5.7或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection来重新初始化连接资源。这个过程不需要重连和重新做权限验证但是会将连接恢复到刚刚创建完时的状态。
</li>
# 查询缓存
连接建立完成后你就可以执行select语句了。执行逻辑就会来到第二步查询缓存。
MySQL拿到一个查询请求后会先到查询缓存看看之前是不是执行过这条语句。之前执行过的语句及其结果可能会以key-value对的形式被直接缓存在内存中。key是查询的语句value是查询的结果。如果你的查询能够直接在这个缓存中找到key那么这个value就会被直接返回给客户端。
如果语句不在查询缓存中就会继续后面的执行阶段。执行完成后执行结果会被存入查询缓存中。你可以看到如果查询命中缓存MySQL不需要执行后面的复杂操作就可以直接返回结果这个效率会很高。
**但是大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利。**
查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。
好在MySQL也提供了这种“按需使用”的方式。你可以将参数query_cache_type设置成DEMAND这样对于默认的SQL语句都不使用查询缓存。而对于你确定要使用查询缓存的语句可以用SQL_CACHE显式指定像下面这个语句一样
```
mysql&gt; 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&gt; 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&gt; 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&gt; select * from T where ID=10;
ERROR 1142 (42000): SELECT command denied to user 'b'@'localhost' for table 'T'
```
如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。
比如我们这个例子中的表T中ID字段没有索引那么执行器的执行流程是这样的
<li>
调用InnoDB引擎接口取这个表的第一行判断ID值是不是10如果不是则跳过如果是则将这行存在结果集中
</li>
<li>
调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
</li>
<li>
执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
</li>
至此,这个语句就执行完成了。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。
你会在数据库的慢查询日志中看到一个rows_examined的字段表示这个语句执行过程中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。
在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此**引擎扫描行数跟rows_examined并不是完全相同的。**我们后面会专门有一篇文章来讲存储引擎的内部机制,里面会有详细的说明。
# 小结
今天我给你介绍了MySQL的逻辑架构希望你对一个SQL语句完整执行流程的各个阶段有了一个初步的印象。由于篇幅的限制我只是用一个查询的例子将各个环节过了一遍。如果你还对每个环节的展开细节存有疑问也不用担心后续在实战章节中我还会再提到它们。
我给你留一个问题吧如果表T中没有字段k而你执行了这个语句 select * from T where k=1, 那肯定是会报“不存在这个列”的错误: “Unknown column k in where clause”。你觉得这个错误是在我们上面提到的哪个阶段报出来的呢
感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,164 @@
<audio id="audio" title="02 | 日志系统一条SQL更新语句是如何执行的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d7/a9/d717e876e054d27aeade4fc8f4f9fea9.mp3"></audio>
前面我们系统了解了一个查询语句的执行流程,并介绍了执行过程中涉及的处理模块。相信你还记得,一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎。
那么,一条更新语句的执行流程又是怎样的呢?
之前你可能经常听DBA同事说MySQL可以恢复到半个月内任意一秒的状态惊叹的同时你是不是心中也会不免会好奇这是怎样做到的呢
我们还是从一个表的一条更新语句说起下面是这个表的创建语句这个表有一个主键ID和一个整型字段c
```
mysql&gt; create table T(ID int primary key, c int);
```
如果要将ID=2这一行的值加1SQL语句就会这么写
```
mysql&gt; update T set c=c+1 where ID=2;
```
前面我有跟你介绍过SQL语句基本的执行链路这里我再把那张图拿过来你也可以先简单看看这个图回顾下。首先可以确定的说查询语句的那一套流程更新语句也是同样会走一遍。
<img src="https://static001.geekbang.org/resource/image/0d/d9/0d2070e8f84c4801adbfa03bda1f98d9.png" alt="">
你执行语句前要先连接数据库,这是连接器的工作。
前面我们说过在一个表上有更新的时候跟这个表有关的查询缓存会失效所以这条语句就会把表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 src="https://static001.geekbang.org/resource/image/16/a7/16a7950217b3f0f4ed02db5db59562a7.png" alt="">
write pos是当前记录的位置一边写一边后移写到第3号文件末尾后就回到0号文件开头。checkpoint是当前要擦除的位置也是往后推移并且循环的擦除记录前要把记录更新到数据文件。
write pos和checkpoint之间的是“粉板”上还空着的部分可以用来记录新的操作。如果write pos追上checkpoint表示“粉板”满了这时候不能再执行新的更新得停下来先擦掉一些记录把checkpoint推进一下。
有了redo logInnoDB就可以保证即使数据库发生异常重启之前提交的记录都不会丢失这个能力称为**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能力。
这两种日志有以下三点不同。
<li>
redo log是InnoDB引擎特有的binlog是MySQL的Server层实现的所有引擎都可以使用。
</li>
<li>
redo log是物理日志记录的是“在某个数据页上做了什么修改”binlog是逻辑日志记录的是这个语句的原始逻辑比如“给ID=2这一行的c字段加1 ”。
</li>
<li>
redo log是循环写的空间固定会用完binlog是可以追加写入的。“追加写”是指binlog文件写到一定大小后会切换到下一个并不会覆盖以前的日志。
</li>
有了对这两个日志的概念性理解我们再来看执行器和InnoDB引擎在执行这个简单的update语句时的内部流程。
<li>
执行器先找引擎取ID=2这一行。ID是主键引擎直接用树搜索找到这一行。如果ID=2这一行所在的数据页本来就在内存中就直接返回给执行器否则需要先从磁盘读入内存然后再返回。
</li>
<li>
执行器拿到引擎给的行数据把这个值加上1比如原来是N现在就是N+1得到新的一行数据再调用引擎接口写入这行新数据。
</li>
<li>
引擎将这行新数据更新到内存中同时将这个更新操作记录到redo log里面此时redo log处于prepare状态。然后告知执行器执行完成了随时可以提交事务。
</li>
<li>
执行器生成这个操作的binlog并把binlog写入磁盘。
</li>
<li>
执行器调用引擎的提交事务接口引擎把刚刚写入的redo log改成提交commit状态更新完成。
</li>
这里我给出这个update语句的执行流程图图中浅色框表示是在InnoDB内部执行的深色框表示是在执行器中执行的。
<img src="https://static001.geekbang.org/resource/image/2e/be/2e5bff4910ec189fe1ee6e2ecc7b4bbe.png" alt="">
你可能注意到了最后三步看上去有点“绕”将redo log的写入拆成了两个步骤prepare和commit这就是"两阶段提交"。
# 两阶段提交
为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致。要说明这个问题,我们得从文章开头的那个问题说起:**怎样让数据库恢复到半个月内任意一秒的状态?**
前面我们说过了binlog会记录所有的逻辑操作并且是采用“追加写”的形式。如果你的DBA承诺说半个月内可以恢复那么备份系统中一定会保存最近半个月的所有binlog同时系统会定期做整库备份。这里的“定期”取决于系统的重要性可以是一天一备也可以是一周一备。
当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那你可以这么做:
- 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;
- 然后从备份的时间点开始将备份的binlog依次取出来重放到中午误删表之前的那个时刻。
这样你的临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。
好了,说完了数据恢复过程,我们回来说说,为什么日志需要“两阶段提交”。这里不妨用反证法来进行解释。
由于redo log和binlog是两个独立的逻辑如果不用两阶段提交要么就是先写完redo log再写binlog或者采用反过来的顺序。我们看看这两种方式会有什么问题。
仍然用前面的update语句来做例子。假设当前ID=2的行字段c的值是0再假设执行update语句过程中在写完第一个日志后第二个日志还没有写完期间发生了crash会出现什么情况呢
<li>
<p>**先写redo log后写binlog**。假设在redo log写完binlog还没有写完的时候MySQL进程异常重启。由于我们前面说过的redo log写完之后系统即使崩溃仍然能够把数据恢复回来所以恢复后这一行c的值是1。<br>
但是由于binlog没写完就crash了这时候binlog里面就没有记录这个语句。因此之后备份日志的时候存起来的binlog里面就没有这条语句。<br>
然后你会发现如果需要用这个binlog来恢复临时库的话由于这个语句的binlog丢失这个临时库就会少了这一次更新恢复出来的这一行c的值就是0与原库的值不同。</p>
</li>
<li>
**先写binlog后写redo log**。如果在binlog写完之后crash由于redo log还没写崩溃恢复以后这个事务无效所以这一行c的值是0。但是binlog里面已经记录了“把c从0改成1”这个日志。所以在之后用binlog来恢复的时候就多了一个事务出来恢复出来的这一行c的值就是1与原库的值不同。
</li>
可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。
你可能会说,这个概率是不是很低,平时也没有什么动不动就需要恢复临时库的场景呀?
其实不是的不只是误操作后需要用这个过程来恢复数据。当你需要扩容的时候也就是需要再多搭建一些备库来增加系统的读能力的时候现在常见的做法也是用全量备份加上应用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日志系统密切相关的“两阶段提交”。两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案即使你不做数据库内核开发日常开发中也有可能会用到。
文章的最后,我给你留一个思考题吧。前面我说到定期全量备份的周期“取决于系统重要性,有的是一天一备,有的是一周一备”。那么在什么场景下,一天一备会比一周一备更有优势呢?或者说,它影响了这个数据库系统的哪个指标?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的答案。
感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,140 @@
<audio id="audio" title="03 | 事务隔离:为什么你改了我还看不见?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/56/11/563a4dc0cd16f4ef1dc363838f41b011.mp3"></audio>
提到事务你肯定不陌生和数据库打交道的时候我们总是会用到事务。最经典的例子就是转账你要给朋友小王转100块钱而此时你的银行卡只有100块钱。
转账过程具体到程序里会有一系列的操作比如查询余额、做加减法、更新余额等这些操作必须保证是一体的不然等程序查完之后还没做减法之前你这100块钱完全可以借着这个时间差再查一次然后再给另外一个朋友转账如果银行这么整不就乱了么这时就要用到“事务”这个概念了。
简单来说事务就是要保证一组数据库操作要么全部成功要么全部失败。在MySQL中事务支持是在引擎层实现的。你现在知道MySQL是一个支持多引擎的系统但并不是所有的引擎都支持事务。比如MySQL原生的MyISAM引擎就不支持事务这也是MyISAM被InnoDB取代的重要原因之一。
今天的文章里我将会以InnoDB为例剖析MySQL在事务支持方面的特定实现并基于原理给出相应的实践建议希望这些案例能加深你对MySQL事务原理的理解。
# 隔离性与隔离级别
提到事务你肯定会想到ACIDAtomicity、Consistency、Isolation、Durability即原子性、一致性、隔离性、持久性今天我们就来说说其中I也就是“隔离性”。
当数据库上有多个事务同时执行的时候就可能出现脏读dirty read、不可重复读non-repeatable read、幻读phantom read的问题为了解决这些问题就有了“隔离级别”的概念。
在谈隔离级别之前你首先要知道你隔离得越严实效率就会越低。因此很多时候我们都要在二者之间寻找一个平衡点。SQL标准的事务隔离级别包括读未提交read uncommitted、读提交read committed、可重复读repeatable read和串行化serializable )。下面我逐一为你解释:
- 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
- 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
- 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
- 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
其中“读提交”和“可重复读”比较难理解所以我用一个例子说明这几种隔离级别。假设数据表T中只有一列其中一行的值为1下面是按照时间顺序执行两个事务的行为。
```
mysql&gt; create table T(c int) engine=InnoDB;
insert into T(c) values(1);
```
<img src="https://static001.geekbang.org/resource/image/7d/f8/7dea45932a6b722eb069d2264d0066f8.png" alt=""><br>
我们来看看在不同的隔离级别下事务A会有哪些不同的返回结果也就是图里面V1、V2、V3的返回值分别是什么。
- 若隔离级别是“读未提交”, 则V1的值就是2。这时候事务B虽然还没有提交但是结果已经被A看到了。因此V2、V3也都是2。
- 若隔离级别是“读提交”则V1是1V2的值是2。事务B的更新在提交后才能被A看到。所以 V3的值也是2。
- 若隔离级别是“可重复读”则V1、V2是1V3是2。之所以V2还是1遵循的就是这个要求事务在执行期间看到的数据前后必须是一致的。
- 若隔离级别是“串行化”则在事务B执行“将1改成2”的时候会被锁住。直到事务A提交后事务B才可以继续执行。所以从A的角度看 V1、V2值是1V3的值是2。
在实现上数据库里面会创建一个视图访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下这个视图是在事务启动时创建的整个事务存在期间都用这个视图。在“读提交”隔离级别下这个视图是在每个SQL语句开始执行的时候创建的。这里需要注意的是“读未提交”隔离级别下直接返回记录上的最新值没有视图概念而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
我们可以看到在不同的隔离级别下数据库行为是有所不同的。Oracle数据库的默认隔离级别其实就是“读提交”因此对于一些从Oracle迁移到MySQL的应用为保证数据库隔离级别的一致你一定要记得将MySQL的隔离级别设置为“读提交”。
配置的方式是将启动参数transaction-isolation的值设置成READ-COMMITTED。你可以用show variables来查看当前的值。
```
mysql&gt; show variables like 'transaction_isolation';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+
```
总结来说,存在即合理,每种隔离级别都有自己的使用场景,你要根据自己的业务情况来定。我想**你可能会问那什么时候需要“可重复读”的场景呢**?我们来看一个数据校对逻辑的案例。
假设你在管理一个个人银行账户表。一个表存了账户余额,一个表存了账单明细。到了月底你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。
这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。
# 事务隔离的实现
理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明“可重复读”。
在MySQL中实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值通过回滚操作都可以得到前一个状态的值。
假设一个值从1被按顺序改成了2、3、4在回滚日志里面就会有类似下面的记录。
<img src="https://static001.geekbang.org/resource/image/d9/ee/d9c313809e5ac148fc39feff532f0fee.png" alt=""><br>
当前值是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的事务启动方式有以下几种
<li>
显式启动事务语句, begin 或 start transaction。配套的提交语句是commit回滚语句是rollback。
</li>
<li>
set autocommit=0这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个select语句这个事务就启动了而且并不会自动提交。这个事务持续存在直到你主动执行commit 或 rollback 语句,或者断开连接。
</li>
有些客户端连接框架会默认连接成功后先执行一个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))&gt;60
```
# 小结
这篇文章里面我介绍了MySQL的事务隔离级别的现象和实现根据实现原理分析了长事务存在的风险以及如何用正确的方式避免长事务。希望我举的例子能够帮助你理解事务并更好地使用MySQL的事务特性。
我给你留一个问题吧。你现在知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,你会有什么方案来避免出现或者处理这种情况呢?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
在上期文章的最后,我给你留下的问题是一天一备跟一周一备的对比。
好处是“最长恢复时间”更短。
在一天一备的模式里最坏情况下需要应用一天的binlog。比如你每天0点做一次全量备份而要恢复出一个到昨天晚上23点的备份。
一周一备最坏情况就要应用一周的binlog了。
系统的对应指标就是 @尼古拉斯·赵四 @慕塔 提到的RTO恢复目标时间
当然这个是有成本的因为更频繁全量备份需要消耗更多存储空间所以这个RTO是成本换来的就需要你根据业务重要性来评估了。
同时也感谢 @super blue cat、@高枕@Jason 留下了高质量的评论。

View File

@@ -0,0 +1,213 @@
<audio id="audio" title="04 | 深入浅出索引(上)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ed/e2/ed975a8ca56e20664145aa15602c76e2.mp3"></audio>
提到数据库索引我想你并不陌生在日常工作中会经常接触到。比如某一个SQL查询比较慢分析完原因之后你可能就会说“给某个字段加个索引吧”之类的解决方案。但到底什么是索引索引又是如何工作的呢今天就让我们一起来聊聊这个话题吧。
数据库索引的内容比较多,我分成了上下两篇文章。索引是数据库系统里面最重要的概念之一,所以我希望你能够耐心看完。在后面的实战文章中,我也会经常引用这两篇文章中提到的知识点,加深你对数据库索引的理解。
一句话简单来说索引的出现其实就是为了提高数据查询的效率就像书的目录一样。一本500页的书如果你想快速找到其中的某一个知识点在不借助目录的情况下那我估计你可得找一会儿。同样对于数据库的表而言索引其实就是它的“目录”。
# 索引的常见模型
索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。可以用于提高读写效率的数据结构很多,这里我先给你介绍三种常见、也比较简单的数据结构,它们分别是哈希表、有序数组和搜索树。
下面我主要从使用的角度,为你简单分析一下这三种模型的区别。
哈希表是一种以键-值key-value存储数据的结构我们只要输入待查找的键即key就可以找到其对应的值即Value。哈希的思路很简单把值放在数组里用一个哈希函数把key换算成一个确定的位置然后把value放在数组的这个位置。
不可避免地多个key值经过哈希函数的换算会出现同一个值的情况。处理这种情况的一种方法是拉出一个链表。
假设,你现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示:
<img src="https://static001.geekbang.org/resource/image/0c/57/0c62b601afda86fe5d0fe57346ace957.png" alt="">
图中User2和User4根据身份证号算出来的值都是N但没关系后面还跟了一个链表。假设这时候你要查ID_card_n2对应的名字是什么处理步骤就是首先将ID_card_n2通过哈希函数算出N然后按顺序遍历找到User2。
需要注意的是图中四个ID_card_n的值并不是递增的这样做的好处是增加新的User时速度会很快只需要往后追加。但缺点是因为不是有序的所以哈希索引做区间查询的速度是很慢的。
你可以设想下,如果你现在要找身份证号在[ID_card_X, ID_card_Y]这个区间的所有用户,就必须全部扫描一遍了。
所以,**哈希表这种结构适用于只有等值查询的场景**比如Memcached及其他一些NoSQL引擎。
而**有序数组在等值查询和范围查询场景中的性能就都非常优秀**。还是上面这个根据身份证号查名字的例子,如果我们使用有序数组来实现的话,示意图如下所示:
<img src="https://static001.geekbang.org/resource/image/bf/49/bfc907a92f99cadf5493cf0afac9ca49.png" alt="">
这里我们假设身份证号没有重复这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查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 src="https://static001.geekbang.org/resource/image/04/68/04fb9d24065635a6a637c25ba9ddde68.png" alt="">
二叉搜索树的特点是父节点左子树所有结点的值小于父节点的值右子树所有结点的值大于父节点的值。这样如果你要查ID_card_n2的话按照图中的搜索顺序就是按照UserA -&gt; UserC -&gt; UserF -&gt; 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&gt; 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 src="https://static001.geekbang.org/resource/image/dc/8d/dcda101051f28502bd5c4402b292e38d.png" alt="">
从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。
主键索引的叶子节点存的是整行数据。在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个字节。
**显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。**
所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。
有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:
<li>
只有一个索引;
</li>
<li>
该索引必须是唯一索引。
</li>
你一定看出来了这就是典型的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);
```
我的问题是,对于上面这两个重建索引的作法,说出你的理解。如果有不合适的,为什么,更好的方法是什么?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上一篇文章末尾给你留下的问题是:如何避免长事务对业务的影响?
这个问题,我们可以从应用开发端和数据库端来看。
**首先,从应用开发端来看:**
<li>
确认是否使用了set autocommit=0。这个确认工作可以在测试环境中开展把MySQL的general_log开起来然后随便跑一个业务逻辑通过general_log的日志来确认。一般框架如果会设置这个值也就会提供参数来控制行为你的目标就是把它改成1。
</li>
<li>
确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用begin/commit框起来。我见过有些是业务并没有这个需要但是也把好几个select语句放到了事务中。这种只读事务可以去掉。
</li>
<li>
业务连接数据库的时候根据业务本身的预估通过SET MAX_EXECUTION_TIME命令来控制每个语句执行的最长时间避免单个语句意外执行太长时间。为什么会意外在后续的文章中会提到这类案例
</li>
**其次,从数据库端来看:**
<li>
监控 information_schema.Innodb_trx表设置长事务阈值超过就报警/或者kill
</li>
<li>
Percona的pt-kill这个工具不错推荐使用
</li>
<li>
在业务功能测试阶段要求输出所有的general_log分析日志行为提前发现问题
</li>
<li>
如果使用的是MySQL 5.6或者更新版本把innodb_undo_tablespaces设置成2或更大的值。如果真的出现大事务导致回滚段过大这样设置后清理起来更方便。
</li>
感谢 @壹笙☞漂泊 @王凯 @易翔 留下的高质量评论。

View File

@@ -0,0 +1,194 @@
<audio id="audio" title="05 | 深入浅出索引(下)" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/2e/6f1813eeaabf1355f29404569e9ae92e.mp3"></audio>
在上一篇文章中我和你介绍了InnoDB索引的数据结构模型今天我们再继续聊聊跟MySQL索引有关的概念。
在开始这篇文章之前,我们先来看一下这个问题:
在下面这个表T中如果我执行 select * from T where k between 3 and 5需要执行几次树的搜索操作会扫描多少行
下面是这个表的初始化语句。
```
mysql&gt; 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 src="https://static001.geekbang.org/resource/image/dc/8d/dcda101051f28502bd5c4402b292e38d.png" alt="">
现在我们一起来看看这条SQL查询语句的执行流程
<li>
在k索引树上找到k=3的记录取得 ID = 300
</li>
<li>
再到ID索引树查到ID=300对应的R3
</li>
<li>
在k索引树取下一个值k=5取得ID=500
</li>
<li>
再回到ID索引树查到ID=500对应的R4
</li>
<li>
在k索引树取下一个值k=6不满足条件循环结束。
</li>
在这个过程中,**回到主键索引树搜索的过程,我们称为回表**。可以看到这个查询过程读了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+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。**
为了直观地说明这个概念我们用nameage这个联合索引来分析。
<img src="https://static001.geekbang.org/resource/image/89/70/89f74c631110cfbc83298ef27dcd6370.jpg" alt="">
可以看到,索引项是按照索引定义里面出现的字段顺序排序的。
当你的逻辑需求是查到所有名字是“张三”的人时可以快速定位到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&gt; 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 src="https://static001.geekbang.org/resource/image/b3/ac/b32aa8b1f75611e0759e52f5915539ac.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/76/1b/76e385f3df5a694cc4238c7b65acfe1b.jpg" alt="">
在图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相关问题难住的经历也可以把这个问题发到评论区我们一起来讨论。如果解答这个问题需要的篇幅会很长的话我可以放到答疑文章展开。

View File

@@ -0,0 +1,182 @@
<audio id="audio" title="06 | 全局锁和表锁 :给表加个字段怎么有这么多阻碍?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/51/94/51b224d44acff677d7484f9066fbaa94.mp3"></audio>
今天我要跟你聊聊MySQL的锁。数据库锁设计的初衷是处理并发问题。作为多用户共享的资源当出现并发访问的时候数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访问规则的重要数据结构。
**根据加锁的范围MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类**。今天这篇文章,我会和你分享全局锁和表级锁。而关于行锁的内容,我会留着在下一篇文章中再和你详细介绍。
这里需要说明的是,锁的设计比较复杂,这两篇文章不会涉及锁的具体实现细节,主要介绍的是碰到锁时的现象和其背后的原理。
# 全局锁
顾名思义全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。
**全局锁的典型使用场景是,做全库逻辑备份。**也就是把整库每个表都select出来存成文本。
以前有一种做法是通过FTWRL确保不会有其他线程对数据库做更新然后对整个库做备份。注意在备份过程中整个库完全处于只读状态。
但是让整库都只读,听上去就很危险:
- 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
- 如果你在从库上备份那么备份期间从库不能执行主库同步过来的binlog会导致主从延迟。
看来加全局锁不太好。但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问题。
假设你现在要维护“极客时间”的购买系统,关注的是用户账户余额表和用户课程表。
现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。
如果时间顺序上是先备份账户余额表(u_account),然后用户购买,然后备份用户课程表(u_course),会怎么样呢?你可以看一下这个图:
<img src="https://static001.geekbang.org/resource/image/cb/cd/cbfd4a0bbb1210792064bcea4e49b0cd.png" alt="">
可以看到这个备份结果里用户A的数据状态是“账户余额没扣但是用户课程表里面已经多了一门课”。如果后面用这个备份来恢复数据的话用户A就发现自己赚了。
作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,又可能会出现什么结果?
也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致的。
说到视图你肯定想起来了,我们在前面讲事务隔离的时候,其实是有一个方法能够拿到一致性视图的,对吧?
是的,就是在可重复读隔离级别下开启一个事务。
>
备注如果你对事务隔离级别的概念不是很清晰的话可以再回顾一下第3篇文章[《事务隔离:为什么你改了我还看不见?》](https://time.geekbang.org/column/article/68963)中的相关内容。
官方自带的逻辑备份工具是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 lockMDL)。
**表锁的语法是 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命令来控制并发毕竟锁住整个表的影响面还是太大。
**另一类表级的锁是MDLmetadata lock)。**MDL不需要显式使用在访问一个表的时候会被自动加上。MDL的作用是保证读写的正确性。你可以想象一下如果一个查询正在遍历一个表中的数据而执行期间另一个线程对这个表结构做变更删了一列那么查询线程拿到的结果跟表结构对不上肯定是不行的。
因此在MySQL 5.5版本中引入了MDL当对一个表做增删改查操作的时候加MDL读锁当要对表做结构变更操作的时候加MDL写锁。
<li>
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
</li>
<li>
读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
</li>
虽然MDL锁是系统默认会加的但却是你不能忽略的一个机制。比如下面这个例子我经常看到有人掉到这个坑里给一个小表加个字段导致整个库挂了。
你肯定知道给一个表加字段或者修改字段或者加索引需要扫描全表的数据。在对大表操作的时候你肯定会特别小心以免对线上服务造成影响。而实际上即使是小表操作不慎也会出问题。我们来看一下下面的操作序列假设表t是一个小表。
>
备注这里的实验环境是MySQL 5.6。
<img src="https://static001.geekbang.org/resource/image/7c/ce/7cf6a3bf90d72d1f0fc156ececdfb0ce.jpg" alt="">
我们可以看到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索引组织表的理解。
我直接贴@老杨同志 的回复略作修改如下(我修改的部分用橙色标出):
表记录<br>
a--|b--|c--|d--<br>
1 2 3 d<br>
1 3 2 d<br>
1 4 3 d<br>
2 1 3 d<br>
2 2 2 d<br>
2 3 4 d<br>
主键 ab的聚簇索引组织顺序相当于 order by a,b 也就是先按a排序再按b排序c无序。
索引 ca 的组织是先按c排序再按a排序同时记录主键<br>
c--|a--|主键部分b-- 注意这里不是ab而是只有b<br>
2 1 3<br>
2 2 2<br>
3 1 2<br>
3 1 4<br>
3 2 1<br>
4 2 3<br>
这个跟索引c的数据是一模一样的。
索引 cb 的组织是先按c排序在按b排序同时记录主键<br>
c--|b--|主键部分a-- (同上)<br>
2 2 2<br>
2 3 1<br>
3 1 2<br>
3 2 1<br>
3 4 1<br>
4 3 2
所以结论是ca可以去掉cb需要保留。
评论区留言点赞:
>
<p>@浪里白条 帮大家总结了复习要点;<br>
@约书亚 的问题里提到了MRR优化<br>
@HwangZHen 留言言简意赅。</p>

View File

@@ -0,0 +1,159 @@
<audio id="audio" title="07 | 行锁功过:怎么减少行锁对性能的影响?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e5/a6/e5b9cbc8e6b42d4a786d67a1f9815ca6.mp3"></audio>
在上一篇文章中我跟你介绍了MySQL的全局锁和表级锁今天我们就来讲讲MySQL的行锁。
MySQL的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁比如MyISAM引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁对于这种引擎的表同一张表上任何时刻只能有一个更新在执行这就会影响到业务并发度。InnoDB是支持行锁的这也是MyISAM被InnoDB替代的重要原因之一。
我们今天就主要来聊聊InnoDB的行锁以及如何通过减少锁冲突来提升业务并发度。
顾名思义行锁就是针对数据表中行记录的锁。这很好理解比如事务A更新了一行而这时候事务B也要更新同一行则必须等事务A的操作完成后才能进行更新。
当然,数据库中还有一些没那么一目了然的概念和设计,这些概念如果理解和使用不当,容易导致程序出现非预期行为,比如两阶段锁。
# 从两阶段锁说起
我先给你举个例子。在下面的操作序列中事务B的update语句执行时会是什么现象呢假设字段id是表t的主键。<br>
<img src="https://static001.geekbang.org/resource/image/51/10/51f501f718e420244b0a2ec2ce858710.jpg" alt="">
这个问题的结论取决于事务A在执行完两条update语句后持有哪些锁以及在什么时候释放。你可以验证一下实际上事务B的update语句会被阻塞直到事务A执行commit之后事务B才能继续执行。
知道了这个答案你一定知道了事务A持有的两个记录的行锁都是在commit的时候才释放的。
也就是说,**在InnoDB事务中行锁是在需要的时候才加上的但并不是不需要了就立刻释放而是要等到事务结束时才释放。这个就是两阶段锁协议。**
知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。我给你举个例子。
假设你负责实现一个电影票在线交易业务顾客A要在影院B购买电影票。我们简化一点这个业务需要涉及到以下操作
<li>
从顾客A账户余额中扣除电影票价
</li>
<li>
给影院B的账户余额增加这张电影票价
</li>
<li>
记录一条交易日志。
</li>
也就是说要完成这个交易我们需要update两条记录并insert一条记录。当然为了保证交易的原子性我们要把这三个操作放在一个事务中。那么你会怎样安排这三个语句在事务中的顺序呢
试想如果同时有另外一个顾客C要在影院B买票那么这两个事务冲突的部分就是语句2了。因为它们要更新同一个影院账户的余额需要修改同一行数据。
根据两阶段锁协议不论你怎样安排语句顺序所有的操作需要的行锁都是在事务提交的时候才释放的。所以如果你把语句2安排在最后比如按照3、1、2这样的顺序那么影院账户余额这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待提升了并发度。
好了,现在由于你的正确设计,影院余额这一行的行锁在一个事务中不会停留很长时间。但是,这并没有完全解决你的困扰。
如果这个影院做活动可以低价预售一年内所有的电影票而且这个活动只做一天。于是在活动时间开始的时候你的MySQL就挂了。你登上服务器一看CPU消耗接近100%但整个数据库每秒就执行不到100个事务。这是什么原因呢
这里,我就要说到死锁和死锁检测了。
# 死锁和死锁检测
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。<br>
<img src="https://static001.geekbang.org/resource/image/4d/52/4d0eeec7b136371b79248a0aed005a52.jpg" alt="">
这时候事务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从主库传过来的时间按照效果不同我打了四个时刻。题目设定为小表我们假定到达后如果开始执行则很快能够执行完成。
参考答案如下:
<li>
如果在Q4语句执行之前到达现象没有影响备份拿到的是DDL后的表结构。
</li>
<li>
如果在“时刻 2”到达则表结构被改过Q5执行的时候报 Table definition has changed, please retry transaction现象mysqldump终止
</li>
<li>
如果在“时刻2”和“时刻3”之间到达mysqldump占着t1的MDL读锁binlog被阻塞现象主从延迟直到Q6执行完成。
</li>
<li>
从“时刻4”开始mysqldump释放了MDL读锁现象没有影响备份拿到的是DDL前的表结构。
</li>
评论区留言点赞板:
>
<p>@Aurora 给了最接近的答案;<br>
@echo_ 问了一个好问题;<br>
@壹笙☞漂泊 做了很好的总结。</p>

View File

@@ -0,0 +1,285 @@
<audio id="audio" title="08 | 事务到底是隔离的还是不隔离的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/5a/797668397b630849768ecf8c7393e85a.mp3"></audio>
>
<p>你好,我是林晓斌。<br>
你现在看到的这篇文章是我重写过的。在第一版文章发布之后,我发现在介绍事务可见性规则时,由于引入了太多概念,导致理解起来很困难。随后,我索性就重写了这篇文章。<br>
现在的用户留言中还能看到第一版文章中引入的up_limit_id的概念为了避免大家产生误解再此特地和大家事先说明一下。</p>
我在第3篇文章和你讲事务隔离级别的时候提到过如果是可重复读隔离级别事务T启动的时候会创建一个视图read-view之后事务T执行期间即使有其他事务修改了数据事务T看到的仍然跟在启动时看到的一样。也就是说一个在可重复读隔离级别下执行的事务好像与世无争不受外界影响。
但是,我在上一篇文章中,和你分享行锁的时候又提到,一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?
我给你举一个例子吧。下面是一个只有两行的表的初始化语句。
```
mysql&gt; 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 src="https://static001.geekbang.org/resource/image/82/d6/823acf76e53c0bdba7beab45e72e90d6.png" alt="">
这里,我们需要注意的是事务的启动时机。
begin/start transaction 命令并不是一个事务的起点在执行到它们之后的第一个操作InnoDB表的语句事务才真正启动。如果你想要马上启动一个事务可以使用start transaction with consistent snapshot 这个命令。
>
<p>第一种启动方式,一致性视图是在执行第一个快照读语句时创建的;<br>
第二种启动方式一致性视图是在执行start transaction with consistent snapshot时创建的。</p>
还需要注意的是在整个专栏里面我们的例子中如果没有特别说明都是默认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用于支持RCRead Committed读提交和RRRepeatable Read可重复读隔离级别的实现。
它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
在第3篇文章[《事务隔离:为什么你改了我还看不见?》](https://time.geekbang.org/column/article/68963)中我跟你解释过一遍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 src="https://static001.geekbang.org/resource/image/68/ed/68d08d277a6f7926a41cc5541d3dfced.png" alt="">
图中虚线框里是同一行数据的4个版本当前最新版本是V4k的值是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 src="https://static001.geekbang.org/resource/image/88/5e/882114aaf55861832b4270d44507695e.png" alt="">
这样对于当前事务的启动瞬间来说一个数据版本的row trx_id有以下几种可能
<li>
如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
</li>
<li>
如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
</li>
<li>
<p>如果落在黄色部分,那就包括两种情况<br>
a. 若 row trx_id在数组中表示这个版本是由还没提交的事务生成的不可见<br>
b. 若 row trx_id不在数组中表示这个版本是已经提交了的事务生成的可见。</p>
</li>
比如对于图2中的数据来说如果有一个事务它的低水位是18那么当它访问这一行数据时就会从V4通过U3计算出V3所以在它看来这一行的值是11。
你看有了这个声明后系统里面随后发生的更新是不是就跟这个事务看到的内容无关了呢因为之后的更新生成的版本一定属于上面的2或者3(a)的情况,而对它来说,这些新的数据版本是不存在的,所以这个事务的快照,就是“静态”的了。
所以你现在知道了,**InnoDB利用了“所有数据都有多个版本”的这个特性实现了“秒级创建快照”的能力。**
接下来我们继续看一下图1中的三个事务分析下事务A的语句返回的结果为什么是k=1。
这里,我们不妨做如下假设:
<li>
事务A开始前系统里面只有一个活跃事务ID是99
</li>
<li>
事务A、B、C的版本号分别是100、101、102且当前系统里只有这四个事务
</li>
<li>
三个事务开始前,(1,1这一行数据的row trx_id是90。
</li>
这样事务A的视图数组就是[99,100], 事务B的视图数组是[99,100,101], 事务C的视图数组是[99,100,101,102]。
为了简化分析我先把其他干扰语句去掉只画出跟事务A查询逻辑有关的操作
<img src="https://static001.geekbang.org/resource/image/94/49/9416c310e406519b7460437cb0c5c149.png" alt="">
从图中可以看到第一个有效更新是事务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不论在什么时候查询看到这行数据的结果都是一致的所以我们称之为一致性读。
这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。
所以,我来给你翻译一下。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以外,有三种情况:
<li>
版本未提交,不可见;
</li>
<li>
版本已提交,但是是在视图创建后提交的,不可见;
</li>
<li>
版本已提交,而且是在视图创建前提交的,可见。
</li>
现在我们用这个规则来判断图4中的查询结果事务A的查询语句的视图数组是在事务A启动的时候生成的这时候
- (1,3)还没提交属于情况1不可见
- (1,2)虽然提交了但是是在视图数组创建之后提交的属于情况2不可见
- (1,1)是在视图数组创建之前提交的,可见。
你看,去掉数字对比后,只用时间先后顺序来判断,分析起来是不是轻松多了。所以,后面我们就都用这个规则来分析。
# 更新逻辑
细心的同学可能有疑问了:**事务B的update语句如果按照一致性读好像结果不对哦**
你看图5中事务B的视图数组是先生成的之后事务C才提交不是应该看不见(1,2)吗,怎么能算出(1,3)来?
<img src="https://static001.geekbang.org/resource/image/86/9f/86ad7e8abe7bf16505b97718d8ac149f.png" alt="">
是的如果事务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&gt; select k from t where id=1 lock in share mode;
mysql&gt; select k from t where id=1 for update;
```
再往前一步假设事务C不是马上提交的而是变成了下面的事务C会怎么样呢
<img src="https://static001.geekbang.org/resource/image/cd/6e/cda2a0d7decb61e59dddc83ac51efb6e.png" alt="">
事务C的不同是更新后并没有马上提交在它提交前事务B的更新语句先发起了。前面说过了虽然事务C还没提交但是(1,2)这个版本也已经生成了并且是当前的最新版本。那么事务B的更新语句会怎么处理呢
这时候我们在上一篇文章中提到的“两阶段锁协议”就要上场了。事务C没提交也就是说(1,2)这个版本上的写锁还没释放。而事务B是当前读必须要读最新版本而且必须加锁因此就被锁住了必须等到事务C释放这个锁才能继续它的当前读。
<img src="https://static001.geekbang.org/resource/image/54/92/540967ea905e8b63630e496786d84c92.png" alt="">
到这里,我们把一致性读、当前读和行锁就串起来了。
现在,我们再回到文章开头的问题:**事务的可重复读的能力是怎么实现的?**
可重复读的核心就是一致性读consistent read而事务更新数据的时候只能用当前读。如果当前的记录的行锁被其他事务占用的话就需要进入锁等待。
而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
- 在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询都共用这个一致性视图;
- 在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
那么我们再看一下在读提交隔离级别下事务A和事务B的查询语句查到的k分别应该是多少呢
这里需要说明一下“start transaction with consistent snapshot; ”的意思是从这个语句开始创建一个持续整个事务的一致性快照。所以在读提交隔离级别下这个用法就没意义了等效于普通的start transaction。
下面是读提交时的状态图可以看到这两个查询语句的创建视图数组的时机发生了变化就是图中的read view框。注意这里我们用的还是事务C的逻辑直接提交而不是事务C
<img src="https://static001.geekbang.org/resource/image/18/be/18fd5179b38c8c3804b313c3582cd1be.jpg" alt="">
这时事务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&gt; 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 src="https://static001.geekbang.org/resource/image/9b/0b/9b8fe7cf88c9ba40dc12e93e36c3060b.png" alt=""><br>
复现出来以后,请你再思考一下,在实际的业务开发中有没有可能碰到这种情况?你的应用代码会不会掉进这个“坑”里,你又是怎么解决的呢?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上一篇文章最后留给你的问题是怎么删除表的前10000行。比较多的留言都选择了第二种方式在一个连接中循环执行20次 delete from T limit 500。
确实是这样的,第二种方式是相对较好的。
第一种方式直接执行delete from T limit 10000里面单个语句占用时间长锁的时间也比较长而且大事务还会导致主从延迟。
第三种方式在20个连接中同时执行delete from T limit 500会人为造成锁冲突。
评论区留言点赞板:
>
<p>@Tony Du的评论详细而且准确。<br>
@Knight²º¹⁸ 提到了如果可以加上特定条件将这10000行天然分开可以考虑第三种。是的实际上在操作的时候我也建议你尽量拿到ID再删除。<br>
@荒漠甘泉 提了一个不错的问题大家需要区分行锁、MDL锁和表锁的区别。对InnoDB表更新一行可能过了MDL关却被挡在行锁阶段。</p>

View File

@@ -0,0 +1,215 @@
<audio id="audio" title="09 | 普通索引和唯一索引,应该怎么选择?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f9/34/f902a691c53fb34aeb9999377d11c434.mp3"></audio>
今天的正文开始前,我要特意感谢一下评论区几位留下高质量留言的同学。
用户名是 @某、人 的同学,对文章的知识点做了梳理,然后提了关于事务可见性的问题,就是先启动但是后提交的事务,对数据可见性的影响。@夏日雨同学也提到了这个问题,我在置顶评论中回复了,今天的文章末尾也会再展开说明。@Justin和@倪大人两位同学提了两个好问题
对于能够引发更深一步思考的问题,我会在回复的内容中写上“好问题”三个字,方便你搜索,你也可以去看看他们的留言。
非常感谢大家很细致地看文章,并且留下了那么多和很高质量的留言。知道文章有给大家带来一些新理解,对我来说是一个很好的鼓励。同时,也让其他认真看评论区的同学,有机会发现一些自己还没有意识到的、但可能还不清晰的知识点,这也在总体上提高了整个专栏的质量。再次谢谢你们。
好了,现在就回到我们今天的正文内容。
在前面的基础篇文章中,我给你介绍过索引的基本概念,相信你已经了解了唯一索引和普通索引的区别。今天我们就继续来谈谈,在不同的业务场景下,应该选择普通索引,还是唯一索引?
假设你在维护一个市民系统每个人都有一个唯一的身份证号而且业务代码已经保证了不会写入两个重复的身份证号。如果市民系统需要按照身份证号查姓名就会执行类似这样的SQL语句
```
select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz';
```
所以你一定会考虑在id_card字段上建索引。
由于身份证号字段比较大我不建议你把身份证号当做主键那么现在你有两个选择要么给id_card字段创建唯一索引要么创建一个普通索引。如果业务代码已经保证了不会写入重复的身份证号那么这两个选择逻辑上都是正确的。
现在我要问你的是,从性能的角度考虑,你选择唯一索引还是普通索引呢?选择的依据是什么呢?
简单起见我们还是用第4篇文章[《深入浅出索引(上)》](https://time.geekbang.org/column/article/69236)中的例子来说明,假设字段 k 上的值都不重复。
<img src="https://static001.geekbang.org/resource/image/1e/46/1ed9536031d6698570ea175a7b7f9a46.png" alt="">
接下来,我们就从这两种索引对查询语句和更新语句的性能影响来进行分析。
# 查询过程
假设,执行查询的语句是 select id from T where k=5。这个查询语句在索引树上查找的过程先是通过B+树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认为数据页内部通过二分法来定位记录。
- 对于普通索引来说,查找到满足条件的第一个记录(5,500)后需要查找下一个记录直到碰到第一个不满足k=5条件的记录。
- 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。
那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。
你知道的InnoDB的数据是按数据页为单位来读写的。也就是说当需要读一条记录的时候并不是将这个记录本身从磁盘读出来而是以页为单位将其整体读入内存。在InnoDB中每个数据页的大小默认是16KB。
因为引擎是按页读写的所以说当找到k=5的记录的时候它所在的数据页就都在内存里了。那么对于普通索引来说要多做的那一次“查找和判断下一条记录”的操作就只需要一次指针寻找和一次计算。
当然如果k=5这个记录刚好是这个数据页的最后一个记录那么要取下一个记录必须读取下一个数据页这个操作会稍微复杂一些。
但是我们之前计算过对于整型字段一个数据页可以放近千个key因此出现这种情况的概率会很低。所以我们计算平均性能差异时仍可以认为这个操作成本对于现在的CPU来说可以忽略不计。
# 更新过程
为了说明普通索引和唯一索引对更新语句性能的影响这个问题我需要先跟你介绍一下change buffer。
当需要更新一个数据页时如果数据页在内存中就直接更新而如果这个数据页还没有在内存中的话在不影响数据一致性的前提下InnoDB会将这些更新操作缓存在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更新语句是如何执行的](https://time.geekbang.org/column/article/68633)中的相关内容。
现在,我们要在表上执行这个插入语句:
```
mysql&gt; insert into t(id,k) values(id1,k1),(id2,k2);
```
这里我们假设当前k索引树的状态查找到位置后k1所在的数据页在内存(InnoDB buffer pool)中k2所在的数据页不在内存中。如图2所示是带change buffer的更新状态图。
<img src="https://static001.geekbang.org/resource/image/98/a3/980a2b786f0ea7adabef2e64fb4c4ca3.png" alt="">
分析这条更新语句你会发现它涉及了四个部分内存、redo logib_log_fileX、 数据表空间t.ibd、系统表空间ibdata1
这条更新语句做了如下的操作(按照图中的数字顺序):
<li>
Page 1在内存中直接更新内存
</li>
<li>
Page 2没有在内存中就在内存的change buffer区域记录下“我要往Page 2插入一行”这个信息
</li>
<li>
将上述两个动作记入redo log中图中3和4
</li>
做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。
同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。
那在这之后的读请求,要怎么处理呢?
比如,我们现在要执行 select * from t where k in (k1, k2)。这里,我画了这两个读请求的流程图。
如果读语句发生在更新语句后不久内存中的数据都还在那么此时的这两个读操作就与系统表空间ibdata1和 redo logib_log_fileX无关了。所以我在图中就没画出这两部分。
<img src="https://static001.geekbang.org/resource/image/6d/8e/6dc743577af1dbcbb8550bddbfc5f98e.png" alt="">
从图中可以看到:
<li>
读Page 1的时候直接从内存返回。有几位同学在前面文章的评论中问到WAL之后如果读数据是不是一定要读盘是不是一定要从redo log里面把数据更新以后才可以返回其实是不用的。你可以看一下图3的这个状态虽然磁盘上还是之前的数据但是这里直接从内存返回结果结果是正确的。
</li>
<li>
要读Page 2的时候需要把Page 2从磁盘读入内存中然后应用change buffer里面的操作日志生成一个正确的版本并返回结果。
</li>
可以看到直到需要读Page 2的时候这个数据页才会被读入内存。
所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,**redo log 主要节省的是随机写磁盘的IO消耗转成顺序写而change buffer主要节省的则是随机读磁盘的IO消耗。**
# 小结
今天我从普通索引和唯一索引的选择开始和你分享了数据的查询和更新过程然后说明了change buffer的机制以及应用场景最后讲到了索引选择的实践。
由于唯一索引用不上change buffer的优化机制因此如果业务可以接受从性能角度出发我建议你优先考虑非唯一索引。
最后,又到了思考题时间。
通过图2你可以看到change buffer一开始是写内存的那么如果这个时候机器掉电重启会不会导致change buffer丢失呢change buffer丢失可不是小事儿再从磁盘读入数据可就没有了merge过程就等于是数据丢失了。会不会出现这种情况呢
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
**补充:**<br>
评论区大家对“是否使用唯一索引”有比较多的讨论,主要是纠结在“业务可能无法确保”的情况。这里,我再说明一下:
- 首先,业务正确性优先。咱们这篇文章的前提是“业务代码已经保证不会写入重复数据”的情况下,讨论性能问题。如果业务不能保证,或者业务就是要求数据库来做约束,那么没得选,必须创建唯一索引。这种情况下,本篇文章的意义在于,如果碰上了大量插入数据慢、内存命中率低的时候,可以给你多提供一个排查思路。
- 然后,在一些“归档库”的场景,你是可以考虑使用普通索引的。比如,线上数据只需要保留半年,然后历史数据保存在归档库。这时候,归档数据已经是确保没有唯一键冲突了。要提高归档效率,可以考虑把表里面的唯一索引改成普通索引。
# 上期问题时间
上期的问题是:如何构造一个“数据无法修改”的场景。评论区里已经有不少同学给出了正确答案,这里我再描述一下。
<img src="https://static001.geekbang.org/resource/image/be/ae/be7a4d8af04cdf93aaa11108933559ae.png" alt=""><br>
这样session A看到的就是我截图的效果了。
其实,还有另外一种场景,同学们在留言区都还没有提到。
<img src="https://static001.geekbang.org/resource/image/e2/fa/e24a0689571337959138d787c408defa.png" alt="">
这个操作序列跑出来session A看的内容也是能够复现我截图的效果的。这个session B启动的事务比A要早其实是上期我们描述事务版本的可见性规则时留的彩蛋因为规则里还有一个“活跃事务的判断”我是准备留到这里再补充的。
当我试图在这里讲述完整规则的时候发现第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中的解释引入了太多的概念,以致于分析起来非常复杂。
因此我重写了第8篇这样我们人工去判断可见性的时候才会更方便。【看到这里我建议你能够再重新打开第8篇文章并认真学习一次。如果学习的过程中有任何问题也欢迎你给我留言】
用新的方式来分析session B的更新为什么对session A不可见就是在session A视图数组创建的瞬间session B是活跃的属于“版本未提交不可见”这种情况。
业务中如果要绕过这类问题,@约书亚提供了一个“乐观锁”的解法,大家可以去上一篇的留言区看一下。
评论区留言点赞板:
>
<p>@某、人、@夏日雨@周巘@李金刚 等同学提了一个很好的问题就是我们今天答案的session B 的情况;<br>
@justin 提到了提交和未提交版本的区别对待,@倪大人 提到了读提交和当前读的区别,都是经过了思考后提出的好问题,大家可以去留言区看看。</p>

View File

@@ -0,0 +1,302 @@
<audio id="audio" title="10 | MySQL为什么有时候会选错索引" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/5f/80f719c1c9411cf2816cd846e1e0065f.mp3"></audio>
前面我们介绍过索引你已经知道了在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&lt;=100000)do
insert into t values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
```
接下来我们分析一条SQL语句
```
mysql&gt; select * from t where a between 10000 and 20000;
```
你一定会说这个语句还用分析吗很简单呀a上有索引肯定是要使用索引a的。
你说得没错图1显示的就是使用explain命令看到的这条语句的执行情况。
<img src="https://static001.geekbang.org/resource/image/2c/e3/2cfce769551c6eac9bfbee0563d48fe3.png" alt="">
从图1看上去这条查询语句的执行也确实符合预期key这个字段值是a表示优化器选择了索引a。
不过别急这个案例不会这么简单。在我们已经准备好的包含了10万行数据的表上我们再做如下操作。
<img src="https://static001.geekbang.org/resource/image/1e/1e/1e5ba1c2934d3b2c0d96b210a27e1a1e.png" alt="">
这里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 src="https://static001.geekbang.org/resource/image/7c/f6/7c58b9c71853b8bba1a8ad5e926de1f6.png" alt="">
可以看到Q1扫描了10万行显然是走了全表扫描执行时间是40毫秒。Q2扫描了10001行执行了21毫秒。也就是说我们在没有使用force index的时候MySQL用错了索引导致了更长的执行时间。
这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。这时MySQL竟然会选错索引是不是有点奇怪呢今天我们就从这个奇怪的结果说起吧。
# 优化器的逻辑
在第一篇文章中,我们就提到过,选择索引是优化器的工作。
而优化器选择索引的目的是找到一个最优的执行方案并用最小的代价去执行语句。在数据库里面扫描行数是影响执行代价的因素之一。扫描的行数越少意味着访问磁盘数据的次数越少消耗的CPU资源越少。
当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行综合判断。
我们这个简单的查询语句并没有涉及到临时表和排序所以MySQL选错索引肯定是在判断扫描行数的时候出问题了。
那么,问题就是:**扫描行数是怎么判断的?**
MySQL在真正开始执行语句之前并不能精确地知道满足这个条件的记录有多少条而只能根据统计信息来估算记录数。
这个统计信息就是索引的“区分度”。显然一个索引上不同的值越多这个索引的区分度就越好。而一个索引上不同的值的个数我们称之为“基数”cardinality。也就是说这个基数越大索引的区分度越好。
我们可以使用show index方法看到一个索引的基数。如图4所示就是表t的show index 的结果 。虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且其实都不准确。
<img src="https://static001.geekbang.org/resource/image/16/d4/16dbf8124ad529fec0066950446079d4.png" alt="">
那么,**MySQL是怎样得到索引的基数的呢**这里我给你简单介绍一下MySQL采样统计的方法。
为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。
采样统计的时候InnoDB默认会选择N个数据页统计这些页面上的不同值得到一个平均值然后乘以这个索引的页面数就得到了这个索引的基数。
而数据表是会持续更新的索引统计信息也不会固定不变。所以当变更的数据行数超过1/M的时候会自动触发重新做一次索引统计。
在MySQL中有两种存储索引统计的方式可以通过设置参数innodb_stats_persistent的值来选择
- 设置为on的时候表示统计信息会持久化存储。这时默认的N是20M是10。
- 设置为off的时候表示统计信息只存储在内存中。这时默认的N是8M是16。
由于是采样统计所以不管N是20还是8这个基数都是很容易不准的。
但,这还不是全部。
你可以从图4中看到这次的索引统计值cardinality列虽然不够精确但大体上还是差不多的选错索引一定还有别的原因。
其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。
接下来,我们再一起看看优化器预估的,这两个语句的扫描行数是多少。
<img src="https://static001.geekbang.org/resource/image/e2/89/e2bc5f120858391d4accff05573e1289.png" alt="">
rows这个字段表示的是预计扫描行数。
其中Q1的结果还是符合预期的rows的值是104620但是Q2的rows值是37116偏差就大了。而图1中我们用explain命令看到的rows是只有10001行是这个偏差误导了优化器的判断。
到这里可能你的第一个疑问不是为什么不准而是优化器为什么放着扫描37000行的执行计划不用却选择了扫描行数是100000的执行计划呢
这是因为如果使用索引a每次从索引a上拿到一个值都要回到主键索引上查出整行数据这个代价优化器也要算进去的。
而如果选择扫描10万行是直接在主键索引上扫描的没有额外的代价。
优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。
使用普通索引需要把回表的代价算进去在图1执行explain的时候也考虑了这个策略的代价 但图1的选择是对的。也就是说这个策略并没有问题。
所以冤有头债有主MySQL选错索引这件事儿还得归咎到没能准确地判断出扫描行数。至于为什么会得到错误的扫描行数这个原因就作为课后问题留给你去分析了。
既然是统计信息不对那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看一下执行效果。
<img src="https://static001.geekbang.org/resource/image/20/9c/209e9d3514688a3bcabbb75e54e1e49c.png" alt="">
这回对了。
所以在实践中如果你发现explain的结果预估的rows值跟实际情况差距比较大可以采用这个方法来处理。
其实如果只是索引统计不准确通过analyze命令可以解决很多问题但是前面我们说了优化器可不止是看扫描行数。
依然是基于这个表t我们看看另外一个语句
```
mysql&gt; select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
```
从条件上看,这个查询没有符合条件的记录,因此会返回空集合。
在开始执行这条语句之前,你可以先设想一下,如果你来选择索引,会选择哪一个呢?
为了便于分析我们先来看一下a、b这两个索引的结构图。
<img src="https://static001.geekbang.org/resource/image/1d/b9/1d037f92063e800c3bfff3f4dbf1a2b9.png" alt="">
如果使用索引a进行查询那么就是扫描索引a的前1000个值然后取到对应的id再到主键索引上去查出每一行然后根据字段b来过滤。显然这样需要扫描1000行。
如果使用索引b进行查询那么就是扫描索引b的最后50001个值与上面的执行过程相同也是需要回到主键索引上取值再判断所以需要扫描50001行。
所以你一定会想如果使用索引a的话执行速度明显会快很多。那么下面我们就来看看到底是不是这么一回事儿。
图8是执行explain的结果。
```
mysql&gt; explain select * from t where (a between 1 and 1000) and (b between 50000 and 100000) order by b limit 1;
```
<img src="https://static001.geekbang.org/resource/image/48/b8/483bcb1ef3bb902844e80d9cbdd73ab8.png" alt="">
可以看到返回结果中key字段显示这次优化器选择了索引b而rows字段显示需要扫描的行数是50198。
从这个结果中,你可以得到两个结论:
<li>
扫描行数的估计值依然不准确;
</li>
<li>
这个例子里MySQL又选错了索引。
</li>
# 索引选择异常和处理
其实大多数时候优化器都能找到正确的索引但偶尔你还是会碰到我们上面举例的这两种情况原本可以执行得很快的SQL语句执行速度却比你预期的慢很多你应该怎么办呢
**一种方法是像我们第一个例子一样采用force index强行选择一个索引。**MySQL会根据词法解析的结果分析出可能可以使用的索引作为候选项然后在候选列表中依次判断每个索引需要扫描多少行。如果force index指定的索引在候选索引列表中就直接选择这个索引不再评估其他索引的执行代价。
我们来看看第二个例子。刚开始分析时我们认为选择索引a会更好。现在我们就来看看执行效果
<img src="https://static001.geekbang.org/resource/image/95/54/9582401a6bed6cb8fd803c9555750b54.png" alt="">
可以看到原本语句需要执行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 src="https://static001.geekbang.org/resource/image/14/94/14cd598e52a2b72dd334a42603e5b894.png" alt="">
之前优化器选择使用索引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&gt; 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 src="https://static001.geekbang.org/resource/image/b1/d7/b1a2ad43c78477d7f93dbc692cbaa0d7.png" alt="">
在这个例子里我们用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的执行流程是这样的
<li>
从磁盘读入数据页到内存(老版本的数据页);
</li>
<li>
从change buffer里找出这个数据页的change buffer 记录(可能有多个),依次应用,得到新版数据页;
</li>
<li>
写redo log。这个redo log包含了数据的变更和change buffer的变更。
</li>
到这里merge过程就结束了。这时候数据页和内存中change buffer对应的磁盘位置都还没有修改属于脏页之后各自刷回自己的物理数据就是另外一个过程了。
评论区留言点赞板:
>
<p>@某、人 把02篇的redo log更新细节和change buffer的更新串了起来<br>
@Ivan 回复了其他同学的问题并联系到Checkpoint机制<br>
@约书亚 问到了merge和redolog的关系。</p>

View File

@@ -0,0 +1,256 @@
<audio id="audio" title="11 | 怎么给字符串字段加索引?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/5b/afca00ca34a6797963b61fda56a1375b.mp3"></audio>
现在,几乎所有的系统都支持邮箱登录,如何在邮箱这样的字段上建立合理的索引,是我们今天要讨论的问题。
假设,你现在维护一个支持邮箱登录的系统,用户表是这么定义的:
```
mysql&gt; create table SUser(
ID bigint unsigned primary key,
email varchar(64),
...
)engine=innodb;
```
由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句:
```
mysql&gt; select f1, f2 from SUser where email='xxx';
```
从第4和第5篇讲解索引的文章中我们可以知道如果email这个字段上没有索引那么这个语句就只能做全表扫描。
同时MySQL是支持前缀索引的也就是说你可以定义字符串的一部分作为索引。默认地如果你创建索引的语句不指定前缀长度那么索引就会包含整个字符串。
比如这两个在email字段上创建索引的语句
```
mysql&gt; alter table SUser add index index1(email);
mysql&gt; alter table SUser add index index2(email(6));
```
第一个语句创建的index1索引里面包含了每个记录的整个字符串而第二个语句创建的index2索引里面对于每个记录都是只取前6个字节。
那么这两种不同的定义在数据结构和存储上有什么区别呢如图2和3所示就是这两个索引的示意图。
<img src="https://static001.geekbang.org/resource/image/d3/b7/d31da662bee595991862c439a5567eb7.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/13/42/134583875561de914991fc2e192cf842.jpg" alt="">
从图中你可以看到由于email(6)这个索引结构中每个邮箱字段都只取前6个字节zhangs所以占用的空间会更小这就是使用前缀索引的优势。
但,这同时带来的损失是,可能会增加额外的记录扫描次数。
接下来,我们再看看下面这个语句,在这两个索引定义下分别是怎么执行的。
```
select id,name,email from SUser where email='zhangssxyz@xxx.com';
```
**如果使用的是index1**即email整个字符串的索引结构执行顺序是这样的
<li>
从index1索引树找到满足索引值是zhangssxyz@xxx.com的这条记录取得ID2的值
</li>
<li>
到主键上查到主键值是ID2的行判断email的值是正确的将这行记录加入结果集
</li>
<li>
取index1索引树上刚刚查到的位置的下一条记录发现已经不满足email='zhangssxyz@xxx.com的条件了循环结束。
</li>
这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。
**如果使用的是index2**即email(6)索引结构),执行顺序是这样的:
<li>
从index2索引树找到满足索引值是zhangs的记录找到的第一个是ID1
</li>
<li>
到主键上查到主键值是ID1的行判断出email的值不是zhangssxyz@xxx.com这行记录丢弃
</li>
<li>
取index2上刚刚查到的位置的下一条记录发现仍然是zhangs取出ID2再到ID索引上取整行然后判断这次值对了将这行记录加入结果集
</li>
<li>
重复上一步直到在idxe2上取到的值不是zhangs循环结束。
</li>
在这个过程中要回主键索引取4次数据也就是扫描了4行。
通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。
但是对于这个查询语句来说如果你定义的index2不是email(6)而是email(7也就是说取email字段的前7个字节来构建索引的话即满足前缀zhangss的记录只有一个也能够直接查到ID2只扫描一行就结束了。
也就是说**使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。**
于是,你就有个问题:当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?
实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。
首先,你可以使用下面这个语句,算出这个列上有多少个不同的值:
```
mysql&gt; select count(distinct email) as L from SUser;
```
然后依次选取不同长度的前缀来看这个值比如我们要看一下4~7个字节的前缀索引可以用这个语句
```
mysql&gt; 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='zhangssxyz@xxx.com';
```
与前面例子中的SQL语句
```
select id,name,email from SUser where email='zhangssxyz@xxx.com';
```
相比这个语句只要求返回id和email字段。
所以如果使用index1即email整个字符串的索引结构的话可以利用覆盖索引从index1查到结果后直接就返回了不需要回到ID索引再去查一次。而如果使用index2即email(6)索引结构的话就不得不回到ID索引再去判断email字段的值。
即使你将index2的定义修改为email(18)的前缀索引这时候虽然index2已经包含了所有的信息但InnoDB还是要回到id索引再查一下因为系统并不确定前缀索引的定义是否截断了完整信息。
也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀索引时需要考虑的一个因素。
# 其他方式
对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。但是,遇到前缀的区分度不够好的情况时,我们要怎么办呢?
比如我们国家的身份证号一共18位其中前6位是地址码所以同一个县的人的身份证号前6位一般会是相同的。
假设你维护的数据库是一个市的公民信息系统这时候如果对身份证号做长度为6的前缀索引的话这个索引的区分度就非常低了。
按照我们前面说的方法可能你需要创建长度为12以上的前缀索引才能够满足区分度要求。
但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。
那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理方法呢?这种方法,既可以占用更小的空间,也能达到相同的查询效率。
答案是,有的。
**第一种方式是使用倒序存储。**如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:
```
mysql&gt; select field_list from t where id_card = reverse('input_id_card_string');
```
由于身份证号的最后6位没有地址码这样的重复逻辑所以最后这6位很可能就提供了足够的区分度。当然了实践中你不要忘记使用count(distinct)方法去做个验证。
**第二种方式是使用hash字段。**你可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。
```
mysql&gt; alter table t add id_card_crc int unsigned, add index(id_card_crc);
```
然后每次插入新记录的时候都同时用crc32()这个函数得到校验码填到这个新字段。由于校验码可能存在冲突也就是说两个不同的身份证号通过crc32()函数得到的结果可能是相同的所以你的查询语句where部分要判断id_card的值是否精确相同。
```
mysql&gt; 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字段的方式也只能支持等值查询。
它们的区别,主要体现在以下三个方面:
<li>
从占用的额外空间来看倒序存储方式在主键索引上不会消耗额外的存储空间而hash字段方法需要增加一个字段。当然倒序存储方式使用4个字节的前缀长度应该是不够的如果再长一点这个消耗跟额外这个hash字段也差不多抵消了。
</li>
<li>
在CPU消耗方面倒序方式每次写和读的时候都需要额外调用一次reverse函数而hash字段的方式需要额外调用一次crc32()函数。如果只从这两个函数的计算复杂度来看的话reverse函数额外消耗的CPU资源会更小些。
</li>
<li>
从查询效率上看使用hash字段方式的查询性能相对更稳定一些。因为crc32算出来的值虽然有冲突的概率但是概率非常小可以认为每次查询的平均扫描行数接近1。而倒序存储方式毕竟还是用的前缀索引的方式也就是说还是会增加扫描行数。
</li>
# 小结
在今天这篇文章中,我跟你聊了聊字符串字段创建索引的场景。我们来回顾一下,你可以使用的方式有:
<li>
直接创建完整索引,这样可能比较占用空间;
</li>
<li>
创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
</li>
<li>
倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
</li>
<li>
创建hash字段索引查询性能稳定有额外的存储和计算消耗跟第三种方式一样都不支持范围扫描。
</li>
在实际应用中,你要根据业务字段的特点选择使用哪种方式。
好了,又到了最后的问题时间。
如果你在维护一个学校的学生信息数据库,学生登录名的统一格式是”学号@gmail.com", 而学号的规则是:十五位的数字,其中前三位是所在城市编号、第四到第六位是学校编号、第七位到第十位是入学年份、最后五位是顺序编号。
系统登录的时候都需要学生输入登录名和密码,验证正确后才能继续使用系统。就只考虑登录验证这个行为的话,你会怎么设计这个登录名的索引呢?
你可以把你的分析思路和设计结果写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上篇文章中的第一个例子评论区有几位同学说没有复现大家要检查一下隔离级别是不是RRRepeatable Read可重复读创建的表t是不是InnoDB引擎。我把复现过程做成了一个视频供你参考。
<video preload="none" controls=""><source src="https://static001.geekbang.org/resource/video/2a/76/2a3ac914a325cdff1ca6ba999d1b8c76.mp4" type="video/mp4"><source src="https://res001.geekbang.org/media/video/2a/76/2a3ac914a325cdff1ca6ba999d1b8c76/sd/sd.m3u8" type="application/x-mpegURL"><source src="https://res001.geekbang.org/media/video/2a/76/2a3ac914a325cdff1ca6ba999d1b8c76/hd/hd.m3u8" type="application/x-mpegURL"></video>
在上一篇文章最后我给你留的问题是为什么经过这个操作序列explain的结果就不对了这里我来为你分析一下原因。
delete 语句删掉了所有的数据然后再通过call idata()插入了10万行数据看上去是覆盖了原来的10万行。
但是session A开启了事务并没有提交所以之前插入的10万行数据是不能删除的。这样之前的数据每一行数据都有两个版本旧版本是delete之前的数据新版本是标记为deleted的数据。
这样索引a上的数据其实就有两份。
然后你会说不对啊主键上的数据也不能删那没有使用force index的语句使用explain命令看到的扫描行数为什么还是100000左右潜台词如果这个也翻倍也许优化器还会认为选字段a作为索引更合适
是的不过这个是主键主键是直接按照表的行数来估计的。而表的行数优化器直接用的是show table status的值。
这个值的计算方法,我会在后面有文章为你详细讲解。
<img src="https://static001.geekbang.org/resource/image/e0/67/e0e4c8381f3feae4d87958470760d367.png" alt=""><br>
评论区留言点赞板:
>
<p>@斜面镜子 Bill 的评论最接近答案;<br>
@某、人 做了两个很不错的对照试验;<br>
@ye7zi 等几位同学很认真的验证赞态度。大家的机器如果IO能力比较差的话做这个验证的时候可以把innodb_flush_log_at_trx_commit 和 sync_binlog 都设置成0。</p>

View File

@@ -0,0 +1,185 @@
<audio id="audio" title="12 | 为什么我的MySQL会“抖”一下" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/eb/d5/eb86ba8089d79cc265dda9ecdf8bfbd5.mp3"></audio>
平时的工作中不知道你有没有遇到过这样的场景一条SQL语句正常执行的时候特别快但是有时也不知道怎么回事它就会变得特别慢并且这样的场景很难复现它不只随机而且持续时间还很短。
看上去,这就像是数据库“抖”了一下。今天,我们就一起来看一看这是什么原因。
# 你的SQL语句为什么变“慢”了
在前面第2篇文章[《日志系统一条SQL更新语句是如何执行的](https://time.geekbang.org/column/article/68633)中我为你介绍了WAL机制。现在你知道了InnoDB在处理更新语句的时候只做了写日志这一个磁盘操作。这个日志叫作redo log重做日志也就是《孔乙己》里咸亨酒店掌柜用来记账的粉板在更新内存写完redo log后就返回给客户端本次更新成功。
做下类比的话掌柜记账的账本是数据文件记账用的粉板是日志文件redo log掌柜的记忆就是内存。
掌柜总要找时间把账本更新一下这对应的就是把内存里的数据写入磁盘的过程术语就是flush。在这个flush操作执行之前孔乙己的赊账总额其实跟掌柜手中账本里面的记录是不一致的。因为孔乙己今天的赊账金额还只在粉板上而账本里的记录是老的还没把今天的赊账算进去。
**当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”**
不论是脏页还是干净页,都在内存中。在这个例子里,内存对应的就是掌柜的记忆。
接下来我们用一个示意图来展示一下“孔乙己赊账”的整个操作过程。假设原来孔乙己欠账10文这次又要赊9文。
<img src="https://static001.geekbang.org/resource/image/34/da/349cfab9e4f5d2a75e07b2132a301fda.jpeg" alt="">
回到文章开头的问题你不难想象平时执行很快的更新操作其实就是在写内存和日志而MySQL偶尔“抖”一下的那个瞬间可能就是在刷脏页flush
那么什么情况会引发数据库的flush过程呢
我们还是继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上?
<li>第一种场景是,粉板满了,记不下了。这时候如果再有人来赊账,掌柜就只得放下手里的活儿,将粉板上的记录擦掉一些,留出空位以便继续记账。当然在擦掉之前,他必须先将正确的账目记录到账本中才行。<br>
这个场景对应的就是InnoDB的redo log写满了。这时候系统会停止所有更新操作把checkpoint往前推进redo log留出空间可以继续写。我在第二讲画了一个redo log的示意图这里我改成环形便于大家理解。</li>
<img src="https://static001.geekbang.org/resource/image/a2/e5/a25bdbbfc2cfc5d5e20690547fe7f2e5.jpg" alt="">
checkpoint可不是随便往前修改一下位置就可以的。比如图2中把checkpoint位置从CP推进到CP就需要将两个点之间的日志浅绿色部分对应的所有脏页都flush到磁盘上。之后图中从write pos到CP之间就是可以再写入的redo log的区域。
<li>
<p>第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出账本把孔乙己这笔账先加进去。<br>
这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。<br>
你一定会说这时候难道不能直接把内存淘汰掉下次需要请求的时候从磁盘读入数据页然后拿redo log出来应用不就行了这里其实是从性能考虑的。如果刷脏页一定会写盘就保证了每个数据页有两种状态</p>
<ul>
- 一种是内存里存在,内存里就肯定是正确的结果,直接返回;
<li>另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。<br>
这样的效率最高。</li>
第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不如更新账本。<br>
这种场景对应的就是MySQL认为系统“空闲”的时候。当然MySQL“这家酒店”的生意好起来可是会很快就能把粉板记满的所以“掌柜”要合理地安排时间即使是“生意好”的时候也要见缝插针地找时间只要有机会就刷一点“脏页”。
第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。<br>
这种场景对应的就是MySQL正常关闭的情况。这时候MySQL会把内存的脏页都flush到磁盘上这样下次MySQL启动的时候就可以直接从磁盘上读数据启动速度会很快。
接下来,**你可以分析一下上面四种场景对性能的影响。**
其中第三种情况是属于MySQL空闲时的操作这时系统没什么压力而第四种场景是数据库本来就要关闭了。这两种情况下你不会太关注“性能”问题。所以这里我们主要来分析一下前两种场景下的性能问题。
第一种是“redo log写满了要flush脏页”这种情况是InnoDB要尽量避免的。因为出现这种情况的时候整个系统就不能再接受更新了所有的更新都必须堵住。如果你从监控上看这时候更新数会跌为0。
第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。**InnoDB用缓冲池buffer pool管理内存缓冲池中的内存页有三种状态**
- 第一种是,还没有使用的;
- 第二种是,使用了并且是干净页;
- 第三种是,使用了并且是脏页。
InnoDB的策略是尽量使用内存因此对于一个长时间运行的库来说未被使用的页面很少。
而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。
所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:
<li>
一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
</li>
<li>
日志写满更新全部堵住写性能跌为0这种情况对敏感业务来说是不能接受的。
</li>
所以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&gt;=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 src="https://static001.geekbang.org/resource/image/cc/74/cc44c1d080141aa50df6a91067475374.png" alt="">
现在你知道了InnoDB会在后台刷脏页而刷脏页的过程是要将内存页写入磁盘。所以无论是你的查询语句在需要内存的时候可能要求淘汰一个脏页还是由于刷脏页的逻辑会占用IO资源并可能影响到了你的更新语句都可能是造成你从业务端感知到MySQL“抖”了一下的原因。
要尽量避免这种情况你就要合理地设置innodb_io_capacity的值并且**平时要多关注脏页比例不要让它经常接近75%**。
其中脏页比例是通过Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total得到的具体的命令参考下面的代码
```
mysql&gt; 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出来。
@小潘 同学提了另外一个极致的方向。如果碰到表数据量特别大的场景,通过这种方式的收益是很不错的。
**评论区留言点赞板:**
>
<p>@lttzzlll ,提到了用整型存“四位年份+五位编号”的方法;<br>
由于整个学号的值超过了int上限@老杨同志 也提到了用8个字节的bigint来存的方法。</p>

View File

@@ -0,0 +1,224 @@
<audio id="audio" title="13 | 为什么表数据删掉一半,表文件大小不变?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/bb/2f794755a2a93be1de4f84da5ce0f8bb.mp3"></audio>
经常会有同学来问我,我的数据库占用空间太大,我把一个最大的表删掉了一半的数据,怎么表文件的大小还是没变?
那么今天,我就和你聊聊数据库表的空间回收,看看如何解决这个问题。
这里我们还是针对MySQL中应用最广泛的InnoDB引擎展开讨论。一个InnoDB表包含两部分表结构定义和数据。在MySQL 8.0版本以前,表结构是存在以.frm为后缀的文件里。而MySQL 8.0版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。
接下来,我会先和你说明为什么简单地删除表数据达不到表空间回收的效果,然后再和你介绍正确回收空间的方法。
# 参数innodb_file_per_table
表数据既可以存在共享表空间里也可以是单独的文件。这个行为是由参数innodb_file_per_table控制的
<li>
这个参数设置为OFF表示的是表的数据放在系统共享表空间也就是跟数据字典放在一起
</li>
<li>
这个参数设置为ON表示的是每个InnoDB表数据存储在一个以 .ibd为后缀的文件中。
</li>
从MySQL 5.6.6版本开始它的默认值就是ON了。
我建议你不论使用MySQL的哪个版本都将这个值设置为ON。因为一个表单独存储为一个文件更容易管理而且在你不需要这个表的时候通过drop table命令系统就会直接删除这个文件。而如果是放在共享表空间中即使表删掉了空间也是不会回收的。
所以,**将innodb_file_per_table设置为ON是推荐做法我们接下来的讨论都是基于这个设置展开的。**
我们在删除整个表的时候可以使用drop table命令回收表空间。但是我们遇到的更多的删除数据的场景是删除某些行这时就遇到了我们文章开头的问题表中的数据被删除了但是表空间却没有被回收。
我们要彻底搞明白这个问题的话,就要从数据删除流程说起了。
# 数据删除流程
我们先再来看一下InnoDB中一个索引的示意图。在前面[第4](https://time.geekbang.org/column/article/69236)和[第5](https://time.geekbang.org/column/article/69636)篇文章中我和你介绍索引时曾经提到过InnoDB里的数据都是用B+树的结构组织的。
<img src="https://static001.geekbang.org/resource/image/f0/c8/f0b1e4ac610bcb5c5922d0b18563f3c8.png" alt="">
假设我们要删掉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 src="https://static001.geekbang.org/resource/image/80/ea/8083f05a4a4c0372833a6e01d5a8e6ea.png" alt="">
可以看到由于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 src="https://static001.geekbang.org/resource/image/02/cd/02e083adaec6e1191f54992f7bc13dcd.png" alt="">
显然花时间最多的步骤是往临时表插入数据的过程如果在这个过程中有新的数据要写入到表A的话就会造成数据丢失。因此在整个DDL过程中表A中不能有更新。也就是说这个DDL不是Online的。
而在**MySQL 5.6版本开始引入的Online DDL对这个操作流程做了优化。**
我给你简单描述一下引入了Online DDL之后重建表的流程
<li>
建立一个临时文件扫描表A主键的所有数据页
</li>
<li>
用数据页中表A的记录生成B+树,存储到临时文件中;
</li>
<li>
生成临时文件的过程中将所有对A的操作记录在一个日志文件row log对应的是图中state2的状态
</li>
<li>
临时文件生成后将日志文件中的操作应用到临时文件得到一个逻辑数据上与表A相同的数据文件对应的就是图中state3的状态
</li>
<li>
用临时文件替换表A的数据文件。
</li>
<img src="https://static001.geekbang.org/resource/image/2d/f0/2d1cfbbeb013b851a56390d38b5321f0.png" alt="">
可以看到与图3过程的不同之处在于由于日志文件记录和重放操作这个功能的存在这个方案在重建表的过程中允许对表A做增删改操作。这也就是Online DDL名字的来源。
我记得有同学在第6篇讲表锁的文章[《全局锁和表锁 :给表加个字段怎么索这么多阻碍?》](https://time.geekbang.org/column/article/69862)的评论区留言说DDL之前是要拿MDL写锁的这样还能叫Online DDL吗
确实图4的流程中alter语句在启动的时候需要获取MDL写锁但是这个写锁在真正拷贝数据之前就退化成读锁了。
为什么要退化呢为了实现OnlineMDL读锁不会阻塞增删改操作。
那为什么不干脆直接解锁呢为了保护自己禁止其他线程对这个表同时做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的。
如果说这两个逻辑之间的关系是什么的话,可以概括为:
<li>
DDL过程如果是Online的就一定是inplace的
</li>
<li>
反过来未必也就是说inplace的DDL有可能不是Online的。截止到MySQL 8.0添加全文索引FULLTEXT index和空间索引(SPATIAL index)就属于这种情况。
</li>
最后,我们再延伸一下。
在第10篇文章[《MySQL为什么有时候会选错索引》](https://time.geekbang.org/column/article/71173)的评论区中有同学问到使用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的这个你需要特别小心。
最后,又到了我们的课后问题时间。
假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的:
<li>
一个表t文件大小为1TB
</li>
<li>
对这个表执行 alter table t engine=InnoDB
</li>
<li>
发现执行完成后空间不仅没变小还稍微大了一点儿比如变成了1.01TB。
</li>
你觉得可能是什么原因呢
你可以把你觉得可能的原因写在留言区里,我会在下一篇文章的末尾把大家描述的合理的原因都列出来,以后其他同学就不用掉到这样的坑里了。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
在上期文章最后我留给你的问题是如果一个高配的机器redo log设置太小会发生什么情况。
每次事务提交都要写redo log如果设置太小很快就会被写满也就是下面这个图的状态这个“环”将很快被写满write pos一直追着CP。
<img src="https://static001.geekbang.org/resource/image/a2/e5/a25bdbbfc2cfc5d5e20690547fe7f2e5.jpg" alt="">
这时候系统不得不停止所有更新去推进checkpoint。
这时,你看到的现象就是**磁盘压力很小,但是数据库出现间歇性的性能下跌。**
评论区留言点赞板:
>
<p>@某、人 给了一个形象的描述而且提到了在这种情况下连change buffer的优化也失效了。因为checkpoint一直要往前推这个操作就会触发merge操作然后又进一步地触发刷脏页操作<br>
有几个同学提到了内存淘汰脏页对应的redo log的操作这个我们会在后面的文章中展开大家可以先看一下 @melon 同学的描述了解一下;<br>
@算不出流源 提到了“动态平衡”,其实只要出现了这种“平衡”,意味着本应该后台的操作,就已经影响了业务应用,属于有损失的平衡。</p>

View File

@@ -0,0 +1,224 @@
<audio id="audio" title="14 | count(*)这么慢,我该怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f8/4f/f8639723a111bd9a607f8b6b6cef744f.mp3"></audio>
在开发系统的时候你可能经常需要计算一个表的行数比如一个交易系统的所有变更记录总数。这时候你可能会想一条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 src="https://static001.geekbang.org/resource/image/5e/97/5e716ba1d464c8224c1c1f36135d0e97.png" alt="">
你会看到在最后一个时刻三个会话A、B、C会同时查询表t的总行数但拿到的结果却不同。
这和InnoDB的事务设计有关系可重复读是它默认的隔离级别在代码上就是通过多版本并发控制也就是MVCC来实现的。每一行记录都要判断自己是否对这个会话可见因此对于count(*)请求来说InnoDB只好把数据一行一行地读出依次判断可见的行才能够用于计算“基于这个查询”的表的总行数。
>
备注如果你对MVCC记忆模糊了可以再回顾下第3篇文章[《事务隔离:为什么你改了我还看不见?》](https://time.geekbang.org/column/article/68963)和第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中的相关内容。
当然现在这个看上去笨笨的MySQL在执行count(*)操作的时候还是做了优化的。
你知道的InnoDB是索引组织表主键索引树的叶子节点是数据而普通索引树的叶子节点是主键值。所以普通索引树比主键索引树小很多。对于count(*)这样的操作遍历哪个索引树得到的结果逻辑上都是一样的。因此MySQL优化器会找到最小的那棵树来遍历。**在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。**
如果你用过show table status 命令的话就会发现这个命令的输出结果里面也有一个TABLE_ROWS用于显示这个表当前有多少行这个命令执行挺快的那这个TABLE_ROWS能代替count(*)吗?
你可能还记得在第10篇文章[《 MySQL为什么有时候会选错索引](https://time.geekbang.org/column/article/71173)中我提到过索引统计的值是通过采样来估算的。实际上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里面取出计数再到数据表里面取数据记录。
我们是这么定义不精确的:
<li>
一种是查到的100行结果里面有最新插入记录而Redis的计数里还没加1
</li>
<li>
另一种是查到的100行结果里没有最新插入的记录而Redis的计数里已经加了1。
</li>
这两种情况,都是逻辑不一致的。
我们一起来看看这个时序图。
<img src="https://static001.geekbang.org/resource/image/39/33/39898af053695dad37227d71ae288e33.png" alt="">
图2中会话A是一个插入交易记录的逻辑往数据表里插入一行R然后Redis计数加1会话B就是查询页面显示时需要的数据。
在图2的这个时序里在T3时刻会话B来查询的时候会显示出新插入的R这个记录但是Redis的计数还没加1。这时候就会出现我们说的数据不一致。
你一定会说这是因为我们执行新增记录逻辑时候是先写数据表再改Redis计数。而读的时候是先读Redis再读数据表这个顺序是相反的。那么如果保持顺序一样的话是不是就没问题了我们现在把会话A的更新顺序换一下再看看执行结果。
<img src="https://static001.geekbang.org/resource/image/5c/db/5c2f786beae1d8917cdc5033b7bf0bdb.png" alt="">
你会发现这时候反过来了会话B在T3时刻查询的时候Redis计数加了1了但还查不到新插入的R这一行也是数据不一致的情况。
在并发系统里面我们是无法精确控制不同线程的执行时刻的因为存在图中的这种操作序列所以我们说即使Redis正常工作这个计数值还是逻辑上不精确的。
# 在数据库保存计数
根据上面的分析,用缓存系统保存计数有丢失数据和计数不精确的问题。那么,**如果我们把这个计数直接放到数据库里单独的一张计数表C中又会怎么样呢**
首先这解决了崩溃丢失的问题InnoDB是支持崩溃恢复不丢数据的。
>
备注关于InnoDB的崩溃恢复你可以再回顾一下第2篇文章[《日志系统一条SQL更新语句是如何执行的](https://time.geekbang.org/column/article/68633)中的相关内容。
然后,我们再看看能不能解决计数不精确的问题。
你会说这不一样吗无非就是把图3中对Redis的操作改成了对计数表C的操作。只要出现图3的这种执行序列这个问题还是无解的吧
这个问题还真不是无解的。
我们这篇文章要解决的问题都是由于InnoDB要支持事务从而导致InnoDB表不能把count(*)直接存起来,然后查询的时候直接返回形成的。
所谓以子之矛攻子之盾,现在我们就利用“事务”这个特性,把问题解决掉。
<img src="https://static001.geekbang.org/resource/image/9e/e3/9e4170e2dfca3524eb5e92adb8647de3.png" alt="">
我们来看下现在的执行结果。虽然会话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的总个数。
至于分析性能差别的时候,你可以记住这么几个原则:
<li>
server层要什么就给什么
</li>
<li>
InnoDB只给必要的值
</li>
<li>
现在的优化器只优化了count(*)的语义为“取行数”,其他“显而易见”的优化并没有做。
</li>
这是什么意思呢?接下来,我们就一个个地来看看。
**对于count(主键id)来说**InnoDB引擎会遍历整张表把每一行的id值都取出来返回给server层。server层拿到id后判断是不可能为空的就按行累加。
**对于count(1)来说**InnoDB引擎遍历整张表但不取值。server层对于返回的每一行放一个数字“1”进去判断是不可能为空的按行累加。
单看这两个用法的差别的话你能对比出来count(1)执行得要比count(主键id)快。因为从引擎返回id会涉及到解析数据行以及拷贝字段值的操作。
**对于count(字段)来说**
<li>
如果这个“字段”是定义为not null的话一行行地从记录里面读出这个字段判断不能为null按行累加
</li>
<li>
如果这个“字段”定义允许为null那么执行的时候判断到有可能是null还要把值取出来再判断一下不是null才累加。
</li>
也就是前面的第一条原则server层要什么字段InnoDB就返回什么字段。
**但是count(*)是例外**并不会把全部字段取出来而是专门做了优化不取值。count(*)肯定不是null按行累加。
看到这里你一定会说优化器就不能自己判断一下吗主键id肯定非空啊为什么不能按照count(*)来处理,多么简单的优化啊。
当然MySQL专门针对这个语句进行优化也不是不可以。但是这种需要专门优化的情况太多了而且MySQL已经优化过count(*)了,你直接使用这种用法就可以了。
所以结论是按照效率排序的话count(字段)&lt;count(主键id)&lt;count(1)≈count(*)所以我建议你尽量使用count(*)。
# 小结
今天我和你聊了聊MySQL中获得表行数的两种方法。我们提到了在不同引擎中count(*)的实现方式是不一样的,也分析了用缓存系统来存储计数值存在的问题。
其实把计数放在Redis里面不能够保证计数和MySQL表里的数据精确一致的原因是**这两个不同的存储构成的系统,不支持分布式事务,无法拿到精确一致的视图。**而把计数值也放在MySQL中就解决了一致性视图的问题。
InnoDB引擎支持事务我们利用好事务的原子性和隔离性就可以简化在业务开发时的逻辑。这也是InnoDB引擎备受青睐的原因之一。
最后,又到了今天的思考题时间了。
在刚刚讨论的方案中,我们用了事务来确保计数准确。由于事务可以保证中间结果不被别的事务读到,因此修改计数值和插入新记录的顺序是不影响逻辑结果的。但是,从并发系统性能的角度考虑,你觉得在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾给出我的参考答案。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期我给你留的问题是什么时候使用alter table t engine=InnoDB会让一个表占用的空间反而变大。
在这篇文章的评论区里面,大家都提到了一个点,就是这个表,本身就已经没有空洞的了,比如说刚刚做过一次重建表操作。
在DDL期间如果刚好有外部的DML在执行这期间可能会引入一些新的空洞。
@飞翔 提到了一个更深刻的机制是我们在文章中没说的。在重建表的时候InnoDB不会把整张表占满每个页留了1/16给后续的更新用。也就是说其实重建表之后不是“最”紧凑的。
假如是这么一个过程:
<li>
将表t重建一次
</li>
<li>
插入一部分数据,但是插入的这些数据,用掉了一部分的预留空间;
</li>
<li>
这种情况下再重建一次表t就可能会出现问题中的现象。
</li>
评论区留言点赞板:
>
<p>@W_T 等同学提到了数据表本身紧凑的情况;<br>
@undifined 提了一个好问题, @帆帆帆帆帆帆帆帆 同学回答了这个问题;<br>
@陈飞 @郜 @wang chen wen 都提了很不错的问题,大家可以去看看。</p>

View File

@@ -0,0 +1,390 @@
<audio id="audio" title="15 | 答疑文章(一):日志和索引相关问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/11/d8b9e7666aec71d6114fab11773abb11.mp3"></audio>
在今天这篇答疑文章更新前MySQL实战这个专栏已经更新了14篇。在这些文章中大家在评论区留下了很多高质量的留言。现在每篇文章的评论区都有热心的同学帮忙总结文章知识点也有不少同学提出了很多高质量的问题更有一些同学帮忙解答其他同学提出的问题。
在浏览这些留言并回复的过程中,我倍受鼓舞,也尽我所知地帮助你解决问题、和你讨论。可以说,你们的留言活跃了整个专栏的氛围、提升了整个专栏的质量,谢谢你们。
评论区的大多数留言我都直接回复了,对于需要展开说明的问题,我都拿出小本子记了下来。这些被记下来的问题,就是我们今天这篇答疑文章的素材了。
到目前为止我已经收集了47个问题很难通过今天这一篇文章全部展开。所以我就先从中找了几个联系非常紧密的问题串了起来希望可以帮你解决关于日志和索引的一些疑惑。而其他问题我们就留着后面慢慢展开吧。
# 日志相关问题
我在第2篇文章[《日志系统一条SQL更新语句是如何执行的](https://time.geekbang.org/column/article/68633)中和你讲到binlog归档日志和redo log重做日志配合崩溃恢复的时候用的是反证法说明了如果没有两阶段提交会导致MySQL出现主备数据不一致等问题。
在这篇文章下面很多同学在问在两阶段提交的不同瞬间MySQL如果发生异常重启是怎么保证数据完整性的
现在,我们就从这个问题开始吧。
我再放一次两阶段提交的图,方便你学习下面的内容。
<img src="https://static001.geekbang.org/resource/image/ee/2a/ee9af616e05e4b853eba27048351f62a.jpg" alt="">
这里我要先和你解释一个误会式的问题。有同学在评论区问到这个图不是一个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会怎么处理
我们先来看一下崩溃恢复时的判断规则。
<li>
如果redo log里面的事务是完整的也就是已经有了commit标识则直接提交
</li>
<li>
<p>如果redo log里面的事务只有完整的prepare则判断对应的事务binlog是否存在并完整<br>
a. 如果是,则提交事务;<br>
b. 否则,回滚事务。</p>
</li>
这里时刻B发生crash对应的就是2(a)的情况,崩溃恢复过程中事务会被提交。
现在,我们继续延展一下这个问题。
## 追问1MySQL怎么知道binlog是完整的?
回答一个事务的binlog是有完整格式的
- statement格式的binlog最后会有COMMIT
- row格式的binlog最后会有一个XID event。
另外在MySQL 5.6.2版本以后还引入了binlog-checksum参数用来验证binlog内容的正确性。对于binlog日志由于磁盘原因可能会在日志中间出错的情况MySQL可以通过校验checksum的结果来发现。所以MySQL还是有办法验证事务binlog的完整性的。
## 追问2redo 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然后可以把提交流程改成这样… -&gt; “数据更新到内存” -&gt; “写 binlog” -&gt; “提交事务”,是不是也可以提供崩溃恢复的能力?
答案是不可以。
如果说**历史原因**的话那就是InnoDB并不是MySQL的原生存储引擎。MySQL的原生引擎是MyISAM设计之初就有没有支持崩溃恢复。
InnoDB在作为MySQL的插件加入MySQL引擎家族之前就已经是一个提供了崩溃恢复和事务支持的引擎了。
InnoDB接入了MySQL后发现既然binlog没有崩溃恢复的能力那就用InnoDB原有的redo log好了。
而如果说**实现上的原因**的话就有很多了。就按照问题中说的只用binlog来实现崩溃恢复的流程我画了一张示意图这里就没有redo log了。
<img src="https://static001.geekbang.org/resource/image/eb/63/eb838b87e9c20fa00aca50ef154f2a63.jpg" alt="">
这样的流程下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还做不到。你看发展生态是多么重要。
## 追问7redo log一般设置多大
回答redo log太小的话会导致很快就被写满然后不得不强行刷redo log这样WAL机制的能力就发挥不出来了。
所以如果是现在常见的几个TB的磁盘的话就不要太小气了直接将redo log设置为4个文件、每个文件1GB吧。
## 追问8正常运行中的实例数据写入后的最终落盘是从redo log更新过来的还是从buffer pool更新过来的呢
回答这个问题其实问得非常好。这里涉及到了“redo log里面到底是什么”的问题。
实际上redo log并没有记录数据页的完整数据所以它并没有能力自己去更新磁盘数据页也就不存在“数据最终落盘是由redo log更新过去”的情况。
<li>
如果是正常运行的实例的话数据页被修改以后跟磁盘的数据页不一致称为脏页。最终数据落盘就是把内存中的数据页写盘。这个过程甚至与redo log毫无关系。
</li>
<li>
在崩溃恢复场景中InnoDB如果判断到一个数据页可能在崩溃恢复的时候丢失了更新就会将它读到内存然后让redo log更新内存内容。更新完成后内存页变成脏页就回到了第一种情况的状态。
</li>
## 追问9redo 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篇文章[](https://time.geekbang.org/column/article/70562)[事务到底是隔离的还是不隔离的?](https://time.geekbang.org/column/article/70562)[](https://time.geekbang.org/column/article/70562)的评论区提到的跟索引相关的一个问题。我觉得这个问题挺有趣、也挺实用的,其他同学也可能会碰上这样的场景,在这里解答和分享一下。
问题是这样的(我文字上稍微做了点修改,方便大家理解):
>
业务上有这样的需求A、B两个用户如果互相关注则成为好友。设计上是有两张表一个是like表一个是friend表like表有user_id、liker_id两个字段我设置为复合唯一索引即uk_user_id_liker_id。语句执行逻辑是这样的
>
<p>以A关注B为例<br>
第一步先查询对方有没有关注自己B有没有关注A<br>
select * from like where user_id = B and liker_id = A;</p>
>
<p>如果有,则成为好友<br>
insert into friend;</p>
>
<p>没有,则只是单向关注关系<br>
insert into like;</p>
>
但是如果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,
`friend_2_id` int(11) NOT NULL,
UNIQUE KEY `uk_friend` (`friend_1_id`,`friend_2_id`),
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
```
虽然这个题干中并没有说到friend表的索引结构。但我猜测friend_1_id和friend_2_id也有索引为便于描述我给加上唯一索引。
顺便说明一下“like”是关键字我一般不建议使用关键字作为库名、表名、字段名或索引名。
我把他的疑问翻译一下,在并发场景下,同时有两个人,设置为关注对方,就可能导致无法成功加为朋友关系。
现在,我用你已经熟悉的时刻顺序表的形式,把这两个事务的执行语句列出来:<br>
<img src="https://static001.geekbang.org/resource/image/c4/ed/c45063baf1ae521bf5d98b6d7c0e0ced.png" alt="">
由于一开始A和B之间没有关注关系所以两个事务里面的select语句查出来的结果都是空。
因此session 1的逻辑就是“既然B没有关注A那就只插入一个单向关注关系”。session 2也同样是这个逻辑。
这个结果对业务来说就是bug了。因为在业务设定里面这两个逻辑都执行完成以后是应该在friend表里面插入一行记录的。
如提问里面说的“第1步即使使用了排他锁也不行因为记录不存在行锁无法生效”。不过我想到了另外一个方法来解决这个问题。
首先要给“like”表增加一个字段比如叫作 relation_ship并设为整型取值1、2、3。
>
<p>值是1的时候表示user_id 关注 liker_id;<br>
值是2的时候表示liker_id 关注 user_id;<br>
值是3的时候表示互相关注。</p>
然后,当 A关注B的时候逻辑改成如下所示的样子
应用代码里面比较A和B的大小如果A&lt;B就执行下面的逻辑
```
mysql&gt; 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&gt;B则执行下面的逻辑
```
mysql&gt; 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 &lt; 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&gt; 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&gt; update t set a=2 where id=1;
```
你会看到这样的结果:
<img src="https://static001.geekbang.org/resource/image/36/70/367b3f299b94353f32f75ea825391170.png" alt=""><br>
结果显示,匹配(rows matched)了一行,修改(Changed)了0行。
仅从现象上看MySQL内部在处理这个命令的时候可以有以下三种选择
<li>
更新都是先读后写的MySQL读出数据发现a的值本来就是2不更新直接返回执行结束
</li>
<li>
MySQL调用了InnoDB引擎提供的“修改为(1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回;
</li>
<li>
InnoDB认真执行了“把这个值修改成(1,2)"这个操作,该加锁的加锁,该更新的更新。
</li>
你觉得实际情况会是以上哪种呢你可否用构造实验的方式来证明你的结论进一步地可以思考一下MySQL为什么要选择这种策略呢
你可以把你的验证方法和思考写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是用一个计数表记录一个业务表的总行数在往业务表插入数据的时候需要给计数值加1。
逻辑实现上是启动一个事务,执行两个语句:
<li>
insert into 数据表;
</li>
<li>
update 计数表计数值加1。
</li>
从系统并发能力的角度考虑,怎么安排这两个语句的顺序。
这里,我直接复制 @阿建 的回答过来供你参考:
>
<p>并发系统性能的角度考虑,应该先插入操作记录,再更新计数表。<br>
知识点在[《行锁功过:怎么减少行锁对性能的影响?》](https://time.geekbang.org/column/article/70215)<br>
因为更新计数表涉及到行锁的竞争,先插入再更新能最大程度地减少事务之间的锁等待,提升并发度。</p>
评论区有同学说应该把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使用主键索引更新加行锁只会锁在一行上。
而在不同业务表插入数据,是更新不同的行,不会有行锁。
评论区留言点赞板:
>
<p>@北天魔狼@斜面镜子 Bil 和@Bin 等同学,都给出了正确答案;<br>
@果然如此 同学提了一个好问题虽然引入事务避免看到”业务上还没提交的更新”但是Redis的计数被提前看到了。核心原因还是两个系统不支持一致性视图<br>
@ 帆帆帆帆帆帆帆帆 同学的问题提醒了大家count(id)也是可以走普通索引得到的。</p>

View File

@@ -0,0 +1,367 @@
<audio id="audio" title="16 | “order by”是怎么工作的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1a/e9/1a7dd4b3fa4cf1d40c85a501997f44e9.mp3"></audio>
在你开发应用的时候一定会经常碰到需要根据指定的字段排序来显示结果的需求。还是以我们前面举例用过的市民表为例假设你要查询城市是“杭州”的所有人名字并且按照姓名排序返回前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 src="https://static001.geekbang.org/resource/image/82/03/826579b63225def812330ef6c344a303.png" alt="">
Extra这个字段中的“Using filesort”表示的就是需要排序MySQL会给每个线程分配一块内存用于排序称为sort_buffer。
为了说明这个SQL查询语句的执行过程我们先来看一下city这个索引的示意图。
<img src="https://static001.geekbang.org/resource/image/53/3e/5334cca9118be14bde95ec94b02f0a3e.png" alt="">
从图中可以看到满足city='杭州条件的行是从ID_X到ID_(X+N)的这些记录。
通常情况下,这个语句执行流程如下所示
<li>
初始化sort_buffer确定放入name、city、age这三个字段
</li>
<li>
从索引city找到第一个满足city='杭州条件的主键id也就是图中的ID_X
</li>
<li>
到主键id索引取出整行取name、city、age三个字段的值存入sort_buffer中
</li>
<li>
从索引city取下一个记录的主键id
</li>
<li>
重复步骤3、4直到city的值不满足查询条件为止对应的主键id也就是图中的ID_Y
</li>
<li>
对sort_buffer中的数据按照字段name做快速排序
</li>
<li>
按照排序结果取前1000行返回给客户端。
</li>
我们暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还会用到这个排序。
<img src="https://static001.geekbang.org/resource/image/6c/72/6c821828cddf46670f9d56e126e3e772.jpg" alt="">
图中“按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 src="https://static001.geekbang.org/resource/image/89/95/89baf99cdeefe90a22370e1d6f5e6495.png" alt="">
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字段的值不能直接返回了整个执行流程就变成如下所示的样子
<li>
初始化sort_buffer确定放入两个字段即name和id
</li>
<li>
从索引city找到第一个满足city='杭州条件的主键id也就是图中的ID_X
</li>
<li>
到主键id索引取出整行取name、id这两个字段存入sort_buffer中
</li>
<li>
从索引city取下一个记录的主键id
</li>
<li>
重复步骤3、4直到不满足city='杭州条件为止也就是图中的ID_Y
</li>
<li>
对sort_buffer中的数据按照字段name进行排序
</li>
<li>
遍历排序结果取前1000行并按照id的值回到原表中取出city、name和age三个字段返回给客户端。
</li>
这个执行流程的示意图如下我把它称为rowid排序。
<img src="https://static001.geekbang.org/resource/image/dc/6d/dc92b67721171206a302eb679c83e86d.jpg" alt="">
对比图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 src="https://static001.geekbang.org/resource/image/27/9b/27f164804d1a4689718291be5d10f89b.png" alt="">
从OPTIMIZER_TRACE的结果中你还能看到另外两个信息也变了。
- sort_mode变成了&lt;sort_key, rowid&gt;表示参与排序的只有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 src="https://static001.geekbang.org/resource/image/f9/bf/f980201372b676893647fb17fac4e2bf.png" alt="">
在这个索引里面我们依然可以用树搜索的方式定位到第一个满足city='杭州的记录并且额外确保了接下来按顺序取“下一条记录”的遍历过程中只要city的值是杭州name的值就一定是有序的。
这样整个查询过程的流程就变成了:
<li>
从索引(city,name)找到第一个满足city='杭州条件的主键id
</li>
<li>
到主键id索引取出整行取name、city、age三个字段的值作为结果集的一部分直接返回
</li>
<li>
从索引(city,name)取下一个记录主键id
</li>
<li>
重复步骤2、3直到查到第1000条记录或者是不满足city='杭州’条件时循环结束。
</li>
<img src="https://static001.geekbang.org/resource/image/3f/92/3f590c3a14f9236f2d8e1e2cb9686692.jpg" alt="">
可以看到这个查询过程不需要临时表也不需要排序。接下来我们用explain的结果来印证一下。
<img src="https://static001.geekbang.org/resource/image/fc/8a/fc53de303811ba3c46d344595743358a.png" alt="">
从图中可以看到Extra字段中没有Using filesort了也就是不需要排序了。而且由于(city,name)这个联合索引本身有序所以这个查询也不用把4000行全都读一遍只要找到满足条件的前1000条记录就可以退出了。也就是说在我们这个例子里只需要扫描1000次。
既然说到这里了,我们再往前讨论,**这个语句的执行流程有没有可能进一步简化呢?**不知道你还记不记得我在第5篇文章[《 深入浅出索引(下)》](https://time.geekbang.org/column/article/69636)中,和你介绍的覆盖索引。
这里我们可以再稍微复习一下。**覆盖索引是指,索引上的信息足够满足查询请求,不需要再回到主键索引上去取数据。**
按照覆盖索引的概念,我们可以再优化一下这个查询语句的执行流程。
针对这个查询我们可以创建一个city、name和age的联合索引对应的SQL语句就是
```
alter table t add index city_user_age(city, name, age);
```
这时对于city字段的值相同的行来说还是按照name字段的值递增排序的此时的查询语句也就不再需要排序了。这样整个查询语句的执行流程就变成了
<li>
从索引(city,name,age)找到第一个满足city='杭州条件的记录取出其中的city、name和age这三个字段的值作为结果集的一部分直接返回
</li>
<li>
从索引(city,name,age)取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接返回;
</li>
<li>
重复执行步骤2直到查到第1000条记录或者是不满足city='杭州’条件时循环结束。
</li>
<img src="https://static001.geekbang.org/resource/image/df/d6/df4b8e445a59c53df1f2e0f115f02cd6.jpg" alt="">
然后我们再来看看explain的结果。
<img src="https://static001.geekbang.org/resource/image/9e/23/9e40b7b8f0e3f81126a9171cc22e3423.png" alt="">
可以看到Extra字段里面多了“Using index”表示的就是使用了覆盖索引性能上会快很多。
当然,这里并不是说要为了每个查询能用上覆盖索引,就要把语句中涉及的字段都建上联合索引,毕竟索引还是有维护代价的。这是一个需要权衡的决定。
# 小结
今天这篇文章我和你介绍了MySQL里面order by语句的几种算法流程。
在开发系统的时候你总是不可避免地会使用到order by语句。你心里要清楚每个语句的排序逻辑是怎么实现的还要能够分析出在最坏情况下每个语句的执行对系统资源的消耗这样才能做到下笔如有神不犯低级错误。
最后,我给你留下一个思考题吧。
假设你的表里面已经有了city_name(city, name)这个联合索引然后你要查杭州和苏州两个城市中所有的市民的姓名并且按名字排序显示前100条记录。如果SQL查询语句是这么写的
```
mysql&gt; select * from t where city in ('杭州',&quot;苏州&quot;) order by name limit 100;
```
那么,这个语句执行的时候会有排序过程吗,为什么?
如果业务端代码由你来开发,需要实现一个在数据库端不需要排序的方案,你会怎么实现呢?
进一步地如果有分页需求要显示第101页也就是说语句最后要改成 “limit 10000,100” 你的实现方法又会是什么呢?
你可以把你的思考和观点写在留言区里,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是当MySQL去更新一行但是要修改的值跟原来的值是相同的这时候MySQL会真的去执行一次修改吗还是看到值相同就直接返回呢
这是第一次我们课后问题的三个选项都有同学选的,所以我要和你需要详细说明一下。
第一个选项是MySQL读出数据发现值与原来相同不更新直接返回执行结束。这里我们可以用一个锁实验来确认。
假设当前表t里的值是(1,2)。
<img src="https://static001.geekbang.org/resource/image/6d/90/6d9d8837560d01b57d252c470157ea90.png" alt="">
session B的update 语句被blocked了加锁这个动作是InnoDB才能做的所以排除选项1。
第二个选项是MySQL调用了InnoDB引擎提供的接口但是引擎发现值与原来相同不更新直接返回。有没有这种可能呢这里我用一个可见性实验来确认。
假设当前表里的值是(1,2)。
<img src="https://static001.geekbang.org/resource/image/44/96/441682b64a3f5dd50f35b12ca4b87c96.png" alt="">
session A的第二个select 语句是一致性读(快照读)它是不能看见session B的更新的。
现在它返回的是(1,3)表示它看见了某个新的版本这个版本只能是session A自己的update语句做更新的时候生成。如果你对这个逻辑有疑惑的话可以回顾下第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中的相关内容)
所以我们上期思考题的答案应该是选项3InnoDB认真执行了“把这个值修改成(1,2)"这个操作,该加锁的加锁,该更新的更新。
然后你会说MySQL怎么这么笨就不会更新前判断一下值是不是相同吗如果判断一下不就不用浪费InnoDB操作多去更新一次了
其实MySQL是确认了的。只是在这个语句里面MySQL认为读出来的值只有一个确定的 (id=1), 而要写的是(a=3),只从这两个信息是看不出来“不需要修改”的。
作为验证,你可以看一下下面这个例子。
<img src="https://static001.geekbang.org/resource/image/63/c1/63dd6df32dacdb827d256e5acb9837c1.png" alt="">
**补充说明:**
上面我们的验证结果都是在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 src="https://static001.geekbang.org/resource/image/d4/89/d413b9235d56c62f9829750a68b06b89.png" alt="">
类似的,@mahonebags 同学提到了timestamp字段的问题。结论是如果表中有timestamp字段而且设置了自动更新的话那么更新“别的字段”的时候MySQL会读入所有涉及的字段这样通过判断就会发现不需要修改。
这两个点我会在后面讲更新性能的文章中再展开。
评论区留言点赞板:
>
<p>@Gavin@melon@阿建 等同学提到了锁验证法;<br>
@郭江伟 同学提到了两个点,都非常好,有去实际验证。结论是这样的:<br>
第一hexdump看出来没改应该是WAL机制生效了要过一会儿或者把库shutdown看看。<br>
第二binlog没写是MySQL Server层知道行的值没变所以故意不写的这个是在row格式下的策略。你可以把binlog_format 改成statement再验证下。</p>

View File

@@ -0,0 +1,368 @@
<audio id="audio" title="17 | 如何正确地显示随机消息?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d2/0f/d2d869276191d77ca7291918606d650f.mp3"></audio>
我在上一篇文章为你讲解完order by语句的几种执行模式后就想到了之前一个做英语学习App的朋友碰到过的一个性能问题。今天这篇文章我就从这个性能问题说起和你说说MySQL中的另外一种排序需求希望能够加深你对MySQL排序逻辑的理解。
这个英语学习App首页有一个随机显示单词的功能也就是根据每个用户的级别有一个单词表然后这个用户每次访问首页的时候都会随机滚动显示三个单词。他们发现随着单词表变大选单词这个逻辑变得越来越慢甚至影响到了首页的打开速度。
现在如果让你来设计这个SQL语句你会怎么写呢
为了便于理解,我对这个例子进行了简化:去掉每个级别的用户都有一个对应的单词表这个逻辑,直接就是从一个单词表中随机选出三个单词。这个表的建表语句和初始数据的命令如下:
```
mysql&gt; 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&lt;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&gt; select word from words order by rand() limit 3;
```
这个语句的意思很直白随机排序取前3个。虽然这个SQL语句写法很简单但执行流程却有点复杂的。
我们先用explain命令来看看这个语句的执行情况。
<img src="https://static001.geekbang.org/resource/image/59/50/59a4fb0165b7ce1184e41f2d061ce350.png" alt="">
Extra字段显示Using temporary表示的是需要使用临时表Using filesort表示的是需要执行排序操作。
因此这个Extra的意思就是需要临时表并且需要在临时表上排序。
这里,你可以先回顾一下[上一篇文章](https://time.geekbang.org/column/article/73479)中全字段排序和rowid排序的内容。我把上一篇文章的两个流程图贴过来方便你复习。
<img src="https://static001.geekbang.org/resource/image/6c/72/6c821828cddf46670f9d56e126e3e772.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/dc/6d/dc92b67721171206a302eb679c83e86d.jpg" alt="">
然后,我再问你一个问题,你觉得对于临时内存表的排序来说,它会选择哪一种算法呢?回顾一下上一篇文章的一个结论:**对于InnoDB表来说**,执行全字段排序会减少磁盘访问,因此会被优先选择。
我强调了“InnoDB表”你肯定想到了**对于内存表,回表过程只是简单地根据数据行的位置,直接访问内存得到数据,根本不会导致多访问磁盘**。优化器没有了这一层顾虑那么它会优先考虑的就是用于排序的行越小越好了所以MySQL这时就会选择rowid排序。
理解了这个算法选择的逻辑,我们再来看看语句的执行流程。同时,通过今天的这个例子,我们来尝试分析一下语句的扫描行数。
这条语句的执行流程是这样的:
<li>
创建一个临时表。这个临时表使用的是memory引擎表里有两个字段第一个字段是double类型为了后面描述方便记为字段R第二个字段是varchar(64)类型记为字段W。并且这个表没有建索引。
</li>
<li>
从words表中按主键顺序取出所有的word值。对于每一个word值调用rand()函数生成一个大于0小于1的随机小数并把这个随机小数和word分别存入临时表的R和W字段中到此扫描行数是10000。
</li>
<li>
现在临时表有10000行数据了接下来你要在这个没有索引的内存临时表上按照字段R排序。
</li>
<li>
初始化 sort_buffer。sort_buffer中有两个字段一个是double类型另一个是整型。
</li>
<li>
从内存临时表中一行一行地取出R值和位置信息我后面会和你解释这里为什么是“位置信息”分别存入sort_buffer中的两个字段里。这个过程要对内存临时表做全表扫描此时扫描行数增加10000变成了20000。
</li>
<li>
在sort_buffer中根据R的值进行排序。注意这个过程没有涉及到表操作所以不会增加扫描行数。
</li>
<li>
排序完成后取出前三个结果的位置信息依次到内存临时表中取出word值返回给客户端。这个过程中访问了表的三行数据总扫描行数变成了20003。
</li>
接下来我们通过慢查询日志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_examined20003就表示这个语句执行过程中扫描了20003行也就验证了我们分析得出的结论。
这里插一句题外话,在平时学习概念的过程中,你可以经常这样做,先通过原理分析算出扫描行数,然后再通过查看慢查询日志,来验证自己的结论。我自己就是经常这么做,这个过程很有趣,分析对了开心,分析错了但是弄清楚了也很开心。
现在,我来把完整的排序执行流程图画出来。
<img src="https://static001.geekbang.org/resource/image/2a/fc/2abe849faa7dcad0189b61238b849ffc.png" alt="">
图中的pos就是位置信息你可能会觉得奇怪这里的“位置信息”是个什么概念在上一篇文章中我们对InnoDB表排序的时候明明用的还是ID字段。
这时候,我们就要回到一个基本概念:**MySQL的表是用什么方法来定位“一行数据”的。**
在前面[第4](https://time.geekbang.org/column/article/69236)和[第5](https://time.geekbang.org/column/article/69636)篇介绍索引的文章中有几位同学问到如果把一个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 src="https://static001.geekbang.org/resource/image/78/ab/78d2db9a4fdba81feadccf6e878b4aab.png" alt="">
然后我们来看一下这次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),先取前三行,构造成一个堆;
(对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组)
<li>
取下一个行(R,rowid)跟当前堆里面最大的R比较如果R小于R把这个(R,rowid)从堆中去掉,换成(R,rowid)
</li>
<li>
重复第2步直到第10000个(R,rowid)完成比较。
</li>
这里我简单画了一个优先队列排序过程的示意图。
<img src="https://static001.geekbang.org/resource/image/e9/97/e9c29cb20bf9668deba8981e444f6897.png" alt="">
图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值可以怎么做呢思路上是这样的
<li>
取得这个表的主键id的最大值M和最小值N;
</li>
<li>
用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
</li>
<li>
取不小于X的第一个ID的行。
</li>
我们把这个算法暂时称作随机算法1。这里我直接给你贴一下执行语句的序列:
```
mysql&gt; select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id &gt;= @X limit 1;
```
这个方法效率很高因为取max(id)和min(id)都是不需要扫描索引的而第三步的select也可以用索引快速定位可以认为就只扫描了3行。但实际上这个算法本身并不严格满足题目的随机要求因为ID中间可能有空洞因此选择不同行的概率不一样不是真正的随机。
比如你有4个id分别是1、2、4、5如果按照上面的方法那么取到 id=4的这一行的概率是取得其他行概率的两倍。
如果这四行的id分别是1、2、40000、40001呢这个算法基本就能当bug来看待了。
所以,为了得到严格随机的结果,你可以用下面这个流程:
<li>
取得整个表的行数并记为C。
</li>
<li>
取得 Y = floor(C * rand())。 floor函数在这里的作用就是取整数部分。
</li>
<li>
再用limit Y,1 取得一行。
</li>
我们把这个算法称为随机算法2。下面这段代码就是上面流程的执行语句的序列。
```
mysql&gt; select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat(&quot;select * from t limit &quot;, @Y, &quot;,1&quot;);
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值呢你可以这么做
<li>
取得整个表的行数记为C
</li>
<li>
根据相同的随机方法得到Y1、Y2、Y3
</li>
<li>
再执行三个limit Y, 1语句得到三行数据。
</li>
我们把这个算法称作随机算法3。下面这段代码就是上面流程的执行语句的序列。
```
mysql&gt; 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 @Y11 //在应用代码里面取Y1、Y2、Y3值拼出SQL后执行
select * from t limit @Y21
select * from t limit @Y31
```
# 小结
今天这篇文章我是借着随机排序的需求跟你介绍了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)联合索引的特性,把这一条语句拆成两条语句,执行流程如下:
<li>
执行select * from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的客户端用一个长度为100的内存数组A保存结果。
</li>
<li>
执行select * from t where city=“苏州” order by name limit 100; 用相同的方法假设结果被存进了内存数组B。
</li>
<li>
现在A和B是两个有序数组然后你可以用归并排序的思想得到name最小的前100值就是我们需要的结果了。
</li>
如果把这条SQL语句里“limit 100”改成“limit 10000,100”的话处理方式其实也差不多要把上面的两条语句改成写
```
select * from t where city=&quot;杭州&quot; order by name limit 10100;
```
```
select * from t where city=&quot;苏州&quot; order by name limit 10100。
```
这时候数据量较大可以同时起两个连接一行行读结果用归并排序算法拿到这两个结果集里按顺序取第10001~10100的name值就是需要的结果了。
当然这个方案有一个明显的损失,就是从数据库返回给客户端的数据量变大了。
所以如果数据的单行比较大的话可以考虑把这两条SQL语句改成下面这种写法
```
select id,name from t where city=&quot;杭州&quot; order by name limit 10100;
```
```
select id,name from t where city=&quot;苏州&quot; order by name limit 10100。
```
然后再用归并排序的方法取得按name顺序第10001~10100的name、id的值然后拿着这100个id到数据库中去查出所有记录。
上面这些方法,需要你根据性能需求和开发的复杂度做出权衡。
评论区留言点赞板:
>
<p>评论区很多同学都提到不能排序,说明各位对索引的存储都理解对了。<br>
@峰 同学提到了归并排序,是我们这个问题解法的核心思想;<br>
@老杨同志 的回答中提到了“从业务上砍掉功能”,这个也确实是在业务设计中可以考虑的一个方向;<br>
@某、人 帮忙回答了@发条橙子同学的问题,尤其是对问题一的回答,非常精彩。</p>

View File

@@ -0,0 +1,333 @@
<audio id="audio" title="18 | 为什么这些SQL语句逻辑相同性能却差异巨大" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/79/46/79eb1abbbbbbdf2ade89fc4156b34d46.mp3"></audio>
在MySQL中有很多看上去逻辑相同但性能却差异巨大的SQL语句。对这些语句使用不当的话就会不经意间导致整个数据库的压力变大。
我今天挑选了三个这样的案例和你分享。希望再遇到相似的问题时,你可以做到举一反三、快速解决问题。
# 案例一:条件字段函数操作
假设你现在维护了一个交易系统其中交易记录表tradelog包含交易流水号tradeid、交易员idoperator、交易时间t_modified等字段。为了便于描述我们先忽略其他字段。这个表的建表语句如下
```
mysql&gt; 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&gt; 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 src="https://static001.geekbang.org/resource/image/3e/86/3e30d9a5e67f711f5af2e2599e800286.png" alt="">
如果你的SQL语句条件用的是where t_modified='2018-7-1的话引擎就会按照上面绿色箭头的路线快速定位到 t_modified='2018-7-1需要的结果。
实际上B+树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。
但是如果计算month()函数的话你会看到传入7的时候在树的第一层就不知道该怎么办了。
也就是说,**对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。**
需要注意的是,优化器并不是要放弃使用这个索引。
在这个例子里放弃了树搜索功能优化器可以选择遍历主键索引也可以选择遍历索引t_modified优化器对比索引大小后发现索引t_modified更小遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引t_modified。
接下来我们使用explain命令查看一下这条SQL语句的执行结果。
<img src="https://static001.geekbang.org/resource/image/27/55/27c2f5ff3549b18ba37a28f4919f3655.png" alt="">
key="t_modified"表示的是使用了t_modified这个索引我在测试表数据中插入了10万行数据rows=100335说明这条语句扫描了整个索引的所有值Extra字段的Using index表示的是使用了覆盖索引。
也就是说由于在t_modified字段加了month()函数操作导致了全索引扫描。为了能够用上索引的快速定位能力我们就要把SQL语句改成基于字段本身的范围查询。按照下面这个写法优化器就能按照我们预期的用上t_modified索引的快速定位能力了。
```
mysql&gt; select count(*) from tradelog where
-&gt; (t_modified &gt;= '2016-7-1' and t_modified&lt;'2016-8-1') or
-&gt; (t_modified &gt;= '2017-7-1' and t_modified&lt;'2017-8-1') or
-&gt; (t_modified &gt;= '2018-7-1' and t_modified&lt;'2018-8-1');
```
当然,如果你的系统上线时间更早,或者后面又插入了之后年份的数据的话,你就需要再把其他年份补齐。
到这里我给你说明了由于加了month()函数操作MySQL无法再使用索引快速定位功能而只能使用全索引扫描。
不过优化器在个问题上确实有“偷懒”行为即使是对于不改变有序性的函数也不会考虑使用索引。比如对于select * from tradelog where id + 1 = 10000这个SQL语句这个加1操作并不会改变有序性但是MySQL优化器还是不能用id索引快速定位到9999这一行。所以需要你在写SQL语句的时候手动改写成 where id = 10000 -1才可以。
# 案例二:隐式类型转换
接下来我再跟你说一说,另一个经常让程序员掉坑里的例子。
我们一起看一下这条SQL语句
```
mysql&gt; select * from tradelog where tradeid=110717;
```
交易编号tradeid这个字段上本来就有索引但是explain的结果却显示这条语句需要走全表扫描。你可能也发现了tradeid的字段类型是varchar(32),而输入的参数却是整型,所以需要做类型转换。
那么,现在这里就有两个问题:
<li>
数据类型转换的规则是什么?
</li>
<li>
为什么有数据类型转换,就需要走全索引扫描?
</li>
先来看第一个问题,你可能会说,数据库里面类型这么多,这种数据类型转换规则更多,我记不住,应该怎么办呢?
这里有一个简单的方法,看 select “10” &gt; 9的结果
<li>
如果规则是“将字符串转成数字”那么就是做数字比较结果应该是1
</li>
<li>
如果规则是“将数字转成字符串”那么就是做字符串比较结果应该是0。
</li>
验证结果如图3所示。
<img src="https://static001.geekbang.org/resource/image/2b/14/2b67fc38f1651e2622fe21d49950b214.png" alt="">
从图中可知select “10” &gt; 9返回的是1所以你就能确认MySQL里的转换规则了在MySQL中字符串和数字做比较的话是将字符串转换成数字。
这时,你再看这个全表扫描的语句:
```
mysql&gt; select * from tradelog where tradeid=110717;
```
就知道对于优化器来说,这个语句相当于:
```
mysql&gt; select * from tradelog where CAST(tradid AS signed int) = 110717;
```
也就是说,这条语句触发了我们上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜索功能。
现在我留给你一个小问题id的类型是int如果执行下面这个语句是否会导致全表扫描呢
```
select * from tradelog where id=&quot;83126&quot;;
```
你可以先自己分析一下,再到数据库里面去验证确认。
接下来,我们再来看一个稍微复杂点的例子。
# 案例三:隐式字符编码转换
假设系统里还有另外一个表trade_detail用于记录交易的操作细节。为了便于量化分析和复现我往交易日志表tradelog和交易详情表trade_detail这两个表里插入一些数据。
```
mysql&gt; 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&gt; select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /*语句Q1*/
```
<img src="https://static001.geekbang.org/resource/image/ad/22/adfe464af1d15f3261b710a806c0fa22.png" alt="">
我们一起来看下这个结果:
<li>
第一行显示优化器会先在交易记录表tradelog上查到id=2的行这个步骤用上了主键索引rows=1表示只扫描一行
</li>
<li>
第二行key=NULL表示没有用上交易详情表trade_detail上的tradeid索引进行了全表扫描。
</li>
在这个执行计划里是从tradelog表中取tradeid字段再去trade_detail表里查询匹配字段。因此我们把tradelog称为驱动表把trade_detail称为被驱动表把tradeid称为关联字段。
接下来我们看下这个explain结果表示的执行流程
<img src="https://static001.geekbang.org/resource/image/82/a9/8289c184c8529acea0269a7460dc62a9.png" alt="">
图中:
- 第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&gt; 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&gt;select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;
```
<img src="https://static001.geekbang.org/resource/image/92/11/92cb498ceb3557e41700fae53ce9bd11.png" alt="">
这个语句里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&gt; select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;
```
<img src="https://static001.geekbang.org/resource/image/aa/d6/aa844a7bf35d330b9ec96fc159331bd6.png" alt="">
这里,我主动把 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&gt; select * from t limit N, M-N+1;
```
再加上取整个表总行数的C行这个方案的扫描行数总共只需要C+M+1行。
当然也可以先取回id值在应用中确定了三个id值以后再执行三次where id=X的语句也是可以的。@倪大人 同学在评论区就提到了这个方法。
这次评论区出现了很多很棒的留言:
>
<p>@老杨同志 提出了重新整理的方法、@雪中鼠[悠闲] 提到了用rowid的方法是类似的思路就是让表里面保存一个无空洞的自增值这样就可以用我们的随机算法1来实现<br>
@吴宇晨 提到了拿到第一个值以后用id迭代往下找的方案利用了主键索引的有序性。</p>

View File

@@ -0,0 +1,295 @@
<audio id="audio" title="19 | 为什么我只查一行的语句,也执行这么慢?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/77/67/77c55a8a64efdb8b272b4f4d1467a367.mp3"></audio>
一般情况下,如果我跟你说查询性能优化,你首先会想到一些复杂的语句,想到查询需要返回大量的数据。但有些情况下,“查一行”,也会执行得特别慢。今天,我就跟你聊聊这个有趣的话题,看看什么情况下,会出现这个现象。
需要说明的是如果MySQL数据库本身就有很大的压力导致数据库服务器CPU占用率很高或ioutilIO利用率很高这种情况下所有语句的执行都有可能变慢不属于我们今天的讨论范围。
为了便于描述我还是构造一个表基于这个表来说明今天的问题。这个表有两个字段id和c并且我在里面插入了10万行记录。
```
mysql&gt; 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&lt;=100000) do
insert into t values(i,i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
```
接下来,我会用几个不同的场景来举例,有些是前面的文章中我们已经介绍过的知识点,你看看能不能一眼看穿,来检验一下吧。
# 第一类:查询长时间不返回
如图1所示在表t执行下面的SQL语句
```
mysql&gt; select * from t where id=1;
```
查询结果长时间不返回。
<img src="https://static001.geekbang.org/resource/image/87/2a/8707b79d5ed906950749f5266014f22a.png" alt="">
一般碰到这种情况的话大概率是表t被锁住了。接下来分析原因的时候一般都是首先执行一下show processlist命令看看当前语句处于什么状态。
然后我们再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。
## 等MDL锁
如图2所示就是使用show processlist命令查看Waiting for table metadata lock的示意图。
<img src="https://static001.geekbang.org/resource/image/50/28/5008d7e9e22be88a9c80916df4f4b328.png" alt="">
出现**这个状态表示的是现在有一个线程正在表t上请求或者持有MDL写锁把select语句堵住了。**
在第6篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》](https://time.geekbang.org/column/article/69862)中我给你介绍过一种复现方法。但需要说明的是那个复现过程是基于MySQL 5.6版本的。而MySQL 5.7版本修改了MDL的加锁策略所以就不能复现这个场景了。
不过在MySQL 5.7版本下复现这个场景也很容易。如图3所示我给出了简单的复现步骤。<br>
<img src="https://static001.geekbang.org/resource/image/74/ca/742249a31b83f4858c51bfe106a5daca.png" alt="">
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 src="https://static001.geekbang.org/resource/image/74/01/74fb24ba3826e3831eeeff1670990c01.png" alt="">
## 等flush
接下来,我给你举另外一种查询被堵住的情况。
我在表t上执行下面的SQL语句
```
mysql&gt; select * from information_schema.processlist where id=1;
```
这里,我先卖个关子。
你可以看一下图5。我查出来这个线程的状态是Waiting for table flush你可以设想一下这是什么原因。<br>
<img src="https://static001.geekbang.org/resource/image/2d/24/2d8250398bc7f8f7dce8b6b1923c3724.png" alt="">
这个状态表示的是现在有一个线程正要对表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 src="https://static001.geekbang.org/resource/image/2b/9c/2bbc77cfdb118b0d9ef3fdd679d0a69c.png" alt="">
在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 src="https://static001.geekbang.org/resource/image/39/7e/398407014180be4146c2d088fc07357e.png" alt="">
## 等行锁
现在经过了表级锁的考验我们的select 语句终于来到引擎里了。
```
mysql&gt; select * from t where id=1 lock in share mode;
```
上面这条语句的用法你也很熟悉了我们在第8篇[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)文章介绍当前读时提到过。
由于访问id=1这个记录时要加读锁如果这时候已经有一个事务在这行记录上持有一个写锁我们的select语句就会被堵住。
复现步骤和现场如下:
<img src="https://static001.geekbang.org/resource/image/3e/75/3e68326b967701c59770612183277475.png" alt="">
<img src="https://static001.geekbang.org/resource/image/3c/8f/3c266e23fc307283aa94923ecbbc738f.png" alt="">
显然session A启动了事务占有写锁还不提交是导致session B被堵住的原因。
这个问题并不难分析但问题是怎么查出是谁占着这个写锁。如果你用的是MySQL 5.7版本可以通过sys.innodb_lock_waits 表查到。
查询方法是:
```
mysql&gt; select * from t sys.innodb_lock_waits where locked_table='`test`.`t`'\G
```
<img src="https://static001.geekbang.org/resource/image/d8/18/d8603aeb4eaad3326699c13c46379118.png" alt="">
可以看到这个信息很全4号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式就是KILL QUERY 4或KILL 4。
不过这里不应该显示“KILL QUERY 4”。这个命令表示停止4号线程当前正在执行的语句而这个方法其实是没有用的。因为占有行锁的是update语句这个语句已经是之前执行完成了的现在执行KILL QUERY无法让这个事务去掉id=1上的行锁。
实际上KILL 4才有效也就是说直接断开这个连接。这里隐含的一个逻辑就是连接被断开的时候会自动回滚这个连接里面正在执行的线程也就释放了id=1上的行锁。
# 第二类:查询慢
经过了重重封“锁”,我们再来看看一些查询慢的例子。
先来看一条你一定知道原因的SQL语句
```
mysql&gt; select * from t where c=50000 limit 1;
```
由于字段c上没有索引这个语句只能走id主键顺序扫描因此需要扫描5万行。
作为确认你可以看一下慢查询日志。注意这里为了把所有语句记录到slow log里我在连接后先执行了 set long_query_time=0将慢查询日志的时间阈值设置为0。
<img src="https://static001.geekbang.org/resource/image/d8/3c/d8b2b5f97c60ae4fc4a03c616847503c.png" alt="">
Rows_examined显示扫描了50000行。你可能会说不是很慢呀11.5毫秒就返回了我们线上一般都配置超过1秒才算慢查询。但你要记住**坏查询不一定是慢查询**。我们这个例子里面只有10万行记录数据量大起来的话执行时间就线性涨上去了。
扫描行数多,所以执行慢,这个很好理解。
但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。
如图12所示是这个例子的slow log。可以看到执行的语句是
```
mysql&gt; select * from t where id=1
```
虽然扫描行数是1但执行时间却长达800毫秒。
<img src="https://static001.geekbang.org/resource/image/66/46/66f26bb885401e8e460451ff6b0c0746.png" alt="">
是不是有点奇怪呢,这些时间都花在哪里了?
如果我把这个slow log的截图再往下拉一点你可以看到下一个语句select * from t where id=1 lock in share mode执行时扫描行数也是1行执行时间是0.2毫秒。
<img src="https://static001.geekbang.org/resource/image/bd/d2/bde83e269d9fa185b27900c8aa8137d2.png" alt="">
看上去是不是更奇怪了按理说lock in share mode还要加锁时间应该更长才对啊。
可能有的同学已经有答案了。如果你还没有答案的话我再给你一个提示信息图14是这两个语句的执行输出结果。
<img src="https://static001.geekbang.org/resource/image/1f/1c/1fbb84bb392b6bfa93786fe032690b1c.png" alt="">
第一个语句的查询结果里c=1带lock in share mode的语句返回的是c=1000001。看到这里应该有更多的同学知道原因了。如果你还是没有头绪的话也别着急。我先跟你说明一下复现步骤再分析原因。
<img src="https://static001.geekbang.org/resource/image/84/ff/84667a3449dc846e393142600ee7a2ff.png" alt="">
你看到了session A先用start transaction with consistent snapshot命令启动了一个事务之后session B才开始执行update 语句。
session B执行完100万次update语句后id=1这一行处于什么状态呢你可以从图16中找到答案。
<img src="https://static001.geekbang.org/resource/image/46/8c/46bb9f5e27854678bfcaeaf0c3b8a98c.png" alt="">
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&gt; 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&gt; select * from table_a where b='1234567890abcd';
```
这时候MySQL会怎么执行呢
最理想的情况是MySQL看到字段b定义的是varchar(10)那肯定返回空呀。可惜MySQL并没有这么做。
那要不就是把1234567890abcd拿到索引里面去做匹配肯定也没能够快速判断出索引树b上并没有这个值也很快就能返回空结果。
但实际上MySQL也不是这么做的。
这条SQL语句的执行很慢流程是这样的
<li>
在传给引擎执行的时候做了字符截断。因为引擎里面这个行只定义了长度是10所以只截了前10个字节就是1234567890进去做匹配
</li>
<li>
这样满足条件的数据有10万行
</li>
<li>
因为是select * 所以要做10万次回表
</li>
<li>
但是每次回表以后查出整行到server层一判断b的值都不是1234567890abcd;
</li>
<li>
返回结果是空。
</li>
这个例子是我们文章内容的一个很好的补充。虽然执行过程中可能经过函数操作但是最终在拿到结果后server层还是要做一轮判断的。
评论区留言点赞板:
>
<p>@赖阿甘 提到了等号顺序问题实际上MySQL优化器执行过程中where 条件部分, a=b和 b=a的写法是一样的。<br>
@沙漠里的骆驼 提到了一个常见的问题。相同的模板语句但是匹配行数不同语句执行时间相差很大。这种情况在语句里面有order by这样的操作时会更明显。<br>
@Justin 回答了我们正文中的问题如果id 的类型是整数,传入的参数类型是字符串的时候,可以用上索引。</p>

View File

@@ -0,0 +1,323 @@
<audio id="audio" title="20 | 幻读是什么,幻读有什么问题?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c9/af/c9756d0344d7c966f32d6f2e768bedaf.mp3"></audio>
在上一篇文章最后,我给你留了一个关于加锁规则的问题。今天,我们就从这个问题说起吧。
为了便于说明问题,这一篇文章,我们就先使用一个小一点儿的表。建表和初始化语句如下(为了便于本期的例子说明,我把上篇文章中用到的表结构做了点儿修改):
```
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 src="https://static001.geekbang.org/resource/image/5b/8b/5bc506e5884d21844126d26bbe6fa68b.png" alt="">
可以看到session A里执行了三次查询分别是Q1、Q2和Q3。它们的SQL语句相同都是select * from t where d=5 for update。这个语句的意思你应该很清楚了查所有d=5的行而且使用的是当前读并且加上写锁。现在我们来看一下这三条SQL语句分别会返回什么结果。
<li>
Q1只返回id=5这一行
</li>
<li>
在T2时刻session B把id=0这一行的d值改成了5因此T3时刻Q2查出来的是id=0和id=5这两行
</li>
<li>
在T4时刻session C又插入一行1,1,5因此T5时刻Q3查出来的是id=0、id=1和id=5的这三行。
</li>
其中Q3读到id=1这一行的现象被称为“幻读”。也就是说幻读指的是一个事务在前后两次查询同一个范围的时候后一次查询看到了前一次查询没有看到的行。
这里,我需要对“幻读”做一个说明:
<li>
在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。
</li>
<li>
上面session B的修改结果被session A之后的select语句用“当前读”看到不能称为幻读。幻读仅专指“新插入的行”。
</li>
如果只从第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)我们学到的事务可见性规则来分析的话上面这三条SQL语句的返回结果都没有问题。
因为这三个查询都是加了for update都是当前读。而当前读的规则就是要能读到所有已经提交的记录的最新值。并且session B和sessionC的两条语句执行后就会提交所以Q2和Q3就是应该看到这两个事务的操作效果而且也看到了这跟事务的可见性规则并不矛盾。
但是,这是不是真的没问题呢?
不,这里还真就有问题。
# 幻读有什么问题?
**首先是语义上的。**session A在T1时刻就声明了“我要把所有d=5的行锁住不准别的事务进行读写操作”。而实际上这个语义被破坏了。
如果现在这样看感觉还不明显的话我再往session B和session C里面分别加一条SQL语句你再看看会出现什么现象。
<img src="https://static001.geekbang.org/resource/image/7a/07/7a9ffa90ac3cc78db6a51ff9b9075607.png" alt="">
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 src="https://static001.geekbang.org/resource/image/dc/92/dcea7845ff0bdbee2622bf3c67d31d92.png" alt="">
update的加锁语义和select …for update 是一致的所以这时候加上这条update语句也很合理。session A声明说“要给d=5的语句加上锁”就是为了要更新数据新加的这条update语句就是把它认为加上了锁的这一行的d值修改成了100。
现在我们来分析一下图3执行完成后数据库里会是什么结果。
<li>
经过T1时刻id=5这一行变成 (5,5,100)当然这个结果最终是在T6时刻正式提交的;
</li>
<li>
经过T2时刻id=0这一行变成(0,5,5);
</li>
<li>
经过T4时刻表里面多了一行(1,5,5);
</li>
<li>
其他行跟这个执行序列无关,保持不变。
</li>
这样看这些数据也没啥问题但是我们再来看看这时候binlog里面的内容。
<li>
T2时刻session B事务提交写入了两条语句
</li>
<li>
T4时刻session C事务提交写入了两条语句
</li>
<li>
T6时刻session A事务提交写入了update t set d=100 where d=5 这条语句。
</li>
我统一放到一起的话,就是这样的:
```
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 src="https://static001.geekbang.org/resource/image/34/47/34ad6478281709da833856084a1e3447.png" alt="">
由于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 src="https://static001.geekbang.org/resource/image/e7/61/e7f7ca0d3dab2f48c588d714ee3ac861.png" alt="">
这样,当你执行 select * from t where d=5 for update的时候就不止是给数据库中已有的6个记录加上了行锁还同时加了7个间隙锁。这样就确保了无法再插入新的记录。
也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。
现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是间隙锁跟我们之前碰到过的锁都不太一样。
比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。
<img src="https://static001.geekbang.org/resource/image/c4/51/c435c765556c0f3735a6eda0779ff151.png" alt="">
也就是说,跟行锁有冲突关系的是“另外一个行锁”。
但是间隙锁不一样,**跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作。**间隙锁之间都不存在冲突关系。
这句话不太好理解,我给你举个例子:
<img src="https://static001.geekbang.org/resource/image/7c/98/7c37732d936650f1cda7dbf27daf7498.png" alt="">
这里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 src="https://static001.geekbang.org/resource/image/df/be/df37bf0bb9f85ea59f0540e24eb6bcbe.png" alt="">
你看到了其实都不需要用到后面的update语句就已经形成死锁了。我们按语句执行顺序来分析一下
<li>
session A 执行select … for update语句由于id=9这一行并不存在因此会加上间隙锁(5,10);
</li>
<li>
session B 执行select … for update语句同样会加上间隙锁(5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
</li>
<li>
session B 试图插入一行(9,9,9)被session A的间隙锁挡住了只好进入等待
</li>
<li>
session A试图插入一行(9,9,9)被session B的间隙锁挡住了。
</li>
至此两个session进入互相等待状态形成死锁。当然InnoDB的死锁检测马上就发现了这对死锁关系让session A的insert语句报错返回了。
你现在知道了,**间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的**。其实,这还只是一个简单的例子,在下一篇文章中我们还会碰到更多、更复杂的例子。
你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法呢。
我在文章一开始就说过如果没有特别说明今天和你分析的问题都是在可重复读隔离级别下的间隙锁是在可重复读隔离级别下才会生效的。所以你如果把隔离级别设置为读提交的话就没有间隙锁了。但同时你要解决可能出现的数据和日志不一致问题需要把binlog格式设置为row。这也是现在不少公司使用的配置组合。
前面文章的评论区有同学留言说他们公司就使用的是读提交隔离级别加binlog_format=row的组合。他曾问他们公司的DBA说你为什么要这么配置。DBA直接答复说因为大家都这么用呀。
所以,这个同学在评论区就问说,这个配置到底合不合理。
关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。
但其实我想说的是,配置是否合理,跟业务场景有关,需要具体问题具体分析。
但是如果DBA认为之所以这么用的原因是“大家都这么用”那就有问题了或者说迟早会出问题。
比如说大家都用读提交可是逻辑备份的时候mysqldump为什么要把备份线程设置成可重复读呢这个我在前面的文章中已经解释过了你可以再回顾下第6篇文章[《全局锁和表锁 :给表加个字段怎么有这么多阻碍?》](https://time.geekbang.org/column/article/69862)的内容)
然后,在备份期间,备份线程用的是可重复读,而业务线程用的是读提交。同时存在两种事务隔离级别,会不会有问题?
进一步地,这两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的?
如果业务开发和运维团队这些问题都没有弄清楚,那么“没问题”这个结论,本身就是有问题的。
# 小结
今天我们从上一篇文章的课后问题说起,提到了全表扫描的加锁方式。我们发现即使给所有的行都加上行锁,仍然无法解决幻读问题,因此引入了间隙锁的概念。
我碰到过很多对数据库有一定了解的业务开发人员他们在设计数据表结构和业务SQL语句的时候对行锁有很准确的认识但却很少考虑到间隙锁。最后的结果就是生产库上会经常出现由于间隙锁导致的死锁现象。
行锁确实比较直观判断规则也相对简单间隙锁的引入会影响系统的并发度也增加了锁分析的复杂度但也有章可循。下一篇文章我就会为你讲解InnoDB的加锁规则帮你理顺这其中的“章法”。
作为对下一篇文章的预习,我给你留下一个思考题。
<img src="https://static001.geekbang.org/resource/image/0d/3d/0d796060073668ca169166a8903fbf3d.png" alt="">
如果你之前没有了解过本篇文章的相关内容一定觉得这三个语句简直是风马牛不相及。但实际上这里session B和session C的insert 语句都会进入锁等待状态。
你可以试着分析一下,出现这种情况的原因是什么?
这里需要说明的是这其实是我在下一篇文章介绍加锁规则后才能回答的问题是留给你作为预习的其中session C被锁住这个分析是有点难度的。如果你没有分析出来也不要气馁我会在下一篇文章和你详细说明。
你也可以说说你的线上MySQL配置的是什么隔离级别为什么会这么配置你有没有碰到什么场景是必须使用可重复读隔离级别的呢
你可以把你的碰到的场景和分析写在留言区里,我会在下一篇文章选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我们在本文的开头回答了上期问题。有同学的回答中还说明了读提交隔离级别下在语句执行完成后是只有行锁的。而且语句执行完成后InnoDB就会把不满足条件的行行锁去掉。
当然了c=5这一行的行锁还是会等到commit的时候才释放的。
评论区留言点赞板:
>
<p>@薛畅@张永志同学给出了正确答案。而且提到了在读提交隔离级别下,是只有行锁的。<br>
@帆帆帆帆帆帆帆帆@欧阳成 对上期的例子做了验证需要说明一下需要在启动配置里面增加performance_schema=on才能用上这个功能performance_schema库里的表才有数据。</p>

View File

@@ -0,0 +1,290 @@
<audio id="audio" title="21 | 为什么我只改一行的语句,锁这么多?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8d/71/8ddd3f3ca71ac7f4b710d56846546f71.mp3"></audio>
在上一篇文章中我和你介绍了间隙锁和next-key lock的概念但是并没有说明加锁规则。间隙锁的概念理解起来确实有点儿难尤其在配合上行锁以后很容易在判断是否会出现锁等待的问题上犯错。
所以今天,我们就先从这个加锁规则开始吧。
首先说明一下,这些加锁规则我没在别的地方看到过有类似的总结,以前我自己判断的时候都是想着代码里面的实现来脑补的。这次为了总结成不看代码的同学也能理解的规则,是我又重新刷了代码临时总结出来的。所以,**这个规则有以下两条前提说明:**
<li>
MySQL后面的版本可能会改变加锁策略所以这个规则只限于截止到现在的最新版本即5.x系列&lt;=5.7.248.0系列 &lt;=8.0.13。
</li>
<li>
如果大家在验证中有发现bad case的话请提出来我会再补充进这篇文章使得一起学习本专栏的所有同学都能受益。
</li>
因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认是可重复读隔离级别。
**我总结的加锁规则里面包含了两个“原则”、两个“优化”和一个“bug”。**
<li>
原则1加锁的基本单位是next-key lock。希望你还记得next-key lock是前开后闭区间。
</li>
<li>
原则2查找过程中访问到的对象才会加锁。
</li>
<li>
优化1索引上的等值查询给唯一索引加锁的时候next-key lock退化为行锁。
</li>
<li>
优化2索引上的等值查询向右遍历时且最后一个值不满足等值条件的时候next-key lock退化为间隙锁。
</li>
<li>
一个bug唯一索引上的范围查询会访问到不满足条件的第一个值为止。
</li>
我还是以上篇文章的表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 src="https://static001.geekbang.org/resource/image/58/6c/585dfa8d0dd71171a6fa16bed4ba816c.png" alt="">
由于表t中没有id=7的记录所以用我们上面提到的加锁规则判断一下的话
<li>
根据原则1加锁单位是next-key locksession A加锁范围就是(5,10]
</li>
<li>
同时根据优化2这是一个等值查询(id=7)而id=10不满足查询条件next-key lock退化成间隙锁因此最终加锁的范围是(5,10)。
</li>
所以session B要往这个间隙里面插入id=8的记录会被锁住但是session C修改id=10这行是可以的。
# 案例二:非唯一索引等值锁
第二个例子是关于覆盖索引上的锁:
<img src="https://static001.geekbang.org/resource/image/46/65/465990fe8f6b418ca3f9992bd1bb5465.png" alt="">
看到这个例子,你是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?我们来分析一下吧。
这里session A要给索引c上c=5的这一行加上读锁。
<li>
根据原则1加锁单位是next-key lock因此会给(0,5]加上next-key lock。
</li>
<li>
要注意c是普通索引因此仅访问c=5这一条记录是不能马上停下来的需要向右遍历查到c=10才放弃。根据原则2访问到的都要加锁因此要给(5,10]加next-key lock。
</li>
<li>
但是同时这个符合优化2等值判断向右遍历最后一个值不满足c=5这个等值条件因此退化成间隙锁(5,10)。
</li>
<li>
根据原则2 **只有访问到的对象才会加锁**这个查询使用覆盖索引并不需要访问主键索引所以主键索引上没有加任何锁这就是为什么session B的update语句可以执行完成。
</li>
但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&gt; select * from t where id=10 for update;
mysql&gt; select * from t where id&gt;=10 and id&lt;11 for update;
```
你可能会想id定义为int类型这两个语句就是等价的吧其实它们并不完全等价。
在逻辑上这两条查语句肯定是等价的但是它们的加锁规则不太一样。现在我们就让session A执行第二个查询语句来看看加锁效果。
<img src="https://static001.geekbang.org/resource/image/30/80/30b839bf941f109b04f1a36c302aea80.png" alt="">
现在我们就用前面提到的加锁规则来分析一下session A 会加什么锁呢?
<li>
开始执行的时候要找到第一个id=10的行因此本该是next-key lock(5,10]。 根据优化1 主键id上的等值条件退化成行锁只加了id=10这一行的行锁。
</li>
<li>
范围查找就往后继续找找到id=15这一行停下来因此需要加next-key lock(10,15]。
</li>
所以session A这时候锁的范围就是主键索引上行锁id=10和next-key lock(10,15]。这样session B和session C的结果你就能理解了。
这里你需要注意一点首次session A定位查找id=10的行的时候是当做等值查询来判断的而向右扫描到id=15的时候用的是范围查询判断。
# 案例四:非唯一索引范围锁
接下来,我们再看两个范围查询加锁的例子,你可以对照着案例三来看。
需要注意的是与案例三不同的是案例四中查询语句的where部分用的是字段c。
<img src="https://static001.geekbang.org/resource/image/73/7a/7381475e9e951628c9fc907f5a57697a.png" alt="">
这次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 src="https://static001.geekbang.org/resource/image/b1/6d/b105f8c4633e8d3a84e6422b1b1a316d.png" alt="">
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&gt; insert into t values(30,10,30);
```
新插入的这一行c=10也就是说现在表里有两个c=10的行。那么这时候索引c上的间隙是什么状态了呢你要知道由于非唯一索引上包含主键的值所以是不可能存在“相同”的两行的。
<img src="https://static001.geekbang.org/resource/image/c1/59/c1fda36c1502606eb5be3908011ba159.png" alt="">
可以看到虽然有两个c=10但是它们的主键值id是不同的分别是10和30因此这两个c=10的记录之间也是有间隙的。
图中我画出了索引c上的主键id。为了跟间隙锁的开区间形式进行区别我用(c=10,id=30)这样的形式,来表示索引上的一行。
现在,我们来看一下案例六。
这次我们用delete语句来验证。注意delete语句加锁的逻辑其实跟select ... for update 是类似的也就是我在文章开始总结的两个“原则”、两个“优化”和一个“bug”。
<img src="https://static001.geekbang.org/resource/image/b5/78/b55fb0a1cac3500b60e1cf9779d2da78.png" alt="">
这时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上的加锁范围就是下图中蓝色区域覆盖的部分。<br>
<img src="https://static001.geekbang.org/resource/image/bb/24/bb0ad92483d71f0dcaeeef278f89cb24.png" alt="">
这个蓝色区域左右两边都是虚线,表示开区间,即(c=5,id=5)和(c=15,id=15)这两行上都没有锁。
# 案例七limit 语句加锁
例子6也有一个对照案例场景如下所示
<img src="https://static001.geekbang.org/resource/image/af/2e/afc3a08ae7a254b3251e41b2a6dae02e.png" alt="">
这个例子里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)这个前开后闭区间,如下图所示:<br>
<img src="https://static001.geekbang.org/resource/image/e5/d5/e5408ed94b3d44985073255db63bd0d5.png" alt="">
可以看到,(c=10,id=30之后的这个间隙并没有在加锁范围里因此insert语句插入c=12是可以执行成功的。
这个例子对我们实践的指导意义就是,**在删除数据的时候尽量加limit**。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
# 案例八:一个死锁的例子
前面的例子中我们在分析的时候是按照next-key lock的逻辑来分析的因为这样分析比较方便。最后我们再看一个案例目的是说明next-key lock实际上是间隙锁和行锁加起来的结果。
你一定会疑惑,这个概念不是一开始就说了吗?不要着急,我们先来看下面这个例子:
<img src="https://static001.geekbang.org/resource/image/7b/06/7b911a4c995706e8aa2dd96ff0f36506.png" alt="">
现在,我们按时间顺序来分析一下为什么是这样的结果。
<li>
session A 启动事务后执行查询语句加lock in share mode在索引c上加了next-key lock(5,10] 和间隙锁(10,15)
</li>
<li>
session B 的update语句也要在索引c上加next-key lock(5,10] ,进入锁等待;
</li>
<li>
然后session A要再插入(8,8,8)这一行被session B的间隙锁锁住。由于出现了死锁InnoDB让session B回滚。
</li>
你可能会问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操作会被锁住呢<br>
<img src="https://static001.geekbang.org/resource/image/3a/1e/3a7578e104612a188a2d574eaa3bd81e.png" alt="">
另外,如果你有兴趣多做一些实验的话,可以设计好语句序列,在执行之前先自己分析一下,然后实际地验证结果是否跟你的分析一致。
对于那些你自己无法解释的结果,可以发到评论区里,后面我争取挑一些有趣的案例在文章中分析。
你可以把你关于思考题的分析写在留言区,也可以分享你自己设计的锁验证方案,我会在下一篇文章的末尾选取有趣的评论跟大家分享。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
## 上期问题时间
上期的问题,我在本期继续作为了课后思考题,所以会在下篇文章再一起公布“答案”。
这里,我展开回答一下评论区几位同学的问题。
- @令狐少侠 说,以前一直认为间隙锁只在二级索引上有。现在你知道了,有间隙的地方就可能有间隙锁。
<li>@浪里白条 同学问如果是varchar类型加锁规则是什么样的。<br>
回答实际上在判断间隙的时候varchar和int是一样的排好序以后相邻两个值之间就有间隙。</li>
- 有几位同学提到说上一篇文章自己验证的结果跟案例一不同就是在session A执行完这两个语句
```
begin;
select * from t where d=5 for update; /*Q1*/
```
以后session B 的update 和session C的insert 都会被堵住。这是不是跟文章的结论矛盾?
其实不是的这个例子用的是反证假设就是假设不堵住会出现问题然后推导出session A需要锁整个表所有的行和所有间隙。
评论区留言点赞板:
>
@ 某、人 、@郭江伟 两位同学尝试分析了上期问题,并给了有启发性的解答。

View File

@@ -0,0 +1,243 @@
<audio id="audio" title="22 | MySQL有哪些“饮鸩止渴”提高性能的方法" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fe/b0/fecb446f3d865de1ff925bfaadae40b0.mp3"></audio>
不知道你在实际运维过程中有没有碰到这样的情景业务高峰期生产环境的MySQL压力太大没法正常响应需要短期内、临时性地提升一些性能。
我以前做业务护航的时候,就偶尔会碰上这种场景。用户的开发负责人说,不管你用什么方案,让业务先跑起来再说。
但,如果是无损方案的话,肯定不需要等到这个时候才上场。今天我们就来聊聊这些临时方案,并着重说一说它们可能存在的风险。
# 短连接风暴
正常的短连接模式就是连接到数据库后执行很少的SQL语句就断开下次需要的时候再重连。如果使用的是短连接在业务高峰期的时候就可能出现连接数突然暴涨的情况。
我在第1篇文章[《基础架构一条SQL查询语句是如何执行的](https://time.geekbang.org/column/article/68319)中说过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 src="https://static001.geekbang.org/resource/image/90/2a/9091ff280592c8c68665771b1516c62a.png" alt="">
在上面这个例子里如果断开session A的连接因为这时候session A还没有提交所以MySQL只能按照回滚事务来处理而断开session B的连接就没什么大影响。所以如果按照优先级来说你应该优先断开像session B这样的事务外空闲的连接。
但是怎么判断哪些是事务外空闲的呢session C在T时刻之后的30秒执行show processlist看到的结果是这样的。
<img src="https://static001.geekbang.org/resource/image/ae/25/ae6a9ceecf8517e47f9ebfc565f0f925.png" alt="">
图中id=4和id=5的两个会话都是Sleep 状态。而要看事务具体状态的话你可以查information_schema库的innodb_trx表。
<img src="https://static001.geekbang.org/resource/image/ca/e8/ca4b455c8eacbf32b98d1fe9ed9876e8.png" alt="">
这个结果里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中会引发性能问题的慢查询大体有以下三种可能
<li>
索引没有设计好;
</li>
<li>
SQL语句没写好
</li>
<li>
MySQL选错了索引。
</li>
接下来,我们就具体分析一下这三种可能,以及对应的解决方案。
**导致慢查询的第一种可能是,索引没有设计好。**
这种场景一般就是通过紧急创建索引来解决。MySQL 5.6版本以后创建索引都支持Online DDL了对于那种高峰期数据库已经被这个语句打挂了的情况最高效的做法就是直接执行alter table 语句。
比较理想的是能够在备库先执行。假设你现在的服务是一主一备主库A、备库B这个方案的大致流程是这样的
<li>
在备库B上执行 set sql_log_bin=off也就是不写binlog然后执行alter table 语句加上索引;
</li>
<li>
执行主备切换;
</li>
<li>
这时候主库是B备库是A。在A上执行 set sql_log_bin=off然后执行alter table 语句加上索引。
</li>
这是一个“古老”的DDL方案。平时在做变更的时候你应该考虑类似gh-ost这样的方案更加稳妥。但是在需要紧急处理时上面这个方案的效率是最高的。
**导致慢查询的第二种可能是,语句没写好。**
比如我们犯了在第18篇文章[《为什么这些SQL语句逻辑相同性能却差异巨大](https://time.geekbang.org/column/article/74059)中提到的那些错误,导致语句没有使用上索引。
这时我们可以通过改写SQL语句来处理。MySQL 5.7提供了query_rewrite功能可以把输入的一种语句改写成另外一种模式。
比如,语句被错误地写成了 select * from t where id + 1 = 10000你可以通过下面的方式增加一个语句改写规则。
```
mysql&gt; insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values (&quot;select * from t where id + 1 = ?&quot;, &quot;select * from t where id = ? - 1&quot;, &quot;db1&quot;);
call query_rewrite.flush_rewrite_rules();
```
这里call query_rewrite.flush_rewrite_rules()这个存储过程是让插入的新规则生效也就是我们说的“查询重写”。你可以用图4中的方法来确认改写规则是否生效。
<img src="https://static001.geekbang.org/resource/image/47/8a/47a1002cbc4c05c74841591d20f7388a.png" alt="">
**导致慢查询的第三种可能就是碰上了我们在第10篇文章**[**《MySQL为什么有时候会选错索引》**](https://time.geekbang.org/column/article/71173)**中提到的情况MySQL选错了索引。**
这时候应急方案就是给这个语句加上force index。
同样地使用查询重写功能给原来的语句加上force index也可以解决这个问题。
上面我和你讨论的由慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,我们就可以预先发现问题。
<li>
上线前在测试环境把慢查询日志slow log打开并且把long_query_time设置成0确保每个语句都会被记录入慢查询日志
</li>
<li>
在测试表里插入模拟线上的数据,做一遍回归测试;
</li>
<li>
观察慢查询日志里每类语句的输出特别留意Rows_examined字段是否与预期一致。我们在前面文章中已经多次用到过Rows_examined方法了相信你已经动手尝试过了。如果还有不明白的欢迎给我留言我们一起讨论
</li>
不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。
如果新增的SQL语句不多手动跑一下就可以。而如果是新项目的话或者是修改了原有项目的 表结构设计全量回归测试都是必要的。这时候你需要工具帮你检查所有的SQL语句的返回结果。比如你可以使用开源工具pt-query-digest([https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html](https://www.percona.com/doc/percona-toolkit/3.0/pt-query-digest.html))。
# QPS突增问题
有时候由于业务突然出现高峰或者应用程序bug导致某个语句的QPS突然暴涨也可能导致MySQL压力过大影响服务。
我之前碰到过一类情况是由一个新功能的bug导致的。当然最理想的情况是让业务把这个功能下掉服务自然就会恢复。
而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。我这里再和你展开说明一下。
<li>
一种是由全新业务的bug导致的。假设你的DB运维是比较规范的也就是说白名单是一个个加的。这种情况下如果你能够确定业务方会下掉这个功能只是时间上没那么快那么就可以从数据库端直接把白名单去掉。
</li>
<li>
如果这个新功能使用的是单独的数据库用户可以用管理员账号把这个用户删掉然后断开现有连接。这样这个新功能的连接不成功由它引发的QPS就会变成0。
</li>
<li>
如果这个新增的功能跟主体功能是部署在一起的那么我们只能通过处理语句来限制。这时我们可以使用上面提到的查询重写功能把压力最大的SQL语句直接重写成"select 1"返回。
</li>
当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用:
<li>
如果别的功能里面也用到了这个SQL语句模板会有误伤
</li>
<li>
很多业务并不是靠这一个语句就能完成逻辑的所以如果单独把这一个语句以select 1的结果返回的话可能会导致后面的业务逻辑一起失败。
</li>
所以方案3是用于止血的跟前面提到的去掉权限验证一样应该是你所有选项里优先级最低的一个方案。
同时你会发现其实方案1和2都要依赖于规范的运维体系虚拟化、白名单机制、业务账号分离。由此可见更多的准备往往意味着更稳定的系统。
# 小结
今天这篇文章,我以业务高峰期的性能问题为背景,和你介绍了一些紧急处理的手段。
这些处理手段中,既包括了粗暴地拒绝连接和断开连接,也有通过重写语句来绕过一些坑的方法;既有临时的高危方案,也有未雨绸缪的、相对安全的预案。
在实际开发中,我们也要尽量避免一些低效的方法,比如避免大量地使用短连接。同时,如果你做业务开发的话,要知道,连接异常断开是常有的事,你的代码里要有正确地重连并重试的机制。
DBA虽然可以通过语句重写来暂时处理问题但是这本身是一个风险高的操作做好SQL审计可以减少需要这类操作的机会。
其实你可以看得出来在这篇文章中我提到的解决方法主要集中在server层。在下一篇文章中我会继续和你讨论一些跟InnoDB有关的处理方法。
最后,又到了我们的思考题时间了。
今天,我留给你的课后问题是,你是否碰到过,在业务高峰期需要临时救火的场景?你又是怎么处理的呢?
你可以把你的经历和经验写在留言区,我会在下一篇文章的末尾选取有趣的评论跟大家一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
前两期我给你留的问题是下面这个图的执行序列中为什么session B的insert语句会被堵住。
<img src="https://static001.geekbang.org/resource/image/3a/1e/3a7578e104612a188a2d574eaa3bd81e.png" alt=""><br>
我们用上一篇的加锁规则来分析一下看看session A的select语句加了哪些锁
<li>
由于是order by c desc第一个要定位的是索引c上“最右边的”c=20的行所以会加上间隙锁(20,25)和next-key lock (15,20]。
</li>
<li>
在索引c上向左遍历要扫描到c=10才停下来所以next-key lock会加到(5,10]这正是阻塞session B的insert语句的原因。
</li>
<li>
在扫描过程中c=20、c=15、c=10这三行都存在值由于是select *所以会在主键id上加三个行锁。
</li>
因此session A 的select语句锁的范围就是
<li>
索引c上 (5, 25)
</li>
<li>
主键索引上id=15、20两个行锁。
</li>
这里我再啰嗦下你会发现我在文章中每次加锁都会说明是加在“哪个索引上”的。因为锁就是加在索引上的这是InnoDB的一个基础设定需要你在分析问题的时候要一直记得。
评论区留言点赞板:
>
@HuaMax 给出了正确的解释。
>
@Justin 同学提了个好问题,&lt;=到底是间隙锁还是行锁其实这个问题你要跟“执行过程”配合起来分析。在InnoDB要去找“第一个值”的时候是按照等值去找的用的是等值判断的规则找到第一个值以后要在索引内找“下一个值”对应于我们规则中说的范围查找。
>
@信信 提了一个不错的问题要知道最终的加锁是根据实际执行情况来的。所以如果一个select * from … for update 语句优化器决定使用全表扫描那么就会把主键索引上next-key lock全加上。
>
@nero 同学的问题提示我需要提醒大家注意“有行”才会加行锁。如果查询条件没有命中行那就加next-key lock。当然等值判断的时候需要加上优化2索引上的等值查询向右遍历时且最后一个值不满足等值条件的时候next-key lock退化为间隙锁。
>
@小李子@发条橙子同学,都提了很好的问题,这期高质量评论很多,你也都可以去看看。
最后,我要为元旦期间还坚持学习的同学们,点个赞 ^_^

View File

@@ -0,0 +1,279 @@
<audio id="audio" title="23 | MySQL是怎么保证数据不丢的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/eb/e93ee828bd857e27e6413cdbc68a54eb.mp3"></audio>
今天这篇文章我会继续和你介绍在业务高峰期临时提升性能的方法。从文章标题“MySQL是怎么保证数据不丢的你就可以看出来今天我和你介绍的方法跟数据的可靠性有关。
在专栏前面文章和答疑篇中我都着重介绍了WAL机制你可以再回顾下[第2篇](https://time.geekbang.org/column/article/68633)、[第9篇](https://time.geekbang.org/column/article/70848)、[第12篇](https://time.geekbang.org/column/article/71806)和[第15篇](https://time.geekbang.org/column/article/73161)文章中的相关内容得到的结论是只要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 src="https://static001.geekbang.org/resource/image/9e/3e/9ed86644d5f39efb0efec595abb92e3e.png" alt="">
可以看到每个线程有自己binlog cache但是共用同一份binlog文件。
- 图中的write指的就是指把日志写入到文件系统的page cache并没有把数据持久化到磁盘所以速度比较快。
- 图中的fsync才是将数据持久化到磁盘的操作。一般情况下我们认为fsync才占磁盘的IOPS。
write 和fsync的时机是由参数sync_binlog控制的
<li>
sync_binlog=0的时候表示每次提交事务都只write不fsync
</li>
<li>
sync_binlog=1的时候表示每次提交事务都会执行fsync
</li>
<li>
sync_binlog=N(N&gt;1)的时候表示每次提交事务都write但累积N个事务后才fsync。
</li>
因此在出现IO瓶颈的场景里将sync_binlog设置成一个比较大的值可以提升性能。在实际的业务场景中考虑到丢失日志量的可控性一般不建议将这个参数设成0比较常见的是将其设置为100~1000中的某个数值。
但是将sync_binlog设置为N对应的风险是如果主机发生异常重启会丢失最近N个事务的binlog日志。
# redo log的写入机制
接下来我们再说说redo log的写入机制。
在专栏的[第15篇答疑文章](https://time.geekbang.org/column/article/73161)中我给你介绍了redo log buffer。事务在执行过程中生成的redo log是要先写到redo log buffer的。
然后就有同学问了redo log buffer里面的内容是不是每次生成后都要直接持久化到磁盘呢
答案是,不需要。
如果事务执行期间MySQL发生异常重启那这部分日志就丢了。由于事务并没有提交所以这时日志丢了也不会有损失。
那么另外一个问题是事务还没提交的时候redo log buffer中的部分日志有没有可能被持久化到磁盘呢
答案是,确实会有。
这个问题要从redo log可能存在的三种状态说起。这三种状态对应的就是图2 中的三个颜色块。
<img src="https://static001.geekbang.org/resource/image/9d/d4/9d057f61d3962407f413deebc80526d4.png" alt="">
这三种状态分别是:
<li>
存在redo log buffer中物理上是在MySQL进程内存中就是图中的红色部分
</li>
<li>
写到磁盘(write)但是没有持久化fsync)物理上是在文件系统的page cache里面也就是图中的黄色部分
</li>
<li>
持久化到磁盘对应的是hard disk也就是图中的绿色部分。
</li>
日志写到redo log buffer是很快的wirte到page cache也差不多但是持久化到磁盘的速度就慢多了。
为了控制redo log的写入策略InnoDB提供了innodb_flush_log_at_trx_commit参数它有三种可能取值
<li>
设置为0的时候表示每次事务提交时都只是把redo log留在redo log buffer中;
</li>
<li>
设置为1的时候表示每次事务提交时都将redo log直接持久化到磁盘
</li>
<li>
设置为2的时候表示每次事务提交时都只是把redo log写到page cache。
</li>
InnoDB有一个后台线程每隔1秒就会把redo log buffer中的日志调用write写到文件系统的page cache然后调用fsync持久化到磁盘。
注意事务执行中间过程的redo log也是直接写在redo log buffer中的这些redo log也会被后台线程一起持久化到磁盘。也就是说一个没有提交的事务的redo log也是可能已经持久化到磁盘的。
实际上除了后台线程每秒一次的轮询操作外还有两种场景会让一个没有提交的事务的redo log写入到磁盘中。
<li>
**一种是redo log buffer占用的空间即将达到 innodb_log_buffer_size一半的时候后台线程会主动写盘。**注意由于这个事务并没有提交所以这个写盘动作只是write而没有调用fsync也就是只留在了文件系统的page cache。
</li>
<li>
**另一种是并行的事务提交的时候顺带将这个事务的redo log buffer持久化到磁盘。**假设一个事务A执行到一半已经写了一些redo log到buffer中这时候有另外一个线程的事务B提交如果innodb_flush_log_at_trx_commit设置的是1那么按照这个参数的逻辑事务B要把redo log buffer里的日志全部持久化到磁盘。这时候就会带上事务A在redo log buffer里的日志一起持久化到磁盘。
</li>
这里需要说明的是我们介绍两阶段提交的时候说过时序上redo log先prepare 再写binlog最后再把redo log commit。
如果把innodb_flush_log_at_trx_commit设置成1那么redo log在prepare阶段就要持久化一次因为有一个崩溃恢复逻辑是要依赖于prepare 的redo log再加上binlog来恢复的。如果你印象有点儿模糊了可以再回顾下[第15篇文章](https://time.geekbang.org/column/article/73161)中的相关内容)。
每秒一次后台轮询刷盘再加上崩溃恢复这个逻辑InnoDB就认为redo log在commit的时候就不需要fsync了只会write到文件系统的page cache中就够了。
通常我们说MySQL的“双1”配置指的就是sync_binlog和innodb_flush_log_at_trx_commit都设置成 1。也就是说一个事务完整提交前需要等待两次刷盘一次是redo logprepare 阶段一次是binlog。
这时候你可能有一个疑问这意味着我从MySQL看到的TPS是每秒两万的话每秒就会写四万次磁盘。但是我用工具测试出来磁盘能力也就两万左右怎么能实现两万的TPS
解释这个问题就要用到组提交group commit机制了。
这里我需要先和你介绍日志逻辑序列号log sequence numberLSN的概念。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 src="https://static001.geekbang.org/resource/image/93/cc/933fdc052c6339de2aa3bf3f65b188cc.png" alt="">
从图中可以看到,
<li>
trx1是第一个到达的会被选为这组的 leader
</li>
<li>
等trx1要开始写盘的时候这个组里面已经有了三个事务这时候LSN也变成了160
</li>
<li>
trx1去写盘的时候带的就是LSN=160因此等trx1返回时所有LSN小于等于160的redo log都已经被持久化到磁盘
</li>
<li>
这时候trx2和trx3就可以直接返回了。
</li>
所以一次组提交里面组员越多节约磁盘IOPS的效果越好。但如果只有单线程压测那就只能老老实实地一个事务对应一次持久化操作了。
在并发更新场景下第一个事务写完redo log buffer以后接下来这个fsync越晚调用组员可能越多节约IOPS的效果就越好。
为了让一次fsync带的组员更多MySQL有一个很有趣的优化拖时间。在介绍两阶段提交的时候我曾经给你画了一个图现在我把它截过来。
<img src="https://static001.geekbang.org/resource/image/98/51/98b3b4ff7b36d6d72e38029b86870551.png" alt="">
图中我把“写binlog”当成一个动作。但实际上写binlog是分成两步的
<li>
先把binlog从binlog cache中写到磁盘上的binlog文件
</li>
<li>
调用fsync持久化。
</li>
MySQL为了让组提交的效果更好把redo log做fsync的时间拖到了步骤1之后。也就是说上面的图变成了这样
<img src="https://static001.geekbang.org/resource/image/5a/28/5ae7d074c34bc5bd55c82781de670c28.png" alt="">
这么一来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来实现。
<li>
binlog_group_commit_sync_delay参数表示延迟多少微秒后才调用fsync;
</li>
<li>
binlog_group_commit_sync_no_delay_count参数表示累积多少次以后才调用fsync。
</li>
这两个条件是或的关系也就是说只要有一个满足条件就会调用fsync。
所以当binlog_group_commit_sync_delay设置为0的时候binlog_group_commit_sync_no_delay_count也无效了。
之前有同学在评论区问到WAL机制是减少磁盘写可是每次提交事务都要写redo log和binlog这磁盘读写次数也没变少呀
现在你就能理解了WAL机制主要得益于两个方面
<li>
redo log 和 binlog都是顺序写磁盘的顺序写比随机写速度要快
</li>
<li>
组提交机制可以大幅度降低磁盘的IOPS消耗。
</li>
分析到这里,我们再来回答这个问题:**如果你的MySQL现在出现了性能瓶颈而且瓶颈在IO上可以通过哪些方法来提升性能呢**
针对这个问题,可以考虑以下三种方法:
<li>
设置 binlog_group_commit_sync_delay 和 binlog_group_commit_sync_no_delay_count参数减少binlog的写盘次数。这个方法是基于“额外的故意等待”来实现的因此可能会增加语句的响应时间但没有丢失数据的风险。
</li>
<li>
将sync_binlog 设置为大于1的值比较常见是100~1000。这样做的风险是主机掉电时会丢binlog日志。
</li>
<li>
将innodb_flush_log_at_trx_commit设置为2。这样做的风险是主机掉电的时候会丢数据。
</li>
我不建议你把innodb_flush_log_at_trx_commit 设置成0。因为把这个参数设置成0表示redo log只保存在内存中这样的话MySQL本身异常重启也会丢数据风险太大。而redo log写到文件系统的page cache的速度也是很快的所以将这个参数设置成2跟设置成0其实性能差不多但这样做MySQL异常重启时就不会丢数据了相比之下风险会更小。
# 小结
在专栏的[第2篇](https://time.geekbang.org/column/article/68633)和[第15篇](https://time.geekbang.org/column/article/73161)文章中我和你分析了如果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保证的是
<li>
如果客户端收到事务成功的消息,事务就一定持久化了;
</li>
<li>
如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
</li>
<li>
如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。
</li>
最后,又到了课后问题时间。
今天我留给你的思考题是你的生产库设置的是“双1”吗 如果平时是的话你有在什么场景下改成过“非双1”吗你的这个操作又是基于什么决定的
另外,我们都知道这些设置可能有损,如果发生了异常,你的止损方案是什么?
你可以把你的理解或者经验写在留言区,我会在下一篇文章的末尾选取有趣的评论和你一起分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上篇文章最后,想要你分享的是线上“救火”的经验。
@Long 同学,在留言中提到了几个很好的场景。
<li>
<p>其中第3个问题“如果一个数据库是被客户端的压力打满导致无法响应的重启数据库是没用的。”说明他很好地思考了。<br>
这个问题是因为重启之后业务请求还会再发。而且由于是重启buffer pool被清空可能会导致语句执行得更慢。</p>
</li>
<li>
他提到的第4个问题也很典型。有时候一个表上会出现多个单字段索引而且往往这是因为运维工程师对索引原理不够清晰做的设计这样就可能出现优化器选择索引合并算法的现象。但实际上索引合并算法的效率并不好。而通过将其中的一个索引改成联合索引的方法是一个很好的应对方案。
</li>
还有其他几个同学提到的问题场景,也很好,很值得你一看。
>
<p>@Max 同学提到一个很好的例子客户端程序的连接器连接完成后会做一些诸如show columns的操作在短连接模式下这个影响就非常大了。<br>
这个提醒我们在review项目的时候不止要review我们自己业务的代码也要review连接器的行为。一般做法就是在测试环境把general_log打开用业务行为触发连接然后通过general log分析连接器的行为。</p>
>
<p>@Manjusaka 同学的留言中,第二点提得非常好:如果你的数据库请求模式直接对应于客户请求,这往往是一个危险的设计。因为客户行为不可控,可能突然因为你们公司的一个运营推广,压力暴增,这样很容易把数据库打挂。<br>
在设计模型里面设计一层,专门负责管理请求和数据库服务资源,对于比较重要和大流量的业务,是一个好的设计方向。</p>
>
@Vincent 同学提了一个好问题用文中提到的DDL方案会导致binlog里面少了这个DDL语句后续影响备份恢复的功能。由于需要另一个知识点主备同步协议我放在后面的文章中说明。

View File

@@ -0,0 +1,318 @@
<audio id="audio" title="24 | MySQL是怎么保证主备一致的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/f6/48bdd60705a53ac547b0ca5ccd4046f6.mp3"></audio>
在前面的文章中我不止一次地和你提到了binlog大家知道binlog可以用来归档也可以用来做主备同步但它的内容是什么样的呢为什么备库执行了binlog就可以跟主库保持一致了呢今天我就正式地和你介绍一下它。
毫不夸张地说MySQL能够成为现下最流行的开源数据库binlog功不可没。
在最开始MySQL是以容易学习和方便的高可用架构被开发人员青睐的。而它的几乎所有的高可用架构都直接依赖于binlog。虽然这些高可用架构已经呈现出越来越复杂的趋势但都是从最基本的一主一备演化过来的。
今天这篇文章我主要为你介绍主备的基本原理。理解了背后的设计原理,你也可以从业务开发的角度,来借鉴这些设计思想。
# MySQL主备的基本原理
如图1所示就是基本的主备切换流程。
<img src="https://static001.geekbang.org/resource/image/fd/10/fd75a2b37ae6ca709b7f16fe060c2c10.png" alt="">
在状态1中客户端的读写都直接访问节点A而节点B是A的备库只是将A的更新都同步过来到本地执行。这样可以保持节点B和A的数据是相同的。
当需要切换的时候就切成状态2。这时候客户端读写访问的都是节点B而节点A是B的备库。
在状态1中虽然节点B没有被直接访问但是我依然建议你把节点B也就是备库设置成只读readonly模式。这样做有以下几个考虑
<li>
有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作;
</li>
<li>
防止切换逻辑有bug比如切换过程中出现双写造成主备不一致
</li>
<li>
可以用readonly状态来判断节点的角色。
</li>
你可能会问,我把备库设置成只读了,还怎么跟主库保持同步更新呢?
这个问题你不用担心。因为readonly设置对超级(super)权限用户是无效的,而用于同步更新的线程,就拥有超级权限。
接下来,我们再看看**节点A到B这条线的内部流程是什么样的**。图2中画出的就是一个update语句在节点A执行然后同步到节点B的完整流程图。
<img src="https://static001.geekbang.org/resource/image/a6/a3/a66c154c1bc51e071dd2cc8c1d6ca6a3.png" alt="">
图2中包含了我在上一篇文章中讲到的binlog和redo log的写入机制相关的内容可以看到主库接收到客户端的更新请求后执行内部事务的更新逻辑同时写binlog。
备库B跟主库A之间维持了一个长连接。主库A内部有一个线程专门用于服务备库B的这个长连接。一个事务日志同步的完整过程是这样的
<li>
在备库B上通过change master命令设置主库A的IP、端口、用户名、密码以及要从哪个位置开始请求binlog这个位置包含文件名和日志偏移量。
</li>
<li>
在备库B上执行start slave命令这时候备库会启动两个线程就是图中的io_thread和sql_thread。其中io_thread负责与主库建立连接。
</li>
<li>
主库A校验完用户名、密码后开始按照备库B传过来的位置从本地读取binlog发给B。
</li>
<li>
备库B拿到binlog后写到本地文件称为中转日志relay log
</li>
<li>
sql_thread读取中转日志解析出日志里的命令并执行。
</li>
这里需要说明后来由于多线程复制方案的引入sql_thread演化成为了多个线程跟我们今天要介绍的原理没有直接关系暂且不展开。
分析完了这个长连接的逻辑我们再来看一个问题binlog里面到底是什么内容为什么备库拿过去可以直接执行。
# binlog的三种格式对比
我在[第15篇答疑文章](https://time.geekbang.org/column/article/73161)中和你提到过binlog有两种格式一种是statement一种是row。可能你在其他资料上还会看到有第三种格式叫作mixed其实它就是前两种格式的混合。
为了便于描述binlog的这三种格式间的区别我创建了一个表并初始化几行数据。
```
mysql&gt; 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&gt; delete from t /*comment*/ where a&gt;=4 and t_modified&lt;='2018-11-10' limit 1;
```
当binlog_format=statement时binlog里面记录的就是SQL语句的原文。你可以用
```
mysql&gt; show binlog events in 'master.000001';
```
命令看binlog中的内容。
<img src="https://static001.geekbang.org/resource/image/b9/31/b9818f73cd7d38a96ddcb75350b52931.png" alt="">
现在我们来看一下图3的输出结果。
- 第一行SET @@SESSION.GTID_NEXT='ANONYMOUS你可以先忽略后面文章我们会在介绍主备切换的时候再提到
- 第二行是一个BEGIN跟第四行的commit对应表示中间是一个事务
<li>第三行就是真实执行的语句了。可以看到在真实执行的delete命令之前还有一个“use test”命令。这条命令不是我们主动执行的而是MySQL根据当前要操作的表所在的数据库自行添加的。这样做可以保证日志传到备库去执行的时候不论当前的工作线程在哪个库里都能够正确地更新到test库的表t。<br>
use 'test命令之后的delete 语句就是我们输入的SQL原文了。可以看到binlog“忠实”地记录了SQL命令甚至连注释也一并记录了。</li>
- 最后一行是一个COMMIT。你可以看到里面写着xid=61。你还记得这个XID是做什么用的吗如果记忆模糊了可以再回顾一下[第15篇文章](https://time.geekbang.org/column/article/73161)中的相关内容。
为了说明statement 和 row格式的区别我们来看一下这条delete命令的执行效果图
<img src="https://static001.geekbang.org/resource/image/96/2b/96c2be9c0fcbff66883118526b26652b.png" alt="">
可以看到运行这条delete命令产生了一个warning原因是当前binlog设置的是statement格式并且语句中有limit所以这个命令可能是unsafe的。
为什么这么说呢这是因为delete 带limit很可能会出现主备数据不一致的情况。比如上面这个例子
<li>
如果delete语句使用的是索引a那么会根据索引a找到第一个满足条件的行也就是说删除的是a=4这一行
</li>
<li>
但如果使用的是索引t_modified那么删除的就是 t_modified='2018-11-09也就是a=5这一行。
</li>
由于statement格式下记录到binlog里的是语句原文因此可能会出现这样一种情况在主库执行这条SQL语句的时候用的是索引a而在备库执行这条SQL语句的时候却使用了索引t_modified。因此MySQL认为这样写是有风险的。
那么如果我把binlog的格式改为binlog_format=row 是不是就没有这个问题了呢我们先来看看这时候binog中的内容吧。
<img src="https://static001.geekbang.org/resource/image/d6/26/d67a38db154afff610ae3bb64e266826.png" alt="">
可以看到与statement格式的binlog相比前后的BEGIN和COMMIT是一样的。但是row格式的binlog里没有了SQL语句的原文而是替换成了两个eventTable_map和Delete_rows。
<li>
Table_map event用于说明接下来要操作的表是test库的表t;
</li>
<li>
Delete_rows event用于定义删除的行为。
</li>
其实我们通过图5是看不到详细信息的还需要借助mysqlbinlog工具用下面这个命令解析和查看binlog中的内容。因为图5中的信息显示这个事务的binlog是从8900这个位置开始的所以可以用start-position参数来指定从这个位置的日志开始解析。
```
mysqlbinlog -vv data/master.000001 --start-position=8900;
```
<img src="https://static001.geekbang.org/resource/image/c3/c2/c342cf480d23b05d30a294b114cebfc2.png" alt="">
从这个图中,我们可以看到以下几个信息:
- 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](https://mariadb.com/kb/en/library/flashback/)工具就是基于上面介绍的原理来回滚数据的。
虽然mixed格式的binlog现在已经用得不多了但这里我还是要再借用一下mixed格式来说明一个问题来看一下这条SQL语句
```
mysql&gt; insert into t values(10,10, now());
```
如果我们把binlog格式设置为mixed你觉得MySQL会把它记录为row格式还是statement格式呢
先不要着急说结果,我们一起来看一下这条语句执行的效果。
<img src="https://static001.geekbang.org/resource/image/01/ef/0150301698979255a6f27711c35e9eef.png" alt="">
可以看到MySQL用的居然是statement格式。你一定会奇怪如果这个binlog过了1分钟才传给备库的话那主备的数据不就不一致了吗
接下来我们再用mysqlbinlog工具来看看
<img src="https://static001.geekbang.org/resource/image/1a/41/1ad3a4c4b9a71955edba5195757dd041.png" alt="">
从图中的结果可以看到原来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 src="https://static001.geekbang.org/resource/image/20/56/20ad4e163115198dc6cf372d5116c956.png" alt="">
对比图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。因此我们可以用下面的逻辑来解决两个节点间的循环复制的问题
<li>
规定两个库的server id必须不同如果相同则它们之间不能设定为主备关系
</li>
<li>
一个备库接到binlog并在重放的过程中生成与原binlog的server id相同的新的binlog
</li>
<li>
每个库在收到从自己的主库发过来的日志后先判断server id如果跟自己的相同表示这个日志是自己生成的就直接丢弃这个日志。
</li>
按照这个逻辑如果我们设置了双M结构日志的执行流就会变成这样
<li>
从节点A更新的事务binlog里面记的都是A的server id
</li>
<li>
传到节点B执行一次以后节点B生成的binlog 的server id也是A的server id
</li>
<li>
再传回给节点AA判断到这个server id与自己的相同就不会再处理这个日志。所以死循环在这里就断掉了。
</li>
# 小结
今天这篇文章我给你介绍了MySQL binlog的格式和一些基本机制是后面我要介绍的读写分离等系列文章的背景知识希望你可以认真消化理解。
binlog在MySQL的各种高可用方案上扮演了重要角色。今天介绍的可以说是所有MySQL高可用方案的基础。在这之上演化出了诸如多节点、半同步、MySQL group replication等相对复杂的方案。
我也跟你介绍了MySQL不同格式binlog的优缺点和设计者的思考。希望你在做系统开发时候也能借鉴这些设计思想。
最后,我给你留下一个思考题吧。
说到循环复制问题的时候我们说MySQL通过判断server id的方式断掉死循环。但是这个机制其实并不完备在某些场景下还是有可能出现死循环。
你能构造出一个这样的场景吗?又应该怎么解决呢?
你可以把你的设计和分析写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期我留给你的问题是你在什么时候会把线上生产库设置成“非双1”。我目前知道的场景有以下这些
<li>
业务高峰期。一般如果有预知的高峰期DBA会有预案把主库设置成“非双1”。
</li>
<li>
备库延迟,为了让备库尽快赶上主库。@永恒记忆和@Second Sight提到了这个场景。
</li>
<li>
用备份恢复主库的副本应用binlog的过程这个跟上一种场景类似。
</li>
<li>
批量导入数据的时候。
</li>
一般情况下把生产库改成“非双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命令看到的就是文件系统返回的结果。

View File

@@ -0,0 +1,305 @@
<audio id="audio" title="25 | MySQL是怎么保证高可用的" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/f4/e93751889d44c375c248416ab07dedf4.mp3"></audio>
在上一篇文章中我和你介绍了binlog的基本内容在一个主备关系中每个备库接收主库的binlog并执行。
正常情况下只要主库执行更新生成的所有binlog都可以传到备库并被正确地执行备库就能达到跟主库一致的状态这就是最终一致性。
但是MySQL要提供高可用能力只有最终一致性是不够的。为什么这么说呢今天我就着重和你分析一下。
这里我再放一次上一篇文章中讲到的双M结构的主备切换流程图。
<img src="https://static001.geekbang.org/resource/image/89/cc/89290bbcf454ff9a3dc5de42a85a69cc.png" alt="">
# 主备延迟
主备切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。
接下来,我们先一起看看主动切换的场景。
在介绍主动切换流程的详细步骤之前,我要先跟你说明一个概念,即“同步延迟”。与数据同步有关的时间点主要包括以下三个:
<li>
主库A执行完成一个事务写入binlog我们把这个时刻记为T1;
</li>
<li>
之后传给备库B我们把备库B接收完这个binlog的时刻记为T2;
</li>
<li>
备库B执行完成这个事务我们把这个时刻记为T3。
</li>
所谓主备延迟就是同一个事务在备库执行完成的时间和主库执行完成的时间之间的差值也就是T3-T1。
你可以在备库上执行show slave status命令它的返回结果里面会显示seconds_behind_master用于表示当前备库延迟了多少秒。
seconds_behind_master的计算方法是这样的
<li>
每个事务的binlog 里面都有一个时间字段,用于记录主库上写入的时间;
</li>
<li>
备库取出当前正在执行的事务的时间字段的值计算它与当前系统时间的差值得到seconds_behind_master。
</li>
可以看到其实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资源影响了同步速度造成主备延迟。
这种情况,我们一般可以这么处理:
<li>
一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力。
</li>
<li>
通过binlog输出到外部系统比如Hadoop这类系统让外部系统提供统计类查询的能力。
</li>
其中,一主多从的方式大都会被采用。因为作为数据库系统,还必须保证有定期全量备份的能力。而从库,就很适合用来做备份。
>
备注这里需要说明一下从库和备库在概念上其实差不多。在我们这个专栏里为了方便描述我把会在HA过程中被选成新主库的称为备库其他的称为从库。
追问2采用了一主多从保证备库的压力不会超过主库还有什么情况可能导致主备延迟吗
**这就是第三种可能了,即大事务。**
大事务这种情况很好理解。因为主库上必须等事务执行完成才会写入binlog再传给备库。所以如果一个主库上的语句执行10分钟那这个事务很可能就会导致从库延迟10分钟。
不知道你所在公司的DBA有没有跟你这么说过不要**一次性地用delete语句删除太多数据**。其实,这就是一个典型的大事务场景。
比如,一些归档类的数据,平时没有注意删除历史数据,等到空间快满了,业务开发人员要一次性地删掉大量历史数据。同时,又因为要避免在高峰期操作会影响业务(至少有这个意识还是很不错的),所以会在晚上执行这些大量数据的删除操作。
结果负责的DBA同学半夜就会收到延迟报警。然后DBA团队就要求你后续再删除数据的时候要控制每个事务删除的数据量分成多次删除。
**另一种典型的大事务场景就是大表DDL。**这个场景我在前面的文章中介绍过。处理方案就是计划内的DDL建议使用gh-ost方案这里你可以再回顾下第13篇文章[《为什么表数据删掉一半,表文件大小不变?》](https://time.geekbang.org/column/article/72388)中的相关内容)。
追问3如果主库上也不做大事务了还有什么原因会导致主备延迟吗
造成主备延迟还有一个大方向的原因,就是**备库的并行复制能力**。这个话题,我会留在下一篇文章再和你详细介绍。
其实还是有不少其他情况会导致主备延迟,如果你还碰到过其他场景,欢迎你在评论区给我留言,我来和你一起分析、讨论。
由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。
# 可靠性优先策略
在图1的双M结构下从状态1到状态2切换的详细过程是这样的
<li>
判断备库B现在的seconds_behind_master如果小于某个值比如5秒继续下一步否则持续重试这一步
</li>
<li>
把主库A改成只读状态即把readonly设置为true
</li>
<li>
判断备库B的seconds_behind_master的值直到这个值变成0为止
</li>
<li>
把备库B改成可读写状态也就是把readonly 设置为false
</li>
<li>
把业务请求切到备库B。
</li>
这个切换流程一般是由专门的HA系统来完成的我们暂时称之为可靠性优先流程。
<img src="https://static001.geekbang.org/resource/image/54/4a/54f4c7c31e6f0f807c2ab77f78c8844a.png" alt="">
备注图中的SBM是seconds_behind_master参数的简写。
可以看到这个切换流程中是有不可用时间的。因为在步骤2之后主库A和备库B都处于readonly状态也就是说这时系统处于不可写状态直到步骤5完成后才能恢复。
在这个不可用状态中比较耗费时间的是步骤3可能需要耗费好几秒的时间。这也是为什么需要在步骤1先做判断确保seconds_behind_master的值足够小。
试想如果一开始主备延迟就长达30分钟而不先做判断直接切换的话系统的不可用时间就会长达30分钟这种情况一般业务都是不可接受的。
当然系统的不可用时间是由这个数据可靠性优先的策略决定的。你也可以选择可用性优先的策略来把这个不可用时间几乎降为0。
# 可用性优先策略
如果我强行把步骤4、5调整到最开始执行也就是说不等主备数据同步直接把连接切到备库B并且让备库B可以读写那么系统几乎就没有不可用时间了。
我们把这个切换流程,暂时称作可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。
接下来,我就和你分享一个可用性优先流程产生数据不一致的例子。假设有一个表 t
```
mysql&gt; 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 src="https://static001.geekbang.org/resource/image/37/3a/3786bd6ad37faa34aca25bf1a1d8af3a.png" alt="">
现在,我们一起分析下这个切换流程:
<li>
步骤2中主库A执行完insert语句插入了一行数据4,4之后开始进行主备切换。
</li>
<li>
步骤3中由于主备之间有5秒的延迟所以备库B还没来得及应用“插入c=4”这个中转日志就开始接收客户端“插入 c=5”的命令。
</li>
<li>
步骤4中备库B插入了一行数据4,5并且把这个binlog发给主库A。
</li>
<li>
步骤5中备库B执行“插入c=4”这个中转日志插入了一行数据5,4。而直接在备库B执行的“插入c=5”这个语句传到主库A就插入了一行新数据5,5
</li>
最后的结果就是主库A和备库B上出现了两行不一致的数据。可以看到这个数据不一致是由可用性优先流程导致的。
那么,如果我还是用**可用性优先策略但设置binlog_format=row**,情况又会怎样呢?
因为row格式在记录binlog的时候会记录新插入的行的所有字段值所以最后只会有一行不一致。而且两边的主备同步的应用线程会报错duplicate key error并停止。也就是说这种情况下备库B的(5,4)和主库A的(5,5)这两行数据,都不会被对方执行。
图4中我画出了详细过程你可以自己再分析一下。
<img src="https://static001.geekbang.org/resource/image/b8/43/b8d2229b2b40dd087fd3b111d1bdda43.png" alt="">
从上面的分析中,你可以看到一些结论:
<li>
使用row格式的binlog时数据不一致的问题更容易被发现。而使用mixed或者statement格式的binlog时数据很可能悄悄地就不一致了。如果你过了很久才发现数据不一致的问题很可能这时的数据不一致已经不可查或者连带造成了更多的数据逻辑不一致。
</li>
<li>
主备切换的可用性优先策略会导致数据不一致。因此,大多数情况下,我都建议你使用可靠性优先策略。毕竟对数据服务来说的话,数据的可靠性一般还是要优于可用性的。
</li>
但事无绝对,**有没有哪种情况数据的可用性优先级更高呢?**
答案是,有的。
我曾经碰到过这样的一个场景:
- 有一个库的作用是记录操作日志。这时候如果数据不一致可以通过binlog来修补而这个短暂的不一致也不会引发业务问题。
- 同时,业务系统依赖于这个日志写入逻辑,如果这个库不可写,会导致线上的业务操作无法执行。
这时候,你可能就需要选择先强行切换,事后再补数据的策略。
当然,事后复盘的时候,我们想到了一个改进措施就是,让业务逻辑不要依赖于这类日志的写入。也就是说,日志写入这个逻辑模块应该可以降级,比如写到本地文件,或者写到另外一个临时库里面。
这样的话,这种场景就又可以使用可靠性优先策略了。
接下来我们再看看,**按照可靠性优先的思路,异常切换会是什么效果?**
假设主库A和备库B间的主备延迟是30分钟这时候主库A掉电了HA系统要切换B作为主库。我们在主动切换的时候可以等到主备延迟小于5秒的时候再启动切换但这时候已经别无选择了。
<img src="https://static001.geekbang.org/resource/image/55/8b/553b7fc2d0dce3ec78bb595e1806eb8b.png" alt="">
采用可靠性优先策略的话你就必须得等到备库B的seconds_behind_master=0之后才能切换。但现在的情况比刚刚更严重并不是系统只读、不可写的问题了而是系统处于完全不可用的状态。因为主库A掉电后我们的连接还没有切到备库B。
你可能会问那能不能直接切换到备库B但是保持B只读呢
这样也不行。
因为,这段时间内,中转日志还没有应用完成,如果直接发起主备切换,客户端查询看不到之前执行完成的事务,会认为有“数据丢失”。
虽然随着中转日志的继续应用,这些数据会恢复回来,但是对于一些业务来说,查询到“暂时丢失数据的状态”也是不能被接受的。
聊到这里你就知道了在满足数据可靠性的前提下MySQL高可用系统的可用性是依赖于主备延迟的。延迟的时间越小在主库故障的时候服务恢复需要的时间就越短可用性就越高。
# 小结
今天这篇文章我先和你介绍了MySQL高可用系统的基础就是主备切换逻辑。紧接着我又和你讨论了几种会导致主备延迟的情况以及相应的改进方向。
然后,由于主备延迟的存在,切换策略就有不同的选择。所以,我又和你一起分析了可靠性优先和可用性优先策略的区别。
在实际的应用中,我更建议使用可靠性优先的策略。毕竟保证数据准确,应该是数据库服务的底线。在这个基础上,通过减少主备延迟,提升系统的可用性。
最后,我给你留下一个思考题吧。
一般现在的数据库运维系统都有备库延迟监控,其实就是在备库上执行 show slave status采集seconds_behind_master的值。
假设现在你看到你维护的一个备库它的延迟监控的图像类似图6是一个45°斜向上的线段你觉得可能是什么原因导致呢你又会怎么去确认这个原因呢
<img src="https://static001.geekbang.org/resource/image/cf/71/cf5ea52aa3b26ef56c567125197fa171.png" alt="">
你可以把你的分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期我留给你的问题是什么情况下双M结构会出现循环复制。
一种场景是在一个主库更新事务后用命令set global server_id=x修改了server_id。等日志再传回来的时候发现server_id跟自己的server_id不同就只能执行了。
另一种场景是有三个节点的时候如图7所示trx1是在节点 B执行的因此binlog上的server_id就是Bbinlog传给节点 A然后A和A搭建了双M结构就会出现循环复制。
<img src="https://static001.geekbang.org/resource/image/f9/71/f968192ce2f436c939dd702b8f409771.png" alt="">
这种三节点复制的场景,做数据库迁移的时候会出现。
如果出现了循环复制可以在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。

View File

@@ -0,0 +1,390 @@
<audio id="audio" title="26 | 备库为什么会延迟好几个小时?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/83/94a0add102bf521f869d88df81955383.mp3"></audio>
在上一篇文章中,我和你介绍了几种可能导致备库延迟的原因。你会发现,这些场景里,不论是偶发性的查询压力,还是备份,对备库延迟的影响一般是分钟级的,而且在备库恢复正常以后都能够追上来。
但是,如果备库执行日志的速度持续低于主库生成日志的速度,那这个延迟就有可能成了小时级别。而且对于一个压力持续比较高的主库来说,备库很可能永远都追不上主库的节奏。
这就涉及到今天我要给你介绍的话题:备库并行复制能力。
为了便于你理解我们再一起看一下第24篇文章[《MySQL是怎么保证主备一致的](https://time.geekbang.org/column/article/76446)的主备流程图。
<img src="https://static001.geekbang.org/resource/image/1a/ef/1a85a3bac30a32438bfd8862e5a34eef.png" alt="">
谈到主备的并行复制能力我们要关注的是图中黑色的两个箭头。一个箭头代表了客户端写入主库另一箭头代表的是备库上sql_thread执行中转日志relay log。如果用箭头的粗细来代表并行度的话那么真实情况就如图1所示第一个箭头要明显粗于第二个箭头。
在主库上影响并发度的原因就是各种锁了。由于InnoDB引擎支持行锁除了所有并发事务都在更新同一行热点行这种极端场景外它对业务并发度的支持还是很友好的。所以你在性能测试的时候会发现并发压测线程32就比单线程时总体吞吐量高。
而日志在备库上的执行就是图中备库上sql_thread更新数据(DATA)的逻辑。如果是用单线程的话,就会导致备库应用日志不够快,造成主备延迟。
在官方的5.6版本之前MySQL只支持单线程复制由此在主库并发高、TPS高时就会出现严重的主备延迟问题。
从单线程复制到最新版本的多线程复制中间的演化经历了好几个版本。接下来我就跟你说说MySQL多线程复制的演进过程。
其实说到底所有的多线程复制机制都是要把图1中只有一个线程的sql_thread拆成多个线程也就是都符合下面的这个模型
<img src="https://static001.geekbang.org/resource/image/bc/45/bcf75aa3b0f496699fd7885426bc6245.png" alt="">
图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在分发的时候需要满足以下这两个基本要求
<li>
不能造成更新覆盖。这就要求更新同一行的两个事务必须被分发到同一个worker中。
</li>
<li>
同一个事务不能被拆开必须放到同一个worker中。
</li>
各个版本的多线程复制,都遵循了这两条基本原则。接下来,我们就看看各个版本的并行复制策略。
# MySQL 5.5版本的并行复制策略
官方MySQL 5.5版本是不支持并行复制的。但是在2012年的时候我自己服务的业务出现了严重的主备延迟原因就是备库只有单线程复制。然后我就先后写了两个版本的并行策略。
这里我给你介绍一下这两个版本的并行策略即按表分发策略和按行分发策略以帮助你理解MySQL官方版本并行复制策略的迭代。
### 按表分发策略
按表分发事务的基本思路是如果两个事务更新不同的表它们就可以并行。因为数据是存储在表里的所以按表分发可以保证两个worker不会更新同一行。
当然如果有跨表的事务还是要把两张表放在一起考虑的。如图3所示就是按表分发的规则。
<img src="https://static001.geekbang.org/resource/image/8b/76/8b6976fedd6e644022d4026581fb8d76.png" alt="">
可以看到每个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的分配流程来看一下分配规则。
<li>
由于事务T中涉及修改表t1而worker_1队列中有事务在修改表t1事务T和队列中的某个事务要修改同一个表的数据这种情况我们说事务T和worker_1是冲突的。
</li>
<li>
按照这个逻辑顺序判断事务T和每个worker队列的冲突关系会发现事务T跟worker_2也冲突。
</li>
<li>
事务T跟多于一个worker冲突coordinator线程就进入等待。
</li>
<li>
每个worker继续执行同时修改hash_table。假设hash_table_2里面涉及到修改表t3的事务先执行完成就会从hash_table_2中把db1.t3这一项去掉。
</li>
<li>
这样coordinator会发现跟事务T冲突的worker只有worker_1了因此就把它分配给worker_1。
</li>
<li>
coordinator继续读下一个中转日志继续分配事务。
</li>
也就是说每个事务在分发的时候跟所有worker的冲突关系包括以下三种情况
<li>
如果跟所有worker都不冲突coordinator线程就会把这个事务分配给最空闲的woker;
</li>
<li>
如果跟多于一个worker冲突coordinator线程就进入等待状态直到和这个事务存在冲突关系的worker只剩下1个
</li>
<li>
如果只跟一个worker冲突coordinator线程就会把这个事务分配给这个存在冲突关系的worker。
</li>
这个按表分发的方案在多个表负载均匀的场景里应用效果很好。但是如果碰到热点表比如所有的更新事务都会涉及到某一个表的时候所有事务都会被分配到同一个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 src="https://static001.geekbang.org/resource/image/f1/78/f19916e27b8ff28e87ed3ad9f5473378.png" alt="">
可以看到这两个事务要更新的行的主键值不同但是如果它们被分到不同的worker就有可能session B的语句先执行。这时候id=1的行的a的值还是1就会报唯一键冲突。
因此基于行的策略事务hash表中还需要考虑唯一键即key应该是“库名+表名+索引a的名字+a的值”。
比如在上面这个例子中我要在表t1上执行update t1 set a=1 where id=2语句在binlog里面记录了整行的数据修改前各个字段的值和修改后各个字段的值。
因此coordinator在解析这个语句的binlog的时候这个事务的hash表就有三个项:
<li>
key=hash_func(db1+t1+“PRIMARY”+2), value=2; 这里value=2是因为修改前后的行id值不变出现了两次。
</li>
<li>
key=hash_func(db1+t1+“a”+2), value=1表示会影响到这个表a=2的行。
</li>
<li>
key=hash_func(db1+t1+“a”+1), value=1表示会影响到这个表a=1的行。
</li>
可见,**相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源。**你可能也发现了,这两个方案其实都有一些约束条件:
<li>
要能够从binlog里面解析出表名、主键值和唯一索引的值。也就是说主库的binlog格式必须是row
</li>
<li>
表必须有主键;
</li>
<li>
不能有外键。表上如果有外键级联更新的行不会记录在binlog中这样冲突检测就不准确。
</li>
好在这三条约束规则本来就是DBA之前要求业务开发人员必须遵守的线上使用规范所以这两个并行复制策略在应用上也没有碰到什么麻烦。
对比按表分发和按行分发这两个方案的话,按行分发策略的并行度更高。不过,如果是要操作很多行的大事务的话,按行分发的策略有两个问题:
<li>
耗费内存。比如一个语句要删除100万行数据这时候hash表就要记录100万个项。
</li>
<li>
耗费CPU。解析binlog然后计算hash值对于大事务这个成本还是很高的。
</li>
所以我在实现这个策略的时候会设置一个阈值单个事务如果超过设置的行数阈值比如如果单个事务更新的行数超过10万行就暂时退化为单线程模式退化过程的逻辑大概是这样的
<li>
coordinator暂时先hold住这个事务
</li>
<li>
等待所有worker都执行完成变成空队列
</li>
<li>
coordinator直接执行这个事务
</li>
<li>
恢复并行模式。
</li>
读到这里,你可能会感到奇怪,这两个策略又没有被合到官方,我为什么要介绍这么详细呢?其实,介绍这两个策略的目的是抛砖引玉,方便你理解后面要介绍的社区版本策略。
# MySQL 5.6版本的并行复制策略
官方MySQL5.6版本支持了并行复制只是支持的粒度是按库并行。理解了上面介绍的按表分发策略和按行分发策略你就理解了用于决定分发策略的hash表里key就是数据库名。
这个策略的并行效果取决于压力模型。如果在主库上有多个DB并且各个DB的压力均衡使用这个策略的效果会很好。
相比于按表和按行分发,这个策略有两个优势:
<li>
构造hash值的时候很快只需要库名而且一个实例上DB数也不会很多不会出现需要构造100万个项这种情况。
</li>
<li>
不要求binlog的格式。因为statement格式的binlog也可以很容易拿到库名。
</li>
但是如果你的主库上的表都放在同一个DB里面这个策略就没有效果了或者如果不同DB的热点不同比如一个是业务逻辑库一个是系统配置库那也起不到并行的效果。
理论上你可以创建不同的DB把相同热度的表均匀分到这些不同的DB中强行使用这个策略。不过据我所知由于需要特地移动数据这个策略用得并不多。
# MariaDB的并行复制策略
在[第23篇文章](https://time.geekbang.org/column/article/76161)中我给你介绍了redo log组提交(group commit)优化, 而MariaDB的并行复制策略利用的就是这个特性
<li>
能够在同一组里提交的事务,一定不会修改同一行;
</li>
<li>
主库上可以并行执行的事务,备库上也一定是可以并行执行的。
</li>
在实现上MariaDB是这么做的
<li>
在一组里面一起提交的事务有一个相同的commit_id下一组就是commit_id+1
</li>
<li>
commit_id直接写到binlog里面
</li>
<li>
传到备库应用的时候相同commit_id的事务分发到多个worker执行
</li>
<li>
这一组全部执行完成后coordinator再去取下一批。
</li>
当时这个策略出来的时候是相当惊艳的。因为之前业界的思路都是在“分析binlog并拆分到worker”上。而MariaDB的这个策略目标是“模拟主库的并行模式”。
但是这个策略有一个问题它并没有实现“真正的模拟主库并发度”这个目标。在主库上一组事务在commit的时候下一组事务是同时处于“执行中”状态的。
如图5所示假设了三组事务在主库的执行情况你可以看到在trx1、trx2和trx3提交的时候trx4、trx5和trx6是在执行的。这样在第一组事务提交完成的时候下一组事务很快就会进入commit状态。
<img src="https://static001.geekbang.org/resource/image/8f/c3/8fec5fb48d6095aecc80016826efbfc3.png" alt="">
而按照MariaDB的并行复制策略备库上的执行效果如图6所示。
<img src="https://static001.geekbang.org/resource/image/8a/22/8ac3799c1ff2f9833619a1624ca3e622.png" alt="">
可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。
另外这个方案很容易被大事务拖后腿。假设trx2是一个超大事务那么在备库应用的时候trx1和trx3执行完成后就只能等trx2完全执行完成下一组才能开始执行。这段时间只有一个worker线程在工作是对资源的浪费。
不过即使如此,这个策略仍然是一个很漂亮的创新。因为,它对原系统的改造非常少,实现也很优雅。
# MySQL 5.7的并行复制策略
在MariaDB并行复制实现之后官方的MySQL5.7版本也提供了类似的功能由参数slave-parallel-type来控制并行复制策略
<li>
配置为DATABASE表示使用MySQL 5.6版本的按库并行策略;
</li>
<li>
配置为 LOGICAL_CLOCK表示的就是类似MariaDB的策略。不过MySQL 5.7这个策略,针对并行度做了优化。这个优化的思路也很有趣儿。
</li>
你可以先考虑这样一个问题:同时处于“执行状态”的所有事务,是不是可以并行?
答案是,不能。
因为这里面可能有由于锁冲突而处于锁等待状态的事务。如果这些事务在备库上被分配到不同的worker就会出现备库跟主库不一致的情况。
而上面提到的MariaDB这个策略的核心是“所有处于commit”状态的事务可以并行。事务处于commit状态表示已经通过了锁冲突的检验了。
这时候,你可以再回顾一下两阶段提交,我把前面[第23篇文章](https://time.geekbang.org/column/article/76161)中介绍过的两阶段提交过程图贴过来。
<img src="https://static001.geekbang.org/resource/image/5a/28/5ae7d074c34bc5bd55c82781de670c28.png" alt="">
其实不用等到commit阶段只要能够到达redo log prepare阶段就表示事务已经通过锁冲突的检验了。
因此MySQL 5.7并行复制策略的思想是:
<li>
同时处于prepare状态的事务在备库执行时是可以并行的
</li>
<li>
处于prepare状态的事务与处于commit状态的事务之间在备库执行时也是可以并行的。
</li>
我在第23篇文章讲binlog的组提交的时候介绍过两个参数
<li>
binlog_group_commit_sync_delay参数表示延迟多少微秒后才调用fsync;
</li>
<li>
binlog_group_commit_sync_no_delay_count参数表示累积多少次以后才调用fsync。
</li>
这两个参数是用于故意拉长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用来控制是否启用这个新策略。这个参数的可选值有以下三种。
<li>
COMMIT_ORDER表示的就是前面介绍的根据同时进入prepare和commit来判断是否可以并行的策略。
</li>
<li>
WRITESET表示的是对于事务涉及更新的每一行计算出这一行的hash值组成集合writeset。如果两个事务没有操作相同的行也就是说它们的writeset没有交集就可以并行。
</li>
<li>
WRITESET_SESSION是在WRITESET的基础上多了一个约束即在主库上同一个线程先后执行的两个事务在备库执行的时候要保证相同的先后顺序。
</li>
当然为了唯一标识这个hash值是通过“库名+表名+索引名+值”计算出来的。如果一个表上除了有主键索引外还有其他唯一索引那么对于每个唯一索引insert语句对应的writeset就要多增加一个hash值。
你可能看出来了这跟我们前面介绍的基于MySQL 5.5版本的按行分发的策略是差不多的。不过MySQL官方的这个实现还是有很大的优势
<li>
writeset是在主库生成后直接写入到binlog里面的这样在备库执行的时候不需要解析binlog内容event里的行数据节省了很多计算量
</li>
<li>
不需要把整个事务的binlog都扫一遍才能决定分发到哪个worker更省内存
</li>
<li>
由于备库的分发策略不依赖于binlog内容所以binlog是statement格式也是可以的。
</li>
因此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发送是主库主动推送的。之所以这么设计也是为了效率和实时性考虑毕竟靠备库轮询会有时间差。

View File

@@ -0,0 +1,353 @@
<audio id="audio" title="27 | 主库出问题了,从库怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/96/24/96d27721ebf63392e89433d8c9846124.mp3"></audio>
在前面的第[24](https://time.geekbang.org/column/article/76446)、[25](https://time.geekbang.org/column/article/76795)和[26](https://time.geekbang.org/column/article/77083)篇文章中我和你介绍了MySQL主备复制的基础结构但这些都是一主一备的结构。
大多数的互联网应用场景都是读多写少,因此你负责的业务,在发展过程中很可能先会遇到读性能的问题。而在数据库层解决读性能问题,就要涉及到接下来两篇文章要讨论的架构:一主多从。
今天这篇文章,我们就先聊聊一主多从的切换正确性。然后,我们在下一篇文章中再聊聊解决一主多从的查询逻辑正确性的方法。
如图1所示就是一个基本的一主多从结构。
<img src="https://static001.geekbang.org/resource/image/aa/79/aadb3b956d1ffc13ac46515a7d619e79.png" alt="">
图中虚线箭头表示的是主备关系也就是A和A互为主备 从库B、C、D指向的是主库A。一主多从的设置一般用于读写分离主库负责所有的写入和一部分读其他的读请求则由从库分担。
今天我们要讨论的就是,在一主多从架构下,主库故障后的主备切换问题。
如图2所示就是主库发生故障主备切换后的结果。
<img src="https://static001.geekbang.org/resource/image/00/53/0014f97423bd75235a9187f492fb2453.png" alt="">
相比于一主一备的切换流程一主多从结构在切换完成后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上已经执行过的事务。
一种取同步位点的方法是这样的:
<li>
等待新主库A把中转日志relay log全部同步完成
</li>
<li>
在A上执行show master status命令得到当前A上最新的File 和 Position
</li>
<li>
取原主库A故障的时刻T
</li>
<li>
用mysqlbinlog工具解析A的File得到T时刻的位点。
</li>
```
mysqlbinlog File --stop-datetime=T --start-datetime=T
```
<img src="https://static001.geekbang.org/resource/image/34/dd/3471dfe4aebcccfaec0523a08cdd0ddd.png" alt="">
图中end_log_pos后面的值“123”表示的就是A这个实例在T时刻写入新的binlog的位置。然后我们就可以把123这个值作为$master_log_pos 用在节点B的change master命令里。
当然这个值并不精确。为什么呢?
你可以设想有这么一种情况假设在T这个时刻主库A已经执行完成了一个insert 语句插入了一行数据R并且已经将binlog传给了A和B然后在传完的瞬间主库A的主机就掉电了。
那么,这时候系统的状态是这样的:
<li>
在从库B上由于同步了binlog R这一行已经存在
</li>
<li>
在新主库A R这一行也已经存在日志是写在123这个位置之后的
</li>
<li>
我们在从库B上执行change master命令指向A的File文件的123位置就会把插入R这一行数据的binlog又同步到从库B去执行。
</li>
这时候从库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的值。
<li>
<p>如果gtid_next=automatic代表使用默认值。这时MySQL就会把server_uuid:gno分配给这个事务。<br>
a. 记录binlog的时候先记录一行 SET @@SESSION.GTID_NEXT=server_uuid:gno;<br>
b. 把这个GTID加入本实例的GTID集合。</p>
</li>
<li>
<p>如果gtid_next是一个指定的GTID的值比如通过set gtid_next='current_gtid指定为current_gtid那么就有两种可能<br>
a. 如果current_gtid已经存在于实例的GTID集合中接下来执行的这个事务会直接被系统忽略<br>
b. 如果current_gtid没有存在于实例的GTID集合中就将这个current_gtid分配给接下来要执行的事务也就是说系统不需要给这个事务生成新的GTID因此gno也不用加1。</p>
</li>
注意一个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 src="https://static001.geekbang.org/resource/image/28/c2/28a5cab0079fb12fd5abecd92b3324c2.png" alt="">
可以看到事务的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 src="https://static001.geekbang.org/resource/image/c8/57/c8d3299ece7d583a3ecd1557851ed157.png" alt="">
可以看到实例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的逻辑是这样的
<li>
实例B指定主库A基于主备协议建立连接。
</li>
<li>
实例B把set_b发给主库A
</li>
<li>
<p>实例A算出set_a与set_b的差集也就是所有存在于set_a但是不存在于set_b的GTID的集合判断A本地是否包含了这个差集需要的所有binlog事务。<br>
a. 如果不包含表示A已经把实例B需要的binlog给删掉了直接返回错误<br>
b. 如果确认全部包含A从自己的binlog文件里面找出第一个不在set_b的事务发给B</p>
</li>
<li>
之后就从这个事务开始往后读文件按顺序取binlog发给B去执行。
</li>
其实这个逻辑里面包含了一个设计思想在基于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有哪些“饮鸩止渴”提高性能的方法](https://time.geekbang.org/column/article/75746)中,我和你提到业务高峰期的慢查询性能问题时,分析到如果是由于索引缺失引起的性能问题,我们可以通过在线加索引来解决。但是,考虑到要避免新增索引对主库性能造成的影响,我们可以先在备库加索引,然后再切换。
当时我说在双M结构下备库执行的DDL语句也会传给主库为了避免传回后对主库造成影响要通过set sql_log_bin=off关掉binlog。
评论区有位同学提出了一个问题这样操作的话数据库里面是加了索引但是binlog并没有记录下这一个更新是不是会导致数据和日志不一致
这个问题提得非常好。当时我在留言的回复中就引用了GTID来说明。今天我再和你展开说明一下。
假设这两个互为主备关系的库还是实例X和实例Y且当前主库是X并且都打开了GTID模式。这时的主备切换流程可以变成下面这样
<li>
在实例X上执行stop slave。
</li>
<li>
在实例Y上执行DDL语句。注意这里并不需要关闭binlog。
</li>
<li>
执行完成后查出这个DDL语句对应的GTID并记为 server_uuid_of_Y:gno。
</li>
<li>
到实例X上执行以下语句序列
</li>
```
set GTID_NEXT=&quot;server_uuid_of_Y:gno&quot;;
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。
**评论区留言点赞板:**
>
<p>@慧鑫coming 问了一个好问题对同一行作更新的几个事务如果commit_id相同是不是在备库并行执行的时候会导致数据不一致这个问题的答案是更新同一行的事务是不可能同时进入commit状态的。<br>
@老杨同志 对这个问题给出了更详细的回答,大家可以去看一下。</p>

View File

@@ -0,0 +1,392 @@
<audio id="audio" title="28 | 读写分离有哪些坑?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ce/40/ce9c998b0e6524551244696dca1d1240.mp3"></audio>
在上一篇文章中,我和你介绍了一主多从的结构以及切换流程。今天我们就继续聊聊一主多从架构的应用场景:读写分离,以及怎么处理主备延迟导致的读写分离问题。
我们在上一篇文章中提到的一主多从的结构,其实就是读写分离的基本结构了。这里,我再把这张图贴过来,方便你理解。
<img src="https://static001.geekbang.org/resource/image/13/aa/1334b9c08b8fd837832fdb2d82e6b0aa.png" alt="">
读写分离的主要目标就是分摊主库的压力。图1中的结构是客户端client主动做负载均衡这种模式下一般会把数据库的连接信息放在客户端的连接层。也就是说由客户端来选择后端数据库进行查询。
还有一种架构是在MySQL和客户端之间有一个中间代理层proxy客户端只连接proxy 由proxy根据请求类型和上下文决定请求的分发路由。
<img src="https://static001.geekbang.org/resource/image/1b/45/1b1ea74a48e1a16409e9b4d02172b945.jpg" alt="">
接下来我们就看一下客户端直连和带proxy的读写分离架构各有哪些特点。
<li>
<p>客户端直连方案因为少了一层proxy转发所以查询性能稍微好一点儿并且整体架构简单排查问题更方便。但是这种方案由于要了解后端部署细节所以在出现主备切换、库迁移等操作的时候客户端都会感知到并且需要调整数据库连接信息。<br>
你可能会觉得这样客户端也太麻烦了信息大量冗余架构很丑。其实也未必一般采用这样的架构一定会伴随一个负责管理后端的组件比如Zookeeper尽量让业务端只专注于业务逻辑开发。</p>
</li>
<li>
带proxy的架构对客户端比较友好。客户端不需要关注后端细节连接维护、后端信息维护等工作都是由proxy完成的。但这样的话对后端维护团队的要求会更高。而且proxy也需要有高可用架构。因此带proxy架构的整体就相对比较复杂。
</li>
理解了这两种方案的优劣具体选择哪个方案就取决于数据库团队提供的能力了。但目前看趋势是往带proxy的架构方向发展的。
但是,不论使用哪种架构,你都会碰到我们今天要讨论的问题:由于主从可能存在延迟,客户端执行完一个更新事务后马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。
**这种“在从库上会读到系统的一个过期状态”的现象,在这篇文章里,我们暂且称之为“过期读”。**
前面我们说过了几种可能导致主备延迟的原因以及对应的优化策略但是主从延迟还是不能100%避免的。
不论哪种结构,客户端都希望查询从库的数据结果,跟查主库的数据结果是一样的。
接下来,我们就来讨论怎么处理过期读问题。
这里,我先把文章中涉及到的处理过期读的方案汇总在这里,以帮助你更好地理解和掌握全文的知识脉络。这些方案包括:
- 强制走主库方案;
- sleep方案
- 判断主备无延迟方案;
- 配合semi-sync方案
- 等主库位点方案;
- 等GTID方案。
# 强制走主库方案
强制走主库方案其实就是,将查询请求做分类。通常情况下,我们可以将查询请求分为这么两类:
<li>
对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品以后,马上要返回主页面,看商品是否发布成功。那么,这个请求需要拿到最新的结果,就必须走主库。
</li>
<li>
对于可以读到旧数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的。那么,这类请求就可以走从库。
</li>
你可能会说,这个方案是不是有点畏难和取巧的意思,但其实这个方案是用得最多的。
当然,这个方案最大的问题在于,有时候你会碰到“所有查询都不能是过期读”的需求,比如一些金融类的业务。这样的话,你就要放弃读写分离,所有读写压力都在主库,等同于放弃了扩展性。
因此接下来,我们来讨论的话题是:可以支持读写分离的场景下,有哪些解决过期读的方案,并分析各个方案的优缺点。
# Sleep 方案
主库更新后读从库之前先sleep一下。具体的方案就是类似于执行一条select sleep(1)命令。
这个方案的假设是大多数情况下主备延迟在1秒之内做一个sleep可以有很大概率拿到最新的数据。
这个方案给你的第一感觉很可能是不靠谱儿应该不会有人用吧并且你还可能会说直接在发起查询时先执行一条sleep语句用户体验很不友好啊。
但,这个思路确实可以在一定程度上解决问题。为了看起来更靠谱儿,我们可以换一种方式。
以卖家发布商品为例商品发布后用AjaxAsynchronous JavaScript + XML异步JavaScript和XML直接把客户端输入的内容作为“新的商品”显示在页面上而不是真正地去数据库做查询。
这样卖家就可以通过这个显示来确认产品已经发布成功了。等到卖家再刷新页面去查看商品的时候其实已经过了一段时间也就达到了sleep的目的进而也就解决了过期读的问题。
也就是说这个sleep方案确实解决了类似场景下的过期读问题。但从严格意义上来说这个方案存在的问题就是不精确。这个不精确包含了两层意思
<li>
如果这个查询请求本来0.5秒就可以在从库上拿到正确结果也会等1秒
</li>
<li>
如果延迟超过1秒还是会出现过期读。
</li>
看到这里你是不是有一种“你是不是在逗我”的感觉这个改进方案虽然可以解决类似Ajax场景下的过期读问题但还是怎么看都不靠谱儿。别着急接下来我就和你介绍一些更准确的方案。
# 判断主备无延迟方案
要确保备库无延迟,通常有三种做法。
通过前面的[第25篇](https://time.geekbang.org/column/article/76795)文章我们知道show slave status结果里的seconds_behind_master参数的值可以用来衡量主备延迟时间的长短。
所以**第一种确保主备无延迟的方法是,**每次从库执行查询请求前先判断seconds_behind_master是否已经等于0。如果还不等于0 那就必须等到这个参数变为0才能执行查询请求。
seconds_behind_master的单位是秒如果你觉得精度不够的话还可以采用对比位点和GTID的方法来确保主备无延迟也就是我们接下来要说的第二和第三种方法。
如图3所示是一个show slave status结果的部分截图。
<img src="https://static001.geekbang.org/resource/image/00/c1/00110923007513e865d7f43a124887c1.png" alt="">
现在我们就通过这个结果来看看具体如何通过对比位点和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在主备库之间的状态
<li>
主库执行完成写入binlog并反馈给客户端
</li>
<li>
binlog被从主库发送给备库备库收到
</li>
<li>
在备库执行binlog完成。
</li>
我们上面判断主备无延迟的逻辑是“备库收到的日志都执行完成了”。但是从binlog在主备之间状态的分析中不难看出还有一部分日志处于客户端已经收到提交确认而备库还没收到日志的状态。
如图4所示就是这样的一个状态。
<img src="https://static001.geekbang.org/resource/image/55/9e/557445207b57d6c0f2747509d7d6619e.png" alt="">
这时主库上执行完成了三个事务trx1、trx2和trx3其中
<li>
trx1和trx2已经传到从库并且已经执行完成了
</li>
<li>
trx3在主库执行完成并且已经回复给客户端但是还没有传到从库中。
</li>
如果这时候你在从库B上执行查询请求按照我们上面的逻辑从库认为已经没有同步延迟但还是查不到trx3的。严格地说就是出现了过期读。
那么,这个问题有没有办法解决呢?
# 配合semi-sync
要解决这个问题就要引入半同步复制也就是semi-sync replication。
semi-sync做了这样的设计
<li>
事务提交的时候主库把binlog发给从库
</li>
<li>
从库收到binlog以后发回给主库一个ack表示收到了
</li>
<li>
主库收到这个ack以后才能给客户端返回“事务完成”的确认。
</li>
也就是说如果启用了semi-sync就表示所有给客户端发送过确认的事务都确保了备库已经收到了这个日志。
在[第25篇文章](https://time.geekbang.org/column/article/76795)的评论区有同学问到如果主库掉电的时候有些binlog还来不及发给从库会不会导致系统数据丢失
答案是如果使用的是普通的异步复制模式就可能会丢失但semi-sync就可以解决这个问题。
这样semi-sync配合前面关于位点的判断就能够确定在从库上执行的查询请求可以避免过期读。
但是semi-sync+位点判断的方案只对一主一备的场景是成立的。在一主多从场景中主库只要等到一个从库的ack就开始给客户端返回确认。这时在从库上执行查询请求就有两种情况
<li>
如果查询是落在这个响应了ack的从库上是能够确保读到最新数据
</li>
<li>
但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题。
</li>
其实判断同步位点的方案还有另外一个潜在的问题如果在业务更新的高峰期主库的位点或者GTID集合更新很快那么上面的两个位点等值判断就会一直不成立很可能出现从库上迟迟无法响应查询请求的情况。
实际上,回到我们最初的业务逻辑里,当发起一个查询请求以后,我们要得到准确的结果,其实并不需要等到“主备完全同步”。
为什么这么说呢?我们来看一下这个时序图。
<img src="https://static001.geekbang.org/resource/image/9c/09/9cf54f3e91dc8f7b8947d7d8e384aa09.png" alt="">
图5所示就是等待位点方案的一个bad case。图中备库B下的虚线框分别表示relaylog和binlog中的事务。可以看到图5中从状态1 到状态4一直处于延迟一个事务的状态。
备库B一直到状态4都和主库A存在延迟如果用上面必须等到无延迟才能查询的方案select语句直到状态4都不能被执行。
但是其实客户端是在发完trx1更新后发起的select语句我们只需要确保trx1已经执行完成就可以执行select语句了。也就是说如果在状态3执行查询请求得到的就是预期结果了。
到这里我们小结一下semi-sync配合判断主备无延迟的方案存在两个问题
<li>
一主多从的时候,在某些从库执行查询请求会存在过期读的现象;
</li>
<li>
在持续延迟的情况下,可能出现过度等待的问题。
</li>
接下来,我要和你介绍的等主库位点方案,就可以解决这两个问题。
# 等主库位点方案
要理解等主库位点方案,我需要先和你介绍一条命令:
```
select master_pos_wait(file, pos[, timeout]);
```
这条命令的逻辑如下:
<li>
它是在从库执行的;
</li>
<li>
参数file和pos指的是主库上的文件名和位置
</li>
<li>
timeout可选设置为正整数N表示这个函数最多等待N秒。
</li>
这个命令正常返回的结果是一个正整数M表示从命令开始执行到应用完file和pos表示的binlog位置执行了多少事务。
当然除了正常返回一个正整数M外这条命令还会返回一些其他结果包括
<li>
如果执行期间备库同步线程发生异常则返回NULL
</li>
<li>
如果等待超过N秒就返回-1
</li>
<li>
如果刚开始执行的时候就发现已经执行过这个位置了则返回0。
</li>
对于图5中先执行trx1再执行一个查询请求的逻辑要保证能够查到正确的数据我们可以使用这个逻辑
<li>
trx1事务更新完成后马上执行show master status得到当前主库执行到的File和Position
</li>
<li>
选定一个从库执行查询语句;
</li>
<li>
在从库上执行select master_pos_wait(File, Position, 1)
</li>
<li>
如果返回值是&gt;=0的正整数则在这个从库执行查询语句
</li>
<li>
否则,到主库执行查询语句。
</li>
我把上面这个流程画出来。
<img src="https://static001.geekbang.org/resource/image/b2/57/b20ae91ea46803df1b63ed683e1de357.png" alt="">
这里我们假设这条select查询最多在从库上等待1秒。那么如果1秒内master_pos_wait返回一个大于等于0的整数就确保了从库上执行的这个查询结果一定包含了trx1的数据。
步骤5到主库执行查询语句是这类方案常用的退化机制。因为从库的延迟时间不可控不能无限等待所以如果等待超时就应该放弃然后到主库去查。
你可能会说如果所有的从库都延迟超过1秒了那查询压力不就都跑到主库上了吗确实是这样。
但是,按照我们设定不允许过期读的要求,就只有两种选择,一种是超时放弃,一种是转到主库查询。具体怎么选择,就需要业务开发同学做好限流策略了。
# GTID方案
如果你的数据库开启了GTID模式对应的也有等待GTID的方案。
MySQL中同样提供了一个类似的命令
```
select wait_for_executed_gtid_set(gtid_set, 1);
```
这条命令的逻辑是:
<li>
等待直到这个库执行的事务中包含传入的gtid_set返回0
</li>
<li>
超时返回1。
</li>
在前面等位点的方案中我们执行完事务后还要主动去主库执行show master status。而MySQL 5.7.6版本开始允许在执行完更新类事务后把这个事务的GTID返回给客户端这样等GTID的方案就可以减少一次查询。
这时等GTID的执行流程就变成了
<li>
trx1事务更新完成后从返回包直接获取这个事务的GTID记为gtid1
</li>
<li>
选定一个从库执行查询语句;
</li>
<li>
在从库上执行 select wait_for_executed_gtid_set(gtid1, 1)
</li>
<li>
如果返回值是0则在这个从库执行查询语句
</li>
<li>
否则,到主库执行查询语句。
</li>
跟等主库位点的方案一样,等待超时后是否直接到主库查询,需要业务开发同学来做限流考虑。
我把这个流程图画出来。
<img src="https://static001.geekbang.org/resource/image/d5/39/d521de8017297aff59db2f68170ee739.png" alt="">
在上面的第一步中trx1事务更新完成后从返回包直接获取这个事务的GTID。问题是怎么能够让MySQL在执行事务后返回包中带上GTID呢
你只需要将参数session_track_gtids设置为OWN_GTID然后通过API接口mysql_session_track_get_first从返回包解析出GTID的值即可。
在专栏的[第一篇文章](https://time.geekbang.org/column/article/68319)中我介绍mysql_reset_connection的时候评论区有同学留言问这类接口应该怎么使用。
这里我再回答一下。其实MySQL并没有提供这类接口的SQL用法是提供给程序的API([https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html](https://dev.mysql.com/doc/refman/5.7/en/c-api-functions.html))。
比如为了让客户端在事务提交后返回的GITD能够在客户端显示出来我对MySQL客户端代码做了点修改如下所示
<img src="https://static001.geekbang.org/resource/image/97/63/973bdd8741f830acebe005cbf37a7663.png" alt="">
这样就可以看到语句执行完成显示出GITD的值。
<img src="https://static001.geekbang.org/resource/image/25/fe/253106d31d9d97aaa2846b2015f593fe.png" alt="">
当然了这只是一个例子。你要使用这个方案的时候还是应该在你的客户端代码中调用mysql_session_track_get_first这个函数。
# 小结
在今天这篇文章中,我跟你介绍了一主多从做读写分离时,可能碰到过期读的原因,以及几种应对的方案。
这几种方案中,有的方案看上去是做了妥协,有的方案看上去不那么靠谱儿,但都是有实际应用场景的,你需要根据业务需求选择。
即使是最后等待位点和等待GTID这两个方案虽然看上去比较靠谱儿但仍然存在需要权衡的情况。如果所有的从库都延迟那么请求就会全部落到主库上这时候会不会由于压力突然增大把主库打挂了呢
其实,在实际应用中,这几个方案是可以混合使用的。
比如先在客户端对请求做分类区分哪些请求可以接受过期读而哪些请求完全不能接受过期读然后对于不能接受过期读的语句再使用等GTID或等位点的方案。
但话说回来,过期读在本质上是由一写多读导致的。在实际应用中,可能会有别的不需要等待就可以水平扩展的数据库方案,但这往往是用牺牲写性能换来的,也就是需要在读性能和写性能中取权衡。
最后 ,我给你留下一个问题吧。
假设你的系统采用了我们文中介绍的最后一个方案也就是等GTID的方案现在你要对主库的一张大表做DDL可能会出现什么情况呢为了避免这种情况你会怎么做呢
你可以把你的分析和方案设计写在评论区,我会在下一篇文章跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期给你留的问题是在GTID模式下如果一个新的从库接上主库但是需要的binlog已经没了要怎么做
@某、人同学给了很详细的分析,我把他的回答略做修改贴过来。
<li>
如果业务允许主从不一致的情况那么可以在主库上先执行show global variables like gtid_purged得到主库已经删除的GTID集合假设是gtid_purged1然后先在从库上执行reset master再执行set global gtid_purged =gtid_purged1最后执行start slave就会从主库现存的binlog开始同步。binlog缺失的那一部分数据在从库上就可能会有丢失造成主从不一致。
</li>
<li>
如果需要主从数据一致的话,最好还是通过重新搭建从库来做。
</li>
<li>
如果有其他的从库保留有全量的binlog的话可以把新的从库先接到这个保留了全量binlog的从库追上日志以后如果有需要再接回主库。
</li>
<li>
如果binlog有备份的情况可以先在从库上应用缺失的binlog然后再执行start slave。
</li>
评论区留言点赞板:
>
@悟空 同学级联实验验证了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是可以跳过整个事务的。

View File

@@ -0,0 +1,237 @@
<audio id="audio" title="29 | 如何判断一个数据库是不是出问题了?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/52/06/52d50a49a19d6b835388715a0c0b5506.mp3"></audio>
我在第[25](https://time.geekbang.org/column/article/76795)和[27](https://time.geekbang.org/column/article/77427)篇文章中和你介绍了主备切换流程。通过这些内容的讲解你应该已经很清楚了在一主一备的双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 src="https://static001.geekbang.org/resource/image/35/55/35076dd3d0a0d44d22b76d2a29885255.png" alt="">
我们设置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篇文章](https://time.geekbang.org/column/article/70215)中讲到的热点更新和死锁检测的时候如果把innodb_thread_concurrency设置为128的话那么出现同一行热点更新的问题时是不是很快就把128消耗完了这样整个系统是不是就挂了呢
实际上,**在线程进入锁等待以后,并发线程的计数会减一**也就是说等行锁也包括间隙锁的线程是不算在128里面的。
MySQL这样设计是非常有意义的。因为进入锁等待的线程已经不吃CPU了更重要的是必须这么设计才能避免整个系统锁死。
为什么呢?假设处于锁等待的线程也占并发线程的计数,你可以设想一下这个场景:
<li>
线程1执行begin; update t set c=c+1 where id=1, 启动了事务trx1 然后保持这个状态。这时候,线程处于空闲状态,不算在并发线程里面。
</li>
<li>
线程2到线程129都执行 update t set c=c+1 where id=1; 由于等行锁进入等待状态。这样就有128个线程处于等待状态
</li>
<li>
如果处于锁等待状态的线程计数不减一InnoDB就会认为线程数用满了会阻止其他语句进入引擎执行这样线程1不能提交事务。而另外的128个线程又处于锁等待状态整个系统就堵住了。
</li>
下图2显示的就是这个状态。
<img src="https://static001.geekbang.org/resource/image/32/1d/3206ea18b8a24b546515b1b95dc4a11d.png" alt="">
这时候InnoDB不能响应任何请求整个系统被锁死。而且由于所有线程都处于等待状态此时占用的CPU却是0而这明显不合理。所以我们说InnoDB在设计时遇到进程进入锁等待的情况时将并发线程的计数减1的设计是合理而且是必要的。
虽然说等锁的线程不算在并发线程计数里但如果它在真正地执行查询就比如我们上面例子中前三个事务中的select sleep(100) from t还是要算进并发线程的计数的。
在这个例子中同时在执行的语句超过了设置的innodb_thread_concurrency的值这时候系统其实已经不行了但是通过select 1来检测系统会认为系统还是正常的。
因此我们使用select 1的判断逻辑要修改一下。
# 查表判断
为了能够检测InnoDB并发线程数过多导致的系统不可用情况我们需要找一个访问InnoDB的场景。一般的做法是在系统库mysql库里创建一个表比如命名为health_check里面只放一行数据然后定期执行
```
mysql&gt; select * from mysql.health_check;
```
使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。
但是,我们马上还会碰到下一个问题,即:空间满了以后,这种方法又会变得不好使。
我们知道更新事务要写binlog而一旦binlog所在磁盘的空间占用率达到100%那么所有的更新语句和事务提交的commit语句就都会被堵住。但是系统这时候还是可以正常读数据的。
因此,我们还是把这条监控语句再改进一下。接下来,我们就看看把查询语句改成更新语句后的效果。
# 更新判断
既然要更新就要放个有意义的字段常见做法是放一个timestamp字段用来表示最后一次执行检测的时间。这条更新语句类似于
```
mysql&gt; 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&gt; 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 src="https://static001.geekbang.org/resource/image/75/dd/752ccfe43b4eab155be17401838c62dd.png" alt="">
图中这一行表示统计的是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&gt; 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&gt; 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&gt;200*1000000000;
```
发现异常后,取到你需要的信息,再通过下面这条语句:
```
mysql&gt; truncate table performance_schema.file_summary_by_event_name;
```
把之前的统计信息清空。这样如果后面的监控中,再次出现这个异常,就可以加入监控累积值了。
# 小结
今天我和你介绍了检测一个MySQL实例健康状态的几种方法以及各种方法存在的问题和演进的逻辑。
你看完后可能会觉得select 1这样的方法是不是已经被淘汰了呢但实际上使用非常广泛的MHAMaster High Availability默认使用的就是这个方法。
MHA中的另一个可选方法是只做连接就是 “如果连接成功就认为主库没问题”。不过据我所知,选择这个方法的很少。
其实,每个改进的方案,都会增加额外损耗,并不能用“对错”做直接判断,需要你根据业务实际情况去做权衡。
我个人比较倾向的方案是优先考虑update系统表然后再配合增加检测performance_schema的信息。
最后,又到了我们的思考题时间。
今天,我想问你的是:业务系统一般也有高可用的需求,在你开发和维护过的服务中,你是怎么判断服务有没有出问题的呢?
你可以把你用到的方法和分析写在留言区,我会在下一篇文章中选取有趣的方案一起来分享和分析。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是如果使用GTID等位点的方案做读写分离在对大表做DDL的时候会怎么样。
假设这条语句在主库上要执行10分钟提交后传到备库就要10分钟典型的大事务。那么在主库DDL之后再提交的事务的GTID去备库查的时候就会等10分钟才出现。
这样这个读写分离机制在这10分钟之内都会超时然后走主库。
这种预期内的操作应该在业务低峰期的时候确保主库能够支持所有业务查询然后把读请求都切到主库再在主库上做DDL。等备库延迟追上以后再把读请求切回备库。
通过这个思考题,我主要想让关注的是,大事务对等位点方案的影响。
当然了使用gh-ost方案来解决这个问题也是不错的选择。
评论区留言点赞板:
>
@曾剑@max 同学提到的备库先做,再切主库的方法也是可以的。

View File

@@ -0,0 +1,301 @@
<audio id="audio" title="30 | 答疑文章(二):用动态的观点看加锁" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8e/15/8e8a19b7170ae07e4bd4ddbc309aee15.mp3"></audio>
在第[20](https://time.geekbang.org/column/article/75173)和[21](https://time.geekbang.org/column/article/75659)篇文章中我和你介绍了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&gt;9 and id&lt;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 src="https://static001.geekbang.org/resource/image/ac/bb/ac1aa07860c565b907b32c5f75c4f2bb.png" alt="">
<li>
首先这个查询语句的语义是order by id desc要拿到满足条件的所有行优化器必须先找到“第一个id&lt;12的值”。
</li>
<li>
这个过程是通过索引树的搜索过程得到的在引擎内部其实是要找到id=12的这个值只是最终没找到但找到了(10,15)这个间隙。
</li>
<li>
然后向左遍历在遍历过程中就不是等值查询了会扫描到id=5这一行所以会加一个next-key lock (0,5]。
</li>
也就是说,在执行过程中,通过树搜索的方式定位记录的时候,用的是“等值查询”的方法。
# 等值查询的过程
与上面这个例子对应的,是@发条橙子同学提出的问题:下面这个语句的加锁范围是什么?
```
begin;
select id from t where c in(5,20,10) lock in share mode;
```
这条查询语句里用的是in我们先来看这条语句的explain结果。<br>
<img src="https://static001.geekbang.org/resource/image/8a/b3/8a089159c82c1458b26e2756583347b3.png" alt="">
可以看到这条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就是记录的最后一次死锁信息。<br>
<img src="https://static001.geekbang.org/resource/image/a7/f6/a7dccb91bc17d12746703eb194775cf6.png" alt="">
我们来看看这图中的几个关键信息。
<li>
这个结果分成三部分:
<ul>
1. (1) TRANSACTION是第一个事务的信息
1. (2) TRANSACTION是第二个事务的信息
1. WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。
</ul>
</li>
<li>
第一个事务的信息中:
<ul>
1. WAITING FOR THIS LOCK TO BE GRANTED表示的是这个事务在等待的锁信息
1. index c of table `test`.`t`说明在等的是表t的索引c上面的锁
1. lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;
1. Record lock说明这是一个记录锁
1. n_fields 2表示这个记录是两列也就是字段c和主键字段id
1. 0: len 4; hex 0000000a; asc ;;是第一个字段也就是c。值是十六进制a也就是10
1. 1: len 4; hex 0000000a; asc ;;是第二个字段也就是主键id值也是10
1. 这两行里面的asc表示的是接下来要打印出值里面的“可打印字符”但10不是可打印字符因此就显示空格。
1. 第一个事务信息就只显示出了等锁的状态,在等待(c=10,id=10)这一行的锁。
1. 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
</ul>
</li>
<li>
第二个事务显示的信息要多一些:
<ul>
1. “ HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;
1. index c of table `test`.`t` 表示锁是在表t的索引c上
1. hex 0000000a和hex 00000014表示这个事务持有c=10和c=20这两个记录锁
1. WAITING FOR THIS LOCK TO BE GRANTED表示在等(c=5,id=5)这个记录锁。
</ul>
</li>
- 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)这一行的锁。
- 当然你是知道的,既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
从上面这些信息中,我们就知道:
<li>
“lock in share mode”的这条语句持有c=5的记录锁在等c=10的锁
</li>
<li>
“for update”这个语句持有c=20和c=10的记录锁在等c=5的记录锁。
</li>
因此导致了死锁。这里,我们可以得到两个结论:
<li>
由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
</li>
<li>
在发生死锁的时刻for update 这条语句占有的资源更多回滚成本更大所以InnoDB选择了回滚成本更小的lock in share mode语句来回滚。
</li>
# 怎么看锁等待?
看完死锁,我们再来看一个锁等待的例子。
在第21篇文章的评论区@Geek_9ca34e 同学做了一个有趣验证,我把复现步骤列出来:
<img src="https://static001.geekbang.org/resource/image/af/75/af3602b81aeb49e33577ba372d220a75.png" alt="">
可以看到由于session A并没有锁住c=10这个记录所以session B删除id=10这一行是可以的。但是之后session B再想insert id=10这一行回去就不行了。
现在我们一起看一下此时show engine innodb status的结果看看能不能给我们一些提示。锁信息是在这个命令输出结果的TRANSACTIONS这一节。你可以在文稿中看到这张图片<br>
<img src="https://static001.geekbang.org/resource/image/c3/a6/c3744fb7b61df2a5b45b8eb1f2a853a6.png" alt="">
我们来看几个关键信息。
<li>
index PRIMARY of table `test`.`t` 表示这个语句被锁住是因为表t主键上的某个锁。
</li>
<li>
lock_mode X locks gap before rec insert intention waiting 这里有几个信息:
<ul>
1. insert intention表示当前线程准备插入一个记录这是一个插入意向锁。为了便于理解你可以认为它就是这个插入动作本身。
1. gap before rec 表示这是一个间隙锁,而不是记录锁。
</ul>
</li>
<li>
那么这个gap是在哪个记录之前的呢接下来的0~4这5行的内容就是这个记录的信息。
</li>
<li>
n_fields 5也表示了这一个记录有5列
<ul>
1. 0: len 4; hex 0000000f; asc ;;第一列是主键id字段十六进制f就是id=15。所以这时我们就知道了这个间隙就是id=15之前的因为id=10已经不存在了它表示的就是(5,15)。
1. 1: len 6; hex 000000000513; asc ;;第二列是长度为6字节的事务id表示最后修改这一行的是trx id为1299的事务。
1. 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为7字节的回滚段信息。可以看到这里的acs后面有显示内容(%和4),这是因为刚好这个字节是可打印字符。
1. 后面两列是c和d的值都是15。
</ul>
</li>
- 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)。
说到这里,你可以联合起来再思考一下这两个现象之间的关联:
<li>
session A执行完select语句后什么都没做但它加锁的范围突然“变大”了
</li>
<li>
第21篇文章的课后思考题当我们执行select * from t where c&gt;=15 and c&lt;=20 order by c desc lock in share mode; 向左扫描到c=10的时候要把(5, 10]锁起来。
</li>
也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。
# update的例子
看过了insert和delete的加锁例子我们再来看一个update语句的案例。在留言区中@信信 同学做了这个试验:
<img src="https://static001.geekbang.org/resource/image/61/a7/61c1ceea7b59201649c2514c9db864a7.png" alt="">
你可以自己分析一下session A的加锁范围是索引c上的 (5,10]、(10,15]、(15,20]、(20,25]和(25,supremum]。
>
注意根据c&gt;5查到的第一个记录是c=10因此不会加(0,5]这个next-key lock。
之后session B的第一个update语句要把c=5改成c=1你可以理解为两步
<li>
插入(c=1, id=5)这个记录;
</li>
<li>
删除(c=5, id=5)这个记录。
</li>
按照我们上一节说的索引c上(5,10)间隙是由这个间隙右边的记录也就是c=10定义的。所以通过这个操作session A的加锁范围变成了图7所示的样子<br>
<img src="https://static001.geekbang.org/resource/image/d2/e9/d2f6a0c46dd8d12f6a90dacc466d53e9.png" alt="">
接下来session B要执行 update t set c = 5 where c = 1这个语句了一样地可以拆成两步
<li>
插入(c=5, id=5)这个记录;
</li>
<li>
删除(c=1, id=5)这个记录。
</li>
第一步试图在已经加了间隙锁的(1,10)中插入数据,所以就被堵住了。
# 小结
今天这篇文章,我用前面[第20](https://time.geekbang.org/column/article/75173)和[第21篇](https://time.geekbang.org/column/article/75659)文章评论区的几个问题,再次跟你复习了加锁规则。并且,我和你重点说明了,分析加锁范围时,一定要配合语句执行逻辑来进行。
在我看来每个想认真了解MySQL原理的同学应该都要能够做到通过explain的结果就能够脑补出一个SQL语句的执行流程。达到这样的程度才算是对索引组织表、索引、锁的概念有了比较清晰的认识。你同样也可以用这个方法来验证自己对这些知识点的掌握程度。
在分析这些加锁规则的过程中我也顺便跟你介绍了怎么看show engine innodb status输出结果中的事务信息和死锁信息希望这些内容对你以后分析现场能有所帮助。
老规矩,即便是答疑文章,我也还是要留一个课后问题给你的。
上面我们提到一个很重要的点:所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的。
那么,一个空表有间隙吗?这个间隙是由谁定义的?你怎么验证这个结论呢?
你可以把你关于分析和验证方法写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上一篇文章最后留给的问题,是分享一下你关于业务监控的处理经验。
在这篇文章的评论区,很多同学都分享了不错的经验。这里,我就选择几个比较典型的留言,和你分享吧:
- @老杨同志 回答得很详细。他的主要思路就是关于服务状态和服务质量的监控。其中,服务状态的监控,一般都可以用外部系统来实现;而服务的质量的监控,就要通过接口的响应时间来统计。
- @Ryoma 同学提到服务中使用了healthCheck来检测其实跟我们文中提到的select 1的模式类似。
- @强哥 同学,按照监控的对象,将监控分成了基础监控、服务监控和业务监控,并分享了每种监控需要关注的对象。
这些都是很好的经验,你也可以根据具体的业务场景借鉴适合自己的方案。

View File

@@ -0,0 +1,275 @@
<audio id="audio" title="31 | 误删数据后除了跑路,还能怎么办?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/25/1d/2536a6e421ab9a68367b99bd7b78131d.mp3"></audio>
今天我要和你讨论的是一个沉重的话题:误删数据。
在前面几篇文章中我们介绍了MySQL的高可用架构。当然传统的高可用架构是不能预防误删数据的因为主库的一个drop table命令会通过binlog传给所有从库和级联从库进而导致整个集群的实例都会执行这个命令。
虽然我们之前遇到的大多数的数据被删都是运维同学或者DBA背锅的。但实际上只要有数据操作权限的同学都有可能踩到误删数据这条线。
今天我们就来聊聊误删数据前后,我们可以做些什么,减少误删数据的风险,和由误删数据带来的损失。
为了找到解决误删数据的更高效的方法我们需要先对和MySQL相关的误删数据做下分类
<li>
使用delete语句误删数据行
</li>
<li>
使用drop table或者truncate table语句误删数据表
</li>
<li>
使用drop database语句误删数据库
</li>
<li>
使用rm命令误删整个MySQL实例。
</li>
# 误删行
在[第24篇文章](https://time.geekbang.org/column/article/76446)中我们提到如果是使用delete语句误删了数据行可以用Flashback工具通过闪回把数据恢复回来。
Flashback恢复数据的原理是修改binlog的内容拿回原库重放。而能够使用这个方案的前提是需要确保binlog_format=row 和 binlog_row_image=FULL。
具体恢复数据时,对单个事务做如下处理:
<li>
对于insert语句对应的binlog event类型是Write_rows event把它改成Delete_rows event即可
</li>
<li>
同理对于delete语句也是将Delete_rows event改为Write_rows event
</li>
<li>
而如果是Update_rows的话binlog里面记录了数据行修改前和修改后的值对调这两行的位置即可。
</li>
如果误操作不是一个,而是多个,会怎么样呢?比如下面三个事务:
```
(A)delete ...
(B)insert ...
(C)update ...
```
现在要把数据库恢复回这三个事务操作之前的状态用Flashback工具解析binlog后写回主库的命令是
```
(reverse C)update ...
(reverse B)delete ...
(reverse A)insert ...
```
也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。
**需要说明的是,我不建议你直接在主库上执行这些操作。**
恢复数据比较安全的做法,是恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。
为什么要这么做呢?
这是因为,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的。可能由于发现数据问题的时间晚了一点儿,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其他数据。所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能会出现对数据的二次破坏。
当然,**我们不止要说误删数据的事后处理办法,更重要是要做到事前预防**。我有以下两个建议:
<li>
把sql_safe_updates参数设置为on。这样一来如果我们忘记在delete或者update语句中写where条件或者where条件里面没有包含索引字段的话这条语句的执行就会报错。
</li>
<li>
代码上线前必须经过SQL审计。
</li>
你可能会说设置了sql_safe_updates=on如果我真的要把一个小表的数据全部删掉应该怎么办呢
如果你确定这个删除操作没问题的话可以在delete语句中加上where条件比如where id&gt;=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点误删了一个库恢复数据的流程如下
<li>
取最近一次全量备份假设这个库是一天一备上次备份是当天0点
</li>
<li>
用备份恢复出一个临时库;
</li>
<li>
从日志备份里面取出凌晨0点之后的日志
</li>
<li>
把这些日志,除了误删除数据的语句外,全部应用到临时库。
</li>
这个流程的示意图如下所示:
<img src="https://static001.geekbang.org/resource/image/2f/db/2fafd0b75286e0163f432f85428ff8db.png" alt="">
关于这个过程,我需要和你说明如下几点:
<li>
为了加速数据恢复如果这个临时库上有多个数据库你可以在使用mysqlbinlog命令时加上一个database参数用来指定误删表所在的库。这样就避免了在恢复数据时还要应用其他库日志的情况。
</li>
<li>
在应用日志的时候需要跳过12点误操作的那个语句的binlog
<ul>
1. 如果原实例没有使用GTID模式只能在应用到包含12点的binlog文件的时候先用stop-position参数执行到误操作之前的日志然后再用start-position从误操作之后的日志继续执行
1. 如果实例使用了GTID模式就方便多了。假设误操作命令的GTID是gtid1那么只需要执行set gtid_next=gtid1;begin;commit; 先把这个GTID加到临时实例的GTID集合之后按顺序执行binlog的时候就会自动跳过误操作的语句。
</ul>
</li>
不过即使这样使用mysqlbinlog方法恢复数据还是不够快主要原因有两个
<li>
如果是误删表最好就是只恢复出这张表也就是只重放这张表的操作但是mysqlbinlog工具并不能指定只解析一个表的日志
</li>
<li>
用mysqlbinlog解析出日志应用应用日志的过程就只能是单线程。我们在[第26篇文章](https://time.geekbang.org/column/article/77083)中介绍的那些并行复制的方法,在这里都用不上。
</li>
**一种加速的方法是,**在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:
<li>
<p>在start slave之前先通过执行<br>
change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表;</p>
</li>
<li>
这样做也可以用上并行复制技术,来加速整个数据恢复过程。
</li>
这个过程的示意图如下所示。
<img src="https://static001.geekbang.org/resource/image/65/f1/65bb04929b8235fb677c7a78b5bd67f1.png" alt="">
可以看到图中binlog备份系统到线上备库有一条虚线是指如果由于时间太久备库上已经删除了临时实例需要的binlog的话我们可以从binlog备份系统中找到需要的binlog再放回备库中。
假设我们发现当前临时实例需要的binlog是从master.000005开始的但是在备库上执行show binlogs 显示的最小的binlog文件是master.000007意味着少了两个binlog文件。这时我们就需要去binlog备份系统中找到这两个文件。
把之前删掉的binlog放回备库的操作步骤是这样的
<li>
从备份系统下载master.000005和master.000006这两个文件,放到备库的日志目录下;
</li>
<li>
打开日志目录下的master.index文件在文件开头加入两行内容分别是 “./master.000005”和“./master.000006”;
</li>
<li>
重启备库,目的是要让备库重新识别这两个日志文件;
</li>
<li>
现在这个备库上就有了临时库需要的所有binlog了建立主备关系就可以正常同步了。
</li>
不论是把mysqlbinlog工具解析出的binlog文件应用到临时库还是把临时库接到备库上这两个方案的共同点是误删库或者表后恢复数据的思路主要就是通过备份再加上应用binlog的方式。
也就是说这两个方案都要求备份系统定期备份全量日志而且需要确保binlog在被从本地删除之前已经做了备份。
但是一个系统不可能备份无限的日志你还需要根据成本和磁盘空间资源设定一个日志保留的天数。如果你的DBA团队告诉你可以保证把某个实例恢复到半个月内的任意时间点这就表示备份系统保留的日志时间就至少是半个月。
另外,我建议你不论使用上述哪种方式,都要把这个数据恢复功能做成自动化工具,并且经常拿出来演练。为什么这么说呢?
这里的原因,主要包括两个方面:
<li>
虽然“发生这种事,大家都不想的”,但是万一出现了误删事件,能够快速恢复数据,将损失降到最小,也应该不用跑路了。
</li>
<li>
而如果临时再手忙脚乱地手动操作,最后又误操作了,对业务造成了二次伤害,那就说不过去了。
</li>
# 延迟复制备库
虽然我们可以通过利用并行复制来加速恢复数据的过程,但是这个方案仍然存在“恢复时间不可控”的问题。
如果一个库的备份特别大或者误操作的时间距离上一个全量备份的时间较长比如一周一备的实例在备份之后的第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&gt;1 for update;
```
这个查询语句加锁的范围就是next-key lock (-∞, supremum]。
验证方法的话你可以使用下面的操作序列。你可以在图4中看到显示的结果。
<img src="https://static001.geekbang.org/resource/image/12/65/12eb6a38c347203f60df72ecaea95565.png" alt="">
<img src="https://static001.geekbang.org/resource/image/53/9f/531b6556ffc82c6b02f9a010a3ceb09f.png" alt="">
评论区留言点赞板:
>
<p>@老杨同志 给出了正确的分析和SQL语句验证方法<br>
@库淘淘 指出了show engine innodb status验证结论。</p>
赞这些思考和反馈。

View File

@@ -0,0 +1,238 @@
<audio id="audio" title="32 | 为什么还有kill不掉的语句" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1b/01/1be5e874c024c3ba91fd9852bf399201.mp3"></audio>
在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 src="https://static001.geekbang.org/resource/image/17/d0/17f88dc70c3fbe06a7738a0ac01db4d0.png" alt="">
可以看到session C 执行kill query以后session B几乎同时就提示了语句被中断。这就是我们预期的结果。
# 收到kill以后线程做什么
但是这里你要停下来想一下session B是直接终止掉线程什么都不管就直接退出吗显然这是不行的。
我在[第6篇文章](https://time.geekbang.org/column/article/69862)中讲过当对一个表做增删改查操作时会在表上加MDL读锁。所以session B虽然处于blocked状态但还是拿着一个MDL读锁的。如果线程被kill的时候就直接终止那之后这个MDL读锁就没机会被释放了。
这样看来kill并不是马上停止的意思而是告诉执行线程说这条语句已经不需要继续执行了可以开始“执行停止的逻辑了”。
>
其实这跟Linux的kill命令类似kill -N pid并不是让进程直接停止而是给进程发一个信号然后进程处理这个信号进入终止逻辑。只是对于MySQL的kill命令来说不需要传信号量参数就只有“停止”这个命令。
**实现上当用户执行kill query thread_id_B时MySQL里处理kill命令的线程做了两件事**
<li>
把session B的运行状态改成THD::KILL_QUERY(将变量killed赋值为THD::KILL_QUERY)
</li>
<li>
给session B的执行线程发一个信号。
</li>
为什么要发信号呢?
因为像图1的我们例子里面session B处于锁等待状态如果只是把session B的线程状态设置THD::KILL_QUERY线程B并不知道这个状态变化还是会继续等待。发一个信号的目的就是让session B退出等待来处理这个THD::KILL_QUERY状态。
上面的分析中,隐含了这么三层意思:
<li>
一个语句执行过程中有多处“埋点”在这些“埋点”的地方判断线程状态如果发现线程状态是THD::KILL_QUERY才开始进入语句终止逻辑
</li>
<li>
如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;
</li>
<li>
语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。
</li>
到这里你就知道了,原来不是“说停就停的”。
接下来,我们**再看一个kill不掉的例子**,也就是我们在前面[第29篇文章](https://time.geekbang.org/column/article/78134)中提到的 innodb_thread_concurrency 不够用的例子。
首先执行set global innodb_thread_concurrency=2将InnoDB的并发线程上限数设置为2然后执行下面的序列
<img src="https://static001.geekbang.org/resource/image/32/6e/32e4341409fabfe271db3dd4c4df696e.png" alt="">
可以看到:
<li>
sesssion C执行的时候被堵住了
</li>
<li>
但是session D执行的kill query C命令却没什么效果
</li>
<li>
直到session E执行了kill connection命令才断开了session C的连接提示“Lost connection to MySQL server during query”
</li>
<li>
但是这时候如果在session E中执行show processlist你就能看到下面这个图。
</li>
<img src="https://static001.geekbang.org/resource/image/91/53/915c20e4c11b104d7bcf9d3457304c53.png" alt="">
这时候id=12这个线程的Commnad列显示的是Killed。也就是说客户端虽然断开了连接但实际上服务端上这条语句还在执行过程中。
**为什么在执行kill query命令时这条语句不像第一个例子的update语句一样退出呢**
在实现上等行锁时使用的是pthread_cond_timedwait函数这个等待状态可以被唤醒。但是在这个例子里12号线程的等待逻辑是这样的每10毫秒判断一下是否可以进入InnoDB执行如果不行就调用nanosleep函数进入sleep状态。
也就是说虽然12号线程的状态已经被设置成了KILL_QUERY但是在这个等待进入InnoDB的循环过程中并没有去判断线程的状态因此根本不会进入终止逻辑阶段。
而当session E执行kill connection 命令时,是这么做的,
<li>
把12号线程状态设置为KILL_CONNECTION
</li>
<li>
关掉12号线程的网络连接。因为有这个操作所以你会看到这时候session C收到了断开连接的提示。
</li>
那为什么执行show processlist的时候会看到Command列显示为killed呢其实这就是因为在执行show processlist的时候有一个特别的逻辑
```
如果一个线程的状态是KILL_CONNECTION就把Command列显示成Killed。
```
所以其实,即使是客户端退出了,这个线程的状态仍然是在等待中。那这个线程什么时候会退出呢?
答案是只有等到满足进入InnoDB的条件后session C的查询语句继续执行然后才有可能判断到线程状态已经变成了KILL_QUERY或者KILL_CONNECTION再进入终止逻辑阶段。
到这里,我们来小结一下。
**这个例子是kill无效的第一类情况线程没有执行到判断线程状态的逻辑。**跟这种情况相同的还有由于IO压力过大读写IO的函数一直无法返回导致不能及时判断线程的状态。
**另一类情况是,终止逻辑耗时较长。**这时候从show processlist结果上看也是Command=Killed需要等到终止逻辑完成语句才算真正完成。这类情况比较常见的场景有以下几种
<li>
超大事务执行期间被kill。这时候回滚操作需要对事务执行期间生成的所有新数据版本做回收操作耗时很长。
</li>
<li>
大查询回滚。如果查询过程中生成了比较大的临时文件加上此时文件系统压力大删除临时文件可能需要等待IO资源导致耗时较长。
</li>
<li>
DDL命令执行到最后阶段如果被kill需要删除中间过程的临时文件也可能受IO资源影响耗时较久。
</li>
之前有人问过我如果直接在客户端通过Ctrl+C命令是不是就可以直接终止线程呢
答案是,不可以。
这里有一个误解,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。
而由于MySQL是停等协议所以这个线程执行的语句还没有返回的时候再往这个连接里面继续发命令也是没有用的。实际上执行Ctrl+C的时候是MySQL客户端另外启动一个连接然后发送一个kill query 命令。
所以你可别以为在客户端执行完Ctrl+C就万事大吉了。因为要kill掉一个线程还涉及到后端的很多操作。
# 另外两个关于客户端的误解
在实际使用中,我也经常会碰到一些同学对客户端的使用有误解。接下来,我们就来看看两个最常见的误解。
**第一个误解是:如果库里面的表特别多,连接就会很慢。**
有些线上的库会包含很多表我见过最多的一个库里有6万个表。这时候你就会发现每次用客户端连接都会卡在下面这个界面上。
<img src="https://static001.geekbang.org/resource/image/7e/83/7e4666bfd580505180c77447d1f44c83.png" alt="">
而如果db1这个库里表很少的话连接起来就会很快可以很快进入输入命令的状态。因此有同学会认为是表的数目影响了连接性能。
从[第一篇文章](https://time.geekbang.org/column/article/68319)你就知道每个客户端在和服务端建立连接的时候需要做的事情就是TCP握手、用户校验、获取权限。但这几个操作显然跟库里面表的个数无关。
但实际上正如图中的文字提示所说的当使用默认参数连接的时候MySQL客户端会提供一个本地库名和表名补全的功能。为了实现这个功能客户端在连接成功后需要多做一些操作
<li>
执行show databases
</li>
<li>
切到db1库执行show tables
</li>
<li>
把这两个命令的结果用于构建一个本地的哈希表。
</li>
在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。
也就是说,**我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。**
图中的提示也说了,如果在连接命令中加上-A就可以关掉这个自动补全的功能然后客户端就可以快速返回了。
这里自动补全的效果就是你在输入库名或者表名的时候输入前缀可以使用Tab键自动补全表名或者显示提示。
实际使用中,如果你自动补全功能用得并不多,我建议你每次使用的时候都默认加-A。
其实提示里面没有说,除了加-A以外quick(或者简写为-q)参数,也可以跳过这个阶段。但是,这个**quick是一个更容易引起误会的参数也是关于客户端常见的一个误解。**
你看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能。为什么这么说呢?
MySQL客户端发送请求后接收服务端返回结果的方式有两种
<li>
一种是本地缓存也就是在本地开一片内存先把结果存起来。如果你用API开发对应的就是mysql_store_result 方法。
</li>
<li>
另一种是不缓存读一个处理一个。如果你用API开发对应的就是mysql_use_result方法。
</li>
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²º¹⁸ 给了一个保护文件的方法**,我之前没有用过这种方法,不过这确实是一个不错的思路。
为了数据安全和服务稳定,多做点预防方案的设计讨论,总好过故障处理和事后复盘。方案设计讨论会和故障复盘会,这两种会议的会议室气氛完全不一样。经历过的同学一定懂的。

View File

@@ -0,0 +1,217 @@
<audio id="audio" title="33 | 我查这么多数据,会不会把数据库内存打爆?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/40/c6/4033eea9451b98eb6a35ea71c6cba8c6.mp3"></audio>
我经常会被问到这样一个问题我的主机内存只有100G现在要对一个200G的大表做全表扫描会不会把数据库主机的内存用光了
这个问题确实值得担心被系统OOMout of memory可不是闹着玩的。但是反过来想想逻辑备份的时候可不就是做整库扫描吗如果这样就会把内存吃光逻辑备份不是早就挂了
所以说,对大表做全表扫描,看来应该是没问题的。但是,这个流程到底是怎么样的呢?
# 全表扫描对server层的影响
假设我们现在要对一个200G的InnoDB表db1. t执行一个全表扫描。当然你要把扫描结果保存在客户端会使用类似这样的命令
```
mysql -h$host -P$port -u$user -p$pwd -e &quot;select * from db1.t&quot; &gt; $target_file
```
你已经知道了InnoDB的数据是保存在主键索引上的所以全表扫描实际上是直接扫描表t的主键索引。这条查询语句由于没有其他的判断条件所以查到的每一行都可以直接放到结果集里面然后返回给客户端。
那么,这个“结果集”存在哪里呢?
实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:
<li>
获取一行写到net_buffer中。这块内存的大小是由参数net_buffer_length定义的默认是16k。
</li>
<li>
重复获取行直到net_buffer写满调用网络接口发出去。
</li>
<li>
如果发送成功就清空net_buffer然后继续取下一行并写入net_buffer。
</li>
<li>
如果发送函数返回EAGAIN或WSAEWOULDBLOCK就表示本地网络栈socket send buffer写满了进入等待。直到网络栈重新可写再继续发送。
</li>
这个过程对应的流程图如下所示。
<img src="https://static001.geekbang.org/resource/image/a0/bd/a027c300d7dde8cea4fad8f34b670ebd.jpg" alt="">
从这个流程中,你可以看到:
<li>
一个查询在发送过程中占用的MySQL内部的内存最大就是net_buffer_length这么大并不会达到200G
</li>
<li>
socket send buffer 也不可能达到200G默认定义/proc/sys/net/core/wmem_default如果socket send buffer被写满就会暂停读数据的流程。
</li>
也就是说,**MySQL是“边读边发的”**这个概念很重要。这就意味着如果客户端接收得慢会导致MySQL服务端由于结果发不出去这个事务的执行时间变长。
比如下面这个状态就是我故意让客户端不去读socket receive buffer中的内容然后在服务端show processlist看到的结果。
<img src="https://static001.geekbang.org/resource/image/18/c3/183a704d4495bebbc13c524695b5b6c3.png" alt="">
如果你看到State的值一直处于**“Sending to client”**,就表示服务器端的网络栈写满了。
我在上一篇文章中曾提到如果客户端使用quick参数会使用mysql_use_result方法。这个方法是读一行处理一行。你可以想象一下假设有一个业务的逻辑比较复杂每读一行数据以后要处理的逻辑如果很慢就会导致客户端要过很久才会去取下一行数据可能就会出现如图2所示的这种情况。
因此,**对于正常的线上业务来说如果一个查询的返回结果不会很多的话我都建议你使用mysql_store_result这个接口直接把查询结果保存到本地内存。**
当然前提是查询返回结果不多。在[第30篇文章](https://time.geekbang.org/column/article/78427)评论区有同学说到自己因为执行了一个大查询导致客户端占用内存近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 src="https://static001.geekbang.org/resource/image/76/4b/7640b0d82965bf8b305514f30425424b.png" alt="">
<img src="https://static001.geekbang.org/resource/image/84/c0/84533515cf36be65582309fbb85e13c0.png" alt="">
可以看到session B明显是在等锁状态显示为Sending data。
也就是说,仅当一个线程处于“等待客户端接收结果”的状态,才会显示"Sending to client"而如果显示成“Sending data”它的意思只是“正在执行”。
现在你知道了,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。
在server层的处理逻辑我们都清楚了在InnoDB引擎里面又是怎么处理的呢 扫描全表会不会对引擎系统造成影响呢?
# 全表扫描对InnoDB的影响
在[第2](https://time.geekbang.org/column/article/68633)和[第15篇](https://time.geekbang.org/column/article/73161)文章中我介绍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 src="https://static001.geekbang.org/resource/image/c7/2e/c70a95ee99826812c292c46de508982e.png" alt="">
如果所有查询需要的数据页都能够直接从内存得到那是最好的对应的命中率就是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 src="https://static001.geekbang.org/resource/image/e0/65/e0ac92febac50a5d881f1188ea5bfd65.jpg" alt="">
InnoDB管理Buffer Pool的LRU算法是用链表来实现的。
<li>
在图6的状态1里链表头部是P1表示P1是最近刚刚被访问过的数据页假设内存里只能放下这么多数据页
</li>
<li>
这时候有一个读请求访问P3因此变成状态2P3被移到最前面
</li>
<li>
状态3表示这次访问的数据页是不存在于链表中的所以需要在Buffer Pool中新申请一个数据页Px加到链表头部。但是由于内存已经满了不能申请新的内存。于是会清空链表末尾Pm这个数据页的内存存入Px的内容然后放到链表头部。
</li>
<li>
从效果上看就是最久没有被访问的数据页Pm被淘汰了。
</li>
这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,会不会有问题呢?
假设按照这个算法我们要扫描一个200G的表而这个表是一个历史数据表平时没有业务访问它。
那么按照这个算法扫描的话就会把当前的Buffer Pool里的数据全部淘汰掉存入扫描过程中访问到的数据页的内容。也就是说Buffer Pool里面主要放的是这个历史数据表的数据。
对于一个正在做业务服务的库这可不妙。你会看到Buffer Pool的内存命中率急剧下降磁盘压力增加SQL语句响应变慢。
所以InnoDB不能直接使用这个LRU算法。实际上InnoDB对LRU算法做了改进。
<img src="https://static001.geekbang.org/resource/image/21/28/21f64a6799645b1410ed40d016139828.png" alt="">
在InnoDB实现上按照5:3的比例把整个LRU链表分成了young区域和old区域。图中LRU_old指向的就是old区域的第一个位置是整个链表的5/8处。也就是说靠近链表头部的5/8是young区域靠近链表尾部的3/8是old区域。
改进后的LRU算法执行流程变成了下面这样。
<li>
图7中状态1要访问数据页P3由于P3在young区域因此和优化前的LRU算法一样将其移到链表头部变成状态2。
</li>
<li>
之后要访问一个新的不存在于当前链表的数据页这时候依然是淘汰掉数据页Pm但是新插入的数据页Px是放在LRU_old处。
</li>
<li>
处于old区域的数据页每次被访问的时候都要做下面这个判断
<ul>
1. 若这个数据页在LRU链表中存在的时间超过了1秒就把它移动到链表头部
1. 如果这个数据页在LRU链表中存在的时间短于1秒位置保持不变。1秒这个时间是由参数innodb_old_blocks_time控制的。其默认值是1000单位毫秒。
</ul>
</li>
这个策略就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描200G的历史数据表为例我们看看改进后的LRU算法的操作逻辑
<li>
扫描过程中需要新插入的数据页都被放到old区域;
</li>
<li>
一个数据页里面有多条记录这个数据页会被多次访问到但由于是顺序扫描这个数据页第一次被访问和最后一次被访问的时间间隔不会超过1秒因此还是会被保留在old区域
</li>
<li>
再继续扫描后续的数据之前的这个数据页之后也不会再被访问到于是始终没有机会移到链表头部也就是young区域很快就会被淘汰出去。
</li>
可以看到这个策略最大的收益就是在扫描这个大表的过程中虽然也用到了Buffer Pool但是对young区域完全没有影响从而保证了Buffer Pool响应正常业务的查询命中率。
# 小结
今天我用“大查询会不会把内存用光”这个问题和你介绍了MySQL的查询结果发送给客户端的过程。
由于MySQL采用的是边算边发的逻辑因此对于数据量很大的查询结果来说不会在server端保存完整的结果集。所以如果客户端读结果不及时会堵住MySQL的查询过程但是不会把内存打爆。
而对于InnoDB引擎内部由于有淘汰策略大查询也不会导致内存暴涨。并且由于InnoDB对LRU算法做了改进冷数据的全表扫描对Buffer Pool的影响也能做到可控。
当然我们前面文章有说过全表扫描还是比较耗费IO资源的所以业务高峰期还是不能直接在线上主库执行全表扫描的。
最后,我给你留一个思考题吧。
我在文章中说到如果由于客户端压力太大迟迟不能接收结果会导致MySQL无法发送结果而影响语句执行。但这还不是最糟糕的情况。
你可以设想出由于客户端的性能问题,对数据库影响更严重的例子吗?或者你是否经历过这样的场景?你又是怎么优化的?
你可以把你的经验和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是如果一个事务被kill之后持续处于回滚状态从恢复速度的角度看你是应该重启等它执行结束还是应该强行重启整个MySQL进程。
因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束。
当然如果这个语句可能会占用别的锁或者由于占用IO资源过多从而影响到了别的语句执行的话就需要先做主备切换切到新主库提供服务。
切换之后别的线程都断开了连接,自动停止执行。接下来还是等它自己执行完成。这个操作属于我们在文章中说到的,减少系统压力,加速终止逻辑。
评论区留言点赞板:
>
<p>@HuaMax 的回答中提到了对其他线程的影响;<br>
@夹心面包 @Ryoma @曾剑 同学提到了重启后依然继续做回滚操作的逻辑。</p>

View File

@@ -0,0 +1,360 @@
<audio id="audio" title="34 | 到底可不可以使用join" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/c7/01cd2dce7cd8d8eab2d5b53e122187c7.mp3"></audio>
在实际生产中关于join语句使用的问题一般会集中在以下两类
<li>
我们DBA不让使用join使用join有什么问题呢
</li>
<li>
如果有两个大小不同的表做join应该用哪个表做驱动表呢
</li>
今天这篇文章我就先跟你说说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&lt;=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&lt;=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 src="https://static001.geekbang.org/resource/image/4b/90/4b9cb0e0b83618e01c9bfde44a0ea990.png" alt="">
可以看到在这条语句里被驱动表t2的字段a上有索引join过程用上了这个索引因此这个语句的执行流程是这样的
<li>
从表t1中读入一行数据 R
</li>
<li>
从数据行R中取出a字段到表t2里去查找
</li>
<li>
取出表t2中满足条件的行跟R组成一行作为结果集的一部分
</li>
<li>
重复执行步骤1到3直到表t1的末尾循环结束。
</li>
这个过程是先遍历表t1然后根据从表t1中取出的每行数据中的a值去表t2中查找满足条件的记录。在形式上这个过程就跟我们写程序时的嵌套查询类似并且可以用上被驱动表的索引所以我们称之为“Index Nested-Loop Join”简称NLJ。
它对应的流程图如下所示:
<img src="https://static001.geekbang.org/resource/image/d8/f6/d83ad1cbd6118603be795b26d38f8df6.jpg" alt="">
在这个流程里:
<li>
对驱动表t1做了全表扫描这个过程需要扫描100行
</li>
<li>
而对于每一行R根据a字段去表t2查找走的是树搜索过程。由于我们构造的数据都是一一对应的因此每次的搜索过程都只扫描一行也是总共扫描100行
</li>
<li>
所以整个执行流程总扫描行数是200。
</li>
现在我们知道了这个过程,再试着回答一下文章开头的两个问题。
先看第一个问题:**能不能使用join?**
假设不使用join那我们就只能用单表查询。我们看看上面这条语句的需求用单表查询怎么实现。
<li>
执行`select * from t1`查出表t1的所有数据这里有100行
</li>
<li>
循环遍历这100行数据
<ul>
1. 从每一行R取出字段a的值$R.a
1. 执行`select * from t2 where a=$R.a`
1. 把返回的结果和R构成结果集的一行。
</ul>
</li>
可以看到在这个查询过程也是扫描了200行但是总共执行了101条语句比直接join多了100次交互。除此之外客户端还要自己拼接SQL语句和结果。
显然这么做还不如直接join好。
我们再来看看第二个问题:**怎么选择驱动表?**
在这个join语句执行过程中驱动表是走全表扫描而被驱动表是走树搜索。
假设被驱动表的行数是M。每次在被驱动表查一行数据要先搜索索引a再搜索主键索引。每次搜索一棵树近似复杂度是以2为底的M的对数记为log<sub>2</sub>M所以在被驱动表上查一行的时间复杂度是 2*log<sub>2</sub>M。
假设驱动表的行数是N执行过程就要扫描驱动表N行然后对于每一行到被驱动表上匹配一次。
因此整个执行过程,近似复杂度是 N + N*2*log<sub>2</sub>M。
显然N对扫描行数的影响更大因此应该让小表来做驱动表。
>
如果你没觉得这个影响有那么“显然”, 可以这么理解N扩大1000倍的话扫描行数就会扩大1000倍而M扩大1000倍扫描行数扩大不到10倍。
到这里小结一下,通过上面的分析我们得到了两个结论:
<li>
使用join语句性能比强行拆成多个单表执行SQL语句的性能要好
</li>
<li>
如果使用join语句的话需要让小表做驱动表。
</li>
但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”。
接下来,我们再看看被驱动表用不上索引的情况。
# 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
这时候,被驱动表上没有可用的索引,算法的流程是这样的:
<li>
把表t1的数据读入线程内存join_buffer中由于我们这个语句中写的是select *因此是把整个表t1放入了内存
</li>
<li>
扫描表t2把表t2中的每一行取出来跟join_buffer中的数据做对比满足join条件的作为结果集的一部分返回。
</li>
这个过程的流程图如下:
<img src="https://static001.geekbang.org/resource/image/15/73/15ae4f17c46bf71e8349a8f2ef70d573.jpg" alt="">
对应地这条SQL语句的explain结果如下所示
<img src="https://static001.geekbang.org/resource/image/67/e1/676921fa0883e9463dd34fb2bc5e87e1.png" alt="">
可以看到在这个过程中对表t1和t2都做了一次全表扫描因此总的扫描行数是1100。由于join_buffer是以无序数组的方式组织的因此对表t2中的每一行都要做100次判断总共需要在内存中做的判断次数是100*1000=10万次。
前面我们说过如果使用Simple Nested-Loop Join算法进行查询扫描行数也是10万行。因此从时间复杂度上来说这两个算法是一样的。但是Block Nested-Loop Join算法的这10万次判断是内存操作速度上会快很多性能也更好。
接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。
假设小表的行数是N大表的行数是M那么在这个算法里
<li>
两个表都做一次全表扫描所以总的扫描行数是M+N
</li>
<li>
内存中的判断次数是M*N。
</li>
可以看到调换这两个算式中的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);
```
执行过程就变成了:
<li>
扫描表t1顺序读取数据行放入join_buffer中放完第88行join_buffer满了继续第2步
</li>
<li>
扫描表t2把t2中的每一行取出来跟join_buffer中的数据做对比满足join条件的作为结果集的一部分返回
</li>
<li>
清空join_buffer
</li>
<li>
继续扫描表t1顺序读取最后的12行数据放入join_buffer中继续执行第2步。
</li>
执行流程图也就变成这样:
<img src="https://static001.geekbang.org/resource/image/69/c4/695adf810fcdb07e393467bcfd2f6ac4.jpg" alt="">
图中的步骤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)。
所以,在这个算法的执行过程中:
<li>
扫描行数是 N+λ*N*M
</li>
<li>
内存判断 N*M次。
</li>
显然内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数在M和N大小确定的情况下N小一些整个算式的结果会更小。
所以结论是,应该让小表当驱动表。
当然你会发现在N+λ*N*M这个式子里λ才是影响扫描行数的关键因素这个值越小越好。
刚刚我们说了N越大分段数K越大。那么N固定的时候什么参数会影响K的大小呢也就是λ的大小答案是join_buffer_size。join_buffer_size越大一次可以放入的行越多分成的段数也就越少对被驱动表的全表扫描次数就越少。
这就是为什么你可能会看到一些建议告诉你如果你的join语句很慢就把join_buffer_size改大。
理解了MySQL执行join的两种算法现在我们再来试着**回答文章开头的两个问题**。
第一个问题能不能使用join语句
<li>
如果可以使用Index Nested-Loop Join算法也就是说可以用上被驱动表上的索引其实是没问题的
</li>
<li>
如果使用Block Nested-Loop Join算法扫描行数就会过多。尤其是在大表上的join操作这样可能要扫描被驱动表很多次会占用大量的系统资源。所以这种join尽量不要用。
</li>
所以你在判断要不要使用join语句时就是看explain结果里面Extra字段里面有没有出现“Block Nested Loop”字样。
第二个问题是如果要使用join应该选择大表做驱动表还是选择小表做驱动表
<li>
如果是Index Nested-Loop Join算法应该选择小表做驱动表
</li>
<li>
如果是Block Nested-Loop Join算法
<ul>
1. 在join_buffer_size足够大的时候是一样的
1. 在join_buffer_size不够大的时候这种情况更常见应该选择小表做驱动表。
</ul>
</li>
所以,这个问题的结论就是,总是应该使用小表做驱动表。
当然了,这里我需要说明下,**什么叫作“小表”**。
我们前面的例子是没有加条件的。如果我在语句的where条件加上 t2.id&lt;=50这个限定条件再来看下这两条语句
```
select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id&lt;=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id&lt;=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&lt;=100;
select t1.b,t2.* from t2 straight_join t1 on (t1.b=t2.b) where t2.id&lt;=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两个算法执行过程的分析我们也得到了文章开头两个问题的答案
<li>
如果可以使用被驱动表的索引join语句还是有其优势的
</li>
<li>
不能使用被驱动表的索引只能使用Block Nested-Loop Join算法这样的语句就尽量不要使用
</li>
<li>
在使用join的时候应该让小表做驱动表。
</li>
最后,又到了今天的问题时间。
我们在上文说到使用Block Nested-Loop Join算法可能会因为join_buffer不够大需要对被驱动表做多次全表扫描。
我的问题是如果被驱动表是一个大表并且是一个冷数据表除了查询过程中可能会导致IO压力大以外你觉得对这个MySQL服务还有什么更严重的影响吗这个问题需要结合上一篇文章的知识点
你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上一篇文章最后留下的问题是,如果客户端由于压力过大,迟迟不能接收数据,会对服务端造成什么严重的影响。
这个问题的核心是,造成了“长事务”。
至于长事务的影响就要结合我们前面文章中提到的锁、MVCC的知识点了。
- 如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住;
- 当然读的事务也有问题就是会导致undo log不能被回收导致回滚段空间膨胀。
评论区留言点赞板:
>
<p>@老杨同志 提到了更新之间会互相等锁的问题。同一个事务,更新之后要尽快提交,不要做没必要的查询,尤其是不要执行需要返回大量数据的查询;<br>
@长杰 同学提到了undo表空间变大db服务堵塞服务端磁盘空间不足的例子。</p>

View File

@@ -0,0 +1,319 @@
<audio id="audio" title="35 | join语句怎么优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/64/f11daf984db1e1ddc708f5b286c8e064.mp3"></audio>
在上一篇文章中我和你介绍了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&lt;=1000)do
insert into t1 values(i, 1001-i, i);
set i=i+1;
end while;
set i=1;
while(i&lt;=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篇文章](https://time.geekbang.org/column/article/69236)中我和你介绍InnoDB的索引结构时提到了“回表”的概念。我们先来回顾一下这个概念。回表是指InnoDB在普通索引a上查到主键id的值后再根据一个个主键id的值到主键索引上去查整行数据的过程。
然后,有同学在留言区问到,回表过程是一行行地查数据,还是批量地查数据?
我们先来看看这个问题。假设,我执行这个语句:
```
select * from t1 where a&gt;=1 and a&lt;=100;
```
主键索引是一棵B+树在这棵树上每次只能根据一个主键id查到一行数据。因此回表肯定是一行行搜索主键索引的基本流程如图1所示。
<img src="https://static001.geekbang.org/resource/image/97/05/97ae269061192f6d7a632df56fa03605.png" alt="">
如果随着a的值递增顺序查询的话id的值就变成随机的那么就会出现随机访问性能相对较差。虽然“按行查”这个机制不能改但是调整查询的顺序还是能够加速的。
**因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。**
就是MRR优化的设计思路。此时语句的执行流程变成了这样
<li>
根据索引a定位到满足条件的记录将id值放入read_rnd_buffer中;
</li>
<li>
将read_rnd_buffer中的id进行递增排序
</li>
<li>
排序后的id数组依次到主键id索引中查记录并作为结果返回。
</li>
这里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 src="https://static001.geekbang.org/resource/image/d5/c7/d502fbaea7cac6f815c626b078da86c7.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/a5/32/a513d07ebaf1ae044d44391c89bc6432.png" alt="">
从图3的explain结果中我们可以看到Extra字段多了Using MRR表示的是用上了MRR优化。而且由于我们在read_rnd_buffer中按照id做了排序所以最后得到的结果集也是按照主键id递增顺序的也就是与图1结果集中行的顺序相反。
到这里,我们小结一下。
**MRR能够提升性能的核心**在于这条查询语句在索引a上做的是一个范围查询也就是说这是一个多值查询可以得到足够多的主键id。这样通过排序以后再去主键索引查数据才能体现出“顺序性”的优势。
# Batched Key Access
理解了MRR性能提升的原理我们就能理解MySQL在5.6版本后开始引入的Batched Key Access(BKA)算法了。这个BKA算法其实就是对NLJ算法的优化。
我们再来看看上一篇文章中用到的NLJ算法的流程图
<img src="https://static001.geekbang.org/resource/image/10/3d/10e14e8b9691ac6337d457172b641a3d.jpg" alt="">
NLJ算法执行的逻辑是从驱动表t1一行行地取出a的值再到被驱动表t2去做join。也就是说对于表t2来说每次都是匹配一个值。这时MRR的优势就用不上了。
那怎么才能一次性地多传些值给表t2呢方法就是从表t1里一次性地多拿些行出来一起传给表t2。
既然如此我们就把表t1的数据取出来一部分先放到一个临时内存。这个临时内存不是别人就是join_buffer。
通过上一篇文章我们知道join_buffer 在BNL算法里的作用是暂存驱动表的数据。但是在NLJ算法里并没有用。那么我们刚好就可以复用join_buffer到BKA算法中。
如图5所示是上面的NLJ算法优化后的BKA算法的流程。
<img src="https://static001.geekbang.org/resource/image/68/88/682370c5640244fa3474d26cc3bc0388.png" alt="">
图中我在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篇文章](https://time.geekbang.org/column/article/79407)中我们说到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算法对系统的影响主要包括三个方面
<li>
可能会多次扫描被驱动表占用磁盘IO资源
</li>
<li>
判断join条件需要执行M*N次对比M、N分别是两张表的行数如果是大表就会占用非常多的CPU资源
</li>
<li>
可能会导致Buffer Pool的热数据被淘汰影响内存命中率。
</li>
我们执行语句之前需要通过理论分析和查看explain结果的方式确认是否要使用BNL算法。如果确认优化器会使用BNL算法就需要做优化。优化的常见做法是给被驱动表的join字段加上索引把BNL算法转成BKA算法。
接下来,我们就具体看看,这个优化怎么做?
# BNL转BKA
一些情况下我们可以直接在被驱动表上建索引这时就可以直接转成BKA算法了。
但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:
```
select * from t1 join t2 on (t1.b=t2.b) where t2.b&gt;=1 and t2.b&lt;=2000;
```
我们在文章开始的时候在表t2中插入了100万行数据但是经过where条件过滤后需要参与join的只有2000行数据。如果这条语句同时是一个低频的SQL语句那么再为这个语句在表t2的字段b上创建一个索引就很浪费了。
但是如果使用BNL算法来join的话这个语句的执行流程是这样的
<li>
把表t1的所有字段取出来存入join_buffer中。这个表只有1000行join_buffer_size默认值是256k可以完全存入。
</li>
<li>
扫描表t2取出每一行数据跟join_buffer中的数据进行对比
<ul>
1. 如果不满足t1.b=t2.b则跳过
1. 如果满足t1.b=t2.b, 再判断其他条件也就是是否满足t2.b处于[1,2000]的条件,如果是,就作为结果集的一部分返回,否则跳过。
</ul>
</li>
我在上一篇文章中说过对于表t2的每一行判断join是否满足的时候都需要遍历join_buffer中的所有行。因此判断等值条件的次数是1000*100万=10亿次这个判断的工作量很大。
<img src="https://static001.geekbang.org/resource/image/92/60/92fbdbfc35da3040396401250cb33f60.png" alt="">
<img src="https://static001.geekbang.org/resource/image/d8/9c/d862bc3e88305688df2c354a4b26809c.png" alt="">
可以看到explain结果里Extra字段显示使用了BNL算法。在我的测试环境里这条语句需要执行1分11秒。
在表t2的字段b上创建索引会浪费资源但是不创建索引的话这个语句的等值条件要判断10亿次想想也是浪费。那么有没有两全其美的办法呢
这时候,我们可以考虑使用临时表。使用临时表的大致思路是:
<li>
把表t2中满足条件的数据放在临时表tmp_t中
</li>
<li>
为了让join使用BKA算法给临时表tmp_t的字段b加上索引
</li>
<li>
让表t1和tmp_t做join操作。
</li>
此时对应的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&gt;=1 and b&lt;=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
```
图8就是这个语句序列的执行效果。
<img src="https://static001.geekbang.org/resource/image/a8/c7/a80cdffe8173fa0fd8969ed976ac6ac7.png" alt="">
可以看到整个过程3个语句执行时间的总和还不到1秒相比于前面的1分11秒性能得到了大幅提升。接下来我们一起看一下这个过程的消耗
<li>
执行insert语句构造temp_t表并插入数据的过程中对表t2做了全表扫描这里扫描行数是100万。
</li>
<li>
之后的join语句扫描表t1这里的扫描行数是1000join比较过程中做了1000次带索引的查询。相比于优化前的join语句需要做10亿次条件判断来说这个优化效果还是很明显的。
</li>
总体来看不论是在原表上加索引还是用有索引的临时表我们的思路都是让join语句能够用上被驱动表上的索引来触发BKA算法提升查询性能。
# 扩展-hash join
看到这里你可能发现了其实上面计算10亿次那个操作看上去有点儿傻。如果join_buffer里面维护的不是一个无序数组而是一个哈希表的话那么就不是10亿次判断而是100万次hash查找。这样的话整条语句的执行速度就快多了吧
确实如此。
也正是MySQL的优化器和执行器一直被诟病的一个原因不支持哈希join。并且MySQL官方的roadmap也是迟迟没有把这个优化排上议程。
实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下:
<li>
`select * from t1;`取得表t1的全部1000行数据在业务端存入一个hash结构比如C++里的set、PHP的数组这样的数据结构。
</li>
<li>
`select * from t2 where b&gt;=1 and b&lt;=2000;` 获取表t2中满足条件的2000行数据。
</li>
<li>
把这2000行数据一行一行地取到业务端到hash结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据就作为结果集的一行。
</li>
理论上,这个过程会比临时表方案的执行速度还要快一些。如果你感兴趣的话,可以自己验证一下。
# 小结
今天我和你分享了Index Nested-Loop JoinNLJ和Block Nested-Loop JoinBNL的优化方法。
在这些优化方法中:
<li>
BKA优化是MySQL已经内置支持的建议你默认使用
</li>
<li>
BNL算法效率低建议你都尽量转成BKA算法。优化的方向就是给被驱动表的关联字段加上索引
</li>
<li>
基于临时表的改进方案对于能够提前过滤出小数据的join语句来说效果还是很好的
</li>
<li>
MySQL目前的版本还不支持hash join但你可以配合应用端自己模拟出来理论上效果要好于临时表的方案。
</li>
最后,我给你留下一道思考题吧。
我们在讲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&gt;=X and t2.c&gt;=Y and t3.c&gt;=Z;
```
现在为了得到最快的执行速度如果让你来设计表t1、t2、t3上的索引来支持这个join语句你会加哪些索引呢
同时如果我希望你用straight_join来重写这个语句配合你创建的索引你就需要安排连接顺序你主要考虑的因素是什么呢
你可以把你的方案和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上篇文章最后留给你的问题,已经在本篇文章中解答了。
这里我再根据评论区留言的情况,简单总结下。根据数据量的大小,有这么两种情况:
- @长杰@老杨同志 提到了数据量小于old区域内存的情况
- @Zzz 同学很认真地看了其他同学的评论并且提了一个很深的问题。对被驱动表数据量大于Buffer Pool的场景做了很细致的推演和分析。
给这些同学点赞,非常好的思考和讨论。

View File

@@ -0,0 +1,301 @@
<audio id="audio" title="36 | 为什么临时表可以重名?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2a/51/2a58b00b42c4ad537d0f8a42b9b6f151.mp3"></audio>
今天是大年三十,在开始我们今天的学习之前,我要先和你道一声春节快乐!
在上一篇文章中我们在优化join查询的时候使用到了临时表。当时我们是这么用的
```
create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b&gt;=1 and b&lt;=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
```
你可能会有疑问,为什么要用临时表呢?直接用普通表是不是也可以呢?
今天我们就从这个问题说起:临时表有哪些特征,为什么它适合这个场景?
这里,我需要先帮你厘清一个容易误解的问题:有的人可能会认为,临时表就是内存表。但是,这两个概念可是完全不同的。
<li>
内存表指的是使用Memory引擎的表建表语法是create table … engine=memory。这种表的数据都保存在内存里系统重启的时候会被清空但是表结构还在。除了这两个特性看上去比较“奇怪”外从其他的特征上看它就是一个正常的表。
</li>
<li>
而临时表,可以使用各种引擎类型 。如果是使用InnoDB引擎或者MyISAM引擎的临时表写数据的时候是写到磁盘上的。当然临时表也可以使用Memory引擎。
</li>
弄清楚了内存表和临时表的区别以后,我们再来看看临时表有哪些特征。
# 临时表的特性
为了便于理解,我们来看下下面这个操作序列:
<img src="https://static001.geekbang.org/resource/image/3c/e3/3cbb2843ef9a84ee582330fb1bd0d6e3.png" alt="">
可以看到,临时表在使用上有以下几个特点:
<li>
建表语法是create temporary table …。
</li>
<li>
一个临时表只能被创建它的session访问对其他线程不可见。所以图中session A创建的临时表t对于session B就是不可见的。
</li>
<li>
临时表可以与普通表同名。
</li>
<li>
session A内有同名的临时表和普通表的时候show create语句以及增删改查语句访问的是临时表。
</li>
<li>
show tables命令不显示临时表。
</li>
由于临时表只能被创建它的session访问所以在这个session结束的时候会自动删除临时表。也正是由于这个特性**临时表就特别适合我们文章开头的join优化这种场景**。为什么呢?
原因主要包括以下两个方面:
<li>
不同session的临时表是可以重名的如果有多个session同时执行join优化不需要担心表名重复导致建表失败的问题。
</li>
<li>
不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。
</li>
# 临时表的应用
由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。
一般分库分表的场景就是要把一个逻辑上的大表分散到不同的数据库实例上。比如。将一个大表ht按照字段f拆分成1024个分表然后分布到32个数据库实例上。如下图所示
<img src="https://static001.geekbang.org/resource/image/dd/81/ddb9c43526dfd9b9a3e6f8c153478181.jpg" alt="">
一般情况下这种分库分表系统都有一个中间层proxy。不过也有一些方案会让客户端直接连接数据库也就是没有proxy这一层。
在这个架构中分区key的选择是以“减少跨库和跨表查询”为依据的。如果大部分的语句都会包含f的等值条件那么就要用f做分区键。这样在proxy这一层解析完SQL语句以后就能确定将这条语句路由到哪个分表做查询。
比如下面这条语句:
```
select v from ht where f=N;
```
这时我们就可以通过分表规则比如N%1024)来确认需要的数据被放在了哪个分表上。这种语句只需要访问一个分表,是分库分表方案最欢迎的语句形式了。
但是如果这个表上还有另外一个索引k并且查询语句是这样的
```
select v from ht where k &gt;= M order by t_modified desc limit 100;
```
这时候由于查询条件里面没有用到分区字段f只能到所有的分区中去查找满足条件的所有行然后统一做order by 的操作。这种情况下,有两种比较常用的思路。
**第一种思路是,**在proxy层的进程代码中实现排序。
这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显:
<li>
需要的开发工作量比较大。我们举例的这条语句还算是比较简单的如果涉及到复杂的操作比如group by甚至join这样的操作对中间层的开发能力要求比较高
</li>
<li>
对proxy端的压力比较大尤其是很容易出现内存不够用和CPU瓶颈的问题。
</li>
**另一种思路就是,**把各个分库拿到的数据汇总到一个MySQL实例的一个表中然后在这个汇总实例上做逻辑操作。
比如上面这条语句,执行流程可以类似这样:
- 在汇总库上创建一个临时表temp_ht表里包含三个字段v、k、t_modified
- 在各个分库上执行
```
select v,k,t_modified from ht_x where k &gt;= M order by t_modified desc limit 100;
```
- 把分库执行的结果插入到temp_ht表中
- 执行
```
select v from temp_ht order by t_modified desc limit 100;
```
得到结果。
这个过程对应的流程图如下所示:
<img src="https://static001.geekbang.org/resource/image/f5/0d/f5ebe0f5af37deeb4d0b63d6fb11fc0d.jpg" alt="">
**在实践中我们往往会发现每个分库的计算量都不饱和所以会直接把临时表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 src="https://static001.geekbang.org/resource/image/22/1b/22078eab5c7688c9fbfd6185555bd91b.png" alt="">
这个进程的进程号是1234session A的线程id是4session 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 src="https://static001.geekbang.org/resource/image/74/ba/74e789024f10bcde515f21c0368847ba.png" alt="">
主库M上的两个session创建了同名的临时表t1这两个create temporary table t1 语句都会被传到备库S上。
但是备库的应用日志线程是共用的也就是说要在应用线程里面先后执行这个create 语句两次。即使开了多线程复制也可能被分配到从库的同一个worker中执行。那么这会不会导致同步线程报错
显然是不会的否则临时表就是一个bug了。也就是说备库线程在执行的时候要把这两个t1表当做两个不同的临时表来处理。这又是怎么实现的呢
MySQL在记录binlog的时候会把主库执行这个语句的线程id写到binlog中。这样在备库的应用线程就能够知道执行每个语句的主库线程id并利用这个线程id来构造临时表的table_def_key
<li>
session A的临时表t1在备库的table_def_key就是库名+t1+“M的serverid”+“session A的thread_id”;
</li>
<li>
session B的临时表t1在备库的table_def_key就是 :库名+t1+“M的serverid”+“session B的thread_id”。
</li>
由于table_def_key不同所以这两个表在备库的应用线程里面是不会冲突的。
# 小结
今天这篇文章,我和你介绍了临时表的用法和特性。
在实际应用中,临时表一般用于处理比较复杂的计算逻辑。由于临时表是每个线程自己可见的,所以不需要考虑多个线程执行同一个处理逻辑时,临时表的重名问题。在线程退出的时候,临时表也能自动删除,省去了收尾和异常处理的工作。
在binlog_format='row的时候临时表的操作不记录到binlog中也省去了不少麻烦这也可以成为你选择binlog_format时的一个考虑因素。
需要注意的是,我们上面说到的这种临时表,是用户自己创建的 ,也可以称为用户临时表。与它相对应的,就是内部临时表,在[第17篇文章](https://time.geekbang.org/column/article/73795)中我已经和你介绍过。
最后,我给你留下一个思考题吧。
下面的语句序列是创建一个临时表,并将其改名:
<img src="https://static001.geekbang.org/resource/image/33/f9/333ad95b2ce16de1931fe347128caff9.png" alt="">
可以看到我们可以使用alter table语法修改临时表的表名而不能使用rename语法。你知道这是什么原因吗
你可以把你的分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是对于下面这个三个表的join语句
```
select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c&gt;=X and t2.c&gt;=Y and t3.c&gt;=Z;
```
如果改写成straight_join要怎么指定连接顺序以及怎么给三个表创建索引。
第一原则是要尽量使用BKA算法。需要注意的是使用BKA算法的时候并不是“先计算两个表join的结果再跟第三个表join”而是直接嵌套查询的。
具体实现是在t1.c&gt;=X、t2.c&gt;=Y、t3.c&gt;=Z这三个条件里选择一个经过过滤以后数据最少的那个表作为第一个驱动表。此时可能会出现如下两种情况。
第一种情况如果选出来是表t1或者t3那剩下的部分就固定了。
<li>
如果驱动表是t1则连接顺序是t1-&gt;t2-&gt;t3要在被驱动表字段创建上索引也就是t2.a 和 t3.b上创建索引
</li>
<li>
如果驱动表是t3则连接顺序是t3-&gt;t2-&gt;t1需要在t2.b 和 t1.a上创建索引。
</li>
同时我们还需要在第一个驱动表的字段c上创建索引。
第二种情况是如果选出来的第一个驱动表是表t2的话则需要评估另外两个条件的过滤效果。
总之整体的思路就是尽量让每一次参与join的驱动表的数据集越小越好因为这样我们的驱动表就会越小。
评论区留言点赞板:
>
<p>@库淘淘 做了实验验证;<br>
@poppy同学做了很不错的分析<br>
@dzkk 同学在评论中介绍了MariaDB支持的hash join大家可以了解一下<br>
@老杨同志提了一个好问题如果语句使用了索引a结果还要对a排序就不用MRR优化了否则回表完还要增加额外的排序过程得不偿失。</p>

View File

@@ -0,0 +1,298 @@
<audio id="audio" title="37 | 什么时候会使用内部临时表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d1/75/d12aa15290366aa10d7afe89673b1075.mp3"></audio>
今天是大年初二,在开始我们今天的学习之前,我要先和你道一声春节快乐!
在[第16](https://time.geekbang.org/column/article/73479)和[第34](https://time.geekbang.org/column/article/79700)篇文章中我分别和你介绍了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&lt;=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 src="https://static001.geekbang.org/resource/image/40/4e/402cbdef84eef8f1b42201c6ec4bad4e.png" alt="">
可以看到:
- 第二行的key=PRIMARY说明第二个子句用到了索引id。
- 第三行的Extra字段表示在对子查询的结果集做union的时候使用了临时表(Using temporary)。
这个语句的执行流程是这样的:
<li>
创建一个内存临时表这个临时表只有一个整型字段f并且f是主键字段。
</li>
<li>
执行第一个子查询得到1000这个值并存入临时表中。
</li>
<li>
执行第二个子查询:
<ul>
1. 拿到第一行id=1000试图插入临时表中。但由于1000这个值已经存在于临时表了违反了唯一性约束所以插入失败然后继续执行
1. 取到第二行id=999插入临时表成功。
</ul>
</li>
<li>
从临时表中按行取出数据返回结果并删除临时表结果中包含两行数据分别是1000和999。
</li>
这个过程的流程图如下所示:
<img src="https://static001.geekbang.org/resource/image/5d/0e/5d038c1366d375cc997005a5d65c600e.jpg" alt="">
可以看到这里的内存临时表起到了暂存数据的作用而且计算过程还用上了临时表主键id的唯一性约束实现了union的语义。
顺便提一下如果把上面这个语句中的union改成union all的话就没有了“去重”的语义。这样执行的时候就依次执行子查询得到的结果直接作为结果集的一部分发给客户端。因此也就不需要临时表了。
<img src="https://static001.geekbang.org/resource/image/c1/6d/c1e90d1d7417b484d566b95720fe3f6d.png" alt="">
可以看到第二行的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 src="https://static001.geekbang.org/resource/image/3d/98/3d1cb94589b6b3c4bb57b0bdfa385d98.png" alt="">
在Extra字段里面我们可以看到三个信息
- Using index表示这个语句使用了覆盖索引选择了索引a不需要回表
- Using temporary表示使用了临时表
- Using filesort表示需要排序。
这个语句的执行流程是这样的:
<li>
创建内存临时表表里有两个字段m和c主键是m
</li>
<li>
扫描表t1的索引a依次取出叶子节点上的id值计算id%10的结果记为x
<ul>
1. 如果临时表中没有主键为x的行就插入一个记录(x,1);
1. 如果表中有主键为x的行就将x这一行的c值加1
</ul>
</li>
<li>
遍历完成后再根据字段m做排序得到结果集返回给客户端。
</li>
这个流程的执行图如下:
<img src="https://static001.geekbang.org/resource/image/03/54/0399382169faf50fc1b354099af71954.jpg" alt="">
图中最后一步,对内存临时表的排序,在[第17篇文章](https://time.geekbang.org/column/article/73795)中已经有过介绍,我把图贴过来,方便你回顾。
<img src="https://static001.geekbang.org/resource/image/b5/68/b5168d201f5a89de3b424ede2ebf3d68.jpg" alt="">
其中临时表的排序过程就是图6中虚线框内的过程。
接下来,我们再看一下这条语句的执行结果:
<img src="https://static001.geekbang.org/resource/image/ae/55/ae6a28d890efc35ee4d07f694068f455.png" alt="">
如果你的需求并不需要对结果进行排序那你可以在SQL语句末尾增加order by null也就是改成
```
select id%10 as m, count(*) as c from t1 group by m order by null;
```
这样就跳过了最后排序的阶段直接从临时表中取数据返回。返回的结果如图8所示。
<img src="https://static001.geekbang.org/resource/image/03/eb/036634e53276eaf8535c3442805dfaeb.png" alt="">
由于表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 src="https://static001.geekbang.org/resource/image/a7/6e/a76381d0f3c947292cc28198901f9e6e.png" alt="">
如果这个表t1的数据量很大很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。
# group by 优化方法 --索引
可以看到不论是使用内存临时表还是磁盘临时表group by逻辑都需要构造一个带唯一索引的表执行代价都是比较高的。如果表的数据量比较大上面这个group by语句执行起来就会很慢我们有什么优化的方法呢
要解决group by语句的优化问题你可以先想一下这个问题执行group by语句为什么需要临时表
group by的语义逻辑是统计不同的值出现的个数。但是由于每一行的id%100的结果是无序的所以我们就需要有一个临时表来记录并统计结果。
那么,如果扫描过程中可以保证出现的数据是有序的,是不是就简单了呢?
假设现在有一个类似图10的这么一个数据结构我们来看看group by可以怎么做。
<img src="https://static001.geekbang.org/resource/image/5c/19/5c4a581c324c1f6702f9a2c70acddd19.jpg" alt="">
可以看到如果可以确保输入的数据是有序的那么计算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 src="https://static001.geekbang.org/resource/image/c9/b9/c9f88fa42d92cf7dde78fca26c4798b9.png" alt="">
从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;
```
的执行流程就是这样的:
<li>
初始化sort_buffer确定放入一个整型字段记为m
</li>
<li>
扫描表t1的索引a依次取出里面的id值, 将 id%100的值存入sort_buffer中
</li>
<li>
扫描完成后对sort_buffer的字段m做排序如果sort_buffer内存不够用就会利用磁盘临时文件辅助排序
</li>
<li>
排序完成后,就得到了一个有序数组。
</li>
根据有序数组得到数组里面的不同值以及每个值的出现次数。这一步的逻辑你已经从前面的图10中了解过了。
下面两张图分别是执行流程图和执行explain命令得到的结果。
<img src="https://static001.geekbang.org/resource/image/82/6a/8269dc6206a7ef20cb515c23df0b846a.jpg" alt="">
<img src="https://static001.geekbang.org/resource/image/83/ec/83b6cd6b3e37dfbf9699cf0ccc0f1bec.png" alt="">
从Extra字段可以看到这个语句的执行没有再使用临时表而是直接用了排序算法。
基于上面的union、union all和group by语句的执行过程的分析我们来回答文章开头的问题MySQL什么时候会使用内部临时表
<li>
如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
</li>
<li>
join_buffer是无序数组sort_buffer是有序数组临时表是二维表结构
</li>
<li>
如果执行逻辑需要用到二维表特性就会优先考虑使用临时表。比如我们的例子中union需要用到唯一索引约束 group by还需要用到另外一个字段来存累积计数。
</li>
# 小结
通过今天这篇文章我重点和你讲了group by的几种实现算法从中可以总结一些使用的指导原则
<li>
如果对group by语句的结果没有排序要求要在语句后面加 order by null
</li>
<li>
尽量让group by过程用上表的索引确认方法是explain结果里没有Using temporary 和 Using filesort
</li>
<li>
如果group by需要统计的数据量不大尽量只使用内存临时表也可以通过适当调大tmp_table_size参数来避免用到磁盘临时表
</li>
<li>
如果数据量实在太大使用SQL_BIG_RESULT这个提示来告诉优化器直接使用排序算法得到group by的结果。
</li>
最后,我给你留下一个思考题吧。
文章中图8和图9都是order by null为什么图8的返回结果里面0是在结果集的最后一行而图9的结果里面0是在结果集的第一行
你可以把你的分析写在留言区里,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是为什么不能用rename修改临时表的改名。
在实现上执行rename table语句的时候要求按照“库名/表名.frm”的规则去磁盘找文件但是临时表在磁盘上的frm文件是放在tmpdir目录下的并且文件名的规则是“#sql{进程id}_{线程id}_序列号.frm”因此会报“找不到文件名”的错误。
评论区留言点赞板:
>
@poppy 同学,通过执行语句的报错现象推测了这个实现过程。

View File

@@ -0,0 +1,261 @@
<audio id="audio" title="38 | 都说InnoDB好那还要不要使用Memory引擎" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/94/b0/94fd1b5036251f9995f86ca261344ab0.mp3"></audio>
我在上一篇文章末尾留给你的问题是两个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 src="https://static001.geekbang.org/resource/image/3f/e6/3fb1100b6e3390357d4efff0ba4765e6.png" alt="">
可以看到内存表t1的返回结果里面0在最后一行而InnoDB表t2的返回结果里0在第一行。
出现这个区别的原因,要从这两个引擎的主键索引的组织方式说起。
表t2用的是InnoDB引擎它的主键索引id的组织方式你已经很熟悉了InnoDB表的数据就放在主键索引树上主键索引是B+树。所以表t2的数据组织方式如下图所示
<img src="https://static001.geekbang.org/resource/image/4e/8d/4e29e4f9db55ace6ab09161c68ad8c8d.jpg" alt="">
主键索引上的值是有序存储的。在执行select *的时候就会按照叶子节点从左到右扫描所以得到的结果里0就出现在第一行。
与InnoDB引擎不同Memory引擎的数据和索引是分开的。我们来看一下表t1中的数据内容。
<img src="https://static001.geekbang.org/resource/image/dd/84/dde03e92074cecba4154d30cd16a9684.jpg" alt="">
可以看到内存表的数据部分以数组的方式单独存放而主键id索引里存的是每个数据的位置。主键id是hash索引可以看到索引上的key并不是有序的。
在内存表t1中当我执行select *的时候走的是全表扫描也就是顺序扫描这个数组。因此0就是最后一个被读到并放入结果集的数据。
可见InnoDB和Memory引擎的数据组织方式是不同的
- InnoDB引擎把数据放在主键索引上其他索引上保存的是主键id。这种方式我们称之为**索引组织表**Index Organizied Table
- 而Memory引擎采用的是把数据单独存放索引上保存数据位置的数据组织形式我们称之为**堆组织表**Heap Organizied Table
从中我们可以看出,这两个引擎的一些典型不同:
<li>
InnoDB表的数据总是有序存放的而内存表的数据就是按照写入顺序存放的
</li>
<li>
当数据文件有空洞的时候InnoDB表在插入新数据的时候为了保证数据有序性只能在固定的位置写入新值而内存表找到空位就可以插入新值
</li>
<li>
数据位置发生变化的时候InnoDB表只需要修改主键索引而内存表需要修改所有索引
</li>
<li>
InnoDB表用主键索引查询时需要走一次索引查找用普通索引查询的时候需要走两次索引查找。而内存表没有这个区别所有索引的“地位”都是相同的。
</li>
<li>
InnoDB支持变长数据类型不同记录的长度可能不同内存表不支持Blob 和 Text字段并且即使定义了varchar(N)实际也当作char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。
</li>
由于内存表的这些特性每个数据行被删除以后空出的这个位置都可以被接下来要插入的数据复用。比如如果要在表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&lt;5;
```
是用不上主键索引的,需要走全表扫描。你可以借此再回顾下[第4篇文章](https://time.geekbang.org/column/article/69236)的内容。那如果要让内存表支持范围扫描,应该怎么办呢
# hash索引和B-Tree索引
实际上内存表也是支B-Tree索引的。在id列上创建一个B-Tree索引SQL语句可以这么写
```
alter table t1 add index a_btree_index using btree (id);
```
这时表t1的数据组织形式就变成了这样
<img src="https://static001.geekbang.org/resource/image/17/e3/1788deca56cb83c114d8353c92e3bde3.jpg" alt="">
新增的这个B-Tree索引你看着就眼熟了这跟InnoDB的b+树索引组织形式类似。
作为对比,你可以看一下这下面这两个语句的输出:
<img src="https://static001.geekbang.org/resource/image/a8/8a/a85808fcccab24911d257d720550328a.png" alt="">
可以看到执行select * from t1 where id&lt;5的时候优化器会选择B-Tree索引所以返回结果是0到4。 使用force index强行使用主键id这个索引id=0这一行就在结果集的最末尾了。
其实一般在我们的印象中内存表的优势是速度快其中的一个原因就是Memory引擎支持hash索引。当然更重要的原因是内存表的所有数据都保存在内存而内存的读写速度总是比磁盘快。
但是,接下来我要跟你说明,为什么我不建议你在生产环境上使用内存表。这里的原因主要包括两个方面:
<li>
锁粒度问题;
</li>
<li>
数据持久化问题。
</li>
# 内存表的锁
我们先来说说内存表的锁粒度问题。
内存表不支持行锁,只支持表锁。因此,一张表只要有更新,就会堵住其他所有在这个表上的读写操作。
需要注意的是这里的表锁跟之前我们介绍过的MDL锁不同但都是表级的锁。接下来我通过下面这个场景跟你模拟一下内存表的表级锁。
<img src="https://static001.geekbang.org/resource/image/f2/29/f216e2d707559ed2ca98fbe21e509f29.png" alt="">
在这个执行序列里session A的update语句要执行50秒在这个语句执行期间session B的查询会进入锁等待状态。session C的show processlist 结果输出如下:
<img src="https://static001.geekbang.org/resource/image/14/16/14d88076dad6db573f0b66f2c17df916.png" alt="">
跟行锁比起来,表锁对并发访问的支持不够好。所以,内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好。
# 数据持久性问题
接下来,我们再看看数据持久性的问题。
数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。
你可能会说如果数据库异常重启内存表被清空也就清空了不会有什么问题啊。但是在高可用架构下内存表的这个特点简直可以当做bug来看待了。为什么这么说呢
**我们先看看M-S架构下使用内存表存在的问题。**
<img src="https://static001.geekbang.org/resource/image/5b/e9/5b910e4c0f1afa219aeecd1f291c95e9.jpg" alt="">
我们来看一下下面这个时序:
<li>
业务正常访问主库;
</li>
<li>
备库硬件升级备库重启内存表t1内容被清空
</li>
<li>
备库重启后客户端发送一条update语句修改表t1的数据行这时备库应用线程就会报错“找不到要更新的行”。
</li>
这样就会导致主备同步停止。当然如果这时候发生主备切换的话客户端会看到表t1的数据“丢失”了。
在图8中这种有proxy的架构里大家默认主备切换的逻辑是由数据库系统自己维护的。这样对客户端来说就是“网络断开重连之后发现内存表数据丢失了”。
你可能说这还好啊,毕竟主备发生切换,连接会断开,业务端能够感知到异常。
但是接下来内存表的这个特性就会让使用现象显得更“诡异”了。由于MySQL知道重启之后内存表的数据会丢失。所以担心主库重启之后出现主备不一致MySQL在实现上做了这样一件事儿在数据库重启之后往binlog里面写入一行DELETE FROM t1。
**如果你使用是如图9所示的双M结构的话**
<img src="https://static001.geekbang.org/resource/image/40/57/4089c9c1f92ce61d2ed779fd0932ba57.jpg" alt="">
在备库重启的时候备库binlog里的delete语句就会传到主库然后把主库内存表的内容删除。这样你在使用的时候就会发现主库的内存表数据突然被清空了。
基于上面的分析,你可以看到,内存表并不适合在生产环境上作为普通数据表使用。
有同学会说,但是内存表执行速度快呀。这个问题,其实你可以这么分析:
<li>
如果你的表更新量大那么并发度是一个很重要的参考指标InnoDB支持行锁并发度比内存表好
</li>
<li>
能放到内存表的数据量都不大。如果你考虑的是读的性能一个读QPS很高并且数据量不大的表即使是使用InnoDB数据也是都会缓存在InnoDB Buffer Pool里的。因此使用InnoDB表的读性能也不会差。
</li>
所以,**我建议你把普通内存表都用InnoDB表来代替。**但是,有一个场景却是例外的。
这个场景就是我们在第35和36篇说到的用户临时表。在数据量可控不会耗费过多内存的情况下你可以考虑使用内存表。
内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因:
<li>
临时表不会被其他线程访问,没有并发性的问题;
</li>
<li>
临时表重启后也是需要删除的,清空数据这个问题不存在;
</li>
<li>
备库的临时表也不会影响主库的用户线程。
</li>
现在我们回过头再看一下第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&gt;=1 and b&lt;=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
```
了解了内存表的特性,你就知道了, 其实这里使用内存临时表的效果更好,原因有三个:
<li>
相比于InnoDB表使用内存表不需要写磁盘往表temp_t的写数据的速度更快
</li>
<li>
索引b使用hash索引查找的速度比B-Tree索引快
</li>
<li>
临时表数据只有2000行占用的内存有限。
</li>
因此,你可以对[第35篇文章](https://time.geekbang.org/column/article/80147)的语句序列做一个改写将临时表temp_t改成内存临时表并且在字段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&gt;=1 and b&lt;=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);
```
<img src="https://static001.geekbang.org/resource/image/a4/92/a468ba6d14ea225623074b6255b99f92.png" alt="">
可以看到不论是导入数据的时间还是执行join的时间使用内存临时表的速度都比使用InnoDB临时表要更快一些。
# 小结
今天这篇文章我从“要不要使用内存表”这个问题展开和你介绍了Memory引擎的几个特性。
可以看到由于重启会丢数据如果一个备库重启会导致主备同步线程停止如果主库跟这个备库是双M架构还可能导致主库的内存表数据被删掉。
因此,在生产上,我不建议你使用普通内存表。
如果你是DBA可以在建表的审核系统中增加这类规则要求业务改用InnoDB表。我们在文中也分析了其实InnoDB表性能还不错而且数据安全也有保障。而内存表由于不支持行锁更新语句会阻塞查询性能也未必就如想象中那么好。
基于内存表的特性我们还分析了它的一个适用场景就是内存临时表。内存表支持hash索引这个特性利用起来对复杂查询的加速效果还是很不错的。
最后,我给你留一个问题吧。
假设你刚刚接手的一个数据库上真的发现了一个内存表。备库重启之后肯定是会导致备库的内存表数据被清空进而导致主备同步停止。这时最好的做法是将它修改成InnoDB引擎表。
假设当时的业务场景暂时不允许你修改引擎,你可以加上什么自动化逻辑,来避免主备同步停止呢?
你可以把你的思考和分析写在评论区,我会在下一篇文章的末尾跟你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
今天文章的正文内容,已经回答了我们上期的问题,这里就不再赘述了。
评论区留言点赞板:
>
@老杨同志@poppy@长杰 这三位同学给出了正确答案,春节期间还持续保持跟进学习,给你们点赞。

View File

@@ -0,0 +1,327 @@
<audio id="audio" title="39 | 自增主键为什么不是连续的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ff/c0/ffeae7cf89facbef6b0414c29726e1c0.mp3"></audio>
在[第4篇文章](https://time.geekbang.org/column/article/69236)中,我们提到过自增主键,由于自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑。
之前我见过有的业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。
今天这篇文章,我们就来说说这个问题,看看什么情况下自增主键会出现 “空洞”?
为了便于说明我们创建一个表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 src="https://static001.geekbang.org/resource/image/cb/ff/cb2637cada0201b18650f56875e94fff.png" alt="">
可以看到表定义里面出现了一个AUTO_INCREMENT=2表示下一次插入数据时如果需要自动生成自增值会生成id=2。
其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,**表的结构定义存放在后缀名为.frm的文件中但是并不会保存自增值。**
不同的引擎对于自增值的保存策略不同。
- MyISAM引擎的自增值保存在数据文件中。
<li>InnoDB引擎的自增值其实是保存在了内存里并且到了MySQL 8.0版本后才有了“自增值持久化”的能力也就是才实现了“如果发生重启表的自增值可以恢复为MySQL重启前的值”具体情况是
<ul>
<li>在MySQL 5.7及之前的版本自增值保存在内存里并没有持久化。每次重启后第一次打开表的时候都会去找自增值的最大值max(id)然后将max(id)+1作为这个表当前的自增值。<br>
举例来说如果一个表当前数据行里最大的id是10AUTO_INCREMENT=11。这时候我们删除id=10的行AUTO_INCREMENT还是11。但如果马上重启实例重启后这个表的AUTO_INCREMENT就会变成10。<br>
也就是说MySQL重启可能会修改一个表的AUTO_INCREMENT的值。</li>
- 在MySQL 8.0版本将自增值的变更记录在了redo log中重启的时候依靠redo log恢复重启之前的值。
理解了MySQL对自增值的保存策略以后我们再看看自增值修改机制。
# 自增值修改机制
在MySQL里面如果字段id被定义为AUTO_INCREMENT在插入一行数据的时候自增值的行为如下
<li>
如果插入数据时id字段指定为0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT值填到自增字段
</li>
<li>
如果插入数据时id字段指定了具体的值就直接使用语句里指定的值。
</li>
根据要插入的值和当前自增值的大小关系自增值的变更结果也会有所不同。假设某次要插入的值是X当前的自增值是Y。
<li>
如果X&lt;Y那么这个表的自增值不变
</li>
<li>
如果X≥Y就需要把当前自增值修改为新的自增值。
</li>
**新的自增值生成算法是**从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的时候新的自增值生成逻辑很简单就是
<li>
如果准备插入的值&gt;=当前自增值,新的自增值就是“准备插入的值+1”
</li>
<li>
否则,自增值不变。
</li>
这就引入了我们文章开头提到的问题在这两个参数都设置为1的时候自增主键id却不能保证是连续的这是什么原因呢
# 自增值的修改时机
要回答这个问题,我们就要看一下自增值的修改时机。
假设表t里面已经有了(1,1,1)这条记录,这时我再执行一条插入数据命令:
```
insert into t values(null, 1, 1);
```
这个语句的执行流程就是:
<li>
执行器调用InnoDB引擎接口写入一行传入的这一行的值是(0,1,1);
</li>
<li>
InnoDB发现用户没有指定自增id的值获取表t当前的自增值2
</li>
<li>
将传入的行的值改成(2,1,1);
</li>
<li>
将表的自增值改成3
</li>
<li>
继续执行插入数据操作由于已经存在c=1的记录所以报Duplicate key error语句返回。
</li>
对应的执行流程图如下:
<img src="https://static001.geekbang.org/resource/image/f1/d3/f16d89a6e7ad6e2cde13b32bb2292dd3.jpg" alt="">
可以看到这个表的自增值改成3是在真正执行插入数据的操作之前。这个语句真正执行的时候因为碰到唯一键c冲突所以id=2这一行并没有插入成功但也没有将自增值再改回去。
所以在这之后再插入新的数据行时拿到的自增id就是3。也就是说出现了自增主键不连续的情况。
如图3所示就是完整的演示结果。
<img src="https://static001.geekbang.org/resource/image/77/26/77b87820b649692a555f19b562d5d926.png" alt="">
可以看到这个操作序列复现了一个自增主键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肯定要加锁然后顺序申请。
<li>
假设事务A申请到了id=2 事务B申请到id=3那么这时候表t的自增值是4之后继续执行。
</li>
<li>
事务B正确提交了但事务A出现了唯一键冲突。
</li>
<li>
如果允许事务A把自增id回退也就是把表t的当前自增值改回2那么就会出现这样的情况表里面已经有id=3的行而当前的自增id值是2。
</li>
<li>
接下来继续执行的其他事务就会申请到id=2然后再申请到id=3。这时就会出现插入语句报错“主键冲突”。
</li>
而为了解决这个主键冲突,有两种方法:
<li>
每次申请id之前先判断表里面是否已经存在这个id。如果存在就跳过这个id。但是这个方法的成本很高。因为本来申请id是一个很快的操作现在还要再去主键索引树上判断id是否存在。
</li>
<li>
把自增id的锁范围扩大必须等到一个事务执行完成并提交下一个事务才能再申请自增id。这个方法的问题就是锁的粒度太大系统并发能力大大下降。
</li>
可见这两个方法都会导致性能问题。造成这些麻烦的罪魁祸首就是我们假设的这个“允许自增id回退”的前提导致的。
因此InnoDB放弃了这个设计语句执行失败也不回退自增id。也正是因为这样所以才只保证了自增id是递增的但不保证是连续的。
# 自增锁的优化
可以看到自增id锁并不是一个事务锁而是每次申请完就马上释放以便允许别的事务再申请。其实在MySQL 5.1版本之前,并不是这样的。
接下来,我会先给你介绍下自增锁设计的历史,这样有助于你分析接下来的一个问题。
在MySQL 5.0版本的时候,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。显然,这样设计会影响并发度。
MySQL 5.1.22版本引入了一个新策略新增参数innodb_autoinc_lock_mode默认值是1。
<li>
这个参数的值被设置为0时表示采用之前MySQL 5.0版本的策略,即语句执行结束后才释放锁;
</li>
<li>
这个参数的值被设置为1时
<ul>
1. 普通insert语句自增锁在申请之后就马上释放
1. 类似insert … select这样的批量插入数据的语句自增锁还是要等语句结束后才被释放
</ul>
</li>
<li>
这个参数的值被设置为2时所有的申请自增主键的动作都是申请后就释放锁。
</li>
你一定有两个疑问:**为什么默认设置下insert … select 要使用语句级的锁为什么这个参数的默认值不是2**
答案是,这么设计还是为了数据的一致性。
我们一起来看一下这个场景:
<img src="https://static001.geekbang.org/resource/image/e0/df/e0a69e151277de54a8262657e4ec89df.png" alt="">
在这个例子里我往表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来串行执行是执行不出来的。
而要解决这个问题,有两种思路:
<li>
一种思路是让原库的批量插入数据语句固定生成连续的id值。所以自增锁直到语句执行结束才释放就是为了达到这个目的。
</li>
<li>
另一种思路是在binlog里面把插入数据的操作都如实记录进来到备库执行的时候不再依赖于自增主键去生成。这种情况其实就是innodb_autoinc_lock_mode设置为2同时binlog_format设置为row。
</li>
因此,**在生产上尤其是有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的策略
<li>
语句执行过程中第一次申请自增id会分配1个
</li>
<li>
1个用完以后这个语句第二次申请自增id会分配2个
</li>
<li>
2个用完以后还是这个语句第三次申请自增id会分配4个
</li>
<li>
依此类推同一个语句去申请自增id每次申请到的自增id个数都是上一次的两倍。
</li>
举个例子,我们一起看看下面的这个语句序列:
```
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 readbinlog_format=statement。这个语句会对表t的所有记录和间隙加锁。
你觉得为什么需要这么做呢?
你可以把你的思考和分析写在评论区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是如果你维护的MySQL系统里有内存表怎么避免内存表突然丢数据然后导致主备同步停止的情况。
我们假设的是主库暂时不能修改引擎那么就把备库的内存表引擎先都改成InnoDB。对于每个内存表执行
```
set sql_log_bin=off;
alter table tbl_name engine=innodb;
```
这样就能避免备库重启的时候,数据丢失的问题。
由于主库重启后会往binlog里面写“delete from tbl_name”这个命令传到备库备库的同名的表数据也会被清空。
因此,就不会出现主备同步停止的问题。
如果由于主库异常重启触发了HA这时候我们之前修改过引擎的备库变成了主库。而原来的主库变成了新备库在新备库上把所有的内存表这时候表里没数据都改成InnoDB表。
所以,如果我们不能直接修改主库上的表引擎,可以配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。
同时,跟业务开发同学约定好建表规则,避免创建新的内存表。
评论区留言点赞板:
>
<p>大家在春节期间还坚持看专栏,并且深入地思考和回复,给大家点赞。<br>
@长杰 同学提到的将数据保存到InnoDB表用来持久化也是一个方法。不过我还是建议釜底抽薪直接修改备库的内存表的引擎。<br>
@老杨同志 提到的是主库异常重启的场景这时候是不会报主备不一致的因为主库重启的时候写了delete from tbl_name主备的内存表都清空了。</p>

View File

@@ -0,0 +1,239 @@
<audio id="audio" title="40 | insert语句的锁为什么这么多" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/48/fa/4874e8adceee873b26f38770cf64dcfa.mp3"></audio>
在上一篇文章中我提到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 src="https://static001.geekbang.org/resource/image/33/86/33e513ee55d5700dc67f32bcdafb9386.png" alt="">
实际的执行效果是如果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 src="https://static001.geekbang.org/resource/image/3e/74/3efdf8256309a44e23d93089459eda74.png" alt="">
通过这个慢查询日志我们看到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 src="https://static001.geekbang.org/resource/image/6f/18/6f90b04c09188bff11dae6e788abb918.png" alt="">
可以看到这时候的Rows_examined的值是5。
我在前面的文章中提到过希望你都能够学会用explain的结果来“脑补”整条语句的执行过程。今天我们就来一起试试。
如图4所示就是这条语句的explain结果。
<img src="https://static001.geekbang.org/resource/image/d7/2a/d7270781ee3f216325b73bd53999b82a.png" alt="">
从Extra字段可以看到“Using temporary”字样表示这个语句用到了临时表。也就是说执行过程中需要把表t的内容读出来写入临时表。
图中rows显示的是1我们不妨先对这个语句的执行流程做一个猜测如果说是把子查询的结果读出来扫描1行写入临时表然后再从临时表读出来扫描1行写回表t中。那么这个语句的扫描行数就应该是2而不是5。
所以这个猜测不对。实际上Explain结果里的rows=1是因为受到了limit 1 的影响。
从另一个角度考虑的话我们可以看看InnoDB扫描了多少行。如图5所示是在执行这个语句前后查看Innodb_rows_read的结果。
<img src="https://static001.geekbang.org/resource/image/48/d7/489281d8029e8f60979cb7c4494010d7.png" alt="">
可以看到这个语句执行前后Innodb_rows_read的值增加了4。因为默认临时表是使用Memory引擎的所以这4行查的都是表t也就是说对表t做了全表扫描。
这样,我们就把整个执行过程理清楚了:
<li>
创建临时表表里有两个字段c和d。
</li>
<li>
按照索引c扫描表t依次取c=4、3、2、1然后回表读到c和d的值写入临时表。这时Rows_examined=4。
</li>
<li>
由于语义里面有limit 1所以只取了临时表的第一行再插入到表t中。这时Rows_examined的值加1变成了5。
</li>
也就是说这个语句会导致在表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 src="https://static001.geekbang.org/resource/image/83/ca/83fb2d877932941b230d6b5be8cca6ca.png" alt="">
这个例子也是在可重复读repeatable read隔离级别下执行的。可以看到session B要执行的insert语句进入了锁等待状态。
也就是说session A执行的insert语句发生唯一键冲突的时候并不只是简单地报错返回还在冲突的索引上加了锁。我们前面说过一个next-key lock就是由它右边界的值定义的。这时候session A持有索引c上的(5,10]共享next-key lock读锁
至于为什么要加这个读锁,其实我也没有找到合理的解释。从作用上来看,这样做可以避免这一行被别的事务删掉。
这里[官方文档](https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html)有一个描述错误认为如果冲突的是主键索引就加记录锁唯一索引才加next-key lock。但实际上这两类索引冲突加的都是next-key lock。
>
备注这个bug是我在写这篇文章查阅文档时发现的已经[发给官方](https://bugs.mysql.com/bug.php?id=93806)并被verified了。
有同学在前面文章的评论区问到,在有多个唯一索引的表中并发插入数据时,会出现死锁。但是,由于他没有提供复现方法或者现场,我也无法做分析。所以,我建议你在评论区发问题的时候,尽量同时附上复现方法,或者现场信息,这样我才好和你一起分析问题。
这里,我就先和你分享一个经典的死锁场景,如果你还遇到过其他唯一键冲突导致的死锁场景,也欢迎给我留言。
<img src="https://static001.geekbang.org/resource/image/63/2d/63658eb26e7a03b49f123fceed94cd2d.png" alt="">
在session A执行rollback语句回滚的时候session C几乎同时发现死锁并返回。
这个死锁产生的逻辑是这样的:
<li>
在T1时刻启动session A并执行insert语句此时在索引c的c=5上加了记录锁。注意这个索引是唯一索引因此退化为记录锁如果你的印象模糊了可以回顾下[第21篇文章](https://time.geekbang.org/column/article/75659)介绍的加锁规则)。
</li>
<li>
在T2时刻session B要执行相同的insert语句发现了唯一键冲突加上读锁同样地session C也在索引c上c=5这一个记录上加了读锁。
</li>
<li>
T3时刻session A回滚。这时候session B和session C都试图继续执行插入操作都要加上写锁。两个session都要等待对方的行锁所以就出现了死锁。
</li>
这个流程的状态变化图如下所示。
<img src="https://static001.geekbang.org/resource/image/3e/b8/3e0bf1a1241931c14360e73fd10032b8.jpg" alt="">
# 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 src="https://static001.geekbang.org/resource/image/5f/02/5f384d6671c87a60e1ec7e490447d702.png" alt="">
可以看到主键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 执行期间有其他线程操作原表,会导致逻辑错误。其实,这是不会的,如果不加锁,就是快照读。
一条语句执行期间,它的一致性视图是不会修改的,所以即使有其他事务修改了原表的数据,也不会影响这条语句看到的数据。
评论区留言点赞板:
>
@长杰 同学回答得非常准确。

View File

@@ -0,0 +1,280 @@
<audio id="audio" title="41 | 怎么最快地复制一张表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/26/80/2673e1081c5c012d9a2596d339a9c180.mp3"></audio>
我在上一篇文章最后给你留下的问题是怎么在两张表中拷贝数据。如果可以控制对源表的扫描行数和加锁范围很小的话我们简单地使用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&lt;=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&gt;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=&quot;a&gt;900&quot; --result-file=/client_tmp/t.sql
```
把结果输出到临时文件。
这条命令中,主要参数含义如下:
<li>
single-transaction的作用是在导出数据的时候不需要对表db1.t加表锁而是使用START TRANSACTION WITH CONSISTENT SNAPSHOT的方法
</li>
<li>
add-locks设置为0表示在输出的文件结果里不增加" LOCK TABLES `t` WRITE;"
</li>
<li>
no-create-info的意思是不需要导出表结构
</li>
<li>
set-gtid-purged=off表示的是不输出跟GTID相关的信息
</li>
<li>
result-file指定了输出文件的路径其中client表示生成的文件是在客户端机器上的。
</li>
通过这条mysqldump命令生成的t.sql文件中就包含了如图1所示的INSERT语句。
<img src="https://static001.geekbang.org/resource/image/8a/de/8acdcefcaf5c9940570bf7e8f73dbdde.png" alt="">
可以看到一条INSERT语句里面会包含多个value对这是为了后续用这个文件来写入数据的时候执行速度可以更快。
如果你希望生成的文件中一条INSERT语句只插入一行数据的话可以在执行mysqldump命令时加上参数skip-extended-insert。
然后你可以通过下面这条命令将这些INSERT语句放到db2库里去执行。
```
mysql -h127.0.0.1 -P13000 -uroot db2 -e &quot;source /client_tmp/t.sql&quot;
```
需要说明的是source并不是一条SQL语句而是一个客户端命令。mysql客户端执行这个命令的流程是这样的
<li>
打开文件默认以分号为结尾读取一条条的SQL语句
</li>
<li>
将SQL语句发送到服务端执行。
</li>
也就是说服务端执行的并不是这个“source t.sql"语句而是INSERT语句。所以不论是在慢查询日志slow log还是在binlog记录的都是这些要被真正执行的INSERT语句。
# 导出CSV文件
另一种方法是直接将结果导出成.csv文件。MySQL提供了下面的语法用来将查询结果导出到服务端本地目录
```
select * from db1.t where a&gt;900 into outfile '/server_tmp/t.csv';
```
我们在使用这条语句时,需要注意如下几点。
<li>
这条语句会将结果保存在服务端。如果你执行命令的客户端和MySQL服务端不在同一个机器上客户端机器的临时目录下是不会生成t.csv文件的。
</li>
<li>
into outfile指定了文件的生成位置/server_tmp/这个位置必须受参数secure_file_priv的限制。参数secure_file_priv的可选值和作用分别是
<ul>
1. 如果设置为empty表示不限制文件生成的位置这是不安全的设置
1. 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
1. 如果设置为NULL就表示禁止在这个MySQL实例上执行select … into outfile 操作。
</ul>
</li>
<li>
这条命令不会帮你覆盖文件,因此你需要确保/server_tmp/t.csv这个文件不存在否则执行语句时就会因为有同名文件的存在而报错。
</li>
<li>
这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。
</li>
得到.csv导出文件后你就可以用下面的load data命令将数据导入到目标表db2.t中。
```
load data infile '/server_tmp/t.csv' into table db2.t;
```
这条语句的执行流程如下所示。
<li>
打开文件/server_tmp/t.csv以制表符(\t)作为字段间的分隔符,以换行符(\n作为记录之间的分隔符进行数据读取
</li>
<li>
启动事务。
</li>
<li>
判断每一行的字段数与表db2.t是否相同
<ul>
1. 若不相同,则直接报错,事务回滚;
1. 若相同则构造成一行调用InnoDB引擎接口写入到表中。
</ul>
</li>
<li>
重复步骤3直到/server_tmp/t.csv整个文件读入完成提交事务。
</li>
你可能有一个疑问,**如果binlog_format=statement这个load语句记录到binlog里以后怎么在备库重放呢**
由于/server_tmp/t.csv文件只保存在主库所在的主机上如果只是把这条语句原文写到binlog中在备库执行的时候备库的本地机器上没有这个文件就会导致主备同步停止。
所以,这条语句执行的完整流程,其实是下面这样的。
<li>
主库执行完成后,将/server_tmp/t.csv文件的内容直接写到binlog文件中。
</li>
<li>
往binlog文件中写入语句load data local infile /tmp/SQL_LOAD_MB-1-0 INTO TABLE `db2`.`t`
</li>
<li>
把这个binlog日志传到备库。
</li>
<li>
<p>备库的apply线程在执行这个事务日志时<br>
a. 先将binlog中t.csv文件的内容读出来写入到本地临时目录/tmp/SQL_LOAD_MB-1-0 中;<br>
b. 再执行load data语句往备库的db2.t表中插入跟主库相同的数据。</p>
</li>
执行流程如图2所示
<img src="https://static001.geekbang.org/resource/image/3a/fd/3a6790bc933af5ac45a75deba0f52cfd.jpg" alt="">
注意这里备库执行的load data语句里面多了一个“local”。它的意思是“将执行这条命令的客户端所在机器的本地文件/tmp/SQL_LOAD_MB-1-0的内容加载到目标表db2.t中”。
也就是说,**load data命令有两种用法**
<li>
不加“local”是读取服务端的文件这个文件必须在secure_file_priv指定的目录或子目录下
</li>
<li>
加上“local”读取的是客户端的文件只要mysql客户端有访问这个文件的权限即可。这时候MySQL客户端会先把本地文件传给服务端然后执行上述的load data流程。
</li>
另外需要注意的是,**select …into outfile方法不会生成表结构文件**, 所以我们导数据时还需要单独的命令得到表结构定义。mysqldump提供了一个tab参数可以同时导出表结构定义文件和csv数据文件。这条命令的使用方法如下
```
mysqldump -h$host -P$port -u$user ---single-transaction --set-gtid-purged=OFF db1 t --where=&quot;a&gt;900&quot; --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具体的执行步骤如下
<li>
执行 create table r like t创建一个相同表结构的空表
</li>
<li>
执行alter table r discard tablespace这时候r.ibd文件会被删除
</li>
<li>
执行flush table t for export这时候db1目录下会生成一个t.cfg文件
</li>
<li>
在db1目录下执行cp t.cfg r.cfg; cp t.ibd r.ibd这两个命令这里需要注意的是拷贝得到的两个文件MySQL进程要有读写权限
</li>
<li>
执行unlock tables这时候t.cfg文件会被删除
</li>
<li>
执行alter table r import tablespace将这个r.ibd文件作为表r的新的表空间由于这个文件的数据内容和t.ibd是相同的所以表r中就有了和表t相同的数据。
</li>
至此,拷贝表数据的操作就完成了。这个流程的执行过程图如下:
<img src="https://static001.geekbang.org/resource/image/ba/a7/ba1ced43eed4a55d49435c062fee21a7.jpg" alt="">
关于拷贝表的这个流程,有以下几个注意点:
<li>
在第3步执行完flsuh table命令之后db1.t整个表处于只读状态直到执行unlock tables命令后才释放读锁
</li>
<li>
在执行import tablespace的时候为了让文件里的表空间id和数据字典中的一致会修改r.ibd的表空间id。而这个表空间id存在于每一个数据页中。因此如果是一个很大的文件比如TB级别每个数据页都需要修改所以你会看到这个import语句的执行是需要一些时间的。当然如果是相比于逻辑导入的方法import语句的耗时是非常短的。
</li>
# 小结
今天这篇文章,我和你介绍了三种将一个表的数据导入到另外一个表中的方法。
我们来对比一下这三种方法的优缺点。
<li>
物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
<ul>
1. 必须是全表拷贝,不能只拷贝部分数据;
1. 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;
1. 由于是通过拷贝物理文件实现的源表和目标表都是使用InnoDB引擎时才能使用。
</ul>
</li>
<li>
用mysqldump生成包含INSERT语句文件的方法可以在where参数增加过滤条件来实现只导出部分数据。这个方式的不足之一是不能使用join这种比较复杂的where条件写法。
</li>
<li>
用select … into outfile的方法是最灵活的支持所有的SQL写法。但这个方法的缺点之一就是每次只能导出一张表的数据而且表结构也需要另外的语句单独备份。
</li>
后两种方式都是逻辑备份方式,是可以跨引擎使用的。
最后,我给你留下一个思考题吧。
我们前面介绍binlog_format=statement的时候binlog记录的load data命令是带local的。既然这条命令是发送到备库去执行的那么备库执行的时候也是本地执行为什么需要这个local呢如果写到binlog中的命令不带local又会出现什么问题呢
你可以把你的分析写在评论区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
我在上篇文章最后给你留下的思考题,已经在今天这篇文章的正文部分做了回答。
上篇文章的评论区有几个非常好的留言,我在这里和你分享一下。
@huolang 同学提了一个问题如果sessionA拿到c=5的记录锁是写锁那为什么sessionB和sessionC还能加c=5的读锁呢
这是因为next-key lock是先加间隙锁再加记录锁的。加间隙锁成功了加记录锁就会被堵住。如果你对这个过程有疑问的话可以再复习一下[第30篇文章](https://time.geekbang.org/column/article/78427)中的相关内容。
@一大只 同学做了一个实验验证了主键冲突以后insert语句加间隙锁的效果。比我在上篇文章正文中提的那个回滚导致死锁的例子更直观体现了他对这个知识点非常好的理解和思考很赞。
@roaming 同学验证了在MySQL 8.0版本中已经能够用临时表处理insert … select写入原表的语句了。
@老杨同志 的回答提到了我们本文中说到的几个方法。

View File

@@ -0,0 +1,229 @@
<audio id="audio" title="42 | grant之后要跟着flush privileges吗" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/fc/fe/fc5570b96111b50b35d4db81bbb878fe.mp3"></audio>
在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代表的是两个不同的用户
这条命令做了两个动作:
<li>
磁盘上往mysql.user表里插入一行由于没有指定权限所以这行数据上所有表示权限的字段的值都是N
</li>
<li>
内存里往数组acl_users里插入一个acl_user对象这个对象的access字段值为0。
</li>
图1就是这个时刻用户ua在user表中的状态。
<img src="https://static001.geekbang.org/resource/image/7e/35/7e75bbfbca0cb932e1256941c99d5f35.png" alt="">
在MySQL中用户权限是有不同的范围的。接下来我就按照用户权限范围从大到小的顺序依次和你说明。
# 全局权限
全局权限作用于整个MySQL实例这些权限信息保存在mysql库的user表里。如果我要给用户ua赋一个最高权限的话语句是这么写的
```
grant all privileges on *.* to 'ua'@'%' with grant option;
```
这个grant命令做了两个动作
<li>
磁盘上将mysql.user表里用户ua@%'这一行的所有表示权限的字段的值都修改为Y
</li>
<li>
内存里从数组acl_users中找到这个用户对应的对象将access值权限位修改为二进制的“全1”。
</li>
在这个grant命令执行完成后如果有新的客户端使用用户名ua登录成功MySQL会为新连接维护一个线程对象然后从acl_users数组里查到这个用户的权限并将权限值拷贝到这个线程对象中。之后在这个连接中执行的语句所有关于全局权限的判断都直接使用线程对象内部保存的权限位。
基于上面的分析我们可以知道:
<li>
grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。
</li>
<li>
对于一个已经存在的连接它的全局权限不受grant命令的影响。
</li>
需要说明的是,**一般在生产环境上要合理控制用户权限的范围**。我们上面用到的这个grant语句就是一个典型的错误示范。如果一个用户有所有权限一般就不应该设置为所有IP地址都可以访问。
如果要回收上面的grant语句赋予的权限你可以使用下面这条命令
```
revoke all privileges on *.* from 'ua'@'%';
```
这条revoke命令的用法与grant类似做了如下两个动作
<li>
磁盘上将mysql.user表里用户ua@%'这一行的所有表示权限的字段的值都修改为“N”
</li>
<li>
内存里从数组acl_users中找到这个用户对应的对象将access的值修改为0。
</li>
# db权限
除了全局权限MySQL也支持库级别的权限定义。如果要让用户ua拥有库db1的所有权限可以执行下面这条命令
```
grant all privileges on db1.* to 'ua'@'%' with grant option;
```
基于库的权限记录保存在mysql.db表中在内存里则保存在数组acl_dbs中。这条grant命令做了如下两个动作
<li>
磁盘上往mysql.db表中插入了一行记录所有权限位字段设置为“Y”
</li>
<li>
内存里增加一个对象到数组acl_dbs中这个对象的权限位为“全1”。
</li>
图2就是这个时刻用户ua在db表中的状态。
<img src="https://static001.geekbang.org/resource/image/32/2e/32cd61ee14ad2f370e1de0fb4e39bb2e.png" alt="">
每次需要判断一个用户对一个数据库读写权限的时候都需要遍历一次acl_dbs数组根据user、host和db找到匹配的对象然后根据对象的权限位来判断。
也就是说grant修改db权限的时候是同时对磁盘和内存生效的。
grant操作对于已经存在的连接的影响在全局权限和基于db的权限效果是不同的。接下来我们做一个对照试验来分别看一下。
<img src="https://static001.geekbang.org/resource/image/ae/c7/aea26807c8895961b666a5d96b081ac7.png" alt="">
需要说明的是图中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里面之前use这个库的时候拿到的库权限会保存在会话变量中。
你可以看到在T6时刻session C和session B对表t的操作逻辑是一样的。但是session B报错而session C可以执行成功。这是因为session C在T2 时刻执行的use db1拿到了这个库的权限在切换出db1库之前session C对这个库就一直有权限。
# 表权限和列权限
除了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 src="https://static001.geekbang.org/resource/image/90/ec/9031814361be42b7bc084ad2ab2aa3ec.png" alt="">
可以看到T3时刻虽然已经用delete语句删除了用户ua但是在T4时刻仍然可以用ua连接成功。原因就是这时候内存中acl_users数组中还有这个用户因此系统判断时认为用户还正常存在。
在T5时刻执行过flush命令后内存更新T6时刻再要用ua来登录的话就会报错“无法访问”了。
直接操作系统表是不规范的操作这个不一致状态也会导致一些更“诡异”的现象发生。比如前面这个通过delete语句删除用户的例子就会出现下面的情况
<img src="https://static001.geekbang.org/resource/image/dd/f1/dd625b6b4eb2dcbdaac73648a1af50f1.png" alt="">
可以看到由于在T3时刻直接删除了数据表的记录而内存的数据还存在。这就导致了
<li>
T4时刻给用户ua赋权限失败因为mysql.user表中找不到这行记录
</li>
<li>
而T5时刻要重新创建这个用户也不行因为在做内存判断的时候会认为这个用户还存在。
</li>
# 小结
今天这篇文章我和你介绍了MySQL用户权限在数据表和内存中的存在形式以及grant和revoke命令的执行逻辑。
grant语句会同时修改数据表和内存判断权限的时候使用的是内存数据。因此规范地使用grant和revoke语句是不需要随后加上flush privileges语句的。
flush privileges语句本身会用数据表的数据重建一份内存权限数据所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用DML语句操作系统权限表导致的所以我们尽量不要使用这类语句。
另外在使用grant语句赋权时你可能还会看到这样的写法
```
grant super on *.* to 'ua'@'%' identified by 'pa';
```
这条命令加了identified by ‘密码’, 语句的逻辑里面除了赋权外,还包含了:
<li>
如果用户ua@%'不存在就创建这个用户密码是pa
</li>
<li>
如果用户ua已经存在就将密码修改成pa。
</li>
这也是一种不建议的写法,因为这种写法很容易就会不慎把密码给改了。
“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。
评论区留言点赞板:
>
<p>@poppy@库淘淘 两位同学提到了第一个场景;<br>
@王显伟 @lionetes 两位同学帮忙回答了 @undifined 同学的疑问拷贝出来的文件要确保MySQL进程可以读。</p>

View File

@@ -0,0 +1,178 @@
<audio id="audio" title="43 | 要不要使用分区表?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/2d/933a010d2267de5eb3ffb0e52eea7f2d.mp3"></audio>
我经常被问到这样一个问题:分区表有什么问题,为什么公司规范不让使用分区表呢?今天,我们就来聊聊分区表的使用行为,然后再一起回答这个问题。
# 分区表是什么?
为了说明分区表的组织形式我先创建一个表t
```
CREATE TABLE `t` (
`ftime` datetime NOT NULL,
`c` int(11) DEFAULT NULL,
KEY (`ftime`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
insert into t values('2017-4-1',1),('2018-4-1',1);
```
<img src="https://static001.geekbang.org/resource/image/06/f5/06f041129783533de9c75580f9decdf5.png" alt="">
我在表t中初始化插入了两行记录按照定义的分区规则这两行记录分别落在p_2018和p_2019这两个分区上。
可以看到,这个表包含了一个.frm文件和4个.ibd文件每个分区对应一个.ibd文件。也就是说
- 对于引擎层来说这是4个表
- 对于Server层来说这是1个表。
你可能会觉得这两句都是废话。其实不然,这两句话非常重要,可以帮我们理解分区表的执行逻辑。
# 分区表的引擎层行为
我先给你举个在分区表加间隙锁的例子目的是说明对于InnoDB来说这是4个表。
<img src="https://static001.geekbang.org/resource/image/d2/c7/d28d6ab873bd8337d88812d45b9266c7.png" alt="">
这里顺便复习一下,我在[第21篇文章](https://time.geekbang.org/column/article/75659)和你介绍的间隙锁加锁规则。
我们初始化表t的时候只插入了两行数据 ftime的值分别是'2017-4-1' 和'2018-4-1' 。session A的select语句对索引ftime上这两个记录之间的间隙加了锁。如果是一个普通表的话那么T1时刻在表t的ftime索引上间隙和加锁状态应该是图3这样的。
<img src="https://static001.geekbang.org/resource/image/27/d2/273c9ca869f5b52621641d73eb6f72d2.jpg" alt="">
也就是说,'2017-4-1' 和'2018-4-1' 这两个记录之间的间隙是会被锁住的。那么sesion B的两条插入语句应该都要进入锁等待状态。
但是从上面的实验效果可以看出session B的第一个insert语句是可以执行成功的。这是因为对于引擎来说p_2018和p_2019是两个不同的表也就是说2017-4-1的下一个记录并不是2018-4-1而是p_2018分区的supremum。所以T1时刻在表t的ftime索引上间隙和加锁的状态其实是图4这样的
<img src="https://static001.geekbang.org/resource/image/92/5c/92f63aba0b24adefac7316c75463b95c.jpg" alt="">
由于分区表的规则session A的select语句其实只操作了分区p_2018因此加锁范围就是图4中深绿色的部分。
所以session B要写入一行ftime是2018-2-1的时候是可以成功的而要写入2017-12-1这个记录就要等session A的间隙锁。
图5就是这时候的show engine innodb status的部分结果。
<img src="https://static001.geekbang.org/resource/image/e3/0f/e3d83d9ba89de9a6f541c9a2f24a3b0f.png" alt="">
看完InnoDB引擎的例子我们再来一个MyISAM分区表的例子。
我首先用alter table t engine=myisam把表t改成MyISAM表然后我再用下面这个例子说明对于MyISAM引擎来说这是4个表。
<img src="https://static001.geekbang.org/resource/image/94/76/941306d4a7193455dcf1cfebf7678876.png" alt="">
在session A里面我用sleep(100)将这条语句的执行时间设置为100秒。由于MyISAM引擎只支持表锁所以这条update语句会锁住整个表t上的读。
但我们看到的结果是session B的第一条查询语句是可以正常执行的第二条语句才进入锁等待状态。
这正是因为MyISAM的表锁是在引擎层实现的session A加的表锁其实是锁在分区p_2018上。因此只会堵住在这个分区上执行的查询落到其他分区的查询是不受影响的。
看到这里,你可能会说,分区表看来还不错嘛,为什么不让用呢?我们使用分区表的一个重要原因就是单表过大。那么,如果不使用分区表的话,我们就是要使用手动分表的方式。
接下来,我们一起看看手动分表和分区表有什么区别。
比如按照年份来划分我们就分别创建普通表t_2017、t_2018、t_2019等等。手工分表的逻辑也是找到需要更新的所有分表然后依次执行更新。在性能上这和分区表并没有实质的差别。
分区表和手工分表一个是由server层来决定使用哪个分区一个是由应用层代码来决定使用哪个分表。因此从引擎层看这两种方式也是没有差别的。
其实这两个方案的区别主要是在server层上。从server层看我们就不得不提到分区表一个被广为诟病的问题打开表的行为。
# 分区策略
每当第一次访问一个分区表的时候MySQL需要把所有的分区都访问一遍。**一个典型的报错情况**是这样的如果一个分区表的分区很多比如超过了1000个而MySQL启动的时候open_files_limit参数使用的是默认值1024那么就会在访问这个表的时候由于需要打开所有的文件导致打开表文件的个数超过了上限而报错。
下图就是我创建的一个包含了很多分区的表t_myisam执行一条插入语句后报错的情况。
<img src="https://static001.geekbang.org/resource/image/ab/e7/abfa0054ec43d97fb18ba3c1c8829ae7.png" alt="">
可以看到这条insert语句明显只需要访问一个分区但语句却无法执行。
这时你一定从表名猜到了这个表我用的是MyISAM引擎。是的因为使用InnoDB引擎的话并不会出现这个问题。
MyISAM分区表使用的分区策略我们称为**通用分区策略**generic partitioning每次访问分区都由server层控制。通用分区策略是MySQL一开始支持分区表的时候就存在的代码在文件管理、表管理的实现上很粗糙因此有比较严重的性能问题。
从MySQL 5.7.9开始InnoDB引擎引入了**本地分区策略**native partitioning。这个策略是在InnoDB内部自己管理打开分区的行为。
MySQL从5.7.17开始将MyISAM分区表标记为即将弃用(deprecated),意思是“从这个版本开始不建议这么使用,请使用替代方案。在将来的版本中会废弃这个功能”。
从MySQL 8.0版本开始就不允许创建MyISAM分区表了只允许创建已经实现了本地分区策略的引擎。目前来看只有InnoDB和NDB这两个引擎支持了本地分区策略。
接下来我们再看一下分区表在server层的行为。
# 分区表的server层行为
如果从server层看的话一个分区表就只是一个表。
这句话是什么意思呢接下来我就用下面这个例子来和你说明。如图8和图9所示分别是这个例子的操作序列和执行结果图。
<img src="https://static001.geekbang.org/resource/image/0e/81/0eca5a3190161e59ea58493915bd5e81.png" alt="">
<img src="https://static001.geekbang.org/resource/image/af/a8/afe662f5e051a2ceb96a87624a589aa8.png" alt="">
可以看到虽然session B只需要操作p_2017这个分区但是由于session A持有整个表t的MDL锁就导致了session B的alter语句被堵住。
这也是DBA同学经常说的分区表在做DDL的时候影响会更大。如果你使用的是普通分表那么当你在truncate一个分表的时候肯定不会跟另外一个分表上的查询语句出现MDL锁冲突。
到这里我们小结一下:
<li>
MySQL在第一次打开分区表的时候需要访问所有的分区
</li>
<li>
在server层认为这是同一张表因此所有分区共用同一个MDL锁
</li>
<li>
在引擎层认为这是不同的表因此MDL锁之后的执行过程会根据分区表规则只访问必要的分区。
</li>
而关于“必要的分区”的判断就是根据SQL语句中的where条件结合分区规则来实现的。比如我们上面的例子中where ftime='2018-4-1'根据分区规则year函数算出来的值是2018那么就会落在p_2019这个分区。
但是如果这个where 条件改成 where ftime&gt;='2018-4-1'虽然查询结果相同但是这时候根据where条件就要访问p_2019和p_others这两个分区。
如果查询语句的where条件中没有分区key那就只能访问所有分区了。当然这并不是分区表的问题。即使是使用业务分表的方式where条件中没有使用分表的key也必须访问所有的分表。
我们已经理解了分区表的概念,那么什么场景下适合使用分区表呢?
# 分区表的应用场景
分区表的一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁。还有,分区表可以很方便的清理历史数据。
如果一项业务跑的时间足够长往往就会有根据时间删除历史数据的需求。这时候按照时间分区的分区表就可以直接通过alter table t drop partition ...这个语法删掉分区,从而删掉过期的历史数据。
这个alter table t drop partition ...操作是直接删除分区文件效果跟drop普通表类似。与使用delete语句删除数据相比优势是速度快、对系统影响小。
# 小结
这篇文章我主要和你介绍的是server层和引擎层对分区表的处理方式。我希望通过这些介绍你能够对是否选择使用分区表有更清晰的想法。
需要注意的是我是以范围分区range为例和你介绍的。实际上MySQL还支持hash分区、list分区等分区方法。你可以在需要用到的时候再翻翻[手册](https://dev.mysql.com/doc/refman/8.0/en/partitioning-types.html)。
实际使用时分区表跟用户分表比起来有两个绕不开的问题一个是第一次访问的时候需要访问所有分区另一个是共用MDL锁。
因此如果要使用分区表就不要创建太多的分区。我见过一个用户做了按天分区策略然后预先创建了10年的分区。这种情况下访问分区表的性能自然是不好的。这里有两个问题需要注意
<li>
分区并不是越细越好。实际上,单表或者单分区的数据一千万行,只要没有特别大的索引,对于现在的硬件能力来说都已经是小表了。
</li>
<li>
分区也不要提前预留太多在使用之前预先创建即可。比如如果是按月分区每年年底时再把下一年度的12个新分区创建上即可。对于没有数据的历史分区要及时的drop掉。
</li>
至于分区表的其他问题,比如查询需要跨多个分区取数据,查询性能就会比较慢,基本上就不是分区表本身的问题,而是数据量的问题或者说是使用方式的问题了。
当然如果你的团队已经维护了成熟的分库分表中间件用业务分表对业务开发同学没有额外的复杂性对DBA也更直观自然是更好的。
最后,我给你留下一个思考题吧。
我们举例的表中没有用到自增主键假设现在要创建一个自增字段id。MySQL要求分区表中的主键必须包含分区字段。如果要在表t的基础上做修改你会怎么定义这个表的主键呢为什么这么定义呢
你可以把你的结论和分析写在留言区,我会在下一篇文章的末尾和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上篇文章后面还不够多,可能很多同学还没来记得看吧,我们就等后续有更多留言的时候,再补充本期的“上期问题时间”吧。
@夹心面包 提到了在grant的时候是支持通配符的"_"表示一个任意字符,“%”表示任意字符串。这个技巧在一个分库分表方案里面同一个分库上有多个db的时候是挺方便的。不过我个人认为权限赋值的时候控制的精确性还是要优先考虑的。

View File

@@ -0,0 +1,324 @@
<audio id="audio" title="44 | 答疑文章(三):说一说这些好问题" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/7f/d8/7f31a28adc4a3e05728c51c7e4730bd8.mp3"></audio>
这是我们专栏的最后一篇答疑文章,今天我们来说说一些好问题。
在我看来,能够帮我们扩展一个逻辑的边界的问题,就是好问题。因为通过解决这样的问题,能够加深我们对这个逻辑的理解,或者帮我们关联到另外一个知识点,进而可以帮助我们建立起自己的知识网络。
在工作中会问好问题,是一个很重要的能力。
经过这段时间的学习从评论区的问题我可以感觉出来紧跟课程学习的同学对SQL语句执行性能的感觉越来越好了提出的问题也越来越细致和精准了。
接下来,我们就一起看看同学们在评论区提到的这些好问题。在和你一起分析这些问题的时候,我会指出它们具体是在哪篇文章出现的。同时,在回答这些问题的过程中,我会假设你已经掌握了这篇文章涉及的知识。当然,如果你印象模糊了,也可以跳回文章再复习一次。
# join的写法
在第35篇文章[《join语句怎么优化](https://time.geekbang.org/column/article/80147)中我在介绍join执行顺序的时候用的都是straight_join。@郭健 同学在文后提出了两个问题:
<li>
如果用left join的话左边的表一定是驱动表吗
</li>
<li>
如果两个表的join包含多个条件的等值匹配是都要写到on里面呢还是只把一个条件写到on里面其他条件写到where部分
</li>
为了同时回答这两个问题我来构造两个表a和b
```
create table a(f1 int, f2 int, index(f1))engine=innodb;
create table b(f1 int, f2 int)engine=innodb;
insert into a values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6);
insert into b values(3,3),(4,4),(5,5),(6,6),(7,7),(8,8);
```
表a和b都有两个字段f1和f2不同的是表a的字段f1上有索引。然后我往两个表中都插入了6条记录其中在表a和b中同时存在的数据有4行。
@郭健 同学提到的第二个问题,其实就是下面这两种写法的区别:
```
select * from a left join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q1*/
select * from a left join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q2*/
```
我把这两条语句分别记为Q1和Q2。
首先需要说明的是这两个left join语句的语义逻辑并不相同。我们先来看一下它们的执行结果。
<img src="https://static001.geekbang.org/resource/image/87/bd/871f890532349781fdc4a4287e9f91bd.png" alt="">
可以看到:
- 语句Q1返回的数据集是6行表a中即使没有满足匹配条件的记录查询结果中也会返回一行并将表b的各个字段值填成NULL。
- 语句Q2返回的是4行。从逻辑上可以这么理解最后的两行由于表b中没有匹配的字段结果集里面b.f2的值是空不满足where 部分的条件判断,因此不能作为结果集的一部分。
接下来我们看看实际执行这两条语句时MySQL是怎么做的。
我们先一起看看语句Q1的explain结果
<img src="https://static001.geekbang.org/resource/image/b7/17/b7f27917ceb0be90ef7b201f2794c817.png" alt="">
可以看到,这个结果符合我们的预期:
- 驱动表是表a被驱动表是表b
- 由于表b的f1字段上没有索引所以使用的是Block Nested Loop Join简称BNL 算法。
看到BNL算法你就应该知道这条语句的执行流程其实是这样的
<li>
把表a的内容读入join_buffer 中。因为是select * 所以字段f1和f2都被放入join_buffer了。
</li>
<li>
顺序扫描表b对于每一行数据判断join条件也就是(a.f1=b.f1) and (a.f1=1))是否满足,满足条件的记录, 作为结果集的一行返回。如果语句中有where子句需要先判断where部分满足条件后再返回。
</li>
<li>
表b扫描完成后对于没有被匹配的表a的行在这个例子中就是(1,1)、(2,2)这两行把剩余字段补上NULL再放入结果集中。
</li>
对应的流程图如下:
<img src="https://static001.geekbang.org/resource/image/8f/d7/8fd4b4b179fb84caaecece84b6406ad7.jpg" alt="">
可以看到这条语句确实是以表a为驱动表而且从执行效果看也和使用straight_join是一样的。
你可能会想语句Q2的查询结果里面少了最后两行数据是不是就是把上面流程中的步骤3去掉呢我们还是先看一下语句Q2的expain结果吧。
<img src="https://static001.geekbang.org/resource/image/f5/9c/f5712c56dc84d331990409a5c313ea9c.png" alt="">
这里先和你说一句题外话专栏马上就结束了我也和你一起根据explain结果“脑补”了很多次一条语句的执行流程了所以我希望你已经具备了这个能力。今天我们再一起分析一次SQL语句的explain结果。
可以看到这条语句是以表b为驱动表的。而如果一条join语句的Extra字段什么都没写的话就表示使用的是Index Nested-Loop Join简称NLJ算法。
因此语句Q2的执行流程是这样的顺序扫描表b每一行用b.f1到表a中去查匹配到记录后判断a.f2=b.f2是否满足满足条件的话就作为结果集的一部分返回。
那么,**为什么语句Q1和Q2这两个查询的执行流程会差距这么大呢**其实这是因为优化器基于Q2这个查询的语义做了优化。
为了理解这个问题我需要再和你交代一个背景知识点在MySQL里NULL跟任何值执行等值判断和不等值判断的结果都是NULL。这里包括 select NULL = NULL 的结果也是返回NULL。
因此语句Q2里面where a.f2=b.f2就表示查询结果里面不会包含b.f2是NULL的行这样这个left join的语义就是“找到这两个表里面f1、f2对应相同的行。对于表a中存在而表b中匹配不到的行就放弃”。
这样这条语句虽然用的是left join但是语义跟join是一致的。
因此优化器就把这条语句的left join改写成了join然后因为表a的f1上有索引就把表b作为驱动表这样就可以用上NLJ 算法。在执行explain之后你再执行show warnings就能看到这个改写的结果如图5所示。
<img src="https://static001.geekbang.org/resource/image/d7/ab/d74878e7469edb8b713a18c6158530ab.png" alt="">
这个例子说明即使我们在SQL语句中写成left join执行过程还是有可能不是从左到右连接的。也就是说**使用left join时左边的表不一定是驱动表。**
这样看来,**如果需要left join的语义就不能把被驱动表的字段放在where条件里面做等值判断或不等值判断必须都写在on里面。**那如果是join语句呢
这时候,我们再看看这两条语句:
```
select * from a join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q3*/
select * from a join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q4*/
```
我们再使用一次看explain 和 show warnings的方法看看优化器是怎么做的。
<img src="https://static001.geekbang.org/resource/image/d9/f5/d9952e4c2150bc649c7f2977e6ea80f5.png" alt="">
可以看到,这两条语句都被改写成:
```
select * from a join b where (a.f1=b.f1) and (a.f2=b.f2);
```
执行计划自然也是一模一样的。
也就是说在这种情况下join将判断条件是否全部放在on部分就没有区别了。
# Simple Nested Loop Join 的性能问题
我们知道join语句使用不同的算法对语句的性能影响会很大。在第34篇文章[《到底可不可以使用join](https://time.geekbang.org/column/article/79700)的评论区中,@书策稠浊@朝夕心 两位同学提了一个很不错的问题。
我们在文中说到虽然BNL算法和Simple Nested Loop Join 算法都是要判断M*N次M和N分别是join的两个表的行数但是Simple Nested Loop Join 算法的每轮判断都要走全表扫描因此性能上BNL算法执行起来会快很多。
为了便于说明,我还是先为你简单描述一下这两个算法。
BNL算法的执行逻辑是
<li>
首先将驱动表的数据全部读入内存join_buffer中这里join_buffer是无序数组
</li>
<li>
然后顺序遍历被驱动表的所有行每一行数据都跟join_buffer中的数据进行匹配匹配成功则作为结果集的一部分返回。
</li>
Simple Nested Loop Join算法的执行逻辑是顺序取出驱动表中的每一行数据到被驱动表去做全表扫描匹配匹配成功则作为结果集的一部分返回。
这两位同学的疑问是Simple Nested Loop Join算法其实也是把数据读到内存里然后按照匹配条件进行判断为什么性能差距会这么大呢
解释这个问题需要用到MySQL中索引结构和Buffer Pool的相关知识点
<li>
<p>在对被驱动表做全表扫描的时候如果数据没有在Buffer Pool中就需要等待这部分数据从磁盘读入<br>
从磁盘读入数据到内存中会影响正常业务的Buffer Pool命中率而且这个算法天然会对被驱动表的数据做多次访问更容易将这些数据页放到Buffer Pool的头部请参考[第35篇文章](https://time.geekbang.org/column/article/80147)中的相关内容)</p>
</li>
<li>
即使被驱动表数据都在内存中每次查找“下一个记录的操作”都是类似指针操作。而join_buffer中是数组遍历的成本更低。
</li>
所以说BNL算法的性能会更好。
# distinct 和 group by的性能
在第37篇文章[《什么时候会使用内部临时表?》](https://time.geekbang.org/column/article/80477)中,@老杨同志 提了一个好问题如果只需要去重不需要执行聚合函数distinct 和group by哪种效率高一些呢
我来展开一下他的问题如果表t的字段a上没有索引那么下面这两条语句
```
select a from t group by a order by null;
select distinct a from t;
```
的性能是不是相同的?
首先需要说明的是这种group by的写法并不是SQL标准的写法。标准的group by语句是需要在select部分加一个聚合函数比如
```
select a,count(*) from t group by a order by null;
```
这条语句的逻辑是按照字段a分组计算每组的a出现的次数。在这个结果里由于做的是聚合计算相同的a只出现一次。
>
备注:这里你可以顺便复习一下[第37篇文章](https://time.geekbang.org/column/article/80477)中关于group by的相关内容。
没有了count(*)以后也就是不再需要执行“计算总数”的逻辑时第一条语句的逻辑就变成是按照字段a做分组相同的a的值只返回一行。而这就是distinct的语义所以不需要执行聚合函数时distinct 和group by这两条语句的语义和执行流程是相同的因此执行性能也相同。
这两条语句的执行流程是下面这样的。
<li>
创建一个临时表临时表有一个字段a并且在这个字段a上创建一个唯一索引
</li>
<li>
遍历表t依次取数据插入临时表中
<ul>
1. 如果发现唯一键冲突,就跳过;
1. 否则插入成功;
</ul>
</li>
<li>
遍历完成后,将临时表作为结果集返回给客户端。
</li>
# 备库自增主键问题
除了性能问题大家对细节的追问也很到位。在第39篇文章[《自增主键为什么不是连续的?》](https://time.geekbang.org/column/article/80531)评论区,@帽子掉了 同学问到在binlog_format=statement时语句A先获取id=1然后语句B获取id=2接着语句B提交写binlog然后语句A再写binlog。这时候如果binlog重放是不是会发生语句B的id为1而语句A的id为2的不一致情况呢
首先这个问题默认了“自增id的生成顺序和binlog的写入顺序可能是不同的”这个理解是正确的。
其次这个问题限定在statement格式下也是对的。因为row格式的binlog就没有这个问题了Write row event里面直接写了每一行的所有字段的值。
而至于为什么不会发生不一致的情况,我们来看一下下面的这个例子。
```
create table t(id int auto_increment primary key);
insert into t values(null);
```
<img src="https://static001.geekbang.org/resource/image/b5/25/b55b2167aa301d899ccc86a00b496b25.png" alt="">
可以看到在insert语句之前还有一句SET INSERT_ID=1。这条命令的意思是这个线程里下一次需要用到自增值的时候不论当前表的自增值是多少固定用1这个值。
这个SET INSERT_ID语句是固定跟在insert语句之前的比如@帽子掉了同学提到的场景主库上语句A的id是1语句B的id是2但是写入binlog的顺序先B后A那么binlog就变成
```
SET INSERT_ID=2;
语句B
SET INSERT_ID=1;
语句A
```
你看在备库上语句B用到的INSERT_ID依然是2跟主库相同。
因此即使两个INSERT语句在主备库的执行顺序不同自增主键字段的值也不会不一致。
# 小结
今天这篇答疑文章我选了4个好问题和你分享并做了分析。在我看来能够提出好问题首先表示这些同学理解了我们文章的内容进而又做了深入思考。有你们在认真的阅读和思考对我来说是鼓励也是动力。
说实话,短短的三篇答疑文章无法全部展开同学们在评论区留下的高质量问题,之后有的同学还会二刷,也会有新的同学加入,大家想到新的问题就请给我留言吧,我会继续关注评论区,和你在评论区交流。
老规矩,答疑文章也是要有课后思考题的。
在[第8篇文章](https://time.geekbang.org/column/article/70562)的评论区, @XD同学提到一个问题他查看了一下innodb_trx发现这个事务的trx_id是一个很大的数281479535353408而且似乎在同一个session中启动的会话得到的trx_id是保持不变的。当执行任何加写锁的语句后trx_id都会变成一个很小的数字118378
你可以通过实验验证一下然后分析看看事务id的分配规则是什么以及MySQL为什么要这么设计呢
你可以把你的结论和分析写在留言区,我会在下一篇文章和你讨论这个问题。感谢你的收听,也欢迎你把这篇文章分享给更多的朋友一起阅读。
# 上期问题时间
上期的问题是怎么给分区表t创建自增主键。由于MySQL要求主键包含所有的分区字段所以肯定是要创建联合主键的。
这时候就有两种可选:一种是(ftime, id),另一种是(id, ftime)。
如果从利用率上来看,应该使用(ftime, id)这种模式。因为用ftime做分区key说明大多数语句都是包含ftime的使用这种模式可以利用前缀索引的规则减少一个索引。
这时的建表语句是:
```
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ftime` datetime NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`ftime`,`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = MyISAM,
PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = MyISAM,
PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = MyISAM,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = MyISAM);
```
当然我的建议是你要尽量使用InnoDB引擎。InnoDB表要求至少有一个索引以自增字段作为第一个字段所以需要加一个id的单独索引。
```
CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`ftime` datetime NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`ftime`,`id`),
KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
```
当然把字段反过来,创建成:
```
PRIMARY KEY (`id`,`ftime`),
KEY `id` (`ftime`)
```
也是可以的。
评论区留言点赞板:
>
@夹心面包@郭江伟 同学提到了最后一种方案。
>
@aliang 同学提了一个好问题关于open_files_limit和innodb_open_files的关系我在回复中做了说明大家可以看一下。
>
@万勇 提了一个好问题实际上对于现在官方的版本将字段加在中间还是最后在性能上是没差别的。但是我建议大家养成习惯如果你是DBA就帮业务开发同学养成习惯将字段加在最后面因为这样还是比较方便操作的。这个问题我也在评论的答复中做了说明你可以看一下。

View File

@@ -0,0 +1,235 @@
<audio id="audio" title="45 | 自增id用完怎么办" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/4b/d0/4b4c8e7b694dc7c6adcc5b49507c38d0.mp3"></audio>
MySQL里有很多自增的id每个自增id都是定义了初始值然后不停地往上加步长。虽然自然数是没有上限的但是在计算机里只要定义了表示这个数的字节长度那它就有上限。比如无符号整型(unsigned int)是4个字节上限就是2<sup>32</sup>-1。
既然自增id有上限就有可能被用完。但是自增id用完了会怎么样呢
今天这篇文章我们就来看看MySQL里面的几种自增id一起分析一下它们的值达到上限以后会出现什么情况。
# 表定义自增值id
说到自增id你第一个想到的应该就是表结构定义里的自增字段也就是我在第39篇文章[《自增主键为什么不是连续的?》](https://time.geekbang.org/column/article/80531)中和你介绍过的自增主键id。
表定义的自增值达到上限后的逻辑是再申请下一个id时得到的值保持不变。
我们可以通过下面这个语句序列验证一下:
```
create table t(id int unsigned auto_increment primary key) auto_increment=4294967295;
insert into t values(null);
//成功插入一行 4294967295
show create table t;
/* CREATE TABLE `t` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295;
*/
insert into t values(null);
//Duplicate entry '4294967295' for key 'PRIMARY'
```
可以看到第一个insert语句插入数据成功后这个表的AUTO_INCREMENT没有改变还是4294967295就导致了第二个insert语句又拿到相同的自增id值再试图执行插入语句报主键冲突错误。
2<sup>32</sup>-14294967295不是一个特别大的数对于一个频繁插入删除数据的表来说是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限如果有可能就应该创建成8个字节的bigint unsigned。
# InnoDB系统自增row_id
如果你创建的InnoDB表没有指定主键那么InnoDB会给你创建一个不可见的长度为6个字节的row_id。InnoDB维护了一个全局的dict_sys.row_id值所有无主键的InnoDB表每插入一行数据都将当前的dict_sys.row_id值作为要插入数据的row_id然后把dict_sys.row_id的值加1。
实际上在代码实现时row_id是一个长度为8字节的无符号长整型(bigint unsigned)。但是InnoDB在设计时给row_id留的只是6个字节的长度这样写到数据表中时只放了最后6个字节所以row_id能写到数据表中的值就有两个特征
<li>
row_id写入表中的值范围是从0到2<sup>48</sup>-1
</li>
<li>
当dict_sys.row_id=2<sup>48</sup>如果再有插入数据的行为要来申请row_id拿到以后再取最后6个字节的话就是0。
</li>
也就是说写入表的row_id是从0开始到2<sup>48</sup>-1。达到上限后下一个值就是0然后继续循环。
当然2<sup>48</sup>-1这个值本身已经很大了但是如果一个MySQL实例跑得足够久的话还是可能达到这个上限的。在InnoDB逻辑里申请到row_id=N后就将这行数据写入表中如果表中已经存在row_id=N的行新写入的行就会覆盖原有的行。
要验证这个结论的话你可以通过gdb修改系统的自增row_id来实现。注意用gdb改变量这个操作是为了便于我们复现问题只能在测试环境使用。
<img src="https://static001.geekbang.org/resource/image/6a/9a/6a7bfd460f9e75afcfcfc4a963339a9a.png" alt="">
<img src="https://static001.geekbang.org/resource/image/5a/5c/5ad1fff81bda3a6b00ec84e84753fa5c.png" alt="">
可以看到在我用gdb将dict_sys.row_id设置为2<sup>48</sup>之后再插入的a=2的行会出现在表t的第一行因为这个值的row_id=0。之后再插入的a=3的行由于row_id=1就覆盖了之前a=1的行因为a=1这一行的row_id也是1。
从这个角度看我们还是应该在InnoDB表中主动创建自增主键。因为表自增id到达上限后再插入数据时报主键冲突错误是更能被接受的。
毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。
# Xid
在第15篇文章[《答疑文章(一):日志和索引相关问题》](https://time.geekbang.org/column/article/73161)中我和你介绍redo log和binlog相配合的时候提到了它们有一个共同的字段叫作Xid。它在MySQL中是用来对应事务的。
那么Xid在MySQL内部是怎么生成的呢
MySQL内部维护了一个全局变量global_query_id每次执行语句的时候将它赋值给Query_id然后给这个变量加1。如果当前语句是这个事务执行的第一条语句那么MySQL还会同时把Query_id赋值给这个事务的Xid。
而global_query_id是一个纯内存变量重启之后就清零了。所以你就知道了在同一个数据库实例中不同事务的Xid也是有可能相同的。
但是MySQL重启之后会重新生成新的binlog文件这就保证了同一个binlog文件里Xid一定是惟一的。
虽然MySQL重启不会导致同一个binlog里面出现两个相同的Xid但是如果global_query_id达到上限后就会继续从0开始计数。从理论上讲还是就会出现同一个binlog里面出现相同Xid的场景。
因为global_query_id定义的长度是8个字节这个自增值的上限是2<sup>64</sup>-1。要出现这种情况必须是下面这样的过程
<li>
执行一个事务假设Xid是A
</li>
<li>
接下来执行2<sup>64</sup>次查询语句让global_query_id回到A
</li>
<li>
再启动一个事务这个事务的Xid也是A。
</li>
不过2<sup>64</sup>这个值太大了,大到你可以认为这个可能性只会存在于理论上。
# Innodb trx_id
Xid和InnoDB的trx_id是两个容易混淆的概念。
Xid是由server层维护的。InnoDB内部使用Xid就是为了能够在InnoDB事务和server之间做关联。但是InnoDB自己的trx_id是另外维护的。
其实你应该非常熟悉这个trx_id。它就是在我们在第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中讲事务可见性时用到的事务idtransaction id
InnoDB内部维护了一个max_trx_id全局变量每次需要申请一个新的trx_id时就获得max_trx_id的当前值然后并将max_trx_id加1。
InnoDB数据可见性的核心思想是每一行数据都记录了更新它的trx_id当一个事务读到一行数据的时候判断这个数据是否可见的方法就是通过事务的一致性视图与这行数据的trx_id做对比。
对于正在执行的事务你可以从information_schema.innodb_trx表中看到事务的trx_id。
我在上一篇文章的末尾留给你的思考题就是关于从innodb_trx表里面查到的trx_id的。现在我们一起来看一个事务现场
<img src="https://static001.geekbang.org/resource/image/94/7c/94c704190f7609b3e6443688368cd97c.png" alt="">
session B里我从innodb_trx表里查出的这两个字段第二个字段trx_mysql_thread_id就是线程id。显示线程id是为了说明这两次查询看到的事务对应的线程id都是5也就是session A所在的线程。
可以看到T2时刻显示的trx_id是一个很大的数T4时刻显示的trx_id是1289看上去是一个比较正常的数字。这是什么原因呢
实际上在T1时刻session A还没有涉及到更新是一个只读事务。而对于只读事务InnoDB并不会分配trx_id。也就是说
<li>
在T1时刻trx_id的值其实就是0。而这个很大的数只是显示用的。一会儿我会再和你说说这个数据的生成逻辑。
</li>
<li>
直到session A 在T3时刻执行insert语句的时候InnoDB才真正分配了trx_id。所以T4时刻session B查到的这个trx_id的值就是1289。
</li>
需要注意的是除了显而易见的修改类语句外如果在select 语句后面加上for update这个事务也不是只读事务。
在上一篇文章的评论区有同学提出实验的时候发现不止加1。这是因为
<li>
update 和 delete语句除了事务本身还涉及到标记删除旧数据也就是要把数据放到purge队列里等待后续物理删除这个操作也会把max_trx_id+1 因此在一个事务中至少加2
</li>
<li>
InnoDB的后台操作比如表的索引信息统计这类操作也是会启动内部事务的因此你可能看到trx_id值并不是按照加1递增的。
</li>
那么,**T2时刻查到的这个很大的数字是怎么来的呢**
其实这个数字是每次查询的时候由系统临时计算出来的。它的算法是把当前事务的trx变量的指针地址转成整数再加上2<sup>48</sup>。使用这个算法,就可以保证以下两点:
<li>
因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx还是在innodb_locks表里同一个只读事务查出来的trx_id就会是一样的。
</li>
<li>
如果有并行的多个只读事务每个事务的trx变量的指针地址肯定不同。这样不同的并发只读事务查出来的trx_id就是不同的。
</li>
那么,**为什么还要再加上2<sup>48</sup>呢?**
在显示值里面加上2<sup>48</sup>目的是要保证只读事务显示的trx_id值比较大正常情况下就会区别于读写事务的id。但是trx_id跟row_id的逻辑类似定义长度也是8个字节。因此在理论上还是可能出现一个读写事务与一个只读事务显示的trx_id相同的情况。不过这个概率很低并且也没有什么实质危害可以不管它。
另一个问题是,**只读事务不分配trx_id有什么好处呢**
- 一个好处是这样做可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务是不影响数据的可见性判断的。所以在创建事务的一致性视图时InnoDB就只需要拷贝读写事务的trx_id。
- 另一个好处是可以减少trx_id的申请次数。在InnoDB里即使你只是执行一个普通的select语句在执行过程中也是要对应一个只读事务的。所以只读事务优化后普通的查询语句不需要申请trx_id就大大减少了并发事务申请trx_id的锁冲突。
由于只读事务不分配trx_id一个自然而然的结果就是trx_id的增加速度变慢了。
但是max_trx_id会持久化存储重启也不会重置为0那么从理论上讲只要一个MySQL服务跑得足够久就可能出现max_trx_id达到2<sup>48</sup>-1的上限然后从0开始的情况。
当达到这个状态后MySQL就会持续出现一个脏读的bug我们来复现一下这个bug。
首先我们需要把当前的max_trx_id先修改成2<sup>48</sup>-1。注意这个case里使用的是可重复读隔离级别。具体的操作流程如下
<img src="https://static001.geekbang.org/resource/image/13/c0/13735f955a437a848895787bf9c723c0.png" alt="">
由于我们已经把系统的max_trx_id设置成了2<sup>48</sup>-1所以在session A启动的事务TA的低水位就是2<sup>48</sup>-1。
在T2时刻session B执行第一条update语句的事务id就是2<sup>48</sup>-1而第二条update语句的事务id就是0了这条update语句执行后生成的数据版本上的trx_id就是0。
在T3时刻session A执行select语句的时候判断可见性发现c=3这个数据版本的trx_id小于事务TA的低水位因此认为这个数据可见。
但,这个是脏读。
由于低水位值会持续增加而事务id从0开始计数就导致了系统在这个时刻之后所有的查询都会出现脏读的。
并且MySQL重启时max_trx_id也不会清0也就是说重启MySQL这个bug仍然存在。
那么,**这个bug也是只存在于理论上吗**
假设一个MySQL实例的TPS是每秒50万持续这个压力的话在17.8年后就会出现这个情况。如果TPS更高这个年限自然也就更短了。但是从MySQL的真正开始流行到现在恐怕都还没有实例跑到过这个上限。不过这个bug是只要MySQL实例服务时间够长就会必然出现的。
当然这个例子更现实的意义是可以加深我们对低水位和数据可见性的理解。你也可以借此机会再回顾下第8篇文章[《事务到底是隔离的还是不隔离的?》](https://time.geekbang.org/column/article/70562)中的相关内容。
# thread_id
接下来我们再看看线程idthread_id。其实线程id才是MySQL中最常见的一种自增id。平时我们在查各种现场的时候show processlist里面的第一列就是thread_id。
thread_id的逻辑很好理解系统保存了一个全局变量thread_id_counter每新建一个连接就将thread_id_counter赋值给这个新连接的线程变量。
thread_id_counter定义的大小是4个字节因此达到2<sup>32</sup>-1后它就会重置为0然后继续增加。但是你不会在show processlist里看到两个相同的thread_id。
是因为MySQL设计了一个唯一数组的逻辑给新线程分配thread_id的时候逻辑代码是这样的
```
do {
new_id= thread_id_counter++;
} while (!thread_ids.insert_unique(new_id).second);
```
这个代码逻辑简单而且实现优雅,相信你一看就能明白。
# 小结
今天这篇文章我给你介绍了MySQL不同的自增id达到上限以后的行为。数据库系统作为一个可能需要7*24小时全年无休的服务考虑这些边界是非常有必要的。
每种自增id有各自的应用场景在达到上限后的表现也不同
<li>
表的自增id达到上限后再申请时它的值就不会改变进而导致继续插入数据时报主键冲突的错误。
</li>
<li>
row_id达到上限后则会归0再重新递增如果出现相同的row_id后写的数据会覆盖之前的数据。
</li>
<li>
Xid只需要不在同一个binlog文件中出现重复值即可。虽然理论上会出现重复值但是概率极小可以忽略不计。
</li>
<li>
InnoDB的max_trx_id 递增值每次MySQL重启都会被保存起来所以我们文章中提到的脏读的例子就是一个必现的bug好在留给我们的时间还很充裕。
</li>
<li>
thread_id是我们使用中最常见的而且也是处理得最好的一个自增id逻辑了。
</li>
当然在MySQL里还有别的自增id比如table_id、binlog文件序号等就留给你去验证和探索了。
不同的自增id有不同的上限值上限值的大小取决于声明的类型长度。而我们专栏声明的上限id就是45所以今天这篇文章也是我们的最后一篇技术文章了。
既然没有下一个id了课后也就没有思考题了。今天我们换一个轻松的话题请你来说说读完专栏以后有什么感想吧。
这个“感想”,既可以是你读完专栏前后对某一些知识点的理解发生的变化,也可以是你积累的学习专栏文章的好方法,当然也可以是吐槽或者对未来的期望。
欢迎你给我留言,我们在评论区见,也欢迎你把这篇文章分享给更多的朋友一起阅读。

View File

@@ -0,0 +1,35 @@
<audio id="audio" title="开篇词 | 这一次让我们一起来搞懂MySQL" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/54/25/54eb59b95b2c47dee5e98029e65cc425.mp3"></audio>
你好我是林晓斌网名“丁奇”欢迎加入我的专栏和我一起开始MySQL学习之旅。我曾先后在百度和阿里任职从事MySQL数据库方面的工作一步步地从一个数据库小白成为MySQL内核开发人员。回想起来从我第一次带着疑问翻MySQL的源码查到答案至今已经有十个年头了。在这个过程中走了不少弯路但同时也收获了很多的知识和思考希望能在这个专栏里分享给你。
记得刚开始接触MySQL是我在百度贴吧做权限系统的时候。我们遇到了一个奇怪的问题一个正常10毫秒就能完成的SQL查询请求偶尔要执行100多毫秒才结束。当时主管问我是什么原因我其实也搞不清楚就上网查答案但怎么找都找不到又脸皮薄不想说自己不知道只好硬着头皮翻源码。后来遇到了越来越多的问题也是类似的情景所以我逐步养成了通过分析源码理解原理的习惯。
当时我自己的感觉是即使我只是一个开发工程师只是MySQL的用户在了解了一个个系统模块的原理后再来使用它感觉是完全不一样的。当在代码里写下一行数据库命令的时候我就能想到它在数据库端将怎么执行它的性能是怎么样的怎样写能让我的应用程序访问数据库的性能最高。进一步哪些数据处理让数据库系统来做性能会更好哪些数据处理在缓存里做性能会更好我心里也会更清楚。在建表和建索引的时候我也会更有意识地为将来的查询优化做综合考虑比如确定是否使用递增主键、主键的列怎样选择等等。
但随后我又有了一个新的困惑我觉得自己了解的MySQL知识点是零散的没有形成网络。于是解决完一个问题后很容易忘记。再碰到类似的问题我又得再翻一次代码。
所幸在阿里工作的时候我参与了阿里云关系型数据库服务内核的开发并且负责开发开源分支AliSQL让我对MySQL内核和源码有了更深层次的研究和理解。在服务内部客户和公有云客户的过程中我有机会面对和解决足够多的问题再通过手册进行系统的学习算是比较坎坷地将MySQL的知识网络补了起来。
所以,在回顾这个过程的时候,我的第一个感受是,如果一开始就有一些从理论到实战的系统性指导,那该多好啊,也许我可以学习得更快些。
在极客时间团队跟我联系策划这个专栏的时候我还是持怀疑态度的。为什么呢现在不比当年了犹记得十余年前你使用MySQL的过程中碰到问题的话基本上都只能到代码里去找答案因为那时网上的资料太少了。
而近十年来MySQL在中国广泛普及技术分享文章可以说是浩如烟海。所以现在要系统地介绍一遍MySQL的话恐怕里面提及的大多数知识点都可以在社区文章中找到。那么我们做这个专栏的意义在哪里而它又凭什么可以收费呢
直到收到极客时间团队的答复我才开始对这个专栏“想做和可以做”的事情感觉清晰起来。数据库是一个综合系统其背后是发展了几十年的数据库理论。同时数据库系统也是一个应用系统可能一个业务开发人员用了两三年MySQL还未必清楚那些自己一直在用的“最佳实践”为什么是最佳的。
于是我希望这个专栏能够帮助这样的一些开发者他们正在使用MySQL知道如何写出逻辑正确的SQL语句来实现业务目标却不确定这个语句是不是最优的他们听说了一些使用数据库的最佳实践但是更想了解为什么这么做他们使用的数据库偶尔会出问题亟需了解如何更快速、更准确地定位问题甚至自己解决问题……
在过去的七年里,我带过十几个应届毕业生,看着他们成长,要求他们原理先行,再实践验证。几年下来,他们的成长速度都很快,其中好几个毕业没两年就成为团队的骨干力量了。我也在社招的时候面试过很多有着不错的运维实践经验和能力的候选人,但都因为对数据库原理仅有一知半解的了解,而最终遗憾地没有通过面试。
因此,我希望这个专栏能够激发开发者对数据库原理的探索欲,从而更好地理解工作中遇到的问题,更能知道背后的为什么。所以**我会选那些平时使用数据库时高频出现的知识,如事务、索引、锁等内容构成专栏的主线**。这些主线上是一个个的知识点。每个点就是一个概念、一个机制或者一个原理说明。在每个说明之后,我会和你讨论一个实践相关的问题。
希望能以这样的方式让你对MySQL的几条主线有一个整体的认识并且了解基本概念。在之后的实践篇中我会引用到这些主线的知识背景并着力说明它们是怎样指导实践的。这样**你可以从点到线再到面形成自己的MySQL知识网络。**
在这里,有一份目录,你也可以先了解下整个专栏的知识结构。
<img src="https://static001.geekbang.org/resource/image/b7/c2/b736f37014d28199c2457a67ed669bc2.jpg" alt="">
如前面说的这几条主线上的每个知识点几乎都不是最新的有些甚至十年前就这样并没有改过。但我希望针对这些点的说明可以让你在使用MySQL时心里更有底知道怎么做选择并且明白为什么。了解了原理才能在实践中不断创新提升个人的价值和工作输出。
从这里开始跟我一起搞懂MySQL!

View File

@@ -0,0 +1,172 @@
在专栏上线后的11月21日我来到极客时间做了一场直播主题就是“我的MySQL心路历程”。今天我特意将这个直播的回顾文章放在了专栏下面希望你可以从我这些年和MySQL打交道的经历中找到对你有所帮助的点。
这里,我先和你说一下,在这个直播中,我主要分享的内容:
<li>
我和MySQL打交道的经历
</li>
<li>
你为什么要了解数据库原理;
</li>
<li>
我建议的MySQL学习路径
</li>
<li>
DBA的修炼之道。
</li>
# 我的经历
## 以丰富的经历进入百度
我是福州大学毕业的,据我了解,那时候我们学校的应届生很难直接进入百度,都要考到浙江大学读个研究生才行。没想到的是,我投递了简历后居然进了面试。
入职以后,我跑去问当时的面试官,为什么我的简历可以通过筛选?他们说:“因为你的简历厚啊”。我在读书的时候,确实做了很多项目,也实习过不少公司,所以简历里面的经历就显得很丰富了。
在面试的时候,有个让我印象很深刻的事儿。面试官问我说,你有这么多实习经历,有没有什么比较好玩儿的事?我想了想答道,跟你说个数据量很大的事儿 ,在跟移动做日志分析的时候我碰到了几千万行的数据。他听完以后就笑了。
后来,我进了百度才知道,几千万行那都是小数据。
## 开始尝试看源码解决问题
加入百度后我是在贴吧做后端程序比如权限系统等等。其实很简单就是写一个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天你可以直接[点击链接](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F27413vk03dzk8)购买。<br>
<img src="https://static001.geekbang.org/resource/image/ce/c8/cebd662dab97995a7718c4a38009cfc8.png" alt="">
# 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++的课。我觉得自己已经会了,完全可以教得了。但去了之后,我才知道,自己会跟能够教别人完全是两码事儿。备课的时候,你不能只讲会用的部分,还得把原理讲清楚。这样,就会倒逼自己进行更深入更全面的学习。
有的人看完技术博客和专栏,会把这篇文章的提纲列一下,写写自己的问题和对这篇文章的理解。这个过程,是非常利于学习的。因为你听进来是一回事儿,讲出去则是另一回事儿。
## 学数据库要保持什么心态?
不只是数据库,所有多线程的服务,调试和追查问题的过程都是很枯燥的,遇到问题都会很麻烦。但是,你找出问题时的那一下会很爽。
我觉得你得找到这种感觉,它可以支持你度过接下来要枯燥很久的那段时光,这样你才能继续坚持下去。
当然,如果有更快乐的学习过程还是更好的,希望这个专栏能让你学习得轻松些。

View File

@@ -0,0 +1,89 @@
<audio id="audio" title="结束语 | 点线网面一起构建MySQL知识网络" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c8/f5/c837ecf76803a518f96b640209fef0f5.mp3"></audio>
时光流逝,这是专栏的最后一篇文章。回顾整个过程,**如果用一个词来描述,就是“没料到”**
我没料到文章这么难写,似乎每一篇文章都要用尽所学;
我没料到评论这么精彩,以致于我花在评论区的时间并不比正文少;
我没料到收获这么大,每一次被评论区的提问问到盲点,都会带着久违的兴奋去分析代码。
**如果让我自己评价这个专栏:**
我最满意的部分,是每一篇文章都带上了实践案例,也尽量讲清楚了原理;
我最得意的段落,是在讲事务隔离级别的时候,把文章重写到第三遍,终于能够写上“到这里,我们把一致性读、当前读和行锁就串起来了”;
我最开心的时候,是看到评论区有同学在回答课后思考题时,准确地用上了之前文章介绍的知识点。因为我理解的构建知识网络,就是这么从点到线,从线到网,从网到面的过程,很欣喜能跟大家一起走过这个过程。
当然,我更看重的还是你的评价。所以,当我看到你们在评论区和知乎说“好”的时候,就只会更细致地设计文章内容和课后思考题。
同时我知道专栏的订阅用户中有刚刚接触MySQL的新人也有使用MySQL多年的同学。所以我始终都在告诫自己要尽量让大家都能有所收获。
在我的理解里介绍数据库的文章需要有操作性每一个操作有相应的原理每一个原理背后又有它的原理这是一个链条。能够讲清楚链条中的一个环节就可能是一篇好文章。但是每一层都有不同的受众。所以我给这45篇文章定的目标就是讲清楚操作和第一层的原理并适当触及第二层原理。希望这样的设计不会让你觉得太浅。
有同学在问MySQL的学习路径我在这里就和你谈谈我的理解。
## 1. 路径千万条,实践第一条
如果你问一个DBA“理解得最深刻的知识点”他很可能告诉你是他踩得最深的那个坑。由此“实践”的重要性可见一斑。
以前我带新人的时候,第一步就是要求他们手动搭建一套主备复制结构。并且,平时碰到问题的时候,我要求要动手复现。
从专栏评论区的留言可以看出来,有不少同学在跟着专栏中的案例做实验,我觉得这是个非常好的习惯,希望你能继续坚持下去。在阅读其他技术文章、图书的时候,也是同样的道理。如果你觉得自己理解了一个知识点,也一定要尝试设计一个例子来验证它。
同时,在设计案例的时候,我建议你也设计一个对照的反例,从而达到知识融汇贯通的目的。就像我在写这个专栏的过程中,就感觉自己也涨了不少知识,主要就得益于给文章设计案例的过程。
## 2. 原理说不清,双手白费劲
不论是先实践再搞清楚原理去解释,还是先明白原理再通过实践去验证,都不失为一种好的学习方法,因人而异。但是,怎么证明自己是不是真的把原理弄清楚了呢?答案是说出来、写出来。
如果有人请教你某个知识点,那真是太好了,一定要跟他讲明白。不要觉得这是在浪费时间。因为这样做,一来可以帮你验证自己确实搞懂了这个知识点;二来可以提升自己的技术表达能力,毕竟你终究要面临和这样的三类人讲清楚原理的情况,即:老板、晋升答辩的评委、新工作的面试官。
我在带新人的时候,如果这一届的新人不止一个,就会让他们组成学习小组,并定期给他们出一个已经有确定答案的问题。大家分头去研究,之后在小组内进行讨论。如果你能碰到愿意跟你结成学习小组的同学,一定要好好珍惜。
而“写出来”又是一个更高的境界。因为,你在写的过程中,就会发现这个“明白”很可能只是一个假象。所以,在专栏下面写下自己对本章知识点的理解,也是一个不错的夯实学习成果的方法。
## 3. 知识没体系,转身就忘记
把知识点“写下来”,还有一个好处,就是你会发现这个知识点的关联知识点。深究下去,点就连成线,然后再跟别的线找交叉。
比如我们专栏里面讲到对临时表的操作不记录日志然后你就可以给自己一个问题这会不会导致备库同步出错再比如了解了临时表在不同的binlog格式下的行为再追问一句如果创建表的时候是statement格式之后再修改为row格式或者反之会怎么样呢
把这些都搞明白以后,你就能够把临时表、日志格式、同步机制,甚至于事务机制都连起来了。
相信你和我一样,在学习过程中最喜欢的就是这种交叉的瞬间。交叉多了,就形成了网络。而有了网络以后,吸收新知识的速度就很快了。
比如如果你对事务隔离级别弄得很清楚了在看到第45篇文章讲的max_trx_id超限会导致持续脏读的时候相信你理解起来就很容易了。
## 4. 手册补全面,案例扫盲点
有同学还问我,要不要一开始就看手册?我的建议是不要。看手册的时机,应该是你的知识网络构建得差不多的时候。
那你可能会问,什么时候算是差不多呢?其实,这没有一个固定的标准。但是,有一些基本实践可以帮你去做一个检验。
- 能否解释清楚错误日志error log、慢查询日志slow log中每一行的意思
- 能否快速评估出一个表结构或者一条SQL语句设计得是否合理
- 能否通过explain的结果来“脑补”整个执行过程我们已经在专栏中练习几次了
<li>到网络上找MySQL的实践建议对于每一条做一次分析
<ul>
- 如果觉得不合理,能否给出自己的意见?
- 如果觉得合理,能否给出自己的解释?
那,怎么判断自己的意见或者解释对不对呢?最快速、有效的途径,就是找有经验的人讨论。比如说,留言到我们专栏的相关文章的评论区,就是一个可行的方法。
这些实践做完后,你就应该对自己比较有信心了。这时候,你可以再去看手册,把知识网络中的盲点补全,进而形成面。而补全的方法就是前两点了,理论加实践。
我希望这45篇文章可以在你构建MySQL知识体系的过程中起到一个加速器的作用。
我特意安排在最后一篇文章和你介绍MySQL里各种自增id达到定义的上限以后的不同行为。“45”就是我们这个专栏的id上限而这一篇结束语便是超过上限后的第一个值。这是一个未定义的值由你来定义
- 有的同学可能会像表定义的自增id一样就让它定格在这里
- 有的同学可能会像row_id一样二刷然后用新的、更全面的理解去替代之前的理解
- 也许最多的情况是会像thread_id一样将已经彻底掌握的文章标记起来专门刷那些之前看过、但是已经印象模糊的文章。
不论是哪一种策略只要这45篇文章中有那么几个知识点像Xid或者InnoDB trx_id一样持久化到了你的知识网络里你和我在这里花费的时间就是“极客”的时间就值了。
这是专栏的最后一篇文章的最后一句话,江湖再见。
[<img src="https://static001.geekbang.org/resource/image/a7/33/a7455b773a63af5588e398ee792e8033.jpg" alt="">](http://lixbr66veiw63rtj.mikecrm.com/7IfMxSe)

View File

@@ -0,0 +1,10 @@
你好,我是林晓斌。
《MySQL实战45讲》这门课程已经全部结束了。我给你准备了一个结课小测试来帮助你检验自己的学习效果。
这套测试题共有 20 道题目包括3道单选题和17道多选题满分 100 分,系统自动评分。
还等什么,点击下面按钮开始测试吧!
[<img src="https://static001.geekbang.org/resource/image/28/a4/28d1be62669b4f3cc01c36466bf811a4.png" alt="">](http://time.geekbang.org/quiz/intro?act_id=109&amp;exam_id=232)