更改查询计划

有几个可能的原因,为什么性能不佳的查询不能按预期工作。这范围从查询明显错误在不良架构到低级原因,如非优查询计划或资源争用。本章将讨论一些常见的案例和解决方案。

本章首先介绍本章中大多数示例中使用的测试数据,并讨论过度完整表扫描的症状。然后介绍查询中的错误如何导致严重的性能问题,以及为什么索引不能始终使用,即使它们存在。本章的中间部分通过改进索引使用或重写复杂查询来改进查询。最后一部分讨论如何使用子句实现队列系统,以及如何处理具有许多条件的查询或具有许多子句。

测试数据

本章主要本章中的示例创建的测试数据。如果您尝试示例,则本书的 GitHub 存储库中的文件包含必要的表定义和数据。该脚本将删除并创建它与表。

您可以使用 MySQL 命令中的命令或命令行客户端中的命令执行脚本。例如:

  1. mysql shell> \source chapter_24.sql
  2. ...
  3. mysql shell> SHOW TABLES FROM chapter_24;
  4. +----------------------+
  5. | Tables_in_chapter_24 |
  6. +----------------------+
  7. | address |
  8. | city |
  9. | country |
  10. | jobqueue |
  11. | language |
  12. | mytable |
  13. | payment |
  14. | person |
  15. +----------------------+
  16. 8 rows in set (0.0033 sec)

该脚本要求在采购数据库。

过度完整表扫描的症状

导致最严重的性能问题原因之一是特别是当涉及联接且查询块中的第一个表中未出现完整表扫描时。它可能导致 MySQL 的很多工作,以使之影响其他连接。当 MySQL 不能对查询使用索引时,将发生完整表扫描,因为不存在筛选条件或存在的条件没有索引。完整表扫描的副作用是,大量数据被拉入缓冲池,可能永远不会返回到应用程序。这会使磁盘 I/O 数量急剧增加,从而导致进一步的性能问题。

当查询执行过多的表扫描时,需要查找的症状包括 CPU 使用率增加、访问行数增加、索引使用率低,以及磁盘 I/O 可能增加,以及 InnoDB 缓冲池的效率降低。

检测过多的完整表扫描的最佳方法就是转向监视。直接的方法是查找在性能架构中标记为使用完整表扫描的查询,并将已检查的行与返回的行数或受影响的行数进行比较,如第章 中讨论。您还可以查看时间序列图,以发现被访问的行太多或 CPU 使用率太大的模式。图显示了在 MySQL 实例上进行完整表扫描的期间内监视图形的示例。(模拟这样的情况,则员工数据库非常有用,因为它具有足够大的表,可以允许一些相对较大的扫描。

更改查询计划 - 图1

请注意,在图形的左侧,访问完全扫描读取行的行访问率以及 CPU 使用率的增加。另一方面,返回的行数与访问行数相比变化很少(百分比)。特别是显示速率行通过索引读取与完整扫描相比的第二个图形,以及读取的行与返回的行之间的比率表明有问题。

最大的问题是,当 CPU 使用率太多行太多时,不幸的是,答案是”这取决于”。如果考虑 CPU 使用率,那么真正要说明的是,正在完成工作,对于正在访问的行数和速率,这些指标只是告诉应用程序正在请求数据。问题是,当太多的工作正在完成,并且访问太多的行时,应用程序需要回答的问题。在某些情况下,优化查询可能会增加其中一些指标,而不是减少它们,只是因为具有优化查询的 MySQL 能够执行更多工作。

这是一个为什么基线如此重要的示例。通常,考虑对指标的更改比查看指标的快照更多。同样,与单独查看指标相比,您从组合查看指标(例如将返回的行与访问中的行进行比较)中得到的更多。

接下来的两节将讨论访问过多行的查询示例以及如何改进它们。

查询错误

执行最差的查询的常见原因之一是查询写。这似乎是一个不太可能的原因,但在实践中,它可能发生比你想象的更容易。通常,问题是联接或筛选器条件缺失或引用错误的表。例如,如果使用框架(例如,使用对象关系映射 (ORM),框架中的 Bug 也可能成为罪魁祸首。

在极端情况下,缺少筛选器条件的查询会使应用程序停止查询(但不会终止查询)并重试它,因此 MySQL 会继续执行越来越多的执行效果很差的查询。这反过来又会使 MySQL 耗尽连接。

另一种可能性是,提交的查询中的第一个开始从磁盘将数据拉取到缓冲池。然后,每个后续查询将越来越快,因为它们可以从缓冲池中读取某些行,然后在到达尚未从磁盘读取的行时减慢速度。最后,查询的所有副本都将在短时间内完成,并开始向应用程序返回大量数据,使网络饱和。饱和网络可能导致连接尝试失败,因为握手错误中的 COUNT_HANDSHAKE_ERRORS 列),并且来自的主机最终可能会被阻止。

这看起来可能很极端,而且在大多数情况下,它不会变得那么糟糕。但是,本书的作者确实经历过这种情况的发生,因为生成查询的框架中出现一个错误。鉴于 MySQL 实例现在通常生活在云中的虚拟机中,资源量可能有限,例如 CPU 和网络,因此糟糕的查询最终也可能耗尽资源。

作为缺少联接条件的查询和查询计划的示例,请考虑加入城市和国家表的清单

  1. Listing 24-1. Query that is missing a join condition
  2. mysql> EXPLAIN
  3. SELECT ci.CountryCode, ci.ID, ci.Name,
  4. ci.District, co.Name AS Country,
  5. ci.Population
  6. FROM world.city ci
  7. INNER JOIN world.country co\G
  8. *************************** 1. row ***************************
  9. id: 1
  10. select_type: SIMPLE
  11. table: co
  12. partitions: NULL
  13. type: ALL
  14. possible_keys: NULL
  15. key: NULL
  16. key_len: NULL
  17. ref: NULL
  18. rows: 239
  19. filtered: 100
  20. Extra: NULL
  21. *************************** 2. row ***************************
  22. id: 1
  23. select_type: SIMPLE
  24. table: ci
  25. partitions: NULL
  26. type: ALL
  27. possible_keys: NULL
  28. key: NULL
  29. key_len: NULL
  30. ref: NULL
  31. rows: 4188
  32. filtered: 100
  33. Extra: Using join buffer (Block Nested Loop)
  34. 2 rows in set, 1 warning (0.0008 sec)
  35. mysql> EXPLAIN ANALYZE
  36. SELECT ci.CountryCode, ci.ID, ci.Name,
  37. ci.District, co.Name AS Country,
  38. ci.Population
  39. FROM world.city ci
  40. INNER JOIN world.country co\G ************ 1. row *********
  41. EXPLAIN:
  42. -> Inner hash join (cost=100125.15 rows=1000932) (actual time=0.194..80.427
  43. rows=974881 loops=1)
  44. -> Table scan on ci (cost=1.78 rows=4188) (actual time=0.025..2.621
  45. rows=4079 loops=1)
  46. -> Hash
  47. -> Table scan on co (cost=25.40 rows=239) (actual time=0.041..0.089
  48. rows=239 loops=1)
  49. 1 row in set (0.4094 sec)

请注意,这两个表如何将访问类型并且联接在块嵌套循环中使用联接缓冲区。通常有类似症状的一个原因是正确的查询,但查询不能使用索引。解释输出显示 8.0.18 版使用哈希联接。它还显示,总共返回了近 100 万行!查询的可视化解释图如图。

更改查询计划 - 图2

请注意,两个(红色)完整表扫描如何脱颖而出,查询成本如何估计超过 100,000。

多个完整表扫描、返回行的估计值非常高以及成本估计非常高的组合是您需要查找的告密符号。

出现类似症状的查询性能不佳的一个原因,是 MySQL 无法对筛选器和联接条件使用索引。

未使用索引

当查询需要查找表中的行时,它基本上可以通过两种方式实现:直接在完整表扫描中访问行或浏览索引。在存在高度选择性的筛选器的情况下,通过索引访问行通常比通过表扫描快得多。

显然,如果筛选器应用于的列上没有索引,MySQL 别无选择,只能使用表扫描。您可能会发现,即使有索引,也不能使用。三个常见原因是,列不是多列索引中的第一列,数据类型与比较不匹配,并且在列上使用函数与索引匹配。本节将讨论其中的每一个原因。

不是索引的左前缀

要使用索引,必须的左前缀。例如,如果索引包含则上的条件只能使用筛选器,如果列 a 上也有相等。

可以使用索引的条件示例包括:

  1. WHERE a = 10 AND b = 20 AND c = 30
  2. WHERE a = 10 AND b = 20 AND c > 10
  3. WHERE a = 10 AND b = 20
  4. WHERE a = 10 AND b > 20
  5. WHERE a = 10

索引不能有效地使用的示例是 WHERE b 。在 MySQL 8.0.13 及更晚,如果 a 是列,MySQL 可以使用跳过扫描范围优化使用索引。如果 值,则无法使用索引。条件在任何情况下都无法使用索引。

同样,对于索引将仅用于对筛选。当查询仅使用索引中列的子集时,索引的顺序与应用筛选器的顺序相对应非常重要。如果其中一列具有范围条件,请确保该列是索引中使用的最后一个列。例如,考虑清单。

  1. Listing 24-2. Query that cannot use the index effectively due to column order
  2. mysql> SHOW CREATE TABLE chapter_24.mytable\G
  3. *************************** 1. row ***************************
  4. Table: mytable
  5. Create Table: CREATE TABLE `mytable` (
  6. `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  7. `a` int(11) NOT NULL,
  8. `b` int(11) DEFAULT NULL,
  9. `c` int(11) DEFAULT NULL,
  10. PRIMARY KEY (`id`),
  11. KEY `abc` (`a`,`b`,`c`)
  12. ) ENGINE=InnoDB AUTO_INCREMENT=16385 DEFAULT CHARSET=utf8mb4
  13. COLLATE=utf8mb4_0900_ai_ci
  14. 1 row in set (0.0004 sec)
  15. mysql> EXPLAIN
  16. SELECT *
  17. FROM chapter_24.mytable
  18. WHERE a > 10 AND b = 20\G
  19. *************************** 1. row ***************************
  20. id: 1
  21. select_type: SIMPLE
  22. table: mytable
  23. partitions: NULL
  24. type: range
  25. possible_keys: abc
  26. key: abc
  27. key_len: 4
  28. ref: NULL
  29. rows: 8326
  30. filtered: 10
  31. Extra: Using where; Using index
  32. 1 row in set, 1 warning (0.0007 sec)

请注意,输出只有 4 个字节,而如果索引同时用于 a 和 b 列, 输出还显示,估计只包含 10% 的检查行。图显示了视觉解释中的相同示例。

更改查询计划 - 图3

请注意”(靠近包含其他详细信息的框的底部)只是列出。但是,如果更改索引中列的顺序,以便在列之前对列进行索引,则索引可用于两列的条件。清单显示了添加新索引(b、a、c)。

  1. Listing 24-3. Query plan with the index in optimal order
  2. mysql> ALTER TABLE chapter_24.mytable
  3. ADD INDEX bac (b, a, c);
  4. Query OK, 0 rows affected (1.4098 sec)
  5. Records: 0 Duplicates: 0 Warnings: 0
  6. mysql> EXPLAIN
  7. SELECT *
  8. FROM chapter_24.mytable
  9. WHERE a > 10 AND b = 20\G
  10. *************************** 1. row ***************************
  11. id: 1
  12. select_type: SIMPLE
  13. table: mytable
  14. partitions: NULL
  15. type: range
  16. possible_keys: abc,bac
  17. key: bac
  18. key_len: 9
  19. ref: NULL
  20. rows: 160
  21. filtered: 100
  22. Extra: Using where; Using index
  23. 1 row in set, 1 warning (0.0006 sec)

请注意列9 个字节,列显示 100% 的检查行将从表中包含在表中。如图。

更改查询计划 - 图4

在图中,您可以看到要行数从 8000 多行减少到 160 行,并且”已使用的关键部分”现在包括 列。估计查询成本也从 1683.84 降低到 33.31。

数据类型不匹配

您需要处理的另一件事是,条件两侧使用相同的数据类型和使用相同的排序。如果不是这样,MySQL 可能无法使用索引。

当查询由于数据类型或排序规则不匹配而无法以最佳方式工作时,首先可能很难理解问题出在哪里。查询是正确的,但 MySQL 拒绝使用您期望的索引。除了查询计划不是您所期望的,查询结果也可能是错误的。这可能是由于强制转换,例如:

  1. mysql> SELECT ('a130' = 0), ('130a131' = 130);
  2. +--------------+-------------------+
  3. | ('a130' = 0) | ('130a131' = 130) |
  4. +--------------+-------------------+
  5. | 1 | 1 |
  6. +--------------+-------------------+
  7. 1 row in set, 2 warnings (0.0004 sec)

请注意,字符串”a130”如何被视为等于整数 0。这是因为字符串以非数字字符开头,因此被强制转换到值 0。同样,字符串”130a131”被视为等于整数130,因为字符串的前导数部分被转换到整数130。当将强制转换用于子句或联接条件时,也可能发生相同的意外匹配。这也是检查查询的警告有时可以帮助解决问题的情况。

如果本中考虑国家/地区表和 World 表(在讨论示例期间将显示表定义),则当使用 CountryId 列联接两个表时,可以看到不使用索引示例。清单显示了查询及其查询计划的示例。

  1. Listing 24-4. Query not using an index due to mismatching data types
  2. mysql> EXPLAIN
  3. SELECT ci.ID, ci.Name, ci.District,
  4. co.Name AS Country, ci.Population
  5. FROM chapter_24.city ci
  6. INNER JOIN chapter_24.country co
  7. USING (CountryId)
  8. WHERE co.CountryCode = 'AUS'\G
  9. *************************** 1. row ***************************
  10. id: 1
  11. select_type: SIMPLE
  12. table: co
  13. partitions: NULL
  14. type: const
  15. possible_keys: PRIMARY,CountryCode
  16. key: CountryCode
  17. key_len: 12
  18. ref: const
  19. rows: 1
  20. filtered: 100
  21. Extra: NULL
  22. *************************** 2. row ***************************
  23. id: 1
  24. select_type: SIMPLE
  25. table: ci
  26. partitions: NULL
  27. type: ALL
  28. possible_keys: CountryId
  29. key: NULL
  30. key_len: NULL
  31. ref: NULL
  32. rows: 4079
  33. filtered: 10
  34. Extra: Using where
  35. 2 rows in set, 3 warnings (0.0009 sec)
  36. Warning (code 1739): Cannot use ref access on index 'CountryId' due to type
  37. or collation conversion on field 'CountryId'
  38. Warning (code 1739): Cannot use range access on index 'CountryId' due to
  39. type or collation conversion on field 'CountryId'
  40. Note (code 1003): /* select#1 */ select `chapter_24`.`ci`.`ID` AS
  41. `ID`,`chapter_24`.`ci`.`Name` AS `Name`,`chapter_24`.`ci`.`District` AS
  42. `District`,'Australia' AS `Country`,`chapter_24`.`ci`.`Population` AS
  43. `Population` from `chapter_24`.`city` `ci` join `chapter_24`.`country` `co`
  44. where ((`chapter_24`.`ci`.`CountryId` = '15'))

请注意城市)表为。此查询既不使用块嵌套循环,也不会使用哈希联接,因为 () 表是常量。警告(如果您未启用了 MySQL Shell,则需要执行来获取警告)已包含此处,因为它们提供了有价值的提示,说明为什么无法使用索引,例如:由于字段”CountryId”上的类型或排序规则转换,无法使用的 ref 访问。因此,有一个索引是候选项,但无法使用,因为数据类型或排序规则已更改。图显示了使用可视化解释的相同查询计划。

更改查询计划 - 图5

这是需要基于文本的输出来获取所有详细信息的情况,因为 Visual Explain 不包含警告。当您看到这样的警告时,请返回并检查表定义。清单。

  1. Listing 24-5. The table definitions for the city and country tables
  2. CREATE TABLE `chapter_24`.`city` (
  3. `ID` int unsigned NOT NULL AUTO_INCREMENT,
  4. `Name` varchar(35) NOT NULL DEFAULT ",
  5. `CountryCode` char(3) NOT NULL DEFAULT ",
  6. `CountryId` char(3) NOT NULL,
  7. `District` varchar(20) NOT NULL DEFAULT ",
  8. `Population` int unsigned NOT NULL DEFAULT '0',
  9. PRIMARY KEY (`ID`),
  10. KEY `CountryCode` (`CountryCode`),
  11. KEY `CountryId` (`CountryId`)
  12. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
  13. Figure 24-5. Visual Explain where the data types do not match
  14. CREATE TABLE `chapter_24`.`country` (
  15. `CountryId` int unsigned NOT NULL AUTO_INCREMENT,
  16. `CountryCode` char(3) NOT NULL,
  17. `Name` varchar(52) NOT NULL,
  18. `Continent` enum('Asia','Europe','North America','Africa','Oceania',
  19. 'Antarctica','South America') NOT NULL DEFAULT 'Asia',
  20. `Region` varchar(26) DEFAULT NULL,
  21. PRIMARY KEY (`CountryId`),
  22. UNIQUE INDEX `CountryCode` (`CountryCode`)
  23. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

这里很明显,城市列是列是一个整数。这就是为什么在城市的表是联接中的第时,无法使用”国家/地区 Id”列。

另请注意,两个表的排序规则。城市使用排序规则(MySQL 5.7 及更早的默认排序规则),而 排序规则)。不同的字符集或排序规则甚至可以阻止查询完全执行:

  1. SELECT ci.ID, ci.Name, ci.District,
  2. co.Name AS Country, ci.Population
  3. FROM chapter_24.city ci
  4. INNER JOIN chapter_24.country co
  5. USING (CountryCode)
  6. WHERE co.CountryCode = 'AUS';
  7. ERROR: 1267: Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and
  8. (utf8mb4_0900_ai_ci,IMPLICIT) for operation '='

如果在 MySQL 8 中创建一个表,并在查询中使用它以及早期 MySQL 版本中创建的表,请注意这一点。在这种情况下,您需要确保所有表使用相同的排序规则。

数据类型不匹配的问题是在筛选器中使用函数的特殊情况,就像 MySQL 执行隐式强制转换一样。通常,在筛选器中使用函数可以防止使用索引。

功能依赖关系

不使用索引的最后个常见原因是函数应用于列,例如。在这种情况下,您需要重写条件以避免函数,或者需要添加函数索引。

如果可能,处理使用函数阻止使用索引的情况的最佳方法就是重写查询以避免该函数。虽然也可以使用功能索引,除非它有助于创建覆盖索引,否则该索引会增加开销,这可以通过重写来避免。考虑一个查询,它想要查找 1970 年出生的人的详细信息,如清单 。

  1. Listing 24-6. The person table and finding persons born in 1970
  2. mysql> SHOW CREATE TABLE chapter_24.person\G
  3. *************************** 1. row ***************************
  4. Table: person
  5. Create Table: CREATE TABLE `person` (
  6. `PersonId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  7. `FirstName` varchar(50) DEFAULT NULL,
  8. `Surname` varchar(50) DEFAULT NULL,
  9. `BirthDate` date NOT NULL,
  10. `AddressId` int(10) unsigned DEFAULT NULL,
  11. `LanguageId` int(10) unsigned DEFAULT NULL,
  12. PRIMARY KEY (`PersonId`),
  13. KEY `BirthDate` (`BirthDate`),
  14. KEY `AddressId` (`AddressId`),
  15. KEY `LanguageId` (`LanguageId`)
  16. ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4
  17. COLLATE=utf8mb4_0900_ai_ci
  18. 1 row in set (0.0012 sec)
  19. mysql> EXPLAIN
  20. SELECT *
  21. FROM chapter_24.person
  22. WHERE YEAR(BirthDate) = 1970\G
  23. *************************** 1. row ***************************
  24. id: 1
  25. select_type: SIMPLE
  26. table: person
  27. partitions: NULL
  28. type: ALL
  29. possible_keys: NULL
  30. key: NULL
  31. key_len: NULL
  32. ref: NULL
  33. rows: 1000
  34. filtered: 100
  35. Extra: Using where
  36. 1 row in set, 1 warning (0.0006 sec)

此查询使用函数来确定人出生地。另一种选择是寻找1970年1月1日至1971年12月31日(包括两天)之间出生的每个人,这等于一样。清单显示,在这种情况下,索引。

  1. Listing 24-7. Rewriting the YEAR() function to a date range condition
  2. mysql> EXPLAIN
  3. SELECT *
  4. FROM chapter_24.person
  5. WHERE BirthDate BETWEEN '1970-01-01'
  6. AND '1970-12-31'\G
  7. *************************** 1. row ***************************
  8. id: 1
  9. select_type: SIMPLE
  10. table: person
  11. partitions: NULL
  12. type: range
  13. possible_keys: BirthDate
  14. key: BirthDate
  15. key_len: 3
  16. ref: NULL
  17. rows: 6
  18. filtered: 100
  19. Extra: Using index condition
  20. 1 row in set, 1 warning (0.0009 sec)

此重写减少了查询,从使用表扫描检查 1000 行到索引范围扫描只是检查 6 行。类似的重写通常是可能的,因为函数用于有效提取一系列值的日期。

并不总是能够重写以刚才演示的方式使用函数的条件。条件不映射到单个范围,或者查询由框架或第三方应用程序生成,因此无法更改它。在这种情况下,您可以添加功能索引。

例如,考虑查找给定中所有具有生日的人员的查询,例如,因为您希望向他们发送生日问候语。原则上,可以使用范围,但它将需要每年一个范围,既不实用,也不非常有效。相反,您可以使用该月份的数值(1 月是 1 月 1 和 12 月 12 日)。清单显示了如何添加功能索引,该索引可与表中在当月有生日的每个人。

  1. Listing 24-8. Using a functional index
  2. mysql> ALTER TABLE chapter_24.person
  3. ADD INDEX ((MONTH(BirthDate)));
  4. Query OK, 0 rows affected (0.4845 sec)
  5. Records: 0 Duplicates: 0 Warnings: 0
  6. mysql> EXPLAIN
  7. SELECT *
  8. FROM chapter_24.person
  9. WHERE MONTH(BirthDate) = MONTH(NOW())\G
  10. *************************** 1. row ***************************
  11. id: 1
  12. select_type: SIMPLE
  13. table: person
  14. partitions: NULL
  15. type: ref
  16. possible_keys: functional_index
  17. key: functional_index
  18. key_len: 5
  19. ref: const
  20. rows: 88
  21. filtered: 100
  22. Extra: NULL
  23. 1 row in set, 1 warning (0.0006 sec)

添加月(计划显示使用的索引

结束关于如何添加当前不使用索引的查询的索引支持的讨论结束。还有其他几个与使用索引相关的重写。下一节将介绍这些内容。

改进索引使用

上一节考虑了未用于联接或 WHERE 子句的索引查询。在某些情况下,使用索引,但您可以改进索引,或者另一个索引提供更好的性能,或者索引由于筛选器的复杂性而无法有效地使用。本节将介绍一些改进已使用索引的查询的示例。

添加覆盖索引

在某些情况下,当您查询表时,筛选由索引执行,但随后您请求了几列,因此 MySQL 需要检索整个行。在这种情况下,将这些额外的列会更加高效,因此索引包含查询所需的所有列。

考虑数据库中的城市

创建表”城市” (

  1. CREATE TABLE `city` (
  2. `ID` int unsigned NOT NULL AUTO_INCREMENT,
  3. `Name` varchar(35) NOT NULL DEFAULT ",
  4. `CountryCode` char(3) NOT NULL DEFAULT ",
  5. `CountryId` char(3) NOT NULL,
  6. `District` varchar(20) NOT NULL DEFAULT ",
  7. `Population` int unsigned NOT NULL DEFAULT '0',
  8. PRIMARY KEY (`ID`),
  9. KEY `CountryCode` (`CountryCode`),
  10. KEY `CountryId` (`CountryId`)
  11. ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

如果要查找使用国家/地区代码 = 的所有城市的名称和区,则可以使用索引查找行。这非常有效,如清单。

  1. mysql> EXPLAIN
  2. SELECT Name, District
  3. FROM chapter_24.city
  4. WHERE CountryCode = 'USA'\G
  5. *************************** 1. row ***************************
  6. id: 1
  7. select_type: SIMPLE
  8. table: city
  9. partitions: NULL
  10. type: ref
  11. possible_keys: CountryCode
  12. key: CountryCode
  13. key_len: 12
  14. ref: const
  15. rows: 274
  16. filtered: 100
  17. Extra: NULL
  18. 1 row in set, 1 warning (0.0376 sec)

请注意,索引使用 12 个字节(每个字符最多 4 个字节宽),不包括”使用如果创建一个新索引,/地区代码”作为第列,将””名称”作为其余列,则索引中具有查询所需的所有列。选择”区域”的顺序,因为最有可能将它们与筛选器中的国家/地区,子句。如果同样可能将列用于筛选器,请选择索引名称”,因为城市名称比区域更具选择性。清单显示了此示例以及新的查询计划。

  1. Listing 24-10. Querying cities by a covering index
  2. mysql> ALTER TABLE chapter_24.city
  3. ALTER INDEX CountryCode INVISIBLE,
  4. ADD INDEX Country_District_Name
  5. (CountryCode, District, Name);
  6. Query OK, 0 rows affected (1.6630 sec)
  7. Records: 0 Duplicates: 0 Warnings: 0
  8. mysql> EXPLAIN
  9. SELECT Name, District
  10. FROM chapter_24.city
  11. WHERE CountryCode = 'USA'\G
  12. *************************** 1. row ***************************
  13. id: 1
  14. select_type: SIMPLE
  15. table: city
  16. partitions: NULL
  17. type: ref
  18. possible_keys: Country_District_Name
  19. key: Country_District_Name
  20. key_len: 12
  21. ref: const
  22. rows: 274
  23. filtered: 100
  24. Extra: Using index
  25. 1 row in set, 1 warning (0.0006 sec)

添加新索引时,仅覆盖”国家代码”列索引将不可见。这是因为新索引也可用于使用旧索引的所有用途,因此通常没有理由保留这两个索引。(鉴于”国家代码上的索引小于新索引,某些查询可能受益于旧索引。通过使其不可见,您可以在删除之前验证它不需要。

密钥长度仍返回为 12 字节,因为这就是用于筛选的字节。但是,”列现在索引”,以显示正在使用覆盖索引。

索引错误

当 MySQL多个索引之间进行选择时,优化器必须根据两个查询计划的估计成本决定使用哪个索引。由于索引统计信息和成本估算不精确,因此 MySQL 选择错误的索引可能会发生。特殊情况是,优化器选择不使用索引,即使可以使用索引,或者优化器选择使用索引,而索引执行表扫描的速度更快。无论采用哪种方式,都需要使用索引提示。

当您怀疑使用了错误的索引时,您需要查看 EXPLAIN以确定哪些索引是候选索引。清单显示了一个在2020年20岁到20岁并说英语的日本人的信息的例子。(假设您想给他们寄一张生日贺卡。树格式的输出的一部分已被省略号所取代,通过将大部分行保留到书页的宽度内,以提高可读性。

  1. Listing 24-11. Finding information about the countries where English is spoken
  2. mysql> SHOW CREATE TABLE chapter_24.person\G
  3. *************************** 1. row ***************************
  4. Table: person
  5. Create Table: CREATE TABLE `person` (
  6. `PersonId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  7. `FirstName` varchar(50) DEFAULT NULL,
  8. `Surname` varchar(50) DEFAULT NULL,
  9. `BirthDate` date NOT NULL,
  10. `AddressId` int(10) unsigned DEFAULT NULL,
  11. `LanguageId` int(10) unsigned DEFAULT NULL,
  12. PRIMARY KEY (`PersonId`),
  13. KEY `BirthDate` (`BirthDate`),
  14. KEY `AddressId` (`AddressId`),
  15. KEY `LanguageId` (`LanguageId`),
  16. KEY `functional_index` ((month(`BirthDate`)))
  17. ) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4
  18. COLLATE=utf8mb4_0900_ai_ci
  19. 1 row in set (0.0007 sec)
  20. mysql> SHOW CREATE TABLE chapter_24.address\G
  21. *************************** 1. row ***************************
  22. Table: address
  23. Create Table: CREATE TABLE `address` (
  24. `AddressId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  25. `City` varchar(35) NOT NULL,
  26. `District` varchar(20) NOT NULL,
  27. `CountryCode` char(3) NOT NULL,
  28. PRIMARY KEY (`AddressId`),
  29. KEY `CountryCode` (`CountryCode`,`District`,`City`)
  30. ) ENGINE=InnoDB AUTO_INCREMENT=4096 DEFAULT CHARSET=utf8mb4
  31. COLLATE=utf8mb4_0900_ai_ci
  32. 1 row in set (0.0007 sec)
  33. mysql> SHOW CREATE TABLE chapter_24.language\G
  34. *************************** 1. row ***************************
  35. Table: language
  36. Create Table: CREATE TABLE `language` (
  37. `LanguageId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  38. `Language` varchar(35) NOT NULL,
  39. PRIMARY KEY (`LanguageId`),
  40. KEY `Language` (`Language`)
  41. ) ENGINE=InnoDB AUTO_INCREMENT=512 DEFAULT CHARSET=utf8mb4
  42. COLLATE=utf8mb4_0900_ai_ci
  43. 1 row in set (0.0005 sec)
  44. mysql> UPDATE mysql.innodb_index_stats
  45. SET stat_value = 1000
  46. WHERE database_name = 'chapter_24'
  47. AND table_name = 'person'
  48. AND index_name = 'LanguageId'
  49. AND stat_name = 'n_diff_pfx01';
  50. Query OK, 1 row affected (0.0920 sec)
  51. Rows matched: 1 Changed: 1 Warnings: 0
  52. mysql> FLUSH TABLE chapter_24.person;
  53. Query OK, 0 rows affected (0.0686 sec)
  54. mysql> EXPLAIN
  55. SELECT PersonId, FirstName,
  56. Surname, BirthDate
  57. FROM chapter_24.person
  58. INNER JOIN chapter_24.address
  59. USING (AddressId)
  60. INNER JOIN chapter_24.language
  61. USING (LanguageId)
  62. WHERE BirthDate BETWEEN '2000-01-01'
  63. AND '2000-12-31'
  64. AND CountryCode = 'JPN'
  65. AND Language = 'English'\G
  66. *************************** 1. row ***************************
  67. id: 1
  68. select_type: SIMPLE
  69. table: language
  70. partitions: NULL
  71. type: ref
  72. possible_keys: PRIMARY,Language
  73. key: Language
  74. key_len: 142
  75. ref: const
  76. rows: 1
  77. filtered: 100
  78. Extra: Using index
  79. *************************** 2. row ***************************
  80. id: 1
  81. select_type: SIMPLE
  82. table: person
  83. partitions: NULL
  84. type: ref
  85. possible_keys: BirthDate,AddressId,LanguageId
  86. key: LanguageId
  87. key_len: 5
  88. ref: chapter_24.language.LanguageId
  89. rows: 1
  90. filtered: 5
  91. Extra: Using where
  92. *************************** 3. row ***************************
  93. id: 1
  94. select_type: SIMPLE
  95. table: address
  96. partitions: NULL
  97. type: eq_ref
  98. possible_keys: PRIMARY,CountryCode
  99. key: PRIMARY
  100. key_len: 4
  101. ref: chapter_24.person.AddressId
  102. rows: 1
  103. filtered: 6.079921722412109
  104. Extra: Using where
  105. 3 rows in set, 1 warning (0.0008 sec)
  106. mysql> EXPLAIN FORMAT=TREE
  107. SELECT PersonId, FirstName,
  108. Surname, BirthDate
  109. FROM chapter_24.person
  110. INNER JOIN chapter_24.address
  111. USING (AddressId)
  112. INNER JOIN chapter_24.language
  113. USING (LanguageId)
  114. WHERE BirthDate BETWEEN '2000-01-01'
  115. AND '2000-12-31'
  116. AND CountryCode = 'JPN'
  117. AND Language = 'English'\G
  118. *************************** 1. row ***************************
  119. EXPLAIN:
  120. -> Nested loop inner join (cost=0.72 rows=0)
  121. -> Nested loop inner join (cost=0.70 rows=0)
  122. -> Index lookup on language using Language...
  123. Chapter 24 Change the Query Plan
  124. 820
  125. -> Filter: ((person.BirthDate between '2000-01-01' and '2000-12-31')
  126. and (person.AddressId is not null))...
  127. -> Index lookup on person using LanguageId...
  128. -> Filter: (address.CountryCode = 'JPN') (cost=0.37 rows=0)
  129. -> Single-row index lookup on address using PRIMARY...
  130. 1 row in set (0.0006 sec)

此示例的关键表是表和地址表。UPDATE 和语句用于通过更新 mysql.innodb_index_stats 表统计信息生效来模拟索引统计信息已过期。

查询可以使用出生日期语言索引。三个 WHERE每个表上一个子句)的有效性非常精确地确定,因为优化器要求存储引擎计算每个条件的行数。优化器的难点在于根据联接条件的有效性以及每个联接使用的索引确定最佳联接顺序。根据,优化器选择从语言表,并使用 索引加入人员表,最后加入表。

如果您怀疑查询使用了错误的索引(在这种情况下,使用在人员的,并且之所以选择,只是因为索引统计信息”错误”),则要做的第一件事是更新索引统计信息。结果在清单。

  1. Listing 24-12. Updating the index statistics to change the query plan
  2. mysql> ANALYZE TABLE
  3. chapter_24.person,
  4. chapter_24.address,
  5. chapter_24.language;
  6. +---------------------+---------+----------+----------+
  7. | Table | Op | Msg_type | Msg_text |
  8. +---------------------+---------+----------+----------+
  9. | chapter_24.person | analyze | status | OK |
  10. | chapter_24.address | analyze | status | OK |
  11. | chapter_24.language | analyze | status | OK |
  12. +---------------------+---------+----------+----------+
  13. 3 rows in set (0.2634 sec)
  14. mysql> EXPLAIN
  15. SELECT PersonId, FirstName,
  16. Surname, BirthDate
  17. FROM chapter_24.person
  18. INNER JOIN chapter_24.address
  19. USING (AddressId)
  20. INNER JOIN chapter_24.language
  21. USING (LanguageId)
  22. WHERE BirthDate BETWEEN '2000-01-01'
  23. AND '2000-12-31'
  24. AND CountryCode = 'JPN'
  25. AND Language = 'English'\G
  26. *************************** 1. row ***************************
  27. id: 1
  28. select_type: SIMPLE
  29. table: language
  30. partitions: NULL
  31. type: ref
  32. possible_keys: PRIMARY,Language
  33. key: Language
  34. key_len: 142
  35. ref: const
  36. rows: 1
  37. filtered: 100
  38. Extra: Using index
  39. *************************** 2. row ***************************
  40. id: 1
  41. select_type: SIMPLE
  42. table: person
  43. partitions: NULL
  44. type: range
  45. possible_keys: BirthDate,AddressId,LanguageId
  46. key: BirthDate
  47. key_len: 3
  48. ref: NULL
  49. rows: 8
  50. filtered: 10
  51. Extra: Using index condition; Using where; Using join buffer (Block
  52. Nested Loop)
  53. *************************** 3. row ***************************
  54. id: 1
  55. select_type: SIMPLE
  56. table: address
  57. partitions: NULL
  58. type: eq_ref
  59. possible_keys: PRIMARY,CountryCode
  60. key: PRIMARY
  61. key_len: 4
  62. ref: chapter_24.person.AddressId
  63. rows: 1
  64. filtered: 6.079921722412109
  65. Extra: Using where
  66. 3 rows in set, 1 warning (0.0031 sec)
  67. mysql> EXPLAIN FORMAT=TREE
  68. SELECT PersonId, FirstName,
  69. Surname, BirthDate
  70. FROM chapter_24.person
  71. INNER JOIN chapter_24.address
  72. USING (AddressId)
  73. INNER JOIN chapter_24.language
  74. USING (LanguageId)
  75. WHERE BirthDate BETWEEN '2000-01-01'
  76. AND '2000-12-31'
  77. AND CountryCode = 'JPN'
  78. AND Language = 'English'\G
  79. *************************** 1. row ***************************
  80. EXPLAIN:
  81. -> Nested loop inner join (cost=7.01 rows=0)
  82. -> Inner hash join...
  83. -> Filter: (person.AddressId is not null)...
  84. -> Index range scan on person using BirthDate...
  85. -> Hash
  86. -> Index lookup on language using Language...
  87. -> Filter: (address.CountryCode = 'JPN')...
  88. -> Single-row index lookup on address using PRIMARY...
  89. 1 row in set (0.0009 sec)

这极大地改变了查询计划(仅包含树格式查询计划的一部分,以进行可读性),这是通过比较树格式查询计划最容易看到的。这些表仍按相同的顺序联接,但现在使用哈希联接来联接语言和人员表。这非常有效,因为语言表中只需要一行,因此在人员表扫描和在出生日期上筛选是不错的选择。在大多数情况下,使用错误的索引,更新索引统计信息将解决问题,可能是在更改 InnoDB 为表编制的索引潜水数之后。

在某些情况下,无法通过更新索引统计信息来解决性能问题。在这种情况下,您可以使用索引提示(忽略索引)来影响 MySQL 将使用的索引。清单显示了在将索引统计信息更改回过时后,对与以前相同的查询执行此项操作的示例。

  1. Listing 24-13. Improving the query plan using an index hint
  2. mysql> UPDATE mysql.innodb_index_stats
  3. SET stat_value = 1000
  4. WHERE database_name = 'chapter_24'
  5. AND table_name = 'person'
  6. AND index_name = 'LanguageId'
  7. AND stat_name = 'n_diff_pfx01';
  8. Query OK, 1 row affected (0.0920 sec)
  9. Rows matched: 1 Changed: 1 Warnings: 0
  10. mysql> FLUSH TABLE chapter_24.person;
  11. Query OK, 0 rows affected (0.0498 sec)
  12. mysql> EXPLAIN
  13. SELECT PersonId, FirstName,
  14. Surname, BirthDate
  15. FROM chapter_24.person USE INDEX (BirthDate)
  16. INNER JOIN chapter_24.address
  17. USING (AddressId)
  18. INNER JOIN chapter_24.language
  19. USING (LanguageId)
  20. WHERE BirthDate BETWEEN '2000-01-01'
  21. AND '2000-12-31'
  22. AND CountryCode = 'JPN'
  23. AND Language = 'English'\G
  24. *************************** 1. row ***************************
  25. id: 1
  26. select_type: SIMPLE
  27. table: language
  28. partitions: NULL
  29. type: ref
  30. possible_keys: PRIMARY,Language
  31. key: Language
  32. key_len: 142
  33. ref: const
  34. rows: 1
  35. filtered: 100
  36. Extra: Using index
  37. *************************** 2. row ***************************
  38. id: 1
  39. select_type: SIMPLE
  40. table: person
  41. partitions: NULL
  42. type: range
  43. possible_keys: BirthDate
  44. key: BirthDate
  45. key_len: 3
  46. ref: NULL
  47. rows: 8
  48. filtered: 0.625
  49. Extra: Using index condition; Using where; Using join buffer (Block
  50. Nested Loop)
  51. *************************** 3. row ***************************
  52. id: 1
  53. select_type: SIMPLE
  54. table: address
  55. partitions: NULL
  56. type: eq_ref
  57. possible_keys: PRIMARY,CountryCode
  58. key: PRIMARY
  59. key_len: 4
  60. ref: chapter_24.person.AddressId
  61. rows: 1
  62. filtered: 6.079921722412109
  63. Extra: Using where
  64. 3 rows in set, 1 warning (0.0016 sec)

这一,该表提供与更新索引统计信息时相同的查询计划。请注意,人表的可能仅包括此方法的缺点是,如果数据发生更改,优化器没有灵活性更改查询计划,索引不再是最佳索引。

此示例在人员表上有条件(出生日期的日期范围和两个联接条件)。在某些情况下,当表上有多个条件时,对查询进行一些更广泛的重写是有益的。

重写复杂的索引条件

在某些情况下,查询变得非常复杂,优化器无法想出一个好的查询计划,因此有必要重写查询。重写可以帮助在同一表上包含多个其中索引合并算法无法有效使用的示例。

请考虑以下查询:

  1. mysql> EXPLAIN FORMAT=TREE
  2. SELECT *
  3. FROM chapter_24.person
  4. WHERE BirthDate < '1930-01-01'
  5. OR AddressId = 3417\G
  6. *************************** 1. row ***************************
  7. EXPLAIN:
  8. -> Filter: ((chapter_24.person.BirthDate < DATE'1930-01-01') or
  9. (chapter_24.person.AddressId = 3417)) (cost=88.28 rows=111)
  10. -> Index range scan on person using sort_union(BirthDate,AddressId)
  11. (cost=88.28 rows=111)
  12. 1 row in set (0.0006 sec)

有”出生日期”和 索引,但没有跨两个列的索引。一种可能性是使用索引合并,如果优化器认为好处足够大,则默认情况下会选择索引合并。通常这是执行查询的首选方法,但对于某些查询(尤其是本例中的复杂),它可以帮助将这两个拆分为两个查询,并使用联合组合结果:

  1. mysql> EXPLAIN FORMAT=TREE
  2. (SELECT *
  3. FROM chapter_24.person
  4. WHERE BirthDate < '1930-01-01'
  5. ) UNION DISTINCT (
  6. SELECT *
  7. FROM chapter_24.person
  8. WHERE AddressId = 3417
  9. )\G
  10. *************************** 1. row ***************************
  11. EXPLAIN:
  12. -> Table scan on <union temporary> (cost=2.50 rows=0)
  13. -> Union materialize with deduplication
  14. -> Index range scan on person using BirthDate, with index
  15. condition: (chapter_24.person.BirthDate < DATE'1930-01-01')
  16. (cost=48.41 rows=107)
  17. -> Index lookup on person using AddressId
  18. (AddressId=3417) (cost=1.40 rows=4)
  19. 1 row in set (0.0006 sec)

联合(也是默认联合)用于确保满足这两个条件的行两次。图并排显示了两个查询计划。

更改查询计划 - 图6

左侧是使用索引合并(使用索引算法查询),右侧是手动写入联合。

重写复杂查询

优化器由 MySQL 8 添加了多个转换规则,因此它可以将查询重写为性能更好的窗体。这意味着,随着优化器知道越来越多的转换,重写复杂查询需求不断减少。例如,在 8.0.l7 版本发布之前,添加了支持以重写入”(子查询)、(子查询子”不为并且(子查询)为反子查询未为 TRUE,这意味着子查询将被删除。

也就是说,考虑如何重写查询还是不错的,因此,对于查询未到达最佳解决方案或不知道如何自己重写的情况,您可以帮助优化器进行编写。还有一些情况下,您可以利用对公共表表达式(CT+,也称为带语法)和窗口函数的支持,使查询更有效、更易于阅读。本节将开始考虑常见的表表达式和窗口函数,然后完成使用查询)重写查询到联接并使用两个查询。

通用表表达式和窗口函数

本书超出了使用常用表表达式和窗口函数的详细信息的范围。本章将包含几个示例,介绍如何使用这些功能。一个一般概述的一个很好的通用表表达式和窗口函数,由丹尼尔·巴塞洛缪揭示,。

Guilhem Bichot(在 MySQL 中实现通用表表达式的 MySQL 开发人员)还撰写了一篇博客系列,内容是首次开发该功能时,有关公共表表达式的。其他 MySQL 开发人员还就窗口函数编写了两个。

有关最新信息,最佳来源是 MySQL 参考手册。公共表表达式在 https://dev.mysql.com/doc/refman/en/with.html。窗口函数由两部分组成,根据函数是常规函数还是还包括对窗口函数的一聚合窗口函数。

通用表表达式

公共表功能允许您在查询开始时定义子查询,并使用它作为查询主要部分中的普通表。使用通用表表达式而不是内联子表达式有几个优点,包括更好的性能和可读性。更好的性能部分来自支持在查询中多次引用公共表表达式,而内部标记只能引用一次。

例如,考虑针对查询,该数据库计算处理租赁的工作人员每月的销售额:

  1. SELECT DATE_FORMAT(r.rental_date,
  2. '%Y-%m-01'
  3. ) AS FirstOfMonth,
  4. r.staff_id,
  5. SUM(p.amount) as SalesAmount
  6. FROM sakila.payment p
  7. INNER JOIN sakila.rental r
  8. USING (rental_id)
  9. GROUP BY FirstOfMonth, r.staff_id;

如果您想知道每月的销售额多少,则需要将一个月的销售额与上个月的销售额进行比较。若要在不使用公共表表达式的情况下实现此功能,您需要将查询结果存储在临时表中,或者将其复制为两个子查询。清单显示了后者的一个例子。

  1. Listing 24-14. The monthly sales and change in sales without CTEs
  2. SELECT current.staff_id,
  3. YEAR(current.FirstOfMonth) AS Year,
  4. MONTH(current.FirstOfMonth) AS Month,
  5. current.SalesAmount,
  6. (current.SalesAmount
  7. - IFNULL(prev.SalesAmount, 0)
  8. ) AS DeltaAmount
  9. FROM (
  10. SELECT DATE_FORMAT(r.rental_date,
  11. '%Y-%m-01'
  12. ) AS FirstOfMonth,
  13. r.staff_id,
  14. SUM(p.amount) as SalesAmount
  15. FROM sakila.payment p
  16. INNER JOIN sakila.rental r
  17. USING (rental_id)
  18. GROUP BY FirstOfMonth, r.staff_id
  19. ) current
  20. LEFT OUTER JOIN (
  21. SELECT DATE_FORMAT(r.rental_date,
  22. '%Y-%m-01'
  23. ) AS FirstOfMonth,
  24. r.staff_id,
  25. SUM(p.amount) as SalesAmount
  26. FROM sakila.payment p
  27. INNER JOIN sakila.rental r
  28. USING (rental_id)
  29. GROUP BY FirstOfMonth, r.staff_id
  30. ) prev ON prev.FirstOfMonth
  31. = current.FirstOfMonth
  32. - INTERVAL 1 MONTH
  33. AND prev.staff_id = current.staff_id
  34. ORDER BY current.staff_id,
  35. current.FirstOfMonth;

这很难使查询符合最容易阅读和理解的查询。这两个子会议与用于查找每个员工每月的销售额的子会议相同。通过比较同一工作人员的当前和前几个月,将两个派生表联接在一起。最后,结果由工作人员和当月排序。结果显示在清单。

  1. Listing 24-15. The result of the monthly sales query
  2. +----------+------+-------+-------------+-------------+
  3. | staff_id | Year | Month | SalesAmount | DeltaAmount |
  4. +----------+------+-------+-------------+-------------+
  5. | 1 | 2005 | 5 | 2340.42 | 2340.42 |
  6. | 1 | 2005 | 6 | 4832.37 | 2491.95 |
  7. | 1 | 2005 | 7 | 14061.58 | 9229.21 |
  8. | 1 | 2005 | 8 | 12072.08 | -1989.50 |
  9. | 1 | 2006 | 2 | 218.17 | 218.17 |
  10. | 2 | 2005 | 5 | 2483.02 | 2483.02 |
  11. | 2 | 2005 | 6 | 4797.52 | 2314.50 |
  12. | 2 | 2005 | 7 | 14307.33 | 9509.81 |
  13. | 2 | 2005 | 8 | 11998.06 | -2309.27 |
  14. | 2 | 2006 | 2 | 296.01 | 296.01 |
  15. +----------+------+-------+-------------+-------------+
  16. 10 rows in set (0.1406 sec)

从结果中可以注意到的是,2005 年 9 月至 2006 年 1 月没有销售数据。查询假定该期间的销售金额为 0。重写此查询以使用窗口函数时,将显示如何添加缺少的月份。

图显示了此版本的查询的查询计划。

更改查询计划 - 图7

查询计划显示子查询计算两次;然后,使用名为”当前”的子查询上的完整表扫描执行联接,并使用嵌套循环中的索引(和自动生成的索引)联接,以形成按文件排序排序的结果。

如果使用公共表表达式只需定义一次子查询,然后引用它两次。这样可以简化查询,使其性能更好。使用公共表表达式的查询版本显示在清单。

与monthly_sales作为 (

  1. Listing 24-16. The monthly sales and change in sales using CTE
  2. WITH monthly_sales AS (
  3. SELECT DATE_FORMAT(r.rental_date,
  4. '%Y-%m-01'
  5. ) AS FirstOfMonth,
  6. r.staff_id,
  7. SUM(p.amount) as SalesAmount
  8. FROM sakila.payment p
  9. INNER JOIN sakila.rental r
  10. USING (rental_id)
  11. GROUP BY FirstOfMonth, r.staff_id
  12. )
  13. SELECT current.staff_id,
  14. YEAR(current.FirstOfMonth) AS Year,
  15. MONTH(current.FirstOfMonth) AS Month,
  16. current.SalesAmount,
  17. (current.SalesAmount
  18. - IFNULL(prev.SalesAmount, 0)
  19. ) AS DeltaAmount
  20. FROM monthly_sales current
  21. LEFT OUTER JOIN monthly_sales prev
  22. ON prev.FirstOfMonth
  23. = current.FirstOfMonth
  24. - INTERVAL 1 MONTH
  25. AND prev.staff_id = current.staff_id
  26. ORDER BY current.staff_id,
  27. current.FirstOfMonth;

常用表表达式首先使用关键字定义,并给出名称 。然后,查询的主要部分中的表列表可以仅。查询在原始查询的一半时间中执行。另一个好处是,如果业务逻辑发生更改,您只需在一个位置更新它,从而减少了在查询中出现 Bug 的可能性。图 公共表表达式查询版本的查询计划。

更改查询计划 - 图8

查询计划显示子一次,然后作为常规表重用。否则,查询计划保持不变。

您也可以使用窗口函数解决此问题。

窗口功能

窗口函数允许您定义一个框架,其中窗口函数返回依赖于框架中其他行的值。您可以使用它生成行数和总计百分比,将行与上一行或下一行进行比较,等等。这里将探讨之前查找每月销售数字并将其与上月进行比较的示例。

可以使用函数获取上一行中的列的值。清单显示了如何使用它重写每月销售查询以使用窗口功能以及添加没有销售的月份。

  1. Listing 24-17. Combing CTEs and the LAG() window function
  2. WITH RECURSIVE
  3. month AS
  4. (SELECT MIN(DATE_FORMAT(rental_date,
  5. '%Y-%m-01'
  6. )) AS FirstOfMonth,
  7. MAX(DATE_FORMAT(rental_date,
  8. '%Y-%m-01'
  9. )) AS LastMonth
  10. FROM sakila.rental
  11. UNION
  12. SELECT FirstOfMonth + INTERVAL 1 MONTH,
  13. LastMonth
  14. FROM month
  15. WHERE FirstOfMonth < LastMonth
  16. ),
  17. staff_member AS (
  18. SELECT staff_id
  19. FROM sakila.staff
  20. ),
  21. monthly_sales AS (
  22. SELECT month.FirstOfMonth,
  23. s.staff_id,
  24. IFNULL(SUM(p.amount), 0) as SalesAmount
  25. FROM month
  26. CROSS JOIN staff_member s
  27. LEFT OUTER JOIN sakila.rental r
  28. ON r.rental_date >=
  29. month.FirstOfMonth
  30. AND r.rental_date < month.FirstOfMonth
  31. + INTERVAL 1 MONTH
  32. AND r.staff_id = s.staff_id
  33. LEFT OUTER JOIN sakila.payment p
  34. USING (rental_id)
  35. GROUP BY FirstOfMonth, s.staff_id
  36. )
  37. SELECT staff_id,
  38. YEAR(FirstOfMonth) AS Year,
  39. MONTH(FirstOfMonth) AS Month,
  40. SalesAmount,
  41. (SalesAmount
  42. - LAG(SalesAmount, 1, 0) OVER w_month
  43. ) AS DeltaAmount
  44. FROM monthly_sales
  45. WINDOW w_month AS (ORDER BY staff_id, FirstOfMonth)
  46. ORDER BY staff_id, FirstOfMonth;

这个查询起初似乎相当复杂;但是,这样做的原因是,前两个通用表表达式用于添加第一个和最后几个月之间的每个月的销售数据以及租金数据。跨产品(请注意,在月份和 staff_member 表之间如何使用显式 CROSS 来明确表 。

主查询现在变得简单,因为所需的所有信息都可以在。窗口通过按”第一个月排序,并在此 窗口函数。清单显示了结果。

  1. Listing 24-18. The result of the sales query using the LAG() function
  2. +----------+------+-------+-------------+-------------+
  3. | staff_id | Year | Month | SalesAmount | DeltaAmount |
  4. +----------+------+-------+-------------+-------------+
  5. | 1 | 2005 | 5 | 2340.42 | 2340.42 |
  6. | 1 | 2005 | 6 | 4832.37 | 2491.95 |
  7. | 1 | 2005 | 7 | 14061.58 | 9229.21 |
  8. | 1 | 2005 | 8 | 12072.08 | -1989.50 |
  9. | 1 | 2005 | 9 | 0.00 | -12072.08 |
  10. | 1 | 2005 | 10 | 0.00 | 0.00 |
  11. | 1 | 2005 | 11 | 0.00 | 0.00 |
  12. | 1 | 2005 | 12 | 0.00 | 0.00 |
  13. | 1 | 2006 | 1 | 0.00 | 0.00 |
  14. | 1 | 2006 | 2 | 218.17 | 218.17 |
  15. | 2 | 2005 | 5 | 2483.02 | 2264.85 |
  16. | 2 | 2005 | 6 | 4797.52 | 2314.50 |
  17. | 2 | 2005 | 7 | 14307.33 | 9509.81 |
  18. | 2 | 2005 | 8 | 11998.06 | -2309.27 |
  19. | 2 | 2005 | 9 | 0.00 | -11998.06 |
  20. | 2 | 2005 | 10 | 0.00 | 0.00 |
  21. | 2 | 2005 | 11 | 0.00 | 0.00 |
  22. | 2 | 2005 | 12 | 0.00 | 0.00 |
  23. | 2 | 2006 | 1 | 0.00 | 0.00 |
  24. | 2 | 2006 | 2 | 296.01 | 296.01 |
  25. +----------+------+-------+-------------+-------------+

请注意,没有销售数据的月份是如何为 0 的。

将子查询重写为联接

当您有子查询时,可以选择将子查询更改为联接。优化器通常会在可能执行此类重写,但偶尔帮助优化器进行操作会很有用。

作为示例,请考虑以下查询:

  1. SELECT *
  2. FROM chapter_24.person
  3. WHERE AddressId IN (
  4. SELECT AddressId
  5. FROM chapter_24.address
  6. WHERE CountryCode = 'AUS'
  7. AND District = 'Queensland');

此查询查找居住在澳大利亚昆士兰州的所有人员。它也可以作为人表和地址表编写:

  1. SELECT person.*
  2. FROM chapter_24.person
  3. INNER JOIN chapter_24.address
  4. USING (AddressId)
  5. WHERE CountryCode = 'AUS'
  6. AND District = 'Queensland';

事实上会进行此精确重写(除了优化器选择地址表是第一个,因为那是筛选器的位)。这是半连体优化的示例。如果遇到优化器无法重写查询的查询,可以记住这样的重写。通常,您越接近仅包含联接的查询,查询的性能越好。但是,查询调优的生命周期比这更复杂,有时相反的方式会提高查询性能。教训总是要测试。

可以使用的另一个选项是将查询拆分为多个部分,然后按步骤执行它们。

将查询拆分为零件

最后一个选项是将查询两个或多个部分。由于 MySQL 8 中对常见表表达式和窗口函数的支持,因此不需要像旧版本的 MySQL 那样频繁地进行这种重写。然而,记住这一点是有用的。

例如,考虑与之前讨论中的查询相同的查询,您可以在其中找到居住在澳大利亚昆士兰州的所有人员。您可以将子查询作为自己的查询执行,然后将结果放回。这种重写在应用程序以编程方式可以生成下一个查询的应用程序中效果最佳。为简单起见,此讨论将只显示所需的 SQL。清单显示了两个查询。

  1. Listing 24-19. Splitting a query into two steps
  2. mysql> SET SESSION transaction_isolation = 'REPEATABLE-READ';
  3. Query OK, 0 rows affected (0.0002 sec)
  4. mysql> START TRANSACTION;
  5. Query OK, 0 rows affected (0.0400 sec)
  6. mysql> SELECT AddressId
  7. FROM chapter_24.address
  8. WHERE CountryCode = 'AUS'
  9. AND District = 'Queensland';
  10. +-----------+
  11. | AddressId |
  12. +-----------+
  13. | 132 |
  14. | 143 |
  15. | 136 |
  16. | 142 |
  17. +-----------+
  18. 4 rows in set (0.0008 sec)
  19. mysql> SELECT *
  20. FROM chapter_24.person
  21. WHERE AddressId IN (132, 136, 142, 143)\G
  22. *************************** 1. row ***************************
  23. PersonId: 79
  24. FirstName: Dimitra
  25. Surname: Turner
  26. BirthDate: 1937-11-16
  27. AddressId: 132
  28. LanguageId: 110
  29. *************************** 2. row ***************************
  30. PersonId: 356
  31. FirstName: Julian
  32. Surname: Serrano
  33. BirthDate: 2017-07-30
  34. AddressId: 132
  35. LanguageId: 110
  36. 2 rows in set (0.0005 sec)
  37. mysql> COMMIT;
  38. Query OK, 0 rows affected (0.0003 sec)

使用具有事务隔离级别的事务执行查询,这意味着两个 查询将使用相同的读取视图,从而以与执行问题为一个查询的方式对应于相同的时间点。对于这么简单的查询,使用多个查询不会获得任何收益;但是,对于非常复杂的查询,拆分部分查询(可能包括一些联接)可能是一种优势。将查询拆分为多个部分的一个好处还包括在某些情况下,您可以提高缓存效率。对于此示例,如果您有其他查询使用相同的子查询来查找昆士兰州的地址,缓存可以允许您将结果重新用于多种用途。

队列系统:跳过锁定

与数据库相关的常见任务是处理存储在队列中的一些任务列表。例如,处理商店的订单。处理所有任务并且只处理一次非常重要,但哪个应用程序线程处理每个任务并不重要。跳过子句非常适合此类方案。

考虑如 。

  1. Listing 24-20. The jobqueue table and data
  2. mysql> SHOW CREATE TABLE chapter_24.jobqueue\G
  3. *************************** 1. row ***************************
  4. Table: jobqueue
  5. Create Table: CREATE TABLE `jobqueue` (
  6. `JobId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  7. `SubmitDate` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  8. `HandledDate` datetime DEFAULT NULL,
  9. PRIMARY KEY (`JobId`),
  10. KEY `HandledDate` (`HandledDate`,`SubmitDate`)
  11. ) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4
  12. COLLATE=utf8mb4_0900_ai_ci
  13. 1 row in set (0.0004 sec)
  14. mysql> SELECT *
  15. FROM chapter_24.jobqueue;
  16. +-------+---------------------+-------------+
  17. | JobId | SubmitDate | HandledDate |
  18. +-------+---------------------+-------------+
  19. | 1 | 2019-07-01 19:32:30 | NULL |
  20. | 2 | 2019-07-01 19:32:33 | NULL |
  21. | 3 | 2019-07-01 19:33:40 | NULL |
  22. | 4 | 2019-07-01 19:35:12 | NULL |
  23. | 5 | 2019-07-01 19:40:24 | NULL |
  24. | 6 | 2019-07-01 19:40:28 | NULL |
  25. +-------+---------------------+-------------+
  26. 6 rows in set (0.0005 sec)

当日期为时,任务尚未处理,并且是抓取的。如果应用程序设置为获取最早的未处理任务,并且希望依赖 InnoDB 行锁来防止两个线程执行相同的任务,那么您可以使用对于 UPDATE,(在现实世界中,语句将是较大事务的一部分):

  1. SELECT JobId
  2. FROM chapter_24.jobqueue
  3. WHERE HandledDate IS NULL
  4. ORDER BY SubmitDate
  5. LIMIT 1
  6. FOR UPDATE;

这适用于第一个请求,但下一个请求将阻塞,直到发生锁定等待超时或处理第一个任务,因此任务处理被序列化。诀窍是确保筛选和排序的列上有一个索引,然后使用 SKIP 句。然后,第二个连接将跳过锁定的行,并找到满足搜索条件的第一个未锁定行。清单显示了两个连接的示例,每个连接从获取作业。

  1. Listing 24-21. Fetching tasks with SKIP LOCKED
  2. Connection 1> START TRANSACTION;
  3. Query OK, 0 rows affected (0.0002 sec)
  4. Connection 1> SELECT JobId
  5. FROM chapter_24.jobqueue
  6. WHERE HandledDate IS NULL
  7. ORDER BY SubmitDate
  8. LIMIT 1
  9. FOR UPDATE
  10. SKIP LOCKED;
  11. +-------+
  12. | JobId |
  13. +-------+
  14. | 1 |
  15. +-------+
  16. 1 row in set (0.0004 sec)
  17. Connection 2> START TRANSACTION;
  18. Query OK, 0 rows affected (0.0003 sec)
  19. Connection 2> SELECT JobId
  20. FROM chapter_24.jobqueue
  21. WHERE HandledDate IS NULL
  22. ORDER BY SubmitDate
  23. LIMIT 1
  24. FOR UPDATE
  25. SKIP LOCKED;
  26. +-------+
  27. | JobId |
  28. +-------+
  29. | 2 |
  30. +-------+
  31. 1 row in set (0.0094 sec)

现在,这两个提取任务并同时处理它们。任务完成后,可以将任务标记为已完成。与连接设置的锁列相比,此方法的优点是,如果连接由于某种原因失败,则将自动释放锁。

您可以使用性能的”模型”表来查看哪个连接具有每个锁(锁的顺序取决于线程 ID,而线程 ID 将有所不同):

  1. mysql> SELECT THREAD_ID, INDEX_NAME, LOCK_DATA
  2. FROM performance_schema.data_locks
  3. WHERE OBJECT_SCHEMA = 'chapter_24'
  4. AND OBJECT_NAME = 'jobqueue'
  5. AND LOCK_TYPE = 'RECORD'
  6. ORDER BY THREAD_ID, EVENT_ID;
  7. +-----------+------------+-----------------------+
  8. | THREAD_ID | INDEX_NAME | LOCK_DATA |
  9. +-----------+------------+-----------------------+
  10. | 21705 | PRIMARY | 1 |
  11. | 21705 | SubmitDate | NULL, 0x99A383381E, 1 |
  12. | 25101 | PRIMARY | 2 |
  13. | 25101 | SubmitDate | NULL, 0x99A3833821, 2 |
  14. +-----------+------------+-----------------------+
  15. 4 rows in set (0.0008 sec)

十六进制值是”提交日期”列的值。从输出中可以看到,每个连接在辅助索引中保留一个记录锁,在主键中保留一个记录锁,与值一样。

许多或或或在条件

在性能方面可能导致的查询类型是具有许多范围条件的查询。当存在许多 OR 条件或运算符具有许多这通常是个问题。在某些情况下,对条件进行小更改可能会完全更改查询计划。

当优化器在索引列上遇到范围条件时,它有两个选项:它可以假定索引中的所有值都同时频繁发生,或者它可以要求存储引擎执行索引跳加以确定每个范围的频率。前者是最便宜的,但后者要准确到远。要决定使用哪种方法,请使用默认值为 200)。如果存在范围,则优化器将只查看索引的基数,并假设所有值都以相同的频率发生。如果范围较少,将要求每个范围存储引擎。

当假设每个值发生同样频繁时,可能会出现性能问题。在这种情况下,当通过,与条件匹配的估计行数可能会突然发生显著变化,从而导致完全不同的查询计划。(如果值,真正重要的是与包含的值匹配的行数与从索引统计信息中获得的估计值接近。因此,列表中的值越多,包含代表性示例的可能性就越小。

清单显示了具有的”联系人 Id”表的示例。大多数行的设置为索引的基数为 21。

  1. Listing 24-22. Query with many range conditions
  2. mysql> SHOW CREATE TABLE chapter_24.payment\G
  3. *************************** 1. row ***************************
  4. Table: payment
  5. Create Table: CREATE TABLE `payment` (
  6. `PaymentId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  7. `Amount` decimal(5,2) NOT NULL,
  8. `ContactId` int(10) unsigned DEFAULT NULL,
  9. PRIMARY KEY (`PaymentId`),
  10. KEY `ContactId` (`ContactId`)
  11. ) ENGINE=InnoDB AUTO_INCREMENT=32798 DEFAULT CHARSET=utf8mb4
  12. COLLATE=utf8mb4_0900_ai_ci
  13. 1 row in set (0.0004 sec)
  14. mysql> SELECT COUNT(ContactId), COUNT(*)
  15. FROM chapter_24.payment;
  16. +------------------+----------+
  17. | COUNT(ContactId) | COUNT(*) |
  18. +------------------+----------+
  19. | 20 | 20000 |
  20. +------------------+----------+
  21. 1 row in set (0.0060 sec)
  22. mysql> SELECT CARDINALITY
  23. FROM information_schema.STATISTICS
  24. WHERE TABLE_SCHEMA = 'chapter_24'
  25. AND TABLE_NAME = 'payment'
  26. AND INDEX_NAME = 'ContactId';
  27. +-------------+
  28. | CARDINALITY |
  29. +-------------+
  30. | 21 |
  31. +-------------+
  32. 1 row in set (0.0009 sec)
  33. mysql> SET SESSION eq_range_index_dive_limit=5;
  34. Query OK, 0 rows affected (0.0003 sec)
  35. mysql> EXPLAIN
  36. SELECT *
  37. FROM chapter_24.payment
  38. WHERE ContactId IN (1, 2, 3, 4)\G
  39. *************************** 1. row ***************************
  40. id: 1
  41. select_type: SIMPLE
  42. table: payment
  43. partitions: NULL
  44. type: range
  45. possible_keys: ContactId
  46. key: ContactId
  47. key_len: 5
  48. ref: NULL
  49. rows: 4
  50. filtered: 100
  51. Extra: Using index condition
  52. 1 row in set, 1 warning (0.0006 sec)

在示例中设置为 5,以避免需要指定一长串值。使用四个值,优化器已请求四个值中每个值的统计信息,估计行数为 4。但是,如果将值列表变长,则情况开始发生变化:

  1. mysql> EXPLAIN
  2. SELECT *
  3. FROM chapter_24.payment
  4. WHERE ContactId IN (1, 2, 3, 4, 5)\G
  5. *************************** 1. row ***************************
  6. ...
  7. key: ContactId
  8. key_len: 5
  9. ref: NULL
  10. rows: 4785
  11. ...

突然之间,估计匹配了 4785 行,而不是真正匹配的五行。索引仍在使用,但如果具有此条件的付款表涉及联接,则优化器很可能选择非优联接顺序。如果使值列表更长,优化器将完全停止使用索引,并执行完整的表扫描,因为它认为索引工作非常可怕:

  1. mysql> EXPLAIN
  2. SELECT *
  3. FROM chapter_24.payment
  4. WHERE ContactId IN (1, 2, 3, 4, 5, 6, 7)\G
  5. *************************** 1. row ***************************
  6. ...
  7. type: ALL
  8. possible_keys: ContactId
  9. key: NULL
  10. ...
  11. rows: 20107
  12. ...

此查询仅返回七行,因此索引具有高度选择性。那么,可以做些什么来提高优化器的理解呢?根据估计结果的确切性质,有各种可能的行动。对于此特定问题,您有以下选项:

  • 增加。
  • 更改选项。
  • 强制 MySQL 使用索引。

最简单的解决办法是默认值为 200,这是一个很好的起点。如果您有候选查询,可以使用测试,并确定执行索引潜水的附加成本是否值得通过获得更好的行估计来节省成本。测试查询的新值,在”提示中设置该值:

  1. SELECT /*+ SET_VAR(eq_range_index_dive_limit=8) */
  2. *
  3. FROM chapter_24.payment
  4. WHERE ContactId IN (1, 2, 3, 4, 5, 6, 7);

在这种情况下,依赖基数导致如此糟糕的行估计的原因是,几乎所有行都将 。默认情况下,InnoDB 认为索引的值的所有行具有相同的值。这就是为什么在这个例子中,基数只有21。如果将基数将仅根据清单中所示的非 NULL 值计算基数。

  1. Listing 24-23. Using innodb_stats_method = nulls_ignored
  2. mysql> SET GLOBAL innodb_stats_method = nulls_ignored;
  3. Query OK, 0 rows affected (0.0003 sec)
  4. mysql> ANALYZE TABLE chapter_24.payment;
  5. +--------------------+---------+----------+----------+
  6. | Table | Op | Msg_type | Msg_text |
  7. +--------------------+---------+----------+----------+
  8. | chapter_24.payment | analyze | status | OK |
  9. +--------------------+---------+----------+----------+
  10. 1 row in set (0.1411 sec)
  11. mysql> SELECT CARDINALITY
  12. FROM information_schema.STATISTICS
  13. WHERE TABLE_SCHEMA = 'chapter_24'
  14. AND TABLE_NAME = 'payment'
  15. AND INDEX_NAME = 'ContactId';
  16. +-------------+
  17. | CARDINALITY |
  18. +-------------+
  19. | 20107 |
  20. +-------------+
  21. 1 row in set (0.0009 sec)
  22. mysql> EXPLAIN
  23. SELECT *
  24. FROM chapter_24.payment
  25. WHERE ContactId IN (1, 2, 3, 4, 5, 6, 7)\G
  26. *************************** 1. row ***************************
  27. id: 1
  28. select_type: SIMPLE
  29. table: payment
  30. partitions: NULL
  31. type: range
  32. possible_keys: ContactId
  33. key: ContactId
  34. key_len: 5
  35. ref: NULL
  36. rows: 7
  37. filtered: 100
  38. Extra: Using index condition
  39. 1 row in set, 1 warning (0.0011 sec)

此方法的最大问题是全局设置,因此会影响所有表,并且可能对其他查询产生负面影响。对于此示例,将返回默认值,然后重新计算索引统计信息:

  1. mysql> SET GLOBAL innodb_stats_method = DEFAULT;
  2. Query OK, 0 rows affected (0.0004 sec)
  3. mysql> SELECT @@global.innodb_stats_method\G
  4. *************************** 1. row ***************************
  5. @@global.innodb_stats_method: nulls_equal
  6. 1 row in set (0.0003 sec)
  7. mysql> ANALYZE TABLE chapter_24.payment;
  8. +--------------------+---------+----------+----------+
  9. | Table | Op | Msg_type | Msg_text |
  10. +--------------------+---------+----------+----------+
  11. | chapter_24.payment | analyze | status | OK |
  12. +--------------------+---------+----------+----------+
  13. 1 row in set (0.6683 sec)

最后一个选项是使用索引提示强制 MySQL 使用索引。您将需要力,如清单。

  1. Listing 24-24. Using FORCE INDEX to force MySQL to use the index
  2. mysql> EXPLAIN
  3. SELECT *
  4. FROM chapter_24.payment FORCE INDEX (ContactId)
  5. WHERE ContactId IN (1, 2, 3, 4, 5, 6, 7)\G
  6. *************************** 1. row ***************************
  7. id: 1
  8. select_type: SIMPLE
  9. table: payment
  10. partitions: NULL
  11. type: range
  12. possible_keys: ContactId
  13. key: ContactId
  14. key_len: 5
  15. ref: NULL
  16. rows: 6699
  17. filtered: 100
  18. Extra: Using index condition
  19. 1 row in set, 1 warning (0.0007 sec)

这将使查询执行得和拥有更准确的统计信息一样快。但是,如果是具有相同 WHERE 子句一部分,则行估计值仍然关闭(估计为 6699 行,与 7 个实际行相比),因此查询计划可能仍然出错,在这种情况下,您需要告诉优化器最佳联接顺序是什么。

总结

本章展示了几种提高查询性能的技术示例。第一个主题是查看过度完整表扫描的症状,然后查看完整表扫描的两个主要原因:查询错误,索引无法使用。不能使用索引的典型原因是使用的列不构成索引的左前缀、数据类型不匹配或在列上使用函数。

它也可能发生使用索引,但使用可以改进。这可以是将索引转换为覆盖查询所需的所有列、使用错误的索引,或者使用复杂条件重写查询可以改进查询计划。

重写复杂查询也很有用。MySQL 8 支持通用表表达式和窗口函数,这些函数可用于简化查询并可能使其性能更好。在其他情况下,它可以帮助执行优化器通常执行的某些重写操作,或将查询拆分为多个部分。

最后,讨论了两个常见案例。第一个队列是使用一个队列,子句可用于有效地访问第一个未锁定的行。第二种是长串 OR 条件或具有许多值的当范围数达到 eq_range_index_dive_limit 选项设置的数字时,。

下一章将介绍提高 DDL 和批量数据负载的性能。