MySQL基础

MySQL逻辑架构

让我们从最简单的情形开始,假设有一张这样的表T,表里只有一个ID字段,在执行下面这个查询语句时:

select * from T where ID=10;

我们看到的只是输入一条语句,返回一个结果,却不知道这条语句在MySQL内部的执行过程,要想更深入的了解,就需要了解MySQL的逻辑架构:

MySQL存储引擎架构图

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

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

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

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

从图中不难看出,不同的存储引擎共用一个Server层,也就是从连接器到执行器的部分。接下来我们会从上文提到的SQL语句,梳理它执行的完整的流程,了解每个组件的作用。

连接器

执行SQL语句的第一步,总是会使用连接器连接到这个数据库上。连接器负责跟客户端建立连接、获取权限、维持和管理连接,连接命令通常如下:

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

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

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

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

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

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

image-20211128232149339

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

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

连接可以分为两种:长连接和短连接。长连接是指连接成功后,如果客户端持续有请求,则一直使用同一个连接。短连接则是指每次执行完很少的几次查询就断开连接,下次查询再重新建立一个。

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

解决这个问题通常有两种方案:

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

查询缓存

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

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

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

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

MySQL提供了参数配置,可以将参数query_cache_type设置成DEMAND,这样对于默认的SQL语句都不使用查询缓存,而对于确定要使用查询缓存的语句,可以使用SQL_CACHE显式指定:

 select SQL_CACHE * from T where ID=10

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

分析器

如果没有命中查询缓存,就要真正开始执行语句了。MySQL会使用分析器对SQL语句做解析,识别出SQL语句中的字符串分别是什么,代表什么。在之前的例子中,MySQL会从输入的“select”关键字识别出来,这是一个查询语句,它也要把字符串“T”识别成表名“T”,把字符串“ID”识别成“列ID”,做完了这些识别以后,就要做“语法分析”,根据词法分析的结果,语法分析器会根据语法规则,判断输入的SQL语句是否满足MySQL语法。

如果语句不对,就会收到“You have an error in your SQL syntax”的错误提醒。

优化器

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

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

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

这两种执行方法的逻辑结果是一样的,但是执行的效率会有所不同,而优化器的作用就是决定选择使用哪一种方案。优化器的阶段完成后,这个语句的执行方案就确定下来了,然后进入执行器阶段。

执行器

MySQL通过分析器知道了要做什么。通过优化器知道了该怎么做,于是就进入了执行器阶段,开始执行语句。开始执行的时候,要先判断有没有对应的查询权限,如果没有,就会返回没有权限的错误,如下所示:

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

如果有权限,就打开表继续执行,打开表的时候,执行器就会根据表的引擎定义,去使用这个引擎提供的接口,比如上述例子中表T中,ID字段没有索引,那么执行器的执行流程如下:

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

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

数据库的慢查询日志中,字段rows_examined表示这个语句执行过程中扫描了多少行,这个值就是在执行器每次调用引擎获取数据行的时候累加的。不过在有些场景下,执行器调用一次,在引擎内部扫描了多行,因此引擎扫描行数跟rows_examined并不是完全相同的。

SQL执行流程

MySQL可以借助重做日志和归档日志恢复到半个月内任意一秒的状态。为了了解它的实现原理,我们从一个表的一条更新语句开始:

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

如果要将ID=2这一行的值加1,SQL如下:

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

同样的更新语句也会按照SQL语句的基本执行链路执行:

image-20211129235535854

与查询流程不一样的是,更新流程还设计两个重要的的日志模块:redo log(重做日志)和binlog(归档日志),这是MySQL中两个核心概念。

重做日志

如果每一次的更新操作都需要写进磁盘,然后磁盘也要找到对应的那条记录,然后再更新,整个过程IO成本、查找成本都很高。为了解决这个问题,MySQL会使用WAL技术(Write-Ahead Logging),先写日志,再写磁盘。

具体来说,当有一条记录需要更新的时候,InnoDB引擎就会先把记录写到redo log里面,并更新内存,这个时候更新就算完成了。同时,InnoDB引擎会在适当的时候,将这个操作记录到磁盘里面,而这个更新往往是在系统比较空闲的时候做。InnoDB的redo log是固定大小的,比如可以配置一组4个文件,每个文件的大小是1GB,那么总共就可以记录4GB的操作。从头开始写入,到末尾又回到开头循环写入,如下图所示:

image-20211130232906506

其中write pos是当前记录的位置,一边写一边后移,写到第3号文件末尾后就回到0号文件开头,checkpoint是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到数据文件。write pos和checkpoint之间还空着的部分,就可以用来记录新的操作,如果write pos追上了checkpoint,那么就表示不能再执行新的更新操作了,就得先停下来擦掉一些记录,然后将checkpoint向后移动。

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

归档日志

MySQL从整体上来看,大致可以分为两部分,一块是Server层,它主要负责MySQL功能层面的事情,另一块是引擎层,负责存储相关的具体事宜。上文提到的redo log是InnoDB引擎特有的日志,而Server层也有自己的日志,称为bin log(归档日志)。由于最开始的MySQL并没有InnoDB引擎,MySQL自带的引擎是MyISAM,但是MyISAM没有crash-safe的能力,binlog日志只能用于归档。

redo log和binlog有以下区别:

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

更新语句执行流程

了解这两个日志的作用,我们再来看执行器和InnoDB引擎在执行这个update语句时的内部流程。

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

update语句的执行如下图:

image-20211130235424315

注意这里并不是直接写入redo log,而是将redo log的写入拆成了两个步骤:prepare和commit,这就是“两阶段提交”。

两阶段提交

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

上文提到过,binlog会记录所有的逻辑操作,并且是采用“追加写”的形式,当需要恢复到指定的某一秒时,比如某天下午两点发现中午十二点有一次误删表,需要找回数据,那么可以:

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

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

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

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

可以看到,如果不使用“两阶段提交”,那么数据库的状态就有可能和用它的日志恢复出来的库的状态不一致,不过你可能会问,碰到需要用日志恢复数据的场景是不是很少,其实,并不只是误操作以后需要用这个过程来恢复数据,当你需要扩容的时候,也就是需要再多搭建一些备库来增加系统的读能力的时候,现在常见的做法也是用全量备份加上应用binlog来实现的,这个“不一致”就不会导致线上出现主从数据库不一致的情况。

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

MySQL性能分析工具

explain 简介

EXPLAIN是什么?

EXPLAIN:SQL的执行计划,使用EXPLAIN关键字可以模拟优化器执行SQL查询语句,从而知道MySQL是如何处理SQL语句的。

EXPLAIN怎么使用?

语法:explain + SQL

mysql> explain select * from pms_category \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: pms_category
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 1425
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)

EXPLAIN能干嘛?

可以查看以下信息:

  • id:表的读取顺序。
  • select_type:数据读取操作的操作类型。
  • possible_keys:哪些索引可以使用。
  • key:哪些索引被实际使用。
  • ref:表之间的引用。
  • rows:每张表有多少行被优化器查询。

explain 字段

id

id:表的读取和加载顺序。

值有以下三种情况:

  • id相同,执行顺序由上至下。
  • id不同,如果是子查询,id的序号会递增,id值越大优先级越高,越先被执行。
  • id相同不同,同时存在。永远是id大的优先级最高,id相等的时候顺序执行。

select_type

select_type:数据查询的类型,主要是用于区别,普通查询、联合查询、子查询等的复杂查询。

  • SIMPLE:简单的SELECT查询,查询中不包含子查询或者UNION
  • PRIMARY:查询中如果包含任何复杂的子部分,最外层查询则被标记为PRIMARY
  • SUBQUERY:在SELECT或者WHERE子句中包含了子查询。
  • DERIVED:在FROM子句中包含的子查询被标记为DERIVED(衍生),MySQL会递归执行这些子查询,把结果放在临时表中。
  • UNION:如果第二个SELECT出现在UNION之后,则被标记为UNION;若UNION包含在FROM子句的子查询中,外层SELECT将被标记为DERIVED
  • UNION RESULT:从UNION表获取结果的SELECT

type

type:访问类型排列。

从最好到最差依次是:system>const>eq_ref>ref>range>index>ALL。除了ALL没有用到索引,其他级别都用到索引了。

一般来说,得保证查询至少达到range级别,最好达到ref

  • system:表只有一行记录(等于系统表),这是const类型的特例,平时不会出现,这个也可以忽略不计。

  • const:表示通过索引一次就找到了,const用于比较primary key或者unique索引。因为只匹配一行数据,所以很快。如将主键置于where列表中,MySQL就能将该查询转化为一个常量。

  • eq_ref:唯一性索引扫描,读取本表中和关联表表中的每行组合成的一行,查出来只有一条记录。除 了 system const 类型之外, 这是最好的联接类型。

  • ref:非唯一性索引扫描,返回本表和关联表某个值匹配的所有行,查出来有多条记录。

  • range:只检索给定范围的行,一般就是在WHERE语句中出现了BETWEEN< >in等的查询。这种范围扫描索引比全表扫描要好,因为它只需要开始于索引树的某一点,而结束于另一点,不用扫描全部索引。

  • indexFull Index Scan,全索引扫描,indexALL的区别为index类型只遍历索引树。也就是说虽然ALLindex都是读全表,但是index是从索引中读的,ALL是从磁盘中读取的。

  • ALLFull Table Scan,没有用到索引,全表扫描。

possible_keys 和 key

possible_keys:显示可能应用在这张表中的索引,一个或者多个。查询涉及到的字段上若存在索引,则该索引将被列出,但不一定被查询实际使用。

key:实际使用的索引。如果为NULL,则没有使用索引。查询中如果使用了覆盖索引,则该索引仅仅出现在key列表中。

key_len

key_len:表示索引中使用的字节数,可通过该列计算查询中使用的索引的长度。key_len显示的值为索引字段的最大可能长度,并非实际使用长度,即key_len是根据表定义计算而得,不是通过表内检索出的。在不损失精度的情况下,长度越短越好。

key_len计算规则:https://blog.csdn.net/qq_34930488/article/details/102931490

mysql> desc pms_category;
+---------------+------------+------+-----+---------+----------------+
| Field         | Type       | Null | Key | Default | Extra          |
+---------------+------------+------+-----+---------+----------------+
| cat_id        | bigint(20) | NO   | PRI | NULL    | auto_increment |
| name          | char(50)   | YES  |     | NULL    |                |
| parent_cid    | bigint(20) | YES  |     | NULL    |                |
| cat_level     | int(11)    | YES  |     | NULL    |                |
| show_status   | tinyint(4) | YES  |     | NULL    |                |
| sort          | int(11)    | YES  |     | NULL    |                |
| icon          | char(255)  | YES  |     | NULL    |                |
| product_unit  | char(50)   | YES  |     | NULL    |                |
| product_count | int(11)    | YES  |     | NULL    |                |
+---------------+------------+------+-----+---------+----------------+
9 rows in set (0.00 sec)


mysql> explain select cat_id from pms_category where cat_id between 10 and 20 \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: pms_category
   partitions: NULL
         type: range
possible_keys: PRIMARY
          key: PRIMARY  # 用到了主键索引,通过查看表结构知道,cat_id是bigint类型,占用8个字节
      key_len: 8        # 这里只用到了cat_id主键索引,所以长度就是8!
          ref: NULL
         rows: 11
     filtered: 100.00
        Extra: Using where; Using index
1 row in set, 1 warning (0.00 sec)

ref

ref:显示索引的哪一列被使用了,如果可能的话,是一个常数。哪些列或常量被用于查找索引列上的值。

rows

rows:根据表统计信息及索引选用情况,大致估算出找到所需的记录需要读取的行数。

Extra

Extra:包含不适合在其他列中显示但十分重要的额外信息。

  • Using filesort:说明MySQL会对数据使用一个外部的索引排序,而不是按照表内的索引顺序进行读取。MySQL中无法利用索引完成的排序操作成为"文件内排序"。
# 排序没有使用索引
mysql> explain select name from pms_category where name='Tangs' order by cat_level \G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: pms_category
   partitions: NULL
         type: ref
possible_keys: idx_name_parentCid_catLevel
          key: idx_name_parentCid_catLevel
      key_len: 201
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using where; Using index; Using filesort
1 row in set, 1 warning (0.00 sec)

#~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
# 排序使用到了索引

mysql> explain select name from pms_category where name='Tangs' order by parent_cid,cat_level\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: pms_category
   partitions: NULL
         type: ref
possible_keys: idx_name_parentCid_catLevel
          key: idx_name_parentCid_catLevel
      key_len: 201
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using where; Using index
1 row in set, 1 warning (0.00 sec)
  • Using temporary:使用了临时表保存中间结果,MySQL在対查询结果排序时使用了临时表。常见于排序order by和分组查询group by临时表対系统性能损耗很大。

  • Using index:表示相应的SELECT操作中使用了覆盖索引,避免访问了表的数据行,效率不错!如果同时出现Using where,表示索引被用来执行索引键值的查找;如果没有同时出现Using where,表明索引用来读取数据而非执行查找动作。

# 覆盖索引
# 就是select的数据列只用从索引中就能够取得,不必从数据表中读取,换句话说查询列要被所使用的索引覆盖。
# 注意:如果要使用覆盖索引,一定不能写SELECT *,要写出具体的字段。
mysql> explain select cat_id from pms_category \G;
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: pms_category
   partitions: NULL
         type: index
possible_keys: NULL       
          key: PRIMARY
      key_len: 8
          ref: NULL
         rows: 1425
     filtered: 100.00
        Extra: Using index   # select的数据列只用从索引中就能够取得,不必从数据表中读取   
1 row in set, 1 warning (0.00 sec)
  • Using where:表明使用了WHERE过滤。
  • Using join buffer:使用了连接缓存。
  • impossible whereWHERE子句的值总是false,不能用来获取任何元组。
mysql> explain select name from pms_category where name = 'zs' and name = 'ls'\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: NULL
   partitions: NULL
         type: NULL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: NULL
     filtered: NULL
        Extra: Impossible WHERE   # 不可能字段同时查到两个名字
1 row in set, 1 warning (0.00 sec)

MySQL索引

索引常见模型

除了数据本身之外,数据库还维护着一个满足特定查找算法的数据结构,这些数据结构以某种方式指向数据,这样就可以在这些数据结构的基础上实现高级查找算法,这种数据结构就是索引。简单来说,索引是排好序的快速查找数据结构。

索引的出现是为了提高查询效率,但是实现索引的方式却有很多中,可以用于提高读写效率的数据结构有很多中,这里我们这里讨论三种常见的数据结构:哈希表、有序数组、搜索树。

哈希表

哈希表是一种以键-值(key-value)存储数据的结构,我们只需要输入待查找的值即key,就可以找到其对应的值即value。使用哈希算法不可避免的就会遇到哈希冲突,链地址法是解决哈希冲突比较常见的做法。

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

image-20211202233834367

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

需要注意的是,图中四个ID_card_n的值并不是递增的,这样做的好处是增加新的User时速度会很快,只需要往后追加,但缺点是,因为不是有序的,所以哈希索引做区间查询的速度是很慢的,如果要查找身份证号在[ID_card_X,ID_card_Y]这个区间的所有用户,就必须全部扫描一遍了。因此,哈希表这种结构适用于只有等值查询的场景,比如Memcached及其他一些NoSQL引擎。

有序数组

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

image-20211202234913046

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

如果仅仅看查询效率,有序数组就是最好的数据结构了,但是,在需要更新数据的时候就麻烦了。往中间插入一条记录就必须得往后挪动所有的记录,成本非常高。因此,有序数组索引只适用于静态存储引擎,比如要保存的是2017年某个城市的所有人口信息,这类不会再修改的数据。

二叉搜索树

如果我们用二叉搜索树来实现上述的例子:

image-20211202235620443

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

不过为了维持O(logN)的查询复杂度,更新的时间复杂度也是O(logN)。

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

为了让一个查询尽量少地读磁盘,就必须让查询过程访问尽量少地数据块。那么,我们就不应该使用二叉树,而是要使用“N叉树”,这里,“N叉”树中地“N”取决于数据块的大小。以InnoDB的一个整数字段为例。这个N差不多是1200。这棵树高是4的时候,就可以存1200的3次方个值,这已经17亿了。考虑到树根的数据块总是在内存中的,一个10亿行的表上一个整数字段的索引,查找一个值最多只需要访问3次从盘。其实,树的第二层也有很大的概率在内存中,那么访问磁盘的平均次数就更少了。

由于N叉树在读写上的性能优点,以及适配磁盘的访问模式,已经被广泛应用在数据库引擎中了。在MySQL中,索引是在存储引擎层的实现的,所以并没有统一的索引标准,即不同存储引擎的索引的工作方式并不一样。而底层的实现也可能不同。

B+树

在InnoDB中,表都是根据主键顺序以索引的形式存放的,这种存储方式的表称为索引组织表,并且InnoDB使用了B+树索引模型,将数据存储在了B+树中,每一个索引在InnoDB中对应一颗B+树。

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

CREATE TABLE T (
	id int PRIMARY KEY,
	k int NOT NULL,
	name varchar(16),
	INDEX(k)
) ENGINE = InnoDB;

然后向表中插入5条记录,表中R1~R5的(ID,K)的值分别为(100,1)、(200,2)、(300,3)、(500,5)、(600,6),两棵树的示例示意图如下:

image-20211204115523479

从图中不难看出,根据叶子节点的内容,索引类型分为主键索引和非主键索引。主键索引的叶子节点存储的是整行数据,在InnoDB中,主键索引也被称为聚簇索引(clustered index)。非主键索引的叶子节点内容是主键的值,在InnoDB里,非主键索引也被称为二级索引(secondary index)。

基于主键索引和普通索引的查询略有差别:

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

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

B+树为了维护索引的有序性,在插入新值的时候需要做必要的维护。以上面的图为例,如果插入新的行ID值为700,则只需要在R5的记录后面插入一个新记录。如果新插入的ID的值为400,就相对麻烦了,需要逻辑上挪动后面的数据,空出位置。而更糟的情况是,如果R5所在的数据页已经满了,根据B+树的算法,这时候需要申请一个新的数据页,然后挪动部分数据过去。这个过程称为页分裂。在这种情况下,性能自然也受收到影响。

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

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

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

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

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

由于每个非主键索引的叶子节点上都是主键的值。如果用身份证号做主键,那么每个二级索引的叶子节点占用约20个字节,而如果用整型做主键,则只要4个字节,如果是长整型(bigint)则是8个字节。显然,主键长度越小,普通索引的叶子节点就越小,普通索引占用的空间也就越小。所以从性能和存储空间方面考量,自增主键往往是更合理的选择。

不过在典型的KV场景,由于没有其它索引,所以就不用考虑其它索引叶子节点大小的问题了,KV场景的特点如下:

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

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

索引的执行流程

创建好索引之后,我们探讨一下索引的执行流程,以下面的表T为例,如果执行select * from T where k between 3 and 5,需要执行几次树的搜索操作,会扫描多少行?

CREATE TABLE T (
	ID int PRIMARY KEY,
	k int NOT NULL DEFAULT 0,
	s varchar(16) NOT NULL DEFAULT '',
	INDEX k(k)
) ENGINE = InnoDB;

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

此时表中的索引结构如下图所示:

image-20211205110530992

回表

此时,上述SQL语句查询的执行流程:

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

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

在这个例子中,由于查询结果所需要的数据只在主键索引上有,所以不得不回表。那么,该如何避免回表呢?

覆盖索引

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

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

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

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

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

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

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

当然,索引字段的维护总是有代价的,因此,在建立冗余索引来支持索引覆盖时就需要权衡考虑了。

最左前缀原则

如果要为每一种查询都设计一个索引,会导致索引数量激增,在B+树这种索引结构中,可以利用索引的“最左前缀”来定位记录,为了直观地说明这个概念,我们用(name,age)这个联合索引来分析。

image-20211205113628876

可以看到,索引项是按照索引定义里面出现的字段顺序排序的。当需要查到所有名字是“张三”的人时,可以快速定位到ID4,然后向后遍历得到所有需要的结果。

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

可以看到,不止是索引的全部定义,只要满足最左前缀,就可以利用索引来加速检索。这个最左前缀可以是联合索引的最左N个字段,也可以是字符串索引的最左M个字符。因此,在建立联合索引的时候,通常我们就会根据索引的复用能力来确定索引内的字段顺序,如果可以通过调整联合索引的顺序,就可以少维护一个索引,那么就要优先考虑建立这样顺序的索引。

那么,如果既有联合查询,又有基于a、b各自的查询呢?查询条件里面只有b的语句,是无法使用(a,b)这个联合索引的,这个时候不得不维护另一个索引,也就是说必须同时维护(a,b)、(b)这两个索引。这种情况下,我们要考虑的原则就是空间了。比如上面这个市民表的情况,name字段是比age字段大的,那么建议创建一个(name,age)的联合索引和一个(age)的单字段索引。

索引下推

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

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

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

这个语句在搜索索引树的时候,只能用“张”,找到第一个满足条件的记录ID3,然后判断其它条件是否满足。在MySQL 5.6之前,只能从ID3开始一个个回表,到主键索引上找出数据行,再对比字段值,而在MySQL 5.6之后引入的索引下推优化(index condition pushdown),可以在索引遍历的过程中,对索引中包含的字段先做判断,直接过滤掉不满足条件的记录,减少回表次数,下面是这两个过程的执行流程图:

image-20211205173708482image-20211205173743641

在这两个图里面,每一个虚线箭头表示回表一次,在第一张图中,InnoDB并不会去看age的值,只是按顺序把“name”第一个字是“张”的记录一条条取出来回表,因此,需要回表4次。下一张图中,InnoDB在(name,age)索引内部就判断了age是否等于10,对于不等于10的记录,直接判断并跳过。在我们这个例子中,只需要对ID4、ID5这两条记录回表取数据判断,就只需要回表2次。

普通索引和唯一索引

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

select name from CUser where id_card = 'xxxxxxxyyyyyyzzzzz';

如果要在id_card字段上创建索引,由于身份证号字段比较大,作为主键并不合适,那么可以给id_card字段创建唯一索引,也可以创建一个普通索引。如果业务代码已经保证了不会写入重复的身份证号,那么这两个选择逻辑上都是正确的,但在性能上有所差别。

image-20211205224925129

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

查询过程

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

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

InnoDB的数据是按数据页为到位来读写的。也就是说,当需要读一条记录的时候,并不是将这个记录本身从磁盘读出来,而是以页为单位,将其整体读入内存。在InnoDB中,每个数据页的大小默认是16KB。由于引擎是按页读写的,所以说,当找到k=5的时候,它所在的数据页就都在内存里了,那么,对于普通索引来说,要多做的那一次“查找和判断下一条记录”的操作,就只需要一次指针寻找和一次计算。

当然,如果k=5这个记录刚好是这个数据页的最后一个记录,那么要取下一个记录,必须读取下一个数据页,这个操作会稍微复杂一些,不过,对于整型字段,一个数据页就可以放近千个key,因此出现这种情况的概率会很低,所以在计算平均性能差异的时候,仍可以认为这个操作成本对于现在的CPU来说可以忽略不计。

总而言之,对于查询的场景来说,唯一索引和普通索引并没有性能上的差距。

更新过程

为了说明普通索引和唯一索引对更新语句性能的影响,需要首先了解change buffer。

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

需要说明的是,虽然名字叫做change buffer,实际上它是可以持久化的数据,也就是说,change buffer在内存中有拷贝,也会被写入到磁盘上。将change buffer中的操作应用到原数据页,得到最新结果的过程称为merge。除了访问这个数据页会触发merge外,系统有后台线程也会定期merge。在数据库正常关闭(shutdown)的过程中,也会执行merge操作。

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

那么,什么情况下会使用到change buffer呢?

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

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

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

理解了change buffer的机制,我们再来看看如果要在这张表中插入一个新记录(4,400)的话,InnoDB的处理流程。

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

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

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

change buffer详解

上文我们说过,change buffer只限于用在普通索引的场景下,而是不适用于唯一索引,那么普通索引的所有场景,使用change buffer都可以起到加速的作用吗?

由于merge的时候是真正进行数据更新的时刻,而change buffer的主要目的就是将记录的变更动作缓存下来,所以在一个数据页做merge之前,change buffer的记录变更越多(也就是这个页面上要更新的次数越多),收益就越大。因此对于写多读少的业务来说,页面在写完以后马上被访问到的概率比较小,此时change buffer的使用效果最好。这种业务模型常见的就是账单类、日志类的系统。反过来,假设一个业务的更新模式是写入之后马上会做查询,那么即使满足了条件,将更新先记录在change buffer,但之后由于马上要访问这个数据页,会立即触发merge过程。这样随机访问IO的次数不会减少,反而增加了change buffer的维护代价。所以,对于这种业务模式来说,change buffer反而起到了副作用。

此时我们再来分析普通索引和唯一索引选择的问题。这两类索引在查询能力上没有差别,主要是对更新性能的影响,因此,尽量选择普通索引。如果所有的更新后面,都马上伴随着对这个记录的查询,那么应该关闭change buffer,而在其它情况下change buffer都能提升更新性能。

change buffer和redo log

change buffer和redo log是两个比较容易混淆的概念,接下来我们通过实例来说明它们之间的区别,先插入两条数据:

INSERT INTO t (id, k) VALUES (id1, k1), (id2, k2);

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

image-20211206231723229

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

这条更新语句做了如下操作:

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

做完上面这些,事务就可以完成了,不难看出,执行这条更新语句的成本很低,就是写了两处内存,然后写了一处磁盘(两次操作合在一起写了一次磁盘),而且还是顺序写的,同时,图中的两个虚线箭头,是后台操作,不影响更新的响应时间。

完成上述操作之后,假设要执行select * from t where k in(k1, k2),执行的流程图如下:

image-20211206235019581

从图中可以看到:

  • 读Page1的时候,直接从内存返回
  • 要读Page2的时候,需要把Page2从磁盘读入内存中,然后应用change buffer里面的操作日志,生成一个正确的版本并返回结果

可以看到,直到需要读Page2的时候,这个数据页才会被读入内存,所以要简单地对比这两个机制在提升性能上地收益的话,redo log主要节省的是随机写磁盘的IO消耗(转成顺序写),而change buffer主要节省的则是随机读磁盘的IO消耗。

索引的选择

MySQL中一张表可以支持多个索引,并且使用哪个索引是由MySQL来确定的,不过在某些场景下,MySQL可能会选错索引,从而导致执行速度变得很慢。

首先先建一张表,表里有a、b两个字段,并分别建立索引:

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

然后执行如下SQL语句:

select * from t where a between 10000 and 20000;

通过explain命令可以这条语句执行的情况:

image-20211211165917452

我们在字段‘a’上建立了普通索引,从分析的结果来看,优化器也选择了索引a,但实际上并没有这么简单,假设这张表上包含了10万行的数据,然后做如下操作:

image-20211211170335749

session A开启一个事务,然后,seesion B把数据都删除后,又调用idata这个存储过程,插入了10万行数据。这时候,session B的查询语句select * from where a between 10000 and 20000就不会再选择索引a了。我们可以通过慢查询日志(show log)来查看以下具体的执行情况。为了说明优化器选择的结果是否正确,这里使用了force index(a)来让优化器强制使用索引a。

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语句执行完成后的慢查询日志如下:

image-20211211171316832

可以看到,Q1扫描了10万行,显然是走了全表扫描,执行时间是40毫秒。Q2扫描了10001行,执行了21毫秒。也就是说,我们在没有使用force index的时候,MySQL用错了索引,导致了更长的执行时间,要理解这个现象,就必须了解优化器选择索引的策略。

优化器逻辑

优化器选择索引的目的,是找到一个最优的执行方案,并用最小的代价去执行语句。在数据库里面,扫描行数是影响执行代价的因素之一。扫描的行数越少,意味着访问磁盘数据的次数越小,消耗的CPU资源越少。除此之外,优化器还会结合是否使用临时表、是否排序等因素进行综合判断,由于这个查询语句并没有涉及到临时表和排序,所以MySQL选错索引肯定是在判断扫描行数的时候出现了问题。

MySQL在真正开始执行语句之前,并不能精确地知道满足这个条件的记录有多少条,而只能根据统计信息来估算记录数,这个统计信息就是索引的“区分度”。显然,一个索引上不同的值越多,这个索引的区分度就越好,而一个索引上不同的值的个数,我们称之为“基数(cardinality)”。也就是说,这个基数越大,索引的区分度就越好。

我们可以使用show index方法,看到一个索引的基数,如下图所示:

image-20211211172622989

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

MySQL会通过采样统计的方式来得到索引的基数,采用采样统计的原因主要是,如果把整张表取出来一行行统计,然后可以得到精确的结果,但是代价太高了,所以只能选择“采样统计”。

采样统计的时候,InnoDB默认会选择N个数据页,统计这些页面上的不同值,得到一个平均值,然后乘以这个索引的页面数,就得到了这个索引的基数。而数据表是会持续更新的,索引统计信息也不会固定不变,所以当变更的数据行超过1/M的时候,会自动触发重新做一次索引统计。

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

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

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

MySQL的优化器除了会统计索引的基数,还会判断这个语句本身要扫描的行数,可以通过explain的rows列来查看:

image-20211211174403746

可以看到,Q2的rows的值是37116,与实际的10000相差较大,这里实际上存在两个问题,一是语句Q1优化器为什么没有选择索引‘a’,二是语句Q2为什么优化器没有选择37116行的执行计划,而是选择扫描行数是100000的执行计划,

对于问题二,如果使用索引a,每次从索引a上拿到一个值,都要回到主键索引上查出整行的数据,而如果选择扫描10万行,是直接在主键索引上扫描的,没有额外的代价,优化器会估算这两个选择的代价,虽然从执行时间上来看,这个选择并不是最优的。

使用普通索引需要把回表的代价算进去,所以,MySQL选错索引,最根本的原因是没有能准确地判断出扫描行数,我们可以使用analyze table t命令,可以用来重新统计索引信息,我们来看一下执行效果。

image-20211211180509121

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

依然基于表t,执行另外一个语句:

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

从查询条件来看,这个查询没有符合条件的记录,因此将会返回空集合。为了方便理解这条语句的索引选择过程,首先来看一下a、b这两个索引的结构图:

image-20211211181122391

如果使用索引a进行查询,首先扫描索引a的前1000个值,然后取到对应的id,再到主键索引上去查出每一行,然后根据字段b来过滤,显然这样需要扫描1000行。如果使用索引b进行查询,首先扫描索引b的最后50001个值,然后取到对应的id,再回到主键索引上取值再判断,所以需要扫描50001行。显然,使用索引a,执行速度明显会快很多,我们来看看MySQL是如何选择的:

image-20211211181647822

可以看到,返回结果中key字段显式,这次优化器选择了索引b,而rows字段显式需要扫描的行数是50198。也就是说,扫描的行数的估计值依然不准确,并且MySQL又选错了索引。

索引选择异常和处理

当碰到优化器选择索引错误的时候,我们应该如何处理呢?

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

image-20211211195432111

原本语句需要执行2.23秒,而使用force index(a)的时候,只用了0.05秒,比优化器的选择快了40多倍。但这种方法并不完美,一是这么写不足够优雅,二是如果索引改了名字,这个语句也需要同步修改,三是这个语法并不是所有的数据库都支持,迁移比较麻烦。

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

image-20211211200251618

之前优化器选择使用索引b,是因为它认为使用索引b可以避免排序(b本身是索引,已经是有序的了,如果选择索引b的话,不需要再做排序,只需要遍历),所以即使扫描的行数多,也判定为代价更小。将语句修改为order by b,a,要求按照b,a排序,就意味着使用这两个索引都需要排序。因此,扫描行数成了影响决策的主要条件,于是此时优化器选了只需要扫描1000行的索引a。

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

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

执行的效果如下:

image-20211211201036594

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

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

字符串添加索引

在业务开发中,我们经常会碰到要存储字符串的场景,例如邮箱、用户名等,那么如何给字符串添加合适的索引呢?

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

CREATE TABLE SUser (
	ID bigint UNSIGNED PRIMARY KEY,
	email varchar(64),
    ...
) ENGINE = innodb;

如果要使用邮箱登录,那么业务代码中一定会出现类似这样的语句:

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

前缀索引

如果email这个字段上没有索引,那么这个语句就只能全表扫描。在MySQL中是支持前缀索引的,也就是说,可以定义字符串的一部分作为索引,如果创建索引的语句不指定前缀长度,那么索引就会包含整个字符串。

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

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

第一个语句创建的index1索引里面,包含了每个记录的整个字符串,而第二个语句创建的index2索引里面,对于每个记录都是只取前6个字节,它们的示意图如下所示:

image-20211208225514256image-20211208225549732

从图中可以看到,由于email(6)这个索引结构中每个邮箱的字段都只取6个字节(即:zhangs),索引占用的空间会更小,这就是使用前缀索引的优势,但同时,前缀索引也可能会增加额外的记录扫描次数,通过它们的执行过程能更加清楚看到这一点。

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

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

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

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

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

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

通过以上两种情况的对比,可以发现,使用前缀索引后,可能会导致查询语句读数据的次数变多,但是对于这个查询语句来说,如果定义的index2不是email(6)而是email(7),也就是说取email字段的前7个字节来构建索引的话,即满足前缀'zhangss'的记录只有一个,也能够直接查到ID2,只扫描这一行就结束了。

这说明在使用前缀索引的时候,定义合理的长度,就可以做到既节省空间,又不用额外增加太多的查询成本。

我们可以通过统计索引上有多少个不同的值来判断需要使用多长的前缀,首先计算这个列上有多少个不同的值:

select count(distinct email) as L from SUser;

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

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

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

前缀索引与覆盖索引

前缀索引除了可能会增加扫描行数,影响到性能外,还可能会导致覆盖索引失效。

假设我们要查询的语句如下:

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

与前面的例子中的SQL语句:

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

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

即使将index2的定义修改为email(18)的前缀索引,这时候,虽然index2已经包含了所有的信息,但InnoDB还是要回到id索引再查一下,因为系统并不确定前缀索引的定义是否截断了完整信息。也就是说前缀索引就用不上覆盖索引对查询性能的优化了,这也是在选择是否使用前缀索引时需要考虑的一个因素。

其它方式

实际场景中,我们很有可能碰到前缀的区分度不够好的情况,例如身份证号,总共18位,其中前6位是地址码,所以同一个县的人的身份证号前6位一般会是相同的。假设维护的数据库是同一个市的公民信息系统,这时候如果对身份证号长度为6的前缀索引的话,这个索引的区分度就非常低了,需要创建长度为12位以上的前缀索引,才能够满足区分度的要求,但是索引选取的越长,占用的磁盘空间就越大,相同的数据页能放下的索引值就越少,搜索的效率也就会越低。解决这个问题通常来说有两种方式:倒序存储和使用哈希字段。

倒序存储是指如果存储身份证号码的时候把它倒过来存,每次查询的时候,可以:

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

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

使用哈希字段指的是可以在表上创建一个整数字段,来保存身份证的校验码,同时在这个字段上创建索引:

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

然后每次插入新记录的时候,都同时用cr32()这个函数得到校验码填到这个新字段。由于校验码可能存在冲突,也就是说两个不同的身份证通过crc32()函数得到的结果可能是相同的,所以查询语句where部分要判断id_card的值是否精确相同。这样一来,索引的长度变成了4个字节,比原来小了很多。

使用倒序存储和使用哈希字段两种方式的异同点如下:

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

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

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

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;

那么查询语句:

select city,name,age from t where city=' 杭州 ' order by name limit 1000;

全字段排序

为了避免全表扫描,我们需要在city字段加上索引,city这个索引的示意图如下:

image-20211211222605665

在city字段上创建索引之后,使用explain查看执行情况:

image-20211211222422522

Extra这个字段中的“Using filesort”表示就是需要排序,MySQL会给每个线程分配一块内存用于排序,称为sort_buffer。

从索引的示意图中可以看出, 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行返回给客户端

这个过程就称为全字段排序,执行流程的示意图如下所示:

image-20211211225315510

图中“按name排序”这个动作,可能在内存中完成,也可能需要使用外部排序,这取决于排序所需要的内存和参数sort_buffer_size。sort_buffer_size就是MySQL为排序开辟的内存(sort_buffer)的大小,如果要排序的数据量小于sort_buffer_size,排序就在内存中完成,但如果排序数据量太大,内存放不下,则不得不利用磁盘临时文件辅助排序。可以通过如下命令来查看

/* 打开 optimizer_trace ,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* @a 保存 Innodb_rows_read 的初始值 */
select VARIABLE_VALUE into @a from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 执行语句 */
select city, name,age from t where city=' 杭州 ' order by name limit 1000;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
/* @b 保存 Innodb_rows_read 的当前值 */
select VARIABLE_VALUE into @b from performance_schema.session_status where variable_name = 'Innodb_rows_read';
/* 计算 Innodb_rows_read 差值 */
select @b-@a;

这个方法是通过查看OPTIMIZER_TRACE的结果来确认的,也可以从number_of_tmp_files中看到是否使用了临时文件。

image-20211214233110080

图中number_of_tmp_files表示的是,排序过程中使用的临时的文件数,之所以是12个文件,是当内存放不下时,就需要外部排序,外部排序一般使用归并排序算法,MySQL将需要排序的数据分成12份,每一份单独排序后存在这些临时文件中,然后把这12个有序文件再合并成一个有序的大文件。如果sort_buffer_size超过了需要排序的数据量的大小,number_of_tmp_files就是0,表示排序可以直接在内存中完成,否则就需要放在临时文件中排序。sort_buffer_size越小,需要的分成的份数就越多,number_of_tmp_files的值就越大。

另外,在示例表中有4000条满足city='杭州'的记录,所以图中的examined_rows=4000,表示参与排序的行数是4000行。

sort_mode里面的packed_additional_fields的意思是,排序过程中对字符串做了“紧凑处理”,即使name字段的定义是varchar(16),在排序的过程中还是按照实际长度来分配空间的。

最后的查询select @b - @a在MyISAM引擎中返回的结果是4000,而在InnoDB引擎中会返回4001,这是因为如果使用的是InnoDB引擎的话,在查询表optimizer_trace的时候,需要用到临时表,InnoDB会把数据从临时表取出来,然后让innodb_rows_read的值加1。

rowid排序

上面的排序算法,只对原表的数据读了一遍,剩下的操作都是在sort_buffer和临时表中执行的,但这个算法有一个问题,就是如果查询要返回的字段很多的话,那么sort_buffer里面要放的字段数太多,这样内存里能够同时放下的行数就很少,要分成很多个临时文件,排序的性能会很差,所以如果单行很大,这个方法效率不够好。

我们可以通过修改参数,让MySQL换成另外一种算法:

SET max_length_for_sort_data = 16;

它的意思是,如果单行的长度超过了这个值,MySQL就认为单行太大,要换一个算法。city、name、age这三个字段的定义总长度是36,超过了16,这个时候,MySQL就会使用rowid排序。

新的算法放入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三个字段返回给客户端

执行的示意图如下:

image-20211214235751360

对于全字段的排序流程图会发现,rowid排序多访问了一次表t的主键索引,也就是步骤7。需要说明的是,最后的“结果集”是一个逻辑概念,实际上MySQL服务端从排序后的sort_buffer中依次取出id,然后原表查到city、name和age这三个字段的结果,不需要在服务端再耗费内存存储结果,是直接返回给客户端的,此时的OPTIMIZER_TRACE的结果如下:

image-20211215230833238

可以发现:

  • sort_mode变成了<sort_key,rowid>,表示参与排序的只有name和id这两个字段
  • number_of_tmp_files变成10了,是因为这时候参与排序的行数虽然仍然是4000行,但是每一行都变小了,因此需要排序的总数据量就变小了,需要的临时文件也相应地变少了

全字段排序和rowid排序对比

当MySQL认为内存足够大,会优先选择全字段排序,把需要的字段都放到sort_buffer中,这样排序后就会直接从内存里面返回查询结果了,不用再会到原表去取数据;当内存太小,会影响排序效率,MySQL才会采用rowid排序算法,这样在排序过程中一次可以排序更多行,但是需要再回到原表去取数据。这体现了MySQL的一个设计理念:如果内存足够,就要多利用内存,尽量减少磁盘访问,对于InnoDB表来说,rowid排序会要求回表,因此不会优先选择。

不难发现,在MySQL中,对于无序的字段,排序是一个成本比较高的操作,因此,优化order by语句的一种方式就是让原来无序的数据变的“有序”。还是以市民表为例,我们在这个市民表上创建一个city和name的联合索引,对应的SQL语句是:

alter table t add index city_user(city, name);

索引的示意图:

image-20211215232534687

在这个索引里面,我们依然可以用树搜索的方式定位到第一个满足city="杭州"的记录,并且额外确保了,接下来按顺序取“下一条记录”的遍历过程中,只要city的值是杭州,name的值就一定是有序的。这样整个查询过程的流程就变成了:

  1. 从索引(city,name)找到第一个满足city="杭州"条件的主键id
  2. 到主键id索引取出整行,取name、city、age三个字段的值,作为结果集的一部分直接返回
  3. 从索引(city,name)取下一个记录主键id
  4. 重复步骤2、3,直到查到1000条记录,或者是不满足city="杭州"条件时循环结束

可以看到,这个查询过程不需要临时表,也不需要排序,explain的结果如下:

image-20211215233134766

可以看到,Extra字段中没有Using filesort了,也就是不需要排序了,而且由于(city,name)这个联合索引本身有序,所以这个查询也不用把4000行全都读一遍,只要找到满足条件的前1000条记录就可以退出了,也就是说,在这个例子中,只需要扫描1000次。

还可以更进一步,使用覆盖索引:

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="杭州"条件时循环结束
image-20211215234018038

explain的结果如下:

image-20211215234055979

可以看到Extra字段里面多了“Using index”,表示的就是使用了覆盖索引,性能上会快很多,不过索引还是有维护代价的,这是一个需要权衡的决定。

索引的创建时机

前面我们了解了一些关于索引的理论知识,接下来我们着重了解一些索引的实践部分。

哪些情况需要建索引

  • 主键自动建立主键索引(唯一 + 非空)。
  • 频繁作为查询条件的字段应该创建索引。
  • 查询中与其他表关联的字段,外键关系建立索引。
  • 查询中排序的字段,排序字段若通过索引去访问将大大提高排序速度。
  • 查询中统计或者分组字段(group by也和索引有关)。

哪些情况不要建索引

  • 记录太少的表。
  • 经常增删改的表。
  • 频繁更新的字段不适合创建索引。
  • Where条件里用不到的字段不创建索引。
  • 假如一个表有10万行记录,有一个字段A只有true和false两种值,并且每个值的分布概率大约为50%,那么对A字段建索引一般不会提高数据库的查询速度。索引的选择性是指索引列中不同值的数目与表中记录数的比。如果一个表中有2000条记录,表索引列有1980个不同的值,那么这个索引的选择性就是1980/2000=0.99。一个索引的选择性越接近于1,这个索引的效率就越高。

索引的最佳实践

单表索引分析

数据准备

DROP TABLE IF EXISTS `article`;

CREATE TABLE IF NOT EXISTS `article`(
`id` INT(10) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
`author_id` INT(10) UNSIGNED NOT NULL COMMENT '作者id',
`category_id` INT(10) UNSIGNED NOT NULL COMMENT '分类id',
`views` INT(10) UNSIGNED NOT NULL COMMENT '被查看的次数',
`comments` INT(10) UNSIGNED NOT NULL COMMENT '回帖的备注',
`title` VARCHAR(255) NOT NULL COMMENT '标题',
`content` VARCHAR(255) NOT NULL COMMENT '正文内容'
) COMMENT '文章';

INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES(1,1,1,1,'1','1');
INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES(2,2,2,2,'2','2');
INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES(3,3,3,3,'3','3');
INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES(1,1,3,3,'3','3');
INSERT INTO `article`(`author_id`, `category_id`, `views`, `comments`, `title`, `content`) VALUES(1,1,4,4,'4','4');

案例:查询category_id为1且comments大于1的情况下,views最多的article_id

1、编写SQL语句并查看SQL执行计划。

# 1、sql语句
SELECT id,author_id FROM article WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1;

# 2、sql执行计划
mysql> EXPLAIN SELECT id,author_id FROM article WHERE category_id = 1 AND comments > 1 ORDER BY views DESC LIMIT 1\G
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: article
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 5
     filtered: 20.00
        Extra: Using where; Using filesort  # 产生了文件内排序,需要优化SQL
1 row in set, 1 warning (0.00 sec)

2、创建索引idx_article_ccv

CREATE INDEX idx_article_ccv ON article(category_id,comments,views);

3、查看当前索引。

show index

4、查看现在SQL语句的执行计划。

explain

我们发现,创建符合索引idx_article_ccv之后,虽然解决了全表扫描的问题,但是在order by排序的时候没有用到索引,MySQL居然还是用的Using filesort,为什么?

5、我们试试把SQL修改为SELECT id,author_id FROM article WHERE category_id = 1 AND comments = 1 ORDER BY views DESC LIMIT 1;看看SQL的执行计划。

explain

推论:当comments > 1的时候order by排序views字段索引就用不上,但是当comments = 1的时候order by排序views字段索引就可以用上!!!所以,范围之后的索引会失效。

6、我们现在知道范围之后的索引会失效,原来的索引idx_article_ccv最后一个字段views会失效,那么我们如果删除这个索引,创建idx_article_cv索引呢????

/* 创建索引 idx_article_cv */
CREATE INDEX idx_article_cv ON article(category_id,views);

查看当前的索引

show index

7、当前索引是idx_article_cv,来看一下SQL执行计划。

explain

两表索引分析

数据准备

DROP TABLE IF EXISTS `class`;
DROP TABLE IF EXISTS `book`;

CREATE TABLE IF NOT EXISTS `class`(
`id` INT(10) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
`card` INT(10) UNSIGNED NOT NULL COMMENT '分类' 
) COMMENT '商品类别';

CREATE TABLE IF NOT EXISTS `book`(
`bookid` INT(10) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
`card` INT(10) UNSIGNED NOT NULL COMMENT '分类'
) COMMENT '书籍';

两表连接查询的SQL执行计划

1、不创建索引的情况下,SQL的执行计划。

explain

bookclass两张表都是没有使用索引,全表扫描,那么如果进行优化,索引是创建在book表还是创建在class表呢?下面进行大胆的尝试!

2、左表(book表)创建索引。

创建索引idx_book_card

/* 在book表创建索引 */
CREATE INDEX idx_book_card ON book(card);

book表中有idx_book_card索引的情况下,查看SQL执行计划

explain

3、删除book表的索引,右表(class表)创建索引。

创建索引idx_class_card

/* 在class表创建索引 */
CREATE INDEX idx_class_card ON class(card);

class表中有idx_class_card索引的情况下,查看SQL执行计划

explain

由此可见,左连接将索引创建在右表上更合适,右连接将索引创建在左表上更合适。

三张表索引分析

数据准备

DROP TABLE IF EXISTS `phone`;

CREATE TABLE IF NOT EXISTS `phone`(
`phone_id` INT(10) UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
`card` INT(10) UNSIGNED NOT NULL COMMENT '分类' 
) COMMENT '手机';

三表连接查询SQL优化

1、不加任何索引,查看SQL执行计划。

explain

2、根据两表查询优化的经验,左连接需要在右表上添加索引,所以尝试在book表和phone表上添加索引。

/* 在book表创建索引 */
CREATE INDEX idx_book_card ON book(card);

/* 在phone表上创建索引 */
CREATE INDEX idx_phone_card ON phone(card);

再次执行SQL的执行计划

explain

结论

JOIN语句的优化:

  • 尽可能减少JOIN语句中的NestedLoop(嵌套循环)的总次数:永远都是小的结果集驱动大的结果集
  • 优先优化NestedLoop的内层循环。
  • 保证JOIN语句中被驱动表上JOIN条件字段已经被索引。
  • 当无法保证被驱动表的JOIN条件字段被索引且内存资源充足的前提下,不要太吝惜Join Buffer 的设置。

索引失效

索引看起来非常美好,能够十分有效的加快我们的查询效率,然而,在MySQL中有很多看上去逻辑相同,但是性能却差异巨大的SQL语句,对这些语句使用不当的话,就会不经意间导致整个数据库的压力变大,

函数操作

假设现在维护的是一个交易系统,其中交易记录表tradelog包含交易流水号(tradeid)、交易员id(operator)、交易时间(t_modified)等字段,建表语句如下:

CREATE TABLE `tradelog` (
  `id` int(11) NOT NULL, 
  `tradeid` varchar(32) DEFAULT NULL, 
  `operator` int(11) DEFAULT NULL, 
  `t_modified` datetime DEFAULT NULL, 
  PRIMARY KEY (`id`), 
  KEY `tradeid` (`tradeid`), 
  KEY `t_modified` (`t_modified`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

假设现在已经记录了从2016年初到2018年底的所有数据,要查询所有年份中7月份的交易记录总数,SQL语句可能如下:

select count(*) from tradelog where month(t_modified)=7;

执行之后就会发现这个SQL语句会比预期的慢很多,虽然在t_modified字段上已经创建了索引,但是MySQL并没有使用这个索引。下面是t_modified索引的示意图,方框上面的数字表示month()函数对应的值:

image-20220111234901523

如果SQL语句中条件是where t_modified='2018-7-1'的话,引擎就会按照上面绿色箭头的路线,快速定位到t_modified='2018-7-1'需要的结果,而如果计算month()函数的话,在传入7的时候已经无法定位记录了。实际上,B+树提供的这个快速定位能力,来源于同一层兄弟节点的有序性,也就是说,对索引字段做函数操作,可能会破坏索引值的有序性,因此优化器决定放弃走树搜索功能。

需要注意的是,优化器并不是要放弃使用这个索引,在这个例子中,放弃了树搜索功能,优化器可以选择遍历主键索引,也可以选择遍历索引t_modified,优化器对比索引大小后发现,索引t_modified更小,遍历这个索引比主键索引来得更快,因此最终还是会选择索引t_modified。

这条语句的explain的结果如下:

image-20220111235919912

key="t_modified"表示的是,使用了t_modified这个索引,这里的测试数据有10万行,rows=100335,说明这条语句扫描了整个索引的所有值,Extra字段的Using index,表示的是使用了覆盖索引,也就是说,由于在t_modified字段加上了month()函数操作,导致了全索引扫描。为了能够用上索引的快速定位能力,我们就要把SQL语句改成基于字段本身的范围查询:

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

如果还有其它年份的数据,都需要手动将年份补齐。实际上,只要where条件后面有函数操作都会导致无法使用索引快速定位的功能,即使不改变有序性,例如select * from tradelog where id + 1 = 10000,也需要将where条件修改为where id = 10000 - 1才可以有效的用上索引。

隐式类型转换

假设我们执行这样一条SQL语句:

select * from tradelog where tradeid = 110717;

交易编号tradeid这个字段上,本来是有索引的,但是explain结果却显式,这条语句需要走全表扫描,这是由于tradeid字段类型是varchar(32),而输入的参数却是整型,所以需要做类型转换,从而导致了索引失效。实际上在MySQL中,字符串和数字做比较的话,会将字符串转换成数字,也就是说对与优化器来说,上面的SQL语句等价于:

select * from tradelog where CAST(tradid AS signed int) = 110717;

也就是说,这条语句触发了我们之前说到的过规则:对索引字段做函数操作,优化器会放弃走树搜索功能。

隐式字符编码转换

假设系统里面还有另外一张表trade_detail,用来记录交易的操作细节。为了便于量化分析和复现,我们往交易日志表tradelog和交易详情表trade_detail这两个表里插入一些数据:

CREATE TABLE `trade_detail` (
  `id` int(11) NOT NULL, 
  `tradeid` varchar(32) DEFAULT NULL, 
  `trade_step` int(11) DEFAULT NULL, 
  `step_info` varchar(32) DEFAULT NULL, 
  PRIMARY KEY (`id`), 
  KEY `tradeid` (`tradeid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8; insert into tradelog 

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语句:

select d.* from tradelog l, trade_detail d where d.tradeid=l.tradeid and l.id=2;

image-20220112234657374

可以看到:

  1. 第一行显式优化器现在交易记录表tradelog上查到id=2的行,这个步骤用上了主键索引,rows=1表示只扫描一行
  2. 第二行key=NULL,表示没有用上交易详情表trade_detail上的tradeid索引,进行了全表扫描

explain的详细过程如下:

image-20220112235754324

图中:

  • 第一步,是根据id在tradelog表里找到L2这一行
  • 第二步,是从L2中取出tradeid字段的值
  • 第三步,是根据tradeid的值到trade_detail表中查找条件匹配的行。explain的结果第二行的key=NULL表示的就是,这个过程是通过遍历主键索引的方式,一个一个地判断tradeid的值是否匹配

可以发现,在执行第三步的时候,并没有使用trade_detail里的tradeied上的索引快速定位到等值的行。实际上,这是因为这两张表的字符集不同导致的,上面的SQL等价于:

select * from trade_detail where CONVERT(traideid USING utf8mb4) = $L2.tradeid.value;

CONVERT()函数,在这里的意思是把输入的字符串转成utf8mb4字符集,这再次触发了本节开始时提到的原则:对索引字段做函数操作,优化器会放弃走树搜索功能。

utf8mb4是utf8的超集,类似地,在程序设计语言里面,做自动类型转换的时候,为了避免数据在转换过程中由于截断导致数据错误,也都是“按数据长度增加地方向”进行转换的。

接下来我们看另外一种场景:

select l.operator from tradelog l , trade_detail d where d.tradeid=l.tradeid and d.id = 4;

explain的结果:

image-20220113234214053

这个语句里trade_detail表成了驱动表,但是explain结果的第二行显示,这次的查询操作用上了被驱动表tradelog里的索引(tradeied),扫描行数是1,这也是两个tradeied字段的join操作,为什么这次能用上被驱动表的tradeied索引呢?假设驱动表trade_detail里id=4的行记为R4,那么在连接的时候。被驱动表tradelog上执行的就是类似这样的SQL语句:

select operator from tradelog where traideid = $R4.tradeid.value;

这个时候的$R4.tradeied.value的字符集是utf8,按照字符集转换规则,要转成utf8mb4,所以这个过程就被改写成:

select operator from tradelog where traideid =CONVERT($R4.tradeid.value USING utf8mb4);

由于这里CONVERT函数是加在输入参数上的,这样就可以用上被驱动表的tradeid索引了。

因此,对于SQL:

select d.* from tradelog l, trade_detail d where d.tradeid = l.tradeid and l.id = 2;

优化的方式通常有两种:

  • 比较常见的优化方法是,把trade_detail表上的tradeid字段的字符集也改成utf8mb4,这样就没有字符集转换问题了

    alter table trade_detail modify tradeid varchar(32) CHARACTER SET utf8mb4 default null;
    
  • 如果数据量比较大,或者其它原因不能执行这个DDL,那么可以修改SQL语句:

    select d.* from tradelog l, trade_detail d where d.tradeid = CONVERT(l.tradeid USING utf8);
    

    这样主动把l.tradeid转成utf8,就避免了被驱动表上的字符编码转换。

索引失效的场景

  • 全值匹配我最爱。
  • 最佳左前缀法则。
  • 不在索引列上做任何操作(计算、函数、(自动or手动)类型转换),会导致索引失效而转向全表扫描。
  • 索引中范围条件右边的字段会全部失效。
  • 尽量使用覆盖索引(只访问索引的查询,索引列和查询列一致),减少SELECT *
  • MySQL在使用!=或者<>的时候无法使用索引会导致全表扫描。
  • is nullis not null也无法使用索引。
  • like以通配符开头%abc索引失效会变成全表扫描(使用覆盖索引就不会全表扫描了)。
  • 字符串不加单引号索引失效。
  • 少用or,用它来连接时会索引失效。

最佳左前缀法则

案例

/* 用到了idx_staffs_name_age_pos索引中的name字段 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 'Ringo';

/* 用到了idx_staffs_name_age_pos索引中的name, age字段 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 'Ringo' AND `age` = 18;

/* 用到了idx_staffs_name_age_pos索引中的name,age,pos字段 这是属于全值匹配的情况!!!*/
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 'Ringo' AND `age` = 18 AND `pos` = 'manager';

/* 索引没用上,ALL全表扫描 */
EXPLAIN SELECT * FROM `staffs` WHERE `age` = 18 AND `pos` = 'manager';

/* 索引没用上,ALL全表扫描 */
EXPLAIN SELECT * FROM `staffs` WHERE `pos` = 'manager';

/* 用到了idx_staffs_name_age_pos索引中的name字段,pos字段索引失效 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 'Ringo' AND `pos` = 'manager';

概念

最佳左前缀法则:如果索引是多字段的复合索引,要遵守最佳左前缀法则。指的是查询从索引的最左前列开始并且不跳过索引中的字段。

口诀:带头大哥不能死,中间兄弟不能断。

索引列上不计算

案例

# 现在要查询`name` = 'Ringo'的记录下面有两种方式来查询!

# 1、直接使用 字段 = 值的方式来计算
mysql> SELECT * FROM `staffs` WHERE `name` = 'Ringo';
+----+-------+-----+---------+---------------------+
| id | name  | age | pos     | add_time            |
+----+-------+-----+---------+---------------------+
|  1 | Ringo |  18 | manager | 2020-08-03 08:30:39 |
+----+-------+-----+---------+---------------------+
1 row in set (0.00 sec)

# 2、使用MySQL内置的函数
mysql> SELECT * FROM `staffs` WHERE LEFT(`name`, 5) = 'Ringo';
+----+-------+-----+---------+---------------------+
| id | name  | age | pos     | add_time            |
+----+-------+-----+---------+---------------------+
|  1 | Ringo |  18 | manager | 2020-08-03 08:30:39 |
+----+-------+-----+---------+---------------------+
1 row in set (0.00 sec)

我们发现以上两条SQL的执行结果都是一样的,但是执行效率有没有差距呢???

通过分析两条SQL的执行计划来分析性能。

explain

由此可见,在索引列上进行计算,会使索引失效。

口诀:索引列上不计算。

范围之后全失效

案例

/* 用到了idx_staffs_name_age_pos索引中的name,age,pos字段 这是属于全值匹配的情况!!!*/
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 'Ringo' AND `age` = 18 AND `pos` = 'manager';


/* 用到了idx_staffs_name_age_pos索引中的name,age字段,pos字段索引失效 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` = '张三' AND `age` > 18 AND `pos` = 'dev';

查看上述SQL的执行计划

explain

由此可知,查询范围的字段使用到了索引,但是范围之后的索引字段会失效。

口诀:范围之后全失效。

覆盖索引尽量用

在写SQL的不要使用SELECT *,用什么字段就查询什么字段。

/* 没有用到覆盖索引 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 'Ringo' AND `age` = 18 AND `pos` = 'manager';

/* 用到了覆盖索引 */
EXPLAIN SELECT `name`, `age`, `pos` FROM `staffs` WHERE `name` = 'Ringo' AND `age` = 18 AND `pos` = 'manager';

使用覆盖索引

口诀:查询一定不用*

不等有时会失效

/* 会使用到覆盖索引 */
EXPLAIN SELECT `name`, `age`, `pos` FROM `staffs` WHERE `name` != 'Ringo';

/* 索引失效 全表扫描 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` != 'Ringo';

like百分加右边

/* 索引失效 全表扫描 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` LIKE '%ing%';

/* 索引失效 全表扫描 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` LIKE '%ing';

/* 使用索引范围查询 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` LIKE 'Rin%';

口诀:like百分加右边。

如果一定要使用%like,而且还要保证索引不失效,那么使用覆盖索引来编写SQL。

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id` FROM `staffs` WHERE `name` LIKE '%in%';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `name` FROM `staffs` WHERE `name` LIKE '%in%';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `age` FROM `staffs` WHERE `name` LIKE '%in%';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `pos` FROM `staffs` WHERE `name` LIKE '%in%';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id`, `name` FROM `staffs` WHERE `name` LIKE '%in%';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id`, `age` FROM `staffs` WHERE `name` LIKE '%in%';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id`,`name`, `age`, `pos` FROM `staffs` WHERE `name` LIKE '%in';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id`, `name` FROM `staffs` WHERE `pos` LIKE '%na';

/* 索引失效 全表扫描 */
EXPLAIN SELECT `name`, `age`, `pos`, `add_time` FROM `staffs` WHERE `name` LIKE '%in';

模糊查询百分号一定加前边

口诀:覆盖索引保两边。

字符要加单引号

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id`, `name` FROM `staffs` WHERE `name` = 'Ringo';

/* 使用到了覆盖索引 */
EXPLAIN SELECT `id`, `name` FROM `staffs` WHERE `name` = 2000;

/* 索引失效 全表扫描 */
EXPLAIN SELECT * FROM `staffs` WHERE `name` = 2000;

这里name = 2000在MySQL中会发生强制类型转换,将数字转成字符串。

口诀:字符要加单引号。

索引相关题目

假设index(a,b,c)

Where语句索引是否被使用
where a = 3Y,使用到a
where a = 3 and b = 5Y,使用到a,b
where a = 3 and b = 5Y,使用到a,b,c
where b = 3 或者 where b = 3 and c = 4 或者 where c = 4N,没有用到a字段
where a = 3 and c = 5使用到a,但是没有用到c,因为b断了
where a = 3 and b > 4 and c = 5使用到a,b,但是没有用到c,因为c在范围之后
where a = 3 and b like 'kk%' and c = 4Y,a,b,c都用到
where a = 3 and b like '%kk' and c = 4只用到a
where a = 3 and b like '%kk%' and c = 4只用到a
where a = 3 and b like 'k%kk%' and c = 4Y,a,b,c都用到

面试题分析

数据准备

/* 创建表 */
CREATE TABLE `test03`(
`id` INT PRIMARY KEY NOT NULL AUTO_INCREMENT,
`c1` CHAR(10),
`c2` CHAR(10),
`c3` CHAR(10),
`c4` CHAR(10),
`c5` CHAR(10)
);

/* 插入数据 */
INSERT INTO `test03`(`c1`,`c2`,`c3`,`c4`,`c5`) VALUES('a1','a2','a3','a4','a5');
INSERT INTO `test03`(`c1`,`c2`,`c3`,`c4`,`c5`) VALUES('b1','b22','b3','b4','b5');
INSERT INTO `test03`(`c1`,`c2`,`c3`,`c4`,`c5`) VALUES('c1','c2','c3','c4','c5');
INSERT INTO `test03`(`c1`,`c2`,`c3`,`c4`,`c5`) VALUES('d1','d2','d3','d4','d5');
INSERT INTO `test03`(`c1`,`c2`,`c3`,`c4`,`c5`) VALUES('e1','e2','e3','e4','e5');

/* 创建复合索引 */
CREATE INDEX idx_test03_c1234 ON `test03`(`c1`,`c2`,`c3`,`c4`);

题目

/* 最好索引怎么创建的,就怎么用,按照顺序使用,避免让MySQL再自己去翻译一次 */

/* 1.全值匹配 用到索引c1 c2 c3 c4全字段 */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' AND `c3` = 'a3' AND `c4` = 'a4';

/* 2.用到索引c1 c2 c3 c4全字段 MySQL的查询优化器会优化SQL语句的顺序*/
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' AND `c4` = 'a4' AND `c3` = 'a3';

/* 3.用到索引c1 c2 c3 c4全字段 MySQL的查询优化器会优化SQL语句的顺序*/
EXPLAIN SELECT * FROM `test03` WHERE `c4` = 'a4' AND `c3` = 'a3' AND `c2` = 'a2' AND `c1` = 'a1';

/* 4.用到索引c1 c2 c3字段,c4字段失效,范围之后全失效 */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' AND `c3` > 'a3' AND `c4` = 'a4';

/* 5.用到索引c1 c2 c3 c4全字段 MySQL的查询优化器会优化SQL语句的顺序*/
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' AND `c4` > 'a4' AND `c3` = 'a3';

/* 
   6.用到了索引c1 c2 c3三个字段, c1和c2两个字段用于查找,  c3字段用于排序了但是没有统计到key_len中,c4字段失效
*/
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' AND `c4` = 'a4' ORDER BY `c3`;

/* 7.用到了索引c1 c2 c3三个字段,c1和c2两个字段用于查找, c3字段用于排序了但是没有统计到key_len中*/
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' ORDER BY `c3`;

/* 
   8.用到了索引c1 c2两个字段,c4失效,c1和c2两个字段用于查找,c4字段排序产生了Using filesort说明排序没有用到c4字段 
*/
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' ORDER BY `c4`;

/* 9.用到了索引c1 c2 c3三个字段,c1用于查找,c2和c3用于排序 */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c5` = 'a5' ORDER BY `c2`, `c3`;

/* 10.用到了c1一个字段,c1用于查找,c3和c2两个字段索引失效,产生了Using filesort */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c5` = 'a5' ORDER BY `c3`, `c2`;

/* 11.用到了c1 c2 c3三个字段,c1 c2用于查找,c2 c3用于排序 */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND  `c2` = 'a2' ORDER BY c2, c3;

/* 12.用到了c1 c2 c3三个字段,c1 c2用于查找,c2 c3用于排序 */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND  `c2` = 'a2' AND `c5` = 'a5' ORDER BY c2, c3;

/* 
   13.用到了c1 c2 c3三个字段,c1 c2用于查找,c2 c3用于排序 没有产生Using filesort 
      因为之前c2这个字段已经确定了是'a2'了,这是一个常量,再去ORDER BY c3,c2 这时候c2已经不用排序了!
      所以没有产生Using filesort 和(10)进行对比学习!
*/
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c2` = 'a2' AND `c5` = 'a5' ORDER BY c3, c2;



/* GROUP BY 表面上是叫做分组,但是分组之前必定排序。 */

/* 14.用到c1 c2 c3三个字段,c1用于查找,c2 c3用于排序,c4失效 */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c4` = 'a4' GROUP BY `c2`,`c3`;

/* 15.用到c1这一个字段,c4失效,c2和c3排序失效产生了Using filesort */
EXPLAIN SELECT * FROM `test03` WHERE `c1` = 'a1' AND `c4` = 'a4' GROUP BY `c3`,`c2`;

GROUP BY基本上都需要进行排序,索引优化几乎和ORDER BY一致,但是GROUP BY会有临时表的产生。

索引失效的原理分析

索引失效的总结

索引优化的一般性建议:

  • 对于单值索引,尽量选择针对当前query过滤性更好的索引。
  • 在选择复合索引的时候,当前query中过滤性最好的字段在索引字段顺序中,位置越靠前越好。
  • 在选择复合索引的时候,尽量选择可以能够包含当前query中的where子句中更多字段的索引。
  • 尽可能通过分析统计信息和调整query的写法来达到选择合适索引的目的。

MySQL查询优化

慢查询基础

索引是如此的重要,以至于我们花费了不小的篇幅来介绍,这对于高性能来说是必不可少的。但这些还不够,还需要合理的设计查询,如果查询写得很糟糕,即使库表结构再合理、索引再合适,也无法实现高性能。查询优化、索引优化、库表结构优化需要多管齐下才能写出高效的SQL。

如果把查询看作是一个任务,那么它由一系列子任务组成,每个子任务都会消耗一定的时间。如果要优化查询,实际上要优化其子任务,要么消除其中一些子任务,要么减少子任务的执行次数,要么让子任务运行得更快。

通常来说,查询的生命周期可以按照顺序来看:从客户端,到服务器,然后在服务器上进行解析,生成执行计划,执行,并返回结果给客户端。其中“执行”可以认为是生命周期中最重要的阶段,这其中包含了大量为了检索数据到存储引擎的调用以及调用后的数据处理,包括排序、分组等。

在完成这些任务的时候,查询需要在不同的地方花费时间,包括网络,CPU计算,生成统计信息和执行计划、锁等待(互斥等待)等操作,尤其是向底层存储引擎检索数据的调用操作,这些调用需要在内存操作、CPU操作和内存不足时导致的I/O操作上消耗时间。根据存储引擎不同,可能还会产生大量的上下文切换以及系统调用。

在发生慢查询的时候,要么是某些操作被额外地重复了很多次,要么是某些操作执行得太慢。优化查询的目的就是减少和消除这些操作所花费的时间。

查询性能低下最基本的原因是访问的数据太多,某些查询可能不可避免地需要筛选大量数据,但这并不常见。大部分性能低下的查询都可以通过减少访问数据量的方式进行优化,对于低效的查询,通过如下两个步骤来分析总是非常有效:

  1. 确认应用程序是否在检索大量超过需要的数据。这通常意味着访问了太多的行,但有时候也可能是访问了太多的列
  2. 确认MySQL服务器层是否在分析大量超过需要的数据行

请求多余的数据

有些查询会请求超过实际需要的数据,然后这些多余的数据会被应用程序丢弃,这会给MySQL服务器带来额外的负担,并增加网络开销,离国内外i啊也会消耗应用服务器的CPU和内存资源。

查询不需要的记录

一个常见的错误是常常会误以为MySQL只会返回需要的数据,实际上MySQL却是先返回全部结果集再进行计算。典型的场景是先使用select语句查询大量的结果,然后获取前面N行后关闭结果集(例如在新闻网站中取出100条记录,但是只是在页面上显示前面10条)。MySQL并不会只查出需要的10条数据,而是会查询出全部的结果集,客户端的应用程序会接收全部的结果集数据,然后抛弃其中的大部分数据。最简单有效的解决方法就是在这样的查询后面加上LIMIT。

多表关联时返回全部列

如果想查询所有在电影Academy Dinosaur中出现的演员,不应该按下面的写法编写查询:

SELECT * FROM 
	sakila.actor 
  INNER JOIN sakila.film_actor USING(actor_id) 
  INNER JOIN sakila.film USING(film_id) 
WHERE 
  sakila.film.title = 'Academy Dinosaur';

这将返回这三表的全部数据列,正确的方式应该像下面这样只取需要的列:

SELECT sakila.actor.* FROM sakila.actor...;

总是取出全部列

每次看到select *的时候都需要用怀疑的眼光审视,是不是真的需要返回全部的数据?很可能不是必须的。取出全部列,会让优化器无法完成索引覆盖扫描这类优化,还会为服务器带来额外的I/O、内存和CPU的消耗,有时候还能避免某些列被修改带来的问题。

不过,查询返回超过需要的数据也不总是坏事,它可以提高相同代码片段的复用性,如果应用程序使用某种缓存机制,或者有其他考虑,获取超过需要的数据也可能有其好处,但不能忘记这样做的代价是什么。

重复查询相同的数据

如果不加以小心,很容易出现这样的错误——不断地重复执行相同的查询,然后每次都返回完全相同的数据。例如,在用户评论的地方需要查询用户头像的URL,那么用户多次评论的时候,可能就会反复查询这个数据。比较好的方案是,当初次查询的时候将这个数据缓存起来,需要的时候从缓存中取出,这样性能显然会更好。

扫描额外的记录

在确定查询只返回需要的数据以后,接下来应该看看查询为了返回结果是否扫描了过多的数据。对于MySQL,最简单的衡量查询开销的三个指标如下:

  • 响应时间
  • 扫描的行数
  • 返回的行数

没有哪个指标能够完美地衡量查询的开销,但它们大致反映了MySQL在内部执行查询时需要访问多少数据,并可以大概推算出查询运行的时间。这三个指标都会记录到MySQL的慢日志中,所以,检查慢日志记录是找出扫描行数过多的查询的好办法。

响应时间

响应时间是两个部分之和:服务时间和排队时间。服务时间是指数据库处理这个查询真正花了多长时间。排队时间是指服务器因为等待某些资源而没有真正地执行查询的时间——可能是等I/O操作完成,也可能是等待行锁,等等。遗憾的是,我们无法把响应时间细分到上面这些部分,除非有什么办法能够逐个测量上面这些消耗,不过很难做到。一般常见和重要的是等待是I/O和锁等待,但是实际情况更加复杂。

所以在不同类型的应用压力下,响应时间并没有什么一致的规律或者公示。诸如存储引擎的锁(表锁、行锁)、高并发资源竞争、硬件响应等诸多因素都会影响响应时间。所以,响应时间既可能是一个问题的结果也可能是一个问题的原因,不同案例的情况不同。

当看到一个查询请求的响应时间的时候,应该使用“快速上限估计”法来估算这个时间是否是一个合理的值。“快速上限估计”法概括地说,了解这个查询需要哪些索引以及它的执行计划是什么,然后计算大概需要多少个顺序和随机I/O,再用其乘以在具体硬件下一次I/O的消耗时间,最后把这些消耗都加起来,就可以获得一个大概参考值来判断当前响应时间是不是一个合理的值。

扫描的行数和返回的行数

分析查询时,查看该查询扫描的行数是非常有帮助的。这在一定程度上能够说明该查询找到需要的数据的效率高不高。

对于找出那些“糟糕”的查询,这个指标可能还不够完美,因为并不是所有的行的访问代价都是相同的。较短的性的访问速度更快,内存中的行也比磁盘中的行的访问速度要快得多。

理想情况下,扫描的行数和返回的行数应该是相同的。但实际情况中这种“美事”并不多。例如在做一个关联查询时,服务器必须要扫描多行才能生成结果集中的一行。扫描的行数对返回的行数的比率通常很小,一般在1:1和10:1之间,不过有时候这个值也可能非常大。

扫描的行数和访问类型

在评估查询开销的时候,需要考虑一下从表中找到某一行数据的成本。MySQL有好几种访问方式可以查找并返回一行结果。有一些访问方式可能需要扫描很多行才能返回一行结果,也有些访问方式可能无需扫描就能返回结果。

在explain语句中的type列反映了访问类型。访问类型有很多中,从全表扫描到索引扫描、范围扫描、唯一索引查询、常数引用等。这里列的这些,速度从慢到快,扫描的行数也从小到大。如果查询没有办法找到合适的访问类型,那么解决的最好办法通常就是增加一个合适的索引。索引让MySQL以最高效、扫描行数最少的方式找到需要的记录。

例如,我们在示例数据库Sakila中的一个查询案例:

SELECT * FROM sakila.film_actor WHERE film_id = 1;

这个查询将返回10行数据,从explain的结果可以看到,MySQL在索引idx_fk_film_id上使用了ref访问类型来执行查询:

EXPLAIN SELECT * FROM sakila.film actor WHERE film id = 1\G
**************************** 1. roW ***************************
                      id: 1
             select_type: SIMPLE
                   table: film_actor
                    type: ref
           possible_keys: idx_fk_film_id
                     key: idx_fk_film_id
                 key_len: 2
                     ref: const
                    rows: 10
                   Extra:

explain的结果也显示MySQL预估需要访问10行数据。换言之,查询优化器认为这种访问类型可以高效地完成查询。如果没有合适的索引MySQL就不得不使用一种更糟糕的访问类型。如果我们删除对应的索引再来运行这个查询:

 ALTER TABLE sakila.film_actor DROP FOREIGN KEY fk_film_actor_film;

ALTER TABLE sakila.film_actor DROP KEY idx_fk_film_id;

EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\G
*************************** 1. row *********************
                    id: 1
           select_type: SIMPLE
                 table: film_actor
                  type: ALL
         possible_keys: NULL
                   key: NULL
               key_len: NULL
                   ref: NULL
                  rows: 5073
                 Extra: Using where

正如我们预测的,访问类型变成了一个全表扫描(ALL),现在MySQL预估需要扫描5073条记录来完成这个查询。这里的“Using Where”表示MySQL将通过WHERE条件来筛选存储引擎返回的记录。

一般MySQL能够使用如下三种方式应用WHERE条件,从好到坏依次为:

  • 在索引中使用WHERE条件来过滤不匹配的记录。这时存储引擎层完成的
  • 使用索引覆盖扫描(在Extra列中出现了Using index)来返回记录,直接从索引中过滤不需要的记录并返回命中的结果。这是在MySQL服务器层完成的,但无需再回表查询记录
  • 从数据表中返回数据,然后过滤不满足条件的记录(在Extra列中出现Using Where)。这在MySQL服务器层完成,MySQL需要先从数据表读出记录然后过滤

上面的这个例子说明了好的索引多么重要。好的索引可以让查询使用合适的访问类型,尽可能地只扫描需要的数据行。但也不是说增加索引就能让扫描的行数等于返回的行数。例如下面使用聚合函数COUNT()的查询:

SELECT actor_id, COUNT (*) FROM sakila.film_actor GROUP BY actor_id;

这个查询需要读取几千行数据,但是仅返回200行结果。没有什么索引能够让这样的查询减少需要扫描的行数。

不幸的是,MySQL不会告诉我们生成结果实际上需要扫描多少行数据,而只会告诉我们生成结果时一共扫描了多少行数据。扫描的行数中的大部分都很可能是被WHERE条件过滤掉的,对最终的结果集没有并没有贡献。在上面的例子中,我们删除索引后,看到MySQL需要扫描多少行和实际需要使用的行数需要先去理解这个查询背后的逻辑和思想。

如果发现查询需要扫描大量的数据但只返回少数的行,那么通常可以尝试下面的技巧进行优化:

  • 使用索引覆盖扫描,把所有需要的列都放到索引中,这样存储引擎无需回表获取对应行就可以返回结果了
  • 改变库表结构,例如使用单独的汇总表
  • 重写这个复杂的查询,让MySQL优化器能够以更优化的方式去执行这个查询

重构查询

复杂查询 or 简单查询

设计查询的时候一个需要考虑的重要问题时,是否需要将一个复杂的查询分成多个简单的查询。在传统的实现中,总是强调需要数据库层完成尽可能多的工作,这样做的逻辑在于以前总是认为网络通信、查询解析和优化是一件代价很高的事情。

但是这样的想法对于MySQL来说并不适用,MySQL从设计上连接和断开连接都很轻量级,在返回一个小的查询结果方面很高效。现代的网络速度比以前要快很多,无论是带宽还是延迟。在某些版本的MySQL上,即使在一个通用服务器上,也能够运行每秒超过10万的查询,即使是一个千兆网卡也能轻松满足每秒超过2000次的查询,所以运行多个小查询现在已经不是大问题了。

MySQL内部每秒能够扫描内存中上百万行的数据,相比之下,MySQL响应数据给客户端就慢得多了。在其他条件都相同的时候,使用尽可能少的查询当然是更好的。但是有时候时候,将一个大查询分解为多个小查询是很有必要的。

切分查询

有时候对于一个大查询我们需要“分而治之”,将大查询切分成小查询,每个查询功能完全一样,只完成一小部分,每次只返回一小部分查询结果。

删除旧的数据就是一个很好的例子。定期地清除大量数据时,如果用一个大的语句一次性完成的话,则可能需要一次锁住很多数据、占满整个事务日志、耗尽系统资源、阻塞很多小的但重要的查询。将一个大的DELETE语句切分成多个较小的查询可以尽可能小地影响MySQL性能,同时还可以减少MySQL复制的延迟。例如,我们需要每个月运行一次下面的查询:

DELETE FROM messages WHERE created < DATE_SUB(NOW(), INTERVAL 3 MONTH);

那么可以用类似下面的方法来完成相同的工作:

rows affected = 0 do { rows_affected = do_query(
  "DELETE FROM messages WHERE created < DATE_SUB(NOW(),INTERVAL 3 MONTH).
LIMIT 10000"
) } while rows_affected > 0

一次删除一万行数据一般来说时一个比较高效而且对服务器影响也最小的做法(如果是事务型引擎,很多时候小事务能够更高效)。同时,需要注意的是,如果每次删除数据后,都暂停一会儿再做下一次删除,这样也可以将服务器上原本一次性的压力分散到一个很长的时间段中,就可以大大降低对服务器的影响,还可以大大减少删除时锁的持有时间。

分解关联查询

很多高性能的应用都会对关联查询进行分解。简单地,可以对每一个表进行一次单表查询,然后将结果在应用程序中进行关联,例如,下面这个查询:

SELECT 
  * 
FROM 
  tag 
  JOIN tag_post ON tag_post.tag_id = tag.id 
  JOIN post ON tag_post.post_id = post.id
WHERE 
  tag.tag = 'mysql';

可以分解成下面这些查询来代替:

SELECT * FROM tag WHERE tag = 'mysql';
SELECT * FROM tag_post WHERE tag_id = 1234;
SELECT * FROM post WHERE post.id in (123,456,567,9098,8904);

使用分解关联查询的优势如下:

  • 让缓存的效率更高。许多应用程序可以方便地缓存单表查询对应的结果对象。例如,上面查询中的tag已经被缓存了,那么应用就可以跳过第一个查询。再例如,应用中已经缓存了ID为123、567、9098的内容,那么第三个查询的IN()中就可以少几个ID。另外,对MySQL的查询缓存来说,如果关联中的某个表发生了变化,那么就无法使用查询缓存了,而拆分后,如果某个表很少改变,那么基于该表的查询就可以重复利用查询缓存结果了
  • 将查询分解后,执行单个查询可以减少锁的竞争
  • 在应用层做关联,可以更容易对数据库进行拆分,更容易做到高性能和可扩展
  • 查询本身效率也可能会有所提升。在这个例子中,使用IN()代替关联查询,可以让MySQL按照ID顺序进行查询,这可能比随机的关联要更高效
  • 可以减少冗余记录的查询。在应用层做关联查询,意味着对于某条记录应用只需要查询一次,而在数据库中做关联查询,则可能需要重复地访问一部分数据。从这点看,这样的重构可能会减少网络和内存的消耗
  • 更进一步,这样做相当于在应用中实现了哈希关联,而不是使用MySQL的嵌套循环关联。某些场景哈希关联的效率要高很多

在很多场景下,通过重构查询将关联当到应用程序中将会更加高效,这样的场景有很多,比如:当应用能够方便地缓存单个查询结果的时候,当可以将数据分布到不同的MySQL服务器上的时候,当能够使用IN()的方式来代替关联查询的时候,当查询中使用同一个数据表的时候。

count(*)优化

在实际的开发中,经常可能需要计算一个表中(或部分)的行数,通常可以使用select count(*) from t,但随着系统中记录数的不断增多,这条语句会执行得越来越慢。

count(*)的实现

实际上,在不同的MySQL引擎中,count(*)有不同的实现方式:

  • MyISAM引擎把一个表的总行数存在了磁盘上,因此执行count(*)的时候会直接返回这个数(如果没有where条件),效率很高
  • InnoDB引擎在执行count(*)的时候,需要把数据一行一行地从引擎里面读出来后,累积计数,显然,这种方式地效率很低

那为什么InnoDB不跟MyISAM一样,也把数字存起来呢?这是因为即使在同一个时刻的多个查询,由于多版本并发控制(MVCC)的原因,InnoDB表“应该返回多少行”也是不确定的,这里,我们以一个算count(*)的例子来说明,假设表t中现在有10000条记录,并且有三个用户并行的会话:

  • 会话A先启动事务并查询一次表的总行数
  • 会话B启动事务,插入一行后记录后,查询表的总行数
  • 会话c先启动一个单独的语句,插入一行记录后,查询表的总行数

这里我们假设从上到下是按照时间顺序执行的,同一行语句是在同一时刻执行的。

image-20211211213023360

可以看到,在最后的同一个时刻,三个会话A、B、C会同时查询表t的总行数,但拿到的结果却不同,这和InnoDB的事务设计有关系,可重复读是它默认的隔离级别,在代码上就是通过多版本并发控制,也就是MVCC来实现的,每一行记录都要判断自己是否对这个会话可见,因此对于count(*)请求来说,InnoDB只好把数据一行一行地读出依次判断,可见的行才能够用于计算“基于这个查询”的表的总行数。

不过,InnoDB对这个语句也做个一定程度的优化,InnoDB是索引组织表,主键索引树的叶子节点是数据,而普通索引树的叶子节点是主键值。所以,普通索引树比主键索引树小很多。对于count(*)这样的操作,遍历哪个索引树得到的结果逻辑上都是一样的,因此,MySQL优化器会找到最小的那棵树来遍历,在保证逻辑正确的前题下,尽量减少扫描的数据量,是数据库系统设计的通用法则之一。

也许你想到show table status命令,这个命令的输出结果也有一个TABLE_ROWS用于显示这个表当前有多少行,并且这个命令执行的速度较快,遗憾的是,TABLE_ROWS是通过采样估算的来的,因此只是一个估算值,官方文档显示误差可能达到40%到50%,因此,也无法使用它来进行统计。

那么如果有一个页面经常要显示交易系统的操作记录总数,到底应该怎么办呢?答案是,只能自己计数,接下来,我们将会讨论自己计数的方法,以及每种方法优缺点。

使用缓存系统保存计数

对常见的做法就是使用缓存,可以使用一个Redis服务来保存这个表的总行数。这个表每被插入一行Redis计数就加1,每被删除一行Redis计数就减1。这种方式的缺点就在于,缓存系统可能会丢失更新。Redis的数据不能永久地留在内存中,所以需要找一个地方把这个值定期地持久化存储起来,但即使这样,仍然可能丢失更新,如果刚刚在数据表中插入了一行,Redis中保存的值也加了1,然后Redis异常重启了,重启后需要从存储Redis数据的地方把这个值读回来,而刚刚加1的这个计数操作却丢失了。

实际上,将计数保存在缓存系统中的方式,还不只是丢失更新的问题,即使Redis正常工作,这个值还是逻辑上不精确的。

在数据库中保存计数

不同count的用法

count()是一个聚合函数,对于返回的结果集,一行行地判断,如果count函数的参数不是NULL,累计值就加1,否则不加,最后返回累计值,所以count(*)、count(主键id)和count(1)都表示返回满足条件的结果集的总行数,而count(字段),则表示返回满足条件的数据行里面,参数“字段”不为NULL的总个数。

对于count(主键id)来说,InnoDB引擎会遍历整张表,把每一行的id值都取出来,返回给server层,server层拿到id后,判断是不可能为空的,就按行累加;对于count(1)来说,InnoDB引擎遍历整张表,但不取值。server层对于返回的每一行,放一个数字“1”进去,判断是不可能为空的,按行累加。相比较而言,count(1)执行得要比count(主键id)快。因为从引擎返回id会涉及到解析数据行,以及拷贝字段值的操作。

虽然主键id一定不为空,但InnoDB并没有做相关的优化。

对于count(字段)来说:

  • 如果这个“字段”是定义为not null的话,一行行地从记录里面读出这个字段,判断不能为null,按行累加
  • 如果这个“字段”定义允许为null,那么执行的时候,判断到有可能是null,还要把值取出来再判断以下,不是null才累加

这些不同的方式按照效率排序:count(字段)< count(主键id) < count(1) ≈ count(*)。

join语句的优化

在实际生产中,关于join语句使用的问题,一般会几种在以下两类:

  • DBA不让使用join,使用join有什么问题?
  • 如果有两个大小不同的表做join,应该用哪个表做驱动表?

为了便于量化分析,我们创建两个表t1和t2:

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

drop 
  procedure idata; delimiter;; create procedure idata() begin declare i int; 
set 
  i = 1; while(i <= 1000) do insert into t2 
values 
  (i, i, i); 
set 
  i = i + 1; end while; end;; delimiter; call idata(); 
  
create table t1 like t2; 
  
insert into t1 (select * from t2 where id <= 100);

这两张表都有一个主键索引id和一个索引a,字段b上无索引。存储过程idata()往表t2里面插入了1000行数据,在表t1插入的是100行数据。

Index Nested-Loop Join

select * from t1 straight_join t2 on (t1.a=t2.a);

如果直接使用join语句,MySQL优化器可能会选择表t1或t2作为驱动表,这样会影响我们分析SQL语句的执行过程,使用straight_join可以让MySQL使用固定的连接方式执行查询,这样优化器只会按照我们指定的方式去join,在这个语句里,t1是驱动表,t2是被驱动表,explain的结果如下:

image-20211215235731451

可以看到,在这条语句里,被驱动表t2的字段上有索引,join过程用上了这个索引,因此这个语句的执行流程如下:

  1. 从表t1中读入一行数据R
  2. 从数据行R中,取出字段到表t2里去查找
  3. 取出表t2中满足条件的行,跟R组成一行,作为结果集的一部分
  4. 重复执行步骤1到3,直到表t1的末尾循环结束

这个过程是现遍历表t1,然后根据从表t1中取出的每行数据中a的值,去表t2查找满足条件的记录,在形式上,这个过程就跟我们写程序时的嵌套查询类似,并且可以用上被驱动表的索引,所以我们称之为“Index Nested-Loop Join”,简称NLJ。对应的流程图如下:

image-20211216233222986

在这个流程里:

  1. 对驱动表t1做了全表扫描,这个过程需要扫描100行
  2. 对于每一行R,根据a字段去表t2查找,走的是树搜索过程,由于我们构造的数据都是一一对应的,因此每次的搜索过程都只扫描一行,也就是总共扫描100行
  3. 所以,整个执行流程,总扫描行数是200

假设不使用join,那我们就只能用单表查询,要实现上述相同的需求,使用单表查询需要:

  1. 执行select * from t1,查出表t1的所有数据,这里有100行
  2. 循环遍历这100行数据
    • 从每一行R取出字段a的值$R.a
    • 执行select * from where a = $R.a
    • 把返回的结果和R构成结果集的一行

可以看到,在这个查询过程,也就是扫描了200行,但是总共执行了101条语句,比直接join多了100次交互,除此之外,客户端还要自己拼接SQL语句和结果,那么显然,这么做不如直接join。

在这个join语句的执行过程中,驱动表是走全表扫描,而被驱动表是走树搜索,假设被驱动表的行数是M,每次在被驱动表查一行数据,要先搜索索引a,再搜索主键索引,每次搜索一棵树近似复杂度是以2为底的M的对数,记为log2M,所以在被驱动表上查一行的时间复杂度是2*log2M。假设驱动表的行数是N,执行过程就要扫描驱动表N行,然后对于每一行,到被驱动表上匹配一次,因此整个执行过程,时间复杂度近似是N+N*2log2M,显然,N对扫描行数影响更大,因此应该让小表做驱动表。

通过以上的分析,我们可以得到两个结论:

  1. 使用join语句,性能比强行拆成多个单表执行SQL语句的性能要好
  2. 如果使用join语句的话,需要让小表做驱动表

Simple Nested-Loop Join

现在,我们将SQL语句修改如下:

select * from t1 straight_join t2 on (t1.a = t2.b);

由于表t2的字段上没有索引,因此在join的时候,每次到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

BNL的执行流程如下:

  1. 将表t1的数据读入线程内存join_buffer中,由于我们这个语句中写的是select *,因此是把整个表t1放入了内存
  2. 扫描表t2,把t2中的每一行取出来,跟join_buffer中的数据做对比,满足join条件的,作为结果集的一部分返回

流程图如下:

image-20211219110609522

explain的结果如下:

image-20211219110725871

可以看到,在这个过程中,对表t1和t2都做了一次全表扫描,因此总的扫描行数是1100。由于join_buffer是以无序数组的方式组织的,因此对表t2中的每一行,都要做100次判断,总共需要在内存中做判断的次数是100*1000=10万次。

从时间复杂度上来看,Simple Nested-Loop Join和Block Nested-Loop Join算法是相同的,但是BNL算法的这10万次判断是内存操作,速度会快上很多,性能也更好。

那么在这种情况下,应该使用哪个表做驱动表呢?假设小表的行数是N,大表的行数是M,那么在这个算法里:

  1. 两个表都做一次全表扫描,所以总的扫描行数是M+N
  2. 内存中的判断次数是M*N

可以看到,调换M和N的位置,并不会影响这个算法的时间复杂度,因此这个时候选择大表或者小表做驱动表,执行耗时都是一样的。

这个算法会将t1表的内容放入到join_buffer中,join_buffer的大小是由参数join_buffer_size设定的,默认值是256k。如果放不下表t1的所有数据的话,就会分段放置。现在我们将join_buffer_size改小,再执行:

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中,继续执行第二步

执行的流程图如下:

image-20211219113254308

图中的步骤4和5表示清空join_buffer复用,这也体现出了这个算法名字中“Block”的由来,表示“分块去join”。由于表t1被分成了两次放入join_buffer中,导致表t2会被扫描两次,虽然分成两次放入join_buffer,但是判断等值条件的次数还是不变的,依然是(88+12)*1000=10万次。

在这个算法里,驱动表的数据行数是N,需要分k段才能完成算法流程,其中K=λN(λ∈(0,1)),被驱动表的数据行数是M:

  1. 扫描行数是N+λ*N*M
  2. 内存判断是N*M次

显然,内存判断次数是不受选择哪个表作为驱动表影响的,而考虑到扫描行数,在M和N大小确定的情况下,N小一些,整个算式的结果会更小,所以在这种算法下,应该让小表当驱动表。

在N+λ*N*M中,还有一个关键的参数λ,可以看到λ越小越好,由定义,我们可以知道λ=K/Nλ=K/N,那么显然,在N确定的情况下,K越小越好,也就是说,当join_buffer_size越大的时候,分成的段数也就越少,对被驱动表的全表扫描次数就越少。

以上结论告诉我们,如果join语句很慢,可以尝试将join_buffer_size改大。

我们回到本小节一开始的两个问题:

  1. 能不能使用join语句?
  2. 如果使用join语句,应该选择大表做驱动表还是选择小表做驱动表?

对于第一个问题:

  • 如果可以使用Index Nested-Loop Join算法,也就是说可以用上被驱动表上的索引,就可以用上被驱动表上的索引,其实是没问题的
  • 如果使用Block Nested-Loop Join算法,扫描行数就会过多,尤其是在大表上的join操作,这样可能要扫描被驱动表很多次,会占用大量的系统资源,所以这种join尽量不要使用

因此,在判断要不要使用join语句时,就是看explain的结果里面,extra字段里面有没有出现“Block Nested Loop”字样。

对于第二个问题:

  • 如果是Index Nested-Loop Join算法,应该选择小表做驱动表
  • 如果是Block Nested-Loop Join算法:
    • 在join_buffer_size足够大的时候,是一样的
    • 在join_buffer_size不够大的时候(这种情况更为常见),应该选择小表做驱动表

所以,这个问题的结论是,应该选择小表做驱动表。不过需要格外说明的是,在决定哪个表做驱动表的时候,应该是两个表按照各自的条件过滤,过滤完成之后,计算参与join的各个字段的总数据量,数据量小的那个表,就是“小表”,应该作为驱动表。

Multi-Range Read 优化

NLJ和BNL都还有优化的空间,为了说明这一点,创建表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优化的主要目的是尽量使用顺序读盘。回表是指,InnoDB在普通索引a上查到主键id的值后,再根据一个个主键id的值到主键索引上去查整行数据的过程。

select * from t1 where a >= 1 and a <= 100;

由于主键索引是一颗B+树,在这棵树上,每次只能根据一个主键id查到一行数据。因此,回表肯定是一行行搜索主键索引的,基本流程如图所示:

image-20211219225104477

如果随着a的值递增顺序查询的话,id的值就变成随机的,那么就会出现随机访问,性能相对较差。虽然还是按行查,但是可以通过调整查询的顺序,还是可以加速查询的效率,因为大多数的数据都是按照主键递增顺序插入得到的,所以我们可以认为,如果按照主键的递增顺序查询的话,对磁盘的读比较接近顺序读,能够提升读性能。这就是MRR优化的设计思路,此时语句的执行流程就变成了这样:

  1. 根据索引a,定位到满足条件的记录,将id值放入read_rnd_buffer中
  2. 将read_rnd_buffer中的id进行递增排序
  3. 排序后的id数组,依次到主键id索引中查记录,并作为结果返回

这里,read_rnd_buffer的大小是由read_rnd_buffer参数控制的。如果步骤1中,read_rnd_buffer放满了,就会执行步骤2和3,然后清空read_rnd_buffer,之后继续找索引a的下个记录,并继续循环。

如果想要稳定地使用MRR优化的话,需要设置`set optimizer_switch="mrr_cost_based=off"`,因为目前的优化器会更倾向于不使用MRR,通过这个设置就可以保证一定可以使用MRR优化。

image-20211219230645249

explain的结果:

image-20211219230712320

从explain的结果来看,Extra字段多了Using MRR,表示的是用上了MRR优化,而且,由于我们在read_md_buffer中按照id做了排序,所以最后得到的结果集也是按照主键id递增顺序的,与没有适用MRR的结果集顺序刚好相反。

总而言之,MRR能够提升性能的核心在于,这条查询语句在索引a上是一个范围查询(也就是说,这是一个多指查询),可以得到足够多的主键id。这样通过排序以后,再去主键索引查数据,才能体现出“顺序性”的优势。

Batched Key Access

MySQL在5.6版本后开始引入了Batched Key Acess(BKA)算法,这个算法其实就是对NLJ算法的优化。首先我们来回顾一下NLJ算法的执行流程:

image-20211219232052731

NLJ算法执行的逻辑是:从驱动表t1,一行行地取出a的值,再到被驱动表t2去做join。也就是说,对于表t2来说,每次都是匹配一个值,这时,MRR的优势就用不上了。那怎么才能一次性地多穿些值给表t2呢?从表t1里一次性地多拿些出来,放入到join_buffer,然后一起传给表t2,这就是BKA算法,简而言之,使用join_buffer优化的NLJ算法就是BKA算法,算法的流程图如下:

image-20211219235052154

图中,join_buffer中放入的数据是R1~R100,表示的是只会取查询需要的字段,如果join_buffer放不下 R1~R100 的所有数据,就会把这100行数据分成多段执行上图的流程。BKA算法并没有默认开启,如果要使用BKA优化算法的话,需要在执行SQL语句之前,先设置:

set optimizer_switch = 'mrr=on,mrr_cost_based=off,batched_key_access=on';

其中,前两个参数的作用是启用MRR,这么做的原因是,BKA算法的优化要依赖于MRR。

BNL算法的性能问题

BNL可能会对被驱动表做多次扫描,如果这个被驱动表是一个大的冷数据表,除了会导致IO压力大以外,还会对系统有什么影响呢?

由于InnoDB对Buffer Pool的LRU算法做了优化,即:第一次从磁盘读入内存的数据页,会先放在old区域。如果1秒之后这个数据也不再被访问了,就不会移动到LRU链表头部,这样对Buffer Pool的命中率影响就不大。但是,如果这个冷表很大,就会出现另外一种情况:业务正常访问的数据页,没有机会进入young区域。

由于这个优化机制的存在,一个正常访问的数据页,要进入young区域,需要隔1秒后再次被访问到。但是,由于我们的join语句在循环读磁盘和淘汰内存页,进入old区域的数据页,很可能在1秒内就被淘汰了。这样,就会导致MySQL实例的Buffer Pool在这段时间内,young区域的数据页没有被合理地淘汰,也就是说,这两种情况都会影响Buffer Pool的正常运作。

BNL算法对系统的影响主要包括三个方面:

  1. 可能会多次扫描被驱动表,占用磁盘IO资源
  2. 判断join条件需要执行M*N次对比(M、N分别是两张表的行数),如果是大表就会占用非常多的CPU资源
  3. 可能会导致Buffer Pool的热数据被淘汰,影响内存命中率

因此,我们在执行语句之前,需要通过理论分析和查看explain结果的方式,确认是否使用BNL算法。如果确认优化器会使用BNL算法,就需要做优化。优化的常见做法是,给被驱动表的join字段加上索引,把BNL算法转成BKA算法。

BNL转BKA

大多数情况下,我们直接在被驱动表上建立索引,就可以直接转为BKA算法了,但是也有一些并不适合在被驱动表上建索引的情况,比如下面这个语句:

select * from t1 join t2 on (t1.b = t2.b) where t2.b >= 1 and t2.b <= 2000;

此时,如果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],如果是,就作为结果集的一部分返回,否则跳过

explain的结果如下:

image-20211221000258809

可以发现,判断join是否满足的时候,会扫描表t2的每一行,判断条件的次数是1000*100万=10亿次,这个判断的工作量很大。但是经过where条件过滤后,需要参与join的实际上只有2000行数据。如果这条语句是一个低频的SQL语句,那么再为这个语句在表t2的字段b上创建一个索引就很浪费了。

在表t2的字段b上创建索引会浪费资源,但是不创建索引的话需要判断10亿次,这个时候就可以考虑使用临时表,使用临时表的步骤如下:

  1. 将表t2中满足条件的数据放在临时表tmp_t中
  2. 为了让join使用BKA算法,给临时表tmp_t的字段b加上索引
  3. 让表t1和tmp_t做join操作
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. 执行insert语句构造temp_t表并插入数据的过程中,对表t2做了全表扫描,这里扫描的行数是100万
  2. 之后的join语句,扫描表t1,这里的扫描行数是1000,join比较过程中,做了1000次带索引的查询,相比于优化前的join语句需要做10亿次条件判断来说,这个优化效果还是很明显的

执行的效果如下:

image-20211221000900031

总体来看,不论是在原表上加索引,还是用有索引的临时表,我们的思路都是让join语句能够用上被驱动表上的索引,来触发BKA算法,提升查询性能。

hash join

在之前的例子中,之所以要有计算10亿次的操作,是因为在join_buffer里面维护的是一个无序数组,而不是一个哈希表,如果能够将无序数组替换为哈希表,这样只需要100万次的哈希查找,整条语句的执行速度就可以加快,但MySQL的优化器和执行器并不支持哈希join。不过,我们可以按照这个思路,在业务端实现:

  1. select * from t1;,取得表t1的全部1000行数据,在业务端存入到哈希这种数据结构的实现,比如HashMap
  2. select * from t2 where b >= 1 and b <= 2000;,获取表t2中满足条件的2000行数据
  3. 把这2000行数据,一行行地取到业务端,到哈希表中寻找匹配的数据,满足匹配的条件的这行数据,就作为结果集的一行

MySQL中的临时表

在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。这种表的数据都保存在内存里,系统重启的时候就会被清空,但是表结构还在。除了这两个特性看上去比较“奇怪”,从其它特征上看,它就是一个正常表
  • 而临时表,可以使用各种引擎类型(包括Memory引擎),如果使用InnDB引擎或者MyISAM引擎的临时表,写数据的时候是写到磁盘上的

临时表的特性

以下列操作序列为例:

image-20211221234203605

可以看到,临时表有以下几个特点:

  1. 建表语法是create temporary table..
  2. 一个临时表只能被创建它的session访问,对其它线程不可见,所以,图中session A创建的临时表t,对于session B就是不可见的
  3. 临时表可以与普通表同名
  4. session A内有同名的临时表和普通表的时候,show create语句,以及增删改查语句访问的是临时表
  5. show tables命令不显示临时表

由于临时表只能被创建它的session访问,所以在这个session结束的时候,会自动删除临时表,也正是由于这个特性,临时表特别适合join优化的场景,理由如下:

  • 不同session的临时表是可以重名的,如果有多个session同时执行join优化,无需担心表名重复导致建表失败的问题
  • 不需要担心数据删除问题。如果使用普通表,在执行流程过程中客户端发生了异常断开,或者数据库发生异常重启,还需要专门清理中间过程中生成的数据表。而临时表由于会自动回收,所以不需要这个额外的操作

临时表的应用

由于不同担心线程之间的重名冲突,临时表经常会被用在复杂查询优化过程中,其中,分库分表系统的跨库查询就是一个典型的使用场景。

一般分库分表的场景,就是要把一个逻辑上的大表分散到不同的数据库实例上。比如,将一个大表ht,按照字段f,拆分成1024个分表,然后分布到32个数据库实例上,如下图所示:

image-20211222000035457

一般情况下,这种分库分表系统都有一个中间层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,只能到所有的分区中去查找满足条件的所有行,然后统一进行排序,这种情况下,有两种比较常用的思路。

第一种思路是在proxy层的进程代码中实现排序。这种方式的优势是处理速度快,拿到分库的数据以后,直接在内存中参与计算,不过,这个方案的缺点也很明显:

  1. 需要的开发工作量比较大。如果仅需要order by还比较简单,但是,如果涉及到复杂的操作,比如group by,甚至join这样的操作,对中间层的开发能力要求比较高
  2. 对proxy端的压力比较大,尤其是很容易出现内存不够用和CPU瓶颈的问题

第二种思路是,将各个分库拿到的数据,汇总到一个MySQL实例的一个表中,然后在这个汇总实例上做逻辑操作,以上这条查询语句的执行流程如下:

  • 在汇总库上创建一个临时表temp_ht,表中包含三个字段v、t、t_modified
  • 在各个分库上执行select v,k,t_modified from ht_x where k >= M order by t_modified desc limit 100;
  • 将分库执行的结果插入到temp_ht表中
  • 执行select v from temp_ht order by t_modified desc limit 100;就可以得到结果

第二种思路的示意图如下:

image-20211226130031919

实践中,由于每个分库的计算量都不饱和,所以会直接把临时表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引入了一个临时文件表空间,专门用来存放临时文件的数据,而无需再创建idb文件

示例如下:

image-20211226131648061

这个进程的进程号是4d2,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和session B创建的两个临时表t1,它们的table_def_key不同,磁盘文件名也不同,因此可以并存。

在实现上,每个线程都维护了自己的临时表链表。这样,每次session内操作表的时候,先遍历链表,检查是否有这个名字的临时表,如果有就优先操作临时表,如果没有再操作普通表,在sessoin结束的时候,对链表里的每个临时表,执行“DROP TEMPARY TABLE + 表名”操作。

临时表和主备复制

临时表只能在线程内自己访问,但在执行DROP TEMPARY 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_normalinsert into t_normal select * from temp_t这两个语句的binlog日志,备库在执行到insert into t_normal的时候,就会报错“表temp_t不存在”。

实际上可以通过设置参数binlog_format=row,那么与临时表有关的语句,就不会记录到binlog里。当参数设置为binlog_format=statment/mixedde的时候,binlog才会记录临时表的操作。

当binlog是row格式的时候,创建临时表的语句会自动在备库执行,主库在线程退出的时候,会自动删除临时表,但是备库同步线程是持续在运行的。所以,需要在主库上再写一个DROP TEMPARY TABLE传给备库执行,这就是这个命令会什么会出现在binlog的原因。

通常情况下,MySQL在记录binlog的时候,都会将SQL语句原封不动的记录下来,但是如果执行drop table_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 */

因为备库上并没有表tmp_t,将这个命令重写后再传到备库执行,才不会备库同步线程停止。所以,drop table命令记录binlog的时候,就必须对语句做改写, “/* generated by server */” 说明了这是一个被服务端改写过的命令。

主库上不同的线程创建同名的临时表是没有关系的,但是传到备库执行时如何处理的呢?下面的序列中实例S是M的备库:

image-20211226182104322

主库M上的两个session创建了同名的临时表t1,这两个create temporary table t1语句都会被传到备库S上,但是,备库的应用日志线程是共用的,也就是说要在应用线程里面先后执行这个create语句两次。(即使开了多线程复制,也可能被分配到从库的同一个worker中执行),如果直接执行,那么显然可能会出现冲突。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不同,所以这两个表在备库的应用线程里不会冲突。

使用临时表优化查询

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的结果:

image-20211226185633150

可以看到:

  • 第二行的key=PRIMARY,说明第二个子句用到了索引id
  • 第三行的Extra字段,表示在对子查询的结果做UNION的时候,使用了临时表(Using temporary)。

这个语句的执行流程如下:

  1. 创建一个内存临时表,这个临时表只有一个整型字段f,并且f是主键字段
  2. 执行第一个子查询,得到1000这个值,并存入临时表中
  3. 执行第二个子查询:
    • 拿到第一行id=1000,试图插入临时表中,但由于1000这个值已经存在于临时表了,违反了唯一性约束,所以插入失败,然后继续执行
    • 取到第二行id=999,插入临时表成功
  4. 从临时表按行取数据,返回结果,并删除临时表,结果中包含两行数据分别是1000和999

这个过程的示意图如下:

image-20211226190524454

可以看到,这里的内存临时表起到了暂存数据的作用,而且计算过程还用上了临时表主键id的唯一性约束,实现了union的语义。如果将这个语句中的union改成union all的话,就没有了“去重”的语义。这样执行的时候,就依次执行子查询,得到的结果直接作为结果集的一部分,发给客户端,因此也就不需要临时表了。

image-20211226190854967

可以看到,第二行的Extra字段显示的是Using index,表示只使用了覆盖索引,没有用临时表了。

group by语句的优化

group by执行流程

还是使用表t1执行如下SQL语句:

select id % 10 as m, count(*) as c from t1 group by m;

这个语句的逻辑是把表t1里的数据,按照id%10进行分组统计,并按照m的结果排序后输出,它的explain结果如下:

image-20211226191521497

在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做排序,得到结果集返回给客户端

流程图如下:

image-20211226204524315

其中,虚线框内表示临时表的排序过程:

image-20211226204657634

这条语句的执行结果如下:

image-20211226204808711

如果并不需要对结果进行排序,那么可以在SQL语句末尾增加order by null:

select id % 10 as m, count(*) as c from t1 group by m order by null;

这样就跳过了最后排序的阶段,直接从临时表中取数据返回,返回的结果如图所示:

image-20211226205042481

由于表t1中的id的值是从1开始的,因此返回的结果集中的第一行是id=1,扫描到id=10的时候才插入m=0这一行,因此结果集里最后一行才是m=0。

这个例子中由于临时表只有10行,内存可以放得下,因此全程只使用了内存临时表。但是,内存临时表的大小是有限制的,参数tmp_table_size就是控制这个内存大小的,默认是16M。此时,如果执行如下语句:

set tmp_table_size = 1024;
select id % 100 as m, count(*) as c from t1 group by m order by null limit 10;

把内存临时表的大小限制为最大1024字节,并把语句改为id%100,这样返回结果里有100行数据。但是,这时的内存临时表大小不够存下这100行数据,也就是说,执行过程中会发现内存临时表大小不够存下这100行数据,也就是说,执行过程中会发现内存临时表大小达到了上限(1024字节)。这个时候,MySQL就会把内存临时表转成磁盘临时表,磁盘临时表默认使用的引擎是InnoDB,这时返回的结果如下:

image-20211226210055235

如果这个表t1的数据量很大,很可能这个查询需要的磁盘临时表就会占用大量的磁盘空间。

使用索引优化group by语句

可以看到,不论是使用内存临时表还是磁盘临时表,group by逻辑都需要构造一个带唯一索引的表,执行代价都是比较高的,如果表的数据量比较大,上面这个group by语句执行起来就会很慢。

在优化group by问题之前,我们得清楚,为什么执行group by语句需要临时表,group by的语义逻辑是统计不同的值出现的个数,但是,由于每一行的id%100的结果是无序的,所以,我们需要一个临时表,来记录并统计结果。那么,如果扫描过程中可以保证出现的数据是有序的,那么group by语句就可以不再需要临时表,假设有如下数据结构:

image-20211226223201473

可以看到,如果可以确保输入的数据是有序的,那么计算group by的时候,就只需要从左到右,顺序扫描,依次累加,也就是下面的这个过程:

  • 当碰到第一个1的时候,已经知道累积了X个0,结果集里的第一行就是(0,x)
  • 当碰到第一个2的时候,已经知道类及了Y个1,结果集里的第一行就是(1,Y)

按照这个逻辑执行的话,扫描到整个输入的数据结束,就可以拿到group by的结果,不需要临时表,也不需要再额外的排序。在MySQL5.7支持了generated cloumn机制,用来实现列数据的关联更新,可以使用如下方法创建一个列z,然后在z列上创建一个索引(如果是MySQL5.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结果,如下图所示:

image-20211226224059201

可以看到这个语句的执行不再需要临时表,也不需要排序了。

直接排序优化group by语句

实际上,并不是所有场景中可以通过加索引来完成group by的逻辑,如果碰到不适合创建索引的场景,还是一定要进行排序的操作。无论数据量大或小的group by语句都要先放到内存临时表,插入一部分数据后,发现内存临时表不够用了再转成磁盘临时表,我们可以在group by语句中加入SQL_BIG_RESULT这个提示(hint),就可以告诉优化器 ,这个语句涉及的数据量很大,直接使用磁盘临时表,MySQL会直接使用数组来存储这些数据,此时

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. 排序完成后,就得到了一个有序数组

根据有序数组,得到数组里面的不同值,以及每个值得出现次数,执行的流程图如下:

image-20211226225313194

explain的结果如下:

image-20211226225353695

从Extra字段可以看到,这个语句的执行没有再使用临时表,而是直接使用了排序算法。

这里我们对MySQL使用内部临时表做如下总结:

  1. 如果语句执行过程可以一边读数据,一边直接得到结果,是不需要额外内存的,否则就需要额外的内存,来保存中间结果
  2. join_buffer是无序数组,sort_buffer是有序数组,临时表是二维表结构
  3. 如果执行逻辑需要用到二维表特性,就会优先考虑使用临时表(之前的例子中,union还需要用到唯一索引约束,group by还需要用到另外一个字段来存累积计数)

order by rand() 优化

假设有一个英语学习APP,用户每次访问首页的时候,都会随机滚动显示三个单词,也就是根据每个用户级别有一个单词表,假设我们的表结构如下:

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

实现这个需求最简单的实现方式,不难想到:

select word from words order by rand() limit 3;

但是随着单词表的变大,这个语句的执行速度越来越慢,那么该如何优化呢?

内存临时表

上述SQL的explain的结果如下:

image-20220116105216709

Extra字段显示Using temporary,表示的是需要使用临时表,Using filesort表示的是需要执行排序操作,也就是说这个SQL需要临时表,并且需要在临时表上排序。

对于InnoDB表来说,执行全字段排序会会减少磁盘访问,因此会优先选择全字段排序,而对于临时内存表的排序来说,回表过程只是简单根据数据行的位置,直接访问内存得到数据,并不会导致过多的访问磁盘,因此,MySQL这时会选择rowid排序。

这条语句的执行流程如下:

  1. 创建一个临时表,这个临时表使用的是memory引擎,表中有两个字段,第一个字段是double类型,为了后面描述方便,记为字段R,第二个字段是varchar(64)类型,记为字段W,并且,这个表没有建索引
  2. 从words表中,按主键顺序取出所有word值,对于每一个word值,调用rand()函数生成一个大于0小于1的随机小数,并且把这个随机小数和word分别存入临时表的R和W字段中,到此,扫描的行数是10000
  3. 接下来按照R排序
  4. 初始化sort_buffer,sort_buffer中有两个字段,一个是double类型,另一个是整型
  5. 从内存临时表中一行一行地取出R值和位置信息,分别存入sort_buffer中的两个字段里,这个过程要对内存临时表做全表扫描,此时扫描行数增加10000,变成了20000
  6. 在sort_buffer中根据R的值进行排序(这个过程没有涉及到表操作,所以不会增加扫描行数)
  7. 排序完成后,取出前三个结果的位置信息,依次到临时表中取出word值,返回给客户端,这个过程中,访问了表的三行数据,总扫描行数变成了20003

接下来,我们通过慢查询日志(show log)来验证扫描行数是否是20003:

# 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行。完整的排序的执行流程图如下:

image-20220116112157514

图中的pos指的是位置信息。在InnoDB中,如果创建的表没有主键,获取把一个表的主键删掉了,那么InnoDB会自己生成一个长度为6字节的rowid来作为主键,这也就是排序模式里面,rowid名字的来历,实际上它表示的就是每个引擎用来唯一标识数据行的信息。

  • 对于有主键的InnoDB表来说,这个rowid就是主键ID
  • 对于没有主键的InnoDB表磊说,这个rowid就是由系统生成的
  • Memory引擎不是索引组织表,这个例子里面,可以认为它就是一个数组,因为rowid其实就是数组的下标

磁盘临时表

上文我们提到,order by rand()使用了内存临时表,内存临时表排序的时候使用了rowid排序方法,那么是不是所有的临时表都是内存表呢?其实并不是,tmp_table_size这个配置限制了内存临时表的大小,默认值是16M。如果临时表大小超过了tmp_table_size,那么内存临时表就会转成磁盘临时表。

磁盘临时表使用的引擎默认是InnoDB,是由参数internal_tmp_disk_storage_engine来控制的,因此当使用磁盘临时表的时候,对应的就是一个没有显式索引的InnoDB表排序的过程。

为了复现这个过程,我们将tmp_table_size设置成1024,把sort_buffer_size设置成32768,把max_length_for_sort_data设置成16。

set tmp_table_size=1024;
set sort_buffer_size=32768;
set max_length_for_sort_data=16;
/* 打开 optimizer_trace ,只对本线程有效 */
SET optimizer_trace='enabled=on';
/* 执行语句 */
select word from words order by rand() limit 3;
/* 查看 OPTIMIZER_TRACE 输出 */
SELECT * FROM `information_schema`.`OPTIMIZER_TRACE`\G
image-20220116114409076

因为将max_length_for_sort_data设置成16,小于word字段的长度定义,所以我们看到sort_mode里面显式的是rowid排序,这个是符合预期的,参与排序的是随机值R字段和rowid段组成的行。

R字段存放的随机值是8个字段,rowid是6个字节,数据总行数是10000,加起来是14000字节,超过了sort_buffer_size定义的32768字节,但是这里的number_of_tmp_files的值却是0,这里因为这里MySQL并没有使用归并排序算法,而是采用了优先队列排序算法。实际上,我们只需要取R值最小的3个rowid,但是,如果使用归并排序算法的话,虽然最终也能得到前3个值,但是这个算法会将1000行数据都排好序,然后再取前3条记录,如果使用归并算法就会浪费非常多的计算量。而优先队列算法,就可以精确地只得到三个最小值,执行流程如下:

  1. 对于这10000个准备排序的(R,rowid),先取前三行,构造成一个堆
  2. 取下一个行(R',rowid'),跟当前堆里面最大地R比较,如果R'小于R,把这个(R,rowid)从堆中去掉,换成(R',rowid'
  3. 重复第二步,直到第10000个(R',rowid')完成比较

优先队列排序地示意图如下:

image-20220116120330311

图中模拟了6个(R,rowid)行,通过优先队列排序找到最小的三个R值的行的过程。整个排序过程中,为了最快地拿到当前堆的最大值,总是保持最大值在堆顶,因此这是一个最大堆。

OPTIMIZER_TRACE结果中,filesort_priority_queue_optimization这个部分的chosen=true,就表示使用了优先队列排序算法,这个过程不需要临时文件,因此对应的number_of_tmp_files是0。

select city,name,age from t where city=' 杭州 ' order by name limit 1000;

排序的过程结束后,在我们构造的堆里面,就是这个10000行里面R值最小的三行。然后,依次把它们的rowid取出来,去临时表里面拿到word字段,就得到了最终的结果。

随机排序法

清楚了order by rand()的执行过程,那么该如何优化呢?

我们先把问题简化以下,如果只随机选择1个word值,可以按照如下思路实现:

  1. 取得这个表的主键id最大值M和最小值N
  2. 用随机函数生成一个最大值和最小值之间的数X=(M-N)*rand()+N
  3. 取不小于X的第一个ID的行

我们将这个算法暂时称作随机算法1,对应的执行语句的序列:

select max(id),min(id) into @M,@N from t;
set @X= floor((@M-@N+1)*rand() + @N);
select * from t where id >= @X limit 1;

这个方法的效率很高,因为取max(id)和min(id)都是不需要扫描索引的,而第三步的select也可以用索引快速定位,可以认为就只扫描了3行。但实际上,这个算法并不严格满足题目的随机要求,因为ID中间可能由空洞,因此选择不同行的概率不一样,不是真正的随机。假设4个id分别是1、2、4、5,如果按照这个算法,那么取到id=4的这一行的概率是取到其它行的概率的两倍。

所以,为了得到严格随机的结果,可以按照如下流程:

  1. 取得整个表的行数,并记为C
  2. 取得Y=floor(C*rand()),floor函数在这里的作用,就是取整数部分
  3. 再用limit Y,1取得一行

我们将这个算法暂时称作随机算法2,对应的执行语句的序列:

select count(*) into @C from t;
set @Y = floor(@C * rand());
set @sql = concat("select * from t limit ", @Y, ",1");
prepare stmt from @sql;
execute stmt;
DEALLOCATE prepare stmt;

由于limit后面的参数不能直接跟变量,所以这里使用了prepare+execute的方法,实际使用时,可以将拼接SQL语句的方法写在应用程序中。

MySQL处理limit Y,1的做法是按照顺序一个一个地读出来,丢掉前Y个,然后把下一个记录作为返回结果,因此这一步需要扫描Y+1行,再加上,第一个扫描地C行,总共需要扫描C+Y+1行,虽然解决了算法1里明显的概率不均匀的问题,但是执行代价要比随机算法1的代价要高,不过于order by rand()相比,执行代价还是小很多。

到这里,我们就可以按照随机算法2的思路,优化本篇一开始的语句:

  1. 取得整个表的行数,记为C
  2. 根据相同的随机方法得到Y1、Y2、Y3
  3. 再执行三个limit Y,1语句得到三行数据

完整的执行序列如下:

select count(*) into @C from t;
set @Y1 = floor(@C * rand());
set @Y2 = floor(@C * rand());
set @Y3 = floor(@C * rand());
select * from t limit @Y11// 在应用代码里面取 Y1 、 Y2 、 Y3 值,拼出 SQL 后执行
select * from t limit @Y21select * from t limit @Y31

分区表

在有些公司的数据库规范中,不允许使用分区表,那么分区表有什么问题呢?

分区表简介

为了说明分区表的组织形式,我们先创建表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);

image-20220116181122059

此时表中有两行记录,按照定义的分区的规则,这两行记录分别落在p_2018和p_2019这两个分区上,可以看到,这个表包含了一个.frm文件和4个.ibd文件,每个分区对应一个.ibd文件,也就是说:

  • 对于引擎层来说,这是4个表
  • 对于Server层来说,这是1个表

接下来我们通过观察分区表加间隙锁的例子来说明对于InnoDB来说,这是4个表:

image-20220116193146452

我们初始化表t的时候,只插入了两行数据,ftime的值分别是,'2017-4-1'和'2018-4-1',session A的select语句对索引ftime上这两个记录之间的间隙加了锁。如果是一个普通表的话,那么T1时刻,在表t的ftime索引,间隙和加锁状态应该如下图:

image-20220116193512618

也就是说,'2017-4-1'和'2018-4-1'这两个记录之间的间隙是会被锁住的,那么session B的两条插入语句应该都要进入锁等待状态。但是从上面的实验效果可以看出,session B的第一个insert语句是可以执行成功,因为,对于引擎来说,p_2018和p_2019是两个不同的表,也就是说2017-4-1的下一个记录并不是2018-4-1,而是p_2018分区的supermum,所以在T1时刻,在表t的ftime索引上,间隙和加锁的状态其实是这样的:

image-20220116193910224

由于分区表的规则,session A的select语句其实只操作了分区p_2018,因此加锁范围就是图中深绿色的部分,所以,session B要写入一行ftime是2018-2-1的时候是可以成功的,而要写入2017-12-1这个记录,就要等session A的间隙锁。

此时show engine innodb status的部分结果如下:

image-20220116194133483

接下来我们看看在MyISAM引擎中的情况,首先使用alter table t engine将表t改成MyISAM表,然后执行如下序列:

image-20220116194544996

在session A里面,使用sleep(100)将这条语句的执行时间设置为100秒,由于MyISAM引擎只支持表锁,所以这条update语句会锁住整个表t上的读,但是我们看到的结果是,session B的第一条查询语句是可以正常执行的,第二条语句才进入锁等待状态,这正是因为MyISAM的表锁是在引擎层实现的,session A加的表锁,其实是锁在分区p_2018上。因此,只会堵住在这个分区上执行的查询,落到其它分区的查询时不受影响的。

此时看起来使用分区表并没有什么不妥,通常我们使用分区表的一个重要原因就是单表过大,如果不使用分区表的话,就要使用手动分表的方式,那么手动分表和分区表有什么区别?比如,按照年份来划分,我们就分别创建普通表t_2017、t_2018、t_2019等等。手工分表的逻辑,也是找到需要更新的所有分表,然后依次执行更新,在性能上,这个分区表并没有实质的差别。另外,分区表和手工分表,一个是由server层来决定使用哪个分区,一个是由应用层代码决定使用哪个分表,因此,从引擎层来看,这两种方式也是没有差别的。

实际上,问题的关键在于server层,分区表最重要的问题在于:打开表的行为。

分区策略

每当第一次访问一个分区表的时候,MySQL需要把所有的分区都访问一遍。一个典型的报错情况是这样的:如果一个分区表的分区很多,比如超过了1000个,而MySQL启动的时候,open_files_limit参数使用的默认值是1024,那么就会在访问这个表的时候,由于需要打开所有的文件,导致打开表文件的个数超过了上限而报错。

下图是创建的一个包含了很多分区的表t_myisam,执行一条插入语句后报错的情况。:

image-20220116191126999

可以看到,这条insert语句,明显只需要访问一个分区,但语句却无法执行。实际上使用InnoDB引擎并不会出现这个问题,MyISAM分区表使用的分区策略,我们称为通用分区策略(generic partitioning),每次访问分区都由server层控制,通用分区策略,是MySQL一开始支持分区表的时候就存在的代码,在文件管理、表管理的实现上很粗糙,因此有比较严重的性能问题。

从MySQL5.7.9开始,InnoDB引擎引入了本地分区策略(native partitioning),这个策略是在InnoDB内部自己管理打开分区的行为。从MySQL5.7.17开始,将MyISAM分区表标记为Deprecated,从MySQL8.0版本开始,就不允许创建MyISAM分区表了,只允许创建已经实现了本地分区策略的引擎。目前来看,只有InnoDB和NDB这两个引擎支持了本地分区策略。

如果从server层看的话,一个分区表就只是一个表。下面我们通过例子来说明,下面两张图分别是这个例子的操作序列和执行结果图。

image-20220116192048055

image-20220116192111558

可以看到,虽然session B只需要操作p_2017这个分区,但是由于session A持有整个表t的MDL锁,就导致了session B的alter语句被堵住,实际上,分区表在做DDL的时候,影响会更大,但是如果是在普通的分表上操作的时候并不会出现这样的问题。

我们可以对分区表做以下总结:

  • MySQL在第一次打开分区表的时候,需要访问所有的分区
  • 在server层,认为这是同一张表,因此所有分区共用同一个MDL锁
  • 在引擎层,认为这是不同的表,因此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,那么就只能访问所有分区了,不过即使是使用业务分表的方式,没有分区的key也需要访问所有的分区表。

分区表的应用场景

分区表的一个显而易见的优势是对业务透明,相对于用户分表来说,使用分区别的业务代码更简洁,另外,分区表可以很方便的清理历史数据。如

如果一项业务跑的时间足够长,往往就会有根据时间删除历史数据的需求,这个时候,按照时间分区的分区表,就可以直接通过alter table t drop partition...这个语法删掉分区,从而删掉过期的历史数据,这个语句的操作时直接删除分区文件,效果跟drop普通表类似,与使用delete语句删除数据相比,优势是速度快、对系统影响小。

分区表在使用的时候,有两个绕不开的问题:一个是第一次访问的时候需要访问所有分区,另一个是共用MDL锁,对于分区表的使用有以下需要注意的点:

  1. 分区并不是越细越好,实际上,单表或者单分区的数据一千万行,只要没有也别大的索引,对于现在的硬件能力来说都已经是小表了
  2. 分区也不要提前预留太多,在使用之前预先创建即可。比如,如果是按月分区,每年年底时再把下一年度的12个新分区创建上即可,对于没有数据的历史分区,要及时drop掉

至于分区表的其它问题,比如查询需要跨多个分区取数据,查询性能就会比较慢,基本上就不是分区表本身的问题,而是数据量的问题或者说时使用方式的问题了。

慢查询日志

基本介绍

慢查询日志是什么?

  • MySQL的慢查询日志是MySQL提供的一种日志记录,它用来记录在MySQL中响应时间超过阈值的语句,具体指运行时间超过long_query_time值的SQL,则会被记录到慢查询日志中。
  • long_query_time的默认值为10,意思是运行10秒以上的语句。
  • 由慢查询日志来查看哪些SQL超出了我们的最大忍耐时间值,比如一条SQL执行超过5秒钟,我们就算慢SQL,希望能收集超过5秒钟的SQL,结合之前explain进行全面分析。

特别说明

**默认情况下,MySQL数据库没有开启慢查询日志,**需要我们手动来设置这个参数。

当然,如果不是调优需要的话,一般不建议启动该参数,因为开启慢查询日志会或多或少带来一定的性能影响。慢查询日志支持将日志记录写入文件。

查看慢查询日志是否开以及如何开启

  • 查看慢查询日志是否开启:SHOW VARIABLES LIKE '%slow_query_log%';

  • 开启慢查询日志:SET GLOBAL slow_query_log = 1;使用该方法开启MySQL的慢查询日志只对当前数据库生效,如果MySQL重启后会失效。

# 1、查看慢查询日志是否开启
mysql> SHOW VARIABLES LIKE '%slow_query_log%';
+---------------------+--------------------------------------+
| Variable_name       | Value                                |
+---------------------+--------------------------------------+
| slow_query_log      | OFF                                  |
| slow_query_log_file | /var/lib/mysql/1dcb5644392c-slow.log |
+---------------------+--------------------------------------+
2 rows in set (0.01 sec)

# 2、开启慢查询日志
mysql> SET GLOBAL slow_query_log = 1;
Query OK, 0 rows affected (0.00 sec)

如果要使慢查询日志永久开启,需要修改my.cnf文件,在[mysqld]下增加修改参数。

# my.cnf
[mysqld]
# 1.这个是开启慢查询。注意ON需要大写
slow_query_log=ON  

# 2.这个是存储慢查询的日志文件。这个文件不存在的话,需要自己创建
slow_query_log_file=/var/lib/mysql/slow.log

开启了慢查询日志后,什么样的SQL才会被记录到慢查询日志里面呢?

这个是由参数long_query_time控制的,默认情况下long_query_time的值为10秒。

MySQL中查看long_query_time的时间:SHOW VARIABLES LIKE 'long_query_time%';

# 查看long_query_time 默认是10秒
# 只有SQL的执行时间>10才会被记录
mysql> SHOW VARIABLES LIKE 'long_query_time%';
+-----------------+-----------+
| Variable_name   | Value     |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.00 sec)

修改long_query_time的时间,需要在my.cnf修改配置文件

[mysqld]
# 这个是设置慢查询的时间,我设置的为1秒
long_query_time=1

查新慢查询日志的总记录条数:SHOW GLOBAL STATUS LIKE '%Slow_queries%';

mysql> SHOW GLOBAL STATUS LIKE '%Slow_queries%';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Slow_queries  | 3     |
+---------------+-------+
1 row in set (0.00 sec)

日志分析工具

日志分析工具mysqldumpslow:在生产环境中,如果要手工分析日志,查找、分析SQL,显然是个体力活,MySQL提供了日志分析工具mysqldumpslow

# 1、mysqldumpslow --help 来查看mysqldumpslow的帮助信息
root@1dcb5644392c:/usr/bin# 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
  
# 2、 案例
# 2.1、得到返回记录集最多的10个SQL
mysqldumpslow -s r -t 10 /var/lib/mysql/slow.log
 
# 2.2、得到访问次数最多的10个SQL
mysqldumpslow -s c -t 10 /var/lib/mysql/slow.log
 
# 2.3、得到按照时间排序的前10条里面含有左连接的查询语句
mysqldumpslow -s t -t 10 -g "left join" /var/lib/mysql/slow.log

# 2.4、另外建议使用这些命令时结合|和more使用,否则出现爆屏的情况
mysqldumpslow -s r -t 10 /var/lib/mysql/slow.log | more

分析慢SQL的步骤

分析:

1、观察,至少跑1天,看看生产的慢SQL情况。

2、开启慢查询日志,设置阈值,比如超过5秒钟的就是慢SQL,并将它抓取出来。

3、explain + 慢SQL分析。

4、show Profile。

5、运维经理 OR DBA,进行MySQL数据库服务器的参数调优。

总结(大纲):

1、慢查询的开启并捕获。

2、explain + 慢SQL分析。

3、show Profile查询SQL在MySQL数据库中的执行细节和生命周期情况。

4、MySQL数据库服务器的参数调优。

Show Profile

Show Profile是什么?

Show Profile:MySQL提供可以用来分析当前会话中语句执行的资源消耗情况。可以用于SQL的调优的测量。默认情况下,参数处于关闭状态,并保存最近15次的运行结果。

分析步骤

1、是否支持,看看当前的MySQL版本是否支持。

# 查看Show Profile功能是否开启
mysql> SHOW VARIABLES LIKE 'profiling';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| profiling     | OFF   |
+---------------+-------+
1 row in set (0.00 sec)

2、开启Show Profile功能,默认是关闭的,使用前需要开启。

# 开启Show Profile功能
mysql> SET profiling=ON;
Query OK, 0 rows affected, 1 warning (0.00 sec)

3、运行SQL

SELECT * FROM `emp` GROUP BY `id`%10 LIMIT 150000;

SELECT * FROM `emp` GROUP BY `id`%20 ORDER BY 5;

4、查看结果,执行SHOW PROFILES;

Duration:持续时间。

mysql> SHOW PROFILES;
+----------+------------+---------------------------------------------------+
| Query_ID | Duration   | Query                                             |
+----------+------------+---------------------------------------------------+
|        1 | 0.00156100 | SHOW VARIABLES LIKE 'profiling'                   |
|        2 | 0.56296725 | SELECT * FROM `emp` GROUP BY `id`%10 LIMIT 150000 |
|        3 | 0.52105825 | SELECT * FROM `emp` GROUP BY `id`%10 LIMIT 150000 |
|        4 | 0.51279775 | SELECT * FROM `emp` GROUP BY `id`%20 ORDER BY 5   |
+----------+------------+---------------------------------------------------+
4 rows in set, 1 warning (0.00 sec)

5、诊断SQL,SHOW PROFILE cpu,block io FOR QUERY Query_ID;

# 这里的3是第四步中的Query_ID。
# 可以在SHOW PROFILE中看到一条SQL中完整的生命周期。
mysql> SHOW PROFILE cpu,block io FOR QUERY 3;
+----------------------+----------+----------+------------+--------------+---------------+
| Status               | Duration | CPU_user | CPU_system | Block_ops_in | Block_ops_out |
+----------------------+----------+----------+------------+--------------+---------------+
| starting             | 0.000097 | 0.000090 |   0.000002 |            0 |             0 |
| checking permissions | 0.000010 | 0.000009 |   0.000000 |            0 |             0 |
| Opening tables       | 0.000039 | 0.000058 |   0.000000 |            0 |             0 |
| init                 | 0.000046 | 0.000046 |   0.000000 |            0 |             0 |
| System lock          | 0.000011 | 0.000000 |   0.000000 |            0 |             0 |
| optimizing           | 0.000005 | 0.000000 |   0.000000 |            0 |             0 |
| statistics           | 0.000023 | 0.000037 |   0.000000 |            0 |             0 |
| preparing            | 0.000014 | 0.000000 |   0.000000 |            0 |             0 |
| Creating tmp table   | 0.000041 | 0.000053 |   0.000000 |            0 |             0 |
| Sorting result       | 0.000005 | 0.000000 |   0.000000 |            0 |             0 |
| executing            | 0.000003 | 0.000000 |   0.000000 |            0 |             0 |
| Sending data         | 0.520620 | 0.516267 |   0.000000 |            0 |             0 |
| Creating sort index  | 0.000060 | 0.000051 |   0.000000 |            0 |             0 |
| end                  | 0.000006 | 0.000000 |   0.000000 |            0 |             0 |
| query end            | 0.000011 | 0.000000 |   0.000000 |            0 |             0 |
| removing tmp table   | 0.000006 | 0.000000 |   0.000000 |            0 |             0 |
| query end            | 0.000004 | 0.000000 |   0.000000 |            0 |             0 |
| closing tables       | 0.000009 | 0.000000 |   0.000000 |            0 |             0 |
| freeing items        | 0.000032 | 0.000064 |   0.000000 |            0 |             0 |
| cleaning up          | 0.000019 | 0.000000 |   0.000000 |            0 |             0 |
+----------------------+----------+----------+------------+--------------+---------------+
20 rows in set, 1 warning (0.00 sec)

Show Profile查询参数备注:

  • ALL:显示所有的开销信息。
  • BLOCK IO:显示块IO相关开销(通用)。
  • CONTEXT SWITCHES:上下文切换相关开销。
  • CPU:显示CPU相关开销信息(通用)。
  • IPC:显示发送和接收相关开销信息。
  • MEMORY:显示内存相关开销信息。
  • PAGE FAULTS:显示页面错误相关开销信息。
  • SOURCE:显示和Source_function。
  • SWAPS:显示交换次数相关开销的信息。

6、Show Profile查询列表,日常开发需要注意的结论:

  • converting HEAP to MyISAM:查询结果太大,内存都不够用了,往磁盘上搬了。
  • Creating tmp table:创建临时表(拷贝数据到临时表,用完再删除),非常耗费数据库性能。
  • Copying to tmp table on disk:把内存中的临时表复制到磁盘,危险!!!
  • locked:死锁。

MySQL中的事务和锁

事务隔离

简单来说,事务就是要保证一组操作,要么全部成功,要么全部失败,事务具有ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)的特性。在MySQL中,事务支持是在引擎层实现的,但并不是所有的引擎都支持事务,比如MySQL原生的MyISAM引擎就不支持事务,这也是MyISAM被InnoDB取代的重要原因之一。

隔离性和隔离级别

当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。隔离级别越高,执行的效率就会越低,因此很多时候,都需要在二者之间寻找一个平衡点。

SQL标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable),它们的含义如下:

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

下面我们通过实例来说明,假设数据表T中只有一列,其中一行的值为1:

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

下面是按照时间顺序执行两个事务的行为:

image-20220103112251081

我们来看下在不同的隔离级别下,事务A会查询到的V1、V2、V3的返回值分别是什么:

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

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

在不同的隔离级别下,数据库的行为是有所不同的。Oracle数据库的默认隔离级别是“读提交”,MySQL的InnoDB默认隔离级别是“可重复读”。因此,对于一些从Oracle迁移到MySQL的应用,为了保证数据库隔离级别的一致,需要将隔离级别设置为“读提交”,将启动参数transaction-isolation的值设置为READ-COMMITTED,可以使用show variables like 'transaction_isolation';来查看当前的值。

每一种隔离级别都有自己的使用场景,具体使用哪一种,需要根据业务情况来定。

事务隔离的实现

那么事务隔离是怎么实现的呢?实际上,在MySQL中,每条记录在更新的时候都会同时记录一条回滚操作,记录上最新的值,通过回滚操作,都可以得到前一个状态的值。假设一个值从1被顺序改成了2、3、4,在回滚日志里面就会有类似下面的记录:

image-20220103115424619

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

回滚日志只有当没有事务需要用到这些回滚日志,也就是当系统里没有比这个回滚日志更早的read-view的时候,才会被删除,这也就是很多文章中建议不要使用长事务的原因之一。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以在这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。在MySQL5.5及以前的版本,回滚日志是跟字典一起放在ibdata文件里的,即使长事务最终提交,回滚段被清理,文件也不会变小,也许数据只有20GB,但是回滚段却有200GB。

可以在information_schema库的innodb_trx这个表中,使用如下语句查询超过60s的长事务:

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

事务的启动方式

MySQL的事务启动方式有以下几种:

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

有些客户端框架会默认连接成功后先执行set autocommit = 0;,这就导致接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。因此,最好使用set autocommit = 1,通过显式语句的方式来启动事务,不过这样需要每次执行依次“begin”,如果不想每次都多“多一次交互”,那么可以使用commit work and chain语法。在autocommit为1的情况下,用begin显式启动事务,如果执行commit则提交事务,如果执行mmit work and chain,则是提交事务并自动启动下一个事务,这样既省去了再次执行begin语句的开销,又可以明确地直到每个语句是否处于事务中。

全局锁

数据库锁的设计的初衷是处理并发问题。作为多用户共享的资源,当出现并发访问的时候,数据库需要合理地控制资源的访问规则,而锁就是用来实现这些访问规则的重要数据结构。根据加锁的范围,MySQL里面的锁大致可以分为全局锁、表级锁和行锁三类。

全局锁的特点

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

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

全局锁对比

MySQL自带的逻辑备份工具是mysqldump,当mysqldump使用参数-single-transaction的时候,导数据之前就会启动一个事务,来确保拿到一致性视图,由于MVCC的支持,这个过程中数据是可以正常更新的。但并不是所有的存储引擎都支持一致性读的隔离级别,例如MyISAM这种不支持事务的引擎,如果备份过程中有更新,总是取到最新的数据,那么就破坏了备份的一致性,这个时候,就只能使用FTWRL命令了,因此-single-transaction只适用于所有的表使用事务引擎的数据库,如果有的表使用了不支持事务的引擎,那么备份只能通过FTWRL方法,这往往是DBA要求业务开发人员使用InnoDB替代MyISAM的原因之一。

全库只读除了使用FTWRL,还可以使用set global readonly = true,不过如果要备份全库,还是应该使用FTWRL,原因有二:

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

表级别的锁

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

表锁

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

元数据锁

另一类表级的锁是MDL(metadata lock)。MDL不需要显式使用,在访问一个表的时候会被自动加上。MDL的作用是,保证读写的正确性。例如,一个查询正在遍历一个表中的数据,而执行期间另一个线程对这个表结构在做变更,删了一列,那么查询线程拿到的结果就与表结构对应不上了,这种情况就需要MDL。在MySQL5.5版本中引入了MDL,当对一个表做增删改查操作的时候,就会加上MDL读锁;当要对表结构做变更操作的时候,就会加上MDL写锁。

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

给一个表加字段或者修改字段、添加索引,都需要扫描全表的数据,因此,操作不慎的话,就可能导致生产事故:

image-20211228232542111

session A先启动,这时候会对表t加上一个MDL读锁,由于session B需要的也是MDL读锁,因此可以正常执行。之后session C会被blocked,因为session A的MDL读锁还没有释放,而session C需要MDL写锁,因此只能被阻塞。之后所有在表上新申请MDL读锁的请求也会被session C阻塞,也就是说,所有对表的增删改查都需要先申请MDL读锁,都被锁住导致表完全不可读写。如果这个表上的查询语句频繁,而且客户端有重试机制,也就是超时后会再起一个新的session 再请求的话,这个库的线程很快就会爆满。

事务中的MDL锁,在语句执行开始时申请,但是语句结束后并不会马上释放,而会等到整个事务提交后再释放。在给小表加字段的时候,首先要解决的是长事务,事务不提交,就会一直占着MDL锁。在MySQL的information_schema库的innodb_trx表中,可以查询到当前执行中的事务,如果要做DDL变更的表刚好有长事务在执行,要考虑先暂停DDL,或者kill掉这个长事务。但如果要变更的表是一个热点表,虽然数据量不大,但是请求很频繁,这个时候只kill掉可能未必管用了,因为新的请求马上就会到。

因此,给小表添加字段比较合理的方案是,在alter table语句里面设定等待时间,如果在这个执行的等待时间里面能够拿到MDL写锁最好,拿不到也不要阻塞后面的业务语句,先放弃,之后开发人员或者DBA再通过重试命令重复这个过程。MairaDB已经合并了AliSQL的这个功能,所以这两个开源分支目前都支持DDL NOWAIT/WAIT n这个语法。

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

行锁

行锁顾名思义,就是针对数据表中行记录的锁,比如事务A更新了一行,而这个时候,事务B也要更新同一行,则必须等事务A的操作完成后才能进行更新。在MySQL中有四种类型的行锁:

  • LOCK_ORDINARY:也称为Next-Key Lock,锁一条记录及其间隙,这是RR隔离级别用的最多的锁
  • LOCK_GAP:间隙锁,锁两个记录之间的GAP,防止记录插入
  • LOCK_REC_NOT_GAP:只锁记录
  • LOCK_INSERT_INTENSION:插入意向GAP锁,插入记录时使用,是LOCK_GAP的一种特例

两阶段锁

为了更好的说明行锁,我们以下面的操作序列为例,假设id是表t的主键:

image-20211228234751212

这个例子中事务B的update语句会被阻塞,直到事务A执行commit之后,事务B才能继续执行,在这个过程中,事务A持有两个记录的行锁,都是在commit的时候才释放的,也就是说,在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这就是两阶段锁协议。基于这个协议,我们在使用事务的时候,如果事务中需要锁多个行,要把最可能造成锁冲突,最可能影响并发度的锁尽量完后放,接下来我们通过一个实例来说明这一点。

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

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

也就是说,要完成这个交易,我们需要update两条记录,并insert一条记录,为了保证交易的原子性,我们需要把这三个操作放在一个事务中。假设此时同时有另外一个顾客C要在影院B买票,那么这两个事务冲突的部分就是语句2了,因为它们要更新同一个影院账户的余额,需要修改同一行数据。根据两阶段锁协议,无论怎样安排语句顺序,所有的操作需要行锁的都是在事务提交的时候才会释放,所以,应该把需要行锁的语句放在最后,这样可以最大程度减少事务之间的锁等待,提升并发度。

死锁和死锁检测

当并发系统中不同线程出现循环资源以来,设计的线程都在等待别的线程释放资源时,就会导致这几个线程都进入无限等待的状态,称为死锁。

image-20220102184417933

这时候,事务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才会超时退出,然后其它线程才有可能继续执行。这对于在线服务来说,这个等待时间往往是无法接受的。但是,将这个时间的值设置的很小也不行,这样当出现死锁的时候,确实很快就可以解开,但是如果不是死锁,只是简单的锁等待也会释放,也会释放掉。所以,一般都会选择第二种策略,即主动死锁检测。

主动死锁检测在发生死锁的时候,虽然能够快速发现并进行处理的,但是它也有额外的负担,每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。

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

总而言之,死锁检测要耗费大量的CPU资源。那么,该如何解决由这种热点行更新导致的性能的问题呢?

  1. 一种方法是,确保业务一定不会出现死锁,然后关掉死锁检测,但是这种操作本身带有一定的风险,因此业务设计的时候一般不会把死锁当做一个严重错误,一旦出现死锁,就可能会出现大量的超时
  2. 另一种方法是控制并发度,根据上面的分析,死锁检测的时间复杂度与并发量正相关,如果可以控制并发量,那么就可以控制死锁检测的时间复杂度。需要注意的是,这个并发控制要做在数据库服务端,因为虽然每个客户端的并发量可能很小,但是汇总到数据库服务端以后,还是会很大。可以在中间件中实现,也可以在MySQL里面做(基本思路:对于相同行的更新,在进入引擎之前排队,这样在InnoDB内部就不会有大量的死锁检测工作了)。
  3. 最后一种方法是,可以考虑通过将一行改成逻辑上的多行来减少锁冲突。以影院的账户为例,可以考虑放在多条记录上,比如10个记录,影院的账户总额等于这10个记录的值的总和。这样每次要给影院账户加金额的时候,随机选其中一条记录来加,这样每次冲突概率变成原来的1/10,可以减少锁等待的个数,也减少了死锁检测的CPU消耗,需要注意的是,当一部分行记录变成0的时候,如果还要减少记录的值,需要特殊处理

间隙锁

幻读

为了说明幻读,我们初始化如下数据:

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

假设执行的场景序列如下:

image-20220103172302968

可以看到,session A里执行了三次查询,分别是Q1、Q2和Q3,具体的执行结果如下:

  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这一行的现象,被称为“幻读”。也就是说,幻读指的是一个事务在前后两次查询同一个范围的时候,后一次查询看到了前一次查询没有看到的行,关于幻读的两点说明:

  • 在可重复读隔离级别下,普通的查询是快照读,是不会看到别的事务插入的数据的。因此,幻读在“当前读”下才会出现
  • 上面session B的修改结果,被session A之后的select语句用“当前读”看到,不能称为幻读,幻读专指“新插入的行”

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

幻读的问题

幻读会带来一些问题,假设时序图如下:

image-20220103174549152

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;,此时的序列图如下:

image-20220103215449655

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这两行,发生了数据不一致,经过分析不难发现,我们只给d=5这一行加了锁,假设我们给扫描过程中碰到的所有行都加上写锁,再观察执行效果:

image-20220103233722467

由于session A把所有的行都加上了写锁,所在session B在执行第一个update语句的时候就被锁住了,需要等到T6时刻session A提交以后,session B才能继续执行,这样对于id=0这一行,在数据库里的最终结果还是(0,5,5)。在binlog里面,执行的序列如下:

insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/* 所有 d=5 的行, d 改成 100*/
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/

可以看到,按照日志顺序执行,id=0这一行的最终结果也是(0,5,5)。所以,id=0这一行的问题解决了,但同时,id=1这一行,在数据库里面的结果是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是没有解决,那么为什么我们将所有的记录都已经上了锁,还是阻止不了id=1这一行的插入和更新呢?因为在T3时刻,我们在给所有行加锁的时候,id=1这一行还不存在,不存在也就加不上锁,也就是说,即使把所有的记录都加上锁,还是阻止不了新插入的记录。

间隙锁

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

image-20220103220303233

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

虽然间隙锁也是一种锁,但是它和之前介绍过的锁都不太一样,它是加载数据行之间间隙上的,行锁的之间的冲突关系是“另外一个行锁”,但间隙锁不一样,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作,间隙锁之间不存在冲突关系。

image-20220103220843354

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

间隙锁和行锁合称next-key lock,每个next-key lock是前后闭区间。也就是说,我们的表t初始以后,如果用select * from for update要把整个表所有记录锁起来,就形成了7个next-key lock,分别是(-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20,25]、(25,+supremum]。InnoDB给每个索引加上了一个不存在得最大值supremum,这样就都是前开后闭区间了。

MySQL的高可用

防止数据丢失

bin log的写入机制

binlog的吸入逻辑比较简单:事务执行过程中,先把日志写到binlog cache,事务提交的时候,再把binlog cache写入到binlog文件中。一个事务的binlog是不能被拆开的,因此不论这个事务多大,也要确保一次性写入。这就涉及到binlog cache的保存问题,系统给binlog cache分配了一片内存,每个线程一个,参数binlog_cache_size用于控制单个线程内binlog cache所占内存的大小。如果超过了这个参数规定的大小,就要暂存到磁盘。事务提交的时候,执行器把binlog cache里的完整事务写入到binlog中,并清空binlog cache。状态图如下:

image-20220210094732836

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

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

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

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

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

redo log的写入机制

事务在执行过程中,生成的redo log会先写入到redo log buffer中,并且并不是每次生成后都会持久化到磁盘中。这意味着如果事务执行期间MySQL发生异常重启,那么这部分日志就丢失了,由于事务没有提交,所以这时日志丢了也不会有损失,那么事务还没有提交的时候,redo log buffer中的部分日志有没有可能被持久化到磁盘呢?这就和redo log的三种状态有关:

image-20220210225451762

这三种状态分别是:

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

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

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

InnoDB有一个后台线程,每个1秒,就会把redo log buffer中的日志,调用write写入到文件系统的page cache,然后调用fsync持久化到磁盘。

事务执行中间过程中的redo log也就是直接写在redo log buffer中的,这些redo log也会被后台线程一起持久化到磁盘。也就是说,一个没有提交的事务的redo log,也是可能持久化到磁盘的。

实际上,除了后台线程每秒一次的轮询操作外,还有两种场景会让一个没有提交的事务的redo log写入到磁盘中:

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

如果把innodb_flush_log_at_trx_commit设置成1,那么redo log在prepaer阶段就要持久化一次,因为有一个崩溃恢复逻辑是要依赖于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,这是因为MySQL中使用了组提交(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。

image-20220210234121462image-20220210234201404

从图中可以看到:

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

所以,一次组提交里面,组员越多,节约磁盘IOPS效果越好,但如果只有单线程压测,那么就是一个事务对应一次持久化操作了。在并发场景下,第一个事务写完redo log buffer以后,接下来这个fsync越晚调用,组员可能越多,节约IOPS的效果就越好,为了让一次fsync带的组员更多,MySQL还有另一个优化:拖时间。两阶段提交的示意图如下:

image-20220210235251648

其实,写binlog其实是分成两步的:

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

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

image-20220210235504487

这么以来,binlog也可以组提交了。在执行图5中第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来实现。

  • binlog_group_commit_sync_delay表示延迟多少微妙后才调用fsync
  • binlog_group_commit_sync_no_delay_count参数,表示累积多少次以后才调用fsync

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

综上所述,如果MySQL出现了性能瓶颈,而且瓶颈在IO上,可以通过哪些方法来提升性能呢?

  • 设置binlog_group_commit_sync_delaybinlog_group_commit_sync_no_delay_count参数,减少binlog的写盘次数。这个方法是基于“额外的故意等待”来实现的,因此可能会增加语句的响应时间,但没有丢失数据的风险
  • sync_binlog设置为大于1的值(比较常见的是100~1000)。这样做的风险是,主机掉电时会丢binlog日志
  • innodb_flush_log_at_trx_commit设置为2,这样做的风险时,主机掉电的时候会丢数据

不过将innodb_flush_log_at_trx_commit设置成0,当MySQL本身异常重启的话,就会丢失数据。而redo log写到文件系统的page cache的速度也是很快的,所以将这个参数设置成2跟设置0的性能相差并不多,但是设置成2,当MySQL异常重启后就不会丢失数据了。

主备一致

主备的基本原理

下图表示的是基本主备切换流程:

image-20220213170252460

在状态1中,客户端的读写都直接访问节点A,而节点B是A的备库,只是将A的更新都同步过来,到本地执行,这样可以保持节点B和A的数据是相同的。当需要切换的时候,就切成状态2,这时候客户端读写访问的都是节点B,而节点A是B的备库。

在状态1中,虽然节点B并没有被直接访问,但是依然建议将节点B(也就是备库)设置成只读(readonly)模式,这样做,有以下考虑:

  • 有时候一些运营类的查询语句会被放到备库上去查,设置为只读可以防止误操作
  • 防止切换逻辑有bug,比如切换过程中出现双写,造成主备不一致
  • 可以用readonly状态,来判断节点的角色

备库虽然设置了readonly,但readonly对超级(super)权限用户是无效的,而用于同步更新的线程,就拥有超级线程,因此,备库可以和主库保持同步更新。

语句在节点A执行,然后同步到节点B的完整示意图如下:

image-20220213171430906

可以看到:主库接收到客户端的更新请求后,执行内部事务的更新逻辑,同时写binlog。备库B和主库A之间维持了一个长连接。主库A内部有一个线程,专门用于服务备库B的这个长连接,一个事务日志同步的完整过程如下:

  1. 在备库B上通过change master命令,设置主库A的IP、端口、用户名、密码,以及要从哪个位置开始请求binlog,这个位置包含文件名和日志偏移量
  2. 在备库B上执行start slave命令,这时候备库会启动两个线程,就是图中io_thread和sql_thread。其中io_thread负责与主库建立连接
  3. 主库A校验完用户名、密码后,开始按照备库B传过来的位置,从本地读取binlog,发给B
  4. 备库B拿到binlog后,写到本地文件,称为中转日志(relay log)
  5. sql_thread读取中转日志,解析出日志里的命令,并执行

不过后来由于多线程复制方案的引入,sql_thread演化成为了多个线程。

bin log的三种格式

bin log其实有三种格式:一种是statement,一种是row,还有一种叫做mixed,其实它就是前两种格式的混合。为了便于描述 binlog 的这三种格式间的区别,这里创建了一个表,并初始化几行数据:

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

insert into t values(1,1,'2018-11-13');
insert into t values(2,2,'2018-11-12');
insert into t values(3,3,'2018-11-11');
insert into t values(4,4,'2018-11-10');
insert into t values(5,5,'2018-11-09');

如果要在表中删除一行数据的话,我们来看看这个delete语句的binlog是怎么记录的:

mysql> delete from t /*comment*/ where a>=4 and t_modified<='2018-11-10' limit 1;

binlog_format=statement时,binlog里面记录的就是SQL语句的原文,可以使用命令:

mysql> show binlog events in 'master.000001';

查看binlog中的内容:

image-20220213173317686

说明如下:

  • 第一行SET @@SESSION.GTID_NEXT='ANONYMOUS'
  • 第二行是一个BEGIN,跟第四行的commit对应,表示中间是一个事务
  • 第三行是真实执行的语句。可以看到,在真正执行的delete命令之前,还有一个“use test”命令,这是MySQL根据当前要操作的表所在的数据库自行添加的,这样做可以保证日志传到备库去执行的时候,不论当前的工作线程在哪个库里,都能够正确地更新到test库的表t。在“use test”命令之后的delete语句,就是我们输入的SQL原文,可以看到,binlog“忠实”地记录了SQL命令,甚至连注释也一并记录了
  • 最后一行是一个COMMIT,并且记录了xid=61

为了说明statement和row格式的区别,delete命令的执行效果如下:

image-20220213202550075

可以看到,运行这条delete命令产生了一个warning,原因是当前binlog设置的是statement格式,并且语句中有limit,所以这个命令可能是unsafe的。为什么会这样呢?这是因为delete带limit,很可能会出现主备数据不一致的情况,比如上面的这个例子:

  • 如果delete语句使用的是索引a,那么会根据索引a找到第一个满足条件的行,也就是说删除的是a=4这一行
  • 但如果使用的是索引t_modified,那么删除的就是t_modified='2018-11-09'也就是a=5这一行

由于statement格式下,记录到binlog里的是语句原文,因此可能会出现这样一种情况:在主库执行这条SQL语句的时候,用的是索引a,而在备库执行这条SQL语句的时候,却使用了索引t_modified。因此,MySQL认为这样写是有风险的。

将binlog的格式修改为binlog_format='row',此时,binlog中的内容如下:

image-20220213203155674 可以看到,与statement格式的binlog相比,前后的BEGIN和COMMIT是一样的。但是,row格式的binlog里没有了SQL语句的原文,而是替换成了两个event:Table_map和Delete_rows:

  • Table_map event用于说明接下来要操作的表是test库的表t
  • Delete_rows event用于定义删除的行为

通过上图还是不能看出详细的信息,这时候需要借助mysqlbinlog工具,使用如下命令解析和查看binlog中的内容:

mysqlbinlog -w data/master.000001 --start-position=8900; /** 根据上图,这个事务的binlog是从8900这个位置开始的。 */

image-20220213203942182

说明如下:

  • server id 1,表示这个事务是在server_id=1的这个库上执行的
  • 每个event都有CRC32的值,这是因为此时的参数设置binlog_checksum=CRC32
  • Table_map event显示了接下来要打开的表,map到数字226。这里只操作了一张表,如果操作的是多张彪,每个表都有一个对应的Table_map event,都会map到一个单独的数字,用于区分不同表的操作
  • -w的参数是为了把内容都解析出来,所以从结果里面可以看到各个字段的值(比如,@1=4,@2=4这些值)
  • binlog_row_image的默认配置是FULL,因此Delete_event里面,包含了删掉的行的所有字段的值。如果把binlog_row_image设置为MINIMAL,则只会记录必要的信息,在这个例子里,只会记录id=4这个信息
  • 最后的Xid event,用于表示事务被正确地提交了

可以看到,当binlog_format=row的时候,binlog里面记录了真实删除的行的主键id,这样binlog传到备库去的时候,就肯定会删除id=4的行,不会有主备删除不同行的问题。

对比statement和row的优缺点,就有了mixed这种binlog格式存在的场景:

  • 因为有些statement格式的binlog可能会导致主备不一致,所以要使用row格式
  • row格式的缺点是,很占空间。比如,使用delete语句删掉10万行数据,用statement的话就是一个SQL语句被记录到binlog中,占用几十个字节的空间。但是如果row格式的binlog,就要把这10万条记录都写到binlog中,这样做,不仅会占用更大的空间,同时写binlog也要耗费IO资源,影响执行速度
  • MySQL有一个折中的方案,也就是mixed格式的binlog。MySQL自己会判断这条SQL语句是否可能引起主备不一致,如果有可能,就用row格式,否则就用statement格式

总而言之,mixed格式可以利用statement格式的优点,同时又避免了数据不一致的风险。比如上文中的这个例子,设置为mixed,就会记录为row格式,而如果执行的语句去掉limit 1,就会记录为statement格式。

不过,现在越来越多的场景要求将MySQL的binlog格式设置为row,这么做的理由有很多,其中可以直接看出来好处的就是:恢复数据。接下来,我们分别从delete、insert和update这三种SQL语句的角度,来看看数据恢复的问题:

如果执行的是delete语句,row格式的binlog会把删掉的行的整行信息保存起来。所以,如果执行完一条delete语句以后,发现删错数据了,可以直接把binlog中记录的delete语句转为insert,把被错删的数据插入回去就可以恢复了,

类似的,如果是执行错了insert语句,在row格式下,insert语句的binlog会记录所有的字段信息,这些信息可以用来精确定位刚刚被插入的那一行。这是,直接把insert语句转成delete语句,删掉这被误插入的一行数据就可以了。

如果执行的update语句,binlog里面会记录修改前整行的数据和修改后的整行数据。所以,如果误执行了update语句的话,只需要把这个event前后的两行信息对调以下,再去数据库里面执行,就能恢复这个更新操作了。

在使用binlog恢复数据的时候,使用mysqlbinlog解析出日志,然后将statement语句直接拷贝出来执行,这种做法可行吗?假设binlog的格式设置为mixed,然后执行如下语句:

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

执行的效果如下:

image-20220213221234250

可以看到,MySQL此时使用的statement格式,那么,如果这个binlog过了1分钟才传给备库的话,那主备的数据不就不一致了吗?使用 mysqlbinlog工具查看执行的详情:

image-20220213221344241

从图中的结果可以看到,binlog在记录event的时候,会多记录SET TIMESTAMP=1546103491,它用SET TIMESTAMP命令约定了接下来now()函数的返回时间。因此,不论这个binlog是1分钟之后被备库执行,还是3天后用来恢复这个库的备库,这个insert语句插入的行,值都是固定的。也就是说,通过SET TIMESTAMP命令,MySQL就确保了主备数据的一致性。

这也就是说直接执行语句的结果可能是错误的,因为有些语句的执行结果是依赖于上下文命令的,所以,使用binlog来恢复数据的标准做法是,用mysqlbinlog工具解析出来,然后把解析结果整个发给MySQL执行,类似下面的命令:

mysqlbinlog master.000001 --start-position=2738 --stop-position=2973 | mysql -h127.0.0.1 -P13000 -u$user -p$pwd;

这个命令的意思是,将master.000001文件里面从第2738字节到2973字节中间这段内容解析出来,放到MySQL去执行。

循环复制问题

上文中主备的结构实际上是M-S结果,但实际生产上使用比较多的是双M结构,也就是下图所展示的主备切换流程:

image-20220213222320129

对比双M结构和M-S结构,其实区别只是多了一条线,即:节点A和B之间总是互为主备关系,这样在切换的时候就不用再修改主备关系。但是,双M结构有一个显著的问题需要解决:

业务逻辑在节点A上更新了一条语句,然后再把生成的binlog发给节点B,节点B执行完这条更新语句后也会生成binlog(log_slave_updates=on),那么,如果节点A同时是节点B的备库,相当于又把节点B新生成的binlog拿过来执行了一次,然后节点A和B间,会不断地循环执行这条更新语句,也就是循环复制了,要解决这个问题,要用到上文中提到的server id:

  1. 规定两个库的server id必须不同,如果相同,则它们之间不能设定为主备关系
  2. 一个备库接到binlog并在重放的过程中,生成与binlog的server id相同的新的binlog
  3. 每个库在收到从自己的主库发过来的日志后,先判断server id,如果跟自己的相同,表示这个日志是自己生成的,就直接丢弃这个日志

按照这个逻辑,如果我们设置了双M结构,日志的执行流程就会变成这样:

  1. 从节点A更新的事务,binlog里面记的都是A的server id
  2. 传到节点B执行一次后,节点B生成的binlog的server id也是A的server id
  3. 再传回给节点A,A判断这个server id与自己的相同,就不会再处理这个日志。所以,死循环在这里就断掉了

主备延迟

正常情况下,只要主库执行更新生成的所有binlog,都可以传到备库并被正确地执行,备库就能达到跟主库一致的状态,这就是最终以执行,但是,MySQL要提供高可用能力,只有最终一致性是不够的,上文中提到的双M结构的主备切换流程图如下:

image-20220313163317617

主备延迟及其来源

主备切换可能是一个主动运维动作,比如软件升级、主库所在机器按计划下线等,也可能是被动操作,比如主库所在机器掉电。

首先要明确的一个概念是“同步延迟”,与数据同步有关的时间点主要包括以下三个:

  • 主库A执行完成一个事务,写入binlog,我们将这个时刻记为T1
  • 之后传给备库B,我们将备库B接收完这个binlog的时刻记为T2
  • 备库B执行完成这个事务,我们将这个时刻记为T3

所谓备库延迟,就是同一个事务,在备库执行完成的时间和主库执行完成的时间之间的差值,也就是T3-T1。可以在备库上执行show slave status命令,它的返回结果里面会显示:seconds_behind_master,用于表示当前备库延迟了多少秒。它的计算方式如下:

  • 每个事务的binlog里面都有一个时间字段,用于记录主库上写入的时间
  • 备库取出当前正在执行的事务的时间字段的值,计算它与当前系统时间的差值,得到seconds_behind_master

可以看到,其实seconds_behind_master这个参数计算的就是T3-T1.所以,我们可以用seconds_behind_master来作为主备延迟的值,这个值的时间精度是秒。需要注意的是,如果主备库及其的系统时间设置不一致,并不会导致主备延迟的值不准确。因为,备库连接到主库的时候,会通过执行SELECT UNIX_TIMESTAMP()函数来获取当前主库的系统时间,如果这个时候发现主库的系统时间与自己的不一致,备库在执行seconds_behind_master计算的时候会自动扣掉这个差值。

在网络正常的时候,日志从主库传给备库所需的时间是很短的,即T2-T1的值是非常小的。也就是说,网络正常的情况下,主备延迟的主要来源是备库接收完binlog和执行完这个事务之间的时间差。所以,主备延迟最直接的表现是,备库消费中转日志(relay log)的速度,比主库生产binlog的速度要慢。

接下来我们分析一下,可能导致主备延迟出现的原因。

第一种情况是,有些部署条件下,备库所在的机器的性能要比主库所在的机器性能差。这里有一个误区是,既然备库没有请求,所以就使用差一点儿的机器,例如,将20个主库放在4台机器上,而把备库集中在一台机器上。但实际上,更新请求对IOPS的压力,在主库和备库是无差别的,这种“非双1”的模式,更新过程中也会触发大量的读操作,所以,当备库主机上的多个备库都在争抢资源的时候,就可能会导致主备延迟了。不过,由于,主备可能发生切换,备库随时可能变成主库,所以主备库选用相同规格的机器,并且做对称部署,是目前比较常见的做法。

第二种情况是,备库的压力大。通常来说,主库提供写能力,备库提供读能力。由于主库直接影响业务,可能使用的时候会比较克制,反而忽视了备库的压力控制。这样做的结果就是,备库上的查询耗费了大量的CPU资源,影响了同步速度,造成了主备延迟。这种情况,可以如下处理:

  • 一主多从。除了备库外,可以多接几个从库,让这些从库来分担读的压力
  • 通过binlog输出到外部系统,比如Hadoop这类系统,让外部系统提供统计类查询的能力

其中,一主多从的方式比较常用,因为作为数据库系统,必须保证要有定期全量备份的能力,而从库,就比较适合来做备份。

第三种情况是大事务。由于主库上必须等事务执行完成才会写入binlog,再传给备库。所以,如果一个主库上执行10分钟,那么事务很有可能就会导致从库延迟10分钟。一次性地使用delete语句删除太多数据,就是一个典型的大事务的场景,比如,一些归档性的数据,平时没有注意删除历史数据,等到空间快满了,业务开发人员要一次性地删掉大量历史数据,这就是一种比较典型的大事务的场景。另一种比较典型的大事务场景,就是大表DDL。

由于主备延迟的存在,所以在主备切换的时候,就相应的有不同的策略。

可靠性优先策略

在双M的结构下,从状态1到状态2切换的详细过程如下:

  1. 判断备库B现在的seconds_behind_master,如果小于某个值(比如5秒)继续下一步,否则持续重试这一步
  2. 把主库A改成只读状态,即readonly=true
  3. 判断备库B的seconds_behind_master的值,直到这个值变成0为止
  4. 把备库B改成可读写状态,也就是readonly=false
  5. 把业务请求切换到备库B

这个切换过程就称为可靠性优先策略,执行的流程图如下:

image-20220313213531273

图中SBM是seconds_behind_master的简称

可以看到,这个切换流程中是有不可用时间的。因为在步骤2之后,主库A和备库B都处于readonly的状态,也就是说这个时候系统处于不可写状态,直到步骤5完成后才能恢复。在这个不可用状态中,比较耗费时间的是步骤3,可能需要耗费好几秒的时间。这也是为什么需要在步骤1先做判断,确保seconds_behind_master的值足够小。试想如果一开始主备延迟就长达30分钟,而没有先做判断直接切换的话,系统的不可用时间就会长达30分钟,这种情况一般业务都是不可接受的。

如果要避免切换过程中有不可用时间的这个问题,那么可以采用可用性优先的策略,可以将不可能用时间几乎降为0。

可用性优先策略

如果将可靠性优先策略步骤的4、5调整到最开始执行,也就是说,不等主备数据同步,直接把连接切到备库B,并且让备库可以读写,那么系统几乎没有不可用时间了,这个切换流程,就称为可用性优先流程。这个切换流程的代价,就是可能出现数据不一致的情况。下面我们来举例来说明这一点,假设有一个表t:

CREATE TABLE `t` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT, 
  `c` int(11) unsigned DEFAULT NULL, 
  PRIMARY KEY (`id`)
) ENGINE = InnoDB; insert into t(c) 

insert into t(c) values(1),(2),(3);

这个表定义了一个字增逐渐id,初始化数据后,主库和备库上都是3行数据。接下来,业务人员要继续在表t上执行两条插入语句的命令,依次是:

insert into t(c) values(4);
insert into t(c) values(5);

假设现在主库上有其他的数据表有大量的更新,导致主备延迟达到5秒,在插入一条c=4的语句后,发起了主备切换。下图是binlog_format=mixed时,切换的流程和结果:

image-20220313222405986

可以看到,由于采用了可用性优先策略,主库A和备库B上出现了两行不一致的数据。那么,如果设置binlog_format=row情况又会如何呢?

因为row格式在记录binlog的时候,会记录插入的行的所有字段的值,所以最后只会有一行不一致。而且,两边的主备同步的应用线程会报错duplicate key error并停止。也就是说,这种情况下,备库B的(5,4)和主库A的(5,5)这两行数据,都不会被对方执行。详细过程如下:

image-20220313223114222

不难发现:

  • 使用row格式的binlog时,数据不一致的问题更容易被发现,而使用mixed或者statement格式的binlog时,数据很可能悄悄地就不一致了。如果过了很久才发现数据不一致的问题,很可能这时的数据不一致已经不可查,或者连带造成了更多的数据逻辑不一致
  • 主备切换的可用性优先策略会导致数据不一致。因此,大多数情况下,都应该选择可靠性策略,数据的可靠性一般还是要优于可用性

那么时候应该使用可用性优化策略呢?一种场景就是一场切换。假设主库A和备库B的主备延迟是30分钟,这时候主库A掉电了,HA系统要切换B作为主库,通常我们在主动切换的时候,需要等到主备延迟小于5秒的时候再启动切换,但这个时候已经别无选择了。

image-20220313224647402

采用可靠性优先策略的话,就必须等到备库B的seconds_behind_master=0之后,才能切换,但现在的情况比较严重,系统已经处于完全不可用的状态。我们就必须先切换到备库B,并且设置备库Breadonly=false。只切换备库,而不设置备只读也是不行的,因为这段时间内,中转日志还没有应用完成,如果直接发起主备切换,客户端查询看不到之前执行完成的事务,会认为有“数据丢失”。虽然随着中转日志的继续应用,这些数据会恢复回来,但是对于一些业务来说,查询到“暂时丢失数据的状态”也是不能被接受的。

总而言之,在满足数据可靠性的前提下,MySQL高可用系统的可用性,是依赖于主备延迟的,延迟的时间越小,在主库故障的时候,服务恢复需要的时间就越短,可用性就越高。

并行复制

前面的小节中我们介绍过MySQL主备流程图:

image-20220315233013490

图中两个黑色的箭头,一个箭头代表了客户端写入库,另一个箭头代表的是备库上sql_thread执行中转日志(relay log)。这里使用箭头的粗细来表示并行度,可以看到,第一个箭头明显粗于第二个箭头,这是由于在主库上,各种锁都会影响并发度。

图中备库上sql_thread更新数据(DATA)的过程如果使用的是单线程的话,就会导致备库应用日志不够快,造成主备延迟,在5.6版本之前,MySQL只支持单线程复制,从单线程复制到最新版本的多线程复制,中间演化经历多个版本,不过,所有的多线程复制都符合下面的这个模型:

image-20220315233935209

上图中,coordinator就是原来的sql_thread,不过现在它并不再直接更新数据,只负责读取中转日志和分发事务,真正更新日志的,变成了worker线程,work线程的个数,是由参数slave_parallel_workers决定的。

需要注意的是,事务并不能简单的按照轮询的方式分发给各个worker。当事务被分发给worker以后,不同的worker就独立执行了,但是由于CPU的调度策略,第二个事务有可能比第一个事务先执行,而如果这时候刚好这两个事务更新的是同一行,也就意味着,同一行上的两个事务,在主库和备库上的执行顺序相反,会导致主备不一致的问题。类似的,同一个事务的多个更新语句,也不能分给多个不同的worker去执行,假设一个事务更新了表t1和t2中的各一行,如果这两条更新语句被分到不同的worker的话,虽然最终的结果是主备一致的,但如果表t1执行完成的瞬间,备库上有一个查询,就会看到这个事务“更新了一半的结果”,破坏了事务逻辑的隔离性。

因此,coordinator在分发的时候,需要满足以下这两个基本要求:

  • 更新同一行的事务,必须被分发到同一个worker中
  • 同一个事务不能被拆开,必须放到同一个worker中

各个版本的多线程复制,都遵循了这两条基本原则。

按表分发策略

按表分发事务的基本思路是,如果两个事务更新不同的表,它们就可以并行,因为数据是存储在表里的,所以按表分发,可以保证两个worker不会更新同一行。不过,如果有跨表的事务,还是要把两张表放在一起,具体如下图所示:

image-20220316000412695

每个worker线程对应一个哈希表,用于保存当前正在这个worker的“执行队列”里的事务所涉及到的表。哈希表的key是“库名.表名”,value是一个数字,表示队列中有多少个事务修改这个表,当有事务分配给worker的时候,事务里面涉及到的表会被加到对应的哈希表中。worker执行完成后,这个表会被从哈希表中去掉。以上图为例,hash_table_1表示,现在worker_1的“待执行事务队列”里,有4个事务涉及到db1.t1表,有一个事务涉及到db2.t2表,hash_table_2表示,现在worker_2中有一个事务会更新到表t3的数据。

假设在上图的基础上,corrdinator从中转日志中读入下一个新事务T,这个事务修改的行涉及到表t1和t3,那么这个事务T的分配流程如下:

  1. 由于事务T中涉及修改表t1,而worker_1队列中有事务在修改表t1,事务T和队列中的某个事务要修改同一个表的数据,这种情况下我们认为事务T和worker是冲突的
  2. 按照这个逻辑,依次判断事务T和每个worker队列的冲突关系,会发现事务T跟worker_2也冲突
  3. 事务T跟多余一个worker冲突,corrdinator线程就进入等待
  4. 每个worker继续执行,同时修改哈希表,假设hash_table_2里面涉及到修改表t3的事务先执行完成,就会从hash_table_2中把db1.t3这一项去掉
  5. 这样corrdinator会发现跟事务T冲突的worker只有worker_1了,此时,就会将事务T分配给worker_1执行
  6. corrdinator继续读下一个中转日志,继续分配事务

也就是说,每个事务在分发的时候,跟所有worker的冲突关系包含以下三种情况:

  1. 如果跟所有worker都不冲突,corrdinator线程就会把这个事务分配给最闲的worker
  2. 如果跟多于一个worker冲突,corrdinator线程就会进入等待状态,直到和这个事务存在冲突关系的worker只剩下一个
  3. 如果只跟一个worker冲突,corrdinator线程就会把这个事务分配给这个存在冲突关系的worker

这个按表分发的方案,在多个表负载均衡的场景里应用效果很好。但是,如果碰到热点表,比如所有的更新事务都会涉及到一个表的时候,所有事务都会被分配到同一个worker中,就变成单线程复制了。

按行分发策略

要解决热点表的并行复制问题,就需要一个按行并行复制的方案。按行复制的核心思路是:如果是两个事务没有更新相同的行,它们在备库上可以并行执行。显然,这个模式要求binlog的格式必须是row。

这个时候,我们判断一个事务T和worker是否冲突,用的规则就不是“修改同一个表”,而是“修改同一行”。

按行复制和按表复制的数据结构差不多,也是为每个worker,分配一个哈希表,只要实现按行分发,此时,哈希表的key就必须是“库名+表名+唯一键的值”。但是,这个“唯一键”只有主键id还是不够的,表中t1中除了主键,还有唯一索引a:

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

insert into t1 values(1,1,1),(2,2,2),(3,3,3),(4,4,4),(5,5,5);

假设,接下来我们要在主库执行下面这两个事务:

image-20220319231335851

可以看到,这两个事务要更新的行的主键值不同,但是如果它们被分到不同的worker,就有可能session B的语句先执行,这时候id=1的行的a的值还是1,就会报唯一键冲突。因此,基于行的策略,事务哈希表还需要考虑唯一键,即key应该是“库名+表名+索引a的名字+a的值”,比如,在上面这个例子中,要在表t1上执行update t1 set a = 1 where id = 2语句,在binlog里面记录了整行的数据修改前各个字段的值,和修改后各个字段的值。因此,corrdinator在解析这个语句的binlog的时候,这个事务的哈希表就有三个项:

  1. key=hash_func(db1+t1+'PRIMARY'+2), value=2,这里value=2是因为修改前后的行id值不变,出现了两次
  2. key=hash_func(db1+t1+'a'+2), value=1,表示会影响到这个表a=2的行
  3. key=hash_func(db1+t1+'a'+1), value=1,表示会影响到这个表a=1的行

可见,相比于按表并行分发策略,按行并行策略在决定线程分发的时候,需要消耗更多的计算资源,并且有以下限制:

  • 主库的binlog格式必须是row,因为要能够从binlog里面解析出表名、主键值和唯一索引值
  • 表必须有主键
  • 不能有外键,表上如果有外键,级联更新的行不会记录在binlog中,这样冲突检测就不准备

对比按表分发和按行分发这两个方案,按行分发策略的并行度会更高,不过,如果要操作多行的大事务的话,按行分发的策略有两个问题:

  1. 耗费内存,例如一个语句要删除100万行数据,这时候哈希表就要记录100万个项
  2. 耗费CPU,解析binlog,然后计算哈希值,对于大事务,这个成本比较高

所以,按行分发这种策略会设置一个阈值,单个事务如果超过设置的行的阈值(比如,如果单个事务的行数超过10万行),就暂时退化为单线程模式,退化的逻辑大概如下:

  1. corrdinator暂时先hold住这个事务
  2. 等待所有wroker都执行完成,变成空队列
  3. corrdinator直接执行这个事务
  4. 恢复并行模式

按表分发和按行分发策略并没有被合并到官方。

MySQL 5.7的并行复制策略

在MySQL5.6的版本中,支持了按库并行的复制策略,在决定分发策略的哈希表中,key就是数据库名。这种策略的并行效果,取决于压力模型。如果在主库上有多个DB,并且各个DB的压力均衡,使用这个策略的效果会很好。

相比于按表和按行分发,这个策略有两个优势:

  1. 构造哈希值的时候很快,只需要库名,而且一个实例上DB数也不会很多,不会出现需要构造100万个项这种情况
  2. 不要求binlog的格式,因为statement格式的binlog也可以很容易拿到库名

但是,如果主库上的表都放在同一个DB里面,这个策略就没有效果了。如果不同DB的热点不同,比如一个是业务逻辑库,一个是系统配置库,那也起不到并行的效果。理论上可以创建不同的DB,把相同热度的表均匀分到这些不同的DB中,强行使用这个策略,不过,这样需要特地异动数据,因此这个策略用的并不多。

MariaDB的并行复制策略

MariaDB利用了redo log组提交(group commit)优化,而MariaDB的并行复制策略利用这个特性:

  • 能够在同一个组里提交的事务,一定不会修改同一行
  • 主库上可以并行执行的事务,备库上也一定是可以并行执行的

MariaDB中的并行复制策略:

  1. 在一组里面一起提交的事务,有一个相同的commit_id,下一组就是commit_id+1
  2. commit_id直接写到binlog里面
  3. 传到备库应用的时候,相同commit_id的事务分到多个worker执行
  4. 这一组全部执行完成后,corrdinator再去取下一批

这个策略与其他策略不同的地方在于,它的目标是“模拟主库的并行模式”,不过,它并没有实现“真正模拟主库并发度”这个目标。在主库上,一组事务在commit的时候,下一组事务是同时处于“执行中”状态的。

下图中,假设了三组事务在主库的执行情况,可以看到trx1、trx2和trx3提交的时候,trx4、trx5和trx6是在执行的。这样,在第一组事务提交完成的时候,下一组事务很快就会进入commit状态。

image-20220319234841629

而按照MariaDB的并行复制策略,备库上的执行效果如下:

image-20220319234944025

可以看到,在备库上执行的时候,要等第一组事务完全执行完成后,第二组事务才能开始执行,这样系统的吞吐量就不够。另外,这个方案很容易被大事务拖后腿。假设trx2是一个超大事务,那么在备库应用的时候,trx1和trx3执行完成后,就只能等trx2完全执行完成,下一组才能开始执行,这段时间,只有一个worker线程在工作,是对资源的浪费,不过即使如此,这个策略仍然是一个令人感到惊艳的创新。

MySQL 5.7的并行复制策略

在MariaDB实现了并行复制之后,MySQL5.7版本也提供了类似的功能,由参数slave-parallel-type来控制并行复制策略:

  • 配置为DATABASE,表示使用MySQL5.6版本的按库并行策略
  • 配置为LOGICAL_CLOCK,表示的就是类似MariaDB的策略。不过,MySQL5.7这个策略,针对并行度做了优化

在redo log的两阶段提交中,其实不用等到commit阶段,只要能够达到redo log prepaer阶段,就表示事务已经通过锁冲突的检验了。因此,MySQL5.7并行复制的思想是:

  • 同时处于prepare状态的事务,在备库执行时时可以并行的
  • 处于prepare状态的事务,与处于commit状态的事务之间,在备库执行时也是可以并行的

binlog的组提交中,有这样两个参数:

  • binlog_group_commit_sync_delay参数,表示延迟多少微妙后才调用fsync
  • binlog_group_commit_sync_no_delay_count参数,表示累积多少次以后才调用fsync

这两个参数用于故意拉长binlog从write到fsync的时间,以此来减少binlog的写盘次数,在MySQL5.7的并行复制策略里,它们可以用来制造更多的“同时处于prepare阶段的事务”,这样就增加了备库复制的并行度。也就是说,这两个参数,既可以“故意”让主库提交得慢些,又可以让备库执行得快些,在MySQL5.7处理备库延迟的时候,可以考虑调整这两个参数值,来达到提升备库复制并发度的目的。

在MySQL5.7.22的版本里,有新增了一个基于WRITESET的并行复制,相应地,增加了一个参数binlog-transaction-dependency-tracking,用来控制是否启动这个新策略,这个参数的可选值有以下三种:

  • COMMIT_ORDER表示根据同时进入prepare和commit来判断是否可以并行的策略
  • WRITESET表示的对于事务涉及更新的每一行,计算出这一行的哈希值,组成集合writeset,如果两个事务没有操作相同的行,也就是说它们的writeset没有交集,就可以并行
  • WRITESET_SESSION,是在writeset的基础上多了一个约束,即在主库上同一个线程先后执行的两个事务,在备库执行的时候,要保证相同的先后顺序

当然为了唯一标识,这个哈希值是通过“库名+表名+索引名+值”计算出来的,如果一个表上除了有主键索引外,还有其他唯一索引,那么对于每个唯一索引,insert语句对应的writeset就要多增加一个哈希值。

可以看到,这种策略与按行分发的策略比较类似,不过这个实现有以下优势:

  • writeset是在主库生成后直接写入binlog里面的,这样在备库执行的时候,不需要解析binlog里面的内容(event里的行数据),节省了很多计算量
  • 不需要把整个事务的binlog都扫一遍才能决定分发到哪个worker,更省内存
  • 由于备库的分发策略不依赖于binlog内容,所以binlog是statement格式也是可以的

当然,对于“表上没有主键”和“外键约束”的场景,writeset策略也是没法并行的,也会暂时退化为单线程模型。

一主多从

大多数的互联网应用都是读多写少,因此,在业务不断发展的过程中,很可能会先遇到读性能的问题,而在数据层解决读性能问题,就会涉及到一主多从的架构。

image-20220320123410458

图中,虚线箭头表示的是主备关系,也就是A和A'互为主备,从库B、C、D指向的是主库A。一主多从的设置,一般用于读写分离,主库负责所有的写入和一部分读,其他的读请求则由从库分担。

在一主多从的架构下,主库故障后的主备切换流程如下:

image-20220320125816253

相比于一主一备的切换流程,一主多从的结构在切换完成后,A'会称为新的主库,从库B、C、D也要改接到A',正是由于多了从库B、C、D重新指向的这个过程,所以主备切换的复杂性也相应增加了。

基于位点的主备切换

当我们把节点B设置成节点A'的从库的时候,需要执行一条change master命令:

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
MASTER_LOG_FILE=$master_log_name 
MASTER_LOG_POS=$master_log_pos

这条命令有6个参数,其中:

  • 前四个参数分别代表了A'的IP、端口、用户名和密码
  • 最后两个参数分别表示,要从主库的master_log_name文件的master_log_pos这个位置的日志继续同步,而这个位置就是我们所说的同步位点,也就是主库对应的文件名和日志偏移量。

当节点B要设置成A'的从库,就要执行change master命令,就不可避免的要设置位点的这两个参数。原来节点B是A的从库,本地记录的也是A的微店,但是相同的日志,A的位点和A'的位点是不同的。因此,从库B要切换的时候,都需要经过”找同步位点“这个逻辑,但是这个位点很难精确取到,只能取到一个大概位置。

考虑到切换过程中不能丢失数据,所以在确定位点的时候,总是要找一个”稍微往前“的,然后再通过判断跳过那些在从库B上已经执行过的事务,一种取位点的方法是这样的:

  1. 等待新主库A'把中转日志(relay log)全部同步完成

  2. 在A'上执行show master status命令,得到当前A'上最新的File和Position

  3. 取原主库A故障的时刻T

  4. 用mysqlbinlog工具解析A'的File,得到T时刻的位点

    mysqlbinlog File --stop-datetime=T --start-datetime=T
    

    image-20220320131636375

图中,end_log_pos后面的值“123”,表示的就是A'这个实例,在T时刻写入新的binlog的位置,那么,我们就可以把123这个作为$master_log_pos的值。

不过,这样得到的$master_log_pos的值并不精确。假设在T这个时刻,主库A已经执行完成了一个insert语句插入了一行数据R,并且已经将binlog传给了A'和B,然后在传完的瞬间主剧A的主机就掉电了。此时,系统的状态是这样的:

  1. 在从库B上,由于同步了binlog,R这一行已经存在
  2. 在新主库A'上,R这一行已经存在,日志是写在123这个位置之后的
  3. 此时,在从库B上执行change_master命令,指向A'的File文件的123位置,就会把插入R这一行数据的binlog又同步到从库B去执行

这个时候,从库B的同步线程就会出现Duplicate entry 'id_of_R' for key 'PRIMARY'错误,提示出现了主键冲突,然后停止同步。

所以,通常情况下,在切换任务的时候,要先主动跳过这些错误,有两种常用的方法,一种做法是,主动跳过一个事务,跳过命令的写法是:

set global sql_slave_skip_counter=1;
start slave;

因为切换过程中,可能会不止重复执行一个事务,所以需要在从库B刚开始接到新主库A'时,持续观察,每次碰到这些错误就停下来,执行一次跳过命令,直到不再出现停下来的情况,以此来跳过可能涉及的所有事务。

另一方式时,通过设置slave_skip_errors参数,直接设置跳过指定的错误,在执行主备切换时,有两种比较常见的错误:

  • 1062错误是插入数据时唯一键冲突
  • 1032错误是删除数据时找不到行

因此,我们可以把slave_skip_errors设置为“1032,1062”,这样中间碰到这两个错误时就直接跳过。

需要注意的是,这种直接跳过指定错误的方法,针对的是主备切换时,由于找不到精确的同步位点,所以只能采用这种方法来创建从库和新主库的主备关系。这么操作的前提是,我们很清楚在主备切换过程中,直接跳过1032和1062这两类错误是无损的,才可以设置slave_skip_errors参数,等到主备间的同步关系建立完成,并稳定执行一段时间之后,我们还需要把这个参数设置为空,以免之后真的出现了主从数据不一致,也跳过了。

GTID

通过sql_slave_skip_counter跳过事务和通过slave_skip_errors忽略错误的方法,虽然都最终可以建立从库B和新主库A'的主备关系,但这两种操作都很复杂,而且容易出错。所以,MySQL5.6版本引入了GTID,彻底解决了这个问题。

GTID的全称是Global Transaction Identifier,也就是全局事务ID,是一个事务在提交的时候生成的,是这个事务的唯一标识。它由两部分组成,格式是:

GTID=source_id:transaction_id

其中:

  • ƒsource_id是一个实例第一次启动时自动生成的,是一个全局唯一的值
  • transaction_id是一个整数,初始值是1,每次提交事务的时候分配给这个事务,并加1

这里的transaction_id与我们通常讲的事务id有所区别,事务id是在事务执行过程中分配的,如果这个事务回滚了,事务id也会递增,而这里transaction_id只有在事务提交的时候才会分配。

GTID模式的启动需要在启动MySQL实例的时候,添加参数gtid_mode=onenforce_gtid_consistency=on。在GTID模式下,每个事务都会跟一个GTID一一对应。这个GTID有两种生成方式,而使用哪种方式取决于session变量gtid_next的值:

  • 如果gtid_nex=automatic,代表使用默认值,这时,MySQL就会把source_id:transaction_id分配给这个事务
    • 记录binlog的时候,先记录一行[email protected]_NEXT='source_id:transaction_id'
    • 把这个GTID加入本实例的GTID集合
  • 如果gtid_next是一个指定的GTID的值,比如通过set gtid_next='current_gtid'指定为current_gtid,那么就有两种可能:
    • 如果current_gtid已经存在于实例的GTID集合中,接下来执行的这个事务会直接被系统忽略
    • 如果current_gtid没有存在于实例的GTID集合中,就将这个current_gtid分配给接下来要执行

这样,每个MySQL实例都维护了一个GTID集合,用来对应“这个实例执行过的所有事务”,接下来我们通过例子来说明这一点。

首先在实例X中创建表t:

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

insert into t values(1,1);

image-20220320221847024

可以看到,事务的BEGIN之前有一条SET SESSION.GTID_NEXT命令。这时,如果实例X有从库,那么将CREATE TABLEinsert语句的binlog同步过去执行的话,执行事务之前就会先执行这两个SET命令,这样被加入从库的GTID集合的,就是图中的这两个GTID。假设,现在这个实例X是另外一个实例Y的从库,并且此时在实例Y上执行下面的这条插入语句:

insert into t values(1,1);

并且,这条语句在实例Y上的GTID是“aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”。那么,实例X作为Y的从库,就要同步这个事务过来执行,显然会出现主键冲突,导致实例X的同步线程停止,这时,我们可以执行如下语句序列:

set gtid_next='aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10'; begin;
commit;
set gtid_next=automatic;
start slave;

其中,前三条语句的作用,是通过提交一个空事务,把这个GTID加到实例X的GTID集合中。下图表示了执行完这个空事务之后的show master status的结果:

image-20220320222841433

可以看到实例X的Executed_Gtid_set里面,已经加入了这个GTID,这样,我再执行start slave命令让同步线程执行起来的时候,虽然实例X还是会继续执行实例Y传过来的事务,但是由于“aaaaaaaa-cccc-dddd-eeee-ffffffffffff:10”已经存在于实例X的GTID集合中了,所以实例X就会直接跳过这个事务,也就不会再出现主键冲突的错误。

在上面的这个语句序列中,start slave命令之前还有一行set gtid_next=automatic。这句话的作用是“恢复GTID的默认分配行为”,也就是说如果之后有新的事务再执行,就还是按照原来的分配方式,继续分配transaction_id=3

在GTID模式下,备库B要设置为新主库A'的从库的语法如下:

CHANGE MASTER TO 
MASTER_HOST=$host_name 
MASTER_PORT=$port 
MASTER_USER=$user_name 
MASTER_PASSWORD=$password 
master_auto_position=1

其中,master_auto_position=1就表示这个主备关系使用的是GTID协议,使用这种方式,就无须设置MASTER_LOG_FILEMASTER_LOG_POS这两个参数了。

现在,将实例A'的GTID的集合记为set_a,实例B的GTID集合记为set_b,那么在实例B上执行start slave命令,取binlog的逻辑如下:

  1. 实例B指定主库A',基于主备协议建立连接
  2. 实例B吧set b发给主库A'
  3. 实例A'算出set_a与set_b的差集,也就是所有存在于set_a,但是不存在于set_b的GTID的集合,判断A'本地是否包含了这个差集需要的所有binlog事务
    • 如果不包含,表示A'已经把实例B需要的binlog给删掉了,直接返回错误
    • 如果确认包含全部,A'从自己的binlog文件里面,找出第一个不在set_b的事务,发给B
  4. 之后就从这个事务开始,往后读文件,按顺序取binlog发给B去执行

其实,这个逻辑里面包含了一个设计思想:在基于GTID的主备关系里,系统认为只要建立主备关系,就必须保证主库发给备库的日志是完整的,因此,如果实例B需要的日志已经不存在,A'就拒绝把日志发给B。这跟基于位点的主备协议不同,基于位点的协议,是由备库决定的,备库指定哪个点位,主库就发哪个位点,不做日志的完整性判断。

在引入GTID之后,一主多从的切换场景下,主备切换就变成了:由于不需要找位点了,所以从库B、C、D只需要分别执行change master命令执行实例A'即可。其实,严谨地说,主备切换不是不需要找位点了,而是找位点的这个工作,在实例A'内部就已经自动完成了,但由于这个工作是自动的,所以对HA系统的开发人员来说,非常友好。之后这个系统就由新主库A'写入,主库A'的自己生成的binlog中的GTID集合格式是:source_id_of_A<sup>'</sup>:1-M。如果之前从库B的GTID集合格式是source_id_of_A<sup>'</sup>:1-N,那么切换之后GTID集合的格式就变成了source_id_of_A<sup>'</sup>:1-N,source_id_of_A<sup>'</sup>:1-M。由于主库A'之前也是A的备库,因此主库A'和从库B的GTID集合是一样的。这样就达到了我们的预期。

读写分离

读写分离的主要目标就是分摊主库的压力,上文提到的一主多从的架构师客户端主动做负载均衡,这种模式下一般会把数据库的连接信息放在客户端的连接层。也就是说,由客户端来选择后端数据库进行查询。还有一种架构是,在MySQL和客户端之间有一个中间代理层proxy,客户端只连接proxy,由proxy根据请求类型和上下文决定请求的分发路由。

image-20220320232030366

客户端直连和带proxy的读写分离架构的优劣势:

  1. 客户端直连方案,因为少了一层proxy转发,所以查询性能更好,并且整体架构简单,排查问题更方便。不过这种方案,由于要了解后端的部署细节,所以在出现主备切换、库迁移等操作的时候,客户端都会感知到,并且需要调整数据库连接信息。
  2. 带proxy的结构,对客户端比较友好。客户端无需关心后端的细节,连接维护、后端信息维护等工作,都是由proxy完成。不过,这样对后端维护团队的要求会更高,而且,proxy也需要有高可用架构,因此,带proxy架构的整体就相对比较复杂

不论采用哪种架构,都会碰到“过期读”的问题,即由于主从可能存在延迟,客户端执行完 一个更新事务马上发起查询,如果查询选择的是从库的话,就有可能读到刚刚的事务更新之前的状态。

处理这种”过期读“的问题,大致有以下几种方案:

  • 强制走主库方案
  • sleep方案
  • 判断主备无延迟方案
  • 配合semi-sync方案
  • 等主库位点方案
  • 等GTID方案

强制走主库方案

强制走主库方案其实就是,将查询请求做分类。通常情况下,我们可以将查询请求分为两类:

  • 对于必须要拿到最新结果的请求,强制将其发到主库上。比如,在一个交易平台上,卖家发布商品后,马上要返回主页面,查看商品是否发布成功,那么这个请求需要拿到最新的结果,就必须走主库
  • 对于可以读到就数据的请求,才将其发到从库上。在这个交易平台上,买家来逛商铺页面,就算晚几秒看到最新发布的商品,也是可以接受的,那么,这类请求就可以走从库

当然,这个方案最大的问题在于,有时候会碰到所有的查询都不能是过期读的需求,比如一些金融类的业务。这样的话,就要放弃读写分离,所有读压力都在主库,等同于放弃了扩展性。

sleep 方案

这个方案的做法是,在主库更新后,读从库之前先sleep一下。具体的方案就是,类似于执行一条select sleep(1)命令。这个方案的假设是,大多数情况下主备延迟在1秒内,做一个sleep可以有很大概率拿到最新的数据。

还是以卖家发布商品为例,商品发布后,用Ajax直接把客户端输入的内容作为“新的商品”显示在页面上,而不是真正地去数据库查询,这样,买家就可以通过这个现实,来确认产品已经发布成功了。等到卖家再刷新页面,去查看商品的时候,其实已经过了一段时间,也就达到了sleep的目的,进而也就解决了过期读的问题。

也就是说,这个sleep方案确实解决了类似场景下的过期读问题,但是也存在两个问题:

  • 如果这个查询请求本来0.5秒就可以在从库上拿到正确结果,也会等1秒
  • 如果延迟超过1秒,还是会出现过期读

判断主备无延迟方案

要判断备库无延迟,通常有三种做法。

第一种确保主备无延迟的方法是,每次从库执行查询请求前,先判断seconds_behind_master是否已经等于0,如果还不等于0,那就必须等待这个参数变为0才能执行查询请求。seconds_behind_master的单位是秒,如果觉得精度不够,就可以采用位点和GTID的方法来确保主备无延迟。

第二种方法是,对比位点确保主备无延迟:

  • Master_Log_FileRead_Master_Log_Pos,表示的是读到的主库的最新位点
  • Relay_Master_Log_FileExec_Master_Log_Pos,表示的是备库执行的最新位点

如果Master_Log_FileRead_Master_Log_PosRelay_Master_Log_FileExec_Master_Log_Pos这两组值完全相同,就表示接收到的日志已经同步完成。

第三种方法是,对比GTID集合确保主备无延迟:

  • Auto_Position=1,表示这对主备关系使用了GTID协议
  • Retrieved_Gtid_Set,是主备收到的所有日志的GTID集合
  • Executed_Gtid_Set,是备库所有已经执行完成的GTID集合

如果这两个集合相同,也表示备库接收到的日志都已经同步完成。

可见,对比位点和对比GTID这两种方法,都比判断seconds_behind_master是否为0更精确。在执行查询请求之前,先判断从库是否同步完成的方法,相比于sleep方法,准确度确实提升了不少,但还是没有达到“精确”的程度。一个事务的binlog在主备之间流转的状态如下:

  1. 主库执行完成,写入binlog,并反馈给客户端
  2. binlog被从库发送给备库,备库收到
  3. 在备库执行binlog完成

三种讨论的判断主备无延迟的逻辑,都是“备库收到的日志都执行完成了”。但是,从binlog在主备之间状态的分析中,可以看到还有一部分日志,处于客户端已经收到提交确认,而备库还没收到日志的状态,如下图:

![image-20220325100353640](/Users/jiyongchao/Library/Application Support/typora-user-images/image-20220325100353640.png)

这时,主库上执行完成了三个事务trx1、trx2和trx3,其中:

  1. trx1和trx2已经传到从库,并且已经执行完成了
  2. trx3在主库执行完成,并且已经回复给客户端,但是还没有传到从库中

如果这时候在从库B上执行查询请求,按照上面提到的判断方法,从库会认为已经没有同步延迟,但还是查不到trx3的。严格地说,就是出现了过期读。

要解决这个问题,就要引入半同步复制,也就是semi-sync replication,semi-sync的设计如下:

  1. 事务提交的时候,主库把binlog发给从库
  2. 从库收到binlog以后,发回给主库一个ack,表示收到了
  3. 主库收到这个ack以后,才能给客户端返回“事务完成”的确认

也就是说,如果启用了semi-sync,就表示所有给客户端发送过确认的事务,都确保了备库已经收到了这个日志。这样,结合前面关于位点的判断,就能够确定在从库上执行的查询请求吗,可以避免过期读。

不过,semi-sync+位点判断的方案,只对一主一备的场景是成立的。在一主多从的场景中国呢,主库只要等到一个从库的ack,就开始给客户端返回确认,这时,在从库上执行查询请求,就有两种情况:

  1. 如果查询是落在这个相应ack的从库上,是能确保读到最新数据
  2. 但如果是查询落到其他从库上,它们可能还没有收到最新的日志,就会产生过期读的问题

另外,判断同步位点的方案还有另外一个潜在的问题,即:如果在业务更新的高峰期,主库的位点或者GTID集合更新很快,那么上面的两个位点等值判断就会一直不成立,很可能出现从库上迟迟无法响应查询请求的情况,示意图如下:

上图中备库B下的虚线框,分别表示relay log和binlog中的事务,可以看到,备库从状态1到状态4都和主库A存在延迟,如果使用上面提到的策略,必须要等到无延迟才能查询的方案,selelct语句直到状态4都不能执行。

总而言之,semi-sync配合判断主备无延迟的方案,存在两个问题:

  • 一主多从的时候,在某些从库执行查询请求会存在过期读的现象
  • 在持续延迟的情况下,可能出现过度等待的问题

仔细观察可以发现,客户端是在发完trx1更新后发起的select语句,其实只要确保trx1已经执行完成就可以执行select语句了,也就是说在状态3执行查询请求,得到的就是预期结果了,这就是等主库位点方案。

等主库位点方案

要理解等主库位点方案,首先要了解一条命令:

select master_pos_wait(file, pos[,timeout]);

它的逻辑如下:

  • 它是在从库执行的
  • 参数file和pos指的是主库上的文件名和位置
  • timeout可选,设置为正整数N表示这个函数最多等待N秒

这个命令正常返回的结果是一个正整数M,表示从命令开始执行,到应用完file和pos表示的binlog位置,执行了多少事务。当然,除了正常返回一个正整数M外,还会返回一些其他结果,包括:

  • 如果执行期间,备库同步线程发生异常,则返回NLL
  • 如果等待超过N秒,就返回-1
  • 如果刚开始执行的时候,就已经执行过这个位置了,则返回0

上文中的例子,就可以使用这个逻辑:

  1. trx1事务更新完成后,马上执行show master status得到当前主库执行到的File和Position
  2. 选定一个从库执行查询语句
  3. 在从库上执行select master_pos_wait(File,Position,1);
  4. 如果返回值是大于等于0的正整数,则在这个从库执行查询语句
  5. 否则,到主库执行查询语句

这个过程的流程图如下:

这里假设这条select查询最多在主库上等待1秒,那么1秒内master_pos_wait返回一个大于等于0的整数,就确保了主库上执行的这个查询结果一定包含了trx1的数据。

其中步骤5到主库执行查询语句,是这类方案常用的退化机制,因为从库的延迟时间不可控,不能无限等待,所以如果等待超时,就应该放弃,然后到主库去查。这样带来的问题是,如果所有的从库都延迟超过1秒了,那查询的压力都会跑到主库上。在不允许过期读的要求下,只有两种选择,一种是超时放弃,一种是转到主库查询,至于具体选择哪一种,取决于业务场景。

GID方案

如果数据库开启了GTID模式,对应的也有等待GTID的方案,MySQL中提供了一个类似的命令:

select wait_for_executed_gtid_set(gtid_set,1);

这条命令的逻辑是:

  1. 等待,直到这个库执行的事务中包含传入的gtid_set,返回0
  2. 超时返回1

在等位点的方案中,我们执行完事务后,还要主动去主库执行show master status。而MySQL5.7.6版本开始,允许在执行更新类事务,把这个事务的GTID返回给客户端,这样等GTID的方案就可以减少一次查询,这时,执行流程就变成了:

  1. trx1事务更新完成后,从返回包直接获取这个事务的GTID,记为gtid1
  2. 选定一个从库执行查询语句
  3. 在从库上执行select wait_for_executed_gtid_set(gtid1,1)
  4. 如果返回值是0,则在这个从库执行查询语句
  5. 否则,到主库执行查询语句

整个过程的流程图如下:

第一步中,trx1事务更新完成后,从返回包获取这个事务的GTID,需要将MySQL的参数设置为session_track_gtids=OWN_GTID,然后通过API接口mysql_session_track_get_first从返回包解析出GTID的值即可。

可用性判断

本小节我们要讨论的是,如何判断一个主库出了问题。

select 1判断

最简单直接的方法就是执行select 1,如果select 1成功返回了,就表示主库没有问题。实际上,select 1成功返回,只能说明这个库的进程还在,并不能说明主库没问题,为了说明这个问题,我们创建表t:

set global innodb_thread_concurrency = 3;

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

insert into t values(1,1)

这里我们设置innodb_thread_concurrency参数的目的是,控制InnoDB的并发线程上限。也就是说,一旦并发线程数达到这个值,InnoDB在接收到新请求的时候,就会进入等待状态,直到有线程退出。

这里设置innodb_thread_concurrency=3表示InnoDB只允许3个线程并发执行。在这个例子中,前三个session中的sleep(100),使得这三个语句都处于“执行”状态,以此来模拟大查询。在session D中,select 1可以成功,但是查询表t的语句会被阻塞,也就是说,如果这时使用select 1是检测不出问题的。

在InnoDB中,innodb_thread_concurrency这个参数的默认值是0,表示不限制并发线程数量。但是,不限制并发线程数肯定是不行的,因为,一个机器的CPU核数有限,如果不加以限制,线程数过高,会导致上下文切换的成本会过高。通常情况下,innodb_thread_concurrency可以设置为64~128之间的值。

需要额外说明的是,并发连接和并发查询并不是同一个概念,使用show processlist的结果里看到的几千个连接,指的就是并发连接,而“当前正在执行”的语句,才是所谓的并发查询。并发连接数达到几千个影响并不大,就是会多占一些内存,但是并发查询过高会占用过多资源,这就是我们为什么要设置innodb_thread_concurrency参数的原因。

假设我们设置innodb_thread_concurrency=128,那么如果出现同一行热点更新的问题时,会不会很快就把128消耗完呢?实际上,在线程进入锁等待以后,并发线程的计数会减一,也就是说等行锁(包括间隙锁)的线程时不算在128里面的。MySQL这样设计的原因是,进入锁等待的线程已经不吃CPU了,更重要的是,必须这么设计,才能避免整个系统锁死,假设有如下场景:

  1. 线程1执行begin; update t set c = c + 1 where id = 1;,启动了事务trx1,然后保持这个状态。这时候,线程处于空闲状态,不算在并发线程里面
  2. 线程2到线程129都执行update t set c = c + 1 where id = 1;,由于等行锁,进入等待状态,这样就有128个线程处于等待状态
  3. 如果处于锁状态的线程技术不减一,InnoDB就会认为线程数用满了,会阻止其他语句进入引擎执行,这样线程1不能提交事务。而另外的128个线程又处于锁等待状态,整个系统就阻塞了

具体过程如下图:

这时候InnoDB不能响应任何请求,整个系统被锁死。而且,由于所有线程处于等待状态,此时占用的CPU却是0,而这明显不合。所以InnoDB在设计时,遇到进程进入锁等待的情况时,将并发线程的计数减1的设计,是合理而且必要的。

虽然说等锁的线程不算在并发线程计数里,但如果它在真正地执行查询,比如上面例子中的select sleep(100) from t,还是要算进并发线程的计数的。

在这个例子中,同时执在执行的语句超过了设置的innnodb_thread_concurrency的值,这时候系统其实已经不正常了,但是通过select 1来检测系统,会认为系统还是正常的。

查表判断

为了能够检测InnoDB并发线程数过多导致的系统不可用的情况,我们需要找一个访问InnoDB的场景。一般的做法是,在系统库(mysql库)里创建一个表,比如命名为health_check,里面只放一行数据,然后定期执行:

select * from mysql.health_check;

使用这个方法,我们可以检测出由于并发线程过多导致的数据库不可用的情况。但是,这种做法也存在一个问题,更新事务要写入binlog,当binlog所在磁盘的空间占用率达到100%,那么所有的更新语句和事务提交的commit语句就都会被堵住,但是这个时候系统还是可以正常读取数据的,因此,我们需要把这条查询语句修改成更新语句。

更新判断

既然要更新,就要放个有意义的字段,常见的做法是放一个timestamp字段,用来表示最后一次执行检测的时间:

update mysql.health_check set t_modified=now();

节点可用性的检测都应该包含主库和备库。如果用来检测主库的话,那么备库也要进行更新检测。但是,备库的检测也是要写bin log的,由于我们一般会把数据库A和B的主备关系设计为双M结构,所以在备库B傻姑娘执行的检测命令,也要发回给主库A。

但是,如果主库A和备库B都用相同的更新命令,就可能出现行冲入,也就是可能会导致主备同步停止,因此,mysql.health_check这个表需要再加一行。为了让主备之间的更新不产生冲突,我们可以在mysql.health_check表上存入多行数据,并且用A、B的server_id做主键。

CREATE TABLE `health_check` (
  `id` int(11) NOT NULL, 
  `t_modified` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, 
  PRIMARY KEY (`id`)
) ENGINE = InnoDB;

/* 检测命令 */
insert into mysql.health_check(id, t_modified) 
values (@@server_id, now()) on duplicate key update t_modified = no

由于MySQL规定了主库和备库的server_id必须不同(否则创建主备关系的时候就会报错),这样就可以保证主、备库各自的检测命令不会发生冲突。

更新判断是一个相对比较常用的方案,不过依然存在一些问题,其中,“判定慢”是最主要的问题。

首先,所有的检测逻辑都需要一个超时时间N,执行一条update语句,超过N秒后还不返回,就认为系统不可用。假设一个日志盘的IO利用率已经是100%了,这个时候,整个系统的响应非常慢,已经需要做主备切换了。但是IO利用率100%表示系统的IO是正在工作的,每个请求都有机会获得IO资源,执行自己的任务。而update语句,需要的资源很少,所以可能在拿到IO资源的时候就可以提交成功,并且在超时时间N秒未达到之前就返回给了检测系统。检测系统发现,update命令没有超时,于是得到了“系统正常”的结论。

之所以出现这个现象,根本原因是上文中提到的所有方法,都是基于外部检测的。外部检测天然有一个问题,就是随机性。因为外部检测都需要定时轮询,所以系统可能已经出问题了,但是却需要等到下一个检测发起执行语句的时候,才有可能发现为你。而且,如果运气不够好的话,可能第一次轮询还不能发现,这就会导致切换慢的问题。

内部统计

内部统计的方案主要是要利用磁盘利用率,在MySQL5.6以后提供的preformance_schema库,在file_summary_by_event_name表里统计了每次IO请求的时间,根据MySQL5.6内部每一次IO请求的时间来判断数据库是否出现了问题会精确的多。

file_summary_by_event_name表里有很多行数据,我们主要关注event_name='wait/io/file/innodb/innodb_log_file'这一行:

![image-20220327105108038](/Users/jiyongchao/Library/Application Support/typora-user-images/image-20220327105108038.png)

图中,这一行统计的是redo log的写入时间,第一列EVENT_NAME表示统计的类型。接下来三组数据,显示的是redo log操作的时间统计。第一组五列,是所有IO类型的统计。其中,COUNT_STAR是所有IO的总次数,接下来四列是具体的统计项,单位是皮秒,前缀SUM、MIN、AVG、MAX,顾名思义指的就是总和、最小值、平均值和最大值。第二组六列,是读操作的统计,最后一列SUM_NUMBER_OF_BYTES_READ统计的是,总共从redo log里读了多少字节。第三组六列,统计的是写操作。最后的第四组数据,是对其他类型数据的统计。在redo log里,可以认为它们是对fsync的统计。

bin log对应的是event_name = "wait/io/file/sql/binlog"这一行,各个字段的统计逻辑,与redo log的各个字段完全相同。

由于每次操作数据库,preformance_schema都需要额外地统计这些信息,所以打开这个统计功能是有性能损耗的。如果打开所有的preformance_schema项,性能会下降10%左右,因此,只打开需要的项进行统计即可。

如果打开redo log的时间监控,可以执行:

update setup_instruments set ENABLED='YES', Timed='YES' where name like '%wait/io/file/innodb/innod

假设已经开启了redo log和binlog这两个统计信息,就可以通过MAX_TIMER的值来判断数据库是否出问题了。比如,可以通过设定阈值,单次IO请求时间超过200毫秒属于异常,然后使用类似下面这条语句作为检测逻辑:

select event_name,MAX_TIMER_WAIT FROM performance_schema.file_summary_by_event_name where EVENT_NAME = 'wait/io/file/innodb/innodb_log_file';

发现异常后,取到需要的信息,再通过下面的这条语句:

truncate table performance_schema.file_summary_by_event_name;

把之前的统计信息清空,这样,再次出现这个异常,就可以加入监控累积值了。

MySQL的最佳实践

Kill命令

在MySQL中有两个kill命令:一个是kill quey + 线程id,表示终止这个线程中正在执行的语句:一个是kill connection + 线程id,这里connection可缺省,表示断开这个线程的连接,当然如果这个线程有语句正在执行,也是要先停止正在执行的语句。在使用kill命令的时候,可能会出现,无法断开这个连接,但是执行show processlist命令,看到这条语句的Command列显示的却是Killed。

其实大多数情况下,kill query/connection命令是有效的,比如,执行一个查询的过程中,发现执行时间太久,要么放弃继续查询,这时我们就可以使用kill query命令,终止这条查询语句。还有一种情况是,语句处于锁等待的时候,直接使用kill命令也是有效的:

可以看到,session C执行kill query以后,session B几乎同时就提示了语句被中断,这就是我们预期的结果。

kill命令的执行过程

当对一个表做增删改查操作时,会在表上加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状态。

上面的分析中,隐含了下面的三层意思:

  • 一个语句执行过程中有多处“埋点”,在这些买点的地方判断线程状态,如果发现线程状态是THD::KILL_QUERY,才开始进入语句终止逻辑
  • 如果处于等待状态,必须是一个可以被唤醒的等待,否则根本不会执行到“埋点”处
  • 语句从开始进入终止逻辑,到终止逻辑完全完成,是有一个过程的

接下来我们来看一个kill不掉的例子。首先,执行set global innodb_thread_concurrency = 2,将InnoDB的并发线程上限设置为2,然后执行下面的序列:

可以看到:

  1. session C执行的时候被阻塞了
  2. 但是session D执行kill query C命令没有效果
  3. 直到session E执行了kill connection命令,才断开了session B的连接,提示“Lost connection to MySQL server during query”
  4. 但是,此时如果在session E中执行show processlist,就可以看到下面这个图:

这时候,id=12这个线程的Command列显示的是Killed。也就是说,客户端虽然断开了连接,但是实际上服务端上这条语句还在执行过程中。

为什么在执行kill query命令时,这条语句不像第一个例子的update语句一样退出呢?在实现等行锁时,使用的是pthread_cond_timewait函数,这个等待状态可以被唤醒。但是,在这个例子中,12号线程的等待逻辑是这样的:每10毫秒判断一下是否可以进入InnoDB执行,如果不行,就调用nanosleep函数进入sleep状态。也就是说,虽然12号线程的状态已经设置成了KILL_QUERY,但是在这个等待进入InnoDB的循环过程中,并没有去判断线程的状态,因此根本不会进入终止逻辑阶段。而当session E执行kill connection命令时:

  1. 把12号线程状态设置为KILL_CONNECTION
  2. 关掉12号线程的网络连接,因为有这个操作,所以这时候session C收到了断开连接的提示

那为什么执行show processlist的时候,会看到Command列显示为killed呢?其实,这时因为在执行show processlist的时候,有一个特别的逻辑:如果一个线程的状态是KILL_CONNECTION,就把Command列线程成Killed,所以,即使是客户端退出了,这个线程的状态仍然是在等待中,那这个线程什么时候会退出呢?

只有等到满足进入InnoDB的条件后,session C的查询语句继续执行,然后才有可能判断到线程的状态已经变成了KILL_QUERY或者KILL_CONNECTION,再进入终止逻辑阶段。

这是kill无效的第一类情况,即:线程没有执行到判断线程状态的逻辑,跟这种情况相同的,还有由于IO压力过大,读写IO的函数一致无法返回,导致不能及时判断线程的状态。另一类情况是,终止逻辑耗时较长,这时候,从show processlist结果上看也是Command = Killed,需要等到终止逻辑完成,语句才算真正完成,这类情况,比较常见的场景有以下几种:

  1. 超大事务执行期间被kill,这时候,回滚操作需要对事务执行期间生成的所有新数据版本做回收操作,耗时很长
  2. 大查询回滚。如果查询过程中生成了比较大的临时文件,加上此时文件系统压力大,删除临时文件可能需要等待IO资源,导致耗时较长
  3. DDL命令执行到最后阶段,如果被kill,需要删除中间过程的临时文件,也可能收到IO资源影响耗时较久。

客户端缓存

在实际使用中,有两个比较常见的误解。

第一个误解是:如果库里面的表特别多,连接就会很慢。有些线上的库,会包含很多表,这时候,每次用客户端连接都会卡在下面这个界面上:

而如果db1这个库里表很少的话,连接起来就会很快,可以很快进入输入命令的状态。但每个客户端在和服务端建立连接的时候,需要做的事情就是TCP握手、用户校验、获取权限。但这几个操作,显然跟库里面表的个数无关。

实际上,当使用默认参数连接的时候,MySQL客户端会提供一个本地库名和表名补全的功能。为了实现这个功能,客户端在连接成功后,需要多做一些操作:

  1. 执行show databases;
  2. 切到db1库,执行show tables;
  3. 把这两个命令的结果用于构建一个本地的哈希表

在这些操作中,最花时间的就是第三步在本地构建哈希表的操作。所以,当一个库中的表个数非常多的时候,这一步就会花比较长的时间。也就是说,我们感知到的连接过程慢,其实并不是连接慢,也不是服务端慢,而是客户端慢。如果在连接命令中加上-A的参数,就可以关掉这个自动不全的功能,然后客户端就可以快速返回了。这里自动补全的效果是,当输入库名或者表名的时候,输入前缀,就可以使用Tab键自动补全表名或者显示提示。如果在实际使用中,自动补全功能用得不多,那么久可以加上-A的参数。

除了可以添加-A的参数以外,加-quick(或简写为-q)参数,也可以跳过这个阶段。但是,这个-quick是一个更容易引起误会的参数,也是关于客户端常见的一个误解。

MySQL客户端发送请求后,接收服务端返回结果的方式有两种:

  1. 一种是本地缓存,也就是在本地打开一片内存,先把结果存起来。如果使用API,对应的就是mysql_store_result方法
  2. 另一种是不读缓存,读一个处理一个。如果使用API开发,对应的就是mysql_use_result方法

MySQL客户端默认采用第一种方式,而如果加上-quick参数,就会使用第二种不缓存的方式。采用不缓存的方式时,如果本地处理得慢,就会导致服务端发送结果被阻塞,因此会让服务端变慢。也就是说,设置了这个参数,反而会降低服务端的性能。

那么,为什么这个参数要取名叫做-quick呢?这是因为:

  • 第一点,跳过了表名自动补全的功能
  • 第二点,mysql_store_result需要申请本地内存来缓存查询结果,如果查询结果太大,会耗费较多的本地内存,可能会影响客户端本地机器的性能
  • 第三点,不会把执行命令记录到本地的命令历史文件

因此,-quick参数的意思,其实是让客户端变得更快。

误删数据

俗话说“常在河边走,哪有不湿鞋”,大多数人都可能会碰到误删数据的场景,为了找到解决误删数据的更高效的方法,我们对MySQL相关的误删数据,做以下分类:

  • 使用delete语句误删数据行
  • 使用drop table或者truncate table语句误删数据表
  • 使用drop database语句误删数据库
  • 使用rm命令误删整个MySQL实例

误删行

如果使用delete语句误删了数据行,可以用Flashback工具通过闪回把数据恢复回来,Flashback恢复数据的原理,是修改binlog的内容,拿回原库重放,而能够使用这个方案的前题是,需要确保binlog_format=rowbinlog_row_image=FULL

具体恢复数据时,对单个事务做如下处理:

  1. 对于insert语句,对应的binlog event类型是Write_rows event,把它改成Delete_rows event即可
  2. 同理,对于delete语句,也是将Delete_rows event改为Write_rows event
  3. 而如果是Update_rows的话,binlog里面记录了数据行修改前和修改后的值,对调这两行的位置即可

如果误操作不是一个,而是多个,比如下面三个事务:

(A)delete ...
(B)insert ...
(C)update...

现在要把数据库恢复到这个三个事务操作之前的状态,用Flashback工具解析binlog后,写回主库的命令是:

(reverse C)update ...
(reverse B)delete ...
(reverse A)insert...

也就是说,如果误删数据涉及到了多个事务的话,需要将事务的顺序调过来再执行。需要注意的是,不要直接在主库上执行这些操作,恢复数据比较安全的做法是,恢复出一个备份,或者找一个从库作为临时库,在这个临时库上执行这些操作,然后再将确认过的临时库的数据,恢复回主库。这么做的原因是,一个在执行线上逻辑的主库,数据状态的变更往往是有关联的,可能由于发现数据问题的时间晚了一点,就导致已经在之前误操作的基础上,业务代码逻辑又继续修改了其它数据,所以,如果这时候单独恢复这几行数据,而又未经确认的话,就可能出现对数据的二次破坏。

比起误删数据时候进行处理,更重要的是做到事前预防:

  1. sql_safe_updates参数设置为on,这样依赖,如果忘记在delete或者update语句中写where条件,或者where条件里面没有包含索引字段的话,这条语句的执行就会报错
  2. 代码上线前,必须经过SQL审计

如果设置了sql_safe_updates=on,但是要删除一个小表的全部数据,可以在delete语句中加上where条件,比如where id >= 0。但是delete全表是很慢的,需要生成回滚日志,写redo log和bin log,所以,从性能的角度考虑,应该优先考虑使用truncate table或者drop table命令。

误删库/表

如果使用了truncate/drop tabledrop database命令删除的数据,就无法通过Flashback来恢复了,这是因为,即使我们配置了binlog_format=row,执行这三个命令时,记录的binlog还是statement格式。binlog里面就只有一个truncate/drop语句,这些信息是恢复不出来数据的。

这种情况下,要想恢复数据,就需要使用全量备份,加增量日志的方式了。这个方案要求线上有定期的全量备份,并且实时备份binlog,在这两个条件都具备的情况下,假如有人中午12点删了一个库,恢复数据的流程如下:

  1. 取最近一次全量备份,假设这个库是一天一备,上次备份是当天0点
  2. 用备份恢复出一个临时库
  3. 从日志备份里面,取出凌晨0点之后的日志
  4. 把这些日志,除了误删数据的语句外,全部应用到临时库

这个流程的示意图如下:

image-20220208233711540

关于这个过程的说明:

  1. 为了加速数据恢复,如果这个临时库上有多个数据库,那么可以在使用mysqlbinlog命令时,加上一个-database参数,用来指定误删表所在的库。这样,就避免了在恢复数据时还要应用其它库日志的情况
  2. 在应用日志的时候,需要跳过12点误操作的那个语句的binlog:
    • 如果原实例没有使用GTID模式,只能在应用到包含12点的binlog文件的时候,先用-stop-position参数执行到误操作之前的日志,然后再用-start-position从误操作之后的日志继续执行
    • 如果实例使用了GTID模式,就方便多了。假设误操作命令的GTID是gtid1,那么只需要执行set gtid_next=gtid1;begin;commit;,先把这个GTID加到临时实例的GTID集合,之后按顺序执行binlog的时候,就会自动跳过误操作的语句

不过即使这样,使用mysqlbinlog方法恢复数据还是不够快,主要原因有两个:

  1. 如果是误删表,最好就是只恢复这张表,也就是只重放这张表的操作,但是mysqlbinlog工具并不能指定只解析一个表的日志
  2. 用mysqlbinlog解析出日志应用,应用日志的过程就只能是单线程。

一种加速方式是,在用备份恢复出临时实例之后,将这个临时实例设置成线上备库的从库,这样:

  1. 在start slave之前,先通过执行change replication filter replicate_do_table = (tbl_name) 命令,就可以让临时库只同步误操作的表
  2. 这样做也可以用上并行复制技术,来加速整个数据恢复过程

这个过程的示意图如下:

image-20220209234951355

图中binlog备份系统到线上备库有一条虚线,是指如果由于时间太久,备库上已经删除了临时实例需要的binlog的话,我们可以从binlog备份系统中找到需要的binlog,再放回备库中。假设,我们发现当前临时实例需要的binlog是从master.000005开始的,但是在备库上执行show binlogs显示的最小的binlog文件是master.000007,意味着少了两个binlog文件。这时,我们就需要去binlog备份系统中找到这两个文件,把之前删掉的binlog放回备库的操作如下:

  1. 从备份系统下载master.000005和master.000006这两个文件,放到备库的日志目录下
  2. 打开日志目录下的master.index文件,在文件开头加入两行,内容分别是“./master.000005”和“.、master.000006”
  3. 重启备库,目的是要让备库重新识别这两个日志文件
  4. 现在这个备库上就有了临时库需要的所有binlog了,建立主备关系,就可以正常同步了

无论是把mysqlbinlog工具解析出的binlog文件应用到临时库,还是把临时库接到备库上,这两个方案的共同点是:误删库或者表后,恢复数据的思路主要就是通过备份,再加上应用binlog的方式。也就是说,这两个方案都要求备份系统定期备份全量日志,而且需要确保binlog在被从本地删除之前已经做了备份,但是一个系统不可能无限制的备份日志,还需要根据成本和磁盘空间资源,设定一个日志保留的天数。

延迟复制备库

虽然可以通过利用并行复制来加速恢复数据的过程,但是这个方案仍然存在“恢复时间不可控”的问题。如果一个库的备份特别大,或者误操作的时间距离上一个全量备份的时间较长,比如一周一备的实例,在备份之后的第6天发生误操作,就需要恢复6天的日志,这个恢复时间可能是要按天来计算的。

这种情况下,就可以考虑搭建延迟复制的备库,这个功能是MySQL5.6版本引入的。一般的主备复制结构存在的问题是,如果主库上有个表被误删了,这个命令很快也会被发给所有从库,进而导致所有从库的数据表也都一起被误删了。

延迟复制的备库是一种特殊的备库,通过CHANGE MASTER TO MASTER_DELAY=N命令,可以指定这个备库持续保持跟主库有N秒的延迟。比如将N设置为3600,这就代表了如果主库上有数据被误删了,并且在1小时内发现了这个误操作命令,这个命令就还没有在这个延迟复制的备库执行,这个时候在备库上执行stop slave,再通过之前介绍的方法,跳过误操作的命令,就可以恢复出需要的数据,这样的话就得到了一个最多只需要追加1个小时,就可以恢复出数据的临时实例,也就缩短了整个数据恢复需要的时间。

预防误删库/表的方法

  1. 账号分离。这样做的目的是,避免写错命令,比如:
    • 只给业务开发DML权限,而不给truncate/drop权限,而如果业务开发人员有DDL需求话,可以通过开发管理系统得到支持
    • 即使是DBA团队成员,日常也都规定只使用只读账号,必要的时候才使用有更新权限的账号
  2. 制定操作规范。这样做的目的,是避免写错要删除的表名,比如:
    • 在删除数据表之前,必须先对表做改名操作,然后,观察一段时间,确保对业务无影响以后再删除这张表
    • 改表名的时候,要求给表名加固定的后缀(比如加_to_be_deleted),删除表的动作必须通过管理系统执行。并且,管理系统删除表的时候,只能删除固定后缀的表

rm删除数据

其实,对于一个有高可用机制的MySQL集群来说,最不怕的就是rm删除数据了,只要不是恶意地把整个集群删除,而只是删掉了其中某一个节点的数据的话,HA系统就会开始工作,选出一个新的主库,从而保证整个集群的正常工作,这是,你要做的就是在这个节点上把数据恢复回来,再接入整个集群。

当然了,现在不止是DBA有自动化系统,SA(系统管理员)也有自动化系统,所以也许一个批量下线机器的操作,会让整个MySQL集群的所有节点都全军覆没,应对这种情况,只能尽量将备份跨机房,或者最好是跨城市保存。

自增主键

自增主键可以让主键索引尽量地保持递增顺序插入,避免了页分裂,因此索引更紧凑,但业务设计不应该依赖于自增主键的连续性,因为自增主键不能保证连续递增,有可能会出现“空洞”。为了便于说明,我们创建一个表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;

自增主键的存储

在这个空表里面执行insert into values(null,1,1);插入一行数据,再执行show create table;命令,就可以看到如下结果:

image-20220117234809443

可以看到,表定义里面出现了AUTO_INCREMENT=2,表示下一次插入数时,如果需要自动生成自增值,会生成id=2。实际上,自增值并不是保存在表结构定义里的,表结构的定义是存放在后缀名为.frm的文件中,但是并不会保存自增值。

不同的引擎对于自增值的保存策略是不同的:

  • MyISAM引擎的自增值保存在数据文件中
  • InnoDB引擎的自增值,其实是保存在了内存里,并且只有在MySQL8.0版本后,才有了“自增值持久化”的能力,也就是才实现了“如果发生重启,表的自增值可以恢复为MySQL重启前的值”,具体情况是:
    • 在MySQL5.7及之前的版本,自增值保存在内存里,并没有持久化,每次重启后,第一次打开表的时候,都会去找自增量的最大值max(id),然后将max(id)+1作为这个表当前的自增值。举例来说,如果一个表当前数据行里最大的id是10,AUTO_INCREMENT=11,这个时候如果删除id=10的行,AUTO_INCREMENT还是11,但是如果马上重启实例,重启后这个表的AUTO_INCREMENT就会变成10,也就是说,MySQL重启可能会修改一个表的AUTO_INCREMENT的值
    • 在MySQL8.0版本,将自增值的变更记录在了redo log中,重启的时候依靠redo log恢复重启之前的值

自增值的修改

在MySQL里面,如果字段id被定义为AUTO_INCREMENT,在插入一行数据的时候,自增值的行为如下:

  • 如果插入数据时id字段指定为0、null、或未指定值,那么就把这个表当前的AUTO_INCREMENT值填到自增字段
  • 如果插入数据时id字段执行了具体的值,就直接使用语句里指定的值

根据要插入的值和当前自增值的大小关系,自增值的变更结果也会有所不同,假设某次要插入的值是X,当前的自增值是Y:

  • 如果X<Y,那么这个表的自增值不变
  • 如果X≥Y,就需要把当前自增值修改为新的自增值

新的自增值的生成算法是:从auto_increment_offset开始,以auto_increment_increment为步长,持续叠加,直到找到第一个大于X的值,作为新的自增值。其中,auto_increment_offsetauto_increment_increment是两个系统参数,分别用来表示自增的初始值和步长,默认值都是1。

在一些场景下,使用的就不全是默认值。比如,双M的主备结构里要求双写的时候,我们就可能会设置auto_increment_increment=2,让一个库的自增id多是奇数,另一个库的自增id都是偶数,避免两个库生成的主键发生冲突。

那么,当这两个参数都设置为1的时候,自增主键为什么还是不能保证连续呢?常见的导致自增主键的原因有:

  • 唯一键冲突
  • 事务回滚
  • 自增锁的优化

接下来我们通过例子来说明这三点,假设表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的记录(字段c有唯一约束),所以报Duplicate key error,语句返回

对应的流程图如下:

image-20220118233718587

可以看到,这个表的自增值改成3,是在真正执行插入数据的操作之前,这个语句真正执行的时候,因为碰到了唯一键c冲突,所以id=2这一行并没有插入成功,但也没有将自增值再该回去,所以,在这之后,再插入新的数据行是,拿到的自增id就是3,也就是说,出现了自增主键不连续的情况。

完整的演示过程如下:

image-20220118234714144

可以看到,这个操作序列复现了一个自增主键id不连续的情况(没有id=2的行)。

事务回滚也会发生类似的情况,以下语句序列可以说明这一点:

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的自增值改回去呢?答案是为了性能,假设有两个并行执行的事务,在申请自增值的时候,为了避免两个事务申请到相同的自增id,肯定要加锁,然后顺序申请:

  1. 假设事务A申请到了id=2,事务B申请到id=3,那么这个时候表t的自增值是4,之后继续执行
  2. 事务B正确提交了,但事务A出现了唯一键冲突
  3. 如果允许事务A把自增id回退,也就是把表t的当前自增值改回2,那么就会出现这样的情况:表里面已经有了id=3的行,而当前的自增id的值是2
  4. 接下来,继续执行的其它事务就会申请到id=3,这时,就会出现插入语句报错“主键冲突”

而为了解决这个主键冲突,有两种方法:

  1. 每次申请id之前,先判断表里面是否已经存在这个id,如果存在,就跳过这个id。但是,这个方法的成本很高,因为,本来申请id是一个很快的操作,现在还要再去主键索引树上判断id是否存在
  2. 把自增id的锁范围扩大,必须等到一个事务执行完成并提交,下一个事务才能再申请自增id,这个方法的问题是锁的粒度太大,系统并发能力会大幅下降

因此,InnoDB放弃了这个设计,语句执行失败也不会回退自增id,也正是这样,才只保证了自增id是递增的,但不保证是连续的。

自增锁的优化

可以看到,自增id锁并不是一个事务锁,而是每次申请完就马上释放,以便允许别的事务再申请。其实,在MySQL5.1版本之前,并不是这样的,在MySQL5.0版本的时候,自增锁的范围是语句级别,也就是说,如果一个语句申请了一个表自增锁,这个锁会等执行结束以后才释放,显然,这会影响并发度。MySQL5.1.22版本引入了一个新策略,新增参数innodb_autoinc_lock_mode,默认值是1,并且:

  • 这个参数的值被设置为0时,表示采用之前MySQL5.0版本的策略,即语句执行结束后才释放锁
  • 这个参数的值被设置为1时:
    • 普通insert语句,自增锁在申请之后就马上释放
    • 类似insert...select这样的批量插入数据的语句,自增锁还是要等语句结束后才被释放
  • 这个参数的值被设置为2时,所有的申请自增主键的动作都是申请后就释放锁

不难发现,insert...select语句在默认设置下,使用了语句级的锁,这主要是出于数据的一致性的考虑,假设有以下场景:

image-20220123115117649

在这个例子中,session A往表t1中插入了4行数据,然后session B创建了一个相同结构的表t2,然后两个session 同时执行向表t2中插入数据的操作,如果session B是申请了自增值以后马上就释放自增锁,就可能出现这样的情况:

  1. session B先插入了两个记录(1,1,1)、(2,2,2)
  2. 然后,session A来申请自增id得到id=3,插入了(3,5,5)
  3. 之后,session B继续执行,插入两条记录(4,3,3)、(5,4,4)

假设数据库的binlog_format=statment,由于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这种批量插入数据的场景时,从并发插入数据性能的角度考虑,可以考虑思路2,这样做,既能提升并发性,又不会出现数据一致性问题。

普通的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...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,),实际上插入的数据就是(8,5,5),这也导致出现了自增id不连续的情况。

自增主键的上限

MySQL里有很多自增的id,每个自增id都是定义了初始值,然后不停地往上加步长,虽然自然数是没有上限的,但是在计算机里,只要定义了表示这个数的字节长度,那它就有上限。比如,无符号整型(unsigned int)是4个字节,上限就是223-1。

表定义自增值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值,再试图执行插入语句,报主键冲突错误。223-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的行,新写入的行就会覆盖原有的行。

从这个角度来看,我们还是应该在InnoDB表中主动创建自增主键,因为表自增id达到上限后,再插入数据时报主键冲突错误,是更能被接受的,毕竟覆盖数据,就意味着数据丢失,影响的是数据可靠性,报主键冲突,是插入失败,影响的是可用性,而一般情况下,可靠性优于可用性。

Xid

redo log和binlog有一个共同的字段叫做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,是另外维护的。InnoDB内部维护了一个max_trx_id全局变量,每次申请一个新的trx_id时,就获得max_trx_id的当前值,然后并将max_trx_id加1.

InnoDB数据可见性的核心思想是:每一行数据都记录了更新它的trx_id,当一个事务读到一行数据的时候,判断这个数据是否可见的方法,就是通过事务的一致性视图与这行数据的trx_id做对比。对于正在执行的事务,可以从infomation_schema.innodb_trx表中看到事务的trx_id。

接下来,我们观察如下事务序列:

image-20220123175439810

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,也就是说:

  • 在T1时刻,trx_id的值其实就是0,而这个很大的数,只是显示用的
  • 直到session A在T3时刻执行insert语句的时候,InnoDB才真正分配了trx_id。所以,T4时刻,session B查到的这个trx_id的值就是1289

需要注意的是,除了显而易见的修改类语句外,如果在select语句后面加上for update,这个事务也不是只读事务。

T2时刻这个数字是每次查询的时候由系统临时计算出来的。它的算法是:把当前事务的trx变量的指针地址转成整数,再加上248,使用这个算法,就可以保证两点:

  • 因为同一个只读事务在执行期间,它的指针地址是不会变的,所以不论是在innodb_trx还是在innodb_locks表里,同一个只读事务查出来的trx_id就会是一样的
  • 如果有并行的多个只读事务,每个事务trx变量的指针地址肯定不同。这样,不同的并发只读事务,查出来的trx_id就是不同的

而在显示值里面加上248,目的是为了保证只读事务显示的trx_id值比较大,正常情况下就会区别于读事务的id。但是trx_id跟row_id的逻辑类似,定义长度也是8个字节。因此,在理论上还是可能出现一个读写事务于一个只读事务显示trx_id相同的情况,不过这个概率很低,并且没有什么实质危害。

那么,只读事务不分配trx_id有什么好处呢?

  • 一个好处是,这样做可以减少事务视图里面活跃事务数组的大小。因为当前正在运行的只读事务,是不影响数据的可见性判断的。所以,在创建事务的一致性视图时,InnoDB就只需要拷贝读写事务的trx_id
  • 另一好处是,可以减少trx_id的申请次数。在InnoDB里,即使只是执行一个普通的select语句,在执行过程中,也是要对应一个只读事务的。所以只读事务优化后,普通的查询语句不需要申请trx_id,就大大减少了并发事务申请trx_id的锁冲突

由于只读事务不分配trx_id,一个自然而然的结果就是trx_id的增加速度变慢了。但是,max_trx_id会持久化存储,重启也不会重置为0,那么从理论上讲,只要一个MySQL服务跑得足够久,就可能出现max_trx_id达到248-1的上限,然后从0开始的情况。当达到这个状态后,MySQL就会持续出现一个脏读的bug。

首先我们需要把当前的max_trx_id先修成248-1。注意:这里使用的是可重复读隔离级别,具体的操作流程如下:

image-20220123221738366

image-20220123221826819

由于此时系统的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仍然存在。

假设一个MySQL实例的TPS是每秒50万,持续这个压力的话,在17.8年后,就会出现这个情况。如果TPS更高,这个年限自然也就更短了。但是,从MySQL的真正开始流行到现在,恐怕都还没有实例跑到过这个上限。不过,这个bug是只要MySQL实例服务时间够长,就必然会出现。

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

自增主键总结

总的来说,每种自增id有各自的应用场景,在达到上限后的表现也不同:

  • 表的自增主键id达到上限后,再申请时它的值就不会改变,进而导致继续插入数据时报主键冲突的错误
  • row_id达到上限后,则会归0再重新递增,如果出现相同的row_id,后写入的数据会覆盖之前的数据
  • Xid只需要不在同一个binlog文件中出现重复值接口,但是概率极小,可以忽略不计
  • InnoDB的max_trx_id递增值MySQL每次重启都会被保存起来,所以上文中脏读的例子就是一个必现的bug
  • thread_id是使用中最常见的,也是处理的最好的自增id逻辑

参考文献

[1] MySQL实战45讲open in new window

[2] MySQL5.7手册open in new window