select * from T where ID=10;
我们看到的只是输入一条语句,返回一个结果,却不知道这条语句在 MySQL 内部的执行过程。
大体来说,MySQL 可以分为 Server 层和存储引擎层两部分。
Server 层包括连接器、查询缓存、分析器、优化器、执行器等,涵盖 MySQL 的大多数核心服务功能,以及所有的内置函数(如日期、时间、数学和加密函数等),所有跨存储引擎的功能都在这一层实现,比如存储过程、触发器、视图等。
而存储引擎层负责数据的存储和提取。其架构模式是插件式的,支持 InnoDB、MyISAM、Memory 等多个存储引擎。现在最常用的存储引擎是 InnoDB,它从 MySQL 5.5.5 版本开始成为了默认存储引擎。
也就是说,你执行 create table 建表的时候,如果不指定引擎类型,默认使用的就是 InnoDB。不过,你也可以通过指定存储引擎的类型来选择别的引擎,比如在 create table 语句中使用 engine=memory, 来指定使用内存引擎创建表。不同存储引擎的表数据存取方式不同,支持的功能也不同。
从图中不难看出,不同的存储引擎共用一个 Server 层,也就是从连接器到执行器的部分。你可以先对每个组件的名字有个印象,接下来我会结合开头提到的那条 SQL 语句,带你走一遍整个执行流程,依次看下每个组件的作用。
第一步,你会先连接到这个数据库上,这时候接待你的就是连接器。连接器负责跟客户端建立连接、获取权限、维持和管理连接。连接命令一般是这么写的:
mysql -h$ip -P$port -u$user -p
输完命令之后,你就需要在交互对话里面输入密码。虽然密码也可以直接跟在 -p 后面写在命令行中,但这样可能会导致你的密码泄露。如果你连的是生产服务器,强烈建议你不要这么做。连接命令中的 mysql 是客户端工具,用来跟服务端建立连接。在完成经典的 TCP 握手后,连接器就要开始认证你的身份,这个时候用的就是你输入的用户名和密码。如果用户名或密码不对,你就会收到一个"Access denied for user"的错误,然后客户端程序结束执行。如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置。
连接完成后,如果你没有后续的动作,这个连接就处于空闲状态,你可以在 show processlist 命令中看到它。文本中这个图是 show processlist 的结果,其中的 Command 列显示为“Sleep”的这一行,就表示现在系统里面有一个空闲连接。客户端如果太长时间没动静,连接器就会自动将它断开。这个时间是由参数 wait_timeout 控制的,默认值是 8 小时。如果在连接被断开之后,客户端再次发送请求的话,就会收到一个错误提醒: Lost connection to MySQL server during query。这时候如果你要继续,就需要重连,然后再执行请求了。数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。建立连接的过程通常是比较复杂的,所以我建议你在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。
但是全部使用长连接后,你可能会发现,有些时候 MySQL 占用内存涨得特别快,这是因为 MySQL 在执行过程中临时使用的内存是管理在连接对象里面的。这些资源会在连接断开的时候才释放。所以如果长连接累积下来,可能导致内存占用太大,被系统强行杀掉(OOM),从现象看就是 MySQL 异常重启了。
怎么解决这个问题呢?你可以考虑以下两种方案。
1.定期断开长连接。使用一段时间,或者程序里面判断执行过一个占用内存的大查询后,断开连接,之后要查询再重连。
2.如果你用的是 MySQL 5.7 或更新版本,可以在每次执行一个比较大的操作后,通过执行 mysql_reset_connection 来重新初始化连接资源。这个过程不需要重连和重新做权限验证,但是会将连接恢复到刚刚创建完时的状态。
连接建立完成后,你就可以执行 select 语句了。执行逻辑就会来到第二步:查询缓存。
MySQL 拿到一个查询请求后,会先到查询缓存看看,之前是不是执行过这条语句。之前执行过的语句及其结果可能会以 key-value 对的形式,被直接缓存在内存中。key 是查询的语句,value 是查询的结果。如果你的查询能够直接在这个缓存中找到 key,那么这个 value 就会被直接返回给客户端。
如果语句不在查询缓存中,就会继续后面的执行阶段。执行完成后,执行结果会被存入查询缓存中。你可以看到,如果查询命中缓存,MySQL 不需要执行后面的复杂操作,就可以直接返回结果,这个效率会很高。
但是大多数情况下我会建议你不要使用查询缓存,为什么呢?因为查询缓存往往弊大于利。
查询缓存的失效非常频繁,只要有对一个表的更新,这个表上所有的查询缓存都会被清空。因此很可能你费劲地把结果存起来,还没使用呢,就被一个更新全清空了。对于更新压力大的数据库来说,查询缓存的命中率会非常低。除非你的业务就是有一张静态表,很长时间才会更新一次。比如,一个系统配置表,那这张表上的查询才适合使用查询缓存。
好在 MySQL 也提供了这种“按需使用”的方式。你可以将参数 query_cache_type 设置成 DEMAND,这样对于默认的 SQL 语句都不使用查询缓存。而对于你确定要使用查询缓存的语句,可以用 SQL_CACHE 显式指定,像下面这个语句一样:
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”。
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
经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。
优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。比如你执行下面这样的语句,这个语句是执行两个表的 join:
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 验证权限)。
select * from T where ID=10;
ERROR 1142 (42000): SELECT command denied to user 'b'@'localhost' for table 'T'
如果有权限,就打开表继续执行。打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口。
比如我们这个例子中的表 T 中,ID 字段没有索引,那么执行器的执行流程是这样的:
1.调用 InnoDB 引擎接口取这个表的第一行,判断 ID 值是不是 10,如果不是则跳过,如果是则将这行存在结果集中;
2.调用引擎接口取“下一行”,重复相同的判断逻辑,直到取到这个表的最后一行。
3.执行器将上述遍历过程中所有满足条件的行组成的记录集作为结果集返回给客户端。
至此,这个语句就执行完成了。
对于有索引的表,执行的逻辑也差不多。第一次调用的是“取满足条件的第一行”这个接口,之后循环取“满足条件的下一行”这个接口,这些接口都是引擎中已经定义好的。
你会在数据库的慢查询日志中看到一个 rows_examined 的字段,表示这个语句执行过程中扫描了多少行。这个值就是在执行器每次调用引擎获取数据行的时候累加的。
在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟 rows_examined 并不是完全相同的。
介绍了 MySQL 的逻辑架构,希望对一个 SQL 语句完整执行流程的各个阶段有了一个初步的印象。
如果表 T 中没有字段 k,而执行了这个语句 select * from T where k=1, 那肯定是会报“不存在这个列”的错误: “Unknown column ‘k’ in ‘where clause’”。这个错误是在上面提到的哪个阶段报出来的呢?
答案:分析器。
上面提到,一条查询语句的执行过程一般是经过连接器、分析器、优化器、执行器等功能模块,最后到达存储引擎。
那么,一条更新语句的执行流程又是怎样的呢?
之前你可能经常听 DBA 同事说,MySQL 可以恢复到半个月内任意一秒的状态,惊叹的同时,你是不是心中也会不免会好奇,这是怎样做到的呢?
我们还是从一个表的一条更新语句说起,下面是这个表的创建语句,这个表有一个主键 ID 和一个整型字段 c:
create table T(ID int primary key, c int);
如果要将 ID=2 这一行的值加 1,SQL 语句就会这么写:
update T set c=c+1 where ID=2;
首先,可以确定的说,查询语句的那一套流程,更新语句也是同样会走一遍。
你执行语句前要先连接数据库,这是连接器的工作。
前面说过,在一个表上有更新的时候,跟这个表有关的查询缓存会失效,所以这条语句就会把表 T 上所有缓存结果都清空。这也就是我们一般不建议使用查询缓存的原因。
接下来,分析器会通过词法和语法解析知道这是一条更新语句。优化器决定要使用 ID 这个索引。然后,执行器负责具体执行,找到这一行,然后更新。
与查询流程不一样的是,更新流程还涉及两个重要的日志模块,它们正是我们今天要讨论的主角:redo log(重做日志)和 binlog(归档日志)。如果接触 MySQL,那这两个词肯定是绕不过的。不过话说回来,redo log 和 binlog 在设计上有很多有意思的地方,这些设计思路也可以用到自己的程序里。
不知道你还记不记得《孔乙己》这篇文章,酒店掌柜有一个粉板,专门用来记录客人的赊账记录。如果赊账的人不多,那么他可以把顾客名和账目写在板上。但如果赊账的人多了,粉板总会有记不下的时候,这个时候掌柜一定还有一个专门记录赊账的账本。
如果有人要赊账或者还账的话,掌柜一般有两种做法:
1.一种做法是直接把账本翻出来,把这次赊的账加上去或者扣除掉
2.另一种做法是先在粉板上记下这次的账,等打烊以后再把账本翻出来核算。
在生意红火柜台很忙时,掌柜一定会选择后者,因为前者操作实在是太麻烦了。首先,你得找到这个人的赊账总额那条记录。你想想,密密麻麻几十页,掌柜要找到那个名字,可能还得带上老花镜慢慢找,找到之后再拿出算盘计算,最后再将结果写回到账本上。
这整个过程想想都麻烦。相比之下,还是先在粉板上记一下方便。你想想,如果掌柜没有粉板的帮助,每次记账都得翻账本,效率是不是低得让人难以忍受?
同样,在 MySQL 里也有这个问题,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者就用了类似酒店掌柜粉板的思路来提升更新效率。
而粉板和账本配合的整个过程,其实就是 MySQL 里经常说到的 WAL 技术,WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志,再写磁盘,也就是先写粉板,等不忙的时候再写账本。
具体来说,当有一条记录需要更新的时候,InnoDB 引擎就会先把记录写到 redo log(粉板)里面,并更新内存,这个时候更新就算完成了。同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做,这就像打烊以后掌柜做的事。
如果今天赊账的不多,掌柜可以等打烊后再整理。但如果某天赊账的特别多,粉板写满了,又怎么办呢?这个时候掌柜只好放下手中的活儿,把粉板中的一部分赊账记录更新到账本中,然后把这些记录从粉板上擦掉,为记新账腾出空间。
与此类似,InnoDB 的 redo log 是固定大小的,比如可以配置为一组 4 个文件,每个文件的大小是 1GB,那么这块“粉板”总共就可以记录 4GB 的操作。从头开始写,写到末尾就又回到开头循环写,如下面这个图所示。
write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。write pos 是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。checkpoint 是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。
write pos 和 checkpoint 之间的是“粉板”上还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,表示“粉板”满了,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下。
有了 redo log,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失,这个能力称为 crash-safe。
要理解 crash-safe 这个概念,可以想想我们前面赊账记录的例子。只要赊账记录记在了粉板上或写在了账本上,之后即使掌柜忘记了,比如突然停业几天,恢复生意后依然可以通过账本和粉板上的数据明确赊账账目。
前面我们讲过,MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。上面我们聊到的粉板 redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。
为什么会有两份日志呢?
因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档。而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 使用另外一套日志系统——也就是 redo log 来实现 crash-safe 能力。
这两种日志有以下三点不同。
1.redo log 是 InnoDB 引擎特有的;binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。
2.redo log 是物理日志,记录的是“在某个数据页上做了什么修改”;binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。
3.redo log 是循环写的,空间固定会用完;binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。
有了对这两个日志的概念性理解,我们再来看执行器和 InnoDB 引擎在执行这个简单的 update 语句时的内部流程。
1.执行器先找引擎取 ID=2 这一行。ID 是主键,引擎直接用树搜索找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
2.执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
3.引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
4.执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
5.执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。
这里这个 update 语句的执行流程图,图中浅色框表示是在 InnoDB 内部执行的,深色框表示是在执行器中执行的。
最后三步看上去有点“绕”,将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是"两阶段提交"。
为什么必须有“两阶段提交”呢?这是为了让两份日志之间的逻辑一致。要说明这个问题,我们得从文章开头的那个问题说起:怎样让数据库恢复到半个月内任意一秒的状态?
binlog 会记录所有的逻辑操作,并且是采用“追加写”的形式。如果你的 DBA 承诺说半个月内可以恢复,那么备份系统中一定会保存最近半个月的所有 binlog,同时系统会定期做整库备份。这里的“定期”取决于系统的重要性,可以是一天一备,也可以是一周一备。
当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据:
1.首先,找到最近的一次全量备份,如果运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;
2.然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。
这样临时库就跟误删之前的线上库一样了,然后你可以把表数据从临时库取出来,按需要恢复到线上库去。
好了,说完了数据恢复过程,回来说说,为什么日志需要“两阶段提交”。这里不妨用反证法来进行解释。
由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。
仍然用前面的 update 语句来做例子。假设当前 ID=2 的行,字段 c 的值是 0,再假设执行 update 语句过程中在写完第一个日志后,第二个日志还没有写完期间发生了 crash,会出现什么情况呢?
1.先写 redo log 后写 binlog。假设在 redo log 写完,binlog 还没有写完的时候,MySQL 进程异常重启。由于我们前面说过的,redo log 写完之后,系统即使崩溃,仍然能够把数据恢复回来,所以恢复后这一行 c 的值是 1。但是由于 binlog 没写完就 crash 了,这时候 binlog 里面就没有记录这个语句。因此,之后备份日志的时候,存起来的 binlog 里面就没有这条语句。然后你会发现,如果需要用这个 binlog 来恢复临时库的话,由于这个语句的 binlog 丢失,这个临时库就会少了这一次更新,恢复出来的这一行 c 的值就是 0,与原库的值不同。
2.先写 binlog 后写 redo log。如果在 binlog 写完之后 crash,由于 redo log 还没写,崩溃恢复以后这个事务无效,所以这一行 c 的值是 0。但是 binlog 里面已经记录了“把 c 从 0 改成 1”这个日志。所以,在之后用 binlog 来恢复的时候就多了一个事务出来,恢复出来的这一行 c 的值就是 1,与原库的值不同。
可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致。
可能会说,这个概率是不是很低,平时也没有什么动不动就需要恢复临时库的场景呀?
其实不是的,不只是误操作后需要用这个过程来恢复数据。当你需要扩容的时候,也就是需要再多搭建一些备库来增加系统的读能力的时候,现在常见的做法也是用全量备份加上应用 binlog 来实现的,这个“不一致”就会导致你的线上出现主从数据库不一致的情况。
简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。
绍了 MySQL 里面最重要的两个日志,即物理日志 redo log 和逻辑日志 binlog。
redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都直接持久化到磁盘。这个参数我建议你设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。
sync_binlog 这个参数设置成 1 的时候,表示每次事务的 binlog 都持久化到磁盘。这个参数我也建议你设置成 1,这样可以保证 MySQL 异常重启之后 binlog 不丢失。
介绍了与 MySQL 日志系统密切相关的“两阶段提交”。两阶段提交是跨系统维持数据逻辑一致性时常用的一个方案,即使你不做数据库内核开发,日常开发中也有可能会用到。
定期全量备份的周期“取决于系统重要性,有的是一天一备,有的是一周一备”。那么在什么场景下,一天一备会比一周一备更有优势呢?或者说,它影响了这个数据库系统的哪个指标?
答案:好处是“最长恢复时间”更短。在一天一备的模式里,最坏情况下需要应用一天的 binlog。比如,你每天 0 点做一次全量备份,而要恢复出一个到昨天晚上 23 点的备份。一周一备最坏情况就要应用一周的 binlog 了。
转账过程具体到程序里会有一系列的操作,比如查询余额、做加减法、更新余额等,这些操作必须保证是一体的,不然等程序查完之后,还没做减法之前,你这 100 块钱,完全可以借着这个时间差再查一次,然后再给另外一个朋友转账,如果银行这么整,不就乱了么?这时就要用到“事务”这个概念了。
简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 MySQL 中,事务支持是在引擎层实现的。你现在知道,MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
提到事务,肯定会想到 ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性),今天就来说说其中 I,也就是“隔离性”。当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。
在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable )。下面你解释:
读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
其中“读提交”和“可重复读”比较难理解,所以一个例子说明这几种隔离级别。假设数据表 T 中只有一列,其中一行的值为 1,下面是按照时间顺序执行两个事务的行为。
create table T(c int) engine=InnoDB;
insert into T(c) values(1);
我们来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2、V3 的返回值分别是什么。
若隔离级别是“读未提交”, 则 V1 的值就是 2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。因此,V2、V3 也都是 2。
若隔离级别是“读提交”,则 V1 是 1,V2 的值是 2。事务 B 的更新在提交后才能被 A 看到。所以, V3 的值也是 2。
若隔离级别是“可重复读”,则 V1、V2 是 1,V3 是 2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1、V2 值是 1,V3 的值是 2。
在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图。在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的。这里需要注意的是,“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
可以看到在不同的隔离级别下,数据库行为是有所不同的。Oracle 数据库的默认隔离级别其实就是“读提交”,因此对于一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,一定要记得将 MySQL 的隔离级别设置为“读提交”。
配置的方式是,将启动参数 transaction-isolation 的值设置成 READ-COMMITTED。可以用 show variables 来查看当前的值。
mysql> show variables like 'transaction_isolation';
+-----------------------+----------------+
| Variable_name | Value |
+-----------------------+----------------+
| transaction_isolation | READ-COMMITTED |
+-----------------------+----------------+
总结来说,存在即合理,每种隔离级别都有自己的使用场景,你要根据自己的业务情况来定。我想你可能会问那什么时候需要“可重复读”的场景呢?我们来看一个数据校对逻辑的案例。
假设你在管理一个个人银行账户表。一个表存了账户余额,一个表存了账单明细。到了月底你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。
这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。
理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明“可重复读
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。
同时会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。
一定会问,回滚日志总不能一直保留吧,什么时候删除呢?答案是,在不需要的时候才删除。也就是说,系统会判断,当没有事务再需要用到这些回滚日志时,回滚日志会被删除。
什么时候才不需要了呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。
基于上面的说明,来讨论一下为什么建议尽量不要使用长事务。
长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
在 MySQL 5.5 及以前的版本,回滚日志是跟数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小。我见过数据只有 20GB,而回滚段有 200GB 的库。最终只好为了清理回滚段,重建整个库。
除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库,这个我们会在后面讲锁的时候展开。
如前面所述,长事务有这些潜在风险,我当然是建议你尽量避免。其实很多时候业务开发同学并不是有意使用长事务,通常是由于误用所致。MySQL 的事务启动方式有以下几种:
1.显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
2.set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。
有些客户端连接框架会默认连接成功后先执行一个 set autocommit=0 的命令。这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。
因此,会建议你总是使用 set autocommit=1, 通过显式语句的方式来启动事务。
但是有的开发同学会纠结“多一次交互”的问题。对于一个需要频繁使用事务的业务,第二种方式每个事务在开始时都不需要主动执行一次 “begin”,减少了语句的交互次数。如果你也有这个顾虑,我建议你使用 commit work and chain 语法。
在 autocommit 为 1 的情况下,用 begin 显式启动的事务,如果执行 commit 则提交事务。如果执行 commit work and chain,则是提交事务并自动启动下一个事务,这样也省去了再次执行 begin 语句的开销。同时带来的好处是从程序开发的角度明确地知道每个语句是否处于事务中。
可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
介绍了 MySQL 的事务隔离级别的现象和实现,根据实现原理分析了长事务存在的风险,以及如何用正确的方式避免长事务。望例子能够帮助理解事务,并更好地使用 MySQL 的事务特性。
现在知道了系统里面应该避免长事务,如果你是业务开发负责人同时也是数据库负责人,你会有什么方案来避免出现或者处理这种情况呢?
答案:可以从应用开发端和数据库端来看。
首先,从应用开发端来看:
1.确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
2.确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
3.业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间。(为什么会意外?在后续的文章中会提到这类案例)
其次,从数据库端来看:
1.监控 information_schema.Innodb_trx 表,设置长事务阈值,超过就报警 / 或者 kill;
2.Percona 的 pt-kill 这个工具不错,推荐使用;
3.在业务功能测试阶段要求输出所有的 general_log,分析日志行为提前发现问题;
4.如果使用的是 MySQL 5.6 或者更新版本,把 innodb_undo_tablespaces 设置成 2(或更大的值)。如果真的出现大事务导致回滚段过大,这样设置后清理起来更方便。
一句话简单来说,索引的出现其实就是为了提高数据查询的效率,就像书的目录一样。一本 500 页的书,如果想快速找到其中的某一个知识点,在不借助目录的情况下,那估计可得找一会儿。同样,对于数据库的表而言,索引其实就是它的“目录”。
索引的出现是为了提高查询效率,但是实现索引的方式却有很多种,所以这里也就引入了索引模型的概念。可以用于提高读写效率的数据结构很多,这里介绍三种常见、也比较简单的数据结构,它们分别是哈希表、有序数组和搜索树。
下面我主要从使用的角度,为你简单分析一下这三种模型的区别。
哈希表是一种以键 - 值(key-value)存储数据的结构,我们只要输入待查找的键即 key,就可以找到其对应的值即 Value。哈希的思路很简单,把值放在数组里,用一个哈希函数把 key 换算成一个确定的位置,然后把 value 放在数组的这个位置。
不可避免地,多个 key 值经过哈希函数的换算,会出现同一个值的情况。处理这种情况的一种方法是,拉出一个链表。
假设,你现在维护着一个身份证信息和姓名的表,需要根据身份证号查找对应的名字,这时对应的哈希索引的示意图如下所示:
图中,User2 和 User4 根据身份证号算出来的值都是 N,但没关系,后面还跟了一个链表。假设,这时候你要查 ID_card_n2 对应的名字是什么,处理步骤就是:首先,将 ID_card_n2 通过哈希函数算出 N;然后,按顺序遍历,找到 User2。
需要注意的是,图中四个 ID_card_n 的值并不是递增的,这样做的好处是增加新的 User 时速度会很快,只需要往后追加。但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的。
可以设想下,如果现在要找身份证号在[ID_card_X, ID_card_Y]这个区间的所有用户,就必须全部扫描一遍了。
所以,哈希表这种结构适用于只有等值查询的场景,比如 Memcached 及其他一些 NoSQL 引擎。
而有序数组在等值查询和范围查询场景中的性能就都非常优秀。还是上面这个根据身份证号查名字的例子,如果我们使用有序数组来实现的话,示意图如下所示:
这里我们假设身份证号没有重复,这个数组就是按照身份证号递增的顺序保存的。这时候如果你要查 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 年某个城市的所有人口信息,这类不会再修改的数据。
二叉搜索树也是课本里的经典数据结构了。还是上面根据身份证号查名字的例子,如果我们用二叉搜索树来实现的话,示意图如下所示:
二叉搜索树的特点是:父节点左子树所有结点的值小于父节点的值,右子树所有结点的值大于父节点的值。这样如果你要查 ID_card_n2 的话,按照图中的搜索顺序就是按照 UserA -> UserC -> UserF -> User2 这个路径得到。这个时间复杂度是 O(log(N))。
当然为了维持 O(log(N)) 的查询复杂度,你就需要保持这棵树是平衡二叉树。为了做这个保证,更新的时间复杂度也是 O(log(N))。
树可以有二叉,也可以有多叉。多叉树就是每个节点有多个儿子,儿子之间的大小保证从左到右递增。二叉树是搜索效率最高的,但是实际上大多数的数据库存储却并不使用二叉树。其原因是,索引不止存在内存中,还要写到磁盘上。
可以想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块。在机械硬盘时代,从磁盘随机读一个数据块需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。
为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少的数据块。那么,我们就不应该使用二叉树,而是要使用“N 叉”树。这里,“N 叉”树中的“N”取决于数据块的大小。
以 InnoDB 的一个整数字段索引为例,这个 N 差不多是 1200。这棵树高是 4 的时候,就可以存 1200 的 3 次方个值,这已经 17 亿了。考虑到树根的数据块总是在内存中的,一个 10 亿行的表上一个整数字段的索引,查找一个值最多只需要访问 3 次磁盘。其实,树的第二层也有很大概率在内存中,那么访问磁盘的平均次数就更少了。
N 叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。
不管是哈希还是有序数组,或者 N 叉树,它们都是不断迭代、不断优化的产物或者解决方案。数据库技术发展到今天,跳表、LSM 树等数据结构也被用于引擎设计中,这里我就不再一一展开了。
心里要有个概念,数据库底层存储的核心就是基于这些数据模型的。每碰到一个新数据库,我们需要先关注它的数据模型,这样才能从理论上分析出这个数据库的适用场景。
截止到这里,用了半篇文章的篇幅和你介绍了不同的数据结构,以及它们的适用场景,可能会觉得有些枯燥。但是,建议还是要多花一些时间来理解这部分内容,毕竟这是数据库处理数据的核心概念之一,在分析问题的时候会经常用到。当理解了索引的模型后,就会发现在分析问题的时候会有一个更清晰的视角,体会到引擎设计的精妙之处。
现在,一起进入相对偏实战的内容吧。
在 MySQL 中,索引是在存储引擎层实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。而即使多个存储引擎支持同一种类型的索引,其底层的实现也可能不同。由于 InnoDB 存储引擎在 MySQL 数据库中使用最为广泛,所以下面我就以 InnoDB 为例,和你分析一下其中的索引模型。
在 InnoDB 中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表。又因为前面我们提到的,InnoDB 使用了 B+ 树索引模型,所以数据都是存储在 B+ 树中的。
每一个索引在 InnoDB 里面对应一棵 B+ 树。
假设,我们有一个主键列为 ID 的表,表中有字段 k,并且在 k 上有索引。
这个表的建表语句是:
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),两棵树的示例示意图如下。
从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。
主键索引的叶子节点存的是整行数据。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。
非主键索引的叶子节点内容是主键的值。在 InnoDB 里,非主键索引也被称为二级索引(secondary index)。
根据上面的索引结构说明,我们来讨论一个问题:基于主键索引和普通索引的查询有什么区别?
如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树;
如果语句是 select * from T where k=5,即普通索引查询方式,则需要先搜索 k 索引树,得到 ID 的值为 500,再到 ID 索引树搜索一次。这个过程称为回表。
也就是说,基于非主键索引的查询需要多扫描一棵索引树。因此,我们在应用中应该尽量使用主键查询。
B+ 树为了维护索引有序性,在插入新值的时候需要做必要的维护。以上面这个图为例,如果插入新的行 ID 值为 700,则只需要在 R5 的记录后面插入一个新记录。如果新插入的 ID 值为 400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。
而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然会受影响。
除了性能外,页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在分到两个页中,整体空间利用率降低大约 50%。
当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。基于上面的索引维护过程说明,来讨论一个案例:可能在一些建表规范里面见到过类似的描述,要求建表语句里一定要有自增主键。当然事无绝对,来分析一下哪些场景下应该使用自增主键,而哪些场景下不应该。
自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。
插入新记录的时候可以不指定 ID 的值,系统会获取当前 ID 最大值加 1 作为下一条记录的 ID 值。
也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作,都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
除了考虑性能外,我们还可以从存储空间的角度来看。假设你的表中确实有一个唯一字段,比如字符串类型的身份证号,那应该用身份证号做主键,还是用自增字段做主键呢?
由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约 20 个字节,而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。
显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。
所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:
1.只有一个索引;
2.该索引必须是唯一索引。
一定看出来了,这就是典型的 KV 场景。由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。
这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。
分析了数据库引擎可用的数据结构,介绍了 InnoDB 采用的 B+ 树结构,以及为什么 InnoDB 要这么选择。B+ 树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。
由于 InnoDB 是索引组织表,一般情况下会建议创建一个自增主键,这样非主键索引占用的空间最小。但事无绝对,讨论了使用业务逻辑字段做主键的应用场景。
对于上面例子中的 InnoDB 表 T,如果你要重建索引 k,你的两个 SQL 语句可以这么写:
alter table T drop index k;
alter table T add index(k);
如果你要重建主键索引,也可以这么写:
alter table T drop index k;
alter table T add index(k);
对于上面这两个重建索引的作法,说出你的理解。如果有不合适的,为什么,更好的方法是什么?
答案:重建索引k的做法是合理的,可以达到省空间的目的。但是,重建主键的过程不合理。不论是删 除主键还是创建主键,都会将整个表重建。所以连着执行这两个语句的话,第一个语句就白做 了。这两个语句,你可以用这个语句代替 : alter table T engine=InnoDB。
在下面这个表T中,如果执行 select * from T where k between 3 and 5,需要执行几次树的搜 索操作,会扫描多少行?
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,'g');
一起来看看这条SQL查询语句的执行流程:
1.在k索引树上找到k=3的记录,取得 ID = 300;
2.再到ID索引树查到ID=300对应的R3;
3.在k索引树取下一个值k=5,取得ID=500;
4.再回到ID索引树查到ID=500对应的R4;
5.在k索引树取下一个值k=6,不满足条件,循环结束。
在这个过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了k
索引树的3条记录(步骤1、3和5),回表了两次(步骤2和4)。
在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,有没有
可能经过索引优化,避免回表过程呢?
如果执行的语句是select ID from T where k between 3 and 5,这时只需要查ID的值,而ID的值已经在k索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面, 索引k已经“覆盖了”我们的查询需求,我们称为覆盖索引。
由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用 的性能优化手段。
需要注意的是,在引擎内部使用覆盖索引在索引k上其实读了三个记录,R3~R5(对应的索引k 上的记录项),但是对于MySQL的Server层来说,它就是找引擎拿到了两条记录,因此MySQL 认为扫描行数是2。
基于上面覆盖索引的说明,我们来讨论一个问题:在一个市民信息表上,是否有必要将身份 证号和名字建立联合索引?
假设这个市民表的定义是这样的:
CREATE TABLE `tuser` (
`id` int(11) NOT NULL,
`id_card` varchar(32) DEFAULT NULL,
`name` varchar(32) DEFAULT NULL,
`age` int(11) DEFAULT NULL,
`ismale` tinyint(1) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `id_card` (`id_card`),
KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB
身份证号是市民的唯一标识。也就是说,如果有根据身份证号查询市民信息的需求, 我们只要在身份证号字段上建立索引就够了。而再建立一个(身份证号、姓名)的联合索引,是 不是浪费空间?
如果现在有一个高频请求,要根据市民的身份证号查询他的姓名,这个联合索引就有意义了。它 可以在这个高频请求上用到覆盖索引,不再需要回表查整行记录,减少语句的执行时间。
当然,索引字段的维护总是有代价的。因此,在建立冗余索引来支持覆盖索引时就需要权衡考虑 了。这正是业务DBA,或者称为业务数据架构师的工作。
看到这里一定有一个疑问,如果为每一种查询都设计一个索引,索引是不是太多了。如果我现 在要按照市民的身份证号去查他的家庭地址呢?虽然这个查询需求在业务中出现的概率不高,但 总不能让它走全表扫描吧?反过来说,单独为一个不频繁的请求创建一个(身份证号,地址)的 索引又感觉有点浪费。应该怎么做呢?
B+树这种索引结构,可以利用索引的“最左前缀”,来定位记录。 为了直观地说明这个概念,我们用(name,age)这个联合索引来分析。
可以看到,索引项是按照索引定义里面出现的字段顺序排序的。
当逻辑需求是查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有 需要的结果。
如果要查的是所有名字第一个字是“张”的人,你的SQL语句的条件是"where name like ‘张%’"。这时,也能够用上这个索引,查找到第一个符合条件的记录是ID3,然后向后遍历, 直到不满足条件为止。
可以看到,不只是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左 前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。
基于上面对最左前缀索引的说明,我们来讨论一个问题:在建立联合索引的时候,如何安排索 引内的字段顺序。
这里评估标准是,索引的复用能力。因为可以支持最左前缀,所以当已经有了(a,b)这个联 合索引后,一般就不需要单独在a上建立索引了。因此,第一原则是,如果通过调整顺序,可 以少维护一个索引,那么这个顺序往往就是需要优先考虑采用的。
所以现在知道了,这段开头的问题里,要为高频请求创建(身份证号,姓名)这个联合索 引,并用这个索引支持“根据身份证号查询地址”的需求。
那么,如果既有联合查询,又有基于a、b各自的查询呢?查询条件里面只有b的语句,是无法使 用(a,b)这个联合索引的,这时候不得不维护另外一个索引,也就是说你需要同时维护(a,b)、 (b) 这两个索引。
这时候,要考虑的原则就是空间了。比如上面这个市民表的情况,name字段是比age字段 大的 ,那就建议创建一个(name,age)的联合索引和一个(age)的单字段索引。
上一段我们说到满足最左前缀原则的时候,最左前缀可以用于在索引中定位记录。这时,你可能 要问,那些不符合最左前缀的部分,会怎么样呢?
还是以市民表的联合索引(name, age)为例。如果现在有一个需求:检索出表中“名字第一 个字是张,而且年龄是10岁的所有男孩”。那么,SQL语句是这么写的:
mysql> select * from tuser where name like '张%' and age=10 and ismale=1;
已经知道了前缀索引规则,所以这个语句在搜索索引树的时候,只能用 “张”,找到第一个满足 条件的记录ID3。当然,这还不错,总比全表扫描要好。
然后呢?当然是判断其他条件是否满足。
在MySQL 5.6之前,只能从ID3开始一个个回表。到主键索引上找出数据行,再对比字段值。
而MySQL 5.6 引入的索引下推优化(index condition pushdown), 可以在索引遍历过程中,对索 引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数。
图3和图4,是这两个过程的执行流程图。
在图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;
这位同事的解释对吗,为了这两个查询模式,这两个索引是否都是必须的?为什么呢?
答案:
表记录
–a–|–b–|–c–|–d-- 123d
132d
143d
213d
222d
234d
主键 a,b的聚簇索引组织顺序相当于 order by a,b ,也就是先按a排序,再按b排序,c无序。
索引 ca 的组织是先按c排序,再按a排序,同时记录主键 –c–|–a–|–主键部分b-- (注意,这里不是ab,而是只有b) 213
222
312
314
321
423 这个跟索引c的数据是一模一样的。
索引 cb 的组织是先按c排序,在按b排序,同时记录主键 –c–|–b–|–主键部分a-- (同上)
222
231
312 321 341 432
所以,结论是ca可以去掉,cb需要保留。
数据库锁设计的初衷是处理并发问题。作为多用户共享的资 源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则。而锁就是用来实现这些访 问规则的重要数据结构。
根据加锁的范围,MySQL里面的锁大致可以分成全局锁、表级锁和行锁三类。今天这篇文 章,我会和你分享全局锁和表级锁。
这里需要说明的是,锁的设计比较复杂,这两篇文章不会涉及锁的具体实现细节,主要介绍的是 碰到锁时的现象和其背后的原理。
顾名思义,全局锁就是对整个数据库实例加锁。MySQL提供了一个加全局读锁的方法,命令是 Flush tables with read lock (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命 令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括 建表、修改表结构等)和更新类事务的提交语句。
全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都select出来存成文本。
以前有一种做法,是通过FTWRL确保不会有其他线程对数据库做更新,然后对整个库做备份。 注意,在备份过程中整个库完全处于只读状态。
但是让整库都只读,听上去就很危险:
1.如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
2.如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。
看来加全局锁不太好。但是细想一下,备份为什么要加锁呢?我们来看一下不加锁会有什么问
题。
假设现在要维护一个系统的购买系统,关注的是用户账户余额表和用户课程表。
现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉 他的余额,然后往已购课程里面加上一门课。
现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉 他的余额,然后往已购课程里面加上一门课。
如果时间顺序上是先备份账户余额表(u_account),然后用户购买,然后备份用户课程表 (u_course),会怎么样呢?可以看一下这个图:
可以看到,这个备份结果里,用户A的数据状态是“账户余额没扣,但是用户课程表里面已经多了
一门课”。如果后面用这个备份来恢复数据的话,用户A就发现,自己赚了。
作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,又可能会出现什么结果?
也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致 的。
说到视图你肯定想起来了,我们在前面讲事务隔离的时候,其实是有一个方法能够拿到一致性视 图的,对吧?
是的,就是在可重复读隔离级别下开启一个事务。官方自带的逻辑备份工具是mysqldump。当mysqldump使用参数–single-transaction的时候,导 数据之前就会启动一个事务,来确保拿到一致性视图。而由于MVCC的支持,这个过程中数据是 可以正常更新的。
你一定在疑惑,有了这个功能,为什么还需要FTWRL呢?一致性读是好,但前提是引擎要支 持这个隔离级别。比如,对于MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是 只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用FTWRL命令了。
所以,single-transaction方法只适用于所有的表使用事务引擎的库。如果有的表使用了不 支持事务的引擎,那么备份就只能通过FTWRL方法。这往往是DBA要求业务开发人员使用 InnoDB替代MyISAM的原因之一。
你也许会问,既然要全库只读,为什么不使用set global readonly=true的方式呢?确实 readonly方式也可以让全库进入只读状态,但我还是会建议你用FTWRL方式,主要有两个原 因:
一是,在有些系统中,readonly的值会被用来做其他逻辑,比如用来判断一个库是主库还是备 库。因此,修改global变量的方式影响面更大,我不建议你使用。
二是,在异常处理机制上有差异。如果执行FTWRL命令之后由于客户端发生异常断开,那么 MySQL会自动释放这个全局锁,整个库回到可以正常更新的状态。而将整个库设置为 readonly之后,如果客户端发生异常,则数据库就会一直保持readonly状态,这样会导致整个 库长时间处于不可写状态,风险较高。
业务的更新不只是增删改数据(DML),还有可能是加字段等修改表结构的操作(DDL)。不论 是哪种方法,一个库被全局锁上以后,你要对里面任何一个表做加字段操作,都是会被锁住的。
但是,即使没有被全局锁住,加字段也不是就能一帆风顺的,因为你还会碰到接下来我们要介绍 的表级锁。
MySQL里面表级别的锁有两种:一种是表锁,一种是元数据锁(meta data lock,MDL)。
表锁的语法是 lock tables …read/write。与FTWRL类似,可以用unlock tables主动释放锁, 也可以在客户端断开的时候自动释放。需要注意,lock tables语法除了会限制别的线程的读写 外,也限定了本线程接下来的操作对象。
举个例子, 如果在某个线程A中执行lock tables t1 read, t2 write; 这个语句,则其他线程写t1、读 写t2的语句都会被阻塞。同时,线程A在执行unlock tables之前,也只能执行读t1、读写t2的操 作。连写t1都不允许,自然也不能访问其他表。
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持 行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。
另一类表级的锁是MDL(metadata lock)。MDL不需要显式使用,在访问一个表的时候会被 自动加上。MDL的作用是,保证读写的正确性。你可以想象一下,如果一个查询正在遍历一个 表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果 跟表结构对不上,肯定是不行的。
因此,在MySQL 5.5版本中引入了MDL,当对一个表做增删改查操作的时候,加MDL读锁;当 要对表做结构变更操作的时候,加MDL写锁。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。
读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性。因此,如果有两个线 程要同时给一个表加字段,其中一个要等另一个执行完才能开始执行。
虽然MDL锁是系统默认会加的,但却是你不能忽略的一个机制。比如下面这个例子,我经常看 到有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。
你肯定知道,给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操 作的时候,你肯定会特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也 会出问题。我们来看一下下面的操作序列,假设表t是一个小表。
备注:这里的实验环境是MySQL 5.6。
我们可以看到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 ...
全局锁主要用在逻辑备份过程中。对于全部是InnoDB引擎的库,我建议你选择使用–single-
transaction参数,对应用会更友好。
表锁一般是在数据库引擎不支持行锁的时候才会被用到的。如果你发现你的应用程序里有lock
tables这样的语句,你需要追查一下,比较可能的情况是:
要么是你的系统现在还在用MyISAM这类不支持事务的引擎,那要安排升级换引擎;
要么是你的引擎升级了,但是代码还没升级。我见过这样的情况,最后业务开发就是把lock tables 和 unlock tables 改成 begin 和 commit,问题就解决了。
MDL会直到事务提交才释放,在做表结构变更的时候,你一定要小心不要导致锁住线上查询和 更新
备份一般都会在备库上执行,你在用–single-transaction方法做逻 辑备份的过程中,如果主库上的一个小表做了一个DDL,比如给一个表上加了一列。这时候,从 备库上会看到什么现象呢?
答案:当备库用–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);
启动事务,这里用 WITH CONSISTENT SNAPSHOT确保这个语句执行完就可以得到一个一致性
视图(Q2); 设置一个保存点,这个很重要(Q3);
show create 是为了拿到表结构(Q4),然后正式导数据 (Q5),回滚到SAVEPOINT sp,在这 里的作用是释放 t1的MDL锁 (Q6。当然这部分属于“超纲”,上文正文里面都没提到。
DDL从主库传过来的时间按照效果不同,我打了四个时刻。题目设定为小表,我们假定到达后, 如果开始执行,则很快能够执行完成。
参考答案如下:
1.如果在Q4语句执行之前到达,现象:没有影响,备份拿到的是DDL后的表结构。
2.如果在“时刻 2”到达,则表结构被改过,Q5执行的时候,报 Table definition has changed, please retry transaction,现象:mysqldump终止;
3.如果在“时刻2”和“时刻3”之间到达,mysqldump占着t1的MDL读锁,binlog被阻塞,现象: 主从延迟,直到Q6执行完成。
4.从“时刻4”开始,mysqldump释放了MDL读锁,现象:没有影响,备份拿到的是DDL前的表 结构。
MySQL的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁,对于这种引擎的表,同 一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB是支持行锁的, 这也是MyISAM被InnoDB替代的重要原因之一。
今天就主要来聊聊InnoDB的行锁,以及如何通过减少锁冲突来提升业务并发度。
顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务A更新了一行,而这时候
事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。
当然,数据库中还有一些没那么一目了然的概念和设计,这些概念如果理解和使用不当,容易导
致程序出现非预期行为,比如两阶段锁。
在下面的操作序列中,事务B的update语句执行时会是什么现象呢?假设字 段id是表t的主键。
这个问题的结论取决于事务A在执行完两条update语句后,持有哪些锁,以及在什么时候释放。 你可以验证一下:实际上事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才 能继续执行。
知道了这个答案,你一定知道了事务A持有的两个记录的行锁,都是在commit的时候才释放的。
也就是说,在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
知道了这个设定,对我们使用事务有什么帮助呢?那就是,如果你的事务中需要锁多个行,要把 最可能造成锁冲突、最可能影响并发度的锁尽量往后放。我给你举个例子。
假设你负责实现一个电影票在线交易业务,顾客A要在影院B购买电影票。我们简化一点,这个 业务需要涉及到以下操作:
1.从顾客A账户余额中扣除电影票价
2.给影院B的账户余额增加这张电影票价;
3.记录一条交易日志。
也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。那么,你会怎样安排这三个语句在事务中的 顺序呢?
试想如果同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了。因为 它们要更新同一个影院账户的余额,需要修改同一行数据。
根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才 释放的。所以,如果你把语句2安排在最后,比如按照3、1、2这样的顺序,那么影院账户余额 这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。
好了,现在由于你的正确设计,影院余额这一行的行锁在一个事务中不会停留很长时间。但是, 这并没有完全解决你的困扰。
如果这个影院做活动,可以低价预售一年内所有的电影票,而且这个活动只做一天。于是在活动 时间开始的时候,你的MySQL就挂了。你登上服务器一看,CPU消耗接近100%,但整个数据库 每秒就执行不到100个事务。这是什么原因呢?这里,我就要说到死锁和死锁检测了。
当并发系统中不同线程出现循环资源依赖,涉及的线程都在等待别的线程释放资源时,就会导致 这几个线程都进入无限等待的状态,称为死锁。这里我用数据库中的行锁举个例子。
这时候,事务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。
答案:第二种方式是相对较好的。
第一种方式(即:直接执行delete from T limit 10000)里面,单个语句占用时间长,锁的时间也 比较长;而且大事务还会导致主从延迟。
第三种方式(即:在20个连接中同时执行delete from T limit 500),会人为造成锁冲突。
讲事务隔离级别的时候提到过,如果是可重复读隔离级别,事务T启动的时 候会创建一个视图read-view,之后事务T执行期间,即使有其他事务修改了数据,事务T看到的 仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下执行的事务,好像与世无 争,不受外界影响。
但是,在上一篇文章中,分享行锁的时候又提到,一个事务要更新一行,如果刚好有另外 一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进 入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?
举一个例子吧。下面是一个只有两行的表的初始化语句。
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);
这里,我们需要注意的是事务的启动时机。
begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作InnoDB表 的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用start transaction with consistent snapshot 这个命令。
还需要注意的是,例子中如果没有特别说明,都是默认 autocommit=1。
在这个例子中,事务C没有显式地使用begin/commit,表示这个update语句本身就是一个事务, 语句完成的时候会自动提交。事务B在更新了行之后查询; 事务A在一个只读事务中查询,并且时 间顺序上是在事务B的查询之后。
这时,如果事务B查到的k的值是3,而事务A查到的k的值是1,是不是感觉有点晕呢?
在MySQL里,有两个“视图”的概念:
一个是view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。 创建视图的语法是create view …,而它的查询方法与表一样。
另一个是InnoDB在实现MVCC时用到的一致性读视图,即consistent read view,用于支持RC(Read Committed,读提交)和RR(Repeatable Read,可重复读)隔离级别的实现。
它没有物理结构,作用是事务执行期间用来定义“我能看到什么数据”。
今天为了说明查询和更新的区别,我换一个方式来说明,把read view拆开。你可以结合这 两篇文章的说明来更深一步地理解MVCC。
在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。
这时,你会说这看上去不太现实啊。如果一个库有100G,那么我启动一个事务,MySQL就要拷 贝100G的数据出来,这个过程得多慢啊。可是,我平时的事务执行起来很快啊。
实际上,我们并不需要拷贝出这100G的数据。我们先来看看这个快照是怎么实现的。
InnoDB里面每个事务有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向
InnoDB的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且 把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时,旧的数据版本要保留, 并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本(row),每个版本有自己的row trx_id。
如图2所示,就是一个记录被多个事务连续更新后的状态。
图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id
为25的事务更新的,因此它的row trx_id也是25。
你可能会问,前面的文章不是说,语句更新会生成undo log(回滚日志)吗?那么,undo log
在哪呢?
实际上,图2中的三个虚线箭头,就是undo log;而V1、V2、V3并不是物理上真实存在的,而 是每次需要的时候根据当前版本和undo log计算出来的。比如,需要V2的时候,就是通过V4依 次执行U3、U2算出来。
明白了多版本和row trx_id的概念后,我们再来想一下,InnoDB是怎么定义那个“100G”的快照 的。
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这 个事务执行期间,其他事务的更新对它不可见。
因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我 启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版 本”。
当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数 据,它自己还是要认的。
在实现上, InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活 跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。
数组里面事务ID的最小值记为低水位,当前系统里面已经创建过的事务ID的最大值加1记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
而数据版本的可见性规则,就是基于数据的row trx_id和这个一致性视图的对比结果得到的。
这个视图数组把所有的row trx_id 分成了几种不同的情况。
这样,对于当前事务的启动瞬间来说,一个数据版本的row trx_id,有以下几种可能:
1.如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是 可见的;
2.如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
3.如果落在黄色部分,那就包括两种情况
a.若row trx_id在数组中,表示这个版本是由还没提交的事务生成的,不可见;
b.若row trx_id不在数组中,表示这个版本是已经提交了的事务生成的,可见。
比如,对于图2中的数据来说,如果有一个事务,它的低水位是18,那么当它访问这一行数据 时,就会从V4通过U3计算出V3,所以在它看来,这一行的值是11。
有了这个声明后,系统里面随后发生的更新,是不是就跟这个事务看到的内容无关了呢? 因为之后的更新,生成的版本一定属于上面的2或者3(a)的情况,而对它来说,这些新的数据版 本是不存在的,所以这个事务的快照,就是“静态”的了。
所以现在知道了,InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建 快照”的能力。
接下来,我们继续看一下图1中的三个事务,分析下事务A的语句返回的结果,为什么是k=1。
这里,我们不妨做如下假设:
1.事务A开始前,系统里面只有一个活跃事务ID是99;
2.事务A、B、C的版本号分别是100、101、102,且当前系统里只有这四个事务;
3.三个事务开始前,(1,1)这一行数据的rowtrx_id是90。
这样,事务A的视图数组就是[99,100], 事务B的视图数组是[99,100,101], 事务C的视图数组是 [99,100,101,102]。
为了简化分析,我先把其他干扰语句去掉,只画出跟事务A查询逻辑有关的操作:
从图中可以看到,第一个有效更新是事务C,把数据从(1,1)改成了(1,2)。这时候,这个数据的最
新版本的row trx_id是102,而90这个版本已经成为了历史版本。
第二个有效更新是事务B,把数据从(1,2)改成了(1,3)。这时候,这个数据的最新版本(即row
trx_id)是101,而102又成为了历史版本。
你可能注意到了,在事务A查询的时候,其实事务B还没有提交,但是它生成的(1,3)这个版本已
经变成当前版本了。但这个版本对事务A必须是不可见的,否则就变成脏读了。
好,现在事务A要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起
的。所以,事务A查询语句的读数据流程是这样的:
找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;
接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;
再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。
这样执行下来,虽然期间这一行数据被修改过,但是事务A不论在什么时候查询,看到这行数据 的结果都是一致的,所以我们称之为一致性读。
这个判断规则是从代码逻辑直接转译过来的,但是正如你所见,用于人肉分析可见性很麻烦。
所以,我来给你翻译一下。一个数据版本,对于一个事务视图来说,除了自己的更新总是可见以 外,有三种情况:
1.版本未提交,不可见;
2.版本已提交,但是是在视图创建后提交的,不可见;
3.版本已提交,而且是在视图创建前提交的,可见。
现在,我们用这个规则来判断图4中的查询结果,事务A的查询语句的视图数组是在事务A启动的 时候生成的,这时候:
(1,3)还没提交,属于情况1,不可见;
(1,2)虽然提交了,但是是在视图数组创建之后提交的,属于情况2,不可见;
(1,1)是在视图数组创建之前提交的,可见。
你看,去掉数字对比后,只用时间先后顺序来判断,分析起来是不是轻松多了。所以,后面我们 就都用这个规则来分析。
细心的同学可能有疑问了:事务B的update语句,如果按照一致性读,好像结果不对哦?
你看图5中,事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能 算出(1,3)来?
是的,如果事务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锁,排他锁)。
select k from t where id=1 lock in share mode;
select k from t where id=1 for update;
再往前一步,假设事务C不是马上提交的,而是变成了下面的事务C’,会怎么样呢?
事务C’的不同是,更新后并没有马上提交,在它提交前,事务B的更新语句先发起了。前面说过
了,虽然事务C’还没提交,但是(1,2)这个版本也已经生成了,并且是当前的最新版本。那么,事 务B的更新语句会怎么处理呢?
这时候,我们在上一篇文章中提到的“两阶段锁协议”就要上场了。事务C’没提交,也就是说(1,2) 这个版本上的写锁还没释放。而事务B是当前读,必须要读最新版本,而且必须加锁,因此就被 锁住了,必须等到事务C’释放这个锁,才能继续它的当前读。
到这里,我们把一致性读、当前读和行锁就串起来了。
现在,我们再回到文章开头的问题:事务的可重复读的能力是怎么实现的? 可重复读的核心就是一致性读(consistent read);而事务更新数据的时候,只能用当前读。如
果当前的记录的行锁被其他事务占用的话,就需要进入锁等待。 而读提交的逻辑和可重复读的逻辑类似,它们最主要的区别是:
在可重复读隔离级别下,只需要在事务开始的时候创建一致性视图,之后事务里的其他查询 都共用这个一致性视图;
在读提交隔离级别下,每一个语句执行前都会重新算出一个新的视图。
那么,我们再看一下,在读提交隔离级别下,事务A和事务B的查询语句查到的k,分别应该是多少呢?
这里需要说明一下,“start transaction with consistent snapshot; ”的意思是从这个语句开始,创 建一个持续整个事务的一致性快照。所以,在读提交隔离级别下,这个用法就没意义了,等效于 普通的start transaction。
下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图 中的read view框。(注意:这里,我们用的还是事务C的逻辑直接提交,而不是事务C’)
这时,事务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值清零,但是却发现了一个“诡异”的、改不掉的情 况。请构造出这种情况,并说明其原理。
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);
复现出来以后,请再思考一下,在实际的业务开发中有没有可能碰到这种情况?应用代码会不会掉进这个“坑”里,又是怎么解决的呢?
问题是:如何构造一个“数据无法修改”的场景。评论区里已经有不少同学给出了正确答 案,这里我再描述一下。
这样,session A看到的就是我截图的效果了。
其实,还有另外一种场景,同学们在留言区都还没有提到。
这个操作序列跑出来,session A看的内容也是能够复现我截图的效果的。这个session B’启动的 事务比A要早,其实是上期我们描述事务版本的可见性规则时留的彩蛋,因为规则里还有一个“活 跃事务的判断”,我是准备留到这里再补充的。
在不同的业务场景下,应该选择普通索引,还是唯一索引?假设在维护一个市民系统,每个人都有一个唯一的身份证号,而且业务代码已经保证了不会写 入两个重复的身份证号。如果市民系统需要按照身份证号查姓名,就会执行类似这样的SQL语 句:
select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz';
所以一定会考虑在id_card字段上建索引。由于身份证号字段比较大,不建议把身份证号当做主键,那么现在有两个选择,要么给 id_card字段创建唯一索引,要么创建一个普通索引。如果业务代码已经保证了不会写入重复的 身份证号,那么这两个选择逻辑上都是正确的。
从性能的角度考虑,选择唯一索引还是普通索引呢?选择的依据是什么呢?假设字段 k 上的值 都不重复。
接下来,我们就从这两种索引对查询语句和更新语句的性能影响来进行分析。
假设,执行查询的语句是 select id from T where k=5。这个查询语句在索引树上查找的过程,先 是通过B+树从树根开始,按层搜索到叶子节点,也就是图中右下角的这个数据页,然后可以认 为数据页内部通过二分法来定位记录。
对于普通索引来说,查找到满足条件的第一个记录(5,500)后,需要查找下一个记录,直到碰 到第一个不满足k=5条件的记录。
对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继 续检索。
那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。
InnoDB的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候, 并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每 个数据页的大小默认是16KB。
因为引擎是按页读写的,所以说,当找到k=5的记录的时候,它所在的数据页就都在内存里了。 那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针 寻找和一次计算。
当然,如果k=5这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下 一个数据页,这个操作会稍微复杂一些。
但是,我们之前计算过,对于整型字段,一个数据页可以放近千个key,因此出现这种情况的概 率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的CPU来说可以 忽略不计。
为了说明普通索引和唯一索引对更新语句性能的影响这个问题,我需要先跟你介绍一下change buffer。
当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中 的话,在不影响数据一致性的前提下,InooDB会将这些更新操作缓存在change buffer中,这样 就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内 存,然后执行change buffer中与这个页有关的操作。通过这种方式就能保证这个数据逻辑的正 确性
需要说明的是,虽然名字叫作change buffer,实际上它是可以持久化的数据。也就是 说,change buffer在内存中有拷贝,也会被写入到磁盘上。
将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge。除了访问这个数据 页会触发merge外,系统有后台线程会定期merge。在数据库正常关闭(shutdown)的过程中,
也会执行merge操作。
显然,如果能够将更新操作先记录在change buffer,减少读磁盘,语句的执行速度会得到明显 的提升。而且,数据读入内存是需要占用buffer pool的,所以这种方式还能够避免占用内存,提 高内存利用率。
那么,什么条件下可以使用change buffer呢?
对于唯一索引来说,所有的更新操作都要先判断这个操作是否违反唯一性约束。比如,要插入 (4,400)这个记录,就要先判断现在表中是否已经存在k=4的记录,而这必须要将数据页读入内存 才能判断。如果都已经读入到内存了,那直接更新内存会更快,就没必要使用change buffer 了。
因此,唯一索引的更新就不能使用change buffer,实际上也只有普通索引可以使用。
change buffer用的是buffer pool里的内存,因此不能无限增大。change buffer的大小,可以通 过参数innodb_change_buffer_max_size来动态设置。这个参数设置为50的时候,表示change buffer的大小最多只能占用buffer pool的50%。
现在,你已经理解了change buffer的机制,那么我们再一起来看看如果要在这张表中插入一个 新记录(4,400)的话,InnoDB的处理流程是怎样的。
第一种情况是,这个记录要更新的目标页在内存中。这时,InnoDB的处理流程如下:
对于唯一索引来说,找到3和5之间的位置,判断到没有冲突,插入这个值,语句执行结束;
对于普通索引来说,找到3和5之间的位置,插入这个值,语句执行结束。
这样看来,普通索引和唯一索引对更新语句性能影响的差别,只是一个判断,只会耗费微小的
CPU时间。
但,这不是我们关注的重点。
第二种情况是,这个记录要更新的目标页不在内存中。这时,InnoDB的处理流程如下:
对于唯一索引来说,需要将数据页读入内存,判断到没有冲突,插入这个值,语句执行结束
对于普通索引来说,则是将更新记录在change buffer,语句执行就结束了。
将数据从磁盘读入内存涉及随机IO的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问,所以对更新性能的提升是会很明显的。
之前就碰到过一件事儿,有个DBA的同学反馈说,负责的某个业务的库内存命中率突 然从99%降低到了75%,整个系统处于阻塞状态,更新语句全部堵住。而探究其原因后,发现这个业务有大量插入数据的操作,而在前一天把其中的某个普通索引改成了唯一索引。
通过上面的分析,你已经清楚了使用change buffer对更新过程的加速作用,也清楚了change buffer只限于用在普通索引的场景下,而不适用于唯一索引。那么,现在有一个问题就是:普通 索引的所有场景,使用change buffer都可以起到加速作用吗?
因为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和WAL。在前面文章的评论中有同学混淆了redo log和change buffer。WAL 提升性能的核心机 制,也的确是尽量减少随机读写,这两个概念确实容易混淆。所以,这里把它们放到了同一个 流程里来说明,便于区分这两个概念。
现在,我们要在表上执行这个插入语句:
insert into t(id,k) values (id1,k1),(id2,2);
这里,我们假设当前k索引树的状态,查找到位置后,k1所在的数据页在内存(InnoDB buffer pool)中,k2所在的数据页不在内存中。如图2所示是带change buffer的更新状态图。
分析这条更新语句,你会发现它涉及了四个部分:内存、redo log(ib_log_fileX)、 数据表空间
(t.ibd)、系统表空间(ibdata1)。
这条更新语句做了如下的操作(按照图中的数字顺序):
1.Page1在内存中,直接更新内存;
2.Page2没有在内存中,就在内存的changebuffer区域,记录下“我要往Page2插入一行”这个 信息
3.将上述两个动作记入redolog中(图中3和4)。
做完上面这些,事务就可以完成了。所以,你会看到,执行这条更新语句的成本很低,就是写了
两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。
同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。
那在这之后的读请求,要怎么处理呢?
比如,我们现在要执行 select * from t where k in (k1, k2)。这里,我画了这两个读请求的流程 图。
如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表 空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,我在图中就没画出这两部分。
从图中可以看到:
1.读Page1的时候,直接从内存返回。有几位同学在前面文章的评论中问到,WAL之后如果 读数据,是不是一定要读盘,是不是一定要从redo log里面把数据更新以后才可以返回?其实是不用的。你可以看一下图3的这个状态,虽然磁盘上还是之前的数据,但是这里直接从 内存返回结果,结果是正确的。
2.要读Page2的时候,需要把Page2从磁盘读入内存中,然后应用changebuffer里面的操作 日志,生成一个正确的版本并返回结果。
可以看到,直到需要读Page 2的时候,这个数据页才会被读入内存。
所以,如果要简单地对比这两个机制在提升更新性能上的收益的话,redo log 主要节省的是随 机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消 耗。
普通索引和唯一索引的选择开始,和你分享了数据的查询和更新过程,然后说明了 change buffer的机制以及应用场景,最后讲到了索引选择的实践。
由于唯一索引用不上change buffer的优化机制,因此如果业务可以接受,从性能角度出发我建 议你优先考虑非唯一索引。
通过图2你可以看到,change buffer一开始是写内存的,那么如果这个时候机器掉电重启,会不 会导致change buffer丢失呢?change buffer丢失可不是小事儿,再从磁盘读入数据可就没有了 merge过程,就等于是数据丢失了。会不会出现这种情况呢?
答案:不会丢失。虽然是只更新内存,但是在事务提 交的时候,我们把change buffer的操作也记录到redo log里了,所以崩溃恢复的时候,change buffer也能找回来。
merge的过程是否会把数据直接写回磁盘?
merge的执行流程是这样的:
1.从磁盘读入数据页到内存(老版本的数据页);
2.从changebuffer里找出这个数据页的changebuffer记录(可能有多个),依次应用,得到新 版数据页;
3.写redolog。这个redolog包含了数据的变更和changebuffer的变更。 到这里merge过程就结束了。这时候,数据页和内存中change buffer对应的磁盘位置都还没有修
改,属于脏页,之后各自刷回自己的物理数据,就是另外一个过程了。
在MySQL中一张表其实是可以支持多个索引的。但是,你 写SQL语句的时候,并没有主动指定使用哪个索引。也就是说,使用哪个索引是由MySQL来确 定的。
不知道你有没有碰到过这种情况,一条本来可以执行得很快的语句,却由于MySQL选错了索 引,而导致执行速度变得很慢?一起来看一个例子吧。
先建一个简单的表,表里有a、b两个字段,并分别建上索引:
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `a` (`a`),
KEY `b` (`b`)
) ENGINE=InnoDB;
然后,我们往表t中插入10万行记录,取值按整数递增,即:(1,1,1),(2,2,2),(3,3,3) 直到 (100000,100000,100000)。如下用存储过程实现:
delimiter ;;
create procedure idata()
begin
declare i int;
set i=1;
while(i<=100000)do
insert into t values(i, i, i);
set i=i+1;
end while;
end;;
delimiter ;
call idata();
接下来,分析一条SQL语句:
select * from t where a between 10000 and 20000;
你一定会说,这个语句还用分析吗,很简单呀,a上有索引,肯定是要使用索引a的。
你说得没错,图1显示的就是使用explain命令看到的这条语句的执行情况。
从图1看上去,这条查询语句的执行也确实符合预期,key这个字段值是’a’,表示优化器选择了索
引a。
不过别急,这个案例不会这么简单。在我们已经准备好的包含了10万行数据的表上,我们再做 如下操作。
这里,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语句执行完成后的慢查询日志。
可以看到,Q1扫描了10万行,显然是走了全表扫描,执行时间是40毫秒。Q2扫描了10001行,
执行了21毫秒。也就是说,我们在没有使用force index的时候,MySQL用错了索引,导致了更 长的执行时间。
这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。这时,MySQL竟然会选错 索引,是不是有点奇怪呢?今天,我们就从这个奇怪的结果说起吧。
在第一篇文章中,我们就提到过,选择索引是优化器的工作。
而优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库 里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越 少,消耗的CPU资源越少。
当然,扫描行数并不是唯一的判断标准,优化器还会结合是否使用临时表、是否排序等因素进行 综合判断。
这个简单的查询语句并没有涉及到临时表和排序,所以MySQL选错索引肯定是在判断扫描 行数的时候出问题了。
那么,问题就是:扫描行数是怎么判断的?
MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根
据统计信息来估算记录数。
这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越 好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越 大,索引的区分度越好。
我们可以使用show index方法,看到一个索引的基数。如图4所示,就是表t的show index 的结果 。虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不 同,而且其实都不准确。
那么,MySQL是怎样得到索引的基数的呢?这里,简单介绍一下MySQL采样统计的方法。为什么要采样统计呢?因为把整张表取出来一行行统计,虽然可以得到精确的结果,但是代价太
高了,所以只能选择“采样统计”。
采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。
而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过1/M的 时候,会自动触发重新做一次索引统计。
在MySQL中,有两种存储索引统计的方式,可以通过设置参数innodb_stats_persistent的值来选 择:
设置为on的时候,表示统计信息会持久化存储。这时,默认的N是20,M是10。
设置为off的时候,表示统计信息只存储在内存中。这时,默认的N是8,M是16。
由于是采样统计,所以不管N是20还是8,这个基数都是很容易不准的。
但,这还不是全部。
你可以从图4中看到,这次的索引统计值(cardinality列)虽然不够精确,但大体上还是差不多 的,选错索引一定还有别的原因。
其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要 扫描多少行。
接下来,我们再一起看看优化器预估的,这两个语句的扫描行数是多少。
rows这个字段表示的是预计扫描行数。
其中,Q1的结果还是符合预期的,rows的值是104620;但是Q2的rows值是37116,偏差就大 了。而图1中我们用explain命令看到的rows是只有10001行,是这个偏差误导了优化器的判断。
到这里,可能你的第一个疑问不是为什么不准,而是优化器为什么放着扫描37000行的执行计划 不用,却选择了扫描行数是100000的执行计划呢?
这是因为,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行数据, 这个代价优化器也要算进去的。
而如果选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价。
优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。
使用普通索引需要把回表的代价算进去,在图1执行explain的时候,也考虑了这个策略的代价 ,但图1的选择是对的。也就是说,这个策略并没有问题。
所以冤有头债有主,MySQL选错索引,这件事儿还得归咎到没能准确地判断出扫描行数。至于为什么会得到错误的扫描行数,这个原因就作为课后问题,留给你去分析了。
既然是统计信息不对,那就修正。analyze table t 命令,可以用来重新统计索引信息。我们来看 一下执行效果。
这回对了。所以在实践中,如果你发现explain的结果预估的rows值跟实际情况差距比较大,可以采用这个 方法来处理。
其实,如果只是索引统计不准确,通过analyze命令可以解决很多问题,但是前面我们说了,优 化器可不止是看扫描行数。
依然是基于这个表t,我们看看另外一个语句:
从条件上看,这个查询没有符合条件的记录,因此会返回空集合。 在开始执行这条语句之前,可以先设想一下,如果来选择索引,会选择哪一个呢? 为了便于分析,我们先来看一下a、b这两个索引的结构图。
如果使用索引a进行查询,那么就是扫描索引a的前1000个值,然后取到对应的id,再到主键索引上去查出每一行,然后根据字段b来过滤。显然这样需要扫描1000行。
如果使用索引b进行查询,那么就是扫描索引b的最后50001个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描50001行。
所以你一定会想,如果使用索引a的话,执行速度明显会快很多。那么,下面我们就来看看到底 是不是这么一回事儿。
图8是执行explain的结果。
可以看到,返回结果中key字段显示,这次优化器选择了索引b,而rows字段显示需要扫描的行
数是50198。 从这个结果中,你可以得到两个结论:
1.扫描行数的估计值依然不准确;
2.这个例子里MySQL又选错了索引。
其实大多数时候优化器都能找到正确的索引,但偶尔你还是会碰到我们上面举例的这两种情况: 原本可以执行得很快的SQL语句,执行速度却比你预期的慢很多,你应该怎么办呢?
一种方法是,像我们第一个例子一样,采用force index强行选择一个索引。MySQL会根据 词法解析的结果分析出可能可以使用的索引作为候选项,然后在候选列表中依次判断每个索引需 要扫描多少行。如果force index指定的索引在候选索引列表中,就直接选择这个索引,不再评估 其他索引的执行代价。
我们来看看第二个例子。刚开始分析时,我们认为选择索引a会更好。现在,我们就来看看执行 效果:
可以看到,原本语句需要执行2.23秒,而当你使用force index(a)的时候,只用了0.05秒,比优化器的选择快了40多倍。
也就是说,优化器没有选择正确的索引,force index起到了“矫正”的作用。 不过很多程序员不喜欢使用forceindex,一来这么写不优美,二来如果索引改了名字,这个语句也得改,显得很麻烦。而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。
但其实使用force index最主要的问题还是变更的及时性。因为选错索引的情况还是比较少出现 的,所以开发的时候通常不会先写上force index。而是等到线上出现问题的时候,你才会再去修 改SQL语句、加上force index。但是修改之后还要测试和发布,对于生产系统来说,这个过程不 够敏捷。
所以,数据库的问题最好还是在数据库内部来解决。那么,在数据库里面该怎样解决呢?
既然优化器放弃了使用索引a,说明a还不够合适,所以第二种方法就是,我们可以考虑修改 语句,引导MySQL使用我们期望的索引。比如,在这个例子里,显然把“orderbyblimit1”改 成 “order by b,a limit 1” ,语义的逻辑是相同的。
我们来看看改之后的效果:
之前优化器选择使用索引b,是因为它认为使用索引b可以避免排序(b本身是索引,已经是有序的了,如果选择索引b的话,不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为 代价更小。
现在order by b,a 这种写法,要求按照b,a排序,就意味着使用这两个索引都需要排序。因此,扫 描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描1000行的索引a。
当然,这种修改并不是通用的优化手段,只是刚好在这个语句里面有limit 1,因此如果有满足条 件的记录, order by b limit 1和order by b,a limit 1 都会返回b是最小的那一行,逻辑上一致,才 可以这么做。
如果你觉得修改语义这件事儿不太好,这里还有一种改法,图11是执行效果。
在这个例子里,我们用limit 100让优化器意识到,使用b索引代价是很高的。其实是我们根据数据特征诱导了一下优化器,也不具备通用性。
第三种方法是,在有些场景下,我们可以新建一个更合适的索引,来提供给优化器做选 择,或删掉误用的索引。
不过,在这个例子中,我没有找到通过新增索引来改变优化器行为的方法。这种情况其实比较 少,尤其是经过DBA索引优化过的库,再碰到这个bug,找到一个更合适的索引一般比较难。
如果我说还有一个方法是删掉索引b,你可能会觉得好笑。但实际上我碰到过两次这样的例子, 最终是DBA跟业务开发沟通后,发现这个优化器错误选择的索引其实根本没有必要存在,于是 就删掉了这个索引,优化器也就重新选择到了正确的索引。
聊了聊索引统计的更新机制,并提到了优化器存在选错索引的可能性。 对于由于索引统计信息不准确导致的问题,可以用analyze table来解决。
而对于其他优化器误判的情况,可以在应用端用force index来强行指定索引,也可以通过修改 语句来引导优化器,还可以通过增加或者删除索引来绕过这个问题。
前面我们在构造第一个例子的过程中,通过session A的配合, 让session B删除数据后又重新插入了一遍数据,然后就发现explain结果中,rows字段从10001 变成37000多。
而如果没有session A的配合,只是单独执行delete from t 、call idata()、explain这三句话,会看 到rows字段其实还是10000左右。你可以自己验证一下这个结果。
这是什么原因呢?
答案:delete 语句删掉了所有的数据,然后再通过call idata()插入了10万行数据,看上去是覆盖了原来 的10万行。
但是,session A开启了事务并没有提交,所以之前插入的10万行数据是不能删除的。这样,之 前的数据每一行数据都有两个版本,旧版本是delete之前的数据,新版本是标记为deleted的数 据。
这样,索引a上的数据其实就有两份。
然后你会说,不对啊,主键上的数据也不能删,那没有使用force index的语句,使用explain命令 看到的扫描行数为什么还是100000左右?(潜台词,如果这个也翻倍,也许优化器还会认为选 字段a作为索引更合适)
是的,不过这个是主键,主键是直接按照表的行数来估计的。而表的行数,优化器直接用的是 show table status的值。
现在,几乎所有的系统都支持邮箱登录,如何在邮箱这样的字段上建立合理的索引,是我们今天 要讨论的问题。
假设,你现在维护一个支持邮箱登录的系统,用户表是这么定义的:
create table SUser(
ID bigint unsigned primary key,
email varchar(64)
)engine=innodb;
由于要使用邮箱登录,所以业务代码中一定会出现类似于这样的语句:
select f1, f2 from SUser where email='xxx';
如果email这个字段上没有索引,那么这个语 句就只能做全表扫描。
同时,MySQL是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。
比如,这两个在email字段上创建索引的语句:
alter table SUser add index index1(email);
或
alter table SUser add index index2(email(6));
第一个语句创建的index1索引里面,包含了每个记录的整个字符串;而第二个语句创建的index2 索引里面,对于每个记录都是只取前6个字节。那么,这两种不同的定义在数据结构和存储上有什么区别呢?如图2和3所示,就是这两个索引 的示意图。
从图中你可以看到,由于email(6)这个索引结构中每个邮箱字段都只取前6个字节(即:
zhangs),所以占用的空间会更小,这就是使用前缀索引的优势。
如果使用的是index1(即email整个字符串的索引结构),执行顺序是这样的:
1.从index1索引树找到满足索引值是’[email protected]’的这条记录,取得ID2的值;
2.到主键上查到主键值是ID2的行,判断email的值是正确的,将这行记录加入结果集;
3.取index1索引树上刚刚查到的位置的下一条记录,发现已经不满足 email='[email protected]’的条件了,循环结束。
这个过程中,只需要回主键索引取一次数据,所以系统认为只扫描了一行。
如果使用的是index2(即email(6)索引结构),执行顺序是这样的:
1.从index2索引树找到满足索引值是’zhangs’的记录,找到的第一个是ID1;
2.到主键上查到主键值是ID1的行,判断出email的值不是’[email protected]’,这行记录丢 弃;
3.取index2上刚刚查到的位置的下一条记录,发现仍然是’zhangs’,取出ID2,再到ID索引上取 整行然后判断,这次值对了,将这行记录加入结果集;
4.重复上一步,直到在idxe2上取到的值不是’zhangs’时,循环结束。
在这个过程中,要回主键索引取4次数据,也就是扫描了4行。
通过这个对比,你很容易就可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多。
但是,对于这个查询语句来说,如果你定义的index2不是email(6)而是email(7),也就是说取 email字段的前7个字节来构建索引的话,即满足前缀’zhangss’的记录只有一个,也能够直接查到 ID2,只扫描一行就结束了。
也就是说使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查 询成本。
于是,你就有个问题:当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?
实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的 键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。
首先,你可以使用下面这个语句,算出这个列上有多少个不同的值
select count(distinct email) as L from SUser;
然后,依次选取不同长度的前缀来看这个值,比如我们要看一下4~7个字节的前缀索引,可以用 这个语句:
select
count(distinct left(email,4))as L4, count(distinct left(email,5))as L5, count(distinct left(email,6))as L6, count(distinct left(email,7))as L7,
from SUser;
当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的L4~L7中,找出不小于 L * 95%的值,假设这里L6、L7都满足,你就可以 选择前缀长度为6。
前面我们说了使用前缀索引可能会增加扫描行数,这会影响到性能。其实,前缀索引的影响不止 如此,我们再看一下另外一个场景。
你先来看看这个SQL语句:
select id,email from SUser where email='[email protected]';
与前面例子中的SQL语句
select id,name,email from SUser where email='[email protected]';
相比,这个语句只要求返回id和email字段。
所以,如果使用index1(即email整个字符串的索引结构)的话,可以利用覆盖索引,从index1查 到结果后直接就返回了,不需要回到ID索引再去查一次。而如果使用index2(即email(6)索引结 构)的话,就不得不回到ID索引再去判断email字段的值。
即使你将index2的定义修改为email(18)的前缀索引,这时候虽然index2已经包含了所有的信息, 但InnoDB还是要回到id索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信 息。
也就是说,使用前缀索引就用不上覆盖索引对查询性能的优化了,这也是你在选择是否使用前缀 索引时需要考虑的一个因素。
对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。但是,遇到前缀的区分度不够好的情况时,我们要怎么办呢?
比如,我们国家的身份证号,一共18位,其中前6位是地址码,所以同一个县的人的身份证号前6位一般会是相同的。 假设你维护的数据库是一个市的公民信息系统,这时候如果对身份证号做长度为6的前缀索引的话,这个索引的区分度就非常低了。 按照我们前面说的方法,可能你需要创建长度为12以上的前缀索引,才能够满足区分度要求。
但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的 效率也就会越低。
那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理 方法呢?这种方法,既可以占用更小的空间,也能达到相同的查询效率。
答案是,有的。
第一种方式是使用倒序存储。如果你存储身份证号的时候把它倒过来存,每次查询的时候,你 可以这么写:
select field_list from t where id_card = reverse('input_id_card_string');
由于身份证号的最后6位没有地址码这样的重复逻辑,所以最后这6位很可能就提供了足够的区 分度。当然了,实践中你不要忘记使用count(distinct)方法去做个验证。
第二种方式是使用hash字段。你可以在表上再创建一个整数字段,来保存身份证的校验码, 同时在这个字段上创建索引。
alter table t add id_card_crc int unsigned, add index(id_card_crc);
然后每次插入新记录的时候,都同时用crc32()这个函数得到校验码填到这个新字段。由于校验码 可能存在冲突,也就是说两个不同的身份证号通过crc32()函数得到的结果可能是相同的,所以你 的查询语句where部分要判断id_card的值是否精确相同。
这样,索引的长度变成了4个字节,比原来小了很多。
接下来,我们再一起看看使用倒序存储和使用hash字段这两种方法的异同点。
首先,它们的相同点是,都不支持范围查询。倒序存储的字段上创建的索引是按照倒序字符串的 方式排序的,已经没有办法利用索引方式查出身份证号码在[ID_X, ID_Y]的所有市民了。同样 地,hash字段的方式也只能支持等值查询。
它们的区别,主要体现在以下三个方面:
1.从占用的额外空间来看,倒序存储方式在主键索引上,不会消耗额外的存储空间,而hash字 段方法需要增加一个字段。当然,倒序存储方式使用4个字节的前缀长度应该是不够的,如 果再长一点,这个消耗跟额外这个hash字段也差不多抵消了。
2.在CPU消耗方面,倒序方式每次写和读的时候,都需要额外调用一次reverse函数,而hash 字段的方式需要额外调用一次crc32()函数。如果只从这两个函数的计算复杂度来看的 话,reverse函数额外消耗的CPU资源会更小些。
3.从查询效率上看,使用hash字段方式的查询性能相对更稳定一些。因为crc32算出来的值虽 然有冲突的概率,但是概率非常小,可以认为每次查询的平均扫描行数接近1。而倒序存储 方式毕竟还是用的前缀索引的方式,也就是说还是会增加扫描行数。
聊了聊字符串字段创建索引的场景。我们来回顾一下,你可以使用的 方式有:
1.直接创建完整索引,这样可能比较占用空间;
2.创建前缀索引,节省空间,但会增加查询扫描次数,并且不能使用覆盖索引;
3.倒序存储,再创建前缀索引,用于绕过字符串本身前缀的区分度不够的问题;
4.创建hash字段索引,查询性能稳定,有额外的存储和计算消耗,跟第三种方式一样,都不支 持范围扫描。
如果在维护一个学校的学生信息数据库,学生登录名的统一格式是”学号@gmail.com", 而学号 的规则是:十五位的数字,其中前三位是所在城市编号、第四到第六位是学校编号、第七位到第 十位是入学年份、最后五位是顺序编号。
系统登录的时候都需要学生输入登录名和密码,验证正确后才能继续使用系统。就只考虑登录验 证这个行为的话,怎么设计这个登录名的索引呢?
答案:由于这个学号的规则,无论是正向还是反向的前缀索引,重复度都比较高。因为维护的只是一个 学校的,因此前面6位(其中,前三位是所在城市编号、第四到第六位是学校编号)其实是固定 的,邮箱后缀都是@gamil.com,因此可以只存入学年份加顺序编号,它们的长度是9位。
而其实在此基础上,可以用数字类型来存这9位数字。比如201100001,这样只需要占4个字 节。其实这个就是一种hash,只是它用了最简单的转换规则:字符串转数字的规则,而刚好我 们设定的这个背景,可以保证这个转换后结果的唯一性。
不知道有没有遇到过这样的场景,一条SQL语句,正常执行的时候特别快,但 是有时也不知道怎么回事,它就会变得特别慢,并且这样的场景很难复现,它不只随机,而且持 续时间还很短。看上去,这就像是数据库“抖”了一下。
InnoDB在处理更新语句的时候,只做了写日志这一个磁盘操作。这个日志 叫作redo log(重做日志),也就是《孔乙己》里咸亨酒店掌柜用来记账的粉板,在更新内存写 完redo log后,就返回给客户端,本次更新成功。
做下类比的话,掌柜记账的账本是数据文件,记账用的粉板是日志文件(redo log),掌柜的记 忆就是内存。
掌柜总要找时间把账本更新一下,这对应的就是把内存里的数据写入磁盘的过程,术语就是 flush。在这个flush操作执行之前,孔乙己的赊账总额,其实跟掌柜手中账本里面的记录是不一 致的。因为孔乙己今天的赊账金额还只在粉板上,而账本里的记录是老的,还没把今天的赊账算 进去。
当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。 不论是脏页还是干净页,都在内存中。在这个例子里,内存对应的就是掌柜的记忆。
接下来,我们用一个示意图来展示一下“孔乙己赊账”的整个操作过程。假设原来孔乙己欠账10 文,这次又要赊9文。
回到文章开头的问题,你不难想象,平时执行很快的更新操作,其实就是在写内存和日志,而MySQL偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。
那么,什么情况会引发数据库的flush过程呢?
我们还是继续用咸亨酒店掌柜的这个例子,想一想:掌柜在什么情况下会把粉板上的赊账记录改到账本上?
第一种场景是,粉板满了,记不下了。这时候如果再有人来赊账,掌柜就只得放下手里的活 儿,将粉板上的记录擦掉一些,留出空位以便继续记账。当然在擦掉之前,他必须先将正确 的账目记录到账本中才行。
这个场景,对应的就是InnoDB的redo log写满了。这时候系统会停止所有更新操作,把 checkpoint往前推进,redo log留出空间可以继续写。我在第二讲画了一个redo log的示意 图,这里我改成环形,便于大家理解。
checkpoint可不是随便往前修改一下位置就可以的。比如图2中,把checkpoint位置从CP推进到
CP’,就需要将两个点之间的日志(浅绿色部分),对应的所有脏页都flush到磁盘上。之后,图 中从write pos到CP’之间就是可以再写入的redo log的区域。
第二种场景是,这一天生意太好,要记住的事情太多,掌柜发现自己快记不住了,赶紧找出 账本把孔乙己这笔账先加进去。 这种场景,对应的就是系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰
一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。 你一定会说,这时候难道不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据 页,然后拿redo log出来应用不就行了?这里其实是从性能考虑的。如果刷脏页一定会写盘, 就保证了每个数据页有两种状态:
一种是内存里存在,内存里就肯定是正确的结果,直接返回; 另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。 这样的效率最高。
第三种场景是,生意不忙的时候,或者打烊之后。这时候柜台没事,掌柜闲着也是闲着,不 如更新账本。 这种场景,对应的就是MySQL认为系统“空闲”的时候。当然,MySQL“这家酒店”的生意好起 来可是会很快就能把粉板记满的,所以“掌柜”要合理地安排时间,即使是“生意好”的时候,也 要见缝插针地找时间,只要有机会就刷一点“脏页”。
第四种场景是,年底了咸亨酒店要关门几天,需要把账结清一下。这时候掌柜要把所有账都 记到账本上,这样过完年重新开张的时候,就能就着账本明确账目情况了。 这种场景,对应的就是MySQL正常关闭的情况。这时候,MySQL会把内存的脏页都flush到磁 盘上,这样下次MySQL启动的时候,就可以直接从磁盘上读数据,启动速度会很快。
接下来,你可以分析一下上面四种场景对性能的影响。
其中,第三种情况是属于MySQL空闲时的操作,这时系统没什么压力,而第四种场景是数据库 本来就要关闭了。这两种情况下,你不会太关注“性能”问题。所以这里,我们主要来分析一下前 两种场景下的性能问题。
第一种是“redo log写满了,要flush脏页”,这种情况是InnoDB要尽量避免的。因为出现这种情况 的时候,整个系统就不能再接受更新了,所有的更新都必须堵住。如果你从监控上看,这时候更 新数会跌为0。
第二种是“内存不够用了,要先将脏页写到磁盘”,这种情况其实是常态。InnoDB用缓冲池 (buffer pool)管理内存,缓冲池中的内存页有三种状态:
第一种是,还没有使用的;
第二种是,使用了并且是干净页;
第三种是,使用了并且是脏页。
InnoDB的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。
而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久 不使用的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。
所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:
1.一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
2.日志写满,更新全部堵住,写性能跌为0,这种情况对敏感业务来说,是不能接受的。 所以,InnoDB需要有控制脏页比例的机制,来尽量避免上面的这两种情况。
接下来,我就来和你说说InnoDB脏页的控制策略,以及和这些策略相关的参数。
首先,你要正确地告诉InnoDB所在主机的IO能力,这样InnoDB才能知道需要全力刷脏页的时候,可以刷多快。
这就要用到innodb_io_capacity这个参数了,它会告诉InnoDB你的磁盘能力。这个值我建议你设 置成磁盘的IOPS。磁盘的IOPS可以通过fio这个工具来测试,下面的语句是我用来测试磁盘随机 读写的命令:
其实,因为没能正确地设置innodb_io_capacity参数,而导致的性能问题也比比皆是。之前,就 曾有其他公司的开发负责人找我看一个库的性能问题,说MySQL的写入速度很慢,TPS很低, 但是数据库主机的IO压力并不大。经过一番排查,发现罪魁祸首就是这个参数的设置出了问题。
他的主机磁盘用的是SSD,但是innodb_io_capacity的值设置的是300。于是,InnoDB认为这个 系统的能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页 累积,影响了查询和更新性能。
虽然我们现在已经定义了“全力刷脏页”的行为,但平时总不能一直是全力刷吧?毕竟磁盘能力不 能只用来刷脏页,还需要服务用户请求。所以接下来,我们就一起看看InnoDB怎么控制引擎按 照“全力”的百分比来刷脏页。
根据我前面提到的知识点,试想一下,如果你来设计策略控制刷脏页的速度,会参考哪些因 素呢?
这个问题可以这么想,如果刷太慢,会出现什么情况?首先是内存脏页太多,其次是redo log写 满。所以,InnoDB的刷盘速度就是要参考这两个因素:一个是脏页比例,一个是redo log写盘速度。
InnoDB会根据这两个因素先单独算出两个数字。
参数innodb_max_dirty_pages_pct是脏页比例上限,默认值是75%。InnoDB会根据当前的脏页
比例(假设为M),算出一个范围在0到100之间的数字,计算这个数字的伪代码类似这样:
F1(M) {
if M>=innodb_max_dirty_pages_pct then
return 100;
return 100*M/innodb_max_dirty_pages_pct;
}
InnoDB每次写入的日志都有一个序号,当前写入的序号跟checkpoint对应的序号之间的差值, 我们假设为N。InnoDB会根据这个N算出一个范围在0到100之间的数字,这个计算公式可以记为 F2(N)。F2(N)算法比较复杂,你只要知道N越大,算出来的值越大就好了。
然后,根据上述算得的F1(M)和F2(N)两个值,取其中较大的值记为R,之后引擎就可以按 照innodb_io_capacity定义的能力乘以R%来控制刷脏页的速度。
上述的计算流程比较抽象,不容易理解,所以我画了一个简单的流程图。图中的F1、F2就是上 面我们通过脏页比例和redo log写入速度算出来的两个值。
现在你知道了,InnoDB会在后台刷脏页,而刷脏页的过程是要将内存页写入磁盘。所以,无论
是你的查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用IO资源 并可能影响到了你的更新语句,都可能是造成你从业务端感知到MySQL“抖”了一下的原因。
要尽量避免这种情况,你就要合理地设置innodb_io_capacity的值,并且平时要多关注脏页比 例,不要让它经常接近75%。
其中,脏页比例是通过Innodb_buffer_pool_pages_dirty/Innodb_buffer_pool_pages_total得到 的,具体的命令参考下面的代码:
接下来,我们再看一个有趣的策略。
一旦一个查询请求需要在执行过程中先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了。
介绍的WAL的概念,和你解释了这个机制后续需要的刷脏页操作 和执行时机。利用WAL技术,数据库将随机写转换成了顺序写,大大提升了数据库的性能。但是,由此也带来了内存脏页的问题。脏页会被后台线程自动flush,也会由于数据页淘汰而触 发flush,而刷脏页的过程由于会占用资源,可能会让你的更新和查询语句的响应时间长一些。 在文章里,我也给你介绍了控制刷脏页的方法和对应的监控方式。
一个内存配置为128GB、innodb_io_capacity设置为20000的大规格实例,正常会建议你将redo log设置成4个1GB的文件。
但如果你在配置的时候不慎将redo log设置成了1个100M的文件,会发生什么情况呢?又为什么 会出现这样的情况呢?
把一个最大的表删掉了一半的数据,怎么表 文件的大小还是没变?
今天,聊聊数据库表的空间回收,看看如何解决这个问题。
这里,还是针对MySQL中应用最广泛的InnoDB引擎展开讨论。一个InnoDB表包含两部 分,即:表结构定义和数据。在MySQL 8.0版本以前,表结构是存在以.frm为后缀的文件里。而 MySQL 8.0版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据。
接下来,我会先和你说明为什么简单地删除表数据达不到表空间回收的效果,然后再和你介绍正确回收空间的方法。
表数据既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数innodb_file_per_table控制的:
1.这个参数设置为OFF表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
2.这个参数设置为ON表示的是,每个InnoDB表数据存储在一个以 .ibd为后缀的文件中。
从MySQL 5.6.6版本开始,它的默认值就是ON了。
建议不论使用MySQL的哪个版本,都将这个值设置为ON。因为,一个表单独存储为一个文 件更容易管理,而且在不需要这个表的时候,通过drop table命令,系统就会直接删除这个文 件。而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。
所以,将innodb_file_per_table设置为ON,是推荐做法,我们接下来的讨论都是基于这个 设置展开的。
我们在删除整个表的时候,可以使用drop table命令回收表空间。但是,我们遇到的更多的删除 数据的场景是删除某些行,这时就遇到了我们文章开头的问题:表中的数据被删除了,但是表空 间却没有被回收
我们要彻底搞明白这个问题的话,就要从数据删除流程说起了。
我们先再来看一下InnoDB中一个索引的示意图。介绍索引时 曾经提到过,InnoDB里的数据都是用B+树的结构组织的。
假设,我们要删掉R4这个记录,InnoDB引擎只会把R4这个记录标记为删除。如果之后要再插入 一个ID在300和600之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。
现在,你已经知道了InnoDB的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记 录,会怎么样?
答案是,整个数据页就可以被复用了。
但是,数据页的复用跟记录的复用是不同的。
记录的复用,只限于符合范围条件的数据。比如上面的这个例子,R4这条记录被删除后,如果 插入一个ID是400的行,可以直接复用这个空间。但如果插入的是一个ID是800的行,就不能复 用这个位置了。
而当整个页从B+树里面摘掉以后,可以复用到任何位置。以图1为例,如果将数据页page A上的 所有记录删除以后,page A会被标记为可复用。这时候如果要插入一条ID=50的记录需要使用新 页的时候,page A是可以被复用的。
如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一 个数据页就被标记为可复用。
进一步地,如果我们用delete命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记 为可复用。但是磁盘上,文件不会变小。
你现在知道了,delete命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件 的大小是不会变的。也就是说,通过delete命令是不能回收表空间的。这些可以复用,而没有被 使用的空间,看起来就像是“空洞”。
实际上,不止是删除数据会造成空洞,插入数据也会。
如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂。
假设图1中page A已经满了,这时我要再插入一行数据,会怎样呢?
可以看到,由于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会自 动完成转存数据、交换表名、删除旧表的操作。
显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到
表A的话,就会造成数据丢失。因此,在整个DDL过程中,表A中不能有更新。也就是说,这个 DDL不是Online的。
而在MySQL 5.6版本开始引入的Online DDL,对这个操作流程做了优化。
简单描述一下引入了Online DDL之后,重建表的流程:
1.建立一个临时文件,扫描表A主键的所有数据页;
2.用数据页中表A的记录生成B+树,存储到临时文件中;
3.生成临时文件的过程中,将所有对A的操作记录在一个日志文件(rowlog)中,对应的是图 中state2的状态;
4.临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表A相同的 数据文件,对应的就是图中state3的状态;
5.用临时文件替换表A的数据文件。
可以看到,与图3过程的不同之处在于,由于日志文件记录和重放操作这个功能的存在,这个方案在重建表的过程中,允许对表A做增删改操作。这也就是Online DDL名字的来源。
DDL之前是要拿MDL写锁的,这样还能叫Online DDL吗?
确实,图4的流程中,alter语句在启动的时候需要获取MDL写锁,但是这个写锁在真正拷贝数据之前就退化成读锁了。
为什么要退化呢?为了实现Online,MDL读锁不会阻塞增删改操作。 那为什么不干脆直接解锁呢?为了保护自己,禁止其他线程对这个表同时做DDL。
而对于一个大表来说,Online DDL最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执 行期间可以接受增删改操作。所以,相对于整个DDL过程来说,锁的时间非常短。对业务来说, 就可以认为是Online的。
需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来 说,这个操作是很消耗IO和CPU资源的。因此,如果是线上服务,你要很小心地控制操作时间。如果想要比较安全的操作的话,我推荐你使用GitHub开源的gh-ost来做。
说到Online,我还要再和你澄清一下它和另一个跟DDL有关的、容易混淆的概念inplace的区别。 你可能注意到了,在图3中,我们把表A中的数据导出来的存放位置叫作tmp_table。这是一个临
时表,是在server层创建的。
在图4中,根据表A重建出来的数据是放在“tmp_file”里的,这个临时文件是InnoDB在内部创建出 来的。整个DDL过程都在InnoDB内部完成。对于server层来说,没有把数据挪动到临时表,是 一个“原地”操作,这就是“inplace”名称的来源。
所以,我现在问你,如果你有一个1TB的表,现在磁盘间是1.2TB,能不能做一个inplace的DDL 呢?
答案是不能。因为,tmp_file也是要占用临时空间的。 我们重建表的这个语句alter table t engine=InnoDB,其实隐含的意思是:
alter table t engine=innodb,ALGORITHM=inplace;
跟inplace对应的就是拷贝表的方式了,用法是:
alter table t engine=innodb,ALGORITHM=copy;
当你使用ALGORITHM=copy的时候,表示的是强制拷贝表,对应的流程就是图3的操作过程。 但我这样说你可能会觉得,inplace跟Online是不是就是一个意思? 其实不是的,只是在重建表这个逻辑中刚好是这样而已。
比如,如果我要给InnoDB表的一个字段加全文索引,写法是:
alter table t add FULLTEXT(field_name);
这个过程是inplace的,但会阻塞增删改操作,是非Online的。 如果说这两个逻辑之间的关系是什么的话,可以概括为:
1.DDL过程如果是Online的,就一定是inplace的;
2.反过来未必,也就是说inplace的DDL,有可能不是Online的。截止到MySQL8.0,添加全文 索引(FULLTEXT index)和空间索引(SPATIAL index)就属于这种情况。
使用optimize table、analyze table和alter table这三种方式重建表的区别
从MySQL 5.6版本开始,alter table t engine = InnoDB(也就是recreate)默认的就是上面图4 的流程了;
analyze table t 其实不是重建表,只是对表的索引信息做重新统计,没有修改数据,这个过程 中加了MDL读锁;
optimize table t 等于recreate+analyze。
讨论了数据库中收缩表空间的方法。
如果要收缩一个表,只是delete掉表里面不用的数据的话,表文件的大小是 不会变的,你还要通过alter table命令重建表,才能达到表文件变小的目的。我跟你介绍了重建 表的两种实现方式,Online DDL的方式是可以考虑在业务低峰期使用的,而MySQL 5.5及之前的 版本,这个命令是会阻塞DML的,这个你需要特别小心。
假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的:
1.一个表t文件大小为1TB;
2.对这个表执行 alter table t engine=InnoDB;
3.发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了1.01TB。
假如是这么一个过程:
1.将表t重建一次;
2.插入一部分数据,但是插入的这些数据,用掉了一部分的预留空间;
3.这种情况下,再重建一次表t,就可能会出现问题中的现象。
在开发系统的时候,你可能经常需要计算一个表的行数,比如一个交易系统的所有变更记录总 数。这时候你可能会想,一条select count(*) from t 语句不就解决了吗?
但是,你会发现随着系统中记录数越来越多,这条语句执行得也会越来越慢。然后你可能就想 了,MySQL怎么这么笨啊,记个总数,每次要查的时候直接读出来,不就好了吗。
那么今天,我们就来聊聊count( * )语句到底是怎样实现的,以及MySQL为什么会这么实现。然后,我会再和你说说,如果应用中有这种频繁变更并需要统计表行数的需求,业务设计上可以怎么做。
首先要明确的是,在不同的MySQL引擎中,count()有不同的实现方式。
MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count()的时候会直接返回这个数, 效率很高;
而InnoDB引擎就麻烦了,它执行count( * )的时候,需要把数据一行一行地从引擎里面读出 来,然后累积计数。
这里需要注意的是,我们在这篇文章里讨论的是没有过滤条件的count( * ),如果加了where 条件 的话,MyISAM表也是不能返回得这么快的。
在前面的文章中,我们一起分析了为什么要使用InnoDB,因为不论是在事务支持、并发能力还 是在数据安全方面,InnoDB都优于MyISAM。我猜你的表也一定是用了InnoDB引擎。这就是当 你的记录数越来越多的时候,计算一个表的总行数会越来越慢的原因。
那为什么InnoDB不跟MyISAM一样,也把数字存起来呢?
这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB
表“应该返回多少行”也是不确定的。这里,我用一个算count()的例子来为你解释一下。
假设表t中现在有10000条记录,我们设计了三个用户并行的会话。
会话A先启动事务并查询一次表的总行数;
会话B启动事务,插入一行后记录后,查询表的总行数;
会话C先启动一个单独的语句,插入一行记录后,查询表的总行数。
我们假设从上到下是按照时间顺序执行的,同一行语句是在同一时刻执行的。
你会看到,在最后一个时刻,三个会话A、B、C会同时查询表t的总行数,但拿到的结果却不
同。
这和InnoDB的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发 控制,也就是MVCC来实现的。每一行记录都要判断自己是否对这个会话可见,因此对于 count()请求来说,InnoDB只好把数据一行一行地读出依次判断,可见的行才能够用于计算“基 于这个查询”的表的总行数
当然,现在这个看上去笨笨的MySQL,在执行count( * )操作的时候还是做了优化的。
InnoDB是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是 主键值。所以,普通索引树比主键索引树小很多。对于count( * )这样的操作,遍历哪个索引树得 到的结果逻辑上都是一样的。因此,MySQL优化器会找到最小的那棵树来遍历。在保证逻辑正 确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。
如果你用过show table status 命令的话,就会发现这个命令的输出结果里面也有一个 TABLE_ROWS用于显示这个表当前有多少行,这个命令执行挺快的,那这个TABLE_ROWS能 代替count( * )吗?
索引统计的值 是通过采样来估算的。实际上,TABLE_ROWS就是从这个采样估算得来的,因此它也很不准。 有多不准呢,官方文档说误差可能达到40%到50%。所以,show table status命令显示的行 数也不能直接使用。
到这里我们小结一下:
MyISAM表虽然count( * )很快,但是不支持事务;
show table status命令虽然返回很快,但是不准确;
InnoDB表直接count(*)会遍历全表,虽然结果准确,但会导致性能问题。
那么,回到文章开头的问题,如果你现在有一个页面经常要显示交易系统的操作记录总数,到底 应该怎么办呢?答案是,我们只能自己计数。
接下来,我们讨论一下,看看自己计数有哪些方法,以及每种方法的优缺点有哪些。
这里,我先和你说一下这些方法的基本思路:你需要自己找一个地方,把操作记录表的行数存起 来。
对于更新很频繁的库来说,你可能会第一时间想到,用缓存系统来支持。
你可以用一个Redis服务来保存这个表的总行数。这个表每被插入一行Redis计数就加1,每被删 除一行Redis计数就减1。这种方式下,读和更新操作都很快,但你再想一下这种方式存在什么 问题吗?
没错,缓存系统可能会丢失更新。
Redis的数据不能永久地留在内存里,所以你会找一个地方把这个值定期地持久化存储起来。但 即使这样,仍然可能丢失更新。试想如果刚刚在数据表中插入了一行,Redis中保存的值也加了 1,然后Redis异常重启了,重启后你要从存储redis数据的地方把这个值读回来,而刚刚加1的这 个计数操作却丢失了。
当然了,这还是有解的。比如,Redis异常重启以后,到数据库里面单独执行一次count(*)获取真 实的行数,再把这个值写回到Redis里就可以了。异常重启毕竟不是经常出现的情况,这一次全 表扫描的成本,还是可以接受的。
但实际上,将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使Redis正常工 作,这个值还是逻辑上不精确的。
你可以设想一下有这么一个页面,要显示操作记录的总数,同时还要显示最近操作的100条记 录。那么,这个页面的逻辑就需要先到Redis里面取出计数,再到数据表里面取数据记录。
我们是这么定义不精确的:
1.一种是,查到的100行结果里面有最新插入记录,而Redis的计数里还没加1;
2.另一种是,查到的100行结果里没有最新插入的记录,而Redis的计数里已经加了1。
这两种情况,都是逻辑不一致的。
我们一起来看看这个时序图。
图2中,会话A是一个插入交易记录的逻辑,往数据表里插入一行R,然后Redis计数加1;会话B
就是查询页面显示时需要的数据。 在图2的这个时序里,在T3时刻会话B来查询的时候,会显示出新插入的R这个记录,但是Redis
的计数还没加1。这时候,就会出现我们说的数据不一致。
你一定会说,这是因为我们执行新增记录逻辑时候,是先写数据表,再改Redis计数。而读的时 候是先读Redis,再读数据表,这个顺序是相反的。那么,如果保持顺序一样的话,是不是就没 问题了?我们现在把会话A的更新顺序换一下,再看看执行结果。
你会发现,这时候反过来了,会话B在T3时刻查询的时候,Redis计数加了1了,但还查不到新插
入的R这一行,也是数据不一致的情况。 在并发系统里面,我们是无法精确控制不同线程的执行时刻的,因为存在图中的这种操作序列,
所以,我们说即使Redis正常工作,这个计数值还是逻辑上不精确的。
根据上面的分析,用缓存系统保存计数有丢失数据和计数不精确的问题。那么,如果我们把这 个计数直接放到数据库里单独的一张计数表C中,又会怎么样呢?
首先,这解决了崩溃丢失的问题,InnoDB是支持崩溃恢复不丢数据的。
然后,我们再看看能不能解决计数不精确的问题。
你会说,这不一样吗?无非就是把图3中对Redis的操作,改成了对计数表C的操作。只要出现图 3的这种执行序列,这个问题还是无解的吧?
这个问题还真不是无解的。
我们这篇文章要解决的问题,都是由于InnoDB要支持事务,从而导致InnoDB表不能把count(*) 直接存起来,然后查询的时候直接返回形成的。
所谓以子之矛攻子之盾,现在我们就利用“事务”这个特性,把问题解决掉。
我们来看下现在的执行结果。虽然会话B的读操作仍然是在T3执行的,但是因为这时候更新事务
还没有提交,所以计数值加1这个操作对会话B还不可见。
因此,会话B看到的结果里, 查计数值和“最近100条记录”看到的结果,逻辑上就是一致的。
在select count(?) from t这样的查询语句里 面,count()、count(主键id)、count(字段)和count(1)等不同用法的性能,有哪些差别。今天谈 到了count( * )的性能问题,我就借此机会和你详细说明一下这几种用法的性能差别。
需要注意的是,下面的讨论还是基于InnoDB引擎的。 这里,首先你要弄清楚count()的语义。count()是一个聚合函数,对于返回的结果集,一行行地
判断,如果count函数的参数不是NULL,累计值就加1,否则不加。最后返回累计值。 所以,count()、count(主键id)和count(1) 都表示返回满足条件的结果集的总行数;而count(字
段),则表示返回满足条件的数据行里面,参数“字段”不为NULL的总个数。 至于分析性能差别的时候,你可以记住这么几个原则:
1.server层要什么就给什么;
2.InnoDB只给必要的值;
3.现在的优化器只优化了count()的语义为“取行数”,其他“显而易见”的优化并没有做。
这是什么意思呢?接下来,我们就一个个地来看看。
对于count(主键id)来说,InnoDB 引 擎 会 遍 历 整 张 表 , 把 每 一 行 的 id 值 都 取 出 来 , 返 回 给 server层。server层拿到id后,判断是不可能为空的,就按行累加。
对于count(1)来说,InnoDB引擎遍历整张表,但不取值。server层对 于返回的每一-行, 放-一个数字“1”进去,判断是不可能为空的,按行累加。.
单看这两个用法的差别的话,你能对比出来,count(1)执行 得要比count(主键id)快。因为从引擎返回id会涉及到解析数据行,以及拷贝字段值的操作。
单看这两个用法的差别的话,你能对比出来,count(1)执行得要比count(主键id)快。因为从引擎
返回id会涉及到解析数据行,以及拷贝字段值的操作。
对于count(字段)来说:
1.如果这个“字段”是定义为not null的话,一行行地从记录里面读出这个字段,判断不能为 null,按行累加;
2.如果这个“字段”定义允许为null,那么执行的时候,判断到有可能是null,还要把值取出来再 判断一下,不是null才累加。
也就是前面的第一条原则,server层要什么字段,InnoDB就返回什么字段。 但是count( * )是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count( * )肯定不
是null,按行累加。
看到这里,你一定会说,优化器就不能自己判断一下吗,主键id肯定非空啊,为什么不能按照
count()来处理,多么简单的优化啊。 当然,MySQL专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多
了,而且MySQL已经优化过count( * )了,你直接使用这种用法就可以了。 所以结论是:按照效率排序的话,count(字段)
聊了聊MySQL中获得表行数的两种方法。我们提到了在不同引擎中count(*)的实现 方式是不一样的,也分析了用缓存系统来存储计数值存在的问题。
其实,把计数放在Redis里面,不能够保证计数和MySQL表里的数据精确一致的原因,是这两个 不同的存储构成的系统,不支持分布式事务,无法拿到精确一致的视图。而把计数值也放在 MySQL中,就解决了一致性视图的问题。
InnoDB引擎支持事务,我们利用好事务的原子性和隔离性,就可以简化在业务开发时的逻辑。 这也是InnoDB引擎备受青睐的原因之一。
在刚刚讨论的方案中,我们用了事务来确保计数准确。由于事务可以保证中间结果不被别的事务 读到,因此修改计数值和插入新记录的顺序是不影响逻辑结果的。但是,从并发系统性能的角度 考虑,你觉得在这个事务序列里,应该先插入操作记录,还是应该先更新计数表呢?
开发应用的时候,一定会经常碰到需要根据指定的字段排序来显示结果的需求。还是以我们 前面举例用过的市民表为例,假设你要查询城市是“杭州”的所有人名字,并且按照姓名排序返回 前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命令来看看这个语句的执行情况。
Extra这个字段中的“Using filesort”表示的就是需要排序,MySQL会给每个线程分配一块内存用于
排序,称为sort_buffer。
为了说明这个SQL查询语句的执行过程,我们先来看一下city这个索引的示意图。
从图中可以看到,满足city='杭州’条件的行,是从ID_X到ID_(X+N)的这些记录。通常情况下,这个语句执行流程如下所示 :
1.初始化sort_buffer,确定放入name、city、age这三个字段;
2.从索引city找到第一个满足city='杭州’条件的主键id,也就是图中的ID_X;
3.到主键id索引取出整行,取name、city、age三个字段的值,存入sort_buffer中;
4.从索引city取下一个记录的主键id;
5.重复步骤3、4直到city的值不满足查询条件为止,对应的主键id也就是图中的ID_Y;
6.对sort_buffer中的数据按照字段name做快速排序;
7.按照排序结果取前1000行返回给客户端。
我们暂且把这个排序过程,称为全字段排序,执行流程的示意图如下所示,下一篇文章中我们还 会用到这个排序。
图中“按name排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所
需的内存和参数sort_buffer_size。
sort_buffer_size,就是MySQL为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量 小于sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不 利用磁盘临时文件辅助排序。
你可以用下面介绍的方法,来确定一个排序语句是否使用了临时文件。
/* 打开optimizer_trace,只对本线程有效 */ SET optimizer_trace='enabled=on';
/* 执行语句 */
select city, name,age from t where city='杭州' order by name limit 1000;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
/* 计算Innodb_rows_read差值 */ select @b-@a;
这个方法是通过查看 OPTIMIZER_TRACE 的结果来确认的,你可以从 number_of_tmp_files中 看到是否使用了临时文件。
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。
在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在sort_buffer和临时文件 中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么sort_buffer里面 要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能 会很差。
所以如果单行很大,这个方法效率不够好。那么,如果MySQL认为排序的单行长度太大会怎么做呢?
接下来,我来修改一个参数,让MySQL采用另外一种算法。
SET max_length_for_sort_data = 16;
max_length_for_sort_data,是MySQL中专门控制用于排序的行数据的长度的一个参数。它的意 思是,如果单行的长度超过这个值,MySQL就认为单行太大,要换一个算法。
city、name、age 这三个字段的定义总长度是36,我把max_length_for_sort_data设置为16,我 们再来看看计算过程有什么改变。
新的算法放入sort_buffer的字段,只有要排序的列(即name字段)和主键id。 但这时,排序的结果就因为少了city和age字段的值,不能直接返回了,整个执行流程就变成如
下所示的样子:
1.初始化sort_buffer,确定放入两个字段,即name和id;
2.从索引city找到第一个满足city='杭州’条件的主键id,也就是图中的ID_X;
3.到主键id索引取出整行,取name、id这两个字段,存入sort_buffer中;
4.从索引city取下一个记录的主键id;
5.重复步骤3、4直到不满足city='杭州’条件为止,也就是图中的ID_Y;
6.对sort_buffer中的数据按照字段name进行排序;
7.遍历排序结果,取前1000行,并按照id的值回到原表中取出city、name和age三个字段返回 给客户端。
这个执行流程的示意图如下,我把它称为rowid排序。
对比图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行。
从OPTIMIZER_TRACE的结果中,你还能看到另外两个信息也变了。
sort_mode变成了
我们来分析一下,从这两个执行流程里,还能得出什么结论。
如果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索引的对比,我们来看看这个索引的示意图。
在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足city='杭州’的记录,并且额外
确保了,接下来按顺序取“下一条记录”的遍历过程中,只要city的值是杭州,name的值就一定是 有序的。
这样整个查询过程的流程就变成了:
1.从索引(city,name)找到第一个满足city='杭州’条件的主键id;
2.到主键id索引取出整行,取name、city、age三个字段的值,作为结果集的一部分直接返 回;
3.从索引(city,name)取下一个记录主键id;
4.重复步骤2、3,直到查到第1000条记录,或者是不满足city='杭州’条件时循环结束。
可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用explain的结果来印证一 下。
从图中可以看到,Extra字段中没有Using filesort了,也就是不需要排序了。而且由于(city,name)
这个联合索引本身有序,所以这个查询也不用把4000行全都读一遍,只要找到满足条件的前 1000条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描1000次。
这里我们可以再稍微复习一下。覆盖索引是指,索引上的信息足够满足查询请求,不需要再 回到主键索引上去取数据。按照覆盖索引的概念,我们可以再优化一下这个查询语句的执行流程。 针对这个查询,我们可以创建一个city、name和age的联合索引,对应的SQL语句就是:
alter table t add index city_user_age(city, name, age);
这时,对于city字段的值相同的行来说,还是按照name字段的值递增排序的,此时的查询语句也 就不再需要排序了。这样整个查询语句的执行流程就变成了:
1.从索引(city,name,age)找到第一个满足city='杭州’条件的记录,取出其中的city、name和age 这三个字段的值,作为结果集的一部分直接返回;
2.从索引(city,name,age)取下一个记录,同样取出这三个字段的值,作为结果集的一部分直接 返回;
3.重复执行步骤2,直到查到第1000条记录,或者是不满足city='杭州’条件时循环结束。
然后,我们再来看看explain的结果。
可以看到,Extra字段里面多了“Using index”,表示的就是使用了覆盖索引,性能上会快很多。
当然,这里并不是说要为了每个查询能用上覆盖索引,就要把语句中涉及的字段都建上联合索 引,毕竟索引还是有维护代价的。这是一个需要权衡的决定。
介绍了MySQL里面order by语句的几种算法流程。在开发系统的时候,你总是不可避免地会使用到order by语句。你心里要清楚每个语句的排序逻 辑是怎么实现的,还要能够分析出在最坏情况下,每个语句的执行对系统资源的消耗,这样才能 做到下笔如有神,不犯低级错误。
假设你的表里面已经有了city_name(city, name)这个联合索引,然后你要查杭州和苏州两个城市
中所有的市民的姓名,并且按名字排序,显示前100条记录。如果SQL查询语句是这么写的 :
select * from t where city in ('杭州',"苏州") order by name limit 100;
那么,这个语句执行的时候会有排序过程吗,为什么? 如果业务端代码由你来开发,需要实现一个在数据库端不需要排序的方案,你会怎么实现呢?
进一步地,如果有分页需求,要显示第101页,也就是说语句最后要改成 “limit 10000,100”, 你 的实现方法又会是什么呢?
虽然有(city,name)联合索引,对于单个city内部,name是递增的。但是由于这条SQL语句不是要 单独地查一个city的值,而是同时查了"杭州"和" 苏州 "两个城市,因此所有满足条件的name就不 是递增的了。也就是说,这条SQL语句需要排序。
那怎么避免排序呢? 这里,我们要用到(city,name)联合索引的特性,把这一条语句拆成两条语句,执行流程如下:
1.执行selectfromtwherecity=“杭州”orderbynamelimit100;这个语句是不需要排序的,客 户端用一个长度为100的内存数组A保存结果。
2.执行selectfromtwherecity=“苏州”orderbynamelimit100;用相同的方法,假设结果被存 进了内存数组B。
3.现在A和B是两个有序数组,然后你可以用归并排序的思想,得到name最小的前100值,就 是我们需要的结果了。
如果把这条SQL语句里“limit 100”改成“limit 10000,100”的话,处理方式其实也差不多,即:要把 上面的两条语句改成写:
select * from t where city="杭州" order by name limit 10100;
或
select * from t where city="杭州" order by name limit 10100;
这时候数据量较大,可以同时起两个连接一行行读结果,用归并排序算法拿到这两个结果集里, 按顺序取第10001~10100的name值,就是需要的结果了。
当然这个方案有一个明显的损失,就是从数据库返回给客户端的数据量变大了。 所以,如果数据的单行比较大的话,可以考虑把这两条SQL语句改成下面这种写法:
select * from t where city="杭州" order by name limit 10100;
和
select id,name from t where city="苏州" order by name limit 10100;
然后,再用归并排序的方法取得按name顺序第10001~10100的name、id的值,然后拿着这100 个id到数据库中去查出所有记录。
上面这些方法,需要你根据性能需求和开发的复杂度做出权衡。
从性能问题说起,说说MySQL 中的另外一种排序需求,希望能够加深对MySQL排序逻辑的理解。
这个英语学习App首页有一个随机显示单词的功能,也就是根据每个用户的级别有一个单词表, 然后这个用户每次访问首页的时候,都会随机滚动显示三个单词。他们发现随着单词表变大,选 单词这个逻辑变得越来越慢,甚至影响到了首页的打开速度。
现在,如果让你来设计这个SQL语句,你会怎么写呢?为了便于理解,我对这个例子进行了简化:去掉每个级别的用户都有一个对应的单词表这个逻 辑,直接就是从一个单词表中随机选出三个单词。这个表的建表语句和初始数据的命令如下:
mysql> CREATE TABLE `words` (
`id` int(11) NOT NULL AUTO_INCREMENT, `word` varchar(64) DEFAULT NULL, PRIMARY KEY (`id`)
) ENGINE=InnoDB;
为了便于量化说明,我在这个表里面插入了10000行记录。接下来,我们就一起看看要随机选择 3个单词,有什么方法实现,存在什么问题以及如何改进。
首先,你会想到用order by rand()来实现这个逻辑。
select word from words order by rand() limit 3;
这个语句的意思很直白,随机排序取前3个。虽然这个SQL语句写法很简单,但执行流程却有点 复杂的。先用explain命令来看看这个语句的执行情况。
Extra字段显示Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。
因此这个Extra的意思就是,需要临时表,并且需要在临时表上排序。
你可以先回顾一下上一篇文章中全字段排序和rowid排序的内容。我把上一篇文章的两个 流程图贴过来,方便你复习。
然后,我再问你一个问题,你觉得对于临时内存表的排序来说,它会选择哪一种算法呢?回顾一下上一篇文章的一个结论:对于InnoDB表来说,执行全字段排序会减少磁盘访问,因此会被 优先选择。
强调了“InnoDB表”,你肯定想到了,对于内存表,回表过程只是简单地根据数据行的位 置,直接访问内存得到数据,根本不会导致多访问磁盘。优化器没有了这一层顾虑,那么它 会优先考虑的,就是用于排序的行越少越好了,所以,MySQL这时就会选择rowid排序。
理解了这个算法选择的逻辑,我们再来看看语句的执行流程。同时,通过今天的这个例子,我们 来尝试分析一下语句的扫描行数。
这条语句的执行流程是这样的:
1.创建一个临时表。这个临时表使用的是memory引擎,表里有两个字段,第一个字段是 double类型,为了后面描述方便,记为字段R,第二个字段是varchar(64)类型,记为字段 W。并且,这个表没有建索引。
2.从words表中,按主键顺序取出所有的word值。对于每一个word值,调用rand()函数生成一 个大于0小于1的随机小数,并把这个随机小数和word分别存入临时表的R和W字段中,到此,扫描行数是10000。
3.现在临时表有10000行数据了,接下来你要在这个没有索引的内存临时表上,按照字段R排序。
4.初始化 sort_buffer。sort_buffer中有两个字段,一个是double类型,另一个是整型。
5.从内存临时表中一行一行地取出R值和位置信息(我后面会和你解释这里为什么是“位置信 息”),分别存入sort_buffer中的两个字段里。这个过程要对内存临时表做全表扫描,此时 扫描行数增加10000,变成了20000。
6.在sort_buffer中根据R的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫 描行数。
7.排序完成后,取出前三个结果的位置信息,依次到内存临时表中取出word值,返回给客户 端。这个过程中,访问了表的三行数据,总扫描行数变成了20003。
接下来,我们通过慢查询日志(slow log)来验证一下我们分析得到的扫描行数是否正确。
Query_time: 0.900376 Lock_time: 0.000347 Rows_sent: 3 Rows_examined: 20003
SET timestamp=1541402277;
select word from words order by rand() limit 3;
其中,Rows_examined:20003就表示这个语句执行过程中扫描了20003行,也就验证了我们分 析得出的结论。
这里插一句题外话,在平时学习概念的过程中,你可以经常这样做,先通过原理分析算出扫描行 数,然后再通过查看慢查询日志,来验证自己的结论。我自己就是经常这么做,这个过程很有 趣,分析对了开心,分析错了但是弄清楚了也很开心。
现在,我来把完整的排序执行流程图画出来。
图中的pos就是位置信息,你可能会觉得奇怪,这里的“位置信息”是个什么概念?在上一篇文章中,我们对InnoDB表排序的时候,明明用的还是ID字段。
这时候,我们就要回到一个基本概念:MySQL的表是用什么方法来定位“一行数据”的。
在前面第4和第5篇介绍索引的文章中,有几位同学问到,如果把一个InnoDB表的主键删掉,是 不是就没有主键,就没办法回表了?
其实不是的。如果你创建的表没有主键,或者把一个表的主键删掉了,那么InnoDB会自己生成 一个长度为6字节的rowid来作为主键。
这也就是排序模式里面,rowid名字的来历。实际上它表示的是:每个引擎用来唯一标识数据行 的信息。
对于有主键的InnoDB表来说,这个rowid就是主键ID;
对于没有主键的InnoDB表来说,这个rowid就是由系统生成的;
MEMORY引擎不是索引组织表。在这个例子里面,你可以认为它就是一个数组。因此,这个 rowid其实就是数组的下标。
到这里,我来稍微小结一下:order by rand()使用了内存临时表,内存临时表排序的时候使 用了rowid排序方法。
那么,是不是所有的临时表都是内存表呢?
其实不是的。tmp_table_size这个配置限制了内存临时表的大小,默认值是16M。如果临时表大小超过了tmp_table_size,那么内存临时表就会转成磁盘临时表。
磁盘临时表使用的引擎默认是InnoDB,是由参数internal_tmp_disk_storage_engine控制的。 当使用磁盘临时表的时候,对应的就是一个没有显式索引的InnoDB表的排序过程。
为了复现这个过程,我把tmp_table_size设置成1024,把sort_buffer_size设置成 32768, 把 max_length_for_sort_data 设置成16。
set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打开 optimizer_trace,只对本线程有效 */ SET optimizer_trace='enabled=on';
/* 执行语句 */
select word from words order by rand() limit 3;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
然后,我们来看一下这次OPTIMIZER_TRACE的结果。
因为将max_length_for_sort_data设置成16,小于word字段的长度定义,所以我们看到 sort_mode里面显示的是rowid排序,这个是符合预期的,参与排序的是随机值R字段和rowid字 段组成的行。
这时候你可能心算了一下,发现不对。R字段存放的随机值就8个字节,rowid是6个字节(至于 为什么是6字节,就留给你课后思考吧),数据总行数是10000,这样算出来就有140000字节, 超过了sort_buffer_size 定义的 32768字节了。但是,number_of_tmp_files的值居然是0,难道 不需要用临时文件吗?
这个SQL语句的排序确实没有用到临时文件,采用是MySQL 5.6版本引入的一个新的排序算法, 即:优先队列排序算法。接下来,我们就看看为什么没有使用临时文件的算法,也就是归并排序 算法,而是采用了优先队列排序算法。
其实,我们现在的SQL语句,只需要取R值最小的3个rowid。但是,如果使用归并排序算法的 话,虽然最终也能得到前3个值,但是这个算法结束后,已经将10000行数据都排好序了。
也就是说,后面的9997行也是有序的了。但,我们的查询并不需要这些数据是有序的。所以, 想一下就明白了,这浪费了非常多的计算量。
而优先队列算法,就可以精确地只得到三个最小值,执行流程如下:
1.对于这10000个准备排序的(R,rowid),先取前三行,构造成一个堆; (对数据结构印象模糊的同学,可以先设想成这是一个由三个元素组成的数组)
1.取下一个行(R’,rowid’),跟当前堆里面最大的R比较,如果R’小于R,把这个(R,rowid)从堆中去掉,换成(R’,rowid’);
2.重复第2步,直到第10000个(R’,rowid’)完成比较。
这里我简单画了一个优先队列排序过程的示意图。
图6是模拟6个(R,rowid)行,通过优先队列排序找到最小的三个R值的行的过程。整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。
图5的OPTIMIZER_TRACE结果中,filesort_priority_queue_optimization这个部分的 chosen=true,就表示使用了优先队列排序算法,这个过程不需要临时文件,因此对应的 number_of_tmp_files是0。
这个流程结束后,我们构造的堆里面,就是这个10000行里面R值最小的三行。然后,依次把它 们的rowid取出来,去临时表里面拿到word字段,这个过程就跟上一篇文章的rowid排序的过程一 样了。
我们再看一下上面一篇文章的SQL查询语句:
select city,name,age from t where city='杭州' order by name limit 1000
你可能会问,这里也用到了limit,为什么没用优先队列排序算法呢?原因是,这条SQL语句是 limit 1000,如果使用优先队列算法的话,需要维护的堆的大小就是1000行的(name,rowid),超 过了我设置的sort_buffer_size大小,所以只能使用归并排序算法。
总之,不论是使用哪种类型的临时表,order by rand()这种写法都会让计算过程非常复杂,需要 大量的扫描行数,因此排序过程的资源消耗也会很大。
再回到我们文章开头的问题,怎么正确地随机排序呢?
我们先把问题简化一下,如果只随机选择1个word值,可以怎么做呢?思路上是这样的:
1.取得这个表的主键id的最大值M和最小值N;
2.用随机函数生成一个最大值到最小值之间的数X=(M-N)*rand()+N;
3.取不小于X的第一个ID的行。
我们把这个算法,暂时称作随机算法1。这里,我直接给你贴一下执行语句的序列:
select max(id),min(id) into @M,@N from t ; set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;
这个方法效率很高,因为取max(id)和min(id)都是不需要扫描索引的,而第三步的select也可以用 索引快速定位,可以认为就只扫描了3行。但实际上,这个算法本身并不严格满足题目的随机要 求,因为ID中间可能有空洞,因此选择不同行的概率不一样,不是真正的随机。
比如你有4个id,分别是1、2、4、5,如果按照上面的方法,那么取到 id=4的这一行的概率是取 得其他行概率的两倍。
如果这四行的id分别是1、2、40000、40001呢?这个算法基本就能当bug来看待了。 所以,为了得到严格随机的结果,你可以用下面这个流程:
1.取得整个表的行数,并记为C。
2.取得Y=floor(C*rand())。floor函数在这里的作用,就是取整数部分。
3.再用limitY,1取得一行。
我们把这个算法,称为随机算法2。下面这段代码,就是上面流程的执行语句的序列。
select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1"); prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;
由于limit 后面的参数不能直接跟变量,所以我在上面的代码中使用了prepare+execute的方法。 你也可以把拼接SQL语句的方法写在应用程序中,会更简单些。
这个随机算法2,解决了算法1里面明显的概率不均匀问题。
MySQL处理limit Y,1 的做法就是按顺序一个一个地读出来,丢掉前Y个,然后把下一个记录作为 返回结果,因此这一步需要扫描Y+1行。再加上,第一步扫描的C行,总共需要扫描C+Y+1行, 执行代价比随机算法1的代价要高。
当然,随机算法2跟直接order by rand()比起来,执行代价还是小很多的。
你可能问了,如果按照这个表有10000行来计算的话,C=10000,要是随机到比较大的Y值,那 扫描行数也跟20000差不多了,接近order by rand()的扫描行数,为什么说随机算法2的代价要小 很多呢?我就把这个问题留给你去课后思考吧。
现在,我们再看看,如果我们按照随机算法2的思路,要随机取3个word值呢?你可以这么做:
1.取得整个表的行数,记为C;
2. 根据相同的随机方法得到Y1、Y2、Y3;
3.再执行三个limitY,1语句得到三行数据。 我们把这个算法,称作随机算法3。下面这段代码,就是上面流程的执行语句的序列。
select count(*) into @C from t; set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; //在应用代码里面取Y1、Y2、Y3值,拼出SQL后执行 select * from t limit @Y2,1;
select * from t limit @Y3,1;
今天这篇文章,我是借着随机排序的需求,跟你介绍了MySQL对临时表排序的执行过程。 如果你直接使用order by rand(),这个语句需要Using temporary 和 Using filesort,查询的执行代
价往往是比较大的。所以,在设计的时候你要量避开这种写法。
今天的例子里面,我们不是仅仅在数据库内部解决问题,还会让应用代码配合拼接SQL语句。在 实际应用的过程中,比较规范的用法就是:尽量将业务逻辑写在业务代码中,让数据库只做“读 写数据”的事情。因此,这类方法的应用还是比较广泛的。
上面的随机算法3的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),实际上它还是可以继续优化,来 进一步减少扫描行数的。
我的问题是,如果你是这个需求的开发人员,你会怎么做,来减少扫描行数呢?说说你的方案, 并说明你的方案需要的扫描行数。
取Y1、Y2和Y3里面最大的一个数,记为M,最小的一个数记为N,然后
执行下面这条SQL语句:
select * from t limit N, M-N+1;
再加上取整个表总行数的C行,这个方案的扫描行数总共只需要C+M+1行。
当然也可以先取回id值,在应用中确定了三个id值以后,再执行三次where id=X的语句也是可以的。
在MySQL中,有很多看上去逻辑相同,但性能却差异巨大的SQL语句。对这些语句使用不当的 话,就会不经意间导致整个数据库的压力变大。
假设现在维护了一个交易系统,其中交易记录表tradelog包含交易流水号(tradeid)、交易员 id(operator)、交易时间(t_modified)等字段。为了便于描述,我们先忽略其他字段。这个 表的建表语句如下:
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语句可能会这么 写:
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()函数对应的值。
如果你的SQL语句条件用的是where t_modified='2018-7-1’的话,引擎就会按照上面绿色箭头的
路线,快速定位到 t_modified='2018-7-1’需要的结果。
实际上,B+树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。
但是,如果计算month()函数的话,你会看到传入7的时候,在树的第一层就不知道该怎么办了。也就是说,对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器就决定放弃 走树搜索功能。
需要注意的是,优化器并不是要放弃使用这个索引。
在这个例子里,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引 t_modified,优化器对比索引大小后发现,索引t_modified更小,遍历这个索引比遍历主键索引 来得更快。因此最终还是会选择索引t_modified。
接下来,我们使用explain命令,查看一下这条SQL语句的执行结果。
key="t_modified"表示的是,使用了t_modified这个索引;我在测试表数据中插入了10万行数
据,rows=100335,说明这条语句扫描了整个索引的所有值;Extra字段的Using index,表示的 是使用了覆盖索引。
也就是说,由于在t_modified字段加了month()函数操作,导致了全索引扫描。为了能够用上索引 的快速定位能力,我们就要把SQL语句改成基于字段本身的范围查询。按照下面这个写法,优化 器就能按照我们预期的,用上t_modified索引的快速定位能力了。
select count(*) from tradelog where
-> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
-> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or
-> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');
当然,如果你的系统上线时间更早,或者后面又插入了之后年份的数据的话,你就需要再把其他 年份补齐。
到这里我给你说明了,由于加了month()函数操作,MySQL无法再使用索引快速定位功能,而只 能使用全索引扫描
不过优化器在个问题上确实有“偷懒”行为,即使是对于不改变有序性的函数,也不会考虑使用索 引。比如,对于select * from tradelog where id + 1 = 10000这个SQL语句,这个加1操作并不会 改变有序性,但是MySQL优化器还是不能用id索引快速定位到9999这一行。所以,需要你在写 SQL语句的时候,手动改写成 where id = 10000 -1才可以。
一起看一下这条SQL语句:
select * from tradelog where tradeid=110717;
交易编号tradeid这个字段上,本来就有索引,但是explain的结果却显示,这条语句需要走全表 扫描。你可能也发现了,tradeid的字段类型是varchar(32),而输入的参数却是整型,所以需要做 类型转换。
那么,现在这里就有两个问题:
1.数据类型转换的规则是什么?
2.为什么有数据类型转换,就需要走全索引扫描?
先来看第一个问题,你可能会说,数据库里面类型这么多,这种数据类型转换规则更多,我记不住,应该怎么办呢?
这里有一个简单的方法,看 select “10” > 9的结果:
1.如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是1;
2.如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是0。 验证结果如图3所示。
从图中可知,select “10” > 9返回的是1,所以你就能确认MySQL里的转换规则了:在MySQL
中,字符串和数字做比较的话,是将字符串转换成数字。
这时,你再看这个全表扫描的语句:
select * from tradelog where tradeid=110717;
就知道对于优化器来说,这个语句相当于:
select * from tradelog where CAST(tradid AS signed int) = 110717;
也就是说,这条语句触发了我们上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜 索功能。
在,我留给你一个小问题,id的类型是int,如果执行下面这个语句,是否会导致全表扫描呢?
elect * from tradelog where id=“83126”;
假设系统里还有另外一个表trade_detail,用于记录交易的操作细节。为了便于量化分析和复 现,我往交易日志表tradelog和交易详情表trade_detail这两个表里插入一些数据。
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,'aaaaaaa',1,'add');
insert into trade _detail values(2, 'aaaaaaa', 2, 'update');
insert into trade_ _detail values(3, 'aaaaaaa', 3, 'commit');
insert into trade_ detail values(4, 'aaaaaab', 1, 'add');
insert into trade_ detail values(5, 'aaaaaab', 2, 'update');
insert into trade_ detail values(6, 'aaaaaab', 3, 'update again');
insert into trade_ detail values(7, 'aaaaaab', 4, 'commit');
insert into trade_ _detail values(8, 'aaaaaac', 1, 'add');
insert into trade_ detail values(9, 'aaaaaac', 2, 'update');
insert into trade _detail values(10, 'aaaaaac', 3, 'update again');
insert into trade_ detail values(11, 'aaaaaaac', 4, 'commit');
这时候,如果要查询id=2的交易的所有操作步骤信息,SQL语句可以这么写:
select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /*语句Q1*/
一起来看下这个结果:
1.第一行显示优化器会先在交易记录表tradelog上查到id=2的行,这个步骤用上了主键索引,rows=1表示只扫描一行;
2.第二行key=NULL,表示没有用上交易详情表trade_detail上的tradeid索引,进行了全表扫描
在这个执行计划里,是从tradelog表中取tradeid字段,再去trade_detail表里查询匹配字段。因此,我们把tradelog称为驱动表,把trade_detail称为被驱动表,把tradeid称为关联字段。接下来,我们看下这个explain结果表示的执行流程:
图中:
第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语句的话,那就是:
select * from trade_detail where tradeid=$L2.tradeid.value;
其中,$L2.tradeid.value的字符集是utf8mb4。参照前面的两个例子,你肯定就想到了,字符集utf8mb4是utf8的超集,所以当这两个类型的字
符串在做比较的时候,MySQL内部的操作是,先把utf8字符串转成utf8mb4字符集,再做比较。这个设定很好理解,utf8mb4是utf8的超集。类似地,在程序设计语言里面,做自动类型转换 的时候,为了避免数据在转换过程中由于截断导致数据错误,也都是“按数据长度增加的方 向”进行转换的。
因此, 在执行上面这个语句的时候,需要将被驱动数据表里的字段一个个地转换成utf8mb4,再 跟L2做比较
也就是说,实际上这个语句等同于下面这个写法:
select*fromtrade_detail whereCONVERT(traideidUSINGutf8mb4)=$L2.tradeid.value;
CONVERT()函数,在这里的意思是把输入的字符串转成utf8mb4字符集。
这就再次触发了我们上面说到的原则:对索引字段做函数操作,优化器会放弃走树搜索功能。
到这里,你终于明确了,字符集不同只是条件之一,连接过程中要求在被驱动表的索引字段 上加函数操作,是直接导致对被驱动表做全表扫描的原因。作为对比验证,我给你提另外一个需求,“查找trade_detail表里id=4的操作,对应的操作者是 谁”,再来看下这个语句和它的执行计划。
select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;
这个语句里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语句的方法了。
select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;
这里,我主动把 l.tradeid转成utf8,就避免了被驱动表上的字符编码转换,从explain结果可以看到,这次索引走对了。
今天我给你举了三个例子,其实是在说同一件事儿,即:对索引字段做函数操作,可能会破坏 索引值的有序性,因此优化器就决定放弃走树搜索功能。
第二个例子是隐式类型转换,第三个例子是隐式字符编码转换,它们都跟第一个例子一样,因为 要求在索引字段上做函数操作而导致了全索引扫描。
MySQL的优化器确实有“偷懒”的嫌疑,即使简单地把where id+1=1000改写成where id=1000-1就 能够用上索引快速查找,也不会主动做这个语句重写。因此,每次你的业务代码升级时,把可能出现的、新的SQL语句explain一下,是一个很好的习 惯。
你遇到过别的、类似今天我们提到的性能问题吗?你认为原因是什 么,又是怎么解决的呢?
一般情况下,如果我跟你说查询性能优化,你首先会想到一些复杂的语句,想到查询需要返回大 量的数据。但有些情况下,“查一行”,也会执行得特别慢。今天,我就跟你聊聊这个有趣的话 题,看看什么情况下,会出现这个现象。需要说明的是,如果MySQL数据库本身就有很大的压力,导致数据库服务器CPU占用率很高或 ioutil(IO利用率)很高,这种情况下所有语句的执行都有可能变慢,不属于我们今天的讨论范 围。为了便于描述,还是构造一个表,基于这个表来说明今天的问题。这个表有两个字段id和c, 并且我在里面插入了10万行记录。
CREATE TABLE `t` ( `id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL, PRIMARY KEY (`id`)
) ENGINE=InnoDB;
delimiter ;;
create procedure idata() begin
declare i int;
set i=1; while(i<=100000)do
insert into t values(i,i);
set i=i+1; end while;
end;; delimiter ;
call idata();
接下来,我会用几个不同的场景来举例,有些是前面的文章中我们已经介绍过的知识点,你看看 能不能一眼看穿,来检验一下吧。
如图1所示,在表t执行下面的SQL语句: select * from t where id=1;查询结果长时间不返回。一般碰到这种情况的话,大概率是表t被锁住了。接下来分析原因的时候,一般都是首先执行一下show processlist命令,看看当前语句处于什么状态。然后我们再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。
如图2所示,就是使用show processlist命令查看Waiting for table metadata lock的示意图。
出现这个状态表示的是,现在有一个线程正在表t上请求或者持有MDL写锁,把select语句堵住了。如图3所示,我给出了简单的复现步骤。
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 命令断开即可。
接下来,我给你举另外一种查询被堵住的情况。我在表t上,执行下面的SQL语句 select * from information_schema.processlist where id=1;
你可以看一下图5。我查出来这个线程的状态是Waiting for table flush,你可以设想一下这是什 么原因。
这个状态表示的是,现在有一个线程正要对表t做flush操作。MySQL里面对表做flush操作的用法,一般有以下两个:
flush tables t with read lock;
flush tables with read lock;
这两个flush语句,如果指定表t的话,代表的是只关闭表t;如果没有指定具体的表名,则表示关 闭MySQL里所有打开的表。
但是正常这两个语句执行起来都很快,除非它们也被别的线程堵住了。
所以,出现Waiting for table flush状态的可能情况是:有一个flush tables命令被别的语句堵住了,然后它又堵住了我们的select语句。现在,我们一起来复现一下这种情况,复现步骤如图6所示:
在session A中,我故意每行都调用一次sleep(1),这样这个语句默认要执行10万秒,在这期间表
t一直是被session A“打开”着。然后,session B的flush tables t命令再要去关闭表t,就需要等 session A的查询结束。这样,session C要再次查询的话,就会被flush 命令堵住了。
图7是这个复现步骤的show processlist结果。这个例子的排查也很简单,你看到这个show processlist的结果,肯定就知道应该怎么做了。
现在,经过了表级锁的考验,我们的select 语句终于来到引擎里了。select * from t where id=1 lock in share mode;上面这条语句的用法你也很熟悉了,我们在第8篇《事务到底是隔离的还是不隔离的?》文章介 绍当前读时提到过。由于访问id=1这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我 们的select语句就会被堵住。复现步骤和现场如下:
显然,session A启动了事务,占有写锁,还不提交,是导致session B被堵住的原因。
这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果你用的是MySQL 5.7版本,可 以通过sys.innodb_lock_waits 表查到。
查询方法是:
select * from t sys.innodb_lock_waits where locked_table=`'test'.'t'`\G
可以看到,这个信息很全,4号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式,就是
KILL QUERY 4或KILL 4。不过,这里不应该显示“KILL QUERY 4”。这个命令表示停止4号线程当前正在执行的语句,而这 个方法其实是没有用的。因为占有行锁的是update语句,这个语句已经是之前执行完成了的, 现在执行KILL QUERY,无法让这个事务去掉id=1上的行锁。实际上,KILL 4才有效,也就是说直接断开这个连接。这里隐含的一个逻辑就是,连接被断开的 时候,会自动回滚这个连接里面正在执行的线程,也就释放了id=1上的行锁。
经过了重重封“锁”,我们再来看看一些查询慢的例子。 先来看一条你一定知道原因的SQL语句: select * from t where c=50000 limit 1;
由于字段c上没有索引,这个语句只能走id主键顺序扫描,因此需要扫描5万行。作为确认,你可以看一下慢查询日志。注意,这里为了把所有语句记录到slow log里,我在连接后先执行了 set long_query_time=0,将慢查询日志的时间阈值设置为0。
Rows_examined显示扫描了50000行。你可能会说,不是很慢呀,11.5毫秒就返回了,我们线上
一般都配置超过1秒才算慢查询。但你要记住:坏查询不一定是慢查询。我们这个例子里面只 有10万行记录,数据量大起来的话,执行时间就线性涨上去了。扫描行数多,所以执行慢,这个很好理解。但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。如图12所示,是这个例子的slow log。可以看到,执行的语句是 select * from t where id=1; 虽然扫描行数是1,但执行时间却长达800毫秒。
是不是有点奇怪呢,这些时间都花在哪里了?如果我把这个slow log的截图再往下拉一点,你可以看到下一个语句,select * from t where id=1 lock in share mode,执行时扫描行数也是1行,执行时间是0.2毫秒。
看上去是不是更奇怪了?按理说lock in share mode还要加锁,时间应该更长才对啊。可能有的同学已经有答案了。如果你还没有答案的话,我再给你一个提示信息,图14是这两个语句的执行输出结果。
第一个语句的查询结果里c=1,带lock in share mode的语句返回的是c=1000001。看到这里应该 有更多的同学知道原因了。如果你还是没有头绪的话,也别着急。我先跟你说明一下复现步骤,再分析原因。
你看到了,session A先用start transaction with consistent snapshot命令启动了一个事务,之后session B才开始执行update 语句。
session B执行完100万次update语句后,id=1这一行处于什么状态呢?你可以从图16中找到答 案。
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;
这个语句序列是怎么加锁的呢?加的锁又是什么时候释放呢?
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这一行加锁,而其他行的不加锁的话,会怎么样。
下面先来看一下这个场景(注意:这是我假设的一个场景):
可以看到,session A里执行了三次查询,分别是Q1、Q2和Q3。它们的SQL语句相同,都是
select * from t where d=5 for update。这个语句的意思你应该很清楚了,查所有d=5的行,而且 使用的是当前读,并且加上写锁。现在,我们来看一下这三条SQL语句,分别会返回什么结果。
1.Q1只返回id=5这一行
2.在T2时刻,sessionB把id=0这一行的d值改成了5,因此T3时刻Q2查出来的是id=0和id=5这两行
3.在T4时刻,sessionC又插入一行(1,1,5),因此T5时刻Q3查出来的是id=0、id=1和id=5的 这三行。
其中,Q3读到id=1这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查 询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。
这里,我需要对“幻读”做一个说明:
1.在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,
幻读在“当前读”下才会出现。
2.上面sessionB的修改结果,被sessionA之后的select语句用“当前读”看到,不能称为幻读。
幻读仅专指“新插入的行”。
因为这三个查询都是加了for update,都是当前读。而当前读的规则,就是要能读到所有已经提 交的记录的最新值。并且,session B和sessionC的两条语句,执行后就会提交,所以Q2和Q3就 是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。
但是,这是不是真的没问题呢?不,这里还真就有问题。
首先是语义上的。session A在T1时刻就声明了,“我要把所有d=5的行锁住,不准别的事务进行
读写操作”。而实际上,这个语义被破坏了。
如果现在这样看感觉还不明显的话,我再往session B和session C里面分别加一条SQL语句,你 再看看会出现什么现象。
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。
update的加锁语义和select …for update 是一致的,所以这时候加上这条update语句也很合理。
session A声明说“要给d=5的语句加上锁”,就是为了要更新数据,新加的这条update语句就是把 它认为加上了锁的这一行的d值修改成了100。
现在,我们来分析一下图3执行完成后,数据库里会是什么结果。
1.经过T1时刻,id=5这一行变成 (5,5,100),当然这个结果最终是在T6时刻正式提交的;
2.经过T2时刻,id=0这一行变成(0,5,5);
3.经过T4时刻,表里面多了一行(1,5,5);
4.其他行跟这个执行序列无关,保持不变。
这样看,这些数据也没啥问题,但是我们再来看看这时候binlog里面的内容。
1.T2时刻,sessionB事务提交,写入了两条语句;
2.T4时刻,sessionC事务提交,写入了两条语句;
3.T6时刻,sessionA事务提交,写入了updatetsetd=100whered=5这条语句。
我统一放到一起的话,就是这样的:
update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/
insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/*所有d=5的行,d改成100*/
好,你应该看出问题了。这个语句序列,不论是拿到备库去执行,还是以后用binlog来克隆一个 库,这三行的结果,都变成了 (0,5,100)、(1,5,100)和(5,5,100)。
也就是说,id=0和id=1这两行,发生了数据不一致。这个问题很严重,是不行的。
到这里,我们再回顾一下,这个数据不一致到底是怎么引入的?我们分析一下可以知道,这是我们假设“select * from t where d=5 for update这条语句只给d=5这 一行,也就是id=5的这一行加锁”导致的。所以我们认为,上面的设定不合理,要改。 那怎么改呢?我们把扫描过程中碰到的行,也都加上写锁,再来看看执行效果。
由于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个间隙。
这样,当你执行 select * from t where d=5 for update的时候,就不止是给数据库中已有的6个记
录加上了行锁,还同时加了7个间隙锁。这样就确保了无法再插入新的记录。 也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上
了间隙锁。
现在你知道了,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体。但是 间隙锁跟我们之前碰到过的锁都不太一样。
比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。
也就是说,跟行锁有冲突关系的是“另外一个行锁”。但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操 作。间隙锁之间都不存在冲突关系。这句话不太好理解,我给你举个例子:
这里session B并不会被堵住。因为表t里并没有c=7这个记录,因此session A加的是间隙锁
(5,10)。而session B也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允 许插入值。但,它们之间是不冲突的。
间隙锁和行锁合称next-key lock,每个next-key lock是前开后闭区间。也就是说,我们的表t初始 化以后,如果用select * from t for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。备注:这篇文章中,如果没有特别说明,我们把间隙锁记为开区间,把next-key lock记为前开 后闭区间。你可能会问说,这个supremum从哪儿来的呢? 这是因为+∞是开区间。实现上,InnoDB给每个索引加了一个不存在的最大值supremum,这样才符合我们前面说的“都是前开后闭区间”。间隙锁和next-key lock的引入,帮我们解决了幻读的问题,但同时也带来了一些“困扰”。
在前面的文章中,就有同学提到了这个问题。我把他的问题转述一下,对应到我们这个例子的表 来说,业务逻辑这样的:任意锁住一行,如果这一行不存在的话就插入,如果存在这一行就更新 它的数据,代码如下:
begin;
select * from t where id=N for update;
/*如果行不存在*/
insert into t values(N,N,N); /*如果行存在*/
update t set d=N set id=N;
commit;
可能你会说,这个不是insert …on duplicate key update 就能解决吗?但其实在有多个唯一键的时 候,这个方法是不能满足这位提问同学的需求的。至于为什么,我会在后面的文章中再展开说 明。
现在,我们就只讨论这个逻辑。
这个同学碰到的现象是,这个逻辑一旦有并发,就会碰到死锁。你一定也觉得奇怪,这个逻辑每
次操作前用for update锁起来,已经是最严格的模式了,怎么还会有死锁呢? 这里,我用两个session来模拟并发,并假设N=9。
你看到了,其实都不需要用到后面的update语句,就已经形成死锁了。我们按语句执行顺序来
分析一下:
1.sessionA执行select…forupdate语句,由于id=9这一行并不存在,因此会加上间隙锁
(5,10);
2.sessionB执行select…forupdate语句,同样会加上间隙锁(5,10),间隙锁之间不会冲突,因
此这个语句可以执行成功;
3.sessionB试图插入一行(9,9,9),被sessionA的间隙锁挡住了,只好进入等待;
4.sessionA试图插入一行(9,9,9),被sessionB的间隙锁挡住了。
至此,两个session进入互相等待状态,形成死锁。当然,InnoDB的死锁检测马上就发现了这对 死锁关系,让session A的insert语句报错返回了。
你现在知道了,间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并 发度的。其实,这还只是一个简单的例子,在下一篇文章中我们还会碰到更多、更复杂的例 子。
你可能会说,为了解决幻读的问题,我们引入了这么一大串内容,有没有更简单一点的处理方法 呢。
我在文章一开始就说过,如果没有特别说明,今天和你分析的问题都是在可重复读隔离级别下 的,间隙锁是在可重复读隔离级别下才会生效的。所以,你如果把隔离级别设置为读提交的话, 就没有间隙锁了。但同时,你要解决可能出现的数据和日志不一致问题,需要把binlog格式设置 为row。这,也是现在不少公司使用的配置组合。
今天我们从上一篇文章的课后问题说起,提到了全表扫描的加锁方式。我们发现即使给所有的行 都加上行锁,仍然无法解决幻读问题,因此引入了间隙锁的概念。
我碰到过很多对数据库有一定了解的业务开发人员,他们在设计数据表结构和业务SQL语句的时 候,对行锁有很准确的认识,但却很少考虑到间隙锁。最后的结果,就是生产库上会经常出现由 于间隙锁导致的死锁现象。
行锁确实比较直观,判断规则也相对简单,间隙锁的引入会影响系统的并发度,也增加了锁分析 的复杂度,但也有章可循。下一篇文章,我就会为你讲解InnoDB的加锁规则,帮你理顺这其中 的“章法”。
在上一篇文章中,我和你介绍了间隙锁和next-key lock的概念,但是并没有说明加锁规则。间隙 锁的概念理解起来确实有点儿难,尤其在配合上行锁以后,很容易在判断是否会出现锁等待的问 题上犯错。
所以今天,我们就先从这个加锁规则开始吧。
首先说明一下,这些加锁规则我没在别的地方看到过有类似的总结,以前我自己判断的时候都是 想着代码里面的实现来脑补的。这次为了总结成不看代码的同学也能理解的规则,是我又重新刷 了代码临时总结出来的。所以,说下这个规则:MySQL后面的版本可能会改变加锁策略,所以这个规则只限于截止到现在的最新版本,即 5.x系列<=5.7.24,8.0系列 <=8.0.13。
因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认 是可重复读隔离级别。
我总结的加锁规则里面,包含了两个“原则”、两个“优化”和一个“bug”。
1.原则1:加锁的基本单位是next-keylock。希望你还记得,next-keylock是前开后闭区间。
2.原则2:查找过程中访问到的对象才会加锁。
3.优化1:索引上的等值查询,给唯一索引加锁的时候,next-keylock退化为行锁。
4.优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁。
5.一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
我还是以上篇文章的表t为例,和你解释一下这些规则。表t的建表语句和初始化语句如下。
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
接下来的例子基本都是配合着图片说明的,所以我建议你可以对照着文稿看,有些例子可能 会“毁三观”,也建议你读完文章后亲手实践一下。