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>