mysql学习笔记:基础架构

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

下面以以下SQL为例,分析其在MySQL中是如何执行的。

mysql> select * from T where ID=10;

MySQL基础架构示意图

MySQL的逻辑架构图

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

  • Server 层

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

  • 存储引擎层

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

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

连接器

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

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

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

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

  • 如果用户名或密码不对,你就会收到一个"Access denied for user"的错误,然后客户端程序结束执行。

  • 如果用户名密码认证通过,连接器会到权限表里面查出你拥有的权限。之后,这个连接里面的权限判断逻辑,都将依赖于此时读到的权限。这就意味着,一个用户成功建立连接后,即使你用管理员账号对这个用户的权限做了修改,也不会影响已经存在连接的权限。修改完成后,只有再新建的连接才会使用新的权限设置

连接状态和超时

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

mysql> show processlist;
+-------+------+---------------------+----------+---------+------+----------+------------------+
| Id    | User | Host                | db       | Command | Time | State    | Info             |
+-------+------+---------------------+----------+---------+------+----------+------------------+
| 16036 | root | 120.24.80.237:45346 | flowable | Sleep   |  590 |          | NULL             |
| 16037 | root | 120.24.80.237:45350 | flowable | Sleep   |  566 |          | NULL             |
... ...

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

mysql> show variables like  "wait_timeout";
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| wait_timeout  | 28800 |
+---------------+-------+
1 row in set (0.00 sec)

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

建立连接和维持连接的消耗

数据库里面,长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。建立连接的过程通常是比较复杂的,所以我建议你在使用中要尽量减少建立连接的动作,也就是尽量使用长连接。

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

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

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

查询缓存

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

MySQL 拿到一个查询请求后:

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

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

不建议使用缓存

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

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

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

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

分析器

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

分析器先会做“词法分析”。你输入的是由多个字符串和空格组成的一条 SQL 语句,MySQL 需要识别出里面的字符串分别是什么,代表什么。MySQL 从你输入的"select"这个关键字识别出来,这是一个查询语句。它也要把字符串“T”识别成“表名 T”,把字符串“ID”识别成“列 ID”。

做完了这些识别以后,结果就是生成一颗语法树,然后根据这颗树来做“语法分析”。根据词法分析的结果,语法分析器会根据语法规则,判断你输入的这个 SQL 语句是否满足 MySQL 语法,如要查询的字段或者条件字段在表中是否存在、各子句顺序是不是有误等。如果你的语句不对,就会收到You have an error in your SQL syntax的错误提醒,比如下面这个语句 select 少打了开头的字母“s”。

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

优化器

经过了分析器,MySQL 就知道你要做什么了。在开始执行之前,还要先经过优化器的处理。优化器是在表里面有多个索引的时候,决定使用哪个索引;或者在一个语句有多表关联(join)的时候,决定各个表的连接顺序。比如你执行下面这样的语句,这个语句是执行两个表的 join:

mysql> select * from t1 join t2 using(ID) where t1.c=10 and t2.d=20;

join是inner join的缩写 using(ID) 是 on t1.id == t2.id的简写 因为联表on条件是两张表中同一个字段,可以简写为using(字段名)

  • 既可以先从表 t1 里面取出 c=10 的记录的 ID 值,再根据 ID 值关联到表 t2,再判断 t2 里面 d 的值是否等于 20。
  • 也可以先从表 t2 里面取出 d=20 的记录的 ID 值,再根据 ID 值关联到 t1,再判断 t1 里面 c 的值是否等于 10。

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

优化器阶段完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段。

执行器

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

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

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

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

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

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

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

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

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

在有些场景下,执行器调用一次,在引擎内部则扫描了多行,因此引擎扫描行数跟 rows_examined 并不是完全相同的

二、日志系统:一条sql更新语句是如何执行的

MySQL 可以恢复到半个月内任意一秒的状态,这是怎样做到的呢?还是从一个表的一条更新语句说起,下面是这个表的创建语句,这个表有一个主键 ID 和一个整型字段 c:

mysql> create table T(ID int primary key, c int);

如果要将 ID=2 这一行的值加 1,SQL 语句就会这么写:

mysql> update T set c=c+1 where ID=2;

查询语句的那一套流程,更新语句也是同样会走一遍。

MySQL的逻辑架构图

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

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

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

与查询流程不一样的是,更新流程还涉及两个重要的日志模块:redo log(重做日志)和 binlog(归档日志)[2]

重要的日志模块:redo log

写磁盘的操作相对是成本比较高昂的操作,如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程 IO 成本、查找成本都很高。为了解决这个问题,MySQL 的设计者引进了 MySQL 里经常说到的 WAL 技术,WAL 的全称是 Write-Ahead Logging,它的关键点就是先写日志[3],再写磁盘。

具体来说,当有一条记录需要更新的时候:

  1. InnoDB 引擎就会先把记录写到 redo log 里面。

    将更新数据页的改动写入redo中记录,这样就可以不用直接同步数据库刷磁盘,减少了写入磁盘IO动作,下次等空闲、内存不足、redo满了时就会 ”刷脏“。

  2. 并更新内存,这个时候更新就算完成了。

    这个动作是将当前更新内容更新到内存中,如果更新内容在内存中不存在就会涉及到先读入内存,在更新的操作。后面引入了change buffer时,你会发现存在与不存在内存都不用读磁盘,用change buffer解决,减少了读磁盘IO的操作,提高了性能

  3. 同时,InnoDB 引擎会在适当的时候,将这个操作记录更新到磁盘里面,而这个更新往往是在系统比较空闲的时候做。

    这个动作叫 ”刷脏“,其实就是将第二步写在内存(buffer)中的数据页更新到位于磁盘中的数据文件,并移动 redo log 的 checkpoint 。

    刷脏时机:

    1. 后台线程定期会刷脏页
    2. 清理LRU链表时会顺带刷脏页
    3. redoLog写满会强制刷
    4. 数据库关闭时会将所有脏页刷回磁盘
    5. 脏页数量过多(默认占缓冲池75%)时,会强制刷

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

redolog

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

从 write pos 开始到 checkpoint 之间还空着的部分,可以用来记录新的操作。如果 write pos 追上 checkpoint,这时候不能再执行新的更新,得停下来先擦掉一些记录,把 checkpoint 推进一下[4:1]

有了 redo log ,InnoDB 就可以保证即使数据库发生异常重启,之前提交的记录都不会丢失(宕机重启的时候,反过来从 checkpoint 开始到 write pos 之间的位置就是还没刷盘的日志,直接拿这段日志出来重放操作,即可恢复宕机前 buffer pool 中的数据页),这个能力称为 crash-safe。

redo log buffer

在一个事务的更新过程中,涉及多个操作,redo log 是要写很多次的,所以在更新过程的 redo log 写入都是先写入到一块内存空间的,它就是 redo log buffer,在事务提交的时候才真正把日志写到 redo log 文件(文件名是 ib_logfile+数字)(当然,这还要取决于innodb_flush_log_at_trx_commit变量如何配置,如果是1才会在commit的时候进行fsync) 。

这样在减少磁盘访问的同时也保证在 commit 之前没有将 redo log 写入同步到 redo log 文件,减少因为回滚带来的 redo log 补偿操作。另外需要注意的是,如果 redo log buffer 写满或者紧张的时候,也还是会提前写入到 redo log 文件中的,所以我们需要尽量避免长事务。

重要的日志模块:binlog

MySQL 整体来看,其实就有两块:一块是 Server 层,它主要做的是 MySQL 功能层面的事情;还有一块是引擎层,负责存储相关的具体事宜。 redo log 是 InnoDB 引擎特有的日志,而 Server 层也有自己的日志,称为 binlog(归档日志)。

使用 binlog 恢复数据操作

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

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

  • 首先,找到最近的一次全量备份,如果你运气好,可能就是昨天晚上的一个备份,从这个备份恢复到临时库;

  • 然后,从备份的时间点开始,将备份的 binlog 依次取出来,重放到中午误删表之前的那个时刻。

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

好了,说完了数据恢复过程,我们回来说说,为什么日志需要“两阶段提交”。这里不妨用反证法来进行解释。由于 redo log 和 binlog 是两个独立的逻辑,如果不用两阶段提交,要么就是先写完 redo log 再写 binlog,或者采用反过来的顺序。我们看看这两种方式会有什么问题。

为什么会有两份日志呢?

因为最开始 MySQL 里并没有 InnoDB 引擎。MySQL 自带的引擎是 MyISAM,但是 MyISAM 没有 crash-safe 的能力,binlog 日志只能用于归档,即记录用户操作的信息,它没有 crash-safe 能力。

而 InnoDB 是另一个公司以插件形式引入 MySQL 的,既然只依靠 binlog 是没有 crash-safe 能力的,所以 InnoDB 直接使用了自己原有的一套支持崩溃恢复和支持事务的日志系统——也就是 redo log 来实现 crash-safe 能力

crash-safe

首先什么是 crash-safe,我们更新的数据它并不是立刻就写到它最终要去的数据文件中的。出于性能方面的考虑:

  1. 它会先写到 redo log 中,而对于 redo log 本身来说,根据用户的配置不同[5][6]
    • 它可能 write 到redo log中就可以了,在特定场景下才会将buffer中的数据同步到磁盘文件中;
    • 或者每次都直接写到磁盘文件中,但是因为 redo log 是顺序IO,非更新数据文件中涉及的随机 IO,所以效率较高。
  2. 然后在内存中更新对应的数据页,就完成了一次数据的更新,之后的数据读取都可以直接读取内存中的数据页。之后在特定的场景再将内存中的数据页同步到磁盘文件中。

所以如果此时 MySQL 发生了 crash,那么在内存中还未同步到磁盘数据文件的数据页将会丢失。在之后重启 MySQL 的时候,MySQL 则会读取 redo log 对这部分数据进行恢复,主要还是上面提到的两个指针,write_pos 和 check_point ,位于 write_pos 之后 、check_point 之前的数据(改动)都是没有同步到数据文件中的,所以 MySQL 可以根据这区间的日志恢复 crash 之前在内存中丢失的数据页。(注意:这里假设采取的是每次写 redo log 都是直接写到磁盘,如果是写到内存,那么在 crash 之前如果 redo log 的 file system cache 没有落盘,对应的数据页页在内存中没有同步到数据文件,那么这部分数据就丢失了)。

上面讲的就是 carsh-safe ,它指的是 MySQL 对于自身已经提交的事务( commit )给予的持久化保证,不会说明明已经 commit 一个更新了,因为你 MySQL 自身的一些性能优化就会导致我数据库重启之后这个更新又不见了。

另外,这个和我们误删数据或者代码 bug 产生脏数据的时候拿 binlog 来恢复数据是不一样的,这个是我们的业务问题,前者是 MySQL 给我们的持久化语义保证。

redo log和binlog的不同

从上面对于 MySQL 的 crash-safe 过程和 binlog 没有关系,单独使用 redo log 即完成了。那为什么还要 binlog 呢?去掉它不行吗?主要从这两种日志的不同点进行分析,有以下三点不同:

  1. redo log 是 InnoDB 引擎特有的;

    binlog 是 MySQL 的 Server 层实现的,所有引擎都可以使用。

  2. redo log 是物理日志,记录的是“在某个数据页上做了什么修改”,不是记录数据页“更新之后的状态”,使用的格式是 InnoDB 独有的格式,所以它支持数据页级别的恢复;

    binlog 是逻辑日志,记录的是这个语句的原始逻辑,比如“给 ID=2 这一行的 c 字段加 1 ”。逻辑日志可以给别的数据库,别的引擎使用。binlog有两种模式,statement 格式的话是记sql语句, row格式会记录行的内容,记两条,更新前和更新后都有。binlog 的记录相对 redo log 来说是比较"粗"的,所以它无法实现 cras-safe。

  3. redo log 是循环写的,空间固定会用完,它记录的信息不会持久保存;

    binlog 是可以追加写入的。“追加写”是指 binlog 文件写到一定大小后会切换到下一个,并不会覆盖以前的日志。binlog的“归档”这个功能,redolog是不具备的。

  4. MySQL 系统依赖于 binlog。binlog 作为 MySQL 一开始就有的功能,被用在了很多地方。其中,MySQL 系统高可用的基础,就是 binlog 复制。还有很多公司有异构系统(比如一些数据分析系统),这些系统就靠消费 MySQL 的 binlog 来更新自己的数据。关掉 binlog 的话,这些下游系统就没法输入了。总之,由于现在包括 MySQL 高可用在内的很多系统机制都依赖于 binlog,所以“鸠占鹊巢”redo log 还做不到。

所以我们基于现有的实现只能保存两份日志,而两份日志都会记录了数据的状态,所以此时就要解决这两份日志的一致性问题,不能让它们对于某一份数据出现不同的记录。如果它们不一致会出现什么样的后果呢?假设在一次数据库重启之后,redo log 中记录的某个数据值为 1,但是因为一致性问题,binlog 中的值是 0 ,此时 MySQL 自身是按照 redo log 在重启的时候恢复数据的。而在此一段时间后我们因为某些业务问题需要读取 binlog 进行数据还原,此时还原出来的数据 0 将会和重启之后的数据库中的数据 1 不一样。

  • 有可能是 binlog 先写了,redo log 还没写就 crash 了,此时我们根据 binlog 还原出来的 0 值对于重启之后的数据库来说相当于多了一个更新操作
  • 有可能是 redo log 先写了,binlog 还没写就 crash 了,此时我们根据 binlog 还原出来的 0 值就相当于少了一个操作

当然,用户可以选择配置关闭 binlog 的功能,这样就相当于不要 binlog 了,也没有需要同步两份日志一说了。

保证两份日志一致

基于上面过程,MySQL 必须保证两份 log 一致,MySQL 本身是基于 redo log 实现事务等功能等,一条数据更新的 redo log 写完成就意味着它的更新操作完成了,所以应该先保证 redo log 记录成功(物理更新)再进行 binlog 的记录(业务操作逻辑记录),然后返回执行成功的结果给用户。而两份日志都有着自己的格式,都以某种格式记录着一个完整的事务(statement 格式的 binlog,最后会有 COMMIT;row 格式的 binlog,最后会有一个 XID event 。另外,在 MySQL 5.6.2 版本以后,还引入了 binlog-checksum 参数,用来验证 binlog 内容的正确性。对于 binlog 日志由于磁盘原因,可能会在日志中间出错的情况,MySQL 可以通过校验 checksum 的结果来发现。),使得 MySQL 可以分析它们的完整性。所以 MySQL 每次重启根据 redo log 恢复那些还没同步到数据文件的内存页数据的时候,可以拿到其中每一条事务的事务ID(XID)到 binlog 中按照 binlog 格式查找该 XID 是否存在以及事务是否完整而得知该事务是否有效(用户提交一个事务的完成标识就是 MySQL 完成了该事务在 binlog 中的写入),无效的事务无需恢复并删除即可,同时修复 binlog 。

以上描述的是发生 crash 、重启之后如何保证两份日志一致,即以 binlog 事务是否完整为准进行数据恢复并修复同步修复两份日志。这个做法有个很明显的问题就是,需要对 redo log 中需要恢复的每一个事务都要到 binlog 中找到对应的事务并按其格式对其进行分析该事务是否完整,这是一个比较耗时的过程。所以做出以下改动:在 binlog 写完一个事务之后,对 redo log 中该事务中一个特定标识位置为一个特定的标识,表示 binlog 对于这个事务已经完成写入了,即事务已提交。这样重启之后就不用对每一个事务都需要到 binlog 中检查了,只需要那些没有被置为提交状态的事务进行检查即可(准确来说应该是准备状态的事务)。

在 MySQL 运行过程中是否会发生不一致呢?不会,因为无论是因为系统错误(如磁盘错误等)还是用户主动回滚,MySQL 都会有对应的逻辑对两份日志进行相应的更新。只要 MySQL 还在运行中,它就会对两份日志进行同步。

参考下面的数据更新执行过程例子参考日志的写入过程。

数据更新执行过程

先来看执行器和 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 内部执行的,深色框表示是在执行器中执行的。

update 语句执行流程

最后三步看上去有点“绕”,将 redo log 的写入拆成了两个步骤:prepare 和 commit,这就是"两阶段提交"。即将"两个事务"[7]的所有的重操作放在 prepare 阶段去做,最后由一个 commit 操作来同一提交两个事务的完成状态。结合上面讲到的内容,MySQL 在重启的时候会检查 redo log 中的事务,所有位于 write_pos 之后、check point 之前的事务如果是 commit 状态的,将其恢复到内存数据页中或者同步到数据文件(这些都是还没同步到数据文件中的事务);如果是 prepare 状态的,说明这个事务可能写了 binlog ,但是没有提交,去 binlog 中查找这个事务,如果事务存在且完整,则恢复这个事务并重新 commit 这个事务,否则删除这个事务并修复 binlog。

commit 动作(对 redo log 中的事务标识为提交状态)不能在完成 redo log 事务写入后就进行(即违反两阶段提交,每一个事务完成后都直接提交),因为 binlog 是有可能写入失败的,这时候将会导致重启的时候 redo log 读到一个 commit 状态的事务立即对其恢复,但是实际上他写入 binlog 的时候失败了,返回给用户也是失败返回。此时将产生了日志不一致,呈现给用户的也是逻辑物理不一致的状态。此时 redo log 中的 commit 标识位将会起到本末倒置的作用,完全无法识别一个事务是否完成了(完整写入了binlog),还是要对于每个事务都到 binlog 中检查完整性。

除此之外,和分布式事务比较类似的点就是降低风险可能性。

在分布式事务中,涉及多个子事务的一致性问题,如果一个子事务已经提交了,此时另外一个子事务失败了,我们需要对已经提交的子事务进行补偿(此时的分布式事务是一个最终一致性/弱一致性),补偿有时候是一个不是那么容易做的工作。所以我们希望通过将这种情况的可能性降到最低,以达到几乎可以忽略的情况,这样我们可以对补偿的动作进行特殊处理(或者说不处理,通过业务方案解决)。此时我们将操作比较重、耗时较长、容易出错的业务处理过程和操作简单、耗时极短、不易出错的提交动作分开,前者为 prepare 阶段,后者为 commit 阶段。所有完成 prepare 阶段的子事务发出 commit 请求,事务管理器收齐所有子事务的请求之后进入 commit 阶段,下发 commit 到各个子事务进行提交;而一旦有一个子事务无法完成 prepare ,事务管理器下发 rollback ,此时各子事务直接调用本地存储器提供的 rollback 回滚即可,而无需自己写大量的补偿逻辑。此时我们将"在其它子事务提交之后另一个子事务执行/提交失败"的可能性由某个事务的整个执行、提交周期降低到了事务的简单的提交周期。大大降低了需要补偿的可能性。

回到 MySQL 自身,现在我们先不讲上面提到的两阶段 commit 给 MySQL 面对 crash 重启之后带来的好处。考虑在 MySQL 运行过程中如果使用立即提交的方式进行两份日志的维护,那么在 redo log 提交之后,如果 binlog 写失败了,此时就需要回滚 redo log 的提交,可能不仅仅是修改 redo log 中的标识位那么简单了,如果 redo log 发生了 check point 前推(MySQL 按需将已经 commit 的事务同步到磁盘数据文件,释放 redo log 空间),即对应的数据已经写入数据文件了,则需要同时补偿恢复内穿+磁盘中对应的数据页。而如果采用两阶段提交,此时就将失败的可能性从整个写 binlog 的区间降到了一个简单的修改 redo log 的 commit 标识期间,这几乎是可以忽略的。

binlog 的写入机制

其实,binlog 的写入逻辑比较简单:事务执行过程中,先把日志写到 binlog cache,事务提交的时候,再把 binlog cache 写到 binlog 文件中。一个事务的 binlog 是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到了 binlog cache 的保存问题。

系统给 binlog cache 分配了一片内存,每个线程一个,参数 binlog_cache_size 用于控制单个线程内 binlog cache 所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。

事务提交的时候,执行器把 binlog cache 里的完整事务写入到 binlog 中,并清空 binlog cache。

binlog 写盘状态

可以看到,每个线程有自己 binlog cache,但是共用同一份 binlog 文件。

  • 图中的 write,指的就是指把日志写入到文件系统的 page cache,并没有把数据持久化到磁盘,所以速度比较快。
  • 图中的 fsync,才是将数据持久化到磁盘的操作。一般情况下,我们认为 fsync 才占磁盘的 IOPS。

write 和 fsync 的时机,是由参数 sync_binlog 控制的:

  1. sync_binlog=0 的时候,表示每次提交事务都只 write,不 fsync;
  2. sync_binlog=1 的时候,表示每次提交事务都会执行 fsync;
  3. sync_binlog=N(N>1) 的时候,表示每次提交事务都 write,但累积 N 个事务后才 fsync。

因此,在出现 IO 瓶颈的场景里,将 sync_binlog 设置成一个比较大的值,可以提升性能。在实际的业务场景中,考虑到丢失日志量的可控性,一般不建议将这个参数设成 0,比较常见的是将其设置为 100~1000 中的某个数值。但是,将 sync_binlog 设置为 N,对应的风险是:如果主机发生异常重启(是主机重启,而不是 MySQL 重启,如果是 MySQL crash 重启,无论sync_binlog如何设置,已提交事务的 binlog 都 write 到了文件系统的 page cache 了),会丢失最近 N 个事务的 binlog 日志。

redo log 的写入机制

事务在执行过程中,生成的 redo log 是要先写到 redo log buffer 的。redo log buffer 里面的内容,不是每次生成后都要直接持久化到磁盘的。如果事务执行期间 MySQL 发生异常重启,那这部分日志就丢了。由于事务并没有提交,所以这时日志丢了也不会有损失。那么,另外一个问题是,事务还没提交的时候,redo log buffer 中的部分日志有没有可能被持久化到磁盘呢?答案是,确实会有。这个问题,要从 redo log 可能存在的三种状态说起。这三种状态,对应的就是图中的三个颜色块。

MySQL redo log 存储状态

这三种状态分别是:

  1. 存在 redo log buffer 中,物理上是在 MySQL 进程内存中,就是图中的红色部分;
  2. 写到磁盘 (write),但是没有持久化(fsync),物理上是在文件系统的 page cache 里面,也就是图中的黄色部分;
  3. 持久化到磁盘,对应的是 hard disk,也就是图中的绿色部分。

日志写到 redo log buffer 是很快的,wirte 到 page cache 也差不多,但是持久化到磁盘的速度就慢多了。为了控制 redo log 的写入策略,InnoDB 提供了 innodb_flush_log_at_trx_commit 参数,它有三种可能取值:

  1. 设置为 0 的时候,表示每次事务提交时都只是把 redo log 留在 redo log buffer 中 ;
  2. 设置为 1 的时候,表示每次事务提交时都将 redo log 直接持久化到磁盘;
  3. 设置为 2 的时候,表示每次事务提交时都只是把 redo log 写到 page cache。

需要注意的是,被动触发持久化的 redo log 中包含的事务如果没有被用户主动提交,是不会有 prepare 状态的。也就是说 redo log 的 prepare 的准备是为了提交事务,只有用户执行了提交事务语句才会将该事务的 redo log 置为 prepare 状态。

未提交事务 redo log 持久化情况

  1. InnoDB 有一个后台线程,每隔 1 秒,就会把 redo log buffer 中的日志,调用 write 写到文件系统的 page cache,然后调用 fsync 持久化到磁盘。注意,事务执行中间过程的 redo log 也是直接写在 redo log buffer 中的,这些 redo log 也会被后台线程一起持久化到磁盘。
  2. redo log buffer 占用的空间即将达到 innodb_log_buffer_size 一半的时候,后台线程会主动写盘。注意,由于这个事务并没有提交,所以这个写盘动作只是 write,而没有调用 fsync,也就是只留在了文件系统的 page cache。
  3. 并行的事务提交的时候,顺带将这个事务的 redo log buffer 持久化到磁盘。假设一个事务 A 执行到一半,已经写了一些 redo log 到 buffer 中,这时候有另外一个线程的事务 B 提交,如果 innodb_flush_log_at_trx_commit 设置的是 1,那么按照这个参数的逻辑,事务 B 要把 redo log buffer 里的日志全部持久化到磁盘。这时候,就会带上事务 A 在 redo log buffer 里的日志一起持久化到磁盘。

以上情况都是因为对于每一个事务,它们都是共享的一个 redo log buffer,写入过程可能是交叉写入的,那么对于该 buffer 的每一次 fsync 固然就是将 buffer 中的所有内容持久化到磁盘上该 buffer 对应的文件了,所以此时只要存在 buffer 中的数据都会被持久化,不管是否完整事务(为什么 binlog cache 是每线程一份,redo log buffer 是全局共享,个人认为是因为前者是逻辑日志,后者是物理日志)。

两阶段提交时序上 redo log 先 prepare, 再写 binlog,最后再把 redo log commit。如果把 innodb_flush_log_at_trx_commit 设置成 1,那么 redo log 在 prepare 阶段就要持久化 fsync 一次(实际上会在 binlog cache write 之后、fsync 之前发生,参考下面的组提交介绍),因为有一个崩溃恢复逻辑是要依赖于 prepare 的 redo log,再加上 binlog 来恢复的。每秒一次后台轮询刷盘,再加上崩溃恢复这个逻辑,InnoDB 就认为 redo log 在 commit 的时候就不需要 fsync 了,只会 write 到文件系统的 page cache 中就够了。

组提交

通常我们说 MySQL 的“双 1”配置,指的就是 sync_binloginnodb_flush_log_at_trx_commit 都设置成 1。也就是说,一个事务完整提交前,需要等待两次刷盘,一次是 redo log(prepare 阶段),一次是 binlog。这时候,可能有一个疑问,这意味着从 MySQL 看到的 TPS 是每秒两万的话,每秒就会写四万次磁盘。但是,用工具测试出来,磁盘能力也就两万左右,怎么能实现两万的 TPS?解释这个问题,就要用到组提交(group commit)机制了。

这里,需要先介绍日志逻辑序列号(log sequence number,LSN)的概念。LSN 是单调递增的,用来对应 redo log 的一个个写入点。每次写入长度为 length 的 redo log, LSN 的值就会加上 length。LSN 也会写到 InnoDB 的数据页中,来确保数据页不会被多次执行重复的 redo log。如图所示,是三个并发事务 (trx1, trx2, trx3) 在 prepare 阶段,都写完 redo log buffer,持久化到磁盘的过程,对应的 LSN 分别是 50、120 和 160。

redo log 组提交

从图中可以看到,

  1. trx1 是第一个到达的,会被选为这组的 leader;
  2. 等 trx1 要开始写盘的时候,这个组里面已经有了三个事务,这时候 LSN 也变成了 160;
  3. trx1 去写盘的时候,带的就是 LSN=160,因此等 trx1 返回时,所有 LSN 小于等于 160 的 redo log,都已经被持久化到磁盘;
  4. 这时候 trx2 和 trx3 就可以直接返回了。

其实就是上面提到的并发事务的时候,一个事务的提交会导致其它事务的 log 一起从 redo log buffer 进行持久化,只不过这里的"其它事务" trx2 和 trx3 都是已经提交的事务

所以,一次组提交里面,组员越多,节约磁盘 IOPS 的效果越好。但如果只有单线程压测,那就只能老老实实地一个事务对应一次持久化操作了。在并发更新场景下,第一个事务写完 redo log buffer 以后,接下来这个 fsync 越晚调用,组员可能越多,节约 IOPS 的效果就越好。

为了让一次 fsync 带的组员更多,MySQL 有一个很有趣的优化:拖时间。以下是两阶段提交的简图:

两阶段提交

但实际上,写 binlog 是分成两步的:

  1. 先把 binlog 从 binlog cache 中 write 到磁盘上的 binlog 文件;
  2. 调用 fsync 持久化。

MySQL 为了让组提交的效果更好,把 redo log 做 fsync 的时间拖到了步骤 1 之后。也就是说,上面的图变成了这样:

两阶段提交细化

这么一来,binlog 也可以组提交了。在执行第 4 步把 binlog fsync 到磁盘时,如果有多个事务的 binlog 已经写完了,也是一起持久化的,这样也可以减少 IOPS 的消耗。不过通常情况下第 3 步执行得会很快,所以 binlog 的 write 和 fsync 间的间隔时间短,导致能集合到一起持久化的 binlog 比较少,因此 binlog 的组提交的效果通常不如 redo log 的效果那么好。

如果你想提升 binlog 组提交的效果,可以通过设置 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count 来实现。

  1. binlog_group_commit_sync_delay 参数,表示延迟多少微秒后才调用 fsync;
  2. binlog_group_commit_sync_no_delay_count 参数,表示累积多少次以后才调用 fsync。

这两个条件是或的关系,也就是说只要有一个满足条件就会调用 fsync。也就是说,当 binlog_group_commit_sync_delay 设置为 0 的时候,binlog_group_commit_sync_no_delay_count 也无效了。

小结

WAL 带来的好处

WAL 机制主要得益于两个方面:

  1. redo log 和 binlog 都是顺序写,磁盘的顺序写比随机写速度要快;
  2. 组提交机制,可以大幅度降低磁盘的 IOPS 消耗。

总结日志写入流程

  1. 用户开启事务并执行相关语句
  2. 写 redo log buffer
  3. 写 binlog cache
  4. 用户提交事务
  5. 根据参数 innodb_flush_log_at_trx_commit 决定实际操作
    • 0:不做操作(留给后台每秒一次将 redo log buffer write and fsync 到文件)
    • 1 和 2:将 redo log buffer write 到 redo log 文件
  6. 将 binlog cache 进行执行 write 写入文件
  7. 如果 innodb_flush_log_at_trx_commit 为1:
    • 是:fsync redo log
    • 否:什么也不做
      • 后台线程每秒也会触发一次将 redo log buffer write and fsync redo log 文件
      • redo log buffer 过载也会触发 write and fsync 到 redo log 文件
      • 其它事务触发 redo log buffer write and fsync 到 redo log 文件
  8. 根据参数 sync_binlog 决定实际操作:
    • 0:不做操作
    • N(N >= 1):提交事务累积次数是否到达 N
      • 是:根据参数 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count决定是否要立刻执行 fsync:
        • 是:立刻
        • 否:延迟
      • 否:不做操作
  9. write commit redo log buffer
  10. 返回用户界面事务提交成功

针对 MySQL 写入日志相关的优化

如果你的 MySQL 现在出现了性能瓶颈,而且瓶颈在 IO 上,可以通过哪些方法来提升性能呢?针对这个问题,可以考虑以下三种方法:

  1. 设置 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count 参数,减少 binlog 的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险(因为这两个参数是延迟了用户提交事务时的返回,提高了事务失败的风险,但是没有丢失数据的风险)
    • 如果是从库设置了 binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count ,会导致一直延迟的情况。在主库设置这两个参数,是为了减少 binlog 的写盘压力。备库这么设置,尤其在“快要追上”的时候,就反而会受这两个参数的拖累。
  2. sync_binlog 设置为大于 1 的值(比较常见是 100~1000)。这样做的风险是,主机掉电时会丢 binlog 日志。
  3. innodb_flush_log_at_trx_commit 设置为 2。这样做的风险是,主机掉电的时候会丢数据。

不建议你把 innodb_flush_log_at_trx_commit 设置成 0。因为把这个参数设置成 0,表示 redo log 只保存在内存中,这样的话 MySQL 本身异常重启也会丢数据,风险太大。而 redo log 写到文件系统的 page cache 的速度也是很快的,所以将这个参数设置成 2 跟设置成 0 其实性能差不多,但这样做 MySQL 异常重启时就不会丢数据了,相比之下风险会更小。

把线上生产库设置成“非双 1”场景

  1. 业务高峰期。一般如果有预知的高峰期,DBA 会有预案,把主库设置成“非双 1”。
  2. 备库延迟,为了让备库尽快赶上主库。
  3. 用备份恢复主库的副本,应用 binlog 的过程,这个跟上一种场景类似。
  4. 批量导入数据的时候。

一般情况下,把生产库改成“非双 1”配置,是设置 innodb_flush_logs_at_trx_commit=2、sync_binlog=1000。

MySQL 的 crash-safe 保证

如果 binlog 写完盘以后发生 crash,这时候还没给客户端答复就重启了。等客户端再重连进来,发现事务已经提交成功了,这是不是 bug?

回答:不是。你可以设想一下更极端的情况,整个事务都提交成功了,redo log commit 完成了,备库也收到 binlog 并执行了。但是主库和客户端网络断开了,导致事务成功的包返回不回去,这时候客户端也会收到“网络断开”的异常。这种也只能算是事务成功的,不能认为是 bug。

实际上数据库的 crash-safe 保证的是:

  1. 如果客户端收到事务成功的消息,事务就一定持久化了;
  2. 如果客户端收到事务失败(比如主键冲突、回滚等)的消息,事务就一定失败了;
  3. 如果客户端收到“执行异常”的消息,应用需要重连后通过查询当前状态来继续后续的逻辑。此时数据库只需要保证内部(数据和日志之间,主库和备库之间)一致就可以了。

如果update的新值和旧值一致 MySQL 如何处理

创建一个简单的表 t,并插入一行,然后对这一行做修改。

mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL primary key auto_increment,
`a` int(11) DEFAULT NULL
) ENGINE=InnoDB;
insert into t values(1,2);

这时候,表 t 里有唯一的一行数据 (1,2)。假设,执行:

mysql> update t set a=2 where id=1;

update旧值与新值一致

结果显示,匹配 (rows matched) 了一行,修改 (Changed) 了 0 行。

仅从现象上看,MySQL 内部在处理这个命令的时候,可以有以下三种选择:

  1. 更新都是先读后写的,MySQL Server 层读出数据,发现 a 的值本来就是 2,不更新,直接返回,执行结束;
  2. MySQL Server 调用了 InnoDB 引擎提供的“修改为 (1,2)”这个接口,但是引擎发现值与原来相同,不更新,直接返回;
  3. InnoDB 认真执行了“把这个值修改成 (1,2)"这个操作,该加锁的加锁,该更新的更新。

验证

  1. 第一个选项

    从加锁角度验证:

    锁验证方式

    session B 的 update 语句被 blocked (行锁)了,加锁这个动作是 InnoDB 才能做的,所以排除选项 1。

  2. 第二个选项和第三个选项统一验证

    从一致性读角度验证:

    可见性验证方式

    可以看到 session A 在执行 update 之后,它再执行 select 就可以看到 session B 的更新了。而 session A 的第二个 select 语句是一致性读(快照读),其实它不是看到了 session B 的更新了,是看到了它自己的更新。实际上它照常执行了更新操作,生成了事务 ID 为它的事务 ID 的数据版本,该版本中的数据基于当前读取到了 B 的更新,所以不是2,而是修改后的3 。之后 session A 的一致性读就可以读到了这个数据。如果 session A 没有执行更新操作,它是不能看见" session B 的更新"的。

综上,MySQL 采取的是第三种方式,但是需要说明的是,这是因为当前验证是建立在binllog_format=statement下进行的,且update语句的条件是id=1,否则可能结果就不是这样了,详情看下面。

其它情况

update语句的set 子句和where子句的值一致

查看以下示例,发现 session A 中查询得到的结果和上面的验证不一样了,它得到的还是(1,2)。这是因为此时update语句的where子句中的条件变成了id=1 and a=3,这条语句会调用 InnoDB 的当前读,可以读到 session B 的更新(此时如果有一个 session C 在 session A 的update 语句之后尝试执行 update 会在 session A 提交之前阻塞,所以证明了调用了当前读并加了锁),但是由于 Server 层是可以根据 set 中只对 a 进行设置为3,且where子句中存在一个必要条件是 a=3,所以从语法分析上就可以直到它不会做任何更新,此时 Server 层将不会再调用 InnoDB 的更新操作进行更新。所以后面的一致性读就还是读到了 session B 的版本,但是这个版本的事务 ID 对于 session A 来说是不可见的,所以往回退找到了 (1,2)。

可见性验证方式--对照

binlog_format=row

  • 如果是 binlog_format=row 并且 binlog_row_image=FULL 的时候,由于 MySQL 需要在 binlog 里面记录所有的字段,所以即使更新语句是update t set a=3 where id=1也要把所有字段值都读出来,此时可以判断a的值是否相等,发现相等之后就不再更新了。因此在这时候,select * from t where id=1,结果就是“返回 (1,2)”而不是(1,3)。

  • 如果是 binlog_format=row 并且 binlog_row_image=NOBLOB 的时候,会读出除 blob 外的所有字段,在上面这个例子里,session A 的第二次select还是“返回 (1,2)”。

  • 如果是 binlog_format=row 并且binlog-row-image=minimal

    此时 binlog 日志的前镜像只记录唯一识别列,即主键所和唯一索引列;后镜像只记录修改列。节省不少磁盘空间,节省一定的io,但是由于前镜像不记录修改列,只在后镜像记录修改列,如果数据出现误操作,必然不能通过 flashback 或 binlog2SQL 等快速闪回工具恢复数据,因此不能通过 binlog 生成反向 SQL 了。

    • 节省磁盘空间:高
    • 数据安全性:低

    此时 Server 层将不会调用 InnoDB 查询 a 字段值,所以就无法判断 a 值更新前后是否相等,则还是调用 InnoDB 进行更新。故 session A 的第二次select“返回 (1,3)”。

表中有自动获取当前时间的timestamp字段

给表t增加一个自动获取当前时间的timestamp字段:

alter table t add column uptime timestamp null default current_timestamp on update current_timestamp;

此时发现和验证中同样是在 session A 中执行 update t set a = 3 where id = 1之后,第二次 select 一致性读却还是读到了(1,2)。则是因为如果表中有 timestamp 字段而且设置了自动更新的话,那么更新“别的字段”的时候,MySQL 会读入所有涉及的字段,这样通过判断,就会发现不需要修改。此时一致性读就不能读到 session B 的"不一致更新版本"了。

如何判断全备周期

在一天一备的模式里,最坏情况下需要应用一天的 binlog。比如,你每天 0 点做一次全量备份,而要恢复出一个到昨天晚上 23 点的备份。一周一备最坏情况就要应用一周的 binlog 了。系统的对应指标就是 RTO(恢复目标时间),根据这个指标需要来确定要选择怎样的全备周期,全备越频繁,周期越短,恢复时间越快,但是消耗更多的存储空间。

评论区的一个SQL执行过程记录

1.首先客户端通过tcp/ip发送一条sql语句到server层的SQL interface
2.SQL interface接到该请求后,先对该条语句进行解析,验证权限是否匹配
3.验证通过以后,分析器会对该语句分析,是否语法有错误等
4.接下来是优化器器生成相应的执行计划,选择最优的执行计划
5.之后会是执行器根据执行计划执行这条语句。在这一步会去open table,如果该table上有MDL,则等待。
如果没有,则加在该表上加短暂的MDL(S)(如果opend_table太大,表明open_table_cache太小。需要不停的去打开frm文件)

6.进入到引擎层,首先会去innodb_buffer_pool里的data dictionary(元数据信息)得到表信息
7.通过元数据信息,去lock info里查出是否会有相关的锁信息,并把这条update语句需要的
锁信息写入到lock info里(锁这里还有待补充)
8.然后涉及到的老数据通过快照的方式存储到innodb_buffer_pool里的undo page里,并且记录undo log修改的redo
(如果data page里有就直接载入到undo page里,如果没有,则需要去磁盘里取出相应page的数据,载入到undo page里)
9.在innodb_buffer_pool的data page做update操作。并把操作的物理数据页修改记录到redo log buffer里
由于update这个事务会涉及到多个页面的修改,所以redo log buffer里会记录多条页面的修改信息。
因为group commit的原因,这次事务所产生的redo log buffer可能会跟随其它事务一同flush并且sync到磁盘上
10.同时修改的信息,会按照event的格式,记录到binlog_cache中。(这里注意binlog_cache_size是transaction级别的,不是session级别的参数,
一旦commit之后,dump线程会从binlog_cache里把event主动发送给slave的I/O线程)
11.之后把这条sql,需要在二级索引上做的修改,写入到change buffer page,等到下次有其他sql需要读取该二级索引时,再去与二级索引做merge
(随机I/O变为顺序I/O,但是由于现在的磁盘都是SSD,所以对于寻址来说,随机I/O和顺序I/O差距不大)
12.此时update语句已经完成,需要commit或者rollback。这里讨论commit的情况,并且双1
13.commit操作,由于存储引擎层与server层之间采用的是内部XA(保证两个事务的一致性,这里主要保证redo log和binlog的原子性),
所以提交分为prepare阶段与commit阶段
14.prepare阶段,将事务的xid写入,将binlog_cache里的进行flush以及sync操作(大事务的话这步非常耗时)
15.commit阶段,由于之前该事务产生的redo log已经sync到磁盘了。所以这步只是在redo log里标记commit
16.当binlog和redo log都已经落盘以后,如果触发了刷新脏页的操作,先把该脏页复制到doublewrite buffer里,把doublewrite buffer里的刷新到共享表空间,然后才是通过page cleaner线程把脏页写入到磁盘中

三、事务隔离

简单来说,事务就是要保证一组数据库操作,要么全部成功,要么全部失败。在 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 ):

  • 读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。

  • 读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。

  • 可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。启动时其它未提交的事务不可见,同时本事务启动之后才提交的其它事务对本事务也不可见。

  • 串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

一个理解隔离级别的例子

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

设数据表 T 中只有一列,其中一行的值为 1,下面是按照时间顺序执行两个事务的行为。

事务隔离级别例子

  • 若隔离级别是“读未提交”, 则 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。

在实现上,数据库里面会创建一个(MVCC)视图,访问的时候以视图的逻辑结果为准。

  • 在“可重复读”隔离级别下,这个视图是在事务启动时创建的,整个事务存在期间都用这个视图,所以 RR 隔离级别下,整个事务期间对于其它事务的视图都是不变的。
  • 在“读提交”隔离级别下,这个视图是在每个 SQL 语句开始执行的时候创建的,所以 RC 隔离级别下,只要在本事务内每条语句执行之前提交的事务对于本事务来说都会被加入到新的视图中,是可见的。
  • “读未提交”隔离级别下直接返回记录上的最新值,没有视图概念。
  • 而“串行化”隔离级别下直接用加锁的方式来避免并行访问。

根据不同情况选择隔离级别

Oracle 数据库的默认隔离级别其实就是“读提交”,因此对于一些从 Oracle 迁移到 MySQL 的应用,为保证数据库隔离级别的一致,一定要记得将 MySQL 的隔离级别设置为“读提交”。配置的方式是,将启动参数 transaction-isolation 的值设置成 READ-COMMITTED。你可以用 show variables 来查看当前的值。

mysql> show variables like "transaction_isolation";
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.00 sec)

要根据自己的业务情况来定。一个需要“可重复读”隔离级别的数据校对逻辑的案例:

假设你在管理一个个人银行账户表。一个表存了账户余额,一个表存了账单明细。到了月底你要做数据校对,也就是判断上个月的余额和当前余额的差额,是否与本月的账单明细一致。你一定希望在校对过程中,即使有用户发生了一笔新的交易,也不影响你的校对结果。这时候使用“可重复读”隔离级别就很方便。事务启动时的视图可以认为是静态的,不受其他事务更新的影响。

事务隔离的实现

在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志[8]里面就会有类似下面的记录。

undolog

当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到(说明每个 read-view 并不是真的将数据复制为一个副本,而是在回滚段中建立一个标识或者直接使用事务id进行匹配)。

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

回滚段的删除策略及长事务带来的影响[8:1]

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

  • undo log 可以删除的大前提是产生该 undo log 的事务本身已经完成了(提交或者回滚),否则该事务需要维护回滚段供用户随时回滚。以下情形都是基于已经完成的事务的 undo log 为准。

  • 如果数据库中当前没有 read view 存在,则所有 undo log 都可以删除,只保留聚簇索引中的数据即可。

  • 如果存在 read view,取所有 read view 的所属事务 ID 中的最小值,对于一个存在多条 undo log 的数据行来说,以小于等于前面取到的最小 read view 所属事务 ID 的最大 undo log 所属事务 ID 为临界点,所属事务 ID 小于这个临界点的 undo log 可以删除;

基于此,建议尽量不要使用长事务(迟迟没有 commit 或者 rollback)。

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

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

  • 除了对回滚段的影响,长事务还占用锁资源,也可能拖垮整个库。

如何避免长事务对业务的影响

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

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

  • 确认是否使用了 set autocommit=0。这个确认工作可以在测试环境中开展,把 MySQL 的 general_log 开起来,然后随便跑一个业务逻辑,通过 general_log 的日志来确认。一般框架如果会设置这个值,也就会提供参数来控制行为,你的目标就是把它改成 1。
  • 确认是否有不必要的只读事务。有些框架会习惯不管什么语句先用 begin/commit 框起来。我见过有些是业务并没有这个需要,但是也把好几个 select 语句放到了事务中。这种只读事务可以去掉。
  • 业务连接数据库的时候,根据业务本身的预估,通过 SET MAX_EXECUTION_TIME 命令,来控制每个语句执行的最长时间,避免单个语句意外执行太长时间(如果不能全库设置global,就根据情况来对某个表进行单独设置,或者在应用层代码中拦截,通过插件的方式根据业务条件来对每一个session进行分别设置)。

其次,从数据库端来看:

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

事务的启动方式

其实很多时候业务开发同学并不是有意使用长事务,通常是由于误用所致。MySQL 的事务启动方式有以下几种:

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

有些客户端连接框架会默认连接成功后先执行一个 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;

四、索引

不同索引模式

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

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

哈希表

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

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

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

hash索引示意

图中,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 的身份证号,退出循环。

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

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

搜索树

二叉搜索树的时间复杂度

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

二叉搜索树索引示意

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

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

二叉平衡搜索树在面对磁盘访问的不适应性

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

你可以想象一下一棵 100 万节点的平衡二叉树,树高 20。一次查询可能需要访问 20 个数据块(这里是以B+树为背景进行描述的,B+树只有叶子节点会存放数据,所以每一次访问数据都必须经历从根节点到叶子节点的访问过程,每个节点单独存储在一个数据块中,所以就可能会访问树高个数据块)。在机械硬盘时代,从磁盘随机读一个数据块[10]需要 10 ms 左右的寻址时间。也就是说,对于一个 100 万行的表,如果使用二叉树来存储,单独访问一个行可能需要 20 个 10 ms 的时间,这个查询可真够慢的。

平衡二叉树、B树、B+树、B*树

1>平衡二叉树
概念

平衡二叉树是基于二分法的策略提高数据的查找速度的二叉树的数据结构;

特点

平衡二叉树是采用二分法思维把数据按规则组装成一个树形结构的数据,用这个树形结构的数据减少无关数据的检索,大大的提升了数据检索的速度;平衡二叉树的数据结构组装过程有以下规则:

  1. 非叶子节点只能允许最多两个子节点存在。
  2. 每一个非叶子节点数据分布规则为左边的子节点小当前节点的值,右边的子节点大于当前节点的值(这里值是基于自己的算法规则而定的,比如hash值);

平衡二叉树1

平衡树的层级结构:因为平衡二叉树查询性能和树的层级(h高度)成反比,h值越小查询越快、为了保证树的结构左右两端数据大致平衡降低二叉树的查询难度一般会采用一种算法机制实现节点数据结构的平衡,实现了这种算法的有比如Treap、红黑树,使用平衡二叉树能保证数据的左右两边的节点层级相差不会大于1.,通过这样避免树形结构由于删除增加变成线性链表影响查询效率,保证数据平衡的情况下查找数据的速度近于二分法查找;

平衡二叉树2

总结平衡二叉树特点
  1. 非叶子节点最多拥有两个子节点;
  2. 非叶子节值大于左边子节点、小于右边子节点;
  3. 树的左右两边的层级数相差不会大于1;
  4. 没有值相等重复的节点;
2>B树(B-tree)

注意:之前有看到有很多文章把B树和B-tree理解成了两种不同类别的树,其实这两个是同一种树;

概念

B树和平衡二叉树稍有不同的是B树属于多叉树又名平衡多路查找树(查找路径不只两个),数据库索引技术里大量使用者B树和B+树的数据结构,让我们来看看他有什么特点;

规则
  1. 排序方式:所有节点关键字是按递增次序排列,并遵循左小右大原则;
  2. 子节点数:非叶节点的子节点数>1,且<=M ,且M>=2,空树除外(注:M阶代表一个树节点最多有多少个查找路径,M=M路,当M=2则是2叉树,M=3则是3叉);
  3. 关键字数:枝节点的关键字数量大于等于ceil(m/2)-1个且小于等于M-1个(注:ceil()是个朝正无穷方向取整的函数 如ceil(1.1)结果为2);
  4. 所有叶子节点均在同一层、叶子节点除了包含了关键字和关键字记录的指针外也有指向其子节点的指针只不过其指针地址都为null对应下图最后一层节点的空格子;

最后我们用一个图和一个实际的例子来理解B树(这里为了理解方便我就直接用实际字母的大小来排列C>B>A)

B树

B树的查询流程:

如上图要从上图中找到E字母,查找流程如下

  1. 获取根节点的关键字进行比较,当前根节点关键字为M,E<M(26个字母顺序),所以往找到指向左边的子节点(二分法规则,左小右大,左边放小于当前节点值的子节点、右边放大于当前节点值的子节点);
  2. 拿到关键字D和G,D<E<G 所以直接找到D和G中间的节点;
  3. 拿到E和F,因为E=E 所以直接返回关键字和指针信息(如果树结构里面没有包含所要查找的节点则返回null);
B树的插入节点流程

定义一个5阶树(平衡5路查找树;),现在我们要把3、8、31、11、23、29、50、28 这些数字构建出一个5阶树出来;

遵循规则:

  • 节点拆分规则:当前是要组成一个5路查找树,那么此时m=5,关键字数必须<=5-1(这里关键字数>4就要进行节点拆分);
  • 排序规则:满足节点本身比左边节点大,比右边节点小的排序规则;

流程:

  1. 插入 3、8、31、11

    B树插入1

  2. 再插入23、29

    B树插入2

  3. 再插入50、28B树插入3

B树节点的删除

规则

  1. 节点合并规则:当前是要组成一个5路查找树,那么此时m=5,关键字数必须大于等于ceil(5/2)(这里关键字数<2就要进行节点合并);
  2. 满足节点本身比左边节点大,比右边节点小的排序规则;
  3. 关键字数小于二时先从子节点取,子节点没有符合条件时就向向父节点取,取中间值往父节点放;

B树删除

特点

B树相对于平衡二叉树的不同是,每个节点包含的关键字增多了,特别是在B树应用到数据库中的时候,数据库充分利用了磁盘块的原理(磁盘数据存储是采用块的形式存储的,每个块的大小为4K,每次IO进行数据读取时,同一个磁盘块的数据可以一次性读取出来)把节点大小限制和充分使用在磁盘快大小范围;把树的节点关键字增多后树的层级比原来的二叉树少了,减少数据查找的次数和复杂度;

3>B+树
概念

B+树是B树的一个升级版,相对于B树来说B+树更充分的利用了节点的空间,让查询速度更加稳定,其速度完全接近于二分法查找。为什么说B+树查找的效率要比B树更高、更稳定;我们先看看两者的区别

规则
  1. B+跟B树不同B+树的非叶子节点不保存关键字记录的指针,只进行数据索引,这样使得B+树每个非叶子节点所能保存的关键字大大增加;
  2. B+树叶子节点保存了父节点的所有关键字记录的指针,所有数据地址必须要到叶子节点才能获取到。所以每次数据查询的次数都一样;
  3. B+树叶子节点的关键字从小到大有序排列,左边结尾数据都会保存右边节点开始数据的指针。
  4. 非叶子节点的子节点数=关键字数(来源百度百科)(根据各种资料 这里有两种算法的实现方式,另一种为非叶节点的关键字数=子节点数-1(来源维基百科),虽然他们数据排列结构不一样,但其原理还是一样的Mysql 的B+树是用第一种方式实现);

B+树

B+树1

特点
  1. B+树的层级更少:相较于B树B+每个非叶子节点存储的关键字数更多,树的层级更少所以查询数据更快;
  2. B+树查询速度更稳定:B+所有关键字数据地址都存在叶子节点上,所以每次查找的次数都相同所以查询速度要比B树更稳定;
  3. B+树天然具备排序功能:B+树所有的叶子节点数据构成了一个有序链表,在查询大小区间的数据时候更方便,数据紧密性很高,缓存的命中率也会比B树高。
  4. B+树全节点遍历更快:B+树遍历整棵树只需要遍历所有的叶子节点即可,,而不需要像B树一样需要对每一层进行遍历,这有利于数据库做全表扫描。

B树相对于B+树的优点是,如果经常访问的数据离根节点很近,而B树非叶子节点本身存有关键字其数据的地址,所以这种数据检索的时候会要比B+树快。

4>B*树
规则

B*树是B+树的变种,相对于B+树他们的不同之处如下:

  1. 首先是关键字个数限制问题,B+树初始化的关键字初始化个数是 cei(m/2),b*树的初始化个数为(cei(2/3*m)
  2. B+树节点满时就会分裂,而B*树节点满时会检查兄弟节点是否满(因为每个节点都有指向兄弟的指针),如果兄弟节点未满则向兄弟节点转移关键字,如果兄弟节点已满,则从当前节点和兄弟节点各拿出1/3的数据创建一个新的节点出来;
特点

在B+树的基础上因其初始化的容量变大,使得节点空间使用率更高,而又存有兄弟节点的指针,可以向兄弟节点转移关键字的特性使得B*树额分解次数变得更少;

B*树

5>总结
1、相同思想和策略

从平衡二叉树、B树、B+树、B*树总体来看它们的贯彻的思想是相同的,都是采用二分法和数据平衡策略来提升查找数据的速度;

2、不同的方式的磁盘空间利用

不同点是他们一个一个在演变的过程中通过IO从磁盘读取数据的原理进行一步步的演变,每一次演变都是为了让节点的空间更合理的运用起来,从而使树的层级减少达到快速查找数据的目的;

补充概念:

附(二分法查找):二分法查找原理 - 知乎专栏

附(B、B+、B*树):从B树、B+树、B*树谈到R 树

附(B、B+、B*树):end’s coding life

附:B树和B+树的插入、删除图文详解 - nullzx - 博客园

N叉树及InnoDB索引模型初识

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

在MySQL中如何调整“N叉树”的N值?

  1. 通过改变key值来调整
    N叉树中非叶子节点 page 存放的是索引信息,索引包含Key和Point指针。Point指针固定为6个字节[12],假如Key为10个字节,那么单个索引就是16个字节。如果B+树中页大小为16K,那么一个页就可以存储1024个索引,此时N就等于1024。我们通过改变Key的大小,就可以改变N的值。Key 越小,一个 Page 能够存放的索引信息就越多,此时子节点数量 N 就越大。
  2. 改变页的大小
    页越大,一页存放的索引就越多,N就越大。而通常认为key的值是固定的,就是4或者8,即 int或者bigint,所以主要还是page大小来决定 N 叉树的节点数量。

通过让N值变大,就可以降低同等数据量下树的高度,降低磁盘IO次数。

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

索引字段为长整型,长度为8个字节,加上记录子节点的指针6B,此时一个非叶子节点的page中的数据单元大小为14B。取默认的 page 大小 16 KB,即一个非叶子节点中可以存储 16 * 1024 / 14 ≈ 1200 个索引信息。

树高为4,根节点对应一个 page ,存储1200 个索引信息,即第二层可以有1200个page,此时第三层则会有 1200 * 1200 个page,那么第四层再乘 1200 即为 1200 的3次方个page,即第四层叶子节点可以有 1200 的3次方个page可以存储数据。

N 叉树由于在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。MySQL 中使用的就是 B+ 树。B+ 树能够很好地配合磁盘的读写特性,减少单次查询的磁盘访问次数。

相反,类似红黑树的平衡二叉树适合面对内存访问的场景,因为内存访问更快。

平衡二叉树每个节点都是一个最小的数据单元,直接经过一次判断对比即可知道该节点是否是要找的数据,或者在它的左子节点还是右子节点,非常高效。而B+树每一次都需要到达叶子节点,即每次访问的层次是固定的,另外其每一个节点都不是最小的数据单元,而是由最小数据单元构成的有序数组,需要对该节点进行查找(二分查找),才能匹配到对应的数据单元或者指向下一层的指针。

但是问题还是因为树高的原因可能会访问很多层才能找到对应的节点。此时如果是访问内存,相对于磁盘来说它是非常迅速的(除了主存本身的访问较快,还可以利用多级缓存)。

小结

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

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

InnoDB的索引模型

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

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

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

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

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

聚簇索引(主键)和二级索引(非主键)

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

InnoDB 的索引组织结构

B+树的高度可以为2或者更多,它的每一个节点都是一个数据页(page),每个数据页中包含着多个最小数据单元。而数据单元的类型随着它是否是叶子节点或者主键索引而不同。

  • 非叶子节点中存储的最小数据单元是"我们认为的索引":它由两部分组成,一个是key,一个是指向其子节点的指针 point。其含义为point指向的子节点中包含的所有"数据行"的当前索引字段的值都小于 key。而非叶子节点 page 中存储的最小数据单元是有序的,指的就是按照 key 从小到大进行排序。

  • 而叶子节点中存储的最小数据单元,又根据当前索引类型为主键索引和非主键索引的不同而有所不同:

    • 主键索引的叶子节点存的是整行数据(数据页page指的就是聚簇索引的叶子节点,它本身是一个有序数组,里面存储着多行数据)。在 InnoDB 里,主键索引也被称为聚簇索引(clustered index)。即整个表数据都是存储在索引中的,索引就是所谓的表。

      注意,即使在建表的时候没有指明某个字段为主键,InnoDB也会有一个默认的主键rowid。如果在后续重新指定一个字段为主键字段,将会重建整张表并构建新的聚簇索引。

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

基于主键索引和普通索引的查询有什么区别

  • 如果语句是 select * from T where ID=500,即主键查询方式,则只需要搜索 ID 这棵 B+ 树(聚簇索引本身就是和表数据联合存放在一起的);

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

    如果语句是 select k from T where k = 5,因为查询的字段 k 的值仅在该索引中即可获得,所以无需回表。如果对于为什么都 k=5 了还仅查询该字段,有什么意义的疑问,这种查询可以被理解为查询有多少行这样的数据,MySQL 还是返回对应命中的行数的,有时候就是有这样的业务需求,不仅仅是要求得命中了多少行,例如某些拼接字符串的场景。

    另外例如查询语句 select k from T where k = 5 and c = 10 ,其中 k 和 c 是联合索引,也会发生索引覆盖,无需回表。

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

索引维护可能遇到的页分裂和解决方案

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

而更糟的情况是,如果 R5 所在的数据页已经满了,根据 B+ 树的算法:

  1. 先查看当前页的前后页是否满了,如果没满就会先将要插入的数据放在前后两个页上
  2. 否则需要申请一个新的数据页,然后挪动部分数据过去

以上过程都会将保存了当前页的部分数据的其它数据页的指针保存在当前数据页中形成一个链表串连(其它数据页里面应该有分割符吧…)。这个过程称为页分裂。在这种情况下,性能自然会受影响(首先是这个过程就需要额外的消耗,其次在读取该页数据的时候,还要遍历链表根据指针进行迭代寻址将逻辑上该页的所有数据读取出来)。

除了性能外,后者页分裂操作还影响数据页的利用率。原本放在一个页的数据,现在新申请了一个数据页分别存在两个页中,整体空间利用率降低大约 50%(之后这两个页如果删除数据,就会出现两个数据页不满的情况,此时体现了利用率低)。当然有分裂就有合并。当相邻两个页由于删除了数据,利用率很低之后,会将数据页做合并。合并的过程,可以认为是分裂过程的逆过程。

使用自增主键以及使用业务字段作为主键对比

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

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

  1. 性能

    • 自增主键是指自增列上定义的主键,在建表语句中一般是这么定义的: NOT NULL PRIMARY KEY AUTO_INCREMENT。插入新记录的时候可以不指定 ID 的值,系统会获取当前 ID 最大值加 1 作为下一条记录的 ID 值。也就是说,自增主键的插入数据模式,正符合了我们前面提到的递增插入的场景。每次插入一条新记录,都是追加操作(因为是自增),都不涉及到挪动其他记录,也不会触发叶子节点的分裂。
    • 而有业务逻辑的字段做主键,则往往不容易保证有序插入,这样写数据成本相对较高。
  2. 存储

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

    由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点中一行数据的占用约 20 个字节(包含身份证的18个字节以及当前字段的值约2个字节,这个2个字节应该是平均得出的),而如果用整型做主键,则只要 4 个字节,如果是长整型(bigint)则是 8 个字节。

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

所以,从性能和存储空间方面考量,自增主键往往是更合理的选择。有没有什么场景适合用业务字段直接做主键的呢?还是有的。比如,有些业务的场景需求是这样的:

  • 只有一个索引;
  • 该索引必须是唯一索引。

你一定看出来了,这就是典型的 KV 场景。由于没有其他索引,所以也就不用考虑其他索引的叶子节点大小的问题。这时候我们就要优先考虑上一段提到的“尽量使用主键查询”原则,直接将这个索引设置为主键,可以避免每次查询需要搜索两棵树。

在分布式环境下的雪花算法生成的分布式 ID 不是递增的,但是是增长的趋势,所以用它来作为主键是没问题的,在插入的时候也是顺序插入,所以插入不会造成主键页分裂,和自增 ID 性能差不多。

重建索引的理由以及方式

对于上面例子中的 InnoDB 表 T,如果要重建索引 k,两个 SQL 语句可以这么写:

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

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

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

上面通过两个 alter 语句重建索引 k,以及通过两个 alter 语句重建主键索引是否合理?

首先为什么要重建索引:索引可能因为删除,或者页分裂等原因,导致数据页有空洞,重建索引的过程会创建一个新的索引,把数据按顺序插入,这样页面的利用率最高,也就是索引更紧凑、更省空间。

重建索引 k 的做法是合理的,可以达到省空间的目的;但是,重建主键的过程不合理。不论是删除主键还是创建主键,都会将整个表重建。所以连着执行这两个语句的话,第一个语句就白做了。这两个语句用这个语句代替 : alter table T engine=InnoDB

评论区回答

  1. 直接删掉主键索引是不好的,它会使得所有的二级索引都失效,并且会用ROWID来作主键索引;

  2. 看到mysql官方文档写了三种措施,第一个是整个数据库迁移,先dump出来再重建表(这个一般只适合离线的业务来做);第二个是用空的alter操作,比如ALTER TABLE t1 ENGINE = InnoDB;这样子就会原地重建表结构;第三个是用repaire table,不过这个是由存储引擎决定支不支持的(InnoDB就不行)。

用户案例

线上的一个表,记录日志用的,会定期删除过早之前的数据.。最后这个表实际内容的大小才10G,而他的索引却有30G。在阿里云控制面板上看就是占了40G空间,这可花的是真金白银啊。后来了解到是 InnoDB 这种引擎导致的,虽然删除了表的部分记录,但是它的索引还在,并未释放,当时没有开启innodb_file_per_table选项(以前 MySQL 数据库是没有默认开启innodb_file_per_table选项的,但现在的新版本已经一个默认配置了),导致所有的表(数据和索引)都存储在了一个文件中。导致上述的alter table T engine=InnoDB无法进行磁盘碎片整理(即修复因为删除数据或者页分裂导致的索引数据页空洞)(没有开启一个表一个索引文件,所有索引都揉再一个文件里面了,很难整理),即使是使用了optimize table都无法释放空间。只能是重新建表并重建索引。

另外,对于记录日志的表最好是分区表(按照时间序列建表按批次归档数据),历史数据清理可以直接drop分区。

五、索引优化

MySQL Server和InnoDB的查询配合

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

mysql> create table T (
ID int primary key,
k int NOT NULL DEFAULT 0, 
s varchar(16) NOT NULL DEFAULT '',
index k(k))
engine=InnoDB;

insert into T values(100,1, 'aa'),(200,2,'bb'),(300,3,'cc'),(500,5,'ee'),(600,6,'ff'),(700,7,'gg');

range查询扫描行数

现在,我们一起来看看这条 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 上其实读了三个记录,R3~R5(对应的索引 k 上的记录项,读取了k=6的项发现不满足,没有回表其数据),但是对于 MySQL 的 Server 层来说,它就是找引擎拿到了两条记录,因此 MySQL 认为扫描行数是 2(行扫描指的是Server层对InnoDB返回的数据进行扫描,InnoDB根据索引将存储的数据返回到Server层,无索引的条件只能由Server层执行器自己来扫描并过滤不匹配的行)。(从扫描字眼以及这里的描述看起来对于数据page中有序数组的查询方式是遍历而不是二分?还是说有一定的策略的?)

mysql> explain select * from T where k between 3 and 5;
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-----------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | T     | NULL       | range | k             | k    | 4       | NULL |    2 |   100.00 | Using index condition |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

覆盖索引

在以上过程中,回到主键索引树搜索的过程,我们称为回表。可以看到,这个查询过程读了 k 索引树的 3 条记录(步骤 1、3 和 5),回表了两次(步骤 2 和 4)。在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,有没有可能经过索引优化,避免回表过程呢?

如果执行的语句是 select ID from T where k between 3 and 5,这时只需要查 ID 的值,而 ID 的值已经在 k 索引树上了,因此可以直接提供查询结果,不需要回表。也就是说,在这个查询里面,索引 k 已经“覆盖了”我们的查询需求,我们称为覆盖索引(查询的字段只有主键或者和条件字段构成联合索引的时候触发)。由于覆盖索引可以减少树的搜索次数,显著提升查询性能,所以使用覆盖索引是一个常用的性能优化手段。

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

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,或者称为业务数据架构师的工作。

最左前缀原则

基于上面提到的覆盖索引,建表语句可以改成:

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_name` (`id_card`, `name`), -- 针对频繁的根据身份证查姓名的需求建立联合索引
  KEY `name_age` (`name`,`age`)
) ENGINE=InnoDB

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

其实无需怎么做,因为 B+ 树这种索引结构,可以利用索引的“最左前缀”,来定位记录。所以前面定义的id_card_name索引它对于单独使用身份证进行查询还是其它字段还是有效的,不会全表扫描,只不过会回表去查询其它字段。

下面后面的(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 开始一个个回表。到主键索引上找出数据行,再对比字段值。

    无索引下推执行流程

    图中去掉了 age 的值,标识这个过程 InnoDB 并不会去看 age 的值,只是按顺序把“name 第一个字是’张’”的记录一条条取出来回表。因此,需要回表 4 次。

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

    索引下推执行流程

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

注意,以上的所说 MySQL 5.6 前后差异指的是模糊查询(使用LIKE关键字)有所差异。在 5.6 之前,对于模糊查询,InnoDB 存储引擎给出的接口只能传入"搜索关键字",无法传入其它匹配条件(即无法进行 index condition pushdown);在 5.6 之后,增加了入参,从而可以实现模糊查询下的索引条件下推,让 InnoDB 自身在使用字符串前缀匹配到该字符串字段所在的联合索引中的page之后使用这些下推的索引条件对其它索引字段对匹配到的数据行进行过滤。(对于非模糊查询,5.6 之前应该就有这个逻辑,不然联合索引意义何在)

当使用了索引下推的时候,使用 explain 输出的 extra 列会显示"Using index condition",而不是"using index"。

多主键索引

问题

实际上主键索引也是可以使用多个字段的。

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;

为了这两个查询模式,这两个索引是否都是必须的?为什么呢?

答案

InnoDB 会把主键字段放到索引定义字段后面,同时去重,类似联合索引。

  • 对于联合主键索引(a,b),在聚簇索引的叶子节点page的有序数组中,数据行就是先按照 a 进行排序,a 相同的情况下再按照 b 进行排序的。
  • 对于索引(c),InnoDB 会把主键字段(a,b)加到后面,成为(c,a,b),此时该二级索引的叶子节点page中的有序数组将按照c 进行排序,然后才是 c 、b。
  • 对于索引(c,a),InnoDB 还是会把主键字段(a,b)加到后面并去重,成为(c,a,b),此时和索引(c)重复了。所以这个索引是可以去掉的。
  • 对于索引(c,b),拼接主键字段并去重后得到(c,b,a),此时将先按照 c 排序,然后是 b ,最后是 a。

所以,索引(c,a)是可以去掉的。

关于explain中的Extra

  • Using filesort:本次查询语句中有order by,且排序依照的字段不在本次使用的索引中,不能自然有序。需要进行额外的排序工作。
  • Using index:使用了覆盖索引——即本次查询所需的所有信息字段都可以从利用的索引上取得。无需回表,额外去主索引上去数据。 The column information is retrieved from the table using only information in the index tree without having to do an additional seek to read the actual row. This strategy can be used when the query uses only columns that are part of a single index.
  • Using index condition: 使用了索引下推技术ICP。(虽然本次查询所需的数据,不能从利用的索引上完全取得,还是需要回表去主索引获取。但在回表前,充分利用索引中的字段,根据where条件进行过滤。提前排除了不符合查询条件的列。这样就减少了回表的次数,提高了效率。) Tables are read by accessing index tuples and testing them first to determine whether to read full table rows. In this way, index information is used to defer (“push down”) reading full table rows unless it is necessary. See Section 8.2.1.5, “Index Condition Pushdown Optimization”.
  • Using where:表示本次查询,Server 层要对从存储层返回的结果进行筛选过滤。
  • Using temporary:表示会使用到临时表
  • Using MRR:使用了 MRR 优化

六、MySQL中的锁

根据加锁的范围,MySQL 里面的锁大致可以分成全局锁、表级锁和行锁[13]三类

全局锁

顾名思义,全局锁就是对整个数据库实例加锁。MySQL 提供了一个加全局读锁的方法,命令是 Flush tables with read lock [14] (FTWRL)。当你需要让整个库处于只读状态的时候,可以使用这个命令,之后其他线程的以下语句会被阻塞:

  • 数据更新语句(数据的增删改)
  • 数据定义语句(包括建表、修改表结构等)
  • 更新类事务的提交语句

全局锁的典型使用场景是,做全库逻辑备份。也就是把整库每个表都 select 出来存成文本。以前有一种做法,是通过 FTWRL 确保不会有其他线程对数据库做更新,然后对整个库做备份。注意,在备份过程中整个库完全处于只读状态。

为什么备份数据库要加全局锁

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

  • 如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;

  • 如果你在从库上备份,那么备份期间从库不能执行主库同步过来的 binlog,会导致主从延迟。

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

假设你现在要维护“极客时间”的购买系统,关注的是用户账户余额表和用户课程表。现在发起一个逻辑备份。假设备份期间,有一个用户,他购买了一门课程,业务逻辑里就要扣掉他的余额,然后往已购课程里面加上一门课。如果时间顺序上是先备份账户余额表 (u_account),然后用户购买,然后备份用户课程表 (u_course),会怎么样呢?

业务和备份状态图

可以看到,这个备份结果里,用户 A 的数据状态是“账户余额没扣,但是用户课程表里面已经多了一门课”。如果后面用这个备份来恢复数据的话,用户 A 就发现,自己赚了。作为用户可别觉得这样可真好啊,你可以试想一下:如果备份表的顺序反过来,先备份用户课程表再备份账户余额表,又可能会出现什么结果?也就是说,不加锁的话,备份系统备份的得到的库不是一个逻辑时间点,这个视图是逻辑不一致的(其实就是备份的数据是不完整的)

除了全局锁外保持视图一致的方法:mysqldump

在可重复读隔离级别下开启一个事务,是可以保持一个全局不变的事务视图的。

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

有了mysqldump为什么还要全局锁

有了这个功能,为什么还需要 FTWRL 呢?一致性读是好,但前提是引擎要支持这个隔离级别。比如,对于 MyISAM 这种不支持事务的引擎,如果备份过程中有更新,总是只能取到最新的数据,那么就破坏了备份的一致性。这时,我们就需要使用 FTWRL 命令了,这里谈到的全局锁是 Server 层实现的,所以它们不依赖于底层存储引擎

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

为什么不通过设置readonly来实现数据库不可写

既然要全库只读,为什么不使用 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(metadata lock)。MDL 不需要显式使用,在访问一个表的时候会被自动加上。MDL 的作用是,保证读写数据前后表的结构是一致的。你可以想象一下,如果一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构做变更,删了一列,那么查询线程拿到的结果跟表结构对不上,肯定是不行的。因此,在 MySQL 5.5 版本中引入了 MDL,当对一个表做增删改查操作的时候,加 MDL 读锁;当要对表做结构变更操作的时候,加 MDL 写锁

  • 读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。

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

  • MDL 有一个同步队列,如果在某一刻有一个线程在获取写锁,写锁进入同步队列,后面所有 MDL 都会被该写锁阻塞,可以看出写锁是有一个相对高优先级的,这应该是防止写锁饿死,因为读锁的获取是非常频繁的,如果锁的获取是随机不公平的,将可能导致写锁一直获取不到锁。

    另外,因为通常获取 MDL 之后执行的 DDL 是要消耗较长时间的 (需要扫描全表) ,如果它获取写锁之后一直不释放,就会一直阻塞其它读锁导致此期间该表无法读写数据。所以在 5.6 之后加入了 “online DDL”:

    1. 申请 MDL 写锁:和第三步形成互斥保证同一时间只有一个线程对一个表执行 DDL。
    2. DDL 执行准备:这里可以写入一些准备数据,第1步加写锁也保证了安全
    3. 降级成 MDL 读锁:准备完成之后可以降级乘读锁,同时读锁保持阻塞其它 DDL 申请写锁(阻塞其它线程执行第一步,也就是不允许其它线程对当前表执行 DDL)。
    4. DDL 核心执行(耗时较长):申请新空间在新空间上执行DDL
    5. 升级成 MDL 写锁:第4步 DDL 执行完之后,需要升级为 MDL 写锁提交修改。
    6. DDL 最终提交
    7. 释放 MDL 锁

    其中第4步真正执行 DDL 的步骤不会阻塞后面其它线程对于该表 MDL 的获取,体现了 “onliine”。

  • MDL 的释放是随着事务的释放才释放的,所以如果手动开启了事务执行 DDL 或者 DML,一定要手动事务,否则将会一直阻塞后面的 DDL/DML 或者 DDL(这一点也是长事务的一个负担)。而自动提交事务则会自动释放 MDL 。

MDL 带来的阻塞问题

虽然 MDL 锁是系统默认会加的,但却是不能忽略的一个机制。比如下面这个例子,经常有人掉到这个坑里:给一个小表加个字段,导致整个库挂了。给一个表加字段,或者修改字段,或者加索引,需要扫描全表的数据。在对大表操作的时候,肯定要特别小心,以免对线上服务造成影响。而实际上,即使是小表,操作不慎也会出问题。

下面是一个操作序列,假设表 t 是一个小表。其中绿色表示成功拿到 MDL,黄色是释放 MDL,红色表示取 MDL 被阻塞。

mysqlmdl

  1. session C 在尝试修改执行 DDL 修改表结构,在第一步获取写锁的时候,因为前面有两个获取 MDL 读锁的 session 是手动开启事务,且还没有提交事务,所以将一直阻塞。因为有一个写锁在队列中阻塞,所以后面的 session D 的获取读锁动作也会被阻塞。此时 session C 和 D 的用户界面将一直 hang 住。
  2. 后续 session A 和 B 都释放了读锁,此时 session C 成功获取到写锁,并进入第二阶段,为执行 DDL 做准备,此时后续 session C 的获取读锁动作将会被持续阻塞。
  3. 直到 session C 第二阶段完成,将写锁降级乘读锁,进入第四阶段,online 执行 DDL, 此时 session D 获取到读锁,执行查询语句返回用户界面,等待用户提交事务。
  4. sessino C 第四阶段完成后,申请写锁,准备提交 DDL 修改,但是因为 session D 没有释放读锁,所以持续阻塞,用户界面持续 hang 住。与此同时,有一个 session E 准备修改表数据,申请读锁,将会因为 session C 的申请写锁阻塞而阻塞。
  5. session D 提交事务释放读锁,session C 顺利拿到写锁提交 DDL 修改后自动释放事务并释放写锁,session E 拿到读锁,执行表数据修改 DML 。

如果对 session D 稍作修改,将begin手动开启、commit手动提交事务都去掉,此时它的查询操作将会在获取到读锁之后执行完查询语句返回用户界面即自动释放读锁。此时 session C 就不会因为 session D 没有提交事务而被 hang 住了。

mysqlmdl1

基于以上分析,online DDL 在第一步获取写锁的时候是会被前面的读锁事务被阻塞的,所以如果该事务是一个长事务,迟迟不释放该读锁,此时获取写锁请求将一直阻塞。如果只有自己被阻塞还没什么关系,但是之后所有要对表的增删改查操作都需要先申请 MDL 读锁,这些新申请 MDL 读锁的请求也会被阻塞,等于这个表现在完全不可读写了。如果某个表上的查询语句频繁,而且客户端有重试机制,也就是说超时后会再起一个新 session 再请求的话,这个库的线程很快就会爆满

如何解决 MDL 的潜在问题

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

  2. 但考虑一下这个场景。如果你要变更的表是一个热点表,虽然数据量不大,但是上面的请求很频繁,而你不得不加个字段,你该怎么做呢?这时候 kill 可能未必管用,因为新的请求马上就来了。

    • 比较理想的机制是,在 alter table 语句里面设定等待时间,如果在这个指定的等待时间里面能够拿到 MDL 写锁最好,拿不到也不要阻塞后面的业务语句,先放弃。之后开发人员或者 DBA 再通过重试命令重复这个过程。MariaDB 已经合并了 AliSQL 的这个功能,所以这两个开源分支目前都支持 DDL NOWAIT/WAIT N 这个语法。

      ALTER TABLE tbl_name NOWAIT add column ...
      ALTER TABLE tbl_name WAIT N add column ... 
      
    • 另外,对于非开源 mysql ,可以通过在准备执行 DDL 的 session 中执行 set lock_wait_timeout = <N> 设置获取锁最大等待时间 N 秒,N 秒无法申请写锁将取消申请,此时将不会阻塞后面读写了。

参考阅读

CSDN: MySQL锁系列之MDL元数据锁之一

CSDN: MySQL锁系列之MDL元数据锁之二

CSDN: MySQL锁系列之MDL元数据锁之三

小结

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

表锁一般是在数据库引擎不支持行锁的时候才会被用到的。如果发现应用程序里有 lock tables 这样的语句,需要追查一下,比较可能的情况是:要么是你的系统现在还在用 MyISAM 这类不支持事务的引擎,那要安排升级换引擎;要么是引擎升级了,但是代码还没升级,将 lock tablesunlock tables 改成 begincommit,问题就解决了。

在备库做备份时遇到主库DDL

备份一般都会在备库上执行,你在用–single-transaction 方法做逻辑备份的过程中,如果主库上的一个小表做了一个 DDL,比如给一个表上加了一列。这时候,从备库上会看到什么现象呢?假设这个 DDL 是针对表 t1 的,以下是 mysqldump 命令的内部流程示例:

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

在备份开始的时候,为了确保 RR(可重复读)隔离级别,再设置一次 RR 隔离级别 (Q1);启动事务,这里用 WITH CONSISTENT SNAPSHOT 确保这个语句执行完就可以得到一个一致性视图(Q2);设置一个保存点,这个很重要(Q3);show create 是为了拿到表结构 (Q4),然后正式导数据 (Q5),回滚到 SAVEPOINT sp,在这里的作用是释放 t1 的 MDL 锁 (Q6)。

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

  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 前的表结构。

评论区的一个描述

备库用不同的备份命令会有不同的情况:

  • 如果备库采用的备份选项为 --single-transaction --dump-slave=(1or2) 时:

    会在备份文件中记录主库备份时点的binlog偏移量,并且关停备库的sql_running进程,备份完成后再开启。此时主库对表的DDL操作传输到备库的relay日志中,但由于备份的sql_running进行处于停止状态,所以并不会运用数据库中,对备库的备份无影响,所有的DDL操作都等待备份完成后再进行。

  • 如果备份采用的备份选项是 --single-transaction --master-data=(1or2)时:

    会在备份文件中记录备库备份时的binlog偏移量,这个时候需要分三种情况进行讨论:

    1. 主库中对表的DDL操作传输到备库时,备库的备份已完成对该表的备份
      此时对备份数据没有影响
    2. 主库中对表的DDL操作传输到备库时,备库的备份还未对该表进行备份
      那么当备份到该表时,备份会报错,例如表定义已改变或者表不存在等
    3. 主库中对表的DDL操作传输到备库时,备库的备份正在对该表进行备份
      此刻DDL语句会被阻塞,处于等待获取MDL写锁的状态。

行锁

MySQL 的行锁是在引擎层由各个引擎自己实现的。但并不是所有的引擎都支持行锁,比如 MyISAM 引擎就不支持行锁。不支持行锁意味着并发控制只能使用表锁(更新操作先加 MDL 读锁互斥 DDL 操作,然后显式声明表锁互斥针其它线程对该表的 DML 操作),对于这种引擎的表,同一张表上任何时刻只能有一个更新在执行,这就会影响到业务并发度。InnoDB 是支持行锁的,相对于表锁,粒度更细,提高了并发效率,这也是 MyISAM 被 InnoDB 替代的重要原因之一。

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

两阶段锁

两阶段锁

以上图中事务 B 会被 事务 A 阻塞,直到事务 A commit 之后,事务 B 才能执行。

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

根据两阶段锁协议,如果事务中需要锁多个行,要把最可能造成锁冲突、最可能影响并发度的锁尽量往后放。此时每个事务对并发越频繁的锁的占有时间会越短,相对较少锁冲突带来的阻塞、并发效率低下、死锁等问题。

例子

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

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

也就是说,要完成这个交易,我们需要 update 两条记录,并 insert 一条记录。当然,为了保证交易的原子性,我们要把这三个操作放在一个事务中。实际上这三个操作产生的锁的频率是不一样的:

  • 因为可能存在很多个顾客都在买影院 B 的票,它的账户余额变化频率应该是最高的
  • 顾客 A 可能同时在买不同影院的票或者在买其它东西,他的账户余额虽然存在变化,但是频率远不及电影院高
  • 剩下的就是交易日志表了,这是一条 insert 操作,它也是会加行锁的,但是因为这是一张日志表,通常都是插入操作,很少有更新,所以它的锁冲突基本没有。

所以应该按照"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) 的操作,而对于并发的所有线程来说是一个接近O(n^2)的操作。假设有 1000 个并发线程要同时更新同一行,那么死锁检测操作就是 100 万这个量级的。虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。

什么情况下会发生死锁检测
  1. 首先是要开启了死锁检测,默认是开启的

  2. 当前事务要加锁的行已经存在了锁,才会进行死锁检测

  3. 一致性读不会加锁(MVCC,如果是串行隔离级别读也会加锁),就不需要进行死锁检测

  4. 死锁检测并不是说要扫描所有事务,基于第2点:当前事务A发现它要申请的锁已经有事务B申请了,此时它就要检测事务B是否有要申请并且在等待的锁,如果有就检查该锁被哪个事务持有了,再检查该事务是否有等待的锁,以此类推,就是一个查找链表上是否存在环的问题,不在这个依赖链表上的事务不会被扫描。

    从这一点也可以看出,死锁检测是从每一个新提交的事务本身开始扫描检测的(因为从逻辑上来说,已经存在的事务都不应该存在死锁,因为存在也会被检测到并回滚了,只有新加进来的事务可能会导致死锁的产生)。

解决热点行更新导致的性能问题
  • 一种头痛医头的方法,就是如果你能确保这个业务一定不会出现死锁(其实可以理解为在业务上保证同一事务中的DML肯定不会出现循环取锁;或者保证取锁有序,即写执行数据库DML的时候要注意保证它们涉及的锁都是按照同一顺序取的,但是涉及到相对复杂的场景就很难做到了),可以临时把死锁检测关掉。但是这种操作本身带有一定的风险,因为业务设计的时候一般不会把死锁当做一个严重错误:

    • 毕竟出现死锁了,就回滚,然后通过业务重试一般就没问题了,这是业务无损的(死锁检测相对较快,直接回滚解开死锁重试)。
    • 而关掉死锁检测意味着可能会出现大量的超时,这是业务有损的(等待过程的时间被白白消耗)。
  • **另一个思路是控制并发度。**根据上面的分析,你会发现如果并发能够控制住,比如同一行同时最多只有 10 个线程在更新,那么死锁检测的成本很低,就不会出现这个问题。一个直接的想法就是,在客户端做并发控制。但是,你会很快发现这个方法不太可行,因为客户端很多。我见过一个应用,有 600 个客户端,这样即使每个客户端控制到只有 5 个并发线程,汇总到数据库服务端以后,峰值并发数也可能要达到 3000。

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

    如果团队里暂时没有数据库方面的专家,不能实现这样的方案,能不能从设计上优化这个问题呢?可以考虑通过将一行改成逻辑上的多行来减少锁冲突。还是以影院账户为例,可以考虑放在多条记录上,比如 10 个记录,影院的账户总额等于这 10 个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的 1/10,可以减少锁等待个数,也就减少了死锁检测的 CPU 消耗。这个方案看上去是无损的,但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退票逻辑,那么这时候就需要考虑当一部分行记录变成 0 的时候,代码要有特殊处理。

如何删除表里面的前 10000 行数据

  • 第一种,直接执行 delete from T limit 10000;

    事务相对较长,则占用锁的时间较长,会导致其他客户端等待资源时间较长

  • 第二种,在一个连接中循环执行 20 次 delete from T limit 500;

    串行化执行,将相对长的事务分成多次相对短的事务,则每次事务占用锁的时间相对较短,其他客户端在等待相应资源的时间也较短。这样的操作,同时也意味着将资源分片使用(每次执行使用不同片段的资源),可以提高并发性(和其它连接)。

  • 第三种,在 20 个连接中同时执行 delete from T limit 500

    人为自己制造锁竞争,加剧并发量

  • 第四种,先查询前 10000 行的主键出来,根据主键分成 20 批,在 20 个连接中同时执行。

    规避第三种方法带来的锁竞争问题,同时又能利用并发执行带来的好处。

next-key lock

先导:例子

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; -- 字段 d 没有索引
commit;

基于上面的行锁介绍比较好理解的是,这个语句会命中 d=5 的这一行,对应的主键 id=5,因此在 select 语句执行完成后,id=5 这一行会加一个写锁,而且由于两阶段锁协议,这个写锁会在执行 commit 语句的时候释放。由于字段 d 上没有索引,因此这条查询语句会做全表扫描。那么,其他被扫描到的,但是不满足条件的 5 行记录上,会不会被加锁呢?接下来没有特殊说明的部分,都是设定在可重复读隔离级别下。

幻读是什么?

下面分析一下,如果只在主键索引的 id=5 这一行加锁,而其他行的不加锁的话,会怎么样。下面先来看一下这个场景(注意:这是假设的 MySQL 不加锁的一个场景):

假设只在 id=5 这一行加行锁

可以看到,session A 里执行了三次查询,分别是 Q1、Q2 和 Q3。它们的 SQL 语句相同,都是 select * from t where d=5 for update。这个语句的意思你应该很清楚了,查所有 d=5 的行,而且使用的是当前读,并且加上写锁。现在,我们来看一下这三条 SQL 语句,分别会返回什么结果。

  1. Q1 只返回 id=5 这一行;
  2. 在 T2 时刻,session B 把 id=0 这一行的 d 值改成了 5,因此 T3 时刻 Q2 查出来的是 id=0 和 id=5 这两行;
  3. 在 T4 时刻,session C 又插入一行(1,1,5),因此 T5 时刻 Q3 查出来的是 id=0、id=1 和 id=5 的这三行。

其中,Q3 读到 id=1 这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行。这里需要对“幻读”做一个说明:

  1. 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现。

    对于不可重复读(RC)隔离级别,快照读会看到别的事务插入的数据,但这是 RC 的版本视图特性,不能算为幻读。

  2. 上面 session B 的修改结果,被 session A 之后的 select 语句用“当前读”看到,不能称为幻读。幻读仅专指“新插入的行”。

此外,幻读是因为 RR 隔离级别下出现下面介绍的问题(bug)的原因。对于 RC 隔离界级别来说,没有这样的问题。所以,可以认为幻读是 RR 隔离级别下才有的一个问题。

因为这三个查询都是加了 for update,都是当前读。而当前读的规则,就是要能读到所有已经提交的记录的最新值。并且,session B 和 sessionC 的两条语句,执行后就会提交,所以 Q2 和 Q3 就是应该看到这两个事务的操作效果,而且也看到了,这跟事务的可见性规则并不矛盾。但是,这是不是真的没问题呢?不,这里还真就有问题。

幻读有什么问题?

破坏了加锁语义

首先是语义上的。session A 在 T1 时刻就声明了,“我要把所有 d=5 的行锁住,不准别的事务进行读写操作”。而实际上,这个语义被破坏了。在例子中 session B 和 session C 里面分别加一条 SQL 语句,再看看会出现什么现象。

假设只在 id=5 这一行加行锁--语义被破坏

session B 的第二条语句 update t set c=5 where id=0,语义是“我把 id=0、d=5 这一行的 c 值,改成了 5”。由于在 T1 时刻,session A 还只是给 id=5 这一行加了行锁, 并没有给 id=0 这行加上锁。因此,session B 在 T2 时刻,是可以执行这两条 update 语句的。这样,就破坏了 session A 里 Q1 语句要锁住所有 d=5 的行的加锁声明。session C 也是一样的道理,对 id=1 这一行的修改,也是破坏了 Q1 的加锁声明。

破坏数据一致性

其次,是数据一致性的问题。我们知道,锁的设计是为了保证数据的一致性。而这个一致性,不止是数据库内部数据状态在此刻的一致性,还包含了数据和日志在逻辑上的一致性。为了说明这个问题,再给 session A 在 T1 时刻再加一个更新语句,即:update t set d=100 where d=5

假设只在 id=5 这一行加行锁--数据一致性问题

update 的加锁语义和 select …for update 是一致的,所以这时候加上这条 update 语句也很合理。session A 声明说“要给 d=5 的语句加上锁”,就是为了要更新数据,新加的这条 update 语句就是把它认为加上了锁的这一行的 d 值修改成了 100。以下是执行过程分析:

  1. 经过 T1 时刻,id=5 这一行变成 (5,5,100),当然这个结果最终是在 T6 时刻正式提交的 ;
  2. 经过 T2 时刻,id=0 这一行变成 (0,5,5);
  3. 经过 T4 时刻,表里面多了一行 (1,5,5);
  4. 其他行跟这个执行序列无关,保持不变。

这样看,这些数据也没啥问题,但是我们再来看看这时候 binlog 里面的内容。

  1. T2 时刻,session B 事务提交,写入了两条语句;
  2. T4 时刻,session C 事务提交,写入了两条语句;
  3. T6 时刻,session A 事务提交,写入了 update t set d=100 where d=5 这条语句。

统一放到一起的话,就是这样的:

update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/

update t set d=100 where d=5;/*所有d=5的行,d改成100*/

这个语句序列,不论是拿到备库去执行,还是以后用 binlog 来克隆一个库,这三行的结果,都变成了 (0,5,100)、(1,5,100) 和 (5,5,100)。也就是说,id=0 和 id=1 这两行和上面主库执行得到的 (0,5,5)、(1,5,5),发生了数据不一致。这个问题很严重,是不行的。

解决其它事务更新带来的不一致性问题

我们分析一下可以知道,这是我们假设 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 这一行的问题解决了。

到这里,可以看到 MySQL 针对条件子句 where 中无索引字段的更新直接残暴地对主键索引中所有行加锁(当然,如果 order by 子句中如果有满足条件的索引字段,就会在该字段中对所有行加锁),因为要被更新的行无法根据某个有序索引紧密地排列在一起从而可以进行简单地锁定,而是散乱地散步在各个索引中,所以只能对所有行加锁。

但同时你也可以看到,id=1 这一行,在数据库里面的结果是 (1,5,5),而根据 binlog 的执行结果是 (1,5,100),也就是说幻读的问题还是没有解决。为什么我们已经这么“凶残”地,把所有的记录都上了锁,还是阻止不了 id=1 这一行的插入和更新呢?原因很简单。在 T3 时刻,我们给所有行加锁的时候,id=1 这一行还不存在,不存在也就加不上锁。也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录,这也是为什么“幻读”会被单独拿出来解决的原因。

解决幻读

产生幻读的原因是,行锁只能锁住行,但是新插入记录这个动作,要更新的是记录之间的“间隙”。因此,为了解决幻读问题,InnoDB 只好引入新的锁,也就是间隙锁 (Gap Lock)。顾名思义,间隙锁,锁的就是两个值之间的空隙。比如文章开头的表 t,初始化插入了 6 个记录,这就产生了 7 个间隙。

表t主键索引上的行锁和间隙锁

这样,当执行 select * from t where d=5 for update 的时候,就不止是给数据库中已有的 6 个记录加上了行锁,还同时加了 7 个间隙锁。这样就确保了无法再插入新的记录。也就是说这时候,在一行行扫描的过程中,不仅将给行加上了行锁,还给行两边的空隙,也加上了间隙锁。所以,数据行是可以加上锁的实体,数据行之间的间隙,也是可以加上锁的实体

行锁和间隙锁的锁语义不同

但是间隙锁跟我们之前碰到过的锁都不太一样。比如行锁,分成读锁和写锁。下图就是这两种类型行锁的冲突关系。

两种行锁间的冲突关系

也就是说,跟行锁有冲突关系的是“另外一个行锁”。但是间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作(其实插入会申请插入意向锁,是它发生了冲突)。间隙锁之间都不存在冲突关系。这句话不太好理解,我给你举个例子:

间隙锁之间不互锁

这里 session B 并不会被堵住。因为表 t 里并没有 c=7 这个记录,因此 session A 加的是间隙锁 (5,10)。而 session B 也是在这个间隙加的间隙锁。它们有共同的目标,即:保护这个间隙,不允许插入值。但,它们之间是不冲突的。

以上的理解是,行锁的后续动作就是需要同步的临界代码区,所有相应操作的线程进入临界代码区之前需要加锁来保证后续临界代码的原子性,加锁动作的互斥关系由锁关系决定,互斥会导致加锁失败的线程阻塞;而对于间隙锁,它是专门针对 insert 和加行锁的互斥性而被创造出来的,在已有的行锁(锁的是一行,重要成员变量就一个,要锁定的行)的数据结构及语义无法满足该需求的情况下,只能创建一个间隙锁出来(锁的是一个区间,成员变量也是只有一个,间隙结束行,但时候由于间隙锁本身的特性,SQL 执行线程发现是间隙锁,就会根据它的间隙结束行找到间隙起始行,所以实际上它的成员变量应该有两个,只不过间隙起始行没有静态地存储下来,而是由执行线程自己动态计算的),在加行锁之前,加间隙锁,然后 insert 动作要加 insert intention lock 插入意向锁,这个锁是会和间隙锁冲突的。

next-key lock

间隙锁和行锁合称 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. session A 执行 select … for update 语句,由于 id=9 这一行并不存在,因此会加上间隙锁 (5,10);
  2. session B 执行 select … for update 语句,同样会加上间隙锁 (5,10),间隙锁之间不会冲突,因此这个语句可以执行成功;
  3. session B 试图插入一行 (9,9,9),被 session A 的间隙锁挡住了,只好进入等待;
  4. session A 试图插入一行 (9,9,9),被 session B 的间隙锁挡住了。

至此,两个 session 进入互相等待状态,形成死锁。当然,InnoDB 的死锁检测马上就发现了这对死锁关系,让 session A 的 insert 语句报错返回了。间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的。

innodb_locks_unsafe_for_binlog设置为1标识不加 gap lock,已经要被废弃了,8.0就没有了,所以不建议设置。如果真要去掉 gap lock,可以考虑改用下面讲的 RC 隔离级别+binlog_format=row

读提交隔离模式

以上分析的问题都是在可重复读隔离级别下的,间隙锁是在可重复读隔离级别下才会生效的。所以,如果把隔离级别设置为读提交的话,就没有间隙锁了。但同时,要解决可能出现的数据和日志不一致问题,需要把 binlog 格式设置为 row。这,也是现在不少公司使用的配置组合。

但是这个配置到底合不合理呢?关于这个问题本身的答案是,如果读提交隔离级别够用,也就是说,业务不需要可重复读的保证,这样考虑到读提交下操作数据的锁范围更小(没有间隙锁),这个选择是合理的。比如说,大家都用读提交,可是逻辑备份的时候,mysqldump 为什么要把备份线程设置成可重复读呢?然后,在备份期间,备份线程用的是可重复读,而业务线程用的是读提交。同时存在两种事务隔离级别,会不会有问题?进一步地,这两个不同的隔离级别现象有什么不一样的,关于我们的业务,“用读提交就够了”这个结论是怎么得到的?如果业务开发和运维团队这些问题都没有弄清楚,那么“没问题”这个结论,本身就是有问题的。

加锁逻辑分析

首先说明一下,这些加锁规则以下前提说明:MySQL 后面的版本可能会改变加锁策略,所以这个规则只限于截止到 5.x 系列 <=5.7.24,8.0 系列 <=8.0.13。

因为间隙锁在可重复读隔离级别下才有效,所以本篇文章接下来的描述,若没有特殊说明,默认是可重复读隔离级别。加锁规则包含了两个“原则”、两个“优化”和一个“bug”。

  1. 原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。
  2. 原则 2:查找过程中访问到的对象(某个字段的某行数据)才会加锁。
  3. 优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
  4. 优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
  5. 一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

先导:例子

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);

案例一:等值查询间隙锁

第一个例子是关于等值条件操作间隙:

等值查询的间隙锁

由于表 t 中没有 id=7 的记录,所以用我们上面提到的加锁规则判断一下的话:

  1. 根据原则 1,加锁单位是 next-key lock,session A 加锁范围就是 (5,10]
  2. 同时根据优化 2,这是一个等值查询 (id=7),而 id=10 不满足查询条件,next-key lock 退化成间隙锁,因此最终加锁的范围是 (5,10)

所以,session B 要往这个间隙里面插入 id=8 的记录会被锁住,但是 session C 修改 id=10 这行是可以的。

案例二:非唯一索引等值锁

第二个例子是关于覆盖索引上的锁:

只加在非唯一索引上的锁

看到这个例子,是不是有一种“该锁的不锁,不该锁的乱锁”的感觉?来分析一下吧。这里 session A 要给索引 c 上 c=5 的这一行加上读锁。

  1. 根据原则 1,加锁单位是 next-key lock,因此会给 (0,5]加上 next-key lock。
  2. 要注意 c 是普通索引,因此仅访问 c=5 这一条记录是不能马上停下来的,需要向右遍历,查到 c=10 才放弃。根据原则 2,访问到的都要加锁,因此要给 (5,10]加 next-key lock。
  3. 但是同时这个符合优化 2:等值判断,向右遍历,最后一个值不满足 c=5 这个等值条件,因此退化成间隙锁 (5,10)
  4. 根据原则 2 ,只有访问到的对象(某个字段的某行)才会加锁,这个查询使用覆盖索引,并不需要访问主键索引,所以主键索引上没有加任何锁,这就是为什么 session B 的 update 语句可以执行完成。

但 session C 要插入一个 (7,7,7) 的记录,就会被 session A 的间隙锁 (5,10) 锁住。需要注意,在这个例子中,lock in share mode 只锁覆盖索引,但是如果是 for update 就不一样了。 执行 for update 时,系统会认为你接下来要更新数据,因此会顺便给主键索引上满足条件的行加上行锁

这个例子说明,锁是加在索引上的;同时,它给我们的指导是,如果你要用 lock in share mode 来给行加读锁避免数据被更新的话,就必须得绕过覆盖索引的优化,在查询字段中加入索引中不存在的字段。比如,将 session A 的查询语句改成 select d from t where c=5 lock in share mode

案例三:主键索引范围锁

第三个例子是关于范围查询的。举例之前,你可以先思考一下这个问题:对于我们这个表 t,下面这两条查询语句,加锁范围相同吗?

mysql> select * from t where id=10 for update;
mysql> select * from t where id>=10 and id<11 for update;

你可能会想,id 定义为 int 类型,这两个语句就是等价的吧?其实,它们并不完全等价。在逻辑上,这两条查语句肯定是等价的,但是它们的加锁规则不太一样。现在,我们就让 session A 执行第二个查询语句,来看看加锁效果。

主键索引上范围查询的锁

现在我们就用前面提到的加锁规则,来分析一下 session A 会加什么锁呢?

  1. 开始执行的时候,要找到第一个 id=10 的行,因此本该是 next-key lock(5,10]。 根据优化 1, 主键 id 上的等值条件,退化成行锁,只加了 id=10 这一行的行锁。
  2. 范围查找就往后继续找,找到 id=15 这一行停下来,因此需要加 next-key lock(10,15]

所以,session A 这时候锁的范围就是主键索引上,行锁 id=10 和 next-key lock(10,15]。这样,session B 和 session C 的结果你就能理解了。这里你需要注意一点,首次 session A 定位查找 id=10 的行的时候,是当做等值查询来判断的,而向右扫描到 id=15 的时候,用的是范围查询判断。

案例四:非唯一索引范围锁

接下来,我们再看两个范围查询加锁的例子,可以对照着案例三来看。需要注意的是,与案例三不同的是,案例四中查询语句的 where 部分用的是字段 c。

非唯一索引范围锁

这次 session A 用字段 c 来判断,加锁规则跟案例三唯一的不同是:在第一次用 c=10 定位记录的时候,索引 c 上加了 (5,10]这个 next-key lock 后,由于索引 c 是非唯一索引,没有优化规则,也就是说不会蜕变为行锁,因此最终 sesion A 加的锁是,索引 c 上的 (5,10](10,15] 这两个 next-key lock。

所以从结果上来看,sesson B 要插入(8,8,8) 的这个 insert 语句时就被堵住了。这里需要扫描到 c=15 才停止扫描,是合理的,因为 InnoDB 要扫到 c=15,才知道不需要继续往后找了。

案例五:唯一索引范围锁 bug

前面的四个案例,我们已经用到了加锁规则中的两个原则和两个优化,接下来再看一个关于加锁规则中 bug 的案例。

唯一索引范围锁的 bug

session A 是一个范围查询,按照原则 1 的话,应该是索引 id 上只加 (10,15]这个 next-key lock,并且因为 id 是唯一键,所以循环判断到 id=15 这一行就应该停止了。但是实现上,InnoDB 会往前扫描到第一个不满足条件的行为止,也就是 id=20。而且由于这是个范围扫描,因此索引 id 上的 (15,20] 这个 next-key lock 也会被锁上。

所以,session B 要更新 id=20 这一行,是会被锁住的。同样地,session C 要插入 id=16 的一行,也会被锁住。

丁大:照理说,这里锁住 id=20 这一行的行为,其实是没有必要的。因为扫描到 id=15,就可以确定不用往后再找了。但实现上还是这么做了,因此我认为这是个 bug。我也曾找社区的专家讨论过,官方 bug 系统上也有提到,但是并未被 verified。所以,认为这是 bug 这个事儿,也只能算我的一家之言,如果你有其他见解的话,也欢迎你提出来。

案例六:非唯一索引上存在"等值"的例子

接下来的例子,是为了更好地说明“间隙”这个概念。这里,给表 t 插入一条新记录。

mysql> insert into t values(30,10,30);

新插入的这一行 c=10,也就是说现在表里有两个 c=10 的行。那么,这时候索引 c 上的间隙是什么状态了呢?要知道,由于非唯一索引上包含主键的值,所以是不可能存在“相同”的两行的。

非唯一索引等值的例子

可以看到,虽然有两个 c=10,但是它们的主键值 id 是不同的(分别是 10 和 30),因此这两个 c=10 的记录之间,也是有间隙的。图中画出了索引 c 上的主键 id。为了跟间隙锁的开区间形式进行区别,用 (c=10,id=30) 这样的形式来表示索引上的一行。

现在,我们来看一下案例六。

这次我们用 delete 语句来验证。注意,delete 语句加锁的逻辑,其实跟 select ... for update 是类似的,也就是在文章开始总结的两个“原则”、两个“优化”和一个“bug”。

delete 示例

这时,session A 在遍历的时候,先访问第一个 c=10 的记录。同样地,根据原则 1,这里加的是 (c=5,id=5)(c=10,id=10) 这个 next-key lock。然后,session A 向右查找,直到碰到 (c=15,id=15) 这一行,循环才结束。根据优化 2,这是一个等值查询,向右查找到了不满足条件的行,所以会退化成 (c=10,id=10)(c=15,id=15) 的间隙锁。也就是说,这个 delete 语句在索引 c 上的加锁范围,就是下图中蓝色区域覆盖的部分。

delete 加锁效果示例

这个蓝色区域左右两边都是虚线,表示开区间,即 (c=5,id=5)(c=15,id=15) 这两行上都没有锁。

案例七:limit 语句加锁

例子 6 也有一个对照案例,场景如下所示:

limit 语句加锁

这个例子里,session A 的 delete 语句加了 limit 2。你知道表 t 里 c=10 的记录其实只有两条,因此加不加 limit 2,删除的效果都是一样的,但是加锁的效果却不同。可以看到,session B 的 insert 语句执行通过了,跟案例六的结果不同。这是因为,案例七里的 delete 语句明确加了 limit 2 的限制,因此在遍历到 (c=10, id=30) 这一行之后,满足条件的语句已经有两条,循环就结束了。因此,索引 c 上的加锁范围就变成了从 (c=5,id=5)(c=10,id=30) 这个前开后闭区间,如下图所示:

带 limit 2 的加锁效果

可以看到,(c=10,id=30) 之后的这个间隙并没有在加锁范围里,因此 insert 语句插入 c=12 是可以执行成功的。

这个例子对我们实践的指导意义就是,在删除数据的时候尽量加 limit。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围

案例八:一个死锁的例子

前面的例子中,我们在分析的时候,是按照 next-key lock 的逻辑来分析的,因为这样分析比较方便。最后我们再看一个案例,目的是说明:next-key lock 实际上是间隙锁和行锁加起来的结果。你一定会疑惑,这个概念不是一开始就说了吗?不要着急,我们先来看下面这个例子:

案例八的操作序列

现在,我们按时间顺序来分析一下为什么是这样的结果。

  1. session A 启动事务后执行查询语句加 lock in share mode,在索引 c 上加了 next-key lock(5,10] 和间隙锁 (10,15)
  2. session B 的 update 语句也要在索引 c 上加 next-key lock(5,10] ,进入锁等待;
  3. 然后 session A 要再插入 (8,8,8) 这一行,被 session B 的间隙锁锁住。由于出现了死锁,InnoDB 让 session B 回滚。

你可能会问,session B 的 next-key lock 不是还没申请成功吗?其实是这样的,session B 的“加 next-key lock(5,10] ”操作,实际上分成了两步,先是加 (5,10) 的间隙锁,加锁成功;然后加 c=10 的行锁,这时候才被锁住的。也就是说,我们在分析加锁规则的时候可以用 next-key lock 来分析。但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的

案例九:索引倒序加锁

事务进入锁等待状态

实际上,这里 session B 和 session C 的 insert 语句都会进入锁等待状态。你可以试着分析一下,出现这种情况的原因是什么?在可重复读级别下,因为是按照索引 c 倒序,c 上面索引且 where 子句有条件筛选,所以该 SQL 会直接走 c 索引树避免排序。但是是反向搜索的(从大到小):

  1. 由于是 order by c desc,第一个要定位的是索引 c 上“最右边的”c=20 的行,所以会加上间隙锁 (20,25) 和 next-key lock (15,20]
  2. 在索引 c 上向左遍历,要扫描到 c=10 才停下来,所以 next-key lock 会加到 (5,10],这正是阻塞 session B 的 insert 语句的原因。
  3. 在扫描过程中,c=20、c=15、c=10 这三行都存在值,由于是 select *,所以会在主键 id 上加三个行锁。

因此,session A 的 select 语句锁的范围就是:

  1. 索引 c 上 (5, 25);
  2. 主键索引上 id=15、20 两个行锁。

案例十:不等号条件里地等值查询

begin;
select * from t where id>9 and id<12 order by id desc for update;

这个语句的加锁范围是主键索引上的 (0,5](5,10](10, 15)。也就是说,id=15 这一行,并没有被加上行锁。加锁单位是 next-key lock,都是前开后闭区间,但是这里用到了优化 2,即索引上的等值查询,向右遍历的时候 id=15 不满足条件,所以 next-key lock 退化为了间隙锁 (10, 15)

  1. 首先这个查询语句的语义是 order by id desc,要拿到满足条件的所有行,优化器必须先找到“第一个 id<12 的值”。
  2. 这个过程是通过索引树的搜索过程得到的,在引擎内部,其实是要找到 id=12 的这个值,只是最终没找到,但找到了 (10,15) 这个间隙。
  3. 然后向左遍历,在遍历过程中,就不是等值查询了,会扫描到 id=5 这一行,所以会加一个 next-key lock (0,5]

也就是说,在执行过程中,无论查询 SQL 是通过大于小于号的范围查询还是等于号的等值查询,在 MySQL 内部通过树搜索的方式定位记录的时候,用的都是“等值查询”的方法,前者是等值查询范围边界,后者就是等值查询用户要查询的值。

案例十一、等值查询的过程及死锁发生与检查

begin;
select id from t where c in(5,20,10) lock in share mode;

这条查询语句里用的是 in,我们先来看这条语句的 explain 结果。

in 语句的 explain 结果

可以看到,这条 in 语句使用了索引 c 并且 rows=3,说明这三个值都是通过 B+ 树搜索定位的。在查找 c=5 的时候,先锁住了 (0,5]。但是因为 c 不是唯一索引,为了确认还有没有别的记录 c=5,就要向右遍历,找到 c=10 才确认没有了,这个过程满足优化 2,所以加了间隙锁 (5,10)。同样的,执行 c=10 这个逻辑的时候,加锁的范围是 (5,10] 和 (10,15);执行 c=20 这个逻辑的时候,加锁的范围是 (15,20](20,25)。通过这个分析,我们可以知道,这条语句在索引 c 上加的三个记录锁的顺序是:先加 c=5 的记录锁,再加 c=10 的记录锁,最后加 c=20 的记录锁。

死锁

如果同时有另外一个语句,是这么写的:

select id from t where c in(5,20,10) order by c desc for update;

间隙锁是不互锁的,但是这两条语句都会在索引 c 上的 c=5、10、20 这三行记录上加记录锁。这里你需要注意一下,由于语句里面是 order by c desc, 这三个记录锁的加锁顺序,是先锁 c=20,然后 c=10,最后是 c=5。也就是说,这两条语句要加锁相同的资源,但是加锁顺序相反。当这两条语句并发执行的时候,就可能出现死锁。

怎么看死锁?

下图是在出现死锁后,执行 show engine innodb status 命令得到的部分输出。这个命令会输出很多信息,有一节 LATESTDETECTED DEADLOCK,就是记录的最后一次死锁信息。

死锁现场

我们来看看这图中的几个关键信息。

  1. 这个结果分成三部分:
    • (1) TRANSACTION,是第一个事务的信息;
    • (2) TRANSACTION,是第二个事务的信息;
    • WE ROLL BACK TRANSACTION (1),是最终的处理结果,表示回滚了第一个事务。
  2. 第一个事务的信息中:
    • WAITING FOR THIS LOCK TO BE GRANTED,表示的是这个事务在等待的锁信息;
      • index c of table test.t,说明在等的是表 t 的索引 c 上面的锁;
      • lock mode S waiting 表示这个语句要自己加一个读锁,当前的状态是等待中;
        • lock_mode X waiting表示next-key lock;
        • lock_mode X locks rec but not gap是只有行锁;
        • locks gap before rec,就是只有间隙锁;
      • Record lock 说明这是一个记录锁;
      • n_fields 2 表示这个记录是两列,也就是字段 c 和主键字段 id;
      • 0: len 4; hex 0000000a; asc ;; 是第一个字段,也就是 c。值是十六进制 a,也就是 10;
      • 1: len 4; hex 0000000a; asc ;; 是第二个字段,也就是主键 id,值也是 10;
      • 这两行里面的 asc 表示的是,接下来要打印出值里面的“可打印字符”,但 10 不是可打印字符,因此就显示空格。
    • 第一个事务信息就只显示出了等锁的状态,在等待 (c=10,id=10) 这一行的锁。
    • 既然出现死锁了,就表示这个事务也占有别的锁,但是没有显示出来。别着急,我们从第二个事务的信息中推导出来。
  3. 第二个事务显示的信息要多一些:
    • “HOLDS THE LOCK(S)”用来显示这个事务持有哪些锁;
    • index c of table test.t 表示锁是在表 t 的索引 c 上;
    • hex 0000000a 和 hex 00000014 表示这个事务持有 c=10 和 c=20 这两个记录锁;
    • WAITING FOR THIS LOCK TO BE GRANTED,表示在等 (c=5,id=5) 这个记录锁。

从上面这些信息中,我们就知道:

  1. “lock in share mode”的这条语句,持有 c=5 的记录锁,在等 c=10 的锁;
  2. “for update”这个语句,持有 c=20 和 c=10 的记录锁,在等 c=5 的记录锁。

因此导致了死锁。这里,我们可以得到两个结论:

  1. 由于锁是一个个加的,要避免死锁,对同一组资源,要按照尽量相同的顺序访问;
  2. 在发生死锁的时刻,for update 这条语句占有的资源更多,回滚成本更大,所以 InnoDB 选择了回滚成本更小的 lock in share mode 语句,来回滚。

案例十二、delete 导致锁范围变化以及查看锁等待

delete 导致间隙变化

可以看到,由于 session A 并没有锁住 c=10 这个记录,所以 session B 删除 id=10 这一行是可以的。但是之后,session B 再想 insert id=10 这一行回去就不行了。现在我们一起看一下此时 show engine innodb status 的结果,看看能不能给我们一些提示。锁信息是在这个命令输出结果的 TRANSACTIONS 这一节。

锁等待信息

我们来看几个关键信息。

  1. index PRIMARY of table test.t ,表示这个语句被锁住是因为表 t 主键上的某个锁。
  2. lock_mode X locks gap before rec insert intention waiting 这里有几个信息:
    • insert intention 表示当前线程准备插入一个记录,这是一个插入意向锁。为了便于理解,你可以认为它就是这个插入动作本身。
    • gap before rec 表示这是一个间隙锁,而不是记录锁。
  3. 那么这个 gap 是在哪个记录之前的呢?接下来的 0~4 这 5 行的内容就是这个记录的信息。
  4. n_fields 5 也表示了,这一个记录有 5 列:
    • 0: len 4; hex 0000000f; asc ;; 第一列是主键 id 字段,十六进制 f 就是 id=15。所以,这时我们就知道了,这个间隙就是 id=15 之前的,因为 id=10 已经不存在了,它表示的就是 (5,15)
    • 1: len 6; hex 000000000513; asc ;; 第二列是长度为 6 字节的事务 id,表示最后修改这一行的是 trx id 为 1299 的事务。
    • 2: len 7; hex b0000001250134; asc % 4;; 第三列长度为 7 字节的回滚段信息。可以看到,这里的 acs 后面有显示内容 (% 和 4),这是因为刚好这个字节是可打印字符。
    • 后面两列是 c 和 d 的值,都是 15。

因此,可以得出由于 delete 操作把 id=10 这一行删掉了,原来的两个间隙 (5,10)(10,15)变成了一个 (5,15)

说到这里,可以联合起来再思考一下这两个现象之间的关联:

  1. session A 执行完 select 语句后,什么都没做,但它加锁的范围突然“变大”了;
  2. 当我们执行 select * from t where c>=15 and c<=20 order by c desc lock in share mode; 向左扫描到 c=10 的时候,要把 (5, 10]锁起来。

也就是说,所谓“间隙”,其实根本就是由“这个间隙右边的那个记录”定义的(在这里看起来,间隙锁貌似又不像是拥有左边界和右边界两个属性,其实就是只有一个属性,就是右边界,左边界相当于是一个虚的概念,由 SQL 执行线程在其扫描索引树的过程自己根据存在的间隙锁的右边界计算出来)。

即使是空表也是有间隙的,因为每个索引都会计算出一个最大值 supremum 作为右边界。

复现空表的 next-key lock

session A 这个查询语句加锁的范围就是 next-key lock (-∞, supremum]。

show engine innodb status 部分结果

案例十三、update导致锁范围变化

update 的例子

session A 的加锁范围是索引 c 上的 (5,10](10,15](15,20](20,25](25,supremum]

注意:根据 c>5 查到的第一个记录是 c=10,因此不会加 (0,5]这个 next-key lock。

之后 session B 的第一个 update 语句,要把 c=5 改成 c=1,可以理解为两步:

  1. 插入 (c=1, id=5) 这个记录;
  2. 删除 (c=5, id=5) 这个记录。

索引 c 上 (5,10) 间隙是由这个间隙右边的记录,也就是 c=10 定义的。所以通过这个操作,session A 的加锁范围变成了下图所示的样子:

session B 修改后, session A 的加锁范围

好,接下来 session B 要执行 update t set c = 5 where c = 1 这个语句了,一样地可以拆成两步:

  1. 插入 (c=5, id=5) 这个记录;
  2. 删除 (c=1, id=5) 这个记录。

第一步试图在已经加了间隙锁的 (1,10) 中插入数据,所以就被堵住了。

总结

上面的所有案例都是在可重复读隔离级别 (repeatable-read) 下验证的。同时,可重复读隔离级别遵守两阶段锁协议,所有加锁的资源,都是在事务提交或者回滚的时候才释放的。

其实加锁动作是随着原本的行扫描动作而发生的,行扫描有两种方式,一种是逐行扫描(也就是我们常说的全表扫描),另一种是根据索引树同层节点的有序性进行树搜索。所以:

  • 如果 where 条件子句中的条件字段都没有索引,那么检索方式就是在主键索引进行全表扫描,此时扫描的每一行都会被按照以上案例逻辑进行加锁,这样的逻辑是对的,因为此时已经无法保证条件子句中的条件字段在索引中是有序的了,匹配条件的数据行无规则散落在各个 page,无法按照某个规律加锁来减小锁的粒度,所以只能全表加锁,即在逐行扫描全表的过程中对每一行加锁。
  • insert 操作会对 insert 成功之后的该行加行锁、updatedelete 都会被加 next-key lock 。update 对于二级索引来说可以算是先删除后插入。
  • where 条件子句中所有的条件字段以及 set 子句中等号=右边的字段都是要读取的字段,即这些字段都会被扫描。如果 where 子句中所有条件字段都没有索引,就会变成主键索引全表扫描,如果存在 order 子句且排序字段存在索引,在满足一定条件下,会扫描该字段的索引,那么无论是扫描哪个索引都会在其上对扫描地每一行加锁;如果有索引就会选择一个最优索引进行索引树检索和加锁、如果该字段不是主键字段,而加的行锁是 X 锁,会相应地对主键索引中该行加 X 行锁,lock in share mode 就不会这样;

在案例八中,可以清楚地知道 next-key lock 实际上是由间隙锁加行锁实现的。如果切换到读提交隔离级别 (read-committed) 的话,就好理解了,过程中去掉间隙锁的部分,也就是只剩下行锁的部分。其实读提交隔离级别在外键场景下还是有间隙锁,相对比较复杂。另外,在读提交隔离级别下还有一个优化,即:语句执行过程中加上的行锁,在语句执行完成后,就要把“不满足条件的行”上的行锁直接释放了,不需要等到事务提交。也就是说,读提交隔离级别下,锁的范围更小,锁的时间更短,这也是不少业务都默认使用读提交隔离级别的原因。

七、MVCC 隔离性和锁的原理

如果是可重复读隔离级别,事务 T 启动的时候会创建一个视图 read-view,之后事务 T 执行期间,即使有其他事务修改了数据,事务 T 看到的仍然跟在启动时看到的一样。也就是说,一个在可重复读隔离级别下执行的事务,好像与世无争,不受外界影响;但是一个事务要更新一行,如果刚好有另外一个事务拥有这一行的行锁,它又不能这么超然了,会被锁住,进入等待状态。问题是,既然进入了等待状态,那么等到这个事务自己获取到行锁要更新数据的时候,它读到的值又是什么呢?肯定是要读到该数据的最新值,不然锁的意义何在,但是这又和前面的隔离性貌似产生了矛盾,所以要看下 MySQL 的 MVCC 下隔离性到底是如何实现的,它提供的语义是怎样的。

先导:一个并发更新示例

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

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

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

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

使用begin,一致性视图是在执行第一个快照读语句时创建的;

使用start transaction,一致性视图是在执行 start transaction with consistent snapshot 时创建的。

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

以上执行结果事务 B 查到的 k 值是 3,而事务 A 查到的 k 值是1。

快照在 MVCC 里是怎么工作的

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

  • 一个是 view。它是一个用查询语句定义的虚拟表,在调用的时候执行查询语句并生成结果。创建视图的语法是 create view … ,而它的查询方法与表一样。
  • 另一个是 InnoDB 在实现 MVCC 时用到的一致性读视图,即 consistent read view,用于支持 RC(Read Committed,读提交)和 RR(Repeatable Read,可重复读)隔离级别的实现。它没有独立的物理结构,作用是事务执行期间用来定义“我能看到什么数据”。

在可重复读隔离级别下,事务在启动的时候就“拍了个快照”。注意,这个快照是基于整库的。如果一个库有 100G,实际上,并不需要拷贝一份这 100G 的数据作为副本,所以呼应了这个视图是没有独立的物理结构的

"版本"的数据结构

InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的(只读事务分配的是随机ID,因为只读事务不会产生新的数据版本,不会被加入到按照事务 ID 排序工作的read view中)。而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本(每个事务会申请空间基于 read view 中待更新行的当前版本拷贝一份副本到该空间中进行修改,当然这个事务后续可以是待提交、提交、回滚等状态的,而一个提交状态的事务产生的数据版本才会被 read view 采纳),并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id

行状态变更图

图中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。所谓 undo log 就是图中的三个虚线剪头 U1、U2、U3 , 它们是代表着一个实际的数据结构,而 V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来(所以在聚簇索引中存储的都是最新版本的数据,在查询的时候根据下面的隔离性算法进行实际的版本计算)。undo Log 记录了每个对应版本对应行数据的值。 undo Log 中分为两种类型:

  1. INSERT_UNDO(INSERT操作),记录插入的唯一键值;
  2. UPDATE_UNDO(包含UPDATE及DELETE操作),记录修改的唯一键值以及old column记录。

基于"多版本"数据结构实现隔离性的算法

以下来分析 InnoDB 定义那个“100G”的快照的算法。

按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。

在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。

数据版本可见性规则

这个视图数组把所有的 row trx_id 分成了几种不同的情况,这不同的情况就决定了 row 的可见性:

  1. 如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;

  2. 如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;

  3. 如果落在黄色部分,那就包括两种情况

    1. row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;

    2. row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。

      为什么存在落在黄色部分但是不在数组中的 row trx_id呢?黄色部分就是 read view 数组,即当前事务创建之初已经创建但是未提交的其它数组,但是其实可能存在一些事务也已经创建了并且提交了的。这些事务或许是晚启动的,只不过是一个短事务,比一些早启动的长事务还要早提交了。

对于一个事务视图来说,某个数据版本的可见性还可以这样定义:

除了自己的更新总是可见以外,有三种情况:

  • 版本未提交,不可见;

  • 版本已提交,但是是在视图创建后提交的,不可见;

  • 版本已提交,而且是在视图创建前提交的,可见。

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

所以 MySQL 在一个事务中进行一致性查询的时候,当查询到某行数据,就会从它的最新数据的 row trx_id 开始匹配,如果匹配结果一直不可见就一直沿着 undo log 往前计算旧版本(旧版本不一定比新版本的事务 ID 小,例如提到的晚创建但是早提交的短事务),直到匹配为止,在 关于回滚段的删除策略 中提到,当数据库中不存在比 undo log 更早的 read view 的时候就可以删除该 undo log 了,就是和 read view 的创建特性有关。

InnoDB 利用了以上“多版本”数据结构和算法,实现了“秒级创建快照”的能力,其实创建快照的动作以及消耗延迟均摊到了事务中各个实际操作上,而不是在创建事务之初就拷贝一份副本。

结合先导例子和 MVCC 实现分析一致性读

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

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

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

事务 A 查询数据逻辑图

从图中可以看到,第一个有效更新是事务 C,把数据从 (1,1) 改成了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。第二个有效更新是事务 B,把数据从 (1,2) 改成了 (1,3)。这时候,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。当事务 A 查询的时候,其实事务 B 还没有提交,但是事务 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 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读

另外,我们还能用另外一种语言来描述它们的可见性:

事务 A 的查询语句的视图数组是在事务 A 启动的时候生成的,这时候:

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

结合先导例子和 MVCC 实现以及行锁分析更新逻辑

以上一致性读的分析过程中,事务 B 的 update 语句,如果按照一致性读,结果是不对的,因为它读到的是最新版本的数据,而该数据不在事务 B 的 read view 中。

事务 B 更新逻辑图

上图中,事务 B 的视图数组是先生成的,之后事务 C 才提交,不是应该看不见 (1,2) 吗,怎么能算出 (1,3) 来?确实如此,如果事务 B 在更新之前查询一次数据,这个查询返回的 k 的值确实是 1但是,当它要去更新数据的时候,就不能再在历史版本上更新了,否则事务 C 的更新就丢失了(这就是 MySQL 给出的 MVCC 语义,读是一致性读,但是更新要基于最新版本数据进行更新)。因此,事务 B 此时的 set k=k+1 是在(1,2)的基础上进行的操作。

所以,这里就用到了这样一条规则:更新数据(指的就是某一句 update 语句,而不是 select 语句)都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。因此,在更新的时候,当前读拿到的数据是 (1,2),更新后生成了新版本的数据 (1,3),同时新版本的 row trx_id 是 101。所以,在事务 B 在更新完毕之后执行到查询语句,一看自己的版本号是 101,最新数据的版本号也是 101,是自己的更新,可以直接使用,所以查询得到的 k 的值是 3。

所有更新语句如果需要读取数据再更新,例如update t set k = k+1,会在第一步读取数据的时候就会加上 X 锁。相当于下面的select ... for update

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

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

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

事务 A、B、C'的执行流程

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

这时候,“两阶段锁协议”就要上场了。事务 C’没提交,也就是说 (1,2) 这个版本上的写锁还没释放。而事务 B 是当前读,必须要读最新版本,而且必须加锁,因此就被阻塞了,必须等到事务 C’释放这个锁,才能完成它的加锁进行当前读。

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

总结:事务的可重复读能力

可重复读的核心就是一致性读(consistent read);

  • 默认的 select 语句执行的就是一致性读,基于 read view + 多版本数据,不加锁。
  • update 语句中涉及的读操作都是当前读,加 X 锁(update 本身就要加 X 锁)。
  • select 语句后面加 lock in share mode 或者 for update 都是当前读,前者加 S 锁,后者加 X 锁。
  • 锁都是锁在最新版本数据。

所以即使是 MVCC 下:

  • 如果当前事务需要对某行数据做更新操作,但是已经存在其它事务对该行进行了当前读或者更新操作且未提交,当前事务就需要进入锁等待。
  • 如果当前事务需要对某行数据做当前读操作,但是已经存在其它事务对该行进行了更新操作且未提交,当前事务也需要进入锁等待。

读已提交的分析

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

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

所以它们的区别主要就是在一致性读,对于当前读、加锁、更新逻辑它们是完全一致。

下面分析在读提交隔离级别下,事务 A 和事务 B 的查询语句查到的 k,分别应该是多少呢?

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

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

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

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

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

所以,这时候事务 A 查询语句返回的是 k=2。显然地,事务 B 查询结果 k=3。

表结构没有MVCC

表结构目前没有多版本控制,都是通过 MDL 控制的,即使是一个 RR 事务在表被删除之前启动了,如果这个事务没有产生读写表操作,那么另一个事务删除表就不会被 MDL 阻塞,删除表之后(即使是还没提交),前一个事务就看到表被删除了,而不是和启动事务时一致,表还存在(此外 DDL 含有一个隐式提交事务,所以即使专门手动开启了一个事务,执行一个 DDL 不手动提交,MySQL 都会对这个 DDL 进行提交,保证其它事务立马能看到这个修改)。

据说 MySQL 目前有这方面支持的打算,可能在不久的未来就支持了。

为何条件匹配却无法更新数据

下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读。现在,我要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是却发现了一个“诡异”的、改不掉的情况。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> select * from t;
+----+------+
| id | c    |
+----+------+
|  1 |    1 |
|  2 |    2 |
|  3 |    3 |
|  4 |    4 |
+----+------+
4 rows in set (0.00 sec)

mysql> update t set c = 0 where id=c;
Query OK, 0 rows affected (11.41 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> select * from t;
+----+------+
| id | c    |
+----+------+
|  1 |    1 |
|  2 |    2 |
|  3 |    3 |
|  4 |    4 |
+----+------+
4 rows in set (0.00 sec)

这是因为:

  1. 在 RR 隔离级别下,begin 手动开启事务之后,第一个 select 触发了事务创建。

  2. 此时存在另外一个事务先执行了 update t set c = 0 where id=c; 操作并提交事务。

  3. 然后当前事务再执行 update t set c = 0 where id=c; 的时候,是基于当前读获取到了另一个已经提交事务(如果该事务没有提交当前语句会被阻塞)的更新,所以条件已经不匹配了,故更新行数为0。

  4. 最后当前事务再次执行 select 的时候,此时是一致性读,还是保持的创建事务之初的 read view。所以读取到的还是老版本数据,出现了无法更新的诡异现象。

这种情形多数出现在业务层面使用乐观锁控制并发的场景下,例如使用一个专门的版本控制字段version放在更新语句后面,如:where condition = xxx and version = xxx,然后根据执行之后返回的affected rows与预期是否一致判断更新是否执行成功,这样就可以实现一个乐观控制了。

至于执行失败之后是要轮询重试;还是在一开始就在事务中对所有影响到更新操作计算的查询使用当前读加 S 锁,还是要看具体情况。前者不断起事务训轮重试,如果并发量高,负担高;后者对参与更新操作条件计算的查询都加 S 锁会阻塞其它对于查询行的更新,具体要看写操作频繁与否以及要加 S 锁的查询多不多。

MVCC下写丢失的一个例子

问题

业务上有这样的需求,A、B 两个用户,如果互相关注,则成为好友。设计上是有两张表,一个是 like 表,一个是 friend 表,like 表有 user_idliker_id 两个字段,我设置为复合唯一索引即 uk_user_id_liker_id。语句执行逻辑是这样的:

以 A 关注 B 为例:

第一步,先查询对方有没有关注自己(B 有没有关注 A)select * from like where user_id = B and liker_id = A;

  • 如果有,则成为好友insert into friend;
  • 没有,则只是单向关注关系insert into like;
  • 但是如果 A、B 同时关注对方,会出现不会成为好友的情况。因为上面第 1 步,双方都没关注对方。第 1 步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。请问这种情况,在 MySQL 锁层面有没有办法处理?(实际上之前都是在应用层代码加锁或者CAS保证的)

以下是表结构:

CREATE TABLE `like` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `liker_id` int(11) NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_id_liker_id` (`user_id`,`liker_id`)
) ENGINE=InnoDB;

CREATE TABLE `friend` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `friend_1_id` int(11) NOT NULL,
  `friend_2_id` int(11) NOT NULL,
  UNIQUE KEY `uk_friend` (`friend_1_id`,`friend_2_id`),
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

在并发场景下,同时有两个人,设置为关注对方,就可能导致无法成功加为朋友关系:

并发逻辑操作顺序

由于一开始 A 和 B 之间没有关注关系,所以两个事务里面的 select 语句查出来的结果都是空。因此,session 1 的逻辑就是“既然 B 没有关注 A,那就只插入一个单向关注关系”。session 2 也同样是这个逻辑。这个结果对业务来说就是 bug 了。因为在业务设定里面,这两个逻辑都执行完成以后,是应该在 friend 表里面插入一行记录的。

方案

首先,要给“like”表增加一个字段,比如叫作 relation_ship,并设为整型,取值 1、2、3。

  • 值是 1 的时候,表示 user_id 关注 liker_id;
  • 值是 2 的时候,表示 liker_id 关注 user_id;
  • 值是 3 的时候,表示互相关注。

然后,当 A 关注 B 的时候,应用代码里面,比较 A 和 B 的大小

  • 如果 A < B

    mysql> begin; /*启动事务*/
    insert into `like`(user_id, liker_id, relation_ship) values(A, B, 1) on duplicate key update relation_ship=relation_ship | 1;
    select relation_ship from `like` where user_id=A and liker_id=B;
    /*代码中判断返回的 relation_ship,
      如果是1,事务结束,执行 commit
      如果是3,则执行下面这两个语句:
      */
    insert ignore into friend(friend_1_id, friend_2_id) values(A,B);
    commit;
    
  • 如果 A > B

    mysql> begin; /*启动事务*/
    insert into `like`(user_id, liker_id, relation_ship) values(B, A, 2) on duplicate key update relation_ship=relation_ship | 2;
    select relation_ship from `like` where user_id=B and liker_id=A;
    /*代码中判断返回的 relation_ship,
      如果是2,事务结束,执行 commit
      如果是3,则执行下面这两个语句:
    */
    insert ignore into friend(friend_1_id, friend_2_id) values(B,A);
    commit;
    

这个设计里,让like表里的数据保证 user_id < liker_id,这样不论是 A 关注 B,还是 B 关注 A,在操作like表的时候,如果反向的关系已经存在,就会出现行锁冲突。然后,insert … on duplicate 语句,确保了在事务内部,执行了这个 SQL 语句后,就强行占住了这个行锁,之后的 select 判断 relation_ship 这个逻辑时就确保了是在行锁保护下的读操作。

操作符 “|” 是按位或,连同最后一句 insert 语句里的 ignore,是为了保证重复调用时的幂等性。这样,即使在双方“同时”执行关注操作,最终数据库里的结果,也是 like 表里面有一条关于 A 和 B 的记录,而且 relation_ship 的值是 3, 并且 friend 表里面也有了 A 和 B 的这条记录。

这里的按位或操作很巧妙,1|2 == 2|1 == 3|1 == 3|2 == 3、1|1 == 1、2|2 == 2

  1. insert ... on duplicate key update语法:对于表中的所有唯一索引(包含主键索引)来说,如果当前插入行和表中某行重复了,则执行后面的更新语句:
    • 单行插入用法:INSERT INTO t1 (a,b,c) VALUES (1,2,3) ON DUPLICATE KEY UPDATE c=c+1;
    • 多行插入用法:INSERT INTO t1 (a,b,c) VALUES (1,2,3),(4,5,6) ON DUPLICATE KEY UPDATE c=VALUES(c);
  2. insert ignore语法:对于表中的所有唯一索引(包含主键索引)来说,如果当前插入行和表中某行重复了则不执行该 insert,不会报错。
    • insert into 检查条件一样,不一样的是出现重复则会报错
    • replace into 检查条件也一样,不一样的是出现重复会覆盖(删除原有插入当前)、不存在则insert into

这里使用两个唯一索引,不是说不建议使用吗?不建议使用指的是在“业务开发保证不会插入重复记录”的情况下,着重要解决性能问题的时候,才建议尽量使用普通索引。而像这个例子里,按照这个设计,业务根本就是保证“我一定会插入重复数据,数据库一定要要有唯一性约束”,这时就没啥好说的了,唯一索引建起来吧。

八、普通索引和唯一索引

  1. 如果确定某个数据列只包含彼此各不相同的值,在为这个数据列创建索引的时候,就应该用关键字UNIQUE把它定义为一个唯一索引, MySQL 会在有新纪录插入数据表时,自动检查新纪录的这个字段的值是否已经在某个记录的这个字段里出现过了。如果是,MySQL 将拒绝插入那条新纪录。也就是说,唯一索引可以保证数据记录的唯一性。

  2. 此外,对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。而对于普通索引来说,查找到满足条件的第一个记录后,需要查找下一个记录,直到碰到第一个不满足条件的记录。

事实上,在许多场合,人们创建唯一索引的目的往往不是为了提高访问速度,而只是为了避免数据出现重复。

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

select name from cuser where id_card = 'xxxxxxxyyyyyyzzzzz';

所以,一定会考虑在 id_card 字段上建索引。由于身份证号字段比较大,建议把身份证号当做主键,那么现在有两个选择,要么给 id_card 字段创建唯一索引,要么创建一个普通索引。如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的。此时再从性能的角度考虑,选择唯一索引还是普通索引呢?选择的依据是什么呢?

先导:示例

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

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

InnoDB 的索引组织结构

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

查询过程:几乎无差异

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

  • 对于普通索引来说,查找到满足条件的第一个记录 (5,500) 后,需要查找下一个记录,直到碰到第一个不满足 k=5 条件的记录。

  • 对于唯一索引来说,由于索引定义了唯一性,查找到第一个满足条件的记录后,就会停止继续检索。

那么,这个不同带来的性能差距会有多少呢?答案是,微乎其微。InnoDB 的数据是按数据页为单位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在 InnoDB 中,每个数据页的大小默认是 16KB。因为引擎是按页读写的,所以说,当找到 k=5 的记录的时候,它所在的数据页就都在内存里了。那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。当然,如果 k=5 这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些。但是,我们之前计算过,对于整型字段,一个数据页可以放近千个 key,因此出现这种情况的概率会很低。所以,我们计算平均性能差异时,仍可以认为这个操作成本对于现在的 CPU 来说可以忽略不计。

更新过程

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

change buffer

当需要更新一个数据页时,如果数据页在内存中就直接更新,而如果这个数据页还没有在内存中的话,在不影响数据一致性的前提下,InnoDB 会将这些更新操作缓存在 change buffer 中,这样就不需要从磁盘中读入这个数据页了。在下次查询需要访问这个数据页的时候,将数据页读入内存,然后才真正将 change buffer 中与这个页有关的操作进行执行并得到最新结果,这个过程称为 merge。除了访问这个数据页会触发 merge 外,系统有后台线程会定期 merge。在数据库正常关闭(shutdown)的过程中,也会执行 merge 操作。

需要说明的是,虽然名字叫作 change buffer,实际上它是可以持久化的数据。也就是说,change buffer 在内存中有拷贝,也会被写入到磁盘上的 ibdata 文件(表共享空间)中,对应的内部系统表名为SYS_IBUF_TABLE,在做 merge 操作的时候应该就会持久化到 ibdata。

显然,如果能够将更新操作先记录在 change buffer,减少读磁盘,语句的执行速度会得到明显的提升。而且,数据读入内存是需要占用 buffer pool 的,所以这种方式还能够避免占用内存,提高内存利用率(change buffer 虽然和 page 一样也占用 buffer pool,但是它是有效内存占用,即仅保存实际要操作的某行数据的信息,而如果把 page 加载到内存中进行实际更新,此时 page 中其它不需要更新的数据就算是无效数据,内存利用率不高)。

什么条件下可以使用 change buffer

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

另外,MySQL 中锁是一个单独存在的数据结构,一个锁记录包含了它要锁的对象,所以如果一个修改操作在锁记录中发现了它要修改的页,此时 change buffer 也会被定义为不能用(实际上连该页都不能修改了)。

对于用户来说,更新表数据操作就是通过 insert、update、delete 等语句对表数据进行修改,而对于 InnoDB 来说,实际上都是在修改索引,即聚簇索引或者二级索引:

  • insert 语句和 delete 语句会修改当前表的聚簇索引和所有二级索引
  • update 语句会修改 update 子句中包含的聚簇索引和二级索引

而修改索引就要先将索引从磁盘加载内存进行修改,change buffer 就是延迟实际修改动作从而延迟读取数据页(磁盘随机读IO)的手段,但是对于一些唯一索引(包含主键索引)的修改和条件子句中包含的索引(字段),都无法应用 change buffer。

  1. 对于 insert 和 update 子句,对于主键索引或者唯一索引的插入或者修改无法应用 change buffer ,因为要查询 insert 或者 update 的主键值是否已经存在,就必须读取主键索引或者唯一索引进行验证。

    但是只要表中包含普通索引,insert 语句就必然也会修改普通索引树,此时对于普通索引树的修改也是可以应用 change buffer 的。

  2. 对于 update、delete 语句的 where 条件子句中包含的索引字段的修改也无法应用 change buffer。

    • update T set a = 1, b = 2, c = 3 where a = 3:a 是普通索引、b 是唯一索引、c 是普通索引,此时 a、b 都无法应用 change buffer,c 可以应用
    • delete from T where c = 3;:此时 c 不能应用 change buffer,a、b 的修改都可以应用,虽然 b 是唯一索引,但是这是删除操作,无须验证是否唯一,在下次涉及索引 b 的插入和修改的时候就会加载对应的 page 到内存,此时会 merge 当前删除(或者后台定期 merge、数据库 shutdown),再进行索引值的唯一验证。
  3. delete 语句除了条件子句中包含的索引字段,剩余的表中定义的索引树的修改,都可以应用 change buffer。

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

修改索引的过程

一起来看看如果要在这张表中插入一个新记录 (4,400) 的话,InnoDB 的处理流程是怎样的。

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

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

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

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

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

    将数据从磁盘读入内存涉及随机 IO 的访问,是数据库里面成本最高的操作之一。change buffer 因为减少了随机磁盘访问(因为延迟了实际修改索引树的操作,所以在写入change buffer到真正触发数据所在页的 change buffer merge 之前的这段时间,可能在该页上发生了多次写操作,产生了很多 buffer ,此时触发该页 merge 的时候,原本需要发生很多次的随机磁盘访问降低到了仅需以此),所以对更新性能的提升是会很明显的。

    某个业务的库内存命中率突然从 99% 降低到了 75% (更新操作立即加载内存页导致利用率低),整个系统处于阻塞状态,更新语句全部堵住。而探究其原因后,发现这个业务有大量插入数据的操作,而在前一天其中的某个普通索引改成了唯一索引。

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 提升性能的核心机制,也是尽量减少随机读写,这两个概念确实容易混淆。所以,这里把它们放到了同一个流程里来说明,便于区分这两个概念。

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

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

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

带 change buffer 的更新过程

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

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

  1. Page 1 在内存中,直接更新内存;

  2. Page 2 没有在内存中,就在内存的 change buffer 区域,记录下“我要往 Page 2 插入一行”这个信息

  3. 将上述两个动作记入 redo log 中(图中 3 和 4)。

    注意:

    • change buffer 中针对 page 2 的操作可能不只一条,发生 merge 之前每一次针对 page 的修改都会增加一条新的记录到 change buffer 中。在 merge 的时候会按顺序一条条执行这些日志,得到最新版的数据页。

    • 对于 change buffer 的 redo log 记录仅仅是记录"修改了 change buffer 的行为",只有在发生 merge 的时候,才会记录"修改了 page 的行为"。且 merge 动作仅会将数据页加载到内存并将 change buffer 中对于该页的操作进行执行并记录相应的 redo log 就完成了,它不包含刷脏的过程,即将内存中得到的最新数据页同步到磁盘数据文件中(刷脏也无需记录 redo log)。

做完上面这些,事务就可以完成了。所以,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的。同时,图中的两个虚线箭头(左边是持久化 change buffer、右边是定时刷内存中的脏页),是后台操作,不影响更新的响应时间。

那在这之后的读请求,要怎么处理呢?比如,我们现在要执行 select * from t where k in (k1, k2)。如果读语句发生在更新语句后不久,内存中的数据都还在,那么此时的这两个读操作就与系统表空间(ibdata1)和 redo log(ib_log_fileX)无关了。所以,图中就没画出这两部分。

change buffer 的读过程

  1. 读 Page 1 的时候,直接从内存返回。WAL 之后如果读数据,不一定要读盘,不一定要从 redo log 里面把数据更新以后才可以返回(刷脏)。可以看一下图中的这个状态,虽然磁盘(指的是磁盘上的数据文件,此时还没有经历刷脏操作)上还是之前的数据,但是这里直接从内存返回结果,结果是正确的。
  2. 要读 Page 2 的时候,需要把 Page 2 从磁盘读入内存中,然后执行 change buffer 里面记录的针对 page 2 的操作日志,生成一个正确的版本并返回结果。可以看到,直到需要读 Page 2 的时候,这个数据页才会被读入内存。

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

change buffer 与宕机

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

  • 如果断电之前该修改操作提交成功,说明已经成功写入 redo log,此时重启后 redo log 将是 commit 状态。如果 redo log 的落盘策略(是每次写都落盘,调用fsync;还是定时刷内存到磁盘)是每次都写磁盘,那么将不会丢失,操作过程已经被持久化,可以恢复。
  • 如果断电之前该操作没有提示提交成功,可能成功写入 redo log 也可能没有,以下以 redo log 和 binlog 都是每次写磁盘的策略进行分析:
    • 如果 redo log 中已经成功写入,但是不是 commit 状态,此时检查到 binlog 的写入是不完整的,此时会发生回滚,这部分数据丢失。
    • 如果 redo log 中已经写入,但是不是 commit 状态,此时检查 binlog 发现写入完整,自动 commit,此时可以从 redo log 进行恢复。

九、MySQL为什么有时候会选错索引

MySQL 中一张表其实是可以支持多个索引的。但是,写 SQL 语句的时候,并没有主动指定使用哪个索引。也就是说,使用哪个索引是由 MySQL 来确定的。可能会碰到这种情况,一条本来可以执行得很快的语句,却由于 MySQL 选错了索引,而导致执行速度变得很慢。

先导:例子

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

CREATE TABLE `t` (
  `id` int(11) AUTO_INCREMENT 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(a, b) values(i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

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

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

这条查询语句的执行符合预期,key 这个字段值是’a’,表示优化器选择了索引 a。

mysql> explain select * from t where a between 10000 and 20000;
+----+-------------+-------+------------+-------+---------------+------+---------+------+-------+----------+-----------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows  | filtered | Extra                 |
+----+-------------+-------+------------+-------+---------------+------+---------+------+-------+----------+-----------------------+
|  1 | SIMPLE      | t     | NULL       | range | a             | a    | 5       | NULL | 10001 |   100.00 | Using index condition |
+----+-------------+-------+------------+-------+---------------+------+---------+------+-------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

在已经准备好的包含了 10 万行数据的表上,再做如下操作。

session A 和 session B 的执行流程

session A 开启了一个事务。随后,session B 把数据都删除后,又调用了 idata 这个存储过程,插入了 10 万行数据。

这时候,session B 的查询语句 select * from t where a between 10000 and 20000; 就不会再选择索引 a 了(原因参考 优化器的预估扫描行),可以看到,下面的执行计划中使用了全表扫描。

mysql> explain select * from t where a between 10000 and 20000;
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------------+
|  1 | SIMPLE      | t     | NULL       | ALL  | a             | NULL | NULL    | NULL | 100015 |    37.11 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-------------+
1 row in set, 1 warning (0.13 sec)

可以通过慢查询日志(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 原来的查询语句执行情况对比。

这三条 SQL 语句执行完成后的慢查询日志:

slow log 结果

可以看到:

  • Q1 扫描了 10 万行,显然是走了全表扫描,执行时间是 40 毫秒。
  • Q2 扫描了 10001 行,执行了 21 毫秒。

也就是说,我们在没有使用 force index 的时候,MySQL 用错了索引(走主键索引逐行扫描,其实就是全表扫描了,相当于没用索引),导致了更长的执行时间。这个例子对应的是我们平常不断地删除历史数据和新增数据的场景。这时,MySQL 竟然会选错索引,是不是有点奇怪呢?

优化器的逻辑

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

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

扫描行数判断

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

基数

这个统计信息包含了索引的“区分度”一个索引上不同的值越多(对于某个索引来说,不同的值越多,相同的值就越少了,越容易定位到某行数据),这个索引的区分度就越好。而一个索引上不同的值的个数,我们称之为“基数”(cardinality)。也就是说,这个基数越大,索引的区分度越好。

我们可以使用 show index 方法,看到一个索引的基数。

表 t 的 show index 结果

虽然这个表的每一行的三个字段值都是一样的,但是在统计信息中,这三个索引的基数值并不同,而且其实都不准确。

如何计算基数

那么,MySQL 是怎样得到索引的基数的呢?通过采样统计的方式。为什么要采样统计呢?因为要对表的每一行进行记录统计,虽然可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。采样统计的时候,InnoDB 默认会选择 N 个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。而数据表是会持续更新的,索引统计信息也不会固定不变。所以,当变更的数据行数超过 1/M 的时候,会自动触发重新做一次索引统计

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

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

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

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

即基数 cardinality 就是预估出来的某个索引上的不同值的数量。这个数量对于最终预估扫描行也是有贡献的,假如有一个用户表,表中有一个性别字段,表中有100W数据,那么此时该字段的基数相较其它字段将会非常低,此时相对来说如果使用这个索引进行检索那么需要扫描的行数就可能越多,因为区分度太低。以如下 SQL 为例:

select * from tuser where gender = 1 and unique_id = 1000

这个 SQL 的条件子句中包含了两个字段 genderunique_id ,前者有一个二级索引,后者有一个唯一索引。

  • 此时表中包含 100W 数据
  • gender 只有 1 和 0 两种取值
  • 基数计算中的 N 值取8
  • 注意索引中的 page 是按照索引字段值排序的,所以此时 gender 取到的每一个 page 中很大概率都是同一个值 1 或者 0。当然,page 的取样应该是分布较散的,所以我们这里还是假设 8 个 page 中 gender 可以取到一样的 1 和 0。
  • unique_id 的 page 中的值肯定都不一样了

那么取 8 个 page ,gender 的不同值都是 2 ,乘以 8 得到 16 ;而 unique_id 因为每个值都不一样,要算一下一个 page 中可以有多少行数据,MySQL page 默认 16 KB,假设 unique_idint 占 4 个字节,主键长度也是 4 个字节,此时一个 page 可以有 16 * 1024 / (4+4) = 2048 行,所以 unique_id 的基数为 2048 * 8 = 16384 。差别很多,如果仅以基数作为参考,那么肯定是选择后者了。

那么实际的索引检索过程中是不是真的基数越小检索效果越差呢?这是相对来说的,因为这个 SQL 中还有 unique_id 字段作为条件了,假设现在我们就是要用 gender 进行检索:

  1. 首先我们计算 gender 的树高。 gender 索引的叶子节点中数据单元大小(即一行数据所占大小)为 gender 字段长度+主键索引字段长度,假设 gender 字段长度为 1 B,主键长度为 4 B,此时一个数据单元就是 5 B。默认 mysql page 16 KB,所以每一个 page 中可以有约 3276 行。仅一个根节点是肯定装不下的。现在按照树高为 2 进行计算。

    非叶子节点中的一个数据单元大小 = gender字段长度 + 子节点指针长度 6B,就是 7 B,索引树根节点可以存储 16 * 1024 / 7 ≈ 2340 个单元,即第二层可以有 2340 个节点。此时 2340 * 16 * 1024 / 5 ≈ 7667712 ,700 多万行数据,仅2层树高就可以存储超过 100W 行数据。

  2. 虽然树高只有 2 层,但是因为这个索引树的区分度非常低,需要从第二层的最左节点一直扫描到中间节点,需要访问磁盘 50 W 次(忽略其它优化,这里按照一个数据块访问一次磁盘计算),然后这 50 W条记录都要回表到主键索引查询数据返回到 Server 层,Server 层拿到这 50 W条数据之后还要对 unique_id 条件进行过滤。

对比直接用 unqiue_id ,它的树高肯定也是 2 层就可以了,所以它直接可以在根节点二分得到对应的第二层节点位置,然后再在第二层叶子节点进行一次二分定位到该行位置,因为是唯一索引,所以直接回表主键索引查询该行数据返回 Server 层即可(即使它不是唯一索引,最多也就是定位到该行后看下一行是不是一样的值,但是因为它拥有极高基数也就是所有值都是唯一的,所以肯定不想等,直接返回)。Server 层仅拿到一条数据,然后再对 gender 字段进行匹配,看这条数据是否正确,正确就返回用户界面,否则返回空。

由此看出两者差距还是挺大的,所以对于基数非常低的字段建立索引是比较尴尬的。

当前例子的基数不会影响优化器判断

从前面图中也可以看到索引统计中的基数(cardinality 列)虽然不够精确,但大体上还是差不多的(因为三个字段 a、b、c 都是每行唯一的,基数理论上就是一样的),选错索引一定还有别的原因。其实索引统计只是一个输入,对于一个具体的语句来说,优化器还要判断,执行这个语句本身要扫描多少行。

个人感觉这里的预估扫描行是预估的存储引擎返回给 Server 的行数,如果索引的基数非常高,并且执行计划类型是 eq 而不是 rangeall就是不用索引全表逐行扫描了),此时 rows 可能是 1 。

如果是 range 或者 all 查询,所以 rows 应该是基数的补数的正相关函数,正相关函数的常数应该和 rannge 查询或者 all 的区间行数正相关。

优化器的预估扫描行

看看优化器预估的,这两个语句的预估扫描行数是多少。

意外的 explain 结果

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

  • Q1 的结果还是符合预期的,rows 的值是 104620(因为 Q1 select * from t where a between 10000 and 20000; 是全表扫描,本身表中确实是有这么多数据,而在主键索引中匹配 a 字段确实就是要逐行扫描每一条数据,因为该字段是无序的,所以 10W 行左右没错);
  • 但是 Q2 的 rows 值是 37116,偏差就大了:在一开始没有做删除再插入全部 10W 行数据的时候执行计划显示的是 10001,但是现在却变成了 3W,Q1 的执行计划显示的全表扫描是正常的(即在删除再插入 10W 行数据前后全表扫描执行计划是一致的,都是 10W,逻辑上也讲的通),所以判断就是因为这个偏差误导了优化器的判断

这里产生了两个问题:

  1. 为什么 Q2 有偏差
  2. 为什么 Q2 的预估扫描行是 3W 优化器却选择了 10 W执行

我们分开来看:

1.为什么 Q2 有偏差
MySQL 是如何删除数据的

首先来看一下 MySQL 是如何删除数据的。MySQL 对于数据并不是立刻删除的:

  1. 每一次删除数据都立即执行并写磁盘,消耗较大
  2. 出于并发访问效率的考虑 MySQL 实现了 MVCC,在 RC 和 RR 模式下基于一个事务一定的一致性视图保证。

所以综合起来不会每次都立即删除数据,以下是删除数据的步骤:

  1. 删除数据都是在一个事务中进行的,会申请一个事务 ID

  2. 所有要删除的行都会在聚簇索引和二级索引中生成一个新的版本,该版本的 row trx_id 是当前事务 ID,然后将该行标记为deleted,并将删除前的版本记录为一个 undo log 数据结构赋值到当前版本中进行记录(这些操作有可能会先保存在 change buffer 中,延迟到需要的时候再真正操作 page)。

    注意:为什么要标记为 deleted 不直接删除,因为:

    1. 所有的读数据动作都是直接在索引中开始读取,索引中的数据都是最新版本,根据最新版本沿着 undo log 一直往前计算,如果直接删除了,删除数据前已经开启的其它事务重新来读取这行数据的时候,发现找不到了,一致性无法保证。所以这个标记为 deleted 的索引行是为了那些已经存在的 read view 保持 undo log。
    2. MySQL 对所有使用 delete 语句删除的数据都不会真正的删除,它们只会被打标为删除,当 page 中被打标的数据行的所有 undo log 都已经删除,那么它就会被标记为可复用,后续新插入的数据行和可复用的数据行的前后也保持有序的情况下,就会复用这个"槽"(或者说空洞)。如果一个页上的所有数据行都被删除并且是可复用的,那么这个页都是可复用的了,此时该数据页将可以被随意复用。
    3. 另外,上面提到表文件本身永久不会减少,同时通过优化表等行为可以重建表,此时这些已经可以是重用的空洞就会被消除,但是也是一个全新的文件了(即是另外一个磁盘空间了,只不过文件名一样,替换了原本空洞过多的文件)。

    那什么时候会将一条被打标删除的数据标记为可复用呢?这条 undo log 删除的时候。那这条 undo log 什么时候删除呢?关于回滚段的删除策略有提到,当这条数据被标记为deleted前所有已经存在的 read view 都不存在了,就可以删除了,那由谁来删除、怎么删除呢?

    后台会有 purge 线程在定期删除那些可以删除的 undo log 并清理已经删除的数据,以下是关于 purge 线程的一些参数:

    • innodb_purge_batch_size:用来设置每次purge操作需要清理的undo log page的数量。默认300,表示每次清理300个page,支持动态修改。设置的越大,表示每次回收的页也就越多,可供重用的undo page也就越多,就能减少磁盘存储空间与分配的开销。不过该参数设置得太大,则每次需要purge处理更多的undo page,从而导致CPU和磁盘IO过于集中于对undo log的处理,使性能下降。普通用户不建议调整这个参数。

    • innodb_purge_threads:当有很多的表进行DML操作时候, 增大 innodb_purge_threads 能提高purge的效率(清理掉MVCC机制导致的老旧数据)。现在的MySQL版本中。purge线程已经从master线程中独立出来,使用单独的线程提高了可伸缩性。从MySQL5.7.8开始,这个参数默认是4,最大可以设置为32.(老版本里面这个值默认是1)

    • innodb_max_purge_lag:当InnoDB存储引擎的压力非常大时,并不能高效地进行purge操作。那么history list(undo log page数量)的长度会变得越来越长。innodb_max_purge_lag 就是控制history list的长度,若长度大于该值,就会延缓DML的操作。该值默认为0,表示不做任何限制。(不建议修改这个参数值!! )

    • innodb_max_purge_lag_delay:表示当上面innodb_max_purge_lag的delay超时时间太大,超过这个参数时,将delay设置为该参数值,防止purge线程操作缓慢导致其他SQL线程长期处于等待状态。默认为0,一般不用修改。

  3. 此后如果在删除数据前已经开启的其它事务再来查询删除行的时候,发现最新版本事务 ID 太大,所以沿着 undo log 查找到删除前的版本,其视图保持一致;而在本次删除事务提交之后才开启的事务来查询删除行的时候,发现索引中该行被标记为 deleted ,而该版本事务 ID 满足在一致性视图,所以读取该行为已删除,即不返回这行给用户。

Q2 的偏差
  1. 在 RR 模式下,当 Session A 开启了一个一致性视图,假设其事务 ID 为 Ta。

  2. 然后在 Session B 中删除表 t 中的 10W 行数据,此时需要保证 Session A的一致性视图。对于删除的每一行数据(所有聚簇索引和二级索引)新增一个版本,row trx_id 指向 Session B 的事务 ID ,并将这些数据标记为删除状态,然后将老版本生成 undo log 链接到索引的当前最新deleted版本中。

    对于所有这 10W 行数据,a,b 都没有唯一索引,条件子句中也没有指明任何字段,所以对于 a,b 索引的修改都可以先写入 change buffer (或者可能 MySQL 计算到删除数据量比较大,可能会放弃 change buffer,直接加载 page 进行操作)

  3. 继续在 Session B 中调用存储过程重新插入 10W 行,需要注意的是,前面的例子中使用的是主键自增 ID。所以主键索引中会有 20W 个最新版本(10W deleted 的,10W 当前新增的)。而 a,b 索引中是要存储主键索引的,此时重新插入的 10W 行的主键和已经 deleted 的 10W 行的主键已经不一样了,所以在 a,b 索引中,每一个值都会有两份最新版本的数据。一份是 deleted的行,持有旧的主键ID;一份是新插入的行,持久最新取到的自增ID。

    此时如果在 Session B 中继续查询 select * from t where a between 10000 and 20000; 的执行计划,在计算索引 a 的预估计扫描行 rows 的时候会索引中已经 deleted 的数据和刚刚新增的 10W 行数据都纳入计算。所以得到了 37116 的错误的更大的值。(这也是为什么表为非自增的时候,无法复现的原因,因为 deleted 的行)

    此时 Session A 还没有提交,故标记为deleted的 undo log 还不能被删除,所以虽然说10w 行数据被标记为删除了,但是如果它不能彻底地从索引中移除(重建表),那么它就还是在该索引中的,所以利用索引a进行检索的时候,必然会检索到这些删除的索引,然后再检查它是否是标记为delete的,版本事务是否符合当前查询事务的视图等,这个是一个有效的计算来的。

    所以优化器在抽样检查加载的 page 中的数据行计算预估计扫描行的rows的时候,就应该将标记为deleted但是未真正删除和已经重新插入的版本都作为计算因子。所以此时 Session B 再次查询select * from t where a between 10000 and 20000;语句对于索引 a 的执行计划时,最终计算出 37116 的数量,反馈给了查询用户扫描行预估过多的情况(因为在用户视角里面,之前的 10 W行删除了)。而此时优化器发现使用二级索引比主键索引要扫描的 1/3 行还要多,所以它选择了主键索引全表扫描的计划,并返回给用户。所以看到的是全表扫描的 10W 行左右,使用explain select * from t force index(a) where a between 10000 and 20000; 可以看到优化器计算索引 a 的预估计rows值。

    CREATE TABLE `t` (
      `id` int(11) AUTO_INCREMENT NOT NULL,
      `a` int(11) DEFAULT NULL,
      `b` int(11) DEFAULT NULL,
      PRIMARY KEY (`id`),
      KEY `a` (`a`),
      KEY `b` (`b`)
    ) ENGINE=InnoDB;
    
    delimiter ;;
    create procedure idata()
    begin
      declare i int;
      set i=1;
      while(i<=100000)do
        insert into t(a, b) values(i, i);
        set i=i+1;
      end while;
    end;;
    delimiter ;
    
    call idata();
    
  4. 另外还需要注意的是,执行计划给出的主键全表扫描是 100015 行,约10W 行,怎么主键的计算就正确了呢?它不是也保留了deleted的 undo log 吗?这是因为对于主键索引和二级索引的rows计算是不一样的。它直接计算表的行数的,优化器直接用的 show table status 中表的 rows字段值。

    mysql> show table status;
    +-------+--------+---------+------------+--------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+---------------------+------------+-------------+----------+----------------+---------+
    | Name  | Engine | Version | Row_format | Rows   | Avg_row_length | Data_length | Max_data_length | Index_length | Data_free | Auto_increment | Create_time         | Update_time         | Check_time | Collation   | Checksum | Create_options | Comment |
    +-------+--------+---------+------------+--------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+---------------------+------------+-------------+----------+----------------+---------+
    | T     | InnoDB |      10 | Dynamic    |      6 |           2730 |       16384 |               0 |        16384 |         0 |           NULL | 2020-10-02 08:59:24 | NULL                | NULL       | utf8mb4_bin |     NULL |                |         |
    | t     | InnoDB |      10 | Dynamic    | 100015 |             68 |     6832128 |               0 |     11567104 |   4194304 |         200001 | 2020-10-03 15:13:04 | 2020-10-03 15:13:49 | NULL       | utf8mb4_bin |     NULL |                |         |
    | tuser | InnoDB |      10 | Dynamic    |      0 |              0 |       16384 |               0 |        32768 |         0 |           NULL | 2020-10-02 09:37:17 | NULL                | NULL       | utf8mb4_bin |     NULL |                |         |
    +-------+--------+---------+------------+--------+----------------+-------------+-----------------+--------------+-----------+----------------+---------------------+---------------------+------------+-------------+----------+----------------+---------+
    3 rows in set (0.03 sec)
    

以下两种情况不会导致优化器预估扫描行错误:

1>主键非自增

上面的 Q2 的索引 a 预估计扫描行从 10001 偏差到 3W 多行只有表是自增的情况下才能复现,如果是非自增的话,优化器对于 select * from t where a between 10000 and 20000; 的执行计划计算算出的 a 索引的预估扫描行是正确的 10001 行。所以它不会选择全表扫描的执行计划,explain select * from t where a between 10000 and 20000; 的结果就是选择了索引 a ,预估扫描行是 10001,而无需 force index(a)

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;

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();

这里和自增主键的区别就是全表删除之后第二次重新插入的主键还都是一样的。当第二次重新插入的主键还是一样的时候,重新插入的每一行数据都会根据(a,id)的值在 a 索引树中找到自己的位置,此时就会定位到已经deleted的那份版本的位置,所以将会发生覆盖,deleted的版本会变成新插入版本的 undo log。此时索引树中只剩下新插入的 10W 数据了,此时查询select * from t where a between 10000 and 20000; 的执行计划,就会得到正常的 10001。

2>没有开启Session A

此时在 Session B 删除数据的时候,被打标为deleted的 10W 行数据即可被置为可复用。因为全部数据都是可复用的,说明所有页都是可复用的,页是可以随意复用的,不用管前后行是否有序,所以后续再新插入 10W 行数据的时候,无论是否主键自增的情况,索引 a 中的数据还是原本的10W 行,不会发生翻倍,此时查询select * from t where a between 10000 and 20000; 的执行计划,也会得到正常的 10001。

2.为什么选择全表扫描

因为优化器的计算是这样的:

  • 如果使用索引 a,每次从索引 a 上拿到一个值,都要回到主键索引上查出整行数据(回表),这个代价优化器也要算进去的。
  • 而如果选择扫描 10 万行,是直接在主键索引上扫描的,没有额外的代价。
  • 所以在优化器看来,使用了一个二级索引之后还需要 1/3 的预估行扫描,就会选择全表扫描。

优化器会估算这两个选择的代价,从结果看来,优化器认为直接扫描主键索引更快。当然,从执行时间看来,这个选择并不是最优的。使用普通索引需要把回表的代价算进去,在前面 一开始 执行 explain 的时候,也考虑了这个策略的代价 ,但选择是对的。也就是说,这个策略并没有问题。所以冤有头债有主,MySQL 选错索引,这件事儿还得归咎到没能准确地判断出使用索引 a 时的扫描行数。

解决

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

analyze table t 命令恢复的 explain 结果

这回对了(没有analyze之前执行计划选择的是全表扫描,analyze之后索引 a 的预估扫描行降到了 10001,选择了走索引 a)。所以在实践中,如果你发现 explain 的结果预估的 rows 值跟实际情况差距比较大,可以采用这个方法来处理。

排序的影响

其实,如果只是索引统计不准确,通过 analyze 命令可以解决很多问题,前面说了,优化器可不止是看扫描行数。依然是基于这个表 t,我们看看另外一个语句:

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

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

  • 如果使用索引 a 进行查询,那么就是扫描索引 a 的前 1000 个值,然后取到对应的 id,再到主键索引上去查出每一行,然后根据字段 b 来过滤。显然这样需要扫描 1000 行。
  • 如果使用索引 b 进行查询,那么就是扫描索引 b 的最后 50001 个值,与上面的执行过程相同,也是需要回到主键索引上取值再判断,所以需要扫描 50001 行。

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

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

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

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

  • 扫描行数的估计值依然不准确();

  • 这个例子里 MySQL 又选错了索引。

其实优化器选择使用索引 b,是因为它认为使用索引 b 可以避免排序(b 本身是索引,已经是有序的了,如果选择索引 b 的话,存储引擎根据索引 b 检索出来的数据就是有序的,Server 层筛选数据的时候不需要再做排序,只需要遍历),所以即使扫描行数多,也判定为代价更小。

如果我们使用 force index 强行选择索引 a:

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

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

索引选择异常和处理

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

force index 强制使用正确索引

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

force index 的弊端

不过很多程序员不喜欢使用 force index,一来这么写不优美,二来如果索引改了名字,这个语句也得改,显得很麻烦。而且如果以后迁移到别的数据库的话,这个语法还可能会不兼容。但其实使用 force index 最主要的问题还是变更的及时性。因为选错索引的情况还是比较少出现的,所以开发的时候通常不会先写上 force index。而是等到线上出现问题的时候,你才会再去修改 SQL 语句、加上 force index。但是修改之后还要测试和发布,对于生产系统来说,这个过程不够敏捷。

修改 SQL 引导数据库选择正确索引

基于 force index 的弊端,数据库的问题最好还是在数据库内部来解决。那么,在数据库里面该怎样解决呢?既然优化器放弃了使用索引 a,说明 a 还不够合适,所以第二种方法就是,我们可以考虑修改语句,引导 MySQL 使用我们期望的索引。比如,在这个例子里,显然把order by b limit 1 order by b,a limit 1 ,语义的逻辑是相同的。我们来看看改之后的效果:

order by b,a limit 1 执行结果

order by b,a 这种写法,要求按照 b,a 排序,就意味着无论使用 a 还是 b 索引都是需要排序的,因为即使使用了 b 索引,b是有序的,但是它不是和 a 的联合索引,所以 b 索引中不包含 a 字段,最终返回给 Server 层还是要对 a 排序。因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描 1000 行的索引 a。

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

如果觉得修改语义这件事儿不太好,这里还有一种改法:

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

执行效果:

改写 SQL 的 explain

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

删除误用的索引

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

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

关于Like的索引问题

一张表两个字段 id, nameid 主键,uname普通索引:

SELECT * FROM tuser WHERE name LIKE 'j'
SELECT * FROM tuser WHERE name LIKE 'j%'
-- 执行计划
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | name          | name | 1022    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
SELECT id FROM tuser WHERE name LIKE 'j'
SELECT id FROM tuser WHERE name LIKE 'j%'
-- 执行计划
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | name          | name | 1022    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
SELECT * FROM tuser WHERE name LIKE '%j'
SELECT * FROM tuser WHERE name LIKE '%j%'
-- 执行计划:表中只要 ID 和 name 两个字段,直接在 name 中全表扫描+索引覆盖
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | NULL          | name | 1022    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+

添加一个age字段:

SELECT * FROM tuser WHERE name LIKE 'j'
SELECT * FROM tuser WHERE name LIKE 'j%'
SELECT age FROM tuser WHERE name LIKE 'j'
SELECT age FROM tuser WHERE name LIKE 'j%'
-- 执行计划:由 using index 变成了 using index condition
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-----------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                 |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-----------------------+
|  1 | SIMPLE      | tuser | NULL       | range | name          | name | 1022    | NULL |    1 |   100.00 | Using index condition |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+-----------------------+
SELECT id FROM tuser WHERE name LIKE 'j'
SELECT id FROM tuser WHERE name LIKE 'j%'
-- 执行计划
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | name          | name | 1022    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
SELECT * FROM tuser WHERE name LIKE '%j'
SELECT * FROM tuser WHERE name LIKE '%j%'
SELECT age FROM tuser WHERE name LIKE '%j'
SELECT age FROM tuser WHERE name LIKE '%j%'
-- 执行计划:不使用索引
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | tuser | NULL       | ALL  | NULL          | NULL | NULL    | NULL |    1 |   100.00 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+

建立 unameage 的联合索引:

SELECT * FROM tuser WHERE name LIKE 'j'
SELECT * FROM tuser WHERE name LIKE 'j%'
SELECT age FROM tuser WHERE name LIKE 'j'
SELECT age FROM tuser WHERE name LIKE 'j%'
-- 执行计划:又变成了 using index,使用的是联合索引
+----+-------------+-------+------------+-------+--------------------+---------------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys      | key           | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+--------------------+---------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | name,idx_namme_age | idx_namme_age | 1027    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+--------------------+---------------+---------+------+------+----------+--------------------------+
SELECT id FROM tuser WHERE name LIKE 'j'
SELECT id FROM tuser WHERE name LIKE 'j%'
SELECT name FROM tuser WHERE name LIKE 'j'
SELECT name FROM tuser WHERE name LIKE '%j'
-- 执行计划:使用的是name索引
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys | key  | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | name          | name | 1022    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+---------------+------+---------+------+------+----------+--------------------------+
SELECT * FROM tuser WHERE name LIKE 'j'
SELECT age FROM tuser WHERE name LIKE 'j%'
SELECT * FROM tuser WHERE name LIKE '%j'
SELECT * FROM tuser WHERE name LIKE '%j%'
SELECT age FROM tuser WHERE name LIKE '%j'
SELECT age FROM tuser WHERE name LIKE '%j%'
-- 执行计划:不使用索引
-- 执行计划:使用的是name、age联合索引
+----+-------------+-------+------------+-------+--------------------+---------------+---------+------+------+----------+--------------------------+
| id | select_type | table | partitions | type  | possible_keys      | key           | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+-------+------------+-------+--------------------+---------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | tuser | NULL       | index | name,idx_namme_age | idx_namme_age | 1027    | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+-------+--------------------+---------------+---------+------+------+----------+--------------------------+

十、慢查询日志

慢查询日志概念

MySQL的慢查询日志是MySQL提供的一种日志记录,它用来记录在MySQL中响应时间超过阀值的语句,具体指运行时间超过 long_query_time 值的SQL,则会被记录到慢查询日志中。long_query_time 的默认值为10,意思是运行 10s 以上的语句。默认情况下,MySQL 数据库并不启动慢查询日志,需要我们手动来设置这个参数,当然,如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响慢查询日志支持将日志记录写入文件,也支持将日志记录写入数据库表

慢查询日志相关参数

参数 描述
slow_query_log 是否开启慢查询日志,1表示开启,0表示关闭
log_slow_queries 旧版(5.6以下版本)MySQL数据库慢查询日志存储路径。可以不设置该参数,系统则会默认给一个缺省的文件host_name-slow.log
slow_query_log_file 新版(5.6及以上版本)MySQL数据库慢查询日志存储路径。可以不设置该参数,系统则会默认给一个缺省的文件host_name-slow.log
long_query_time 慢查询阈值,当查询时间多于设定的阈值时,记录日志。
log_queries_not_using_indexes 未使用索引的查询也被记录到慢查询日志中(可选项)。
log_output 日志存储方式。log_output='FILE' 表示将日志存入文件,默认值是FILElog_output='TABLE' 表示将日志存入数据库,这样日志信息就会被写入到 mysql.slow_log 表中。MySQL数据库支持同时两种日志存储方式,配置的时候以逗号隔开即可,如:log_output='FILE,TABLE'。日志记录到系统的专用日志表中,要比记录到文件耗费更多的系统资源,因此对于需要启用慢查询日志,又需要能够获得更高的系统性能,那么建议优先记录到文件。
log_slow_admin_statements 表示是否将慢管理语句例如 ANALYZE TABLEALTER TABLE 等记入慢查询日志
show global status like '%Slow_queries%' 查询记录了多少条慢查询
log_slow_slave_statements 默认情况下,副本从服务器不会记录复制主服务器的 SQL 到慢日志。通过激活这个系统变量,使得那些在从服务器上执行超过 long_query_time 秒的 SQL 被记录。这个变量在 5.7.1 版本中加入,设置这个变量不会立马生效。这个变量值会应用在随后的 START SLAVE 的 statement 中。

慢查询日志配置

默认情况下slow_query_log的值为OFF,表示慢查询日志是禁用的,可以通过设置 slow_query_log 的值来开启,如下所示:

mysql> show variables like '%slow_query_log%';
+---------------------+-------------------------------------------------+
| Variable_name       | Value                                           |
+---------------------+-------------------------------------------------+
| slow_query_log      | OFF                                             |
| slow_query_log_file | /var/lib/mysql/izwz920kp0myp15p982vp4z-slow.log |
+---------------------+-------------------------------------------------+
2 rows in set (0.01 sec)

mysql> set global slow_query_log=1;
Query OK, 0 rows affected (0.04 sec)

mysql> show variables like '%slow_query_log%';
+---------------------+-------------------------------------------------+
| Variable_name       | Value                                           |
+---------------------+-------------------------------------------------+
| slow_query_log      | ON                                              |
| slow_query_log_file | /var/lib/mysql/izwz920kp0myp15p982vp4z-slow.log |
+---------------------+-------------------------------------------------+
2 rows in set (0.00 sec)

使用set global slow_query_log=1开启了慢查询日志只对当前数据库生效,如果MySQL重启后则会失效。如果要永久生效,就必须修改配置文件 my.cnf(其它系统变量也是如此)。

那么开启了慢查询日志后,什么样的SQL才会记录到慢查询日志里面呢? 这个是由参数 long_query_time 控制,默认情况下 long_query_time 的值为10秒,可以使用命令修改,也可以在 my.cnf 参数里面修改。关于运行时间正好等于 long_query_time 的情况,并不会被记录下来。也就是说,在 MySQL 源码里是判断大于 long_query_time ,而非大于等于。从 MySQL 5.1 开始, long_query_time 开始以微秒精度记录SQL语句运行时间,之前仅到秒的精度。如果记录到表里面,只会记录整数部分,不会记录微秒部分。

mysql> show variables like '%long_query_time%';
+-----------------+-----------+
| Variable_name   | Value     |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.00 sec)

mysql> set global long_query_time=1;
Query OK, 0 rows affected (0.00 sec)

mysql> show global variables like 'long_query_time';
+-----------------+----------+
| Variable_name   | Value    |
+-----------------+----------+
| long_query_time | 1.000000 |
+-----------------+----------+
1 row in set (0.00 sec)

mysql> show variables like 'long_query_time';
+-----------------+-----------+
| Variable_name   | Value     |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.00 sec)

这个参数很奇怪,set global 之后使用 show variables 无法查询到修改后的变量值,使用 show global variables 才可以。退出当前会话重连之后使用 show variables 才能看到:

mysql> show variables like 'long_query_time';
+-----------------+----------+
| Variable_name   | Value    |
+-----------------+----------+
| long_query_time | 1.000000 |
+-----------------+----------+
1 row in set (0.01 sec)

但是上面的 slow_log_query 参数又是可以立刻看到的。

查看慢查询

在MySQL里面执行下面SQL语句

mysql> select sleep(2);
+----------+
| sleep(2) |
+----------+
|        0 |
+----------+
1 row in set (2.04 sec)

然后我们去检查对应的慢查询日志,就会发现类似下面这样的信息。

[root@izwz920kp0myp15p982vp4z /]# more /var/lib/mysql/izwz920kp0myp15p982vp4z-slow.log
/usr/sbin/mysqld, Version: 5.7.30-log (MySQL Community Server (GPL)). started with:
Tcp port: 3306  Unix socket: /var/lib/mysql/mysql.sock
Time                 Id Command    Argument
set global slow_query_log_file='/var/lib/mysql/izwz920kp0myp15p982vp4z-slow.log';
# Time: 2020-10-03T00:57:21.183639Z
# User@Host: root[root] @ localhost []  Id:   410
# Query_time: 2.000210  Lock_time: 0.000000 Rows_sent: 1  Rows_examined: 0
SET timestamp=1601686641;
select sleep(2);

日志分析工具mysqldumpslow

在生产环境中,如果要手工分析日志,查找、分析SQL,显然是个体力活,MySQL提供了日志分析工具mysqldumpslow

查看mysqldumpslow的帮助信息:

[root@izwz920kp0myp15p982vp4z ~]# mysqldumpslow --help
Usage: mysqldumpslow [ OPTS... ] [ LOGS... ]

Parse and summarize the MySQL slow query log. Options are

  --verbose    verbose
  --debug      debug
  --help       write this text to standard output

  -v           verbose
  -d           debug
  -s ORDER     what to sort by (al, at, ar, c, l, r, t), 'at' is default
                al: average lock time
                ar: average rows sent
                at: average query time
                 c: count
                 l: lock time
                 r: rows sent
                 t: query time
  -r           reverse the sort order (largest last instead of first)
  -t NUM       just show the top n queries
  -a           don't abstract all numbers to N and strings to 'S'
  -n NUM       abstract numbers with at least n digits within names
  -g PATTERN   grep: only consider stmts that include this string
  -h HOSTNAME  hostname of db server for *-slow.log filename (can be wildcard),
               default is '*', i.e. match all
  -i NAME      name of server instance (if using mysql.server startup script)
  -l           don't subtract lock time from total time
  • -s 是表示按照何种方式排序:

    • c:访问计数
    • l:锁定时间
    • r:返回记录
    • t:查询时间
    • al:平均锁定时间
    • ar:平均返回记录数
    • at:平均查询时间
  • -t:是top n的意思,即为返回前面多少条的数据;

  • -g: 后边可以写一个正则匹配模式,大小写不敏感的;

示例:

# 得到返回记录集最多的10个SQL。

mysqldumpslow -s r -t 10 /database/mysql/mysql06_slow.log

# 得到访问次数最多的10个SQL

mysqldumpslow -s c -t 10 /database/mysql/mysql06_slow.log

# 得到按照时间排序的前10条里面含有左连接的查询语句。

mysqldumpslow -s t -t 10 -g “left join” /database/mysql/mysql06_slow.log

# 另外建议在使用这些命令时结合 | 和more 使用 ,否则有可能出现刷屏的情况。

mysqldumpslow -s r -t 20 /mysqldata/mysql/mysql06-slow.log | more

十一、给字符串字段加索引

假设,有一个支持邮箱登录的系统,用户表是这么定义的:

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

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

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

如果 email 这个字段上没有索引,那么这个语句就只能做全表扫描。同时,MySQL 是支持前缀索引的,也就是说,你可以定义字符串的一部分作为索引。默认地,如果你创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。

前缀索引

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

第一个语句创建的 index1 索引里面,包含了每个记录的整个字符串;而第二个语句创建的 index2 索引里面,对于每个记录都是只取前 6 个字节,这就是前缀索引。注意,只要在定义索引的时候对一个字段使用了括号进行长度定义,无论长度是否大于等于该字段的长度,都会被定义为前缀索引,这会对覆盖索引产生影响。以下是两种索引结构图:

email 索引结构

email(6) 索引结构

前缀索引的优势

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

前缀索引的劣势

增加回表扫描行数

但,这同时带来的损失是,可能会增加额外的记录扫描次数。接下来,再看看下面这个语句,在这两个索引定义下分别是怎么执行的。

select id,name,email from SUser where email='zhangssxyz@xxx.com';
  • 如果使用的是 index1(即 email 整个字符串的索引结构),执行顺序是这样的:

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

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

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

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

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

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

select id,email from SUser where email='zhangssxyz@xxx.com';

这个语句只要求返回 id 和 email 字段。所以,如果使用 index1(即 email 整个字符串的索引结构)的话,可以利用覆盖索引,从 index1 查到结果后直接就返回了,不需要回到 ID 索引再去查一次。

而如果使用 index2(即 email(6) 索引结构)的话,就不得不回到 ID 索引再去判断 email 字段的值。即使你将 index2 的定义修改为 email(18) 的前缀索引,这时候虽然 index2 已经包含了所有的信息,但 InnoDB 还是要回到 id 索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息

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

如何给长字符串定义好索引

定义前缀索引并指定一个好长度

前面回表扫描行数的例子提到使用前缀索引后,可能会导致查询语句读数据的次数变多。但是,对于该查询语句来说,如果定义的 index2 不是 email(6) 而是 email(7),也就是说取 email 字段的前 7 个字节来构建索引的话,即满足前缀’zhangss’的记录只有一个,也能够直接查到 ID2,只扫描一行就结束了。也就是说使用前缀索引,定义好长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

当要给字符串创建前缀索引时,有什么方法能够确定我应该使用多长的前缀呢?实际上,我们在建立索引时关注的是区分度,区分度越高越好。因为区分度越高,意味着重复的键值越少。因此,我们可以通过统计索引上有多少个不同的值来判断要使用多长的前缀。

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

    mysql> select count(distinct email) as L from SUser;
    
  2. 然后,依次选取不同长度的前缀来看这个值,比如我们要看一下 4~7 个字节的前缀索引,可以用这个语句:

    mysql> select 
      count(distinct left(email,4))as L4,
      count(distinct left(email,5))as L5,
      count(distinct left(email,6))as L6,
      count(distinct left(email,7))as L7,
    from SUser;
    
  3. 当然,使用前缀索引很可能会损失区分度,所以你需要预先设定一个可以接受的损失比例,比如 5%。然后,在返回的 L4~L7 中,找出不小于 L * 95% 的值,假设这里 L6、L7 都满足,你就可以选择前缀长度为 6。

其他方式

对于类似于邮箱这样的字段来说,使用前缀索引的效果可能还不错。但是,遇到前缀的区分度不够好的情况时,我们要怎么办呢?比如,我们国家的身份证号,一共 18 位,其中前 6 位是地址码,所以同一个县的人的身份证号前 6 位一般会是相同的。假设你维护的数据库是一个市的公民信息系统,这时候如果对身份证号做长度为 6 的前缀索引的话,这个索引的区分度就非常低了。

按照我们前面说的方法,可能你需要创建长度为 12 以上的前缀索引,才能够满足区分度要求。但是,索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。那么,如果我们能够确定业务需求里面只有按照身份证进行等值查询的需求,还有没有别的处理方法呢?这种方法,既可以占用更小的空间,也能达到相同的查询效率。答案是,有的。

1.字符串倒叙加前缀索引

如果你存储身份证号的时候把它倒过来存,每次查询的时候,你可以这么写:

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

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

2.添加一个字符串的hash字段并在hash字段上加索引

可以在表上再创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引。

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

然后每次插入新记录的时候,都同时用 crc32() 这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证号通过 crc32() 函数得到的结果可能是相同的,所以你的查询语句 where 部分要判断 id_card 的值是否精确相同(这个判断会在 hash 索引回表后在主键索引中进行判断,不会因为没有索引触发全表扫描)。

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

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

两者异同

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

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

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

拆分字段

如果这个字段和身份证一样是有区间的,且可以直到某个区间的区分度较高,可以将这个字段按照各区间的区分度不同拆分成多个字段,为区分度高的部分字段建立索引,也可以在保证存储空间占用小但是又能减少回表次数。

问题:如何设计一个同一格式字符串字段的索引

如果你在维护一个学校的学生信息数据库,学生登录名的统一格式是”学号 @gmail.com", 而学号的规则是:十五位的数字,其中前三位是所在城市编号、第四到第六位是学校编号、第七位到第十位是入学年份、最后五位是顺序编号。系统登录的时候都需要学生输入登录名和密码,验证正确后才能继续使用系统。就只考虑登录验证这个行为的话,你会怎么设计这个登录名的索引呢?

  1. 根据业务量预估,一个学校每年预估2万新生,50年才100万记录,能节省多少空间,直接全字段索引。省去了开发转换及局限性风险
  2. 数据量上来后这里遇到瓶颈,就将"@gmail.com"去掉,单独将学号设计为一个 bigint 8个字节单独作为索引。
  3. 利用bitmap对学号进行压缩,假设一个学生的学号是 ‘20200517’,这是一个长度为 8 的字符串,以ascii 编码为例,这个字符串需要占据 8 个字节的空间。但是,你发现,这个字符串里面所有的内容都是数字,而一个数字有 10 种可能,也就是说,这个长度的学号最多有 10 ^ 8 种可能性,也就是一亿种可能性。在计算机中,使用 32 位二进制数就可以表示 2 ^ 32 种可能性,这个数字是远远大于一亿的,所以也就是说,你完全可以用 4 字节的内存存下这个学号的所有信息(所以你完全可以使用 int 进行存储)。所以,学号只有 一亿 种可能性,可将它转化成 2^32 进制进行存储。当然,只是最粗略的方法,可以设计一种方法,将这种受限的字符串压缩,这样就能减少存储空间的利用了。但是不建议这样做,这种操作可以将信息压缩到极致(实际上极值情况就和哈希有点像了),但是设计这种方法可能会增加复杂性,在数据规模没有达到极其庞大的底部,它带来的收益其实是有限的。

十二、MySQL 刷脏:数据库抖一下

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

MySQL 在写入数据的时候利用了 WAL 优化,先写 redo log 并更新内存中的 page 就返回了。此时该页在内存中和磁盘中的数据是不一致的。当内存数据页跟磁盘数据页内容不一致的时候,我们称这个内存页为“脏页”。内存数据写入到磁盘后,内存和磁盘上的数据页的内容就一致了,称为“干净页”。

所以平时执行很快的更新操作,其实就是在写内存和日志,而 MySQL 偶尔“抖”一下的那个瞬间,可能就是在刷脏页(flush)。

刷脏(flush)时机

  1. InnoDB 的 redo log 写满了。这时候系统会停止所有更新操作,把 checkpoint 往前推进,redo log 留出空间可以继续写。

    redo log 状态图

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

  2. 系统内存不足。当需要新的内存页,而内存不够用的时候,就要淘汰一些数据页,空出内存给别的数据页使用。如果淘汰的是“脏页”,就要先将脏页写到磁盘。那为什么不能直接把内存淘汰掉,下次需要请求的时候,从磁盘读入数据页,然后拿 redo log 出来恢复不就行了?这里其实是从性能考虑的。如果刷脏页一定会写盘,就保证了每个数据页有两种状态:

    • 一种是内存里存在,内存里就肯定是正确的结果,直接返回;
    • 另一种是内存里没有数据,就可以肯定数据文件上是正确的结果,读入内存后返回。这样的效率最高。
  3. MySQL 认为系统“空闲”的时候。即使不空闲,也要见缝插针地找时间,只要有机会就刷一点“脏页”。

  4. MySQL 正常关闭的情况。这时候,MySQL 会把内存的脏页都 flush 到磁盘上,这样下次 MySQL 启动的时候,就可以直接从磁盘上读数据,启动速度会很快。

4种刷脏时机对性能的影响

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

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

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

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

    InnoDB 的策略是尽量使用内存,因此对于一个长时间运行的库来说,未被使用的页面很少。而当要读入的数据页没有在内存的时候,就必须到缓冲池中申请一个数据页。这时候只能把最久不使用(LRU)的数据页从内存中淘汰掉:如果要淘汰的是一个干净页,就直接释放出来复用;但如果是脏页呢,就必须将脏页先刷到磁盘,变成干净页后才能复用。

    这里保有疑问,脏页的"是否为脏"的逻辑是由 redo log 中每行日志的序列号也就是 LSN (Log Sequence Number)实现的:

    • Checkpoint 对象会记录 LSN,则小于该 LSN 的都是已经刷了的脏页。
    • 而 buffer pool 中的 page 的头部中也会记录 LSN,表示修改当前 page 的最后操作在 redo log 中的 LSN。所以正常来说 page 的 LSN 如果小于 checkpoint 的 LSN,那么它肯定是 flush 了的。所以它是干净页。(如果系统宕机重启后还是会根据 redo log 重放 buffer pool 中的 page,LSN 也重新写入)

    对于脏页,除了 buffer pool 满了加载新脏页需要根据 LRU 淘汰脏页的其它三种情况,其实都可以理解为正常推进 checkpoint 的过程,所以可以保证 LSN 的有序推进,即 LSN 大于 checkpoint 中 LSN 的 page 肯定是脏页。但是这里 LRU 淘汰的脏页就不一样了,根据惯性语义最久不使用应该包含读,所以此时要淘汰的可能就不是 buffer pool 中 LSN 最小、checkpoint LSN + 1 的脏页了,那这里就有问题,此时要怎么处理呢?

    • 将小于该 LSN 的脏页也一起 flush 了?
    • 还是有一个专门的地方记录大于 checkpoint 但是已经刷了的脏页?
    • 还是说就不管这种情况,直接 flush 后就把该页淘汰,反正已经刷盘到数据文件了,数据不会丢失,就是后面 checkpoint 推进到当前 LSN 的时候会发生重复 flush?
    • 还是说其实并不是按照最久不使用的淘汰策略的,而是最早被修改的脏页?

所以,刷脏页虽然是常态,但是出现以下这两种情况,都是会明显影响性能的:

  1. 一个查询要淘汰的脏页个数太多,会导致查询的响应时间明显变长;
  2. 日志写满,更新全部堵住,写性能跌为 0,这种情况对敏感业务来说,是不能接受的。

所以,InnoDB 需要有控制脏页比例的机制,来尽量避免上面的这两种情况。

InnoDB 刷脏页的控制策略

告诉 InnoDB 主机的 IO 能力

首先,你要正确地告诉 InnoDB 所在主机的 IO 能力,这样 InnoDB 才能知道需要全力刷脏页的时候,可以刷多快(另外,不只刷脏,应该也会和其它操作引发的磁盘 IO 有关)。这就要用到 innodb_io_capacity 这个参数了,它会告诉 InnoDB 你的磁盘能力。这个值我建议你设置成磁盘的 IOPS。磁盘的 IOPS 可以通过 fio 这个工具来测试,下面的语句是用来测试磁盘随机读写的命令:

 fio -filename=$filename -direct=1 -iodepth 1 -thread -rw=randrw -ioengine=psync -bs=16k -size=500M -numjobs=10 -runtime=10 -group_reporting -name=mytest 

MySQL 5.7 默认值是 200,用上面命令查看了主要能力不是 IO 的 ecs 的 IOPS 在 950 左右。

其实,因为没能正确地设置 innodb_io_capacity 参数,而导致的性能问题也比比皆是。之前,存在一个案例,一个数据库的性能有问题,说 MySQL 的写入速度很慢,TPS 很低,但是数据库主机的 IO 压力并不大。经过一番排查,发现罪魁祸首就是这个参数的设置出了问题。他的主机磁盘用的是 SSD,但是 innodb_io_capacity 的值设置的是 300。于是,InnoDB 认为这个系统的能力就这么差,所以刷脏页刷得特别慢,甚至比脏页生成的速度还慢,这样就造成了脏页累积,影响了查询和更新性能。

虽然现在已经定义了“全力刷脏页”的行为,但平时总不能一直是全力刷吧?毕竟磁盘能力不能只用来刷脏页,还需要服务用户请求。所以接下来,还要配置 InnoDB 控制引擎按照“全力”的百分比来刷脏页。

InnoDB 如何利用主机 IO 能力进行刷脏

如果刷太慢,会出现什么情况?首先是内存脏页太多,其次是 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;
    }
    

    脏页比例 M 是通过以下两个变量计算出来的:

    • Innodb_buffer_pool_pages_dirty :buffer pool 中脏页数量
    • Innodb_buffer_pool_pages_total :buffer pool 中总页数。(举个例子,buffer pool size 是 16G 的时候,page 默认 16 K,缓存池就可以存储 100W 个 page)
    mysql> select VARIABLE_VALUE into @a from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_dirty';
    select VARIABLE_VALUE into @b from global_status where VARIABLE_NAME = 'Innodb_buffer_pool_pages_total';
    select @a/@b;
    
  • InnoDB 每次写入的日志都有一个序号,当前写入的序号跟 checkpoint 对应的序号之间的差值,我们假设为 N。InnoDB 会根据这个 N 算出一个范围在 0 到 100 之间的数字,这个计算公式可以记为 F2(N)。F2(N) 算法比较复杂,你只要知道 N 越大,算出来的值越大就好了。

然后,根据上述算得的 F1(M) 和 F2(N) 两个值,取其中较大的值记为 R,之后引擎就可以按照 innodb_io_capacity 定义的能力乘以 R% 来控制刷脏页的速度。

InnoDB 刷脏页速度策略

所以,无论是查询语句在需要内存的时候可能要求淘汰一个脏页,还是由于刷脏页的逻辑会占用 IO 资源并可能影响到了更新语句,都可能是造成从业务端感知到 MySQL“抖”了一下的原因。要尽量避免这种情况,你就要合理地设置 innodb_io_capacity 的值,并且平时要多关注脏页比例,不要让它经常接近 75%

InnoDB 脏页连坐策略

一旦一个查询请求需要在执行过程中先 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 了。

redo log 设置过小导致频繁刷脏

一个内存配置为 128GB、innodb_io_capacity 设置为 20000 的大规格实例,正常会建议将 redo log 设置成 4 个 1GB 的文件。但如果你在配置的时候不慎将 redo log 设置成了 1 个 100M 的文件,会发生什么情况呢?

每次事务提交都要写 redo log,如果设置太小,很快就会被写满,也就是下面这个图的状态,这个“环”将很快被写满,write pos 一直追着 CP。这时候系统不得不停止所有更新,去推进 checkpoint。这时,你看到的现象就是磁盘压力很小,但是数据库出现间歇性的性能下跌。

redo log 建议大小:如果是现在常见的几个 TB 的磁盘的话,就不要太小气了,直接将 redo log 设置为 4 个文件、每个文件 1GB 吧。

十三、页空洞:为什么表数据删掉一半,表文件大小不变

是针对 MySQL 中应用最广泛的 InnoDB 引擎展开讨论。一个 InnoDB 表包含两部分,即:表结构定义和数据。在 MySQL 8.0 版本以前,表结构是存在以.frm 为后缀的文件里。而 MySQL 8.0 版本,则已经允许把表结构定义放在系统数据表中了。因为表结构定义占用的空间很小,所以我们今天主要讨论的是表数据

开启innodb_file_per_table参数

表数据(就是索引)既可以存在共享表空间里,也可以是单独的文件。这个行为是由参数 innodb_file_per_table 控制的:

  • 这个参数设置为 OFF 表示的是,表的数据放在系统共享表空间,也就是跟数据字典放在一起;
  • 这个参数设置为 ON 表示的是,每个 InnoDB 表数据存储在一个以 .ibd 为后缀的文件中。

从 MySQL 5.6.6 版本开始,它的默认值就是 ON 了。

建议不论使用 MySQL 的哪个版本,都将这个值设置为 ON。

  • 因为,一个表单独存储为一个文件更容易管理,而且在你不需要这个表的时候,通过 drop table 命令,系统就会直接删除这个文件(drop table 直接将文件删除了。truncate = drop + create 文件)
  • 而如果是放在共享表空间中,即使表删掉了,空间也是不会回收的。

所以,将 innodb_file_per_table 设置为 ON,是推荐做法,接下来的讨论都是基于这个设置展开的。

页空洞问题

我们在删除整个表的时候,可以使用 drop table 命令回收表空间。但是,我们遇到的更多的删除数据的场景是删除某些行,这时就遇到问题了:表中的数据被删除了,但是表空间却没有被回收。

MySQL 的数据删除造成空洞

参考 [前面关于优化器预估扫描行的逻辑中提到的](######MySQL 是如何删除数据的) ,删除数据不会真正把数据删除,而是将其标记为删除,而如果没有其它一致性视图在引用删除前数据,它就会被标记为可复用(以下讨论都建立再没有其它事务的前提下,所以删除即可复用)。

B+ 树索引示意图

假设,我们要删掉 R4 这个记录,InnoDB 引擎只会把 R4 这个记录标记为删除。如果之后要再插入一个 ID 在 300 和 600 之间的记录时,可能会复用这个位置。但是,磁盘文件的大小并不会缩小。

InnoDB 的数据是按页存储的,那么如果我们删掉了一个数据页上的所有记录,整个数据页就可以被复用了。但是,数据页的复用跟记录的复用是不同的。记录的复用,只限于符合范围条件的数据。比如上面的这个例子,R4 这条记录被删除后,如果插入一个 ID 是 400 的行,可以直接复用这个空间。但如果插入的是一个 ID 是 800 的行,就不能复用这个位置了。而当整个页都是可复用的时候,会从 B+ 树里面摘掉,可以复用到任何位置。以上图为例,如果将数据页 page A 上的所有记录删除以后,page A 会被标记为可复用。这时候如果要插入一条 ID=50 的记录需要使用新页的时候,page A 是可以被复用的。

如果相邻的两个数据页利用率都很小,系统就会把这两个页上的数据合到其中一个页上,另外一个数据页就被标记为可复用

进一步地,如果我们用 delete 命令把整个表的数据删除呢?结果就是,所有的数据页都会被标记为可复用。但是磁盘上,文件不会变小。 delete 命令其实只是把记录的位置,或者数据页标记为了“可复用”,但磁盘文件的大小是不会变的。也就是说,通过 delete 命令是不能回收表空间的。这些可以复用,而没有被使用的空间,看起来就像是“空洞”。

插入更新数据也可能造成空洞

实际上,不止是删除数据会造成空洞,插入数据也会。如果数据是按照索引递增顺序插入的,那么索引是紧凑的。但如果数据是随机插入的,就可能造成索引的数据页分裂

插入数据导致页分裂

由于 page A 满了,再插入一个 ID 是 550 的数据时,就不得不再申请一个新的页面 page B 来保存数据了。页分裂完成后,page A 的末尾就留下了空洞(注意:实际上,可能不止 1 个记录的位置是空洞)。

另外,更新索引上的值,可以理解为删除一个旧的值,再插入一个新值(对于主键索引来说,更新操作不会删除再插入;但是二级索引就需要了,它要保持有序,所以要将该二级索引字段旧值所在页的行删除,然后将新值插入到所在页的行)。不难理解,这也是会造成空洞的。

重建表

经过大量增删改的表,都是可能是存在空洞的。所以,如果能够把这些空洞去掉,就能达到收缩表空间的目的。而重建表,就可以达到这样的目的。可以使用 alter table A engine=InnoDB 命令来重建表。

MySQL 5.5 版本及之前

新建一个与表 A 结构相同的表 B,然后按照主键 ID 递增的顺序,把数据一行一行地从表 A 里读出来再插入到表 B 中。由于表 B 是新建的表,所以表 A 主键索引上的空洞,在表 B 中就都不存在了。显然地,表 B 的主键索引更紧凑,数据页的利用率也更高。把表 B 作为临时表,数据从表 A 导入表 B 的操作完成后,用表 B 替换 A,交换表A、B的名字,删除旧表。从效果上看,就起到了收缩表 A 空间的作用。

重建表锁表 DDL

显然,花时间最多的步骤是往临时表插入数据的过程,如果在这个过程中,有新的数据要写入到表 A 的话,就会造成数据丢失。因此,在整个 DDL (指的就是alter table A engine=InnoDB)过程中,表 A 被锁定,无法修改也无法读写。也就是说,这个 DDL 不是 Online 的

MySQL 5.6 版本及之后

在 MySQL 5.6 版本开始引入了 Online DDL([这里也有提到 online ddl](###MDL(metadata lock))),指的是执行特定 DDL 的过程中,允许对表进行读写数据。其中 alter table A engine=InnoDB 这条 DDL 也支持了 Online DDL。为了保证 “Onilne” 重建该表的过程可以写入数据且不丢失,需要对重建表流程做优化:

  1. 建立一个临时文件,扫描表 A 主键的所有数据页;
  2. 用数据页中表 A 的记录生成 B+ 树,存储到临时文件中;
  3. 生成临时文件的过程中,将所有对 A 的操作记录在一个日志文件(row log)中,对应的是图中 state2 的状态;
  4. 临时文件生成后,将日志文件中的操作应用到临时文件,得到一个逻辑数据上与表 A 相同的数据文件,对应的就是图中 state3 的状态;
  5. 用临时文件替换表 A 的数据文件。

Online DDL

可以看到,不同之处在于,由于日志文件记录和重放操作这个功能的存在,这个方案在重建表的过程中,允许对表 A 做增删改操作。对于一个大表来说,Online DDL 最耗时的过程就是拷贝数据到临时表的过程,这个步骤的执行期间可以接受增删改操作。所以,相对于整个 DDL 过程来说,锁的时间非常短。对业务来说,就可以认为是 Online 的。

Online 和 inplace

说到 Online,要再澄清一下它和另一个跟 DDL 有关的、容易混淆的概念 inplace 的区别。

5.5及5.6重建表的两个操作中,前者把表 A 中的数据导出来的存放位置叫作 tmp_table。这是一个临时表,是在 server 层创建的。后者根据表 A 重建出来的数据是放在“tmp_file”里的,这个临时文件是 InnoDB 在内部创建出来的。整个 DDL 过程都在 InnoDB 内部完成。对于 server 层来说,整个过程对它来说是透明的,所以是一个“原地”操作,这就是“inplace”名称的来源(alter table A engine=InnoDB语句也说明了引擎是 InnoDB,而该工作完全是 InnoDB 内部完成,即 InnoDB inplace)。

所以 inplace 也是要占用额外空间的,它和平常的数据结构算法中提到的原地算法不一样。

我们重建表的这个语句 alter table t engine=InnoDB,其实隐含的意思是:

alter table t engine=innodb,ALGORITHM=inplace;

即指定由 InnoDB 自己来完成这项工作。跟 inplace 对应的就是拷贝表的方式了,用法是:

alter table t engine=innodb,ALGORITHM=copy;

当你使用 ALGORITHM=copy 的时候,表示的是强制拷贝表,表示执行 5.5 之前的那个临时表复制流程。

需要注意的是,这里重建表的 DDL 虽然 inplace 是 online 的,但是不代表所有 inplace 的 DDL 都是可以 online 的。比如,如果要给 InnoDB 表的一个字段加全文索引,写法是:

alter table t add FULLTEXT(field_name);

这个 DDL 没有指定 ALGORITHM,所以默认也是 inplace 的,但是它会阻塞增删改操作,是不支持 Online 的。

Online 和 inplace 的联系

如果说这两个逻辑之间的关系是什么的话,可以概括为:

  1. DDL 过程如果是 Online 的,就一定是 inplace 的;
  2. 反过来未必,也就是说 inplace 的 DDL,有可能不是 Online 的。截止到 MySQL 8.0,添加全文索引(FULLTEXT index)和空间索引 (SPATIAL index) 就属于这种情况。

重建表需谨慎

需要补充说明的是,上述的这些重建方法都会扫描原表数据和构建临时文件。对于很大的表来说,这个操作是很消耗 IO 和 CPU 资源的。因此,如果是线上服务,要很小心地控制操作时间。如果想要比较安全的操作的话,推荐你使用 GitHub 开源的 gh-ost 来做。

optimize tableanalyze tablealter table区别

  • 从 MySQL 5.6 版本开始,alter table t engine = InnoDB 就是上面提到的 Online 流程;
  • analyze table t 其实不是重建表,只是对表的索引信息做重新(正确)统计,没有修改数据,这个过程中加了 MDL 读锁;
  • optimize table t 等于前两者操作之和。

收缩表空间却适得其反?

假设现在有人碰到了一个“想要收缩表空间,结果适得其反”的情况,看上去是这样的:

  1. 一个表 t 文件大小为 1TB;
  2. 对这个表执行 alter table t engine=InnoDB;
  3. 发现执行完成后,空间不仅没变小,还稍微大了一点儿,比如变成了 1.01TB。

如果这个表,本身就已经没有空洞的了,比如说刚刚做过一次重建表操作。所以没能整出多少剩余空间。

  • 在 DDL online 重新收缩的过程中,如果刚好有外部的 DML 在执行,这期间可能会引入一些新的空洞。
  • 另外,在重建表的时候,InnoDB 不会把整张表占满,每个页留了 1/16 给后续的更新用。也就是说,其实重建表之后不是“最”紧凑的。

INFORMATION_SCHEMA.INNODB_BUFFER_PAGE 这里面可以看到每个page的尺寸,如果离16KB很近,那就说明基本满了,基本没空洞。做个统计就行了。

十四、count(*)

count(*) 的实现方式

在不同的 MySQL 引擎中,count(*) 有不同的实现方式。

  • MyISAM 引擎把一个表的总行数存在了磁盘上,因此执行 count(*) 的时候会直接返回这个数,效率很高;

  • 而 InnoDB 引擎就麻烦了,它执行 count(*) 的时候,需要把数据一行一行地从引擎里面读出来,然后累积计数。当表中记录数越来越多的时候,计算一个表的总行数会越来越慢的原因。

这里需要注意的是,这里讨论的是没有过滤条件的 count(*),如果加了 where 条件的话,MyISAM 表也是不能返回得这么快的。

InnoDB 实现

那为什么 InnoDB 不跟 MyISAM 一样,也把数字存起来呢?这是因为即使是在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB 表“应该返回多少行”也是不确定的。这和 InnoDB 的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发控制,也就是 MVCC 来实现的。每一行记录都要判断自己是否对这个会话可见,因此对于 count(*) 请求来说,InnoDB 只好把数据一行一行地读出依次判断(从当前版本一直沿着 undo log 寻找匹配 read view 的版本),可见的行才能够用于计算“基于这个查询”的表的总行数。

InnoDB的优化

当然,现在这个看上去笨笨的 MySQL,在执行 count(*) 操作的时候还是做了优化的。

InnoDB 是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于 count(*) 这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的。因此,MySQL 优化器会找到最小的那棵树来遍历。在保证逻辑正确的前提下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。

show table statustable_rowsexplain对于全表扫描的预估扫描行

如果你用过 show table status 命令的话,就会发现这个命令的输出结果里面也有一个 TABLE_ROWS 用于显示这个表当前有多少行,这个命令执行挺快的,但是它不能代替 count(*)explain 中对于全表扫描的预估扫描行也是取的这个值。实际上,TABLE_ROWS 就是从这个采样估算得来的,因此它也很不准。有多不准呢,官方文档说误差可能达到 40% 到 50%。所以,show table status 命令显示的行数也不能直接使用。

如何解决经常查询表行数的需求

如果现在有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,我们只能自己计数,需要自己找一个地方,把操作记录表的行数存起来。

用缓存系统保存计数

用 Redis 存储缓存是最容易想到的做法,但是有以下要注意的:

  1. 要保证持久性,缓存系统如果宕机或者重启后如何恢复。当然可以利用 Redis 本身的持久化机制实现,但问题是可能在宕机期间又有新数据写入了。所以需要到数据库里面单独执行一次 count(*) 获取真实的行数,再把这个值写回到 Redis 里就可以了。异常重启毕竟不是经常出现的情况,这一次全表扫描的成本,还是可以接受的。

  2. 将计数保存在缓存系统中的方式,还不只是丢失更新的问题。即使 Redis 正常工作,这个值还是逻辑上不精确的。这是因为,在并发系统里面,同时使用 Redis 和 MySQL 两个中间件,它们之间终究不是同一个系统,数据库的新增数据行操作和 Redis 的计数操作不能保证原子性,所以很容易会出现不一致性读的问题,这就涉及到分布式事务的问题了。

在数据库保存计数

其实就是利用 MySQL InnoDB 支持事务的特性,专门建一个表或者一个字段来存储行数,将新增数据行和计数加1的动作放在一个事务中进行,另外读取计数以及读取数据行也放在一个事务里面实现一致性读。

另外,需要注意的是,出于并发性能的考虑,在新增数据行的事务中,要先执行新增数据行,再更新计数,因为前者并发度较低,后者并发读较高,这样可以尽量降低行锁阻塞事务的概率

不同的 count 用法

count(*)count(主键 id)count(字段)count(1) 等不同用法的性能,有哪些差别?

首先要弄清楚 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层不会要值)。server 层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。单看这两个用法的差别的话,你能对比出来,count(1) 执行得要比 count(主键 id) 快。因为从引擎返回 id 会涉及到解析数据行,以及拷贝字段值的操作。

对于 count(字段) 来说:如果这个“字段”是定义为 not null 的话,一行行地从记录里面读出这个字段,根据 not null 约束可以直到该字段不能为 null,直接按行累加;如果这个“字段”定义允许为 null,那么执行的时候,知道有可能是 null,还要把值取出来再判断一下,不是 null 才累加。也就是前面的第一条原则,server 层要什么字段,InnoDB 就返回什么字段。

但是 count(*) 是例外,并不会把全部字段取出来,而是专门做了优化,不取值。count(*) 的语义就是对所有的行进行计数,肯定不是 null,按行累加。看到这里,你一定会说,优化器就不能自己判断一下吗,主键 id 肯定非空啊,为什么不能按照 count(*) 来处理,多么简单的优化啊。当然,MySQL 专门针对这个语句进行优化,也不是不可以。但是这种需要专门优化的情况太多了,而且 MySQL 已经优化过 count(*) 了,你直接使用这种用法就可以了。

所以结论是:按照效率排序的话,count(字段)<count(主键 id)<count(1)count(*),所以建议尽量使用 count(*)

十五、order by

先导:例子

在开发应用的时候,一定会经常碰到需要根据指定的字段排序来显示结果的需求。还是以我们前面举例用过的市民表为例,假设你要查询城市是“杭州”的所有人名字,并且按照姓名排序返回前 1000 个人的姓名、年龄。

CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `city` varchar(16) NOT NULL,
  `name` varchar(16) NOT NULL,
  `age` int(11) NOT NULL,
  `addr` varchar(128) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `city` (`city`)
) ENGINE=InnoDB;

这时,SQL 语句可以这么写:

select city,name,age from t where city='杭州' order by name limit 1000  ;

下面来分析 MySQL 是如何执行 order by 的。

全字段排序

为避免全表扫描,我们需要在 city 字段加上索引。在 city 字段上创建索引之后,我们用 explain 命令来看看这个语句的执行情况。

mysql> explain select city,name,age from t where city='杭州' order by name limit 1000;
+----+-------------+-------+------------+------+---------------+------+---------+-------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref   | rows | filtered | Extra                                 |
+----+-------------+-------+------------+------+---------------+------+---------+-------+------+----------+---------------------------------------+
|  1 | SIMPLE      | t     | NULL       | ref  | city          | city | 66      | const | 4000 |   100.00 | Using index condition; Using filesort |
+----+-------------+-------+------------+------+---------------+------+---------+-------+------+----------+---------------------------------------+
1 row in set, 1 warning (0.14 sec)

Extra 这个字段中的“Using filesort”表示的就是需要排序,MySQL 会给每个线程分配一块内存用于排序,称为 sort_buffer,排序过程是在 Server 层进行的,申请的 sort_buffer 在排序完成后即归还系统。

全字段排序流程

为了说明这个 SQL 查询语句的执行过程,我们先来看一下 city 这个索引的示意图。

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_sizesort_buffer_size,就是 MySQL 为排序开辟的内存(sort_buffer)的大小。如果要排序的数据量小于 sort_buffer_size,排序就在内存中完成。但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。

查看是否外部排序以及其它排序指标

可以用下面介绍的方法,来确定一个排序语句是否使用了临时文件。

/* 打开optimizer_trace,只对本线程有效 */
SET optimizer_trace='enabled=on'; 

/* @a保存Innodb_rows_read的初始值 */
select VARIABLE_VALUE into @a from  performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 执行语句 */
select city, name,age from t where city='杭州' order by name limit 1000; 

/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G

/* @b保存Innodb_rows_read的当前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';

/* 计算Innodb_rows_read差值 */
select @b-@a;

这个方法是通过查看 OPTIMIZER_TRACE 的结果来确认的,可以从 number_of_tmp_files 中看到是否使用了临时文件。

全排序的 OPTIMIZER_TRACE 部分结果

  • number_of_tmp_files 表示的是,排序过程中使用的临时文件数。为什么需要 12 个文件?内存放不下时,就需要使用外部排序,外部排序一般使用归并排序算法。可以这么简单理解,MySQL 将需要排序的数据分成 12 份,每一份单独排序后存在这些临时文件中。然后把这 12 个有序文件再合并成一个有序的大文件。如果 sort_buffer_size 超过了需要排序的数据量的大小,number_of_tmp_files 就是 0,表示排序可以直接在内存中完成;否则就需要放在临时文件中排序。sort_buffer_size 越小,需要分成的份数越多,number_of_tmp_files 的值就越大。

  • 示例表t中有 4000 条满足 city='杭州’的记录,所以可以看到 examined_rows=4000,表示参与排序的行数是 4000 行。

  • sort_mode 里面的 packed_additional_fields 的意思是,将所有查询要返回的字段都打包参与到了排序中,且排序过程对字符串做了“紧凑”处理。即使 name 字段的定义是 varchar(16),在排序过程中还是要按照实际长度来分配空间的。

  • 同时,最后一个查询语句 select @b-@a 的返回结果是 4000,表示整个执行过程只扫描了 4000 行

这里需要注意的是,为了避免对结论造成干扰,把 internal_tmp_disk_storage_engine 设置成 MyISAM。否则,select @b-@a的结果会显示为 4001。这是因为查询 OPTIMIZER_TRACE 这个表时,需要用到临时表,而 internal_tmp_disk_storage_engine 的默认值是 InnoDB。如果使用的是 InnoDB 引擎的话,把数据从临时表取出来的时候,会让 Innodb_rows_read 的值加 1。

rowid 排序

在上面这个算法过程里面,只对原表的数据读了一遍,剩下的操作都是在 sort_buffer 和临时文件中执行的。但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么 sort_buffer 里面要放的字段数太多,这样内存里能够同时放下的行数很少,要分成很多个临时文件,排序的性能会很差。

MySQL 通过参数 max_length_for_sort_data 来控制排序时单行数据可以接受的最大长度,如果在进行全字段排序的时候所有字段的长度加起来超过了这个阈值,就会采用 row id 排序的方式。

citynameage 这三个字段的定义总长度是 36,把 max_length_for_sort_data 设置为 16,我们再来看看计算过程有什么改变。

SET max_length_for_sort_data = 16;

row id 排序流程

放入 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 排序

对比全字段排序流程图会发现,rowid 排序多访问了一次表 t 的主键索引,就是步骤 7。

需要说明的是,最后的“结果集”是一个逻辑概念,实际上 MySQL 服务端从排序后的 sort_buffer 中依次取出 id,然后到原表查到 citynameage 这三个字段的结果,不需要在服务端再耗费内存存储结果,是直接返回给客户端的(直接从主键索引内存页拷贝到网络IO缓存)。

查看是否外部排序以及其它排序指标

图中的 examined_rows 的值还是 4000,表示用于排序的数据是 4000 行。但是 select @b-@a 这个语句的值变成 5000 了。因为这时候除了排序过程外,在排序完成后,还要根据 id 去原表取值。由于语句是 limit 1000,因此会多读 1000 行。

rowid 排序的 OPTIMIZER_TRACE 部分输出

OPTIMIZER_TRACE 的结果中,还能看到另外两个信息也变了。

  • sort_mode 变成了 ,表示参与排序的只有 nameid 这两个字段。
  • number_of_tmp_files 变成 10 了,是因为这时候参与排序的行数虽然仍然是 4000 行,但是每一行都变小了,因此需要排序的总数据量就变小了,需要的临时文件也相应地变少了。

全字段排序 VS rowid 排序

如果 MySQL 实在是担心排序内存太小,会影响排序效率,才会采用 rowid 排序算法,这样排序过程中一次可以排序更多行,但是需要再回到原表去取数据。如果 MySQL 认为内存足够大,会优先选择全字段排序,把需要的字段都放到 sort_buffer 中,这样排序后就会直接从内存里面返回查询结果了,不用再回到原表去取数据。这也就体现了 MySQL 的一个设计思想:如果内存够,就要多利用内存,尽量减少磁盘访问

使用全字段排序会面临临时文件过多,也有磁盘访问的压力;使用 rowid 排序则有索引访问的压力。需要对比。但是,如果排序内存完全够全字段排序,那必然是使用全字段排序优先。

对于 InnoDB 表来说,rowid 排序会要求回表多造成磁盘读(由于 InnoDB 是索引组织表且存储在磁盘中),因此不会被优先选择。

不用 order by 也可以排序

所以 MySQL 做排序是一个成本比较高的操作。那么你会问,是不是所有的 order by 都需要排序操作呢?如果不排序就能得到正确的结果,那对系统的消耗会小很多,语句的执行时间也会变得更短。其实,并不是所有的 order by 语句,都需要排序操作的。从上面分析的执行过程,我们可以看到,MySQL 之所以需要生成临时表,并且在临时表上做排序操作,其原因是原来的数据都是无序的。如果能够保证从 city 这个索引上取出来的行,天然就是按照 name 递增排序的话,就可以不用再排序。

可以在这个市民表上创建一个 city 和 name 的联合索引,对应的 SQL 语句是:

alter table t add index city_user(city, name);

作为与 city 索引的对比,我们来看看这个索引的示意图。

city 和 name 联合索引示意图

在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足 city='杭州’的记录,并且额外确保了,接下来按顺序取“下一条记录”的遍历过程中,只要 city 的值是杭州,name 的值就一定是有序的。这样整个查询过程的流程就变成了:

  1. 从索引 (city,name) 找到第一个满足 city='杭州’条件的主键 id;
  2. 到主键 id 索引取出整行,取 name、city、age 三个字段的值,作为结果集的一部分直接返回;
  3. 从索引 (city,name) 取下一个记录主键 id;
  4. 重复步骤 2、3,直到查到第 1000 条记录,或者是不满足 city='杭州’条件时循环结束。

引入 (city,name) 联合索引后,查询语句的执行计划

可以看到,这个查询过程不需要临时表,也不需要排序。接下来,我们用 explain 的结果来印证一下。

mysql> explain select city,name,age from t where city='杭州' order by name limit 1000  ;
+----+-------------+-------+------------+------+----------------+-----------+---------+-------+------+----------+-----------------------+
| id | select_type | table | partitions | type | possible_keys  | key       | key_len | ref   | rows | filtered | Extra                 |
+----+-------------+-------+------------+------+----------------+-----------+---------+-------+------+----------+-----------------------+
|  1 | SIMPLE      | t     | NULL       | ref  | city,city_user | city_user | 66      | const | 4000 |   100.00 | Using index condition |
+----+-------------+-------+------------+------+----------------+-----------+---------+-------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

从图中可以看到,Extra 字段中没有 Using filesort 了,也就是不需要排序了。而且由于 (city,name) 这个联合索引本身有序,所以这个查询也不用把 4000 行全都读一遍,只要找到满足条件的前 1000 条记录就可以退出了。也就是说,在我们这个例子里,只需要扫描 1000 次。

对该 SQL 执行的进一步优化

按照覆盖索引的概念,我们可以再优化一下这个查询语句的执行流程。针对这个查询,我们可以创建一个 citynameage 的联合索引,对应的 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='杭州’条件时循环结束。

引入 (city,name,age) 联合索引后,查询语句的执行流程

再来看看 explain 的结果:

mysql> explain select city,name,age from t where city='杭州' order by name limit 1000  ;
+----+-------------+-------+------------+------+------------------------------+--------------+---------+-------+------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys                | key          | key_len | ref   | rows | filtered | Extra                    |
+----+-------------+-------+------------+------+------------------------------+--------------+---------+-------+------+----------+--------------------------+
|  1 | SIMPLE      | t     | NULL       | ref  | city,city_user,city_user_age | city_user_age| 66      | const | 4000 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+------+------------------------------+--------------+---------+-------+------+----------+--------------------------+
1 row in set, 1 warning (0.03 sec)

可以看到,Extra 字段里面多了“Using index”,表示的就是使用了覆盖索引,性能上会快很多。

单纯给 order by 字段加索引是否可以加速

上面的例子中,select SQL 是有条件子句 where 的,且 where 中的条件字段 city 本身就有了索引,所以该查询语句必然会去检索这个二级索引,然后再回表检索,此时我们给 order by 字段与 city 字段建立联合索引,才能避免排序。但是如果 SQL 本身就没有 where 子句,或者说 where 子句中的字段不能加索引,此时单独对 order by 字段建立索引,是否可以避免排序起到加速的效果呢?看下面例子:

有个页面,需要按数据插入时间 create_time 倒序来查看一张记录表的信息 ,因为除了分页的参数 , 没有其他 where 的条件 ,所以除了主键外没有其他索引 。这时候给 create_time 创建索引, 查询会利用这个二级索引增快吗?

  1. where 条件,只有 order by create_time即便 create_time 上有索引,也不会使用到。因为优化器认为走二级索引再去回表成本比直接全表扫描出来进行排序成本更高

    个人认为也是的,先遍历一遍二级索引树得到主键值然后一遍遍回表检索,此时每一遍都要从主键索引树根到叶子节点的检索过程,更糟糕的是如果相对主键索引顺序较散乱还可能导致主键索引中的一个 page 要从磁盘加载多次才能访问完它里面的所有数据行;

    如果是全表扫描,直接将叶子节点一个个加载出来就行了。

    相对来说,时间复杂度应该是前者更高的。

  2. where 条件,但是 order by create_time limit m,如果 m 值较小,是可以走索引的。

    • 此时 m 值较小,需要回表的动作就不多,相对来说,此时排序的成本又上去了,走全表扫描是要对全表进行排序的,所以优化器此时会选择走二级索引,这时候就可以利用这个二级索引避免排序了
    • 即使没有二级索引,MySQL 针对 order by limit 也做了优化,采用堆排序。
  3. 如果查询子句 select 中要查询的字段只有 create_time,或者说和 create_time 都在一个联合索引以内,那么因为覆盖索引的原因有无 limit 都可以走这个二级索引,因此优化器认为此时是无需回表的,这时候也可以利用这个二级索引避免排序了。但是在此例中不成立,select 中要查询的字段肯定不只有 create_time

总的来说,如果分析出来 SQL 需要回表且回表行数过多,优化器就不会走二级索引,而是主键索引全表扫描,这个不只对 order by 字段有效,对于 where 字段同样有效。

在联合索引上使用了范围查询导致不能避免排序

假设你的表里面已经有了 city_name(city, name) 这个联合索引,然后你要查杭州和苏州两个城市中所有的市民的姓名,并且按名字排序,显示前 100 条记录。如果 SQL 查询语句是这么写的 :

mysql> 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. 执行 select * from t where city=“杭州” order by name limit 100; 这个语句是不需要排序的,客户端用一个长度为 100 的内存数组 A 保存结果。
  2. 执行 select * from t where city=“苏州” order by name limit 100; 用相同的方法,假设结果被存进了内存数组 B。
  3. 现在 A 和 B 是两个有序数组,然后你可以用归并排序的思想,得到 name 最小的前 100 值,就是我们需要的结果了。

如果把这条 SQL 语句里“limit 100”改成“limit 10000,100”的话,处理方式其实也差不多,即:要把上面的两条语句改成写:

select * from t where city="杭州" order by name limit 10100; 
select * from t where city="苏州" order by name limit 10100;

这时候数据量较大,可以同时起两个连接一行行读结果,用归并排序算法拿到这两个结果集里,按顺序取第 10001~10100 的 name 值,就是需要的结果了。当然这个方案有一个明显的损失,就是从数据库返回给客户端的数据量变大了。所以,如果数据的单行比较大的话,可以考虑把这两条 SQL 语句改成下面这种写法:

select id, name from t where city="杭州" order by name limit 10100; 
select id, name from t where city="苏州" order by name limit 10100;

然后,再用归并排序的方法取得按 name 顺序第 10001~10100 的 name、id 的值,然后拿着这 100 个 id 到数据库中去查出所有记录。

上面这些方法,需要你根据性能需求和开发的复杂度做出权衡。

MySQL 类型后面的括号

bigintint 后面圆括号括住一个数字不会对可以存储的内容的大小产生影响。以 bigint 为例,bigint(1)bigint(19) 都能存储 2^64-1 范围内的值。int 是2^32-1。括号里面的值目前是被客户端用来对该字段的内容显示的时候进行截取的长度,例如bigint(1) 只显示 1 位数字。

十六、随机排序和临时表

一个英语学习 App 首页有一个随机显示单词的功能,也就是根据每个用户的级别有一个单词表,然后这个用户每次访问首页的时候,都会随机滚动显示三个单词。但是随着单词表变大,选单词这个逻辑变得越来越慢,甚至影响到了首页的打开速度。现在,如果让你来设计这个 SQL 语句,你会怎么写呢?

为了便于理解,对这个例子进行了简化:去掉每个级别的用户都有一个对应的单词表这个逻辑,直接就是从一个单词表中随机选出三个单词。这个表的建表语句和初始数据的命令如下:

mysql> CREATE TABLE `words` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `word` varchar(64) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=0;
  while i<10000 do
    insert into words(word) values(concat(char(97+(i div 1000)), char(97+(i % 1000 div 100)), char(97+(i % 100 div 10)), char(97+(i % 10))));
    set i=i+1;
  end while;
end;;
delimiter ;

call idata();

为了便于量化说明,我在这个表里面插入了 10000 行记录。接下来,一起看看要随机选择 3 个单词,有什么方法实现,存在什么问题以及如何改进。

order by rand()

首先会想到用 order by rand() 来实现这个逻辑。

mysql> select word from words order by rand() limit 3;

select 对于每行数据调用一次 rand() 函数获得一个随机值,根据随机值排序,取前三行的 word 字段进行返回。

内存临时表

查看 explain 执行计划:

mysql> explain select word from words order by rand() limit 3;
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra                           |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+
|  1 | SIMPLE      | words | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 9980 |   100.00 | Using temporary; Using filesort |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+---------------------------------+
1 row in set, 1 warning (0.00 sec)

Extra 字段显示 Using temporary,表示的是需要使用临时表;Using filesort,表示的是需要执行排序操作。因此这个 Extra 的意思就是,需要建立临时表,并且需要对建立的临时表上排序。

rowid 排序策略

MySQL 的排序策略有两种:全字段排序和 row id 排序。

  • 对于 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 类型(存储随机函数值的字段 R),另一个是整型(存储 memory 表中行的唯一标识)。
  5. 从内存临时表中一行一行地取出 R 值和位置信息[15],分别存入 sort_buffer 中的两个字段里。这个过程要对内存临时表做全表扫描,此时扫描行数增加 10000,变成了 20000
  6. 在 sort_buffer 中根据 R 的值进行排序。注意,这个过程没有涉及到表操作,所以不会增加扫描行数。
  7. 排序完成后,取出前三个结果的位置信息[15:1],依次到内存临时表中取出 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 行,也就验证了分析得出的结论。

完整的排序执行流程图如下:

随机排序完整流程图1

磁盘临时表

那么,是不是所有的临时表都是内存表呢?其实不是的。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 部分结果

然后,看一下这次 OPTIMIZER_TRACE 的结果。因为将 max_length_for_sort_data 设置成 16,小于 word 字段的长度定义,所以我们看到 sort_mode 里面显示的是 rowid 排序,这个是符合预期的,参与排序的是随机值 R 字段和 rowid 字段组成的行。

排序算法:堆排序 VS 快速排序

对于上面的 OPTIMIZER_TRACE 的结果。R 字段存放的随机值就 8 个字节,rowid 是 6 个字节(如果一个 InnoDB 表中没有主键或者主键删掉了,就会默认生成一个 6 个字节的 row id 字段作为主键),数据总行数是 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),先取前三行,构造成一个堆;
  2. 取下一个行 (R’,rowid’),跟当前堆里面最大的 R 比较,如果 R’小于 R,把这个 (R,rowid) 从堆中去掉,换成 (R’,rowid’);
  3. 重复第 2 步,直到第 10000 个 (R’,rowid’) 完成比较。

优先队列排序算法示例

上图是模拟 6 个 (R,rowid) 行,通过优先队列排序算法找到最小的三个 R 值的行的过程。整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。前面的 OPTIMIZER_TRACE 结果中,filesort_priority_queue_optimization 这个部分的 chosen=true,就表示使用了优先队列排序算法,这个过程不需要临时文件,因此对应的 number_of_tmp_files 是 0。

总结:这里之所以会用到优先队列算法,是因为

  • MySQL 识别到了 limit 3 的需求,直到最终只需要 3 行最小数据,而不是需要全表数据都返回
  • 定义的 sort_buffer_size 完全可以满足 3 行数据堆排序过程的内存占用;否则需要分批进行归并排序,对堆排序做归并较复杂且成本也较高,将会转成快排+归并。

所以除了这里的临时表,我们自己定义的表在使用 order by 的时候也可以利用这两点:

  • 如果可以,尽量 limit 一个较小的数量
  • sort_buffer_size 尽量设置大一点

小结

总之,不论是使用哪种类型的临时表,order by rand() 这种写法都会让计算过程非常复杂,需要大量的扫描行数,因此排序过程的资源消耗也会很大。

其它随机排序方法

随机算法 1

先把问题简化一下,如果只随机选择 1 个 word 值,可以怎么做呢?思路上是这样的:

  1. 取得这个表的主键 id 的最大值 M 和最小值 N;
  2. 用随机函数生成一个最大值到最小值之间的数 X = (M-N)*rand() + N;
  3. 取不小于 X 的第一个 ID 的行。
mysql> select max(id),min(id) into @M,@N from t ;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;
  • 优点

    这个方法效率很高,因为取 max(id) 和 min(id) 都是不需要扫描索引的,而第三步的 select 也可以用索引快速定位,可以认为就只扫描了 3 行。

  • 缺点

    但实际上,这个算法本身并不严格满足题目的随机要求,因为 ID 中间可能有空洞,因此选择不同行的概率不一样,不是真正的随机。比如你有 4 个 id,分别是 1、2、4、5,如果按照上面的方法,那么取到 id=4 的这一行的概率是取得其他行概率的两倍。如果这四行的 id 分别是 1、2、40000、40001 呢?这个算法基本就能当 bug 来看待了。

随机算法 2

rand() 函数返回的是一个小数,直接乘以表行数并取整就可以得到一个比较随机的 id:

  1. 取得整个表的行数,并记为 C。
  2. 取得 Y = floor(C * rand())。 floor 函数在这里的作用,就是取整数部分。
  3. 再用 limit Y,1 取得一行。
mysql> select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;

MySQL 处理 limit Y,1 的做法就是按顺序一个一个地读出来,丢掉前 Y 个,然后把下一个记录作为返回结果,因此这一步需要扫描 Y+1 行。再加上,第一步扫描的 C 行,总共需要扫描 C+Y+1 行,执行代价比随机算法 1 的代价要高。当然,随机算法 2 跟直接 order by rand() 比起来,执行代价还是小很多的。(为什么都得到一个 id 了,还要 limit 呢?因为大于最小 id、小于最大 id 不一定是一个有效 id,首先你要保证 id 是自增的,而且没有删除过数据)

order by rand()

  1. 要构建临时表
  2. 构建完成后还需要按照 rand()字段排序
  3. 如果使用的是快排需要对全表排序;优先队列排序还快一点

随机算法2:直接根据有序的主键索引扫描到 limit 行即可。

随机算法 3

如果我们按照随机算法 2 的思路,要随机取 3 个 word 值呢?

  1. 取得整个表的行数,记为 C;
  2. 根据相同的随机方法得到 Y1、Y2、Y3;
  3. 再执行三个 limit Y, 1 语句得到三行数据。
mysql> select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y1,1; //在应用代码里面取Y1、Y2、Y3值,拼出SQL后执行
select * from t limit @Y2,1;
select * from t limit @Y3,1;

随机算法 4

上面的随机算法 3 的总扫描行数是 C+(Y1+1)+(Y2+1)+(Y3+1),实际上它还是可以继续优化,来进一步减少扫描行数的。取 Y1、Y2 和 Y3 里面最大的一个数,记为 M,最小的一个数记为 N,然后执行下面这条 SQL 语句:

mysql> select * from t limit N, M-N+1;

再加上取整个表总行数的 C 行,这个方案的扫描行数总共只需要 C+M+1 行。

十七、"有索引但是很慢"的场景示例

案例一:条件字段函数操作

假设现在维护了一个交易系统,其中交易记录表 tradelog 包含交易流水号(tradeid)、交易员 id(operator)、交易时间(t_modified)等字段。为了便于描述,我们先忽略其他字段。这个表的建表语句如下:

mysql> CREATE TABLE `tradelog` (
  `id` int(11) NOT NULL,
  `tradeid` varchar(32) DEFAULT NULL,
  `operator` int(11) DEFAULT NULL,
  `t_modified` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `tradeid` (`tradeid`),
  KEY `t_modified` (`t_modified`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

假设,现在已经记录了从 2016 年初到 2018 年底的所有数据,运营部门有一个需求是,要统计发生在所有年份中 7 月份的交易记录总数。这个逻辑看上去并不复杂,你的 SQL 语句可能会这么写:

mysql> select count(*) from tradelog where month(t_modified)=7;

由于 t_modified 字段上有索引,于是你就很放心地在生产库中执行了这条语句,但却发现执行了特别久,才返回了结果。如果你问 DBA 同事为什么会出现这样的情况,他大概会告诉你:如果对字段做了函数计算,就用不上索引了,这是 MySQL 的规定。为什么条件是 where t_modified='2018-7-1’的时候可以用上索引,而改成 where month(t_modified)=7 的时候就不行了?

下面是这个 t_modified 索引的示意图。方框上面的数字就是 month() 函数对应的值。

t_modified 索引示意图

如果 SQL 语句条件用的是 where t_modified='2018-7-1’的话,引擎就会按照上面绿色箭头的路线,快速定位到 t_modified='2018-7-1’需要的结果。实际上,B+ 树提供的这个快速定位能力,来源于同一层兄弟节点的有序性。但是,如果计算 month() 函数的话,会发现本来有序的索引变成了无序的了,也就是说,涉及对索引字段进行函数计算的操作,可能会破坏索引值的有序性,因此优化器就决定放弃走树搜索功能。需要注意的是,优化器并不是要放弃使用这个索引。在这个例子里,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引 t_modified,优化器对比索引大小后发现,索引 t_modified 更小,遍历这个索引比遍历主键索引来得更快。因此最终还是会选择索引 t_modified

使用 explain 命令,查看一下这条 SQL 语句的执行计划:

explain 结果

key="t_modified"表示的是,使用了 t_modified 这个索引;我在测试表数据中插入了 10 万行数据,rows=100335,说明这条语句扫描了整个索引的所有值;Extra 字段的 Using index,表示的是使用了覆盖索引(使用了索引不一定是使用了索引树搜索功能)。

也就是说,由于在 t_modified 字段加了 month() 函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把 SQL 语句改成基于字段本身的范围查询。按照下面这个写法,优化器就能按照我们预期的,用上 t_modified 索引的快速定位能力了。

mysql> select count(*) from tradelog where
    -> (t_modified >= '2016-7-1' and t_modified<'2016-8-1') or
    -> (t_modified >= '2017-7-1' and t_modified<'2017-8-1') or 
    -> (t_modified >= '2018-7-1' and t_modified<'2018-8-1');

当然,如果系统上线时间更早,或者后面又插入了之后年份的数据的话,就需要再把其他年份补齐。

优化器在对索引字段进行函数计算的问题上确实有“偷懒”行为,即使是对于不改变有序性的函数,也不会考虑使用索引。比如,对于 select * from tradelog where id + 1 = 10000 这个 SQL 语句,这个加 1 操作并不会改变有序性,但是 MySQL 优化器还是不能用 id 索引快速定位到 9999 这一行。所以,需要你在写 SQL 语句的时候,手动改写成 where id = 10000 -1 才可以。

案例二:隐式类型转换

一起看一下这条 SQL 语句:

mysql> select * from tradelog where tradeid=110717;

交易编号 tradeid 这个字段上,本来就有索引,但是 explain 的结果却显示,这条语句需要走全表扫描。你可能也发现了,tradeid 的字段类型是 varchar(32),而输入的参数却是整型,所以需要做类型转换。

那么,现在这里就有两个问题:

  1. 数据类型转换的规则是什么?
  2. 为什么有数据类型转换,就需要走全索引扫描?

select “10” > 9 的结果:

  1. 如果规则是“将字符串转成数字”,那么就是做数字比较,结果应该是 1;
  2. 如果规则是“将数字转成字符串”,那么就是做字符串比较,结果应该是 0。

验证结果:

mysql> select '10' > 9;
+----------+
| '10' > 9 |
+----------+
|        1 |
+----------+
1 row in set (0.09 sec)

由于 select “10” > 9 返回的是 1,所以就能确认 MySQL 里的转换规则了:在 MySQL 中,字符串和数字做比较的话,是将字符串转换成数字。

另外:

mysql> select 'a'=0 && '中'=0;
+------------------+
| 'a'=0 && '中'=0  |
+------------------+
|                1 |
+------------------+
1 row in set, 2 warnings (0.00 sec)

由上可以知道,对于不能转成数字的字符,都会被转成数字0 。

所以此时这个全表扫描的语句:

mysql> select * from tradelog where tradeid=110717;

就相当于:

mysql> select * from tradelog where  CAST(tradid AS signed int) = 110717;

也就是说,这条语句触发了我们上面说到的规则:对索引字段做函数操作,优化器会放弃走树搜索功能。

以下语句中 id 的类型是 int,但是不会导致全表扫描,因为类型转换是字符串转成 int,所以此时类型转换发生在右边,并不会对索引字段做任何处理,所以可以走索引树搜索功能。

select * from tradelog where id="83126";

案例三:隐式字符编码转换

假设系统里还有另外一个表 trade_detail,用于记录交易的操作细节。为了便于量化分析和复现,我往交易日志表 tradelog 和交易详情表 trade_detail 这两个表里插入一些数据。

mysql> CREATE TABLE `trade_detail` (
  `id` int(11) NOT NULL,
  `tradeid` varchar(32) DEFAULT NULL,
  `trade_step` int(11) DEFAULT NULL, /*操作步骤*/
  `step_info` varchar(32) DEFAULT NULL, /*步骤信息*/
  PRIMARY KEY (`id`),
  KEY `tradeid` (`tradeid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

insert into tradelog values(1, 'aaaaaaaa', 1000, now());
insert into tradelog values(2, 'aaaaaaab', 1000, now());
insert into tradelog values(3, 'aaaaaaac', 1000, now());

insert into trade_detail values(1, 'aaaaaaaa', 1, 'add');
insert into trade_detail values(2, 'aaaaaaaa', 2, 'update');
insert into trade_detail values(3, 'aaaaaaaa', 3, 'commit');
insert into trade_detail values(4, 'aaaaaaab', 1, 'add');
insert into trade_detail values(5, 'aaaaaaab', 2, 'update');
insert into trade_detail values(6, 'aaaaaaab', 3, 'update again');
insert into trade_detail values(7, 'aaaaaaab', 4, 'commit');
insert into trade_detail values(8, 'aaaaaaac', 1, 'add');
insert into trade_detail values(9, 'aaaaaaac', 2, 'update');
insert into trade_detail values(10, 'aaaaaaac', 3, 'update again');
insert into trade_detail values(11, 'aaaaaaac', 4, 'commit');

这时候,如果要查询 id=2 的交易的所有操作步骤信息,SQL 语句可以这么写:

mysql> select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /*语句Q1*/

explain

mysql> explain select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2; /*语句Q1*/
+----+-------------+-------+------------+-------+-----------------+---------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys   | key     | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+-----------------+---------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | l     | NULL       | const | PRIMARY,tradeid | PRIMARY | 4       | const |    1 |   100.00 | NULL        |
|  1 | SIMPLE      | d     | NULL       | ALL   | NULL            | NULL    | NULL    | NULL  |   11 |   100.00 | Using where |
+----+-------------+-------+------------+-------+-----------------+---------+---------+-------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)

一起来看下这个结果:

  1. 第一行显示优化器会先在交易记录表 tradelog 上查到 id=2 的行,这个步骤用上了主键索引,rows=1 表示只扫描一行;
  2. 第二行 key=NULL,表示没有用上交易详情表 trade_detail 上的 tradeid 索引,进行了全表扫描。

在这个执行计划里,是从 tradelog 表中取 tradeid 字段,再去 trade_detail 表里查询匹配字段。因此,我们把 tradelog 称为驱动表,把 trade_detail 称为被驱动表,把 tradeid 称为关联字段

接下来,我们看下这个 explain 结果表示的执行流程:

语句 Q1 的执行过程

图中:

  1. 第 1 步,是根据 idtradelog 表里找到 L2 这一行;
  2. 第 2 步,是从 L2 中取出 tradeid 字段的值;
  3. 第 3 步,是根据 tradeid 值到 trade_detail 表中查找条件匹配的行。explain 的结果里面第二行的 key=NULL 表示的就是,这个过程是通过遍历主键索引的方式,一个一个地判断 tradeid 的值是否匹配。

进行到这里,会发现第 3 步不符合预期。因为表 trade_detailtradeid 字段上是有索引的,我们本来是希望通过使用 tradeid 索引能够快速定位到等值的行。但,这里并没有。如果去问 DBA 同学,他们可能会说,因为这两个表的字符集不同,一个是 utf8,一个是 utf8mb4,所以做表连接查询的时候用不上关联字段的索引。

如果单独把第3步改成 SQL 语句的话,那就是:

mysql> select * from trade_detail where tradeid=$L2.tradeid.value; 

其中,$L2.tradeid.value 的字符集是 utf8mb4。参照前面例子,字符集 utf8mb4utf8 的超集,所以当这两个类型的字符串在做比较的时候,MySQL 内部的操作是,先把 utf8 字符串转成 utf8mb4 字符集(向上转型/宽化转换),再做比较。因此, 在执行上面这个语句的时候,需要将被驱动数据表里的字段一个个地转换成 utf8mb4,再跟 L2 做比较。也就是说,实际上这个语句等同于下面这个写法:

select * from trade_detail  where CONVERT(traideid USING utf8mb4)=$L2.tradeid.value; 

CONVERT() 函数,在这里的意思是把输入的字符串转成 utf8mb4 字符集。**这就再次触发了我们上面说到的原则:对索引字段做函数操作,优化器会放弃走树搜索功能。**所以字符集不同只是条件之一,连接过程中要求在被驱动表的索引字段上加函数操作,是直接导致对被驱动表做全表扫描的原因

同样是隐式字符编码转换却走了索引树搜索

作为对比验证,现在有另外一个需求,“查找 trade_detail 表里 id=4 的操作,对应的操作者是谁”,再来看下这个语句和它的执行计划。

mysql>select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;

执行计划:

mysql> explain select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id=4;
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | d     | NULL       | const | PRIMARY       | PRIMARY | 4       | const |    1 |   100.00 | NULL  |
|  1 | SIMPLE      | l     | NULL       | ref   | tradeid       | tradeid | 131     | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
2 rows in set, 1 warning (0.03 sec)

这个语句里 trade_detail 表成了驱动表,但是 explain 结果的第二行显示,这次的查询操作用上了被驱动表 tradelog 里的索引 (tradeid),扫描行数是 1。

这也是两个 tradeid 字段的 join 操作,为什么这次能用上被驱动表的 tradeid 索引呢?

  1. 假设驱动表 trade_detailid=4 的行记为 R4,那么在连接的时候,被驱动表 tradelog 上执行的就是类似这样的 SQL 语句

    select operator from tradelog  where traideid =$R4.tradeid.value; 
    
  2. 这时候 $R4.tradeid.value 的字符集是 utf8, 按照字符集转换规则,要转成 utf8mb4,所以这个过程就被改写成:

    select operator from tradelog  where traideid =CONVERT($R4.tradeid.value USING utf8mb4); 
    

    这里的 CONVERT 函数是加在输入参数上的,这样就可以用上被驱动表的 traideid 索引。

优化前面的语句

理解了原理以后,就可以用来指导操作了。如果要优化语句

select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;

的执行过程,有两种做法:

  • 比较常见的优化方法是,把 trade_detail 表上的 tradeid 字段的字符集也改成 utf8mb4,这样就没有字符集转换的问题了。

    alter table trade_detail modify tradeid varchar(32) CHARACTER SET utf8mb4 default null;
    
  • 如果能够修改字段的字符集的话,是最好不过了。但如果数据量比较大, 或者业务上暂时不能做这个 DDL 的话,那就只能采用修改 SQL 语句的方法了。

    mysql> select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2; 
    

    执行计划:

    mysql> explain select d.* from tradelog l , trade_detail d where d.tradeid=CONVERT(l.tradeid USING utf8) and l.id=2;
    +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
    | id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
    +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
    |  1 | SIMPLE      | l     | NULL       | const | PRIMARY       | PRIMARY | 4       | const |    1 |   100.00 | NULL  |
    |  1 | SIMPLE      | d     | NULL       | ref   | tradeid       | tradeid | 99      | const |    4 |   100.00 | NULL  |
    +----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
    2 rows in set, 1 warning (0.00 sec)
    

    这里,主动把 l.tradeid 转成 utf8,就避免了被驱动表上的字符编码转换,从 explain 结果可以看到,这次索引走对了。

案例四:隐式截断字段

表结构如下:

mysql> CREATE TABLE `table_a` (
  `id` int(11) NOT NULL,
  `b` varchar(10) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `b` (`b`)
) ENGINE=InnoDB;

假设现在表里面,有 100 万行数据,其中有 10 万行数据的 b 的值是’1234567890’, 假设现在执行语句是这么写的:

mysql> select * from table_a where b='1234567890abcd';

最理想的情况是,MySQL 看到字段 b 定义的是 varchar(10),那肯定返回空呀。可惜,MySQL 并没有这么做。那要不,就是把’1234567890abcd’拿到索引里面去做匹配,肯定也没能够快速判断出索引树 b 上并没有这个值,也很快就能返回空结果。

相对以上处理方式来说,MySQL 对于这条 SQL 语句的执行很慢,流程是这样的:

  1. 在传给引擎执行的时候,做了字符截断。因为引擎里面这个行只定义了长度是 10,所以只截了前 10 个字节,就是’1234567890’进去做匹配;
  2. 这样满足条件的数据有 10 万行;因为是 select *, 所以要做 10 万次回表;
  3. 但是每次回表以后查出整行,到 server 层一判断,b 的值都不是’1234567890abcd’;
  4. 返回结果是空。

以上流程不只说明了字符串超长的处理方式,还说明了在做函数处理的时候,有可能从存储引擎查回数据到 Server 层之后还要对所有数据做一次判断,因为穿给存储引擎的条件是做了特殊处理的,返回来的不一定就是最终要返回给用户的正确的数据。

MySQL 在连表的时候的优化

上面案例三提到了连表的场景,在 MySQL 进行连表操作的时候,驱动表的选择至关重要。而 MySQL 在决定驱动表的时候是做了优化的,该逻辑不会受到连表 on 子句中连表字段顺序、where 子句中连表字段顺序、from 子句中参与连表的表名书写顺序所影响。

例1

类似案例三中的 SQL :

select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;

可以看到 tradelogtrade_detail 发生了连表,连表字段写在了 where 子句中,而 trade_detail 的字段放在了前面,但是从执行计划可以看出,驱动表是 tradelog

  • 这是因为 where 子句中存在另外一个条件 l.id=2,这个条件是对表 tradelog 的筛选条件,它是 tradelog 的主键索引,如果 MySQL 选择了 tradelog 作为驱动表,就可以先利用这个条件走 tradelog 的主键索引,此时就可以经过较短的时间(O(log(M)))即可得到 tradelog 中对应的一行数据,然后取出该行数据的 tradeid 字段值,然后再到表 trade_detail 上走一次索引树搜索即可(O(log(N)))。
  • 但是如果选择了 trade_detail 作为驱动表,那么由于 where 子句中没有 trade_detail 的筛选条件,此时就需要走 trade_id 的索引全表扫描,然后对于得到的每一行数据的 trade_id 值,到 tradelogtrade_id 索引树中进行搜索,然后再根据得到的 id 值进行筛选,此时相较于使用 tradelog 作为驱动表的 “O(log(N)) + O(log(M))”,当前选择需要 “O(N) + N * O(log(M))”。

综上,前者的时间复杂度相对是低很多的,所以 MySQL 会选择前者。

例2

上面的例子中是因为 where 子句中存在某个参与连表的表的某个索引字段的筛选条件,MySQL 优化器决定使用它作为驱动表,那么如果是 where 子句中只有连表条件,没有其它筛选条件呢?如下:

select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid

因为两个表在 where 子句中没有筛选条件,只有连接条件,所以驱动表肯定会扫描全表,所以时间复杂度是O(N);然后连接条件可以相当于被驱动表的筛选条件了,此时时间复杂度为 N * log(M)。所以总的时间复杂度为 O(N) + N * log(M),此时 N 为驱动表的行数,M 为被驱动表的行数,显然当 N > M 的时候,时间复杂度相对是大于当 M > N 的时候的(可以画函数曲线看看),也就是说大表作为驱动表的时候效率相对较低。所以 MySQL 在针对这种情况的时候,会使用小表驱动大表。

十八、只查一行的语句也很慢的场景

一般情况下,如果说查询性能优化,首先会想到一些复杂的语句,想到查询需要返回大量的数据。但有些情况下,“查一行”,也会执行得特别慢。需要说明的是,如果 MySQL 数据库本身就有很大的压力,导致数据库服务器 CPU 占用率很高或 ioutil(IO 利用率)很高,这种情况下所有语句的执行都有可能变慢,不属于讨论范围。

为了便于描述,构造一个表,基于这个表来说明今天的问题。这个表有两个字段 id 和 c,并且在里面插入了 10 万行记录。

mysql> CREATE TABLE `t` (
  `id` int(11) NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

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

call idata();

第一类:查询长时间不返回

在表 t 执行下面的 SQL 语句:

mysql> select * from t where id=1;

查询结果长时间不返回。

查询长时间不返回

一般碰到这种情况的话,大概率是表 t 被锁住了。接下来分析原因的时候,一般都是首先执行一下 show processlist 命令,看看当前语句处于什么状态。然后再针对每种状态,去分析它们产生的原因、如何复现,以及如何处理。

等 MDL 锁

如图所示,就是使用 show processlist 命令查看 Waiting for table metadata lock 的示意图。

Waiting for table metadata lock 状态示意图

出现这个状态表示的是,现在有一个线程正在表 t 上请求或者持有 MDL 写锁,把 select 语句堵住了

在 MySQL 5.7 版本下复现这个场景,也很容易。如下图所示给出了简单的复现步骤。

MySQL 5.7 中 Waiting for table metadata lock 的复现步骤

session A 通过 lock table 命令持有表 t 的 MDL 写锁,而 session B 的查询需要获取 MDL 读锁。所以,session B 进入等待状态。

解决

这类问题的处理方式,就是找到谁持有 MDL 写锁,然后把它 kill 掉。但是,由于在 show processlist 的结果里面,session A 的 Command 列是“Sleep”,导致查找起来很不方便。不过有了 performance_schemasys 系统库以后,就方便多了。(MySQL 启动时需要设置 performance_schema=on,相比于设置为 off 会有 10% 左右的性能损失)。通过查询 sys.schema_table_lock_waits 这张表,我们就可以直接找出造成阻塞的 process id,把这个连接用 kill 命令断开即可。

执行

select * from performance_schema.setup_instruments where name='wait/lock/metadata/sql/mdl

看一下ENABLEDTIMED是不是都是YES,只有两个都是YES的时候才能执行文章中说的操作。 具体可以参考官方文档: https://dev.mysql.com/doc/refman/5.7/en/sys-schema-table-lock-waits.htmlhttps://dev.mysql.com/doc/refman/5.7/en/metadata-locks-table.html

如果都匹配,直接手动更新:

UPDATE performance_schema.setup_instruments SET ENABLED = 'YES', TIMED = 'YES' where name='wait/lock/metadata/sql/mdl';

等 flush

接下来举另外一种查询被堵住的情况。在表 t 上,执行下面的 SQL 语句:

mysql> select * from information_schema.processlist where id=1;

Waiting for table flush 状态示意图

查出来这个线程的状态是 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 语句。

复现步骤:

Waiting for table flush 的复现步骤

在 session A 中,故意每行都调用一次 sleep(1),这样这个语句默认要执行 10 万秒,在这期间表 t 一直是被 session A“打开”着。然后,session B 的 flush tables t 命令再要去关闭表 t,就需要等 session A 的查询结束。这样,session C 要再次查询的话,就会被 flush 命令堵住了。

下图是这个复现步骤的 show processlist 结果。这个例子的排查也很简单,看到这个 show processlist 的结果,直接找到源头 kill 掉。

等行锁

现在,经过了表级锁的考验, select 语句终于来到引擎里了。

mysql> select * from t where id=1 lock in share mode; 

由于访问 id=1 这个记录时要加读锁,如果这时候已经有一个事务在这行记录上持有一个写锁,我们的 select 语句就会被堵住。复现步骤和现场如下:

行锁复现

行锁 show processlist 现场

显然,session A 启动了事务,占有写锁,还不提交,是导致 session B 被堵住的原因。这个问题并不难分析,但问题是怎么查出是谁占着这个写锁。如果用的是 MySQL 5.7 版本,可以通过 sys.innodb_lock_waits 表查到。

mysql> select * from t sys.innodb_lock_waits where locked_table='`test`.`t`'\G

通过 sys.innodb_lock_waits 查行锁

可以看到,这个信息很全,4 号线程是造成堵塞的罪魁祸首。而干掉这个罪魁祸首的方式,就是 KILL QUERY 4KILL 4。不过,这里不应该显示“KILL QUERY 4”。这个命令表示停止 4 号线程当前正在执行的语句,而这个方法其实是没有用的。因为占有行锁的是 update 语句,这个语句已经是之前执行完成了的,现在执行 KILL QUERY,无法让这个事务去掉 id=1 上的行锁。实际上,KILL 4 才有效,也就是说直接断开这个连接。这里隐含的一个逻辑就是,连接被断开的时候,会自动回滚这个连接里面正在执行的线程,也就释放了 id=1 上的行锁。

第二类:查询慢

经过了重重封“锁”,再来看看一些查询慢的例子。

字段没加索引导致全表扫描

mysql> select * from t where c=50000 limit 1;

由于字段 c 上没有索引,这个语句只能走 id 主键顺序扫描,因此需要实际扫描 5 万行。作为确认,可以看一下慢查询日志。注意,这里为了把所有语句记录到 slow log 里,在连接后先执行了 set long_query_time=0,将慢查询日志的时间阈值设置为 0。

全表扫描 5 万行的 slow log

Rows_examined 显示扫描了 50000 行。你可能会说,不是很慢呀,11.5 毫秒就返回了,我们线上一般都配置超过 1 秒才算慢查询。但你要记住:坏查询不一定是慢查询。这个例子里面只有 10 万行记录,数据量大起来的话,执行时间就线性涨上去了。

版本落后太多只查扫描也很慢

扫描行数多,所以执行慢,这个很好理解。但是接下来,我们再看一个只扫描一行,但是执行很慢的语句。

mysql> select * from t where id=1;

slow log:

扫描一行却执行得很慢

虽然扫描行数是 1,但执行时间却长达 800 毫秒。

如果把这个 slow log 的截图再往下拉一点,你可以看到下一个语句,select * from t where id=1 lock in share mode,执行时扫描行数也是 1 行,执行时间是 0.2 毫秒。

加上 lock in share mode 的 slow log

两个语句的输出结果:

两个语句的输出结果

第一个语句的查询结果里 c=1,带 lock in share mode 的语句返回的是 c=1000001 。

下面是复现步骤:

复现步骤

session B 更新完 100 万次,生成了 100 万个回滚日志 (undo log)。

id=1 的数据状态

lock in share mode 的 SQL 语句,是当前读,因此会直接读到 1000001 这个结果,所以速度很快;而 select * from t where id=1 这个语句,是一致性读,因此需要从 1000001 开始,依次执行 undo log,执行了 100 万次以后,才将 1 这个结果返回。注意,undo log 里记录的其实是“把 2 改成 1”,“把 3 改成 2”这样的操作逻辑,画成减 1 的目的是方便看图。

十九、饮鸩止渴提高性能地方法

短连接风暴

正常的短连接模式就是连接到数据库后,执行很少的 SQL 语句就断开,下次需要的时候再重连。如果使用的是短连接,在业务高峰期的时候,就可能出现连接数突然暴涨的情况。MySQL 建立连接的过程,成本是很高的。除了正常的网络连接三次握手外,还需要做登录权限判断和获得这个连接的数据读写权限。在数据库压力比较小的时候,这些额外的成本并不明显。

但是,短连接模型存在一个风险,就是一旦数据库处理得慢一些,连接数就会暴涨。max_connections 参数,用来控制一个 MySQL 实例同时存在的连接数的上限,超过这个值,系统就会拒绝接下来的连接请求,并报错提示“Too many connections”。对于被拒绝连接的请求来说,从业务角度看就是数据库不可用。在机器负载比较高的时候,处理现有请求的时间变长,每个连接保持的时间也更长。这时,再有新建连接的话,就可能会超过 max_connections 的限制。

碰到这种情况时,一个比较自然的想法,就是调高 max_connections 的值。但这样做是有风险的。因为设计 max_connections 这个参数的目的是想保护 MySQL,如果我们把它改得太大,让更多的连接都可以进来,那么系统的负载可能会进一步加大,大量的资源耗费在权限验证等逻辑上,结果可能是适得其反,已经连接的线程拿不到 CPU 资源去执行业务的 SQL 请求。那么这种情况下,还有没有别的建议呢?这里还有两种方法,但要注意,这些方法都是有损的。

第一种方法:杀连接

先处理掉那些占着连接但是不工作的线程。max_connections 的计算,不是看谁在 running,是只要连着就占用一个计数位置。对于那些不需要保持的连接,我们可以通过 kill connection 主动踢掉。这个行为跟事先设置 wait_timeout 的效果是一样的。设置 wait_timeout 参数表示的是,一个线程空闲 wait_timeout 这么多秒之后,就会被 MySQL 直接断开连接。但是需要注意,在 show processlist 的结果里,踢掉显示为 sleep 的线程,可能是有损的。我们来看下面这个例子。

sleep 线程的两种状态

在上面这个例子里,如果断开 session A 的连接,因为这时候 session A 还没有提交,所以 MySQL 只能按照回滚事务来处理;而断开 session B 的连接,就没什么大影响。所以,如果按照优先级来说,你应该优先断开像 session B 这样的事务外空闲的连接。

但是,怎么判断哪些是事务外空闲的呢?session C 在 T 时刻之后的 30 秒执行 show processlist,看到的结果是这样的。

sleep 线程的两种状态,show processlist 结果

图中 id=4 和 id=5 的两个会话都是 Sleep 状态。而要看事务具体状态的话,可以查 information_schema 库的 innodb_trx 表。

从 information_schema.innodb_trx 查询事务状态

这个结果里,trx_mysql_thread_id=4,表示 id=4 的线程还处在事务中。因此,如果是连接数过多,你可以优先断开事务外空闲太久的连接;如果这样还不够,再考虑断开事务内空闲太久的连接。从服务端断开连接使用的是 kill connection + id 的命令, 一个客户端处于 sleep 状态时,它的连接被服务端主动断开后,这个客户端并不会马上知道。直到客户端在发起下一个请求的时候,才会收到这样的报错“ERROR 2013 (HY000): Lost connection to MySQL server during query”。

从数据库端主动断开连接可能是有损的,尤其是有的应用端收到这个错误后,不重新连接,而是直接用这个已经不能用的句柄重试查询。这会导致从应用端看上去,“MySQL 一直没恢复”。所以,在做业务开发的时候,遇到这种情况就要重新建立连接了。

第二种方法:减少连接过程的消耗

有的业务代码会在短时间内先大量申请数据库连接做备用,如果现在数据库确认是被连接行为打挂了,那么一种可能的做法,是让数据库跳过权限验证阶段。跳过权限验证的方法是:重启数据库,并使用 –skip-grant-tables 参数启动。这样,整个 MySQL 会跳过所有的权限验证阶段,包括连接过程和语句执行过程在内。但是,这种方法特别符合标题里说的“饮鸩止渴”,风险极高,是特别不建议使用的方案。尤其你的库外网可访问的话,就更不能这么做了。在 MySQL 8.0 版本里,如果启用 –skip-grant-tables 参数,MySQL 会默认把 --skip-networking 参数打开,表示这时候数据库只能被本地的客户端连接。可见,MySQL 官方对 skip-grant-tables 这个参数的安全问题也很重视。

慢查询性能问题

除了短连接数暴增可能会带来性能问题外,实际上,我们在线上碰到更多的是查询或者更新语句导致的性能问题。其中,查询问题比较典型的有两类,一类是由新出现的慢查询导致的,一类是由 QPS(每秒查询数)突增导致的。

在 MySQL 中,会引发性能问题的慢查询,大体有以下三种可能:

  1. 索引没有设计好;
  2. SQL 语句没写好;
  3. MySQL 选错了索引。

索引没有设计好

这种场景一般就是通过紧急创建索引来解决。MySQL 5.6 版本以后,创建索引都支持 Online DDL 了,对于那种高峰期数据库已经被这个语句打挂了的情况,最高效的做法就是直接执行 alter table 语句。比较理想的是能够在备库先执行。假设现在的服务是一主一备,主库 A、备库 B,这个方案的大致流程是这样的:

  1. 在备库 B 上执行 set sql_log_bin=off,也就是不写 binlog,然后执行 alter table 语句加上索引;
  2. 执行主备切换;
  3. 这时候主库是 B,备库是 A。在 A 上执行 set sql_log_bin=off,然后执行 alter table 语句加上索引。

这是一个“古老”的 DDL 方案。平时在做变更的时候,你应该考虑类似 gh-ost 这样的方案,更加稳妥。但是在需要紧急处理时,上面这个方案的效率是最高的。

SQL 语句没写好

这时,我们可以通过改写 SQL 语句来处理。MySQL 5.7 提供了 query_rewrite 功能,可以把输入的一种语句改写成另外一种模式。比如,语句被错误地写成了 select * from t where id + 1 = 10000,你可以通过下面的方式,增加一个语句改写规则。

mysql> insert into query_rewrite.rewrite_rules(pattern, replacement, pattern_database) values ("select * from t where id + 1 = ?", "select * from t where id = ? - 1", "db1");
call query_rewrite.flush_rewrite_rules();

这里,call query_rewrite.flush_rewrite_rules() 这个存储过程,是让插入的新规则生效,也就是我们说的“查询重写”。可以用图 4 中的方法来确认改写规则是否生效。

查询重写效果

MySQL 选错了索引

这时候,应急方案就是给这个语句加上 force index。同样地,使用查询重写功能,给原来的语句加上 force index,也可以解决这个问题。上面讨论的由慢查询导致性能问题的三种可能情况,实际上出现最多的是前两种,即:索引没设计好和语句没写好。而这两种情况,恰恰是完全可以避免的。比如,通过下面这个过程,我们就可以预先发现问题。

  1. 上线前,在测试环境,把慢查询日志(slow log)打开,并且把 long_query_time 设置成 0,确保每个语句都会被记录入慢查询日志;
  2. 在测试表里插入模拟线上的数据,做一遍回归测试;
  3. 观察慢查询日志里每类语句的输出,特别留意 Rows_examined 字段是否与预期一致。

不要吝啬这段花在上线前的“额外”时间,因为这会帮你省下很多故障复盘的时间。如果新增的 SQL 语句不多,手动跑一下就可以。而如果是新项目的话,或者是修改了原有项目的 表结构设计,全量回归测试都是必要的。这时候,你需要工具帮你检查所有的 SQL 语句的返回结果。比如,你可以使用开源工具 pt-query-digest

QPS 突增问题

有时候由于业务突然出现高峰,或者应用程序 bug,导致某个语句的 QPS 突然暴涨,也可能导致 MySQL 压力过大,影响服务。当然,最理想的情况是让业务把这个功能下掉,服务自然就会恢复。而下掉一个功能,如果从数据库端处理的话,对应于不同的背景,有不同的方法可用。这里展开说明一下。

  1. 一种是由全新业务的 bug 导致的。假设你的 DB 运维是比较规范的,也就是说白名单是一个个加的。这种情况下,如果你能够确定业务方会下掉这个功能,只是时间上没那么快,那么就可以从数据库端直接把白名单去掉。
  2. 如果这个新功能使用的是单独的数据库用户,可以用管理员账号把这个用户删掉,然后断开现有连接。这样,这个新功能的连接不成功,由它引发的 QPS 就会变成 0。
  3. 如果这个新增的功能跟主体功能是部署在一起的,那么我们只能通过处理语句来限制。这时,我们可以使用上面提到的查询重写功能,把压力最大的 SQL 语句直接重写成"select 1"返回。

当然,这个操作的风险很高,需要你特别细致。它可能存在两个副作用:

  1. 如果别的功能里面也用到了这个 SQL 语句模板,会有误伤;
  2. 很多业务并不是靠这一个语句就能完成逻辑的,所以如果单独把这一个语句以 select 1 的结果返回的话,可能会导致后面的业务逻辑一起失败。

所以,方案 3 是用于止血的,跟前面提到的去掉权限验证一样,应该是你所有选项里优先级最低的一个方案。同时你会发现,其实方案 1 和 2 都要依赖于规范的运维体系:虚拟化、白名单机制、业务账号分离。由此可见,更多的准备,往往意味着更稳定的系统。

二十、关于 kill

在 MySQL 中有两个 kill 命令:一个是 kill query + 线程 id,表示终止这个线程中正在执行的语句;一个是 kill connection + 线程 id,这里 connection 可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句的。

在使用 MySQL 的时候,可能会遇到这样的现象:使用了 kill 命令,却没能断开这个连接。再执行 show processlist 命令,看到这条语句的 Command 列显示的是 Killed。显示为 Killed 是什么意思,不是应该直接在 show processlist 的结果里看不到这个线程了吗?

其实大多数情况下,kill query/connection 命令是有效的。比如,执行一个查询的过程中,发现执行时间太久,要放弃继续查询,这时我们就可以用 kill query 命令,终止这条查询语句。还有一种情况是,语句处于锁等待的时候,直接使用 kill 命令也是有效的。我们一起来看下这个例子:

kill query 成功的例子

可以看到,session C 执行 kill query 以后,session B 几乎同时就提示了语句被中断。这,就是我们预期的结果。

收到 kill 以后,线程做什么?

上面的 session B 是直接终止掉线程,什么都不管就直接退出吗?显然,这是不行的。例如当对一个表做增删改查操作时,会在表上加 MDL 读锁。所以,session B 虽然处于 blocked 状态,但还是拿着一个 MDL 读锁的。如果线程被 kill 的时候,就直接终止,那之后这个 MDL 读锁就没机会被释放了。这样看来,kill 并不是马上停止的意思,而是告诉执行线程说,这条语句已经不需要继续执行了,可以开始“执行停止的逻辑了”。

其实,这跟 Linux 的 kill 命令类似,kill -N pid 并不是让进程直接停止,而是给进程发一个信号,然后进程处理这个信号,进入终止逻辑。只是对于 MySQL 的 kill 命令来说,不需要传信号量参数,就只有“停止”这个命令。

实现上,当用户执行 kill query thread_id_B 时,MySQL 里处理 kill 命令的线程做了两件事:

  1. 把 session B 的运行状态改成 THD::KILL_QUERY(将变量 killed 赋值为 THD::KILL_QUERY);
  2. 给 session B 的执行线程发一个信号。

为什么要发信号呢?因为像上图中的例子里面,session B 处于锁等待状态,如果只是把 session B 的线程状态设置 THD::KILL_QUERY,线程 B 并不知道这个状态变化,还是会继续等待。发一个信号的目的,就是让 session B 退出等待(操作系统的信号处理,线程注册自己感兴趣的信号量然后挂起,如果操作系统收到这些信号,就会激活该挂起的现成),来处理这个 THD::KILL_QUERY 状态。

上面的分析中,隐含了这么三层意思:

  1. 一个语句执行过程中有多处“埋点”,在这些“埋点”的地方判断线程状态,如果发现线程状态是 THD::KILL_QUERY,才开始进入语句终止逻辑;
  2. 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处;
  3. 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的。

所以 kill 掉 MySQL 现成不是说停就停的。

kill 不掉的例子

1>被 kill 的线程无法执行到"埋点"

首先,执行 set global innodb_thread_concurrency=2,将 InnoDB 的并发线程上限数设置为 2;然后,执行下面的序列:

kill query 无效的例子

可以看到:

  1. sesssion C 执行的时候被堵住了;
  2. 但是 session D 执行的 kill query C 命令却没什么效果,
  3. 直到 session E 执行了 kill connection 命令,才断开了 session C 的连接,提示“Lost connection to MySQL server during query”,
  4. 但是这时候,如果在 session E 中执行 show processlist,你就能看到下面这个图。

kill connection 之后的效果

这时候,id=12 这个线程的 Commnad 列显示的是 Killed。也就是说,客户端虽然断开了连接,但实际上服务端上这条语句还在执行过程中。为什么在执行 kill query 命令时,这条语句不像第一个例子的 update 语句一样退出呢?

  1. 在实现上,等行锁时,使用的是 pthread_cond_timedwait 函数,这个等待状态可以被唤醒。但是,在这个例子里,12 号线程的等待逻辑是这样的:每 10 毫秒判断一下是否可以进入 InnoDB 执行,如果不行,就调用 nanosleep 函数进入 sleep 状态。也就是说,虽然 12 号线程的状态已经被设置成了 KILL_QUERY,但是在这个等待进入 InnoDB 的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。
  2. 而当 session E 执行 kill connection 命令时,是这么做的:
    • 把 12 号线程状态设置为 KILL_CONNECTION;
    • 关掉 12 号线程的网络连接。因为有这个操作,所以你会看到,这时候 session C 收到了断开连接的提示(服务端断开网络连接),但是线程还是继续执行的,只不过该线程的网络连接的相关资源都被回收了。

那为什么执行 show processlist 的时候,会看到 Command 列显示为 killed 呢?其实,这就是因为在执行 show processlist 的时候,有一个特别的逻辑:

如果一个线程的状态是KILL_CONNECTION,就把Command列显示成Killed。

所以其实,即使是客户端退出(发出该 SQL 的客户端连接已经被断开)了,这个线程的状态仍然是在等待中。那这个线程什么时候会退出呢?答案是,只有等到满足进入 InnoDB 的条件后,session C 的查询语句继续执行,然后才有可能判断到线程状态已经变成了 KILL_QUERY 或者 KILL_CONNECTION,再进入终止逻辑阶段。

无法 kill 的原因总结

这个例子是 kill 无效的第一类情况,即:线程没有执行到判断线程状态的逻辑。跟这种情况相同的,还有由于 IO 压力过大,读写 IO 的函数一直无法返回,导致不能及时判断线程的状态

2>终止逻辑耗时较长

另一类情况是,终止逻辑耗时较长。这时候,从 show processlist 结果上看也是 Command=Killed,需要等到终止逻辑完成,语句才算真正完成。这类情况,比较常见的场景有以下几种:

  1. 超大事务执行期间被 kill。这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长。
  2. 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待 IO 资源,导致耗时较长。
  3. DDL 命令执行到最后阶段,如果被 kill,需要删除中间过程的临时文件,也可能受 IO 资源影响耗时较久。

Ctrl+C 误解

如果直接在客户端通过 Ctrl+C 命令,是不是就可以直接终止线程呢?答案是,不可以。这里有一个误解,其实在客户端的操作只能操作到客户端的线程,客户端和服务端只能通过网络交互,是不可能直接操作服务端线程的。而由于 MySQL 是停等协议,所以这个线程执行的语句还没有返回的时候,再往这个连接里面继续发命令也是没有用的。实际上,执行 Ctrl+C 的时候,是 MySQL 客户端另外启动一个连接,然后发送一个 kill query 命令。所以,你可别以为在客户端执行完 Ctrl+C 就万事大吉了。因为,要 kill 掉一个线程,还涉及到后端的很多操作。

另外两个关于客户端的误解

在实际使用中,我也经常会碰到一些同学对客户端的使用有误解。接下来,我们就来看看两个最常见的误解。

表很多连接慢

第一个误解是:如果库里面的表特别多,连接就会很慢。有些线上的库,会包含很多表(我见过最多的一个库里有 6 万个表)。这时候,你就会发现,每次用客户端连接都会卡在下面这个界面上。

连接等待

而如果 db1 这个库里表很少的话,连接起来就会很快,可以很快进入输入命令的状态。因此,有同学会认为是表的数目影响了连接性能。

其实每个客户端在和服务端建立连接的时候,需要做的事情就是 TCP 握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关。但实际上,正如图中的文字提示所说的,当使用默认参数连接的时候,MySQL 客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:

  1. 执行 show databases
  2. 切到 db1 库,执行 show tables
  3. 把这两个命令的结果用于构建一个本地的哈希表。

在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢

图中的提示也说了,如果在连接命令中加上 -A,就可以关掉这个自动补全的功能,然后客户端就可以快速返回了。这里自动补全的效果就是,你在输入库名或者表名的时候,输入前缀,可以使用 Tab 键自动补全表名或者显示提示。实际使用中,如果你自动补全功能用得并不多,建议你每次使用的时候都默认加 -A

客户端连接 –quick 参数

其实上图提示里面没有说,除了加 -A 以外,加–quick(或者简写为 -q) 参数,也可以跳过这个阶段。但是,这个–quick 是一个更容易引起误会的参数,也是关于客户端常见的一个误解。

看到这个参数,是不是觉得这应该是一个让服务端加速的参数?但实际上恰恰相反,设置了这个参数可能会降低服务端的性能。为什么这么说呢?MySQL 客户端发送请求后,接收服务端返回结果的方式有两种:

  1. 一种是本地缓存,也就是在本地开一片内存,先把结果存起来。如果你用 API 开发,对应的就是 mysql_store_result 方法。
  2. 另一种是不缓存,读一个处理一个。如果你用 API 开发,对应的就是 mysql_use_result 方法。

MySQL 客户端默认采用第一种方式,而如果加上–quick 参数,就会使用第二种不缓存的方式。采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。既然这样,为什么要给这个参数取名叫作 quick 呢?这是因为使用这个参数可以达到以下三点效果:

  • 第一点,就是前面提到的,跳过表名自动补全功能。
  • 第二点,mysql_store_result 需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能;
  • 第三点,是不会把执行命令记录到本地的命令历史文件。

所以 –quick 参数的意思,是让客户端变得更快。

如何处理 kill 不掉的问题

这些“kill 不掉”的情况,其实是因为发送 kill 命令的客户端,并没有强行停止目标线程的执行,而只是设置了个状态,并唤醒对应的线程。而被 kill 的线程,需要执行到判断状态的“埋点”,才会开始进入终止逻辑阶段。并且,终止逻辑本身也是需要耗费时间的。所以,如果发现一个线程处于 Killed 状态,可以做的事情就是,通过影响系统环境,让这个 Killed 状态尽快结束。比如,如果是第一个例子里 InnoDB 并发度的问题,你就可以临时调大 innodb_thread_concurrency 的值,或者停掉别的线程,让出位子给这个线程执行。而如果是回滚逻辑由于受到 IO 资源限制执行得比较慢,就通过减少系统压力让它加速。做完这些操作后,其实你已经没有办法再对它做什么了,只能等待流程自己完成。

如果碰到一个被 killed 的事务一直处于回滚状态,是应该直接把 MySQL 进程强行重启,还是应该让它自己执行完成呢?为什么呢?

因为重启之后该做的回滚动作还是不能少的,所以从恢复速度的角度来说,应该让它自己结束。当然,如果这个语句可能会占用别的锁,或者由于占用 IO 资源过多,从而影响到了别的语句执行的话,就需要先做主备切换,切到新主库提供服务。切换之后别的线程都断开了连接,自动停止执行。接下来还是等它自己执行完成。这个操作属于前面说到的,减少系统压力,加速终止逻辑。

二十一、一次性查询很多数据会不会把数据库内存打爆

主机内存只有 100G,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光了?这个问题确实值得担心,被系统 OOM(out of memory)可不是闹着玩的。但是,反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了?所以说,对大表做全表扫描,看来应该是没问题的。但是,这个流程到底是怎么样的呢?

全表扫描对 server 层的影响

假设,我们现在要对一个 200G 的 InnoDB 表 db1. t,执行一个全表扫描。当然,你要把扫描结果保存在客户端,会使用类似这样的命令:

mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_file

InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。

那么,这个“结果集”存在哪里呢?实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:

  1. 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的(针对每个客户端连接都会有两个缓冲区,一个是连接缓冲一个是结果集缓冲,net_buffer_length 指定这个缓冲区的初始值,max_allowed_packet 是这个缓冲区可以达到的最大值,缓冲区随着需要扩容。结果集缓冲在每次 statement 执行之后会恢复为初始值即 net_buffer_length。net_buffer_length 默认是 16k,最小值 1K,最大值1M)。
  2. 重复获取行,直到 net_buffer 写满,调用网络接口发出去(socket send buffer)。
  3. 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
  4. 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。

这个过程对应的流程图如下所示。

查询结果发送流程

从这个流程中,你可以看到:

  1. 一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,并不会达到 200G;
  2. socket send buffer 也不可能达到 200G(默认定义 /proc/sys/net/core/wmem_default),

如果 socket send buffer 被写满,就会暂停读数据的流程。也就是说,MySQL 是“边读边发的”,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。比如下面这个状态,就是故意让客户端不去读 socket receive buffer 中的内容,然后在服务端 show processlist 看到的结果。

服务端发送阻塞

如果看到 State 的值一直处于“Sending to client”,就表示服务器端的网络栈写满了。如果客户端使用–quick 参数,会使用 mysql_use_result 方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现如上图所示的这种情况。

  • 因此,对于业务开发来说,正常的线上业务,如果一个查询的返回结果不会很多的话,都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存。当然前提是查询返回结果不多,否则可能会因为执行了一个大查询导致客户端占用内存上几十G的情况都有,这种情况下就需要改用 mysql_use_result 接口了。

    MySQL JDBC中的fetchSize()方法,不做分页通过一次大查询然后客户端流式读取来批量查询数据,这个内部原理就是使用了mysql_use_result接口读一行处理一行的接口。

    • 一次性取的好处是,对服务端只全表,只扫描一遍;坏处是可能会出现大事务。
    • 一般更常见的做法还是调用 mysql_store_result 接口分批取,每一批拿到最大的一个id(主键值),下一批查询的时候用 where Id > N 这种写法。
  • 另一方面,对于 DBA 来说,如果在自己负责维护的 MySQL 里看到很多个线程都处于“Sending to client”这个状态,就意味着要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。而如果要快速减少处于这个状态的线程的话,将 net_buffer_length 参数设置为一个更大的值是一个可选方案(对于执行器来说,写进 net_buffer 的数据都算是执行完成了,不算到 sending data,即 sending data 指的是从存储引擎加载数据发送到 net buffer)。

Sending to data 状态

与“Sending to client”长相很类似的一个状态是“Sending data”,这是一个经常被误会的问题。有同学问我说,在自己维护的实例上看到很多查询语句的状态是“Sending data”,但查看网络也没什么问题啊,为什么 Sending data 要这么久?实际上,一个查询语句的状态变化是这样的(注意:这里略去了其他无关的状态):

  1. MySQL 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
  2. 然后,发送执行结果的列相关的信息(meta data) 给客户端;
  3. 再继续执行语句的流程;
  4. 执行完成后,把状态设置成空字符串。

也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段。比如,可以构造一个锁等待的场景,就能看到 Sending data 状态。

读全表被锁

Sending data 状态

可以看到,session B 明显是在等锁,状态显示为 Sending data。也就是说,仅当一个线程处于“等待客户端接收结果”的状态,才会显示"Sending to client";而如果显示成“Sending data”,它的意思只是“正在执行”。

综上,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。在 server 层的处理逻辑我们都清楚了,在 InnoDB 引擎里面又是怎么处理的呢? 扫描全表会不会对引擎系统造成影响呢?

全表扫描对 InnoDB 的影响

基于 WAL 机制, InnoDB 内存的一个作用,是保存更新的结果,再配合 redo log,就避免了随机写盘。内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询。

由于有 WAL 机制,当事务提交的时候,磁盘上的数据页是旧的,那如果这时候马上有一个查询要来读这个数据页,是不是要马上把 redo log 应用到数据页呢?答案是不需要。因为这时候内存数据页的结果是最新的,直接读内存页就可以了。你看,这时候查询根本不需要读磁盘,直接从内存拿结果,速度是很快的。所以说,Buffer Pool 还有加速查询的作用。而 Buffer Pool 对查询的加速效果,依赖于一个重要的指标,即:内存命中率

可以在 show engine innodb status 结果中,查看一个系统当前的 BP 命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。

show engine innodb status 显示内存命中率

如果所有查询需要的数据页都能够直接从内存得到,那是最好的,对应的命中率就是 100%。但,这在实际生产上是很难做到的。

InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成可用物理内存的 60%~80%。

InnoDB 内存池管理算法

在大约十年前,单机的数据量是上百个 G,而物理内存是几个 G;现在虽然很多服务器都能有 128G 甚至更高的内存,但是单机的数据量却达到了 T 级别。所以,innodb_buffer_pool_size 小于磁盘的数据量是很常见的。如果一个 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。

普通的 LRU 算法

基本 LRU 算法

InnoDB 管理 Buffer Pool 的 LRU 算法,是用链表来实现的。

  1. 在上图的状态 1 里,链表头部是 P1,表示 P1 是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
  2. 这时候有一个读请求访问 P3,因此变成状态 2,P3 被移到最前面;
  3. 状态 3 表示,这次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 Px,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 Pm 这个数据页的内存,存入 Px 的内容,然后放到链表头部。
  4. 从效果上看,就是最久没有被访问的数据页 Pm,被淘汰了。

这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,会不会有问题呢?假设按照这个算法,我们要扫描一个 200G 的表,而这个表是一个历史数据表,平时没有业务访问它。那么,按照这个算法扫描的话,就会把当前的 Buffer Pool 里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说 Buffer Pool 里面主要放的是这个历史数据表的数据。对于一个正在做业务服务的库,这可不妙。你会看到,Buffer Pool 的内存命中率急剧下降,磁盘压力增加,SQL 语句响应变慢。

InnoDB 的 LRU 算法

所以,InnoDB 不能直接使用这个 LRU 算法。实际上,InnoDB 对 LRU 算法做了改进。

改进的 LRU 算法

在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。图中 LRU_old 指向的就是 old 区域的第一个位置,是整个链表的 5/8 处。也就是说,靠近链表头部的 5/8 是 young 区域,靠近链表尾部的 3/8 是 old 区域。改进后的 LRU 算法执行流程变成了下面这样:

  1. 上图中状态 1,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2。

  2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页 Pm,但是新插入的数据页 Px,是放在 LRU_old 处。

  3. 处于 old 区域的数据页,每次被访问的时候都要做下面这个判断:

    • 若这个数据页在 LRU 链表中存在的时间超过了 1 秒(最后一次被访问时间-第一次被访问时间),就把它移动到链表头部(此时 young 区最后的数据页就会被挤到 old 了);
    • 如果这个数据页在 LRU 链表中存在的时间短于 1 秒(最后一次被访问时间-第一次被访问时间),位置保持不变。1 秒这个时间,是由参数 innodb_old_blocks_time 控制的。其默认值是 1000,单位毫秒。

    即每个数据页至少要在 old 段停留一秒,一秒之后还没被淘汰且还被访问到就会进入 young

  • 和 JVM 的 young、old 不一样,JVM 的设计思想是大多对象朝生夕灭,所以它的 young/old 指的就是字面意思,一个对象存活长短,无论一个对象被访问地频繁与否,只要它的指针一直 GC Root 可达,就会越来越old,而越 old 的对象越难被淘汰;

  • 这里 InnoDB 的 young/old 指的是对象的活跃度、新鲜度,一个对象如果越久没有被访问就越 old,而越 old 就越容易被淘汰,刚好相反。

这个策略,就是为了处理类似全表扫描的操作量身定制的。还是以刚刚的扫描 200G 的历史数据表为例,我们看看改进后的 LRU 算法的操作逻辑:

  1. 扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
  2. 一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;
  3. 再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。

可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了 Buffer Pool,但是对 young 区域完全没有影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。

总结

由于 MySQL 采用的是边算边发的逻辑,因此对于数据量很大的查询结果来说,不会在 server 端保存完整的结果集。所以,如果客户端读结果不及时,会堵住 MySQL 的查询过程,但是不会把内存打爆。而对于 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于 InnoDB 对 LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。当然,全表扫描还是比较耗费 IO 资源的,所以业务高峰期还是不能直接在线上主库执行全表扫描的。

如果客户端由于压力过大,迟迟不能接收数据,对服务端的内存占用影响或许不大,但是更严重的是造成了“长事务”。至于长事务的影响,就要结合锁、MVCC 分析了。如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住;当然读的事务也有问题,就是会导致 undo log 不能被回收,导致回滚段空间膨胀。

另外一般我们说“MySQL挂掉”,其实大多数情况下就是响应慢了:

  • 如果说重启的话, 有一种是InnoDB 读 io迟迟不返回,会自己重启;
  • 还有是 innodb_buffer_pool_size 设置太大,再加上server层使用的内存,是可能导致内存超过系统上限被 OOM。我们说一个大查询不会打爆,但是如果很多并发查询,还是可能的

二十二、join

在实际生产中,关于 join 语句使用的问题,一般会集中在以下两类:

  1. 我们 DBA 不让使用 join,使用 join 有什么问题呢?
  2. 如果有两个大小不同的表做 join,应该用哪个表做驱动表呢?

先导:例子

为了便于量化分析,创建两个表 t1 和 t2 来说明。

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

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

create table t1 like t2;
insert into t1 (select * from t2 where id<=100)

可以看到,这两个表都有一个主键索引 id 和一个索引 a,字段 b 上无索引。存储过程 idata() 往表 t2 里插入了 1000 行数据,在表 t1 里插入的是 100 行数据。

Index Nested-Loop Join

如果直接使用 join 语句,MySQL 优化器可能会选择表 t1 或 t2 作为驱动表,这样会影响我们分析 SQL 语句的执行过程。所以,为了便于分析执行过程中的性能问题,改用 straight_join 让 MySQL 使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去 join。在这个语句里,t1 是驱动表,t2 是被驱动表。

select * from t1 straight_join t2 on (t1.a=t2.a);

使用索引字段 join 的 explain 结果

可以看到,在这条语句里,被驱动表 t2 的字段 a 上有索引,join 过程用上了这个索引,因此这个语句的执行流程是这样的:

  1. 从表 t1 中读入一行数据 R;
  2. 从数据行 R 中,取出 a 字段到表 t2 里去查找;
  3. 取出表 t2 中满足条件的行,跟 R 组成一行,作为结果集的一部分;
  4. 重复执行步骤 1 到 3,直到表 t1 的末尾循环结束。

这个过程是先遍历表 t1,然后根据从表 t1 中取出的每行数据中的 a 值,去表 t2 中查找满足条件的记录。在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称 NLJ。

它对应的流程图如下所示:

Index Nested-Loop Join 算法的执行流程

在这个流程里:

  1. 对驱动表 t1 做了全表扫描,这个过程需要扫描 100 行;
  2. 而对于每一行 R,根据 a 字段去表 t2 查找,走的是树搜索过程。由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也是总共扫描 100 行;
  3. 所以,整个执行流程,总扫描行数是 200。

能不能使用 join?

假设不使用 join,那我们就只能用单表查询。我们看看上面这条语句的需求,用单表查询怎么实现。

  1. 执行 select * from t1,查出表 t1 的所有数据,这里有 100 行;
  2. 循环遍历这 100 行数据:
    • 从每一行 R 取出字段 a 的值 $R.a
    • 执行 select * from t2 where a=$R.a;
    • 把返回的结果和 R 构成结果集的一行。

可以看到,在这个查询过程,也是扫描了 200 行,但是总共执行了 101 条语句,比直接 join 多了 100 次交互。除此之外,客户端还要自己拼接 SQL 语句和结果。显然,这么做还不如直接 join 好。

怎么选择驱动表?

在这个 join 语句执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索。假设被驱动表的行数是 M。每次在被驱动表查一行数据,要先搜索索引 a,再搜索主键索引。每次搜索一棵树近似复杂度是以 2 为底的 M 的对数,记为 log2M,所以在被驱动表上查一行的时间复杂度是 2*log2M。假设驱动表的行数是 N,执行过程就要扫描驱动表 N 行,然后对于每一行,到被驱动表上匹配一次。因此整个执行过程,近似复杂度是 N + N*2*log2M

显然,N 对扫描行数的影响更大,因此应该让小表来做驱动表。

如果你没觉得这个影响有那么“显然”, 可以这么理解:N 扩大 1000 倍的话,扫描行数就会扩大 1000 倍;而 M 扩大 1000 倍,扫描行数扩大不到 10 倍。

小结

通过上面的分析我们得到了两个结论:使用 join 语句,性能比强行拆成多个单表执行 SQL 语句的性能要好;如果使用 join 语句的话,需要让小表做驱动表。但是,你需要注意,这个结论的前提是“可以使用被驱动表的索引”。接下来,我们再看看被驱动表用不上索引的情况。

Simple Nested-Loop Join

现在,我们把 SQL 语句改成这样:

select * from t1 straight_join t2 on (t1.a=t2.b);

由于表 t2 的字段 b 上没有索引,因此再用上图的执行流程时,每次到 t2 去匹配的时候,就要做一次全表扫描。你可以先设想一下这个问题,继续使用上图的算法,是不是可以得到正确的结果呢?如果只看结果的话,这个算法是正确的,而且这个算法也有一个名字,叫做“Simple Nested-Loop Join”。

但是,这样算来,这个 SQL 请求就要扫描表 t2 多达 100 次,总共扫描 100*1000=10 万行。这还只是两个小表,如果 t1 和 t2 都是 10 万行的表(当然了,这也还是属于小表的范围),就要扫描 100 亿行,这个算法看上去太“笨重”了。当然,MySQL 也没有使用这个 Simple Nested-Loop Join 算法,而是使用了另一个叫作“Block Nested-Loop Join”的算法,简称 BNL。

Block Nested-Loop Join

这时候,被驱动表上没有可用的索引,算法的流程是这样的:

  1. 把表 t1 的数据读入线程内存 join_buffer 中,由于我们这个语句中写的是 select *,因此是把整个表 t1 放入了内存;
  2. 扫描表 t2,把表 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回。

这个过程的流程图如下:

Block Nested-Loop Join 算法的执行流程

对应地,这条 SQL 语句的 explain 结果如下所示:

不使用索引字段 join 的 explain 结果

可以看到,在这个过程中,对表 t1 和 t2 都做了一次全表扫描,因此总的扫描行数是 1100。由于 join_buffer 是以无序数组的方式组织的,因此对表 t2 中的每一行,都要做 100 次判断,总共需要在内存中做的判断次数是:100*1000=10 万次。

前面我们说过,如果使用 Simple Nested-Loop Join 算法进行查询,扫描行数也是 10 万行。因此,从时间复杂度上来说,这两个算法是一样的。但是,Block Nested-Loop Join 算法的这 10 万次判断是内存操作,速度上会快很多,性能也更好。

如何选择驱动表

接下来,我们来看一下,在这种情况下,应该选择哪个表做驱动表。

join_buffer_size 足够大:完全可以容纳两个表

假设小表的行数是 N,大表的行数是 M,那么在这个算法里:

  1. 两个表都做一次全表扫描,所以总的扫描行数是 M+N;
  2. 内存中的判断次数是 M*N。

可以看到,调换这两个算式中的 M 和 N 没差别,因此这时候选择大表还是小表做驱动表,执行耗时是一样的。

join_buffer_size 不够大:只能容纳一个表或者两个都不行

这个例子里表 t1 才 100 行,要是表 t1 是一个大表,join_buffer 放不下怎么办呢?join_buffer 的大小是由参数 join_buffer_size 设定的,默认值是 256k。如果放不下表 t1 的所有数据话,策略很简单,就是分段放。我把 join_buffer_size 改成 1200,再执行:

select * from t1 straight_join t2 on (t1.a=t2.b);

执行过程就变成了:

  1. 扫描表 t1,顺序读取数据行放入 join_buffer 中,放完第 88 行 join_buffer 满了,继续第 2 步;
  2. 扫描表 t2,把 t2 中的每一行取出来,跟 join_buffer 中的数据做对比,满足 join 条件的,作为结果集的一部分返回;
  3. 清空 join_buffer;
  4. 继续扫描表 t1,顺序读取最后的 12 行数据放入 join_buffer 中,继续执行第 2 步。

执行流程图也就变成这样:

Block Nested-Loop Join--两段

图中的步骤 4 和 5,表示清空 join_buffer 再复用。这个流程才体现出了这个算法名字中“Block”的由来,表示“分块去 join”。可以看到,这时候由于表 t1 被分成了两次放入 join_buffer 中,导致表 t2 会被扫描两次。虽然分成两次放入 join_buffer,但是判断等值条件的次数还是不变的,依然是 (88+12)*1000=10 万次。

我们再来看下,在这种情况下驱动表的选择问题。假设,驱动表的数据行数是 N,需要分 K 段才能完成算法流程,被驱动表的数据行数是 M。注意,这里的 K 不是常数,N 越大 K 就会越大,因此把 K 表示为λ*N,显然 λ 的取值范围是 (0,1)。所以,在这个算法的执行过程中:扫描行数是 N+λ*N*M;内存判断 N*M 次。

显然,内存判断次数是不受选择哪个表作为驱动表影响的。而考虑到扫描行数,在 MN 大小确定的情况下,N 小一些,整个算式的结果会更小。所以结论是,应该让小表当驱动表

当然,你会发现,在 N+λ*N*M 这个式子里,λ 才是影响扫描行数的关键因素,这个值越小越好。刚刚我们说了 N 越大,分段数 K 越大。那么,N 固定的时候,什么参数会影响 K 的大小呢?(也就是 λ 的大小)答案是 join_buffer_sizejoin_buffer_size 越大,一次可以放入的行越多,分成的段数也就越少,对被驱动表的全表扫描次数就越少。这就是为什么,你可能会看到一些建议告诉你,如果你的 join 语句很慢,就把 join_buffer_size 改大。

总结

理解了 MySQL 执行 join 的两种算法,现在我们再来看开头的两个问题。

能不能使用 join 语句?

  1. 如果可以使用 Index Nested-Loop Join 算法,也就是说可以用上被驱动表上的索引,其实是没问题的;

  2. 如果使用 Block Nested-Loop Join 算法,扫描行数就会过多。尤其是在大表上的 join 操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源。所以这种 join 尽量不要用。

所以你在判断要不要使用 join 语句时,就是看 explain 结果里面,Extra 字段里面有没有出现“Block Nested Loop”字样。

如果要使用 join,应该选择大表做驱动表还是选择小表做驱动表?

  1. 如果是 Index Nested-Loop Join 算法,应该选择小表做驱动表;
  2. 如果是 Block Nested-Loop Join 算法:在 join_buffer_size 足够大的时候,是一样的;在 join_buffer_size 不够大的时候(这种情况更常见),应该选择小表做驱动表。

所以,这个问题的结论就是,总是应该使用小表做驱动表。当然了,这里我需要说明下,什么叫作“小表”。我们前面的例子是没有加条件的。如果在语句的 where 条件加上 t2.id<=50 这个限定条件,再来看下这两条语句:

select * from t1 straight_join t2 on (t1.b=t2.b) where t2.id<=50;
select * from t2 straight_join t1 on (t1.b=t2.b) where t2.id<=50;

注意,为了让两条语句的被驱动表都用不上索引,所以 join 字段都使用了没有索引的字段 b。但如果是用第二个语句的话,join_buffer 只需要放入 t2 的前 50 行,显然是更好的。所以这里,“t2 的前 50 行”是那个相对小的表,也就是“小表”。

我们再来看另外一组例子:

select t1.b,t2.* from  t1  straight_join t2 on (t1.b=t2.b) where t2.id<=100;
select t1.b,t2.* from  t2  straight_join t1 on (t1.b=t2.b) where t2.id<=100;

这个例子里,表 t1 和 t2 都是只有 100 行参加 join。但是,这两条语句每次查询放入 join_buffer 中的数据是不一样的:

  • 表 t1 只查字段 b,因此如果把 t1 放到 join_buffer 中,则 join_buffer 中只需要放入 b 的值;
  • 表 t2 需要查所有的字段,因此如果把表 t2 放到 join_buffer 中的话,就需要放入三个字段 id、a 和 b。

这里,我们应该选择表 t1 作为驱动表。也就是说在这个例子里,“只需要一列参与 join 的表 t1”是那个相对小的表。

所以,更准确地说,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与 join 的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表

join 优化

使用 NLJ 算法的时候,其实效果还是不错的,比通过应用层拆分成多个语句然后再拼接查询结果更方便,而且性能也不会差。但是,BNL 算法在大表 join 的时候性能就差多了,比较次数等于两个表参与 join 的行数的乘积,很消耗 CPU 资源。当然了,这两个算法都还有继续优化的空间。

为了便于分析,创建两个表 t1、t2 来展开。

create table t1(id int primary key, a int, b int, index(a));
create table t2 like t1;
drop procedure idata;
delimiter ;;
create procedure idata()
begin
  declare i int;
  set i=1;
  while(i<=1000)do
    insert into t1 values(i, 1001-i, i);
    set i=i+1;
  end while;
  
  set i=1;
  while(i<=1000000)do
    insert into t2 values(i, i, i);
    set i=i+1;
  end while;

end;;
delimiter ;
call idata();

为了便于后面量化说明,在表 t1 里,插入了 1000 行数据,每一行的 a=1001-id 的值。也就是说,表 t1 中字段 a 是逆序的。同时,在表 t2 中插入了 100 万行数据。

Multi-Range Read 优化

在介绍 join 语句的优化方案之前,需要先介绍一个知识点,即:Multi-Range Read 优化 (MRR)。这个优化的主要目的是尽量使用顺序读盘。回表是指,InnoDB 在普通索引 a 上查到主键 id 的值后,再根据一个个主键 id 的值到主键索引上去查整行数据的过程。那么回表过程是一行行地查数据,还是批量地查数据?我们先来看看这个问题。假设,执行这个语句:

select * from t1 where a>=1 and a<=100;

主键索引是一棵 B+ 树,在这棵树上,每次只能根据一个主键 id 查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图所示。

基本回表流程

如果随着 a 的值递增顺序查询的话,id 的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然“按行查”这个机制不能改,但是调整查询的顺序,还是能够加速的。因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能(降低磁盘随机读带来的寻道及旋转延迟耗时、以及在内存紧张的情况下充分提高内存页的命中率)。

这就是 MRR 优化的设计思路。此时,语句的执行流程变成了这样:

  1. 根据索引 a,定位到满足条件的记录,将 id 值放入 read_rnd_buffer 中 ;
  2. 将 read_rnd_buffer 中的 id 进行递增排序;
  3. 排序后的 id 数组,依次到主键 id 索引中查记录,并作为结果返回。

这里,read_rnd_buffer 的大小是由 read_rnd_buffer_size 参数控制的。如果步骤 1 中,read_rnd_buffer 放满了,就会先执行完步骤 2 和 3,然后清空 read_rnd_buffer。之后继续找索引 a 的下个记录,并继续循环。

另外需要说明的是,如果你想要稳定地使用 MRR 优化的话,需要设置set optimizer_switch="mrr_cost_based=off"。(官方文档的说法,是现在的优化器策略,判断消耗的时候,会更倾向于不使用 MRR,把 mrr_cost_based 设置为 off,就是固定使用 MRR 了。)

下面两幅图就是使用了 MRR 优化后的执行流程和 explain 结果。

MRR 执行流程

MRR 执行流程的 explain 结果

从 explain 结果中,我们可以看到 Extra 字段多了 Using MRR,表示的是用上了 MRR 优化。而且,由于我们在 read_rnd_buffer 中按照 id 做了排序,所以最后得到的结果集也是按照主键 id 递增顺序的,也就是与前面未使用 MRR 的结果集中行的顺序相反。

小结

MRR 能够提升性能的核心在于,这条查询语句在索引 a 上做的是一个范围查询(也就是说,这是一个多值查询),可以得到足够多的主键 id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

不要使用 MMR 的场景

select * from t1 where a>=1 and a<=100 order by a; 语句,a是索引列,此时就不要使用 MMR 了,因为查询出来的数据就是要按照 a 排序的,且存在索引 a ,不使用 MMR 的情况下查询出来的数据就是有序的。如果还要使用 MMR,就会在查询出结果后增加额外排序,且 MMR 又带来了 read_rnd_buffer 的消耗和排序 id 的消耗,得不偿失。

Batched Key Access

理解了 MRR 性能提升的原理,我们就能理解 MySQL 在 5.6 版本后开始引入的 Batched Key Access(BKA) 算法了。这个 BKA 算法,其实就是对 NLJ 算法的优化

再来看看 NLJ 算法的流程图:

Index Nested-Loop Join 流程图

NLJ 算法执行的逻辑是:从驱动表 t1,一行行地取出 a 的值,再到被驱动表 t2 去做 join。也就是说,对于表 t2 来说,每次都是匹配一个值。这时,MRR 的优势就用不上了。

那怎么才能一次性地多传些值给表 t2 呢?方法就是,从表 t1 里一次性地多拿些行出来,一起传给表 t2。既然如此,我们就把表 t1 的数据取出来一部分,先放到一个临时内存。这个临时内存不是别人,就是 join_buffer。join_buffer 在 BNL 算法里的作用,是暂存驱动表的数据。但是在 NLJ 算法里并没有用。那么,我们刚好就可以复用 join_buffer 到 BKA 算法中。

下图是上面的 NLJ 算法优化后的 BKA 算法的流程。

Batched Key Access 流程

图中,在 join_buffer 中放入的数据是 P1~P100,表示的是只会取查询需要的字段。当然,如果 join buffer 放不下 P1~P100 的所有数据,就会把这 100 行数据分成多段执行上图的流程。

那么,这个 BKA 算法到底要怎么启用呢?如果要使用 BKA 优化算法的话,你需要在执行 SQL 语句之前,先设置

set optimizer_switch='mrr=on,mrr_cost_based=off,batched_key_access=on';

其中,前两个参数的作用是要启用 MRR。这么做的原因是,BKA 算法的优化要依赖于 MRR。

BNL 算法的性能问题

说完了 NLJ 算法的优化,我们再来看 BNL 算法的优化。

在使用 Block Nested-Loop Join(BNL) 算法时,可能会对被驱动表做多次扫描。如果这个被驱动表是一个大的冷数据表,除了会导致 IO 压力大以外,还会对系统有什么影响呢?

由于 InnoDB 对 Bufffer Pool 的 LRU 算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在 old 区域,且在一秒之内都只能存在 old 区域,如果此期间有其它数据页插入到 old 区或者 young 区,该数据页就会一直被往后推直到被提出 LRU 队列;如果能够坚持到一秒之后还没被提出且再被访问到的时候,就会移动到队首进入 young 区。

此时我们针对 BNL 的被驱动表是一个较小的表和一个较大的表分别进行分析(较大较小是和 old 区的相对概念,整个表都能存在 old 区就是较小表;否则较大表),在此之前,我们假设被驱动表是一个冷表且驱动表不能完全装入 join_buffer,需要分批,则被驱动表需要多次扫描,这是最差的情况:

  • 被驱动表是较小表:

    这种情况下,被驱动表数据量小于整个 buffer pool 的 3/8,能够完全放入 old 区域。如果一个使用 BNL 算法的 join 语句,多次扫描一个冷表,而且这个语句执行时间超过 1 秒,就会在再次扫描冷表的时候,把冷表的数据页移到 LRU 链表头部,进入 young 区域,此时就会导致:

    1. 原本在 young 区真正的热点 page 被挤到 old 区了。
    2. 在 join 语句执行完之后,被驱动表的数据就真正的冷却了,将很少被访问到,此时 MySQL 会逐渐淘汰这些数据页,修复 LRU 链表的冷热特征,但是因为这些冷表(被驱动表)的数据页被移动到 young 区,导致修复周期加长,此间总体内存命中率低。
  • 被驱动表是较大表:

    如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入 young 区域。由于优化机制的存在,一个正常访问的数据页,要进入 young 区域,需要隔 1 秒后再次被访问到。但是,由于我们的 join 语句在循环读磁盘和淘汰内存页,进入 old 区域的数据页,很可能在 1 秒之内就被淘汰了。这样,就会导致:

    1. 这个 MySQL 实例的 Buffer Pool 在这段时间内,young 区域的数据页没有被合理地淘汰。
    2. IO 负载变高

    假设大表t大小是M页>old区域N页,由于Block Nested-Loop Join需要对t进行k次全表扫描。第一次扫描时,1N页依次被放入old区域,访问N+1页时淘汰1页,放入N+1页,以此类推,第一次扫描结束后old区域存放的是M-N+1M页。第二次扫描开始,访问1页,淘汰M-N+1页,放入1页。可以把M页想象成一个环,N页想象成在这个环上滑动的窗口,由于M>N,如果一个数据页内的数据行数量不能支撑 MySQL 扫描 1s,那么将永远不会有被驱动表的数据页"因为被访问的时候在LRU链表上存在超过1s进入young区":

    • 如果 page size 太小,或者一行数据太多,就会导致扫描被驱动表的时候在一个 page 上停留的时间很短,就要到磁盘加载下一个 page,此时冷表数据页无法进入 young 区,在 old 区此时循环加载和淘汰尾部数据页。
    • 如果 page size 太大,或者一行数据太少,导致扫描被驱动表的时候在一个 page 上停留时间长,从磁盘加载下一个内存页时间变慢,使得访问该页超过了1s,这样被驱动表的数据页将会进入 young 区。

也就是说,这两种情况都会影响 Buffer Pool 的正常运作。**大表 join 操作虽然对 IO 有影响,但是在语句执行结束后,对 IO 的影响也就结束了。但是,对 Buffer Pool 的影响就是持续性的,需要依靠后续的查询请求慢慢恢复内存命中率。**为了减少这种影响,你可以考虑增大 join_buffer_size 的值,减少对被驱动表的扫描次数。

也就是说,BNL 算法对系统的影响主要包括三个方面:

  1. 可能会多次扫描被驱动表,占用磁盘 IO 资源;
  2. 判断 join 条件需要执行 M*N 次对比(M、N 分别是两张表的行数),如果是大表就会占用非常多的 CPU 资源;
  3. 可能会导致 Buffer Pool 的热数据被淘汰,影响内存命中率。

优化:BNL 转 BKA

我们执行语句之前,需要通过理论分析和查看 explain 结果的方式,确认是否要使用 BNL 算法。如果确认优化器会使用 BNL 算法,就需要做优化。优化的常见做法是,给被驱动表的 join 字段加上索引,把 BNL 算法转成 BKA 算法。接下来,我们就具体看看,这个优化怎么做?

一些情况下,我们可以直接在被驱动表上建索引,这时就可以直接转成 BKA 算法了。但是,有时候你确实会碰到一些不适合在被驱动表上建索引的情况。比如下面这个语句:

select * from t1 join t2 on (t1.b=t2.b) where t2.b>=1 and t2.b<=2000;

表 t2 中是插入了 100 万行数据,但是经过 where 条件过滤后,需要参与 join 的只有 2000 行数据。如果这条语句同时是一个低频的 SQL 语句,那么再为这个语句在表 t2 的字段 b 上创建一个索引就很浪费了(当该查询频率很低的时候,此时认为写数据和读数据频率一致,而写数据的时候维护索引和读数据全表扫描索引的相比前者成本高)。

但是,如果使用 BNL 算法来 join 的话,这个语句的执行流程是这样的:

  1. 把表 t1 的所有字段取出来,存入 join_buffer 中。这个表只有 1000 行,join_buffer_size 默认值是 256k,可以完全存入。
  2. 扫描表 t2,取出每一行数据跟 join_buffer 中的数据进行对比,
    • 如果不满足 t1.b=t2.b,则跳过;
    • 如果满足 t1.b=t2.b, 再判断其他条件,也就是是否满足 t2.b 处于[1,2000]的条件,如果是,就作为结果集的一部分返回,否则跳过。

对于表 t2 的每一行,判断 join 是否满足的时候,都需要遍历 join_buffer 中的所有行。因此判断等值条件的次数是 1000*100 万 =10 亿次,这个判断的工作量很大。

BNL explain 结果

语句执行时间

可以看到,explain 结果里 Extra 字段显示使用了 BNL 算法。在我的测试环境里,这条语句需要执行 1 分 11 秒。

建立临时表

在表 t2 的字段 b 上创建索引会浪费资源,但是不创建索引的话这个语句的等值条件要判断 10 亿次,想想也是浪费。那么,有没有两全其美的办法呢?这时候,我们可以考虑使用临时表。使用临时表的大致思路是:

  1. 把表 t2 中满足条件的数据放在临时表 tmp_t 中;
  2. 为了让 join 使用 BKA 算法,给临时表 tmp_t 的字段 b 加上索引;
  3. 让表 t1tmp_t 做 join 操作。

此时,对应的 SQL 语句的写法如下:

create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

下图就是这个语句序列的执行效果。

使用临时表的执行效果

可以看到,整个过程 3 个语句执行时间的总和还不到 1 秒,相比于前面的 1 分 11 秒,性能得到了大幅提升。接下来,我们一起看一下这个过程的消耗:

  1. 执行 insert 语句构造 temp_t 表并插入数据的过程中,对表 t2 做了全表扫描,这里扫描行数是 100 万。
  2. 之后的 join 语句,扫描表 t1,这里的扫描行数是 1000;join 比较过程中,做了 1000 次带索引的查询。相比于优化前的 join 语句需要做 10 亿次条件判断来说,这个优化效果还是很明显的。

总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让 join 语句能够用上被驱动表上的索引,来触发 BKA 算法,提升查询性能。

扩展:hash join

其实上面计算 10 亿次那个操作,看上去有点儿傻。如果 join_buffer 里面维护的不是一个无序数组,而是一个哈希表的话,那么就不是 10 亿次判断,而是 100 万次 hash 查找。这样的话,整条语句的执行速度就快多了吧?确实如此。这,也正是 MySQL 的优化器和执行器一直被诟病的一个原因:不支持哈希 join。并且,MySQL 官方的 roadmap,也是迟迟没有把这个优化排上议程。

实际上,这个优化思路,我们可以自己实现在业务端。实现流程大致如下:select * from t1;取得表 t1 的全部 1000 行数据,在业务端存入一个 hash 结构,比如 C++ 里的 set、PHP 的数组这样的数据结构。select * from t2 where b>=1 and b<=2000; 获取表 t2 中满足条件的 2000 行数据。把这 2000 行数据,一行一行地取到业务端,到 hash 结构的数据表中寻找匹配的数据。满足匹配的条件的这行数据,就作为结果集的一行。理论上,这个过程会比临时表方案的执行速度还要快一些。

优化例子

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

create table t2 like t1;
create table t3 like t2;
insert into ... //初始化三张表的数据
select * from t1 join t2 on(t1.a=t2.a) join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;

为了得到最快的执行速度,如果让你来设计表 t1、t2、t3 上的索引,来支持这个 join 语句,你会加哪些索引呢?

第一原则是要尽量使用 BKA 算法。需要注意的是,使用 BKA 算法的时候,并不是“先计算两个表 join 的结果,再跟第三个表 join”,而是直接嵌套查询的。具体实现是:在 t1.c>=X、t2.c>=Y、t3.c>=Z 这三个条件里,选择一个经过过滤以后,数据最少的那个表,作为第一个驱动表。此时,可能会出现如下两种情况。

  • 第一种情况,如果选出来是表 t1 或者 t3,那剩下的部分就固定了。

    • 如果驱动表是 t1,则连接顺序是 t1->t2->t3,要在被驱动表字段创建上索引,也就是 t2.a 和 t3.b 上创建索引;

      如:t1增加索引©、t2增加组合索引(b,c)、t3增加组合索引(b,c);语句改成:select * from t1 straight_join t2 on(t1.a=t2.a) straight_join t3 on (t2.b=t3.b) where t1.c>=X and t2.c>=Y and t3.c>=Z;

    • 如果驱动表是 t3,则连接顺序是 t3->t2->t1,需要在 t2.b 和 t1.a 上创建索引。同时,我们还需要在第一个驱动表的字段 c 上创建索引。

  • 第二种情况是,如果选出来的第一个驱动表是表 t2 的话,则需要评估另外两个条件的过滤效果。

总之,整体的思路就是,尽量让每一次参与 join 的驱动表的数据集,越小越好,因为这样我们的驱动表就会越小。

join 的写法

上面介绍 join 执行顺序的时候,用的都是 straight_join

  • 如果用 left join 的话,左边的表一定是驱动表吗?
  • 如果两个表的 join 包含多个条件的等值匹配,是都要写到 on 里面呢,还是只把一个条件写到 on 里面,其他条件写到 where 部分?

例子说明

构造两个表 a 和 b:

create table a(f1 int, f2 int, index(f1))engine=innodb;
create table b(f1 int, f2 int)engine=innodb;
insert into a values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6);
insert into b values(3,3),(4,4),(5,5),(6,6),(7,7),(8,8);

表 a 和 b 都有两个字段 f1 和 f2,不同的是表 a 的字段 f1 上有索引。然后,我往两个表中都插入了 6 条记录,其中在表 a 和 b 中同时存在的数据有 4 行。上面的第二个问题,其实就是下面这种写法的区别:

select * from a left join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q1*/
select * from a left join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q2*/

首先,需要说明的是,这两个 left join 语句的语义逻辑并不相同。我们先来看一下它们的执行结果。

两个 join 的查询结果

可以看到:

  • 语句 Q1 返回的数据集是 6 行,表 a 中即使没有满足匹配条件的记录,查询结果中也会返回一行,并将表 b 的各个字段值填成 NULL。
  • 语句 Q2 返回的是 4 行。从逻辑上可以这么理解,最后的两行,由于表 b 中没有匹配的字段,结果集里面 b.f2 的值是空,不满足 where 部分的条件判断,因此不能作为结果集的一部分。

接下来,我们看看实际执行这两条语句时,MySQL 是怎么做的。我们先一起看看语句 Q1 的 explain 结果:

Q1 的 explain 结果

可以看到,这个结果符合我们的预期:

  • 驱动表是表 a,被驱动表是表 b;
  • 由于表 b 的 f1 字段上没有索引,所以使用的是 Block Nested Loop Join(简称 BNL) 算法。

看到 BNL 算法,你就应该知道这条语句的执行流程其实是这样的:

  1. 把表 a 的内容读入 join_buffer 中。因为是 select * ,所以字段 f1 和 f2 都被放入 join_buffer 了。
  2. 顺序扫描表 b,对于每一行数据,判断 join 条件(也就是 (a.f1=b.f1) and (a.f2=b.f2))是否满足,满足条件的记录, 作为结果集的一行返回。如果语句中有 where 子句,需要先判断 where 部分满足条件后,再返回。
  3. 表 b 扫描完成后,对于没有被匹配的表 a 的行(在这个例子中就是 (1,1)、(2,2) 这两行),把剩余字段补上 NULL,再放入结果集中。

对应的流程图如下:

left join -BNL 算法

可以看到,这条语句确实是以表 a 为驱动表,而且从执行效果看,也和使用 straight_join 是一样的。那语句 Q2 的查询结果里面少了最后两行数据,是不是就是把上面流程中的步骤 3 去掉呢?我们还是先看一下语句 Q2 的 expain 结果吧。

Q2 的 explain 结果

可以看到,这条语句是以表 b 为驱动表的。而如果一条 join 语句的 Extra 字段什么都没写的话,就表示使用的是 Index Nested-Loop Join(简称 NLJ)算法。因此,语句 Q2 的执行流程是这样的:顺序扫描表 b,每一行用 b.f1 到表 a 中去查,匹配到记录后判断 a.f2=b.f2 是否满足,满足条件的话就作为结果集的一部分返回。

那么,为什么语句 Q1 和 Q2 这两个查询的执行流程会差距这么大呢?其实,这是因为优化器基于 Q2 这个查询的语义做了优化。为了理解这个问题,需要知道一个背景知识点:在 MySQL 里,NULL 跟任何值执行等值判断和不等值判断的结果,都是 NULL。这里包括, select NULL = NULL 的结果,也是返回 NULL。因此,语句 Q2 里面 where a.f2=b.f2 就表示,查询结果里面不会包含 b.f2 是 NULL 的行,这样这个 left join 的语义就是“找到这两个表里面,f1、f2 对应相同的行。对于表 a 中存在,而表 b 中匹配不到的行,就放弃”(where a.f2=b.f2 的意思是 where (a.f2 is not null) and (b.f2 is not null) and (a.f2 =b.f2),因为 on a.f1=b.f1where a.f2=b.f2 是与关系,所以后者对前者的left join不匹配则填充 null 行为限制退化成了和join语义一样的行为)。

这样,这条语句虽然用的是 left join,但是语义跟 join 是一致的。因此,优化器就把这条语句的 left join 改写成了 join,然后因为表 a 的 f1 上有索引,就把表 b 作为驱动表,这样就可以用上 NLJ 算法。在执行 explain 之后,你再执行 show warnings,就能看到这个改写的结果,如图所示。

Q2 的改写结果

这个例子说明,即使我们在 SQL 语句中写成 left join,执行过程还是有可能不是从左到右连接的。也就是说,使用 left join 时,左边的表不一定是驱动表。这样看来,如果需要 left join 的语义,就不能把被驱动表的字段放在 where 条件里面做等值判断或不等值判断,必须都写在 on 里面。那如果是 join 语句呢?这时候,我们再看看这两条语句:

select * from a join b on(a.f1=b.f1) and (a.f2=b.f2); /*Q3*/
select * from a join b on(a.f1=b.f1) where (a.f2=b.f2);/*Q4*/

我们再使用一次看 explain 和 show warnings 的方法,看看优化器是怎么做的。

join 语句改写

可以看到,这两条语句都被改写成:

select * from a join b where (a.f1=b.f1) and (a.f2=b.f2);

执行计划自然也是一模一样的。也就是说,在这种情况下,join 将判断条件是否全部放在 on 部分就没有区别了。

二十三、临时表

在优化 join 查询的时候使用到了临时表:

create temporary table temp_t like t1;
alter table temp_t add index(b);
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

有的人可能会认为,临时表就是内存表。但是,这两个概念可是完全不同的。

  • 内存表,指的是使用 Memory 引擎的表,建表语法是 create table … engine=memory。这种表的数据都保存在内存里,系统重启的时候会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”外,从其他的特征上看,它就是一个正常的表。
  • 而临时表,可以使用各种引擎类型 。如果是使用 InnoDB 引擎或者 MyISAM 引擎的临时表,写数据的时候是写到磁盘上的。当然,临时表也可以使用 Memory 引擎。

弄清楚了内存表和临时表的区别以后,我们再来看看临时表有哪些特征。

临时表的特性

为了便于理解,我们来看下下面这个操作序列:

临时表特性示例

可以看到,临时表在使用上有以下几个特点:

  1. 建表语法是 create temporary table …
  2. 一个临时表只能被创建它的 session 访问,对其他线程不可见。所以,图中 session A 创建的临时表 t,对于 session B 就是不可见的。
  3. 临时表可以与普通表同名。
  4. session A 内有同名的临时表和普通表的时候,show create 语句,以及增删改查语句访问的是临时表。
  5. show tables 命令不显示临时表。

由于临时表只能被创建它的 session 访问,所以在这个 session 结束的时候,会自动删除临时表。也正是由于这个特性,临时表就特别适合我们文章开头的 join 优化这种场景。为什么呢?原因主要包括以下两个方面:

  1. 不同 session 的临时表是可以重名的,如果有多个 session 同时执行 join 优化,不需要担心表名重复导致建表失败的问题。

  2. 不需要担心数据删除问题。如果使用普通表,在流程执行过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门来清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作。

    但是需要注意的时候,现在的数据库连接通常都是使用连接池管理,不会轻易关闭,所以临时表用完就删除是一个好习惯。

临时表的应用场景示例:分库分表

由于不用担心线程之间的重名冲突,临时表经常会被用在复杂查询的优化过程中。其中,分库分表系统的跨库查询就是一个典型的使用场景。一般分库分表的场景,就是要把一个逻辑上的大表分散到不同的数据库实例上。比如。将一个大表 ht,按照字段 f,拆分成 1024 个分表,然后分布到 32 个数据库实例上。如下图所示:

分库分表简图

一般情况下,这种分库分表系统都有一个中间层 proxy。不过,也有一些方案会让客户端直接连接数据库,也就是没有 proxy 这一层。在这个架构中,分区 key 的选择是以“减少跨库和跨表查询”为依据的。如果大部分的语句都会包含 f 的等值条件,那么就要用 f 做分区键。这样,在 proxy 这一层解析完 SQL 语句以后,就能确定将这条语句路由到哪个分表做查询。比如下面这条语句:

select v from ht where f=N;

这时,我们就可以通过分表规则(比如,N%1024) 来确认需要的数据被放在了哪个分表上。这种语句只需要访问一个分表,是分库分表方案最欢迎的语句形式了。

但是,如果这个表上还有另外一个索引 k,并且查询语句是这样的:

select v from ht where k >= M order by t_modified desc limit 100;

这时候,由于查询条件里面没有用到分区字段 f,只能到所有的分区中去查找满足条件的所有行,然后统一做 order by 的操作。这种情况下,有两种比较常用的思路。

  1. 第一种思路是,在 proxy 层的进程代码中实现排序。

    这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算。不过,这个方案的缺点也比较明显:

    • 需要的开发工作量比较大。我们举例的这条语句还算是比较简单的,如果涉及到复杂的操作,比如 group by,甚至 join 这样的操作,对中间层的开发能力要求比较高;
    • 对 proxy 端的压力比较大,尤其是很容易出现内存不够用和 CPU 瓶颈的问题。
  2. 另一种思路就是,把各个分库拿到的数据,汇总到一个 MySQL 实例的一个表中,然后在这个汇总实例上做逻辑操作。

    比如上面这条语句,执行流程可以类似这样:

    • 在汇总库上创建一个临时表 temp_ht,表里包含三个字段 vkt_modified

    • 在各个分库上执行

      select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
      
    • 把分库执行的结果插入到 temp_ht 表中;

    • 执行

      select v from temp_ht order by t_modified desc limit 100; 
      
    • 得到结果。

    这个过程对应的流程图如下所示:

    跨库查询流程示意图

    在实践中,我们往往会发现每个分库的计算量都不饱和,所以会直接把临时表 temp_ht 放到 32 个分库中的某一个上。这时的查询逻辑与上图类似。

为什么临时表可以重名?

我们在执行

create temporary table temp_t(id int primary key)engine=innodb;

这个语句的时候,MySQL 要给这个 InnoDB 表创建一个 frm 文件保存表结构定义,还要有地方保存表数据。

这个 frm 文件放在临时文件目录下,文件名的后缀是.frm,前缀是#sql{进程 id}_{线程 id}_ 序列号。你可以使用 select @@tmpdir 命令,来显示实例的临时文件目录。

而关于表中数据的存放方式,在不同的 MySQL 版本中有着不同的处理方式:

  • 在 5.6 以及之前的版本里,MySQL 会在临时文件目录下创建一个相同前缀、以.ibd 为后缀的文件,用来存放数据文件;
  • 而从 5.7 版本开始,MySQL 引入了一个临时文件表空间,专门用来存放临时文件的数据。因此,我们就不需要再创建 ibd 文件了。

从文件名的前缀规则,我们可以看到,其实创建一个叫作 t1 的 InnoDB 临时表,MySQL 在存储上认为我们创建的表名跟普通表 t1 是不同的,因此同一个库下面已经有普通表 t1 的情况下,还是可以再创建一个临时表 t1 的。为了便于后面讨论,先来举一个例子。

临时表的表名

这个进程的进程号是 1234,session A 的线程 id 是 4,session B 的线程 id 是 5。session A 和 session B 创建的临时表,在磁盘上的文件不会重名。MySQL 维护数据表,除了物理上要有文件外,内存里面也有一套机制区别不同的表,每个表都对应一个 table_def_key。

  • 一个普通表的 table_def_key 的值是由“库名 + 表名”得到的,所以如果你要在同一个库下创建两个同名的普通表,创建第二个表的过程中就会发现 table_def_key 已经存在了。
  • 而对于临时表,table_def_key 在“库名 + 表名”基础上,又加入了“server_id+thread_id”。

也就是说,session A 和 sessionB 创建的两个临时表 t1,它们的 table_def_key 不同,磁盘文件名也不同,因此可以并存。

在实现上,每个线程都维护了自己的临时表链表。这样每次 session 内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表;在 session 结束的时候,对链表里的每个临时表,执行 “DROP TEMPORARY TABLE + 表名”操作。这时候你会发现,binlog 中也记录了 DROP TEMPORARY TABLE 这条命令。你一定会觉得奇怪,临时表只在线程内自己可以访问,为什么需要写到 binlog 里面?这,就需要说到主备复制了。

临时表和主备复制

既然写 binlog,就意味着备库需要。你可以设想一下,在主库上执行下面这个语句序列:

create table t_normal(id int primary key, c int)engine=innodb;/*Q1*/
create temporary table temp_t like t_normal;/*Q2*/
insert into temp_t values(1,1);/*Q3*/
insert into t_normal select * from temp_t;/*Q4*/

如果关于临时表的操作都不记录,那么在备库就只有 create table t_normal 表和 insert into t_normal select * from temp_t 这两个语句的 binlog 日志,备库在执行到 insert into t_normal 的时候,就会报错“表 temp_t 不存在”。

如果把 binlog 设置为 row 格式就好了吧?因为 binlog 是 row 格式时,在记录 insert into t_normal 的 binlog 时,记录的是这个操作的数据,即:write_row event 里面记录的逻辑是“插入一行数据(1,1)”。你可能会说,如果把 binlog 设置为 row 格式就好了吧?因为 binlog 是 row 格式时,在记录 insert into t_normal 的 binlog 时,记录的是这个操作的数据,即:write_row event 里面记录的逻辑是“插入一行数据(1,1)”。

确实是这样。如果当前的 binlog_format=row,那么跟临时表有关的语句,就不会记录到 binlog 里。也就是说,只在 binlog_format=statment/mixed 的时候,binlog 中才会记录临时表的操作。这种情况下,创建临时表的语句会传到备库执行,因此备库的同步线程就会创建这个临时表。主库在线程退出的时候,会自动删除临时表,但是备库同步线程是持续在运行的。所以,这时候我们就需要在主库上再写一个 DROP TEMPORARY TABLE 传给备库执行。

MySQL 在记录 binlog 的时候,不论是 create table 还是 alter table 语句,都是原样记录,甚至于连空格都不变。但是如果执行 drop table t_normal,系统记录 binlog 就会写成:

DROP TABLE `t_normal` /* generated by server */

也就是改成了标准的格式。为什么要这么做呢 ?因为 drop table 命令是可以一次删除多个表的。比如,在上面的例子中,设置 binlog_format=row,如果主库上执行 drop table t_normal, temp_t; 这个命令,那么 binlog 中就只能记录:

DROP TABLE `t_normal` /* generated by server */

因为备库上并没有表 temp_t,将这个命令重写后再传到备库执行,才不会导致备库同步线程停止。所以,drop table 命令记录 binlog 的时候,就必须对语句做改写。“/* generated by server */”说明了这是一个被服务端改写过的命令。

主备复制临时表如何解决重名问题

说到主备复制,还有另外一个问题需要解决:主库上不同的线程创建同名的临时表是没关系的,但是传到备库执行是怎么处理的呢?举个例子,下面的序列中实例 S 是 M 的备库。

主备关系中的临时表操作

主库 M 上的两个 session 创建了同名的临时表 t1,这两个 create temporary table t1 语句都会被传到备库 S 上。但是,备库的应用日志线程是共用的,也就是说要在应用线程里面先后执行这个 create 语句两次。(即使开了多线程复制,也可能被分配到从库的同一个 worker 中执行)。那么,这会不会导致同步线程报错 ?

显然是不会的,否则临时表就是一个 bug 了。也就是说,备库线程在执行的时候,要把这两个 t1 表当做两个不同的临时表来处理。这,又是怎么实现的呢?MySQL 在记录 binlog 的时候,会把主库执行这个语句的线程 id 写到 binlog 中。这样,在备库的应用线程就能够知道执行每个语句的主库线程 id,并利用这个线程 id 来构造临时表的 table_def_key:

  1. session A 的临时表 t1,在备库的 table_def_key 就是:库名 +t1+“M 的 serverid”+“session A 的 thread_id”;
  2. session B 的临时表 t1,在备库的 table_def_key 就是 :库名 +t1+“M 的 serverid”+“session B 的 thread_id”。

由于 table_def_key 不同,所以这两个表在备库的应用线程里面是不会冲突的。

为什么临时表改名是 alter 而不是 rename

我们可以使用 alter table 语法修改临时表的表名,而不能使用 rename 语法。这是什么原因呢?

临时表改名

在实现上,执行 rename table 语句的时候,要求按照“库名 / 表名.frm”的规则去磁盘找文件,但是临时表在磁盘上的 frm 文件是放在 tmpdir 目录下的,并且文件名的规则是 #sql{进程 id}_{线程 id}_ 序列号.frm,因此会报“找不到文件名”的错误。

内部临时表

上面提到的都是用户根据需求通过 DDL 建立的临时表,称为用户临时表。sort buffer、内存临时表和 join buffer。这三个数据结构都是用来存放语句执行过程中的中间数据,以辅助 SQL 语句的执行的。其中,我们在排序的时候用到了 sort buffer,在使用 join 语句的时候用到了 join buffer,而类似函数计算字段等操作会涉及到内部临时表构建,除了函数计算之后其实还有其它操作会使得 MySQL 通过构建临时表来实现,下面进行详细介绍。

union 执行流程

为了便于量化分析,用下面的表 t1 来举例。

create table t1(id int primary key, a int, b int, index(a));
delimiter ;;
create procedure idata()
begin
  declare i int;

  set i=1;
  while(i<=1000)do
    insert into t1 values(i, i, i);
    set i=i+1;
  end while;
end;;
delimiter ;
call idata();

然后,我们执行下面这条语句:

(select 1000 as f) union (select id from t1 order by id desc limit 2);

这条语句用到了 union,它的语义是,取这两个子查询结果的并集。并集的意思就是这两个集合加起来,重复的行只保留一行。下图是这个语句的 explain 结果。

union 语句 explain 结果

可以看到:

  • 第二行的 key=PRIMARY,说明第二个子句用到了索引 id。
  • 第三行的 Extra 字段,表示在对子查询的结果集做 union 的时候,使用了临时表 (Using temporary)。

这个语句的执行流程是这样的:

  1. 创建一个内存临时表,这个临时表只有一个整型字段 f,并且 f 是主键字段。
  2. 执行第一个子查询,得到 1000 这个值,并存入临时表中。
  3. 执行第二个子查询:
    • 拿到第一行 id=1000,试图插入临时表中。但由于 1000 这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行;
    • 取到第二行 id=999,插入临时表成功。
  4. 从临时表中按行取出数据,返回结果,并删除临时表,结果中包含两行数据分别是 1000 和 999。

这个过程的流程图如下所示:

union 执行流程

可以看到,这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键 id 的唯一性约束,实现了 union 的语义。

顺便提一下,如果把上面这个语句中的 union 改成 union all 的话,就没有了“去重”的语义。这样执行的时候,就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端。因此也就不需要临时表了

union all 的 explain 结果

可以看到,第二行的 Extra 字段显示的是 Using index,表示只使用了覆盖索引,没有用临时表了。

group by 执行流程

另外一个常见的使用临时表的例子是 group by,我们来看一下这个语句:

select id%10 as m, count(*) as c from t1 group by m;

这个语句的逻辑是把表 t1 里的数据,按照 id%10 进行分组统计,并按照 m 的结果排序后输出。它的 explain 结果如下:

group by 的 explain 结果

在 Extra 字段里面,我们可以看到三个信息:

  • Using index,表示这个语句使用了覆盖索引,选择了索引 a,不需要回表;
  • Using temporary,表示使用了临时表;
  • Using filesort,表示需要排序。

这个语句的执行流程是这样的:

  1. 创建内存临时表,表里有两个字段 m 和 c,主键是 m;
  2. 扫描表 t1 的索引 a,依次取出叶子节点上的 id 值,计算 id%10 的结果,记为 x;
    • 如果临时表中没有主键为 x 的行,就插入一个记录 (x,1);
    • 如果表中有主键为 x 的行,就将 x 这一行的 c 值加 1;
  3. 遍历完成后,再根据字段 m 做排序(为什么需要额外做排序?因为内存临时表默认使用 memory 引擎,该引擎的表数据是无序的,因为 group by 又提供了有序语义,所以返回前需要做额外排序,作为对比可以看下面的磁盘临时表),得到结果集返回给客户端。

这个流程的执行图如下:

group by 执行流程

其中,临时表的排序过程就是下图中虚线框内的过程。:

内存临时表排序流程

接下来,我们再看一下这条语句的执行结果(有序):

group by 执行结果

优化:order by null 去除默认排序

如果你的需求并不需要对结果进行排序,那你可以在 SQL 语句末尾增加 order by null,也就是改成:

select id%10 as m, count(*) as c from t1 group by m order by null;

这样就跳过了最后排序的阶段,直接从临时表中取数据返回。返回的结果如下图所示。

group + order by null 的结果(内存临时表)

由于表 t1 中的 id 值是从 1 开始的,因此返回的结果集中第一行是 id=1;扫描到 id=10 的时候才插入 m=0 这一行,因此结果集里最后一行才是 m=0。

可以看到现在 0 排在了最后,这和上面提到的内存临时表使用的是 memory 引擎,表中数据无序有关。 order by null 使得返回前无序额外使用 sort_buffer 排序,所以将内存临时表的内容直接返回。

内存临时表转成磁盘临时表

这个例子里由于临时表只有 10 行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,参数 tmp_table_size 就是控制这个内存大小的,默认是 16M。如果执行下面这个语句序列:

set tmp_table_size=1024;
select id%100 as m, count(*) as c from t1 group by m order by null limit 10;

把内存临时表的大小限制为最大 1024 字节,并把语句改成 id % 100,这样返回结果里有 100 行数据。但是,这时的内存临时表大小不够存下这 100 行数据,也就是说,执行过程中会发现内存临时表大小到达了上限(1024 字节)。那么,这时候就会把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是 InnoDB。 这时,返回的结果如下所示。

group + order by null 的结果(磁盘临时表)

如果这个表 t1 的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。

磁盘临时表默认使用 InnoDB 引擎,所以表内数据都是按照主键索引组织的,故即使使用了 order by null,表内数据还是会按照主键 mid % 100进行排序,在返回的时候和内存临时表的动作是一样的,不会进行额外排序。

优化:索引

可以看到,不论是使用内存临时表还是磁盘临时表,group by 逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的。如果表的数据量比较大,上面这个 group by 语句执行起来就会很慢,我们有什么优化的方法呢?

可以先想一下这个问题:执行 group by 语句为什么需要临时表?group by 的语义逻辑,是统计不同的值出现的个数。但是,由于每一行的 id%100 的结果是无序的,所以我们就需要有一个临时表,来记录并统计结果。那么,如果扫描过程中可以保证出现的数据是有序的,是不是就简单了呢?假设,现在有一个类似下图的这么一个数据结构,我们来看看 group by 可以怎么做。

group by 算法优化 - 有序输入

可以看到,如果可以确保输入的数据是有序的,那么计算 group by 的时候,就只需要从左到右,顺序扫描,依次累加。也就是下面这个过程:

  1. 当碰到第一个 1 的时候,已经知道累积了 X 个 0,结果集里的第一行就是 (0,X);
  2. 当碰到第一个 2 的时候,已经知道累积了 Y 个 1,结果集里的第二行就是 (1,Y);

按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到 group by 的结果,不需要临时表,也不需要再额外排序。

InnoDB 的索引,就可以满足这个输入有序的条件。在 MySQL 5.7 版本支持了 generated column 机制,用来实现列数据的关联更新(由一个列自动计算出另一个列的数据)。可以用下面的方法创建一个列 z,然后在 z 列上创建一个索引(如果是 MySQL 5.6 及之前的版本,也可以创建普通列和索引,来解决这个问题)。

alter table t1 add column z int generated always as(id % 100), add index(z);

这样,索引 z 上的数据就是类似上图这样有序的了。上面的 group by 语句就可以改成:

select z, count(*) as c from t1 group by z;

优化后的 group by 语句的 explain 结果,如下图所示:

group by 优化的 explain 结果

从 Extra 字段可以看到,这个语句的执行不再需要临时表,也不需要排序了。

优化:直接排序

所以,如果可以通过加索引来完成 group by 逻辑就再好不过了。但是,如果碰上不适合创建索引的场景,我们还是要老老实实做排序的。那么,这时候的 group by 要怎么优化呢?

如果我们明明知道,一个 group by 语句中需要放到临时表上的数据量特别大,却还是要按照“先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表”,看上去就有点儿傻。那么,我们就会想了,MySQL 有没有让我们直接走磁盘临时表的方法呢?答案是,有的。

group by 语句中加入 SQL_BIG_RESULT 这个提示(hint),就可以告诉优化器:这个语句涉及的数据量很大,请直接用磁盘临时表。MySQL 的优化器一看,磁盘临时表是 B+ 树存储,存储效率不如数组来得高。所以,既然你告诉我数据量很大,那从磁盘空间考虑,还是直接用数组来存吧。因此,下面这个语句

select SQL_BIG_RESULT id%100 as m, count(*) as c from t1 group by m;

的执行流程就是这样的:

  1. 初始化 sort_buffer,确定放入一个整型字段,记为 m;
  2. 扫描表 t1 的索引 a,依次取出里面的 id 值, 将 id%100 的值存入 sort_buffer 中;
  3. 扫描完成后,对 sort_buffer 的字段 m 做排序(如果 sort_buffer 内存不够用,就会利用磁盘临时文件辅助排序);
  4. 排序完成后,就得到了一个有序数组。

根据有序数组,得到数组里面的不同值,以及每个值的出现次数,此时开始就类似前面说到的加索引之后针对索引做的聚合操作了。下面两张图分别是执行流程图和执行 explain 命令得到的结果。

使用 SQL_BIG_RESULT 的执行流程图

使用 SQL_BIG_RESULT 的 explain 结果

从 Extra 字段可以看到,这个语句的执行没有再使用临时表,而是直接用了排序算法。

总结

基于上面的 unionunion allgroup by 语句的执行过程的分析:MySQL 什么时候会使用内部临时表?

  1. 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果;
  2. join_buffer 是无序数组,sort_buffer 是有序数组,临时表是二维表结构;
  3. 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表。比如我们的例子中,union 需要用到唯一索引约束, group by 还需要用到另外一个字段来存累积计数。

优化思想:

  1. 如果对 group by 语句的结果没有排序要求,要在语句后面加 order by null;
  2. 尽量让 group by 过程用上表的索引,确认方法是 explain 结果里没有 Using temporary 和 Using filesort;
  3. 如果 group by 需要统计的数据量不大,尽量只使用内存临时表;也可以通过适当调大 tmp_table_size 参数,来避免用到磁盘临时表;
  4. 如果数据量实在太大,使用 SQL_BIG_RESULT 这个提示,来告诉优化器直接使用排序算法得到 group by 的结果。

distinctgroup by 的性能

如果只需要去重,不需要执行聚合函数,distinctgroup by 哪种效率高一些呢?展开一下这个问题:如果表 t 的字段 a 上没有索引,那么下面这两条语句:

select a from t group by a order by null;
select distinct a from t;

的性能是不是相同的?

首先需要说明的是,这种 group by 的写法,并不是 SQL 标准的写法。标准的 group by 语句,是需要在 select 部分加一个聚合函数,比如:

select a,count(*) from t group by a order by null;

这条语句的逻辑是:按照字段 a 分组,计算每组的 a 出现的次数。在这个结果里,由于做的是聚合计算,相同的 a 只出现一次。

如果没有了 count(*) 以后,也就是不再需要执行“计算总数”的逻辑时,第一条语句的逻辑就变成是:按照字段 a 做分组,相同的 a 的值只返回一行。而这就是 distinct 的语义,所以不需要执行聚合函数时,distinctgroup by 这两条语句的语义和执行流程是相同的,因此执行性能也相同。这两条语句的执行流程是下面这样的。

  1. 创建一个临时表,临时表有一个字段 a,并且在这个字段 a 上创建一个唯一索引;
  2. 遍历表 t,依次取数据插入临时表中:
    • 如果发现唯一键冲突,就跳过;
    • 否则插入成功;
  3. 遍历完成后,将临时表作为结果集返回给客户端。

其实,a 上有索引两者的执行性能也是一样的,只要 group by 没有聚合操作,和 distinct 的性能都是一样,因为有了聚合操作不就是多了一步操作了吗(废话)。

  • 如果有索引,两者都无需建立临时表,直接根据索引遍历

    loop row in index_rows
    	if $prev == $cur then
    		continue
    	else
    		results.add($cur)
    		$prev = $cur
    	end if
    end loop
    
  • 如果没有索引,两者都需要建立临时表并建立主键 id 进行去重。

另外,如果 a 上没有索引,group by 语句后面必须带 order by null 两者性能才一样;否则 distinctgroup by 要好,因为两者都因为没有索引都需要建立临时表进行去重,而 group by 去完重之后还要做一次排序。

二十四、Memory 引擎

内存表的数据组织结构

假设有以下的两张表 t1 和 t2,其中表 t1 使用 Memory 引擎, 表 t2 使用 InnoDB 引擎。

create table t1(id int primary key, c int) engine=Memory;
create table t2(id int primary key, c int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);
insert into t2 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9),(0,0);

然后,分别执行 select * from t1select * from t2

两个查询结果 -0 的位置

可以看到,内存表 t1 的返回结果里面 0 在最后一行,而 InnoDB 表 t2 的返回结果里 0 在第一行。出现这个区别的原因,要从这两个引擎的主键索引的组织方式说起。表 t2 用的是 InnoDB 引擎,InnoDB 表的数据就放在主键索引树上,主键索引是 B+ 树。所以表 t2 的数据组织方式如下图所示:

表 t2 的数据组织

主键索引上的值是有序存储的。在执行 select * 的时候,就会按照叶子节点从左到右扫描,所以得到的结果里,0 就出现在第一行。与 InnoDB 引擎不同,Memory 引擎的数据和索引是分开的。我们来看一下表 t1 中的数据内容。

表 t1 的数据组织

可以看到,内存表的数据部分以数组的方式单独存放,而主键 id 索引里,存的是每个数据的位置。主键 id 是 hash 索引,可以看到索引上的 key 并不是有序的。在内存表 t1 中,当执行 select * 的时候,走的是全表扫描,也就是顺序扫描这个数组。因此,0 就是最后一个被读到,并放入结果集的数据。

可见,InnoDB 和 Memory 引擎的数据组织方式是不同的:

  • InnoDB 引擎把数据放在主键索引上,其他索引上保存的是主键 id。这种方式,我们称之为索引组织表(Index Organizied Table)。
  • 而 Memory 引擎采用的是把数据单独存放,索引上保存数据位置的数据组织形式,我们称之为堆组织表(Heap Organizied Table)。

从中我们可以看出,这两个引擎的一些典型不同:

  1. InnoDB 表的数据总是有序存放的,而内存表的数据就是按照写入顺序存放的;
  2. 当数据文件有空洞的时候,InnoDB 表在插入新数据的时候,为了保证数据有序性,只能在固定的位置写入新值,而内存表找到空位就可以插入新值;
  3. 数据位置发生变化的时候,InnoDB 表只需要修改主键索引,而内存表需要修改所有索引(也就说在 memory 表中建立的所有索引都会包含数据所在内存地址的指针,内存地址发生变化,所有指向该行数据的索引更新为修改变化后的指针);
  4. InnoDB 表用主键索引查询时需要走一次索引查找,用普通索引查询的时候,需要走两次索引查找。而内存表没有这个区别,所有索引的“地位”都是相同的。
  5. InnoDB 支持变长数据类型,不同记录的长度可能不同;内存表不支持 Blob 和 Text 字段,并且即使定义了 varchar(N),实际也当作 char(N),也就是固定长度字符串来存储,因此内存表的每行数据长度相同。

由于内存表的这些特性,每个数据行被删除以后,空出的这个位置都可以被接下来要插入的数据复用。比如,如果要在表 t1 中执行:

delete from t1 where id=5;
insert into t1 values(10,10);
select * from t1;

就会看到返回结果里,id=10 这一行出现在 id=4 之后,也就是原来 id=5 这行数据的位置。需要指出的是,表 t1 的这个主键索引是哈希索引,因此如果执行范围查询,比如

select * from t1 where id<5;

是用不上主键索引的,需要走全表扫描。那如果要让内存表支持范围扫描,应该怎么办呢 ?

hash 索引和 B-Tree 索引

实际上,内存表也是支 B-Tree 索引的。在 id 列上创建一个 B-Tree 索引,SQL 语句可以这么写:

alter table t1 add index a_btree_index using btree (id);

这时,表 t1 的数据组织形式就变成了这样:

表 t1 的数据组织 -- 增加 B-Tree 索引

这跟 InnoDB 的 b+ 树索引组织形式类似。作为对比,可以看一下这下面这两个语句的输出:

使用 B-Tree 和 hash 索引查询返回结果对比

可以看到,执行 select * from t1 where id<5 的时候,优化器会选择 B-Tree 索引,所以返回结果是 0 到 4。 使用 force index 强行使用主键 id 这个索引,id=0 这一行就在结果集的最末尾了。其实,一般在我们的印象中,内存表的优势是速度快,其中的一个原因就是 Memory 引擎支持 hash 索引。当然,更重要的原因是,内存表的所有数据都保存在内存,而内存的读写速度总是比磁盘快。

不建议在生产环境使用内存表

这里的原因主要包括两个方面:

  1. 锁粒度问题;
  2. 数据持久化问题。

内存表的锁

先来说说内存表的锁粒度问题。内存表不支持行锁,只支持表锁。因此,一张表只要有更新,就会堵住其他所有在这个表上的读写操作。需要注意的是,这里的表锁跟之前我们介绍过的 MDL 锁不同,但都是表级的锁。接下来,通过下面这个场景,模拟一下内存表的表级锁。

内存表的表锁 -- 复现步骤

在这个执行序列里,session A 的 update 语句要执行 50 秒,在这个语句执行期间 session B 的查询会进入锁等待状态。session C 的 show processlist 结果输出如下:

内存表的表锁 -- 结果

跟行锁比起来,表锁对并发访问的支持不够好。所以,内存表的锁粒度问题,决定了它在处理并发事务的时候,性能也不会太好。

数据持久性问题

接下来,我们再看看数据持久性的问题。数据放在内存中,是内存表的优势,但也是一个劣势。因为,数据库重启的时候,所有的内存表都会被清空。你可能会说,如果数据库异常重启,内存表被清空也就清空了,不会有什么问题啊。但是,在高可用架构下,内存表的这个特点简直可以当做 bug 来看待了。为什么这么说呢?

我们先看看 M-S 架构下,使用内存表存在的问题。

M-S 基本架构

我们来看一下下面这个时序:

  1. 业务正常访问主库;
  2. 备库硬件升级,备库重启,内存表 t1 内容被清空;
  3. 备库重启后,客户端发送一条 update 语句,修改表 t1 的数据行,这时备库应用线程就会报错“找不到要更新的行”。

这样就会导致主备同步停止。当然,如果这时候发生主备切换的话,客户端会看到,表 t1 的数据“丢失”了。

在上图中这种有 proxy 的架构里,大家默认主备切换的逻辑是由数据库系统自己维护的。这样对客户端来说,就是“网络断开,重连之后,发现内存表数据丢失了”。可能说这还好啊,毕竟主备发生切换,连接会断开,业务端能够感知到异常。但是,接下来内存表的这个特性就会让使用现象显得更“诡异”了。由于 MySQL 知道重启之后,内存表的数据会丢失。所以,担心主库重启之后,出现主备不一致,MySQL 在实现上做了这样一件事儿:在数据库重启之后,往 binlog 里面写入一行 DELETE FROM t1。如果你使用是如下图所示的双 M 结构的话:

双 M 结构

在备库重启的时候,备库 binlog 里的 delete 语句就会传到主库,然后把主库内存表的内容删除。这样你在使用的时候就会发现,主库的内存表数据突然被清空了。

双 M 结构下存在内存表导致主库表删除备库停止同步如何处理

如果你维护的 MySQL 系统里有内存表,怎么避免内存表突然丢数据,然后导致主备同步停止的情况?假设当时的业务场景暂时不允许你修改引擎,你可以加上什么自动化逻辑,来避免主备同步停止呢?那么就把备库的内存表引擎先都改成 InnoDB。对于每个内存表,执行

set sql_log_bin=off;
alter table tbl_name engine=innodb;

这样就能避免备库重启的时候,数据丢失的问题。由于主库重启后,会往 binlog 里面写 delete from tbl_name,这个命令传到备库,备库的同名的表数据也会被清空。因此,就不会出现主备同步停止的问题。

如果由于主库异常重启,触发了 HA,这时候我们之前修改过引擎的备库变成了主库。而原来的主库变成了新备库,在新备库上把所有的内存表(这时候表里没数据)都改成 InnoDB 表。所以,如果我们不能直接修改主库上的表引擎,可以配置一个自动巡检的工具,在备库上发现内存表就把引擎改了。同时,跟业务开发同学约定好建表规则,避免创建新的内存表。

总结

基于上面的分析,内存表并不适合在生产环境上作为普通数据表使用。但是内存表执行速度快呀,岂不是很浪费。这个问题,其实可以这么分析:

  1. 如果你的表更新量大,那么并发度是一个很重要的参考指标,InnoDB 支持行锁,并发度比内存表好;
  2. 能放到内存表的数据量都不大。如果你考虑的是读的性能,一个读 QPS 很高并且数据量不大的表,即使是使用 InnoDB,数据也是都会缓存在 InnoDB Buffer Pool 里的。因此,使用 InnoDB 表的读性能也不会差。

推荐使用的场景

所以,建议你把普通内存表都用 InnoDB 表来代替。但是,有一个场景却是例外的。这个场景就是,前面提到的用户临时表。在数据量可控,不会耗费过多内存的情况下,可以考虑使用内存表。

内存临时表刚好可以无视内存表的两个不足,主要是下面的三个原因:

  1. 临时表不会被其他线程访问,没有并发性的问题;
  2. 临时表重启后也是需要删除的,清空数据这个问题不存在;
  3. 备库的临时表也不会影响主库的用户线程。

现在,再看一下前面 join 语句优化的例子,当时建议的是创建一个 InnoDB 临时表,使用的语句序列是:

create temporary table temp_t(id int primary key, a int, b int, index(b))engine=innodb;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

了解了内存表的特性,其实这里使用内存临时表的效果更好,原因有三个:

  1. 相比于 InnoDB 表,使用内存表不需要写磁盘,往表 temp_t 的写数据的速度更快;
  2. 索引 b 使用 hash 索引,查找的速度比 B-Tree 索引快;
  3. 临时表数据只有 2000 行,占用的内存有限。

因此,你可以对该语句序列做一个改写,将临时表 temp_t 改成内存临时表,并且在字段 b 上创建一个 hash 索引。

create temporary table temp_t(id int primary key, a int, b int, index (b))engine=memory;
insert into temp_t select * from t2 where b>=1 and b<=2000;
select * from t1 join temp_t on (t1.b=temp_t.b);

使用内存临时表的执行效果

可以看到,不论是导入数据的时间,还是执行 join 的时间,使用内存临时表的速度都比使用 InnoDB 临时表要更快一些。

二十五、自增主键为什么不是连续的

业务设计依赖于自增主键的连续性,也就是说,这个设计假设自增主键是连续的。但实际上,这样的假设是错的,因为自增主键不能保证连续递增。那么什么情况下自增主键会出现 “空洞”?

为了便于说明,创建一个表 t,其中 id 是自增主键字段、c 是唯一索引。

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;

自增值保存在哪儿?

在这个空表 t 里面执行 insert into t values(null, 1, 1); 插入一行数据,再执行 show create table 命令,就可以看到如下图所示的结果:

自动生成的 AUTO_INCREMENT 值

可以看到,表定义里面出现了一个 AUTO_INCREMENT=2,表示下一次插入数据时,如果需要自动生成自增值,会生成 id=2。其实,这个输出结果容易引起这样的误解:自增值是保存在表结构定义里的。实际上,表的结构定义存放在后缀名为.frm 的文件中,但是并不会保存自增值。不同的引擎对于自增值的保存策略不同:

  • MyISAM 引擎的自增值保存在数据文件中。

  • InnoDB 引擎的自增值,其实是保存在了内存里,并且到了 MySQL 8.0 版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为 MySQL 重启前的值”,具体情况是:

    • 在 MySQL 5.7 及之前的版本,自增值保存在内存里,并没有持久化。每次重启后,第一次打开表的时候,都会去找自增值的最大值 max(id),然后将 max(id)+1 作为这个表当前的自增值。

      举例来说,如果一个表当前数据行里最大的 id 是 10,AUTO_INCREMENT=11。这时候,我们删除 id=10 的行,AUTO_INCREMENT 还是 11。但如果马上重启实例,重启后这个表的 AUTO_INCREMENT 就会变成 10。也就是说,MySQL 重启可能会修改一个表的 AUTO_INCREMENT 的值。

    • 在 MySQL 8.0 版本,将自增值的变更记录在了 redo log 中,重启的时候依靠 redo log 恢复重启之前的值。

理解了 MySQL 对自增值的保存策略以后,我们再看看自增值修改机制。

自增值修改机制

在 MySQL 里面,如果字段 id 被定义为 AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:

  1. 如果插入数据时 id 字段指定为 0、null 或未指定值,那么就把这个表当前的 AUTO_INCREMENT 值填到自增字段;
  2. 如果插入数据时 id 字段指定了具体的值,就直接使用语句里指定的值。

根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同。假设,某次要插入的值是 X,当前的自增值是 Y。

  1. 如果 X<Y,那么这个表的自增值不变;

  2. 如果 X≥Y,就需要把当前自增值修改为新的自增值。

    新的自增值生成算法是:从 auto_increment_offset 开始,以 auto_increment_increment 为步长,持续叠加,直到找到第一个大于 X 的值,作为新的自增值。

    其中,auto_increment_offsetauto_increment_increment 是两个系统参数,分别用来表示自增的初始值和步长,默认值都是 1。

    备注:在一些场景下,使用的就不全是默认值。比如,双 M 的主备结构里要求双写的时候,我们就可能会设置成 auto_increment_increment=2,让一个库的自增 id 都是奇数,另一个库的自增 id 都是偶数,避免两个库生成的主键发生冲突。

auto_increment_offsetauto_increment_increment 都是 1 的时候,新的自增值生成逻辑很简单,就是:如果准备插入的值 >= 当前自增值,新的自增值就是“准备插入的值 +1”;否则,自增值不变。这就引入了开头提到的问题,在这两个参数都设置为 1 的时候,自增主键 id 却不能保证是连续的,这是什么原因呢?要回答这个问题,我们就要看一下自增值的修改时机

1>唯一键冲突或者rollback导致事务回滚

假设,表 t 里面已经有了 (1,1,1) 这条记录,这时我再执行一条插入数据命令:

insert into t values(null, 1, 1); 

这个语句的执行流程就是:

  1. 执行器调用 InnoDB 引擎接口写入一行,传入的这一行的值是 (0,1,1);
  2. InnoDB 发现用户没有指定自增 id 的值,获取表 t 当前的自增值 2;
  3. 将传入的行的值改成 (2,1,1);
  4. 将表的自增值改成 3;
  5. 继续执行插入数据操作,由于已经存在 c=1 的记录,所以报 Duplicate key error,语句返回。

对应的执行流程图如下:

insert(null, 1,1) 唯一键冲突

可以看到,这个表的自增值改成 3,是在真正执行插入数据的操作之前。这个语句真正执行的时候,因为碰到唯一键 c 冲突,所以 id=2 这一行并没有插入成功,但也没有将自增值再改回去。所以,在这之后,再插入新的数据行时,拿到的自增 id 就是 3。也就是说,出现了自增主键不连续的情况。

如下图所示就是完整的演示结果。

一个自增主键 id 不连续的复现步骤

可以看到,这个操作序列复现了一个自增主键 id 不连续的现场 (没有 id=2 的行)。可见,唯一键冲突是导致自增主键 id 不连续的第一种原因。同样地,事务回滚也会产生类似的现象,这就是第二种原因。下面这个语句序列就可以构造不连续的自增 id

insert into t values(null,1,1);
begin;
insert into t values(null,2,2);
rollback;
insert into t values(null,2,2);
//插入的行是(3,2,2)

为什么事务回滚不回退自增值

为什么在出现唯一键冲突或者回滚的时候,MySQL 没有把表 t 的自增值改回去呢?如果把表 t 的当前自增值从 3 改回 2,再插入新数据的时候,不就可以生成 id=2 的一行数据了吗?其实,MySQL 这么设计是为了提升性能。接下来分析一下这个设计思路,看看自增值为什么不能回退。

假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增 id,肯定要加锁,然后顺序申请。

  1. 假设事务 A 申请到了 id=2, 事务 B 申请到 id=3,那么这时候表 t 的自增值是 4,之后继续执行。
  2. 事务 B 正确提交了,但事务 A 出现了唯一键冲突。
  3. 如果允许事务 A 把自增 id 回退,也就是把表 t 的当前自增值改回 2,那么就会出现这样的情况:表里面已经有 id=3 的行,而当前的自增 id 值是 2。
  4. 接下来,继续执行的其他事务就会申请到 id=2,然后再申请到 id=3。这时,就会出现插入语句报错“主键冲突”。

而为了解决这个主键冲突,有两种方法:

  1. 每次申请 id 之前,先判断表里面是否已经存在这个 id。如果存在,就跳过这个 id。但是,这个方法的成本很高。因为,本来申请 id 是一个很快的操作,现在还要再去主键索引树上判断 id 是否存在(这种方案逻辑上还是不连续的,后面插入的行补了前面回滚事务申请的自增 id 的坑,此时数据的插入顺序和自增 id 大小排序是不匹配的)。
  2. 把自增 id 的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增 id。这个方法的问题,就是锁的粒度太大,系统并发能力大大下降。

可见,这两个方法都会导致性能问题。造成这些麻烦的罪魁祸首,就是我们假设的这个“允许自增 id 回退”的前提导致的。因此,InnoDB 放弃了这个设计,语句执行失败也不回退自增 id。也正是因为这样,所以才只保证了自增 id 是递增的,但不保证是连续的

2>自增锁的优化

可以看到,自增 id 锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。其实,在 MySQL 5.1 版本之前,并不是这样的。接下来,先介绍下自增锁设计的历史,这样有助于分析接下来的一个问题。在 MySQL 5.0 版本的时候,自增锁的范围是语句级别。也就是说,如果一个语句申请了一个表自增锁,这个锁会等语句执行结束以后才释放。显然,这样设计会影响并发度。

5.1 版本自增锁优化及数据一致性考虑

MySQL 5.1.22 版本引入了一个新策略,新增参数 innodb_autoinc_lock_mode,默认值是 1。

  1. 这个参数的值被设置为 0 时,表示采用之前 MySQL 5.0 版本的策略,即语句执行结束后才释放锁;
  2. 这个参数的值被设置为 1 时:普通 insert 语句,自增锁在申请之后就马上释放;类似 insert … select 这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放;
  3. 这个参数的值被设置为 2 时,所有的申请自增主键的动作都是申请后就释放锁。

为什么默认设置下,insert … select 要使用语句级的锁?为什么这个参数的默认值不是 2?答案是,这么设计还是为了数据的一致性。一起来看一下这个场景:

批量插入数据的自增锁

在这个例子里,往表 t1 中插入了 4 行数据,然后创建了一个相同结构的表 t2,然后两个 session 同时执行向表 t2 中插入数据的操作。你可以设想一下,如果 session B 是申请了自增值以后马上就释放自增锁,那么就可能出现这样的情况:

  • session B 先插入了两个记录,(1,1,1)、(2,2,2);
  • 然后,session A 来申请自增 id 得到 id=3,插入了(3,5,5);
  • 之后,session B 继续执行,插入两条记录 (4,3,3)、 (5,4,4)。

最终导致为 t1 数据为(1,1,1)、(2,2,2)、(3,3,3)、(4,4,4),而 t2 数据为(1,1,1)、(2,2,2)、(3,5,5)、(4,3,3)、(5,4,4) 。或许也没关系吧,毕竟 session B 的语义本身就没有要求表 t2 的所有行的数据都跟 session A 相同。是的,从数据逻辑上看是对的。但是,如果我们现在的 binlog_format=statement,你可以设想下,binlog 会怎么记录呢?由于两个 session 是同时执行插入数据命令的,所以 binlog 里面对表 t2 的更新日志只有两种情况:要么先记 session A 的,要么先记 session B 的。

但不论是哪一种,这个 binlog 拿去从库执行,或者用来恢复临时实例,备库和临时实例里面,session B 这个语句执行出来,生成的结果里面,id 都是连续的。这时,这个库就发生了数据不一致。你可以分析一下,出现这个问题的原因是什么?其实,这是因为原库 session B 的 insert 语句,生成的 id 不连续。这个不连续的 id,用 statement 格式的 binlog 来串行执行,是执行不出来的。而要解决这个问题,有两种思路:

  1. 一种思路是,让原库的批量插入数据语句,固定生成连续的 id 值。所以,自增锁直到语句执行结束才释放,就是为了达到这个目的。
  2. 另一种思路是,在 binlog 里面把插入数据的操作都如实记录进来,到备库执行的时候,不再依赖于自增主键去生成。这种情况,其实就是 innodb_autoinc_lock_mode 设置为 2,同时 binlog_format 设置为 row。

因此,在生产上,尤其是有 insert … select 这种批量插入数据的场景时,从并发插入数据性能的角度考虑,建议这样设置:innodb_autoinc_lock_mode=2 ,并且 binlog_format=row. 这样做,既能提升并发性,又不会出现数据一致性问题。需要注意的是,这里说的批量插入数据,包含的语句类型是 insert … selectreplace … selectload data 语句

但是,在普通的 insert 语句里面包含多个 value 值的情况下,即使 innodb_autoinc_lock_mode 设置为 1,也不会等语句执行完成才释放锁。因为这类语句在申请自增 id 的时候,是可以精确计算出需要多少个 id 的,然后一次性申请,申请完成后锁就可以释放了。

针对会多次申请自增值的语句的优化

也就是说,批量插入数据的语句,之所以需要这么设置,是因为“不知道要预先申请多少个 id”。既然预先不知道要申请多少个自增 id,那么一种直接的想法就是需要一个时申请一个。但如果一个 select … insert 语句要插入 10 万行数据,按照这个逻辑的话就要申请 10 万次。显然,这种申请自增 id 的策略,在大批量插入数据的情况下,不但速度慢,还会影响并发插入的性能。

因此,对于批量插入数据的语句,MySQL 有一个批量申请自增 id 的策略:

  1. 语句执行过程中,第一次申请自增 id,会分配 1 个;
  2. 1 个用完以后,这个语句第二次申请自增 id,会分配 2 个;
  3. 2 个用完以后,还是这个语句,第三次申请自增 id,会分配 4 个;
  4. 依此类推,同一个语句去申请自增 id,每次申请到的自增 id 个数都是上一次的两倍。

举个例子,我们一起看看下面的这个语句序列:

insert into t values(null, 1,1);
insert into t values(null, 2,2);
insert into t values(null, 3,3);
insert into t values(null, 4,4);
create table t2 like t;
insert into t2(c,d) select c,d from t;
insert into t2 values(null, 5,5);

insert…select,实际上往表 t2 中插入了 4 行数据。但是,这四行数据是分三次申请的自增 id,第一次申请到了 id=1,第二次被分配了 id=2 和 id=3, 第三次被分配到 id=4 到 id=7。由于这条语句实际只用上了 4 个 id,所以 id=5 到 id=7 就被浪费掉了。之后,再执行 insert into t2 values(null, 5,5),实际上插入的数据就是(8,5,5)。这是主键 id 出现自增 id 不连续的第三种原因

备库自增主键问题

binlog_format=statement 时,语句 A 先获取 id=1,然后语句 B 获取 id=2;接着语句 B 提交,写 binlog,然后语句 A 再写 binlog。这时候,如果 binlog 重放,是不是会发生语句 B 的 id 为 1,而语句 A 的 id 为 2 的不一致情况呢?

首先,这个问题默认了“自增 id 的生成顺序,和 binlog 的写入顺序可能是不同的”,这个理解是正确的。其次,这个问题限定在 statement 格式下,也是对的。因为 row 格式的 binlog 就没有这个问题了,Write row event 里面直接写了每一行的所有字段的值。而至于为什么不会发生不一致的情况,我们来看一下下面的这个例子。

create table t(id int auto_increment primary key);
insert into t values(null);

insert 语句的 binlog

可以看到,在 insert 语句之前,还有一句 SET INSERT_ID=1。这条命令的意思是,这个线程里下一次需要用到自增值的时候,不论当前表的自增值是多少,固定用 1 这个值。这个 SET INSERT_ID 语句是固定跟在 insert 语句之前的,比如上面提到的场景,主库上语句 A 的 id 是 1,语句 B 的 id 是 2,但是写入 binlog 的顺序先 B 后 A,那么 binlog 就变成:

SET INSERT_ID=2;
语句B;
SET INSERT_ID=1;
语句A;

在备库上语句 B 用到的 INSERT_ID 依然是 2,跟主库相同。因此,即使两个 INSERT 语句在主备库的执行顺序不同,自增主键字段的值也不会不一致。

二十六、insert 语句加锁

前面提到 MySQL 对自增主键锁做了优化,尽量在申请到自增 id 以后,就释放自增锁。因此,insert 语句是一个很轻量的操作。不过,这个结论对于“普通的 insert 语句”才有效。也就是说,还有些 insert 语句是属于“特殊情况”的,在执行过程中需要给其他资源加锁,或者无法在申请到自增 id 以后就立马释放自增锁。

先导:例子

表 t 和 t2 的表结构、初始化数据语句如下

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `c` int(11) DEFAULT NULL,
  `d` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `c` (`c`)
) ENGINE=InnoDB;

insert into t values(null, 1,1);
insert into t values(null, 2,2);

insert into t values(null, 3,3);
insert into t values(null, 4,4);

create table t2 like t

insert … select 语句

现在,我们一起来看看为什么在可重复读隔离级别下,binlog_format=statement 时执行:

insert into t2(c,d) select c,d from t;

这个语句时,需要对表 t 的所有行和间隙加锁呢?其实,这个问题我们需要考虑的还是日志和数据的一致性。我们看下这个执行序列:

并发 insert 场景

实际的执行效果是,如果 session B 先执行,由于这个语句对表 t 主键索引加了 (-∞,1]这个 next-key lock,会在语句执行完成后,才允许 session A 的 insert 语句执行。但如果没有锁的话,就可能出现 session B 的 insert 语句先执行,但是后写入 binlog 的情况。于是,在 binlog_format=statement 的情况下,binlog 里面就记录了这样的语句序列:

insert into t values(-1,-1,-1);
insert into t2(c,d) select c,d from t;

这个语句到了备库执行,就会把 id=-1 这一行也写到表 t2 中,出现主备不一致。

insert 循环写入

当然了,执行 insert … select 的时候,对目标表也不是锁全表,而是只锁住需要访问的资源。如果现在有这么一个需求:要往表 t2 中插入一行数据,这一行的 c 值是表 t 中 c 值的最大值加 1。此时,我们可以这么写这条 SQL 语句 :

insert into t2(c,d)  (select c+1, d from t force index(c) order by c desc limit 1);

这个语句的加锁范围,就是表 t 索引 c 上的 (3,4](4,supremum]这两个 next-key lock,以及主键索引上 id=4 这一行。

它的执行流程也比较简单,从表 t 中按照索引 c 倒序,扫描第一行,拿到结果写入到表 t2 中。因此整条语句的扫描行数是 1。这个语句执行的慢查询日志(slow log),如下图所示:

慢查询日志 -- 将数据插入表 t2

通过这个慢查询日志,我们看到 Rows_examined=1,正好验证了执行这条语句的扫描行数为 1。

那么,如果我们是要把这样的一行数据插入到表 t 中的话:

insert into t(c,d)  (select c+1, d from t force index(c) order by c desc limit 1);

语句的执行流程是怎样的?扫描行数又是多少呢?这时候,我们再看慢查询日志就会发现不对了。

慢查询日志 -- 将数据插入表 t

可以看到,这时候的 Rows_examined 的值是 5。

看一下这条语句的 explain 结果。

将数据插入表t explain 结果

从 Extra 字段可以看到“Using temporary”字样,表示这个语句用到了临时表。也就是说,执行过程中,需要把表 t 的内容读出来,写入临时表。图中 rows 显示的是 1,我们不妨先对这个语句的执行流程做一个猜测:如果说是把子查询的结果读出来(扫描 1 行),写入临时表,然后再从临时表读出来(扫描 1 行),写回表 t 中。那么,这个语句的扫描行数就应该是 2,而不是 5。所以,这个猜测不对。实际上,Explain 结果里的 rows=1 是因为受到了 limit 1 的影响。从另一个角度考虑的话,我们可以看看 InnoDB 扫描了多少行。如下图所示,是在执行这个语句前后查看 Innodb_rows_read 的结果。

查看 Innodb_rows_read 变化

可以看到,这个语句执行前后,Innodb_rows_read 的值增加了 4。因为默认临时表是使用 Memory 引擎的,所以这 4 行查的都是表 t,也就是说对表 t 做了全表扫描。这样,我们就把整个执行过程理清楚了:

  1. 创建临时表,表里有两个字段 c 和 d。
  2. 按照索引 c 扫描表 t,依次取 c=4、3、2、1,然后回表,读到 c 和 d 的值写入临时表。这时,Rows_examined=4。
  3. 由于语义里面有 limit 1,所以只取了临时表的第一行,再插入到表 t 中。这时,Rows_examined 的值加 1,变成了 5。

也就是说,这个语句会导致在表 t 上做全表扫描,并且会给索引 c 上的所有间隙都加上共享的 next-key lock。所以,这个语句执行期间,其他事务不能在这个表上插入数据。至于这个语句的执行为什么需要临时表,原因是这类一边遍历数据,一边更新数据的情况,如果读出来的数据直接写回原表,就可能在遍历过程中,读到刚刚插入的记录,新插入的记录如果参与计算逻辑,就跟语义不符。

由于实现上这个语句没有在子查询中就直接使用 limit 1,从而导致了这个语句的执行需要遍历整个表 t。它的优化方法也比较简单,就是用前面介绍的方法,先自己直接 select 出来然后再使用 insert into 到临时表 temp_t,这样就只需要扫描一行;然后再从表 temp_t 里面取出这行数据插入表 t1。

当然,由于这个语句涉及的数据量很小,你可以考虑使用内存临时表来做这个优化。使用内存临时表优化时,语句序列的写法如下:

create temporary table temp_t(c int,d int) engine=memory;
insert into temp_t  (select c+1, d from t force index(c) order by c desc limit 1);
insert into t select * from temp_t;
drop table temp_t;

insert 唯一键冲突

前面的两个例子是使用 insert … select 的情况,接下来要介绍的这个例子就是最常见的 insert 语句出现唯一键冲突的情况。对于有唯一键的表,插入数据时出现唯一键冲突也是常见的情况了。先举一个简单的唯一键冲突的例子。唯一键冲突加锁

这个例子也是在可重复读(repeatable read)隔离级别下执行的。可以看到,session B 要执行的 insert 语句进入了锁等待状态。也就是说,session A 执行的 insert 语句,发生唯一键冲突的时候,并不只是简单地报错返回,还在冲突的索引上加了锁。我们前面说过,一个 next-key lock 就是由它右边界的值定义的。这时候,session A 持有索引 c 上的 (5,10]共享 next-key lock(读锁)。

至于为什么要加这个读锁,其实我也没有找到合理的解释。从作用上来看,这样做可以避免这一行被别的事务删掉。这里官方文档有一个描述错误,认为如果冲突的是主键索引,就加记录锁,唯一索引才加 next-key lock。但实际上,这两类索引冲突加的都是 next-key lock。

备注:这个 bug,是丁大在写这篇文章查阅文档时发现的,已经发给官方并被 verified 了。

死锁示例

有多个唯一索引的表中并发插入数据时,会出现死锁。这里先和你分享一个经典的死锁场景:

唯一键冲突 -- 死锁

在 session A 执行 rollback 语句回滚的时候,session C 几乎同时发现死锁并返回。这个死锁产生的逻辑是这样的:

  1. 在 T1 时刻,启动 session A,并执行 insert 语句,此时在索引 c 的 c=5 上加了记录锁。注意,这个索引是唯一索引,因此退化为记录锁 X but not gap lock。
  2. 在 T2 时刻,session B 要执行相同的 insert 语句,发现了唯一键冲突,尝试加上 S Next-key lock;同样地,session C 也在索引 c 上,c=5 这一个记录上,尝试加上 S Next-key lock;两者都被 session A 的 X but not gap lock 阻塞。
  3. T3 时刻,session A 回滚。这时候,session B 和 session C 都顺利获得 S Next-key lock,并且都要继续执行插入操作,继续尝试插入意向锁(LOCK_INSERT_INTENTION) 后插入数据,但是双方的插入意向锁动作都被双方的 S Next-key lock 阻塞,所以就出现了死锁。

insert into … on duplicate key update

上面这个例子是主键冲突后直接报错,如果是改写成

insert into t values(11,10,10) on duplicate key update d=100; 

的话,就会给索引 c 上 (5,10] 加一个排他的 next-key lock(写锁)。insert into … on duplicate key update 这个语义的逻辑是,插入一行数据,如果碰到唯一键约束,就执行后面的更新语句。

注意,如果有多个列违反了唯一性约束,就会按照索引的顺序,对和第一个索引冲突的行进行更新操作。假设现在表 t 里面已经有了 (1,1,1) 和 (2,2,2) 这两行,我们再来看看下面这个语句执行的效果:

两个唯一键同时冲突

可以看到,主键 id 是先判断的,MySQL 认为这个语句跟 id=2 这一行冲突,所以修改的是 id=2 的行。需要注意的是,执行这条语句的 affected rows 返回的是 2,很容易造成误解。实际上,真正更新的只有一行,只是在代码实现上,insert 和 update 都认为自己成功了,update 计数加了 1, insert 计数也加了 1。

总结

insert … select 是很常见的在两个表之间拷贝数据的方法。你需要注意,在可重复读隔离级别下

  • 这个语句会给 select 的表里扫描到的记录和间隙加读锁。

  • 而如果 insertselect 的对象是同一个表,则有可能会造成循环写入。这种情况下,我们需要引入用户临时表来做优化。

  • insert 语句如果出现唯一键冲突,会在冲突的唯一值上加共享的 next-key lock(S 锁)。因此,碰到由于唯一键约束导致报错后,要尽快提交或回滚事务,避免加锁时间过长。

二十七、怎么最快地复制一张表

简单地使用 insert … select 语句即可实现。当然,为了避免对源表加读锁,更稳妥的方案是先将数据写到外部文本文件,然后再写回目标表。这时,有两种常用的方法。接下来的内容,会详细展开一下这两种方法。

为了便于说明,还是先创建一个表 db1.t,并插入 1000 行数据,同时创建一个相同结构的表 db2.t

create database db1;
use db1;

create table t(id int primary key, a int, b int, index(a))engine=innodb;
delimiter ;;
  create procedure idata()
  begin
    declare i int;
    set i=1;
    while(i<=1000)do
      insert into t values(i,i,i);
      set i=i+1;
    end while;
  end;;
delimiter ;
call idata();

create database db2;
create table db2.t like db1.t

假设,我们要把 db1.t 里面 a>900 的数据行导出来,插入到 db2.t 中。

mysqldump 方法

一种方法是,使用 mysqldump 命令将数据导出成一组 INSERT 语句。你可以使用下面的命令:

mysqldump -h$host -P$port -u$user --add-locks=0 --no-create-info --single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --result-file=/client_tmp/t.sql

把结果输出到临时文件。这条命令中,主要参数含义如下:

  1. –single-transaction 的作用是,在导出数据的时候不需要对表 db1.t 加表锁,而是使用 START TRANSACTION WITH CONSISTENT SNAPSHOT 的方法;
  2. –add-locks 设置为 0,表示在输出的文件结果里,不增加" LOCK TABLES t WRITE;" ;
  3. –no-create-info 的意思是,不需要导出表结构;
  4. –set-gtid-purged=off 表示的是,不输出跟 GTID 相关的信息;
  5. –result-file 指定了输出文件的路径,其中 client 表示生成的文件是在客户端机器上的。

通过这条 mysqldump 命令生成的 t.sql 文件中就包含了如下图所示的 INSERT 语句

mysqldump 输出文件的部分结果

可以看到,一条 INSERT 语句里面会包含多个 value 对,这是为了后续用这个文件来写入数据的时候,执行速度可以更快。如果你希望生成的文件中一条 INSERT 语句只插入一行数据的话,可以在执行 mysqldump 命令时,加上参数–skip-extended-insert

然后,你可以通过下面这条命令,将这些 INSERT 语句放到 db2 库里去执行。

mysql -h127.0.0.1 -P13000  -uroot db2 -e "source /client_tmp/t.sql"

需要说明的是,source 并不是一条 SQL 语句,而是一个客户端命令。mysql 客户端执行这个命令的流程是这样的:

  1. 打开文件,默认以分号为结尾读取一条条的 SQL 语句;
  2. 将 SQL 语句发送到服务端执行。

也就是说,服务端执行的并不是这个 source t.sql 语句,而是 INSERT 语句。所以,不论是在慢查询日志(slow log),还是在 binlog,记录的都是这些要被真正执行的 INSERT 语句。

另一种应用场景是使用 mysqlbinlog 工具解析 binlog 文件,并应用到目标库的情况。可以使用下面这条命令 :

mysqlbinlog $binlog_file | mysql -h$host -P$port -u$user -p$pwd

把日志直接解析出来发给目标库执行。增加 local,就能让这个方法支持非本地的 $host

导出 CSV 文件

另一种方法是直接将结果导出成.csv 文件。MySQL 提供了下面的语法,用来将查询结果导出到服务端本地目录:

select * from db1.t where a>900 into outfile '/server_tmp/t.csv';

我们在使用这条语句时,需要注意如下几点。

  1. 这条语句会将结果保存在服务端。如果你执行命令的客户端和 MySQL 服务端不在同一个机器上,客户端机器的临时目录下是不会生成 t.csv 文件的。
  2. into outfile 指定了文件的生成位置(/server_tmp/),这个位置必须受参数 secure_file_priv 的限制。参数 secure_file_priv 的可选值和作用分别是:
    • 如果设置为 empty,表示不限制文件生成的位置,这是不安全的设置;
    • 如果设置为一个表示路径的字符串,就要求生成的文件只能放在这个指定的目录,或者它的子目录;
    • 如果设置为 NULL,就表示禁止在这个 MySQL 实例上执行 select … into outfile 操作。
  3. 这条命令不会帮你覆盖文件,因此你需要确保 /server_tmp/t.csv 这个文件不存在,否则执行语句时就会因为有同名文件的存在而报错。
  4. 这条命令生成的文本文件中,原则上一个数据行对应文本文件的一行。但是,如果字段中包含换行符,在生成的文本中也会有换行符。不过类似换行符、制表符这类符号,前面都会跟上“\”这个转义符,这样就可以跟字段之间、数据行之间的分隔符区分开。

得到.csv 导出文件后,你就可以用下面的 load data 命令将数据导入到目标表 db2.t 中。

load data infile '/server_tmp/t.csv' into table db2.t;

这条语句的执行流程如下所示。

  1. 打开文件 /server_tmp/t.csv,以制表符 (\t) 作为字段间的分隔符,以换行符(\n)作为记录之间的分隔符,进行数据读取;
  2. 启动事务。
  3. 判断每一行的字段数与表 db2.t 是否相同:
    • 若不相同,则直接报错,事务回滚;
    • 若相同,则构造成一行,调用 InnoDB 引擎接口,写入到表中。
  4. 重复步骤 3,直到 /server_tmp/t.csv 整个文件读入完成,提交事务。

如果 binlog_format=statement,这个 load 语句记录到 binlog 里以后,怎么在备库重放呢?由于 /server_tmp/t.csv 文件只保存在主库所在的主机上,如果只是把这条语句原文写到 binlog 中,在备库执行的时候,备库的本地机器上没有这个文件,就会导致主备同步停止。所以,这条语句执行的完整流程,其实是下面这样的。

  1. 主库执行完成后,将 /server_tmp/t.csv 文件的内容直接写到 binlog 文件中。

  2. 往 binlog 文件中写入语句 load data local infile ‘/tmp/SQL_LOAD_MB-1-0’ INTO TABLE db2.t

  3. 把这个 binlog 日志传到备库。

  4. 备库的 apply 线程在执行这个事务日志时:

    a. 先将 binlog 中 t.csv 文件的内容读出来,写入到本地临时目录 /tmp/SQL_LOAD_MB-1-0 中;

    b. 再执行 load data 语句,往备库的 db2.t 表中插入跟主库相同的数据。

执行流程如下图所示:

load data 的同步流程

注意,这里备库执行的 load data 语句里面,多了一个“local”。它的意思是“将执行这条命令的客户端所在机器的本地文件 /tmp/SQL_LOAD_MB-1-0 的内容,加载到目标表 db2.t 中”,而此时对于接受 binlog 的备库来说,local laod data 和 load data 都是加载在同一机器下的文件。也就是说,load data 命令有两种用法:

  1. 不加“local”,是读取服务端的文件,这个文件必须在 secure_file_priv 指定的目录或子目录下;
  2. 加上“local”,读取的是客户端的文件,只要 mysql 客户端有访问这个文件的权限即可。这时候,MySQL 客户端会先把本地文件传给服务端,然后执行上述的 load data 流程。

所以在通过 binlog 传输 load data 命令给备库的时候,加上 local 是为了确保备库应用 binlog 正常。因为备库可能配置了 secure_file_priv=null,所以如果不用 local 的话,可能会导入失败,造成主备同步延迟。

另外需要注意的是,select …into outfile 方法不会生成表结构文件, 所以我们导数据时还需要单独的命令得到表结构定义。mysqldump 提供了一个–tab 参数,可以同时导出表结构定义文件和 csv 数据文件。这条命令的使用方法如下:

mysqldump -h$host -P$port -u$user ---single-transaction  --set-gtid-purged=OFF db1 t --where="a>900" --tab=$secure_file_priv

这条命令会在 $secure_file_priv 定义的目录下,创建一个 t.sql 文件保存建表语句,同时创建一个 t.txt 文件保存 CSV 数据。

物理拷贝方法

前面我们提到的 mysqldump 方法和导出 CSV 文件的方法,都是逻辑导数据的方法,也就是将数据从表 db1.t 中读出来,生成文本,然后再写入目标表 db2.t 中。那么有物理导数据的方法吗?比如,直接把 db1.t 表的.frm 文件和.ibd 文件拷贝到 db2 目录下,是否可行呢?

答案是不行的。

因为,一个 InnoDB 表,除了包含这两个物理文件外,还需要在数据字典中注册。直接拷贝这两个文件的话,因为数据字典中没有 db2.t 这个表,系统是不会识别和接受它们的。不过,在 MySQL 5.6 版本引入了可传输表空间(transportable tablespace) 的方法,可以通过导出 + 导入表空间的方式,实现物理拷贝表的功能。

假设我们现在的目标是在 db1 库下,复制一个跟表 t 相同的表 r,具体的执行步骤如下:

  1. 执行 create table r like t,创建一个相同表结构的空表;
  2. 执行 alter table r discard tablespace,这时候 r.ibd 文件会被删除;
  3. 执行 flush table t for export,这时候 db1 目录下会生成一个 t.cfg 文件;
  4. db1 目录下执行 cp t.cfg r.cfgcp t.ibd r.ibd;这两个命令(这里需要注意的是,拷贝得到的两个文件,MySQL 进程要有读写权限);
  5. 执行 unlock tables,这时候 t.cfg 文件会被删除;
  6. 执行 alter table r import tablespace,将这个 r.ibd 文件作为表 r 的新的表空间,由于这个文件的数据内容和 t.ibd 是相同的,所以表 r 中就有了和表 t 相同的数据。

至此,拷贝表数据的操作就完成了。这个流程的执行过程图如下

物理拷贝表

关于拷贝表的这个流程,有以下几个注意点:

  1. 在第 3 步执行完 flsuh table 命令之后,db1.t 整个表处于只读状态,直到执行 unlock tables 命令后才释放读锁;
  2. 在执行 import tablespace 的时候,为了让文件里的表空间 id 和数据字典中的一致,会修改 r.ibd 的表空间 id。而这个表空间 id 存在于每一个数据页中。因此,如果是一个很大的文件(比如 TB 级别),每个数据页都需要修改,所以你会看到这个 import 语句的执行是需要一些时间的。当然,如果是相比于逻辑导入的方法,import 语句的耗时是非常短的。

总结对比

我们来对比一下这三种方法的优缺点。

  1. 物理拷贝的方式速度最快,尤其对于大表拷贝来说是最快的方法。如果出现误删表的情况,用备份恢复出误删之前的临时库,然后再把临时库中的表拷贝到生产库上,是恢复数据最快的方法。但是,这种方法的使用也有一定的局限性:
    • 必须是全表拷贝,不能只拷贝部分数据;
    • 需要到服务器上拷贝数据,在用户无法登录数据库主机的场景下无法使用;
    • 由于是通过拷贝物理文件实现的,源表和目标表都是使用 InnoDB 引擎时才能使用。
  2. 用 mysqldump 生成包含 INSERT 语句文件的方法,可以在 where 参数增加过滤条件,来实现只导出部分数据。这个方式的不足之一是,不能使用 join 这种比较复杂的 where 条件写法。
  3. select … into outfile 的方法是最灵活的,支持所有的 SQL 写法。但,这个方法的缺点之一就是,每次只能导出一张表的数据,而且表结构也需要另外的语句单独备份。后两种方式都是逻辑备份方式,是可以跨引擎使用的。

二十八、grant之后要跟着flush privileges吗?

在 MySQL 里面,grant 语句是用来给用户赋权的。不知道你有没有见过一些操作文档里面提到,grant 之后要马上跟着执行一个 flush privileges 命令,才能使赋权语句生效。那么,grant 之后真的需要执行 flush privileges 吗?如果没有执行这个 flush 命令的话,赋权语句真的不能生效吗?

为了便于说明,先创建一个用户:

create user 'ua'@'%' identified by 'pa';

这条语句的逻辑是创建一个用户’ua’@’%’,密码是 pa。注意,在 MySQL 里面,用户名 (user)+ 地址 (host) 才表示一个用户,因此 ua@ip1ua@ip2 代表的是两个不同的用户。

这条命令做了两个动作:

  1. 磁盘上,往 mysql.user 表里插入一行,由于没有指定权限,所以这行数据上所有表示权限的字段的值都是 N;
  2. 内存里,往数组 acl_users 里插入一个 acl_user 对象,这个对象的 access 字段值为 0。

下图就是这个时刻用户 uauser 表中的状态。

mysql.user 数据行

在 MySQL 中,用户权限是有不同的范围的。接下来,就按照用户权限范围从大到小的顺序依次说明。

全局权限

全局权限,作用于整个 MySQL 实例,这些权限信息保存在 mysql 库的 user 表里。如果要给用户 ua 赋一个最高权限的话,语句是这么写的:

grant all privileges on *.* to 'ua'@'%' with grant option;

这个 grant 命令做了两个动作:

  1. 磁盘上,将 mysql.user 表里,用户’ua’@’%'这一行的所有表示权限的字段的值都修改为‘Y’;
  2. 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 值(权限位)修改为二进制的“全 1”。

在这个 grant 命令执行完成后,如果有新的客户端使用用户名 ua 登录成功,MySQL 会为新连接维护一个线程对象,然后从 acl_users 数组里查到这个用户的权限,并将权限值拷贝到这个线程对象中。之后在这个连接中执行的语句,所有关于全局权限的判断,都直接使用线程对象内部保存的权限位。

基于上面的分析我们可以知道:

  1. grant 命令对于全局权限,同时更新了磁盘和内存。命令完成后即时生效,接下来新创建的连接会使用新的权限。
  2. 对于一个已经存在的连接,它的全局权限不受 grant 命令的影响。

需要说明的是,一般在生产环境上要合理控制用户权限的范围。上面用到的这个 grant 语句就是一个典型的错误示范。如果一个用户有所有权限,一般就不应该设置为所有 IP 地址都可以访问。如果要回收上面的 grant 语句赋予的权限,可以使用下面这条命令:

revoke all privileges on *.* from 'ua'@'%';

这条 revoke 命令的用法与 grant 类似,做了如下两个动作:

  1. 磁盘上,将 mysql.user 表里,用户’ua’@’%'这一行的所有表示权限的字段的值都修改为“N”;
  2. 内存里,从数组 acl_users 中找到这个用户对应的对象,将 access 的值修改为 0。

db 权限

除了全局权限,MySQL 也支持库级别的权限定义。如果要让用户 ua 拥有库 db1 的所有权限,可以执行下面这条命令:

grant all privileges on db1.* to 'ua'@'%' with grant option;

基于库的权限记录保存在 mysql.db 表中,在内存里则保存在数组 acl_dbs 中。这条 grant 命令做了如下两个动作:

  1. 磁盘上,往 mysql.db 表中插入了一行记录,所有权限位字段设置为“Y”;
  2. 内存里,增加一个对象到数组 acl_dbs 中,这个对象的权限位为“全 1”。

下图就是这个时刻用户 uadb 表中的状态。

mysql.db 数据行

每次需要判断一个用户对一个数据库读写权限的时候,都需要遍历一次 acl_dbs 数组,根据 userhostdb 找到匹配的对象,然后根据对象的权限位来判断。也就是说,grant 修改 db 权限的时候,是同时对磁盘和内存生效的。

grant 操作对于已经存在的连接的影响,在全局权限和基于 db 的权限效果是不同的。接下来,我们做一个对照试验来分别看一下。

权限操作效果

需要说明的是,图中 set global sync_binlog 这个操作是需要 super 权限的。

可以看到,虽然用户 ua 的 super 权限在 T3 时刻已经通过 revoke 语句回收了,但是在 T4 时刻执行 set global 的时候,权限验证还是通过了。这是因为 super 是全局权限,这个权限信息在线程对象中,而 revoke 操作影响不到这个线程对象。

而在 T5 时刻去掉 uadb1 库的所有权限后,在 T6 时刻 session B 再操作 db1 库的表,就会报错“权限不足”。这是因为 acl_dbs 是一个全局数组,所有线程判断 db 权限都用这个数组,这样 revoke 操作马上就会影响到 session B。

这里在代码实现上有一个特别的逻辑,如果当前会话已经处于某一个 db 里面,之前 use 这个库的时候拿到的库权限会保存在会话变量中。你可以看到在 T6 时刻,session C 和 session B 对表 t 的操作逻辑是一样的。但是 session B 报错,而 session C 可以执行成功。这是因为 session C 在 T2 时刻执行的 use db1,拿到了这个库的权限,在切换出 db1 库之前,session C 对这个库就一直有权限。

表权限和列权限

除了 db 级别的权限外,MySQL 支持更细粒度的表权限和列权限。其中,表权限定义存放在表 mysql.tables_priv 中,列权限定义存放在表 mysql.columns_priv 中。这两类权限,组合起来存放在内存的 hash 结构 column_priv_hash 中。

这两类权限的赋权命令如下:

create table db1.t1(id int, a int);

grant all privileges on db1.t1 to 'ua'@'%' with grant option;
GRANT SELECT(id), INSERT (id,a) ON mydb.mytbl TO 'ua'@'%' with grant option;

db 权限类似,这两个权限每次 grant 的时候都会修改数据表,也会同步修改内存中的 hash 结构。因此,对这两类权限的操作,也会马上影响到已经存在的连接。

是否需要 flush privileges

看上去 grant 语句都是即时生效的,那这么看应该就不需要执行 flush privileges 语句了呀。答案也确实是这样的。flush privileges 命令会清空 acl_users 数组,然后从 mysql.user 表中读取数据重新加载,重新构造一个 acl_users 数组。也就是说,以数据表中的数据为准,会将全局权限内存数组重新加载一遍。

同样地,对于 db 权限、表权限和列权限,MySQL 也做了这样的处理。也就是说,如果内存的权限数据和磁盘数据表相同的话,不需要执行 flush privileges。而如果我们都是用 grant/revoke 语句来执行的话,内存和数据表本来就是保持同步更新的。

因此,正常情况下,grant 命令之后,没有必要跟着执行 flush privileges 命令。

flush privileges 使用场景

那么,flush privileges 是在什么时候使用呢?显然,当数据表中的权限数据跟内存中的权限数据不一致的时候,flush privileges 语句可以用来重建内存数据,达到一致状态。这种不一致往往是由不规范的操作导致的,比如直接用 DML 语句操作系统权限表。我们来看一下下面这个场景:

使用 flush privileges

可以看到,T3 时刻虽然已经用 delete 语句删除了用户 ua,但是在 T4 时刻,仍然可以用 ua 连接成功。原因就是,这时候内存中 acl_users 数组中还有这个用户,因此系统判断时认为用户还正常存在。在 T5 时刻执行过 flush 命令后,内存更新,T6 时刻再要用 ua 来登录的话,就会报错“无法访问”了。直接操作系统表是不规范的操作,这个不一致状态也会导致一些更“诡异”的现象发生。比如,前面这个通过 delete 语句删除用户的例子,就会出现下面的情况:

不规范权限操作导致的异常

可以看到,由于在 T3 时刻直接删除了数据表的记录,而内存的数据还存在。这就导致了:

  1. T4 时刻给用户 ua 赋权限失败,因为 mysql.user 表中找不到这行记录;
  2. 而 T5 时刻要重新创建这个用户也不行,因为在做内存判断的时候,会认为这个用户还存在。

总结

grant 语句会同时修改数据表和内存,判断权限的时候使用的是内存数据。因此,规范地使用 grantrevoke 语句,是不需要随后加上 flush privileges 语句的。flush privileges 语句本身会用数据表的数据重建一份内存权限数据,所以在权限数据可能存在不一致的情况下再使用。而这种不一致往往是由于直接用 DML 语句操作系统权限表导致的,所以我们尽量不要使用这类语句。

另外,在使用 grant 语句赋权时,你可能还会看到这样的写法:

grant super on *.* to 'ua'@'%' identified by 'pa';

这条命令加了 identified by ‘密码’, 语句的逻辑里面除了赋权外,还包含了:如果用户’ua’@’%'不存在,就创建这个用户,密码是 pa;如果用户 ua 已经存在,就将密码修改成 pa。这也是一种不建议的写法,因为这种写法很容易就会不慎把密码给改了。

二十九、要不要使用分区表?

分区表是什么?

为了说明分区表的组织形式,先创建一个表 t:

CREATE TABLE `t` (
  `ftime` datetime NOT NULL,
  `c` int(11) DEFAULT NULL,
  KEY (`ftime`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
 PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
 PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);
insert into t values('2017-4-1',1),('2018-4-1',1);

表 t 的磁盘文件

在表 t 中初始化插入了两行记录,按照定义的分区规则,这两行记录分别落在 p_2018 和 p_2019 这两个分区上。可以看到,这个表包含了一个.frm 文件和 4 个.ibd 文件,每个分区对应一个.ibd 文件。也就是说:

  • 对于引擎层来说,这是 4 个表;
  • 对于 Server 层来说,这是 1 个表。

分区表的引擎层行为

先举个在分区表加间隙锁的例子,目的是说明对于 InnoDB 来说,这是 4 个表。

分区表间隙锁示例

初始化表 t 的时候,只插入了两行数据, ftime 的值分别是,‘2017-4-1’ 和’2018-4-1’ 。session A 的 select 语句对索引 ftime 上这两个记录之间的间隙加了锁。如果是一个普通表的话,那么 T1 时刻,在表 t 的 ftime 索引上,间隙和加锁状态应该是图 3 这样的。

普通表的加锁范围

也就是说,‘2017-4-1’ 和’2018-4-1’ 这两个记录之间的间隙是会被锁住的。那么,sesion B 的两条插入语句应该都要进入锁等待状态。

但是,从上面的实验效果可以看出,session B 的第一个 insert 语句是可以执行成功的。这是因为,对于引擎来说,p_2018 和 p_2019 是两个不同的表,也就是说 2017-4-1 的下一个记录并不是 2018-4-1,而是 p_2018 分区的 supremum。所以 T1 时刻,在表 t 的 ftime 索引上,间隙和加锁的状态其实是下图这样的:

分区表 t 的加锁范围

由于分区表的规则,session A 的 select 语句其实只操作了分区 p_2018,因此加锁范围就是上图中深绿色的部分。所以,session B 要写入一行 ftime 是 2018-2-1 的时候是可以成功的,而要写入 2017-12-1 这个记录,就要等 session A 的间隙锁。下图就是这时候的 show engine innodb status 的部分结果。

session B 被锁住信息

看完 InnoDB 引擎的例子,我们再来一个 MyISAM 分区表的例子。首先用 alter table t engine=myisam,把表 t 改成 MyISAM 表;然后,再用下面这个例子说明,对于 MyISAM 引擎来说,这是 4 个表。

用 MyISAM 表锁验证

在 session A 里面,用 sleep(100) 将这条语句的执行时间设置为 100 秒。由于 MyISAM 引擎只支持表锁,所以这条 update 语句会锁住整个表 t 上的读。但我们看到的结果是,session B 的第一条查询语句是可以正常执行的,第二条语句才进入锁等待状态。

这正是因为 MyISAM 的表锁是在引擎层实现的,session A 加的表锁,其实是锁在分区 p_2018 上。因此,只会堵住在这个分区上执行的查询,落到其他分区的查询是不受影响的。

看上去分区表看来还不错嘛,为什么很多公司规范都不让用呢?我们使用分区表的一个重要原因就是单表过大。那么,如果不使用分区表的话,我们就是要使用手动分表的方式。接下来,我们一起看看手动分表和分区表有什么区别。

比如,按照年份来划分,我们就分别创建普通表 t_2017、t_2018、t_2019 等等。手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新。在性能上,这和分区表并没有实质的差别。分区表和手工分表,一个是由 server 层来决定使用哪个分区,一个是由应用层代码来决定使用哪个分表。因此,从引擎层看,这两种方式也是没有差别的。其实这两个方案的区别,主要是在 server 层上。从 server 层看,我们就不得不提到分区表一个被广为诟病的问题:打开表的行为。

分区策略

每当第一次访问一个分区表的时候,MySQL 需要把所有的分区都访问一遍。一个典型的报错情况是这样的:如果一个分区表的分区很多,比如超过了 1000 个,而 MySQL 启动的时候,open_files_limit 参数使用的是默认值 1024,那么就会在访问这个表的时候,由于需要打开所有的文件,导致打开表文件的个数超过了上限而报错。下图就是创建的一个包含了很多分区的表 t_myisam,执行一条插入语句后报错的情况。

insert 语句报错

可以看到,这条 insert 语句,明显只需要访问一个分区,但语句却无法执行。可以看出这个表用的是 MyISAM 引擎。是的,因为使用 InnoDB 引擎的话,并不会出现这个问题。

  • MyISAM 分区表使用的分区策略,我们称为通用分区策略(generic partitioning),每次访问分区都由 server 层控制。通用分区策略,是 MySQL 一开始支持分区表的时候就存在的代码,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题。

  • 从 MySQL 5.7.9 开始,InnoDB 引擎引入了本地分区策略(native partitioning)。这个策略是在 InnoDB 内部自己管理打开分区的行为。

  • MySQL 从 5.7.17 开始,将 MyISAM 分区表标记为即将弃用 (deprecated),意思是“从这个版本开始不建议这么使用,请使用替代方案。在将来的版本中会废弃这个功能”。

  • 从 MySQL 8.0 版本开始,就不允许创建 MyISAM 分区表了,只允许创建已经实现了本地分区策略的引擎。目前来看,只有 InnoDB 和 NDB 这两个引擎支持了本地分区策略。

分区表的 server 层行为

接下来,我们再看一下分区表在 server 层的行为。如果从 server 层看的话,一个分区表就只是一个表。这句话是什么意思呢?接下来就用下面这个例子来和你说明。如下面两图所示,分别是这个例子的操作序列和执行结果图。

分区表的 MDL 锁

show processlist 结果

可以看到,虽然 session B 只需要操作 p_2107 这个分区,但是由于 session A 持有整个表 t 的 MDL 锁,就导致了 session B 的 alter 语句被堵住。这也是 DBA 同学经常说的,分区表,在做 DDL 的时候,影响会更大。如果你使用的是普通分表,那么当你在 truncate 一个分表的时候,肯定不会跟另外一个分表上的查询语句,出现 MDL 锁冲突。

小结

  1. MySQL 在第一次打开分区表的时候,需要访问所有的分区;
  2. 在 server 层,认为这是同一张表,因此所有分区共用同一个 MDL 锁;
  3. 在引擎层,认为这是不同的表,因此 MDL 锁之后的执行过程,会根据分区表规则,只访问必要的分区。

而关于“必要的分区”的判断,就是根据 SQL 语句中的 where 条件,结合分区规则来实现的。比如我们上面的例子中,where ftime=‘2018-4-1’,根据分区规则 year 函数算出来的值是 2018,那么就会落在 p_2019 这个分区。

但是,如果这个 where 条件改成 where ftime>=‘2018-4-1’,虽然查询结果相同,但是这时候根据 where 条件,就要访问 p_2019 和 p_others 这两个分区。

如果查询语句的 where 条件中没有分区 key,那就只能访问所有分区了。当然,这并不是分区表的问题。即使是使用业务分表的方式,where 条件中没有使用分表的 key,也必须访问所有的分表。

分区表的应用场景

我们已经理解了分区表的概念,那么什么场景下适合使用分区表呢?

分区表的一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区表的业务代码更简洁。

还有,分区表可以很方便的清理历史数据。如果一项业务跑的时间足够长,往往就会有根据时间删除历史数据的需求。这时候,按照时间分区的分区表,就可以直接通过 alter table t drop partition …这个语法删掉分区,从而删掉过期的历史数据。这个 alter table t drop partition …操作是直接删除分区文件,效果跟 drop 普通表类似。与使用 delete 语句删除数据相比,优势是速度快、对系统影响小。

分区表自增主键

表中没有用到自增主键,假设现在要创建一个自增字段 id。MySQL 要求分区表中的主键必须包含分区字段。如果要在表 t 的基础上做修改,你会怎么定义这个表的主键呢?为什么这么定义呢?

这时候就有两种可选:一种是 (ftime, id),另一种是 (id, ftime)。

如果从利用率上来看,应该使用 (ftime, id) 这种模式。因为用 ftime 做分区 key,说明大多数语句都是包含 ftime 的,使用这种模式,可以利用前缀索引的规则,减少一个索引。

这时的建表语句是:

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `ftime` datetime NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`ftime`,`id`)
) ENGINE=MyISAM DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = MyISAM,
 PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = MyISAM,
 PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = MyISAM,
 PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = MyISAM);

建议尽量使用 InnoDB 引擎。InnoDB 表要求至少有一个索引,以自增字段作为第一个字段,所以需要加一个 id 的单独索引。

CREATE TABLE `t` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `ftime` datetime NOT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`ftime`,`id`),
  KEY `id` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1
PARTITION BY RANGE (YEAR(ftime))
(PARTITION p_2017 VALUES LESS THAN (2017) ENGINE = InnoDB,
 PARTITION p_2018 VALUES LESS THAN (2018) ENGINE = InnoDB,
 PARTITION p_2019 VALUES LESS THAN (2019) ENGINE = InnoDB,
 PARTITION p_others VALUES LESS THAN MAXVALUE ENGINE = InnoDB);

当然把字段反过来,创建成:

  PRIMARY KEY (`id`,`ftime`),
  KEY `id` (`ftime`)

总结

需要注意的是,上面是以范围分区(range)为例介绍分区表的。实际上,MySQL 还支持 hash 分区、list 分区等分区方法。可以在需要用到的时候,再翻翻手册

实际使用时,分区表跟用户分表比起来,有两个绕不开的问题:

  1. 一个是第一次访问的时候需要访问所有分区,
  2. 另一个是共用 MDL 锁。

因此,如果要使用分区表,就不要创建太多的分区。曾经有一个用户做了按天分区策略,然后预先创建了 10 年的分区。这种情况下,访问分区表的性能自然是不好的。这里有两个问题需要注意:

  1. 分区并不是越细越好。实际上,单表或者单分区的数据一千万行,只要没有特别大的索引,对于现在的硬件能力来说都已经是小表了。
  2. 分区也不要提前预留太多,在使用之前预先创建即可。比如,如果是按月分区,每年年底时再把下一年度的 12 个新分区创建上即可。对于没有数据的历史分区,要及时的 drop 掉。

至于分区表的其他问题,比如查询需要跨多个分区取数据,查询性能就会比较慢,基本上就不是分区表本身的问题,而是数据量的问题或者说是使用方式的问题了。当然,如果你的团队已经维护了成熟的分库分表中间件,用业务分表,对业务开发同学没有额外的复杂性,对 DBA 也更直观,自然是更好的。

三十、MySQL 中的各种自增 ID

MySQL 里有很多自增的 id,每个自增 id 都是定义了初始值,然后不停地往上加步长。虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型 (unsigned int) 是 4 个字节,上限就是 232-1。既然自增 id 有上限,就有可能被用完。但是,自增 id 用完了会怎么样呢?

表定义自增值 id

表定义的自增值达到上限后的逻辑是:再申请下一个 id 时,得到的值保持不变。我们可以通过下面这个语句序列验证一下:

create table t(id int unsigned auto_increment primary key) auto_increment=4294967295;
insert into t values(null);
//成功插入一行 4294967295
show create table t;
/* CREATE TABLE `t` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4294967295;
*/

insert into t values(null);
//Duplicate entry '4294967295' for key 'PRIMARY'

可以看到,第一个 insert 语句插入数据成功后,这个表的 AUTO_INCREMENT 没有改变(还是 4294967295),就导致了第二个 insert 语句又拿到相同的自增 id 值,再试图执行插入语句,报主键冲突错误。

232-1(4294967295)不是一个特别大的数,对于一个频繁插入删除数据的表来说,是可能会被用完的。因此在建表的时候你需要考察你的表是否有可能达到这个上限,如果有可能,就应该创建成 8 个字节的 bigint unsigned。

InnoDB 系统自增 row_id

如果你创建的 InnoDB 表没有指定主键,那么 InnoDB 会给你创建一个不可见的,长度为 6 个字节的 row_id。InnoDB 维护了一个全局的 dict_sys.row_id 值,所有无主键的 InnoDB 表,每插入一行数据,都将当前的 dict_sys.row_id 值作为要插入数据的 row_id,然后把 dict_sys.row_id 的值加 1。

实际上,在代码实现时 row_id 是一个长度为 8 字节的无符号长整型 (bigint unsigned)。但是,InnoDB 在设计时,给 row_id 留的只是 6 个字节的长度,这样写到数据表中时只放了最后 6 个字节,所以 row_id 能写到数据表中的值,就有两个特征:

  1. row_id 写入表中的值范围,是从 0 到 248-1;
  2. dict_sys.row_id=248时,如果再有插入数据的行为要来申请 row_id,拿到以后再取最后 6 个字节的话就是 0。

也就是说,写入表的 row_id 是从 0 开始到 248-1。达到上限后,下一个值就是 0,然后继续循环。

当然,248-1 这个值本身已经很大了,但是如果一个 MySQL 实例跑得足够久的话,还是可能达到这个上限的。在 InnoDB 逻辑里,申请到 row_id=N 后,就将这行数据写入表中;如果表中已经存在 row_id=N 的行,新写入的行就会覆盖原有的行。要验证这个结论的话,可以通过 gdb 修改系统的自增 row_id 来实现。注意,用 gdb 改变量这个操作是为了便于我们复现问题,只能在测试环境使用。

row_id 用完的验证序列

row_id 用完的效果验证

可以看到,在用 gdb 将 dict_sys.row_id 设置为 248之后,再插入的 a=2 的行会出现在表 t 的第一行,因为这个值的 row_id=0。之后再插入的 a=3 的行,由于 row_id=1,就覆盖了之前 a=1 的行,因为 a=1 这一行的 row_id 也是 1。

从这个角度看,我们还是应该在 InnoDB 表中主动创建自增主键。因为,表自增 id 到达上限后,再插入数据时报主键冲突错误,是更能被接受的。毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性;报主键冲突,是插入失败,影响的是可用性。而一般情况下,可靠性优先于可用性。

Xid

在介绍 redo log 和 binlog 相配合的时候,提到了它们有一个共同的字段叫作 Xid。它在 MySQL 中是用来对应事务的。那么,Xid 在 MySQL 内部是怎么生成的呢?

MySQL 内部维护了一个全局变量 global_query_id,每次执行语句的时候将它赋值给 Query_id,然后给这个变量加 1。如果当前语句是这个事务执行的第一条语句,那么 MySQL 还会同时把 Query_id 赋值给这个事务的 Xid。

global_query_id 是一个纯内存变量,重启之后就清零了。所以在同一个数据库实例中,不同事务的 Xid 也是有可能相同的。但是 MySQL 重启之后会重新生成新的 binlog 文件,这就保证了,同一个 binlog 文件里,Xid 一定是唯一的

虽然 MySQL 重启不会导致同一个 binlog 里面出现两个相同的 Xid,但是如果 global_query_id 达到上限后,就会继续从 0 开始计数。从理论上讲,还是就会出现同一个 binlog 里面出现相同 Xid 的场景。因为 global_query_id 定义的长度是 8 个字节,这个自增值的上限是 264-1。要出现这种情况,必须是下面这样的过程:

  1. 执行一个事务,假设 Xid 是 A;
  2. 接下来执行 264次查询语句,让 global_query_id 回到 A;
  3. 再启动一个事务,这个事务的 Xid 也是 A。

不过,264这个值太大了,大到你可以认为这个可能性只会存在于理论上。

Innodb trx_id

Xid 和 InnoDB 的 trx_id 是两个容易混淆的概念。**Xid 是由 server 层维护的。InnoDB 内部使用 Xid,就是为了能够在 InnoDB 事务和 server 之间做关联。但是,InnoDB 自己的 trx_id,是另外维护的。**其实这个 trx_id 就是事务隔离中的实现事务可见性用到的事务 id(transaction id)。

InnoDB 内部维护了一个 max_trx_id 全局变量,每次需要申请一个新的 trx_id 时,就获得 max_trx_id 的当前值,然后并将 max_trx_id 加 1。InnoDB 数据可见性的核心思想是:每一行数据都记录了更新它的 trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的 trx_id 做对比。对于正在执行的事务,你可以从 information_schema.innodb_trx 表中看到事务的 trx_id

他查看了一下 innodb_trx,发现这个事务的 trx_id 是一个很大的数(281479535353408),而且似乎在同一个 session 中启动的会话得到的 trx_id 是保持不变的。当执行任何加写锁的语句后,trx_id 都会变成一个很小的数字(118378)。

事务的 trx_id

session B 里,从 innodb_trx 表里查出的这两个字段,第二个字段 trx_mysql_thread_id 就是线程 id。显示线程 id,是为了说明这两次查询看到的事务对应的线程 id 都是 5,也就是 session A 所在的线程。

可以看到,T2 时刻显示的 trx_id 是一个很大的数;T4 时刻显示的 trx_id 是 1289,看上去是一个比较正常的数字。这是什么原因呢?实际上,在 T1 时刻,session A 还没有涉及到更新,是一个只读事务。而对于只读事务,InnoDB 并不会分配 trx_id。也就是说:

  1. 在 T1 时刻,trx_id 的值其实就是 0。而这个很大的数,只是显示用的。下面会提到这个数据的生成逻辑。
  2. 直到 session A 在 T3 时刻执行 insert 语句的时候,InnoDB 才真正分配了 trx_id。所以,T4 时刻,session B 查到的这个 trx_id 的值就是 1289。

需要注意的是,除了显而易见的修改类语句外,如果在 select 语句后面加上 for update,这个事务也不是只读事务。

另外,虽然说 max_trx_id 是按步长 1 递增的,但是:

  1. updatedelete 语句除了事务本身,还涉及到标记删除旧数据,也就是要把数据放到 purge 队列里等待后续物理删除,这个操作也会把 max_trx_id+1, 因此在一个事务中至少加 2;
  2. InnoDB 的后台操作,比如表的索引信息统计这类操作,也是会启动内部事务的,因此你可能看到,trx_id 值并不是按照加 1 递增的。

那么,T2 时刻查到的这个很大的数字是怎么来的呢?

其实,这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的 trx 变量的指针地址转成整数,再加上 248。使用这个算法,就可以保证以下两点:

  1. 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在 innodb_trx 还是在 innodb_locks 表里,同一个只读事务查出来的 trx_id 就会是一样的。
  2. 如果有并行的多个只读事务,每个事务的 trx 变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的 trx_id 就是不同的。

那么,**为什么还要再加上 248呢?**在显示值里面加上 248,目的是要保证只读事务显示的 trx_id 值比较大,正常情况下就会区别于读写事务的 id。但是,trx_idrow_id 的逻辑类似,定义长度也是 8 个字节。因此,在理论上还是可能出现一个读写事务与一个只读事务显示的 trx_id 相同的情况。不过这个概率很低,并且也没有什么实质危害,可以不管它。

另一个问题是,只读事务不分配 trx_id,有什么好处呢?

  • 一个好处是,这样做可以减小事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,是不影响数据的可见性判断的。所以,在创建事务的一致性视图时,InnoDB 就只需要拷贝读写事务的 trx_id
  • 另一个好处是,可以减少 trx_id 的申请次数。在 InnoDB 里,即使你只是执行一个普通的 select 语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请 trx_id,就大大减少了并发事务申请 trx_id 的锁冲突。

由于只读事务不分配 trx_id,一个自然而然的结果就是 trx_id 的增加速度变慢了。但是,max_trx_id 会持久化存储,重启也不会重置为 0,那么从理论上讲,只要一个 MySQL 服务跑得足够久,就可能出现 max_trx_id 达到 248-1 的上限,然后从 0 开始的情况。当达到这个状态后,MySQL 就会持续出现一个脏读的 bug,我们来复现一下这个 bug。

首先我们需要把当前的 max_trx_id 先修改成 248-1。注意:这个 case 里使用的是可重复读隔离级别。具体的操作流程如下:

复现脏读1

  • 由于我们已经把系统的 max_trx_id 设置成了 248-1,所以在 session A 启动的事务 TA 的低水位就是 248-1。
  • 在 T2 时刻,session B 执行第一条 update 语句的事务 id 就是 248-1,而第二条 update 语句的事务 id 就是 0 了,这条 update 语句执行后生成的数据版本上的 trx_id 就是 0。
  • 在 T3 时刻,session A 执行 select 语句的时候,判断可见性发现,c=3 这个数据版本的 trx_id,小于事务 TA 的低水位,因此认为这个数据可见。但这个是脏读。由于低水位值会持续增加,而事务 id 从 0 开始计数,就导致了系统在这个时刻之后,所有的查询都会出现脏读的。并且,MySQL 重启时 max_trx_id 也不会清 0,也就是说重启 MySQL,这个 bug 仍然存在。

那么,**这个 bug 也是只存在于理论上吗?**假设一个 MySQL 实例的 TPS 是每秒 50 万,持续这个压力的话,在 17.8 年后,就会出现这个情况。如果 TPS 更高,这个年限自然也就更短了。但是,从 MySQL 的真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过,这个 bug 是只要 MySQL 实例服务时间够长,就会必然出现的。

thread_id

接下来,我们再看看线程 id(thread_id)。其实,线程 id 才是 MySQL 中最常见的一种自增 id。平时我们在查各种现场的时候,show processlist 里面的第一列,就是 thread_id

thread_id 的逻辑很好理解:系统保存了一个全局变量 thread_id_counter,每新建一个连接,就将 thread_id_counter 赋值给这个新连接的线程变量。thread_id_counter 定义的大小是 4 个字节,因此达到 232-1 后,它就会重置为 0,然后继续增加。但是,你不会在 show processlist 里看到两个相同的 thread_id

这是因为 MySQL 设计了一个唯一数组的逻辑,给新线程分配 thread_id 的时候,逻辑代码是这样的:

do {
  new_id= thread_id_counter++;
} while (!thread_ids.insert_unique(new_id).second);

  1. 存储引擎一般是以页为单位从硬盘读取,而Server层的执行器每次读取一行 ↩︎

  2. 只要我们写的是DML语句(insert,update,delete,create)等等,那么我们在数据库服务端执行的时候就会涉及到 redo log(重做日志) 和 binlog(归档日志) 两个日志文件的变动。 ↩︎

  3. 日志也是在磁盘上的,这也是一个写磁盘的过程,但是与更新过程不一样的是,更新过程是在磁盘上随机IO(需要现在磁盘上找到要更新的那行数据),费时。 而写redo log 是在磁盘上顺序IO(直接将当前更新追加在指定文件后面)。效率要高。 ↩︎

  4. 即"刷脏"操作,此时也会将在buffer中的数据写入到数据文件中,此时发生随机IO ↩︎ ↩︎

  5. redo log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 redo log 都 fsync 直接持久化到磁盘,即使宕机也不会丢失事务;当设置为2时,则在事务提交时只做write操作,只保证写到系统的page cache,因此实例crash不会丢失事务,但宕机则可能丢失事务;当设置为0时,事务提交不会触发redo写操作(当前事务 redo log 保留在 redo log buffer 中),而是留给后台线程每秒一次的刷盘(flush/fsync)操作,因此实例crash将最多丢失1秒钟内的事务。建议设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。 ↩︎

  6. sync_binlog=0,当事务提交之后,MySQL不做fsync之类的磁盘同步指令刷新binlog_cache中的信息到磁盘,而让Filesystem自行决定什么时候来做同步,或者cache满了之后才同步到磁盘。sync_binlog=n,当每进行n次事务提交之后,MySQL将进行一次fsync之类的磁盘同步指令来将binlog_cache中的数据强制写入磁盘。 ↩︎

  7. 这里的事务和我们前面讲的事务不是同一意义的,这里的事务仅指写入日志文件本身。 ↩︎

  8. undo log,原子性的实现也会利用到,另外如果一个事务长时间没有commit 或者 rollback,那么这个事务在回滚段中的写入将一直不能被删除,所以为什么说看上去业务处理失败了,一定要 rollback 事务呢。因为即使是显示开启事务,没有 commit 无需 rollback 看上去对于数据也没有影响,但是事务不提交就一致会占用回滚段导致其无法删除,无用地占用资源。undo log 存储在共享表空间中,共享表空间会持久化为"ibdata"文件。 ↩︎ ↩︎

  9. 对比跳表,跳表是多层链表的结合,所以对于它的修改成本处理得好的话是接近O(1)的,当然,如果是要先查找再修改,还是要O(log(N)). ↩︎

  10. 对于机械硬盘来说,磁盘IO的最小单位是某个磁道的一个扇面(即存储数据的单位、寻址单位),如果某个数据块跨越两个扇面,则需要进行两次磁盘寻址,磁盘寻址是较耗时的过程,涉及寻道时间(磁头从当前磁道移动到操作系统指定的磁道需要的时间)、旋转延迟(磁头移动到指定磁道之后等待磁盘旋转到指定扇区)。操作系统在对磁盘进行管理时通常以磁盘块作为最小单位。磁盘块通常为扇面数的整数倍,更为常见的是2的幂次方倍,如32个扇面或者64个扇面等。而访问数据也是按照磁盘块为单位进行访问,对应的就是磁盘块地址,它相对于实际的硬盘地址(磁道+扇面)是一个逻辑地址。 ↩︎

  11. 操作系统按照数据块为单位进行磁盘访问,因为数据块的大小是固定的,所以每次操作系统调度磁盘访问都会经过一样的磁盘寻址将数据块中包含的扇面数据全部加载出来。所以减少数据块的访问次数就是减少了磁盘访问,减少磁盘寻址时间。 ↩︎ ↩︎

  12. 在64位系统中,理论可以访问的内存高达16EB(2的64次幂)字节。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间,所以目前64位的硬件实际能够支持的最大内存只有256TB。此外,操作系统一侧也还会施加自己的约束,64位的Linux则分别支持47位(128TB)的进程虚拟地址空间和46位(64TB)的物理地址空间,64位的Windows系统甚至只支持44位(16TB)的物理地址空间。 ↩︎

  13. 这里提到的全局锁和表锁由Server层实现,行锁由 InnoDB 实现 ↩︎

  14. 可以通过 unlock tables; 语句解锁 ↩︎

  15. memory 表的位置信息相当于 InnoDB 表的 row id。所谓的 row id 排序就是参与排序的每行数据只包含两个字段,一个是排序字段,另外一个是行的唯一标识字段。InnoDB 表中行的唯一标识字段是 row id,如果表中指明了主键,则该主键为 row id;如果没有,就自动生成一个 row id。memory 表的组织方式和 InnoDB 的索引组织(树+有序数组)方式不同,可以把它当成一个数组,每行相当于数组中的一个元素,位置信息相当于每行的数组索引位置。这样设计将 row id 排序策略与实际的表组织方式解耦开,每个表根据自己的逻辑提供对应的行唯一标识即可。 ↩︎ ↩︎


   转载规则


《mysql学习笔记:基础架构》 阿钟 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录