MySQL8-查询性能调优教程-全-

MySQL8 查询性能调优教程(全)

原文:MySQL 8 Query Performance Tuning

协议:CC BY-NC-SA 4.0

一、MySQL 性能调优

欢迎来到 MySQL 性能调优的世界。这是一个有时似乎被黑魔法和运气主宰的世界,但希望这本书可以帮助你以一种结构化的方式有条不紊地工作,以达到更好的表现。

本章通过讨论整个堆栈以及监控和基于数据采取行动的重要性,向您介绍了 MySQL 性能调优。因为这本书主要是关于使用查询,所以在结束本章之前回顾一下查询的生命周期。

Tip

如果您需要一个测试实例,无论是在阅读本书时还是在工作中解决问题时,云都可以成为您的朋友。它允许您快速启动一个测试实例。如果你只是需要一个小的实例,例如,探索本书中的例子,你甚至可以使用免费的实例,例如通过甲骨文云的免费层(仍然需要注册和信用卡): https://mysql.wisborg.dk/oracle_cloude_free_tier

考虑整个堆栈

当您研究性能问题时,重要的是您要考虑系统的所有部分,从最终用户到应用再到 MySQL。当有人报告说应用很慢,而你知道 MySQL 是应用的核心部分,那么就很容易得出“MySQL 很慢”的结论。然而,这将排除性能不佳的大量潜在原因。

当应用需要查询的结果或者需要在 MySQL 中存储数据时,它通过网络向 MySQL 发送请求,为了执行请求,MySQL 与操作系统进行交互,并使用内存和磁盘等主机资源。一旦请求的结果准备好了,就会通过网络传回给应用。如图 1-1 所示。

img/484666_1_En_1_Fig1_HTML.jpg

图 1-1

围绕 MySQL 的堆栈

金字塔是一个非常简化的图形,它忽略了应用之外的一切,而应用可能会与用户通信并使用自己的资源。通过网络通信还涉及主机和操作系统。

为了说明这些层如何交互,考虑一个真实世界的例子。一个 MySQL 用户报告了 MySQL 遇到暂时停止的问题。使用 Linux 上的perf工具进行的一项调查显示,出现停顿是因为内存碎片过多,这主要是由 I/O 缓存引起的。当您通过网络提交数据时,Linux 请求一块连续的内存(使用kmalloc),但是由于严重的内存碎片,Linux 必须首先对内存进行碎片整理(压缩)。当这种压缩发生时,包括 MySQL 在内的所有东西都停止了,而且在最糟糕的情况下,这需要一分钟(服务器有大量内存可用于 I/O 缓存),这导致了严重的影响。在这种情况下,将 MySQL 配置更改为使用直接 I/O 解决了这个问题。虽然这是一个极端的情况,但是值得记住的是,交互可能会导致令人惊讶的拥塞点。

一个更简单的真实例子是一个使用框架生成查询的应用。框架中有一个 bug,这意味着针对大型表的查询省略了一个WHERE子句。这意味着一连串的问题,包括应用重试查询,并在几秒钟内完成 50 个查询副本(因为数据最终被读入缓冲池,使得最后一个查询的执行速度比第一个查询快得多),以及将大量数据发送回应用,导致网络过载和应用耗尽内存。

这本书主要关注 MySQL 和影响查询的方面,但是不要忘记系统的其他部分。这包括当你监控你的系统。

监控

如果你从这本书里只带走了一样东西,那么就让它成为监控对于保持一个健康的系统是至关重要的。你所做的一切都应该围绕着监控。在某些情况下,通过专用监控解决方案进行监控可以提供您需要的所有数据,而在其他情况下,您需要进行特别的观察。

您的监控应该使用几个信息源。这些包括但不限于

  • 性能模式包括从低级互斥到查询和事务度量的信息。这是查询性能调优的最重要的信息源。sys模式提供了一个方便的接口,特别是对于特定的查询。

  • 信息模式,包括模式信息、InnoDB 统计信息等等。

  • SHOW语句,例如,包括来自 InnoDB 的信息以及详细的引擎统计数据。

  • 慢速查询日志,可以记录符合特定条件的查询,例如耗时超过预定义的阈值。

  • 返回查询执行计划的EXPLAIN语句。这是一个非常有价值的工具,可以用来研究为什么一个查询由于缺少索引、查询以次优的方式编写或者 MySQL 选择了次优的方式来执行查询而表现不佳。在调查特定的查询时,EXPLAIN语句通常以特别的方式使用。

  • 操作系统指标,如磁盘利用率、内存利用率和网络利用率。不要忘记一些简单的指标,如可用存储量,因为存储空间不足会导致停机。

这些信息源在本书中都有讨论和使用。

当您在整个性能调优过程中使用监控时,您可以验证问题是什么,找到原因,并证明您已经解决了问题。在处理解决方案时,了解查询的生命周期也很有用。

查询的生命周期

当您执行一个查询时,在查询结果返回到应用或客户端之前,它会经历几个步骤。每一步都需要时间,并且本身可能是由几个子部分组成的复杂操作。

在图 1-2 中可以看到查询生命周期的简化概述。在实践中,涉及到更多的步骤,如果你安装插件,如查询重写器,它会添加自己的步骤。然而,该图确实涵盖了基本步骤,其中几个步骤将在后面更详细地介绍。

img/484666_1_En_1_Fig2_HTML.png

图 1-2

基本查询生命周期

MySQL 服务器可以分为两层。例如,SQL 层处理连接并为执行准备语句。实际数据是由存储引擎存储的,这些存储引擎是作为插件实现的,这使得实现不同的数据处理方式变得相对容易。主要的存储引擎——也是本书将考虑的唯一一个——是 InnoDB,它是完全事务性的,对高并发性工作负载有很好的支持。另一个存储引擎的例子是 NDBCluster,它也是事务性的,是 MySQL NDB 集群的一部分。

当应用需要执行一个查询时,首先要做的是创建一个连接(图中没有包括这个连接,因为这个连接可以被重用来执行更多的查询)。当查询到达时,MySQL 解析它。这包括将查询拆分成标记,因此查询类型是已知的,并且存在查询所需的表和列的列表。在下一步检查用户是否有执行查询的必要权限时,需要这个列表。

此时,查询已经到了决定如何执行查询的重要步骤。这是优化器的工作,包括重写查询以及确定访问表的顺序和使用哪些索引。

实际的执行步骤包括从存储引擎层请求数据。存储引擎本身可能很复杂。对于 InnoDB,它包括一个缓冲池,用于缓存数据和索引、重做和撤消日志、其他缓冲区以及表空间文件。如果查询返回行,这些行将通过 SQL 层从存储引擎发送回应用。

在查询调优中,最重要的步骤是优化器和执行步骤,包括存储引擎。本书中的大部分信息都与这三个部分直接或间接相关。

摘要

本章只触及了性能调优的皮毛,并为您准备了本书其余部分的旅程。关键要点是,您需要考虑从最终用户到主机和操作系统的底层细节的整个体系,并且在性能调优中,监控是绝对必要的。执行查询包括几个步骤,其中优化器和执行步骤是您将在本书中学到最多的。

下一章将更仔细地研究一种对解决性能问题有用的方法。

二、查询调优方法

解决问题有几种方法。在极端情况下,你可以一头扎进去,尝试做一些改变。虽然这看起来像是节省时间,但更多的时候,它只会导致沮丧,即使当改变看起来起作用时,您也不能确定您是否真正解决了根本问题,或者问题只是暂时变好了。

相反,建议从方法上进行工作,通过分析和使用监控来确认变更的效果。本章将向您介绍一种在解决 MySQL 问题时非常有用的方法,重点是性能调优。首先介绍该方法中的步骤。然后,本章的其余部分将更详细地讨论每个步骤,以及为什么花尽可能多的时间积极工作是重要的。

Note

此处描述的方法基于 Oracle 技术支持中用于解决客户报告的问题的方法。

概观

MySQL 性能调优可以被视为一个永无止境的过程,在这个过程中,随着时间的推移,使用迭代方法来逐步提高性能。显然,有时会出现像查询这样的特定问题,需要半个小时才能完成,但是一定要记住,性能不是二元状态,因此有必要知道什么是足够好的性能。否则,你将永远无法完成哪怕一项任务。

2-1 显示了如何描述性能调整生命周期的示例。循环从左上角开始,由四个阶段组成,其中第一个阶段是验证问题。

img/484666_1_En_2_Fig1_HTML.jpg

图 2-1

性能调整生命周期

当您遇到性能问题时,第一阶段是验证问题是什么,包括收集问题的证据,并定义认为问题得到解决的要求是什么。

第二阶段是确定性能问题的原因,第三阶段是确定解决方案。最后,在第四阶段,您实施解决方案。解决方案的实施应包括验证更改的效果。

Tip

这种循环既适用于危机期间的救火工作,也适用于积极主动的工作。

然后,您就可以从头开始了,要么进行第二次迭代来进一步提高您刚才看到的问题的性能,要么您可能需要处理第二个问题。也有可能在两个周期之间会有一段很长的时间。

验证问题

在你试图确定问题的原因和解决方案之前,重要的是你要清楚你要解决的是什么问题。仅仅说“MySQL 很慢”是不够的——这是什么意思?一个具体的问题可能是“前端网页的第二部分中使用的查询需要 5 秒钟”或者“MySQL 每秒只能支持 5000 个事务”你越具体,解决问题的机会就越大。

问题的定义还应该包括验证问题是什么。问题最初看起来是什么和真正的问题是什么是有区别的。验证问题可能很简单,只需执行一个查询并观察查询是否真的像声称的那样耗时,或者可能涉及到检查您的监控。

准备工作还应该包括从您的监控中收集基线,或者运行说明问题的数据收集。没有基线,您可能无法在故障诊断结束时证明您已经解决了问题。

最后,您需要决定性能调优的目标是什么。引用斯蒂芬·R·科维

高效人士的 7 个习惯从心里的目的开始。

慢速查询运行速度的最低可接受目标是什么,或者所需的最小事务吞吐量是多少?这将确保你在做出改变时知道目标是否已经达到。

当问题得到明确定义和验证后,您可以开始分析问题并确定原因。

确定原因

第二阶段是确定性能不佳的原因。确保你思想开放,考虑整个问题,这样你就不会在某个方面看不到自己,而这个方面与问题没有任何关系。

当你认为你知道原因时,你还需要论证为什么那是原因。您可能有一个EXPLAIN语句的输出,清楚地显示查询执行了全表扫描,因此这可能是原因,或者您可能有一个图形显示 InnoDB 重做日志已满 75%,因此您可能有一个异步刷新,导致临时性能问题。

找到原因通常是调查中最困难的部分。一旦知道了原因,你就可以决定一个解决方案。

确定解决方案

为您调查的问题确定解决方案需要两个步骤。第一步是找到可能的解决方案;第二,你必须选择实现哪一个。

当你寻找可能的解决方案时,做一次头脑风暴会很有用,在头脑风暴中你可以写下你能想到的所有想法。重要的是,你不要强迫自己只考虑一个狭窄的领域,这个领域的根本原因可能经常在不同的领域找到解决方案。一个例子是上一章提到的由于内存碎片造成的延迟,解决方案是改变 MySQL 的配置,使用直接 I/O 来减少操作系统 I/O 缓存的使用。您还应该记住短期解决方案和长期解决方案,因为如果需要重新启动或升级 MySQL、更换硬件或类似的事情,并不总是能够立即实现完整的解决方案。

Tip

一个有时不受重视的解决方案是升级 MySQL 或操作系统以获得新功能。但是,您当然需要进行仔细的测试,以验证您的应用在新版本中运行良好,特别要注意优化器是否有任何导致查询性能下降的更改。

确定解决方案的第二部分是选择最有效的候选解决方案。为了做到这一点,你必须为每一个解决方案辩护,为什么它有效,利弊是什么。在这一步中,重要的是对自己诚实,并仔细考虑可能的副作用。

一旦你对所有可能的解决方案有了很好的理解,你就可以选择哪一个来进行。您也可以选择一个解决方案作为临时缓解措施,同时开发一个更可靠的解决方案。无论哪种情况,下一阶段都是实现解决方案。

实施解决方案

您通过一系列步骤来实现解决方案,在这些步骤中,您定义行动计划、测试行动计划、优化行动计划等等,直到您最终将解决方案应用到您的生产系统。重要的是不要仓促行事,因为这是发现解决方案问题的最后机会。在某些情况下,测试还可能表明您需要放弃该解决方案,并返回到前一阶段,选择一个不同的解决方案。图 2-2 说明了实施解决方案的工作流程。

img/484666_1_En_2_Fig2_HTML.png

图 2-2

实施解决方案的工作流程

你选择了解决方案,并为其制定了行动计划。在这里,非常具体是很重要的,这样您就可以确保您测试的行动计划也是您最终在生产系统上应用的计划。写下将要使用的确切命令和语句会很有用,这样您就可以复制和粘贴它们,或者将它们收集到脚本中,这样它们就可以自动应用。

然后,您需要在测试系统上测试行动计划。重要的是,它尽可能地反映生产情况。测试系统上的数据必须代表您的生产数据。实现这一点的一种方法是复制生产数据,还可以选择使用数据屏蔽来避免将个人详细信息和信用卡信息等敏感信息复制到生产系统之外。

Tip

MySQL 企业版订阅(付费订阅)包含一个数据屏蔽特性: www.mysql.com/products/enterprise/masking.html

测试应该验证解决方案解决了问题,并且没有意外的副作用。需要什么样的测试取决于您试图解决的问题和建议的解决方案。如果您有一个缓慢的查询,它涉及到在实现解决方案之后测试查询的性能。如果修改一个或多个表上的索引,还必须验证这会如何影响其他查询。在实现解决方案之后,您可能还需要对系统进行基准测试。在所有情况下,您都需要与您在问题验证期间收集的基线进行比较。

第一次尝试可能不像预期的那样成功。通常,只需要对行动计划进行一些改进,其他时候,您可能必须完全放弃提议的解决方案,并返回到上一阶段,选择另一个解决方案。如果建议的解决方案部分解决了问题,您也可以选择将它应用到生产系统中,并从头开始评估如何继续改进性能。

当您对测试显示解决方案有效感到满意时,您可以将它应用到试运行系统,如果一切仍然有效,还可以应用到生产系统。一旦你这样做了,你需要再次验证它的工作。无论您在建立一个代表生产系统的测试系统时有多小心,由于这样或那样的原因,解决方案都有可能在生产中不能完全按照预期工作。本书作者遇到的一种可能性是,本质上随机的索引统计是不同的,因此在生产系统上应用解决方案时,更新索引统计的ANALYZE TABLE语句是必要的。

如果该解决方案有效,您应该收集一个新的基线,用于将来的监控和优化。如果该解决方案证明不起作用,您需要决定如何继续,要么回滚更改并寻找新的解决方案,要么进行新一轮的故障排除并确定该解决方案不起作用的原因并应用第二个解决方案。

主动工作

性能调优是一个永无止境的过程。如果你有一个基本上健康的系统,大部分的工作将是积极主动的,在你工作的地方预防紧急情况,在那里紧急程度相对较低。这不会给你的工作带来很多关注,但会让你的日常生活压力更小,用户会更开心。

Note

这个讨论在某种程度上是基于斯蒂芬·R·科维的《高效能人士的 7 个习惯》中的习惯 3“把重要的事情放在第一位”。

2-3 展示了如何将你的任务按照紧急程度和重要性进行分类。紧急任务通常会引起其他人的注意,而其他任务可能很重要,但只有在没有及时完成的情况下才会显现出来,所以它们会突然变得紧急。

img/484666_1_En_2_Fig3_HTML.png

图 2-3

根据紧急程度和重要性对任务进行分类

最容易分类的任务是那些与危机相关的任务,如生产系统停机,公司收入损失,因为客户无法使用产品或进行购买。这些任务既紧迫又重要。在这些任务上花费大量时间可能会让你觉得自己很重要,但这也是一种非常有压力的工作方式。

处理性能问题最有效的方法是处理重要但不紧急的问题。这是预防危机发生的主动工作,包括监控、在问题变得明显之前进行改进等等。这一类的一个重要任务也是准备,所以你准备好处理危机。例如,这可能是建立一个备用系统,在发生危机时可以故障转移到该系统,或者建立快速启动替换实例的过程。这有助于缩短危机的持续时间,使其回到重要但不那么紧急的类别。你花在这一类任务上的时间越多,通常你就越成功。

最后两类包括不太重要的任务。紧急但不重要的任务包括你无法重新安排的会议、其他人推掉的任务以及感觉到的(但不是真实的)危机。不紧急和不重要的任务包括行政任务和检查电子邮件。当然,这些任务中的一些对于你保住工作可能是必需的,也是重要的,但是对于保持 MySQL 的良好运行却并不重要。虽然这些类别中总会有必须处理的任务,但是最大限度地减少在这里花费的时间是很重要的。

避免处理不重要的任务的一部分包括您理解任务的重要性,例如,通过定义何时性能足够好,这样您就不会过度优化查询或吞吐量。在实践中,如果不重要的任务引起了组织中其他人的注意(这些往往是紧急的任务),当然很难将它们推后,但重要的是,你要尽可能地将工作转移到重要但不紧急的任务上,以避免危机任务在以后接手。

摘要

本章讨论了一种可以用来解决 MySQL 性能问题(以及其他类型的问题)的方法。)以及积极工作的重要性。

当报告一个问题时,您开始验证问题是什么,并确定什么被认为已经解决了它。对于本质上开放式的绩效问题,知道什么是足够好是很重要的,否则你将冒着永远无法停止执行危机管理并回到主动工作的风险。

一旦你有了一个清晰的问题描述,你就可以着手确定原因;而一旦明确了原因,你就可以确定你想做什么来解决问题。最后一个阶段是实施解决方案,如果您最初选择的解决方案不起作用或者有不可接受的副作用,您可能需要重新考虑潜在的解决方案。因此,在尽可能真实的环境中测试解决方案非常重要。

本章的最后一部分讨论了花尽可能多的时间做积极主动的工作的重要性,这可以防止危机的发生,并帮助你在危机发生时做好准备。这将帮助你有一份压力较小的工作,并以更健康的状态管理数据库。

正如本章所讨论的,在将您的解决方案部署到您的生产系统之前,测试它的影响是非常重要的。下一章将介绍基准测试,重点是 Sysbench 基准测试。

三、Sysbench 基准测试

在将更改应用到生产系统之前,验证更改的影响是非常重要的。这既适用于修改查询这样的小变化,也适用于重构应用和模式以及 MySQL 升级这样的大变化。您可能认为最佳性能测试是基于您的生产模式和使用应用执行的相同查询的数据。然而,重新创建合适的工作负载并不总是像听起来那么简单,因此有时有必要使用标准的基准测试套件。

本章首先介绍了执行基准测试时的一些最佳实践,并概述了 MySQL 使用的一些最常见的基准测试和工具。然后将更详细地考虑最常用的基准 Sysbench。

最佳实践

安装一个基准程序并执行它是很容易的。难的是正确使用它。执行 MySQL 基准测试分享了一些性能调优的概念,第一点也是最重要的一点是,您需要以“知情的方式”工作。这意味着您必须很好地了解您的工具,并且清楚地定义测试的目标和成功标准。对于您的工具,您需要知道如何正确地使用它们,因为使用默认参数执行它们可能不会产生您想要的测试。

这与基准的目标是联系在一起的。你需要确定什么?例如,您可能想要验证更改某些配置变量的效果,在这种情况下,您必须确保您的测试已经设置好,以便对该区域进行测试。考虑一个选项,比如影响 InnoDB 写速度的innodb_io_capacity。如果您的基准是只读测试,那么改变innodb_io_capacity不会有任何影响。在这种情况下,您还需要确保一次只更改一件事情,并且只进行相对较小的更改——就像您在对生产系统进行更改时应该做的那样。否则,如果您同时更改几个设置,那么一些设置可能会对结果产生积极影响,而另一些则会产生消极影响,但是您无法确定哪些更改要保留,哪些要恢复。如果您进行大的更改,您可能会超过最佳值,因此您最终会放弃该更改,即使还有改进的空间。

在测试结束读取结果时,需要了解基准测试的是什么;否则,结果只是一个没有意义的数字。这还包括定义在测试过程中要调整哪些变量,至于一般的性能调优,限制变量的数量是很重要的,这样您就可以很容易地识别每个变量的影响。为了使结果有效,您还必须确保测试是可重复的,也就是说,如果您两次执行相同的测试,那么您会得到相同的结果。测试可重复的一个要求是您有一个定义良好的系统起始状态。

Tip

不要假设一个客户端就足以生成您想要的负载。需要多少客户机取决于并发查询的数量和您正在执行的基准。

这就引出了下一个重点。您的基准应该反映应用的工作负载。如果您的应用具有在线分析处理(OLAP)工作负载,那么使用在线事务处理(OLTP)基准测试来证明您的配置更改效果很好,或者如果您的应用是写入密集型的,那么证明您的只读性能很好,这些都没有帮助。

您可能认为设计基准的最佳方式是捕获生产中执行的所有查询,并将其作为基准重放。这肯定有一些优点,但也有挑战。收集所有执行的查询是很昂贵的,但是如果您已经启用了 MySQL 企业审计日志来进行审计,那么可以使用它。将生产数据复制到测试系统还可能存在数据隐私问题。最后,与当前的生产负载相比,很难扩展测试来改变数据集的大小(无论是为了使其更易于管理还是为了测试的增长)或增加测试工作负载。由于这些原因,通常有必要使用人工基准。

Tip

您可以使用 MySQL 企业审计日志(需要订阅)或通用查询日志(开销非常大)来捕获一段时间内的所有查询。这包括执行查询时的时间戳,因此您可以使用日志以相同的顺序和相同的并发性重放查询。但是,它要求您自己创建一个脚本来提取查询并执行它们。

下一点是关于基准测试结果,它也与前面的几点相关。当您有了基准测试的结果时,理解结果的含义是很重要的,并且不要因为结果看起来不正确就丢弃它们。因此,基准结果是“永远不会错的”;这是一些工作的结果。如果结果出乎意料,理解为什么会这样是很重要的。也许,您没有使用预期的参数,或者使用了与预期不同的表大小,但也可能是其他因素干扰了基准测试,或者是第三个因素。如果有什么东西干扰了基准测试,那么它是否也可能发生在生产中?如果可以,那么基准测试就非常重要,您需要决定如何在生产中处理这种情况。

为了了解基准测试期间发生了什么,监控 MySQL 和主机系统也很重要。一种选择是使用与生产系统相同的监控解决方案。然而,测试或开发系统上的基准测试与生产系统稍有不同,因为您通常对较高频率的采样感兴趣,但在基准测试期间持续时间较短,因此使用专门针对基准测试的专用监控解决方案会很有用。dim_STAT ( http://dimitrik.free.fr/ )就是这样一个选项,它是由 Dimitri Kravtchuk 开发的,他是 MySQL 的性能架构师,也是许多 MySQL 服务器基准测试的幕后推手。

总的来说,理解结果并不是一件简单的事情。您还需要注意的一件事是,如果出现临时停顿,在基准测试期间会发生什么。基准测试是阻止后续查询,还是继续提交查询?如果它停滞不前,那么随后的查询将会比实际情况下更快,因为用户不会仅仅因为积压而停止提交请求。

最后,基准测试通常会产生几个指标,所以您需要分析结果,因为它与您的系统最相关。例如,延迟或吞吐量是最重要的?还是对两者都有要求?或者你对第三种度量更感兴趣?

标准 TPC 基准

有一个几乎无止境的基准列表,但最终常用的都归结为少数几个测试。这并不意味着您不应该考虑其他基准;最后,重要的是基准测试能够满足您的需求。

最常用的标准基准由 TPC ( www.tpc.org/ )定义,随着硬件和软件的变化,旧的基准变得过于简单,新的基准也被设计出来。TPC 网站包含了对基准的详细描述和规范。表 3-1 总结了当前的企业 TPC 基准。

表 3-1

通用 TPC 基准

|

名字

|

类型

|

描述

|
| --- | --- | --- |
| TPC-C 战式攻击机 | 联机事务处理 | 这可能是最经典的 TPC 基准测试,可以追溯到 1992 年。它模拟了一个批发供应商的查询,并使用了九个表。 |
| TPC-DI(消歧义) | 数据集成 | 测试提取、转换和加载(ETL)工作负载。 |
| TPC-DS(卢旺达问题国际法庭) | 决策支持 | 该基准测试包括数据仓库(星型模式)的复杂查询。 |
| TPC-E 游戏 | 联机事务处理 | 这意味着用一个更复杂的模式和查询来代替 TPC-C,所以它对现代数据库更现实。它包括 33 个表。 |
| TPC-H 导弹 | 决策支持 | 这是另一个常用于测试优化器特性的经典基准。它由 22 个复杂的查询组成,旨在模拟 OLTP 数据库的报告端。 |
| TPC-VMS | 虚拟化 | 它使用 TPC-C、TPC-DS、TPS-E 和 TPC-H 基准来确定虚拟化数据库的性能指标。 |

这些标准基准的优点是,您更有可能找到实现它们的工具,并且可以与其他人获得的结果进行比较。

Tip

如果您想了解更多关于 TPC 基准测试以及如何以最佳方式执行数据库基准测试的信息,请考虑 Bert Scalzo 的书:数据库基准测试和压力测试 (Apress)、 www.apress.com/gp/book/9781484240076

与标准基准测试一样,也有一些通用的基准测试工具。

通用基准工具

实现一个基准绝非易事,所以在大多数情况下,最好使用预先存在的可以为您执行基准的基准工具。一些工具是跨平台的和/或可以使用几个不同的数据库系统,而另一些工具则更加具体。您应该选择一个实现您需要的基准并在您的生产系统上工作的平台。

3-2 总结了一些最常用的测试 MySQL 性能的基准工具。

表 3-2

与 MySQL 一起使用的通用基准

|

基准

|

描述

|
| --- | --- |
| 二 | 这是最常用的基准,也是本章将重点介绍的。它具有针对 OLTP 工作负载的内置测试、非数据库测试(例如纯 I/O、CPU 和内存测试)等等。此外,最新版本支持定制工作负载。它是开源的,主要在 Linux 上使用。可以从 https://github.com/akopytov/sysbench 下载。 |
| DBT2 | DBT2 可用于使用订单系统(TPC-C)模拟 OLTP 工作负载。DBT2 也可用于自动化 Sysbench,可从 https://dev.mysql.com/downloads/benchmarks.html 获得。 |
| DBT3 | DBT3 实现了 TPC-H 基准,用于测试复杂查询的性能。这是 MySQL 优化器开发者最喜欢使用的测试之一,用来验证实现新的优化器特性后的性能。从 https://sourceforge.net/projects/osdldbt/ 可以得到 DBT3 的副本。 |
| HammerDB | HammerDB 工具是一个免费的跨数据库工具,支持 Microsoft Windows 和 Linux。它支持 TPC-C 和 TPC-H 基准,可从 https://hammerdb.com/ 获得。 |
| 数据库工厂 | Database Factory 是一个强大的 Microsoft Windows 基准测试工具,支持多种数据库和基准测试。它支持 TPC-H、TPC-C、TPC-D 和 TPC-E 基准测试等。是商用产品(可免费试用): www.quest.com/products/benchmark-factory/ 。 |
| ii 长凳 | iiBench 测试将数据插入数据库的速度,因此如果您经常需要接收大量数据,它会非常有用。可以从 https://github.com/tmcallaghan/iibench-mysql 下载。 |
| DVD 商店版本 3 | DVD 商店将样本 DVD 商店的数据与基准相结合。它可以生成任何给定大小的数据,标准大小为 10 MB、1 GB 和 100 GB。它也可用作一般测试数据,可从 https://github.com/dvdstore/ds3 下载。它基于旧的戴尔 DVD 商店数据库测试套件。 |
| mysqlslap | mysqlslap工具很特别,因为它包含在 MySQL 安装中。它可用于根据您选择的表生成并发工作负载。它是一个非常简单的工具,所以不能有太多的用途,但是很好用。mysqlslap的手册页可以在 https://dev.mysql.com/doc/refman/en/mysqlslap.html 找到。 |

MySQL 最常用的工具是 Sysbench,本章的其余部分将介绍它的安装和使用示例。

Sysbench 安装

因为 Sysbench 是一个开源工具,所以有几个可用的分支。MySQL 维护其中一个分支;但是,要获得最新功能的版本,建议使用 Alexey Kopytov 的 fork。(这也是 MySQL 性能架构师 Dimitri Kravtchuk 推荐的 fork。)本章中的例子都使用 Kopytov 的 fork 版本 1.0.17(但是注意输出中列出的版本是 1.1.0),但是对于其他 Sysbench forks 来说例子是相似的,只要 fork 足够新,能够包含所演示的特性。

支持使用原生 Linux 包安装 Sysbench,在 macOS 上从 Homebrew 安装,或者自己编译。虽然使用原生包安装更简单,但通常自己编译会更好,因为这样可以确保针对 MySQL 8 开发库进行编译,并且可以在比可用包更多的平台上编译 Sysbench。

Tip

有关所有安装说明的详细信息,包括所需的依赖项和使用本机包,请参见 https://github.com/akopytov/sysbench 。Sysbench 1.0 中已不再支持 Microsoft Windows。目前还不知道是否会重新引入支持。如果您使用的是 Microsoft Windows,建议您通过 Windows Subsystem for Linux(WSL)(https://msdn.microsoft.com/en-us/commandline/wsl/about)安装 Sysbench,在这种情况下,本章中的说明只需稍加修改(取决于您选择的 Linux 发行版)。另一种方法是使用虚拟机,例如在 VirtualBox 中。

编译软件可能不再很常见,但幸运的是,编译 Sysbench 很简单。您需要下载源代码,然后配置构建,编译它,最后安装它。

在编译 Sysbench 之前,您需要安装一些工具。所需的具体工具取决于您的操作系统。详见项目 GitHub 页面的安装说明。例如,在 Oracle Linux 7 上:

shell$ sudo yum install make automake libtool \
                        pkgconfig libaio-devel \
                        openssl-devel

您还需要安装 MySQL 8 开发库。在 Linux 上,最简单的方法是从 https://dev.mysql.com/downloads/ 为您的 Linux 发行版安装 MySQL 存储库。清单 3-1 展示了在 Oracle Linux 7 上安装 MySQL 8 开发库的例子。

shell$ wget https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm
...
Saving to: 'mysql80-community-release-el7-3.noarch.rpm'

100%[=================>] 26,024      --.-K/s   in 0.006s

2019-10-12 14:21:18 (4.37 MB/s) - 'mysql80-community-release-el7-3.noarch.rpm' saved [26024/26024]

shell$ sudo yum install mysql80-community-release-el7-3.noarch.rpm
Loaded plugins: langpacks, ulninfo
Examining mysql80-community-release-el7-3.noarch.rpm: mysql80-community-release-el7-3.noarch
Marking mysql80-community-release-el7-3.noarch.rpm to be installed
Resolving Dependencies
--> Running transaction check
---> Package mysql80-community-release.noarch 0:el7-3 will be installed
--> Finished Dependency Resolution

Dependencies Resolved

===========================================================
 Package
   Arch   Version
          Repository                               Size
===========================================================
Installing:
 mysql80-community-release
   noarch el7-3
             /mysql80-community-release-el7-3.noarch  31 k

Transaction Summary
===========================================================
Install  1 Package

Total size: 31 k
Installed size: 31 k
Is this ok [y/d/N]: y
Downloading packages:
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : mysql80-community-release-el7-3.noarc   1/1
  Verifying  : mysql80-community-release-el7-3.noarc   1/1

Installed:
  mysql80-community-release.noarch 0:el7-3

Complete!

shell$ sudo yum install mysql-devel
...
Dependencies Resolved

===========================================================
 Package       Arch   Version      Repository         Size
===========================================================
Installing:
 mysql-community-client
         x86_64 8.0.17-1.el7 mysql80-community  32 M
     replacing  mariadb.x86_64 1:5.5.64-1.el7
 mysql-community-devel
         x86_64 8.0.17-1.el7 mysql80-community 5.5 M
 mysql-community-libs
         x86_64 8.0.17-1.el7 mysql80-community 3.0 M
     replacing  mariadb-libs.x86_64 1:5.5.64-1.el7
 mysql-community-libs-compat
         x86_64 8.0.17-1.el7 mysql80-community 2.1 M
     replacing  mariadb-libs.x86_64 1:5.5.64-1.el7
 mysql-community-server
         x86_64 8.0.17-1.el7 mysql80-community 415 M
     replacing  mariadb-server.x86_64 1:5.5.64-1.el7
Installing for dependencies:
 mysql-community-common
         x86_64 8.0.17-1.el7 mysql80-community 589 k

Transaction Summary
===========================================================
Install  5 Packages (+1 Dependent package)

Total download size: 459 M

...

Complete!

Listing 3-1Installing the MySQL 8 development libraries

输出取决于您已经安装的内容。注意其他几个 MySQL 包,包括mysql-community-server,是如何作为依赖项被拉进来的。这是因为在这种情况下,mysql-community-devel包替换了另一个预先存在的包,从而触发了一系列的依赖关系更新。

Note

如果你安装了一个旧版本的 MySQL 或者 fork,所有相关的包都会被升级。为此,最好在可以自由替换包或者已经安装了正确的 MySQL 8 开发库的主机上编译 Sysbench。

您现在可以考虑 Sysbench 本身了。您可以选择克隆 GitHub 存储库或者下载 ZIP 文件形式的源代码。要克隆存储库,您需要安装git,然后使用git clone命令:

shell$ git clone https://github.com/akopytov/sysbench.git
Cloning into 'sysbench'...
remote: Enumerating objects: 14, done.
remote: Counting objects: 100% (14/14), done.
remote: Compressing objects: 100% (12/12), done.
remote: Total 9740 (delta 4), reused 5 (delta 2), pack-reused 9726
Receiving objects: 100% (9740/9740), 4.12 MiB | 2.12 MiB/s, done.
Resolving deltas: 100% (6958/6958), done.

带有源代码的 ZIP 文件可以从 GitHub 存储库中下载,例如,使用wget:

shell$ wget https://github.com/akopytov/sysbench/archive/master.zip
...
Connecting to codeload.github.com (codeload.github.com)|52.63.100.255|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: unspecified [application/zip]
Saving to: 'master.zip'

    [    <=>               ] 2,282,636   3.48MB/s   in 0.6s

2019-10-12 16:01:33 (3.48 MB/s) - 'master.zip' saved [2282636]

或者,您可以使用浏览器下载 ZIP 文件,如图 3-1 所示。

img/484666_1_En_3_Fig1_HTML.jpg

图 3-1

在浏览器中从 GitHub 下载 Sysbench 源代码

点击下载 ZIP 文件,文件将被下载。下载源代码后,将其解压缩。

现在,您可以配置编译了。输入带有源代码的顶级目录。目录列表应类似于以下输出:

shell$ ls
autogen.sh    COPYING     Makefile.am    rpm      tests
ChangeLog     debian      missing        scripts  third_party
config        install-sh  mkinstalldirs  snap
configure.ac  m4          README.md      src

使用清单 3-2 中所示的autogen.sh脚本后跟configure命令来完成配置。

shell$ ./autogen.sh
autoreconf: Entering directory `.'
...
parallel-tests: installing 'config/test-driver'
autoreconf: Leaving directory `.'

shell$ ./configure
checking build system type... x86_64-unknown-linux-gnu
checking host system type... x86_64-unknown-linux-gnu
...
===========================================================================
sysbench version   : 1.1.0-74f3b6b
CC                 : gcc -std=gnu99
CFLAGS             : -O3 -funroll-loops -ggdb3  -march=core2 -Wall -Wextra -Wpointer-arith -Wbad-function-cast -Wstrict-prototypes -Wnested-externs -Wno-format-zero-length -Wundef -Wstrict-prototypes -Wmissing-prototypes -Wmissing-declarations -Wredundant-decls -Wcast-align -Wvla   -pthread
CPPFLAGS           : -D_GNU_SOURCE   -I$(top_srcdir)/src -I$(abs_top_builddir)/third_party/luajit/inc -I$(abs_top_builddir)/third_party/concurrency_kit/include
LDFLAGS            : -L/usr/local/lib
LIBS               : -laio -lm

prefix             : /usr/local
bindir             : ${prefix}/bin
libexecdir         : ${prefix}/libexec
mandir             : ${prefix}/share/man
datadir            : ${prefix}/share

MySQL support      : yes
PostgreSQL support : no

LuaJIT             : bundled

LUAJIT_CFLAGS      : -I$(abs_top_builddir)/third_party/luajit/inc
LUAJIT_LIBS        : $(abs_top_builddir)/third_party/luajit/lib/libluajit-5.1.a -ldl
LUAJIT_LDFLAGS     : -rdynamic

Concurrency Kit    : bundled
CK_CFLAGS          : -I$(abs_top_builddir)/third_party/concurrency_kit/include
CK_LIBS            : $(abs_top_builddir)/third_party/concurrency_kit/lib/libck.a
configure flags    :
===========================================================================

Listing 3-2Configuring Sysbench for compilation and installation

配置的末尾显示了将用于编译的选项。确保MySQL support同意。默认安装在/usr/local中。您可以在执行配置时使用--prefix选项进行更改,例如./configure --prefix=/home/myuser/sysbench

下一步是使用make命令编译代码:

shell$ make -j
Making all in third_party/luajit
...
make[1]: Nothing to be done for `all-am'.
make[1]: Leaving directory `/home/myuser/git/sysbench'

-j选项告诉make并行编译源代码,这样可以减少编译时间。然而,Sysbench 在所有情况下都可以快速编译,因此在这种情况下并不重要。

最后一步是安装 Sysbench 的编译版本:

shell$ sudo make install
Making install in third_party/luajit
...
make[2]: Leaving directory `/home/myuser/git/sysbench'
make[1]: Leaving directory `/home/myuser/git/sysbench'

就是这样。您现在已经准备好使用 Sysbench 来执行基准测试了。

执行基准

Sysbench 包括几个现成可用的基准。这包括从非数据库内置测试到各种数据库测试。非数据库测试被认为是内置的,因为它们是在 Sysbench 源代码本身中定义的。其他测试在 Lua 脚本中定义,并安装在/usr/local/share/sysbench/目录中(假设您安装在默认位置)。

Note

这一节和下一节假设您在安装 Sysbench 的同一台主机上有一个可用于测试的 MySQL 实例。如果不是这样,您需要根据需要调整主机名。

您可以通过使用--help参数调用sysbench来获得理解 Sysbench 参数的一般帮助:

shell$ sysbench –help
...
Compiled-in tests:
  fileio - File I/O test
  cpu - CPU performance test
  memory - Memory functions speed test
  threads - Threads subsystem performance test
  mutex - Mutex performance test

See 'sysbench <testname> help' for a list of options for each test.

在输出的底部是一个内置测试的列表和一个关于如何获得给定测试的更多信息的提示。您可以通过列出共享目录中的文件来获得附加测试的列表:

shell$ ls /usr/local/share/sysbench/
bulk_insert.lua        oltp_update_index.lua
oltp_common.lua        oltp_update_non_index.lua
oltp_delete.lua        oltp_write_only.lua
oltp_insert.lua        select_random_points.lua
oltp_point_select.lua  select_random_ranges.lua
oltp_read_only.lua     tests
oltp_read_write.lua

除了oltp_common.lua(OLTP 测试的共享代码)之外,带有.lua扩展名的文件是可用的测试。Lua 语言 1 是一种轻量级编程语言,常用于将代码嵌入到程序中。使用 Lua 程序类似于使用脚本语言,比如 Python,除了您的代码是通过另一个程序执行的(在这种情况下是 Sysbench)。

如上所述,您可以通过提供测试名称和help命令来获得关于测试的额外帮助。例如,要获得关于在oltp_read_only.lua中定义的测试的附加信息,您可以使用清单 3-3 中所示的help命令。

shell$ sysbench oltp_read_only help
sysbench 1.1.0-74f3b6b (using bundled LuaJIT 2.1.0-beta3)

oltp_read_only options:
  --auto_inc[=on|off]           Use AUTO_INCREMENT column as Primary Key (for MySQL), or its alternatives in other DBMS. When disabled, use client-generated IDs [on]
  --create_secondary[=on|off]   Create a secondary index in addition to the PRIMARY KEY [on]
  --create_table_options=STRING Extra CREATE TABLE options []
  --delete_inserts=N            Number of DELETE/INSERT combinations per transaction [1]
  --distinct_ranges=N           Number of SELECT DISTINCT queries per transaction [1]
  --index_updates=N             Number of UPDATE index queries per transaction [1]
  --mysql_storage_engine=STRING Storage engine, if MySQL is used [innodb]
  --non_index_updates=N         Number of UPDATE non-index queries per transaction [1]
  --order_ranges=N              Number of SELECT ORDER BY queries per transaction [1]
  --pgsql_variant=STRING        Use this PostgreSQL variant when running with the PostgreSQL driver. The only currently supported variant is 'redshift'. When enabled, create_secondary is automatically disabled, and delete_inserts is set to 0
  --point_selects=N             Number of point SELECT queries per transaction [10]
  --range_selects[=on|off]      Enable/disable all range SELECT queries [on]
  --range_size=N                Range size for range SELECT queries [100]
  --reconnect=N                 Reconnect after every N events. The default (0) is to not reconnect [0]
  --secondary[=on|off]          Use a secondary index in place of the PRIMARY KEY [off]
  --simple_ranges=N             Number of simple range SELECT queries per transaction [1]
  --skip_trx[=on|off]           Don't start explicit transactions and execute all queries in the AUTOCOMMIT mode [off]
  --sum_ranges=N                Number of SELECT SUM() queries per transaction [1]
  --table_size=N                Number of rows per table [10000]

  --tables=N                    Number of tables [1]

Listing 3-3Obtaining help for the oltp_read_only test

方括号中的值是默认值。

help命令只是几个可用命令中的一个(一些测试可能不会实现所有的命令)。其他命令涵盖了基准测试的各个阶段:

  • prepare : 执行设置测试所需的步骤,例如,通过创建和填充测试所需的表格。

  • warmup : 确保缓冲区和缓存是热的,例如,表数据和索引已经加载到 InnoDB 缓冲池中。这对于 OLTP 基准来说是特殊的。

  • run : 执行测试本身。该命令由所有测试提供。

  • cleanup : 删除测试使用的任何表格。

作为一个例子,考虑您以前检索帮助的只读 OLTP 测试。首先,创建一个可以执行所需查询的 MySQL 用户。默认情况下使用sbtest模式作为基准,因此一个简单的解决方案是创建一个拥有该模式所有特权的用户:

mysql> CREATE USER sbtest@localhost IDENTIFIED BY 'password';
Query OK, 0 rows affected (0.02 sec)

mysql> GRANT ALL ON sbtest.* TO sbtest@localhost;
Query OK, 0 rows affected (0.01 sec)

mysql> CREATE SCHEMA sbtest;
Query OK, 1 row affected (0.01 sec)

在这种情况下,用户需要从localhost开始连接。一般来说,情况并非如此,因此您需要更改帐户的主机名部分,以反映 Sysbench 用户的连接位置。用户名选择为sbtest,因为这是 Sysbench 使用的默认用户名。当 Sysbench 测试要求模式在第一次连接时存在时,也会创建sbtest模式。

Note

强烈建议为帐户选择强密码。

如果您想执行一个使用四个各有 20000 行的表的基准测试,那么您可以像清单 3-4 所示那样准备测试。

shell$ sysbench oltp_read_only \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --table_size=20000 \
         --tables=4 \
         --threads=4 \
         prepare
sysbench 1.1.0-74f3b6b (using bundled LuaJIT 2.1.0-beta3)

Initializing worker threads...

Creating table 'sbtest1'...
Creating table 'sbtest3'...
Creating table 'sbtest4'...
Creating table 'sbtest2'...
Inserting 20000 records into 'sbtest2'
Inserting 20000 records into 'sbtest3'
Inserting 20000 records into 'sbtest1'
Inserting 20000 records into 'sbtest4'
Creating a secondary index on 'sbtest3'...
Creating a secondary index on 'sbtest2'...
Creating a secondary index on 'sbtest4'...
Creating a secondary index on 'sbtest1'...

Listing 3-4Preparing the test

这使用四个线程创建了四个表sbtest1sbtest2sbtest3sbtest4。在这种情况下,准备步骤会很快,因为表很小;但是,如果您使用大型表来执行基准测试,那么设置测试会花费大量的时间。由于基准测试通常涉及执行一系列测试,因此您可以通过创建二进制备份(复制表,关闭 MySQL 或使用 MySQL Enterprise Backup 等工具)或文件系统快照来加速测试。对于每个后续测试,您可以恢复备份,而不是重新创建表。

可选地,作为下一步,您可以经历清单 3-5 中所示的预热阶段。

shell$ sysbench oltp_read_only \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --table_size=20000 \
         --tables=4 \
         --threads=4 \
         warmup
sysbench 1.1.0-74f3b6b (using bundled LuaJIT 2.1.0-beta3)

Initializing worker threads...

Preloading table sbtest3
Preloading table sbtest1
Preloading table sbtest2
Preloading table sbtest4

Listing 3-5Warming MySQL up for the test

在这里,包含--tables--table-size选项很重要,否则只会预加载sbtest1表的默认行数(10,000)。预加载包括平均化id列和一个简单的SELECT COUNT(*)查询,在子查询中提取行(查询已经被重新格式化):

SELECT AVG(id)
  FROM (SELECT *
          FROM sbtest1 FORCE KEY (PRIMARY)
         LIMIT 20000
       ) t

SELECT COUNT(*)
  FROM (SELECT *
          FROM sbtest1
         WHERE k LIKE '%0%'
         LIMIT 20000
       ) t

因此预热阶段可能并不等同于暂时运行实际的基准测试。

Tip

在执行基准测试时,您还可以使用--warmup-time=N选项来禁用第一个N秒的统计。

基准本身正在使用run命令执行。有两个选项可以指定测试的持续时间:

  • --events=N : 要执行的最大事件数。默认值为 0。

  • --time=N : 以秒为单位的最大持续时间。默认值为 10。

当其中一个选项的值为 0 时,表示无穷大。因此,如果您将--events--time都设置为 0,测试将永远运行。例如,如果您对基准统计数据本身不感兴趣,但希望收集监控指标或希望在执行其他任务时创建工作负载,这可能会很有用。

Tip

本书的作者使用 Sysbench,将事件数量和时间限制都设置为 0,为创建备份的测试生成并发工作负载。

例如,如果您想执行一个一分钟(60 秒)的测试,您可以使用清单 3-6 中的命令。

shell$ sysbench oltp_read_only \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --table_size=20000 \
         --tables=4 \
         --time=60 \
         --threads=8 \
         run
sysbench 1.1.0-74f3b6b (using bundled LuaJIT 2.1.0-beta3)

Running the test with following options:
Number of threads: 8
Initializing random number generator from current time

Initializing worker threads...

Threads started!

SQL statistics:
    queries performed:
        read:                            766682
        write:                           0
        other:                           109526
        total:                           876208
    transactions:                        54763  (912.52 per sec.)
    queries:                             876208 (14600.36 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          0      (0.00 per sec.)

Throughput:
    events/s (eps):                      912.5224
    time elapsed:                        60.0128s
    total number of events:              54763

Latency (ms):
         min:                                    3.26
         avg:                                    8.76
         max:                                  122.43
         95th percentile:                       11.24
         sum:                               479591.29

Threads fairness:
    events (avg/stddev):           6845.3750/70.14
    execution time (avg/stddev):   59.9489/0.00

Listing 3-6Executing a Sysbench test for one minute

注意,与准备和预热阶段不同,run命令是用八个线程运行的。在一系列测试中,线程数量通常是变化的因素之一,以确定系统可以支持的并发工作负载。有必要指定run命令应该使用的表和行数,否则将使用默认值(Sysbench 命令之间没有共享状态)。

一旦您完成了测试,您就可以告诉 Sysbench 使用 clean up 命令进行自我清理,如清单 3-7 所示。

shell$ sysbench oltp_read_only \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --tables=4 \
         cleanup
sysbench 1.1.0-74f3b6b (using bundled LuaJIT 2.1.0-beta3)

Dropping table 'sbtest1'...
Dropping table 'sbtest2'...
Dropping table 'sbtest3'...
Dropping table 'sbtest4'...

Listing 3-7Cleaning up after a test

请注意,有必要指定表的数量;否则,只会删除第一个表。

内置测试很棒,但让 Sysbench 成为真正强大的工具的是,您还可以定义自己的基准。

创建自定义基准

正如您在上一节中看到的,Sysbench 包含的数据库测试是在 Lua 脚本中定义的( www.lua.org/ )。这意味着定义自己的测试所需要做的就是用测试的定义创建一个 Lua 脚本,并将它保存在 Sysbench 的共享目录中。一个有用的例子是,如果您想要基于应用的特定需求创建一个测试,以测试索引的效果、重构应用或类似的事情。

这一节将整理一个小的测试脚本示例,这样您就可以看到创建您自己的测试的原则。该测试也可以在本书的 GitHub 资源库的sequence.lua中找到。

Tip

学习如何编写自己的 Sysbench Lua 脚本的一个好方法是研究现有的脚本。除了本章中的例子,你可以看看 Sysbench 附带的 Lua 脚本和 https://gist.github.com/utdrmac/92d00a34149565bc155cdef80b6cba12 中另一个相对简单的例子。

自定义脚本概述

示例基准测试将测试一个序列的性能,该序列是通过在一个表中为每个序列指定一行来实现的。这种构造有时用于在应用中实现自定义序列。列表 3-8 中显示了表格定义和表格使用示例。

mysql> SHOW CREATE TABLE sbtest.sbtest1\G
*************************** 1\. row ***************************
       Table: sbtest1
Create Table: CREATE TABLE `sbtest1` (
  `id` varchar(10) NOT NULL,
  `val` bigint(20) unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

mysql> SELECT * FROM sbtest.sbtest1;
+--------+-----+
| id     | val |
+--------+-----+
| sbkey1 |   0 |
+--------+-----+
1 row in set (0.00 sec)

mysql> UPDATE sbtest1
          SET val = LAST_INSERT_ID(val+1)
        WHERE id = 'sbkey1';
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> SELECT LAST_INSERT_ID();
+------------------+
| LAST_INSERT_ID() |
+------------------+
|                1 |
+------------------+
1 row in set (0.00 sec)

mysql> SELECT * FROM sbtest.sbtest1;
+--------+-----+
| id     | val |
+--------+-----+
| sbkey1 |   1 |
+--------+-----+
1 row in set (0.00 sec)

Listing 3-8Using a custom sequence table

UPDATE语句中使用了LAST_INSERT_ID()函数来为最后插入的 id 分配会话值,因此可以在之后的SELECT语句中获取该值。

示例测试将具有以下特征:

  • 支持prepareruncleanuphelp命令。

  • preparerun命令可以并行执行。

  • 支持指定表的数量、表的大小以及是否使用显式事务。

  • 验证每个表的行数是否在 1–99999 的范围内。表格的id列被创建为varchar(10),关键字以sbkey为前缀,因此最多可以有五位数字。

3-2 总结了将要实施的功能。

img/484666_1_En_3_Fig2_HTML.png

图 3-2

顺序测试中的功能概述

“准备”、“运行”和“清理”组代表命令,而“助手”组包含将在多个命令中使用的两个助手函数。runhelp命令是特殊的,因为它们总是存在。帮助是根据脚本添加的选项自动生成的,因此不需要特别考虑。还有一些函数之外的代码,首先是健全性检查和脚本将支持的选项。

定义选项

脚本支持的选项是通过向sysbench.cmdline.options散列添加元素来配置的。这是 Sysbench 的内置特性之一,您可以在脚本中使用。另一个是sysbench.cmdline.command,它是为执行提供的命令的名称。

清单 3-9 展示了如何验证命令已经设置,然后添加该脚本支持的三个选项。

-- Validate that a command was provided
if sysbench.cmdline.command == nil then
    error("Command is required. Supported commands: " ..
          "prepare, run, cleanup, help")
end

-- Specify the supported options for this test
sysbench.cmdline.options = {
    skip_trx = {"Don't start explicit transactions and " ..
                "execute all queries in the AUTOCOMMIT mode",
                false},
    table_size = {"The number of rows per table. Supported " ..
                  "values: 1-99999", 1},
    tables = {"The number of tables", 1}
}

Listing 3-9Verifying a command is specified and adding the options

如果没有设置命令,内置的error()函数用于发出错误消息,并列出支持的命令。没有必要验证该命令是否是受支持的命令之一,因为 Sysbench 会自动进行验证。

这些选项是通过由帮助文本和默认值组成的数组添加的。使用此脚本中的定义,生成的帮助文本将变成:

shell$ sysbench sequence help
sysbench 1.1.0-74f3b6b (using bundled LuaJIT 2.1.0-beta3)

sequence options
  --skip_trx[=on|off] Don't start explicit transactions and execute all queries in the AUTOCOMMIT mode [off]
  --table_size=N      The number of rows per table. Supported values: 1-99999 [1]
  --tables=N          The number of tables [1]

选项值在sysbench.opt散列中可用,例如,要获得测试中的表数,可以使用sysbench.opt.tables。散列是全局可用的,因此在使用它之前您不需要做任何事情。

现在,您已经准备好实现脚本支持的三个命令。因为run命令是强制性的,所以这是将要讨论的第一个命令。

运行命令

run命令是特殊的,因为它是强制性的,并且总是支持并行执行。与在单个函数中实现的其他命令不同(可选地调用其他函数),Sysbench 为run命令使用了三个函数。必须始终存在的三个功能是

  • thread_init() : 这是 Sysbench 初始化脚本时调用的。

  • thread_done() : 当 Sysbench 执行完脚本时调用。

  • event() : 这是实现实际测试的地方,并且在每次迭代中调用一次。

对于这个例子,thread_init()函数可以保持非常简单:

-- Initialize the script
-- Initialize the global variables used in the rest of the script
function thread_init()
    -- Initialize the database driver and connections
    db = sysbench.sql.driver()
    cnx = db:connect()
end

对于这个简单的测试,所有需要的初始化就是创建到 MySQL 的连接,包括初始化数据库驱动程序,并使用它来创建连接。驱动程序可从sysbench对象获得。通过在thread_init()函数中创建连接,Sysbench 可以重用这些连接,而不是为每次迭代创建一个新的连接。如果您想模拟为每组查询创建一个新的连接,您也可以选择通过在event()函数中添加代码来实现,并使连接对象成为本地的,就像稍后对preparecleanup命令所做的一样。

类似地,thread_done()函数在执行后进行清理:

-- Clean up after the test
function thread_done()
    -- Close the connection to the database
    cnx:disconnect()
end

在这种情况下,只需要关闭连接,这是使用连接的disconnect()方法完成的。

三个必需函数中最有趣的是event()函数,它定义了执行测试时要做什么。示例脚本的代码可以在清单 3-10 中看到。

-- Called for each iteration
function event()
    -- Check the --skip_trx option which determines
    -- whether explicit transactions are required.
    if not sysbench.opt.skip_trx then
        cnx:query("BEGIN")
    end

    -- Execute the customer test
    do_increment()

    -- If necessary, commit the transaction
    if not sysbench.opt.skip_trx then
        cnx:query("COMMIT")
    end
end

Listing 3-10The event() function

这段代码使用了一个选项,即--skip_trx选项。如果--skip_trx被禁用,那么测试依赖于自动提交特性;否则,执行显式的BEGINCOMMIT

Note

在 Sysbench Lua 脚本中,您不能使用START TRANSACTION来开始一个事务。

在这种情况下,event()函数本身实际上并不执行任何工作。这被委托给do_increment()函数来展示如何添加额外的函数来像在其他程序中一样分离工作。清单 3-11 中显示了do_increment()函数和几个助手函数。

-- Generate the table name from the table number
function gen_table_name(table_num)
    return string.format("sbtest%d", table_num)
end

-- Generate the key from an id
function gen_key(id)
    return string.format("sbkey%d", id)
end

-- Increment the counter and fetch the new value
function do_increment()
    -- Choose a random table and id
    -- among the tables and rows available
    table_num = math.random(sysbench.opt.tables)
    table_name = gen_table_name(table_num)
    id = math.random(sysbench.opt.table_size)
    key = gen_key(id)
    query = string.format([[
UPDATE %s
   SET val = LAST_INSERT_ID(val+1)
 WHERE id = '%s']], table_name, key)
    cnx:query(query)
    cnx:query("SELECT LAST_INSERT_ID()")
end

Listing 3-11The do_increment() and helper functions

gen_table_name()函数基于整数生成表名,gen_key()函数同样基于整数 id 生成键值。表名和键值用在脚本中的其他一些地方,所以通过将逻辑分成助手函数,可以确保它们在整个脚本中以相同的方式生成。

do_increment()函数本身根据测试中的表数和每个表中的行数,基于随机值生成表名和键。在实际的应用中,您可能没有这样统一的序列访问,在这种情况下,您可以修改脚本中的逻辑。最后,执行UPDATESELECT语句。该脚本的一个可能的扩展是在其他查询中使用生成的序列号,但是要小心,不要最终做了与您试图进行基准测试无关的工作。

这就是run命令所需的全部内容。注意,没有做任何事情来实现并行执行;这由 Sysbench 自动处理,除非您不想对所有线程一视同仁。线程不应该执行相同工作的一个例子是prepare命令,其中每个线程负责自己的表。

准备命令

prepare命令是支持并行执行的定制命令的一个例子。该命令的顶层代码在do_prepare()函数中实现,该函数又使用create_table()函数根据传递给该函数的表编号创建一个特定的表。这两个函数可以在清单 3-12 中看到。

-- Prepare the table
-- Can be parallelized up to the number of tables
function do_prepare()
    -- The script only supports up to 99999 rows
    -- as the id column is a varchar(10) and five
    -- characters is used by 'sbkey'
    assert(sysbench.opt.table_size > 0 and
           sysbench.opt.table_size < 100000,
           "Only 1-99999 rows per table is supported.")

    -- Initialize the database driver and connection
    local db = sysbench.sql.driver()
    local cnx = db:connect()

    -- Create table based on thread id
    for i = sysbench.tid % sysbench.opt.threads + 1,
            sysbench.opt.tables,
            sysbench.opt.threads do
        create_table(cnx, i)
    end

    -- Disconnect
    cnx:disconnect()
end

-- Create the Nth table
function create_table(cnx, table_num)
    table_name = gen_table_name(table_num)
    print(string.format(
          "Creating table '%s'...", table_name))

    -- Drop the table if it exists
    query = string.format(
        "DROP TABLE IF EXISTS %s", table_name)
    cnx:query(query)

    -- Create the new table

    query = string.format([[
CREATE TABLE %s (
  id varchar(10) NOT NULL,
  val bigint unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (id)
)]], table_name)
    cnx:query(query)

    -- Insert the rows inside a transaction
    cnx:query("BEGIN")
    for i = 1, sysbench.opt.table_size, 1 do
        query = string.format([[
INSERT INTO %s (id)
VALUES ('%s')]], table_name, gen_key(i))
        cnx:query(query)
    end
    cnx:query("COMMIT")
end

Listing 3-12The do_prepare() and create_table() functions

do_prepare()函数中做的第一件事是验证行数在 1–99999 的范围内。这是通过使用assert()函数完成的,其中第一个参数的值必须为 true 否则,将打印作为第二个输出给出的错误消息,并且脚本存在。

每个线程调用一次do_prepare()函数,因此并行化是为您处理的(在示例的最后会有更多相关内容),但是您需要确保每个表只创建一次。这是通过for循环完成的,其中sysbench.tid(Sysbench 线程 id)与线程数量的模数用于确定每个线程处理的表号。

实际的表创建是在create_table()中执行的,以将任务分离出来,从而更容易维护脚本。如果该表已经存在,则删除它,然后创建它,最后用请求的行数填充该表。所有行都被插入到一个事务中,以提高性能。如果您需要填充更大的表,每隔几千行就提交一次是值得的,但是因为这个表中的最大行数是 99999,而且行非常小,所以为了简单起见,每个表只使用一个事务就可以了。

清理命令

最后一个必须执行的命令是cleanup,这是单线程命令的一个例子。该命令的工作在cleanup()函数中完成,如清单 3-13 所示。

-- Cleanup after the test
function cleanup()
    -- Initialize the database driver and connection
    local db = sysbench.sql.driver()
    local cnx = db:connect()

    -- Drop each table
    for i = 1, sysbench.opt.tables, 1 do
        table_name = gen_table_name(i)
        print(string.format(
              "Dropping table '%s' ...", table_name))
        query = string.format(
            "DROP TABLE IF EXISTS %s", table_name)
        cnx:query(query)
    end

    -- Disconnect
    cnx:disconnect()
end

Listing 3-13The cleanup() function

cleanup()函数只支持串行执行,所以它可以遍历这些表并一个接一个地删除它们。

这就留下了一个问题:Sysbench 怎么知道prepare命令可以并行运行,而cleanup命令却不能?

注册命令

默认情况下,除了run以外的所有命令都是串行执行的,执行命令的功能与命令同名。因此,对于prepare命令,有必要在脚本中设置prepare对象指向do_prepare()函数,并附加一个参数,即每个线程应该调用一次do_prepare():

-- Specify the actions other than run that support
-- execution in parallel.
-- (Other supported actions are found based on the
-- function name except 'help' that is built-in.)
sysbench.cmdline.commands = {
    prepare = {do_prepare, sysbench.cmdline.PARALLEL_COMMAND}
}

sysbench.cmdline.PARALLEL_COMMAND常量是内置的,指定命令应该并行执行。重要的是,该代码位于do_prepare()的定义之后,否则将分配一个零值。实际上,将代码添加到脚本的末尾是很方便的。

剧本到此结束。如果您已经将它复制到共享的 Sysbench 目录中(当您自己编译 Sysbench 时,使用默认的安装目录),那么您现在可以像使用 Sysbench 附带的测试一样使用它。假设您已经将脚本保存为sequence.lua,在清单 3-14 中显示了该脚本的使用示例——没有输出。

shell$ sysbench sequence \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --table_size=10 \
         --tables=4 \
         --threads=4 \
         prepare

shell$ sysbench sequence \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --table_size=10 \
         --tables=4 \
         --time=60 \
         --threads=8 \
         run

shell$ sysbench sequence \
         --mysql-host=127.0.0.1 \
         --mysql-port=3306 \
         --mysql-user=sbtest \
         --mysql-password=password \
         --mysql-ssl=REQUIRED \
         --mysql-db=sbtest \
         --tables=4 \
         cleanup

Listing 3-14Example commands for the sequence test

注意,对于oltp_read_only测试,sbtest模式必须在执行prepare命令之前存在。留给读者一个练习,用不同的--threads--tables--table_size--skip_trx值来尝试这个脚本。

摘要

本章讨论了如何在 MySQL 中使用基准测试。首先,讨论了一些使用基准的一般最佳实践。最重要的事情是你已经确定了什么是基准,什么是成功的标准。这与一般的性能调优没有什么不同。理解基准执行的测试以及结果意味着什么也很重要。通常,您需要通过正常的监控解决方案或专门的脚本来收集额外的指标,以确定基准测试是否成功。

接下来,介绍了标准的 TPC 基准。TPC-C 和 TPC-E 基准很适合测试 OLTP 工作负载,其中 TPC-C 使用最多,因为它是最老的,但是 TPC-E 对于现代应用来说是最现实的。TPC-H 和 TPC-DS 使用复杂的查询,例如,探索可能影响查询计划的变化。

虽然您可以选择自己从头实现一个基准,但更有可能的是您将使用一个预先存在的基准工具。MySQL 最常用的工具是 Sysbench,我们已经详细介绍过了。首先,Sysbench 是通过编译安装的。然后展示了如何执行标准的 Sysbench 基准测试。然而,Sysbench 的真正优势在于您可以定义自己的定制测试。上一节给出了一个简单的例子。

同样,不可能总是使用真实世界的基准,也不可能总是使用真实世界的数据进行一般测试。下一章探讨了 MySQL 中经常使用的一些通用数据集,其中一些也在本书中使用。

四、测试数据

测试是性能调优工作中非常重要的一部分,因为在将更改应用到生产系统之前,验证这些更改是否有效非常重要。验证您的更改的最佳数据与您的生产数据密切相关;然而,为了探索 MySQL 是如何工作的,使用一些通用的测试数据会更好。本章介绍了四个带有安装说明的标准数据集,以及其他一些可用的数据集。

Tip

在本书的剩余部分中,worldworld_xsakila数据库被用作测试数据。

但是,首先,您需要知道如何下载数据库。

下载示例数据库

本章详细讨论的示例数据库的共同点是,它们可以从 https://dev.mysql.com/doc/index-other.html 下载,或者有一个可以下载它们的链接。对于几个数据库,也有在线文档和 PDF 文件从这个页面链接。页面相关部分如图 4-1 所示。

img/484666_1_En_4_Fig1_HTML.jpg

图 4-1

包含示例数据库链接的表

员工数据(employees数据库)是从朱塞佩·马霞(也被称为 Data Charmer)的 GitHub 存储库下载的,而其他数据库是从甲骨文的 MySQL 网站下载的。与雇员数据一起下载的还包括一个sakila数据库的副本。对于雇员数据、world数据库和sakila数据库,也有可用的文档。

Note

如果您没有使用最新版本的数据,则在安装测试数据库时,您可能会看到关于不推荐使用的功能的警告。您可以忽略这些警告,但是,建议您获取最新版本的数据。

menagerie数据库是一个很小的两表数据库,总共不到 20 行,是为 MySQL 手册中的教程部分创建的。不再赘述。

世界数据库

world样本数据库是简单测试中最常用的数据库之一。它由三个有几百到几千行的表组成。这使它成为一个小数据集,这意味着它甚至可以很容易地用于小的测试实例。

计划

数据库由citycountrycountrylanguage表组成。表格之间的关系如图 4-2 所示。

img/484666_1_En_4_Fig2_HTML.jpg

图 4-2

world数据库

country表包含关于 239 个国家的信息,并作为来自citycountrylanguage表的外键的父表。数据库中总共有 4079 个城市和 984 种国家和语言组合。

装置

下载的文件由一个名为world.sql.gzworld.sql.zip的文件组成,这取决于您选择的是 Gzip 还是 zip 链接。在这两种情况下,下载的档案包含一个文件world.sql。数据的安装非常简单,只需执行脚本即可。

如果您将 MySQL Shell 与 2020 年 1 月左右或之前的世界数据库副本一起使用,您将需要使用传统协议,因为 X 协议(默认)要求 UTF-8,而世界数据库使用拉丁 1。您使用\source命令从 MySQL Shell 加载数据:

MySQL [localhost ssl] SQL> \source world.sql

如果您使用传统的mysql命令行客户端,请使用SOURCE命令:

mysql> SOURCE world.sql

在这两种情况下,如果world.sql文件不在您启动 MySQL Shell 或mysql的目录中,请添加该文件的路径。

一个相关的数据库是world_x,它包含与world相同的数据,但是它的组织方式不同。

世界 x 数据库

MySQL 8 增加了对 MySQL Document Store 的支持,它支持以 JavaScript Object Notation (JSON)文档的形式存储和检索数据。world_x数据库将一些数据存储在 JSON 文档中,为您提供一个测试数据库,可以很容易地用于包含使用 JSON 的测试。

计划

world_x数据库包括与world数据库相同的三个表,尽管列略有不同,例如,city表包括 JSON 列Info和人口,而不是Population列,并且country表省略了几个列。取而代之的是countryinfo表,这是一个纯文档存储类型的表,其中的信息是从country表中删除的。模式图如图 4-3 所示。

img/484666_1_En_4_Fig3_HTML.jpg

图 4-3

world_x数据库

虽然citycountryinfo表中没有外键,但是可以分别使用CountryCode列和doc->>'$.Code'值将它们连接到country表。countryinfo表的_id列是一个存储生成列的例子,其中的值是从 JSON 文档的doc列中提取的。

装置

world_x数据库的安装与world数据库非常相似。你可以下载world_x-db.tar.gz或者world_x-db.zip文件并解压。提取的文件包括一个名为world_x.sql的文件和一个README文件。world_x.sql文件包括创建模式所需的所有语句。

由于world_x模式使用 UTF-8,您可以使用任何一种 MySQL 协议来安装它。例如,使用 MySQL Shell:

MySQL [localhost+ ssl] SQL> \source world_x.sql

如果world_x.sql文件不在当前目录中,则添加其路径。

worldworld_x数据库非常简单,易于使用;然而,有时你会需要一些稍微复杂一点的东西,而sakila数据库可以提供这些东西。

萨基拉数据库

sakila数据库是一个真实的数据库,它包含一个电影租赁业务的模式,其中包含关于电影、库存、商店、员工和客户的信息。它添加了一个全文索引、一个空间索引、视图和存储程序,以提供一个使用 MySQL 特性的更完整的示例。数据库大小仍然非常适中,适合小型实例。

计划

sakila数据库由 16 个表、7 个视图、3 个存储过程、3 个存储函数和 6 个触发器组成。这些表可以分为三组,客户数据、业务和库存。为了简洁起见,图中没有包括所有的列,大多数索引也没有显示。图 4-4 显示了表格、视图和存储程序的完整概览。

img/484666_1_En_4_Fig4_HTML.jpg

图 4-4

sakila数据库概述

包含客户相关数据的表格(加上员工和商店的地址)位于左上角的区域。左下角的区域包含与业务相关的数据,右上角的区域包含关于电影和库存的信息。右下角用于视图和存储的程序。

Tip

您可以通过在 MySQL Workbench 中打开安装中包含的sakila.mwb文件来查看整个图表(尽管格式不同)。这也是一个很好的例子,说明如何在 MySQL Workbench 中使用增强的实体关系(EER)图来记录您的模式。

由于对象的数量相对较多,所以在讨论模式时,将它们分成五组(每个表组、视图和存储例程)。第一组是客户相关数据,表格如图 4-5 所示。

img/484666_1_En_4_Fig5_HTML.jpg

图 4-5

sakila数据库中包含客户数据的表格

有四个表包含与客户相关的数据。customer表是主表,地址信息存储在addresscitycountry表中。

客户和业务组之间存在外键,外键从业务组的customer表指向store表。业务组中的表还有四个外键指向addresscustomer表。业务群如图 4-6 所示。

img/484666_1_En_4_Fig6_HTML.jpg

图 4-6

sakila数据库中包含业务数据的表

业务表包含关于商店、员工、租金和付款的信息。storestaff表有两个方向的外键,员工属于一个商店,而商店的经理是员工的一部分。租金和付款由员工处理,因此与商店间接相关,付款是为了租金。

表的业务组是与其它组关系最密切的组。staffstore表有address表的外键,而rentalpayment表引用客户。最后,rental表有一个外键指向库存组中的inventory表。库存组的示意图如图 4-7 所示。

img/484666_1_En_4_Fig7_HTML.jpg

图 4-7

sakila数据库中包含库存数据的表格

库存组中的主表是film表,它包含关于商店提供的电影的元数据。此外,还有一个带有标题和描述的film_text表,带有全文索引。

filmcategory以及actor表之间存在多对多的关系。最后,在业务组中有一个从inventory表到store表的外键。

这涵盖了sakila数据库中的所有表格,但也有一些如图 4-8 所示的视图。

img/484666_1_En_4_Fig8_HTML.jpg

图 4-8

sakila数据库中的视图

这些视图可以像报告一样使用,并且可以分为两类。film_listnicer_but_slower_film_listactor_info视图与存储在数据库中的电影相关。第二类包含与sales_by_storesales_by_film_categorystaff_listcustomer_list视图中的商店相关的信息。

为了完善数据库,还有如图 4-9 所示的存储函数和过程。

img/484666_1_En_4_Fig9_HTML.jpg

图 4-9

存储在sakila数据库中的程序

film_in_stock()film_not_in_stock()过程返回一个结果集,该结果集由给定电影和商店的库存 id 组成,基于电影是否有库存。找到的库存条目总数作为 out 参数返回。rewards_report()程序根据上个月的最低花费生成一份报告。

get_customer_balance()函数返回给定客户在给定数据上的余额。剩下的两个函数检查一个库存 id 的状态,其中inventory_held_by_customer()返回当前租赁该商品的客户的客户 id(如果没有客户租赁该商品,则返回NULL),如果您想检查给定的库存 id 是否有库存,可以使用inventory_in_stock()函数。

装置

下载的文件展开到一个包含三个文件的目录中,其中两个文件创建模式和数据,最后一个文件包含 MySQL Workbench 使用的格式的 ETL 图。

Note

本节和本书后面的例子使用了从 MySQL 主页下载的sakila数据库的副本。

这些文件是

  • sakila-data.sql : 填充表格所需的INSERT语句以及触发器定义。

  • sakila-schema.sql : 模式定义语句。

  • sakila.mwb:MySQL 工作台 ETL 图。这类似于图 4-4 所示,细节如图 4-54-9 所示。

通过首先获取sakila-schema.sql文件,然后获取sakila-data.sql文件来安装sakila数据库。例如,下面是使用 MySQL Shell:

MySQL [localhost+ ssl] SQL> \source sakila-schema.sql
MySQL [localhost+ ssl] SQL> \source sakila-data.sql

如果文件不在当前目录中,请添加文件的路径。

到目前为止,这三个数据集的共同点是它们包含的数据很少。虽然在许多情况下这是一个很好的特性,因为它使工作变得更容易,但是在某些情况下,您需要更多的数据来研究查询计划中的差异。employees数据库是一个具有更多数据的选项。

雇员数据库

employees数据库(在 MySQL 文档下载页面上称为雇员数据;GitHub 知识库的名字是test_db)最初是由王辅生和卡洛·扎尼奥洛创建的,是 MySQL 主页上链接的最大的测试数据集。对于非分区版本,数据文件的总大小约为 180 MiB,对于分区版本,约为 440 MiB。

计划

employees数据库由六个表和两个视图组成。您可以选择再安装两个视图、五个存储函数和两个存储过程。表格如图 4-10 所示。

img/484666_1_En_4_Fig10_HTML.jpg

图 4-10

employees数据库中的表格、视图和例程

可以选择让salariestitles表按照from_date列的年份进行分区,如清单 4-1 所示。

PARTITION BY RANGE  COLUMNS(from_date)
(PARTITION p01 VALUES LESS THAN ('1985-12-31') ENGINE = InnoDB,
 PARTITION p02 VALUES LESS THAN ('1986-12-31') ENGINE = InnoDB,
 PARTITION p03 VALUES LESS THAN ('1987-12-31') ENGINE = InnoDB,
 PARTITION p04 VALUES LESS THAN ('1988-12-31') ENGINE = InnoDB,
 PARTITION p05 VALUES LESS THAN ('1989-12-31') ENGINE = InnoDB,
 PARTITION p06 VALUES LESS THAN ('1990-12-31') ENGINE = InnoDB,
 PARTITION p07 VALUES LESS THAN ('1991-12-31') ENGINE = InnoDB,
 PARTITION p08 VALUES LESS THAN ('1992-12-31') ENGINE = InnoDB,
 PARTITION p09 VALUES LESS THAN ('1993-12-31') ENGINE = InnoDB,
 PARTITION p10 VALUES LESS THAN ('1994-12-31') ENGINE = InnoDB,
 PARTITION p11 VALUES LESS THAN ('1995-12-31') ENGINE = InnoDB,
 PARTITION p12 VALUES LESS THAN ('1996-12-31') ENGINE = InnoDB,
 PARTITION p13 VALUES LESS THAN ('1997-12-31') ENGINE = InnoDB,
 PARTITION p14 VALUES LESS THAN ('1998-12-31') ENGINE = InnoDB,
 PARTITION p15 VALUES LESS THAN ('1999-12-31') ENGINE = InnoDB,
 PARTITION p16 VALUES LESS THAN ('2000-12-31') ENGINE = InnoDB,
 PARTITION p17 VALUES LESS THAN ('2001-12-31') ENGINE = InnoDB,
 PARTITION p18 VALUES LESS THAN ('2002-12-31') ENGINE = InnoDB,
 PARTITION p19 VALUES LESS THAN (MAXVALUE) ENGINE = InnoDB)

Listing 4-1The optional partitioning of the salaries and titles tables

4-1 显示了employees数据库中表的行数和表空间文件的大小(请注意,当您加载数据时,大小可能会稍有变化)。该大小假定您加载了未分区的数据;分区表有点大。

表 4-1

employees数据库中每个表的大小

|

桌子

|

行数

|

表空间大小

|
| --- | --- | --- |
| departments | nine | 128 kiB |
| dept_emp | Three hundred and thirty-one thousand six hundred and three | 25600 千桶 |
| dept_manager | Twenty-four | 128 kiB |
| employees | Three hundred thousand and twenty-four | 22528 kiB |
| salaries | Two million eight hundred and forty-four thousand and forty-seven | 106496 kiB |
| titles | Four hundred and forty-three thousand three hundred and eight | 27648 kiB |

按照今天的标准,它仍然是一个相对较小的数据量,但是它足够大,您可以开始看到不同查询计划的一些性能差异。

4-11 总结了视图和程序。

img/484666_1_En_4_Fig11_HTML.jpg

图 4-11

employees数据库中的视图和例程

dept_emp_latest_datecurrent_dept_emp视图与表格一起安装,而其余的对象分别安装在objects.sql文件中。存储程序自带内置帮助,可通过使用employees_usage()功能或employees_help()程序获得。后者如清单 4-2 所示。

mysql> CALL employees_help()\G
*************************** 1\. row ***************************
info:
    == USAGE ==
    ====================

    PROCEDURE show_departments()

        shows the departments with the manager and
        number of employees per department

    FUNCTION current_manager (dept_id)

        Shows who is the manager of a given departmennt

    FUNCTION emp_name (emp_id)

        Shows name and surname of a given employee

    FUNCTION emp_dept_id (emp_id)

        Shows the current department of given employee

1 row in set (0.00 sec)

Query OK, 0 rows affected (0.02 sec)

Listing 4-2The built-in help for the stored routines in the employees database

装置

您可以下载一个包含安装所需文件的 ZIP 文件,也可以在 https://github.com/datacharmer/test_db 克隆 GitHub 库。在撰写本文时,只有一个名为master的分支。如果你已经下载了 ZIP 文件,它会解压到一个名为test_db-master的目录中。

有几个文件。在 MySQL 8 中与安装employees数据库相关的两个是employees.sqlemployees_partitioned.sql。区别在于salariestitles表是否被分区。(还有针对 MySQL 5.1 的employees_partitioned_5.1.sql,其中不支持employees_partitioned.sql中使用的分区方案。)

通过使用SOURCE命令获取.dump文件来加载数据。在撰写本文时,MySQL Shell 不支持SOURCE命令,因此您需要使用遗留的mysql命令行客户端来导入数据。转到源文件所在的目录,根据您是否想要使用分区,选择employees.sqlemployees_partitioned.sql文件,例如:

mysql> SOURCE employees.sql

导入需要一点时间,并通过显示花费的时间来完成:

+---------------------+
| data_load_time_diff |
+---------------------+
| 00:01:51            |
+---------------------+
1 row in set (0.44 sec)

或者,您可以通过获取objects.sql文件来加载一些额外的视图和存储的例程:

mysql> SOURCE objects.sql

除了这里讨论的数据集之外,还有其他一些选择来获取示例数据。

其他数据库

可能会发生这样的情况,您需要执行测试,这些测试需要的数据有一些需求是到目前为止所讨论的标准示例数据库无法满足的。幸运的是,还有其他选择。

Tip

不要忽视创建您自己的定制示例数据库的可能性,例如,通过对您的生产数据使用数据屏蔽。

如果你正在寻找一个非常大的真实世界的例子,那么你可以在 https://en.wikipedia.org/wiki/Wikipedia:Database_download 下载维基百科数据库。2019 年 9 月 20 日起的英文维基百科转储,bzip2 压缩 XML 格式,16.3 GiB。

如果您正在寻找 JSON 数据,那么一个选项是来自美国地质调查局(USGS)的地震信息,该信息以 GeoJSON 格式提供,可以下载过去一小时、一天、一周或一个月的地震信息,可以根据地震强度进行筛选。可以在 https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php 找到格式描述和提要链接。由于数据包含 GeoJSON 格式的地理信息,因此对于需要空间索引的测试非常有用。

前一章描述的基准工具也包括测试数据或者支持创建测试数据。这些数据也可能对您自己的测试有用。

如果您搜索互联网,还可以找到其他示例数据库。最后,要考虑的重要事情是数据对于您的测试是否有合适的大小,以及它是否使用了您需要的特性。

摘要

本章介绍了四个标准示例数据库和一些其他的测试数据示例。讨论的四个标准数据库是worldworld_xsakilaemployees。这些都可以在 https://dev.mysql.com/doc/index-other.html 通过 MySQL 手册找到。除employees外,这些数据库用于本书中的示例,除非另有说明。

worldworld_x数据库是最简单的,区别在于world_x使用 JSON 来存储一些信息,而world数据库是纯关系型的。这些数据库不包含太多的数据,但是由于它们的小尺寸和简单性,它们对于简单的测试和例子是有用的。特别是world数据库在本书中被广泛使用。

sakila数据库有一个更复杂的模式,包括不同的索引类型、视图和存储例程。这使得它更现实,并允许更复杂的测试。然而,数据的大小仍然足够小,甚至可以在小型 MySQL 实例上使用。它在本书中也被广泛使用。

employees数据库的模式在复杂性上介于worldsakila数据库之间,但是有更多的数据,这使得它更适合测试各种查询计划之间的差异。如果您需要在实例上生成一些负载,例如使用表扫描,这也很有用。本书中没有直接使用employees数据库,但是如果您想要重现一些需要加载的示例,那么这是四个标准测试数据库中最好的一个。

您不应该限制自己去考虑标准的测试数据库。您可以创建自己的数据库,使用基准工具创建一个,或者在互联网上查找可用的数据。维基百科的数据库和美国地质调查局(USGS)的地震数据都是可以下载的数据。

这就完成了 MySQL 查询性能调优的介绍。第二部分从性能模式开始,介绍了与诊断性能问题相关的常见信息源。

五、性能模式

性能模式是 MySQL 中与性能相关的诊断信息的主要来源。它最初是在 MySQL 5.5 版本中引入的,然后在 5.6 版本中被大量修改为当前的结构,此后在 5.7 和 8 版本中逐渐得到改进。

本章介绍并概述了性能模式,因此在本书的其余部分使用性能模式时,它是如何工作的就很清楚了。与性能模式密切相关的是将在下一章讨论的 sys 模式和第 7 章的主题信息模式。

本章讨论了性能模式特有的概念,特别关注线程、工具、使用者、事件、摘要和动态配置。但是,首先有必要熟悉性能模式中使用的术语。

术语

在学习一门新学科时,术语是很难的事情之一,性能模式也不例外。由于术语之间几乎是循环关系,所以没有明确的顺序来描述它们。相反,本节将提供本章中使用的最重要术语的简要概述,以便您了解这些术语的含义。到本章结束时,你应该能更好地理解这些概念的含义以及它们之间的关系。

5-1 总结了性能模式中最重要的术语。

表 5-1

MySQL 性能模式术语

|

学期

|

描述

|
| --- | --- |
| 行动者 | 用户名和主机名的组合(帐户)。 |
| 消费者 | 收集由仪器产生的数据的过程。 |
| 摘要 | 规范化查询的校验和。摘要用于聚集相似查询的统计数据。 |
| 动态配置 | 性能模式可以在运行时配置,这称为动态配置。这是通过设置表完成的,而不是通过改变系统变量。 |
| 事件 | 事件是由消费者仪器收集数据而产生的。因此,一个事件包含度量和关于度量在何时何地被收集的信息。 |
| 工具 | 进行测量的代码点。 |
| 目标 | 表、事件、函数、过程或触发器。 |
| 设置表 | 性能模式有几个用于动态配置的表。这些被称为设置表,表名以setup_开头。 |
| 一览表 | 包含汇总数据的表。表名包括单词 summary,名称的其余部分表示数据的类型和分组依据。 |
| 线 | 线程对应于连接或后台线程。性能模式线程和操作系统线程之间是一一对应的。 |

当你阅读这一章时,如果你遇到不确定其含义的术语,参考这个表会很有用。

线

线程是性能模式中的一个基本概念。当在 MySQL 中做任何事情时,无论是处理连接还是执行后台工作,工作都是由线程完成的。MySQL 在任何时候都有几个线程,因为它允许 MySQL 并行执行工作。对于连接,只有一个线程。

Note

InnoDB 中引入了对执行聚集索引和分区的并行读取的支持,这在一定程度上混淆了一个连接一个线程的画面。但是,由于执行并行扫描的线程被认为是后台线程,因此对于本讨论,您可以将连接视为单线程。

每个线程都有一个唯一标识该线程的 id,在性能模式表中存储该 id 的列称为THREAD_ID。检查线程的主表是清单 5-1 中的threads表,展示了 MySQL 8 中存在的线程类型的典型示例。可用的线程数量和确切的线程类型取决于查询threads表时实例的配置和使用情况。

mysql> SELECT THREAD_ID AS TID,
              SUBSTRING_INDEX(NAME, '/', -2) AS THREAD_NAME,
              IF(TYPE = 'BACKGROUND', '*', ") AS B,
              IFNULL(PROCESSLIST_ID, ") AS PID
         FROM performance_schema.threads;
+-----+--------------------------------------+---+-----+
| TID | THREAD_NAME                          | B | PID |
+-----+--------------------------------------+---+-----+
|   1 | sql/main                             | * |     |
|   2 | mysys/thread_timer_notifier          | * |     |
|   4 | innodb/io_ibuf_thread                | * |     |
|   5 | innodb/io_log_thread                 | * |     |
|   6 | innodb/io_read_thread                | * |     |
|   7 | innodb/io_read_thread                | * |     |
|   8 | innodb/io_read_thread                | * |     |
|   9 | innodb/io_read_thread                | * |     |
|  10 | innodb/io_write_thread               | * |     |
|  11 | innodb/io_write_thread               | * |     |
|  12 | innodb/io_write_thread               | * |     |
|  13 | innodb/io_write_thread               | * |     |
|  14 | innodb/page_flush_coordinator_thread | * |     |
|  15 | innodb/log_checkpointer_thread       | * |     |
|  16 | innodb/log_closer_thread             | * |     |
|  17 | innodb/log_flush_notifier_thread     | * |     |
|  18 | innodb/log_flusher_thread            | * |     |
|  19 | innodb/log_write_notifier_thread     | * |     |
|  20 | innodb/log_writer_thread             | * |     |
|  21 | innodb/srv_lock_timeout_thread       | * |     |
|  22 | innodb/srv_error_monitor_thread      | * |     |
|  23 | innodb/srv_monitor_thread            | * |     |
|  24 | innodb/buf_resize_thread             | * |     |
|  25 | innodb/srv_master_thread             | * |     |
|  26 | innodb/dict_stats_thread             | * |     |
|  27 | innodb/fts_optimize_thread           | * |     |

|  28 | mysqlx/worker                        |   | 9   |
|  29 | mysqlx/acceptor_network              | * |     |
|  30 | mysqlx/acceptor_network              | * |     |
|  31 | mysqlx/worker                        | * |     |
|  34 | innodb/buf_dump_thread               | * |     |
|  35 | innodb/clone_gtid_thread             | * |     |
|  36 | innodb/srv_purge_thread              | * |     |
|  37 | innodb/srv_purge_thread              | * |     |
|  38 | innodb/srv_worker_thread             | * |     |
|  39 | innodb/srv_worker_thread             | * |     |
|  40 | innodb/srv_worker_thread             | * |     |
|  41 | innodb/srv_worker_thread             | * |     |
|  42 | innodb/srv_worker_thread             | * |     |
|  43 | innodb/srv_worker_thread             | * |     |
|  44 | sql/event_scheduler                  |   | 4   |
|  45 | sql/compress_gtid_table              |   | 6   |
|  46 | sql/con_sockets                      | * |     |
|  47 | sql/one_connection                   |   | 7   |
|  48 | mysqlx/acceptor_network              | * |     |
|  49 | innodb/parallel_read_thread          | * |     |
|  50 | innodb/parallel_read_thread          | * |     |
|  51 | innodb/parallel_read_thread          | * |     |
|  52 | innodb/parallel_read_thread          | * |     |
+-----+--------------------------------------+---+-----+

49 rows in set (0.0615 sec)

Listing 5-1Threads in MySQL 8

TID列是每个线程的THREAD_ID,THREAD_NAME列包括线程名称的最后两个部分(第一个部分是所有线程的thread),B列有一个星号表示后台线程,PID列有前台线程的进程列表 id。

Note

不幸的是,术语 thread 在 MySQL 中被重载了,在某些地方被用作连接的同义词。在本书中,连接是指用户连接,线程是指性能模式线程,也就是说,它可以是后台或前台(包括连接)线程。例外情况是当讨论一个明显违反该约定的表时。

线程列表显示了线程的几个重要概念。进程列表 id 和线程 id 不相关。事实上,线程 id = 28 的线程的进程列表 id (9)比线程 id 为 44 的线程的进程列表 id(4)高。因此,甚至不能保证顺序是相同的(尽管对于非mysqlx线程来说,通常是这样)。

对于mysqlx/worker线程,一个是前台线程,另一个是后台线程。这反映了 MySQL 如何使用 X 协议处理连接,这与传统的连接处理方式有很大的不同。

还有一些“混合”线程既不是完全的后台线程,也不是完全的前台线程。压缩mysql.gtid_executed表的sql/compress_gtid_table线程就是一个例子。它是一个前台线程,但是如果你执行SHOW PROCESSLIST,它将不会被包含。

Tip

performance_schema.threads表非常有用,也包含了SHOW PROCESSLIST显示的所有信息。因为与执行SHOW PROCESSLIST或查询information_schema.PROCESSLIST表相比,查询该表的开销更小,所以推荐使用线程表以及sys.processlistsys.session视图来获得连接列表。

获取连接的线程 id 有时会很有用。这有两个功能:

  • PS_THREAD_ID() : 获取作为参数提供的连接 id 的性能模式线程 id。

  • PS_CURRENT_THREAD_ID() : 获取当前连接的性能模式线程 id。

在 MySQL 8.0.15 和更早的版本中,使用sys.ps_thread_id()并给出一个参数NULL来获取当前连接的线程 id。使用这些函数的一个例子是

mysql> SELECT CONNECTION_ID(),
              PS_THREAD_ID(13),
              PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
       CONNECTION_ID(): 13
      PS_THREAD_ID(13): 54
PS_CURRENT_THREAD_ID(): 54
1 row in set (0.0003 sec)

使用这些函数相当于查询performance_schema.threads表中的PROCESSLIST_IDTHREAD_ID列来链接一个连接 id 和一个线程 id。清单 5-2 展示了一个使用PS_CURRENT_THREAD_ID()函数查询当前连接的threads表的例子。

mysql> SELECT *
         FROM performance_schema.threads
        WHERE THREAD_ID = PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
          THREAD_ID: 54
               NAME: thread/mysqlx/worker
               TYPE: FOREGROUND
     PROCESSLIST_ID: 13
   PROCESSLIST_USER: root
   PROCESSLIST_HOST: localhost
     PROCESSLIST_DB: performance_schema
PROCESSLIST_COMMAND: Query
   PROCESSLIST_TIME: 0
  PROCESSLIST_STATE: statistics
   PROCESSLIST_INFO: SELECT *
         FROM threads
        WHERE THREAD_ID = PS_CURRENT_THREAD_ID()
   PARENT_THREAD_ID: 1
               ROLE: NULL
       INSTRUMENTED: YES
            HISTORY: YES
    CONNECTION_TYPE: SSL/TLS
       THREAD_OS_ID: 31516
     RESOURCE_GROUP: SYS_default
1 row in set (0.0005 sec)

Listing 5-2Querying the threads table for the current connection

有几栏提供了有关性能调优的有用信息,将在后面的章节中使用。这里值得注意的是名称以PROCESSLIST_开头的列。这些等同于由SHOW PROCESSLIST返回的信息,但是查询threads表对连接的影响较小。INSTRUMENTEDHISTORY列指定是否为线程收集指令数据,以及是否为线程保存事件历史。您可以更新这两列来改变线程的行为,或者您可以基于setup_threads表中的线程类型或者基于使用setup_actors表的帐户来定义线程的默认行为。这就回避了乐器和事件是什么的问题。接下来的三个部分将讨论这一点以及如何使用这些工具。

工具

仪器是进行测量的代码点。有两种类型的工具:可以定时的和不能定时的。定时工具是事件和idle工具(当线程空闲时测量),而非定时工具计算错误和内存使用。

仪器按其名称分组,形成一个层次结构,组件之间用/分隔。一个名字有多少个组成部分没有规则,有些只有一个组成部分,而另一些有多达五个组成部分。

工具名的一个例子是statement/sql/select,它代表直接执行的SELECT语句(即,不是从存储过程中执行的)。另一个工具是statement/sp/stmt,它是一个在存储过程中执行的语句。

随着新特性的增加,以及更多的检测点被插入到现有代码中,检测的数量也在不断增加。在 MySQL 8.0.18 中,没有安装额外的插件或组件时,大约有 1229 个工具(工具的确切数量也取决于平台)。这些仪器在表 5-2 中列出的顶级组件中进行拆分。定时栏显示仪器是否可以定时,计数栏显示该顶层组件的仪器总数以及 8.0.18 中默认启用的仪器数量。

表 5-2

MySQL 8.0.18 中的顶级仪器组件

|

成分

|

定时的

|

数数

|

描述

|
| --- | --- | --- | --- |
| error | 不 | 总计:1 已启用:1 | 是否收集有关遇到的错误和警告的信息。没有子组件。 |
| idle | 是 | 总计:1 已启用:1 | 用于检测线程何时空闲。没有子组件。 |
| memory | 不 | 总数:511 启用:511 | 收集内存分配和释放的数量和大小。名称由三部分组成:memory、代码区和仪器名称。 |
| stage | 是 | 总数:119 已启用:16 | 收集有关查询阶段事件的信息。这些名称有三个组成部分:stage、代码区和阶段名。 |
| statement | 是 | 总数:212 已启用:212 | 收集关于语句事件的信息。有一到两个子组件。 |
| transaction | 是 | 总计:1 已启用:1 | 收集有关事务事件的信息。没有子组件。 |
| wait | 是 | 总数:384 已启用:52 | 收集有关最低级别事件的等待事件的信息。例如,这包括获取锁和互斥锁以及执行 I/O 操作 |

命名方案使得确定仪器测量的内容相对容易。您可以在setup_instruments表中找到所有可用的仪器,该表还允许您配置仪器是否启用和定时。对于某些仪器,还有一个简短的文档,记录了该仪器收集的数据。

如果您想在 MySQL 启动时启用或禁用工具,您可以使用performance-schema-instrument选项。它的工作方式与大多数选项不同,因为您可以多次指定它来更改几个仪器的设置,并且您可以使用%通配符来匹配模式。如何使用该选项的示例如下

[mysqld]
performance-schema-instrument = "stage/sql/altering table=ON"
performance-schema-instrument = "memory/%=COUNTED"

第一个选项启用stage/sql/altering table乐器的计数和计时,而第二个选项启用所有记忆乐器的计数(这也是默认设置)。

Caution

启用所有工具(以及接下来讨论的消费者)似乎很诱人。但是,检测和使用的越多,开销就越大。启用所有功能实际上会导致停机(本书的作者已经看到了这种情况)。特别是,wait/synch/%仪器和events_waits_%消费者增加了开销。根据经验,监控的粒度越细,增加的开销就越多。在大多数情况下,MySQL 8 中的默认设置在可观察性和开销之间提供了一个很好的折衷。

仪器生成的数据必须被使用,以便这些数据在性能模式表中可用。这是消费者做的。

顾客

消费者处理由仪器生成的数据,并使其在性能模式表中可用。消费者在setup_consumers表中定义,除了消费者名称之外,该表还有一列指定消费者是否被启用。

消费者形成如图 5-1 所示的层次结构。该图分为两部分,虚线上方为高级消费者,虚线下方为事件消费者。默认情况下,绿色(浅色)用户处于启用状态,红色(深色)用户处于禁用状态。

img/484666_1_En_5_Fig1_HTML.png

图 5-1

消费者等级制度

消费者形成一个层次结构意味着消费者只有在它自己和层次结构中所有更高的消费者都被启用的情况下才消费事件。因此,禁用global_instrumentation消费者实际上禁用了所有消费者。您可以使用sys模式函数ps_is_consumer_enabled()来确定消费者及其依赖的消费者是否被启用,例如:

mysql> SELECT sys.ps_is_consumer_enabled(
                  'events_statements_history'
              ) AS IsEnabled;
+-----------+
| IsEnabled |
+-----------+
| YES       |
+-----------+
1 row in set (0.0005 sec)

statements_digest消费者负责收集按语句摘要分组的数据,例如,通过events_statements_summary_by_digest表提供的数据。对于查询性能调优,这可能是最重要的消费者。它只取决于全球消费者。thread_instrumentation使用者确定线程是否正在收集特定于线程的测量数据。它还控制是否有任何事件消费者收集数据。

对于消费者,每个消费者有一个配置选项,选项名称由前缀performance-schema-consumer-和消费者名称组成,例如:

[mysqld]
performance-schema-consumer-events-statements-history-long = ON

这将使events_statements_history_long消费者。

您很少需要考虑禁用三个高级消费者中的任何一个。事件消费者通常是专门配置的,并且将与事件的概念一起讨论。

事件

事件是消费者记录仪器收集的数据的结果,您可以用它来观察 MySQL 中正在发生的事情。有几种事件类型,并且事件是相互关联的,因此通常一个事件既有一个父事件,又有一个或多个子事件。本节介绍事件是如何工作的。

事件类型

有四种事件类型,涵盖了从事务到等待的各种级别的细节。事件类型还将相似类型的事件分组,为事件收集的信息取决于其类型。例如,表示语句执行的事件包括查询和检查了多少行,而事务的事件包含诸如请求的事务隔离级别之类的信息。事件类型如图 5-2 所示。

img/484666_1_En_5_Fig2_HTML.jpg

图 5-2

四种事件类型

事件对应于不同级别的详细信息,事务是最高级别(最低级别的详细信息),等待事件是最低级别(最高级别的详细信息):

  • 事务:事件描述事务,包括诸如请求的事务隔离级别(但不一定使用)、事务状态等细节。默认情况下,收集每个线程的当前和最后十个事务。

  • 语句:这是最常用的事件类型,包含有关所执行查询的信息。它还包括有关在存储过程中执行的语句的信息。这包括检查的行数、返回的行数、是否使用了索引以及执行时间等信息。默认情况下,收集每个线程的当前和最后十条语句。

  • 阶段:这大致对应于SHOW PROCESSLIST报告的状态。这些在默认情况下是不启用的(InnoDB 进度信息是部分例外)。

  • 等待:这些是低级事件,包括 I/O 和等待互斥。这些非常具体,对于低级性能调优非常有用,但也是最昂贵的。默认情况下,不会启用任何等待事件使用者。

还有一个问题是记录的事件要保存多久。

事件范围

对于每种事件类型,都有三个使用者,它们指定了被使用事件的生存期。范围是

  • current: 当前正在进行的事件,以及空闲线程最后完成的事件。在某些情况下,同一级别的事件可能不止一个。例如,当执行存储过程时,过程本身既有语句事件,又有当前正在过程中执行的语句。

  • 历史:每个线程的最后十个(默认)事件。当线程关闭时,事件被丢弃。

  • history_long: 最后 10,000 个(默认)事件,不考虑生成事件的线程。即使在线程关闭后,事件仍会保留。

事件类型和范围共同构成了 12 个事件消费者。每个事件消费者都对应一个性能模式表,表名与消费者名相同,如清单 5-3 所示。

mysql> SELECT TABLE_NAME
         FROM performance_schema.setup_consumers c
              INNER JOIN information_schema.TABLES t
                 ON t.TABLE_NAME = c.NAME
        WHERE t.TABLE_SCHEMA = 'performance_schema'
              AND c.NAME LIKE 'events%'
        ORDER BY c.NAME;
+----------------------------------+
| TABLE_NAME                       |
+----------------------------------+
| events_stages_current            |
| events_stages_history            |
| events_stages_history_long       |
| events_statements_current        |
| events_statements_history        |
| events_statements_history_long   |
| events_transactions_current      |
| events_transactions_history      |
| events_transactions_history_long |
| events_waits_current             |
| events_waits_history             |
| events_waits_history_long        |
+----------------------------------+
12 rows in set (0.0323 sec)

Listing 5-3The correspondence between consumer and table names

如图 5-2 中事件类型之间的箭头所暗示的,类型之间的关系超出了它们所代表的细节层次。这种关系不是层次结构,而是由事件嵌套组成。

事件嵌套

一般来说,事件是由其他事件生成的,所以事件形成一个树,每个事件有一个父事件,可能还有许多子事件。虽然看起来事件类型形成了一个层次结构,例如,事务是语句的父级,但关系要比这复杂得多,而且是双向的。以开始一个事务的START TRANSACTION语句为例,该语句成为该事务的父级,而该事务又是其他语句的父级。另一个例子是调用存储过程的CALL语句,该存储过程成为在该过程中执行的语句的父级。

嵌套会变得非常复杂。图 5-3 显示了包括所有四种事件类型的事件链示例。

img/484666_1_En_5_Fig3_HTML.png

图 5-3

一连串事件的例子

对于语句事件,显示实际的查询,而对于其他事件类型,显示事件名称或事件名称的一部分。这个链从启动一个事务的START TRANSACTION语句开始。在事务内部,调用了myproc()过程,这使得它成为了SELECT语句的父语句,该语句经历了包括stage/sql/statistics在内的几个阶段,该阶段又包括请求 InnoDB 中的trx_mutex

事件表有两列用于跟踪事件之间的关系:

  • NESTING_EVENT_ID : 父事件 id

  • NESTING_EVENT_TYPE : 父事件的事件类型(TRANSACTIONSTATEMENTSTAGEWAIT)

语句事件表有一些与嵌套语句事件相关的附加列:

  • OBJECT_TYPE : 父语句事件的对象类型。

  • OBJECT_SCHEMA : 存储父语句对象的模式。

  • OBJECT_NAME : 父报表对象的名称。

  • NESTING_EVENT_LEVEL : 语句嵌套有多深。最顶层语句的级别为 0,每创建一个子级别,NESTING_EVENT_LEVEL就加 1。

sys.ps_trace_thread()过程是如何自动生成事件树的一个很好的例子。在第 20 章中有一个使用ps_trace_thread()的例子。

事件属性

无论事件的类型如何,它们都有一些共同的属性。这些属性包括主键、事件 id 和事件的计时方式。

事件的当前和历史(但不是长期历史)表的主键由THREAD_IDEVENT_ID列组成。随着线程创建更多的事件,EVENT_ID列会增加,所以如果您想让事件按顺序排列,您必须按EVENT_ID排序。每个线程都有自己的事件 id 序列。每个事件表中都有两个事件 id 列:

  • EVENT_ID : 这是事件的主事件 id,在事件开始时设置。

  • END_EVENT_ID : 事件结束时设置该 id。这意味着您可以通过检查END_EVENT_ID列是否为NULL来确定事件是否正在进行中。

此外,EVENT_NAME列包含负责该事件的仪器的名称,而用于语句、阶段和等待的SOURCE列包含仪器触发的源代码中的文件名和行号。

有三列与记录事件开始、结束和持续时间的事件计时相关:

  • TIMER_START : 当 MySQL 启动时,内部定时器计数器设置为 0,每皮秒递增一次。当一个事件开始时,计数器的值被取并赋给 TIMER_START。但是,由于单位是皮秒,计数器可能会达到支持的最大值(大约 30.5 周后发生),在这种情况下,计数器会再次从 0 开始计数。

  • TIMER_END : 对于正在进行的事件,这是当前时间,对于已完成的事件,这是事件完成的时间。

  • TIMER_WAIT : 这是事件的持续时间。对于仍在进行中的事件,它是自事件开始以来的时间量。

不包括计时的事务除外。

Note

不同的事件类型使用不同的计时器,因此您不能使用TIMER_STARTTIMER_END列来对不同类型的事件进行排序。

计时以皮秒为单位(10 -12 秒)。选择这个单位是出于性能原因,因为它允许 MySQL 在尽可能多的情况下使用乘法(最便宜的数学运算和加法)。计时列是 64 位无符号整数,这意味着它们将在大约 30.5 周后溢出,此时值又从 0 开始。

虽然从计算的角度来看,使用皮秒是好的,但对人类来说不太实际。因此,函数FORMAT_PICO_TIME()的作用是将皮秒转换成人类可读的格式,例如:

SELECT FORMAT_PICO_TIME(111577500000);
+--------------------------------+
| FORMAT_PICO_TIME(111577500000) |
+--------------------------------+
| 111.58 ms                      |
+--------------------------------+
1 row in set (0.0004 sec)

MySQL 8.0.16 中增加了该功能。在早期版本中,您需要使用sys.format_time()函数来代替。

演员和对象

性能模式允许您配置默认情况下应该检测哪些用户帐户和模式对象。账户通过setup_actors表配置,对象通过setup_objects表配置。默认情况下,除了mysqlinformation_schemaperformance_schema系统模式中的对象之外,所有帐户和所有模式对象都会被检测。

摘要

性能模式为基于语句摘要执行的语句生成统计信息。这是基于规范化查询的阿沙-256 哈希。具有相同摘要的语句被视为相同的查询。

规范化包括删除注释(但不包括优化器提示),将空格改为单空格字符,用问号替换WHERE子句中的值,等等。您可以使用函数STATEMENT_DIGEST_TEXT()来获得规范化的查询,例如:

mysql> SELECT STATEMENT_DIGEST_TEXT(
                 'SELECT *
                    FROM city
                   WHERE ID = 130'
              ) AS DigestText\G
*************************** 1\. row ***************************
DigestText: SELECT * FROM `city` WHERE `ID` = ?
1 row in set (0.0004 sec)

类似地,您可以使用STATEMENT_DIGEST()函数来获取查询的 SHA-256 散列:

mysql> SELECT STATEMENT_DIGEST(
                 'SELECT *
                    FROM city
                   WHERE ID = 130'
              ) AS Digest\G
*************************** 1\. row ***************************
Digest: 26b06a0b2f651e04e61751c55f84d0d721d31041ea57cef5998bc475ab9ef773
1 row in set (0.0004 sec)

例如,如果您想要查询一个语句事件表,即events_statements_histogram_by_digest表或events_statements_summary_by_digest表,以找到关于具有相同摘要的查询的信息,那么STATEMENT_DIGEST()函数会很有用。

Note

升级 MySQL 时,不能保证给定查询的摘要保持不变。这意味着您不应该比较不同 MySQL 版本的摘要。

当 MySQL 计算摘要时,查询被标记化,为了避免过多的内存使用,这个过程允许的每个连接的内存量是有上限的。这意味着,如果您有大型查询(就查询文本而言),规范化查询(称为摘要文本)将被截断。您可以使用max_digest_length变量配置在规范化过程中允许连接为令牌使用多少内存(默认为 1024,需要重启 MySQL)。如果您有大型查询,您可能需要增加这个值,以避免长度超过max_digest_length字节的查询之间的冲突。如果您增加了max_digest_length,您可能还想增加performance_schema_max_digest_length选项,它指定了存储在性能模式中的摘要文本的最大长度。但是,要小心,因为这将增加存储在性能模式中的所有摘要文本值的大小,并且由于性能模式表存储在内存中,这可能会导致内存使用的显著增加。作者已经看到了几个支持票,其中 MySQL 无法启动,因为摘要长度设置得太高,所以 MySQL 耗尽了内存。

Caution

不要盲目地增加摘要长度选项,否则可能会耗尽内存。

表格类型

您已经遇到了性能模式中可用的一些表。这些表可以根据它们包含的信息类型进行分组,本章前面提到的设置表和事件表构成了其中的两个组。表 5-3 总结了从 MySQL 8.0.18 开始可用的表的类型。

表 5-3

性能模式表类型

|

表格类型

|

描述

|
| --- | --- |
| 设置 | 具有动态配置的表。这包括setup_consumerssetup_instruments。所有设置表的名称都以setup_.开头 |
| 事件 | 存储当前正在进行的或历史的单个事件的表。这包括events_statements_current。所有表都与其中一个事件使用者同名。常见的还有表名以events_开头,但不包括summaryhistogram。 |
| 情况 | 实例表包含从互斥到预准备语句的实例信息。最常用的实例表是prepared_statements_instances,它包含服务器端准备语句的统计信息。除了table_handles之外,所有实例表都有以_instances结尾的表名。 |
| 摘要 | 汇总表可以被认为是一种报告。它们汇总事件表中的事件,因此您可以获得更长期的概述。最常用的汇总表是events_statements_summary_by_digest,它按照默认模式和语句摘要对语句事件数据进行分组。汇总表的另一个例子是file_summary_by_instance,它根据文件实例对与文件相关的统计数据进行分组。所有表名都包含_summary_或以status_开头。表名还包括_by_,后跟数据分组依据的描述。从 8.0.18 开始,有 45 个汇总表,这是最大的一组表。 |
| 柱状图 | 直方图表是类似于汇总表的报告表,但提供了语句延迟的直方图统计。目前有两种直方图表格:events_statements_histogram_by_digestevents_statements_histogram_global。 |
| 连接和螺纹 | 各种包含连接和线程信息的表格。这包括threadssession_account_connect_attrssession_connect_attrsaccountshost_cachehostsusers表格。 |
| 复制 | 关于传统异步复制和组复制的复制配置和状态的信息。除了log_status以外的所有表名都以replication_开头。 |
| 锁 | 这个组包括三个表,其中包含关于数据和元数据锁的信息:data_locksdata_lock_waitsmetadata_locks。 |
| 可变的 | 变量表包含关于系统和状态变量(全局和会话范围)以及用户变量的信息。所有的表名都包含单词variablesstatus。 |
| 克隆 | 使用克隆插件时有关状态和进度的信息。表格包括clone_progressclone_status。 |
| 多方面的 | keyring_keysperformance_timers表。 |

最常用的表格是汇总表,因为它们提供了对数据的简单访问,这些数据本身可以用作报告,类似于您将在下一章的sys模式视图中看到的内容。

动态配置

除了可以使用SET PERSIST_ONLY或在配置文件中设置的传统 MySQL 配置选项之外,性能模式还通过设置表提供了自己独特的动态配置。本节解释了动态配置的工作原理。

5-4 列出了 MySQL 8 中可用的设置表。对于允许插入和删除的表,所有列都可以更改,但对于可设置的列,只列出非键列。

表 5-4

绩效模式设置表

|

设置表

|

关键列

|

可设置列

|

描述

|
| --- | --- | --- | --- |
| setup_actors | HOST``USER``ROLE | ENABLED``HISTORY | 此表用于确定前台线程是否被检测,以及是否根据帐户默认收集了历史记录。ROLE列目前未被使用。您可以在该表中插入和删除行。 |
| setup_consumers | NAME | ENABLED | 此表定义了启用哪些使用者。 |
| setup_instruments | NAME | ENABLED``TIMED | 此表定义了启用和定时的仪器。 |
| setup_objects | OBJECT_TYPE``OBJECT_SCHEMA``OBJECT_NAME`` | ENABLEDTIMED` | 此表定义了启用和计时的模式对象。您可以在该表中插入和删除行。 | | `setup_threads` | `NAME` | `ENABLEDHISTORY` | 此表定义了缺省情况下检测哪些线程类型并收集历史记录。 |

对于带有HISTORY列的表格,只有在仪器也启用的情况下才能记录历史。同样,对于TIMED栏,仅当仪器或对象被启用时才相关。对于setup_instruments,注意不是所有的仪器都支持计时,在这种情况下TIMED栏总是NULL

在设置表中,setup_actorssetup_objects表是特殊的,因为您可以为它们插入和删除行。这包括使用TRUNCATE TABLE语句删除所有行。因为表存储在内存中,所以不能随意插入任意多的行。相反,最大行数由performance_schema_setup_actors_sizeperformance_schema_setup_objects_size配置选项定义。默认情况下,两个选项都是自动调整大小的。需要重启 MySQL 来使对表大小的更改生效。

您使用常规的UPDATE语句来操作配置。对于setup_actorssetup_objects表,您也可以使用INSERTDELETETRUNCATE TABLE。启用events_statements_history_long消费者的一个例子是

mysql> UPDATE performance_schema.setup_consumers
          SET ENABLED = 'YES'
        WHERE NAME = 'events_statements_history_long';
Query OK, 1 row affected (0.2674 sec)

Rows matched: 1  Changed: 1  Warnings: 0

当重新启动 MySQL 时,这种配置不是持久的,因此如果您想在没有配置选项的情况下更改这些表的配置,可以将所需的 SQL 语句添加到一个 init 文件中,并通过init_file选项执行它。

对性能模式的介绍到此结束,但是您将在本书的剩余部分看到许多使用这些表的例子。

摘要

本章涵盖了性能模式中最重要的概念。MySQL 是一个多线程进程,性能模式包括所有线程的信息,包括前台线程(连接)和后台线程。

工具对应于源代码中被检测的代码点,从而决定收集哪些数据。启用仪器时,除了记忆和错误仪器外,还可以选择对其进行计时。

消费者获取仪器收集的数据,对其进行处理,并通过性能模式表使其可用。十二个使用者代表四种事件类型,每种类型有三个作用域。

这四种事件类型是事务、语句、阶段和等待,涵盖不同的详细级别。这三个事件范围是当前或最后完成的事件的当前范围、仍然存在的每个线程的最后十个事件的历史范围以及最后 10,000 个事件的历史范围,而不考虑生成它们的线程。事件可以触发其他事件,所以它们形成一棵树。

一个重要的概念是摘要,它允许 MySQL 通过规范化查询来聚合数据分组。当您要寻找查询调优的候选对象时,这个特性将被证明是特别有用的。

最后,总结了性能模式中的各种类型的表。最常用的一组表是汇总表,它本质上是报告,使从性能模式中访问聚合数据变得容易。基于性能模式的报告的另一个例子——在一些汇总表的情况下——是在sys模式中可用的信息,这是下一章的主题。

六、sys模式

这个模式是马克·利斯的创意,他也是 MySQL 企业监控器开发团队的一员。他启动了ps_helper项目来试验监控思想,并展示性能模式能够做什么,同时使它变得更简单。该项目后来被重命名为sys模式,并被转移到 MySQL 中。从那以后,包括本书作者在内的其他几个人也做出了贡献。

sys模式适用于 MySQL Server 5.6 和更高版本。在 MySQL 5.7 中,它成为标准安装的一部分,因此您不需要做任何事情来安装或升级sys模式。从 MySQL 8.0.18 开始,sys模式源代码是 MySQL 服务器源代码的一部分。

本书通篇使用了sys模式来分析查询、锁等等。本章将给出sys模式的高级概述,包括如何配置它、格式化功能、视图如何工作以及各种助手例程。

Tip

sys模式源代码( https://github.com/mysql/mysql-server/tree/8.0/scripts/sys_schema 和旧的 MySQL 版本 https://github.com/mysql/mysql-sys/ )也是学习如何针对性能模式编写查询的有用资源。

sys 模式配置

sys模式使用自己的配置系统,因为它最初是独立于 MySQL 服务器实现的。有两种方法可以更改配置,具体取决于您是要永久更改设置还是仅针对会话更改设置。

持久化配置存储在sys_config表中,该表包括变量名、变量值、最后一次设置值的时间和用户。清单 6-1 显示了默认内容(set_time将取决于sys模式最后一次安装或升级的时间)。

mysql> SELECT * FROM sys.sys_config\G
*************************** 1\. row ***************************
variable: diagnostics.allow_i_s_tables
   value: OFF
set_time: 2019-07-13 19:19:29
  set_by: NULL
*************************** 2\. row ***************************
variable: diagnostics.include_raw
   value: OFF
set_time: 2019-07-13 19:19:29
  set_by: NULL
*************************** 3\. row ***************************
variable: ps_thread_trx_info.max_length
   value: 65535
set_time: 2019-07-13 19:19:29
  set_by: NULL
*************************** 4\. row ***************************
variable: statement_performance_analyzer.limit
   value: 100
set_time: 2019-07-13 19:19:29
  set_by: NULL
*************************** 5\. row ***************************
variable: statement_performance_analyzer.view
   value: NULL
set_time: 2019-07-13 19:19:29
  set_by: NULL
*************************** 6\. row ***************************
variable: statement_truncate_len
   value: 64
set_time: 2019-07-13 19:19:29
  set_by: NULL
6 rows in set (0.0005 sec)

Listing 6-1The sys schema persisted configuration

目前,set_by列总是为NULL,除非@sys.ignore_sys_config_triggers用户变量被设置为评估为FALSE但不是NULL的值。

您最有可能更改的选项是statement_truncate_len,它指定了sys模式将用于格式化视图中的语句的最大长度(稍后将详细介绍)。选择默认值 64 是为了增加查询视图适合控制台宽度的可能性;但是,有时候太少了,无法获得足够有用的语句信息。

您可以通过更新sys_config中的值来更新配置设置。这将保持更改并立即应用于所有连接,除非它们已经设置了自己的会话值(当使用格式化语句的sys模式中的某个东西时,这将隐式发生)。由于sys_config是一个普通的 InnoDB 表,重启 MySQL 后这个变化仍然存在。

或者,您可以仅针对该会话更改设置。这是通过获取配置变量的名称,加上sys.并将其转换为用户变量来实现的。清单 6-2 展示了使用sys_config表和一个用户变量来改变statement_truncate_len配置的例子。用format_statement()函数测试结果,这个函数是sys模式用来截断语句的。

mysql> SET @query = 'SELECT * FROM world.city INNER JOIN world.city ON country.Code = city.CountryCode';
Query OK, 0 rows affected (0.0003 sec)

mysql> SELECT sys.sys_get_config(
                  'statement_truncate_len',
                  NULL
              ) AS TruncateLen\G
*************************** 1\. row ***************************
TruncateLen: 64
1 row in set (0.0007 sec)

mysql> SELECT sys.format_statement(@query) AS Statement\G
*************************** 1\. row ***************************
Statement: SELECT * FROM world.city INNER ... ountry.Code = city.CountryCode
1 row in set (0.0019 sec)

mysql> UPDATE sys.sys_config SET value = 48 WHERE variable = 'statement_truncate_len';
Query OK, 1 row affected (0.4966 sec)

mysql> SET @sys.statement_truncate_len = NULL;
Query OK, 0 rows affected (0.0004 sec)

mysql> SELECT sys.format_statement(@query) AS Statement\G
*************************** 1\. row ***************************
Statement: SELECT * FROM world.ci ... ode = city.CountryCode
1 row in set (0.0009 sec)

mysql> SET @sys.statement_truncate_len = 96;
Query OK, 0 rows affected (0.0003 sec)

mysql> SELECT sys.format_statement(@query) AS Statement\G
*************************** 1\. row ***************************
Statement: SELECT * FROM world.city INNER JOIN world.city ON country.Code = city.CountryCode
1 row in set (0.0266 sec)

Listing 6-2Changing the sys schema configuration

首先,在用户变量@query中设置一个查询。这纯粹是为了方便,所以很容易一直引用同一个查询。sys_get_config()函数用于获取statement_truncate_len选项的当前配置值。这考虑了是否设置了@sys.statement_trauncate_len用户变量。如果所提供的选项不存在,第二个参数提供要返回的值。

format_statement()函数用于演示格式化@query中的语句,首先用默认值 64 表示statement_truncate_len,然后将sys_config更新为值 48,最后将会话的值设置为 96。注意在更新了sys_config表之后,用户变量@sys.statement_truncate_len是如何被设置为NULL的,以使 MySQL 将更新后的设置应用到会话中。

Note

一些默认情况下不在sys_config表中的sys模式特性支持一些配置选项,例如调试选项。sys模式对象( https://dev.mysql.com/doc/refman/en/sys-schema-reference.html )的文档包括支持哪些配置选项的信息。

format_statement()函数不是sys模式中唯一的格式化函数,所以让我们看看所有的函数。

格式化功能

sys模式包括四个函数,帮助您根据性能模式格式化查询的输出,使结果更容易阅读或占用更少的空间。在 MySQL 8.0.16 中,有两个函数已被弃用,因为添加了本地性能模式函数来替代它们。

6-1 总结了四个函数以及在format_time()format_bytes()的情况下取代它们的新的本地函数。

表 6-1

sys模式格式化功能

|

系统模式功能

|

本地功能

|

描述

|
| --- | --- | --- |
| format_bytes() | FORMAT_BYTES() | 将字节值转换为带单位的字符串(基于 1024)。 |
| format_path() |   | 获取文件的路径,并用表示相应全局变量的字符串替换数据目录、临时目录等。 |
| format_statement() |   | 通过用省略号(...)替换语句的中间部分,将语句截断到最多由statement_truncate_len配置选项设置的字符数。 |
| format_time() | FORMAT_PICO_TIME() | 将皮秒时间转换为人类可读的字符串。 |

清单 6-3 显示了一个使用格式化函数的例子,对于format_bytes()format_time(),结果将与本地性能模式函数进行比较。

mysql> SELECT sys.format_bytes(5000) AS SysBytes,
              FORMAT_BYTES(5000) AS P_SBytes\G
*************************** 1\. row ***************************
SysBytes: 4.88 KiB
P_SBytes: 4.88 KiB
1 row in set, 1 warning (0.0015 sec)
Note (code 1585): This function 'format_bytes' has the same name as a native function

mysql> SELECT @@global.datadir AS DataDir,
              sys.format_path(
                  'D:\\MySQL\\Data_8.0.18\\ib_logfile0'
              ) AS LogFile0\G
*************************** 1\. row ***************************
 DataDir: D:\MySQL\Data_8.0.18\
LogFile0: @@datadir\ib_logfile0
1 row in set (0.0027 sec)

mysql> SELECT sys.format_statement(
                  'SELECT * FROM world.city INNER JOIN world.city ON country.Code = city.CountryCode'
              ) AS Statement\G
*************************** 1\. row ***************************
Statement: SELECT * FROM world.city INNER ... ountry.Code = city.CountryCode
1 row in set (0.0016 sec)

mysql> SELECT sys.format_time(123456789012) AS SysTime,
              FORMAT_PICO_TIME(123456789012) AS P_STime\G
*************************** 1\. row ***************************
SysTime: 123.46 ms
P_STime: 123.46 ms

1 row in set (0.0006 sec)

Listing 6-3Using the formatting functions

请注意,sys.format_bytes()的使用触发了一个警告(但仅在连接第一次使用它时),因为sys模式函数名与本机函数名相同。format_path()函数在 Microsoft Windows 上要求路径名使用反斜杠,在其他平台上使用正斜杠。format_statement()函数的结果假设statement_truncate_len选项的值已经被重置为默认值 64。

Tip

虽然format_time()format_bytes()sys模式实现仍然存在,但是最好使用新的本地函数,因为sys模式实现可能会在未来版本中被删除,并且本地函数会更快。

这些函数不仅本身有用,它们还被sys模式用来实现返回格式化数据的视图。因为在某些情况下需要处理未格式化的数据,所以大多数sys模式视图有两种实现,您将在下面看到。

风景

sys模式提供了许多作为预定义报告的视图。视图大多使用性能模式表,但也有一些使用信息模式。这些视图既可以方便地从性能模式中获取信息,也可以作为查询性能模式的示例。

因为视图是现成的报告,您可以作为数据库管理员或开发者使用,所以它们是用默认顺序定义的。这意味着使用视图的典型方式是做一个普通的SELECT * FROM <view name>,例如:

mysql> SELECT *
         FROM sys.schema_tables_with_full_table_scans\G
*************************** 1\. row ***************************
    object_schema: world
      object_name: city
rows_full_scanned: 4079
          latency: 269.13 ms
*************************** 2\. row ***************************
    object_schema: sys
      object_name: sys_config
rows_full_scanned: 18
          latency: 328.80 ms
2 rows in set (0.0021 sec)

结果取决于全表扫描使用了哪些表。请注意延迟是如何被格式化的,如使用FORMAT_PICO_TIME()sys.format_time()函数。

大多数sys模式视图以两种形式存在,一种是格式化的语句、路径、字节值和计时,另一种返回原始数据。如果您在控制台查询视图并自己查看数据,格式化视图非常有用,而如果您需要处理程序中的数据或想要更改默认排序,非格式化视图会更好。MySQL Workbench 中的性能报告使用无格式视图,因此您可以在用户界面中更改排序。

您可以从名称中区分格式化视图和未格式化视图。如果一个视图包含格式,也会有一个相同名称的未格式化视图,但是名称前面会加上x$。例如,对于前面例子中使用的schema_tables_with_full_table_scans视图,无格式视图被命名为x$schema_tables_with_full_table_scans:

mysql> SELECT *
         FROM sys.x$schema_tables_with_full_table_scans\G
*************************** 1\. row ***************************
    object_schema: world
      object_name: city
rows_full_scanned: 4079
          latency: 269131954854
*************************** 2\. row ***************************
    object_schema: sys
      object_name: sys_config
rows_full_scanned: 18
          latency: 328804286013
2 rows in set (0.0017 sec)

sys模式的最后一个主题是所提供的助手函数和过程。

助手函数和过程

sys模式提供了几个实用程序,可以帮助您使用 MySQL。这些功能包括执行动态创建的查询、操作列表等。表 6-2 总结了最重要的助手功能和程序。

表 6-2

sys模式中的帮助函数和过程

|

例行程序名

|

常规类型

|

描述

|
| --- | --- | --- |
| extract_schema_from_file_name | 功能 | 从 InnoDB 表空间文件的每表文件路径中提取模式名。 |
| extract_table_from_file_name | 功能 | 从每个表的 InnoDB 表空间文件的路径中提取表名。 |
| list_add | 功能 | 将元素添加到列表中,除非它已经存在于列表中。例如,如果您需要更改 SQL 模式,这很有用。 |
| list_drop | 功能 | 从列表中移除元素。 |
| quote_identifier | 功能 | 用反斜杠()将标识符(例如,表名)括起来。 | | version_major| 功能 | 返回您正在查询的实例的主要版本。例如,对于 8.0.18,它返回 8。 | |version_minor| 功能 | 返回正在查询的实例的次要版本。例如,对于 8.0.18,它返回 0。 | |version_patch| 功能 | 返回您正在查询的实例的补丁程序发布版本。例如,对于 8.0.18,它返回 18。 | |execute_prepared_stmt| 程序 | 执行以字符串形式给出的查询。使用预处理语句执行查询,过程在执行完成后释放预处理语句。 | |table_exists` | 程序 | 返回表是否存在,如果存在,则返回它是基表、临时表还是视图。 |

这些实用程序中有几个也在sys模式中内部使用。这些例程最常见的用途是在需要动态处理数据和查询的存储程序中。

Tip

sys 模式函数和过程以例程注释的形式提供了内置帮助。您可以通过查询information_schema.ROUTINES视图的ROUTINE_COMMENT列获得帮助。

摘要

本章提供了对sys模式的简要介绍,因此当您在后面的章节中看到示例时,您会知道它是什么以及如何使用它。sys模式是一个有用的补充,它提供了现成的报告和实用程序,可以简化您的日常任务和调查。在 MySQL 5.7 和更高版本中,sys模式是一个系统模式,所以您不需要采取任何措施就可以开始使用它。

首先,讨论了sys模式配置。全局配置存储在sys.sys_config表中,如果您喜欢不同于 MySQL 安装时提供的默认值,可以更新该表。您还可以通过设置一个用户变量,在配置选项名称前加上sys.来更改会话的配置选项。

然后,sys模式格式化函数包含了添加本地性能模式函数来替代sys模式函数的情况。一些视图中还使用了格式化功能,以帮助人们更容易地阅读数据。对于使用格式化功能的视图,也有一个相应的无格式视图,名称前带有x$

最后,讨论了几个辅助函数和过程。当您尝试动态工作时,例如执行存储过程中生成的查询时,这些功能会对您有所帮助。

下一章是关于信息模式的。

七、信息模式

当您需要优化查询时,通常需要关于模式、索引等的信息。在这种情况下,信息模式是一个很好的数据资源。本章介绍了信息模式以及它包含的视图的概述。在本书的其余部分,信息模式被多次使用。

什么是信息模式?

信息模式是几个关系数据库通用的模式,包括 MySQL,它是在 MySQL 5.0 中添加的。MySQL 基本上遵循 SQL:2003 标准的 F021 基本信息模式,进行了必要的修改以反映 MySQL 的独特特性,并添加了不属于该标准的其他视图。

Note

信息模式是虚拟的,因为其中没有存储任何数据。因此,本章将所有视图和表格都称为视图,即使SHOW CREATE TABLE将其显示为常规表格。这也符合将所有对象的表格类型设置为SYSTEM VIEWinformation_schema.TABLES视图。

在 MySQL 5.5 中引入性能模式后,目标是通过信息模式使相对静态的数据(如模式信息)和属于性能模式的更不稳定的数据可用。也就是说,并不总是很清楚什么属于哪里,例如,索引统计相对不稳定,但也是模式信息的一部分。还有一些信息,比如 InnoDB 指标,由于历史原因,仍然存在于信息模式中。

因此,您可以将信息模式视为描述 MySQL 实例的数据集合。在带有关系数据字典的 MySQL 8 中,一些视图是底层数据字典表上的简单 SQL 视图。这意味着 MySQL 8 中许多信息模式查询的性能将大大优于您在旧版本中所体验到的。当查询不需要从存储引擎检索信息的架构数据时,尤其如此。

Caution

如果您仍在使用 MySQL 5.7 或更早的版本,那么在查询信息模式中的视图(如TABLESCOLUMNS视图)时要小心。如果它们包含数据的表还不在表定义缓存中,或者缓存不够大,无法容纳所有的表,那么它们可能需要很长时间。MySQL 服务器团队在博客中讨论了 MySQL 5.7 和 8 之间信息模式的性能差异的一个例子: https://mysqlserverteam.com/mysql-8-0-scaling-and-performance-of-information_schema/

特权

信息模式是一个虚拟数据库,对视图的访问与其他表略有不同。所有用户都将看到information_schema模式存在,并且他们将看到所有视图。但是,查询视图的结果取决于分配给帐户的权限。例如,一个除了全局USAGE权限之外没有其他权限的帐户在查询information_schema.TABLES视图时将只能看到信息模式视图。

有些视图需要额外的权限,在这种情况下,会返回一个ER_SPECIFIC_ACCESS_DENIED_ERROR(错误号 1227)错误,并描述缺少哪个权限。例如,INNODB_METRICS视图需要PROCESS权限,因此如果没有PROCESS权限的用户查询该视图,就会出现以下错误:

mysql> SELECT *
         FROM information_schema.INNODB_METRICS;
ERROR: 1227: Access denied; you need (at least one of) the PROCESS privilege(s) for this operation

现在,是时候看看在信息模式视图中可以找到什么样的信息了。

视图

信息模式中可用的数据范围从关于系统的高级信息到低级 InnoDB 指标。本节提供了这些视图的概述,但不会详细介绍,因为从性能调优的角度来看,最重要的视图将在后面章节的相关部分中讨论。

Note

一些插件将自己的视图添加到信息模式中。这里不考虑额外的插件视图。

系统信息

信息模式中最高级别的信息涉及整个 MySQL 实例。这包括诸如哪些字符集可用以及安装了哪些插件之类的信息。

7-1 总结了包含系统信息的视图。

表 7-1

包含系统信息的信息架构视图

|

视图名称

|

描述

|
| --- | --- |
| CHARACTER_SETS | 可用的字符集。 |
| COLLATIONS | 每个字符集可用的排序规则。这包括排序规则的 id,它在某些情况下(例如,在二进制日志中)用于唯一地指定排序规则和字符集。 |
| COLLATION_CHARACTER_SET_APPLICABILITY | 排序规则到字符集的映射(与COLLATIONS的前两列相同)。 |
| ENGINES | 已知的存储引擎以及它们是否已加载。 |
| INNODB_FT_DEFAULT_STOPWORD | 在 InnoDB 表上创建全文索引时使用的默认停用词列表。 |
| KEYWORDS | MySQL 中的关键字列表以及该关键字是否被保留。 |
| PLUGINS | MySQL 已知的插件,包括状态。 |
| RESOURCE_GROUPS | 线程用来完成其工作的资源组。资源组指定线程的优先级以及它可以使用的 CPU。 |
| ST_SPATIAL_REFERENCE_SYSTEMS | 空间参考系统列表,包括包含用于指定空间列参考系统的 id 的SRS_ID列。 |

与系统相关的视图主要作为参考视图,RESOURCE_GROUPS表略有不同,因为可以添加资源组,这将在第 17 章中讨论。

例如,KEYWORDS视图在测试升级时非常有用,因为您可以使用它来验证您的模式、表、列、例程或参数名称是否与新版本中的关键字匹配。如果是这种情况,您将需要更新应用以引用标识符,如果还没有这样做的话。要查找与关键字匹配的所有列名:

SELECT TABLE_SCHEMA, TABLE_NAME,
       COLUMN_NAME, RESERVED
  FROM information_schema.COLUMNS
       INNER JOIN information_schema.KEYWORDS
          ON KEYWORDS.WORD = COLUMNS.COLUMN_NAME
 WHERE TABLE_SCHEMA NOT IN ('mysql',
                            'information_schema',
                            'performance_schema',
                            'sys'
                           )
 ORDER BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME;

该查询使用COLUMNS视图来查找除系统模式之外的所有列名(如果您在应用或脚本中使用它们,您可以选择包含它们)。COLUMNS视图是描述模式对象的几个视图之一。

模式信息

包含模式对象信息的视图是信息模式中最有用的视图。这些也是几个SHOW语句的来源。您可以使用视图来查找从存储例程的参数到数据库名称的所有信息。表 7-2 总结了包含模式信息的视图。

表 7-2

具有架构信息的信息架构视图

|

视图名称

|

描述

|
| --- | --- |
| CHECK_CONSTRAINTS | 该视图包含关于CHECK约束的信息,在 MySQL 8.0.16 和更高版本中可用。 |
| COLUMN_STATISTICS | 直方图的定义,包括统计数据。对于查询性能调优来说,这是一个非常有用的视图。 |
| COLUMNS | 列定义。 |
| EVENTS | 存储事件的定义。 |
| FILES | 关于 InnoDB 表空间文件的信息。 |
| INNODB_COLUMNS | InnoDB 表中列的元数据信息。 |
| INNODB_DATAFILES | 该视图将 InnoDB 表空间 id 链接到文件系统路径。 |
| INNODB_FIELDS | InnoDB 索引中包含的列的元数据。 |
| INNODB_FOREIGN | InnoDB 外键的元数据。 |
| INNODB_FOREIGN_COLS | 列出 InnoDB 外键的子列和父列。 |
| INNODB_FT_BEING_DELETED | 在针对在innodb_ft_aux_table选项中指定的 InnoDB 表的OPTIMIZE TABLE语句期间,INNODB_FT_DELETED视图的快照。 |
| INNODB_FT_CONFIG | 在innodb_ft_aux_table选项中指定的 InnoDB 表上全文索引的配置信息。 |
| INNODB_FT_DELETED | 从在innodb_ft_aux_table选项中指定的 InnoDB 表的全文索引中删除的行。出于性能原因,InnoDB 使用这个额外的列表来避免为每个 DML 语句更新索引本身。 |
| INNODB_FT_INDEX_CACHE | 在innodb_ft_aux_table选项中指定的 InnoDB 表的全文索引中新插入的行。出于性能原因,InnoDB 使用这个额外的列表来避免为每个 DML 语句更新索引本身。 |
| INNODB_FT_INDEX_TABLE | 在innodb_ft_aux_table选项中指定的 InnoDB 表的反向全文索引。 |
| INNODB_INDEXES | 关于 InnoDB 表上的索引的信息。这包括内部信息,如根页面的页码和合并阈值。 |
| INNODB_TABLES | InnoDB 表的元数据。 |
| INNODB_TABLESPACES | InnoDB 表空间的元数据。 |
| INNODB_TABLESPACES_BRIEF | 该视图将来自INNODB_TABLESPACESSPACENAMEFLAGSPACE_TYPE列与来自INNODB_DATAFILESPATH列组合在一起,以提供 InnoDB 表空间的摘要。 |
| INNODB_TABLESTATS | InnoDB 表的表统计信息。其中一些统计数据与索引统计数据同时更新;其他的是持续维护的。 |
| INNODB_TEMP_TABLE_INFO | InnoDB 临时表的元数据(内部的和显式的)。 |
| INNODB_VIRTUAL | InnoDB 表上虚拟生成的列的内部元数据信息。 |
| KEY_COLUMN_USAGE | 关于主键、唯一键和外键的信息。 |
| PARAMETERS | 关于存储函数和存储过程的参数的信息。 |
| PARTITIONS | 关于表分区的信息。 |
| REFERENTIAL_CONSTRAINTS | 关于外键的信息。 |
| ROUTINES | 存储函数和存储过程的定义。 |
| SCHEMATA | 关于模式(数据库)的信息。(从技术上讲,Schemata 是模式复数形式的正确词汇,但现在大多数人都使用模式。) |
| ST_GEOMETRY_COLUMNS | 关于具有空间数据类型的列的信息。 |
| STATISTICS | 索引定义和统计。谈到查询性能调整,这是最有用的视图之一。 |
| TABLE_CONSTRAINTS | 主键、唯一键、外键和CHECK约束的概要。 |
| TABLES | 关于表和视图及其属性的信息。 |
| TABLESPACES | 此视图仅用于 NDB 集群表空间。 |
| TRIGGERS | 触发器定义。 |
| VIEW_ROUTINE_USAGE | 列出视图中使用的存储函数。该表是在 8.0.13 中添加的。 |
| VIEW_TABLE_USAGE | 列出视图引用的表。该视图是在 8.0.13 中添加的。 |
| VIEWS | 视图定义。 |

有几个视图是密切相关的,例如,列在模式中的表中,约束引用表和列。这意味着一些列名出现在几个视图中。与这些视图相关的最常用的列名是

  • TABLE_NAME : 用于不特定于 InnoDB 的视图中的表名。

  • TABLE_SCHEMA : 用于不特定于 InnoDB 的视图中的模式名。

  • COLUMN_NAME : 用于不特定于 InnoDB 的视图中的列名。

  • SPACE : 用于 InnoDB 特定视图中的表空间 id。

  • TABLE_ID : 用于特定于 InnoDB 的视图中,以唯一地标识表格。这也在 InnoDB 内部使用。

  • NAME : 特定于 InnoDB 的视图使用一个名为NAME的列来给出对象的名称,而不考虑对象的类型。

除了使用此列表中的名称之外,还有一些示例对这些列名进行了细微的修改,如在视图 KEY_COLUMN_USAGE 中,您可以找到外键描述中使用的列 REFERENCED_TABLE_SCHEMA、REFERENCED_TABLE_NAME 和 REFERENCED_COLUMN_NAME。例如,如果要使用 KEY_COLUMN_USAGE 视图来查找外键引用 sakila.film 表的表,可以使用如下查询:

mysql> SELECT TABLE_SCHEMA, TABLE_NAME
    FROM information_schema.KEY_COLUMN_USAGE

    WHERE REFERENCED_TABLE_SCHEMA = 'sakila'
        AND REFERENCED_TABLE_NAME = 'film';
+--------------+---------------+
| TABLE_SCHEMA | TABLE_NAME    |
+--------------+---------------+
| sakila       | film_actor    |
| sakila       | film_category |
| sakila       | inventory     |
+--------------+---------------+
3 rows in set (0.0078 sec)

这表明 film_actor、film_category 和 inventory 表都有外键,其中 film 表是父表。例如,如果您查看 film_actor 的表定义:

mysql> SHOW CREATE TABLE sakila.film_actor\G
*************************** 1\. row ***************************
       Table: film_actor
Create Table: CREATE TABLE `film_actor` (
  `actor_id` smallint(5) unsigned NOT NULL,
  `film_id` smallint(5) unsigned NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`,`film_id`),
  KEY `idx_fk_film_id` (`film_id`),
  CONSTRAINT `fk_film_actor_actor` FOREIGN KEY (`actor_id`) REFERENCES `actor` (`actor_id`) ON DELETE RESTRICT ON UPDATE CASCADE,
  CONSTRAINT `fk_film_actor_film` FOREIGN KEY (`film_id`) REFERENCES `film` (`film_id`) ON DELETE RESTRICT ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8
1 row in set (0.0097 sec)

fk_film_actor_film约束引用胶片表中的film_id列。您可以将此作为查找外键完整链的起点,方法是针对KEY_COLUMN_USAGE视图对查询中返回的每个表手动执行查询,或者创建一个递归公用表表达式(CTE)。这是留给读者的一个练习。

Tip

关于在递归公用表表达式中使用KEY_COLUMN_USAGE视图查找外键依赖链的示例,请参见 https://mysql.wisborg.dk/tracking-foreign-keys

为了完整起见,通过外键依赖于film表的表的可视化表示可以在图 7-1 中找到。

img/484666_1_En_7_Fig1_HTML.jpg

图 7-1

来自sakila.film的外部密钥链的可视化表示

图表是使用 MySQL Workbench 的逆向工程特性创建的。

包含特定于 InnoDB 的信息的视图使用SPACETABLE_ID来标识表空间和表。每个表空间都有一个唯一的 id,其范围是为不同的表空间类型保留的。例如,数据字典表空间文件(<datadir>/mysql.ibd)的空间 id 为 4294967294,临时表空间的 id 为 4294967293,还原日志表空间从 4294967279 开始并递减,用户表空间从 1 开始。

包含 InnoDB 全文索引信息的视图很特殊,因为它们要求您用您想要获取信息的表的名称来设置innodb_ft_aux_table全局变量。例如,要获得sakila.film_text表的全文索引配置:

mysql> SET GLOBAL innodb_ft_aux_table = 'sakila/film_text';
Query OK, 0 rows affected (0.0685 sec)

mysql> SELECT *
         FROM information_schema.INNODB_FT_CONFIG;
+---------------------------+-------+
| KEY                       | VALUE |
+---------------------------+-------+
| optimize_checkpoint_limit | 180   |
| synced_doc_id             | 1002  |
| stopword_table_name       |       |
| use_stopword              | 1     |
+---------------------------+-------+
4 rows in set (0.0009 sec)

INNODB_FT_CONFIG视图中的值可能因您而异。

InnoDB 还包括带有与性能相关的信息的视图。这些将与其他一些与性能相关的表一起讨论。

性能信息

与性能相关的一组视图是您在性能调优工作中可能使用最多的视图,以及前一组视图中的COLUMN_STATISTICSSTATISTICS视图。带有性能相关信息的视图列于表 7-3 中。

表 7-3

具有性能相关信息的信息架构视图

|

视图名称

|

描述

|
| --- | --- |
| INNODB_BUFFER_PAGE | InnoDB 缓冲池中的页面列表,可用于确定当前缓存了哪些表和索引。警告:查询这个表的开销很大,特别是对于大型缓冲池和许多表和索引。它最适用于测试系统。 |
| INNODB_BUFFER_PAGE_LRU | 关于 InnoDB 缓冲池中页面的信息,以及它们在最近最少使用(LRU)列表中的排序方式。警告:查询这个表的开销很大,特别是对于大型缓冲池和许多表和索引。它最适用于测试系统。 |
| INNODB_BUFFER_POOL_STATS | 关于 InnoDB 缓冲池使用情况的统计信息。该信息类似于在BUFFER POOL AND MEMORY部分的SHOW ENGINE INNODB STATUS输出中可以找到的信息。这是最有用的观点之一。 |
| INNODB_CACHED_INDEXES | 每个索引在 InnoDB 缓冲池中缓存的索引页数的摘要。 |
| INNODB_CMP``INNODB_CMP_RESET | 与压缩的 InnoDB 表相关的操作的统计信息。 |
| INNODB_CMP_PER_INDEX``INNODB_CMP_PER_INDEX_RESET | 与INNODB_CMP相同,但按索引分组。 |
| INNODB_CMPMEM``INNODB_CMPMEM_RESET | 关于 InnoDB 缓冲池中压缩页面的统计信息。 |
| INNODB_METRICS | 类似于全局状态变量,但特定于 InnoDB。 |
| INNODB_SESSION_TEMP_TABLESPACES | 元数据包括 InnoDB 临时表空间文件的连接 id、文件路径和大小(在 MySQL 8.0.13 和更高版本中,每个会话都有自己的文件)。它可以用来将一个会话链接到一个表空间文件,如果您注意到一个文件变大了,这将非常有用。该视图是在 8.0.13 中添加的。 |
| INNODB_TRX | 关于 InnoDB 事务的信息。 |
| OPTIMIZER_TRACE | 当启用优化器跟踪时,可以从该视图中查询跟踪。 |
| PROCESSLIST | 同SHOW PROCESSLIST。 |
| PROFILING | 启用性能分析时,可以从该视图中查询性能分析统计信息。这已被否决,建议改用性能架构。 |

对于包含 InnoDB 压缩表信息的视图,以_RESET为后缀的表以增量形式返回自上次查询视图以来的操作和计时统计信息。

INNODB_METRICS视图包括类似于全局状态变量但特定于 InnoDB 的指标。度量被分组到子系统中(SUBSYSTEM列),对于每个度量,在COMMENT列中有一个度量测量什么的描述。您可以使用全局系统变量启用、禁用和重置指标:

  • innodb_monitor_disable : 禁用一个或多个度量。

  • innodb_monitor_enable : 启用一个或多个指标。

  • innodb_monitor_reset : 重置一个或多个指标的计数器。

  • innodb_monitor_reset_all : 重置所有统计信息,包括一个或多个度量的计数器、最小值和最大值。

可以根据需要打开和关闭指标,当前状态在STATUS列中。您将指标的名称指定为innodb_monitor_enableinnodb_monitor_disable变量的值,并且可以使用%作为通配符。值all作为一个特殊值来影响所有指标。清单 7-1 展示了一个启用和使用所有匹配%cpu%的指标的例子(恰好是cpu子系统中的指标)。计数器值取决于查询时的工作负载。

mysql> SET GLOBAL innodb_monitor_enable = '%cpu%';
Query OK, 0 rows affected (0.0005 sec)

mysql> SELECT NAME, COUNT, MIN_COUNT,
              MAX_COUNT, AVG_COUNT,
              STATUS, COMMENT
         FROM information_schema.INNODB_METRICS
        WHERE NAME LIKE '%cpu%'\G
*************************** 1\. row ***************************
     NAME: module_cpu
    COUNT: 0
MIN_COUNT: NULL
MAX_COUNT: NULL
AVG_COUNT: 0
   STATUS: enabled
  COMMENT: CPU counters reflecting current usage of CPU
*************************** 2\. row ***************************
     NAME: cpu_utime_abs
    COUNT: 51
MIN_COUNT: 0
MAX_COUNT: 51
AVG_COUNT: 0.4358974358974359
   STATUS: enabled
  COMMENT: Total CPU user time spent
*************************** 3\. row ***************************
     NAME: cpu_stime_abs
    COUNT: 7
MIN_COUNT: 0
MAX_COUNT: 7
AVG_COUNT: 0.05982905982905983
   STATUS: enabled
  COMMENT: Total CPU system time spent
*************************** 4\. row ***************************
     NAME: cpu_utime_pct
    COUNT: 6
MIN_COUNT: 0
MAX_COUNT: 6
AVG_COUNT: 0.05128205128205128
   STATUS: enabled
  COMMENT: Relative CPU user time spent
*************************** 5\. row ***************************
     NAME: cpu_stime_pct

    COUNT: 0
MIN_COUNT: 0
MAX_COUNT: 0
AVG_COUNT: 0
   STATUS: enabled
  COMMENT: Relative CPU system time spent
*************************** 6\. row ***************************
     NAME: cpu_n
    COUNT: 8
MIN_COUNT: 8
MAX_COUNT: 8
AVG_COUNT: 0.06837606837606838
   STATUS: enabled
  COMMENT: Number of cpus
6 rows in set (0.0011 sec)

mysql> SET GLOBAL innodb_monitor_disable = '%cpu%';
Query OK, 0 rows affected (0.0004 sec)

Listing 7-1Using the INNODB_METRICS view

首先,使用innodb_monitor_enable变量启用指标;然后检索这些值。除了显示的值,还有一组带_RESET后缀的列,当您设置innodb_monitor_reset(仅计数器)或innodb_monitor_reset_all系统变量时,这些列会被重置。最后,指标再次被禁用。

Caution

这些指标有不同的开销,因此建议您在生产中启用指标之前先测试您的工作负载。

InnoDB 指标也包含在sys.metrics视图中,还有全局状态变量和一些其他指标,以及检索指标的时间。

其余的信息模式视图包含关于特权的信息。

特权信息

MySQL 使用分配给帐户的特权来确定哪些帐户可以访问哪些模式、表和列。确定给定帐户特权的常用方法是使用SHOW GRANTS语句,但是信息模式还包括允许您查询特权的视图。

7-4 总结了信息模式权限视图。视图按照从全局权限到列权限的顺序排列。

表 7-4

具有特权信息的信息模式表

|

表名

|

描述

|
| --- | --- |
| USER_PRIVILEGES | 全球特权。 |
| SCHEMA_PRIVILEGES | 访问模式的权限。 |
| TABLE_PRIVILEGES | 访问表的特权。 |
| COLUMN_PRIVILEGES | 访问列的权限。 |

在所有视图中,帐户都被称为GRANTEE,其形式为'username'@'hostname',引号始终存在。清单 7-2 展示了一个检索mysql.sys@localhost账户的特权并将其与SHOW GRANTS语句的输出进行比较的例子。

mysql> SHOW GRANTS FOR 'mysql.sys'@'localhost'\G
*************************** 1\. row ***************************
Grants for mysql.sys@localhost: GRANT USAGE ON *.* TO `mysql.sys`@`localhost`
*************************** 2\. row ***************************
Grants for mysql.sys@localhost: GRANT TRIGGER ON `sys`.* TO `mysql.sys`@`localhost`
*************************** 3\. row ***************************
Grants for mysql.sys@localhost: GRANT SELECT ON `sys`.`sys_config` TO `mysql.sys`@`localhost`
3 rows in set (0.2837 sec)

mysql> SELECT *
         FROM information_schema.USER_PRIVILEGES
        WHERE GRANTEE = '''mysql.sys''@''localhost'''\G
*************************** 1\. row ***************************
       GRANTEE: 'mysql.sys'@'localhost'
 TABLE_CATALOG: def
PRIVILEGE_TYPE: USAGE
  IS_GRANTABLE: NO
1 row in set (0.0006 sec)

mysql> SELECT *
         FROM information_schema.SCHEMA_PRIVILEGES
        WHERE GRANTEE = '''mysql.sys''@''localhost'''\G
*************************** 1\. row ***************************
       GRANTEE: 'mysql.sys'@'localhost'
 TABLE_CATALOG: def
  TABLE_SCHEMA: sys
PRIVILEGE_TYPE: TRIGGER
  IS_GRANTABLE: NO
1 row in set (0.0005 sec)

mysql> SELECT *
         FROM information_schema.TABLE_PRIVILEGES
        WHERE GRANTEE = '''mysql.sys''@''localhost'''\G
*************************** 1\. row ***************************
       GRANTEE: 'mysql.sys'@'localhost'
 TABLE_CATALOG: def
  TABLE_SCHEMA: sys
    TABLE_NAME: sys_config
PRIVILEGE_TYPE: SELECT
  IS_GRANTABLE: NO
1 row in set (0.0005 sec)

mysql> SELECT *
         FROM information_schema.COLUMN_PRIVILEGES
        WHERE GRANTEE = '''mysql.sys''@''localhost'''\G
Empty set (0.0005 sec)

Listing 7-2Using the Information Schema privilege views

注意用户名和主机名的单引号是如何被双引号转义的。

虽然带有权限信息的视图不能直接用于性能调优,但是它们对于维护稳定的系统非常有用,因为您可以使用它们来轻松识别是否有任何帐户拥有不需要的权限。

Tip

最好的做法是限制帐户只拥有他们需要的特权,不要更多。这是保证系统安全的步骤之一。

关于信息模式要考虑的最后一个主题是如何缓存与索引统计相关的数据。

索引统计数据的缓存

理解索引统计相关视图(以及等效的SHOW语句)中的信息来自哪里是很重要的。大部分数据来自 MySQL 数据字典。在 MySQL 8 中,数据字典存储在 InnoDB 表中,因此视图只是数据字典之上的普通 SQL 视图。(例如,您可以尝试执行SHOW CREATE VIEW information_schema.STATISTICS来获得STATISTICS视图的定义。)

然而,索引统计信息本身仍然来自存储引擎层,因此查询这些信息的成本相对较高。为了提高性能,统计数据被缓存在数据字典中。您可以控制在 MySQL 刷新缓存之前允许统计数据存在多长时间。这是通过默认为 86400 秒(一天)的information_schema_stats_expiry变量完成的。如果将该值设置为 0,您将始终从存储引擎获得最新的可用值;这相当于 MySQL 5.7 的行为。该变量既可以在全局范围内设置,也可以在会话范围内设置,因此,如果您正在调查一个需要查看当前统计信息的问题,例如,如果优化程序没有使用您期望的索引,您可以将该变量设置为 0。

Tip

使用information_schema_stats_expiry变量来控制索引统计信息可以在数据字典中缓存多长时间。这只是为了显示,优化器总是使用最新的统计数据。例如,将information_schema_stats_expiry设置为 0 以禁用缓存,这在调查优化器使用错误索引的问题时非常有用。您可以根据需要在全局和会话范围内更改该值。

缓存会影响表 7-5 中列出的列。显示相同数据的SHOW语句也会受到影响。

表 7-5

information_schema_stats_expiry影响的列

|

视图名称

|

列名

|

描述

|
| --- | --- | --- |
| STATISTICS | CARDINALITY | 同一行中的列的索引部分的唯一值的估计数。 |
| TABLES | AUTO_INCREMENT | 表的自动递增计数器的下一个值。 |
| AVG_ROW_LENGTH | 估计的数据长度除以估计的行数。 |
| CHECKSUM | 表校验和。InnoDB 不使用它,所以值是NULL。 |
| CHECK_TIME | 上次检查表格的时间(CHECK TABLE)。对于分区表,InnoDB 总是返回NULL。 |
| CREATE_TIME | 创建表的时间。 |
| DATA_FREE | 该表所属的表空间中空闲空间量的估计值。对于 InnoDB,这是完全自由的扩展区的大小减去安全余量。 |
| DATA_LENGTH | 行数据的估计大小。对于 InnoDB,它是聚集索引的大小,即聚集索引中的页数乘以页面大小。 |
| INDEX_LENGTH | 辅助索引的估计大小。对于 InnoDB,这是非聚集索引中的页面总数乘以页面大小。 |
| MAX_DATA_LENGTH | 数据长度的最大允许大小。InnoDB 不使用它,所以值是NULL。 |
| TABLE_ROWS | 估计的行数。对于 InnoDB 表,这来自主键或聚集索引的基数。 |
| UPDATE_TIME | 上次更新表空间文件的时间。对于 InnoDB 系统表空间中的表,该值为NULL。由于数据是异步写入表空间的,因此时间通常不会反映最后一条更改数据的语句的时间。 |

您可以通过对表执行ANALYZE TABLE来强制更新给定表的数据。

有时候,查询数据不会更新缓存的数据:

  • 当缓存数据尚未过期时,也就是说,它在不到information_schema_stats_expiry秒之前被刷新

  • information_schema_stats_expiry设置为 0 时

  • 当 MySQL 或 InnoDB 以只读模式运行时,即当read_onlysuper_read_onlytransaction_read_onlyinnodb_read_only模式之一被启用时。

  • 当查询还包括来自性能模式的数据时

摘要

本章介绍了信息模式,首先讨论了什么是信息模式以及用户特权是如何工作的。本章的剩余部分介绍了标准视图和缓存的工作原理。信息模式视图可以根据它们包含的信息类型进行分组:系统、模式、性能和特权信息。

系统信息包括字符集和排序规则、资源组、关键字以及与空间数据相关的信息。这是使用参考手册的一种有用的替代方法。

模式信息是最大的一组视图,包括从模式数据到列、索引和约束的所有可用信息。这些视图以及包含度量和 InnoDB 缓冲池统计信息等信息的性能视图是性能调优中最常用的视图。与权限相关的视图不经常用于性能调优,但是它们对于帮助维护稳定的系统非常有用。

从信息模式视图中获取信息的一个常见快捷方式是使用一个SHOW语句。这些将在下一章讨论。

八、SHOW语句

对于数据库管理员来说,SHOW语句是 MySQL 中用来获取关于模式对象和系统上发生的事情的信息的老一套工具。虽然今天大多数信息都可以在信息模式或性能模式中找到,但是由于其简短的语法,SHOW命令仍然非常流行于交互使用。

Tip

建议查询基础信息模式视图和性能模式表。这尤其适用于对数据的非交互式访问。查询底层数据源也更加强大,因为它允许您连接到其他视图和表。

本章首先概述了SHOW语句如何与信息模式视图和性能模式表相匹配。本章的剩余部分涵盖了在信息模式和性能模式中没有视图或表的SHOW语句,包括通过SHOW ENGINE INNODB STATUS语句提供的 InnoDB monitor 输出的更深入视图获取引擎状态信息,以及获取复制和二进制日志信息。

与信息模式的关系

对于返回关于模式对象或特权的信息的SHOW语句,可以在信息模式中找到相同的信息。表 8-1 列出了从信息模式视图中获取信息的SHOW语句,以及可以在哪些视图中找到信息。

表 8-1

SHOW语句和信息模式之间的关联

|

SHOW语句

|

我的观点

|

评论

|
| --- | --- | --- |
| CHARACTER SET | CHARACTER_SETS |   |
| COLLATION | COLLATIONS |   |
| COLUMNS | COLUMNS |   |
| CREATE DATABASE | SCHEMATA |   |
| CREATE EVENT | EVENTS |   |
| CREATE FUNCTION | ROUTINES | ROUTINE_TYPE = 'FUNCTION' |
| CREATE PROCEDURE | ROUTINES | ROUTINE_TYPE = 'PROCEDURE' |
| CREATE TABLE | TABLES |   |
| CREATE TRIGGER | TRIGGERS |   |
| CREATE VIEW | VIEWS |   |
| DATABASES | SCHEMATA |   |
| ENGINES | ENGINES |   |
| EVENTS | EVENTS |   |
| FUNCTION STATUS | ROUTINES | ROUTINE_TYPE = 'FUNCTION' |
| GRANTS | COLUMN_PRIVILEGES``SCHEMA_PRIVILEGES``TABLE_PRIVILEGES``USER_PRIVILEGES |   |
| INDEX | STATISTICS | SHOW INDEXESSHOW INDEXESSHOW INDEX的同义词。 |
| PLUGINS | PLUGINS |   |
| PROCEDURE STATUS | ROUTINES | ROUTINE_TYPE = 'PROCEDURE' |
| PROCESSLIST | PROCESSLIST | 建议用performance_schema.threads代替。 |
| PROFILE | PROFILING | 已弃用–请改用性能模式。 |
| PROFILES | PROFILING | 已弃用–请改用性能模式。 |
| TABLE STATUS | TABLES |   |
| TABLES | TABLES |   |
| TRIGGERS | TRIGGERS |   |

SHOW语句和相应的信息模式视图之间,信息并不总是相同的。在某些情况下,使用视图可以获得更多的信息,并且通常视图更加灵活。

还有几个SHOW语句可以在性能模式中找到底层数据。

与性能模式的关系

在引入性能模式之后,一些原本放在信息模式中的信息被移到了它逻辑上所属的性能模式中。这也反映在与SHOW语句的关系中,现在有几个表,如表 8-2 所示,它们从性能模式表中获取数据。

表 8-2

SHOW语句和性能模式之间的关联

|

SHOW语句

|

性能模式表

|
| --- | --- |
| MASTER STATUS | log_status |
| SLAVE STATUS | log_status``replication_applier_configuration``replication_applier_filters``replication_applier_global_filters``replication_applier_status``replication_applier_status_by_coordinator``replication_applier_status_by_worker``replication_connection_configuration``replication_connection_status |
| STATUS | global_status``session_status``events_statements_summary_global_by_event_name``events_statements_summary_by_thread_by_event_name |
| VARIABLES | global_variables``session_variables |

SHOW MASTER STATUS包括将事件写入二进制日志时启用何种过滤的信息。该信息无法从性能模式中获得,因此如果您使用binlog-do-dbbinlog-ignore-db选项(不推荐,因为它们会阻止时间点恢复),那么您仍然需要使用SHOW MASTER STATUS

SHOW SLAVE STATUS输出中有几列在性能模式表中找不到。其中一些可以在mysql模式的slave_master_infoslave_relay_log_info表中找到(如果master_info_repositoryrelay_log_info_repository已经被设置为默认的TABLE)。

对于SHOW STATUSSHOW VARIABLES,一个区别是如果没有会话值,返回会话范围值的SHOW语句将包括全局值。当查询session_statussession_variables时,只返回属于请求范围的值。此外,SHOW STATUS语句包括Com_%计数器,而当直接查询性能模式时,这些计数器对应于events_statements_summary_global_by_event_nameevents_statements_summary_by_thread_by_event_name表中的事件(取决于查询的是全局范围还是会话范围)。

还有一些SHOW语句没有任何对应的表。将要讨论的第一组是发动机状态。

发动机状态

SHOW ENGINE语句可用于获取特定于存储引擎的信息。目前已经为 InnoDB、Performance_Schema 和 NDBCluster 引擎实现了该功能。对于所有三个引擎,都可以请求状态,对于 InnoDB 引擎,还可以获得互斥信息。

SHOW ENGINE PERFORMANCE_SCHEMA STATUS语句有助于获得关于性能模式的一些状态信息,包括表的大小及其内存使用情况。(内存使用量也可以从内存检测中获得。)

到目前为止,最常用的引擎状态语句是SHOW ENGINE INNODB STATUS,它提供了一个名为 InnoDB monitor report 的综合报告,其中包含一些无法从其他来源获得的信息。本节的其余部分将介绍 InnoDB monitor 报告。

Tip

您还可以通过启用系统变量innodb_status_output让 InnoDB 定期将监控器报告输出到错误日志中。当设置了innodb_status_output_locks选项时,InnoDB 监控器(无论是因为innodb_status_output = ON还是使用SHOW ENGINE INNODB STATUS生成的)包括附加的锁信息。

InnoDB monitor 报告以标题和注释开始,说明平均值涵盖的时间:

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2019-09-14 19:52:40 0x6480 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 59 seconds

报告本身分为几个部分,包括

  • BACKGROUND THREAD : 主后台线程完成的工作。

  • SEMAPHORES : 信号量统计。在争用导致长时间信号量等待的情况下,该部分是最重要的,在这种情况下,该部分可用于获取关于锁以及谁持有锁的信息。

  • LATEST FOREIGN KEY ERROR : 如果遇到外键错误,此部分包括错误的详细信息。否则,将省略该部分。

  • LATEST DETECTED DEADLOCK : 如果发生了死锁,此部分包括两个事务和导致死锁的锁的详细信息。否则,将省略该部分。

  • TRANSACTIONS:InnoDB 事务信息。只包括修改了 InnoDB 表的事务。如果启用了innodb_status_output_locks选项,将列出每个事务持有的锁;否则,它只是锁等待中涉及的锁。一般来说,最好使用information_schema.INNODB_TRX视图来查询事务信息,对于锁信息,最好使用performance_schema.DATA_LOCKSperformance_schema.DATA_LOCK_WAITS表。

  • FILE I/O : 关于 InnoDB 使用的 I/O 线程的信息,包括插入缓冲线程、日志线程、读取线程和写入线程。

  • INSERT BUFFER AND ADAPTIVE HASH INDEX : 关于更改缓冲区(以前称为插入缓冲区)和自适应散列索引的信息。

  • LOG : 关于重做日志的信息。

  • BUFFER POOL AND MEMORY : 关于 InnoDB 缓冲池的信息。该信息最好从information_schema.INNODB_BUFFER_POOL_STATS视图中获得。

  • INDIVIDUAL BUFFER POOL INFO : 如果innodb_buffer_pool_instances大于 1,则此部分包括单个缓冲池实例的信息,其信息与上一部分中的全局概要信息相同。否则,将省略该部分。这个信息最好从information_schema.INNODB_BUFFER_POOL_STATS的角度获得。

  • ROW OPERATIONS : 这个部分显示了关于 InnoDB 的各种信息,包括当前活动、主线程正在做什么,以及插入、更新、删除和读取的行活动。

在后面的章节中,当它们的内容用于分析性能或锁定问题时,将会用到其中的几个部分。

复制和二进制日志

在处理复制时,SHOW语句一直很重要。虽然性能模式复制表现在已经在很大程度上取代了SHOW SLAVE STATUSSHOW MASTER STATUS语句,但是如果您想从 MySQL 内部查看连接了哪些副本并检查二进制日志或中继日志中的事件,那么您仍然需要使用SHOW语句。

列出二进制日志

SHOW BINARY LOGS语句对于检查存在哪些二进制日志很有用。如果您想知道二进制日志占用了多少空间,它们是否被加密,以及对于基于位置的复制,副本所需的日志是否仍然存在,这将非常有用。

输出结果的一个例子是

mysql> SHOW BINARY LOGS;
+---------------+-----------+-----------+
| Log_name      | File_size | Encrypted |
+---------------+-----------+-----------+
| binlog.000044 |      2616 | No        |
| binlog.000045 |       886 | No        |
| binlog.000046 |       218 | No        |
| binlog.000047 |       218 | No        |
| binlog.000048 |       218 | No        |
| binlog.000049 |       575 | No        |
+---------------+-----------+-----------+
6 rows in set (0.0018 sec)

MySQL 8.0.14 中添加了Encrypted列,并支持加密二进制日志。

一般来说,文件大小会比示例中的大,因为在写入事务后,当文件大小超过max_binlog_size(默认为 1 GiB)时,二进制日志文件会自动循环。由于事务不在文件之间分割,如果您有大型事务,文件可能会变得比max_binlog_size大一些。

查看日志事件

SHOW BINLOG EVENTSSHOW RELAYLOG EVENTS语句分别读取二进制日志和中继日志,并返回与参数匹配的事件。有四个参数,其中一个仅适用于中继日志事件:

  • IN: 从中读取事件的二进制日志或中继日志文件的名称。

  • FROM: 开始读取的字节位置。

  • 限制:包含在可选偏移量中的事件数量。语法与SELECT语句相同:[offset], row_count

  • 对于通道:对于中继日志,为其读取事件的复制通道。

所有参数都是可选的。如果没有给出IN参数,则返回第一个日志中的事件。清单 8-1 中显示了使用SHOW BINLOG EVENTS的示例。如果您想尝试这个例子,您将需要替换二进制日志文件名、位置和限制。

mysql> SHOW BINLOG EVENTS IN 'binlog.000049' FROM 195 LIMIT 5\G
*************************** 1\. row ***************************
   Log_name: binlog.000049
        Pos: 195
 Event_type: Gtid
  Server_id: 1
End_log_pos: 274
       Info: SET @@SESSION.GTID_NEXT= '4d22b3e5-a54f-11e9-8bdb-ace2d35785be:603'
*************************** 2\. row ***************************
   Log_name: binlog.000049
        Pos: 274
 Event_type: Query
  Server_id: 1
End_log_pos: 372
       Info: BEGIN
*************************** 3\. row ***************************
   Log_name: binlog.000049
        Pos: 372
 Event_type: Table_map
  Server_id: 1
End_log_pos: 436
       Info: table_id: 89 (world.city)
*************************** 4\. row ***************************
   Log_name: binlog.000049
        Pos: 436
 Event_type: Update_rows
  Server_id: 1
End_log_pos: 544
       Info: table_id: 89 flags: STMT_END_F
*************************** 5\. row ***************************
   Log_name: binlog.000049
        Pos: 544
 Event_type: Xid
  Server_id: 1
End_log_pos: 575
       Info: COMMIT /* xid=44 */
5 rows in set (0.0632 sec)

Listing 8-1Using SHOW BINLOG EVENTS

这个例子说明了使用SHOW语句检查二进制和中继日志的一些局限性。结果是一个正常的查询结果集,由于文件大小通常在 1gb 左右,这意味着结果可能同样大。您可以像示例中那样只选择特定的事件,但是知道感兴趣的事件从哪里开始并不总是那么简单,而且您不能按事件类型或它们影响的表进行过滤。最后,默认的事件格式(binlog_format选项)是行格式,从结果的第三和第四行可以看出,从SHOW BINGOG EVENTS中可以看到的是事务更新了world.city表。您看不到更新了哪些行以及值是什么。

实际上,如果您可以访问文件系统,在大多数情况下最好使用 MySQL 附带的mysqlbinlog实用程序。(SHOW BINLOG EVENTSSHOW RELAYLOG EVENTS语句在受控测试中或者当复制停止并且您想要快速检查导致错误的事件时仍然有用。)清单 8-2 中显示了使用mysqlbinlog实用程序执行前面的SHOW BINLOG EVENTS语句的等效命令。该示例还使用 verbose 标志来显示更新world.city表的基于行的事件的前后图像。

shell> mysqlbinlog -v --base64-output=decode-rows --start-position=195 --stop-position=575 binlog.000049
/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=1*/;
/*!50003 SET @OLD_COMPLETION_TYPE=@@COMPLETION_TYPE,COMPLETION_TYPE=0*/;
DELIMITER /*!*/;
# at 124
#190914 20:38:43 server id 1  end_log_pos 124 CRC32 0x751322a6  Start: binlog v 4, server v 8.0.18 created 190914 20:38:43 at startup
# Warning: this binlog is either in use or was not closed properly.
ROLLBACK/*!*/;
# at 195
#190915 10:18:45 server id 1  end_log_pos 274 CRC32 0xe1b8b9a1  GTID    last_committed=0        sequence_number=1       rbr_only=yes    original_committed_timestamp=1568506725779031   immediate_commit_timestamp=1568506725779031     transaction_length=380
/*!50718 SET TRANSACTION ISOLATION LEVEL READ COMMITTED*//*!*/;
# original_commit_timestamp=1568506725779031 (2019-09-15 10:18:45.779031 AUS Eastern Standard Time)
# immediate_commit_timestamp=1568506725779031 (2019-09-15 10:18:45.779031 AUS Eastern Standard Time)
/*!80001 SET @@session.original_commit_timestamp=1568506725779031*//*!*/;
/*!80014 SET @@session.original_server_version=80018*//*!*/;
/*!80014 SET @@session.immediate_server_version=80018*//*!*/;
SET @@SESSION.GTID_NEXT= '4d22b3e5-a54f-11e9-8bdb-ace2d35785be:603'/*!*/;
# at 274

#190915 10:18:45 server id 1  end_log_pos 372 CRC32 0x2d716bd5  Query   thread_id=8     exec_time=0     error_code=0
SET TIMESTAMP=1568506725/*!*/;
SET @@session.pseudo_thread_id=8/*!*/;
SET @@session.foreign_key_checks=1, @@session.sql_auto_is_null=0, @@session.unique_checks=1, @@session.autocommit=1/*!*/;
SET @@session.sql_mode=1168113696/*!*/;
SET @@session.auto_increment_increment=1, @@session.auto_increment_offset=1/*!*/;
/*!\C utf8mb4 *//*!*/;
SET @@session.character_set_client=45,@@session.collation_connection=45,@@session.collation_server=255/*!*/;
SET @@session.lc_time_names=0/*!*/;
SET @@session.collation_database=DEFAULT/*!*/;
/*!80011 SET @@session.default_collation_for_utf8mb4=255*//*!*/;
BEGIN
/*!*/;
# at 372
#190915 10:18:45 server id 1  end_log_pos 436 CRC32 0xb62c64d7  Table_map: `world`.`city` mapped to number 89
# at 436
#190915 10:18:45 server id 1  end_log_pos 544 CRC32 0x62687b0b  Update_rows: table id 89 flags: STMT_END_F
### UPDATE `world`.`city`
### WHERE
###   @1=130
###   @2='Sydney'
###   @3='AUS'
###   @4='New South Wales'
###   @5=3276207
### SET
###   @1=130
###   @2='Sydney'
###   @3='AUS'
###   @4='New South Wales'
###   @5=3276208
# at 544
#190915 10:18:45 server id 1  end_log_pos 575 CRC32 0x149e2b5c  Xid = 44
COMMIT/*!*/;
SET @@SESSION.GTID_NEXT= 'AUTOMATIC' /* added by mysqlbinlog */ /*!*/;
DELIMITER ;
# End of log file
/*!50003 SET COMPLETION_TYPE=@OLD_COMPLETION_TYPE*/;
/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=0*/;

Listing 8-2Inspecting the binary log using the mysqlbinlog utility

-v参数请求详细模式,最多可以给出两次,以增加包含的信息量。单个-v在事件中从位置 436 开始生成带有伪查询的注释。--base64-output=decode-rows参数告诉mysqlbinlog不要在行格式中包含事件的 base64 编码版本。--start-position--stop-position参数以字节为单位指定开始和停止偏移量。

事务中最有趣的事件是以注释# at 436开始的事件,这意味着事件从偏移量 436(以字节为单位)开始。它被写成一个伪 update 语句,其中的WHERE部分显示更改前的值,SET部分显示更新后的值。这也称为前后图像。

Note

如果使用加密的二进制日志,则不能直接使用mysqlbinlog来读取文件。一种选择是让mysqlbinlog连接到服务器并读取它们,这将返回未加密的日志。如果您使用keyring_file插件来存储加密密钥,另一个选择是使用 Python 或标准 Linux 工具来解密文件。这些方法在 https://mysql.wisborg.dk/decrypt-binary-logshttps://mysqlhighavailability.com/how-to-manually-decrypt-an-encrypted-binary-log-file/ 中有描述。

显示连接的副本

另一个有用的命令是要求复制源列出与其连接的所有副本。这可用于在监控工具中自动发现复制拓扑。

列出连接副本的命令是SHOW SLAVE HOSTS,例如:

mysql> SHOW SLAVE HOSTS\G
*************************** 1\. row ***************************
 Server_id: 2
      Host: replica.example.com
      Port: 3308
 Master_id: 1
Slave_UUID: 0b072c80-d759-11e9-8423-ace2d35785be
1 row in set (0.0003 sec)

如果在执行该语句时没有连接任何副本,结果将为空。Server_idMaster_id列分别是副本和源上的server_id系统变量的值。Host是用report_host选项指定的副本的主机名。类似地,Port列是副本的report_port值。最后,Slave_UUID列是副本上@@global.server_uuid的值。

剩下的唯一一组SHOW语句由各种语句组成,用于获取关于特权、用户、打开的表、警告和错误的信息。

杂项声明

有几个SHOW陈述是有用的,但是不适合到目前为止已经讨论过的任何组。它们可以用来列出可用的特权,返回帐户的CREATE USER语句,列出打开的表,以及在执行语句后列出警告或错误。这些声明总结在表 8-3 中。

表 8-3

杂项SHOW报表

|

SHOW语句

|

描述

|
| --- | --- |
| PRIVILEGES | 列出了可用的权限、它们适用的上下文,以及对某些权限的权限控制内容的描述。 |
| CREATE USER | 返回帐户的CREATE USER语句。 |
| GRANTS | 列出为当前帐户或另一个帐户分配的权限。 |
| OPEN TABLES | 列出表缓存中的表、表锁或锁请求的数量,以及表的名称是否被锁定(发生在DROP TABLERENAME TABLE期间)。 |
| WARNINGS | 列出警告和错误,如果sql_notes已启用(默认),则为最后执行的语句添加注释。 |
| ERRORS | 列出最后执行的语句的错误。 |

三种最常用的杂项SHOW语句是SHOW CREATE USERSHOW GRANTSSHOW WARNINGS

SHOW CREATE USER语句可用于检索账户的CREATE USER语句。这对于检查帐户的元数据很有用,无需直接查询底层的mysql.user表。允许所有用户为当前用户执行该语句。例如:

mysql> SET print_identified_with_as_hex = ON;
Query OK, 0 rows affected (0.0200 sec)

mysql> SHOW CREATE USER CURRENT_USER()\G
*************************** 1\. row ***************************
CREATE USER for root@localhost: CREATE USER 'root'@'localhost' IDENTIFIED WITH 'caching_sha2_password' AS 0x24412430303524377B743F5E176E1A77494F574D216C41563934064E58364E385372734B77314E43587745314F506F59502E747079664957776F4948346B526B59467A642F30 REQUIRE NONE PASSWORD EXPIRE DEFAULT ACCOUNT UNLOCK PASSWORD HISTORY DEFAULT PASSWORD REUSE INTERVAL DEFAULT PASSWORD REQUIRE CURRENT DEFAULT
1 row in set (0.0003 sec)

启用print_identified_with_as_hex变量(在 8.0.17 和更高版本中可用)来返回十六进制表示的密码摘要。当将值返回到控制台时,这是首选方法,因为摘要可能包含不可打印的字符。SHOW CREATE USER输出等同于用户的创建方式,可用于创建具有相同设置(包括密码)的新用户。

Note

只有 MySQL 8.0.17 和更高版本支持在创建用户时以十六进制表示法指定身份验证摘要。

SHOW GRANTS语句通过返回分配给帐户的特权来补充SHOW CREATE USER。默认为当前用户返回,但是如果您拥有mysql系统数据库的SELECT权限,您也可以获得分配给其他帐户的权限。例如,要列出 root@localhost 帐户的权限:

mysql> SHOW GRANTS FOR root@localhost\G
*************************** 1\. row ***************************
Grants for root@localhost: GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, RELOAD, SHUTDOWN, PROCESS, FILE, REFERENCES, INDEX, ALTER, SHOW DATABASES, SUPER, CREATE TEMPORARY TABLES, LOCK TABLES, EXECUTE, REPLICATION SLAVE, REPLICATION CLIENT, CREATE VIEW, SHOW VIEW, CREATE ROUTINE, ALTER ROUTINE, CREATE USER, EVENT, TRIGGER, CREATE TABLESPACE, CREATE ROLE, DROP ROLE ON *.* TO `root`@`localhost` WITH GRANT OPTION
*************************** 2\. row ***************************
Grants for root@localhost: GRANT APPLICATION_PASSWORD_ADMIN,AUDIT_ADMIN,BACKUP_ADMIN,BINLOG_ADMIN,BINLOG_ENCRYPTION_ADMIN,CLONE_ADMIN,CONNECTION_ADMIN,ENCRYPTION_KEY_ADMIN,GROUP_REPLICATION_ADMIN,INNODB_REDO_LOG_ARCHIVE,PERSIST_RO_VARIABLES_ADMIN,REPLICATION_APPLIER,REPLICATION_SLAVE_ADMIN,RESOURCE_GROUP_ADMIN,RESOURCE_GROUP_USER,ROLE_ADMIN,SERVICE_CONNECTION_ADMIN,SESSION_VARIABLES_ADMIN,SET_USER_ID,SYSTEM_USER,SYSTEM_VARIABLES_ADMIN,TABLE_ENCRYPTION_ADMIN,XA_RECOVER_ADMIN ON *.* TO `root`@`localhost` WITH GRANT OPTION
*************************** 3\. row ***************************
Grants for root@localhost: GRANT PROXY ON “@” TO 'root'@'localhost' WITH GRANT OPTION
3 rows in set (0.0129 sec)

SHOW WARNINGS语句是 MySQL 中使用最少的语句之一。如果 MySQL 遇到问题但能够继续,它将生成一个警告,但在其他情况下完成语句的执行。虽然语句完成时没有错误,但警告可能是更大问题的迹象,最佳实践是始终检查警告,并力求在应用执行的查询中不出现警告。

Note

MySQL Shell 不支持SHOW WARNINGS语句,因为如果启用了\W模式(默认),它将自动获取警告,否则不会提供警告。然而,该语句在传统的mysql命令行客户端和一些连接器(如 MySQL Connector/Python)中仍然有用。

清单 8-3 展示了一个例子,其中SHOW WARNINGS与传统的mysql命令行客户端一起使用来识别模式定义和数据不匹配。

mysql> SELECT @@sql_mode\G
*************************** 1\. row ***************************
@@sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
1 row in set (0.0004 sec)

mysql> SET sql_mode = sys.list_drop(
                          @@sql_mode,
                          'STRICT_TRANS_TABLES'
                      );
Query OK, 0 rows affected, 1 warning (0.00 sec)

mysql> SHOW WARNINGS\G
*************************** 1\. row ***************************
  Level: Warning
   Code: 3135
Message: 'NO_ZERO_DATE', 'NO_ZERO_IN_DATE' and 'ERROR_FOR_DIVISION_BY_ZERO' sql modes should be used with strict mode. They will be merged with strict mode in a future release.
1 row in set (0.00 sec)

mysql> UPDATE world.city
          SET Population = Population/0
        WHERE ID = 130;
Query OK, 0 rows affected, 2 warnings (0.00 sec)
Rows matched: 1  Changed: 0  Warnings: 2

mysql> SHOW WARNINGS\G
*************************** 1\. row ***************************
  Level: Warning
   Code: 1365
Message: Division by 0
*************************** 2\. row ***************************
  Level: Warning
   Code: 1048
Message: Column 'Population' cannot be null
2 rows in set (0.00 sec)

mysql> SELECT *
         FROM world.city
        WHERE ID = 130\G
*************************** 1\. row ***************************
         ID: 130
       Name: Sydney
CountryCode: AUS
   District: New South Wales
 Population: 0
1 row in set (0.03 sec)

Listing 8-3Using SHOW WARNINGS to identify problems

这个例子从 MySQL 8 中默认设置的 SQL 模式开始。首先,使用sys.list_drop()函数更改 SQL 模式,删除触发警告的STRICT_TRANS_TABLES模式,因为禁用严格模式应该与其他模式一起完成,因为它们将在以后合并在一起。然后更新了world.city表中一个城市的人口,但是计算结果是除以 0,这触发了两个警告。一个警告是针对未定义的除以 0,所以 MySQL 使用了一个NULL值,这导致了第二个警告,因为Population列是一个NOT NULL列。结果是分配给城市的人口数为 0,这可能不是应用中所预期的。这也说明了为什么启用严格 SQL 模式很重要,因为这会使除以零成为错误并阻止更新。

Caution

不要禁用STRICT_TRANS_TABLES SQL 模式,因为它更有可能导致表中出现无效数据。

摘要

本章介绍了在信息模式和性能模式实现之前的SHOW语句。现在,在信息模式和性能模式中使用底层数据源通常更好。前两节给出了SHOW语句和数据源之间的映射。

还有一些SHOW语句返回无法通过其他来源访问的数据。一个常用的特性是通过SHOW ENGINE INNODB STATUS语句从 InnoDB 获得的 InnoDB monitor 报告。该报告分为几个部分,其中一些将在调查性能和锁定问题时使用。

还有一些关于复制和二进制日志的语句非常有用。其中最常用的语句是SHOW BINARY LOGS,它列出了 MySQL 已知的该实例的二进制日志。这些信息包括日志的大小以及日志是否加密。您还可以在二进制日志或中继日志中列出事件,但实际上mysqlbinlog实用程序通常是更好的选择。

最后,涵盖了一组杂七杂八的SHOW语句。其中最常用的三个是:SHOW CREATE USER显示可用于重新创建用户的语句,SHOW GRANTS返回分配给用户的权限,SHOW WARNINGS列出上次执行查询时出现的错误、警告和默认注释。检查警告是执行查询时经常被忽略的一个方面,因为警告可能表明查询结果不是您所期望的。建议始终检查警告并启用STRICT_TRANS_TABLES SQL 模式。

关于信息源的最后一章是关于慢速查询日志的。

九、慢速查询日志

在能够从性能模式中获取查询统计信息之前,慢速查询日志是查找可进行优化的查询的主要信息源。即使在今天,缓慢的查询日志也不应该被完全抛弃。

与性能模式中的语句摘要信息相比,慢速查询日志有三个主要优势。记录的查询是持久化的,所以您可以在 MySQL 重新启动后查看信息,查询被记录了时间戳,并且实际的查询被记录。由于这些原因,慢速查询日志通常与性能模式一起使用。

Tip

像 MySQL Enterprise Monitor(https://dev.mysql.com/doc/mysql-monitor/en/mem-qanal-using.html)这样的监控解决方案可以克服性能模式的这些限制,所以如果您有一个包含详细查询信息的监控解决方案,您就不太可能需要缓慢的查询日志。

缓慢的查询日志也有缺点。开销高于性能模式,因为查询被写入纯文本文件,并且在写入事件时没有并发支持。对查询日志的支持也很有限(您可以将慢速查询日志存储在一个表中,但这有其自身的缺点),这使得在调查过程中使用它不太实际。

本章将介绍如何配置慢速查询日志、原始日志事件的外观,以及如何使用mysqldumpslow(在 Microsoft Windows 上为mysqldumpslow.pl)脚本来聚合日志。

配置

有几个选项可用于配置慢速查询日志以及记录哪些查询。由于启用日志的开销随着记录的查询数量的增加而增加,所以配置良好的慢速查询日志非常重要。记录“恰到好处”的查询也使得识别感兴趣的查询变得更加容易。

默认情况下不启用慢速查询日志,当启用该日志时,默认情况下只记录直接在本地实例上执行的非管理查询,并且该查询的执行时间超过 10 秒。表 9-1 总结了可用于微调该行为的配置选项。该信息包括默认值以及该选项是用于全局范围还是会话范围,或者两者都用。选项按字母顺序列出。

表 9-1

慢速查询日志的配置选项

|

选项/默认值/范围

|

描述

|
| --- | --- |
| min_examined_row_limit默认值:0 范围:全局,会话 | 只有检查的行数超过该值的查询才会被记录。这在允许记录所有执行全扫描的查询时特别有用。 |
| log_output默认:FILE范围:全球 | 控制慢速查询日志和常规查询日志是记录到文件、表或两者中,还是根本不记录。 |
| log_queries_not_using_indexes默认:OFF范围:全球 | 启用后,所有执行全表或索引扫描的查询都会被记录下来,不管它们需要多长时间。 |
| log_short_format默认:OFF范围:全球 | 启用时,记录的信息较少。此选项只能在配置文件中设置。 |
| log_slow_admin_statements默认:OFF范围:全球 | 启用时,像ALTER TABLEOPTIMIZE TABLE这样的管理语句可以进行日志记录。 |
| log_slow_extra默认:OFF范围:全球 | 启用时,有额外的信息,如查询的Handler_%状态变量的值。只有在记录到文件以及 MySQL 8.0.14 和更高版本中,才支持该功能。不启用log_slow_extra的主要原因是如果您有需要旧格式的脚本。 |
| log_slow_slave_statements默认:OFF范围:全球 | 启用后,复制的语句也有资格进行日志记录。这只适用于语句格式的二进制日志事件。 |
| log_throttle_queries_not_using_indexes默认:0范围:全球 | 当您启用了对执行完全扫描的所有查询的记录时,此选项可以限制每分钟可以记录查询的最大次数。 |
| log_timestamps默认:UTC范围:全球 | 时间戳是使用 UTC 还是系统时区。此选项也适用于错误日志和一般查询日志。它仅在记录到文件时适用。 |
| long_query_time默认:10范围:全局,会话 | 记录查询之前的最短查询延迟时间(秒)(除非它正在进行完全扫描,并且您已启用记录这些查询)。支持小数秒。设置为 0 以记录所有查询。警告:记录所有查询有很大的开销,最好在测试系统上或短时间内完成。 |
| slow_query_log默认:OFF范围:全球 | 是否启用慢速查询日志。 |
| slow_query_log_file默认:<hostname>-slow.log范围:全球 | 慢速查询日志文件的路径和文件名。默认位置在数据目录中,根据系统的主机名命名。 |

建议将log_output保留为默认值,并将事件记录到由slow_query_log_file设置的文件中。以表格的形式获取慢速查询日志似乎很有吸引力;但是,在这种情况下,数据保存为逗号分隔值(CSV ),对表的查询不能使用索引。还有一些功能如log_slow_extralog_output = TABLE不支持的。

这些选项意味着您可以细粒度地控制记录哪些查询。除了log_short_format以外的所有选项都可以动态更改,所以您可以根据情况需要进行更改。如果你觉得很难确定选项是如何相互作用的,那么图 9-1 显示了一个决定过程的流程图,该过程决定一个查询是否应该被记录。(流程图只是说明性的,实际的代码路径是不同的。)

img/484666_1_En_9_Fig1_HTML.png

图 9-1

确定是否将查询记录到慢速日志的流程图

流程从查询类型开始。对于管理语句和复制语句,它们只有在启用了相应选项的情况下才会继续。常规查询首先检查它们是否符合不使用索引的条件,然后依靠检查查询执行时间(延迟)。如果满足任一条件,则检查是否检查了足够多的行。一些更好的细节,比如不使用索引的语句的节流,在图中被忽略了。

一旦有了所需的查询设置,就需要查看日志中的事件,以确定是否有任何查询需要注意。

记录事件

慢速查询日志由纯文本格式的事件组成。这意味着您可以使用任何您喜欢的文本查看器来检查文件。在 Linux 和 Unix 上,less命令是一个很好的选择,因为它对处理大文件有很好的支持。在 Microsoft Windows 上,Notepad++是一种常见的选择,但对大文件没有同样好的支持。Windows 上的另一个选项是安装 Windows Subsystem for Linux (WSL ),它允许您安装 Linux 发行版,并以这种方式访问像less这样的命令。

事件的格式取决于设置。清单 9-1 显示了一个默认格式的事件示例,用long_query_time = 0记录所有查询。请注意,由于页面宽度有限,一些行已经换行。

# Time: 2019-09-17T09:37:53.269881Z
# User@Host: root[root] @ localhost [::1]  Id:    22
# Query_time: 0.032531  Lock_time: 0.000221 Rows_sent: 10  Rows_examined: 4089
SET timestamp=1568713073;
SELECT CountryCode, COUNT(*) FROM world.city GROUP BY CountryCode ORDER BY COUNT(*) DESC LIMIT 10;

Listing 9-1A slow query log event in the default format

第一行显示了查询的执行时间。这是时间戳,您可以控制是使用 UTC 还是系统时间来使用log_timestamp选项。第二行显示了哪个帐户执行了查询和连接 id。第三行包括查询的一些基本统计数据:查询执行时间、等待锁的时间、返回给客户机的行数和检查的行数。

SET timestamp查询设置查询的时间戳,以 epoch(1970 年 1 月 1 日 00:00:00 UTC)以来的秒数度量,最后慢速查询在最后一行。

在统计数据中,查询时间和检查的行数与发送的行数之间的比率是特别重要的。与返回的行数相比,检查的行数越多,索引的效率通常越低。但是,您应该总是在查询的上下文中查看信息。在这种情况下,查询会找到城市最多的十个国家代码。如果不执行全表或索引扫描,就无法找到,所以在这种情况下,检查的行数与发送的行数之比很低是有原因的。

如果您在版本 8.0.14 和更高版本中启用了log_slow_extra,那么您将获得查询的附加信息,如清单 9-2 所示。

# Time: 2019-09-17T10:09:50.054970Z
# User@Host: root[root] @ localhost [::1]  Id:    22
# Query_time: 0.166589  Lock_time: 0.099952 Rows_sent: 10  Rows_examined: 4089 Thread_id: 22 Errno: 2336802955 Killed: 0 Bytes_received: 0 Bytes_sent: 0 Read_first: 1 Read_last: 0 Read_key: 1 Read_next: 4079 Read_prev: 0 Read_rnd: 0 Read_rnd_next: 0 Sort_merge_passes: 0 Sort_range_count: 0 Sort_rows: 10 Sort_scan_count: 1 Created_tmp_disk_tables: 0 Created_tmp_tables: 0 Start: 2019-09-17T10:09:49.888381Z End: 2019-09-17T10:09:50.054970Z
SET timestamp=1568714989;
SELECT CountryCode, COUNT(*) FROM world.city GROUP BY CountryCode ORDER BY COUNT(*) DESC LIMIT 10;

Listing 9-2Using log_slow_extra with the slow query log

从性能角度来看,主要感兴趣的统计数据是以Bytes_received开始,以Created_tmp_tables结束的统计数据。其中一些统计数据相当于查询的Handler_%状态变量。在这种情况下,您可以看到Read_next计数器是大量被检查行的主要贡献者。Read_next在扫描索引以查找行时使用,因此可以断定查询执行了索引扫描。

如果您需要知道在给定时间执行了什么,查看原始事件会非常有用。如果您更想知道哪些查询通常对系统负载贡献最大,那么您需要聚合数据。

聚合

可以使用 MySQL 安装中包含的mysqldumpslow(在 Microsoft Windows 上为mysqldumpslow.pl)脚本来聚合慢速查询日志中的数据。mysqldumpslow是一个 Perl 脚本,默认情况下,它通过用N替换数值,用'S'替换字符串来规范化日志中的查询。这允许脚本以类似于性能模式中的events_statements_summary_by_digest表的方式来聚合查询。

Note

该脚本要求在您的系统上安装 Perl。在总是有 Perl 的 Linux 和 Unix 上,这不是问题,但是在 Microsoft Windows 上,您将需要自己安装 Perl。一种选择是从 http://strawberryperl.com/ 安装草莓 Perl。

有几个选项可以控制mysqldumpslow的行为。这些总结在表 9-2 中。此外,慢速查询日志文件可以作为不带选项名称的参数给出。

表 9-2

mysqldumpslow的命令行参数

|

[计]选项

|

缺省值

|

描述

|
| --- | --- | --- |
| -a |   | 不要用N'S'替换数字和字符串值。 |
| --debug |   | 在调试模式下执行。 |
| -g |   | 对查询执行模式匹配(使用与grep相同的语法),并且只包括匹配的查询。 |
| -h | * | 默认情况下,mysqldumpslow搜索 MySQL 配置文件中设置的datadir中的文件。此选项指定文件应该匹配的主机名,假设使用默认的慢速查询日志文件名。可以使用通配符。 |
| --help |   | 显示帮助文本。 |
| -i |   | 在自动算法中使用的mysql.server启动脚本中的实例名,以查找慢速查询日志文件。 |
| -l |   | 不要提取查询的锁定时间。 |
| -n | 0 | 在抽象为N之前,数字中必须包含的最小位数。 |
| -r |   | 颠倒查询的返回顺序。 |
| -s | at | 如何对查询进行排序。默认情况下,根据平均查询时间进行排序。排序选项的完整列表将单独介绍。 |
| -t | (全部) | 在结果中返回的最大查询数。 |
| --verbose |   | 在脚本执行期间打印附加信息。 |

-s-t-r选项是最常用的。虽然mysqldumpslow可以使用默认路径和主机名中的 MySQL 配置文件来搜索慢速查询日志,但更常见的是在命令行中将慢速查询日志文件的路径指定为参数。

-s选项用于指定如何对结果中包含的查询进行排序。对于某些排序选项,可以选择使用总计或平均值进行排序。分类选项在表 9-3 中列出,也可从mysqldumpslow --help输出中获得。总计列指定用于按总计排序的选项,而平均值列显示用于按平均值排序的选项。

表 9-3

mysqldumpslow的排序选项

|

总数

|

平均的

|

描述

|
| --- | --- | --- |
| c |   | 按查询执行的次数(计数)排序。 |
| l | al | 按锁定时间排序。 |
| r | ar | 按发送的行数排序。 |
| t | at | 按查询时间排序。 |

有时使用不同的排序选项生成几个报告会很有用,这样可以更好地了解在实例上执行的查询。

作为一个案例研究,考虑一个实例从一个空的慢速查询日志文件开始;然后执行清单 9-3 中的查询。执行这些查询时,会话的long_query_time设置为 0,以记录所有查询,这有助于避免花费很长时间执行查询。

SET GLOBAL slow_query_log = ON;
SET long_query_time = 0;
SELECT * FROM world.city WHERE ID = 130;
SELECT * FROM world.city WHERE ID = 131;
SELECT * FROM world.city WHERE ID = 201;
SELECT * FROM world.city WHERE ID = 2010;
SELECT * FROM world.city WHERE ID = 1;
SELECT * FROM world.city WHERE ID = 828;
SELECT * FROM world.city WHERE ID = 131;
SELECT * FROM world.city WHERE CountryCode = 'AUS';
SELECT * FROM world.city WHERE CountryCode = 'CHN';
SELECT * FROM world.city WHERE CountryCode = 'IND';
SELECT * FROM world.city WHERE CountryCode = 'GBR';
SELECT * FROM world.city WHERE CountryCode = 'USA';
SELECT * FROM world.city WHERE CountryCode = 'NZL';
SELECT * FROM world.city WHERE CountryCode = 'BRA';
SELECT * FROM world.city WHERE CountryCode = 'AUS';
SELECT * FROM world.city WHERE CountryCode = 'DNK';
SELECT * FROM world.city ORDER BY Population DESC LIMIT 10;
SELECT * FROM world.city ORDER BY Population DESC LIMIT 4;
SELECT * FROM world.city ORDER BY Population DESC LIMIT 9;

Listing 9-3The queries used to create slow query log events for a case study

对于WHERE子句或LIMIT子句,有三个具有不同值的基本查询。首先,通过主键找到城市,主键将搜索一行以返回一行。第二,城市是通过作为二级索引的CountryCode找到的,所以找到了几行,但返回的行数仍然相同。第三,检查所有城市以返回人口最多的城市。

假设慢速查询日志文件被命名为mysql-slow.log,并且您正在文件所在的同一个目录中执行mysqldumpslow,那么您可以对查询进行分组,并按照查询被执行的次数对它们进行排序,如清单 9-4 所示。-t选项用于将报告限制为包括三个(规范化的)查询。

shell$ mysqldumpslow -s c -t 3 mysql-slow.log

Reading mysql slow query log from mysql-slow.log
Count: 9  Time=0.00s (0s)  Lock=0.00s (0s)  Rows=150.1 (1351), root[root]@localhost
  SELECT * FROM world.city WHERE CountryCode = 'S'

Count: 7  Time=0.02s (0s)  Lock=0.00s (0s)  Rows=1.0 (7), root[root]@localhost
  SELECT * FROM world.city WHERE ID = N

Count: 3  Time=0.00s (0s)  Lock=0.00s (0s)  Rows=7.7 (23), root[root]@localhost
  SELECT * FROM world.city ORDER BY Population DESC LIMIT N

Listing 9-4Using mysqldumpslow to sort the queries by count

注意WHERELIMIT条款是如何被修改成使用N'S'的。查询时间以Time=0.00s (0s)的形式列出,平均查询时间(0.00s)在前,总时间在括号中。锁和行统计信息也是如此。

因为mysqldumpslow脚本是用 Perl 编写的,所以如果您想包含对新排序选项的支持或者更改输出,修改脚本相对容易。例如,如果您想在平均执行时间中包含更多的小数,您可以在usage子例程之前修改printf语句(MySQL 8.0.18 包含的脚本中的第 168–169 行),如下所示

    printf "Count: %d  Time=%.6fs (%ds)  Lock=%.2fs (%ds)  Rows=%.1f (%d), $user\@$host\n%s\n\n",
          $c, $at,$t, $al,$l, $ar,$r, $_;

变化出现在第一行的Time=%.6fs部分。这将打印以微秒为单位的平均执行时间。

摘要

本章展示了如何使用慢速查询日志来收集在 MySQL 实例上执行的查询的信息。慢速查询日志侧重于根据执行时间和查询是否使用索引(实际上是执行全表扫描还是索引扫描)来捕获查询。与性能模式相比,慢速查询日志的主要优点是日志包括执行的确切语句,并且是持久化的。缺点是开销大,而且很难得到返回您感兴趣的查询的报告。

首先,讨论了用于配置慢速查询日志的配置选项。有一些选项可以控制最小执行时间、是否应该记录不使用索引的查询而不管执行时间、要记录的查询类型等等。在 MySQL 8.0.14 和更高版本中,您可以使用log_slow_extra来包含关于慢速查询的更多详细信息。

其次,讨论了两个慢速查询日志事件的例子。有一个使用默认信息的例子和一个启用了log_slow_extra的例子。如果您正在查找在给定时间点执行的查询的信息,原始事件会很有用。对于更一般的查询,用mysqldumpslow脚本聚集数据更有用。在上一节中讨论了mysqldumpslow的使用。

下一部分将介绍一些对性能调优有用的工具,首先以 MySQL Enterprise Monitor 为例讨论监控。

十、MySQL 企业监控器

无论是在系统级还是查询级,监控都是性能调优的关键之一。本章将介绍 MySQL 可用的监控解决方案之一,MySQL 企业监控器,也称为 MEM。

本章将首先概述 MySQL 企业监控器的架构和原理。如果您想尝试 MySQL Enterprise Monitor,那么有一个部分提供了安装说明,随后讨论了如何启动和停止服务管理器,以及如何将 MySQL 实例添加到受监控实例的列表中。最后,是用户界面之旅。

本书的其余部分使用 MySQL Enterprise Monitor 中的图表和报告来说明监控工具的使用,但是您也可以使用其他监控解决方案。如果你对 MySQL 企业监控器不感兴趣,可以跳过这一章。

概观

MySQL Enterprise Monitor 是 Oracle 专用于 MySQL 的监控解决方案。它作为 MySQL Server 的配套产品提供给客户,由 MySQL 开发团队开发。

Note

MySQL Enterprise Monitor 需要 MySQL Enterprise Edition 或 MySQL Cluster CGE(运营商级版本)订阅才能在 30 天试用版之后使用(另请参见下一节中的下载说明)。您可以在 www.mysql.com/products/enterprise/ 查看 MySQL 商业特性。

MySQL Enterprise Monitor 由多个组件组成,每个组件在整个监控解决方案中都扮演着自己的角色。在版本 8 中,有两个主要组件:

  • 服务管理器:该组件存储收集的指标,并提供查看数据和管理配置的前端接口。服务管理器由两部分组成,一部分是 Tomcat 服务器,它是服务管理器的应用端,另一部分是存储库,它是一个存储数据的 MySQL 数据库。

  • 代理: MySQL Enterprise Monitor 使用代理连接到被监控的 MySQL 实例。服务管理器包括一个内置代理,默认情况下它会监控存储库。代理可以监控本地操作系统以及本地和远程 MySQL 实例。

Note

本书遵循 MySQL 企业监控手册( https://dev.mysql.com/doc/mysql-monitor/en/ )的惯例,用标题案例来写服务经理和代理。

由于代理只能监控运行它的操作系统 CPU 和内存使用量、磁盘容量等指标——所以最好在监控 MySQL 实例的每台主机上安装一个代理。这将允许您将主机指标与 MySQL 活动相关联。如果您无法在本地安装代理,例如,如果您使用的云解决方案不允许您访问操作系统,您可以使用安装在另一台主机上的代理来监控 MySQL 指标。在这种情况下,一种选择是使用服务管理器中的内置代理。图 10-1 显示了一个有三台主机的设置示例,其中一台用于服务管理器,两台主机安装了被监控的 MySQL 实例。

img/484666_1_En_10_Fig1_HTML.jpg

图 10-1

MySQL 企业监控器组件概述

顶部的主机安装了 MySQL 企业监控服务管理器。它包括前端——这里用一个带有图形的网页来描述——以及内置的代理和存储库。内置代理监控存储库,也可以选择性地用于监控其他 MySQL 实例(图中未显示),如果您无法访问主机(例如某些云产品),或者如果您正在测试并希望监控安装了服务管理器的同一主机上的第二个 MySQL 实例,这将非常有用。

主机 1 和主机 2 是两台安装了 MySQL 服务器的主机。每台主机上都安装了一个 MySQL 企业监控代理。代理向 MySQL 实例查询指标,并将指标发送给服务管理器,服务管理器将它们存储在存储库中。服务管理器还可以向代理发送请求,例如,运行特别报告或更改代理收集指标的频率。

服务经理和代理的安装过程相似,都使用客户安装程序。下一节将介绍如何安装服务管理器。如果您想尝试安装代理,那么它将作为一个练习留给读者。

装置

MySQL Enterprise Monitor 的安装非常简单,尽管与其他 MySQL 产品不同。下载该软件与您使用 MySQL 社区版时可能习惯的方式不同,安装总是通过专用的安装程序来完成。本节将指导您完成 MySQL Enterprise Monitor 的下载、安装过程和设置。

[计] 下载

安装的第一步是下载 MySQL 企业监控器。有两个地方可以下载 MySQL 企业监控器。现有的 MySQL 客户可以从 My Oracle Support (MOS)中的 Patches & Updates 选项卡下载。这是推荐给客户的位置,因为修补程序和更新会更频繁地更新,并且包括自 2011 年以来的所有版本。另一个地点是位于 https://edelivery.oracle.com/ 的甲骨文软件交付云,它也允许注册用户下载 30 天的试用版。这些说明涵盖了 Oracle 软件交付云。

Note

新帐户和一段时间没有使用的帐户可能需要进行出口验证,这可能需要几天时间。

你从“主页”开始,如图 10-2 所示。

img/484666_1_En_10_Fig2_HTML.jpg

图 10-2

甲骨文软件交付云主页

如果您没有登录,您需要使用新用户创建一个新用户?在此注册图标。登录后,您将进入搜索页面。图 10-3 显示了搜索表单的一部分。

img/484666_1_En_10_Fig3_HTML.jpg

图 10-3

Oracle 软件交付云搜索表单

在文本字段左侧的下拉框中选择释放。如果您对其他产品也感兴趣,您可以保留默认值,即所有类别,其中包括软件包。在文本字段中,输入 MySQL Enterprise Monitor ,并在显示的搜索列表中单击 MySQL Enterprise Monitor ,或者单击文本字段右侧的搜索按钮(图中未显示列表和按钮)。然后点击 MySQL Enterprise Monitor 结果旁边的添加到购物车

当产品被添加到购物车后,您可以点击页面右上角附近的结帐链接(图中也没有显示)。下一个屏幕如图 10-4 所示,允许您选择为哪些平台下载。

img/484666_1_En_10_Fig4_HTML.jpg

图 10-4

选择要下载的平台

选择你感兴趣的平台。如果您计划在一个平台上安装服务管理器,同时在另一个平台上安装代理来监控实例,那么您需要选择两个平台。当您决定要为哪些平台下载时,点击继续

下一步是接受许可协议。接受前请仔细阅读。Oracle 试用许可协议位于文档末尾。接受条款和条件后,点击继续

Note

作为其中一个步骤,您可能需要完成一项关于 Oracle 软件交付云可用性的调查。

最后一步是选择您想要下载 MySQL Enterprise Monitor 的哪些部分。如图 10-5 所示。

img/484666_1_En_10_Fig5_HTML.jpg

图 10-5

选择要下载 MySQL 企业监控器的哪些部分

每个平台有两个软件包,一个用于服务经理,一个用于代理。可选地(推荐),您可以单击屏幕截图底部中间的查看摘要详细信息链接,以显示每个文件的 SHA-1 和 SHA-256 校验和。您可以使用这些来验证下载是否成功完成。

您可以通过两种方式下载文件。如果您单击文件名,您将一个接一个地下载文件。或者,检查您想要的文件并点击下载按钮,使用下载管理器开始下载。如果您没有安装下载管理器,您将在下载开始前被引导完成安装。

Tip

Oracle 软件交付云使用通用文件名,如V982880-01.zip。将文件重命名为包含您下载的产品、平台和版本信息的名称非常有用。

下载完成后,您可以开始安装过程。

安装过程

MySQL Enterprise Monitor 使用自己的安装程序,该程序在所有平台上都是一样的。支持通过图形用户界面或文本模式使用向导模式执行安装,或者您可以在命令行上提供所有参数并使用无人值守模式。

下载文件的名称取决于您下载的平台和 MySQL 企业监控器的版本。例如,用于微软 Windows 的服务管理器版本 8.0.17 被命名为V982881-01.zip。其他文件的名称类似。如果解压缩 ZIP 文件,您会发现几个文件:

PS> ls | select Length,Name

   Length Name
   ------ ----
  6367299 monitor.a4.pdf
  6375459 monitor.pdf
  5275639 mysql-monitor-html.tar.gz
  5300438 mysql-monitor-html.zip
281846252 mysqlmonitor-8.0.17.1195-windows64-installer.exe
281866739 mysqlmonitor-8.0.17.1195-windows64-update-installer.exe
      975 README_en.txt
      975 READ_ME_ja.txt

确切的文件名和大小取决于平台和 MySQL 企业监控器版本。注意,有两个可执行文件,在本例中是mysqlmonitor-8.0.17.1195-windows64-installer.exemysqlmonitor-8.0.17.1195-windows64-update-installer.exe。前者用于从头开始安装 MySQL Enterprise Monitor,而另一个(有时也称为更新安装程序)用于执行现有安装的升级。PDF 和 HTML 文件是手册,但您通常最好使用位于 https://dev.mysql.com/doc/mysql-monitor/en/ 的在线手册,因为它会定期更新。

Tip

如果您想使用基于文本的向导或无人值守模式,请使用--help参数调用安装程序,以获得受支持参数的列表。

本讨论将继续使用图形用户界面进行安装。您可以通过执行不带任何参数的安装程序来开始安装。第一步是选择语言(英语、日语和简体中文均可)。然后,系统会告诉您需要确保将安装过程中输入的用户名和密码保存在一个安全的位置。

通过欢迎屏幕后,通过指定安装位置来正确启动配置。在 Microsoft Windows 上,默认位置是C:\Program Files\MySQL\Enterprise\Monitor,在 Linux 上,作为root用户安装时是/opt/mysql/enterprise/monitor,作为非特权用户安装时是相对于主目录的mysql/enterprise/monitor

10-6 所示的下一个屏幕要求您选择要监控多大的系统。

img/484666_1_En_10_Fig6_HTML.jpg

图 10-6

选择系统的大小

系统大小决定了默认设置,例如服务管理器的内存配置。安装完成后,您可以手动调整内存设置,但是选择正确的系统大小意味着您不必一开始就担心这些设置。除非您只想用几个实例来尝试 MySQL Enterprise Monitor,否则请选择中型或大型系统。

接下来,您需要指定要使用的端口号。MySQL Enterprise Monitor 使用 Tomcat 服务器作为前端,端口 18080 作为默认的未加密端口,18443 作为默认的 SSL 端口。您将始终使用 SSL 端口。(非 SSL 端口是出于传统原因而存在的,但不能用于前端。)

此时,如果您使用root帐户在 Linux 上安装,将会询问您希望在哪个用户帐户下运行 Tomcat 进程(MySQL 服务器存储库进程将使用mysql用户)。默认是mysqlmem。如果您使用非 root 帐户在 Linux 上安装,将会通知您安装程序无法设置自动启动。

服务管理器使用 MySQL 实例来存储数据,包括收集的指标。您可以选择(见图 10-7 )使用安装程序附带的 MySQL 实例或使用现有的 MySQL 实例。

img/484666_1_En_10_Fig7_HTML.jpg

图 10-7

选择要使用的 MySQL 实例

除非您有非常充分的理由选择其他方式,否则建议使用捆绑的 MySQL 数据库。这不仅允许安装程序使用已知与服务管理器配合良好的基本配置,还简化了升级。

Caution

不要试图使用您想要监控的 MySQL 实例作为服务管理器的存储库。MySQL Enterprise Monitor 确实会导致大量的数据库活动,如果您使用生产数据库,那么当它应该监控的数据库关闭时,您的监控将停止工作。

现在,您可以选择服务管理器用来连接 MySQL 实例的用户名和密码,以及端口号和模式名。如图 10-8 所示。

img/484666_1_En_10_Fig8_HTML.jpg

图 10-8

为捆绑的 MySQL 服务器选择设置

不要轻易选择密码。监控将包括许多关于您的系统的细节,包括主机名和查询。这意味着选择强密码很重要。

这就是配置的结束,安装程序准备开始实际的安装步骤。安装需要一点时间,因为它包括安装 MySQL 服务器实例和 Tomcat 服务器前端。安装完成后,显示一个确认屏幕,随后显示图 10-9 中的警告。

img/484666_1_En_10_Fig9_HTML.jpg

图 10-9

关于默认情况下使用的自签名证书的警告

安装程序为 SSL 连接创建自签名证书。这将很好地加密通信,但它不允许验证您是否连接到正确的服务器。您可以选择购买由可信提供商签名的证书,并让 MySQL Enterprise Monitor 使用该证书。如果您继续使用默认的自签名证书(这里假设的),浏览器将在您第一次连接到服务管理器时抱怨您不能信任该连接(在这种情况下这是无害的)。

这就完成了安装。最后一个屏幕显示确认您已完成向导,您可以选择打开自述文件并启动浏览器。安装程序已经在后台启动了服务管理器,因此除了在浏览器中打开服务管理器的 URL 之外,您不需要做任何其他事情。如果您的浏览器与安装服务管理器的主机在同一台主机上,并且您选择了默认的 SSL 端口(18443),则 URL 为https://localhost:18443/

Note

Tomcat 可能需要一点时间来准备响应连接,这使得第一次连接尝试需要一段时间才能完成。

如上所述,如果您使用默认的自签名证书,浏览器将警告您存在潜在的安全风险。Firefox 的一个例子如图 10-10 所示。

img/484666_1_En_10_Fig10_HTML.jpg

图 10-10

Firefox 警告网站无法验证

你需要接受这个风险。如何做到这一点取决于您的浏览器和版本。在 Firefox 68 的情况下,你进入高级选项并选择接受风险并继续

连接到服务管理器的第一步是进行更多的配置。如图 10-11 所示,大部分信息都集中在一个屏幕上。

img/484666_1_En_10_Fig11_HTML.jpg

图 10-11

服务管理器配置屏幕

顶部要求您配置两个用户。具有管理员角色的用户是您用来通过浏览器登录服务管理器的管理用户(如果需要,您可以在以后创建更多具有较少权限的用户)。具有代理角色的用户是在其他主机上安装代理来监控 MySQL 实例时使用的用户。确保为两个用户选择强密码。

左下角允许您配置 MySQL Enterprise Monitor 是否应该自动检查升级,如果是,是否需要代理设置。在右下角,您可以配置数据应该保留多长时间。您保存数据的时间越长,您就可以追溯到更远的时间来调查问题,并且您保存的细节也越多。代价是数据库的大小增加了。

完成设置后,您将被带到一个新特性页面,您可以为新创建的管理用户设置想要使用的时区和区域设置。

Tip

如果要再次卸载服务管理器,可以使用卸载程序。在 Microsoft Windows 上,您可以通过控制面板中的程序应用来完成此操作。在其他平台上,使用最顶层安装目录中的uninstall命令。

因为在测试期间您可能需要启动和停止服务管理器,下一节将展示如何做。

启动和停止服务管理器

服务管理器被设计为作为服务启动和停止。在 Microsoft Windows 上,当您在 Linux 上使用root帐户安装服务管理器时,安装程序将始终为您安装服务。如果您在 Linux 上以非 root 用户的身份安装它,您可以手动执行服务脚本来启动和停止服务管理器。

Tip

如果手动启动进程,首先启动 MySQL 存储库服务,然后启动 Tomcat。停止它的时候,是反过来的,先停止 Tomcat,再停止 MySQL repository 服务。

微软视窗软件

在 Microsoft Windows 上,安装程序始终需要管理员权限才能运行,这意味着它也可以将 Service Manager 进程作为服务安装。默认情况下,这些服务被设置为在您启动和关闭计算机时自动启动和停止。

您可以通过打开服务应用来编辑服务的设置。在 Windows 10 上,最简单的方法是使用键盘上的 Windows 键(或者通过单击左下角的 Windows 图标打开开始菜单),然后输入 Services ,如图 10-12 所示。

img/484666_1_En_10_Fig12_HTML.jpg

图 10-12

打开服务应用

与截图相比,搜索结果可能在某种程度上有所不同。点击最佳匹配下的服务应用匹配。这将打开应用,您可以在其中控制服务。在服务应用中,您可以通过启动、停止、暂停或重新启动服务来控制服务。存储库服务命名为 MySQL Enterprise MySQL,,Tomcat 服务命名为 MySQL Enterprise Tomcat ,如图 10-13 所示。

img/484666_1_En_10_Fig13_HTML.jpg

图 10-13

控制服务

点按服务时,您会在服务列表左侧的面板中获得基本控制操作。您也可以右键单击服务来获取操作以及编辑服务属性的选项。这些属性包括是否自动启动和停止服务。

Linux 操作系统

如何在 Linux 上启动和停止 MySQL Enterprise Monitor 取决于您是否使用root操作系统用户执行了安装。如果您使用的是root用户,那么您可以通过mysql-monitor-server服务使用service命令(没有对systemd的本地支持)来启动和停止进程;否则,使用安装目录下的mysqlmonitorctl.sh脚本。无论哪种方式,您都可以添加tomcatmysql参数来更改其中一个进程的状态。

清单 10-1 展示了如何使用service命令来启动、重启和停止 MySQL 企业监控器。

shell$ sudo service mysql-monitor-server start
Starting mysql service  [ OK ]
2019-08-24T06:45:43.062790Z mysqld_safe Logging to '/opt/mysql/enterprise/monitor/mysql/data/ol7.err'.
2019-08-24T06:45:43.168359Z mysqld_safe Starting mysqld daemon with databases from /opt/mysql/enterprise/monitor/mysql/data
Starting tomcat service  [ OK ]

shell$ sudo service mysql-monitor-server restart
Stopping tomcat service . [ OK ]
Stopping mysql service 2019-08-24T06:47:57.907854Z mysqld_safe mysqld from pid file /opt/mysql/enterprise/monitor/mysql/runtime/mysqld.pid ended
. [ OK ]
Starting mysql service  [ OK ]
2019-08-24T06:48:04.441201Z mysqld_safe Logging to '/opt/mysql/enterprise/monitor/mysql/data/ol7.err'.
2019-08-24T06:48:04.544643Z mysqld_safe Starting mysqld daemon with databases from /opt/mysql/enterprise/monitor/mysql/data
Starting tomcat service  [ OK ]

shell$ sudo service mysql-monitor-server stop tomcat
Stopping tomcat service . [ OK ]

shell$ sudo service mysql-monitor-server stop mysql
Stopping mysql service 2019-08-24T06:48:54.707288Z mysqld_safe mysqld from pid file /opt/mysql/enterprise/monitor/mysql/runtime/mysqld.pid ended
. [ OK ]

Listing 10-1Changing the status of the services with the service command

首先启动两个服务,然后重新启动,最后逐个停止服务。没有必要一个接一个地停止服务,但是这可能是有用的,例如,如果您需要对存储库进行维护。

清单 10-2 展示了使用mysqlmonitorctl.sh脚本的相同示例。

shell $ ./mysqlmonitorctl.sh start
Starting mysql service  [ OK ]
2019-08-24T06:52:34.245379Z mysqld_safe Logging to '/home/myuser/mysql/enterprise/monitor/mysql/data/ol7.err'.
2019-08-24T06:52:34.326811Z mysqld_safe Starting mysqld daemon with databases from /home/myuser/mysql/enterprise/monitor/mysql/data
Starting tomcat service  [ OK ]

shell$ ./mysqlmonitorctl.sh restart
Stopping tomcat service . [ OK ]
Stopping mysql service 2019-08-24T06:53:08.292547Z mysqld_safe mysqld from pid file /home/myuser/mysql/enterprise/monitor/mysql/runtime/mysqld.pid ended
. [ OK ]
Starting mysql service  [ OK ]
2019-08-24T06:53:15.310640Z mysqld_safe Logging to '/home/myuser/mysql/enterprise/monitor/mysql/data/ol7.err'.
2019-08-24T06:53:15.397898Z mysqld_safe Starting mysqld daemon with databases from /home/myuser/mysql/enterprise/monitor/mysql/data
Starting tomcat service  [ OK ]

shell$ ./mysqlmonitorctl.sh stop tomcat
Stopping tomcat service . [ OK ]

shell$ ./mysqlmonitorctl.sh stop mysql
Stopping mysql service 2019-08-24T06:54:39.592847Z mysqld_safe mysqld from pid file /home/myuser/mysql/enterprise/monitor/mysql/runtime/mysqld.pid ended
. [ OK ]

Listing 10-2Changing the status of the services with mysqlmonitorctl.sh

这些步骤与前面使用service命令的例子非常相似。事实上,service 命令调用的脚本与mysqlmonitorctl.sh脚本相同,只是其中的路径和用户名取决于用来安装服务管理器的操作用户和安装路径。

添加 MySQL 实例

如果您只是想玩玩 MySQL Enterprise Monitor,您不需要做更多的事情。服务管理器的内置代理将自动监控存储库实例,因此当您第一次登录到用户界面时,已经有了可用的监控数据。如果安装了代理,代理还会自动注册它正在监控的实例。最后一个选项是从用户界面添加一个实例,这将在本节中讨论。

如果您要添加监控的 MySQL 实例与服务管理器或现有代理安装在同一台主机上,它将被自动检测到,并且页面右上方带有海豚和问号的图标将被突出显示,如图 10-14 所示。

img/484666_1_En_10_Fig14_HTML.jpg

图 10-14

一个实例显示为不受监控

请注意,在海豚的右边写着 1,在一个(黄色)圆圈里有一个问号。这是已找到但未被监控的 MySQL 实例的数量。当您将鼠标悬停在图标上时,将显示一个工具提示,其中包含未受监控的实例的数量。如果您单击海豚或数字,它会将您带到 MySQL 实例配置屏幕,您也可以通过左侧窗格中的菜单访问该屏幕。

Note

通过用户界面添加的实例将由现有代理(如果您自己没有安装任何代理,则为内置代理)监控。只有安装了代理的系统的操作系统才会受到监控。

实例配置屏幕包括添加新实例的选项、MySQL Enterprise Monitor 找到的未受监控实例的列表以及受监控实例的列表。图 10-15 显示了与开始监控新的和未被监控的实例相关的页面部分。

img/484666_1_En_10_Fig15_HTML.jpg

图 10-15

“实例配置”页面

您可以通过使用页面顶部的添加 MySQL 实例添加批量 MySQL 实例按钮来添加对任何 MySQL 实例的监控。如果您想要监控的实例列在未监控的 MySQL 实例列表中,您也可以在那里选择它,然后单击监控实例按钮,这将带您进入与添加 MySQL 实例相同的表单,不同之处在于已知的连接设置已经预先填充。该表单有多个选项卡,其中连接设置选项卡如图 10-16 所示。

img/484666_1_En_10_Fig16_HTML.jpg

图 10-16

“添加实例”表单的“连接设置”选项卡

关于连接设置需要注意的主要问题是,您可以选择让 MySQL Enterprise Monitor 自动创建比用于设置监控的管理用户权限更少的用户。建议允许创建这些用户,因为这允许代理使用权限尽可能少的用户来执行任务。

如果您有加密要求,可以在“加密设置”选项卡中编辑这些要求。很少需要高级设置选项卡。如果要设置对多个实例的监控,您可能需要在“组设置”选项卡中为实例指定一个组。添加实例后,也可以更改这些设置。

添加实例需要一些时间。当它准备好时,您可以开始探索用户界面的其余部分。

图形用户界面

服务管理器的 Tomcat 服务器提供的用户界面是您使用 MySQL Enterprise Monitor 花费最多时间的地方。正如您已经看到的,它可以用来添加新的实例。本节将进一步深入用户界面,并讨论一般导航、指导、时间序列图和查询分析器。

一般导航

MySQL Enterprise Monitor 用户界面将特性分成逻辑组,支持按组、主机、代理或实例进行过滤。本节将简要介绍该界面,目的是当本书后面提到图表或报告时,如果您想更深入地了解它,可以在界面中找到它们。

10-17 显示了用户界面中页面的左上部分。在这里,您可以选择要访问的功能以及要显示哪些目标的数据。

img/484666_1_En_10_Fig17_HTML.jpg

图 10-17

MySQL 企业监控器中页面的左上角

功能导航位于左侧窗格的中央,页面顶部的两个搜索字段中应用了过滤器。屏幕截图中带有标签 Global Summaries 的搜索字段允许您选择一组实例。可以手动创建组,也可以为相互复制的实例自动创建组。全局概要是一个包含所有实例的特殊组。右侧的搜索字段允许您限制组中包括的实例、代理或主机。

这些功能包括仪表板、图表、报告等。可用功能列表取决于您应用的过滤器。菜单项包括

  • 概述:这是一个高级仪表板。

  • 拓扑:仅当选择了复制组时,此选项才可用。它将带您进入一个图表,该图表显示了该组的拓扑以及每个实例的复制状态。

  • 事件:返回实例的监控事件报告。当顾问(稍后)设置的某些条件满足时,就会引发事件。这些事件具有不同的严重性,从通知到紧急情况不等。

  • 指标:这将带您进入显示代理收集的指标的报告。无论使用哪种过滤器,时间序列图表总是可用的(但是哪个图表取决于过滤器)。对于单个实例,还有关于表统计、用户统计、内存使用、数据库文件 I/O、InnoDB 缓冲池、进程和锁等待的报告。这些报告中的几个将在后面的章节中使用。

  • 查询:这是 MySQL 查询分析器,允许您调查在实例上执行了哪些查询。timeseries 图形链接到查询分析器,因此您可以从检查图形到查看在被调查的时间段内执行了哪些查询。

  • 复制:复制仪表板和其他与复制相关的报告。

  • 备份:MySQL 企业备份(MEB)创建的备份信息。

  • 配置:配置 MySQL Enterprise Monitor 的各个方面,包括实例和顾问。

  • 帮助:文档包括您已经看到的新内容,以及下载可用于故障排除的诊断报告的权限。如果您有 MySQL 支持合同,并且需要在支持票据中提供诊断,则通常会使用诊断报告。

有必要进一步解释的一个术语是顾问。

顾问

Advisor 是 MySQL Enterprise Monitor 用于定义数据收集频率、触发事件的条件以及事件严重性的规则的名称。这是一个重要的概念,您应该花一些时间来理解和配置。

获得有用的监控解决方案的最重要步骤之一是确保在正确的时间获得正确的事件(警报),但避免不必要的事件。这包括确保将每个警报设置为适当的严重性。起初,你可能认为事件越多越好,这样你就能知道发生的一切。然而,这并不是使用监控系统的最佳方式。如果您在检查事件时有许多误报,或者您在凌晨 3:00 因为一个很容易等到早上的问题被不必要的叫醒,那么您开始忽略事件,这肯定会迟早错过一个重要的事件。简而言之,你与顾问的合作应该是持续的,以不断改进他们,在“正确的”时间触发“正确的”事件。

Tip

监控工作的一个重要部分是确保监控系统触发与问题紧急程度相匹配的事件。我们的目标应该是永远不要忽略一个事件,并且总是在一个适合紧急情况的时间和方式得到提醒。

可以在左侧窗格的配置项下配置顾问。顾问按组组织,如图 10-18 所示。

img/484666_1_En_10_Fig18_HTML.jpg

图 10-18

顾问被组织成小组

每个组都包含相似类型的指导,例如,有一个性能组有 22 个指导,如过多的锁定进程和未被有效使用的索引。

默认情况下,所有指导都启用了严重性级别的阈值,这些阈值设置为在许多情况下都能正常工作的值。然而,由于没有两个系统是相同的,您需要通过展开组并点击顾问名称左侧的菜单图标来微调设置,如图 10-19 所示。

img/484666_1_En_10_Fig19_HTML.jpg

图 10-19

用于编辑指导配置的菜单

还可以使用指导项目左侧的+图标展开指导,这允许您编辑特定实例组或单个实例的指导。那个? Info 列中的图标提供了附加信息,如评估的表达式或指导的数据源。还有图中未示出的附加信息。

时间序列图表

时间序列图是显示一段时间内指标的图表。这是所有监控解决方案的标准功能。您可以过滤要显示的图形,并更改要绘制的时间范围以及绘制样式。

10-20 显示了 timeseries graphs 页面的一部分,重点是访问过滤和绘图风格的控件。

img/484666_1_En_10_Fig20_HTML.jpg

图 10-20

时间序列图

图表上方是选择显示哪些图表以及图表的时间范围的选项。屏幕截图左侧的搜索字段允许您在保存的时间序列组之间进行选择。默认情况下,有一个名为 All Timeseries Graphs 的组——顾名思义——包括所有适合实例过滤的时间序列图。

您可以使用屏幕截图右上角的漏斗图标来访问时间序列图表的选项。这将打开一个框架,允许您选择要显示的图表和要覆盖的时间范围。

每个图形下方的两个小按钮允许您在使用折线图和堆叠图模式之间切换。该屏幕截图显示了顶部图形中的堆叠模式和下部图形中的线条模式的示例。线路模式是默认模式。您还可以使用字段左侧的滑块(不包括在屏幕截图中)在保存的图表组之间进行选择,来更改图表的高度。

当您将鼠标悬停在图表上方时,图表上方的三个图标会变得可见,并允许您以 CSV 格式导出图表数据、以 PNG 图像打开图表或移动图表,从而允许您根据自己的需要对图表进行重新排序。在这种情况下,如果有两个组合在一起的图形,控件将应用于这两个图形。

更改图表时间范围的另一种方法是突出显示感兴趣的图表部分,并放大该部分。这还允许您转到查询分析器,检查在此期间执行了哪些查询。图 10-21 显示了一个在图表中突出显示时间帧的例子。

img/484666_1_En_10_Fig21_HTML.jpg

图 10-21

选择时间序列图的一部分

请注意,在突出显示区域的右上方,有三个图标来控制如何处理选择。框中的 X 放弃选择,数据库圆柱体在查询分析器中打开所选时间范围的图形,放大镜缩放时间序列图形以使用所选时间范围。

查询分析器

查询分析器是一个使 MySQL Enterprise Monitor 从其他监控解决方案中脱颖而出的特性。它允许您查看在给定时间段内对实例执行了哪些查询,这在调查性能问题时是非常宝贵的。

查询分析器页面分为三个区域。在顶部可以访问过滤选项,然后可以选择一个或多个图表,页面的其余部分是语句列表。图 10-22 显示了一个例子。

img/484666_1_En_10_Fig22_HTML.jpg

图 10-22

查询分析器

顶部的下拉框显示截图中的所有语句,允许您选择要SHOW语句的语句类型。默认情况下包括所有语句。右边是配置视图按钮,它带您到一个页面,您可以在这里配置查询分析器页面应该如何配置。这包括要覆盖的时间范围、要显示的图表、过滤选项以及每个语句要包含的信息。

默认情况下,查询分析器包括查询响应时间索引(QRTi)的图表。查询响应时间索引的定义以及如何使用将在第 19 章中介绍,在该章中,查询分析器用于寻找优化的候选对象。

MySQL 企业监控器之旅到此结束。我们鼓励您自己进一步探索用户界面。

摘要

本章简要介绍了 MySQL Enterprise Monitor,目的是让您安装它并监控一个 MySQL 实例。首先,讨论了体系结构和原理的概述。MySQL Enterprise Monitor 包含一个服务管理器,数据在其中聚合,您可以通过用户界面访问监控系统。对主机和实例的监控由代理完成。服务管理器中有一个内置的代理,您可以在 MySQL 实例的主机上安装其他代理。

概述之后是下载和安装说明。由于 MySQL Enterprise Monitor 是一款纯商业产品,您可以从 Oracle 软件交付云或 My Oracle Support 下载。使用安装程序完成安装。本章介绍了如何使用服务管理器安装程序的图形用户界面。

启动和停止服务管理器的基础是将其作为服务安装。在 Linux 和 Unix 上,您也可以将服务管理器安装为非 root 用户,在这种情况下,可以从安装目录中直接调用service命令使用的相同脚本。

添加要监控的实例有两种主要方式。如果安装代理来监控实例,代理将注册该实例。您还可以从服务管理器的用户界面添加实例。

最后,我们快速浏览了一下服务管理器的图形用户界面。重点是过滤您看到的实例数据和特性列表、时间序列图和查询分析器。这些特性中的几个将在本书的剩余部分用来演示监控。

下一章将介绍在后面章节中用到的另一个有用的工具:MySQL Workbench。

十一、MySQL 工作台

MySQL Workbench 是 Oracle 的图形用户界面,用于查询和管理 MySQL 服务器。它可以被看作是使用 MySQL 的两把瑞士军刀之一,另一把是 MySQL Shell,将在下一章讨论。

MySQL Workbench 的主要特性是查询模式,在这种模式下可以执行查询。然而,还有几个其他功能,如性能报告、可视化解释、管理配置和检查模式的能力等等。

如果将 MySQL Workbench 与 MySQL Enterprise Monitor 进行比较,那么 MySQL Enterprise Monitor 专用于监控,是一个服务器解决方案,而 MySQL Workbench 是一个桌面解决方案,主要是一个与 MySQL 服务器协同工作的客户端。类似地,MySQL Workbench 中包含的监控都是特定的监控,而 MySQL Enterprise Monitor 作为一个服务器解决方案包含了对存储历史数据的支持。

本章将介绍 MySQL Workbench,并介绍其安装、基本用法以及如何创建 EER 图。性能报告和直观解释将在后面的章节中介绍。

Tip

如果你已经熟悉 MySQL Workbench,可以考虑跳过这一章或者略读。

装置

MySQL Workbench 的安装方式与其他 MySQL 程序相同,只是只支持使用软件包管理器(因此没有独立安装)。MySQL Workbench 版本号遵循 MySQL Server 版本,因此 MySQL Workbench 8.0.18 与 MySQL Server 8.0.18 同时发布。MySQL Workbench 版本支持在发布时仍在维护的 MySQL 服务器版本,因此 MySQL Workbench 8.0.18 支持连接到 MySQL 服务器 5.6、5.7 和 8。

Tip

最好使用最新的 MySQL Workbench 版本。你可以在 https://dev.mysql.com/doc/mysql-compat-matrix/en/ 看到 MySQL 工具的兼容性。

本节将展示如何在 Microsoft Windows、“Enterprise Linux 7”(Oracle Linux、Red Hat Enterprise Linux 和 CentOS)以及 Ubuntu 19.10 上安装 MySQL Workbench 的示例。其他 Linux 平台在概念上类似于这两个 Linux 示例。

Tip

如果您是 MySQL 客户,建议从 My Oracle Support (MOS)中的 Patches & Updates 下载 MySQL Workbench。这将使您能够访问商业版的 MySQL Workbench,它具有一些额外的功能,如审计日志检查器和 MySQL 企业备份(MEB)的图形用户界面。

微软视窗软件

在 Microsoft Windows 上,安装 MySQL Workbench 的首选方式是使用 MySQL Installer for Windows。如果您安装了其他 MySQL 产品,您可能已经安装了 MySQL 安装程序,在这种情况下,您可以跳过这些说明的第一步,而是点击主屏幕上的添加,这将带您进入图 11-5 的位置。

可以从 https://dev.mysql.com/downloads/installer/ 下载 MySQL 安装程序。图 11-1 显示下载部分。

img/484666_1_En_11_Fig1_HTML.jpg

图 11-1

MySQL 工作台下载页面

安装程序有两种选择。第一个称为 web 安装程序(mysql-installer-web-community-8.0.18.0.msi)的只是 MySQL 安装程序,而第二个(mysql-installer-community-8.0.18.0.msi)也包括 MySQL 服务器。如果您还打算安装 MySQL Server,那么选择包含 MySQL 安装程序和 MySQL Server 的下载文件是有意义的,因为这样可以避免以后等待安装程序下载 MySQL Server 安装文件。此示例假设您选择了 web 安装程序。

你点击下载按钮进入下载。如果您没有登录,它将带您到开始下载页面,您可以在登录和立即开始下载之间进行选择。如图 11-2 所示。

img/484666_1_En_11_Fig2_HTML.jpg

图 11-2

下载 MySQL Workbench 的第二步

如果您已经有帐户,您可以登录。否则,您可以选择注册 Oracle 帐户。您也可以选择不登录就下载安装程序,方法是单击不,谢谢,只需启动我的下载链接。

下载完成后,启动下载的文件。除了确认您将允许安装程序和 MySQL 安装程序修改已安装的程序之外,安装 MySQL 安装程序不需要任何操作。安装完成后,MySQL 安装程序会自动启动并检测已经使用 MSI 安装程序安装的 MySQL 程序,如图 11-3 所示。

img/484666_1_En_11_Fig3_HTML.jpg

图 11-3

MySQL 安装程序检测以前安装的 MySQL 程序

如果您没有安装任何 MySQL 程序,则会出现一个屏幕,要求您确认是否同意许可条款。请在继续之前仔细阅读许可条款。如果您可以接受许可,请勾选我接受许可条款复选框,然后点击标有下一个 ➤的按钮继续。

下一步是选择安装什么。设置类型选择屏幕如图 11-4 所示。

img/484666_1_En_11_Fig4_HTML.jpg

图 11-4

MySQL 安装程序安装类型选择器

您可以在几个包中进行选择,比如 developer bundle(称为 Developer Default ),它安装了开发环境中通常使用的产品。当您选择安装类型时,屏幕右侧的描述包括将要安装的产品列表。对于此示例,将使用自定义安装类型。

下一步是选择要安装的产品。使用如图 11-5 所示的选择器。

img/484666_1_En_11_Fig5_HTML.jpg

图 11-5

选择要安装的内容

您可以在应用下的可用产品列表中找到 MySQL Workbench。单击指向右侧的箭头,将 MySQL Workbench 添加到要安装的产品和功能列表中。随意选择附加产品;对于这本书,建议还包括 MySQL Shell。添加完所有需要的产品后,点击下一步 ➤继续。

以下屏幕提供了将要安装的产品的摘要。点击执行开始安装。如果 MySQL 安装程序没有本地副本,安装过程包括下载产品。安装可能需要一段时间才能完成。完成后,点击下一步 ➤继续。最后一个屏幕列出了已安装的程序,并让您选择启动 MySQL Workbench 和 MySQL Shell。点击完成关闭 MySQL 安装程序。

如果您稍后想要安装更多产品或执行升级或删除产品,您可以再次启动 MySQL 安装程序,这将带您进入 MySQL 安装程序主屏幕,如图 11-6 所示。

img/484666_1_En_11_Fig6_HTML.jpg

图 11-6

MySQL 安装程序主屏幕

您可以在屏幕的最右边选择想要执行的操作。这些行动是

  • 添加:安装产品和功能。

  • 修改:改变现有产品的安装。这主要对 MySQL 服务器有用。

  • 升级:升级已经安装的产品。

  • 卸载:卸载一个产品。

  • 目录:更新 MySQL 安装程序的可用 MySQL 产品列表。

这五个动作允许您执行 MySQL 产品生命周期中所需的所有步骤。

企业版 Linux 7

如果您使用的是 Linux,您可以使用包管理器安装 MySQL Workbench。在 Oracle Linux、Red Hat Enterprise Linux 和 CentOS 7 上,首选的软件包管理器是yum,因为它将帮助解决您安装或升级的软件包的依赖性。MySQL 为其社区产品提供了一个 yum 知识库。这个例子将展示如何安装它并使用它来安装 MySQL Workbench。

您可以在 https://dev.mysql.com/downloads/repo/yum/ 找到存储库定义的 URL。还有 APT 和 SUSE 的存储库。选择与您的 Linux 发行版相对应的文件,然后点击下载。图 11-7 显示了企业版 Linux 7 的文件。

img/484666_1_En_11_Fig7_HTML.jpg

图 11-7

Enterprise Linux 7 的存储库定义下载

如果您没有登录,它会将您带到第二个屏幕,就像在 Microsoft Windows 上安装 MySQL Workbench 的例子一样。这将允许您登录到您的 Oracle Web 帐户,创建一个帐户,或在不登录的情况下下载。或者下载 RPM 文件并保存在您想要安装的目录中,或者右键单击下载按钮(如果您已登录)或不,谢谢,只需启动我的下载链接并复制 URL,如图 11-8 所示。

img/484666_1_En_11_Fig8_HTML.jpg

图 11-8

将链接复制到存储库安装文件

您现在可以安装清单 11-1 中所示的存储库定义。

shell$ wget https://dev.mysql.com/get/mysql80-community-release-el7-3.noarch.rpm
...
HTTP request sent, awaiting response... 200 OK
Length: 26024 (25K) [application/x-redhat-package-manager]
Saving to: 'mysql80-community-release-el7-3.noarch.rpm'

100%[=========================>] 26,024      --.-K/s   in 0.001s

2019-08-18 12:13:47 (20.6 MB/s) - 'mysql80-community-release-el7-3.noarch.rpm' saved [26024/26024]

shell$ sudo yum install mysql80-community-release-el7-3.noarch.rpm
Loaded plugins: langpacks, ulninfo
Examining mysql80-community-release-el7-3.noarch.rpm: mysql80-community-release-el7-3.noarch
Marking mysql80-community-release-el7-3.noarch.rpm to be installed
Resolving Dependencies
--> Running transaction check
---> Package mysql80-community-release.noarch 0:el7-3 will be installed
--> Finished Dependency Resolution

Dependencies Resolved

=================================================================
 Package
      Arch   Version
                   Repository                               Size
=================================================================
Installing:
 mysql80-community-release
      noarch el7-3 /mysql80-community-release-el7-3.noarch  31 k

Transaction Summary
=================================================================
Install  1 Package

Total size: 31 k
Installed size: 31 k
Is this ok [y/d/N]: y
Downloading packages:
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : mysql80-community-release-el7-3.noarch        1/1
  Verifying  : mysql80-community-release-el7-3.noarch        1/1

Installed:
  mysql80-community-release.noarch 0:el7-3

Complete!

Listing 11-1Installing the MySQL community repository

MySQL Workbench 需要 EPEL 仓库中的一些包。在 Oracle Linux 7 上,您可以像这样启用它

sudo yum install oracle-epel-release-el7

在 Red Hat Enterprise Linux 和 CentOS 上,您需要从 Fedora 下载存储库定义:

wget https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm

sudo yum install epel-release-latest-7.noarch.rpm

您现在可以安装 MySQL Workbench 了,如清单 11-2 所示。

shell$ sudo yum install mysql-workbench
...
Dependencies Resolved

================================================================
 Package        Arch   Version      Repository             Size
================================================================
Installing:
 mysql-workbench-community
                x86_64 8.0.18-1.el7 mysql-tools-community  26 M

Transaction Summary
================================================================
Install  1 Package

Total download size: 26 M
Installed size: 116 M
Is this ok [y/d/N]: y
Downloading packages:
warning: /var/cache/yum/x86_64/7Server/mysql-tools-community/packages/mysql-workbench-community-8.0.18-1.el7.x86_64.rpm: Header V3 DSA/SHA1 Signature, key ID 5072e1f5: NOKEY
Public key for mysql-workbench-community-8.0.18-1.el7.x86_64.rpm is not installed
mysql-workbench-community-8.0.18-1\.         |  31 MB  00:14
Retrieving key from file:///etc/pki/rpm-gpg/RPM-GPG-KEY-mysql
Importing GPG key 0x5072E1F5:
 Userid     : "MySQL Release Engineering <mysql-build@oss.oracle.com>"
 Fingerprint: a4a9 4068 76fc bd3c 4567 70c8 8c71 8d3b 5072 e1f5
 Package    : mysql80-community-release-el7-3.noarch (@/mysql80-community-release-el7-3.noarch)
 From       : /etc/pki/rpm-gpg/RPM-GPG-KEY-mysql
Is this ok [y/N]: y
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : mysql-workbench-community-8.0.18-1.el7.x86   1/1
  Verifying  : mysql-workbench-community-8.0.18-1.el7.x86   1/1

Installed:
  mysql-workbench-community.x86_64 0:8.0.17-1.el7

Complete!

Listing 11-2Installing MySQL Workbench on Enterprise Linux 7

您的输出看起来可能会有所不同,例如,根据您已经安装的包,可能会引入依赖项。第一次从 MySQL 数据库安装软件包时,会要求您接受用于验证下载的软件包的 GPG 密钥。如果您从 Fedora 安装了 EPEL 存储库,那么您还需要接受来自该存储库的 GPG 密钥。

Debian 和 Ubuntu

在 Debian 和 Ubuntu 上安装 MySQL Workbench 遵循与上例相同的原则。对于这里演示的步骤,将使用 Ubuntu 19.10。

Tip

参见 https://dev.mysql.com/doc/mysql-apt-repo-quick-guide/en/ 获取使用 MySQL APT 库的完整文档。

对于 Debian 和 Ubuntu,需要安装 MySQL APT 库,定义文件可以从 https://dev.mysql.com/downloads/repo/apt/ 下载。在撰写本文时,只有一个文件可用——见图11-9——它是独立于架构的,适用于所有支持的 Debian 和 Ubuntu 版本。

img/484666_1_En_11_Fig9_HTML.jpg

图 11-9

APT 存储库配置文件

如果您没有登录,您将被带到一个屏幕,您可以在登录和立即开始下载之间进行选择。要么下载 DEB 包,要么右键点击下载按钮(如果你已经登录)或者不用谢,直接启动我的下载链接,复制网址如图 11-10 所示。

img/484666_1_En_11_Fig10_HTML.jpg

图 11-10

将链接复制到存储库安装文件

您现在可以安装 MySQL 存储库,如清单 11-3 所示。

shell$ wget https://dev.mysql.com/get/mysql-apt-config_0.8.14-1_all.deb
...
Connecting to repo.mysql.com (repo.mysql.com)|23.202.169.138|:443... connected.
HTTP request sent, awaiting response... 200 OK
Length: 35564 (35K) [application/x-debian-package]
Saving to: 'mysql-apt-config_0.8.14-1_all.deb'

mysql-apt-config_0\. 100%[==================>]  34.73K  --.-KB/s    in 0.02s

2019-10-26 17:16:46 (1.39 MB/s) - 'mysql-apt-config_0.8.14-1_all.deb' saved [35564/35564]

shell$ sudo dpkg -i mysql-apt-config_0.8.14-1_all.deb
Selecting previously unselected package mysql-apt-config.
(Reading database ... 161301 files and directories currently installed.)
Preparing to unpack mysql-apt-config_0.8.14-1_all.deb ...
Unpacking mysql-apt-config (0.8.14-1) ...
Setting up mysql-apt-config (0.8.14-1) ...
Warning: apt-key should not be used in scripts (called from postinst maintainerscript of the package mysql-apt-config)
OK

Listing 11-3Installing the DEB package definition

在第二步中(dpkg -i命令),您可以选择哪些 MySQL 产品应该可以通过存储库获得。此设置的屏幕如图 11-11 所示。

img/484666_1_En_11_Fig11_HTML.jpg

图 11-11

MySQL APT 存储库的包配置

默认情况下,启用 MySQL 服务器和集群以及工具和连接器。对于 MySQL 服务器和集群,您还可以选择想要使用的版本,默认值为 8。为了安装 MySQL Shell,你需要确保 MySQL 工具&连接器被设置为启用。完成更改后,选择确定

在开始使用存储库之前,您需要对apt-get执行更新命令:

shell$ sudo apt-get update
Hit:1 http://repo.mysql.com/apt/ubuntu eoan InRelease
Hit:2 http://au.archive.ubuntu.com/ubuntu eoan InRelease
Hit:3 http://au.archive.ubuntu.com/ubuntu eoan-updates InRelease
Hit:4 http://au.archive.ubuntu.com/ubuntu eoan-backports InRelease
Hit:5 http://security.ubuntu.com/ubuntu eoan-security InRelease
Reading package lists... Done

现在可以使用 apt-get 的 install 命令安装 MySQL 产品。清单 11-4 展示了一个安装 MySQL Workbench 的例子(注意包名是mysql-workbench-community——结尾的“-community”很重要)。

shell$ sudo apt-get install mysql-workbench-community
Reading package lists... Done
Building dependency tree
Reading state information... Done
...
Setting up mysql-workbench-community (8.0.18-1ubuntu19.10) ...
Setting up libgail-common:amd64 (2.24.32-4ubuntu1) ...
Processing triggers for libc-bin (2.30-0ubuntu2) ...
Processing triggers for man-db (2.8.7-3) ...
Processing triggers for shared-mime-info (1.10-1) ...
Processing triggers for desktop-file-utils (0.24-1ubuntu1) ...
Processing triggers for mime-support (3.63ubuntu1) ...
Processing triggers for hicolor-icon-theme (0.17-2) ...
Processing triggers for gnome-menus (3.32.0-1ubuntu1) ...

Listing 11-4Installing MySQL Workbench from the APT repository

输出非常详细,包括安装 MySQL Workbench 所需的其他包的更改列表。软件包列表取决于您已经安装了什么。

您现在可以开始使用 MySQL Workbench 了。

创建连接

第一次启动 MySQL Workbench 时,您需要定义到 MySQL 服务器实例的连接。如果您安装了 MySQL 通知程序 1 ,MySQL Workbench 将自动为 root 用户创建一个到 MySQL 通知程序所监控的每个实例的连接。

您也可以根据需要创建连接。一种选择是从 MySQL Workbench connections 屏幕上进行,如图 11-12 所示。

img/484666_1_En_11_Fig12_HTML.jpg

图 11-12

MySQL 工作台连接屏幕

点击左上角的图标可以访问连接屏幕,该图标显示了一个带有 dolphin 的数据库。下面的图标带有由线连接的表,将带您进入数据库建模功能,三个图标中的最后一个图标打开数据迁移功能的选项卡。

该屏幕截图显示了 connections 屏幕,其中包含欢迎消息和一个已经存在的连接。您可以右键单击连接来访问连接选项,包括打开连接(创建到 MySQL 实例的连接)、编辑连接、将其添加到组中等等。

您可以通过点击 MySQL 连接右侧的+来添加一个新连接。配置连接的对话框如图 11-13 所示。创建新连接和编辑现有连接的对话框非常相似。

img/484666_1_En_11_Fig13_HTML.jpg

图 11-13

用于创建新连接的对话框

您可以用自己选择的名称来命名连接。它是一个自由格式的字符串,只是用来更容易地识别连接的目的。剩下的选项都是常用的连接选项。

建立连接后,您可以在连接屏幕上双击它来创建连接。

使用 MySQL 工作台

MySQL Workbench 中最常用的特性是执行查询的能力。这是通过“查询”选项卡完成的,除了能够执行查询之外,该选项卡还包括几个功能。这些特性包括显示结果集、获得名为 Visual Explain 的查询计划的可视化表示、获得上下文帮助、重新格式化查询等等。本节将从概述开始,介绍一些特性。

概观

“查询”选项卡包含两个区域,一个是编辑器,您可以在其中编写查询,另一个是查询结果。还支持显示上下文帮助和查询统计。从技术上讲,这两个附加区域不是查询选项卡的一部分,但是由于它们通常与查询选项卡一起使用,所以这里也将对它们进行讨论。

11-14 显示了 MySQL Workbench 的查询选项卡和最重要的特性编号。

img/484666_1_En_11_Fig14_HTML.jpg

图 11-14

MySQL 工作台和查询选项卡

标记为①的区域是您编写查询的地方。您可以在这里保存几个查询,MySQL Workbench 会保存它们,所以当您再次打开连接时,它们会被恢复。这使得存储最常用的查询非常方便。

您可以使用标记为②的三个闪电图标之一来执行一个或多个查询。左侧图标是一个简单的闪电符号,用于执行在查询编辑器部件中选择的一个或多个查询。这与使用键盘快捷键 Ctrl+Shift+Enter 相同。带有闪电符号和光标的中间图标执行光标所在位置的查询。使用此图标等同于在编辑器中使用快捷键 Ctrl+Enter。第三个图标在闪电符号前面有一个放大镜,它为当前光标所在的查询创建表单中的查询计划。显示查询计划的默认方式是可视化的解释图。也可以使用快捷键 Ctrl+Alt+X 来获取查询计划。

结果显示在查询编辑器③的下方,您可以使用查询结果右侧的项目在几种格式之间进行选择。最后一项是执行计划 ④,如果您直接从查询编辑器中请求,它会以相同的方式显示查询的查询计划。

query 选项卡下面是 output frame ⑤,默认情况下,它显示上次执行的查询的统计信息。这包括执行查询的时间、查询、找到的行数以及执行查询需要多长时间。右边是一个带有 SQL 附加物⑥的框架,默认显示上下文帮助。您可以启用自动上下文帮助,或者使用帮助文本上方的图标手动请求帮助。

配置

MySQL Workbench 有几个设置可以更改,从颜色到行为和程序路径,比如 MySQL Workbench 依赖的mysqldump

有几种方法可以达到如图 11-15 所示的设置。该图显示了 MySQL Workbench 窗口的左上和右上部分。

img/484666_1_En_11_Fig15_HTML.jpg

图 11-15

访问 MySQL 工作台首选项

在左侧,您可以通过使用编辑并转到底部的首选项项目,从菜单中获得首选项。或者,您可以单击窗口右侧的齿轮图标。无论哪种方式,您都会看到如图 11-16 所示的首选项弹出窗口。

img/484666_1_En_11_Fig16_HTML.jpg

图 11-16

MySQL 工作台首选项

通用编辑器设置包括诸如语法检查器考虑的 SQL 模式以及是否使用空格或制表符进行缩进的设置。 SQL 编辑器设置包括是否使用安全设置、是否保存编辑器、编辑器和查询页签的一般行为。如果您不想使用捆绑的二进制文件,则管理设置指定要使用的路径,包括用于mysqldump的路径。建模设置用于数据库建模功能。字体&颜色设置允许你改变 MySQL 工作台的视觉外观。当您使用需要 SSH 连接到远程主机的功能时,将使用 SSH 设置。最后,其他设置包括一些不适合其他类别的设置,例如是否应在连接的开始屏幕上显示欢迎消息。

这些设置包括安全设置。那些是什么?

安全设置

MySQL Workbench 默认启用了两个安全设置,以帮助防止更改或删除表中的所有行,并避免获取太多行。安全设置意味着UPDATEDELETE语句如果没有WHERE子句就会被阻塞,而SELECT语句添加了LIMIT 1000(可以配置最大行数)。用于UPDATEDELETE语句的WHERE子句不能是无关紧要的子句。

Caution

不要因为启用了安全设置就自满。使用WHERE子句时,UPDATEDELETE语句仍然会造成很大损害,使用LIMIT 1000SELECT查询仍然会要求 MySQL 检查更多的行。

通常最好是启用这些设置,但是对于某些查询,您需要更改这些设置,以便它们按预期工作。如前所述,可以在设置中更改SELECT限值。在 SQL 编辑器下的 SQL 执行子菜单下设置限制。或者,更简单的方法是使用编辑器上方的下拉框,如图 11-17 所示。

img/484666_1_En_11_Fig17_HTML.jpg

图 11-17

改变SELECT极限

以这种方式更改限制会更新相同的设置,就像您浏览首选项一样。

可以在最下面的 SQL 编辑器设置中更改UPDATEDELETE安全设置。除非您真的需要更新或删除表中的所有行,否则建议保持该状态。请注意,禁用设置需要重新连接。

重新格式化查询

MySQL Workbench 的一个很好的特性是查询美化工具,它通常不会引起太多关注。这对于查询调优也很有用,因为格式良好的查询可以更容易理解查询正在做什么。

查询美化器接受一个查询,将选择列表、表和过滤器拆分成单独的行,并添加缩进。图 11-18 显示了一个例子。

img/484666_1_En_11_Fig18_HTML.jpg

图 11-18

查询美化功能

第一个查询是原始查询,整个查询在一行中。第二个查询是重新格式化的查询。对于像本例中这样的简单查询,美化没有什么价值,但是对于更复杂的查询,它可以使查询更容易阅读。

默认情况下,美化包括将 SQL 关键字改为大写。您可以在首选项中的 SQL 编辑器设置的查询编辑器子菜单中更改这是否应该发生。

能效比图表

最后一个要探讨的特性是对模式逆向工程和创建增强的实体关系(EER)图的支持。这是了解您正在使用的模式的一个有用的方法。如果已经定义了外键,MySQL Workbench 将使用这些定义将表链接在一起。

你可以从数据库菜单选项启动逆向工程向导,然后选择逆向工程。或者,Ctrl+R 键盘组合也可以带您去那里。如图 11-19 所示。

img/484666_1_En_11_Fig19_HTML.jpg

图 11-19

打开逆向工程特征

该向导将引导您完成导入模式的步骤,首先选择要使用的存储连接,或者手动配置连接。下一步是连接并导入第三步中显示的可用模式列表。在这里,您选择一个或多个模式进行逆向工程,如图 11-20 所示。

img/484666_1_En_11_Fig20_HTML.jpg

图 11-20

选择要实施反向工程的架构

在这个例子中,选择了world模式。接下来的步骤获取模式对象,并允许您过滤要包含的对象。最后,对象被导入并放置在图中,并显示确认信息。由此产生的能效比图如图 11-21 所示。

Tip

如果 MySQL Workbench 在创建图表时崩溃,尝试打开菜单中的编辑➤配置… ➤建模,并选中强制使用基于软件的 EER 图表渲染选项。

img/484666_1_En_11_Fig21_HTML.jpg

图 11-21

world数据库的 EER 图

该图显示了world数据库中的三个表。当鼠标悬停在某个表上方时,对于子表,与其他表的关系将以绿色突出显示,对于父表,以蓝色突出显示。这允许您快速探索表之间的关系,从而在您需要调优查询时获得至关重要的知识。

摘要

本章介绍了 MySQL Workbench,它是 MySQL 的图形用户界面解决方案。展示了如何安装 MySQL Workbench 和创建连接。然后给出了主查询视图的概述,并展示了如何配置 MySQL Workbench。默认情况下,如果没有真正的WHERE子句,就不能执行UDPATEDELETE语句,并且SELECT查询被限制为 1000 行。

讨论的两个特性是查询美化和 EER 图。这些不是唯一的特性,后面的章节将展示性能报告和可视化解释查询计划图的例子。

下一章将讨论 MySQL Shell,它是 MySQL 提供的两把“瑞士军刀”中的第二把。

十二、MySQL Shell

MySQL Shell 是第二代命令行客户端,与传统的mysql命令行客户端相比,它支持 X 协议以及 Python 和 JavaScript 语言。它还附带了几个实用程序,并且高度可扩展。这使得它不仅是日常任务的好工具,也是研究性能问题的好工具。

本章首先概述了 MySQL Shell 提供的功能,包括内置的帮助和丰富的提示。本章的第二部分介绍了如何通过使用外部代码模块、报告基础设施和插件来扩展 MySQL Shell 的功能。

概观

第一个正式发布的 MySQL Shell 版本是在 2017 年,所以它仍然是 MySQL 工具箱中非常新的工具。然而,它已经有了一大堆远远超过传统的mysql命令行客户端的功能。这些特性并不局限于使用 MySQL Shell 作为 MySQL InnoDB 集群解决方案的一部分所需的特性;还有几个特性对于日常数据库管理任务和性能优化非常有用。

MySQL Shell 相对于mysql命令行客户端的一个优势是,MySQL Shell 编辑器在 Linux 和 Microsoft Windows 上的行为是相同的,因此如果您在这两个平台上工作,您将获得一致的用户体验。这意味着 Ctrl+D 在 Linux、macOS 和 Microsoft Windows 上都存在 shell,Ctrl+W 删除前面的单词,依此类推。

Tip

查尔斯·贝尔(本书技术审稿人,MySQL 开发者)撰写了《介绍 MySQL Shell(Apress)一书,全面介绍了 MySQL Shell: www.apress.com/gp/book/9781484250822 。此外,本书的作者已经发表了几篇关于 MySQL Shell 的博客。 https://mysql.wisborg.dk/mysql-shell-blogs/

这一节将介绍 MySQL Shell 的安装、调用以及一些基本特性。然而,不可能详细介绍 MySQL Shell 的所有特性。由于您正在使用 MySQL Shell,建议您通过 https://dev.mysql.com/doc/mysql-shell/en 查阅在线手册以获取更多信息。

安装 MySQL Shell

MySQL Shell 的安装方式与其他 MySQL 产品相同(MySQL Enterprise Monitor 除外)。可以从 https://dev.mysql.com/downloads/shell/ 下载;它可用于 Microsoft Windows、Linux 和 macOS,并作为源代码提供。对于微软 Windows,也可以通过 MySQL Installer 安装。

如果您使用本机包格式和 MySQL Installer for Microsoft Windows 安装 MySQL Shell,除了名称之外,安装说明与 MySQL Workbench 相同。详情请见上一章。

您还可以在 Microsoft Windows 上使用 ZIP 归档文件,或者在 Linux 和 macOS 上使用 TAR 归档文件来安装 MySQL Shell。如果您选择该选项,您只需解压下载的文件,就大功告成了。

调用 MySQL Shell

MySQL Shell 使用安装目录的bin目录中的mysqlsh(或者微软 Windows 上的mysqlsh.exe)二进制文件调用。当您使用本地包安装 MySQL Shell 时,二进制文件将在您的PATH环境变量中,因此操作系统可以找到它,而无需您显式提供路径。

这意味着启动 MySQL Shell 最简单的方法就是执行mysqlsh:

shell> mysqlsh
MySQL Shell 8.0.18

Copyright (c) 2016, 2019, Oracle and/or its affiliates. All rights reserved.
Oracle is a registered trademark of Oracle Corporation and/or its affiliates.
Other names may be trademarks of their respective owners.

Type '\help' or '\?' for help; '\quit' to exit.
MySQL JS>

该提示看起来与输出中的不同,因为默认提示不能完全用纯文本表示。与mysql命令行不同,MySQL Shell 不要求存在连接,默认情况下不创建任何连接。

创建连接

有几种方法可以为 MySQL Shell 创建连接,包括从命令行和 MySQL Shell 内部。

如果您在调用mysqlsh时添加了任何与连接相关的参数,那么 MySQL Shell 将创建一个连接作为启动的一部分。任何未指定的连接选项都将使用其默认值。例如,要作为root MySQL 用户使用默认端口(以及 Linux 和 macOS 套接字)值连接到本地主机上的 MySQL 实例,只需指定--user参数:

shell> mysqlsh --user=root
Please provide the password for 'root@localhost': ********
Save password for 'root@localhost'? [Y]es/[N]o/Ne[v]er (default No): yes
MySQL Shell 8.0.18

Copyright (c) 2016, 2019, Oracle and/or its affiliates. All rights reserved.
Oracle is a registered trademark of Oracle Corporation and/or its affiliates.
Other names may be trademarks of their respective owners.

Type '\help' or '\?' for help; '\quit' to exit.
Creating a session to 'root@localhost'
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 39581 (X protocol)
Server version: 8.0.18 MySQL Community Server - GPL
No default schema selected; type \use <schema> to set one.
MySQL  localhost:33060+ ssl  JS >

第一次连接时,会要求您输入该帐户的密码。如果 MySQL Shell 在路径中找到了mysql_config_editor命令,或者您在 Microsoft Windows 上,MySQL Shell 可以使用 Windows keyring 服务,MySQL Shell 将为您保存密码,因此您以后不需要输入密码。

或者,您可以使用 URI 来指定连接选项,例如:

shell> mysqlsh root@localhost:3306?schema=world

MySQL Shell 启动后,请注意提示符是如何变化的。MySQL Shell 有一个自适应的提示,它会根据您的连接状态而变化。默认提示包括您所连接的端口号。如果您连接到 MySQL Server 8,那么使用的默认端口是 33060,而不是端口 3306,因为当服务器支持 X 协议而不是传统的 MySQL 协议时,MySQL Shell 默认使用 X 协议。这就是端口号不是您所期望的原因。

您还可以在 MySQL Shell 中创建(或更改)连接。您甚至可以拥有多个连接,因此您可以同时处理两个或多个实例。创建会话有几种方法,包括表 12-1 中列出的方法。该表还包括如何设置和检索全局会话。语言命令栏显示根据使用的语言模式调用的命令或方法。

表 12-1

创建和使用连接的各种方法

|

方法

|

语言命令

|

描述

|
| --- | --- | --- |
| 全球会议 | 所有模式:\connect(或简称\c | 创建全局会话(默认会话)。这相当于在mysql命令行客户端中的连接。 |
| 全体会议 | JavaScript:mysqlx.getSession()Python:mysqlx.get_session() | 返回会话,以便将其赋给变量。可用于使用 X 协议和经典协议的连接。 |
| 经典会话 | JavaScript:mysql.getClassicSession()Python:mysql.get_classic_session() | 类似于常规会话,但总是返回经典会话。 |
| 设置全局会话 | JavaScript:shell.setSession()Python:shell.set_session() | 从包含会话的变量设置全局会话。 |
| 获取全局会话 | JavaScript:shell.getSession()Python:shell.get_session() | 返回全局会话,因此可以将其赋给一个变量。 |
| 再连接 | 所有模式:\reconnect | 使用与现有全局连接相同的参数重新连接。 |

创建会话的所有命令和方法都支持格式为[scheme://][user[:password]@]<host[:port]|socket>[/schema][?option=value&option=value...]的 URI。这些方法还支持在字典中提供选项。如果您不包括密码,并且 MySQL Shell 没有存储该帐户的密码,系统将提示您以交互方式输入密码(与传统的命令行客户端不同,MySQL Shell 可以在命令执行期间提示输入信息)。例如,作为myuser用户连接到localhost

MySQL JS> \connect myuser@localhost
Creating a session to 'myuser@localhost'
Please provide the password for 'myuser@localhost': *******

语言模式提到过几次。下一小节将介绍如何使用它。

语言模式

MySQL Shell 的一个最大的特点是,您不局限于执行 SQL 语句。您拥有 JavaScript 和 Python 的全部能力,当然还有 SQL 语句。这使得 MySQL Shell 在自动化任务方面非常强大。

您一次只能在一种语言模式下工作,尽管可以通过 JavaScript 和 Python 中的 API 执行查询。表 12-2 总结了如何从命令行和 MySQL Shell 中选择想要使用的语言模式。

表 12-2

选择 MySQL Shell 语言模式

|

方式

|

命令行

|

MySQL Shell

|
| --- | --- | --- |
| Java Script 语言 | --js | \js |
| 计算机编程语言 | --py | \py |
| 结构化查询语言 | --sql | \sql |

默认模式是 JavaScript。提示符反映了您所处的语言模式,因此您总是知道您使用的是哪种模式。

Tip

在 MySQL Shell 8.0.16 和更高版本中,您可以在 Python 和 JavaScript 模式下为命令添加前缀\sql,这使得 MySQL Shell 将命令作为 SQL 语句执行。

在列出如何创建连接时,需要注意的一点是,MySQL Shell(如 X DevAPI)试图保持该语言通常使用的命名约定。这意味着在 JavaScript 模式下,函数和方法使用 camel case(例如,getSession()),而在 Python 模式下使用 snake case ( get_session())。如果您使用内置帮助,帮助将反映您所处的语言模式的名称。

内置帮助

很难掌握 MySQL Shell 的所有特性以及如何使用它们。幸运的是,有一个广泛的内置帮助功能,让您不必每次都回到在线手册就可以获得关于这些功能的信息。

如果您使用--help参数执行mysqlsh,您将获得关于所有支持的命令行参数的信息。启动 shell 后,您还可以获得关于命令、对象和方法的帮助。使用所有语言模式下可用的\h\?\help命令获得最顶层的帮助。这里列出了命令和全局对象,以及如何获得进一步的帮助。

第二级帮助是针对命令和全局对象的。您可以使用其中一个帮助命令来指定全局对象的命令名,以获取有关该命令或对象的更多信息。例如:

mysql-js> \h \connect
NAME
      \connect - Connects the shell to a MySQL server and assigns the global session.

SYNTAX
      \connect [<TYPE>] <URI>

      \c [<TYPE>] <URI>

DESCRIPTION
...

最终级别的帮助是针对全局对象的功能。全局对象和全局对象的模块都有一个为对象或模块提供帮助的help()方法。help()方法也可以将模块或对象的方法名作为字符串,该字符串将返回该方法的帮助。一些例子是(输出被省略,因为它非常详细,建议您自己尝试命令以查看返回的帮助文本):

MySQL JS> \h shell

MySQL JS> shell.help()

MySQL JS> shell.help('reconnect')

MySQL JS> shell.reports.help()

MySQL JS> shell.reports.help('query')

前两个命令检索相同的帮助文本。熟悉帮助特性是值得的,因为它可以大大提高您使用 MySQL Shell 的效率。

帮助的上下文感知比检测全局对象是否存在以及方法名称是否遵循 JavaScript 或 Python 约定更进一步。考虑一个关于“选择”的帮助请求你的意思有几种可能。它可以是 X DevAPI 中的select()方法之一,或者您可以想到SELECT SQL 语句。如果您在 SQL 模式下请求帮助,MySQL Shell 会认为您指的是 SQL 语句。然而,在 Python 和 JavaScript 模式下,你会被问到你指的是哪一个:

MySQL Py> \h select
Found several entries matching select

The following topics were found at the SQL Syntax category:

- SQL Syntax/SELECT

The following topics were found at the X DevAPI category:

- mysqlx.Table.select
- mysqlx.TableSelect.select

For help on a specific topic use: \? <topic>

e.g.: \? SQL Syntax/SELECT

MySQL Shell 可以在 SQL 模式下为SELECT语句提供帮助而不考虑 X DevAPI 的原因是 X DevAPI 方法只能从 Python 和 JavaScript 中访问。另一方面,“select”的三种含义在 Python 和 JavaScript 模式中都有意义。

如前所述,存在几个全局对象。那些是什么?

内置全局对象

MySQL Shell 使用全局对象对特性进行分组。使 MySQL Shell 如此强大的许多功能都可以在全局对象中找到。正如您将在“插件”部分看到的,也可以添加您自己的全局对象。

内置的全局对象包括

  • db : 设置默认模式后,db保存默认模式的 X DevAPI 模式对象。X DevAPI 表对象可以作为db对象的属性找到(除非表或视图名称与现有属性相同)。会话对象也可以从db对象中获得。

  • dba : 用于管理 MySQL InnoDB 集群。

  • mysql : 用于使用经典 MySQL 协议连接到 MySQL。

  • mysqlx : 用于使用 MySQL X 协议会话。

  • session : 用于处理当前全局会话(连接到 MySQL 实例)。

  • shell : 各种通用方法和属性。

  • util : 升级检查器、导入 JSON 数据、将 CSV 文件中的数据导入关系表等各种实用程序。

MySQL Shell 的概述到此结束。接下来,您将了解关于提示以及如何定制它的更多信息。

提示

MySQL Shell 区别于传统命令行客户端的一个特性是丰富的提示,它不仅可以轻松查看您正在使用的主机和模式,还可以添加一些信息,例如您是否连接到生产实例、是否使用了 SSL 以及定制字段。

内置提示

MySQL Shell 安装附带了几个预定义的提示模板,您可以从中进行选择。默认情况下,使用提供连接信息并支持 256 种颜色的提示,但也有更简单的提示。

提示定义模板的位置取决于您安装 MySQL Shell 的方式。该位置的示例包括

  • ZIP 和 TAR 归档:归档中的share/mysqlsh/prompt目录。

  • Oracle Linux 7 上的 RPM:/usr/share/mysqlsh/prompt/

  • 微软 Windows 上的 MySQL 安装程序: C:\Program Files\MySQL\MySQL Shell 8.0\share\mysqlsh\prompt

提示定义是 JSON 文件,MySQL Shell 8.0.18 中包含的定义是

  • prompt_16.json : 一个彩色提示限于使用 16/8 色 ANSI 颜色和属性。

  • prompt_256.json : 提示使用 256 种索引色。这是默认情况下使用的。

  • prompt_256inv.json :prompt_256.json一样,但是有一个“不可见”的背景色(只是和终端用的一样)和不同的前景色。

  • prompt_256pl.json :prompt_256.json相同,但带有额外的符号。这需要电力线修补字体,例如随电力线项目安装的字体。当您使用 SSL 连接到 MySQL 并使用“箭头”分隔符时,这将添加一个带有提示的挂锁。稍后显示了安装电力线字体的示例。

  • prompt_256pl+aw.json :prompt_256pl.json相同,但带有“令人敬畏的符号”这额外需要在电力线字体中包含牛逼的符号。稍后显示了安装 awesome 符号的示例。

  • prompt_classic.json : 这是一个非常基本的提示,只根据使用的模式显示mysql-js>mysql-py>mysql-sql>

  • prompt_dbl_256.json : 两行版本的prompt_256.json提示。

  • prompt_dbl_256pl.json : 两行版本的prompt_256pl.json提示。

  • prompt_dbl_256pl+aw.json : 两行版本的prompt_256pl+aw.json提示。

  • prompt_nocolor.json : 给出了完整的提示信息,但是完全没有颜色。提示的一个例子是MySQL [localhost+ ssl/world] JS>

如果您的 shell 窗口宽度有限,这种两行模板特别有用,因为它们将信息放在一行上,并允许您在下一行键入命令,而无需在命令前输入完整的提示符。

有两种方法可以指定您想要使用的提示。MySQL Shell 首先在用户的 MySQL Shell 目录中寻找文件prompt.json。默认位置取决于您的操作系统:

  • Linux 和 MAC OS:~/.mysqlsh/prompt.json——即在用户主目录的.mysqlsh目录下。

  • 微软 Windows:%AppData%\MySQL\mysqlsh\prompt.json——即在用户主目录的AppData\Roaming\MySQL\mysqlsh目录下。

您可以通过设置MYSQLSH_HOME环境变量来更改目录。如果您喜欢不同于默认的提示,您可以将该定义复制到目录中,并将文件命名为prompt.json

指定提示定义位置的另一种方法是设置MYSQLSH_PROMPT_THEME环境变量,例如,在 Microsoft Windows 上使用命令提示符:

C:\> set MYSQLSH_PROMPT_THEME=C:\Program Files\MySQL\MySQL Shell 8.0\share\mysqlsh\prompt\prompt_256inv.json

在 PowerShell 中,语法略有不同:

PS> $env:MYSQLSH_PROMPT_THEME = "C:\Program Files\MySQL\MySQL Shell 8.0\share\mysqlsh\prompt\prompt_256inv.json";

在 Linux 和 Unix 上:

shell$ export MYSQLSH_PROMPT_THEME=/usr/share/mysqlsh/prompt/prompt_256inv.json

如果您临时想要使用不同于您通常使用的提示,这将非常有用。

正如已经暗示的,大多数提示定义有几个部分。最简单的方法是看一个提示的例子,如图 12-1 所示的默认(prompt_256.json)提示。

img/484666_1_En_12_Fig1_HTML.jpg

图 12-1

默认的 MySQL Shell 提示符

提示符有几个部分。首先,它在红色背景上显示PRODUCTION,这是为了警告您已经连接到生产实例。一个实例是否被视为生产实例取决于您所连接的主机名是否包含在PRODUCTION_SERVERS环境变量中。第二个元素是没有任何特殊含义的MySQL字符串。

第三是你连接的主机和端口,是否使用 X 协议,是否使用 SSL。在这种情况下,端口号后面有一个+,表示 X 协议正在使用中。第四个元素是默认模式。

第五个也是最后一个元素(不算最后的>)是语言模式。根据您是否启用了 SQL、Python 或 JavaScript 模式,它将分别显示SQLPyJS。该元素的背景颜色也会随着语言的变化而变化。SQL 用橙色,Python 用蓝色,JavaScript 用黄色。

一般来说,您不会看到提示的所有元素,因为 MySQL Shell 只包含那些相关的元素。例如,只有当您设置了默认模式时,才会包括默认模式,并且只有当您连接到实例时,连接信息才会出现。

在使用 MySQL Shell 时,您可能会意识到需要对提示定义进行一些修改。让我们来看看如何做到这一点。

自定义提示定义

提示定义是 JSON 文件,您可以根据自己的喜好编辑定义。最好的方法是复制最接近您想要的模板,然后进行更改。

Tip

创建自己的提示定义的最佳帮助来源是与模板文件位于同一目录下的README.prompt文件。

与其详细阅读规范,不如看一下prompt_256.json模板并讨论它的一些部分。清单 12-1 显示了文件的末尾是定义提示元素的地方。

  "segments": [
    {
      "classes": ["disconnected%host%", "%is_production%"]
    },
    {
      "text": " My",
      "bg": 254,
      "fg": 23
    },
    {
      "separator": "",
      "text": "SQL ",
      "bg": 254,
      "fg": 166
    },
    {
      "classes": ["disconnected%host%", "%ssl%host%session%"],
      "shrink": "truncate_on_dot",
      "bg": 237,
      "fg": 15,
      "weight": 10,
      "padding" : 1
    },
    {
      "classes": ["noschema%schema%", "schema"],
      "bg": 242,
      "fg": 15,
      "shrink": "ellipsize",
      "weight": -1,
      "padding" : 1
    },
    {
      "classes": ["%Mode%"],
      "text": "%Mode%",
      "padding" : 1
    }
  ]

Listing 12-1The definition of the elements of the prompt

这里有几件事值得注意。首先,请注意有一个类为disconnected%host%%is_production%的对象。百分号中的名称是在同一个文件中定义的变量,或者来自 MySQL Shell 本身(它有主机和端口之类的变量)。例如,is_production被定义为

  "variables" : {
    "is_production": {
        "match" : {
            "pattern": "*;%host%;*",
            "value": ";%env:PRODUCTION_SERVERS%;"
        },
        "if_true" : "production",
        "if_false" : ""
    },

因此,如果主机包含在环境变量PRODUCTION_SERVERS中,它就被视为生产实例。

关于元素列表要注意的第二件事是,有一些特殊的字段,比如shrink,可以用来定义文本如何保持相对较短。例如,host 元素使用truncate_on_dot,因此如果完整主机名太长,则只显示主机名中第一个点之前的部分。或者,可以使用ellipsize在截断值后添加…。

第三,分别使用bgfg元素定义背景色和前景色。这允许您根据自己的喜好完全自定义颜色提示。可以通过以下方式之一指定颜色:

  • 按名称:有几种颜色是按名称来命名的:黑色、红色、绿色、黄色、蓝色、品红色、青色和白色。

  • 按索引:0 到 255 之间的值(包括 0 和 255),其中 0 表示黑色,63 表示浅蓝色,127 表示洋红色,193 表示黄色,255 表示白色。

  • By RGB: 使用#rrggbb 格式的值。这要求终端支持真彩色。

值得举例说明的一组内置变量在某种程度上依赖于您所连接的环境或 MySQL 实例。这些是

  • %env:varname% : 这使用了一个环境变量。确定您是否连接到生产服务器的方式就是使用环境变量的一个例子。

  • %sysvar:varname% : 这使用了来自 MySQL 的一个全局系统变量的值,也就是SELECT @@global.varname返回的值。

  • %sessvar:varname% : 与前面的类似,但使用了一个会话系统变量。

  • %status:varname% : 这个使用了 MySQL 中一个全局状态变量的值,也就是SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'varname'返回的值。

  • %status:varname% : 类似于前面的,但是使用了一个会话状态变量。

例如,如果您想在提示中包含您所连接的实例的 MySQL 版本,您可以添加一个元素,如

    {
      "separator": "",
      "text": "%sysvar:version%",
      "bg": 250,
      "fg": 166
    },

我们鼓励你尝试定义,直到你找到最适合你的配色方案和元素。改进 Linux 上提示符的另一种方法是安装 Powerline 和 Awesome 字体。

电力线和可怕的字体

如果你觉得普通的 MySQL Shell 提示符太方了,并且你在 Linux 上使用 MySQL Shell,你可以考虑使用依赖于 Powerline 和牛逼字体的模板之一。默认情况下不会安装字体。

这个例子将向你展示如何最小化安装电力线字体 1 并使用 GitHub 上 gabrielelana 的 awesome-terminal-fonts 项目的补丁策略分支安装 Awesome 字体。22

Tip

另一个选项是 Fantasque Awesome 电力线字体( https://github.com/ztomer/fantasque_awesome_powerline ),包括电力线和 Awesome 字体。这些字体看起来与本例中安装的略有不同。选择你喜欢的。

您可以通过克隆 GitHub 存储库来安装 Awesome 字体,然后切换到 patching-strategy 分支。然后就是把需要的文件复制到 home 目录下的.local/share/fonts/,重新构建字体信息缓存文件。步骤如清单 12-2 所示。该输出也可以在本书的 GitHub 资源库的listing_12_2.txt中找到,以便于复制命令。

shell$ git clone https://github.com/gabrielelana/awesome-terminal-fonts.git
Cloning into 'awesome-terminal-fonts'...
remote: Enumerating objects: 329, done.
remote: Total 329 (delta 0), reused 0 (delta 0), pack-reused 329
Receiving objects: 100% (329/329), 2.77 MiB | 941.00 KiB/s, done.
Resolving deltas: 100% (186/186), done.

shell$ cd awesome-terminal-fonts

shell$ git checkout patching-strategy
Branch patching-strategy set up to track remote branch patching-strategy from origin.
Switched to a new branch 'patching-strategy'

shell$ mkdir -p ~/.local/share/fonts/

shell$ cp patched/SourceCodePro+Powerline+Awesome+Regular.* ~/.local/share/fonts/

shell$ fc-cache -fv ~/.local/share/fonts/
/home/myuser/.local/share/fonts: caching, new cache contents: 1 fonts, 0 dirs
/usr/lib/fontconfig/cache: not cleaning unwritable cache directory
/home/myuser/.cache/fontconfig: cleaning cache directory
/home/myuser/.fontconfig: not cleaning non-existent cache directory
/usr/bin/fc-cache-64: succeeded

Listing 12-2Installing the Awesome fonts

这要求您已经安装了git。下一步是安装电力线字体,如清单 12-3 所示。该输出也可以在本书的 GitHub 资源库的listing_12_3.txt中找到,以便于复制命令。

shell$ wget --directory-prefix="${HOME}/.local/share/fonts" https://github.com/powerline/powerline/raw/develop/font/PowerlineSymbols.otf
...
2019-08-25 14:38:41 (5.48 MB/s) - '/home/myuser/.local/share/fonts/PowerlineSymbols.otf' saved [2264/2264]

shell$ fc-cache -vf ~/.local/share/fonts/
/home/myuser/.local/share/fonts: caching, new cache contents: 2 fonts, 0 dirs
/usr/lib/fontconfig/cache: not cleaning unwritable cache directory
/home/myuser/.cache/fontconfig: cleaning cache directory
/home/myuser/.fontconfig: not cleaning non-existent cache directory
/usr/bin/fc-cache-64: succeeded

shell$ wget --directory-prefix="${HOME}/.config/fontconfig/conf.d" https://github.com/powerline/powerline/raw/develop/font/10-powerline-symbols.conf
...
2019-08-25 14:39:11 (3.61 MB/s) - '/home/myuser/.config/fontconfig/conf.d/10-powerline-symbols.conf' saved [2713/2713]

Listing 12-3Installing the Powerline font

这并不需要完全安装电力线字体,但是如果您只是想在 MySQL Shell 中使用电力线字体,这就足够了。两个wget命令下载字体和配置文件,fc-cache命令重建字体信息缓存文件。您需要重启 Linux 以使更改生效。

重启完成后,您可以复制一个pl+aw模板作为您的新提示,例如:

shell$ cp /usr/share/mysqlsh/prompt/prompt_dbl_256pl+aw.json ~/.mysqlsh/prompt.json

结果提示如图 12-2 所示。

img/484666_1_En_12_Fig2_HTML.jpg

图 12-2

双线电力线+牛逼字体提示

此示例还显示了当您更改语言模式并设置默认模式时,提示是如何变化的。关于对多模块的支持,这就是为什么 MySQL Shell 是如此强大的工具,所以下一节将讨论如何在 MySQL Shell 中使用外部模块。

使用外部模块

对 JavaScript 和 Python 的支持使得在 MySQL Shell 中执行任务变得很容易。您不仅可以使用核心功能,还可以导入标准模块和您自己的定制模块。这一节将从使用外部模块的基础开始(与内置的 MySQL Shell 模块相对)。下一节将介绍报告基础设施,之后将介绍插件。

Note

本书的讨论重点是 Python。如果你更喜欢 JavaScript,用法非常相似。一个主要的区别是 Python 使用 snake case(例如import_table()),而 JavaScript 使用 camel case ( importTable())。参见 https://dev.mysql.com/doc/mysql-shell/en/mysql-shell-code-execution.html 了解 MySQL Shell 中代码执行的一般信息。

在 MySQL Shell 中使用 Python 模块的方式与使用交互式 Python 解释器的方式相同,例如:

mysql-py> import sys
mysql-py> print(sys.version)
3.7.4 (default, Sep 13 2019, 06:53:53) [MSC v.1900 64 bit (AMD64)]

mysql-py> import uuid
mysql-py> print(uuid.uuid1())
fd37319e-c70d-11e9-a265-b0359feab2bb

确切的输出取决于 MySQL Shell 的版本和您使用它的平台。

Note

MySQL Shell 8.0.17 和更早版本提供了 Python 2.7,而 MySQL Shell 8.0.18 和更高版本提供了 Python 3.7。

MySQL Shell 解释器允许您导入 Python 中包含的所有常用模块。如果您想要导入您自己的模块,您将需要调整搜索路径。您可以在交互式会话中直接执行此操作,例如:

mysql-py> sys.path.append('C:\MySQL\Shell\Python')

以这种方式修改路径对于模块的一次性使用来说很好;然而,如果你已经创建了一个你将经常使用的模块,这是不方便的。

当 MySQL Shell 启动时,它读取两个配置文件,一个用于 Python,一个用于 JavaScript。对于 Python,文件是mysqlshrc.py,对于 JavaScript 是mysqlshrc.js。MySQL Shell 在四个地方搜索文件。在 Microsoft Windows 上,路径按搜索顺序排列:

  1. %PROGRAMDATA%\MySQL\mysqlsh\

  2. %MYSQLSH_HOME%\shared\mysqlsh\

  3. <mysqlsh binary path>\

  4. %APPDATA%\MySQL\mysqlsh\

在 Linux 和 Unix 上:

  1. /etc/mysql/mysqlsh/

  2. $MYSQLSH_HOME/shared/mysqlsh/

  3. <mysqlsh binary path>/

  4. $HOME/.mysqlsh/

始终搜索所有四个路径,如果在多个位置找到文件,将执行每个文件。这意味着,如果文件影响相同的变量,则最后找到的文件优先。如果你做出对你个人有意义的改变,最好的地方是在第四个位置。步骤 4 中的路径可以用环境变量MYSQLSH_USER_CONFIG_HOME覆盖。

如果您添加想要定期使用的模块,您可以在mysqlshrc.py文件中修改搜索路径。这样,您可以像导入任何其他 Python 模块一样导入该模块。

Tip

支持外部模块的一个很好的例子是 Innotop 的 MySQL Shell 端口( https://github.com/lefred/mysql-shell-innotop )。它也揭示了两个局限性。因为 Innotop 的报告部分是使用curses库实现的,所以它不能在 Microsoft Windows 上工作,并且因为实现使用 Python,所以它要求您在 Python 语言模式下执行 Innotop。本章后面讨论的报告基础设施和插件避免了这些限制。

举个简单的例子,考虑一个非常简单的模块,它有一个滚动虚拟骰子并返回 1 到 6 之间的值的函数:

import random

def dice():
    return random.randint(1, 6)

本书的 GitHub 资源库中的文件example.py也提供了这个例子。如果将文件保存到目录C:\MySQL\Shell\Python,将以下代码添加到mysqlshrc.py文件中(根据保存文件的位置调整sys.path.append()行中的路径):

import sys
sys.path.append('C:\MySQL\Shell\Python')

下一次启动 MySQL Shell 时,您可以使用该模块,例如(由于dice()函数返回一个随机值,您的输出会有所不同):

mysql-py> import example
mysql-py> example.dice()
5
mysql-py> example.dice()
3

这是扩展 MySQL Shell 最简单的方法。另一种方法是将报告添加到报告基础结构中。

报告基础设施

从 MySQL Shell 8.0.16 开始,有了一个报告基础设施,您可以使用内置报告和自己的定制报告。当您遇到性能问题时,这是使用 MySQL Shell 监控 MySQL 实例和收集信息的一种非常强大的方法。

Tip

由于报告基础设施仍然非常新,建议检查每个新版本的新内置报告。

本节将首先展示如何获得关于可用报告的帮助,然后讨论如何执行报告,最后讨论如何添加您自己的报告。

报告信息和帮助

MySQL Shell 的内置帮助也扩展到了报告,因此您可以很容易地获得如何使用报告的帮助。您可以使用不带任何参数的\show命令来获取可用报告的列表。如果您将报告名称作为参数与--help选项一起添加,您将获得该报告的详细帮助。清单 12-4 展示了这两种用法的一个例子。

mysql-py> \show
Available reports: query, thread, threads.

mysql-py> \show query --help
NAME
      query - Executes the SQL statement given as arguments.

SYNTAX
      \show query [OPTIONS] [ARGS]
      \watch query [OPTIONS] [ARGS]

DESCRIPTION
      Options:

      --help, -h  Display this help and exit.

      --vertical, -E
                  Display records vertically.

      Arguments:

      This report accepts 1-* arguments.

Listing 12-4Obtaining a list of reports and help for the query report

\show命令的输出显示有三个报告可用。这些是从版本 8.0.18 开始的内置报告。第二个命令返回对query报告的帮助,显示它接受一个或多个参数,并有两个选项:--help用于返回帮助文本,--vertical-E用于以垂直格式返回查询结果。

内置报告包括

  • 查询:执行作为参数提供的查询。

  • 线程:返回当前连接的信息。

  • 线程:返回当前用户、前台线程或后台线程的所有连接信息。

您应该在帮助输出中注意到的另一件事是,它列出了执行报告的两种方法。您可以使用同样用于生成帮助的\show命令,也可以使用\watch命令。您可以使用常用的内置帮助来获得关于每个命令的更多帮助:

mysql-py> \h \show

mysql-py> \h \watch

帮助输出相当详细,所以这里省略了。相反,下一小节将讨论如何使用这两个命令。

执行报告

有两种不同的方法来执行报告。您可以要求只执行一次报告,也可以要求以固定的时间间隔反复执行报告。

有两个命令可用于执行报告:

  • \显示:执行一次报告。

  • \watch: 像 Linux 上的watch命令一样,按照规定的时间间隔执行报告。

这两个命令都可以在两种语言模式下使用。\show命令本身没有任何参数(但报告可能会添加特定于它的参数)。\watch命令有两个选项,指定何时以及如何输出报告。这些选项是

  • --interval=float -i float : 每次执行报告之间等待的秒数。该值必须在 0.1–86400(一天)秒的范围内。默认值为 2 秒。

  • --nocls : 输出报告结果时不清空屏幕。这会将新结果附加到以前结果的下方,并允许您查看报告结果的历史记录,直到最早的结果滚动到视图之外。

当您用\watch命令执行一个报告时,您用 Ctrl+C 停止执行。

作为执行报告的一个示例,考虑您给出将被执行的查询的查询报告。如果希望结果以垂直格式返回,可以使用--vertical参数。清单 12-5 显示了第一次执行报告的结果示例,使用\show命令从sys.session视图获取活动查询,然后使用\watch命令每 5 秒刷新一次,并且不清空屏幕。为了确保返回一些数据,例如,您可以在第二个连接中执行查询SELECT SLEEP(60)

mysql-sql> \show query --vertical SELECT conn_id, current_statement AS stmt, statement_latency AS latency FROM sys.session WHERE command = 'Query' AND conn_id <> CONNECTION_ID()
*************************** 1\. row ***************************
conn_id: 34979
   stmt: SELECT SLEEP(60)
latency: 32.62 s

mysql-sql> \watch query --interval=5 --nocls --vertical SELECT conn_id, current_statement AS stmt, statement_latency AS latency FROM sys.session WHERE command = 'Query' AND conn_id <> CONNECTION_ID()
*************************** 1\. row ***************************
conn_id: 34979
   stmt: SELECT SLEEP(60)
latency: 43.02 s
*************************** 1\. row ***************************
conn_id: 34979
   stmt: SELECT SLEEP(60)
latency: 48.09 s
*************************** 1\. row ***************************
conn_id: 34979
   stmt: SELECT SLEEP(60)
latency: 53.15 s
*************************** 1\. row ***************************
conn_id: 34979
   stmt: SELECT SLEEP(60)
latency: 58.22 s
Report returned no data.

Listing 12-5Using the query report

如果您执行相同的命令,您的输出将取决于报告运行时其他线程中正在执行的语句。用于报告的查询添加了一个条件,即连接 id 必须不同于生成报告的连接的 id。带有查询报告的\show命令本身没有什么价值,因为您也可以执行查询。对于其他报告和在使用\watch命令之前检查查询更有用。

\watch命令更有趣,因为它允许您不断更新结果。在本例中,报告运行了五次才停止。前四次,有另一个连接执行查询,第五次报告不生成数据。请注意,在连续执行之间,查询的语句延迟增加了五秒以上。这是因为这 5 秒钟是 MySQL Shell 从显示一次迭代的结果到再次开始执行查询所等待的时间。因此,两次输出之间的总时间是间隔加上查询执行时间加上处理结果所需的时间。

报告基础结构不仅允许您使用内置报告。您也可以添加自己的报告。

添加您自己的报告

报告基础设施的真正强大之处在于它易于扩展,因此 MySQL 开发团队和您都可以添加更多的报告。虽然您可以使用对外部模块的支持来添加报告,就像使用 Innotop 一样,但是这种方法要求您自己实现报告基础结构,并且您必须使用模块的语言模式来执行报告。当您使用报告基础结构时,这一切都会为您处理,并且报告可用于所有语言模式。

Note

本节中的报告代码并不打算在 MySQL Shell 会话中执行(如果您照原样复制并粘贴它,将会导致错误,因为 MySQL Shell 在交互模式下使用块中的空行来退出该块)。相反,代码必须保存到调用 MySQL Shell 时加载的文件中。如何安装代码的说明在示例的最后。

讨论如何创建自己的报告的一个好方法是创建一个简单的报告,并讨论组成它的各个部分。清单 12-6 显示了创建查询sys.session视图的报告所需的代码。代码也可以从本书的 GitHub 库中的文件listing_12_6.py中获得。将代码保存在哪里,以便它可以作为 MySQL Shell 中的报告使用,这将在后面讨论。

'''Defines the report "sessions" that queries the sys.x$session view
for active queries. There is support for specifying what to order by
and in which direction, and the maximum number of rows to include in
the result.'''

SORT_ALLOWED = {
    'thread': 'thd_id',
    'connection': 'conn_id',
    'user': 'user',
    'db': 'db',
    'latency': 'statement_latency',
    'memory': 'current_memory',
}

def sessions(session, args, options):
    '''Defines the report itself. The session argument is the MySQL
    Shell session object, args are unnamed arguments, and options
    are the named options.'''
    sys = session.get_schema('sys')
    session_view = sys.get_table('x$session')
    query = session_view.select(
        'thd_id', 'conn_id', 'user', 'db',
        'sys.format_statement(current_statement) AS statement',
        'sys.format_time(statement_latency) AS latency',
        'format_bytes(current_memory) AS memory')

    # Set what to sort the rows by (--sort)
    try:
        order_by = options['sort']
    except KeyError:
        order_by = 'latency'

    if order_by in ('latency', 'memory'):
        direction = 'DESC'
    else:
        direction = 'ASC'
    query.order_by('{0} {1}'.format(SORT_ALLOWED[order_by], direction))

    # If ordering by latency, ignore those statements with a NULL latency
    # (they are not active)
    if order_by == 'latency':
        query.where('statement_latency IS NOT NULL')

    # Set the maximum number of rows to retrieve is --limit is set.
    try:
        limit = options['limit']
    except KeyError:
        limit = 0
    if limit > 0:
        query.limit(limit)

    result = query.execute()
    report = [result.get_column_names()]
    for row in result.fetch_all():
        report.append(list(row))

    return {'report': report}

Listing 12-6Report querying the sys.session view

代码首先定义一个字典,其中包含对结果进行排序的支持值。这将在后面的代码中使用,既用于sessions()函数内部,也用于注册报告。sessions()函数是创建报告的地方。该函数有三个参数:

  • session : 这是一个 MySQL Shell Session对象(定义了与 MySQL 实例的连接)。

  • args : 传递给报表的未命名参数列表。这是用于查询报告的,您只需指定查询,而无需在查询前添加参数名称。

  • options : 为报表命名参数的字典。

会话报告使用命名选项,因此不使用args参数。

接下来的八行使用 X DevAPI 来定义基本查询。首先,从会话中获取sys模式的模式对象。然后从模式对象中获取sessions视图(使用get_table()来获取视图和表)。最后,创建一个 select 查询,其中的参数指定应该检索哪些列以及为这些列使用哪些别名。

接下来,处理--sort参数,它在options字典中作为sort键可用。如果键不存在,报告将退回到按延迟排序。如果根据等待时间或内存使用情况对输出进行排序,则排序顺序定义为降序;否则,排序顺序为升序。order_by()方法用于将排序信息添加到查询中。此外,当按延迟排序时,只包括延迟不为NULL的会话。

以类似的方式处理--limit参数,取值 0 表示所有匹配的会话。最后,执行查询。报告以列表形式生成,第一项是列标题,其余是结果中的行。报告返回一个字典,在report项中有报告列表。

此报告返回列表格式的结果。还有另外两种格式。总体而言,支持以下结果格式:

  • 列表类型:结果以列表的形式返回,第一项是标题,其余的是按显示顺序排列的行。标题和行本身就是列表。

  • 报表类型:结果是单个项目的列表。MySQL Shell 使用 YAML 来显示结果。

  • 打印类型:结果直接打印到屏幕上。

剩下的就是登记报告了。这是使用清单 12-7 中所示的shell对象的register_report()方法完成的(这也包含在文件listing_12-6.py中)。

# Make the report available in MySQL Shell.
shell.register_report(
    'sessions',
    'list',
    sessions,
    {
        'brief': 'Shows which sessions exist.',
        'details': ['You need the SELECT privilege on sys.session view and ' +
                    'the underlying tables and functions used by it.'],
        'options': [
            {
                'name': 'limit',
                'brief': 'The maximum number of rows to return.',
                'shortcut': 'l',
                'type': 'integer'
            },
            {
                'name': 'sort',
                'brief': 'The field to sort by.',
                'shortcut': 's',
                'type': 'string',
                'values': list(SORT_ALLOWED.keys())
            }
        ],
        'argc': '0'
    }
)

Listing 12-7Registering the sessions report

register_report()方法接受定义报告的四个参数,并提供由 MySQL Shell 的内置帮助特性返回的帮助信息。这些论点是

  • name : 报表的名称。您可以相对自由地选择名称,只要它是一个单词,并且对所有报表都是唯一的。

  • type : 结果格式:'list''report''print'

  • report : 生成报告的函数的对象,这里是sessions

  • description : 描述报表的可选参数。如果您提供了描述,您将使用下面描述的字典。

描述是最复杂的论点。它由带有以下关键字的字典组成(所有项目都是可选的):

  • brief : 报告的简短描述。

  • details : 作为字符串列表提供的报告的详细描述。

  • options : 将命名的自变量作为字典列表。

  • argc : 未命名的参数数量。您可以指定一个精确的数字(如本例所示),一个星号(*)代表任意数量的参数,一个精确数字范围(如'1-3'),或者一个最小数量的参数范围('3-*')。

options元素用于定义报告的命名参数。列表中的每个 dictionary 对象都必须包含参数的名称,并且支持几个可选参数来提供关于参数的更多信息。表 12-3 列出了字典键及其默认值和描述。需要使用name键;其余的是可选的。

表 12-3

用于定义报表参数的字典键

|

钥匙

|

缺省值

|

描述

|
| --- | --- | --- |
| name |   | 调用报告时使用双破折号(如--sort)的参数名称。 |
| brief |   | 参数的简短描述。 |
| details |   | 以字符串列表形式提供的参数的详细说明。 |
| shortcut |   | 可用于访问参数的单个字母数字字符。 |
| type | string | 参数类型。编写时支持的值有 string、bool、integer 和 float。当选择一个布尔值时,该参数作为默认设置为False的开关。 |
| required | False | 参数是否是强制的。 |
| values |   | 字符串参数的允许值。如果未提供值,则支持所有值。这就是示例中用来限制允许的排序选项的内容。 |

导入报告的典型方式是将报告定义和注册码保存在用户配置路径下的目录下的init.d中,在 Microsoft Windows 上默认为%AppData%\MySQL\mysqlsh\,在 Linux 和 Unix 上默认为$HOME/.mysqlsh/(与搜索配置文件的第四个路径相同)。当启动 MySQL Shell 时,所有文件扩展名为.py的脚本都将作为 Python 脚本执行(对于 JavaScript,则为.js)。

Tip

如果脚本中有错误,有关问题的信息将被记录到 MySQL Shell 日志中,该日志存储在用户配置路径中的文件mysqlsh.log中。

如果您将listing_12_6.py文件复制到这个目录并重启 MySQL Shell(确保您使用 MySQL X 端口连接——默认端口 33060 ),您可以使用清单 12-8 中所示的会话报告。报告的结果会有所不同,因此如果您执行报告,您将不会看到相同的结果。

mysql-py> \show
Available reports: query, sessions, thread, threads.

mysql-py> \show sessions --help
NAME
      sessions - Shows which sessions exist.

SYNTAX
      \show sessions [OPTIONS]
      \watch sessions [OPTIONS]

DESCRIPTION
      You need the SELECT privilege on sys.session view and the underlying
      tables and functions used by it.

      Options:

      --help, -h  Display this help and exit.

      --vertical, -E
                  Display records vertically.

      --limit=integer, -l
                  The maximum number of rows to return.

      --sort=string, -s
                  The field to sort by. Allowed values: thread, connection,
                  user, db, latency, memory.

mysql-py> \show sessions --vertical
*************************** 1\. row ***************************
   thd_id: 81
  conn_id: 36
     user: mysqlx/worker
       db: NULL
statement: SELECT `thd_id`,`conn_id`,`use ... ER BY `statement_latency` DESC
  latency: 40.81 ms
   memory: 1.02 MiB

mysql-py> \js
Switching to JavaScript mode...

mysql-js> \show sessions --vertical
*************************** 1\. row ***************************
   thd_id: 81
  conn_id: 36
     user: mysqlx/worker
       db: NULL
statement: SELECT `thd_id`,`conn_id`,`use ... ER BY `statement_latency` DESC
  latency: 71.40 ms
   memory: 1.02 MiB

mysql-js> \sql
Switching to SQL mode... Commands end with ;

mysql-sql> \show sessions --vertical
*************************** 1\. row ***************************
   thd_id: 81
  conn_id: 36
     user: mysqlx/worker
       db: NULL
statement: SELECT `thd_id`,`conn_id`,`use ... ER BY `statement_latency` DESC
  latency: 44.80 ms
   memory: 1.02 MiB

Listing 12-8Using the sessions report

新的sessions报告以与内置报告相同的方式显示,并且您拥有与内置报告相同的特性,例如,支持在垂直输出中显示结果。支持垂直输出的原因是因为报告以列表形式返回结果,所以 MySQL Shell 处理格式。还要注意报告是如何在所有三种语言模式下使用的,即使它是用 Python 编写的。

还有一种导入报告的替代方法。您可以将报告作为插件的一部分,而不是将文件保存到init.d目录中。

插件

MySQL Shell 在 8.0.17 版本中增加了对插件的支持。一个插件由一个或多个代码模块组成,这些代码模块可以包括报告、实用程序或任何其他可能对您有用的东西,并且可以作为 Python 或 JavaScript 代码来执行。这是扩展 MySQL Shell 最强大的方法。代价是它也相对复杂,但好处是更容易共享和导入一个包的特性。插件的另一个好处是,不仅可以从任何语言模式执行报告;您的代码的其余部分也可以在 Python 和 JavaScript 中使用。

Tip

关于添加插件的所有细节,包括用于创建插件对象和注册它们的方法的参数描述,参见 https://dev.mysql.com/doc/mysql-shell/en/mysql-shell-plugins.html 。在 Mike Zinner(MySQL 开发经理,他的团队包括 MySQL Shell 开发者)的 GitHub 存储库中也有一个值得研究的示例插件: https://github.com/mzinner/mysql-shell-ex

您可以通过在用户配置路径下的plugins目录中添加一个带有插件名称的目录来创建一个插件,在 Microsoft Windows 上默认为%AppData%\MySQL\mysqlsh\,在 Linux 和 Unix 上默认为$HOME/.mysqlsh/(与搜索配置文件的第四个路径相同)。插件可以包含任意数量的文件和目录,但是所有的文件必须使用相同的编程语言。

Note

插件中的所有代码必须使用相同的编程语言。如果你需要同时使用 Python 和 JavaScript,你必须把代码分成两个不同的插件。

在本书的 GitHub 库的目录Chapter_12/myext中包含了一个名为myext的示例插件。它包括图 12-3 中描述的目录和文件。浅色(黄色)圆角矩形代表目录,深色(红色)文档形状是目录中的文件列表。

Note

示例插件非常简单,目的是演示插件基础设施是如何工作的。如果您在产品中使用插件,请确保在代码中添加适当的验证和错误处理。

img/484666_1_En_12_Fig3_HTML.png

图 12-3

myext插件的目录和文件

你可以像 Python 包和模块一样查看插件的结构。需要注意的两件重要事情是,每个目录中必须有一个__init__.py文件,并且当插件被导入时,只执行init.py文件(JavaScript 模块的init.js)。这意味着您必须在init.py文件中包含注册插件公共部分所必需的代码。在这个示例插件中,所有的__init__.py文件都是空的。

Note

插件不应该被交互地创建。确保将代码保存到文件中,最后重启 MySQL Shell 来导入插件。更多细节将在本节的剩余部分给出。

reports目录中的sessions.py文件与清单 12-6 中生成的sessions报告相同,除了报告的注册在reports/init.py中完成,并且报告被重命名为sessions_myext以避免两个报告同名。

utils目录包括一个具有get_columns()功能的模块,该模块由tools/example.py中的describe()功能使用。get_columns()功能也在utils/init.py中注册为util.get_columns()。清单 12-9 显示了来自utils/util.pyget_columns()函数。

'''Define utility functions for the plugin.'''

def get_columns(table):
    '''Create query against information_schema.COLUMNS to obtain
    meta data for the columns.'''
    session = table.get_session()
    i_s = session.get_schema("information_schema")
    i_s_columns = i_s.get_table("COLUMNS")

    query = i_s_columns.select(
        "COLUMN_NAME AS Field",
        "COLUMN_TYPE AS Type",
        "IS_NULLABLE AS `Null`",
        "COLUMN_KEY AS Key",
        "COLUMN_DEFAULT AS Default",
        "EXTRA AS Extra"
    )
    query = query.where("TABLE_SCHEMA = :schema AND TABLE_NAME = :table")
    query = query.order_by("ORDINAL_POSITION")

    query = query.bind("schema", table.schema.name)
    query = query.bind("table", table.name)

    result = query.execute()
    return result

Listing 12-9The get_columns() function from utils/util.py

该函数接受一个表对象,并使用 X DevAPI 来构造一个针对information_schema.COLUMNS视图的查询。请注意该函数是如何通过表对象获得会话和模式的。最后,返回执行查询的结果对象。

清单 12-10 展示了如何注册get_columns()函数,因此它在myext插件中作为util.get_columns()可用。注册发生在utils/init.py

'''Import the utilities into the plugin.'''
import mysqlsh
from myext.utils import util

shell = mysqlsh.globals.shell
# Get the global object (the myext plugin)
try:
    # See if myext has already been registered
    global_obj = mysqlsh.globals.myext
except AttributeError:
    # Register myext
    global_obj = shell.create_extension_object()
    description = {
        'brief': 'Various MySQL Shell extensions.',
        'details': [
            'More detailed help. To be added later.'
        ]
    }
    shell.register_global('myext', global_obj, description)

# Get the utils extension

try:
    plugin_obj = global_obj.utils
except IndexError:
    # The utils extension does not exist yet, so register it
    plugin_obj = shell.create_extension_object()
    description = {
        'brief': 'Utilities.',
        'details': ['Various utilities.']
    }
    shell.add_extension_object_member(global_obj, "util", plugin_obj,
                                      description)

definition = {
    'brief': 'Describe a table.',
    'details': ['Show information about the columns of a table.'],
    'parameters': [
        {
            'name': 'table',
            'type': 'object',
            'class': 'Table',
            'required': True,
            'brief': 'The table to get the columns for.',
            'details': ['A table object for the table.']
        }
    ]
}

try:
    shell.add_extension_object_member(plugin_obj, 'get_columns',
                                      util.get_columns, definition)
except SystemError as e:
    shell.log("ERROR", "Failed to register myext util.get_columns ({0})."
              .format(str(e).rstrip()))

Listing 12-10Registering the get_columns() function as util.get_columns()

第一个重要的观察是mysqlsh模块是导入的。shellsession对象都可以通过mysqlsh模块获得,所以在 MySQL Shell 中使用扩展时,这是一个重要的模块。还要注意util模块是如何导入的。总是需要使用从插件名称开始的完整路径来导入插件模块。

为了注册该功能,首先检查mysqlsh.globals中是否已经存在myext插件。如果没有,它是用shell.create_extension_object()创建的,并用shell.register_global()方法注册。这个舞蹈是必要的,因为有多个init.py文件,你不应该依赖它们的执行顺序。

接下来,使用shell.create_extension_object()shell.add_extension_object_member()方法以类似的方式注册utils模块。如果你有一个大的插件,有可能会重复代码和执行很多类似的步骤,所以你可以考虑创建实用函数来避免重复。

最后,使用shell.add_extension_object_member()方法注册函数本身。因为table参数接受一个对象,所以可以指定所需的对象类型。

对于模块和函数的注册,不要求代码中的名称与注册的名称相同。reports/init.py中的报告注册包括一个更改名称的示例,如果您感兴趣的话。然而,在大多数情况下,保持名称不变是更好的方法,这样可以更容易地找到特性背后的代码。

tools/example.py文件添加了两个都已注册的函数。还有前面的dice()函数和使用get_columns()获取列信息的describe()函数。与describe()功能相关的部分代码如清单 12-11 所示。

import mysqlsh
from myext.utils import util

def describe(schema_name, table_name):
    shell = mysqlsh.globals.shell
    session = shell.get_session()
    schema = session.get_schema(schema_name)
    table = schema.get_table(table_name)
    columns = util.get_columns(table)
    shell.dump_rows(columns)

Listing 12-11The describe() function in tools/example.py

需要注意的最重要的事情是,shell对象被作为mysqlsh.globals.shell获得,从那里可以获得sessionschematable对象。shell.dump_rows()方法用于生成结果的输出。该方法接受一个结果对象和可选的格式(默认为表格格式)。在输出结果的过程中,结果对象被消耗。

你现在已经准备好尝试这个插件了。你需要把整个myext目录复制到plugins目录,重启 MySQL Shell。清单 12-12 显示了帮助内容中的全局对象。

Tip

如果 MySQL Shell 在导入插件时遇到错误,启动 MySQL Shell 时会生成类似WARNING: Found errors loading plugins, for more details look at the log at: C:\Users\myuser\AppData\Roaming\MySQL\mysqlsh\mysqlsh.log的一行。

mysql-py> \h
...
GLOBAL OBJECTS

The following modules and objects are ready for use when the shell starts:

 - dba     Used for InnoDB cluster administration.
 - myext   Various MySQL Shell extensions.
 - mysql   Support for connecting to MySQL servers using the classic MySQL
           protocol.
 - mysqlx  Used to work with X Protocol sessions using the MySQL X DevAPI.
 - session Represents the currently open MySQL session.
 - shell   Gives access to general purpose functions and properties.
 - util    Global object that groups miscellaneous tools like upgrade checker
           and JSON import.

For additional information on these global objects use: <object>.help()

Listing 12-12The global objects in the help content

注意myext插件是如何作为一个全局对象出现的。您可以像使用任何内置的全局对象一样使用myext插件。这包括获得插件子部分的帮助,如清单12-13myext.tools所示。

mysql-py> myext.tools.help()
NAME
      tools - Tools.

SYNTAX
      myext.tools

DESCRIPTION
      Various tools including describe() and dice().

FUNCTIONS
      describe(schema_name, table_name)
            Describe a table.

      dice()
            Roll a dice

      help([member])
            Provides help about this object and it's members

Listing 12-13Obtaining help for myext.tools

作为最后一个例子,考虑如何使用describe()get_columns()方法。清单 12-14 在 Python 语言模式下对world.city表使用了这两种方法。

mysql-py> myext.tools.describe('world', 'city')
+-------------+----------+------+-----+---------+----------------+
| Field       | Type     | Null | Key | Default | Extra          |
+-------------+----------+------+-----+---------+----------------+
| ID          | int(11)  | NO   | PRI | NULL    | auto_increment |
| Name        | char(35) | NO   |     |         |                |
| CountryCode | char(3)  | NO   | MUL |         |                |
| District    | char(20) | NO   |     |         |                |
| Population  | int(11)  | NO   |     | 0       |                |
+-------------+----------+------+-----+---------+----------------+
mysql-py> \use world
Default schema `world` accessible through db.

mysql-py> result = myext.util.get_columns(db.city)

mysql-py> shell.dump_rows(result, 'json/pretty')
{
    "Field": "ID",
    "Type": "int(11)",
    "Null": "NO",
    "Key": "PRI",
    "Default": null,
    "Extra": "auto_increment"
}
{
    "Field": "Name",
    "Type": "char(35)",
    "Null": "NO",
    "Key": "",
    "Default": "",
    "Extra": ""
}
{
    "Field": "CountryCode",
    "Type": "char(3)",
    "Null": "NO",
    "Key": "MUL",
    "Default": "",
    "Extra": ""
}
{
    "Field": "District",
    "Type": "char(20)",
    "Null": "NO",
    "Key": "",
    "Default": "",
    "Extra": ""
}
{
    "Field": "Population",
    "Type": "int(11)",
    "Null": "NO",
    "Key": "",
    "Default": "0",
    "Extra": ""
}
5

Listing 12-14Using the describe() and get_columns() methods

in Python

首先,使用describe()方法。模式和表使用它们的名称作为字符串提供,结果作为表打印出来。然后,当前模式被设置为world模式,这允许您将表作为db对象的属性进行访问。然后使用shell.dump_rows()方法将结果打印成漂亮的 JSON。

Tip

因为 MySQL Shell 会检测你是否交互使用了一个方法,如果你没有把get_columns()的结果赋给一个变量,MySQL Shell 会直接输出到控制台。

MySQL Shell 的讨论到此结束。如果您还没有利用它提供的特性,我们鼓励您开始使用它。

摘要

本章介绍了 MySQL Shell。它首先概述了如何安装和使用 MySQL Shell,包括使用连接;SQL、Python 和 JavaScript 语言模式;内置的帮助;和全局对象。本章的其余部分介绍了 MySQL Shell、报告和扩展 MySQL 的定制。

MySQL Shell 提示符不仅仅是一个静态标签。它根据连接和默认模式进行调整,您可以对其进行定制,以包含诸如您所连接的 MySQL 版本等信息,并且您可以更改所使用的主题。

MySQL Shell 的强大之处在于内置的复杂特性及其对创建复杂方法的支持。扩展特性的最简单方法是使用 JavaScript 或 Python 的外部模块。您还可以使用报告基础结构,包括创建自己的自定义报告。最后,MySQL Shell 8.0.17 和更高版本支持插件,您可以使用插件在全局对象中添加您的特性。报告基础设施和插件的优势在于,您添加的特性与语言无关。

除非另有说明,本书剩余部分中使用命令行界面的所有示例都是用 MySQL Shell 创建的。为了最小化所用的空间,提示符已经被替换为mysql>,除非语言模式很重要,在这种情况下,语言模式被包括在内,例如,mysql-py>表示 Python 模式。

关于性能转变工具的讨论到此结束。第四部分包括模式考虑和查询优化器,下一章讨论数据类型。

十三、数据类型

在 MySQL(和其他关系数据库)中创建表时,需要为每一列指定数据类型。为什么不把所有东西都存储成字符串呢?毕竟,当数字 42 在本书中被使用时,它被表示为一个字符串,那么为什么不直接对所有内容使用字符串,并允许每一列都有各种值呢?这个想法有一些优点。这是 NoSQL 数据库的部分工作方式(尽管不止如此),本书的作者见过所有列都被定义为varchar(255)字符串的表格。为什么要为整数、小数、浮点数、日期、字符串等等而烦恼呢?这有几个原因,这就是本章的主题。

首先,将讨论对不同类型的值使用不同数据类型的好处。然后会有 MySQL 支持的数据类型的概述。最后,将讨论数据类型如何影响查询性能以及如何为列选择数据类型。

为什么是数据类型?

列的数据类型定义了可以存储什么类型的值以及如何存储这些值。此外,可能存在与数据类型相关联的元属性,例如大小(例如,用于数字的字节数和字符串中的最大字符数)以及用于字符串的字符集和校对。虽然数据类型属性看起来像是不必要的限制,但它们也有好处。这些好处包括

  • 数据有效性

  • 文件

  • 优化存储

  • 表演

  • 正确排序

本节的其余部分将讨论这些好处。

数据有效性

在它们的核心,数据类型定义了什么样的值是允许的。定义为整数数据类型的列只能存储整数值。这也是一种保障。如果您犯了一个错误,并试图将一个值存储到一个与定义的数据类型不同的列中,则可能会拒绝该值或转换该值。

Tip

将错误数据类型的值分配给列是否会导致错误或数据类型被转换取决于您是否启用了STRICT_TRANS_TABLES(对于事务存储引擎)和STRICT_ALL_TABLES(对于所有存储引擎)SQL 模式,以及转换数据类型是否被认为是安全的。某些被认为是安全的转换总是被允许的,例如,将“42”转换为 42,反之亦然。建议始终启用严格模式,当试图进行不安全的转换或截断数据时,该模式会使 DML 查询失败。

当您可以确保存储在表中的数据总是具有预期的数据类型时,您的工作会更轻松。如果用整数查询列,那么对返回值进行算术运算是安全的。同样,如果您知道值是一个字符串,您可以安全地执行字符串操作。这需要提前多做一点规划,但是一旦完成,您将会发现自己了解数据的数据类型。

关于数据类型和数据验证还有一点需要考虑。通常,有一些属性与数据类型相关联。在最简单的情况下,你有最大的尺寸。例如,整数的大小可以是 1、2、3、4 或 8 个字节。这会影响可以存储的值的范围。此外,整数可以是有符号的,也可以是无符号的。一个更复杂的例子是字符串,它不仅对存储多少文本有限制,而且需要一个字符集来定义数据如何编码,还需要一个排序规则来定义数据如何排序。

清单 13-1 展示了 MySQL 如何根据数据类型验证数据的例子。

mysql> SELECT @@sql_mode\G
*************************** 1\. row ***************************
@@sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
1 row in set (0.0003 sec)

mysql> SHOW CREATE TABLE t1\G
*************************** 1\. row ***************************
       Table: t1
Create Table: CREATE TABLE `t1` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `val1` int(10) unsigned DEFAULT NULL,
  `val2` varchar(5) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0011 sec)

mysql> INSERT INTO t1 (val1) VALUES ('abc');
ERROR: 1366: Incorrect integer value: 'abc' for column 'val1' at row 1

mysql> INSERT INTO t1 (val1) VALUES (-5);
ERROR: 1264: Out of range value for column 'val1' at row 1

mysql> INSERT INTO t1 (val2) VALUES ('abcdef');
ERROR: 1406: Data too long for column 'val2' at row 1

mysql> INSERT INTO t1 (val1, val2) VALUES ('42', 42);
Query OK, 1 row affected (0.0825 sec)

Listing 13-1Data validation based on data type

SQL 模式被设置为默认模式,包括STRICT_TRANS_TABLES。除了主键之外,该表还有两列,其中一列是无符号整数,另一列是varchar(5),这意味着它最多可以存储五个字符。当试图将字符串或负整数插入到val1列时,该值会被拒绝,因为它不能安全地转换为无符号整数。类似地,试图将一个包含六个字符的字符串存储到val2列中也会失败。然而,将字符串'42'存储到val1并将整数 42 存储到val2被认为是安全的,因此是允许的。

数据验证的一个副作用是,您还描述了您期望的数据——这是列的隐式文档。

文件

当您设计表格时,您知道表格的预期用途。然而,当您或其他人以后使用该表时,这不一定清楚。有几种方法记录列:使用描述值的列名、COMMENT列子句、CHECK约束和数据类型。

虽然不是记录列的最详细的方法——当然也不应该独立存在——但是数据类型确实有助于描述您期望的数据类型。如果您选择了date列而不是datetime,那么很明显您只打算存储日期部分。类似地,使用tinyint而不是int表明您只期望相对较小的值。这些都有助于你自己或他人理解什么样的数据是可以预期的。当您需要优化查询时,对数据的理解越好,您所做的更改就越成功,这样它就可以间接地帮助查询优化。

Tip

在表中提供文档的最佳方式是使用COMMENT子句和CHECK约束。然而,这些在表格图中通常是看不到的,在表格图中,数据类型有助于更好地理解预期的数据类型。

关于性能,显式选择数据类型也有好处。其中之一与值的存储方式有关。

优化存储

MySQL 并不以相同的方式存储所有数据。给定数据类型的存储格式选择得尽可能紧凑,以减少所需的存储空间。例如,考虑值 123456。如果将其存储为字符串,则至少需要 6 个字节加上可能的 1 个字节来存储字符串的长度。如果您选择一个整数,您只需要 3 个字节(对于整数,所有值总是使用相同数量的字节,这取决于该列允许的最大存储)。此外,从存储器中读取整数不需要对值 1 进行任何解释,而对于字符串,则需要使用其字符集对值进行解码。

选择正确的最大列大小可以减少所需的存储量。如果您需要存储整数,并且知道您从不需要需要超过 4 个字节存储的值,那么您可以使用int数据类型,而不是使用 8 个字节存储的bigint。这是该列所需存储量的一半。如果您使用大数据,存储(和内存)节省可能会变得非常大。但是,注意不要过度优化。在许多情况下,更改列的数据类型或大小需要重新构建整个表,如果表很大,这可能是一个开销很大的操作。这样,最好现在就使用多一点的存储空间,以节省以后的工作。

Tip

与其他类型的优化一样,注意不要过度优化数据类型。现在相对较小的存储节省可能会导致以后的痛苦。

数据的存储方式也会影响性能。

表演

并非所有数据类型都是平等的。在计算和比较中使用整数非常便宜,而存储字节的字符串必须使用字符集解码,因此相对昂贵。通过选择正确的数据类型,可以显著提高查询的性能。特别是,如果您需要比较两列中的值(可能在不同的表中),请确保它们具有相同的数据类型,包括字符集和字符串的排序规则。否则,必须先转换其中一列中的数据,然后才能与另一列进行比较。

虽然理解为什么整数比字符串的性能好很简单,但究竟是什么使一种数据类型的性能比另一种数据类型好或差却相对复杂,这取决于数据类型是如何实现的(存储在磁盘上)。因此,关于性能的进一步讨论将推迟到下一节介绍 MySQL 数据类型之后。

将讨论的最后一个好处是排序。

正确排序

日期类型对值的排序方式有很大影响。虽然人脑通常可以直观地理解数据,但计算机需要一些帮助来理解两个值如何相互比较。数据类型和字符串的排序规则是用于确保数据正确排序的关键属性。

为什么排序很重要?这有几个原因:

  • 正确的排序需要知道两个值是否相等或者一个值是否在给定的范围内。这对于让WHERE子句和连接条件按预期工作是至关重要的。

  • 当您创建索引时,排序用于确保 MySQL 能够快速找到具有您正在寻找的值的行。 2 指标将在下一章详细介绍。

考虑值 8 和 10。它们是如何排序的?如果你认为它们是整数,8 在 10 之前。但是,如果您将它们视为字符串,那么“10”(ASCII:0x 3130)位于“8”(ASCII:0x 38)之前。您是否期望一个或另一个取决于您的应用,但是除非也有包含非数字部分的值,否则您可能期望 integer 行为,该行为要求数据类型为 integer 类型。

既然已经讨论了显式数据类型的好处,那么是时候了解 MySQL 支持的数据类型了。

MySQL 数据类型

MySQL 中有 30 多种不同的数据类型。其中几个可以根据大小、精度以及是否接受有符号值进行微调。乍一看,这似乎让人不知所措,但是如果您将数据类型分成不同的类别,您可以逐步为您的数据选择正确的数据类型。

MySQL 中的数据类型可被视为以下类别之一的一部分:

  • 数值:这包括整数、固定精度小数类型、近似精度浮点类型和位类型。

  • Temporal: 这包括年份、日期、时间、日期时间和时间戳值。

  • 字符串:这包括二进制对象和带有字符集的字符串。

  • JSON:JSON 数据类型可以存储 JSON 文档。

  • Spatial: 这些类型用于存储描述坐标系中一个或多个点的值。

  • Hybrid: MySQL 有两种数据类型,都可以作为整数和字符串使用。

Tip

《MySQL 参考手册》对 https://dev.mysql.com/doc/refman/8.0/en/data-types.html 中的数据类型以及其中的引用有全面的论述。

本节的其余部分将介绍数据类型并讨论它们的细节。

数字数据类型

数值数据类型是 MySQL 支持的最简单的数据类型。您可以在整数、固定精度十进制值和近似浮点值之间进行选择。

13-1 总结了数字数据类型,包括其存储要求(以字节为单位)和支持的取值范围。对于整数,您可以选择值是有符号的还是无符号的,这会影响支持的值的范围。对于支持的值,开始值和结束值都包括在允许值的范围内。

表 13-1

数字数据类型(整数、定点和浮点)

|

数据类型

|

存储的字节数

|

范围

|
| --- | --- | --- |
| tinyint | one | 签名:-128–127 无符号:0–255 |
| smallint | Two | 签名:-32768–32767 无符号:0–65535 |
| mediumint | three | 签名:-8388608–8388607 无符号:0–16777215 |
| int | four | 签名:-2147483648–2147483647 无符号:0–4294967295 |
| bigint | eight | 签名:-263–263-1 无符号:0–264-1 |
| decimal(M, N) | 1–29 | 取决于 M 和 N |
| float | four | 可变的 |
| double | eight | 可变的 |
| bit(M) | 1–8 |   |

整数数据类型是最简单的,具有固定的存储要求和固定的支持值范围。tinyint的同义词是bool(布尔值)。

decimal数据类型(numeric是同义词)有两个参数,M 和 N,它们定义了值的精度和小数位数。如果有decimal(5,2),数值最多有五位,其中两位是小数(小数点右边)。这意味着允许值在-999.99 和 999.99 之间。最多支持 65 位数字。小数的存储量取决于位数,每个 9 位数的倍数使用 4 个字节,其余位数使用 0-4 个字节。

floatdouble数据类型存储近似值。这些类型对于数值计算很有效,但代价是它们的值存在不确定性。它们分别使用 4 字节和 8 字节进行存储。

Tip

不要使用浮点数据类型来存储精确的数据,如货币金额。请改用精确小数数据类型。对于近似浮点数据类型,永远不要使用等号(=)和不等号(<>)运算符,因为比较两个近似值通常不会返回相等的结果,即使它们应该相等。

最后一种数值数据类型是bit类型。它可以在一个值中存储 1 到 64 位。例如,这可以用于位屏蔽。所需的存储取决于所需的位数(M 值);可以近似为FLOOR((M+7)/8)字节。

与数值类型相关的一类数据类型是时态数据类型,这是下一个将要讨论的类别。

时态数据类型

时态数据定义一个时间点。精度范围从一年到一微秒。除了 year 数据类型之外,值是以字符串形式输入的,但是在内部使用了优化的格式,并且值将根据值所代表的时间点正确排序。

13-2 显示了 MySQL 支持的时态数据类型,每种类型使用的存储量(以字节为单位),以及支持的值的范围。

表 13-2

时态数据类型

|

数据类型

|

存储的字节数

|

范围

|
| --- | --- | --- |
| year | one | 1901–2155 |
| date | 3–6 | “1000-01-01”到“9999-12-31” |
| datetime | 5–8 | “1000-01-01 00:00:00.000000”到“9999-12-31 23:59:59.999999” |
| timestamp | 4–7 | “1970-01-01 00:00:01.000000”到“2038-01-19 03:14:07.999999” |
| time | 3–6 | -838:59:59.000000 '到' 838:59:59.000000 ' |

datetimetimestamptime类型都支持高达微秒分辨率的小数秒。小数秒的存储要求为 0-3 字节,具体取决于位数(每两位数一个字节)。

datetimetimestamp列略有不同。当您在datetime列中存储一个值时,MySQL 会按照您指定的方式存储它。另一方面,对于一个timestamp列,使用 MySQL 被配置为使用的时区——变量@@session.time_zone(默认为系统时区)将值转换为 UTC。同样,当您检索数据时,datetime值将按照您最初指定的方式返回,而timestamp列将被转换为在@@session.time_zone变量中设置的时区。

Tip

使用datetime列时,以 UTC 时区存储数据,并在使用数据时转换为所需的时区。通过始终以 UTC 格式存储值,如果操作系统时区或 MySQL 服务器时区被更改,或者您与来自不同时区的用户共享数据,出现问题的可能性就会降低。

当您使用字符串输入和检索日期和时间时,它们以专用格式存储在内部。实际的字符串呢?让我们来看看字符串和二进制数据类型。

字符串和二进制数据类型

字符串和二进制数据类型是存储任意数据的非常灵活的类型。二进制值和字符串的区别在于,字符串有一个与之关联的字符集,所以 MySQL 知道如何解释数据。另一方面,二进制值存储原始数据,这意味着您可以将它们用于任何类型的数据,包括图像和自定义数据格式。

虽然字符串和二进制数据非常灵活,但它们也有代价。对于字符串,MySQL 需要解释字节来确定它们代表哪些字符。就所需的计算能力而言,这是相对昂贵的。有些字符集,包括 MySQL 8 中默认的字符集 UTF-8,是可变宽度的,即一个字符使用可变数量的字节;对于 UTF-8,它的范围是每个字符 1 到 4 个字节。这意味着,如果您请求一个字符串的前四个字符,它可能需要读取 4 到 16 个字节,这取决于是哪些字符,因此 MySQL 将需要分析这些字节来确定何时找到了四个字符。对于二进制字符串,数据含义的解释被放回到应用中。

13-3 显示了 MySQL 中代表字符串和二进制数据的数据类型。该表包括可存储的最大数据量以及存储要求的描述。对于数据类型,(M)是该列必须能够存储的最大字符数,而在存储的字节数中,L 是表示用于编码的字符集中的字符串值所需的字节数。

表 13-3

字符串和二进制数据类型

|

数据类型

|

存储的字节数

|

最大长度

|
| --- | --- | --- |
| char(M) | M*char 宽度 | 255 个字符 |
| varchar(M) | L+1 或 L+2 | utf8mb4的 16383 个字符和latin1的 65532 个字符 |
| tinytext | L+1 | 255 字节 |
| text | L+2 | 65535 字节 |
| mediumtext | L+3 | 16777216 字节 |
| longtext | L+4 | 4294967296 字节 |
| binary(M) | M | 255 字节 |
| varbinary(M) | L+1 或 L+2 | 65532 字节 |
| tinyblob | L+1 | 255 字节 |
| blob | L+2 | 65536 字节 |
| mediumblob | L+3 | 16777216 字节 |
| longblob | L+4 | 4294967296 字节 |

字符串和二进制对象的存储要求取决于数据的长度。l 是存储该值所需的字节数;对于文本字符串,字符集也必须考虑在内。对于可变宽度类型,1–4 个字节用于存储值的长度。对于char(M)列,当使用紧凑系列的 InnoDB 存储格式并且字符串用可变宽度字符集编码时,所需的存储空间可能小于字符宽度的 M 倍。

对于除 char 和 varchar 之外的所有类型,字符串支持的最大长度以字节为单位指定。这意味着字符串类型中可以存储的字符数取决于字符集。此外,charvarcharbinaryvarbinary列计入行宽,行宽总计必须小于 64kb,这实际上意味着很少可能使用理论上的最大长度创建列。(这也是varcharvarbinary列最多可以存储 65532 个字符/字节的原因。)对于longtextlongblob列,应该注意的是,虽然它们原则上可以存储多达 4 GiB 的数据,但实际上存储受到max_allowed_packet变量的限制,该变量最多可以是 1 GiB。

对于存储字符串的数据类型,另一个需要考虑的问题是,您必须为列选择字符集和排序规则。如果没有明确选择,将使用表的默认值。在 MySQL 8 中,默认字符集是使用utf8mb4_0900_ai_ci排序规则的utf8mb4utf8mb4utf8mb4_0900_ai_ci是什么意思?

utf8mb4字符集是 UTF 8,支持每个字符多达 4 个字节(例如,对于一些表情符号是必需的)。最初,MySQL 只支持 UTF-8 的每个字符最多 3 个字节,后来添加了utf8mb4来扩展支持。今天,你不应该使用utf8mb3(每个字符最多 3 个字节)或其别名utf8(已弃用,所以后来可以改为表示utf8mb4)。当您使用 UTF-8 时,请始终选择 4 字节变体,因为 3 字节变体几乎没有什么好处,并且已经被弃用。在 MySQL 5.7 和更早的版本中,拉丁语 1 是默认字符集,但随着 MySQL 8 中 UTF-8 的改进,建议使用utf8mb4,除非你有特定的理由选择另一个字符集。

utf8mb4_0900_ai_ci排序规则是用于utf8mb4的通用排序规则。归类定义了排序和比较规则,因此当您比较两个字符串时,它们会正确地进行比较。这些规则可能相当复杂,包括某些字符序列与其他单个字符相等(例如,在某些归类中,德语中的 sharp 与“ss”相同)。排序规则名称由几个部分组成,它们是

  • utf8mb4: 归类所属的字符集。

  • 0900: 这意味着该排序规则是基于 Unicode 排序算法(UCA) 9.0.0 的排序规则之一。这些是在 MySQL 8 中引入的,与旧的 UTF-8 排序规则相比,提供了显著的性能改进。

  • ai: 排序规则可以是不区分重音的(ai)或区分重音的(as)。当排序规则不区分重音时,带重音的字符(如à)被视为等于不带重音的字符 a。在这种情况下,它不区分重音。

  • ci: 排序规则可以不区分大小写(ci)或区分大小写(cs)。在这种情况下,它不区分大小写。

名称也可以包含其他部分,其他字符集也有其他排序规则。特别是,有几个特定于国家的字符集需要考虑本地排序和比较规则;对于这些,国家代码被添加到名称中。建议使用 UCA 9.0.0 排序规则之一,因为与其他排序规则相比,这些排序规则具有更好的性能,并且更加现代化。information_schema.COLLATIONS视图包括 MySQL 支持的所有排序规则,支持按字符集过滤。从 8.0.18 开始,有 75 种归类可用于 utf8mb4,其中 49 种是 UCA 9.0.0 归类。

Tip

字符集和排序规则本身就是一个大而有趣的话题。如果你想深入这个话题,可以从本书作者的博客和其中的参考资料开始: https://mysql.wisborg.dk/mysql-8_charset

JSON 文档是一种特殊的字符串。MySQL 为它们提供了专用的数据类型。

JSON 数据类型

比关系表更灵活的一种流行的数据存储格式是 JavaScript 对象表示法(JSON)格式。这也是 MySQL 8 中可用的 MySQL 文档存储所选择的格式。MySQL 5.7 引入了对json数据类型的支持。

JSON 文档是 JSON 对象(键和值)、JSON 数组和 JSON 值的组合。下面是一个简单的 JSON 文档示例:

{
    "name": "Sydney",
    "demographics": {
        "population": 5500000
    },
    "geography": {
        "country": "Australia",
        "state": "NSW"
    },
    "suburbs": [
        "The Rocks",
        "Surry Hills",
        "Paramatta"
    ]
}

因为 JSON 文档也是一个字符串(或二进制对象),所以它也可以存储在字符串或二进制对象列中。但是,通过拥有专用的数据类型,可以添加验证,并且存储为访问文档中的特定元素而优化。

MySQL 8 中 JSON 文档的一个很好的性能相关特性是支持部分更新。这使得更改就地进行,不仅减少了更新期间所做的工作量,还可以只将部分更改写入二进制日志。要使部分就地更新成为可能,需要满足一些要求。这些措施如下:

  • 仅支持JSON_SET()JSON_REPLACE()JSON_REMOVE()功能。

  • 仅支持列内的更新。也就是说,不支持将一列设置为对另一列起作用的三个 JSON 函数之一的返回值。

  • 它必须是被替换的现有值。添加新的对象或数组元素会导致整个文档被重写。

  • 新值最多必须与被替换的值大小相同。例外情况是先前部分更新释放的空间可以重用。

为了将部分更新作为部分更新记录到二进制日志中,您需要将binlog_row_value_options选项设置为PARTIAL_JSON。该选项可以在会话和全局级别动态设置。

在内部,文档存储为一个长二进制对象(longblob),文本使用utf8mb4字符集进行解释。最大存储量限制为 1 GiB。存储需求与longblob相似,但是有必要考虑元数据和用于查找的字典的开销。

到目前为止,已经讨论了数字、时态数据、字符串、二进制对象和 JSON 文档。指定空间中一个点的数据呢?这是要涵盖的下一类数据类型。

空间数据类型

空间数据指定坐标系中的一个或多个点,可能形成一个对象,如多边形。例如,这对于在地图上指定项目的位置非常有用。

MySQL 8 增加了指定使用哪个参考系的支持;这被称为空间参考系统标识符(SRID)。支持的参考系统可以在information_schema.ST_SPATIAL_REFERENCE_SYSTEMS视图中找到(SRS_ID列有用于 SRID 的值);有 5000 多种可供选择。每个空间值都有一个关联的参考系统,以便 MySQL 能够正确识别两个值之间的关系,例如,计算两点之间的距离。要使用地球作为参考系统,请将 SRID 设置为 4326。

支持八种不同的空间数据类型,其中四种是单值类型,四种是值的集合。表 13-4 总结了以字节为单位列出的所需存储的空间类型。

表 13-4

空间数据类型

|

数据类型

|

存储的字节数

|

描述

|
| --- | --- | --- |
| geometry | 可变的 | 任何类型的单个空间对象。 |
| point | Twenty-five | 单点,例如,一个人的位置。 |
| linestring | 9+16 * #点 | 形成一条线的一组点,也就是说,它不是一个封闭的对象。 |
| polygon | 13+16 * #点 | 包围一个区域的一组点。一个多边形可以包括几个这样的集合,例如,创建环形对象的内环和外环。 |
| multipoint | 13+21 * #点 | 点的集合。 |
| multilinestring | 可变的 | linestring 值的集合。 |
| multipolygon | 可变的 | 多边形的集合。 |
| geometrycollection | 可变的 | 几何值的集合。 |

MySQL 使用二进制格式存储数据。geometrymultilinestringmultipolygongeometrycollection类型的存储需求取决于值中包含的对象的大小。这些对象集合的存储比将对象存储在单独的列中稍大一些。您可以使用LENGTH()函数来获得空间对象的大小,然后添加 4 个字节来存储 SRID,以获得数据所需的总存储空间。

这就剩下一类数据类型需要讨论:数字和字符串数据类型的混合。

混合数据类型

有两种特殊的数据类型结合了整数和字符串的属性:enumset。两者都可以被认为是可能值的集合,区别在于enum数据类型允许您选择其中一个可能值,而set数据类型允许您选择任何可能值。

使enumset数据类型混合在一起的是,您可以将它们作为整数和字符串使用。后者是最常见的,也是最容易使用的。在内部,值被存储为整数,这提供了紧凑和有效的存储,同时仍然允许在设置或查询列时使用字符串。两种数据类型都可以使用查找表来实现。

enum数据类型是两者中最常用的。创建列时,可以指定允许值的列表,例如:

CREATE TABLE t1 (
   id int unsigned NOT NULL PRIMARY KEY,
   val enum('Sydney', 'Melbourne', 'Brisbane')
);

数值是列表中从 1 开始的位置。也就是说,悉尼的整数值为 1,墨尔本为 2,布里斯班为 3。根据列表中成员的数量,总存储需求只有 1 或 2 个字节,最多支持 65535 个成员。

除了您可以选择多个选项之外,set数据类型的工作方式与enum类似。要创建它,请列出您希望可用的成员,例如:

CREATE TABLE t1 (
   id int unsigned NOT NULL PRIMARY KEY,
   val set('Sydney', 'Melbourne', 'Brisbane')
);

根据成员在列表中的位置,列表中的每个成员都将获得 1、2、4、8 等系列中的一个数值。在本例中,悉尼的值为 1,墨尔本的值为 2,布里斯班的值为 4。那么值 3 代表什么呢?是悉尼和墨尔本。如果要包含多个值,可以对它们的单个值求和。这样,set 数据类型的工作方式与 bit 类型相同。当您将值指定为字符串时会更简单,因为您将值的成员包括在逗号分隔的列表中。清单 13-2 显示了两个插入set值的例子,每个例子使用数字和字符串值两次插入相同的值。

mysql> INSERT INTO t1
       VALUES (1, 4),
              (2, 'Brisbane');
Query OK, 2 rows affected (0.0812 sec)

Records: 2  Duplicates: 0  Warnings: 0

mysql> INSERT INTO t1
       VALUES (3, 7),
              (4, 'Sydney,Melbourne,Brisbane');
Query OK, 2 rows affected (0.0919 sec)

Records: 2  Duplicates: 0  Warnings: 0

mysql> SELECT *
         FROM t1\G
*************************** 1\. row ***************************
 id: 1
val: Brisbane
*************************** 2\. row ***************************
 id: 2
val: Brisbane
*************************** 3\. row ***************************
 id: 3
val: Brisbane,Melbourne,Sydney
*************************** 4\. row ***************************
 id: 4
val: Brisbane,Melbourne,Sydney
4 rows in set (0.0006 sec)

Listing 13-2Working with set values

首先,插入'Brisbane'的值。因为它是集合中的第三个元素,所以它的数值为 4。然后插入悉尼、墨尔本和布里斯班的布景。这里你需要把 1,2,4 相加。注意在SELECT查询中,元素的顺序与set定义中的不同。

根据集合中成员的数量,set列使用 1、2、3、4 或 8 个字节的存储空间。一个集合中最多可以有 64 个成员。

对可用数据类型的讨论到此结束。数据类型如何影响查询的性能?可能很多,所以值得考虑一下。

表演

数据类型的选择不仅对于数据完整性和判断预期的数据类型很重要,而且不同的数据类型具有不同的性能特征。本节将讨论在比较数据类型时性能如何变化。

一般来说,数据类型越简单,性能越好。整数的性能最好,浮点(近似值)紧随其后。十进制(精确)值比近似浮点值有更高的开销。二进制对象比文本字符串执行得更好,因为二进制对象没有字符集的开销。

当谈到像 JSON 这样的数据类型时,您可能认为它的性能比使用二进制对象差,因为 JSON 文档有一些存储开销,如本章前面所述。然而,正是这种存储开销意味着 JSON 数据类型的性能要优于将相同的数据存储为 blob。开销包括元数据和用于查找的字典,这意味着访问数据更快。此外,JSON 文档支持就地更新,而 text 和 blob 数据类型会替换整个对象,即使只替换了单个字符或字节。

在给定的数据类型族中(例如,intbigint),较小的数据类型比较大的数据类型执行得更好;然而,在实践中,还需要考虑硬件寄存器内的对齐,因此对于内存中的工作负载,这种差异可以忽略不计,甚至相反。

那么应该使用哪些数据类型呢?这是本章的最后一个主题。

应该选择哪种数据类型?

在本章的开始,我们讨论了如何将所有数据存储在字符串或二进制对象中以获得最大的灵活性。在本章的过程中,我们已经讨论了使用特定数据类型的好处,并且在上一节中讨论了不同数据类型的性能。那么应该选择哪种数据类型呢?

您可以开始问自己一些关于需要存储在列中的数据的问题。一些问题的例子如下:

  • 数据的本机格式是什么?

  • 最初可以预期多大的值?

  • 值的大小会随着时间增长吗?如果有,有多少,多快?

  • 在查询中检索数据的频率是多少?

  • 你期望有多少独特的价值观?

  • 需要对值进行索引吗?特别是,它是表的主键吗?

  • 您是否需要存储数据,或者是否可以通过另一个表中的外键(使用整数引用列)获取数据?

您应该为需要存储的数据选择本地数据类型。如果你需要存储整数,选择一个整数数据类型,通常是intbigint,这取决于你需要多大的值。如果要限制值,可以选择较小的整数类型;例如,存储关于父母的数据的表的孩子数量不需要是一个bigint,但是一个tinyint就足够了。同样,如果你想存储 JSON 文档,使用json类型,而不是longtextlongblob

对于数据类型的大小,您需要同时考虑当前需求和未来需求。如果您预计在 long 内需要更大的值,那么最好立即选择更大的数据类型。这样可以避免以后更改表定义。但是,如果预期的变化是几年后的事情,现在使用较小的数据类型并随着时间的推移重新评估您的需求可能会更好。对于varcharvarbinary,只要不改变存储字符串或字符集长度所需的字节数,也可以就地改变宽度。

当处理字符串和二进制对象时,还可以考虑将数据存储在单独的表中,并使用整数引用这些值。这将在您需要检索值时添加一个联接;但是,如果您只是很少需要实际的字符串值,那么保持主表较小可能是一个整体的优势。这种方法的好处还取决于表中的行数以及如何查询这些行;检索许多行的大型扫描将比单行查找受益更多,即使不需要所有列也使用SELECT *将比只选择需要的列受益更多。

如果只有几个唯一的字符串值,那么使用enum数据类型也是值得考虑的。它的工作方式类似于查找表,但它保存连接并允许您直接检索字符串值。

对于非整数数字数据,您可以选择精确的decimal数据类型和近似的floatdouble数据类型。如果您需要存储数据,比如必须精确的货币值,您应该总是选择decimal数据类型。如果需要进行相等和不相等的比较,这也是可以选择的类型。如果不需要精确的数据,那么floatdouble数据类型会表现得更好。

对于字符串值,那么charvarchartinytexttextmediumtextlongtext数据类型需要一个字符集和一个排序规则。通常,建议选择带有基于 UCA 9.0.0 的排序规则之一的utf8mb4(名称中带有_0900_的排序规则)。默认的utf8mb4_0900_ai_ci是一个不错的选择,如果你没有特定的需求。Latin 1 的性能会稍微好一点,但不足以保证为不同的需求增加不同字符集的复杂性。UCA 9.0.0 排序规则还提供了比 Latin 1 更现代的排序规则。

当您需要决定允许多大的值时,选择支持您现在和不久的将来需要的值的最小数据类型或宽度。更小的数据类型也意味着更少的空间用于行大小限制(64 kiB ),更多的数据可以放入 InnoDB 页面。由于 InnoDB 缓冲池可以根据缓冲池和页面的大小存储一定数量的页面,这反过来意味着可以将更多的数据放入缓冲池,从而有助于减少磁盘 I/O。同时,请记住,优化还意味着知道何时已经进行了足够的优化。不要花很长时间来删除几个字节,结果却不得不在一年内进行昂贵的表重建。

最后要考虑的是值是否包含在索引中。值越大,索引也越大。这是主键的一个特殊问题。InnoDB 根据主键(作为聚集索引)组织数据,因此当您添加辅助索引时,主键会添加到索引的末尾,以提供到行的链接。此外,这种数据组织方式意味着通常单调递增的值最适合作为主键。如果主键列随时间随机变化和/或很大,那么最好添加一个带有自动递增整数的伪列,并将其用作主键。

索引本身是一个重要的大主题,将在下一章讨论。

摘要

本章介绍了数据类型的概念。使用数据类型有几个好处:数据验证、文档、优化存储、性能和正确排序。

MySQL 支持大范围的数据类型,从字符串和空间对象上的简单整数到复杂的 JSON 文档。我们讨论了每种数据类型,重点是支持的值、支持的值大小以及所需的存储量。

本章的最后部分讨论了数据类型如何影响性能,以及如何确定为列选择哪种数据类型。这包括考虑列是否会被索引,这也涉及到数据类型的一个好处:正确的排序。索引是一个非常重要的主题,实际上下一章将会涉及到它们。

十四、索引

向表中添加索引是提高查询性能的一种非常有效的方法。索引允许 MySQL 快速找到查询所需的数据。当向表中添加正确的索引时,查询性能可能会提高几个数量级。诀窍是知道要添加哪些索引。为什么不在所有列上添加索引呢?索引也有开销,所以您需要在添加随机索引之前分析您的需求。

本章首先讨论什么是索引,一些索引概念,以及添加索引会有什么缺点。然后介绍 MySQL 支持的各种索引类型和特性。本章的下一部分开始讨论 InnoDB 如何使用与索引组织的表特别相关的索引。最后,讨论了如何选择应该向表中添加哪些索引以及何时添加。

什么是索引?

为了能够使用索引来适当地提高性能,理解什么是索引是很重要的。这一节将不讨论不同的索引类型——这将在本章后面的“索引类型”一节中讨论——而是更高层次的索引概念。

索引的概念并不新鲜,早在计算机数据库出现之前就已经存在了。举个简单的例子,考虑这本书。在这本书的结尾,有一些单词和术语的索引,这些单词和术语被选为与本书中的文本最相关的搜索术语。图书索引的工作方式在概念上类似于数据库索引的工作方式。它组织数据库中的“术语”,因此您的查询可以比读取所有数据并检查它是否与搜索标准匹配更快地找到相关数据。这里引用了术语一词,因为索引不一定是由人类可读的词组成的。也可以索引二进制数据,如空间数据。

简而言之,索引组织数据的方式可以减少查询需要检查的行数。精心选择的索引带来的加速可能是巨大的——几个数量级。再次考虑这本书:如果你想阅读 B 树索引,你可以从第 1 页开始,继续阅读整本书,或者在书的索引中查找术语“B 树索引”,直接跳到相关的页面。当查询 MySQL 数据库时,改进是相似的,不同之处在于查询可能比在书中查找信息复杂得多,因此索引的重要性增加了。

显然,您只需要添加所有可能的索引,对吗?不。除了添加索引的管理复杂性之外,索引本身不仅在正确使用时提高了性能;它们也增加了开销。所以你需要小心选择你的索引。

另一件事是,即使可以使用索引,它也不总是比扫描整个表更有效。如果你想阅读这本书的重要部分,在索引中查找每一个感兴趣的术语,找出主题在哪里讨论,然后阅读它,最终会比从头到尾阅读整本书慢。同样,如果您的查询无论如何都需要访问表中的大部分数据,那么从一端到另一端读取整个表会变得更快。扫描整个表变得更便宜的确切阈值取决于几个因素。这些因素包括磁盘类型、顺序 I/O 与随机 I/O 相比的性能、数据是否适合内存等等。

在深入研究索引的细节之前,有必要快速浏览一下一些关键的索引概念。

索引概念

考虑到主题索引有多大,有几个术语用来描述索引就不足为奇了。当然还有索引类型的名称,比如 B 树、全文、空间等等,但是还有一些更通用的术语需要注意。索引类型将在本章后面介绍,因此这里将讨论更一般的术语。

键与索引

您可能已经注意到,有时会使用“索引”一词,有时会使用“键”一词。有什么区别?索引是键的列表。然而,在 MySQL 语句中,这两个术语经常可以互换。

一个很重要的例子是“主键”——在这种情况下,必须使用“key”。另一方面,当你添加一个索引时,你可以随心所欲地写ALTER TABLE table_name ADD INDEX ...ALTER TABLE table_name ADD KEY ...。在这种情况下,手册使用“索引”,因此为了保持一致,建议坚持使用索引。

有几个术语可以描述你正在使用的索引类型。首先要讨论的是一个独特的索引。

唯一索引

唯一索引是指对于索引中的每个值只允许一行的索引。考虑一个包含人的数据的表。您可以包括该人的社会保险号或类似的标识符。任何两个人都不应该共享社会保险号,所以在存储社会保险号的列上定义一个惟一的索引是有意义的。

从这个意义上说,“唯一”更多地是指一种约束,而不是索引特征。然而,索引部分对于 MySQL 能够快速确定新值是否已经存在是至关重要的。

在 MySQL 中使用惟一索引时,一个重要的考虑因素是如何处理NULL值。比较两个NULL值是未定义的(或者换句话说NULL不等于NULL,所以允许NULL值的列上的唯一索引不会限制该列可以有多少行NULL。如果您想要将您的唯一约束限制为只允许一个NULL值,那么使用触发器来检查是否已经有一个NULL值,并使用SIGNAL语句引发一个错误。在清单 14-1 中可以看到一个触发器的例子。

CREATE TABLE my_table (
  Id int unsigned NOT NULL,
  Name varchar(50),
  PRIMARY KEY (Id),
  UNIQUE INDEX (Name)
);

DELIMITER $$
CREATE TRIGGER befins_my_table
BEFORE INSERT ON my_table
   FOR EACH ROW
BEGIN
   DECLARE v_errmsg, v_value text;
   IF EXISTS(SELECT 1 FROM my_table WHERE Name <=> NEW.Name) THEN
         IF NEW.Name IS NULL THEN
            SET v_value = 'NULL';
         ELSE
            SET v_value = CONCAT('''', NEW.Name, '''');
      END IF;
         SET v_errmsg = CONCAT('Duplicate entry ',
                               v_value,
                               ' For key ''Name''');
      SIGNAL SQLSTATE '23000'
         SET MESSAGE_TEXT = v_errmsg,
             MYSQL_ERRNO = 1062;
   END IF;
END$$
DELIMITER ;

Listing 14-1Trigger checking for unique constraint violations

这将处理Name列的任何类型的重复值。它使用NULL安全等于运算符(<=>)来确定Name的新值是否已经存在于表中。如果是的话,它引用不是NULL的值,否则不引用,所以可以区分字符串“NULL”和NULL值。最后,发出一个 SQL 状态为 23000、MySQL 错误号为 1062 的信号。错误消息、SQL 状态和错误号与正常的重复键约束错误相同。

一种特殊的唯一索引是主键。

主关键字

表的主键是唯一定义行的索引。NULL主键不允许有值。如果您的表上有多个NOT NULL唯一索引,那么任何一个都可以作为主键。出于稍后将在讨论聚集索引时解释的原因,您应该为主键选择一个或多个具有不可变值的列。也就是说,不要改变给定行的主键。

主键对于 InnoDB 来说非常特殊,而对于其他存储引擎来说,它可能更像是一个约定俗成的问题。然而,在所有情况下,最好总是有一些值来唯一地标识一行,例如,允许复制快速地确定要修改哪一行(在第 26 章中有更多关于这一点的内容),并且组复制特性明确地要求所有表都有一个主键或一个非唯一索引。在 MySQL 8.0.13 和更高版本中,您可以启用sql_require_primary_key选项来要求所有新表都必须有一个主键。如果更改现有表的结构,该限制也适用。

Tip

启用sql_require_primary_key选项(默认禁用)。没有主键的表可能会导致性能问题,有时是以意想不到的微妙方式。如果将来要使用组复制,这还可以确保您的表准备就绪。

如果有主键,是否也有辅键?

次要索引

术语“辅助索引”用于表示不是主键的索引。它没有任何特殊的含义,因此该名称只是用来明确表示该索引不是主键,无论它是唯一的还是非唯一的索引。

如前所述,主键对于 InnoDB 有特殊的意义,因为它用于聚集索引。

聚集索引

聚集索引是 InnoDB 特有的,是 InnoDB 如何组织数据的术语。如果您熟悉 Oracle DB,您可能知道按索引组织的表;描述了同样的事情。

InnoDB 中的一切都是索引。行数据位于 B 树索引的叶页面中(B 树索引将在稍后描述)。该索引称为聚集索引。该名称来源于索引值聚集在一起的事实。主键用于聚集索引。如果不指定显式主键,InnoDB 将查找不允许使用NULL值的唯一索引。如果不存在,InnoDB 将使用一个全局(对于所有 InnoDB 表)自动增量值添加一个隐藏的 6 字节整数列,以生成一个唯一值。

主键的选择也会影响性能。这些将在本章后面的“索引策略”一节中讨论。聚集索引也可以看作是覆盖索引的一个特例。这是什么?你马上就会知道了。

覆盖索引

如果一个索引包含了给定查询所需的索引表中的所有列,则称该索引为覆盖索引。也就是说,索引是否覆盖取决于使用索引的查询。一个索引可以覆盖一个查询,但不能覆盖另一个查询。考虑一个索引列(a, b)的索引和一个选择这两列的查询:

SELECT a, b
  FROM my_table
 WHERE a = 10;

在这种情况下,查询只需要列ab,因此没有必要查找其余的行——索引足以检索所有需要的数据。另一方面,如果查询还需要列c,则索引不再覆盖。当您使用EXPLAIN语句分析一个查询时(这将在第 20 章中介绍),并且一个覆盖索引被用于该表,EXPLAIN输出中的Extra列将包括“使用索引”

覆盖索引的一个特例是 InnoDB 的聚集索引(尽管EXPLAIN不会说“使用索引”)。聚集索引包括叶节点中的所有行数据(尽管通常只对列的子集进行索引),因此索引将总是包括所有必需的数据。一些数据库在创建可用于模拟聚集索引工作方式的索引时支持include子句。

聪明地创建索引,使它们可以用作最常执行的查询的覆盖索引,可以极大地提高性能,这将在“索引策略”一节中讨论。

添加索引时,您需要遵守一些限制。这些限制是接下来要讨论的内容。

索引限制

关于 InnoDB 索引有一些限制。这些范围从索引大小到表上允许的索引数量。最重要的限制如下:

  • B 树索引的最大宽度是 3072 字节或 767 字节,具体取决于 InnoDB 行格式。最大大小基于 16 kiB InnoDB 页面,较小的页面大小有较低的限制。

  • 当指定了前缀长度时,Blob 和 text 类型的列只能用于全文索引以外的索引中。前缀索引将在本章后面的“索引功能”一节中讨论

  • 功能键部分计入表中 1017 列的限制。

  • 每个表上最多可以有 64 个辅助索引。

  • 多列索引最多可以包含 16 列和功能键部分。

您可能会遇到的限制是 B 树索引的最大索引宽度。当使用DYNAMIC(默认)或COMPRESSED行格式时,索引不能超过 3072 字节,对于REDUNDANTCOMPACT行格式,索引不能超过 767 字节。对于使用DYNAMICCOMPRESSED行格式的表格,8 个 KiB 页面的限制减少到一半(1536 字节),4 个 KiB 页面的限制减少到四分之一(768 字节)。这对于字符串和二进制列上的索引来说是一个特别的限制,因为这些值不仅本质上通常很大,而且也是在大小计算中使用的最大可能存储量。这意味着使用utf8mb4字符集的varchar(10)将贡献 40 个字节,即使您从未在列中存储任何单字节字符。

当您向文本或 blob 类型的列添加 B 树索引时,您必须始终提供一个键长度,以指定要在索引中包含多少列前缀。这甚至适用于仅支持 256 字节数据的tinytexttinyblob。对于charvarcharbinaryvarbinary列,如果以字节为单位的值的最大大小超过了表允许的最大索引宽度,则只需要指定前缀长度。

Tip

对于文本和 blob 类型的列,不使用前缀索引,通常更好的方法是使用全文索引(稍后介绍),添加带有 blob 散列的生成列,或者以其他方式优化访问。

如果向表中添加函数索引,那么每个功能键部分都将计入表中列的限制。如果您创建一个包含两个功能部分的索引,那么这将作为表限制中的两列。对于 InnoDB,一个表中最多可以有 1017 列。

最后两个限制与表中可以包含的索引数量以及单个索引中可以包含的列和功能键部分的数量有关。一个表上最多可以有 64 个辅助索引。实际上,如果您已经接近这个限制,那么重新考虑您的索引策略可能会对您有所帮助。正如在“索引的缺点是什么?”在本章的后面,有一些与索引相关的开销,所以在所有情况下,最好将索引的数量限制在那些真正有利于查询的数量上。同样,添加到索引中的部分越多,索引就越大。InnoDB 的限制是最多可以添加 16 个部分。

如果需要向表中添加索引或删除多余的索引,该怎么办?索引可以与表一起创建,也可以稍后创建,还可以删除索引,如下所述。

SQL 语法

当您第一次创建模式时,您通常会对添加哪些索引有一些想法。然后,久而久之,你的监控可能会确定一些索引不再需要,但其他的应该添加。对索引的这些更改可能是由于对所需索引的误解;数据可能已经更改,或者查询可能已经更改。

在更改表上的索引时,有三种不同的操作:在创建表本身时创建索引,向现有表添加索引,或者从表中删除索引。无论是将索引与表一起添加,还是作为后续操作添加,索引定义都是相同的。删除索引时,只需要索引名。

本节将展示添加和删除索引的一般语法。在本章的其余部分,将会有更多基于特定索引类型和特性的例子。

创建带索引的表

创建表时,可以将索引定义添加到CREATE TABLE语句中。索引紧接在列之后定义。您可以选择指定索引的名称;否则,索引将根据索引中的第一列命名。

清单 14-2 展示了一个创建了几个索引的表的例子。如果您不知道所有的索引类型在做什么,也不用担心——这将在本章的后面讨论。

CREATE TABLE db1.person (
  Id int unsigned NOT NULL,
  Name varchar(50),
  Birthdate date NOT NULL,
  Location point NOT NULL SRID 4326,
  Description text,
  PRIMARY KEY (Id),
  INDEX (Name),
  SPATIAL INDEX (Location),
  FULLTEXT INDEX (Description)
);

Listing 14-2Example of creating a table with indexes

这在db1模式中创建了具有四个索引的表person(必须预先存在)。第一个是主键,它是Id列上的 B 树索引(稍后会详细介绍)。第二个也是 B 树索引,但它是所谓的二级索引,索引Name列。第三个索引是关于Location列的空间索引。第四个是Description列上的全文索引。

您也可以创建包含多个列的索引。如果您需要将条件放在多列上,将条件放在第一列上并按第二列排序,等等,这将非常有用。要创建多列索引,请以逗号分隔列表的形式指定列名:

INDEX (Name, Birthdate)

列的顺序非常重要,这将在“索引策略”中解释简而言之,MySQL 将只能使用左边的索引,也就是说,只有同时使用了Name才能使用索引的Birthdate部分。这意味着索引(Name, Birthdate)(Birthdate, Name)不是同一个索引。

一个表上的索引通常不会保持静态,所以如果您想向一个现有的表中添加一个索引,该怎么做呢?

添加索引

如果确定需要,可以向现有表中添加索引。为此,您需要使用ALTER TABLECREATE INDEX语句。由于ALTER TABLE可以用于表的所有修改,您可能希望坚持这样做;然而,所做的工作是相同的。

清单 14-3 展示了如何使用ALTER TABLE创建索引的两个例子。第一个示例添加了一个索引;第二个在一条语句上添加两个索引。

ALTER TABLE db1.person
  ADD INDEX (Birthdate);

ALTER TABLE db1.person
  DROP INDEX Birthdate;

ALTER TABLE db1.person
  ADD INDEX (Name, Birthdate),
  ADD INDEX (Birthdate);

Listing 14-3Adding indexes using ALTER TABLE

第一个和最后一个ALTER TABLE语句使用ADD INDEX子句告诉 MySQL 应该向表中添加一个索引。第三条语句添加了两个这样的子句,用逗号分隔,以便在一条语句中添加两个索引。在这两者之间,索引被删除,因为拥有重复的索引是不好的做法,MySQL 也会对此发出警告。

如果使用两条语句添加两个索引,或者使用一条语句添加两个索引,会有什么不同吗?是的,可能会有很大的不同。添加索引时,需要执行全表扫描来读取索引所需的所有值。对于大型表来说,全表扫描是一项开销很大的操作,因此从这个意义上说,最好在一条语句中添加两个索引。另一方面,只要索引可以完全保存在 InnoDB 缓冲池中,创建索引就会快得多。将两个索引的创建分成两个语句可以减轻缓冲池的压力,从而提高索引创建性能。

最后一个操作是删除不再需要的索引。

删除索引

删除索引的行为类似于添加索引。您可以使用ALTER TABLEDROP INDEX语句。当使用ALTER TABLE时,可以将删除索引与表的其他数据定义操作结合起来。

当您删除一个索引时,您需要知道该索引的名称。有几种方法可以做到这一点,如清单 14-4 所示。

mysql> SHOW CREATE TABLE db1.person\G
*************************** 1\. row ***************************
       Table: person
Create Table: CREATE TABLE `person` (
  `Id` int(10) unsigned NOT NULL,
  `Name` varchar(50) DEFAULT NULL,
  `Birthdate` date NOT NULL,
  `Location` point NOT NULL /*!80003 SRID 4326 */,
  `Description` text,
  PRIMARY KEY (`Id`),
  KEY `Name` (`Name`),
  SPATIAL KEY `Location` (`Location`),
  KEY `Name_2` (`Name`,`Birthdate`),
  KEY `Birthdate` (`Birthdate`),
  FULLTEXT KEY `Description` (`Description`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0010 sec)

mysql> SELECT INDEX_NAME, INDEX_TYPE,
              GROUP_CONCAT(COLUMN_NAME
                           ORDER BY SEQ_IN_INDEX) AS Columns
         FROM information_schema.STATISTICS
        WHERE TABLE_SCHEMA = 'db1'
              AND TABLE_NAME = 'person'
        GROUP BY INDEX_NAME, INDEX_TYPE;
+-------------+------------+----------------+
| INDEX_NAME  | INDEX_TYPE | Columns        |
+-------------+------------+----------------+
| Birthdate   | BTREE      | Birthdate      |
| Description | FULLTEXT   | Description    |
| Location    | SPATIAL    | Location       |
| Name        | BTREE      | Name           |
| Name_2      | BTREE      | Name,Birthdate |
| PRIMARY     | BTREE      | Id             |
+-------------+------------+----------------+
6 rows in set (0.0013 sec)

Listing 14-4Find the index names for a table

在您的情况下,索引可能会以不同的顺序列出。第一个查询使用SHOW CREATE TABLE语句获取完整的表定义,其中也包括索引及其名称。第二个查询查询information_schema.STATISTICS视图。这个视图对于获取关于索引的信息非常有用,将在下一章详细讨论。一旦决定了要删除哪个索引,就可以使用清单 14-5 中所示的ALTER TABLE

ALTER TABLE db1.person DROP INDEX name_2;

Listing 14-5Dropping an index using ATLER TABLE

这将删除名为name_2的索引,即(Name, Birthdate)列上的索引。

本章的其余部分将介绍什么是索引的各种细节,在本章的最后,“索引策略”一节将讨论如何选择要索引的数据。首先,理解为什么索引有开销是很重要的。

索引的缺点是什么?

生活中很少有免费的东西——索引也不例外。虽然索引对于提高查询性能很有帮助,但是它们也需要被存储并保持最新。此外,一个不太明显的开销是,当执行查询时,索引越多,优化器需要做的工作就越多。本节将讨论索引的这三个缺点。

仓库

添加索引的一个最明显的成本是需要存储索引,以便在需要时随时可用。您不希望每次需要时都先创建索引,因为这样会降低索引的性能优势。 1 存储开销是双重的:索引存储在磁盘上以持久化它,并且它需要 InnoDB 缓冲池中的内存供查询使用。

磁盘存储意味着您可能需要向系统添加磁盘或块存储。如果您使用 MySQL 企业备份(MEB)等备份解决方案来复制原始表空间文件,您的备份也会变得更大,需要更长的时间才能完成。

InnoDB 总是使用其缓冲池来读取查询所需的数据。如果数据不存在于缓冲池中,则首先将数据读入缓冲池,然后用于查询。因此,当您使用索引时,索引和行数据通常都会被读入缓冲池(使用覆盖索引时是一个例外)。需要放入缓冲池的空间越多,容纳其他索引和数据的空间就越少——除非将缓冲池变得更大。它当然比这更复杂,因为避免全表扫描还会阻止将整个表读入缓冲池,这减轻了缓冲池的压力。与开销相比,总体的好处在于使用索引可以避免检查多少表,以及其他查询是否会读取索引避免访问的数据。

总而言之,当您添加索引时,您将需要额外的磁盘,并且通常您将需要更大的 InnoDB 缓冲池来保持相同的缓冲池命中率。另一个开销是索引只有保持最新才有用。这在更新数据时增加了工作量。

更新索引

每当您对数据进行更改时,索引都必须更新。这包括在插入或删除数据时添加或删除行链接,以及在更新值时修改索引。您可能对此不以为然,但这可能是一笔巨大的开销。事实上,在诸如恢复逻辑备份(通常包括用于创建数据的 SQL 语句的文件,例如用mysqlpump程序创建的文件)的批量数据加载期间,保持索引更新的开销通常是限制插入速率的原因。

Tip

保持索引最新的开销可能会很高,因此通常建议在向空表进行大规模导入时删除辅助索引,然后在导入完成后重新创建索引。

对于 InnoDB,开销还取决于二级索引是否适合缓冲池。只要整个索引都在缓冲池中,保持索引最新就相对便宜,不太可能成为严重的瓶颈。如果索引不合适,InnoDB 将不得不在表空间文件和缓冲池之间不断调整页面,这时开销将成为导致严重性能问题的主要瓶颈。

还有一个不太明显的性能开销。索引越多,优化器确定最佳查询计划的工作量就越大。

优化器

当优化器分析一个查询以确定它认为最佳的查询执行计划时,它需要评估每个表上的索引,以确定是否应该使用该索引,以及是否可能对两个索引进行索引合并。目标当然是尽可能快地评估查询。然而,花费在优化器上的时间通常是不可忽略的,在某些情况下甚至会成为瓶颈。

考虑一个非常简单的查询示例,从单个表中选择一些行:

SELECT ID, Name, District, Population
  FROM world.city
 WHERE CountryCode = 'AUS';

在这种情况下,如果表city上没有索引,显然需要进行表扫描。如果有一个索引,也有必要使用该索引评估查询成本,依此类推。如果您有一个复杂的查询,其中包含许多表,每个表都有十几个可能的索引,那么它将产生许多组合,这将反映在查询执行时间中。

Tip

如果花费在优化器上的时间成为一个问题,您可以添加优化器和连接顺序提示,如第 1724 章中所讨论的,以帮助优化器,因此它不需要评估所有可能的查询计划。

虽然这些描述添加索引的开销的页面听起来好像索引不好,但是不要避免索引。对频繁执行的查询有很大选择性的索引将会有很大的好处。但是,不要为了添加索引而添加索引。我们将在本章末尾的“索引策略”一节中讨论选择索引的一些方法,在本书的其余部分也会有讨论索引的例子。在此之前,有必要讨论一下 MySQL 支持的各种索引类型以及其他索引特性。

索引类型

对于所有用途,最佳索引类型并不相同。为查找给定值范围内的行(例如,2019 年的所有日期)而优化的索引需要与在大量文本中搜索给定单词或短语的索引有很大不同。这意味着当您选择添加索引时,您必须决定需要哪种索引类型。MySQL 目前支持五种不同的索引类型:

  • b 树索引

  • 全文索引

  • 空间索引(R 树索引)

  • 多值索引

  • 哈希索引

本节将详细介绍这五种索引类型,并讨论它们可以用来加速哪种类型的问题。

b 树索引

b 树索引是 MySQL 中最常用的索引类型。事实上,所有 InnoDB 表都包含至少一个 B 树索引,因为数据是以 B 树索引(聚集索引)组织的。

B 树索引是一种有序索引,因此它很适合于查找这样的行:您要查找的列等于某个值,列大于或小于某个给定值,或者列介于两个值之间。这使它成为许多查询的一个非常有用的索引。

B 树索引的另一个好特性是它们具有可预测的性能。顾名思义,索引被组织成一棵树,从根页面开始,到叶页面结束。InnoDB 使用 B 树索引的扩展,称为 B+-tree。+表示同一级别的节点是链接的,因此在到达节点中的最后一条记录时,无需返回到父节点就可以轻松地扫描索引。

Note

在 MySQL 中,术语 B 树和 B+-树可以互换使用。

在图 14-1 中可以看到带有城市名称的索引的索引树示例。(对于索引级别,该图是从左到右定向的,这不同于 B 树索引的一些其他图示的从上到下的定向。这样做主要是因为空间的原因。)

img/484666_1_En_14_Fig1_HTML.png

图 14-1

B+树索引的示例

在该图中,文档形状表示 InnoDB 页面,多个文档堆叠在一起的形状(例如,在级别 0 中标有“基督城”的文档)表示几个页面。从左到右的箭头从根页面指向叶页面。根页是索引搜索开始的地方,叶页是索引记录存在的地方。中间的页面通常称为内部页面或分支页面。页面也可以称为节点。连接同一级别页面的双箭头区分了 B 树和 B+-树索引,并允许 InnoDB 快速移动到上一个或下一个兄弟页面,而不必通过父页面。

对于小型索引,可能只有一个页面同时充当根页面和叶页面。在更一般的情况下,索引有一个根页面,如图的最左边部分所示。在图的最右边部分是叶页。对于大型索引,中间可能还有更多级别。叶节点的级别为 0,其父页面的级别为 1,依此类推,直到到达根页面。

在图中,为页面标注的值,例如“A Coruñ”,表示树的该部分包含的第一个值。因此,如果您处于第 1 级,并且正在查找值“Adelaide”,您知道它将位于叶页面的最上面一页,因为该页包含以“A Coruñ”开始的值,并以值排序顺序中早于“Beijing”的最后一个值结束。这是上一章讨论的排序规则发挥作用的一个例子。

一个关键的特性是,无论您遍历哪个分支,级别的数量总是相同的。例如,在图中,这意味着无论您寻找哪个值,都将读取四个页面,四个级别中的每一个都有一个页面(如果几行具有相同的值,并且对于范围扫描,可能会读取叶级别中的更多页面)。因此,据说树是平衡的。正是这个特性提供了可预测的性能,并且级别的数量可以很好地伸缩,也就是说,级别的数量随着索引记录的数量缓慢增长。当需要从相对较慢的存储设备(如磁盘)访问数据时,这一特性尤为重要。

Note

你可能也听说过 T 树索引。虽然 B 树索引针对磁盘访问进行了优化,但 T 树索引类似于 B 树索引,只是它们针对内存访问进行了优化。因此,将所有索引数据存储在内存中的NDBCluster存储引擎使用 T 树索引,即使它们在 SQL 级别被称为 B 树索引。

在本节的开始,我们提到了 B 树索引是 MySQL 中最常用的索引类型。事实上,如果您有任何 InnoDB 表,即使您自己从未添加任何索引,您也在使用 B 树索引。InnoDB 使用聚集索引存储数据索引,这实际上意味着行存储在 B+树索引中。B 树索引也不仅仅用于关系数据库,例如,一些文件系统以 B 树结构组织它们的元数据。

B 树索引的一个重要特性是,它们只能用于比较索引列的整值或左前缀。这意味着,如果您想检查索引日期的月份是否是五月,则不能使用该索引。如果您想检查一个索引字符串是否包含一个给定的短语,这也是一样的。

当您在一个索引中包含多个列时,同样的原则也适用。考虑索引(Name, Birthdate):在这种情况下,您可以使用索引来搜索给定的姓名或者姓名和生日的组合。但是,在不知道姓名的情况下,不能使用索引来搜索具有给定出生日期的人。

有几种方法可以处理这种限制。在某些情况下,可以使用函数索引,或者可以将有关列的信息提取到可以索引的生成列中。在其他情况下,可以使用另一种索引类型。如下所述,例如,可以使用全文索引来搜索字符串中带有短语“query performance tuning”的列。

全文索引

全文索引专门用于回答诸如“哪个文档包含这个字符串?”也就是说,它们没有经过优化来查找列与字符串完全匹配的行——因此,B 树索引是更好的选择。

全文索引通过对被索引的文本进行标记来工作。具体如何实现取决于所使用的解析器。InnoDB 支持使用自定义解析器,但通常使用内置解析器。默认解析器假设文本使用空格作为单词分隔符。MySQL 包括两个可选的解析器:支持中文、日文和韩文的 ngram 解析器 2 和支持日文的 MeCab 解析器。

InnoDB 使用一个名为FTS_DOC_ID的特殊列将全文索引链接到行,该列是一个bigint unsigned NOT NULL列。如果您添加了全文索引,而该列尚不存在,InnoDB 会将其添加为隐藏列。添加隐藏列需要重新构建表,因此如果要向大型表添加全文索引,就需要考虑这一点。如果您知道您打算对一个表使用全文索引,那么您可以预先自己添加该列以及该列的惟一索引FTS_DOC_ID_INDEX。您也可以选择使用FTS_DOC_ID列作为您的主键,但是请注意不允许重用FTS_DOC_ID值。自己准备表格的示例如下:

DROP TABLE IF EXISTS db1.person;

CREATE TABLE db1.person (
  FTS_DOC_ID bigint unsigned NOT NULL auto_increment,
  Name varchar(50),
  Description text,
  PRIMARY KEY (FTS_DOC_ID),
  FULLTEXT INDEX (Description)
);

如果您没有FTS_DOC_ID列,并且您将一个全文列添加到一个现有的表中,MySQL 将返回一个警告,告诉该表已经被重建以添加该列:

Warning (code 124): InnoDB rebuilding table to add column FTS_DOC_ID

如果您计划使用全文索引,从性能角度来看,建议显式添加FTS_DOC_ID列,并将其设置为表上的主键,或者为其创建一个辅助唯一索引。自己创建列的缺点是必须自己管理值。

另一种专门的索引类型是针对空间数据的。全文索引用于文本文档(或字符串),而空间索引用于空间数据类型。

空间索引(R 树)

从历史上看,空间特性在 MySQL 中使用得不多。然而,随着 5.7 版的 InnoDB 对空间索引的支持以及 MySQL 8 中对为空间数据指定空间参考系统标识符(SRID)的支持等其他改进,您可能会在某些时候需要空间索引。

空间索引的一个典型用例是一个包含感兴趣点的表,每个点的位置与其余信息存储在一起。例如,用户可以要求获得其当前位置 50 公里内的所有电动车辆充电站。为了尽可能高效地回答这样的问题,您将需要一个空间索引。

MySQL 将空间索引实现为 R 树。R 代表矩形,暗示了索引的用法。R 树索引组织数据,使得在空间上接近的点彼此靠近地存储在索引中。这使得确定空间值是否满足某些边界条件(例如,矩形)变得有效。

只有当列声明为NOT NULL并且已经设置了空间参考系统标识符时,才能使用空间索引。空间条件是使用一个函数指定的,比如MBRContains(),它接受两个空间值,并返回第一个值是否包含另一个值。否则,对使用空间索引没有特殊要求。清单 14-6 展示了一个带有空间索引的表和一个可以使用该索引的查询的例子。

mysql> CREATE TABLE db1.city (
         id int unsigned NOT NULL,
         Name varchar(50) NOT NULL,
         Location point SRID 4326 NOT NULL,
         PRIMARY KEY (id),
         SPATIAL INDEX (Location));
Query OK, 0 rows affected (0.5578 sec)

mysql> INSERT INTO db1.city
       VALUES (1, 'Sydney',
               ST_GeomFromText('Point(-33.8650 151.2094)',
                               4326));
Query OK, 1 row affected (0.0783 sec)

mysql> SET @boundary = ST_GeomFromText('Polygon((-9 112, -45 112, -45 160, -9 160, -9 112))', 4326);
Query OK, 0 rows affected (0.0004 sec)

mysql> SELECT id, Name
         FROM db1.city
        WHERE MBRContains(@boundary, Location);
+----+--------+
| id | Name   |
+----+--------+
|  1 | Sydney |
+----+--------+
1 row in set (0.0006 sec)

Listing 14-6Using a spatial index

在本例中,一个包含城市位置的表在Location列上有一个空间索引。空间参考系统标识符(SRID)设置为 4326 来表示地球。对于这个示例,插入了一行,并定义了一个边界(如果您很好奇,那么边界包含澳大利亚)。您也可以在MBRContains()函数中直接指定多边形,但是这里分两步完成,以使查询的各个部分更加清晰。

因此,空间索引有助于回答某个几何形状是否在某个边界内。同样,多值索引可以帮助回答给定值是否在值列表中。

多值索引

MySQL 在 MySQL 5.7 中引入了对 JSON 数据类型的支持,并在 MySQL 8 中用 MySQL 文档存储扩展了该特性。您可以使用生成列的索引或函数索引来创建 JSON 文档的索引;然而,到目前为止讨论的索引类型没有涵盖的一个用例是搜索 JSON 数组包含一些值的文档。一个例子是城市的集合,每个城市都有一组郊区。前一章中的 JSON 文档例子就是这样的:

{
    "name": "Sydney",
    "demographics": {
        "population": 5500000
    },
    "geography": {
        "country": "Australia",
        "state": "NSW"
    },
    "suburbs": [
        "The Rocks",
        "Surry Hills",
        "Paramatta"
    ]
}

如果您想搜索城市集合中的所有城市,并返回那些郊区名为“Surry Hills”的城市,那么您需要一个多值索引。MySQL 8.0.17 增加了对多值索引的支持。

解释多值索引如何有用的最简单的方法是看一个例子。清单 14-7world_x示例数据库中获取countryinfo表,将其复制到mvalue_index表中,并对其进行修改,使每个 JSON 文档都包含一个城市数组,其中包含这些城市的人口及其所在的地区。最后,包含一个查询来展示检索澳大利亚所有城市名称的示例(_id = 'AUS')。这些查询也可以在本书的 GitHub 资源库的文件listing_14_7.sql中找到,并且可以在 MySQL Shell 中使用命令\source listing_14_7.sql执行。

mysql> \use world_x
Default schema set to `world_x`.
Fetching table and column names from `world_x` for auto-completion... Press ^C to stop.

mysql> DROP TABLE IF EXISTS mvalue_index;
Query OK, 0 rows affected, 1 warning (0.0509 sec)
Note (code 1051): Unknown table 'world_x.mvalue_index'

mysql> CREATE TABLE mvalue_index LIKE countryinfo;
Query OK, 0 rows affected (0.3419 sec)

mysql> INSERT INTO mvalue_index (doc)
       SELECT doc
         FROM countryinfo;
Query OK, 239 rows affected (0.5781 sec)

Records: 239  Duplicates: 0  Warnings: 0

mysql> UPDATE mvalue_index
          SET doc = JSON_INSERT(
                      doc,
                      '$.cities',
                      (SELECT JSON_ARRAYAGG(
                                JSON_OBJECT(
                                  'district', district,
                                  'name', name,
                                  'population',
                                     Info->'$.Population'
                                )
                              )
                         FROM city
                        WHERE CountryCode = mvalue_index.doc->>'$.Code'
                      )
                    );
Query OK, 239 rows affected (3.6697 sec)

Rows matched: 239  Changed: 239  Warnings: 0

mysql> SELECT JSON_PRETTY(doc->>'$.cities[*].name')

         FROM mvalue_index
        WHERE doc->>'$.Code' = 'AUS'\G
*************************** 1\. row ***************************
JSON_PRETTY(doc->>'$.cities[*].name'): [
  "Sydney",
  "Melbourne",
  "Brisbane",
  "Perth",
  "Adelaide",
  "Canberra",
  "Gold Coast",
  "Newcastle",
  "Central Coast",
  "Wollongong",
  "Hobart",
  "Geelong",
  "Townsville",
  "Cairns"
]
1 row in set (0.0022 sec)

Listing 14-7Preparing the mvalue_index table for multi-valued indexes

清单首先将world_x模式作为默认模式,然后删除mvalue_index表(如果存在的话),并使用与countryinfo表相同的定义和相同的数据再次创建它。您也可以直接修改countryinfo表,但是通过处理mvalue_index副本,您可以通过删除mvalue_index表来轻松地重置world_x模式。该表由一个名为doc的 JSON 文档列和一个名为_id的生成列(主键)组成:

mysql> SHOW CREATE TABLE mvalue_index\G
*************************** 1\. row ***************************
       Table: mvalue_index
Create Table: CREATE TABLE `mvalue_index` (
  `doc` json DEFAULT NULL,
  `_id` varbinary(32) GENERATED ALWAYS AS (json_unquote(json_extract(`doc`,_utf8mb4'$._id'))) STORED NOT NULL,
  `_json_schema` json GENERATED ALWAYS AS (_utf8mb4'{"type":"object"}') VIRTUAL,
  PRIMARY KEY (`_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0006 sec)

UPDATE语句使用JSON_ARRAYAGG()函数为每个国家创建一个 JSON 数组,该数组包含三个 JSON 对象,即地区、名称和人口。最后,执行一个SELECT语句来返回澳大利亚城市的名称。

现在,您可以为城市名称添加多值索引:

ALTER TABLE mvalue_index
  ADD INDEX (((CAST(doc->>'$.cities[*].name'
                    AS char(35) ARRAY))));

索引从位于doc文档根目录下的cities数组的所有元素中提取name对象。产生的数据被转换成一个由char(35)值组成的数组。数据类型被选择为城市名来源于char(35)city表。在CAST()函数中,您将char用于charvarchar数据类型。

通过使用MEMBER OF操作符以及JSON_CONTAINS()JSON_OVERLAPS()函数,新索引可以用于WHERE子句。MEMBER OF操作符询问给定的值是否是数组的成员。JSON_CONTAINS()非常相似,但与MEMBER OF的参考搜索相比,需要范围搜索。JSON_OVERLAPS()可用于查找至少包含几个值之一的文档。清单 14-8 显示了一个使用操作符和每个函数的例子。

mysql> SELECT doc->>'$.Code' AS Code, doc->>'$.Name'
         FROM mvalue_index
        WHERE 'Sydney' MEMBER OF (doc->'$.cities[*].name');
+------+----------------+
| Code | doc->>'$.Name' |
+------+----------------+
| AUS  | Australia      |
+------+----------------+
1 row in set (0.0032 sec)

mysql> SELECT doc->>'$.Code' AS Code, doc->>'$.Name'
         FROM mvalue_index
        WHERE JSON_CONTAINS(
                doc->'$.cities[*].name',
                '"Sydney"'
              );
+------+----------------+
| Code | doc->>'$.Name' |
+------+----------------+
| AUS  | Australia      |
+------+----------------+
1 row in set (0.0033 sec)

mysql> SELECT doc->>'$.Code' AS Code, doc->>'$.Name'
         FROM mvalue_index
        WHERE JSON_OVERLAPS(
                doc->'$.cities[*].name',
                '["Sydney", "New York"]'
              );
+------+----------------+
| Code | doc->>'$.Name' |
+------+----------------+
| AUS  | Australia      |
| USA  | United States  |
+------+----------------+
2 rows in set (0.0060 sec)

Listing 14-8Queries taking advantage of a multi-valued index

使用MEMBER OFJSON_CONTAINS()的两个查询都寻找城市名为悉尼的国家。使用JSON_OVERLAPS()的最后一个查询查找城市名为悉尼或纽约或两者皆名的国家。

MySQL 中还剩下一种索引类型:散列索引。

哈希索引

如果您想搜索某列正好等于某个值的行,您可以使用本章前面讨论的 B 树索引。不过还有一个替代方法:为每个列值创建一个散列,并使用该散列来搜索匹配的行。你为什么要这么做?答案是,这是一种非常快速的查找行的方法。

散列索引在 MySQL 中用得不多。一个值得注意的例外是NDBCluster存储引擎,它使用散列索引来确保主键和惟一索引的惟一性,并使用这些索引来提供快速查找。在 InnoDB 方面,没有对哈希索引的直接支持;但是,InnoDB 有一个名为自适应散列索引的特性,值得多考虑一下。

自适应散列索引特性在 InnoDB 中自动工作。如果 InnoDB 检测到您正在频繁使用二级索引,并且启用了自适应散列索引,它将动态构建最常用值的散列索引。哈希索引以独占方式存储在缓冲池中,因此当您重新启动 MySQL 时,它不会被持久化。如果 InnoDB 检测到内存可以更好地用于将更多页面加载到缓冲池中,它将丢弃部分散列索引。这就是所谓的自适应索引的含义:InnoDB 将努力使它适应您的查询。您可以使用innodb_adaptive_hash_index选项启用或禁用该功能。

理论上,自适应哈希索引是一个双赢的局面。您获得了拥有散列索引的优势,而无需考虑需要为哪些列添加它,并且内存使用都是自动处理的。但是,启用它会产生开销,而且并非所有工作负载都能从中受益。事实上,对于某些工作负载,开销会变得非常大,以至于出现严重的性能问题。

有两种方法可以监控自适应散列索引:信息模式中的INNODB_METRICS表和 InnoDB 监控器。INNODB_METRICS表包括自适应散列索引的八个指标,其中两个默认启用。清单 14-9 显示了INNODB_METRICS中包含的八个指标。

mysql> SELECT NAME, COUNT, STATUS, COMMENT
         FROM information_schema.INNODB_METRICS
        WHERE SUBSYSTEM = 'adaptive_hash_index'\G
*************************** 1\. row ***************************
   NAME: adaptive_hash_searches
  COUNT: 10717
 STATUS: enabled
COMMENT: Number of successful searches using Adaptive Hash Index
*************************** 2\. row ***************************
   NAME: adaptive_hash_searches_btree
  COUNT: 29515
 STATUS: enabled
COMMENT: Number of searches using B-tree on an index search
*************************** 3\. row ***************************
   NAME: adaptive_hash_pages_added
  COUNT: 0
 STATUS: disabled
COMMENT: Number of index pages on which the Adaptive Hash Index is built
*************************** 4\. row ***************************
   NAME: adaptive_hash_pages_removed
  COUNT: 0
 STATUS: disabled
COMMENT: Number of index pages whose corresponding Adaptive Hash Index entries were removed

*************************** 5\. row ***************************
   NAME: adaptive_hash_rows_added
  COUNT: 0
 STATUS: disabled
COMMENT: Number of Adaptive Hash Index rows added
*************************** 6\. row ***************************
   NAME: adaptive_hash_rows_removed
  COUNT: 0
 STATUS: disabled
COMMENT: Number of Adaptive Hash Index rows removed
*************************** 7\. row ***************************
   NAME: adaptive_hash_rows_deleted_no_hash_entry
  COUNT: 0
 STATUS: disabled
COMMENT: Number of rows deleted that did not have corresponding Adaptive Hash Index entries
*************************** 8\. row ***************************
   NAME: adaptive_hash_rows_updated
  COUNT: 0
 STATUS: disabled
COMMENT: Number of Adaptive Hash Index rows updated
8 rows in set (0.0015 sec)

Listing 14-9The metrics for the adaptive hash index in INNODB_METRICS

默认情况下,使用自适应散列索引(adaptive_hash_searches)的成功搜索次数和使用 B 树索引(adaptive_hash_searches_btree)完成的搜索次数是启用的。您可以使用这些来确定与底层 B 树索引相比,InnoDB 使用散列索引解析查询的频率。其他指标不太需要,因此默认情况下是禁用的。也就是说,如果您想更详细地探索自适应散列索引的用处,您可以安全地启用这六个指标。

监控自适应散列索引的另一种方法是使用 InnoDB 监控器,如清单 14-10 所示。在您的情况下,输出中的数据会有所不同。

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2019-05-05 17:22:14 0x1a7c INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 16 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 52 srv_active, 0 srv_shutdown, 25121 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 8
OS WAIT ARRAY INFO: signal count 11
RW-shared spins 12, rounds 12, OS waits 0
RW-excl spins 102, rounds 574, OS waits 8
RW-sx spins 0, rounds 0, OS waits 0
Spin rounds per wait: 1.00 RW-shared, 5.63 RW-excl, 0.00 RW-sx
...
-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 0, seg size 2, 0 merges
merged operations:
 insert 0, delete mark 0, delete 0
discarded operations:
 insert 0, delete mark 0, delete 0
Hash table size 2267, node heap has 2 buffer(s)
Hash table size 2267, node heap has 1 buffer(s)
Hash table size 2267, node heap has 2 buffer(s)
Hash table size 2267, node heap has 1 buffer(s)
Hash table size 2267, node heap has 1 buffer(s)
Hash table size 2267, node heap has 1 buffer(s)
Hash table size 2267, node heap has 2 buffer(s)
Hash table size 2267, node heap has 3 buffer(s)
0.00 hash searches/s, 0.00 non-hash searches/s

...

Listing 14-10Using the InnoDB monitor to monitor the adaptive hash index

首先要检查的是信号量部分。如果自适应散列索引是争用的主要来源,那么在btr0sea.ic文件(自适应散列索引在源代码中实现的地方)周围会有信号量。如果您偶尔——但很少——看到信号量,这不一定是个问题,但是如果您看到频繁且长的信号量,您可能最好禁用自适应散列索引。

感兴趣的另一部分是插入缓冲区和自适应散列索引部分。这包括用于哈希索引的内存量,以及使用哈希和非哈希搜索回答查询的速率。请注意,这些速率是针对监控器输出顶部附近列出的时间段的,在本例中,是针对 2019-05-05 17:22:14 之前的最后 16 秒。

对支持的索引类型的讨论到此结束。索引还有更多的内容,因为有几个特性值得您熟悉。

索引功能

知道存在哪些类型的索引是一回事,但能够充分利用它们是另一回事。要做到这一点,您需要更多地了解 MySQL 中与索引相关的特性。这些范围从以逆序排序索引中的值到函数索引和自动生成的索引。本节将介绍这些功能,以便您可以在日常工作中使用它们。

功能索引

到目前为止,索引已经直接应用于列。这是添加索引的最常见方式,但也有需要使用派生值的情况。例如,一个查询要求所有生日在五月的人:

DROP TABLE IF EXISTS db1.person;

CREATE TABLE db1.person (
  Id int unsigned NOT NULL,
  Name varchar(50),
  Birthdate date NOT NULL,
  PRIMARY KEY (Id)
);

SELECT *
  FROM db1.person
 WHERE MONTH(Birthdate) = 5;

如果您在Birthdate列上添加了一个索引,这不能用于回答该查询,因为日期是根据它们的完整值存储的,并且您没有与该列的最左边部分进行匹配。(另一方面,搜索所有 1970 年出生的人可以在Birthdate列上使用 B 树索引。)

一种方法是生成一个包含派生值的列。在 MySQL 5.7 和更高版本中,您可以告诉 MySQL 自动更新列,例如:

CREATE TABLE db1.person (
   Id int unsigned NOT NULL,
   Name varchar(50) NOT NULL,
   Birthdate date NOT NULL,
   BirthMonth tinyint unsigned
              GENERATED ALWAYS AS (MONTH(Birthdate))
              VIRTUAL NOT NULL,
   PRIMARY KEY (Id),
   INDEX (BirthMonth)
);

在 MySQL 8.0.13 中,有一种更直接的方式来实现这一点。您可以直接索引函数的结果:

CREATE TABLE db1.person (
   Id int unsigned NOT NULL,
   Name varchar(50) NOT NULL,
   Birthdate date NOT NULL,
   PRIMARY KEY (Id),
   INDEX ((MONTH(Birthdate)))
);

使用函数索引的优点是,它更明确地说明了您想要索引什么,并且您没有额外的BirthMonth列。否则,添加函数索引的两种方式工作方式相同。

前缀索引

表的索引部分变得比表数据本身大是很常见的。如果索引大型字符串值,情况尤其如此。B 树索引的索引数据的最大长度也有限制——使用DYNAMICCOMPRESSED行格式的 InnoDB 表的最大长度为 3072 字节,其他表的最大长度更小。这实际上意味着您不能索引一个text列,更不用说一个longtext列了。减轻大型字符串索引的一种方法是只索引值的第一部分。这被称为前缀索引。

通过指定要索引的字符串的字符数或二进制对象的字节数,可以创建前缀索引。如果您想索引city表中Name列的前十个字符(来自world数据库),您可以这样做

ALTER TABLE world.city ADD INDEX (Name(10));

请注意要索引的字符数是如何添加到括号中的。只要您选择足够多的字符来提供良好的选择性,这个索引将几乎与索引整个名称一样好,并且从好的方面来说,它使用更少的存储和内存。需要包含多少个字符?这完全取决于您要索引的数据。您可以查询数据来了解前缀的独特性。清单 14-11 展示了一个检查有多少城市名共享前十个字符的例子。

mysql> SELECT LEFT(Name, 10), COUNT(*),
              COUNT(DISTINCT Name) AS 'Distinct'
         FROM world.city
        GROUP BY LEFT(Name, 10)
        ORDER BY COUNT(*) DESC, LEFT(Name, 10)
        LIMIT 10;
+----------------+----------+----------+
| LEFT(Name, 10) | COUNT(*) | Distinct |
+----------------+----------+----------+
| San Pedro      |        6 |        6 |
| San Fernan     |        5 |        3 |
| San Miguel     |        5 |        3 |
| Santiago d     |        5 |        5 |
| San Felipe     |        4 |        3 |
| San José       |        4 |        1 |
| Santa Cruz     |        4 |        4 |
| São José d     |        4 |        4 |
| Cambridge      |        3 |        1 |
| Ciudad de      |        3 |        3 |
+----------------+----------+----------+
10 rows in set (0.0049 sec)

Listing 14-11The frequency of city names based on the first ten characters

这表明,使用这个索引前缀,您将最多读取六个城市来找到匹配。虽然这不仅仅是一个完整的匹配,但仍然比扫描整个表要好得多。在这种比较中,您当然还需要验证前缀匹配的数量是由于前缀冲突,还是城市名称相同。例如,对于“Cambridge”,有三个城市使用了该名称,因此无论是索引前十个字符还是整个名称都没有区别。您可以对不同的前缀长度进行这种分析,以了解增加索引大小会带来微小回报的阈值。在许多情况下,您不需要那么多的字符来使索引正常工作。

如果你认为你可以删除一个索引,或者你想推出一个索引,但不能让它立即生效,你该怎么办?答案是隐形索引。

不可见索引

MySQL 8 引入了一个叫做不可见索引的新特性。它允许您拥有一个维护好并随时可以使用的索引,但是优化器会忽略这个索引,直到您决定让它可见。这允许您在复制拓扑中推出新索引,或者禁用您认为不需要或类似的索引。您可以快速启用或禁用索引,因为它只需要更新表的元数据,所以更改是“即时的”

例如,如果您认为不需要索引,那么在告诉 MySQL 删除索引之前,首先使它不可见可以让您监控数据库在没有索引的情况下如何工作。如果发现某些查询——例如,在您监控的时间段内没有执行的月度报告查询——确实需要索引,您可以快速重新启用它。

使用关键字INVISIBLE将索引标记为不可见,使用关键字VISIBLE使不可见的索引再次可见。例如,要在world.city表的Name列上创建一个不可见的索引,并在以后使其可见,您可以使用

mysql> ALTER TABLE world.city ADD INDEX (Name) INVISIBLE;
Query OK, 0 rows affected (0.0649 sec)

Records: 0  Duplicates: 0  Warnings: 0

mysql> ALTER TABLE world.city ALTER INDEX Name VISIBLE;
Query OK, 0 rows affected (0.0131 sec)

Records: 0  Duplicates: 0  Warnings: 0

如果禁用索引,并且查询使用了引用隐藏索引的索引提示,则查询将返回错误:

ERROR: 1176: Key 'Name' doesn't exist in table 'city'

您可以通过启用优化器开关use_invisible_indexes(默认为off)来覆盖索引的不可见性。如果您遇到由于某个索引不可见而无法立即重新启用该索引的问题,或者如果您希望在新索引全面可用之前对其进行测试,这将非常有用。为连接临时启用不可见索引的一个示例是

SET SESSION optimizer_switch = 'use_invisible_indexes=on';

即使启用了use_invisible_indexes优化器开关,也不允许在索引提示中引用索引。

MySQL 8 的另一个新特性是降序索引。

降序索引

在 MySQL 5.7 和更早的版本中,当你添加一个 B 树索引时,它总是以升序排序。这对于查找精确匹配、按索引升序检索行等非常有用。然而,虽然升序索引可以加快按降序查找行的查询速度,但它们并不那么有效。MySQL 8 增加了降序索引来帮助处理这些用例。

您不需要做什么特别的事情来利用降序索引。所需要的只是将关键字DESC用于索引,例如:

ALTER TABLE world.city ADD INDEX (Name DESC);

如果索引中有多列,则不需要以升序或降序包含所有列。您可以混合使用升序和降序列,这样最适合您的查询。

分区和索引

如果创建分区表,分区列必须是主键和所有唯一键的一部分。原因是 MySQL 没有全局索引的概念,所以必须保证唯一性检查只需要考虑单个分区。

关于性能调优,分区可用于有效地使用两个索引来解析查询,而无需使用索引合并。当用于分区的列在查询的条件中使用时,MySQL 将修剪分区,因此只搜索与条件匹配的分区。然后可以使用索引来解析查询的其余部分。

考虑一个表t_part,它根据作为时间戳的Created列进行分区,每个月有一个分区。如果查询 2019 年 3 月val列的值小于 2 的所有行,那么查询将首先根据Created的值修剪分区,然后使用val上的索引。清单 14-12 显示了一个这样的例子。

mysql> CREATE TABLE db1.t_part (
  id int unsigned NOT NULL AUTO_INCREMENT,
  Created timestamp NOT NULL,
  val int unsigned NOT NULL,
  PRIMARY KEY (id, Created),
  INDEX (val)
) ENGINE=InnoDB
  PARTITION BY RANGE (unix_timestamp(Created))
(PARTITION p201901 VALUES LESS THAN (1548939600),
 PARTITION p201902 VALUES LESS THAN (1551358800),
 PARTITION p201903 VALUES LESS THAN (1554037200),
 PARTITION p201904 VALUES LESS THAN (1556632800),
 PARTITION p201905 VALUES LESS THAN (1559311200),
 PARTITION p201906 VALUES LESS THAN (1561903200),
 PARTITION p201907 VALUES LESS THAN (1564581600),
 PARTITION p201908 VALUES LESS THAN (1567260000),
 PARTITION pmax VALUES LESS THAN MAXVALUE);
1 row in set (5.4625 sec)

-- Insert random data
-- 1546261200 is 2019-01-01 00:00:00 UTC
-- The common table expression (CTE) is just
-- a convenient way to quickly generate 1000 rows.
mysql> INSERT INTO db1.t_part (Created, val)
       WITH RECURSIVE counter (i) AS (
         SELECT 1
          UNION SELECT i+1
           FROM counter
          WHERE i < 1000)
       SELECT FROM_UNIXTIME(
                 FLOOR(RAND()*(1567260000-1546261200))
                 +1546261200
              ), FLOOR(RAND()*10) FROM counter;
Query OK, 1000 rows affected (0.0238 sec)

Records: 1000  Duplicates: 0  Warnings: 0

mysql> EXPLAIN
        SELECT id, Created, val
          FROM db1.t_part
         WHERE Created BETWEEN '2019-03-01 00:00:00'
                           AND '2019-03-31 23:59:59'
               AND val < 2\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: t_part
   partitions: p201903
         type: range
possible_keys: val
          key: val
      key_len: 4
          ref: NULL
         rows: 22
     filtered: 11.110000610351562
        Extra: Using where; Using index
1 row in set, 1 warning (0.0005 sec)

Listing 14-12Combining partition pruning and filtering using an index

使用Created列的 Unix 时间戳,按范围对t_part表进行分区。EXPLAIN输出(EXPLAIN将在第 20 章中详细介绍)显示只有p201903分区将被包含在查询中,并且val索引将被用作索引。鉴于示例使用随机数据,EXPLAIN的确切输出可能会有所不同。

到目前为止,所有关于索引的讨论都是针对显式创建的索引。对于某些查询,MySQL 也将能够自动生成索引。这是要讨论的最后一个索引特性。

自动生成的索引

对于包含与其他表或子查询联接的子查询的查询,联接的开销可能很大,因为子查询不能包含显式索引。为了避免对子查询生成的临时表进行全表扫描,MySQL 可以在连接条件中添加一个自动生成的索引。

例如,考虑来自sakila样本数据库的film表。它有一个名为release_year的栏目,标明了电影上映的年份。如果要查询有数据的年份中每年发行了多少部电影,可以使用下面的查询(是的,如果没有子查询,该查询可以写得更好,但这样写是为了演示自动生成索引的功能):

SELECT release_year, COUNT(*)
  FROM sakila.film
       INNER JOIN (SELECT DISTINCT release_year
                     FROM sakila.film
                  ) release_years USING (release_year)
 GROUP BY release_year;

MySQL 选择对film表进行全表扫描,并在子查询上添加一个自动生成的索引。当 MySQL 添加一个自动生成的索引时,EXPLAIN输出将包含<auto_key0>(或者用不同的值替换为 0)作为可能的键和使用的键。

自动生成的索引可以显著提高包含优化器无法重写为普通连接的子查询的查询的性能。最棒的是,它是自动发生的。

索引特性的讨论到此结束。在讨论应该如何使用索引之前,也有必要了解 InnoDB 如何使用索引。

InnoDB 和索引

自 20 世纪 90 年代中期的第一个版本以来,InnoDB 组织其表的方式一直是使用聚集索引来组织数据。这一事实导致了一种常见的说法,即 InnoDB 中的一切都是索引。数据的组织实际上就是一个索引。默认情况下,InnoDB 使用聚集索引的主键。如果没有主键,它将寻找不允许NULL值的唯一索引。作为最后的手段,将使用一种自动递增计数器向表中添加一个隐藏列。

对于索引组织的表,InnoDB 中的所有内容都是索引。聚集索引本身被组织为 B+-树索引,实际的行数据在叶页中。这对查询性能和索引有一些影响。下一节将介绍 InnoDB 如何使用主键以及主键对辅键的意义,提供一些建议,并介绍索引组织表的最佳用例。

聚集索引

因为数据是根据聚集索引(主键或其替代项)组织的,所以主键的选择非常重要。如果在现有值之间插入一个主键值的新行,InnoDB 将不得不重新组织数据,以便为新行腾出空间。在最坏的情况下,InnoDB 将不得不将现有页面一分为二,因为页面大小是固定的。页面拆分会导致底层存储上的叶页面顺序混乱,从而导致更多的随机 I/O,进而导致更差的查询性能。页面分割将在第 25 章中作为 DDL 和批量数据加载的一部分进行讨论。

次要索引

辅助索引的叶页存储对行本身的引用。因为根据聚集索引,行存储在 B+-树索引中,所以所有辅助索引都必须包括聚集索引的值。如果您选择了一个值需要很多字节的列,例如,一个具有很长且可能是多字节字符串的列,这会大大增加二级索引的大小。

这还意味着,当您使用辅助索引执行查找时,实际上会进行两次索引查找:首先是预期的辅助关键字查找,然后从叶页面获取主键值并用于主关键字查找以获取实际数据。

对于非唯一二级索引,如果您有一个显式主键或一个NOT NULL唯一索引,则添加到索引中的是用于主键的列。MySQL 知道这些额外的列,即使它们没有被显式地作为索引的一部分,如果它将改进查询计划,MySQL 将使用它们。

推荐

由于 InnoDB 使用主键的方式以及将主键添加到二级索引的方式,最好使用使用尽可能少字节的单调递增主键。自动递增的整数满足这些属性,因此是一个很好的主键。

如果表没有任何合适的索引,则用于聚集索引的隐藏列使用类似自动递增的计数器来生成新值。但是,由于这个计数器对于 MySQL 实例中的所有 InnoDB 表都是全局的,并带有一个隐藏的主键,因此它可能会成为一个争论点。隐藏键也不能在复制中用来定位受事件影响的行,组复制需要主键或NOT NULL唯一索引来进行冲突检测。因此,建议总是为所有表显式选择一个主键。

另一方面,UUID 不是单调递增的,不是一个好的选择。MySQL 8 中的一个选项是使用第二个参数设置为 1 的UUID_TO_BIN()函数,这将使 MySQL 交换第一组和第三组十六进制数字。第三组是 UUID 时间戳部分的高位字段,因此将其放在 UUID 的开头有助于确保 ID 不断增加,并将它们存储为二进制数据所需的存储量不到十六进制值的一半。

最佳使用案例

按索引组织的表对于使用该索引的查询特别有用。顾名思义,“聚集索引”将具有相似聚集索引值的行存储在彼此附近。由于 InnoDB 总是将整个页面读入内存,这也意味着主键值相似的两行可能会被一起读入。如果您的查询中需要这两个行,或者在查询中彼此紧接着执行,那么缓冲池中已经有了第二行。

现在,您应该对 MySQL 中的索引以及 InnoDB 如何使用索引(包括其数据组织)有了很好的背景知识。现在是时候将所有这些放在一起讨论索引策略了。

索引策略

对于索引来说,最大的问题是索引什么,其次是使用什么样的索引和索引特性。不可能创建最终的逐步说明来确保最佳索引;为此,需要经验和对模式、数据和查询的良好理解。然而,可以给出一些通用指南,这将在本节中讨论。

首先要考虑的是何时应该添加索引;是应该在最初创建表时进行还是以后进行。然后是主键的选择以及如何选择的注意事项。最后,还有二级索引,包括要向索引添加多少列,以及该索引是否可以用作覆盖索引。

何时应该添加或删除索引?

索引维护是一项永无止境的任务。它从您第一次创建表时开始,并在表的整个生命周期中持续。不要轻视索引工作——如前所述,好的和差的索引之间的差别可以是几个数量级。你不能通过投入更多的硬件资源来摆脱索引差的情况。索引不仅影响原始查询性能,还会影响锁定(将在第 18 章中进一步讨论)、内存使用和 CPU 使用。

当您创建表时,您应该特别花时间选择一个好的主键。在表的生命周期中,主键通常不会改变,如果您决定改变主键,对于按索引组织的表,它必然需要完全重建表。二级索引可以随着时间的推移进行更大程度的调整。事实上,如果您计划为表的初始填充导入大量数据,最好等到数据加载完毕后再添加辅助索引。一个可能的例外是唯一索引,因为它们是数据验证所必需的。

一旦创建了表并填充了初始数据,就需要监控表的使用情况。在sys模式中有两个视图可用于通过全表扫描来查找表和语句:

  • schema_tables_with_full_table_scans : 该视图显示所有不使用索引读取行的表,并根据索引号按降序排列。如果一个表在没有使用索引的情况下读取了大量的行,您可以使用这个表查找查询,看看索引是否有帮助。该视图基于table_io_waits_summary_by_index_usage性能模式表,该表也可以直接使用,例如,如果您想要进行更高级的分析,比如在不使用索引的情况下查找读取的行的百分比。

  • statements_with_full_table_scans : 该视图显示了根本不使用索引或者没有使用好的索引的语句的规范化版本。这些语句按照它们在完全没有使用索引的情况下被执行的次数排序,然后按照它们没有使用有效索引的次数排序——两者都是降序。该视图基于events_statements_summary_by_digest性能模式表。

1920 章将更详细地介绍这些视图和底层性能模式表的使用。

当您发现查询可以从额外的索引中受益时,您需要评估在执行查询时获得额外好处的成本是否值得。

同时,您还需要留意是否有不再使用的索引。性能模式和sys模式对于查找未使用或不常使用的索引特别有用。三个有用的模式视图是

  • schema_index_statistics : 该视图统计了使用给定索引读取、插入、更新和删除行的频率。像schema_tables_with_full_table_scan视图一样,schema_index_statistics基于table_io_waits_summary_by_index_usage性能模式表。

  • schema_unused_indexes : 该视图将返回自数据上次重置以来(不超过自上次重启以来)尚未使用的索引的名称。这个视图也基于table_io_waits_summary_by_index_usage性能模式表。

  • schema_redundant_indexes : 如果有两个索引覆盖相同的列,那么 InnoDB 需要加倍努力来保持索引最新,并且增加了优化器的负担,但是没有任何收获。顾名思义,schema_redundant_indexes视图可以用来查找冗余索引。该视图基于STATISTICS信息模式表。

当您使用前两个视图时,您必须记住数据来自性能模式中的内存表。如果您有一些只是偶尔执行的查询,那么统计数据可能无法反映您的总体索引需求。这就是不可见索引特性可以派上用场的情况之一,因为它允许您禁用索引,同时保留索引,直到您确定删除它是安全的。如果发现一些很少执行的查询需要索引,您可以很容易地再次启用索引。

如前所述,首先要考虑的是选择什么作为主键。您应该包括哪些列?那是接下来要讨论的事情。

主键的选择

当您使用按索引组织的表时,主索引的选择非常重要。主键会影响随机 I/O 和顺序 I/O 之间的比率、辅助索引的大小以及需要读入缓冲池的页面数量。InnoDB 表的主键总是 B+-树索引。

与聚集索引相关的最佳主键尽可能小(以字节为单位),保持单调递增,并对您频繁查询的行进行分组,并且彼此之间的时间间隔很短。实际上,要做到这一切是不可能的,在这种情况下,你需要做出最好的妥协。对于许多工作负载,自动递增的无符号整数intbigint是一个不错的选择,这取决于表中预期的行数;但是,可能有一些特殊的考虑,比如跨多个 MySQL 实例的唯一性要求。主键最重要的特性是它应该尽可能的有序,并且是不可变的。如果更改某一行的主键的值,则需要将整行移动到聚集索引中的新位置。

Tip

自动递增的无符号整数通常是作为主键的好选择。它保持单调递增,不需要太多存储空间,并且在聚集索引中将最近的行组合在一起。

您可能认为对于聚集索引来说,隐藏主键可能是与任何其他列一样好的选择。毕竟它是一个自动递增的整数。然而,hidden key 有两个主要缺点:它只标识本地 MySQL 实例的行,并且计数器对于所有 InnoDB 表(在实例中)是全局的,没有用户定义的主键。隐藏键仅在本地有用,这意味着在复制中,隐藏值不能用于标识在副本上更新哪一行。计数器是全局的意味着它可能成为争用点,并在插入数据时导致性能下降。

底线是你应该总是明确地定义你想要什么作为你的主键。对于二级索引,有更多的选择,这将在下面看到。

添加二级索引

辅助索引是所有那些不是主键的索引。它们可以是唯一的,也可以是不唯一的,您可以在所有受支持的索引类型和功能之间进行选择。如何选择添加哪些索引?这一部分将使你更容易做出决定。

注意不要预先给一个表添加太多的索引。索引有开销,所以当您添加最终没有被使用的索引时,查询和整个系统的性能会更差。这并不意味着在创建表时不应该添加任何辅助索引。只是你需要花点心思。

执行查询时,可以通过多种方式使用辅助索引。其中一些如下:

  • 减少检查的行:当您有一个WHERE子句或连接条件来查找所需的行而不扫描整个表时,可以使用这个选项。

  • 排序数据: B 树索引可用于按照查询所需的顺序读取行,从而允许 MySQL 绕过排序步骤。

  • 验证数据:这就是唯一索引中的唯一性的用途。

  • 避免读取行:覆盖索引可以返回所有需要的数据,而不需要读取整行。

  • 查找 MIN() MAX() 值:对于GROUP BY查询,只需检查索引中的第一条和最后一条记录,就可以找到索引列的最小值和最大值。

主键显然也可以用于所有这些目的。从查询的角度来看,主键和辅键没有区别。

当您需要决定是否添加一个索引时,您需要问自己哪个目的需要该索引,以及它是否能够实现这些目的。一旦确认了这一点,就可以查看应该为多列索引添加哪些顺序列,以及是否应该添加额外的列。接下来的两个小节将对此进行更详细的讨论。

多列索引

只要不超过索引的最大宽度,最多可以向索引中添加 16 列或功能部分。这适用于主键和辅助索引。InnoDB 的每个索引限制为 3072 字节。如果包含使用可变宽度字符集的字符串,则它是计入索引宽度的最大可能宽度。

向索引中添加多个列的一个优点是,它允许您将索引用于多个条件。这是提高查询性能的一种非常有效的方法。例如,考虑一个查询,该查询在给定的国家中寻找具有该城市人口的最低要求的城市:

SELECT ID, Name, District, Population
  FROM world.city
 WHERE CountryCode = 'AUS'
       AND Population > 1000000;

您可以使用CountryCode列上的索引来查找国家代码设置为 AUS 的城市,并且可以使用Population列上的索引来查找人口超过 100 万的城市。更好的是,您可以将它合并到一个包含两列的索引中。

你如何做到这一点很重要。国家代码使用相等的参考,而人口是一个范围搜索。一旦索引中的列用于范围搜索或排序,则除了作为覆盖索引的一部分之外,索引中就不能再使用其他列。对于本例,您需要在Population列之前添加CountryCode列,以便对两个条件使用索引:

ALTER TABLE world.city
  ADD INDEX (CountryCode, Population);

在本例中,索引甚至可以用于对使用总体的结果进行排序。

如果您需要添加几个全部用于等式条件的列,那么有两件事情需要考虑:哪些列是最常使用的,以及该列对数据的过滤效果如何。当一个索引中有多个列时,MySQL 将只使用索引的左前缀。例如,如果你有一个索引(col_a, col_b, col_c),你只能使用索引过滤col_b,如果你也过滤col_a(这必须是一个等式条件)。所以你需要谨慎选择顺序。在某些情况下,可能需要为相同的列添加多个索引,而这些索引之间的列顺序是不同的。

如果您不能根据使用情况决定包含列的顺序,那么首先添加最具选择性的列。下一章将讨论索引的选择性,但简而言之,一个列的值越多,它的选择性就越强。通过首先添加最具选择性的列,可以更快地缩小索引部分包含的行数。

您可能还希望包含不用于筛选的列。你为什么要这么做?答案是,它可以帮助形成一个覆盖索引。

覆盖索引

覆盖索引是表上的索引,其中给定查询的索引包括该表中所需的所有列。这意味着当 InnoDB 到达索引的叶页时,它已经拥有了所需的所有信息,并且不需要读取整行。根据您的表,这可能会很好地提高查询性能,特别是如果您可以使用它来排除大部分行,如大文本或 blob 列。

还可以使用覆盖索引来模拟辅助聚集索引。请记住,聚集索引只是一个 B+-树索引,整行都包含在叶页中。覆盖索引拥有叶页中行的完整子集,因此模拟了该列子集的聚集索引。与聚集索引一样,任何 B 树索引都将相似的值组合在一起,因此它可以用来减少读入缓冲池的页面数量,并且在执行索引扫描时有助于进行顺序 I/O。

但是,与聚集索引相比,覆盖索引有一些限制。覆盖索引仅模拟用于读取的聚集索引。如果需要写入数据,更改总是必须访问聚集索引。另一件事是,由于 InnoDB 的多版本并发控制(MVCC),即使当您使用覆盖索引时,也需要检查聚集索引以验证是否存在另一个版本的行。

添加索引时,值得考虑索引所针对的查询需要哪些列。添加 select 部分中使用的任何额外列可能是值得的,即使索引不会用于对这些列进行筛选或排序。您需要平衡覆盖索引的好处和索引增加的大小。因此,如果您只是错过了一两个小列,这种策略非常有用。覆盖索引受益的查询越多,您可以接受添加到索引中的额外数据就越多。

摘要

本章是一次索引世界之旅。一个好的索引策略可能意味着一个数据库停止运转和一台运转良好的机器之间的差别。索引有助于减少查询中检查的行数,另外包含索引可以避免读取整行。另一方面,索引在存储和日常维护方面都有开销。因此,有必要平衡对索引的需求和拥有索引的成本。

MySQL 支持几种不同的索引类型。最重要的是 B 树索引,InnoDB 也用它来组织索引表中的行,该表使用聚集索引。其他索引类型包括全文索引、空间(R 树)索引、多值索引和散列索引。后一种类型在 InnoDB 中比较特殊,因为只有使用自适应散列索引特性才支持它,该特性决定自动添加哪些散列索引。

已经讨论了一系列索引功能。函数索引可用于索引在表达式中使用列的结果。前缀索引可用于减少文本和二进制数据类型的索引大小。不可见索引可以在新索引的展示过程中使用,也可以在软删除现有索引时使用。降序索引提高了按降序遍历索引值的效率。索引也在分区方面发挥作用,您可以使用分区来有效地实现对查询中的单个表使用两个索引的支持。最后,MySQL 能够自动生成与子查询相关的索引。

本章的最后一部分从 InnoDB 的细节和使用按索引组织的表的注意事项开始。这对于与主键相关的查询来说是最佳的,但是对于以随机主键顺序插入的数据和通过辅助索引查询的数据来说,效果不太好。

最后一节讨论了索引策略。第一次创建表时,请仔细选择主键。基于对度量的观察,可以在更大程度上随着时间的推移添加和删除辅助索引。您可以使用多列索引来筛选多列和/或进行排序。最后,覆盖索引可用于模拟辅助聚集索引。

关于什么是索引以及何时使用索引的讨论到此结束。在下一章讨论索引统计时,我们会看到更多关于索引的内容。

十五、索引统计

在前一章中,你学习了索引。前面提到过,优化器评估每个索引来决定是否使用该索引。它是怎么做到的?这也是本章的主题,包括索引统计、如何查看有关索引统计的信息以及如何维护统计。

本章首先讨论什么是索引统计以及 InnoDB 如何处理索引统计。然后,您将了解瞬时和持久统计。本章的其余部分将介绍如何监控统计数据并更新它们。

什么是索引统计?

当 MySQL 决定是否使用索引时,它归结为 MySQL 认为索引对查询有多有效。请记住,当您使用辅助索引时,将有效地通过额外的主键查找来获取数据。辅助索引的排序方式也不同于行,因此使用索引通常意味着随机 I/O(这可以通过使用覆盖索引来实现)。另一方面,表扫描在很大程度上是顺序 I/O。因此,对于行来说,进行表扫描比使用二级索引查找相同的行更便宜。

这意味着要使索引有效,它必须过滤掉表中的大部分内容。必须过滤掉多少取决于硬件的性能特征、缓冲池中有多少表、表定义等等。在旧的旋转磁盘时代,经验法则是,如果需要超过 30%的行,那么表扫描是首选。内存中的行数越多,磁盘的随机 I/O 性能越好,这个阈值就越高。

Note

覆盖索引改变了这种情况,因为它们减少了跳转到实际行数据所需的随机 I/O 量。

这就是索引统计发挥作用的地方。优化器——它是 MySQL 的一部分,决定使用哪个查询计划——需要一些简单的方法来确定一个索引对于给定的查询计划有多好。优化器显然知道索引包括哪些列,但是它还需要一些指标来衡量索引对行的过滤能力。这些信息就是索引统计提供的信息。因此,索引统计是对索引选择性的一种度量。有两个主要的统计数据:唯一值的数量和某个范围内的值的数量。

在讨论索引统计时,唯一值的数量是最常想到的。这就是所谓的索引的基数。基数越高,唯一值就越多。对于主键和其他不允许NULL值的唯一索引,基数是表中的行数,因为所有值都必须是唯一的。

优化器在逐个查询的基础上请求给定范围内的行数。这对于范围条件很有用,如WHERE val > 5IN()条件或一系列OR条件。一个例外是 MySQL 8 支持的直方图,这种信息是为单个查询专门收集的。直方图将在下一章讨论。

简而言之,索引统计信息是关于索引中数据分布的近似信息。在 MySQL 中,存储引擎负责提供索引统计信息。因此,深入研究 InnoDB 如何处理索引统计数据是值得的。

InnoDB 和索引统计

存储引擎向服务器层和优化器提供索引统计信息。因此,理解 InnoDB 如何确定其统计数据是很重要的。InnoDB 支持两种存储统计数据的方式:持久和瞬时。无论哪种方式,统计数据都是以相同的方式确定的。这一节将首先讨论如何收集统计数据,然后详细介绍持久性和瞬态统计数据。

如何收集统计数据

InnoDB 通过分析索引的随机叶页面来计算其索引统计信息。例如,可以对 20 个随机索引页面进行采样(这也称为 20 次索引潜水),并检查这些页面由哪些索引值组成。然后,InnoDB 根据索引的总大小对其进行缩放。

这意味着 InnoDB 索引统计数据并不准确。当您看到给定的查询条件意味着将读取 100 行时,这只是基于所分析的样本的估计。这甚至包括主键和其他唯一索引,以及在information_schema.TABLES视图中报告的总行数。表中的估计行数与主键的估计基数相同。

另一个考虑是如何处理NULL值,因为NULL具有不等于NULL的属性。所以,当您收集统计数据时,您应该将所有的NULL值分组到一个桶中还是将它们分开?最佳解决方案取决于您的查询。将所有的NULL值视为不同的值会增加索引的基数,特别是如果有许多行的索引列带有NULL。这对于查找非NULL值的查询来说很好。另一方面,如果您将所有的NULL都视为相同,这将减少基数,这对包含NULL的查询有意义。您可以使用innodb_stats_method选项选择 InnoDB 应该如何处理NULL值。它可以取三个值之一:

  • nulls_equal : 在这种情况下,所有的NULL值被认为是相同的。这是默认设置。如果您不确定选择哪个值,请选择nulls_equal

  • nulls_unequal : 在这种情况下,NULL值被认为是不同的值。

  • nulls_ignored : 在这种情况下,收集统计数据时会忽略NULL值。

为什么使用估计值而不是精确的统计值(意味着全索引扫描)?原因是性能。对于大型索引,执行完整的索引扫描需要很长时间。它通常还包括磁盘 I/O,这使得性能问题更加严重。为了避免计算索引统计对查询性能产生负面影响,我们选择将扫描限制在相对较少的页面上。

样本页面

使用近似统计的缺点是,它们并不总是很好地表示值的实际分布。发生这种情况时,优化器可能会选择错误的索引或错误的连接顺序,从而导致查询速度慢于必要的速度。但是,也可以调整随机索引潜水的次数。如何做到这一点取决于是使用持久统计还是瞬态统计:

  • 持久统计使用innodb_stats_persistent_sample_pages选项作为缺省的采样页数。表格选项STATS_SAMPLE_PAGES可用于指定给定表格的页数。

  • 瞬态统计对所有表使用由innodb_stats_transient_sample_pages选项指定的页数。

关于持久统计和瞬时统计的两个小节详细介绍了处理索引统计的两种方法。

将样本页数设置为给定值是什么意思?这取决于索引中的列数。如果只有一列,该值实际上意味着对该数量的叶页面进行采样。但是,对于多列索引,页数是每列的。例如,如果将示例页数设置为 20,并且索引中有四列,则总共会对 4*20=80 页进行采样。

Note

实际上,索引统计抽样比本章描述的要复杂得多。例如,并不总是需要一直下降到叶页面。考虑两个相邻非叶节点具有相同值的情况。那么可以得出结论,最左边(按照排序)部分的所有叶页面具有相同的值。如果您有兴趣了解更多,一个很好的起点是源代码中storage/innobase/ dict/dict0stats.cc文件顶部的注释: https://github.com/mysql/mysql-server/blob/8.0/storage/innobase/dict/dict0stats.cc

要得到一个好的估计,必须检查多少页?那取决于桌子。如果数据是统一的,也就是说,每个索引值的行数大致相同,那么只需要检查相对较少的页数,默认页数通常就足够了。另一方面,如果您的数据具有非常不规则的分布,您可能需要增加采样的页数。非常不规则的数据的一个例子是队列中任务的状态。随着时间的推移,大多数任务将处于已完成状态。在最坏的情况下,您可能会发现所有随机的 dives 都看到相同的状态,这使得 InnoDB 断定只有一个值,并且该索引作为过滤器毫无价值。

Tip

对于只有几行值用于过滤的数据,下一章讨论的直方图对于改进查询计划非常有用。

桌子的大小也是一个需要考虑的因素。表越大,通常需要检查的页面就越多,这样才能得到准确的估计。原因是表越大,整个叶页越有可能指向具有相同索引值的行。这降低了每个采样页面的值,因此为了补偿,需要采样更多的页面。

一个特例是 InnoDB 被配置为进行比叶页面更多的索引潜水。在这种情况下,InnoDB 会检查所有叶页面,并在该点停止。这将给出尽可能准确的统计数据。如果在分析期间没有活动的事务,则该时间点的统计数据将是准确的。这包括表格中的页数。在本章的后面,您将学习如何使用持久统计信息来查找表的索引和表中的叶页数。

实际上,不可能使用精确的值。InnoDB 支持多版本,即使事务涉及到写操作,也能实现高并发性。由于每个事务都有自己的数据视图,精确的统计意味着每个事务都有自己的索引统计。这是不可行的,那么 InnoDB 是如何处理的呢?这是接下来要考虑的事情。

事务隔离级别

一个相关的问题是在收集统计数据时使用什么事务隔离级别。InnoDB 支持四种隔离级别:未提交读、提交读、可重复读(默认)和可序列化。收集索引统计信息时,选择使用 read uncommitted。这是有意义的,因为这是一个很好的假设,即大多数事务最终都会被提交,或者如果失败,它们会被重试。统计数据是为将来的查询准备的,所以没有理由在收集统计数据时增加维护读取视图的开销。

但是,这对于对表进行较大更改的事务确实有影响。对于一种极端(但并非不可能)的情况,考虑一个缓存表,其中数据由包含两个步骤的事务刷新:

  1. 从表中删除所有现有数据。

  2. 用更新的数据重建表。

默认情况下,当表的“大部分”发生变化时,索引统计信息会更新。(构成“大部分”的内容将在本章后面的“持久索引统计信息”和“临时索引统计信息”部分中介绍。)这意味着当步骤 1 完成时,InnoDB 将重新计算统计数据。这很简单——桌子是空的,所以没有桌子。如果某个查询恰好在此时执行,优化器会将该表视为空表。但是,除非在 read uncommitted transaction 隔离级别执行查询,否则查询仍将读取所有旧行,并且查询计划很可能会导致查询执行效率低下。

对于刚才讨论的问题,您需要持久的统计数据,因为有更好的配置选项来处理特殊情况。在开始讨论持久统计的细节之前,有必要了解如何在持久统计和瞬态统计之间进行选择。

配置统计类型

如上所述,InnoDB 有两种方法来存储索引统计信息。它可以使用持久存储,也可以使用临时存储。您可以使用innodb_stats_persistent选项设置表格的默认方法。当设置为1ON(默认值)时,则使用持久统计;将其设置为0OFF会将方法更改为瞬态统计。您也可以使用STATS_PERSISTENT工作台选项为每个工作台配置方法。例如,要为world.city表启用持久统计,可以像下面这样使用ALTER TABLE

ALTER TABLE world.city
      STATS_PERSISTENT = 1;

使用CREATE TABLE语句创建新表时,也可以设置STATS_PERSISTENT选项。对于STATS_PERSISTENT,只有01可以作为数值。

自从引入持久索引统计以来,它就是默认的,并且也是推荐的选择,除非您遇到测试表明瞬态统计可以解决的问题。持久性统计数据和瞬态统计数据之间存在一些差异,理解这些差异非常重要。接下来将讨论这些差异。

持久索引统计

MySQL 5.6 中引入了持久索引统计,使得查询计划比以前的临时索引统计更加稳定。顾名思义,如果启用了持久索引统计,那么统计数据将被保存,这样在 MySQL 重启时就不会丢失。除了坚持,还有更多的不同,尽管这将变得很清楚。

除了稳定的查询计划之外,持久统计允许对要采样的页面数量进行详细配置,并具有良好的监控,您甚至可以直接查询保存统计信息的表。由于监控与瞬态统计有很大的重叠,这将推迟到本章的后面,因此本节将集中讨论持久统计的配置和存储统计的表。

配置

可以配置持久性统计数据,以便在收集统计数据的成本和统计数据的准确性之间取得良好的平衡。与瞬态统计不同,可以在全局级别和每个表中配置行为。当未设置特定于表的选项时,全局配置充当缺省配置。

有三个特定于持久性统计信息的全局选项。这些是

  • innodb_stats_persistent_sample_pages : 要采样的页数。页面越多,统计越准确,但成本也越高。如果该值大于索引的叶页数,则对整个索引进行采样。默认值为 20。

  • innodb_stats_auto_recalc : 当表格中超过 10%的行发生变化时,是否自动更新统计数据。默认启用(ON)。

  • innodb_stats_include_delete_marked : 是否将标记为已删除但尚未提交的行纳入统计。稍后将更详细地讨论这个选项。默认为禁用(OFF)。

也可以按表设置innodb_stats_persistent_sample_pagesinnodb_stats_auto_recalc选项。这允许您根据与特定表相关的大小、数据分布和工作负载来微调需求。虽然不推荐使用微管理,但是可以使用它来处理前面讨论的缓存表场景以及一般默认值无法覆盖的其他表。

建议尝试为innodb_stats_persistent_sample_pages找到一个好的折衷方案,给出足够好的统计信息,这样优化器可以确定最佳的查询计划,同时避免计算统计信息的过多扫描。如果您发现查询性能很差,因为不准确的索引统计信息会导致优化器选择低效的计划,那么您需要增加抽样页面的数量。另一方面,如果ANALYZE TABLE花费的时间太长,你可以考虑减少采样页数。然后,您可以使用下面介绍的特定于表的选项,根据需要减少或增加特定表的采样页数。

对于大多数表格,建议启用innodb_stats_auto_recalc。这将有助于确保统计数据不会因大量更改而过时。自动重新计算在后台进行,因此不会延迟对触发更新的应用的响应。当超过 10%的表发生更改时,该表将排队等待索引统计信息更新。为了避免不断地重新计算小表的统计数据,还需要在每次索引统计数据更新之间至少间隔 10 秒钟。

当然,也有不希望自动重新计算统计数据的例外情况,例如,如果您有一个缓存表来加快报告查询的执行速度,并且缓存表中的数据有时会完全重新创建,但在其他情况下不会改变。在这种情况下,禁用统计信息的自动重新计算并在重建完成时显式重新计算它们可能是一种优势。另一个选项是在统计数据中包含删除标记的行。

请记住,索引统计信息是使用 read uncommitted 事务隔离级别计算的。虽然在大多数情况下这是最好的统计,但也有例外。当一个事务临时完全改变数据的分布时,它可能导致不正确的统计。表的完全重建是最极端的情况,也是最常见的问题。正是为了这样的情况,才引入了innodb_stats_include_delete_marked选项。InnoDB 不会将未提交的已删除行视为已删除行,而是将它们包含在统计数据中。该选项仅作为全局选项存在,因此它将影响所有表,即使只有一个表出现该问题。如上所述,另一种方法是禁用受影响表的统计数据的自动重新计算,并自己处理。

Tip

如果您的事务对表进行了较大的更改,例如删除所有行,然后重新构建表,请考虑禁用表的索引统计信息的自动重新计算,或者启用innodb_stats_include_delete_marked

迄今为止,只提到了全球选项。如何更改表的索引统计设置?由于您可以使用STATS_PERSISTENT table 选项来覆盖表的全局值innodb_stats_persistent,因此有一些选项可以控制表的持久性统计信息的行为。表格选项包括

  • STATS_AUTO_RECALC : 覆盖表是否启用指标统计自动重算。

  • STATS_SAMPLE_PAGES : 覆盖表格的抽样页数。

您可以在使用CREATE TABLE创建表格时或者稍后使用ALTER TABLE设置这些选项,如清单 15-1 所示。

mysql> CREATE SCHEMA IF NOT EXISTS chapter_15;
Query OK, 1 row affected (0.4209 sec)

mysql> use chapter_15
Default schema set to `chapter_15`.
Fetching table and column names from `chapter_15` for auto-completion... Press ^C to stop.

mysql> CREATE TABLE city (
         City_ID int unsigned NOT NULL auto_increment,
         City_Name varchar(40) NOT NULL,
         State_ID int unsigned DEFAULT NULL,
         Country_ID int unsigned NOT NULL,
         PRIMARY KEY (City_ID),
         INDEX (City_Name, State_ID, City_ID)
       ) STATS_AUTO_RECALC = 0,
         STATS_SAMPLE_PAGES = 10;
Query OK, 0 rows affected (0.0637 sec)

mysql> ALTER TABLE city
             STATS_AUTO_RECALC = 1,
             STATS_SAMPLE_PAGES = 20;
Query OK, 0 rows affected (0.0280 sec)

Records: 0  Duplicates: 0  Warnings: 0

Listing 15-1Setting the persistent statistics options for a table

首先,在禁用自动重新计算的情况下创建了表city,并创建了十个示例页面。然后更改设置以启用自动重新计算,并将示例页数增加到 20。注意ALTER TABLE如何返回 0 行受影响的行。更改 persistent stats 选项只会更改表的元数据,因此它们会立即发生,不会影响数据。这意味着您可以根据需要更改设置,而不必担心执行昂贵的操作。例如,您可能希望在批量操作期间禁用自动重新计算。

有机会调优索引统计数据时,能够查看收集的数据是很重要的。在讨论了瞬态统计之后,在“监控”一节中将讨论一些通用的方法。然而,使持久统计数据持久的是它们存储在表中,并且这些表也提供有价值的信息。

索引统计表

InnoDB 在mysql模式中使用两个表来存储与持久统计相关的数据。这不仅有助于调查统计数据和采样数据,而且有助于从总体上了解更多关于索引的信息。

最常用的表是innodb_index_stats表。这个表的每个 B 树索引都有几行,提供了关于索引每个部分的唯一值(基数)的数量、索引中的叶页数以及索引的总大小的信息。表 15-1 总结了表中的列。

表 15-1

innodb_index_stats

|

列名

|

数据类型

|

描述

|
| --- | --- | --- |
| database_name | varchar(64) | 包含索引的表所在的架构。 |
| table_name | varchar(199) | 带有索引的表的名称。 |
| index_name | varchar(64) | 索引的名称。 |
| last_update | timestamp | 上次更新索引统计信息的时间。 |
| stat_name | varchar(64) | stat_value所针对的统计的名称。另请参见此表后的内容。 |
| stat_value | bigint unsigned | 统计数据的值。 |
| sample_size | bigint unsigned | 取样了多少页。 |
| stat_description | varchar(1024) | 统计数据的描述。对于基数,它是计算基数时包含的列。 |

主键由列database_nametable_nameindex_namestat_name组成。数据库、表和索引名称定义了统计数据用于哪个索引。last_update列有助于查看自上次更新统计数据以来已经过去了多长时间。stat_namestat_value是给你实际的统计数据。sample_size是为确定统计数据而检查的叶页数。这将是索引中的叶页数和为表设置的样本页数中较小的一个。最后,stat_description列给出了关于统计的更多信息。对于基数,描述显示了索引中包含了哪些列,每列有一行(稍后将提供一个示例)。

如前所述,innodb_index_stats表中包含了几个统计数据。该名称可以是下列值之一:

  • n_diff_pfxNN : 索引中前 NN 列的基数。NN 是从 1 开始的,所以对于一个有两列的索引,n_diff_pfx01n_diff_pfx02存在。对于包含这些统计信息的行,stat_description包含了该统计信息所包含的列。

  • n_leaf_pages : 索引中的总叶页数。您可以将它与n_diff_pfxNN统计数据的样本大小进行比较,以确定已经被采样的索引部分。

  • size : 索引中的总页数。这包括非叶页面。

查看一个示例会有助于更好地理解这些数据代表了什么。world.city表有两个索引:主键在ID列,?? 索引在CountryCode列。清单 15-2 显示了这两个索引的统计数据。请注意,如果您执行相同的查询,统计值可能会不同,如果您仍然有在第 14 章中添加的额外索引,将会有更多的行。

mysql> SELECT index_name, stat_name,
              stat_value, sample_size,
              stat_description
         FROM mysql.innodb_index_stats
        WHERE database_name = 'world'
              AND table_name = 'city'\G
*************************** 1\. row ***************************
      index_name: CountryCode
       stat_name: n_diff_pfx01
      stat_value: 232
     sample_size: 7
stat_description: CountryCode
*************************** 2\. row ***************************
      index_name: CountryCode
       stat_name: n_diff_pfx02
      stat_value: 4079
     sample_size: 7
stat_description: CountryCode,ID
*************************** 3\. row ***************************
      index_name: CountryCode
       stat_name: n_leaf_pages
      stat_value: 7
     sample_size: NULL
stat_description: Number of leaf pages in the index
*************************** 4\. row ***************************
      index_name: CountryCode
       stat_name: size
      stat_value: 8
     sample_size: NULL
stat_description: Number of pages in the index
*************************** 5\. row ***************************
      index_name: PRIMARY
       stat_name: n_diff_pfx01
      stat_value: 4188
     sample_size: 20
stat_description: ID
*************************** 6\. row ***************************
      index_name: PRIMARY
       stat_name: n_leaf_pages
      stat_value: 24
     sample_size: NULL
stat_description: Number of leaf pages in the index
*************************** 7\. row ***************************
      index_name: PRIMARY
       stat_name: size
      stat_value: 25
     sample_size: NULL
stat_description: Number of pages in the index

7 rows in set (0.0007 sec)

Listing 15-2The innodb_index_stats table for the world.city table

第 1–4 行用于索引CountryCode,而第 5–7 行用于主键。首先要注意的是,对于CountryCode索引,既有n_diff_pfx01统计数据,也有n_diff_pfx02统计数据。为什么,考虑到索引只包含一列?请记住,InnoDB 使用聚集索引,非唯一索引总是附加主键,因为无论如何都需要它来定位实际的行。这就是你在这里看到的,n_diff_pfx01代表CountryCode列,n_diff_pfx02代表CountryCodeID列的组合。

CountryCode索引有八页大,其中七页是叶节点。这意味着索引有两个级别,叶节点是级别 0,根节点是级别 1。我们鼓励您回到上一章中关于 B 树索引的讨论,并在查看表中一些索引的大小统计时进行回顾。

主键更简单,因为它只包含一列。这里有 24 个叶页面,所以只对索引的一个子集进行了采样。(记住,对于主键,索引就是表。)这样做的后果是统计数字不准确。主键的n_diff_pfx01预测 4188 个唯一值。因为它是主键,所以这也是对总行数的估计。但是,如果您查看一下CountryCode的统计数据,就会发现CountryCodeID值有 4079 种不同的组合。由于CountryCode索引只有七个叶页,所以所有的页都被检查过了,并且行估计是准确的。

另一个与持久统计相关的表是innodb_table_stats表。它类似于innodb_index_stats,除了它是包含的整个表的聚合统计。innodb_table_stats的栏目汇总在表 15-2 中。

表 15-2

innodb_table_stats

|

列名

|

数据类型

|

描述

|
| --- | --- | --- |
| database_name | varchar(64) | 表所在的架构。 |
| table_name | varchar(199) | 表的名称。 |
| last_update | timestamp | 上次更新表统计信息的时间。 |
| n_rows | bigint unsigned | 表中估计的行数。 |
| clustered_index_size | bigint unsigned | 聚集索引中的页数。 |
| sum_of_other_index_sizes | bigint unsigned | 辅助索引的总页数。 |

主键由列database_nametable_name组成。关于表统计,需要注意的重要一点是,它们和索引统计一样近似。表中的行数就是主键的估计基数。类似地,聚集索引的大小与来自innodb_index_stats表的主键的大小相同。二级索引页数是每个二级索引大小的总和。清单 15-3 显示了world.city表的innodb_table_stats表的内容示例,使用了与上一个示例相同的索引统计。

mysql> SELECT *
         FROM mysql.innodb_table_stats
        WHERE database_name = 'world'
              AND table_name = 'city'\G
*************************** 1\. row ***************************
           database_name: world
              table_name: city
             last_update: 2019-05-25 13:51:40
                  n_rows: 4188
    clustered_index_size: 25
sum_of_other_index_sizes: 8
1 row in set (0.0005 sec)

Listing 15-3The innodb_table_stats table for the world.city table

Tip

innodb_index_statsinnodb_table_stats是常规表。在备份中包含这些表是很有用的,这样,如果查询计划突然发生变化,您就可以回过头来比较统计数据。

也可以为拥有UPDATE权限的用户更新表格。这似乎是一个非常有用的属性,但是要小心。如果您不知道正确的统计数据,您将会得到非常糟糕的查询计划。几乎不应该手动修改索引统计信息。如果完成,更改仅在刷新表后生效。

如果您觉得对innodb_index_statsinnodb_table_stats中可用信息的讨论听起来与您可能习惯看到的SHOW INDEX语句以及TABLESSTATISTICS信息模式表类似,那么您是对的。有一些重叠。由于这些来源也适用于瞬态统计,所以对它们的讨论将推迟到瞬态索引统计讨论完之后。

瞬时索引统计

瞬态索引统计是 InnoDB 中实现的处理索引统计的原始方法。顾名思义,统计数据不是持久的,也就是说,当 MySQL 重新启动时,它们不会持久。相反,统计数据是在第一次打开表时计算的(在其他时候),并且只保存在内存中。因为统计数据不是持久的,所以它们不太稳定,因此更有可能看到查询计划的变化。

有两个配置选项可以影响瞬态统计的行为。这些是

  • innodb_stats_transient_sample_pages : 更新索引统计时要采样的页数。默认值为 8。

  • innodb_stats_on_metadata : 查询表元数据时是否重新统计。缺省值是OFF,从 MySQL 5.6 开始就是这样。

除了应用于使用瞬态统计的表之外,innodb_stats_transient_sample_pages选项等同于innodb_stats_persistent_sample_pages。使用瞬态统计信息的表不仅在第一次打开时重新计算统计信息,而且当只有 6.25% (1/16)的行发生变化时也需要重新计算统计信息,要求至少发生 16 次更新。此外,当统计数据自动重新计算时,瞬态统计数据不使用后台线程,因此更新更有可能影响性能。因此,innodb_stats_transient_sample_pages的缺省值只有八页。

如果您想更频繁地更新临时索引统计信息,您可以启用innodb_stats_on_metadata选项。当启用该功能时,查询信息模式中的TABLESSTATISTICS表或者使用它们的等价SHOW语句触发索引统计信息的更新。实际上,很少会出现这种情况,关闭该选项是安全的。

没有特殊的表可用于瞬态统计。然而,MySQL 中的所有表都有可用的表和语句。

监控

索引统计信息对于优化器帮助确定执行查询的最佳方式非常重要。因此,了解如何检查表的索引统计信息也很重要。已经讨论过,对于持久统计,有mysql.innodb_index_statsmysql.innodb_table_stats表。然而也有一些通用的方法,这里将讨论这些方法。

Tip

记住,information_schema_stats_expiry变量影响数据字典刷新与索引统计相关的数据视图的频率。

信息模式统计视图

获取索引统计详细信息的主表是信息模式中的STATISTICS视图。该视图不仅包含索引统计信息本身,还包含关于索引的元信息。事实上,您可以基于STATISTICS视图中的数据重新创建索引定义。这是上一章中用来在表上查找索引名的视图。

15-3 包含了视图中各列的概要。您通常只需要列的一个子集,但是在需要的时候访问案例的所有信息是很方便的。CARDINALITY列是唯一受information_schema_stats_expiry变量影响的列。

表 15-3

STATISTICS信息模式视图

|

列名

|

数据类型

|

描述

|
| --- | --- | --- |
| TABLE_CATALOG | varchar(64) | 该表所属的目录。该值将始终为def。 |
| TABLE_SCHEMA | varchar(64) | 表所在的架构。 |
| TABLE_NAME | varchar(64) | 索引所在的表。 |
| NON_UNIQUE | int | 索引是唯一的(0)还是不唯一的(1)。 |
| INDEX_SCHEMA | varchar(64) | 与TABLE_SCHEMA相同(因为索引总是与表位于同一位置)。 |
| INDEX_NAME | varchar(64) | 索引的名称。 |
| SEQ_IN_INDEX | int unsigned | 列在索引中的位置。对于单列索引,该值始终为 1。 |
| COLUMN_NAME | varchar(64) | 列的名称。 |
| COLLATION | varchar(1) | 索引的排序方式。值可以是NULL(未排序)、A(升序)或 D(降序)。 |
| CARDINALITY | bigint | 对索引部分的唯一值数量的估计,包括行中的列。 |
| SUB_PART | bigint | 对于前缀索引,它是被索引的字符或字节数。如果对整列进行索引,则值为NULL。 |
| PACKED | binary(0) | 对于 InnoDB 表,这始终是NULL。 |
| NULLABLE | varchar(3) | 是否允许使用NULL值。该列要么是空字符串,要么是YES。 |
| INDEX_TYPE | varchar(11) | 索引类型,例如,BTREE为 B 树索引。 |
| COMMENT | varchar(8) | 关于索引的额外信息。这不适用于 InnoDB 表。 |
| INDEX_COMMENT | varchar(2048) | 添加索引时指定的注释。 |
| IS_VISIBLE | varchar(3) | 索引是可见的(YES)还是不可见的(NO)。 |
| EXPRESSION | longtext | 对于函数索引,此列包含用于生成索引值的表达式。对于非功能性索引,该值始终为NULL。 |

STATISTICS视图不仅对索引统计有用,而且对索引本身也有用,它包括所有索引的信息,而不管索引类型如何。例如,您可以使用它来查找不可见的索引和用于函数索引的表达式。关于索引统计,最有趣的列是CARDINALITY,它是估计索引中存在的唯一值的数量。

查询STATISTICS视图时,建议按TABLE_SCHEMATABLE_NAMEINDEX_NAMESEQ_IN_INDEX列对结果进行排序。这将把相关的行组合在一起,对于多列索引,将按照索引中列的顺序返回这些行。清单 15-4 显示了world.countrylanguage表上的索引示例。在这种情况下,由于表模式和表名是固定的,所以排序只基于索引名和索引中的序列。由于这些值本质上是不精确的,您的结果可能会有所不同。

mysql> SELECT INDEX_NAME, NON_UNIQUE,
              SEQ_IN_INDEX, COLUMN_NAME,
              CARDINALITY, INDEX_TYPE,
              IS_VISIBLE
         FROM information_schema.STATISTICS
        WHERE TABLE_SCHEMA = 'world'
              AND TABLE_NAME = 'countrylanguage'
        ORDER BY INDEX_NAME, SEQ_IN_INDEX\G
*************************** 1\. row ***************************
  INDEX_NAME: CountryCode
  NON_UNIQUE: 1
SEQ_IN_INDEX: 1
 COLUMN_NAME: CountryCode
 CARDINALITY: 233
  INDEX_TYPE: BTREE
  IS_VISIBLE: YES
*************************** 2\. row ***************************
  INDEX_NAME: PRIMARY
  NON_UNIQUE: 0
SEQ_IN_INDEX: 1
 COLUMN_NAME: CountryCode
 CARDINALITY: 233
  INDEX_TYPE: BTREE
  IS_VISIBLE: YES
*************************** 3\. row ***************************
  INDEX_NAME: PRIMARY
  NON_UNIQUE: 0
SEQ_IN_INDEX: 2
 COLUMN_NAME: Language
 CARDINALITY: 984
  INDEX_TYPE: BTREE
  IS_VISIBLE: YES
3 rows in set (0.0010 sec)

Listing 15-4The STATISTICS view for the world.countrylanguage table

countrylanguage表有两个索引。在CountryCodeLanguage列上有一个主键,在CountryCode列上有一个辅助索引。与mysql.innodb_index_stats表不同,当主键被附加到辅助非唯一索引时,该表中也有一行,STATISTICS视图不包含该信息。

Note

因为CountryCode列是主键中的第一列,所以CountryCode列上的辅助索引是多余的。这意味着主键也可以用作辅助索引。最佳实践是避免冗余索引。

您可能希望在STATISTICS视图中记录数据,并比较数据随时间的变化。突然的变化可能表明数据发生了意外情况,或者索引统计信息的最新重新计算可能导致不同的查询计划。

STATISTICS视图中的一些信息也可以通过SHOW INDEX语句获得。

SHOW INDEX 语句

SHOW INDEX语句是获取 MySQL 中索引信息的原始方式。如今,它从与information_schema.STATISTICS相同的来源获取数据,所以你可以选择最适合你的来源。STATISTICS视图的一个主要优点是你可以选择你想要的信息以及如何订购;使用SHOW INDEX语句,您总是可以获得单个表的索引,并且可以选择根据可用字段进行过滤。

除了省略了表目录、表模式和索引模式之外,SHOW INDEX返回的列与STATISTICS视图中的相同。另一方面,SHOW INDEX可以选择使用EXTENDED关键字,该关键字包含关于索引隐藏部分的信息。这不应该与不可见的索引混淆,而是附加的部分,如附加到辅助索引的主键。标准输出和扩展输出对于共有的行具有相同的信息。

清单 15-5 显示了world.city表的SHOW INDEX输出的一个例子(该结果假设来自章节 14 的索引已经被移除)。首先,返回标准输出,然后是扩展输出。由于扩展输出有几页长,所以通过删除一些列和行对其进行了简化。要查看完整的输出,请自己执行该语句或查看本书 GitHub 库中的listing_15_5.txt文件。

mysql> SHOW INDEX FROM world.city\G
*************************** 1\. row ***************************
        Table: city
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: ID
    Collation: A
  Cardinality: 4188
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
*************************** 2\. row ***************************
        Table: city
   Non_unique: 1
     Key_name: CountryCode
 Seq_in_index: 1
  Column_name: CountryCode
    Collation: A
  Cardinality: 232
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
2 rows in set (0.0013 sec)

mysql> SHOW EXTENDED INDEX FROM world.city\G
*************************** 1\. row ***************************
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: ID
  Cardinality: 4188
*************************** 2\. row ***************************
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 2
  Column_name: DB_TRX_ID
  Cardinality: NULL
*************************** 3\. row ***************************
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 3
  Column_name: DB_ROLL_PTR
  Cardinality: NULL
*************************** 4\. row ***************************
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 4
  Column_name: Name
  Cardinality: NULL
...
*************************** 8\. row ***************************
   Non_unique: 1
     Key_name: CountryCode
 Seq_in_index: 1
  Column_name: CountryCode
  Cardinality: 232
*************************** 9\. row ***************************
   Non_unique: 1
     Key_name: CountryCode
 Seq_in_index: 2
  Column_name: ID
  Cardinality: NULL

9 rows in set (0.0013 sec)

Listing 15-5The SHOW INDEX output for the world.city table

请注意,列名与STATISTICS视图所使用的并不相同。但是,列的顺序是相同的,名称也是相似的,因此很容易将两个输出相互映射。

在扩展输出中,主键在 InnoDB 内部有两个隐藏的列:DB_TRX_ID是 6 字节的事务标识符,而DB_ROLL_PTR是 7 字节的回滚指针,指向写入回滚段的撤销日志记录。这些是 InnoDB 多版本支持的一部分。 1 在这两个内部字段之后,表格中剩余的每一列都被添加。这反映了 InnoDB 对其行使用聚集索引,因此主键是行。

对于CountryCode上的二级索引,主键现在显示为索引的第二部分。这是意料之中的,也反映了在mysql.innodb_index_stats表中看到的情况。

虽然在研究性能问题时,人们通常对扩展输出不感兴趣,但在探索 InnoDB 如何工作时,它是有价值的。

在处理索引统计信息时,另一个有用的信息模式视图是INNODB_TABLESTATS视图。

信息模式 INNODB_TABLESTATS 视图

信息模式中的INNODB_TABLESTATS视图是位于 InnoDB 内存结构之上的视图,其中保存了关于索引的信息。它不包含任何可用于验证基数和索引大小的信息,这些信息不包含在已经描述过的表和视图中。但是,它确实提供了一些关于索引统计状态和自上次分析该表以来的修改次数的信息。该视图包括所有 InnoDB 表的信息,而不管它们是使用持久统计还是临时统计。表 15-4 总结了INNODB_TABLESTATS视图的列。

表 15-4

INNODB_TABLESTATS信息模式视图

|

列名

|

数据类型

|

描述

|
| --- | --- | --- |
| TABLE_ID | bigint unsigned | 内部 InnoDB 表 ID。例如,您可以使用它在INNODB_TABLES信息模式视图中查找表格。 |
| NAME | varchar(193) | 格式为<schema>/<table>的表名,例如world/city。 |
| STATS_INITIALIZED | varchar(193) | 表的内存结构是否已初始化。这与索引统计数据是否存在并不相同。可能的值是UninitializedInitialized。 |
| NUM_ROWS | bigint unsigned | 表中估计的行数。 |
| CLUST_INDEX_SIZE | bigint unsigned | 聚集索引中的页数。 |
| OTHER_INDEX_SIZE | bigint unsigned | 辅助索引的总页数。 |
| MODIFIED_COUNTER | bigint unsigned | 自上次更新索引统计信息以来,使用 DML 语句更改的行数。 |
| AUTOINC | bigint unsigned | 自动递增计数器的值(如果存在)。对于没有自动递增列的表,该值为 0。 |
| REF_COUNT | int | 有多少对元数据的引用。当参考计数器达到零时,InnoDB 可能会清除数据,初始化状态返回到Uninitialized。 |

初始化状态会造成混乱。这显示了索引统计信息和相关元数据(如该视图所示)是否已经加载到内存中。即使统计数据存在,状态也总是以Uninitialized开始。当某个连接或后台线程需要数据时,InnoDB 会将数据加载到内存中,状态变为Initialized。每当没有线程持有对该表的引用时,InnoDB 就可以自由地再次驱逐该信息,并且状态变为Uninitialized。例如,这可能发生在表被刷新或对表执行ANALYZE TABLE时。

修改后的计数器很有趣,因为它可以用来查看自上次更新索引统计信息以来有多少行发生了更改。只有当 DML 查询影响索引时,计数器才会增加。这意味着,如果您更新了一个非索引列,而保留该行不变,计数器将不会递增。该计数器与自动更新相关,当发生一定数量的更改时,会触发自动更新。

清单 15-6 有一个来自world.city表的INNODB_TABLESTATS视图的示例输出。如果执行相同的查询,表 ID、行数和引用计数可能会不同。

mysql> SELECT *
         FROM information_schema.INNODB_TABLESTATS
        WHERE NAME = 'world/city'\G
*************************** 1\. row ***************************
         TABLE_ID: 1670
             NAME: world/city
STATS_INITIALIZED: Initialized
         NUM_ROWS: 4188
 CLUST_INDEX_SIZE: 25
 OTHER_INDEX_SIZE: 8
 MODIFIED_COUNTER: 0
          AUTOINC: 4080
        REF_COUNT: 2
1 row in set (0.0009 sec)

Listing 15-6The INNODB_TABLESTATS view for the world.city table

输出显示索引统计信息是最新的,因为自上次分析以来没有修改过任何行。行数以及聚集索引和辅助索引的大小与使用mysql.innodb_index_stats表找到的相同。这些与表格大小相关的数字也用于information_schema.TABLES视图和SHOW TABLE STATUS语句。

信息模式表查看和显示表状态

索引统计信息集合还用于填充由information_schema.TABLES视图和SHOW TABLE STATUS语句使用的表中的一些列。这包括对行数以及数据和索引大小的估计。

15-5 显示了TABLES视图中各列的汇总。除了TABLE_CATALOGTABLE_SCHEMATABLE_TYPETABLE_COMMENT列之外,SHOW TABLE STATUS语句的输出中有相同的列,少数列的名称略有不同。标有星号(*)的列受information_schema_stats_expiry变量影响。

表 15-5

TABLES信息模式视图

|

列名

|

数据类型

|

描述

|
| --- | --- | --- |
| TABLE_CATALOG | varchar(64) | 该表所属的目录。该值将始终为def。 |
| TABLE_SCHEMA | varchar(64) | 表所在的架构。 |
| TABLE_NAME | varchar(64) | 表的名称。 |
| TABLE_TYPE | enum | 是什么样的桌子。可能的值有BASE TABLEVIEWSYSTEM VIEW。用CREATE TABLE创建一个基表,用CREATE VIEW创建一个视图,系统视图是像 MySQL 创建的信息模式视图这样的视图。 |
| ENGINE | varchar(64) | 表使用的存储引擎。 |
| VERSION | int | 在 MySQL 8 中未使用,因为它与 MySQL 5.7 和更早版本中的.frm文件相关。版本值现在被硬编码为 10。 |
| ROW_FORMAT | enum | 用于表格的行格式。可能的值有固定、动态、压缩、冗余、压缩和分页。 |
| TABLE_ROWS * | bigint unsigned | 估计的行数。对于 InnoDB 表,这来自主键或聚集索引的基数。 |
| AVG_ROW_LENGTH * | bigint unsigned | 估计的数据长度除以估计的行数。 |
| DATA_LENGTH * | bigint unsigned | 行数据的估计大小。对于 InnoDB,它是聚集索引的大小,即聚集索引中的页数乘以页面大小。 |
| MAX_DATA_LENGTH * | bigint unsigned | 数据长度的最大允许大小。InnoDB 不使用,所以值为NULL。 |
| INDEX_LENGTH * | bigint unsigned | 辅助索引的估计大小。对于 InnoDB,这是非聚集索引中的页面总数乘以页面大小。 |
| DATA_FREE * | bigint unsigned | 该表所属的表空间中空闲空间量的估计值。对于 InnoDB,这是完全自由的扩展区的大小减去安全余量。 |
| AUTO_INCREMENT * | bigint unsigned | 表的自动递增计数器的下一个值。 |
| CREATE_TIME * | timestamp | 创建表的时间。 |
| UPDATE_TIME * | datetime | 上次更新表空间文件的时间。对于 InnoDB 系统表空间中的表,该值为NULL。由于数据是异步写入表空间的,因此时间通常不会反映最后一条更改数据的语句的时间。 |
| CHECK_TIME * | datetime | 上次检查表格的时间(CHECK TABLE)。对于分区表,InnoDB 总是返回NULL。 |
| TABLE_COLLATION | varchar(64) | 用于对字符串列的值进行排序和比较的默认排序规则(没有为列显式设置)。 |
| CHECKSUM | bigint | 表校验和。InnoDB 不使用,所以值为NULL。 |
| CREATE_OPTIONS | varchar(256) | 表格选项,如STATS_AUTO_RECALCSTATS_SAMPLE_PAGES。 |
| TABLE_COMMENT | text | 创建表时指定的注释。 |

在可用的信息中,行数以及数据和索引的大小与索引统计信息的关系最为密切。TABLES视图不仅有助于查询表大小的估计值,还可以用来查询哪些表显式设置了持久统计变量。清单 15-7 显示了一个示例chapter_15.t1表,用一百万行填充它,然后查询该表的TABLES视图的内容。

mysql> CREATE TABLE chapter_15.t1 (
         id int unsigned NOT NULL auto_increment,
         val varchar(36) NOT NULL,
         PRIMARY KEY (id)
       ) STATS_PERSISTENT=1,
         STATS_SAMPLE_PAGES=50,
         STATS_AUTO_RECALC=1;
Query OK, 0 rows affected (0.5385 sec)

mysql> SET SESSION cte_max_recursion_depth = 1000000;
Query OK, 0 rows affected (0.0003 sec)

mysql> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)

mysql> INSERT INTO chapter_15.t1 (val)
       WITH RECURSIVE seq (i) AS (
         SELECT 1
          UNION ALL
         SELECT i + 1
           FROM seq WHERE i < 1000000
       )
       SELECT UUID()
         FROM seq;
Query OK, 1000000 rows affected (15.8552 sec)

Records: 1000000  Duplicates: 0  Warnings: 0

mysql> COMMIT;
Query OK, 0 rows affected (0.8306 sec)

mysql> SELECT *
         FROM information_schema.TABLES
        WHERE TABLE_SCHEMA = 'chapter_15'
              AND TABLE_NAME = 't1'\G
*************************** 1\. row ***************************
  TABLE_CATALOG: def
   TABLE_SCHEMA: chapter_15
     TABLE_NAME: t1
     TABLE_TYPE: BASE TABLE
         ENGINE: InnoDB
        VERSION: 10
     ROW_FORMAT: Dynamic
     TABLE_ROWS: 996442
 AVG_ROW_LENGTH: 64
    DATA_LENGTH: 64569344
MAX_DATA_LENGTH: 0
   INDEX_LENGTH: 0
      DATA_FREE: 7340032
 AUTO_INCREMENT: 1048561
    CREATE_TIME: 2019-11-02 11:48:28
    UPDATE_TIME: 2019-11-02 11:49:25
     CHECK_TIME: NULL
TABLE_COLLATION: utf8mb4_0900_ai_ci
       CHECKSUM: NULL
 CREATE_OPTIONS: stats_sample_pages=50 stats_auto_recalc=1 stats_persistent=1

  TABLE_COMMENT:
1 row in set (0.0653 sec)

Listing 15-7The TABLES view for the table chapter_15.t1

该表使用递归公用表表达式填充随机数据,以确保正好插入一百万行。要做到这一点,有必要将cte_max_recursion_depth设置为 1000000,否则公共表表达式将因递归深度过高而失败。

请注意,估计的行数只有 996442 行,比实际的行数少 0.3%左右。这在预期范围之内——10%或以上的差异并不罕见。该表还设置了几个表选项,以显式配置持久统计信息用于启用了自动重新计算的表,并使用了 50 个示例页面。

如果您更喜欢使用SHOW TABLE STATUS语句,您可以使用不带参数的语句,在这种情况下,将返回默认模式中所有表的状态。或者,您可以添加一个LIKE子句来只包含表的子集。要检索非默认模式中表的状态,请使用FROM子句指定模式名。例如,假设默认模式是world,那么下面的查询将返回city表的状态:

mysql> use world
mysql> SHOW TABLE STATUS LIKE 'city';
mysql> SHOW TABLE STATUS LIKE 'ci%';
mysql> SHOW TABLE STATUS FROM world LIKE 'city';

前两个查询依赖默认模式来知道在哪里查找表。第三个查询显式地在world模式中查找city表。

如果索引统计没有数据,如何更新它们?这是在结束本章之前要探讨的最后一个话题。

更新统计数据

为了让优化器获得最佳的查询执行计划,最新的索引统计信息非常重要。有两种方法可以更新索引:自动更新,因为表已经发生了足够多的变化,可以触发统计信息的重新计算;手动更新。

自动更新

在讨论持久和瞬时统计时,已经在一定程度上讨论了自动更新机制。表 15-6 总结了基于索引统计类型的特性。

表 15-6

InnoDB 索引统计信息的自动重新计算概要

|

财产

|

坚持的

|

短暂的

|
| --- | --- | --- |
| 行已更改 | 表格的 10% | 表格的 6.25% |
| 由于行更改而导致的更新的最小间隔时间 | 10 秒 | 16 次更新 |
| 引发变化的其他行动 |   | 第一次打开表,可以选择在查询表元数据时打开。 |
| 背景更新 | 是 | 不 |
| 配置 | innodb_stats_auto_recalc变量和STATS_AUTO_RECALC表格选项 | 没有人 |

摘要显示,持久性统计信息通常更新频率较低,并且影响较小,因为自动更新发生在后台。持久统计也有更好的配置选项。

也可以手动触发索引统计信息的更新。您可以使用ANALYZE TABLE语句或mysqlcheck命令行程序,这将在接下来的章节中讨论。

ANALYZE TABLE 语句

当您在mysql命令行客户端或 MySQL Shell 中工作或者更新将由存储过程触发时,使用ANALYZE TABLE语句非常方便。该语句可以更新索引统计信息和直方图。后者将在下一章讨论,所以这里只讨论索引统计的更新。

ANALYZE TABLE有一个参数,即是否将语句记录到二进制日志中。如果在ANALYZETABLE之间指定NO_WRITE_TO_BINLOGLOCAL,该语句将只应用于本地实例,而不会写入二进制日志。

当您执行ANALYZE TABLE时,它会强制刷新索引统计信息和表缓存值,否则这些值会受到information_schema_stats_expiry变量的影响。因此,如果您强制更新索引统计数据,您不需要更改information_schema_stats_expiry来拥有information_schema.STATISTICS视图并类似地反映更新后的值。

您可以选择指定多个表来更新它们的索引统计信息。您可以通过在逗号分隔的列表中列出这些表来实现这一点。在清单 15-8 中可以看到一个更新world模式中三个表的统计数据的例子。

mysql> ANALYZE LOCAL TABLE
               world.city, world.country,
               world.countrylanguage\G
*************************** 1\. row ***************************
   Table: world.city
      Op: analyze
Msg_type: status
Msg_text: OK
*************************** 2\. row ***************************
   Table: world.country
      Op: analyze
Msg_type: status
Msg_text: OK
*************************** 3\. row ***************************
   Table: world.countrylanguage
      Op: analyze
Msg_type: status
Msg_text: OK
3 rows in set (0.0248 sec)

Listing 15-8Analyzing the index statistics for the tables in the world schema

在示例中,LOCAL关键字用于避免将语句记录到二进制日志中。如果没有指定模式名和表名(例如,用city代替world.city),MySQL 会在当前默认模式中查找表。

Note

虽然可以使用ANALYZE TABLE同时查询表,但是请注意,作为最后一步(在返回到客户端之后),被分析的表将被刷新(一个隐式的FLUSH TABLES语句)。表刷新只能在所有正在进行的查询完成后发生,所以当您有长时间运行的查询时,您不应该使用ANALYZE TABLE(或mysqlcheck)。

当您确切地知道您想要分析哪些表时,ANALYZE TABLE语句非常适合临时更新。对于分析给定模式中的所有表或实例中的所有表来说,它的用处不大。为此,下面讨论的mysqlcheck是一个更好的选择。

mysqlcheck 程序

例如,如果您想通过 cron 守护进程或 Windows 任务调度程序从 shell 脚本中触发更新,那么mysqlcheck程序非常方便。它不仅可以用于更新单个表或多个表上的索引统计信息,如ANALYZE TABLE,还可以告诉mysqlcheck更新模式中所有表或实例中所有表的索引统计信息。mysqlcheck所做的是对符合您的标准的表执行ANALYZE TABLE,所以从索引统计的角度来看,手动执行ANAYZE TABLE和使用mysqlcheck没有区别。

Note

mysqlcheck程序不仅仅可以分析表来更新索引统计数据。这里只介绍分析功能。要阅读mysqlcheck程序的完整文档,请参见 https://dev.mysql.com/doc/refman/en/mysqlcheck.html

您使用--analyze选项让mysqlcheck更新索引统计数据,并使用--write-binlog / --skip-write-binlog参数告诉您是否希望将语句记录到二进制日志中。默认设置是记录语句。你还需要告诉如何连接到 MySQL 为此,您可以使用标准连接选项。

有三种方法可以指定要分析哪些表。默认情况下,分析同一个模式中的一个或多个表,比如对于ANALYZE TABLE语句。如果选择这种方式,就不需要添加任何额外的选项,指定的第一个值被解释为模式名,可选参数被解释为表名。清单 15-9 展示了如何以两种方式分析world模式中的所有表:显式列出表名和不列出表。

shell$ mysqlcheck --user=root --password --host=localhost --port=3306 --analyze world city country countrylanguage
Enter password: ********
world.city                   OK
world.country                OK
world.countrylanguage        OK

shell$ mysqlcheck --user=root --password --host=localhost --analyze world
Enter password: ********
world.city                   OK
world.country                OK
world.countrylanguage        OK

Listing 15-9Using mysqlcheck to analyze all tables in the world schema

在这两种情况下,输出都列出了被分析的三个表。

如果您想要分析多个模式中的所有表,但是仍然列出要包括哪些模式,那么您可以使用--databases参数。当出现这种情况时,命令行上列出的所有对象名都被解释为模式名。清单 15-10 展示了一个分析sakilaworld模式中所有表的例子。

shell$ mysqlcheck --user=root --password --host=localhost --port=3306 --analyze --databases sakila world
Enter password: ********
sakila.actor                 OK
sakila.address               OK
sakila.category              OK
sakila.city                  OK
sakila.country               OK
sakila.customer              OK
sakila.film                  OK
sakila.film_actor            OK
sakila.film_category         OK
sakila.film_text             OK
sakila.inventory             OK
sakila.language              OK
sakila.payment               OK
sakila.rental                OK
sakila.staff                 OK
sakila.store                 OK
world.city                   OK
world.country                OK
world.countrylanguage        OK

Listing 15-10Analyze all tables in the sakila and world schemas

最后一个选项是使用--all-databases选项来分析所有的表,不管它们位于哪个模式中。除了信息模式和性能模式之外,这还包括系统表。清单 15-11 展示了一个使用mysqlcheck--?? 的例子。

shell$ mysqlcheck --user=root --password --host=localhost --port=3306 --analyze --all-databases
Enter password: ********
mysql.columns_priv                OK
mysql.component                   OK
mysql.db                          OK
mysql.default_roles               OK
mysql.engine_cost                 OK
mysql.func                        OK
mysql.general_log
note     : The storage engine for the table doesn't support analyze
mysql.global_grants               OK
mysql.gtid_executed               OK
mysql.help_category               OK
mysql.help_keyword                OK
mysql.help_relation               OK
mysql.help_topic                  OK
mysql.innodb_index_stats          OK
mysql.innodb_table_stats          OK
mysql.password_history            OK
mysql.plugin                      OK
mysql.procs_priv                  OK
mysql.proxies_priv                OK
mysql.role_edges                  OK
mysql.server_cost                 OK
mysql.servers                     OK
mysql.slave_master_info           OK
mysql.slave_relay_log_info        OK
mysql.slave_worker_info           OK
mysql.slow_log
note     : The storage engine for the table doesn't support analyze
mysql.tables_priv                 OK
mysql.time_zone                   OK
mysql.time_zone_leap_second       OK
mysql.time_zone_name              OK
mysql.time_zone_transition        OK
mysql.time_zone_transition_type   OK
mysql.user                        OK
sakila.actor                      OK
sakila.address                    OK
sakila.category                   OK
sakila.city                       OK
sakila.country                    OK
sakila.customer                   OK
sakila.film                       OK
sakila.film_actor                 OK
sakila.film_category              OK
sakila.film_text                  OK
sakila.inventory                  OK
sakila.language                   OK
sakila.payment                    OK
sakila.rental                     OK
sakila.staff                      OK
sakila.store                      OK
sys.sys_config                    OK
world.city                        OK
world.country                     OK
world.countrylanguage             OK

Listing 15-11Analyzing all tables

请注意,有两个表回复说它们的存储引擎不支持 analyze。mysqlcheck程序试图分析所有的表,而不考虑它们的存储引擎,所以像示例中这样的消息是意料之中的。默认情况下,mysql.general_logmysql.slow_log表都使用不支持索引的 CSV 存储引擎,因此ANALYZE TABLE也不支持索引。

摘要

本章通过查看 InnoDB 如何处理索引统计数据,继承了上一章的内容。InnoDB 有两种方法来存储统计数据:要么持久存储在mysql.innodb_index_statsmysql.innodb_table_stats表中,要么暂时存储在内存中。持久统计通常是首选,因为它们提供更一致的查询计划,允许对更多页面进行采样,在后台进行更新,并且可以在更大程度上进行配置,包括支持表级选项。

有几个表、视图和SHOW语句可以用来研究和了解 InnoDB 索引及其统计数据。特别有趣的是information_schema.STATISTICS视图,它包含 MySQL 中所有索引的细节。还讨论了information_schema.INNODB_TABLESTATSinformation_schema.TABLES视图、SHOW INDEXSHOW TABLE STATUS声明。

您可以通过两种方式更新索引统计信息:使用ANALYZE TABLE语句或mysqlcheck程序。前者在交互式客户端或存储过程中很有用,而后者对于 shell 脚本和更新一个或多个模式中的所有表更有用。这两种方法还强制更新 MySQL 数据字典中的表元数据和索引基数的缓存值。

在讨论ANALYZE TABLE语句时,提到 MySQL 也支持直方图。这些与索引有关,是下一章的主题。

十六、直方图

在前两章中,你学习了索引和索引统计。索引的目的是减少访问查询和索引统计所需的行所需的读取次数,以帮助优化器确定最佳查询计划。这些都很好,但是索引并不是免费的,有些情况下索引并不是非常有效,不值得开销,但是您仍然需要优化器了解数据分布。这就是直方图有用的地方。

本章首先讨论什么是直方图,直方图对哪些工作负载有用。然后讨论了使用直方图的更实际的方面,包括添加、维护和检查直方图数据。最后,有一个查询示例,其中查询计划随着直方图的添加而改变。

什么是直方图?

对直方图的支持是 MySQL 8 中的新特性。它使得分析和存储关于表中数据分布的信息成为可能。虽然直方图与索引有一些相似之处,但它们并不相同,没有任何索引的列也可以有直方图。

当您创建直方图时,您告诉 MySQL 将数据划分到桶中。这可以通过在每个桶中放入一个值或者在每个桶中放入大致相等数量的行的值来实现。关于数据分布的知识可以帮助优化器更准确地估计给定的WHERE子句或连接条件将过滤掉表中的多少数据。如果没有这方面的知识,优化器可能会假设某个条件返回表的三分之一,而直方图可能会显示只有 5%的行匹配该条件。这些知识对于优化器选择最佳查询计划至关重要。

同时,重要的是要认识到直方图不同于索引。与不使用直方图执行的相同查询计划相比,MySQL 不能使用直方图来减少使用直方图的表中检查的行数。但是,通过了解表中有多少内容将被过滤,优化器可以更好地确定最佳连接顺序。

直方图的一个优点是它们只有在创建或更新时才有成本。与索引不同,当您更改数据时,直方图不会发生变化。您可能会不时地重新创建直方图,以确保统计数据是最新的,但是 DML 查询没有开销。通常,直方图应该与索引统计数据进行比较,而不是与索引进行比较。

Note

理解索引和直方图之间的根本区别是很重要的。索引可以用来减少访问所需行所需的工作,直方图则不能。当直方图用于查询时,它不会直接减少检查的行数,但是它可以帮助优化器选择更优的查询计划。

就像索引一样,您应该小心选择为哪一列添加直方图。所以让我们讨论一下哪些列应该被认为是好的候选列。

何时应该添加直方图?

添加直方图的好处的重要因素是将它们添加到正确的列中。简而言之,直方图对于不是索引中第一列的列、具有非均匀分布的值的列以及对这些列应用条件的列最为有利。这听起来像是一个非常有限的用例,实际上直方图在 MySQL 中并不像在其他一些数据库中那样有用。这是因为 MySQL 在估计索引列范围内的行数方面很有效,因此直方图不会与同一列上的索引一起使用。还要注意,虽然直方图对于数据分布不均匀的列特别有用,但是在不值得添加索引的情况下,直方图对于数据分布均匀的列也很有用。

Tip

不要将直方图添加到索引中的第一列。对于稍后出现在索引中的列,直方图对于由于需要使用索引的左前缀而无法将索引用于该列的查询仍然有价值。

也就是说,仍然存在直方图可以大大提高查询性能的情况。一个典型的用例是在数据非均匀分布的列上有一个或多个连接和一些次要条件的查询。在这种情况下,直方图可以帮助优化器确定最佳的连接顺序,以便尽早过滤掉尽可能多的行。

具有非均匀数据分布的数据的一些例子是状态值、类别、一天中的时间、工作日和价格。状态栏可能有大量处于终止状态(如“已完成”或“失败”)的行和一些处于工作状态的值。同样,产品表中某些类别的产品可能比其他类别的多。一天中的时间和工作日的值可能不一致,因为某些事件在某些时间或日期比其他时间或日期更有可能发生。例如,球赛发生的工作日可能(取决于运动)更可能发生在周末而不是工作日。对于价格,您可能有大多数产品在一个相对狭窄的价格范围内,但最低和最高价格都在这个范围之外。低选择性列的例子有数据类型为enum的列、布尔值和其他只有几个唯一值的列。

与索引相比,直方图的一个好处是,在确定一个范围内的行数时,直方图比索引更便宜,例如,对于长的IN子句或许多OR条件。这是因为直方图统计信息很容易为优化器所用,而索引在确定查询计划时估计一个范围内的行数,因此要对每个查询重复进行。

Tip

对于索引列,当有eq_range_index_dive_limit(默认为 200)或更多的相等范围时,优化器将从执行相对昂贵但非常精确的索引挖掘切换到仅使用索引统计来估计匹配行的数量。

当您可以添加索引时,您可能会争论为什么要麻烦直方图,但是请记住,随着数据的变化,维护索引并不是没有成本的。当您执行 DML 查询时,需要对它们进行维护,并且它们会增加表空间文件的大小。此外,在执行查询的优化阶段,会动态计算某个范围(包括相等范围)内的值数量的统计信息。也就是说,它们是根据每个查询的需要进行计算的。另一方面,直方图只存储统计数据,只有在明确请求时才会更新。直方图统计对于优化器来说也总是可用的。

总之,直方图的最佳候选列是符合以下标准的列:

  • 数据分布不均匀,或者值太多,以至于优化器的粗略估计(将在下一章讨论)不能很好地估计数据的选择性。

  • 选择性差(否则索引可能是更好的选择)。

  • 用于在WHERE子句或连接条件中过滤表中的数据。如果不对列进行筛选,优化器将无法使用直方图。

  • 随着时间的推移具有稳定的数据分布。直方图统计不会自动更新,因此如果在数据分布频繁变化的列上添加直方图,直方图统计很可能不准确。直方图不适合的一个主要例子是存储事件日期和时间的列。

这些规则的一个例外是,如果您可以使用直方图统计来取代昂贵的查询。可以查询直方图统计数据,因为它将在“检查直方图数据”一节中显示,所以如果您只需要数据分布的近似结果,您可能能够改为查询直方图统计数据。

Tip

如果您有确定给定范围内的值的数量的查询,并且您只需要近似值,那么即使您不打算使用直方图来改进查询计划,也可以考虑创建直方图。

因为直方图存储列中的值,所以不允许向加密表中添加直方图。否则,加密的数据可能会意外地以未加密的形式写入磁盘。此外,临时表上不支持直方图。

为了以最佳方式应用直方图,您需要了解一些直方图的内部工作原理,包括支持的直方图类型。

直方图内部

为了有效地使用直方图,有必要了解直方图的一些内部信息。您应该理解的概念是存储桶、累积频率和直方图类型。本节将逐一介绍这些概念。

大量

创建直方图时,值被分布到存储桶。每个存储桶可能包含一个或多个不同的值,对于每个存储桶,MySQL 计算累积频率。因此,桶的概念很重要,因为它与直方图统计的准确性紧密相关。

MySQL 最多支持 1024 个桶。存储桶越多,每个存储桶中的值就越少,因此存储桶越多,每个值的统计数据就越精确。在最好的情况下,每个存储桶只有一个值,所以您“确切地”(在统计数据准确的程度上)知道该值的行数。如果每个时段有多个值,则计算值范围的行数。

在这种情况下,理解什么构成独特的价值是很重要的。对于字符串,在值的比较中只考虑前 42 个字符,对于二进制值,只考虑前 42 个字节。如果您有带有相同前缀的长字符串或二进制值,直方图可能不太适合您。

Note

只有字符串的前 42 个字符和二进制对象的前 42 个字节用于确定直方图的值。

值是按顺序相加的,因此,如果您将存储桶从左到右排序并检查给定的存储桶,那么您就会知道左边的所有存储桶都具有较小的值,而右边的所有存储桶都具有较大的值。铲斗的概念如图 16-1 所示。

img/484666_1_En_16_Fig1_HTML.jpg

图 16-1

分布到桶中的值和累积频率

在图中,前面的黑柱是每个桶中值的频率。频率是具有该值的行的百分比。背景中(颜色较亮的列)是累积频率,其值与时段 0 的计数列相同,然后逐渐增加,直到时段 7 达到 100。什么是累积频率?这是你应该理解的直方图的第二个概念。

累积频率

存储桶的累积频率是当前存储桶和先前存储桶中的行的百分比。如果您正在查看第 3 个存储桶,并且累积频率为 50%,那么 50%的行适合存储桶 0、1、2 和 3。这使得优化器可以很容易地用直方图确定列的选择性。

计算选择性时,有两种情况需要考虑:相等条件和范围条件。对于相等条件,优化器确定该条件的值在哪个桶中,然后获取该桶的累积频率,并减去前一桶的累积频率(对于桶 0,不减去任何内容)。如果桶中只有一个值,那就足够了。否则,优化器假定存储桶中的每个值都以相同的频率出现,因此存储桶的频率除以存储桶中值的数量。

对于范围条件,其工作方式非常相似。优化器会找到边缘条件所在的存储桶。例如,对于val < 4,定位值为 4 的桶。使用的累积频率取决于桶中值的数量和条件类型。至于相等条件,对于多值桶,通过假设桶中的值的相等分布来找到累积频率。根据条件类型,累积频率使用如下:

  • 小于:使用先前值的累积频率。

  • 小于等于:使用条件中值的累计频率。

  • 大于等于:前一值的累计频率减 1。

  • 大于:从 1 中减去条件中值的累计频率。

这意味着,通过使用累积频率,最多需要考虑两个存储桶来确定条件对表中的行的过滤程度。看一个例子会有助于更好地理解累积频率是如何工作的。表 16-1 显示了一个直方图示例,每个桶有一个值,每个桶有累积频率。

表 16-1

每个存储桶一个值的直方图

|

水桶

|

价值

|

累积频率

|
| --- | --- | --- |
| Zero | Zero | Zero point one |
| one | one | Zero point two five |
| Two | Two | Zero point three seven |
| three | three | Zero point five five |
| four | four | Zero point six three |
| five | five | Zero point eight three |
| six | six | Zero point nine five |
| seven | seven | One |

在本例中,这些值与桶号相同,但通常情况并非如此。累积频率从 0.1 (10%)开始,随着每个存储桶中行的百分比增加,直到最后一个存储桶达到 100%。这种分布与图 16-1 中看到的分布相同。

如果将五种条件类型与值 4 进行比较,那么每种类型的估计行数如下:

  • val = 4 : 从桶 4 的累计频率中减去桶 3 的累计频率:estimate = 0.63 – 0.55 = 0.08。因此,估计有 8%的行将被包括在内。

  • val < 4 : 使用桶 3 的累积频率,所以估计包括 55%的行。

  • val <= 4 : 使用存储桶 4 的累积频率,因此估计包括 63%的行。

  • val >= 4 : 从 1 中减去桶 3 的累积频率,因此估计包括 45%的行。

  • val > 4 : 从 1 中减去桶 4 的累积频率,因此估计包括 37%的行。

当每个桶中包含多个值时,情况会变得稍微复杂一些。表 16-2 显示了相同的表和值的分布,但是这次直方图只有四个桶,所以平均每个桶有两个值。

表 16-2

每个存储桶有多个值的直方图

|

水桶

|

价值观念

|

累积频率

|
| --- | --- | --- |
| Zero | 0-1 | Zero point two five |
| one | 2-3 | Zero point five five |
| Two | 4-5 | Zero point eight three |
| three | 6-7 | One |

在这种情况下,每个桶中恰好有两个值,但通常情况下并非如此(在讨论直方图类型时会有更多相关信息)。现在,评估相同的五个条件需要考虑每个存储桶包括对多个值的行数的估计:

  • val = 4 : 桶 2 的累计频率减去桶 1 的累计频率;然后结果除以桶 2 中值的个数:estimate = (0.83 – 0.55)/2 = 0.14。因此,估计会包含 14%的行。这高于每桶一个值的更精确的估计,因为值 4 和 5 的频率被一起考虑。

  • val < 4 : 时段 1 的累计频率是唯一需要的,因为时段 0 和 1 包括所有小于 4 的值。因此,估计将包括 55%的行(这与前面的示例相同,因为在这两种情况下,估计只需要考虑完整的存储桶)。

  • val <= 4 : 这更复杂,因为桶 2 中的值有一半包含在过滤中,另一半没有。因此,估计值将是桶 1 的累积频率加上桶 2 的频率除以桶中值的数量:estimate = 0.55 + (0.83 – 0.55)/2 = 0.69或 69%。这比使用每个桶一个值的估计值要高,但不太准确。这种估计不太准确的原因是,它假设值 4 和 5 具有相同的频率。

  • val >= 4 : 该条件需要桶 2 和桶 3 中的所有值,所以估计是包括 1 减去桶 1 的累计频率;这是 45%,与每个桶一个值的情况下的估计值相同。

  • val > 4 : 这种情况和val <= 4类似,只是要包含的值是相反的,所以你可以取 0.69,然后从 1 中减去,得到 0.31 或者 31%。同样,因为涉及到两个桶,所以估计不如每个桶的单个值精确。

如您所见,将值分布到存储桶中有两种不同的情况:要么存储桶的数量至少与值的数量相同,并且可以为每个值分配自己的存储桶,要么多个值必须共享一个存储桶。这是两种不同类型的直方图,接下来将讨论它们的细节。

直方图类型

MySQL 8 中有两种类型的直方图。在创建或更新直方图时,会根据值是否多于存储桶来自动选择直方图类型。两种直方图类型是

  • 单例:对于单例直方图,每个桶只有一个值。这些是最精确的直方图,因为在创建直方图时存在对每个值的估计。

  • Equi-height: 当列的值多于桶的数量时,MySQL 将分配这些值,因此每个桶的行数大致相同——也就是说,每个桶的高度大致相同。因为具有相同值的所有行都被分配到同一个存储桶,所以存储桶的高度不会完全相同。对于等高直方图,每个桶代表不同数量的值。

在研究累积频率时,您已经遇到了这两种直方图类型。单一直方图是最简单和最准确的,但等高直方图是最灵活的,因为它们可以处理任何数据集。

为了演示单一高度和等高度直方图,您可以从world.city表创建city_histogram表,其中包含基于八个国家代码的城市子集。可以使用以下查询创建该表:

use world

CREATE TABLE city_histogram LIKE city;

INSERT INTO city_histogram
SELECT *
  FROM city
 WHERE CountryCode IN
          ('AUS', 'BRA', 'CHN', 'DEU',
           'FRA', 'GBR', 'IND', 'USA');

16-2 显示了CountryCode列上的单一直方图示例。因为有八个值,所以直方图有八个桶。(您将在本章的后面了解如何创建和检索直方图统计。)

img/484666_1_En_16_Fig2_HTML.jpg

图 16-2

单一直方图

直方图中每个桶只有一个值。从澳大利亚的 1.0%到中国的 24.9%不等。这是一个例子,如果在CountryCode列上没有索引,直方图可以极大地帮助给出更精确的过滤估计。原始的world.city表有 232 个不同的CountryCode值,所以单一直方图效果很好。

16-3 显示了相同数据的等高直方图,但只有四个桶用于统计。

img/484666_1_En_16_Fig3_HTML.jpg

图 16-3

等高直方图

对于等高直方图,MySQL 的目标是让每个桶的频率(高度)相同。但是,由于列值将完全位于一个存储桶中,并且值是按顺序分布的,因此通常不可能获得完全相同的高度。在本例中也是如此,桶 0 和桶 3 的频率比桶 1 和桶 2 的频率稍小。

该图还显示了等高直方图的缺点。巴西(BRA)、中国(CHN)和印度(IND)城市的高频率在某种程度上被它们共享水桶的国家的低频率所掩盖。因此,等高直方图的精度不如单一直方图的精度高。当值的频率变化很大时尤其如此。一般来说,相等条件比范围条件更容易降低精度,因此等高直方图最适合主要用于范围条件的列。

在使用直方图统计数据之前,您需要创建它们,并且一旦创建,您需要维护统计数据。如何做到这一点是下一节的主题。

添加和维护直方图

直方图只作为统计信息存在,与在表空间中有物理存在的索引不同。使用也用于更新索引统计信息的ANALYZE TABLE语句来创建、更新和删除直方图也就不足为奇了。该语句有两种变体:更新统计数据和删除统计数据。在创建和更新直方图时,您还需要知道采样率。本节将逐一介绍这些主题。

创建和更新直方图

您可以通过将UPDATE HISTOGRAM子句添加到ANALYZE TABLE语句来创建或更新直方图。如果没有统计数据,并且提出了更新请求,则创建直方图;否则,现有的直方图将被替换。您需要指定要将统计数据分成多少个时段。

要使用最多 256 个存储桶向sakila.film表的length列添加直方图(length以分钟为单位,因此 256 个存储桶应该足以确保一个单独的直方图),可以使用如下示例所示的语句:

mysql> ANALYZE TABLE sakila.film
        UPDATE HISTOGRAM ON length
          WITH 256 BUCKETS\G
**************************** 1\. row *****************************
   Table: sakila.film
      Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'length'.
1 row in set (0.0057 sec)

或者,您可以在ANALYZETABLE之间添加NO_WRITE_TO_BINLOGLOCAL关键字,以避免将语句写入二进制日志。这与更新索引统计信息的方式相同。

Tip

如果您不想将ANALYZE TABLE语句写入二进制日志,请添加NO_WRITE_TO_BINLOGLOCAL关键字,例如ANALYZE LOCAL TABLE ...

ANALYZE TABLE无错误地完成直方图的创建时,Msg_type将等于status,并且Msg_text显示已经创建了直方图统计以及针对哪一列。如果出现错误,Msg_type等于Error,而Msg_text解释问题。例如,如果您尝试为不存在的列创建直方图,错误将类似于以下示例:

mysql> ANALYZE TABLE sakila.film
        UPDATE HISTOGRAM ON len
          WITH 256 BUCKETS\G
**************************** 1\. row ***************************
   Table: sakila.film
      Op: histogram
Msg_type: Error
Msg_text: The column 'len' does not exist.
1 row in set (0.0004 sec)

还可以使用相同的语句更新同一表中几个列的直方图。例如,如果您想更新sakila.film表的lengthrating列的直方图,您可以使用清单 16-1 中的语句。

mysql> ANALYZE TABLE sakila.film
        UPDATE HISTOGRAM ON length, rating
          WITH 256 BUCKETS\G
*************************** 1\. row ***************************
   Table: sakila.film
      Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'length'.
**************************** 2\. row ***************************
   Table: sakila.film
      Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'rating'.
2 rows in set (0.0119 sec)

Listing 16-1Updating histograms for multiple columns

你应该选择多少桶?如果唯一值少于 1024 个,建议使用足够的存储桶来创建单一直方图(即至少与唯一值一样多的存储桶)。如果选择的存储桶多于值,MySQL 将只使用存储每个值的频率所需的存储桶。从这个意义上来说,桶的数量应该是可以使用的最大数量。

如果有超过 1024 个不同的值,您需要足够的桶来获得数据的良好表示。25 到 100 桶通常是一个好的起点。对于 100 个桶,等高直方图在每个桶中平均有 1%的行。行的分布越均匀,需要的桶就越少,分布差异越大,需要的桶就越多。目标是将最频繁出现的值放在自己的存储桶中。例如,对于上一节中使用的world.city表的子集,五个存储桶将中国(CHN)、印度(IND)和美国放在它们自己的存储桶中。

直方图是通过采样值创建的。如何实现取决于可用的内存量。

抽样

当 MySQL 创建直方图时,它需要读取行来确定可能的值及其频率。这与索引统计数据的采样方式类似,但又有所不同。计算索引统计数据时,确定唯一值的数量,这是一项简单的任务,因为它只需要计数。因此,您所需要指定的就是您想要取样多少个页面。

对于直方图,MySQL 不仅要确定不同值的数量,还要确定它们的频率以及如何将值分布到桶中。因此,采样值被读入内存,然后用于创建存储桶和计算直方图统计。这意味着指定可用于采样的内存量比指定页数更自然。根据可用的内存量,MySQL 将决定可以采样多少个页面。

Tip

在 MySQL 8.0.18 和更早的版本中,总是需要全表扫描。在 MySQL 8.0.19 和更高版本中,InnoDB 可以直接自己执行采样,因此它可以跳过不会在采样中使用的页面。这使得大型表的采样更加有效。information_schema.INNODB_METRICS中的sampled_pages_readsampled_pages_skipped计数器为 InnoDB 提供关于被采样和跳过的页面的统计信息。

ANALYZE TABLE .. UPDATE HISTOGRAM …语句中可用的内存由histogram_generation_max_mem_size选项指定。默认值为 20,000,000 字节。在“检查直方图数据”一节中讨论的information_schema.COLUMN_STATISTICS视图包含了关于最终采样率的信息。如果您没有获得预期的过滤精度,您可以检查采样率,如果采样率低,您可以增加histogram_generation_max_mem_size的值。被采样的页面数量与可用内存量成线性比例,而存储桶的数量对采样速率没有任何影响。

删除直方图

如果您确定不再需要直方图,可以再次删除它。与更新直方图统计数据一样,您可以使用DROP HISTOGRAM子句使用ANALYZE TABLE语句删除统计数据。您可以在一条语句中删除一个或多个直方图。清单 16-2 显示了在sakila.film表的lengthrating列上删除直方图的例子。本章后面的示例部分包括一个查询,可用于查找所有现有直方图。

mysql> ANALYZE TABLE sakila.film
          DROP HISTOGRAM ON length, rating\G
*************************** 1\. row ***************************
   Table: sakila.film
      Op: histogram
Msg_type: status
Msg_text: Histogram statistics removed for column 'length'.
*************************** 2\. row ***************************
   Table: sakila.film
      Op: histogram
Msg_type: status
Msg_text: Histogram statistics removed for column 'rating'.
2 rows in set (0.0120 sec)

Listing 16-2Dropping histograms

ANALYZE TABLE语句的输出类似于创建统计数据。您还可以选择在ANALYZETABLE之间添加NO_WRITE_TO_BINLOGLOCAL关键字,以避免将语句写入二进制日志。

一旦有了直方图,如何检查它们的统计数据和元数据?您可以使用下面讨论的信息模式。

检查直方图数据

当查询计划不是您所期望的时候,知道哪些信息对优化器是可用的非常重要。就像索引统计数据有各种视图一样,信息模式也包含一个视图,因此您可以查看直方图统计数据。数据可通过information_schema.COLUMN_STATISTICS视图获得。下一节包括使用这个视图检索直方图信息的例子。

COLUMN_STATISTICS视图是包含直方图信息的数据字典部分的视图。表 16-3 总结了四列。

表 16-3

列统计视图

|

列名

|

数据类型

|

描述

|
| --- | --- | --- |
| SCHEMA_NAME | varchar(64) | 表所在的架构。 |
| TABLE_NAME | varchar(64) | 直方图的列所在的表。 |
| COLUMN_NAME | varchar(64) | 带直方图的列。 |
| HISTOGRAM | json | 直方图的细节。 |

前三列(SCHEMA_NAMETABLE_NAMECOLUMN_NAME)构成主键,允许您查询感兴趣的直方图。HISTOGRAM列是最有趣的,因为它存储了直方图的元数据和直方图统计数据。

直方图信息以 JSON 文档的形式返回,该文档包含几个对象,这些对象包含统计数据的创建时间、采样率和统计数据本身等信息。表 16-4 显示了文件中包含的字段。当您查询COLUMN_STATISTICS视图时,这些字段按字母顺序列出,可能与它们被包含的顺序不同。

表 16-4

JSON 文档中直方图列的字段

|

字段名

|

JSON 类型

|

描述

|
| --- | --- | --- |
| buckets | 排列 | 每个桶有一个元素的数组。每个存储桶的可用信息取决于直方图类型,稍后将对其进行描述。 |
| collation-id | 整数 | 数据排序规则的 id。这仅与字符串数据类型相关。id 与INFORMATION_SCHEMA.COLLATIONS视图中的ID列相同。 |
| data-type | 线 | 为其创建直方图的列中数据的数据类型。这不是一个 MySQL 数据类型,而是一个更通用的类型,比如字符串类型的“string”。可能的值有 int、uint(无符号整数)、double、decimal、datetime 和 string。 |
| histogram-type | 线 | 直方图类型,单一或等高。 |
| last-updated | 线 | 上次更新统计数据的时间。格式为YYYY-mm-dd HH:MM:SS.uuuuuu。 |
| null-values | 小数 | 采样值的分数,即NULL。该值介于 0.0 和 1.0 之间。 |
| number-of-buckets-specified | 整数 | 请求的存储桶数。对于单一直方图,这可能大于桶的实际数量。 |
| sampling-rate | 小数 | 表中被采样的页面比例。该值介于 0.0 和 1.0 之间。当值为 1.0 时,读取整个表,统计数据是精确的。 |

该视图不仅有助于确定直方图统计数据,还可以用它来检查元数据,例如,确定统计数据自上次更新以来已经过了多长时间,并使用它来确保统计数据定期更新。

buckets字段值得更多关注,因为它是存储统计数据的地方。这是一个每个桶有一个元素的数组。per bucket 元素本身就是 JSON 数组。对于单一直方图,每个桶有两个元素,而对于等高直方图,有四个元素。

单一直方图包含的元素有

  • 索引 0: 存储桶的列值。

  • 指标 1: 累计频率。

等高统计的信息是类似的,但是总共有四个元素来说明每个存储桶可能包含多个列值的信息。这些要素是

  • 索引 0: 包含在桶中的列值的下限。

  • 索引 1: 包含在桶中的列值的上界。

  • 指标 2: 累计频率。

  • 索引 3: 包含在桶中的值的个数。

如果您回过头来考虑计算各种条件下的预期过滤效果的示例,您可以看到桶信息包括所有必要的信息,但也不包括任何额外的信息。

由于直方图数据存储为一个 JSON 文档,所以有必要看一下检索各种信息的几个示例查询。

直方图报告示例

COLUMN_STATISTICS视图对于查询直方图数据非常有用。因为元数据和统计数据存储在 JSON 文档中,所以考虑一些可用的 JSON 操作函数是很有用的,这样就可以检索直方图报告。本节将展示几个为系统中的直方图生成报告的例子。所有的例子也可以从这本书的 GitHub 库中获得,例如,清单 16-3 中的查询可以在文件listing_16_3.sql中获得。

列出所有直方图

一个基本的报告是列出 MySQL 实例中的所有直方图。要包含的一些相关信息是直方图的模式信息、直方图类型、直方图上次更新的时间、采样率、存储桶的数量等等。清单 16-3 显示了一个直方图的查询和输出(根据您创建的直方图,您可能会看到不同的直方图列表)。

mysql> SELECT SCHEMA_NAME, TABLE_NAME, COLUMN_NAME,
       HISTOGRAM->>'$."histogram-type"' AS Histogram_Type,
       CAST(HISTOGRAM->>'$."last-updated"'
           AS DATETIME(6)) AS Last_Updated,
       CAST(HISTOGRAM->>'$."sampling-rate"'
           AS DECIMAL(4,2)) AS Sampling_Rate,
       JSON_LENGTH(HISTOGRAM->'$.buckets')
           AS Number_of_Buckets,
       CAST(HISTOGRAM->'$."number-of-buckets-specified"'AS UNSIGNED) AS Number_of_Buckets_Specified
  FROM information_schema.COLUMN_STATISTICS\G
**************************** 1\. row ****************************
                SCHEMA_NAME: sakila
                 TABLE_NAME: film
                COLUMN_NAME: length
             Histogram_Type: singleton
               Last_Updated: 2019-06-02 08:49:18.261357
              Sampling_Rate: 1.00
          Number_of_Buckets: 140
Number_of_Buckets_Specified: 256
1 row in set (0.0006 sec)

Listing 16-3Listing all histograms

该查询提供了直方图的高级视图。->操作符从 JSON 文档中提取一个值,而->>操作符另外取消对提取值的引用,这在提取字符串时会很有用。例如,从示例输出中,您可以看到sakila.film表中的length列上的直方图有 140 个存储桶,但是请求了 256 个存储桶。您还可以看到它是一个单独的直方图,这并不奇怪,因为并不是所有请求的存储桶都被使用了。

列出单个直方图的所有信息

查看直方图的整个输出会很有用。例如,考虑本章前面为八个国家创建并填充数据的world.city_histogram表。您可以在CountryCode列上创建一个具有四个桶的等高直方图,如下所示

ANALYZE TABLE world.city_histogram
 UPDATE HISTOGRAM ON CountryCode
   WITH 4 BUCKETS;

清单 16-4 查询该直方图的数据。这与讨论等比直方图时用于图 16-3 的直方图相同。

mysql> SELECT JSON_PRETTY(HISTOGRAM) AS Histogram
         FROM information_schema.COLUMN_STATISTICS
        WHERE SCHEMA_NAME = 'world'
              AND TABLE_NAME = 'city_histogram'
              AND COLUMN_NAME = 'CountryCode'\G
**************************** 1\. row ****************************
Histogram: {
  "buckets": [
    [
      "base64:type254:QVVT",
      "base64:type254:QlJB",
      0.1813186813186813,
      2
    ],
    [
      "base64:type254:Q0hO",
      "base64:type254:REVV",
      0.4945054945054945,
      2
    ],
    [
      "base64:type254:RlJB",
      "base64:type254:SU5E",
      0.8118131868131868,
      3
    ],
    [
      "base64:type254:VVNB",
      "base64:type254:VVNB",
      1.0,
      1
    ]
  ],
  "data-type": "string",
  "null-values": 0.0,
  "collation-id": 8,
  "last-updated": "2019-06-03 10:35:42.102590",
  "sampling-rate": 1.0,
  "histogram-type": "equi-height",
  "number-of-buckets-specified": 4
}
1 row in set (0.0006 sec)

Listing 16-4Retrieving all data for a histogram

这个查询有几个有趣的地方。JSON_PRETTY()功能用于方便读取直方图信息。如果没有JSON_PRETTY()函数,整个文档将作为单行返回。

还要注意,每个的下限和上限都作为 base64 编码的字符串返回。这是为了确保直方图可以处理字符串和二进制列中的任何值。其他数据类型直接存储它们的值。

列出单一直方图的存储桶信息

在前面的例子中,查询了直方图的原始数据。通过使用JSON_TABLE()函数将数组转换成表格输出,可以更好地处理桶信息。示例中使用的表是city_histogram,它是八个国家的world.city表的副本,以避免过多的输出。在CountryCode列上有一个单独的直方图:

ANALYZE TABLE world.city_histogram
 UPDATE HISTOGRAM ON CountryCode
   WITH 8 BUCKETS;

这与讨论单一直方图时图 16-2 中的例子使用的直方图相同。清单 16-5 显示了一个为单一直方图这样做的例子。

mysql> SELECT (Row_ID - 1) AS Bucket_Number,
              SUBSTRING_INDEX(Bucket_Value, ':', -1) AS
                  Bucket_Value,
              ROUND(Cumulative_Frequency * 100, 2) AS
                  Cumulative_Frequency,
              ROUND((Cumulative_Frequency - LAG(Cumulative_Frequency, 1, 0) OVER()) * 100, 2) AS Frequency
         FROM information_schema.COLUMN_STATISTICS
              INNER JOIN JSON_TABLE(
                 histogram->'$.buckets',
                 '$[*]' COLUMNS(
                      Row_ID FOR ORDINALITY,
                      Bucket_Value varchar(42) PATH '$[0]',
                      Cumulative_Frequency double PATH '$[1]'
                 )
              ) buckets
        WHERE SCHEMA_NAME  = 'world'
              AND TABLE_NAME = 'city_histogram'
              AND COLUMN_NAME = 'CountryCode'
        ORDER BY Row_ID\G
**************************** 1\. row *****************************
       Bucket_Number: 0
        Bucket_Value: AUS
Cumulative_Frequency: 0.96
           Frequency: 0.96
**************************** 2\. row ****************************
       Bucket_Number: 1
        Bucket_Value: BRA
Cumulative_Frequency: 18.13
           Frequency: 17.17
**************************** 3\. row *****************************
       Bucket_Number: 2
        Bucket_Value: CHN
Cumulative_Frequency: 43.06
           Frequency: 24.93
**************************** 4\. row *****************************
       Bucket_Number: 3
        Bucket_Value: DEU
Cumulative_Frequency: 49.45
           Frequency: 6.39
**************************** 5\. row *****************************
       Bucket_Number: 4
        Bucket_Value: FRA
Cumulative_Frequency: 52.2
           Frequency: 2.75
**************************** 6\. row *****************************
       Bucket_Number: 5
        Bucket_Value: GBR
Cumulative_Frequency: 57.76
           Frequency: 5.56
**************************** 7\. row *****************************
       Bucket_Number: 6
        Bucket_Value: IND
Cumulative_Frequency: 81.18
           Frequency: 23.42
**************************** 8\. row *****************************
       Bucket_Number: 7
        Bucket_Value: USA
Cumulative_Frequency: 100
           Frequency: 18.82
8 rows in set (0.0008 sec)

Listing 16-5Listing the bucket information for a singleton histogram

该查询将JSON_TABLE()函数 1 上的COLUMN_STATISTICS视图连接起来,将 JSON 文档转换成 SQL 表。该函数有两个参数,第一个是 JSON 文档,第二个是结果表的值和列定义的路径。列定义包括为每个时段创建的三列:

  • Row_ID : 该列有一个FOR ORDINALITY子句,使其成为一个基于 1 的自动递增计数器,因此它可以通过减去 1 来用于桶号。

  • Bucket_Value : 与桶一起使用的列值。请注意,该值是在从 base64 编码解码后返回的,因此相同的查询适用于字符串和数值。

  • Cumulative_Frequency : 以 0.0-1.0 之间的十进制数表示时段的累计频率。

JSON_TABLE()函数的结果可以像派生表一样使用。累积频率在查询的SELECT部分被转换成百分比,并且LAG()窗口函数 2 被用于计算每个桶的频率(也作为百分比)。

列出等高直方图的存储桶信息

检索等高直方图的存储桶信息的查询与刚才讨论的单一直方图的查询非常相似。唯一的区别是等高直方图有两个值(间隔的开始和结束)来定义桶和桶中值的数量。

例如,您可以使用四个存储桶在world.city_histogram表的CountryCode列上创建一个直方图:

ANALYZE TABLE world.city_histogram
 UPDATE HISTOGRAM ON CountryCode
   WITH 4 BUCKETS;

清单 16-6 显示了用四个存储桶提取world.city_histogram表中CountryCode列的存储桶信息的例子。

mysql> SELECT (Row_ID - 1) AS Bucket_Number,
              SUBSTRING_INDEX(Bucket_Value1, ':', -1) AS
                  Bucket_Lower_Value,
              SUBSTRING_INDEX(Bucket_Value2, ':', -1) AS
                  Bucket_Upper_Value,
              ROUND(Cumulative_Frequency * 100, 2) AS
                  Cumulative_Frequency,
              ROUND((Cumulative_Frequency - LAG(Cumulative_Frequency, 1, 0) OVER()) * 100, 2) AS Frequency,
              Number_of_Values
         FROM information_schema.COLUMN_STATISTICS
              INNER JOIN JSON_TABLE(
                 histogram->'$.buckets',
                 '$[*]' COLUMNS(
                      Row_ID FOR ORDINALITY,
                      Bucket_Value1 varchar(42) PATH '$[0]',
                      Bucket_Value2 varchar(42) PATH '$[1]',
                      Cumulative_Frequency double PATH '$[2]',
                      Number_of_Values int unsigned PATH '$[3]'
                 )
              ) buckets
        WHERE SCHEMA_NAME  = 'world'
              AND TABLE_NAME = 'city_histogram'
              AND COLUMN_NAME = 'CountryCode'
        ORDER BY Row_ID\G
**************************** 1\. row *****************************
       Bucket_Number: 0
  Bucket_Lower_Value: AUS
  Bucket_Upper_Value: BRA
Cumulative_Frequency: 18.13
           Frequency: 18.13

    Number_of_Values: 2
**************************** 2\. row *****************************
       Bucket_Number: 1
  Bucket_Lower_Value: CHN
  Bucket_Upper_Value: DEU
Cumulative_Frequency: 49.45
           Frequency: 31.32
    Number_of_Values: 2
**************************** 3\. row *****************************
       Bucket_Number: 2
  Bucket_Lower_Value: FRA
  Bucket_Upper_Value: IND
Cumulative_Frequency: 81.18
           Frequency: 31.73
    Number_of_Values: 3
**************************** 4\. row *****************************
       Bucket_Number: 3
  Bucket_Lower_Value: USA
  Bucket_Upper_Value: USA
Cumulative_Frequency: 100
           Frequency: 18.82
    Number_of_Values: 1
4 rows in set (0.0011 sec)

Listing 16-6Listing the bucket information for an equi-height histogram

现在您有了一些检查直方图数据的工具,剩下的就是展示直方图如何改变查询计划的例子。

查询示例

直方图的主要目标是帮助优化器实现执行查询的最佳方式。看一个直方图如何影响优化器来改变查询计划的例子是很有用的,所以为了结束这一章,我们将讨论一个当直方图被添加到WHERE子句中的一列时改变计划的查询。

该查询使用sakila示例数据库,并查询短于 55 分钟且以名为 Elvis 的演员为主角的电影。这似乎是一个人为的例子,但是类似的查询很常见,例如,为满足某些条件的客户查找订单。此示例查询可以编写如下:

SELECT film_id, title, length,
       GROUP_CONCAT(
           CONCAT_WS(' ', first_name, last_name)
       ) AS Actors
  FROM sakila.film
       INNER JOIN sakila.film_actor USING (film_id)
       INNER JOIN sakila.actor USING (actor_id)
 WHERE length < 55 AND first_name = 'Elvis'
 GROUP BY film_id;

film_idtitlelength列来自film表,而first_namelast_name列来自actor表。如果电影中有不止一个名为猫王的演员,则使用GROUP_CONCAT()功能。(这个查询的另一种方法是使用EXISTS(),但是这样名字为 Elvis 的演员的全名就会包含在查询结果中。)

lengthfirst_name列上没有索引,所以优化器无法知道这些列上的条件过滤得有多好。默认情况下,它假设length上的条件返回电影表中大约三分之一的行,而first_name上的条件返回 10%的行。(下一章包括这些默认过滤器值的来源。)

16-4 显示了不存在直方图时的查询计划。查询计划显示为直观解释图,这将在第 20 章中讨论。

Tip

您可以通过在 MySQL Workbench 中执行查询并单击查询结果右侧的执行计划按钮来创建一个可视化的解释图。

img/484666_1_En_16_Fig4_HTML.jpg

图 16-4

没有直方图的查询计划

在查询计划中需要注意的重要事情是,优化器已经选择从对actor表进行全表扫描开始,然后遍历film_actor表,最后在film表上进行连接。总查询成本(在图的右上角)计算为 467.20(图中的查询成本数字可能与您得到的不同,因为它们取决于索引和直方图统计)。

如上所述,默认情况下,优化器估计大约三分之一的电影长度小于 55 分钟。刚刚给出了length的可能值的范围,这表明这是一个糟糕的估计(但是优化器对电影一无所知,所以它看不到)。事实上,只有 6.6%的电影长度在这个范围内。这使得length列成为直方图的一个很好的候选,您可以像前面显示的那样添加它:

ANALYZE TABLE sakila.film
 UPDATE HISTOGRAM ON length
   WITH 256 BUCKETS;

现在查询计划的变化如图 16-5 所示。

img/484666_1_En_16_Fig5_HTML.jpg

图 16-5

长度列上有直方图的查询计划

直方图意味着如果首先扫描film表,现在优化器确切地知道将返回多少行。这将查询的总开销降低到了 282.26,这是一个很好的改进。(同样,根据您的索引统计,您可能会看到不同的变化。示例中重要的一点是直方图改变了查询计划和估计成本。)

Note

实际上,本例中使用的表中的行很少,所以查询执行的顺序并不重要。然而,在现实世界的例子中,使用直方图可以提供很大的增益,在某些情况下超过一个数量级。

这个例子的有趣之处还在于,如果您更改条件来查找短于 60 分钟的电影,那么连接顺序会更改回首先扫描actor表。原因是,有了那个条件,足够的电影将根据长度被包括在内,这是更好地开始寻找候选演员。同样,如果您另外在 actor 表的first_name上添加一个直方图,优化器将意识到名字是这个数据库中 actor 的一个相当好的过滤器;尤其是,只有一个演员叫猫王。留给读者一个练习,尝试改变WHERE子句和直方图,看看查询计划如何变化。

摘要

本章展示了如何使用直方图来改进优化器在尝试确定最佳查询计划时可用的信息。直方图将列值划分为多个桶,每个桶一个值称为单一直方图,或者每个桶多个值称为等高直方图。对于每个存储桶,确定遇到这些值的频率,并计算每个存储桶的累积频率。

直方图主要用于不适合拥有索引的列,但它们仍然用于在具有连接的查询中进行筛选。在这种情况下,直方图可以帮助优化器确定最佳的连接顺序。本章最后给出了一个例子,展示直方图如何改变查询的连接顺序。

直方图的元数据和统计数据可在information_schema.COLUMN_STATISTICS视图中查看。这些信息包括优化器使用的每个存储桶的所有数据,以及元数据,如直方图上次更新的时间、直方图类型和请求的存储桶数量。

在查询示例中,提到了优化器对于各种条件的估计过滤效果有一些缺省值。到目前为止,在关于索引和直方图的讨论中,优化器大多被忽略了。现在是时候改变这种情况了:下一章将讨论查询优化器。

十七、查询优化器

当你提交一个查询给 MySQL 执行的时候,就不仅仅是读取数据并返回那么简单了。的确,对于从单个表中请求所有数据的简单查询,没有多少检索数据的选择。然而,大多数查询更复杂——有些复杂得多——并且完全按照编写的方式执行查询绝不是获得结果的最有效方式。在阅读索引时,您已经接触到了这种复杂性。您可以添加索引选择、连接顺序、用于执行连接的算法、各种连接优化等等。这就是优化器发挥作用的地方。

优化器的主要工作是准备执行查询,并确定最佳查询计划。第一阶段包括对查询进行转换,目的是使重写后的查询能够以比原始查询更低的成本执行。第二阶段包括计算执行查询的各种方式的成本,并确定最便宜的选项。

Note

重要的是要认识到,由于数据及其分布的变化,优化器所做的工作并不是精确的科学。优化器选择的转换和计算的成本在某种程度上都是基于估计的。通常这些估计足以得到一个好的查询计划,但是偶尔您将需要提供提示。如何配置优化器将在本章后面的“配置优化器”一节中讨论。

本章首先讨论转换和基于成本的优化。然后,本章继续讨论基本的连接算法,以及其他优化特性,如批量键访问。本章的最后一部分介绍了如何配置优化器以及如何使用资源组来区分查询的优先级。

转换

人们认为编写查询很自然的方式可能与在 MySQL 中执行查询的最佳方式不同。优化器知道可以使用几种转换来更改查询,同时仍然返回相同的结果,因此查询对 MySQL 来说变得更好。

当然,最重要的是原始查询和重写查询返回相同的结果。幸运的是,关系数据库基于数学集合论,所以许多转换可以使用标准的数学规则,确保两个版本的查询返回相同的结果(bar 实现错误)。

优化器执行的最简单的转换类型之一是常量传播。例如,考虑以下查询:

SELECT *
  FROM world.country
       INNER JOIN world.city
          ON city.CountryCode = country.Code
 WHERE city.CountryCode = 'AUS';

这个查询有两个条件:city.CountryCode列必须等于“AUS”,city表的CountryCode列必须等于country表的Code列。根据这两个条件,可以推导出country.Code列也必须等于“AUS”。优化器使用这些知识直接过滤 country 表。由于Code列是country表的主键,这意味着优化器知道只有一行符合条件,并且优化器可以将country表视为常量。实际上,查询最终会以来自country表的列值作为选择列表中的常量来执行,并使用CountryCode = 'AUS'扫描city表中的条目:

SELECT 'AUS' AS `Code`,
       'Australia' AS `Name`,
       'Oceania' AS `Continent`,
       'Australia and New Zealand' AS `Region`,
       7741220.00 AS `SurfaceArea`,
       1901 AS `IndepYear`,
       18886000 AS `Population`,
       79.8 AS `LifeExpectancy`,
       351182.00 AS `GNP`,
       392911.00 AS `GNPOld`,
       'Australia' AS `LocalName`,
       'Constitutional Monarchy, Federation' AS `GovernmentForm`,
       'Elisabeth II' AS `HeadOfState`,
       135 AS `Capital`,
       'AU' AS `Code2`,
       city.*
  FROM world.city
 WHERE CountryCode = 'AUS';

从性能角度来看,这是一个安全的转换。其他转换更复杂,并不总是能提高性能。因此,可以配置是否启用优化。配置是使用optimizer_switch选项和优化器提示完成的,在讨论优化和如何配置优化器时会讨论这些选项和提示。

一旦优化器决定了要做哪些转换,它就需要决定如何执行重写的查询,这将在下面讨论。

基于成本的优化

MySQL 使用基于成本的查询优化。这意味着优化器计算执行查询所需的各种操作的成本,然后结合这些部分成本来计算可能的查询计划的总查询成本,并选择最便宜的计划。本节介绍了估计查询计划成本的原则。

基础知识:单表选择

不管查询是什么,计算成本的原则都是相同的,但是显然查询越复杂,成本估算就变得越复杂。举个简单的例子,考虑一个在索引列上使用WHERE子句查询单个表的查询:

SELECT *
  FROM world.city
 WHERE CountryCode = 'IND';

从表定义中可以看出,world.city表在CountryCode列上有一个次要的非唯一索引:

mysql> SHOW CREATE TABLE world.city\G
**************************** 1\. row ****************************
       Table: city
Create Table: CREATE TABLE `city` (
  `ID` int(11) NOT NULL AUTO_INCREMENT,
  `Name` char(35) NOT NULL DEFAULT ",
  `CountryCode` char(3) NOT NULL DEFAULT ",
  `District` char(20) NOT NULL DEFAULT ",
  `Population` int(11) NOT NULL DEFAULT '0',
  PRIMARY KEY (`ID`),
  KEY `CountryCode` (`CountryCode`),
  CONSTRAINT `city_ibfk_1` FOREIGN KEY (`CountryCode`) REFERENCES `country` (`Code`)
) ENGINE=InnoDB AUTO_INCREMENT=4080 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0008 sec)

优化器可以选择两种方式来获取匹配的行。一种方法是使用CountryCode上的索引来查找索引中的匹配行,然后查找所请求的行值。另一种方法是进行全表扫描,检查每一行,以确定它是否满足过滤条件。

在这些访问方法中,哪种成本最低(最快)并不像看起来那样容易确定。这取决于几个因素:

  • 该索引的选择性如何?通过二级索引读取一行包括首先在索引中找到该行,然后可能(参见下一项)执行主键查找以获得该行。这意味着使用辅助索引检查和检索行比直接读取行的开销更大,为了使索引访问总体上比表扫描更便宜,索引必须显著减少要检查的行数。索引的选择性越强,使用起来相对就越便宜。

  • 索引是覆盖索引吗?如果索引包含查询所需的所有列,则可以跳过实际行的读取,这样更有利于使用索引。

  • 读取记录有多贵?这同样取决于几个因素,例如索引和行数据是否已经在缓冲池中,如果没有,从磁盘读取记录的速度有多快。考虑到读取索引和读取聚集索引之间的切换,使用索引将需要更多的随机 I/O,因此定位记录所需的寻道时间变得非常重要。

MySQL 8 中的一个新特性是,优化器可以询问 InnoDB 查询所需的记录是否可以在缓冲池中找到,或者是否有必要从磁盘中读取。这可以极大地帮助改进查询计划。

读取记录所涉及的成本问题更加复杂,因为 MySQL 不知道硬件的性能特征。MySQL 8 默认假设从磁盘读取的开销是内存的四倍。这可以按照“配置优化器”一节中“引擎成本”中的讨论进行配置。

一旦在查询中引入第二个表,优化器还需要决定以何种顺序连接这些表。

表连接顺序

对于比单个 table SELECT语句更复杂的查询,优化器不仅需要考虑访问每个表的成本,还需要考虑每个表的包含顺序以及每个表使用哪个索引。

对于外部连接和直接连接,连接顺序是固定的,但是对于内部连接,优化器可以自由选择顺序,因此优化器必须计算每个组合的成本。可能的组合数量是 N!(阶乘),伸缩性很差。如果有五个表参与内部联接,优化器可以选择五个表作为第一个表,然后四个表作为第二个表,三个表作为第三个表,两个表作为第四个表,最后一个表作为最后一个表:

Combinations = 5 * 4 * 3 * 2 * 1 = 5! = 120

MySQL 支持连接多达 61 个表,在这种情况下,可能有 5.1E83 个组合来计算成本,这是成本过高的,并且可能比执行查询本身需要更长的时间。因此,默认情况下,优化器会根据对成本的部分评估来删除查询计划,因此只有最有希望的计划会得到完全评估。也可以告诉优化器在包含给定数量的表后停止评估成本。修剪和搜索深度分别用optimizer_prune_leveloptimizer_search_depth选项配置,这将在“配置优化器”一节中讨论。

最佳连接顺序与表的大小以及过滤器在减少每个表中包含的行数方面的表现有关。

默认过滤效果

当连接两个或更多的表时,优化器需要知道每个表包含多少行,以便能够确定最佳的连接顺序。这绝不是一项微不足道的任务。

当使用索引时,当过滤器与其他表不相关时,优化器可以非常准确地估计有多少行将匹配索引。如果没有索引,直方图统计可用于获得良好的过滤估计。当被筛选的列没有统计信息时,困难就出现了。在这种情况下,优化器会依靠内置的默认估计。表 17-1 包括当没有可用的索引或直方图统计时使用的默认过滤效果的例子。

表 17-1

无统计条件下的默认过滤效果

|

类型

|

过滤器%

|

注释/示例

|
| --- | --- | --- |
| 全部 | One hundred | 这在按索引过滤或没有过滤条件时使用。 |
| 平等 | Ten | Name = 'Sydney' |
| 不相等 | Ninety | Name <> 'Sydney' |
| 不平等 | Thirty-three point three three | Population > 4000000 |
| 在...之间 | Eleven point one one | Population BETWEEN 1000000 AND 4000000 |
| 在…里 | min(``#items * 10,``50``) | Name IN ('Sydney', 'Melbourne') |

过滤效果基于 Selinger 等人的文章“关系数据库管理系统中的访问路径选择” 1 您有时会看到不同的过滤值。一些例子包括

  • 已知不同的值:这包括enum和位数据类型。考虑一下world.country表中的Continent列。这是一个有七个值的enum,所以优化器将估计像Continent = 'Europe'这样的WHERE子句的过滤效果为 1/7。

  • 少行:如果一个表中少于十行,并且您添加了一个等式条件,那么过滤估计将是1/number_of_rows并且对于不相等的过滤估计也是类似的。

  • 过滤器组合:如果在几个非索引列上组合过滤器,估计的过滤效果就是组合效果。例如,对于world.city表,由于Name上的等式,过滤器Name = 'Sydney' AND Population > 3000000估计会占用 10%的行,由于Population上的不等式,估计会占用 33%的行,因此组合的效果是P(Equality on Name) * P(Inequality on Population) = 0.1 * 0.33 = 0.0333 = 3.33%

这个列表并不详尽,但它应该能让您很好地了解 MySQL 是如何得出过滤估计值的。默认的过滤效果显然不是很准确,特别是对于大型表,因为数据不遵循这样严格的规则。这就是为什么索引和直方图对于获得好的查询计划如此重要。

在确定查询计划的最后,有对单个部分和整个查询的成本估计。这些信息有助于理解优化器如何获得查询执行计划。

查询成本

如果您想检查优化器发现的成本,您将需要使用树-(包括EXPLAIN ANALYZE)或 JSON 格式的EXPLAIN输出、MySQL Workbench 可视化解释图或优化器跟踪。这些都在第 20 章中有详细描述。

举个简单的例子,考虑一个连接world样本数据库的countrycity表的查询:

SELECT *
  FROM world.country
       INNER JOIN world.city
          ON CountryCode = Code;

17-1 显示了查询的直观解释图,包括city表的额外细节。

img/484666_1_En_17_Fig1_HTML.jpg

图 17-1

成本估算的直观解释

该图显示了优化器如何决定执行查询。如何阅读该图将在第 20 章中讨论。这里重要的部分是箭头所指的数字。这些是优化器为查询执行的各个部分所做的成本估计,成本越低越好。该示例显示,成本估计是为非常具体的任务计算的,例如读取数据、评估筛选条件等。在图的顶部,总查询成本估计为 1535.43。

Note

由于计算出的成本取决于索引统计等因素,而索引统计并不精确,因此随着时间的推移,成本会有所不同。这也意味着,如果您执行相同的查询,与本书中的示例相比,您可能会看到不同的成本估计。

在您执行了一个查询之后,您还可以从Last_query_cost状态变量中获得估计的成本。清单 17-1 展示了对与图 17-1 中相同的查询这样做的例子。

mysql> SELECT *
         FROM world.country
              INNER JOIN world.city
                 ON CountryCode = Code;
...

mysql> SHOW SESSION STATUS LIKE 'Last_query_cost';
+-----------------+-------------+
| Variable_name   | Value       |
+-----------------+-------------+
| Last_query_cost | 1535.425669 |
+-----------------+-------------+
1 row in set (0.0013 sec)

Listing 17-1Obtaining the estimated query cost after executing a query

输出中已经删除了查询的结果,因为它对于本次讨论并不重要。关于Last_query_cost需要注意的一件重要事情是,它是估计成本,这就是为什么它在直观的解释图中显示与总成本相同的值。如果您想了解执行查询的实际成本,您需要使用EXPLAIN ANALYZE

可视化解释图提到查询是使用一个嵌套循环执行的。这只是 MySQL 支持的连接算法之一。

连接算法

在 MySQL 中,连接是一个非常宽泛的概念——以至于你可以说一切都是连接。即使查询单个表也被认为是一个连接。也就是说,最有趣的连接是两个或多个表之间的连接。在本讨论中,表也可以是派生表。

当执行查询时,需要连接两个表,MySQL 支持三种不同的算法。这些算法是

  • 嵌套循环

  • 块嵌套循环

  • 散列连接

Note

本节中显示的时间仅用于说明目的。您在系统上看到的计时会有所不同,彼此之间的计时也可能有所不同。

本节和下一节将引用几个优化器开关和优化器提示的名称。优化器开关指的是optimizer_switch配置选项,优化器提示指的是可以添加到查询中的/*+ ... */注释,用来告诉优化器您希望查询如何执行。这两个概念以及如何使用它们将在本章后面的“配置优化器”一节中进一步讨论。

嵌套循环

嵌套循环算法是 MySQL 中使用的最简单的算法。在 MySQL 5.6 之前,它也是唯一可用的算法。顾名思义,它的工作方式是嵌套循环,连接中的每个表一个循环。嵌套连接算法不仅非常简单;它也适用于索引查找。

考虑在查询亚洲国家和城市的world.country表中加入world.city表的查询。您可以按以下方式编写查询:

SELECT CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country
       INNER JOIN world.city
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

它将使用嵌套循环来执行,对country表进行表扫描,其中应用了WHERE子句中的过滤器,然后对city表进行索引查找。在树表示法中,查询看起来像

-> Nested loop inner join
    -> Filter: (country.Continent = 'Asia')
        -> Table scan on country
    -> Index lookup on city using CountryCode
                       (CountryCode=country.`Code`)

也可以把这个写成伪代码。使用类似 Python 的语法,嵌套循环连接可以写成如下代码片段:

result = []
for country_row in country:
    if country_row.Continent == 'Asia':
        for city_row in city.CountryCode['country_row.Code']:
            result.append(join_rows(country_row, city_row))

伪代码中,countrycity分别代表countrycity表,city.CountryCode是城市表上的CountryCode索引,country_rowcity_row代表单行。join_rows()函数用于表示将结果集中两行所需的列合并为一行的过程。

17-2 使用图表显示了相同的嵌套循环连接。为了简单起见,为了关注连接,只包括匹配行的主键值,即使所有行都是从country表中读取的。

img/484666_1_En_17_Fig2_HTML.png

图 17-2

嵌套循环联接的示例

该图显示,MySQL 扫描 country 表,直到找到与WHERE子句匹配的行。在图中,第一个匹配行是 AFG(代表阿富汗)。然后在城市表中找到CountryCode = AFG的所有行(ID等于 1、2、3 和 4),每个组合用于在结果中形成一行。这种情况持续到国家代码等于 ARE(阿联酋)等,直到 YEM(也门人)。

country表中和在city表的CountryCode索引中扫描行的确切顺序取决于索引定义以及优化器、执行器和存储引擎的内部结构。除非你有一个明确的ORDER BY子句,否则永远不要依赖于排序保持不变。

一般来说,一个连接可能比本例中的更复杂,因为可能有额外的过滤器。然而,这个概念仍然是一样的。

虽然简单通常是一个好的属性,但是嵌套循环连接有一些限制。它不能用于执行完整的外部联接,因为嵌套循环联接要求第一个表返回行,而完整的外部联接并不总是如此。解决方法是将完整的外部连接编写为左外部连接和右外部连接的联合。考虑一个查找所有国家和城市的查询,包括国家没有城市和城市没有国家的情况。可以写成一个完整的外部连接(在 MySQL 中无效):

SELECT *
  FROM world.country
       FULL OUTER JOIN world.city
               ON city.CountryCode = country.Code;

为了在 MySQL 中执行,可以使用country LEFT JOIN citycountry RIGHT JOIN city的联合,比如

SELECT *
  FROM world.country
       LEFT OUTER JOIN world.city
               ON city.CountryCode = country.Code
 UNION
SELECT *
  FROM world.country
       RIGHT OUTER JOIN world.city
               ON city.CountryCode = country.Code;

另一个限制是嵌套循环连接对于不能使用索引的连接不是很有效。由于嵌套循环联接一次只能处理联接中第一个表的一行,因此有必要对第二个表的第一个表中的每一行进行全表扫描。这很快就会变得过于昂贵。考虑一下前面检查的查询,其中找到了亚洲的所有城市:

mysql> SELECT PS_CURRENT_THREAD_ID();
+------------------------+
| PS_CURRENT_THREAD_ID() |
+------------------------+
|                     30 |
+------------------------+
1 row in set (0.0017 sec)

SELECT CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country
       INNER JOIN world.city
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

通过对 country 表(239 行)的表扫描和对 city 表的索引查找,总共将检查 2005 行(在第二个连接中执行此查询):

mysql> SELECT rows_examined, rows_sent,
              last_statement_latency AS latency
         FROM sys.session
        WHERE thd_id = 30\G
*************************** 1\. row ***************************
rows_examined: 2005
    rows_sent: 1766
      latency: 4.36 ms
1 row in set (0.0539 sec)

thd_id上的过滤器需要匹配执行查询的连接的性能模式线程 id(这可以通过 MySQL 8.0.16 和更高版本中的PS_CURRENT_THREAD_ID()函数找到)。2005 年检查的行来自对亚洲国家的country表中的 239 行进行全表扫描,然后读取city表中的 1766 行。

如果 MySQL 不能为连接使用索引,那么查询性能会发生巨大变化。您可以通过以下方式使用嵌套循环连接而不使用索引来执行查询(NO_BNL(city)注释是一个优化器提示):

SELECT /*+ NO_BNL(city) */
       CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country IGNORE INDEX (Primary)
       INNER JOIN world.city IGNORE INDEX (CountryCode)
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

IGNORE INDEX ()子句是一个索引提示,告诉 MySQL 忽略括号中给出的索引。该版本查询的查询统计数据显示,现在检查了 200,000 多行,并且该查询的执行时间大约是以前的 10 倍(以与以前相同的方式执行该测试,其中查找亚洲城市的查询在一个连接中执行,下面针对sys.session的查询在另一个连接中执行,并且thd_id = 30被更改为使用第一个连接的线程 id):

mysql> SELECT rows_examined, rows_sent,
              last_statement_latency AS latency
         FROM sys.session
        WHERE thd_id = 30\G
**************************** 1\. row ****************************
rows_examined: 208268
    rows_sent: 1766
      latency: 44.83 ms

有 51 个国家有Continent = 'Asia',这意味着有 51 个城市表的全表扫描。因为 city 表中有 4079 行,所以总共有 51 * 4079 + 239 = 208268 行必须检查。额外的 239 行来自对有 239 行的country表的扫描。

为什么有必要在示例中添加带有NO_BNL(country,city)的注释?BNL 主张块嵌套循环,这有助于改善没有索引的连接,注释禁用了这种优化。通常,您确实希望保持它的启用状态,这将在下面解释。

块嵌套循环

块嵌套循环算法是嵌套循环算法的扩展。它也被称为 BNL 算法。联接缓冲区用于收集尽可能多的行,并在第二个表的一次扫描中对它们进行比较,而不是一个接一个地提交第一个表中的行。与嵌套循环算法相比,这可以大大提高某些查询的性能。

如果考虑用作嵌套循环算法示例的同一个查询,但是禁用了索引(模拟两个没有索引的表)并且不允许哈希联接(在 8.0.18 和更高版本中),则您有一个可以利用块嵌套循环算法的查询。该查询是

SELECT /*+ NO_HASH_JOIN(country,city) */
       CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country IGNORE INDEX (Primary)
       INNER JOIN world.city IGNORE INDEX (CountryCode)
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

在 MySQL 8.0.17 和更早版本中,删除带有NO_HASH_JOIN()优化器提示的注释。

清单 17-2 展示了一个使用类似 Python 的代码实现块嵌套循环算法的伪代码示例。

result = []
join_buffer = []
for country_row in country:
    if country_row.Continent == 'Asia':
        join_buffer.append(country_row.Code)

        if is_full(join_buffer):
            for city_row in city:
                CountryCode = city_row.CountryCode
                if CountryCode in join_buffer:
                    country_row = get_row(CountryCode)
                    result.append(
                        join_rows(country_row, city_row))
            join_buffer = []

if len(join_buffer) > 0:
    for city_row in city:
        CountryCode = city_row.CountryCode
        if CountryCode in join_buffer:
            country_row = get_row(CountryCode)
            result.append(join_rows(country_row, city_row))
    join_buffer = []

Listing 17-2Pseudo code representing a block nested loop join

join_buffer列表表示存储连接所需列的连接缓冲区。在伪代码中,用required_columns()函数提取列。对于用作示例的查询,只需要来自country表的Code列。这是一件值得注意的重要事情,稍后将进一步讨论。当连接缓冲区满时,在city表上执行表扫描;如果city表的CountryCode列与连接缓冲区中存储的Code值之一相匹配,则构建结果行。

17-3 显示了连接的示意图。为简单起见,即使对两个表都执行了全表扫描,也只包括连接所需行的主键值。

img/484666_1_En_17_Fig3_HTML.png

图 17-3

块嵌套循环联接的示例

该图显示了如何一起读取来自country表的行并将其存储在连接缓冲区中。每当连接缓冲区满时,就会对city表执行一次全表扫描,并逐步构建结果。在图中,一次有六行适合连接缓冲区。由于Code列每行仅需要 3 个字节,实际上,除了使用join_buffer_size的最小可能设置时,连接缓冲器将能够保存所有国家代码。

使用连接缓冲区缓冲几个国家代码对查询统计有什么影响?对于前面的示例,首先执行查询,在一个连接中查找亚洲城市:

SELECT /*+ NO_HASH_JOIN(country,city) */
       CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country IGNORE INDEX (Primary)
       INNER JOIN world.city IGNORE INDEX (CountryCode)
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

然后在另一个连接中,查询sys.session以获得检查的行数和查询延迟(更改thd_id = 30以使用第一个连接的线程 id):

mysql> SELECT rows_examined, rows_sent,
              last_statement_latency AS latency
         FROM sys.session
        WHERE thd_id = 30\G
**************************** 1\. row ****************************
rows_examined: 4318
    rows_sent: 1766
      latency: 16.87 ms
1 row in set (0.0490 sec)

结果假定为join_buffer_size的默认值。统计数据显示,块嵌套循环的性能明显优于不使用索引的嵌套循环算法。相比之下,使用索引执行查询检查了 2005 行,花费了大约 4 毫秒,而使用没有索引的嵌套循环连接检查了 208268 行,花费了大约 45 毫秒。这看起来似乎是查询执行时间上的无关紧要的差异,但是countrycity表都非常小。对于大型表,这种差异将非线性增长,可能意味着查询完成和似乎永远运行之间的差异。

关于块嵌套循环,有几点您应该知道,因为它有助于您最佳地使用它。这些要点包括

  • 只有连接所需的列存储在连接缓冲区中。这意味着连接缓冲区需要的内存比最初预期的要少。

  • 连接缓冲区的大小由join_buffer_size变量配置。join_buffer_size的值是缓冲区的最小大小!即使在所讨论的例子中少于 1 个 KiB 的国家代码值将被存储在连接缓冲器中,如果join_buffer_size被设置为 1 个 GiB,则 1 个 GiB 将被分配。为此,保持join_buffer_size的值较低,仅在需要时增加。“配置优化器”一节包含了如何为单个查询更改连接缓冲区大小的信息。

  • 使用块嵌套循环算法为每个连接分配一个连接缓冲区。

  • 每个连接缓冲区都是在整个查询期间分配的。

  • 块嵌套循环算法可用于全表扫描、全索引扫描和范围扫描。

  • 块嵌套循环算法永远不会用于常数表和第一个非常数表。这意味着,在使用块嵌套循环算法按唯一索引进行筛选后,需要在具有多行的两个表之间进行联接。

您可以通过设置block_nested_loop优化器开关来配置是否允许优化器选择块嵌套循环算法。默认设置是启用它。对于单个查询,您可以使用BNL()NO_BNL()优化器提示来启用或禁用特定连接的块嵌套循环。

虽然块嵌套循环对于非索引连接来说是一个很大的改进,但是在大多数情况下,使用散列连接可能会做得更好。

散列连接

散列连接算法是 MySQL 最近新增的功能,在 MySQL 8.0.18 和更高版本中受支持。它标志着嵌套循环连接传统的重大突破,包括块嵌套循环变体。它对于没有索引的大型连接特别有用,但在某些情况下甚至优于索引连接。

MySQL 实现了经典内存哈希连接和磁盘 GRACE 哈希连接算法的混合。 2 如果有可能将所有的哈希都存储在内存中,那么就使用纯内存实现。连接缓冲区用于内存中部分,因此可用于散列的内存量受到join_buffer_size的限制。当连接不适合内存时,连接会溢出到磁盘,但是实际的连接操作仍然在内存中执行。

内存中散列连接算法由两个步骤组成:

  1. 连接中的一个表被选为构建表。为连接所需的列计算散列,并将其加载到内存中。这被称为构建阶段

  2. 连接中的另一个表是探针输入。对于这个表,一次读取一行,并计算散列。然后,对从构建表中计算出的散列执行散列键查找,并从匹配的行中生成连接结果。这被称为探测阶段

当构建表的散列不适合内存时,MySQL 会自动切换到使用磁盘上的实现(基于 GRACE hash join 算法)。如果在构建阶段连接缓冲区变满,就会发生从内存算法到磁盘算法的切换。磁盘算法包括三个步骤:

  1. 计算构建表和探测表中所有行的散列,并将它们存储在磁盘上由散列分区的几个小文件中。选择分区的数量,以使探测表的每个分区适合连接缓冲区,但限制为最多 128 个分区。

  2. 将构建表的第一个分区加载到内存中,并以与内存中算法的探测阶段相同的方式迭代探测表中的散列。由于步骤 1 中的分区对构建表和探测表使用相同的散列函数,因此只需要迭代探测表的第一个分区。

  3. 清除内存中的缓冲区,并继续逐个处理剩余的分区。

内存中和磁盘上的算法都使用 xxHash64 哈希函数,该函数速度快,但仍能提供高质量的哈希(减少哈希冲突的数量)。为了获得最佳性能,连接缓冲区需要足够大,以容纳构建表中的所有散列。也就是说,对于散列连接和块嵌套循环连接,join_buffer_size存在相同的考虑因素。

每当选择块嵌套循环时,MySQL 将使用散列连接,并且散列连接算法支持查询。在撰写本文时,对于要使用的散列连接算法,存在以下要求:

  • 该联接必须是内部联接。

  • 无法使用索引执行联接,可能是因为没有可用的索引,也可能是因为已为查询禁用了索引。

  • 查询中的所有联接在联接中的两个表之间必须至少有一个等价联接条件,并且在该条件中只引用两个表中的列和常数。

  • 从 8.0.20 开始,还支持反联接、半联接和外联接。 3 。如果连接两个表t1t2,那么散列连接支持的连接条件的例子包括

  • t1.t1_val = t2.t2_val

  • t1.t1_val = t2.t2_val + 2

  • t1.t1_val1 = t2.t2_val AND t1.t1_val2 > 100

  • MONTH(t1.t1_val) = MONTH(t2.t2_val)

如果考虑本节的重复示例查询,可以通过忽略可用于连接的表上的索引,使用散列连接来执行它:

SELECT CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country IGNORE INDEX (Primary)
       INNER JOIN world.city IGNORE INDEX (CountryCode)
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

执行该连接的伪代码类似于块嵌套循环的伪代码,只是连接所需的列被散列,并且支持溢出到磁盘。伪代码如清单 17-3 所示。

result = []
join_buffer = []
partitions = 0
on_disk = False
for country_row in country:
    if country_row.Continent == 'Asia':
        hash = xxHash64(country_row.Code)
        if not on_disk:
            join_buffer.append(hash)

            if is_full(join_buffer):
                # Create partitions on disk
                on_disk = True
                partitions = write_buffer_to_disk(join_buffer)
                join_buffer = []
        else
           write_hash_to_disk(hash)

if not on_disk:
    for city_row in city:
        hash = xxHash64(city_row.CountryCode)
        if hash in join_buffer:
            country_row = get_row(hash)
            city_row = get_row(hash)
            result.append(join_rows(country_row, city_row))
else:
    for city_row in city:
        hash = xxHash64(city_row.CountryCode)
        write_hash_to_disk(hash)

    for partition in range(partitions):
        join_buffer = load_build_from_disk(partition)
        for hash in load_hash_from_disk(partition):
            if hash in join_buffer:
                country_row = get_row(hash)
                city_row = get_row(hash)
                result.append(join_rows(country_row, city_row))
        join_buffer = []

Listing 17-3Pseudo code representing a hash join

伪代码从读取country表中的行开始,计算Code列的散列,并将其存储在连接缓冲区中。如果缓冲区变满,那么代码切换到磁盘上的算法,并从缓冲区写出散列。这也是确定分区数量的地方。在这之后,country表的其余部分被散列。

在下一部分中,对于内存算法,在city表中的行上有一个简单的循环,将哈希值与缓冲区中的哈希值进行比较。对于磁盘算法,首先计算city表的哈希值并存储在磁盘上;然后分区逐个处理。

Note

与实际使用的算法相比,所描述的算法稍微简化了一些。真正的算法必须考虑哈希冲突,对于磁盘上的算法,一些分区可能变得太大而不适合连接缓冲区,在这种情况下,它们被分块处理,以避免使用比配置更多的内存。

17-4 显示了内存中散列连接算法的示意图。为简单起见,即使对两个表都执行了全表扫描,也只包括连接所需行的主键值。

img/484666_1_En_17_Fig4_HTML.png

图 17-4

内存中散列连接的示例

来自country表的匹配行的Code列的值被散列并存储在连接缓冲区中。然后对city表执行表扫描,对每一行计算散列值CountryCode,从匹配的行中构造结果。

通过首先在一个连接中执行查询,可以用与前面算法相同的方式检查查询的统计信息:

SELECT CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country IGNORE INDEX (Primary)
       INNER JOIN world.city IGNORE INDEX (CountryCode)
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

然后,您可以通过在第二个连接中查询sys.session视图来查看查询的性能模式统计数据(将thd_id = 30更改为使用第一个连接的线程 id):

mysql> SELECT rows_examined, rows_sent,
              last_statement_latency AS latency
         FROM sys.session
        WHERE thd_id = 30\G
rows_examined: 4318
    rows_sent: 1766
      latency: 3.53 ms
1 row in set (0.0467 sec)

您可以看到,散列连接与块嵌套循环检查相同数量的行时,查询执行得非常好,但它比索引连接更快。这不是一个错误:在某些情况下,散列连接甚至可以胜过索引连接。与索引和块嵌套循环联接相比,您可以使用以下规则来评估哈希联接算法的执行情况:

  • 对于不使用索引的连接,散列连接通常比块嵌套连接快得多,除非添加了一个LIMIT子句。已经观察到超过 1000 倍的改善。4

  • 对于没有索引的连接,如果有一个LIMIT子句,当找到足够多的行时,块嵌套循环可以退出,而散列连接将完成整个连接(但是可以跳过获取行)。如果由于LIMIT子句而包含的行数与连接找到的总行数相比很小,那么块嵌套循环可能会更快。

  • 对于支持索引的连接,如果索引的选择性较低,哈希连接算法会更快。

使用散列连接的最大好处是对于没有索引和没有LIMIT子句的连接。最后,只有测试才能证明哪种连接策略最适合您的查询。

您可以使用hash_join optimizer 开关启用和禁用对散列连接的支持。此外,必须启用block_nested_loop优化器开关。默认情况下,两者都是启用的。如果您想为特定的连接配置散列连接的使用,您可以使用HASH_JOIN()NO_HASH_JOIN()优化器提示。

关于 MySQL 中支持的三种高级连接策略的讨论到此结束。还有一些值得考虑的低级优化。

连接优化

MySQL 可以使用连接优化来改进上一节中讨论的连接算法的基本概念,或者决定如何执行部分查询。本节将详细介绍索引合并、多范围读取(MRR)和批量键访问(BKA)优化。这三种优化最有可能需要您帮助优化器使查询计划达到最优。其余的可配置优化将在本节的末尾介绍,但不太详细。

索引合并

通常 MySQL 对每个表只使用一个索引。但是,如果在同一个表的多个列上有条件,并且没有覆盖所有列的单一索引,那么这不是最佳选择。对于这些情况,MySQL 支持索引合并。

Tip

用筛选条件覆盖列的多列索引比使用索引合并优化更有效。您应该权衡这种性能差异与额外索引的可能性。

支持三种索引合并算法。表 17-2 总结了算法,何时使用算法,以及查询计划中包含的信息。

表 17-2

索引合并算法

|

算法

|

用例

|

EXPLAIN Extra 铜铬

|

EXPLAIN JSON Key字段

|
| --- | --- | --- | --- |
| 交集 | AND | Using intersect(...) | intersect(...) |
| 联盟 | OR | Using union(...) | union(...) |
| 排序联合 | OR有范围 | sort_union(...) | sort_union(...) |

除了表中列出的EXPLAIN信息,访问类型被设置为index_merge

用例指定了加入条件的操作符。联合算法和排序联合算法的区别在于,联合算法用于相等条件,而排序联合算法用于范围条件。对于EXPLAIN输出,与索引合并一起使用的索引名称列在括号内。

在讨论这三种算法时,考虑使用每种算法的实际查询会很有用。sakila数据库中的payment表对此很有用。sakila.payment的表格定义是

CREATE TABLE `payment` (
  `payment_id` smallint unsigned NOT NULL,
  `customer_id` smallint unsigned NOT NULL,
  `staff_id` tinyint unsigned NOT NULL,
  `rental_id` int(DEFAULT NULL,
  `amount` decimal(5,2) NOT NULL,
  `payment_date` datetime NOT NULL,
  `last_update` timestamp NULL,
  PRIMARY KEY (`payment_id`),
  KEY `idx_fk_staff_id` (`staff_id`),
  KEY `idx_fk_customer_id` (`customer_id`),
  KEY `fk_payment_rental` (`rental_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

默认值、自动递增信息和外键定义已从表中删除,以关注列和索引。该表有四个索引,都在一个列上,这使它成为索引合并优化的良好候选。

索引合并讨论的其余部分将介绍每个索引合并算法、性能考虑事项以及如何配置索引合并的使用。这些例子都只包含两列的条件,但是这些算法支持包含更多列的索引合并。

Note

优化器是否选择索引合并取决于索引统计信息。这意味着对于同一个查询,WHERE子句中的不同值可能会导致不同的查询计划,并且对索引统计信息的更改可能会使查询以不同的方式执行,即使在使用索引合并和不使用索引合并之间的条件完全相同的情况下也是如此——反之亦然。

交集算法

当在由AND分隔的几个索引列上有条件时,使用交集算法。使用交集索引合并算法的两个查询示例是

SELECT *
  FROM sakila.payment
 WHERE staff_id = 1
       AND customer_id = 75;

SELECT *
  FROM sakila.payment
 WHERE payment_id > 10
       AND customer_id = 318;

第一个查询在两个辅助索引上有一个相等条件,第二个查询在主键上有一个范围条件,在辅助索引上有一个相等条件。第二个查询的索引合并优化只适用于 InnoDB 表。清单 17-4 显示了使用两种不同格式的两个查询中的第一个查询的EXPLAIN输出。

mysql> EXPLAIN
        SELECT *
          FROM sakila.payment
         WHERE staff_id = 1
               AND customer_id = 75\G
**************************** 1\. row *****************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: index_merge
possible_keys: idx_fk_staff_id,idx_fk_customer_id
          key: idx_fk_customer_id,idx_fk_staff_id
      key_len: 2,1
          ref: NULL
         rows: 20
     filtered: 100
        Extra: Using intersect(idx_fk_customer_id,idx_fk_staff_id); Using where
1 row in set, 1 warning (0.0007 sec)

mysql> EXPLAIN FORMAT=TREE
        SELECT *
          FROM sakila.payment
         WHERE staff_id = 1
               AND customer_id = 75\G
**************************** 1\. row ****************************
EXPLAIN: -> Filter: ((sakila.payment.customer_id = 75) and (sakila.payment.staff_id = 1))  (cost=14.48 rows=20)
    -> Index range scan on payment using intersect(idx_fk_customer_id,idx_fk_staff_id)  (cost=14.48 rows=20)

1 row in set (0.0004 sec)

Listing 17-4Example of an EXPLAIN output for an intersection merge

注意Extra列中的Using intersect(...)消息,以及树格式输出中的索引范围扫描。这表明idx_fk_customer_ididx_fk_staff_id索引用于索引合并。传统输出还在key列中包含两个索引,并在key_len列中提供两个密钥长度。

合并算法

当用OR分隔的表有一系列相等条件时,使用 union 算法。可以使用联合算法的两个查询示例是

SELECT *
  FROM sakila.payment
 WHERE staff_id = 1
       OR customer_id = 318;

SELECT *
  FROM sakila.payment
 WHERE payment_id > 15000
       OR customer_id = 318;

第一个查询在辅助索引上有两个相等条件,而第二个查询在主键上有一个范围条件,在辅助索引上有一个相等条件。第二个查询将只对 InnoDB 表使用索引合并。清单 17-5 展示了第一个查询的相应EXPLAIN输出的例子。

mysql> EXPLAIN
        SELECT *
          FROM sakila.payment
         WHERE staff_id = 1
               OR customer_id = 318\G
**************************** 1\. row *****************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: index_merge
possible_keys: idx_fk_staff_id,idx_fk_customer_id
          key: idx_fk_staff_id,idx_fk_customer_id
      key_len: 1,2
          ref: NULL
         rows: 8069
     filtered: 100
        Extra: Using union(idx_fk_staff_id,idx_fk_customer_id); Using where
1 row in set, 1 warning (0.0008 sec)

mysql> EXPLAIN FORMAT=TREE
        SELECT *
          FROM sakila.payment
         WHERE staff_id = 1
               OR customer_id = 318\G
**************************** 1\. row ****************************
EXPLAIN: -> Filter: ((sakila.payment.staff_id = 1) or (sakila.payment.customer_id = 318))  (cost=2236.18 rows=8069)
    -> Index range scan on payment using union(idx_fk_staff_id,idx_fk_customer_id)  (cost=2236.18 rows=8069)

1 row in set (0.0010 sec)

Listing 17-5The EXPLAIN output for a union merge

注意Extra列中的Using union(...)以及树格式输出中的索引范围扫描。这表明idx_fk_staff_ididx_fk_customer_id索引用于索引合并。

排序联合算法

排序联合算法用于类似于使用联合算法的查询,但是条件是范围条件而不是相等条件。可以使用排序联合算法的两个查询示例是

SELECT *
  FROM sakila.payment
 WHERE customer_id < 30
       OR rental_id < 10;

SELECT *
  FROM sakila.payment
 WHERE customer_id < 30
       OR rental_id > 16000;

两个查询都在两个辅助索引上有范围条件。清单 17-6 显示了第一个查询使用传统 and 树格式的相应EXPLAIN输出。

mysql> EXPLAIN
        SELECT *
          FROM sakila.payment
         WHERE customer_id < 30
               OR rental_id < 10\G
**************************** 1\. row *****************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: index_merge
possible_keys: idx_fk_customer_id,fk_payment_rental
          key: idx_fk_customer_id,fk_payment_rental
      key_len: 2,5
          ref: NULL
         rows: 826
     filtered: 100
        Extra: Using sort_union(idx_fk_customer_id,fk_payment_rental); Using where
1 row in set, 1 warning (0.0009 sec)

mysql> EXPLAIN FORMAT=TREE
        SELECT *
          FROM sakila.payment
         WHERE customer_id < 30
               OR rental_id < 10\G
**************************** 1\. row *****************************
EXPLAIN: -> Filter: ((sakila.payment.customer_id < 30) or (sakila.payment.rental_id < 10))  (cost=1040.52 rows=826)
    -> Index range scan on payment using sort_union(idx_fk_customer_id,fk_payment_rental)  (cost=1040.52 rows=826)

1 row in set (0.0005 sec)

Listing 17-6The EXPLAIN output using a sort-union merge

注意在额外的列中使用了sort_union(...),并且在树格式的输出中使用了索引范围扫描。这表明idx_fk_customer_idfk_payment_rental索引用于索引合并。

性能考虑因素

优化器很难知道什么时候索引合并比只使用单个索引更好。乍一看,对更多的列使用索引似乎总是一种胜利,但是索引合并有很大的开销,所以只有当索引的索引选择性的正确组合存在时,索引合并才有用。当由于过时的索引统计信息而选择索引合并时,会发生严重的性能退化,这是更常见的原因之一。

如果优化器选择了索引合并,而查询的性能很差(例如,与其通常的性能相比),那么您应该做的第一件事就是对使用索引合并的表执行ANALYZE TABLE。这通常会改进查询计划。否则,可能需要更改优化器配置来决定是否使用索引合并。

配置

索引合并功能由四个优化器开关控制,其中一个开关控制整体功能,另外三个开关分别控制三种算法。这些选项包括

  • index_merge : 是否启用或禁用指标合并。

  • index_merge_intersection : 是否启用交集算法。

  • index_merge_union : 是否启用联合算法。

  • index_merge_sort_union : 是否启用排序并算法。

默认情况下,所有索引合并优化器开关都是启用的。

此外,还有两个优化器提示:INDEX_MERGE()NO_INDEX_MERGE()。这两种提示都将表名作为参数,并且可以选择应该考虑或忽略的索引。例如,如果您想在不使用索引合并的情况下执行查询,查找将staff_id设置为 1、customer_id设置为 75 的付款,您可以使用以下查询之一:

SELECT /*+ NO_INDEX_MERGE(payment) */
       *
  FROM sakila.payment
 WHERE staff_id = 1
       AND customer_id = 75;

SELECT /*+ NO_INDEX_MERGE(
              payment
              idx_fk_staff_id,idx_fk_customer_id) */
       *
  FROM sakila.payment
 WHERE staff_id = 1
       AND customer_id = 75;

由于索引合并被认为是范围优化的一种特殊情况,NO_RANGE_OPTIMIZATION()优化器提示也禁用索引合并。通过EXPLAIN输出可以确认,对于清单 17-7 中的第一个查询,不再使用索引合并。

mysql> EXPLAIN
        SELECT /*+ NO_INDEX_MERGE(payment) */
               *
          FROM sakila.payment
         WHERE staff_id = 1
               AND customer_id = 75\G
**************************** 1\. row *****************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: ref
possible_keys: idx_fk_staff_id,idx_fk_customer_id
          key: idx_fk_customer_id
      key_len: 2
          ref: const
         rows: 41
     filtered: 50.0870361328125
        Extra: Using where
1 row in set, 1 warning (0.0010 sec)

mysql> EXPLAIN FORMAT=TREE
        SELECT /*+ NO_INDEX_MERGE(payment) */
               *
          FROM sakila.payment
         WHERE staff_id = 1
               AND customer_id = 75\G
**************************** 1\. row ****************************
EXPLAIN: -> Filter: (sakila.payment.staff_id = 1)  (cost=26.98 rows=21)
    -> Index lookup on payment using idx_fk_customer_id (customer_id=75)  (cost=26.98 rows=41)

1 row in set (0.0006 sec)

Listing 17-7The EXPLAIN output when index merges are unselected

另一种优化是多范围读取优化。

多量程读数(MRR)

多范围读取(MRR)优化旨在减少对辅助索引进行范围扫描所导致的随机 I/O 数量。优化首先读取索引,根据行 id(InnoDB 的聚集索引)对键进行排序,然后按照行的存储顺序检索行。多范围读取优化可用于使用索引的范围扫描和等同联接。虚拟生成的列的辅助索引不支持它。

InnoDB 多范围读取优化的主要用例是针对没有覆盖索引的磁盘绑定查询。优化的效果取决于需要多少行以及存储的寻道次数。MySQL 会尝试估计优化什么时候有用;但是,成本估计过于悲观,而不是过于乐观,因此可能有必要提供信息来帮助优化器做出正确的决策。

多范围读取优化由两个优化器开关控制:

  • mrr : 是否允许优化器使用多范围读优化。默认为ON

  • mrr_cost_based : 决定使用多范围读取优化是否基于成本。您可以禁用此选项,以便在支持时始终使用优化。默认为ON

或者,您可以使用MRR()NO_MRR() optimizer 开关在每个表或索引的基础上启用和禁用多范围读取优化。

您可以从查询计划中看到是否使用了多范围读取优化。在这种情况下,传统的EXPLAIN输出在Extra列中指定Using MRR,JSON 输出将using_MRR字段设置为true。清单 17-8 显示了使用多范围读取优化时传统格式的完整EXPLAIN输出示例。

mysql> EXPLAIN
        SELECT /*+ MRR(city) */
               *
          FROM world.city
         WHERE CountryCode BETWEEN 'AUS' AND 'CHN'\G
**************************** 1\. row *****************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: range
possible_keys: CountryCode
          key: CountryCode
      key_len: 3
          ref: NULL
         rows: 812
     filtered: 100
        Extra: Using index condition; Using MRR
1 row in set, 1 warning (0.0006 sec)

Listing 17-8The EXPLAIN output for a query using Multi-Range Read

有必要使用MRR()优化器提示或通过禁用mrr_cost_based优化器开关来明确请求使用多范围读取优化,因为示例查询的估计行数太小,无法使用多范围读取优化和基于成本的优化来选择它。

当使用优化时,MySQL 使用随机读取缓冲区来存储索引。使用read_rnd_buffer_size选项设置缓冲区的大小。

一个相关的优化是批量密钥访问优化。

批量密钥访问(BKA)

批量键访问(BKA)优化结合了块嵌套循环和多范围读取优化。这使得可以以类似于非索引连接的方式为索引连接使用连接缓冲区,并使用多范围读取优化来减少随机 I/O 的数量。

对于批量键访问,最有用的查询类型是大型磁盘绑定查询,但是没有确定的指南来确定优化何时有所帮助,何时会导致性能下降。当优化效果最佳时,它将查询执行时间减少了 1/2 到 1/10。然而,当它表现最差时,查询执行时间可能会增加 2-3 倍。 5

因为批处理键访问优化主要受益于范围相对较窄的查询,并且性能可能会因其他查询而降低,所以默认情况下禁用该优化。启用优化的最佳方式是在查询中使用BKA()优化器提示,您已经发现优化可以提供收益。

如果要使用optimizer_switch变量启用优化,必须启用batched_key_access优化器开关(默认禁用),禁用mrr_cost_based优化器开关(默认启用),并确保mrr优化器开关启用(默认启用)。要为会话启用批量密钥访问,可以使用以下查询:

SET SESSION
    optimizer_switch
       = 'mrr=on,mrr_cost_based=off,batched_key_access=on';

当以这种方式启用优化时,您还可以使用BKA()NO_BKA()优化器提示来影响是否应该使用优化。使用时,传统EXPLAIN输出中的Extra列包含Using join buffer (Batched Key Access),JSON 输出中的using_join_buffer字段设置为Batched Key Access。清单 17-9 显示了使用批量密钥访问时的完整EXPLAIN输出示例。

mysql> EXPLAIN
        SELECT /*+ BKA(ci) */
               co.Code, co.Name AS Country,
               ci.Name AS City
          FROM world.country co
               INNER JOIN world.city ci
                     ON ci.CountryCode = co.Code\G
**************************** 1\. row *****************************
           id: 1
  select_type: SIMPLE
        table: co
   partitions: NULL
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 239
     filtered: 100
        Extra: NULL
**************************** 2\. row *****************************
           id: 1
  select_type: SIMPLE
        table: ci
   partitions: NULL
         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 3
          ref: world.co.Code
         rows: 18
     filtered: 100
        Extra: Using join buffer (Batched Key Access)
2 rows in set, 1 warning (0.0007 sec)

Listing 17-9The EXPLAIN output with Batched Key Access

在这个例子中,通过使用CountryCode索引对city ( ci)表的连接使用优化器提示来启用批量键访问。

连接缓冲区的大小通过join_buffer_size选项进行配置。因为批量键访问优化主要用于大型连接,所以连接缓冲区通常应该配置得相对较大,通常为 4 兆字节或更大。因为对于大多数查询来说,大的连接缓冲区不是一个好的选择,所以建议只增加使用批量键访问优化的查询的大小。

其他优化

MySQL 包括对其他几种优化的支持。当它们对查询有益时,优化器会自动使用它们,很少需要手动禁用优化。了解优化是什么仍然是有用的,这样你就可以知道当你遇到它们时意味着什么,例如,在EXPLAIN输出中,当优化器在极少数情况下需要正确的推动时,你知道如何改变行为。

这一小节将按字母顺序介绍一些剩余的优化,重点是那些可以配置的优化。对于每个优化,都包括传统格式(Extra列)和 JSON 格式的优化器开关、优化器提示和EXPLAIN输出细节。

条件过滤

当一个表有两个或更多相关联的条件,并且一个索引可以用于部分条件时,使用条件筛选优化。启用条件筛选时,在估计表的整体筛选时,会考虑其余条件的筛选效果。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:condition_fanout_filter–默认启用

  • 优化器提示:

  • EXPLAIN 输出:

派生合并

优化器可以将派生表、视图引用和公用表表达式合并到它们所属的查询块中。优化的替代方法是物化表、视图引用或公共表表达式。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:derived_merge–默认开启。

  • 优化器提示: MERGE()NO_MERGE().

  • EXPLAIN 输出:查询计划反映派生表已经合并。

发动机状态下推

这种优化将条件下推到存储引擎。目前仅支持NDBCluster存储引擎。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:engine_condition_pushdown–默认开启。

  • 优化器提示:无。

  • EXPLAIN 输出:警告包括关于已经被下推的条件的信息。

索引条件下推

MySQL 可以下推所有可以通过使用单个索引中的列来确定的条件,但是索引只能直接过滤部分条件。例如,当您有一个类似于Name LIKE '%abc%'的条件并且Name是多列索引的一部分时,就会发生这种情况。优化也用于二级索引的范围条件。对于 InnoDB,索引条件下推仅支持二级索引。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:index_condition_pushdown–默认开启。

  • 优化器提示: NO_ICP().

  • EXPLAIN 输出:传统格式在Extra列有Using index condition,JSON 格式用推送的索引条件设置index_condition字段。

索引扩展

InnoDB 中的所有次要非唯一索引都将主键列附加到索引上。启用索引扩展优化时,MySQL 会将主键列视为索引的一部分。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:use_index_extensions–默认启用

  • 优化器提示:

  • EXPLAIN 输出:

索引可见性

当一个表有一个不可见的索引时,默认情况下,优化器在创建查询计划时不会考虑它。如果启用了索引可见性优化器开关,将考虑不可见的索引。例如,这可用于测试已添加但尚未可见的索引的效果。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:use_invisible_indexes–默认禁用

  • 优化器提示:

  • EXPLAIN 输出:

松散索引扫描

在某些情况下,MySQL 可以使用部分索引来提高聚合数据或包含DISTINCT子句的查询的性能。这要求用于对数据进行分组的列与不用于分组的附加列一起形成多列索引的左前缀。当有一个GROUP BY子句时,只允许使用MIN()MAX()聚合函数。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:无。

  • 优化器提示: NO_RANGE_OPTIMIZATION()禁用松散索引扫描优化以及索引合并和范围扫描。

  • EXPLAIN 输出:传统格式在Extra栏有Using index for group-by。JSON 格式将using_index_for_group_by字段设置为true

范围访问方法

范围优化与其他优化略有不同,因为它被视为一种访问方法。MySQL 将只扫描表或索引的一个或多个部分,而不是进行完整的表或索引扫描。范围访问法常用于涉及运算符>>=<=<BETWEEN的过滤条件。、IN()IS NULLLIKE等等。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:无。

  • 优化器提示:NO_RANGE_OPTIMIZATION()–这也会禁用松散索引扫描和索引合并优化。但是,它不会禁用跳过扫描优化,即使它也使用范围访问。

  • EXPLAIN 输出:接入方式设置为range

您可以使用range_optimizer_max_mem_size选项来限制用于范围访问的内存量。默认值为 8 MiB。如果将该值设置为 0,则意味着可以使用无限量的内存。

塞莫金

半连接优化用于INEXIST条件。有四种支持的策略:具体化、重复剔除、首次匹配和松散扫描(不要与松散索引扫描优化混淆)。启用子查询具体化时,半连接优化会尽可能使用具体化策略。对于EXISTS,半连接优化仅在 MySQL 8.0.16 及更高版本中受支持,而对于NOT EXISTS(以及类似的——这也称为反连接),需要 MySQL 8.0.17 或更高版本。

可以使用semijoin optimizer 开关来控制半连接优化,以完全启用或禁用优化。使用一个或多个MATERIALIZATIONDUPSWEEDOUTFIRSTMATCHLOOSESCAN作为参数,可以将SEMIJOIN()NO_SEMIJOIN()优化器提示用于单个查询。

物化策略与子查询物化优化相同。详情请看那个。

重复剔除策略执行半连接,就像执行普通连接一样,并使用临时表删除重复项。优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:duplicateweedout–默认开启。

  • 优化器提示: SEMIJOIN(DUPSWEEDOUT)NO_SEMIJOIN(DUPSWEEDOUT).

  • EXPLAIN 输出:传统格式在Extra列中有Start temporaryEnd temporary用于所涉及的表格。JSON 格式的输出使用一个名为duplicates_removal的块。

第一个匹配策略返回每个值的第一个匹配,而不是所有值。优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:firstmatch–默认开启。

  • 优化器提示: SEMIJOIN(FIRSTMATCH)NO_SEMIJOIN(FIRSTMATCH).

  • EXPLAIN 输出:传统格式的Extra列中有FirstMatch(...),其中括号中的值是引用表的名称。JSON 格式将first_match字段的值设置为引用表的名称。

松散扫描策略使用索引从子查询的每个值组中选择一个值。优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:loosescan–默认开启。

  • 优化器提示: SEMIJOIN(LOOSESCAN)NO_SEMIJOIN(LOOSESCAN).

  • EXPLAIN 输出:传统格式的Extra列中有LooseScan(m..n),其中mn表示索引的哪些部分用于松散扫描。JSON 格式将loosescan字段设置为等于true

跳过扫描

跳过扫描优化是 MySQL 8.0.13 中的新功能,其工作方式类似于松散索引扫描。当多列索引的第二列有范围条件,但第一列没有条件时,使用该方法。跳过扫描优化将完全索引扫描转变为一系列范围扫描(对索引中第一列的每个值进行一次范围扫描)。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:skip_scan–默认开启。

  • 优化器提示: SKIP_SCAN()NO_SKIP_SCAN().

  • EXPLAIN 输出:传统格式在Extra列有Using index for skip scan,JSON 格式将using_index_for_skip_scan字段设置为true

子查询具体化

子查询具体化策略将子查询的结果存储在内部临时表中。如果可能的话,优化器将在临时表上添加一个自动生成的散列索引,这样可以快速地将它加入到查询的其余部分中。

优化器开关、提示和EXPLAIN细节如下:

  • 优化器开关:materialization–默认开启。

  • 优化器提示: SUBQUERY(MATERIALIZATION).

  • EXPLAIN 输出:传统格式选择类型为MATERIALIZED。JSON 格式创建了一个名为materialized_from_subquery的块。

当启用subquery_materialization_cost_based优化器开关时(默认),优化器将使用估计成本在子查询具体化优化和IN-to-EXIST子查询转换之间做出决定(将IN条件重写为EXISTS)。当开关关闭时,优化器总是选择子查询具体化。

正如在最后两节中显而易见的,有许多配置优化器的可能性。下一节将对此进行更深入的探讨。

配置优化程序

有几种方法可以配置 MySQL 来影响优化器。您已经遇到了一些配置选项、优化器开关和优化器提示。本节将首先展示如何配置与不同操作相关的引擎和服务器成本,然后介绍配置选项以及关于优化器开关的更多详细信息。最后,将讨论优化器提示。

发动机成本

引擎开销提供了读取数据的开销信息。由于数据可以从内存或磁盘中读取,并且不同的存储引擎读取数据的成本可能不同,因此不存在一刀切的情况。因此,MySQL 允许您配置每个存储引擎从内存和磁盘读取的成本。

您可以使用mysql.engine_cost表来改变读取数据的成本。该表包含以下列:

  • engine_name : 成本数据的存储引擎。值default用于表示没有特定数据的所有存储引擎。

  • device_type : 当前未使用,必须为 0。

  • cost_name : 费用的名称。目前,有两个受支持的值:io_block_read_cost用于基于磁盘的读取,而memory_block_read_cost用于基于内存的读取。

  • cost_value : 读取操作的成本。值NULL(默认值)意味着使用存储在default_value列中的值。

  • last_update : 上次更新行的时间。时间以由time_zone会话变量设置的时区返回。

  • comment : 您可以提供的可选注释,用于说明成本更改的原因。注释最长可达 1024 个字符。

  • default_value : 该操作使用的默认成本。这是一个只读列。io_block_read_cost的默认值为 1,memory_block_read_cost的默认值为 0.25。

主键由engine_namedevice_typecost_name列组成。引擎成本对 InnoDB 特别有用,因为在 MySQL 8 中,InnoDB 可以向优化器提供数据是否在缓冲池中或者是否有必要从磁盘中读取的估计。

您可以使用UPDATE语句更新现有的成本估算。如果您想插入对存储引擎的估计,您可以使用INSERT语句,如果您想删除自定义成本值,您可以使用DELETE语句。无论哪种情况,您都必须执行FLUSH OPTIMIZER_COSTS语句,以使更改对新连接生效(现有连接继续使用旧值)。例如,如果您想添加特定于 InnoDB 的数据,假设主机的磁盘 I/O 很慢,内存很快,您可以使用如下语句

mysql> INSERT INTO mysql.engine_cost
              (engine_name, device_type, cost_name,
               cost_value, comment)
       VALUES ('InnoDB', 0, 'io_block_read_cost',
               2, 'InnoDB on non-local cloud storage'),
              ('InnoDB', 0, 'memory_block_read_cost',
               0.15, 'InnoDB with very fast memory');
Query OK, 2 rows affected (0.0887 sec)

Records: 2  Duplicates: 0  Warnings: 0

mysql> FLUSH OPTIMIZER_COSTS;
Query OK, 0 rows affected (0.0877 sec)

如果您想要更改成本值,建议将这些值大致增加一倍或一半,然后评估效果。因为引擎开销是全局的,所以您应该确保在更改之前有一个良好的监控基线,并在更改之后比较查询性能,以检测更改是否达到了预期的效果。

MySQL 还有一些更一般的服务器开销,可以用来影响与查询相关的各种操作。

服务器成本

MySQL 使用基于成本的方法来确定最佳查询计划。为了尽可能好地工作,它必须知道各种类型的操作有多昂贵。计算中最重要的部分是相对成本是正确的,这很有帮助。然而,不同系统之间的相对成本及其对工作负载的影响可能存在差异。

您可以使用mysql.server_cost表来更改几项操作的成本。该表包含以下各列:

  • cost_name : 操作的名称。

  • cost_value : 进行手术的费用。如果成本设置为NULL,则使用默认成本(default_value列)。成本以浮点数的形式提供。

  • last_update : 上次更新成本的时间。时间以由time_zone会话变量设置的时区返回。

  • comment : 您可以提供的可选注释,用于说明成本更改的原因。注释最长可达 1024 个字符。

  • default_value : 该操作使用的默认成本。这是一个只读列。

当前有六个操作可以在server_cost表中配置。这些是

  • disk_temptable_create_cost : 在磁盘上创建内部临时表的开销。disk_temptable_create_costdisk_temptable_row_cost的成本越低,优化器就越有可能选择需要磁盘上临时表的查询计划。默认成本为 20 英镑。

  • disk_temptable_row_cost : 在磁盘上创建的内部临时表的行操作的开销。默认成本为 0.5。

  • key_compare_cost : 比较记录键的成本。如果您对使用文件排序的按索引排序的查询计划有问题,而基于非索引的排序会更快,那么您可能会增加这些操作的成本。默认成本为 0.05。

  • memory_temptable_create_cost : 在内存中创建内部临时表的开销。memory_temptable_create_costmemory_temptable_row_cost的成本越低,优化器选择需要内存内部临时表的查询计划的可能性就越大。默认成本是 1。

  • memory_temptable_row_cost : 对内存中创建的内部临时表进行行操作的开销。默认成本为 0.1。

  • row_evaluate_cost : 评估行条件的一般成本。成本越低,MySQL 就越倾向于检查许多行,比如使用全表扫描。成本越高,MySQL 就越会尝试减少检查的行数,并使用更多的索引查找和范围扫描。默认成本为 0.1。

如果您确实想改变其中一个服务器成本,那么您需要使用一个常规的UPDATE语句,后跟FLUSH OPTIMIZER_COSTS。这些更改将影响新的连接。例如,如果您将磁盘上的内部临时表存储在 RAM 磁盘(共享内存磁盘)上,并希望降低成本以反映这一点

mysql> UPDATE mysql.server_cost
          SET cost_value = 1,
              Comment = 'Stored on memory disk'
        WHERE cost_name = 'disk_temptable_create_cost';
Query OK, 1 row affected (0.1051 sec)

Rows matched: 1  Changed: 1  Warnings: 0

mysql> UPDATE mysql.server_cost
          SET cost_value = 0.1,
              Comment = 'Stored on memory disk'
        WHERE cost_name = 'disk_temptable_row_cost';
Query OK, 1 row affected (0.1496 sec)

Rows matched: 1  Changed: 1  Warnings: 0

mysql> FLUSH OPTIMIZER_COSTS;
Query OK, 0 rows affected (0.1057 sec)

更改成本并不总是会影响查询计划,因为优化器可能除了使用给定的查询计划之外别无选择,或者计算出的成本相差很大,以至于更改服务器成本来影响查询计划会对其他查询产生太大的影响。请记住,所有连接的服务器开销都是全局的,因此只有在出现系统问题时才应该更改开销。如果问题只影响少数查询,最好使用优化器提示来影响查询计划。

影响查询计划的另一个选项是优化器开关。

优化器开关

优化器开关在本章中都有提及。它们通过optimizer_switch选项进行配置。优化器开关的工作方式与其他配置选项有些不同,因此有必要深入研究一下它们的使用。

optimizer_switch选项是一个复合选项,所有优化器开关使用同一个选项,但是可以更改单个开关,而不包括您不想更改的开关。您将想要切换的开关设置到onoff来启用或禁用它。优化器开关可以在影响所有新连接的全局范围内进行更改,也可以在会话级别进行更改。例如,如果您想禁用当前连接的derived_merge优化器开关,您可以使用以下语句:

mysql> SET SESSION optimizer_switch = 'derived_merge=off';
Query OK, 0 rows affected (0.0003 sec)

如果你想永久改变数值,你可以用同样的方法使用SET PERSISTSET PERSIST_ONLY:

mysql> SET PERSIST optimizer_switch = 'derived_merge=off';
Query OK, 0 rows affected (0.0431 sec)

如果您喜欢将值存储在 MySQL 配置文件中,同样的原则也适用,例如:

[mysqld]
optimizer_switch = "derived_merge=off"

17-3 列出了从 MySQL 8.0.18 开始可用的优化器开关,以及它们的默认值和开关功能的简要总结。优化器开关按照它们在optimizer_switch选项中出现的顺序排列。

表 17-3

优化器切换

|

优化器开关

|

缺省值

|

描述

|
| --- | --- | --- |
| index_merge | on | 总开关控制索引合并。 |
| index_merge_union | on | 联合索引合并策略。 |
| index_merge_sort_union | on | 排序-联合索引合并策略。 |
| index_merge_intersection | on | 交叉索引合并策略。 |
| engine_condition_pushdown | on | 将条件下推到NDBCluster存储引擎。 |
| index_condition_pushdown | on | 将索引条件下推到存储引擎。 |
| mrr | on | 多范围读取优化。 |
| mrr_cost_based | on | 是否使用多范围读取优化应该基于成本估计。 |
| block_nested_loop | on | 块嵌套循环连接算法。这与hash_join开关一起控制是否可以使用散列连接。 |
| batched_key_access | off | 批量密钥访问优化。还要求启用mrr开关,禁用mrr_cost_based开关,以便使用批量密钥访问。 |
| materialization | on | 是否可以使用物化子查询。这也会影响物化半连接优化是否可用。 |
| semijoin | on | 启用或禁用半连接优化的总开关。 |
| loosescan | on | 半连接松散扫描策略。 |
| firstmatch | on | 半连接首次匹配策略。 |
| duplicateweedout | on | 半连接重复淘汰策略。 |
| subquery_materialization_cost_based | on | 使用子查询具体化是否基于成本估计。 |
| use_index_extensions | on | InnoDB 添加到非唯一二级索引的主键列是否用作索引的一部分。 |
| condition_fanout_filter | on | 访问方法未处理的条件是否包括在过滤估计中。 |
| derived_merge | on | 派生的合并优化。 |
| use_invisible_indexes | off | 是否应该使用不可见的索引。 |
| skip_scan | on | 跳过扫描优化。 |
| hash_join | on | 哈希连接算法。要启用散列连接,还必须启用block_nested_loop开关。 |

本章前面更详细地描述了各种优化、策略和算法。

如果你想改变全局设置或会话期间的设置,optimizer_switch选项非常有用;但是,在许多情况下,您只需要更改优化器开关或单个查询的设置。在这种情况下,优化器提示是更好的选择。

优化器提示

优化器提示特性是在 MySQL 5.7 中引入的,并在 MySQL 8 中得到扩展。它允许您向优化器提供信息,因此您可以影响查询计划的最终结果。与打开或关闭选项的optimizer_switch选项不同,可以为每个查询块、表或索引设置等同的优化器提示。此外,还支持在查询期间更改配置选项的值。当优化器无法自己获得最佳查询计划时,或者当您需要使用比某个选项的全局缺省值更大的值来执行查询时,这是一种提高查询性能的强大方法。

优化器提示是在SELECTINSERTREPLACEUPDATEDELETE子句之后使用特殊的注释语法设置的。该语法使用行内注释,在注释开始后紧跟一个+,例如:

SELECT /*+ MAX_EXECUTION_TIME(2000) */
       id, Name, District
  FROM world.city
 WHERE CountryCode = 'AUS';

此示例将查询的最大执行时间设置为 2000 毫秒。

17-4 列出了从 MySQL 8.0.18 开始可用的优化器提示,包括每个提示支持的范围和简要描述。对于许多提示,有两个版本,一个用于启用特性,另一个用于禁用特性;这些是一起列出来的。除了NO_ICPNO_RANGE_OPTIMIZATION提示没有相应的启用该功能的提示之外,提示按照启用该功能的提示的字母顺序列出。

表 17-4

优化器提示

|

暗示

|

范围

|

描述

|
| --- | --- | --- |
| BKA``NO_BKA | 查询块桌子 | 批量密钥访问优化。 |
| BNL``NO_BNL | 查询块桌子 | 块嵌套循环连接算法。 |
| HASH_JOIN``NO_HASH_JOIN | 查询块桌子 | 哈希连接算法。 |
| INDEX_MERGE``NO_INDEX_MERGE | 桌子索引 | 索引合并优化。 |
| JOIN_FIXED_ORDER | 查询块 | 强制查询块中的所有联接按照它们在查询中列出的顺序执行。这和使用SELECT STRAIGHT_JOIN是一样的。 |
| JOIN_ORDER | 查询块 | 强制两个或多个表以特定顺序连接。优化器可以随意更改未列出的表的连接顺序。 |
| JOIN_PREFIX | 查询块 | 强制指定的表成为联接的第一个表,并按给定的顺序联接它们。 |
| JOIN_SUFFIX | 查询块 | 强制指定的表成为联接的最后一个表,并按给定的顺序联接它们。 |
| MAX_EXECUTION_TIME | 全球的 | 限制SELECT语句的查询执行时间。该值以毫秒为单位。 |
| MERGE``NO_MERGE | 桌子 | 派生的合并优化。 |
| MRR``NO_MRR | 桌子索引 | 多范围读取优化。 |
| NO_ICP | 桌子索引 | 索引条件下推优化。 |
| NO_RANGE_OPTIMIZATION | 桌子索引 | 不要对表和/或索引使用范围访问。这还会禁用索引合并和松散索引扫描。如果查询会导致许多范围扫描,并且会导致性能或资源问题,那么它非常有用。 |
| QB_NAME | 查询块 | 设置查询块的名称。该名称可用于在其他优化器提示中引用查询块。 |
| RESOURCE_GROUP | 全球的 | 用于查询的资源组。资源组将在下一节讨论。 |
| SEMIJOIN``NO_SEMIJOIN | 查询块 | 半连接优化。 |
| SKIP_SCAN``NO_SKIP_SCAN | 桌子索引 | 跳过扫描优化。 |
| SET_VAR | 全球的 | 在查询期间设置配置变量的值。 |
| SUBQUERY | 查询块 | 子查询是否可以使用物化优化或IN-to-EXISTS转换。 |

在本章前面讨论连接算法和优化时,已经遇到了一些这样的优化器提示。范围指定提示应用于查询的哪一部分。范围包括

  • 全局:提示适用于整个查询。

  • 查询块:提示适用于一组联接。例如,查询的顶层是一个查询块;子查询是另一个查询块。在某些情况下,应用于查询块的提示还可以接受连接的表名,以将提示限制为特定的连接。

  • 表:该提示适用于特定的表。

  • 索引:该提示适用于特定索引的使用。

当您指定一个表时,您需要使用该表在查询中使用的名称。如果已经为表指定了别名,则需要使用别名而不是表名,这样可以确保查询块中的所有表都可以被唯一标识。

Tip

详细讨论使用优化器提示的所有细节已经超出了本书的范围。随着新特性的增加,提示列表也会相对频繁地更新。建议您阅读 https://dev.mysql.com/doc/refman/en/optimizer-hints.html 以查看当前的优化器提示列表以及所有关于使用和可能冲突的详细信息。

优化器提示的指定方式与函数调用相同,参数在括号中指定。当优化器提示不带任何参数时,将使用一组空括号。您可以为同一个查询指定多个优化器提示,在这种情况下,您可以使用空格来分隔它们。如果指定了除前导查询块名称之外的几个参数,则必须用逗号分隔这些参数(但请注意,在某些情况下,空格用于将两条信息组合成一个参数,例如,在指定索引时,表名和索引名用空格分隔)。

对于具有多个查询块的复杂查询,命名查询块很有用,这样您就可以指定优化器提示应该应用到的查询块。您使用QB_NAME()优化器提示来设置查询块的名称:

SELECT /*+ QB_NAME(payment) */
       rental_id
  FROM sakila.payment
 WHERE staff_id = 1 AND customer_id = 75;

然后,在指定提示时,可以通过在查询块名称前添加@来引用查询块:

SELECT /*+ NO_INDEX_MERGE(@payment payment) */
       rental_id, rental_date, return_date
  FROM sakila.rental
 WHERE rental_id IN (
         SELECT /*+ QB_NAME(payment) */
                rental_id
           FROM sakila.payment
          WHERE staff_id = 1 AND customer_id = 75
       );

该示例将IN条件中的查询块的名称设置为付款。然后在顶层引用该块名,以禁用付款查询块中payment表的索引合并功能。当您以这种方式使用查询块名称时,提示中列出的所有表必须来自同一个查询块。指定查询块的另一种表示法是将其添加到表名之后,例如:

SELECT /*+ NO_INDEX_MERGE(payment@payment) */
       rental_id, rental_date, return_date
  FROM sakila.rental
 WHERE rental_id IN (
         SELECT /*+ QB_NAME(payment) */
                rental_id
           FROM sakila.payment
          WHERE staff_id = 1 AND customer_id = 75
       );

这与前面的示例相同,但是它的优点是可以对不同查询块中的表使用一个提示。

优化器提示的一个重要用途是在查询期间更改配置变量的值。这对于像join_buffer_sizeread_rnd_buffer_size这样的选项特别有用,它们最好保持一个小的全局值,但是对于某些查询来说,较大的值可以提高性能。使用SET_VAR()优化器提示,参数作为变量赋值。在参考手册中,可以与SET_VAR()优化器提示一起使用的变量有“SET_VAR提示适用:是”。例如,要将join_buffer_size设置为 1 MiB,将optimizer_search_depth设置为 0(这个选项稍后会解释),您可以使用

SELECT /*+ SET_VAR(join_buffer_size = 1048576)
           SET_VAR(optimizer_search_depth = 0) */
       CountryCode, country.Name AS Country,
       city.Name AS City, city.District
  FROM world.country IGNORE INDEX (Primary)
       INNER JOIN world.city IGNORE INDEX (CountryCode)
             ON city.CountryCode = country.Code
 WHERE Continent = 'Asia';

这个例子中有几件事情需要注意。首先,SET_VAR()提示不支持在同一个提示中设置两个选项,所以需要为每个选项指定一次提示。第二,不支持表达式或单位,所以对于join_buffer_size需要直接用字节提供值。

有一件事优化器提示帮不了你。如果您对优化器做出的索引选择不满意,您将需要使用索引提示。

索引提示

索引提示在 MySQL 中已经存在很长时间了。您可以使用它们为每个表指定允许优化器使用哪些索引以及应该忽略哪些索引。在禁用用于块嵌套循环和散列连接算法的示例的索引时,您已经遇到了IGNORE INDEX提示。

MySQL 支持三种索引提示:

  • IGNORE INDEX : 优化器根本不允许使用命名索引。

  • USE INDEX : 如果使用了索引,优化器应该使用其中一个指定的索引。

  • FORCE INDEX : 这与USE INDEX相同,除了如果有可能使用一个指定的索引,应该总是避免表扫描。

当您使用其中一个索引提示时,您需要在括号内的逗号分隔列表中提供应该受该提示影响的索引的名称。索引提示就放在表名的后面。如果为表添加别名,请将索引提示放在别名之后。例如,要在不使用country表的主键和city表的CountryCode索引的情况下查询亚洲的所有城市,可以使用以下查询:

SELECT ci.CountryCode, co.Name AS Country,
       ci.Name AS City, ci.District
  FROM world.country co IGNORE INDEX (Primary)
       INNER JOIN world.city ci IGNORE INDEX (CountryCode)
             ON ci.CountryCode = co.Code
 WHERE co.Continent = 'Asia';

注意主键是如何被调用的Primary。在示例中,索引提示适用于所有可以对表使用索引的操作。可以通过添加FOR JOINFOR ORDER BYFOR GROUP BY来限制连接、排序或分组的范围,例如:

SELECT *
  FROM world.city USE INDEX FOR ORDER BY (Primary)
 WHERE CountryCode = 'AUS'
 ORDER BY ID;

虽然在大多数情况下最好限制索引提示的使用,以便优化器可以随着索引和数据的变化而自由地改变查询计划,但是索引提示是可用的最强大的工具之一,在需要时不应该回避使用它们。

影响优化器的最后一种方法是使用配置选项。

配置选项

除了optimizer_switch选项之外,还有一些配置选项会影响优化器。这些选项控制优化器搜索最佳查询计划的详尽程度,以及是否应该使用优化器跟踪功能跟踪其步骤。优化器跟踪特性将推迟到第 20 章,在那里它将与EXPLAIN语句一起讨论。

这里将讨论的两个选项是

  • optimizer_prune_level

  • optimizer_search_depth

optimizer_prune_level选项的值可以是 0 或 1。默认值为 1。它决定了优化器是否会删除查询计划,以避免进行彻底的搜索。值 1 表示启用修剪。如果您遇到一个查询,其中修剪阻止优化器找到足够好的查询计划,那么可以为会话更改optimizer_prune_level。全局值应该几乎总是 1。

optimizer_search_depth选项决定了在搜索最佳查询计划时应该包括多少个表(连接)。允许的值为 0–62,默认值为 62。因为一个查询块允许的最大表数是 61,所以值 62 意味着除了通过修剪移除的搜索路径之外,进行了穷举搜索。值 0 表示 MySQL 选取最大搜索深度;目前,这与将值设置为 7 是一样的。

如果您的查询块包含许多通过内部连接连接的表,并且与查询执行时间相比,确定查询计划需要很长时间,那么您可能希望将optimizer_search_depth设置为 0 或小于 62 的值。另一种方法是使用JOIN_ORDER()JOIN_PREFIX()JOIN_SUFFIX()优化器提示来锁定部分查询的连接顺序。

到目前为止,讨论一直围绕着优化过程和优化器拥有的选项。还有一个层面需要考虑:当查询执行时应该使用哪个资源组。

资源组

资源组的概念在 MySQL 8 中是新的,它允许您为一个查询或一组查询可以使用的资源使用设置规则。这可能是提高高并发系统性能的一种强大方法,并允许您将一些查询的优先级设置得比其他查询高。本节介绍如何获取现有资源组的信息、如何管理资源组以及如何使用它们。

Note

在撰写本文时,macOS 或使用商业线程池插件时不支持资源组。此外,当没有设置CAP_SYS_NICE功能时,Solaris 和 FreeBSD 以及 Linux 上的线程优先级会被忽略。要查看最新的限制以及如何启用CAP_SYS_NICE功能,请参见 https://dev.mysql.com/doc/refman/en/resource-groups.html#resource-group-restrictions

检索关于资源组的信息

关于现有资源组的信息可以在information_schema.RESOURCE_GROUPS视图中找到,该视图位于存储资源组的数据字典表的顶部。该视图包括以下列:

  • RESOURCE_GROUP_NAME : 资源组的名称。

  • RESOURCE_GROUP_TYPE : 资源组是针对SYSTEM级线程还是USER级线程。SYSTEM由系统线程使用,USER由用户连接使用。

  • RESOURCE_GROUP_ENABLED : 资源组是否启用。

  • VCPU_IDS : 允许资源组使用哪些虚拟 CPU。虚拟 CPU 考虑了物理 CPU 内核、超线程、硬件线程等。

  • THREAD_PRIORITY : 使用资源组的线程的线程优先级。值越低,优先级越高。

清单 17-10 显示了 MySQL 安装中默认资源组的资源组信息。VCPU_IDS列的值取决于系统中虚拟 CPU 的数量。

mysql> SELECT *
         FROM information_schema.RESOURCE_GROUPS\G
*************************** 1\. row ***************************
   RESOURCE_GROUP_NAME: USR_default
   RESOURCE_GROUP_TYPE: USER
RESOURCE_GROUP_ENABLED: 1
              VCPU_IDS: 0-7
       THREAD_PRIORITY: 0
*************************** 2\. row ***************************
   RESOURCE_GROUP_NAME: SYS_default
   RESOURCE_GROUP_TYPE: SYSTEM
RESOURCE_GROUP_ENABLED: 1
              VCPU_IDS: 0-7
       THREAD_PRIORITY: 0
2 rows in set (0.0007 sec)

Listing 17-10The information for the default resource groups

默认情况下有两个资源组:用于用户连接的USR_default组和用于系统线程的SYS_default组。这两个组的配置相同,并且允许使用所有的 CPU。这两个组既不能删除,也不能修改。但是,您可以创建自己的资源组。

管理资源组

只要不尝试修改或删除其中一个默认组,就可以创建、更改和删除资源组。这允许您创建资源组,您可以使用这些资源组在查询之间划分资源。创建、更改或删除资源组需要RESOURCE_GROUP_ADMIN权限。

以下语句可用于管理资源组:

  • CREATE RESOURCE GROUP : 创建新的资源组

  • ALTER RESOURCE GROUP : 修改现有的资源组

  • DROP RESOURCE GROUP : 删除资源组

对于所有三个语句,必须始终指定组名,并且指定组名时不带任何参数名(示例将在后面给出)。表 17-5 显示了三个语句的参数。其中值指定 N 或 M-N,M 和 N 代表整数。

表 17-5

管理资源组时使用的参数

|

[计]选项

|

句法

|

价值观念

|

操作

|
| --- | --- | --- | --- |
| 名字 |   | 最多 64 个字符 | CREATE``ALTER``DROP |
| 类型 | TYPE = ... | SYSTEM``USER | CREATE |
| 中央处理器(central processing units 的缩写) | VCPU = ... | 逗号分隔列表中的NM-N | CREATE``ALTER |
| 优先 | THREAD_PRIORITY | N | CREATE``ALTER |
| 状态 |   | ENABLED``DISABLED | CREATE``ALTER |
| 力 | FORCE |   | ALTER``DROP |

对于优先级,值的有效范围取决于组类型。SYSTEM组的优先级介于-20 和 0 之间,而USER类型的优先级介于-20 和 19 之间。优先级的含义遵循 Linux 中 nice 特性的原则,即优先级值越低,线程获得的优先级越高。因此,-20 是最高优先级,而 19 是最低优先级。在 Microsoft Windows 上,有五个本地优先级可用。表 17-6 列出了从资源组优先级到微软视窗优先级的映射。

表 17-6

从 Microsoft Windows 的资源组优先级映射

|

开始优先级

|

结束优先级

|

Microsoft Windows 优先级

|
| --- | --- | --- |
| -20 | -10 | THREAD_PRIORITY_HIGHEST |
| -9 | -1 | THREAD_PRIORITY_ABOVE_NORMAL |
| Zero | Zero | THREAD_PRIORITY_NORMAL |
| one | Ten | THREAD_PRIORITY_BELOW_NORMAL |
| Eleven | Nineteen | THREAD_PRIORITY_LOWEST |

创建新的资源组时,必须设置组的名称和类型。其余的参数是可选的。默认设置是将VCPU设置为包含主机上所有可用的 CPU,将优先级设置为 0,并启用该组。为可以使用 id 为 2、3、6 和 7 的 CPU 的用户连接创建名为my_group的已启用组的示例如下(这要求主机至少有八个虚拟 CPU):

CREATE RESOURCE GROUP my_group
  TYPE = USER
  VCPU = 2-3,6,7
THREAD_PRIORITY = 0
ENABLE;

VCPU参数的说明展示了如何一个接一个地列出 CPU 或者使用一个范围。资源组名被视为一个标识符,因此在与模式和表名相同的情况下,您只需要用反斜杠将其括起来。

ALTER RESOURCE GROUP语句类似于CREATE RESOURCE GROUP语句,但是您不能更改组名或组类型。例如,更改名为my_group的组的 CPU 和优先级

 ALTER RESOURCE GROUP my_group
  VCPU = 2-5
THREAD_PRIORITY = 10;

如果需要删除资源组,可以使用DROP RESOURCE GROUP语句,该语句只需要组名,例如:

DROP RESOURCE GROUP my_group;

对于ALTER RESOURCE GROUPDROP RESOURCE GROUP语句,有一个可选参数FORCE。这指定了当有线程使用资源组时,MySQL 应该如何处理。表 17-7 总结了这些行为。

表 17-7

使用武力或不使用武力的结果

|

强制(force 的现在分词形式)

|

改变

|

|
| --- | --- | --- |
| 不强迫 | 当使用该组的所有现有线程都已终止时,更改生效。在此之前,没有新线程可以使用该资源组。 | 如果将任何线程分配给该组,则会发生错误。 |
| 强制(force 的现在分词形式) | 基于线程类型,现有线程被移动到默认组。 | 基于线程类型,现有线程被移动到默认组。 |

在修改和删除资源组时,如果您有FORCE选项,分配给该组的现有线程将被重新分配给默认组。这意味着用户连接的USR_default组和系统线程的SYS_default组。对于ALTER RESOURCE GROUP,只有同时指定了DISABLE选项,才能使用FORCE选项。

现在,您可以为线程分配资源组了。

分配资源组

有两种方法可以为线程设置资源组。您可以为线程显式设置资源组,也可以使用优化器提示为单个查询设置资源组。它需要RESOURCE_GROUP_ADMINRESOURCE_GROUP_USER特权来将线程分配给资源组,而不考虑所使用的方法。

首先,重新创建my_group组(这次只使用一个 CPU,让它在所有系统上工作):

CREATE RESOURCE GROUP my_group
  TYPE = USER
  VCPU = 0
THREAD_PRIORITY = 0
ENABLE;

Note

使用 X 协议(MySQL Shell 的默认协议)的连接目前不允许创建、修改或设置资源组,除非使用优化器提示为单个查询设置资源组。

您使用SET RESOURCE GROUP语句将一个线程分配给一个资源组。这对系统线程和用户线程都有效。要将连接本身分配给资源组,请使用将资源组名称作为唯一参数的语句,例如:

SET RESOURCE GROUP my_group;

如果您想要为一个或多个其他线程更改资源组,您可以在末尾添加FOR关键字,后跟一个以逗号分隔的列表,列出您想要分配给该组的性能模式线程 id。例如,将线程 47、49 和 50 分配给my_group(在整个示例中,线程 id 在您的情况下显然会有所不同——替换为您系统中存在的线程)

SET RESOURCE GROUP my_group FOR 47, 49, 50;

或者,您可以使用RESOURCE_GROUP()优化器提示在查询期间将 As 资源组分配给一个线程,例如:

SELECT /*+ RESOURCE_GROUP(my_group) */
       *
  FROM world.city
 WHERE CountryCode = 'USA';

optimizer 提示通常是使用资源组的最佳方式,因为它允许您为每个查询设置它,并且当您使用 X 协议时它是受支持的。它还可以与 MySQL 重写插件或代理(如 ProxySQL)结合使用,后者支持向查询添加优化器提示注释。

您可以使用performance_schema.threads表中的RESOURCE_GROUP列来查看每个线程正在使用哪个资源组。例如,查看前面用SET RESOURCE GROUP FOR 47, 49, 50语句更改的三个线程使用的资源组

mysql> SELECT THREAD_ID, RESOURCE_GROUP
         FROM performance_schema.threads
        WHERE THREAD_ID IN (47, 49, 50);
+-----------+----------------+
| THREAD_ID | RESOURCE_GROUP |
+-----------+----------------+
|        47 | my_group       |
|        49 | my_group       |
|        50 | my_group       |
+-----------+----------------+
3 rows in set (0.0008 sec)

这就留下了您应该如何使用资源组的问题。

性能考虑因素

使用资源组的效果取决于几个因素。默认情况下,所有线程都可以在任何 CPU 上执行,并且具有相同的中间优先级,这与 MySQL 5.7 和更早版本中的行为相同。当 MySQL 开始遇到资源争用时,为资源组使用不同配置的主要好处就来了。

不可能给出如何最佳使用资源组的具体建议,因为它在很大程度上取决于硬件和查询工作负载的组合。随着对 MySQL 代码的新改进,资源组的最佳使用也会发生变化。这意味着,像往常一样,您需要使用监控来确定更改资源组和使用它们的效果。

也就是说,对于如何使用资源组来提高性能或用户体验,可以提出一些建议。这些包括但不限于

  • 对不同的连接给予不同的优先级。例如,这可以确保批处理作业不会过多地影响与前端应用相关的查询,或者可以为不同的应用提供不同的优先级。

  • 将不同应用的线程分配给不同的 CPU 集,以减少它们之间的干扰。

  • 将写线程和读线程分配给不同的 CPU 集,以设置不同任务的最大并发性。例如,如果写线程遇到资源争用,这对于限制写线程的并发性是有用的。

  • 为执行占用许多锁的事务的线程赋予高优先级,以便事务可以尽快完成并再次释放锁。

根据经验,如果没有足够的 CPU 资源来并行执行所有事情,或者写并发性变得太高,则资源组是有用的,通过限制哪些 CPU 处理写工作负载来限制写并发性可以用来避免争用。对于低并发性工作负载,通常最好使用默认的资源组。

摘要

本章介绍了优化器的工作原理、可用的连接算法和优化、如何配置优化器以及资源组。

MySQL 使用基于成本的优化器,在该优化器中,对查询执行的每个部分的成本进行估计,并选择整体查询计划来最小化成本。作为优化的一部分,优化器将使用各种转换重写查询,找到最佳连接顺序,并做出其他决定,例如应该使用哪些索引。

MySQL 支持三种连接算法。最简单也是最原始的算法是嵌套循环连接,它简单地遍历最外层表中的行,然后对下一个表进行嵌套循环,依此类推。块嵌套循环是一种扩展,其中非索引联接可以使用联接缓冲区来减少内部表的表扫描次数。MySQL 8.0.18 中的新特性是散列连接算法,它也用于不使用索引的连接,并且对于它支持的连接非常有效——对于低选择性索引,它的效果甚至超过了索引连接。

还有一系列其他的优化方法可以使用。特别关注索引合并、多范围读取和批量键访问优化。索引合并优化允许 MySQL 对每个表使用多个索引。多范围读取优化用于减少二级索引读取导致的随机 I/O 数量。批量键访问优化结合了块嵌套循环和多范围读取优化。

有几种方法可以改变 MySQL 的配置来影响优化器。mysql.engine_cost表存储从内存和磁盘读取的成本信息。这可以针对每个存储引擎进行设置。mysql.server_cost包含各种操作的基本成本估计,例如使用内部临时表和比较记录。optimizer_switch配置选项用于启用或禁用各种优化器特性,如块嵌套循环、批量键访问等。

影响优化器的两个灵活选项是使用优化器提示和索引提示。优化器提示可用于启用或禁用特性,以及为查询设置选项,甚至更精细地细化到索引级别。索引提示可用于启用或禁用表的索引。可选地,索引提示可以限于特定的操作,例如排序。最后,optimizer_prune_leveloptimizer_search_depth选项可以用来限制优化器为找到最佳查询计划所做的工作量。

最后一个功能是 MySQL 8 中添加的资源组。资源组可以用来指定一个线程可以使用哪个 CPU,以及该线程应该以哪个优先级执行。这对于某些线程的优先级高于其他线程或防止资源争用非常有用。

下一章将探讨 MySQL 中的锁是如何工作的。

十八、锁定理论和监控

与上一章讨论的优化器一起,锁可能是查询优化中最复杂的主题。当锁显示出它们最糟糕的一面时,即使是最好的锁专家也会头发变白。然而,不要绝望。本章将向你介绍你需要的大多数锁的知识,甚至更多。在你读完这一章之后,你应该能够开始研究锁,并利用它获得更多的知识。

本章开始讨论为什么需要锁以及锁的访问级别。本章最大的部分介绍了 MySQL 中最常见的锁。本章的另一半讨论为什么锁请求可能失败,如何减少锁的影响,以及如何监控锁。

Note

大多数例子都包含了再现输出的重要部分的语句(某些数据在本质上会因情况而异)。因为锁定的有趣部分通常包括多个连接,所以查询提示被设置为在重要时指示哪个连接用于哪个查询。例如,Connection 1>意味着查询应该由您的第一个连接执行。

为什么需要锁?

这似乎是一个不需要锁定数据库的完美世界。但是价格会很高,只有少数用例可以使用该数据库,对于 MySQL 这样的通用数据库来说是不可能的。如果没有锁定,就不能有任何并发性。想象一下,只允许一个到数据库的连接(你可以说它本身是一个锁,因此系统不是无锁的)——这对大多数应用来说不是很有用。

Note

通常,MySQL 中所谓的锁实际上是一个锁请求,它可以处于授权或挂起状态。

当您有几个连接同时执行查询时,您需要某种方法来确保这些连接不会互相妨碍。这就是锁进入画面的地方。您可以将锁想象成道路交通中的交通信号,它控制资源的访问以避免事故。在道路交叉路口,要保证两车不交叉,不发生碰撞。在数据库中,有必要确保两个查询对数据的访问不冲突。

由于控制进入十字路口有不同的级别——让行、停车标志和交通灯——数据库中有不同的锁类型。

锁定访问级别

锁访问级别决定了给定锁允许哪种类型的访问。它有时也被称为锁类型,但是因为它可能与锁粒度混淆,所以这里使用术语锁访问级别。

本质上有两种访问级别:共享或独占。访问级别顾名思义。共享锁允许其他连接也获得共享锁。这是最宽松的锁访问级别。独占锁只允许一个连接获得锁。共享锁也称为读锁,排他锁也称为写锁。

MySQL 还有一个叫做意向锁的概念,它指定了事务的意向。意向锁可以是共享的,也可以是排他的。在下一节讨论 MySQL 中的主要锁粒度级别时,将更详细地讨论意向锁。

锁定粒度

MySQL 使用一系列不同的锁粒度(也称为锁类型)来控制对数据的访问。通过使用不同的锁粒度,在最大程度上允许对数据的并发访问。本节将介绍 MySQL 使用的主要粒度级别。

用户级锁

用户级锁是应用可以用来保护的显式锁类型,例如,工作流。它们不常使用,但是对于一些需要序列化访问的复杂任务来说,它们会很有用。所有用户锁都是排他锁,使用最长 64 个字符的名称获得。

您可以使用一组函数来操作用户级锁:

  • GET_LOCK(name, timeout) : 通过指定锁的名称获得锁。第二个参数是以秒为单位的超时;如果在这段时间内没有获得锁,该函数将返回 0。如果获得了锁,返回值为 1。如果超时为负,该函数将无限期等待锁变为可用。

  • IS_FREE_LOCK(name) : 检查命名锁是否可用。如果锁可用,函数返回 1,如果锁不可用,函数返回 0。

  • IS_USED_LOCK(name) : 这是IS_FREE_LOCK()功能的反义词。如果锁在使用中(不可用),该函数返回持有锁的连接的连接 id,如果锁不在使用中(可用),则返回NULL

  • RELEASE_ALL_LOCKS() : 释放连接持有的所有用户级锁。返回值是释放的锁的数量。

  • RELEASE_LOCK(name) : 用提供的名字解锁。如果锁被释放,返回值为 1;如果锁存在但不属于连接,返回值为 0;如果锁不存在,返回值为NULL

通过多次调用GET_LOCK()可以获得多个锁。如果这样做,请注意确保所有用户以相同的顺序获得锁,否则可能会发生死锁。如果发生死锁,将返回一个ER_USER_LOCK_DEADLOCK错误(错误代码 3058)。清单 18-1 中显示了一个例子。

-- Connection 1
Connection 1> SELECT GET_LOCK('my_lock_1', -1);
+---------------------------+
| GET_LOCK('my_lock_1', -1) |
+---------------------------+
|                         1 |
+---------------------------+
1 row in set (0.0100 sec)

-- Connection 2
Connection 2> SELECT GET_LOCK('my_lock_2', -1);
+---------------------------+
| GET_LOCK('my_lock_2', -1) |
+---------------------------+
|                         1 |
+---------------------------+
1 row in set (0.0006 sec)

Connection 2> SELECT GET_LOCK('my_lock_1', -1);

-- Connection 1

Connection 1> SELECT GET_LOCK('my_lock_2', -1);
ERROR: 3058: Deadlock found when trying to get user-level lock; try rolling back transaction/releasing locks and restarting lock acquisition.

Listing 18-1A deadlock for user-level locks

当连接 2 试图获取my_lock_1锁时,该语句将被阻塞,直到连接 1 试图获取触发死锁的my_lock_2锁。如果您获得多个锁,您应该准备好处理死锁。请注意,对于用户级锁,死锁不会触发事务回滚。

被授予和挂起的用户级锁可以在performance_schema.metadata_locks表中找到,其中OBJECT_TYPE列设置为USER LEVEL LOCK,如清单 18-2 所示。列出的锁假设您离开了清单 18-1 中的死锁被触发时的系统。请注意,有些值如OBJECT_INSTANCE_BEGIN会因您而异。

mysql> SELECT *
         FROM performance_schema.metadata_locks
        WHERE OBJECT_TYPE = 'USER LEVEL LOCK'\G
*************************** 1\. row ***************************
          OBJECT_TYPE: USER LEVEL LOCK
        OBJECT_SCHEMA: NULL
          OBJECT_NAME: my_lock_1
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2600542870816
            LOCK_TYPE: EXCLUSIVE
        LOCK_DURATION: EXPLICIT
          LOCK_STATUS: GRANTED
               SOURCE: item_func.cc:4840
      OWNER_THREAD_ID: 76
       OWNER_EVENT_ID: 33
*************************** 2\. row ***************************
          OBJECT_TYPE: USER LEVEL LOCK
        OBJECT_SCHEMA: NULL
          OBJECT_NAME: my_lock_2
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2600542868896
            LOCK_TYPE: EXCLUSIVE
        LOCK_DURATION: EXPLICIT
          LOCK_STATUS: GRANTED
               SOURCE: item_func.cc:4840
      OWNER_THREAD_ID: 62
       OWNER_EVENT_ID: 25
*************************** 3\. row ***************************
          OBJECT_TYPE: USER LEVEL LOCK
        OBJECT_SCHEMA: NULL
          OBJECT_NAME: my_lock_1
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2600542870336
            LOCK_TYPE: EXCLUSIVE
        LOCK_DURATION: EXPLICIT
          LOCK_STATUS: PENDING
               SOURCE: item_func.cc:4840
      OWNER_THREAD_ID: 62
       OWNER_EVENT_ID: 26

3 rows in set (0.0086 sec)

Listing 18-2Listing user-level locks

用户级锁的OBJECT_TYPEUSER LEVEL LOCK,锁的持续时间是EXPLICIT,因为这取决于用户或应用是否再次释放锁。在第 1 行中,具有性能模式线程 id 76 的连接已经被授予了my_lock_1锁,而在第 3 行中,线程 id 62 正在等待它被授予。线程 id 62 也具有包含在行 2 中的授权锁。

下一级锁涉及非数据表级锁。首先要讨论的是冲水锁。

清空锁

大多数参与备份的人都熟悉刷新锁。它是在使用FLUSH TABLES语句时获取的,并持续整个语句期间,除非您添加了WITH READ LOCK,在这种情况下,共享(读)锁将被持有,直到该锁被显式释放。在ANALYZE TABLE语句的结尾也会触发隐式的表刷新。刷新锁是一个表级锁。用FLUSH TABLES WITH READ LOCK获取的读锁将在后面的显式锁中讨论。

刷新锁的锁问题的一个常见原因是长时间运行的查询。只要存在打开表的查询,一个FLUSH TABLES语句就不能刷新表。这意味着,如果在一个长时间运行的查询使用一个或多个被刷新的表时执行一个FLUSH TABLES语句,那么FLUSH TABLES语句将阻塞所有其他需要这些表的语句,直到锁的情况得到解决。

嵌入式锁受lock_wait_timeout设置的影响。如果获得锁的时间超过lock_wait_timeout秒,MySQL 将放弃锁。如果FLUSH TABLES声明被扼杀,同样适用。然而,由于 MySQL 的内部原因,在长时间运行的查询完成之前,一个称为表定义缓存(TDC)版本锁的较低级别的锁不能总是被释放。 1 这意味着确保锁问题得到解决的唯一方法是终止长时间运行的查询,但是要注意,如果查询已经更改了许多行,回滚查询可能需要很长时间。

当围绕刷新锁存在锁争用时,FLUSH TABLES语句和随后启动的查询都将状态设置为“等待表刷新”清单 18-3 展示了一个包含三个查询的例子。为了自己重现这个场景,开始执行三个查询,将提示设置为Connection N>,其中N为 1、2 或 3,代表三个不同的连接。针对sys.session的查询在第四个连接中完成。所有查询都必须在第一个查询完成之前执行(需要三分钟)。

-- Connection 1
Connection 1> SELECT *, SLEEP(180) FROM world.city WHERE ID = 130;

-- Connection 2
Connection 2> FLUSH TABLES world.city;

-- Connection 3
Connection 3> SELECT * FROM world.city WHERE ID = 201;

-- Connection 4
Connection 4> SELECT thd_id, conn_id, state,
                     current_statement
                FROM sys.session
               WHERE current_statement IS NOT NULL
                     AND thd_id <> PS_CURRENT_THREAD_ID()\G
*************************** 1\. row ***************************
           thd_id: 61
          conn_id: 21
            state: User sleep
current_statement: SELECT *, SLEEP(180) FROM world.city WHERE ID = 130
*************************** 2\. row ***************************
           thd_id: 62
          conn_id: 22
            state: Waiting for table flush
current_statement: FLUSH TABLES world.city
*************************** 3\. row ***************************
           thd_id: 64
          conn_id: 23
            state: Waiting for table flush
current_statement: SELECT * FROM world.city WHERE ID = 201
3 rows in set (0.0598 sec)

Listing 18-3Example of waiting for a flush lock

该示例使用了sys.session视图;使用performance_schema.threadsSHOW PROCESSLIST可以获得类似的结果。为了减少输出以仅包括与刷新锁讨论相关的查询,当前线程和没有正在进行的查询的线程被过滤掉。

conn_id = 21的连接正在执行一个使用world.city表的慢速查询(使用了一个SLEEP(180)来确保它花费很长时间)。同时,conn_id = 22world.city表执行了一条FLUSH TABLES语句。因为第一个查询仍然打开着表(一旦查询完成,它就会被释放),所以FLUSH TABLES语句最终会等待表刷新锁。最后,conn_id = 23试图查询表,因此必须等待FLUSH TABLES语句。

另一种非数据表锁是元数据锁。

元数据锁

元数据锁是 MySQL 中较新的锁类型之一。它们是在 MySQL 5.5 中引入的,它们的目的是保护模式,因此当查询或事务依赖于模式不变时,它不会被改变。元数据锁在表级别工作,但是它们应该被视为独立于表锁的锁类型,因为它们不保护表中的数据。

语句和 DML 查询使用共享元数据锁,而 DDL 语句使用排他锁。当第一次使用表时,连接获取表上的元数据锁,并保持该锁直到事务结束。当持有元数据锁时,不允许其他连接更改表的模式定义。但是,执行SELECT语句和 DML 语句的其他连接不受限制。通常,关于元数据锁的最大问题是阻止 DDL 语句开始工作的空闲事务。

如果遇到关于元数据锁定的冲突,您会看到进程列表中的查询状态设置为“等待表元数据锁定”清单 18-4 中显示了一个包括要设置的查询的例子。

-- Connection 1
Connection 1> SELECT CONNECTION_ID();
+-----------------+
| CONNECTION_ID() |
+-----------------+
|              21 |
+-----------------+
1 row in set (0.0003 sec)

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> SELECT * FROM world.city WHERE ID = 130\G
*************************** 1\. row ***************************
         ID: 130
       Name: Sydney
CountryCode: AUS
   District: New South Wales
 Population: 3276207
1 row in set (0.0005 sec)

-- Connection 2
Connection 2> SELECT CONNECTION_ID();
+-----------------+
| CONNECTION_ID() |
+-----------------+
|              22 |
+-----------------+
1 row in set (0.0003 sec)

Connection 2> OPTIMIZE TABLE world.city;

-- Connection 3
Connection 3> SELECT thd_id, conn_id, state,
                     current_statement,
                     last_statement
                FROM sys.session
               WHERE conn_id IN (21, 22)\G
*************************** 1\. row ***************************
           thd_id: 61
          conn_id: 21
            state: NULL

current_statement: SELECT * FROM world.city WHERE ID = 130
   last_statement: SELECT * FROM world.city WHERE ID = 130
*************************** 2\. row ***************************
           thd_id: 62

          conn_id: 22
            state: Waiting for table metadata lock
current_statement: OPTIMIZE TABLE world.city
   last_statement: NULL
2 rows in set (0.0549 sec)

Listing 18-4Example of waiting for table metadata lock

在本例中,与conn_id = 21的连接有一个正在进行的事务,并且在前一条语句中查询了world.city表(本例中的当前语句与下一条语句执行之前不会被清除的语句相同)。当事务仍然活跃时,conn_id = 22已经执行了一个OPTIMIZE TABLE语句,该语句现在正在等待元数据锁定。(是的,OPTIMIZE TABLE并没有改变模式定义,但是它作为 DDL 语句仍然受到元数据锁定的影响。)

当导致元数据锁定的是当前或最后一条语句时,这是很方便的。在更一般的情况下,您可以使用将OBJECT_TYPE列设置为TABLEperformance_schema.metadata_locks表来查找授予的和挂起的元数据锁。清单 18-5 显示了一个使用与前一个例子相同的设置的被授予和挂起的元数据锁的例子。第 22 章详细介绍了元数据锁的研究。

-- Connection 3
Connection 3> SELECT *
                FROM performance_schema.metadata_locks
               WHERE OBJECT_SCHEMA = 'world'
                     AND OBJECT_NAME = 'city'\G
*************************** 1\. row ***************************
          OBJECT_TYPE: TABLE
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2195760373456
            LOCK_TYPE: SHARED_READ
        LOCK_DURATION: TRANSACTION
          LOCK_STATUS: GRANTED
               SOURCE: sql_parse.cc:6014
      OWNER_THREAD_ID: 61
       OWNER_EVENT_ID: 53
*************************** 2\. row ***************************
          OBJECT_TYPE: TABLE
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2194784109632
            LOCK_TYPE: SHARED_NO_READ_WRITE
        LOCK_DURATION: TRANSACTION
          LOCK_STATUS: PENDING
               SOURCE: sql_parse.cc:6014
      OWNER_THREAD_ID: 62
       OWNER_EVENT_ID: 26
2 rows in set (0.0007 sec)

-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)

Listing 18-5Example of metadata locks

在这个例子中,由于一个正在进行的事务,线程 id 61(与来自sys.session输出的conn_id = 22相同)拥有一个对world.city表的共享读锁,而线程 id 62 正在等待一个锁,因为它试图在这个表上执行一个 DDL 语句。

元数据锁的一个特例是用LOCK TABLES语句显式获取的锁。

显式表锁

使用LOCK TABLESFLUSH TABLES WITH READ LOCK语句获取显式表锁。使用LOCK TABLES语句,可以获取共享锁或独占锁;FLUSH TABLES WITH READ LOCK总是使用共享锁。这些表被锁定,直到用UNLOCK TABLES语句显式释放它们。当FLUSH TABLES WITH READ LOCK在没有列出任何表的情况下被执行时,全局读锁(即,影响所有表)被获取。虽然这些锁也保护数据,但在 MySQL 中它们被视为元数据锁。

除了与备份相关的带读锁的刷新表之外,显式表锁并不经常与 InnoDB 一起使用,因为 InnoDB 复杂的锁特性在大多数情况下都优于自己处理锁。但是,如果您真的需要锁定整个表,显式锁会很有用,因为 MySQL 检查它们非常便宜。

world.countryworld.countrylanguage表上获取显式读锁并在world.city表上获取写锁的连接示例如下

mysql> LOCK TABLES world.country READ,
                   world.countrylanguage READ,
                   world.city WRITE;
Query OK, 0 rows affected (0.0500 sec)

当您使用显式锁时,只允许您根据请求的锁来使用您已经锁定的表。这意味着,如果您获取一个读锁并试图写入表(ER_TABLE_NOT_LOCKED_FOR_WRITE),或者如果您试图使用一个没有获取锁(ER_TABLE_NOT_LOCKED)的表,您将会得到一个错误,例如:

mysql> UPDATE world.country
          SET Population = Population + 1
        WHERE Code = 'AUS';
ERROR: 1099: Table 'country' was locked with a READ lock and can't be updated

mysql> SELECT *
         FROM sakila.film
        WHERE film_id = 1;
ERROR: 1100: Table 'film' was not locked with LOCK TABLES

因为显式锁被认为是元数据锁,所以performance_schema.metadata_locks表中的症状和信息与隐式元数据锁相同。

另一种隐式处理的表级锁被称为表锁。

隐式表锁

当查询一个表时,MySQL 采用隐式表锁。除了刷新、元数据和显式锁之外,表锁对 InnoDB 表没有太大的作用,因为 InnoDB 使用记录锁来允许对表的并发访问,只要事务不修改相同的行(粗略地说——如下一小节所示——还有更多内容)。

然而,InnoDB 确实在表级别使用了意向锁的概念。由于您在研究锁问题时可能会遇到这些问题,因此有必要熟悉一下它们。正如在锁访问级别的讨论中提到的,意图锁标记了事务的意图。如果您使用一个显式的LOCK TABLES语句,该表将被您所请求的访问级别直接锁定。

对于由事务获取的锁,首先获取一个意向锁,然后如果需要的话可以升级它。为了获得共享锁,事务首先获取意向共享锁,然后获取共享锁。类似地,对于排他锁,首先采用意图排他锁。意向锁定的一些示例如下:

  • 一个SELECT ... FOR SHARE语句在被查询的表上获取一个意向共享锁。SELECT ... LOCK IN SHARE MODE语法是同义词。

  • 一个SELECT ... FOR UPDATE语句在被查询的表上获取一个意向排他锁。

  • 一个 DML 语句(不包括SELECT)在修改后的表上获取一个意向排他锁。如果修改了外键列,就会在父表上获得一个意向共享锁。

两个意向锁总是互相兼容的。这意味着即使一个事务有一个意向排他锁,它也不会阻止另一个事务获取一个意向锁。但是,它将阻止另一个事务将其意向锁升级为完全锁。表 18-1 显示了锁类型之间的兼容性。共享锁表示为 S,排他锁表示为 x。意向锁以 I 为前缀,因此 IS 是意向共享锁,IX 是意向排他锁。

表 18-1

InnoDB 锁兼容性

|   |

独占(X)

|

意图排他(九)

|

共享的

|

共享意向(IS)

|
| --- | --- | --- | --- | --- |
| 独占(X) | -什么 | -什么 | -什么 | -什么 |
| 意图排他(IX) | -什么 | ✔ | -什么 | ✔ |
| 共享 | -什么 | -什么 | ✔ | ✔ |
| 意向共享(是) | -什么 | ✔ | ✔ | ✔ |

在该表中,复选标记表示这两种锁兼容,而叉号表示这两种锁相互冲突。唯一的意向冲突锁是独占锁和共享锁。排他锁与所有其他锁冲突,包括两种意向锁类型。共享锁只与排他锁和意图排他锁冲突。

为什么意向锁甚至是必要的?它们允许 InnoDB 在不阻塞兼容操作的情况下按顺序解决锁定请求。细节超出了本次讨论的范围。重要的是你知道意向锁的存在,所以当你看到它们时,你知道它们来自哪里。

可以在LOCK_TYPE列设置为TABLEperformance_schema.data_locks表中找到表级锁。清单 18-6 展示了一个意向共享锁的例子。

-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> SELECT *
                FROM world.city
               WHERE ID = 130
                 FOR SHARE;
Query OK, 1 row affected (0.0010 sec)

-- Connection 2
Connection 2> SELECT *
                FROM performance_schema.data_locks
               WHERE LOCK_TYPE = 'TABLE'\G
*************************** 1\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098223824:1720:2195068346872
ENGINE_TRANSACTION_ID: 283670074934480
            THREAD_ID: 61
             EVENT_ID: 81
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2195068346872
            LOCK_TYPE: TABLE
            LOCK_MODE: IS
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
1 row in set (0.0354 sec)

-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)

Listing 18-6Example of an InnoDB intention shared lock

这显示了一个在world.city表上的意向共享锁。注意,ENGINE被设置为INNODB,而LOCK_DATANULL。如果执行相同的查询,ENGINE_LOCK_IDENGINE_TRANSACTION_IDOBJECT_INSTANCE_BEGIN列的值将会不同。

如前所述,InnoDB 的主要访问级别保护是在记录级别,所以让我们看看那些。

记录锁

记录锁通常被称为行锁;但是,它不仅仅是行上的锁,因为它还包括索引和间隙锁。当谈到 InnoDB 锁时,这些通常是指的锁。它们是细粒度的锁,旨在锁定最少量的数据,同时仍然确保数据的完整性。

记录锁可以是共享的,也可以是排他的,并且只影响事务访问的行和索引。排他锁的持续时间通常是有例外的事务,例如,被删除标记的记录用于在INSERT INTO ... ON DUPLICATE KEYREPLACE语句中进行唯一性检查。对于共享锁,持续时间可能取决于事务隔离级别,如“减少锁定问题”一节中的“事务隔离级别”所述

可以使用performance_schema.data_locks表找到记录锁,该表也用于在表级别找到意图锁。清单 18-7 展示了一个使用二级索引CountryCode更新world.city表中的行的锁的例子。

-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> UPDATE world.city
                 SET Population = Population + 1
               WHERE CountryCode = 'LUX';
Query OK, 1 row affected (0.0009 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Connection 2
Connection 2> SELECT *
                FROM performance_schema.data_locks\G
*************************** 1\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098223824:1720:2195068346872
ENGINE_TRANSACTION_ID: 117114
            THREAD_ID: 61
             EVENT_ID: 121
        OBJECT_SCHEMA: world

          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2195068346872
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098223824:507:30:1112:2195068344088
ENGINE_TRANSACTION_ID: 117114
            THREAD_ID: 61
             EVENT_ID: 121
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: CountryCode
OBJECT_INSTANCE_BEGIN: 2195068344088
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 'LUX', 2452
*************************** 3\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098223824:507:20:113:2195068344432
ENGINE_TRANSACTION_ID: 117114
            THREAD_ID: 61
             EVENT_ID: 121
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 2195068344432
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2452
*************************** 4\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098223824:507:30:1113:2195068344776
ENGINE_TRANSACTION_ID: 117114
            THREAD_ID: 61
             EVENT_ID: 121
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: CountryCode
OBJECT_INSTANCE_BEGIN: 2195068344776
            LOCK_TYPE: RECORD

            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 'LVA', 2434
4 rows in set (0.0005 sec)

-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0685 sec)

Listing 18-7Example of InnoDB record locks

第一行是已经讨论过的意图排他表锁。第二行是值(' LUX ',2452)在CountryCode索引上的 next-key 锁(更简短),其中' LUX '是在WHERE子句中使用的国家代码,2452 是添加到非唯一二级索引的主键 id。带有ID = 2452的城市是唯一匹配WHERE子句的城市,主键记录(行本身)显示在输出的第三行。锁定模式是X,REC_NOT_GAP,这意味着它是记录上的排他锁,而不是间隙上的排他锁。

什么是差距?输出的第四行显示了一个示例。间隙锁如此重要,以至于关于间隙锁的讨论被分成单独的部分。

间隙锁、下一键锁和谓词锁

间隙锁保护两条记录之间的空间。这可以在聚集索引的行中,也可以在辅助索引中。在索引页中的第一条记录之前和最后一条记录之后,分别有称为下确界记录和上确界记录的伪记录。间隙锁通常是最容易引起混淆的锁类型。研究锁问题的经验是熟悉它们的最好方法。

考虑前面示例中的查询:

UPDATE world.city
   SET Population = Population + 1
 WHERE CountryCode = 'LUX';

该查询更改所有带有CountryCode = 'LUX'的城市的人口。如果在事务的更新和提交之间插入一个新的城市,会发生什么情况?如果UPDATEINSERT语句提交的顺序与它们执行的顺序相同,一切都没问题。但是,如果以相反的顺序提交更改,结果将会不一致,因为预计插入的行也将被更新。

这就是间隙锁发挥作用的地方。它保护插入新记录(包括从不同位置移动的记录)的空间,因此在持有间隙锁的事务完成之前,它不会被更改。如果您查看清单 18-7 中示例的输出中第四行的最后几列,您可以看到一个间隙锁的示例:

           INDEX_NAME: CountryCode
OBJECT_INSTANCE_BEGIN: 2195068344776
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 'LVA', 2434

这是值(' LVA ',2434)的CountryCode索引上的独占间隙锁。由于该查询请求更新所有将CountryCode设置为“LUX”的行,间隙锁确保没有为“LUX”国家代码插入新行。国家代码“LVA”是CountryCode索引中的下一个值,因此“勒克司”和“LVA”之间的差距受到独占锁的保护。另一方面,用CountryCode = 'LVA'插入新城市还是有可能的。在某些地方,这被称为“记录前间隙”,这样更容易理解间隙锁是如何工作的。

当您使用READ COMMITTED事务隔离级别而不是REPEATABLE READSERIALIZABLE时,间隙锁被采用的程度要小得多。这将在“减少锁定问题”一节中的“事务隔离级别”中进一步讨论

与间隙锁相关的是下一键锁和谓词锁。下一键锁是记录锁和记录前间隙上的间隙锁的组合。这实际上是 InnoDB 中的默认锁类型,因此在锁输出中您只会看到它是SX。在本小节和上一小节讨论的示例中,值(' LUX ',2452)的CountryCode索引上的锁及其之前的间隙是下一个键锁的示例。来自performance_schema.data_locks表的清单 18-7 中输出的相关部分是

*************************** 2\. row ***************************
           INDEX_NAME: CountryCode
            LOCK_TYPE: RECORD
            LOCK_MODE: X
          LOCK_STATUS: GRANTED
            LOCK_DATA: 'LUX', 2452
*************************** 3\. row ***************************
           INDEX_NAME: PRIMARY
            LOCK_TYPE: RECORD
            LOCK_MODE: X,REC_NOT_GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 2452
*************************** 4\. row ***************************
           INDEX_NAME: CountryCode
            LOCK_TYPE: RECORD
            LOCK_MODE: X,GAP
          LOCK_STATUS: GRANTED
            LOCK_DATA: 'LVA', 2434

概括一下,第 2 行是下一个键锁,第 3 行是主键(行)上的记录锁,第 4 行是“LUX”和“LVA”之间的间隙锁(或者是 LVA 之前的间隙锁)。

谓词锁类似于间隙锁,但它适用于无法进行绝对排序的空间索引,因此间隙锁没有意义。对于REPEATABLE READSERIALIZABLE事务隔离级别中的空间索引,InnoDB 在用于查询的最小边界矩形(MBR)上创建了一个谓词锁,而不是间隙锁。这将通过防止对最小边界矩形内的数据进行更改来实现一致的读取。

您应该知道的与记录相关的最后一种锁类型是插入意图锁。

插入意向锁

请记住,对于表锁,InnoDB 有意向锁,决定事务是以共享还是独占的方式使用表。类似地,InnoDB 在记录级别有插入意图锁。InnoDB 使用这些锁——顾名思义——和INSERT语句向其他事务发出信号。因此,锁是在一个尚未创建的记录上(因此它是一个间隙锁),而不是在一个现有的记录上。使用插入意图锁有助于提高执行插入的并发性。

您不太可能在锁输出中看到插入意图锁,除非一个INSERT语句正在等待一个锁被授予。您可以通过在另一个事务中创建一个间隙锁来阻止INSERT语句完成,从而强制出现这种情况。清单 18-8 中的例子在连接 1 中创建了一个间隙锁,然后在连接 2 中试图插入一个与间隙锁冲突的行。最后,在第三个连接中,检索锁信息。

-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0004 sec)

Connection 1> SELECT *
                FROM world.city
               WHERE ID > 4079
                 FOR UPDATE;
Empty set (0.0009 sec)

-- Connection 2
Connection 2> SELECT PS_CURRENT_THREAD_ID();
+------------------------+
| PS_CURRENT_THREAD_ID() |
+------------------------+
|                     62 |
+------------------------+
1 row in set (0.0003 sec)

Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 2> INSERT INTO world.city
              VALUES (4080, 'Darwin', 'AUS',
                      'Northern Territory', 146000);

-- Connection 3
Connection 3> SELECT *
                FROM performance_schema.data_locks
               WHERE THREAD_ID = 62\G
*************************** 1\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098220336:1720:2195068326968
ENGINE_TRANSACTION_ID: 117144
            THREAD_ID: 62
             EVENT_ID: 119
        OBJECT_SCHEMA: world

          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2195068326968
            LOCK_TYPE: TABLE
            LOCK_MODE: IX
          LOCK_STATUS: GRANTED
            LOCK_DATA: NULL
*************************** 2\. row ***************************
               ENGINE: INNODB
       ENGINE_LOCK_ID: 2195098220336:507:29:1:2195068320072
ENGINE_TRANSACTION_ID: 117144
            THREAD_ID: 62
             EVENT_ID: 119
        OBJECT_SCHEMA: world
          OBJECT_NAME: city
       PARTITION_NAME: NULL
    SUBPARTITION_NAME: NULL
           INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 2195068320072
            LOCK_TYPE: RECORD
            LOCK_MODE: X,INSERT_INTENTION
          LOCK_STATUS: WAITING
            LOCK_DATA: supremum pseudo-record
2 rows in set (0.0005 sec)

-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)

-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0004 sec)

Listing 18-8Example of an insert intention lock

连接 2 的性能模式线程 id 为 62,因此在连接 3 中,可以只查询该线程并排除连接 1 获取的锁。注意对于RECORD锁,锁模式包括INSERT_INTENTION——插入意图锁。在这种情况下,锁定的数据是上确界伪记录,但根据具体情况,它也可以是主键的值。如果您还记得下一个键锁的讨论,那么X意味着下一个键锁,但是这是一个特例,因为锁是在上确界伪记录上,并且不可能锁定它,所以实际上它只是上确界伪记录之前的间隙上的间隙锁。

插入数据时需要注意的另一个锁是自动增量锁。

自动增量锁

当您将数据插入到具有自动递增计数器的表中时,有必要保护计数器,以便保证两个事务获得唯一的值。如果对二进制日志使用基于语句的日志记录,则会有进一步的限制,因为在重播语句时,将为除第一行之外的所有行重新创建自动增量值。

InnoDB 支持三种锁定模式,因此您可以根据需要调整锁定量。使用innodb_autoinc_lock_mode选项选择锁定模式,该选项取值为 0、1 和 2,MySQL 8 中的默认值为 2。它需要重新启动 MySQL 来改变这个值。表 18-2 中总结了这些值的含义。

表 18-2

innodb_autoinc_lock_mode 选项的支持值

|

价值

|

方式

|

描述

|
| --- | --- | --- |
| Zero | 传统的 | MySQL 5.0 及更早版本的锁定行为。锁一直保持到语句结束,所以值是以可重复的连续顺序赋值的。 |
| one | 连续的 | 对于查询开始时行数已知的INSERT语句,所需数量的自动增量值被分配在一个轻量级互斥体下,并且避免了自动增量锁。对于行数未知的语句,自动增量锁被获取并保持到语句结束。这是 MySQL 5.7 和更早版本的默认设置。 |
| Two | 插入纸 | 自动增量锁永远不会被占用,并发插入的自动增量值可能是交错的。只有当二进制记录被禁用或binlog_format被设置为ROW时,该模式才是安全的。它是 MySQL 8 中的默认值。 |

innodb_autoinc_lock_mode值越高,锁定越少。为此付出的代价是增加自动增量值序列中的间隙数量,以及innodb_autoinc_lock_mode = 2交错值的可能性。除非不能使用基于行的二进制日志记录,或者对连续的自动增量值有特殊需求,否则建议使用值 2。

对用户级锁、元数据锁和数据级锁的讨论到此结束。您应该知道一些与备份相关的其他锁。

备用锁

备份锁是实例级锁;也就是说,它影响整个系统。它是 MySQL 8 中引入的新锁。备份锁防止可能导致备份不一致的语句,同时仍然允许其他语句与备份同时执行。被阻止的语句包括

  • 创建、重命名或删除文件的语句。这包括CREATE TABLECREATE TABLESPACERENAME TABLEDROP TABLE语句。

  • CREATE USERALTER USERDROP USERGRANT等账户管理报表。

  • 不将其更改记录到重做日志中的 DDL 语句。例如,这包括添加索引。

LOCK INSTANCE FOR BACKUP语句创建备份锁,用UNLOCK INSTANCE语句释放锁。执行LOCK INSTANCE FOR BACKUP需要BACKUP_ADMIN权限。获取备份锁并再次释放它的一个示例是

mysql> LOCK INSTANCE FOR BACKUP;
Query OK, 0 rows affected (0.00 sec)

mysql> UNLOCK INSTANCE;
Query OK, 0 rows affected (0.00 sec)

Note

在编写时,使用 X 协议(通过用mysqlx_port指定的端口或用mysqlx_socket指定的套接字连接)时,不允许获取备份锁并释放它。尝试这样做将返回一个ER_PLUGGABLE_PROTOCOL_COMMAND_NOT_SUPPORTED错误:ERROR: 3130: Command not supported by pluggable protocols

此外,与备份锁冲突的语句也会使用备份锁。由于 DDL 语句有时由几个步骤组成,例如,在新文件中重建一个表并重命名文件,备份锁可以在这些步骤之间释放,以避免阻塞LOCK INSTANCE FOR BACKUP超过必要的时间。

备份锁可以在performance_schema.metadata_locks表中找到,其中OBJECT_TYPE列设置为BACKUP LOCK。清单 18-9 显示了一个查询等待LOCK INSTANCE FOR BACKUP持有的备份锁的例子。

-- Connection 1
Connection 1> LOCK INSTANCE FOR BACKUP;
Query OK, 0 rows affected (0.00 sec)

-- Connection 2
Connection 2> OPTIMIZE TABLE world.city;

-- Connection 3
Connection 3> SELECT *
                FROM performance_schema.metadata_locks
               WHERE OBJECT_TYPE = 'BACKUP LOCK'\G
*************************** 1\. row ***************************
          OBJECT_TYPE: BACKUP LOCK
        OBJECT_SCHEMA: NULL
          OBJECT_NAME: NULL
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2520402231312
            LOCK_TYPE: SHARED
        LOCK_DURATION: EXPLICIT
          LOCK_STATUS: GRANTED
               SOURCE: sql_backup_lock.cc:101
      OWNER_THREAD_ID: 49
       OWNER_EVENT_ID: 8
*************************** 2\. row ***************************
          OBJECT_TYPE: BACKUP LOCK
        OBJECT_SCHEMA: NULL
          OBJECT_NAME: NULL
          COLUMN_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2520403183328
            LOCK_TYPE: INTENTION_EXCLUSIVE
        LOCK_DURATION: TRANSACTION
          LOCK_STATUS: PENDING
               SOURCE: sql_base.cc:5400
      OWNER_THREAD_ID: 60
       OWNER_EVENT_ID: 19
2 rows in set (0.0007 sec)

-- Connection 1
Connection 1> UNLOCK INSTANCE;
Query OK, 0 rows affected (0.00 sec)

Listing 18-9Example of a conflict for the backup lock

在本例中,线程 id 为 49 的连接拥有备份锁,而线程 id 为 60 的连接正在等待它。注意LOCK INSTANCE FOR BACKUP持有一个共享锁,而 DDL 语句请求一个意向排他锁。

与备份锁相关的是日志锁,它的引入也是为了减少备份过程中的锁定。

日志锁

创建备份时,您通常希望包括与备份一致的日志位置的相关信息。在 MySQL 5.7 和更早的版本中,在获取这些信息时需要全局读锁。在 MySQL 8 中,引入了日志锁,允许您在不使用全局读锁的情况下读取 InnoDB 的信息,如执行的全局事务标识符(GTIDs)、二进制日志位置和日志序列号(LSN)。

日志锁防止对日志相关信息进行更改的操作。实际上,这意味着提交、FLUSH LOGS等等。日志锁是通过查询performance_schema.log_status表隐式获取的。它需要BACKUP_ADMIN特权来访问表。清单 18-10 显示了log_status表的输出示例。

mysql> SELECT *
         FROM performance_schema.log_status\G
*************************** 1\. row ***************************
    SERVER_UUID: 59e3f95b-e0d6-11e8-94e8-ace2d35785be
          LOCAL: {"gtid_executed": "59e3f95b-e0d6-11e8-94e8-ace2d35785be:1-5343", "binary_log_file": "mysql-bin.000033", "binary_log_position": 3874615}
    REPLICATION: {"channels": []}
STORAGE_ENGINES: {"InnoDB": {"LSN": 7888992157, "LSN_checkpoint": 7888992157}}
1 row in set (0.0004 sec)

Listing 18-10Example output of the log_status table

对 MySQL 中主要锁类型的回顾到此结束。当一个查询请求一个锁,但是不能被授予时会发生什么?让我们考虑一下。

无法获得锁

锁的整体思想是限制对对象或记录的访问,以避免并发执行的冲突操作。这意味着有时锁不能被授予。那种情况下会发生什么?这取决于请求的锁和环境。元数据锁(包括显式请求的表锁)操作超时。InnoDB 记录锁支持超时和显式死锁检测。

Note

确定两个锁是否相互兼容非常复杂。这变得特别有趣,因为这种关系是不对称的,也就是说,一个锁可以在另一个锁存在时被允许,但反之则不行。例如,插入意图锁必须等待间隙锁,但是间隙锁不必等待插入意图锁。另一个例子(缺乏传递性)是间隙加记录锁必须等待仅记录锁,插入意图锁必须等待间隙加记录锁,但是插入意图锁不需要等待仅记录锁。

使用数据库时,无法获得锁是不可避免的,理解这一点很重要。原则上,您可以使用非常粗粒度的锁并避免失败的锁,除非超时——这就是 MyISAM 存储引擎在写入并发性非常差的情况下所做的事情。然而,在实践中,考虑到写工作负载的高并发性,细粒度锁是首选,这也引入了死锁的可能性。

结论是,您应该始终让您的应用准备好重试获取锁或优雅地失败。无论是显式锁还是隐式锁,这都适用。

Tip

总是准备好处理失败以获得锁。无法获得锁并不是一个灾难性的错误,通常不应该被认为是一个 bug。也就是说,正如“减少锁定问题”一节中所讨论的,在开发应用时,有一些减少锁争用的技术值得考虑。

本章的其余部分将讨论表级超时、记录级超时和 InnoDB 死锁的细节。

元数据和备份锁等待超时

当您请求刷新、元数据或备份锁时,获取锁的尝试将在lock_wait_timeout秒后超时。默认超时是 31536000 秒(365 天)。您可以在全局和会话范围内动态设置lock_wait_timeout选项,这允许您根据给定流程的特定需求调整超时。

当超时发生时,语句失败,出现错误ER_LOCK_WAIT_TIMEOUT(错误号 1205)。例如:

mysql> LOCK TABLES world.city WRITE;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction

lock_wait_timeout选项的推荐设置取决于应用的要求。使用较小的值来防止锁请求长时间阻塞其他查询可能是一个优势。这通常需要您实现对锁请求失败的处理,例如,通过重试该语句。另一方面,较大的值有助于避免重试该语句。对于FLUSH TABLES语句,还要记住它与低级表定义缓存(TDC)版本锁交互,这可能意味着放弃该语句不允许后续查询继续进行。在这种情况下,最好为lock_wait_timeout设置一个较高的值,以便更清楚地了解锁的关系。

InnoDB 锁等待超时

当查询请求 InnoDB 中的记录级锁时,它会超时,类似于刷新、元数据和备份锁的超时。由于记录级锁争用比表级锁争用更常见,并且记录级锁增加了死锁的可能性,因此超时默认为 50 秒。它可以使用innodb_lock_wait_timeout选项进行设置,该选项可以针对全局和会话范围进行设置。

当超时发生时,查询失败,并出现ER_LOCK_WAIT_TIMEOUT错误(错误号 1205 ),就像表级锁超时一样。清单 18-11 展示了一个发生 InnoDB 锁等待超时的例子。

-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 130;
Query OK, 1 row affected (0.0005 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Connection 2
Connection 2> SET SESSION innodb_lock_wait_timeout = 3;
Query OK, 0 rows affected (0.0004 sec)

Connection 2> UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 130;
ERROR: 1205: Lock wait timeout exceeded; try restarting transaction

-- Connection 1
Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)

Listing 18-11Example of an InnoDB lock wait timeout

在本例中,连接 2 的锁等待超时设置为 3 秒,因此没有必要等待通常的 50 秒超时。

当超时发生时,innodb_rollback_on_timeout选项定义了事务完成的工作有多少被回滚。当innodb_rollback_on_timeout被禁用时(默认),只有触发超时的语句被回滚。启用该选项后,整个事务将回滚。innodb_rollback_on_timeout选项只能在全局级别配置,并且需要重启才能更改值。

Caution

处理锁等待超时是非常重要的,否则它可能会使事务带有未释放的锁。如果发生这种情况,其他事务可能无法获得它们需要的锁。

一般情况下,建议将 InnoDB 记录级锁的超时值保持在较低水平。通常,最好降低默认值 50 秒。允许查询等待锁的时间越长,其他锁请求受影响的可能性就越大,这也可能导致其他查询停止。这也使得死锁更有可能发生。如果您禁用死锁检测(接下来将讨论),您应该为innodb_lock_wait_timeout使用一个非常小的值,比如一秒或两秒,因为您将使用超时来检测死锁。如果没有死锁检测,也建议启用innodb_rollback_on_timeout选项。

僵局

死锁听起来是一个非常可怕的概念,但是你不应该让这个名字吓住你。就像锁等待超时一样,死锁是高并发数据库世界中的现实。它真正的意思是锁请求之间存在循环关系。解决僵局的唯一方法是强制放弃其中一个请求。从这个意义上说,死锁与锁等待超时没有什么不同。事实上,您可以禁用死锁检测,在这种情况下,其中一个锁将以锁等待超时结束。

那么,如果不是真正需要的话,为什么会有死锁呢?因为当锁请求之间存在循环关系时会出现死锁,所以 InnoDB 可以在循环完成后立即检测到死锁。这允许 InnoDB 立即告诉用户发生了死锁,而不必等待锁等待超时。告知发生了死锁也是有用的,因为这通常提供了改进应用中数据访问的机会。因此,您应该将死锁视为朋友,而不是敌人。图 18-1 显示了两个事务查询一个导致死锁的表的例子。

img/484666_1_En_18_Fig1_HTML.png

图 18-1

导致死锁的两个事务的示例

在本例中,事务 1 首先用ID = 130更新行,然后用ID = 3805更新行。在此期间,事务 2 首先用ID = 3805更新行,然后用ID = 130更新行。这意味着当事务 1 试图更新ID = 3805时,事务 2 已经锁定了该行。事务 2 也无法继续,因为它无法锁定ID = 130,因为事务 1 已经持有该锁。这是一个简单死锁的典型例子。图 18-2 中也显示了环锁关系。

img/484666_1_En_18_Fig2_HTML.png

图 18-2

导致死锁的锁的循环关系

在该图中,事务 1 和事务 2 持有哪个锁,请求哪个锁,以及如果没有干预,冲突如何永远无法解决,这一点很清楚。这使得它有资格成为一个僵局。

在现实世界中,死锁往往更加复杂。在这里讨论的例子中,只涉及到主键记录锁。一般来说,通常还包括二级钥匙、间隙锁和其他可能的锁类型。也可能涉及两个以上的事务。然而,原则是一样的。

Note

对于两个事务中的每一个,即使只有一个查询,也会发生死锁。如果一个查询按升序读取记录,而另一个按降序读取记录,则可能会出现死锁。

当死锁发生时,InnoDB 选择“工作最少”的事务成为受害者。您可以检查information_schema.INNODB_TRX视图中的trx_weight列,查看 InnoDB 使用的权重(完成的工作越多,权重越高)。实际上,这意味着持有最少锁的事务将被回滚。当这种情况发生时,事务中被选作牺牲品的查询失败,并返回错误ER_LOCK_DEADLOCK(错误代码 1213),事务被回滚以释放尽可能多的锁。清单 18-12 中显示了一个发生死锁的例子。

-- Connection 1
Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 130;
Query OK, 1 row affected (0.0006 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Connection 2
Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 2> UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 3805;
Query OK, 1 row affected (0.0006 sec)

Rows matched: 1  Changed: 1  Warnings: 0

Connection 2> UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 130;

-- Connection 1
Connection 1> UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 3805;
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction

Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0438 sec)

-- Connection 2
Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0438 sec)

Listing 18-12Example of a deadlock

在大多数情况下,自动死锁检测对于避免查询延迟过长时间是非常有用的。不过死锁检测不是免费的。对于具有非常高的查询并发性的 MySQL 实例,查找死锁的成本会变得很高,您最好禁用死锁检测,这是通过将innodb_deadlock_detect选项设置为OFF来完成的。也就是说,在 MySQL 8.0.18 和更高版本中,死锁检测被移到了一个专用的后台线程中,从而提高了性能。

如果您确实禁用了死锁检测,建议将innodb_lock_wait_timeout设置为一个非常低的值,比如一秒钟,以便快速检测锁争用。此外,启用innodb_rollback_on_timeout选项以确保锁被释放。

既然您已经了解了锁是如何工作的以及锁请求是如何失败的,那么您需要考虑如何减少锁的影响。

减少锁定问题

当您编写应用并为其数据和访问设计模式时,记住锁是很重要的。减少锁定的策略包括添加索引、更改事务隔离级别和抢先锁定。

Tip

不要被优化锁冲昏了头脑。如果只是偶尔遇到锁等待超时和死锁,通常最好重试查询或事务,而不是花时间来避免这个问题。多频繁取决于您的工作负载,但是对于许多应用来说,每小时重试几次不是问题。

事务规模和年龄

减少锁问题的一个重要策略是保持您的事务较小,并避免使事务打开的时间超过必要时间的延迟。锁问题最常见的原因是事务修改了大量的行,或者事务的活动时间超过了必要的时间。

事务的大小是事务所做的工作量,尤其是它占用的锁的数量,但是事务执行所花费的时间也很重要。正如本讨论中的一些其他主题将会提到的,您可以通过索引和事务隔离级别来部分地降低影响。然而,记住总体结果也很重要。如果您需要修改许多行,问问自己是否可以将工作分成更小的批,或者要求所有工作都在同一个事务中完成。也可以将一些准备工作分离出来,在主事务之外完成。

事务的持续时间也很重要。一个常见的问题是使用autocommit = 0的连接。每次在没有活动事务的情况下执行一个查询(包括SELECT)时,都会启动一个新的事务,直到执行一个显式的COMMITROLLBACK(或者连接关闭),事务才会完成。一些连接器默认禁用自动提交,因此您可能在没有意识到的情况下使用这种模式,这可能会错误地让事务打开几个小时。

Tip

启用autocommit选项,除非您有特定的理由禁用它。当您启用自动提交时,InnoDB 还可以为许多SELECT查询检测出它是一个只读事务,并减少查询的开销。

另一个缺陷是在事务活动时启动事务并在应用中执行缓慢的操作。这可以是发送回用户的数据、交互式提示或文件 I/O。确保在 MySQL 中没有打开活动事务时执行这些缓慢的操作。

索引

索引减少了访问给定行所需的工作量。这样,索引是减少锁定的一个很好的工具,因为只有在执行查询时访问的记录才会被锁定。

考虑一个简单的例子,在world.city表中查询名为 Sydney 的城市:

START TRANSACTION;

SELECT *
  FROM world.city
 WHERE Name = 'Sydney'
   FOR SHARE;

FOR SHARE选项用于强制查询对读取的记录使用共享锁。默认情况下,Name列上没有索引,因此查询将执行全表扫描来查找结果中需要的行。如果没有索引,则有 4103 个记录锁(有些是重复的):

mysql> SELECT INDEX_NAME, LOCK_TYPE,
              LOCK_MODE, COUNT(*)
         FROM performance_schema.data_locks
        WHERE OBJECT_SCHEMA = 'world'
              AND OBJECT_NAME = 'city'
        GROUP BY INDEX_NAME, LOCK_TYPE, LOCK_MODE;
+------------+-----------+-----------+----------+
| INDEX_NAME | LOCK_TYPE | LOCK_MODE | COUNT(*) |
+------------+-----------+-----------+----------+
| NULL       | TABLE     | IS        |        1 |
| PRIMARY    | RECORD    | S         |     4103 |
+------------+-----------+-----------+----------+
2 rows in set (0.0210 sec)

如果在Name列上添加一个索引,锁计数将减少到总共三个记录锁:

mysql> SELECT INDEX_NAME, LOCK_TYPE,
              LOCK_MODE, COUNT(*)
         FROM performance_schema.data_locks
        WHERE OBJECT_SCHEMA = 'world'
              AND OBJECT_NAME = 'city'
        GROUP BY INDEX_NAME, LOCK_TYPE, LOCK_MODE;
+------------+-----------+---------------+----------+
| INDEX_NAME | LOCK_TYPE | LOCK_MODE     | COUNT(*) |
+------------+-----------+---------------+----------+
| NULL       | TABLE     | IS            |        1 |
| Name       | RECORD    | S             |        1 |
| PRIMARY    | RECORD    | S,REC_NOT_GAP |        1 |
| Name       | RECORD    | S,GAP         |        1 |
+------------+-----------+---------------+----------+
4 rows in set (0.0005 sec)

另一方面,更多的索引提供了更多访问相同行的方法,这可能会增加死锁的数量。

记录访问顺序

确保您尽可能多地以相同的顺序访问不同事务的记录。在本章前面讨论的死锁示例中,导致死锁的原因是两个事务以相反的顺序访问行。如果它们以相同的顺序访问这些行,就不会出现死锁。当您访问不同表中的记录时,这也适用。

确保相同的访问顺序绝非易事。当您执行连接并且优化器为两个查询决定不同的连接顺序时,甚至可能发生不同的访问顺序。如果不同的连接顺序导致过多的锁问题,您可以考虑使用第 17 章中描述的优化器提示来告诉优化器改变连接顺序,但是在这种情况下,您当然也应该考虑查询性能。

事务隔离级别

InnoDB 支持几种事务隔离级别。不同的隔离级别有不同的锁需求:特别是REPEATABLE READSERIALIZABLEREAD COMMITTED需要更多的锁。

READ COMMITTED事务隔离级别可以从两个方面帮助解决锁定问题。使用的间隙锁要少得多,并且在 DML 语句期间被访问但未被修改的行在语句完成后会再次释放它们的锁。对于REPEATABLE READSERIALIZABLE,锁仅在事务结束时释放。

Note

人们常说READ COMMITTED事务隔离级别不采用间隙锁。这是一个神话,是不正确的。虽然使用的间隙锁要少得多,但仍然需要一些。例如,这包括 InnoDB 在更新时执行页面分割。(页面分割将在第 25 章中讨论。)

考虑一个例子,其中使用CountryCode列将查询限制在一个国家,名为 Sydney 的城市的人口发生了变化。这可以通过以下查询来完成:

START TRANSACTION;

UPDATE world.city
   SET Population = 5000000
 WHERE Name = 'Sydney'
       AND CountryCode = 'AUS';

Name列上没有索引,但是在CountryCode上有一个。所以更新需要扫描部分CountryCode索引。清单 18-13 展示了一个在REPEATABLE READ事务隔离级别执行查询的例子。

-- Connection 1
Connection 1> SET transaction_isolation = 'REPEATABLE-READ';
Query OK, 0 rows affected (0.0003 sec)

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> UPDATE world.city
                 SET Population = 5000000
               WHERE Name = 'Sydney'
                 AND CountryCode = 'AUS';
Query OK, 1 row affected (0.0005 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Connection 2
Connection 2> SELECT INDEX_NAME, LOCK_TYPE,
                     LOCK_MODE, COUNT(*)
                FROM performance_schema.data_locks
               WHERE OBJECT_SCHEMA = 'world'
                     AND OBJECT_NAME = 'city'
               GROUP BY INDEX_NAME, LOCK_TYPE, LOCK_MODE;
+-------------+-----------+---------------+----------+
| INDEX_NAME  | LOCK_TYPE | LOCK_MODE     | COUNT(*) |
+-------------+-----------+---------------+----------+
| NULL        | TABLE     | IX            |        1 |
| CountryCode | RECORD    | X             |       14 |
| PRIMARY     | RECORD    | X,REC_NOT_GAP |       14 |
| CountryCode | RECORD    | X,GAP         |        1 |
+-------------+-----------+---------------+----------+
4 rows in set (0.0007 sec)

Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0725 sec)

Listing 18-13The locks held in the REPEATABLE READ transaction isolation level

在每个CountryCode索引和主键上有 14 个记录锁,在CountryCode索引上有一个间隙锁。将这与在清单 18-14 中所示的READ COMMITTED事务隔离级别中执行查询后持有的锁进行比较。

-- Connection 1
Connection 1> SET transaction_isolation = 'READ-COMMITTED';
Query OK, 0 rows affected (0.0003 sec)

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> UPDATE world.city
                 SET Population = 5000000
               WHERE Name = 'Sydney'
                 AND CountryCode = 'AUS';
Query OK, 1 row affected (0.0005 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Connection 2
Connection 2> SELECT INDEX_NAME, LOCK_TYPE,
                     LOCK_MODE, COUNT(*)
                FROM performance_schema.data_locks
               WHERE OBJECT_SCHEMA = 'world'
                     AND OBJECT_NAME = 'city'
               GROUP BY INDEX_NAME, LOCK_TYPE, LOCK_MODE;
+-------------+-----------+---------------+----------+
| INDEX_NAME  | LOCK_TYPE | LOCK_MODE     | COUNT(*) |
+-------------+-----------+---------------+----------+
| NULL        | TABLE     | IX            |        1 |
| CountryCode | RECORD    | X,REC_NOT_GAP |        1 |
| PRIMARY     | RECORD    | X,REC_NOT_GAP |        1 |
+-------------+-----------+---------------+----------+
3 rows in set (0.0006 sec)

Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.0816 sec)

Listing 18-14The locks held in the READ-COMMITTED transaction isolation level

在这里,记录锁减少为每个索引和主键上的一个锁。没有间隙锁。

并非所有工作负载都可以使用READ COMMITTED事务隔离级别。如果您必须让SELECT语句在同一事务中多次执行时返回相同的结果,或者让不同的查询对应于同一时间快照,您必须使用REPEATABLE READSERIALIZABLE。但是,在许多情况下,降低隔离级别是一个选项,您可以为不同的事务选择不同的隔离级别。如果您正在从 Oracle DB 迁移应用,那么您已经在使用READ COMMITTED,并且您也可以在 MySQL 中使用它。

抢先锁定

将讨论的最后一个策略是抢先锁定。如果您有一个执行多个查询的复杂事务,在某些情况下,执行一个SELECT ... FOR UPDATESELECT ... FOR SHARE查询来锁定您知道在事务中稍后会用到的记录可能是一种优势。另一个有用的情况是,确保对于不同的任务以相同的顺序访问行。

抢先锁定对于减少死锁的频率特别有效。一个缺点是,你最终会持有更长时间的锁。总的来说,抢占式锁定是一种应该谨慎使用的策略,但是在正确的情况下,它可以有效地防止死锁。

本章的最后一个主题是回顾如何监控锁。

监控锁

已经有几个查询持有的锁的信息的例子。本节将回顾已经提到的资源,并介绍一些额外的资源。第 22 章将通过展示调查锁问题的例子对此进行深入探讨。监控选项可以分为四组:性能模式、sys模式、状态指标和 InnoDB 锁监控。

性能模式

性能模式包含除死锁之外的大多数可用锁信息的来源。您不仅可以直接使用性能模式中的锁信息;它还用于sys模式中两个与锁相关的视图。

这些信息可通过四个表格获得:

  • data_locks : 该表包含 InnoDB 级别的表和锁记录的详细信息。它显示当前持有的或待定的所有锁。

  • data_lock_waits :data_locks表一样,它显示了与 InnoDB 相关的锁,但是只显示那些等待被授予关于哪些线程阻塞了请求的信息的锁。

  • metadata_locks : 该表包含关于用户级锁、元数据锁等的信息。要记录信息,必须启用wait/lock/metadata/sql/mdl性能模式工具(在 MySQL 8 中默认启用)。OBJECT_TYPE列显示持有哪种锁。

  • table_handles : 该表保存了关于哪些表锁当前有效的信息。必须启用wait/lock/table/sql/handler性能模式仪器才能记录数据(这是默认设置)。与其他表格相比,此表格的使用频率较低。

metadata_locks表是最通用的表,它支持从全局读锁到低级锁(如访问控制列表(ACL ))的各种锁。表 18-3 按字母顺序总结了OBJECT_TYPE列的可能值,并简要说明了每个值代表的锁。

表 18-3

performance _ schema . metadata _ locks 表中的对象类型

|

对象类型

|

描述

|
| --- | --- |
| ACL_CACHE | 用于访问控制列表(ACL)缓存。 |
| BACKUP_LOCK | 备用锁。 |
| CHECK_CONSTRAINT | 对于CHECK约束的名称。 |
| COLUMN_STATISTICS | 用于直方图和其他列统计。 |
| COMMIT | 用于阻止提交。它与全局读锁相关。 |
| EVENT | 对于存储的事件。 |
| FOREIGN_KEY | 对于外键名。 |
| GLOBAL | 用于全局读锁(由FLUSH TABLES WITH READ LOCK触发)。 |
| FUNCTION | 对于存储函数。 |
| LOCKING_SERVICE | 对于使用锁定服务接口获取的锁。 |
| PROCEDURE | 对于存储过程。 |
| RESOURCE_GROUPS | 对于资源组。 |
| SCHEMA | 对于模式/数据库。这些类似于表的元数据锁,只是它们是用于模式的。 |
| SRID | 用于空间参考系统。 |
| TABLE | 对于表和视图。这包括本章中讨论的元数据锁。 |
| TABLESPACE | 对于表空间。 |
| TRIGGER | For 触发器(在表上)。 |
| USER_LEVEL_LOCK | 用于用户级锁。 |

性能模式表中的数据是原始锁数据。通常,当您调查锁问题或监控锁问题时,确定是否有锁等待更有意义。对于这些信息,您需要使用sys模式。

sys 架构

sys模式有两个视图,获取性能模式表中的信息并返回锁对,其中一个锁由于另一个锁而不能被授予。因此,它们显示了锁等待的问题所在。这两个视图是innodb_lock_waitsschema_table_lock_waits

innodb_lock_waits视图使用性能模式中的data_locksdata_lock_waits视图返回 InnoDB 记录锁的所有锁等待情况。它显示诸如连接试图获取什么锁以及涉及哪些连接和查询之类的信息。如果您需要没有格式的信息,视图也以x$innodb_lock_waits的形式存在。

schema_table_lock_waits视图以类似的方式工作,但是使用metadata_locks表返回与模式对象相关的锁等待。该信息在x$schema_table_lock_waits视图中也是无格式的。

22 章包含了使用两种视图来调查锁问题的例子。

状态计数器和 InnoDB 指标

有几个状态计数器和 InnoDB 指标提供关于锁定的信息。这些主要用于全局(实例)级别,对于检测锁问题的总体增加非常有用。一起监控所有这些指标的一个好方法是使用sys.metrics视图。清单 18-15 展示了一个检索指标的例子。

mysql> SELECT Variable_name,
              Variable_value AS Value,
              Enabled
         FROM sys.metrics
        WHERE Variable_name LIKE 'innodb_row_lock%'
              OR Variable_name LIKE 'Table_locks%'
              OR Type = 'InnoDB Metrics - lock';
+-------------------------------+--------+---------+
| Variable_name                 | Value  | Enabled |
+-------------------------------+--------+---------+
| innodb_row_lock_current_waits | 0      | YES     |
| innodb_row_lock_time          | 595876 | YES     |
| innodb_row_lock_time_avg      | 1683   | YES     |
| innodb_row_lock_time_max      | 51531  | YES     |
| innodb_row_lock_waits         | 354    | YES     |
| table_locks_immediate         | 4194   | YES     |
| table_locks_waited            | 0      | YES     |
| lock_deadlocks                | 1      | YES     |
| lock_rec_lock_created         | 0      | NO      |
| lock_rec_lock_removed         | 0      | NO      |
| lock_rec_lock_requests        | 0      | NO      |
| lock_rec_lock_waits           | 0      | NO      |
| lock_rec_locks                | 0      | NO      |
| lock_row_lock_current_waits   | 0      | YES     |
| lock_table_lock_created       | 0      | NO      |
| lock_table_lock_removed       | 0      | NO      |
| lock_table_lock_waits         | 0      | NO      |
| lock_table_locks              | 0      | NO      |
| lock_timeouts                 | 1      | YES     |
+-------------------------------+--------+---------+
19 rows in set (0.0076 sec)

Listing 18-15Lock metrics

如您所见,默认情况下,并非所有指标都是启用的。未启用的可使用第 7 章中讨论的innodb_monitor_enable选项启用。innodb_row_lock_%lock_deadlockslock_timeouts度量是最有趣的。行锁指标显示了当前有多少锁正在等待,并统计了等待获取 InnoDB 记录锁所花费的时间(毫秒)。lock_deadlockslock_timeouts指标分别显示遇到的死锁和锁等待超时的数量。

InnoDB 锁监控器和死锁记录

InnoDB 很久以前就有了自己的锁监控器,锁信息在 InnoDB 监控器输出中返回。默认情况下,InnoDB 监控器包含关于最新死锁以及锁等待中涉及的锁的信息。通过启用innodb_status_output_locks选项(默认禁用),将列出所有锁;这类似于性能模式data_locks表中的内容。

为了演示死锁和事务信息,您可以从清单 18-12 中创建死锁,并创建一个新的正在进行的事务,该事务通过world.city表中的主键更新了一行:

mysql> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)

mysql> UPDATE world.city
          SET Population = Population + 1
        WHERE ID = 130;
Query OK, 1 row affected (0.0005 sec)

Rows matched: 1  Changed: 1  Warnings: 0

使用SHOW ENGINE INNODB STATUS语句生成 InnoDB 锁监控器输出。清单 18-16 显示了启用所有锁定信息并生成监控器输出的示例。完整的 InnoDB monitor 输出也可以从本书的 GitHub 资源库的文件listing_18_16.txt中获得。

mysql> SET GLOBAL innodb_status_output_locks = ON;
Query OK, 0 rows affected (0.0022 sec)

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2019-11-04 17:04:48 0x6e88 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 51 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 170 srv_active, 0 srv_shutdown, 62448 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 138
OS WAIT ARRAY INFO: signal count 133
RW-shared spins 1, rounds 1, OS waits 0
RW-excl spins 109, rounds 1182, OS waits 34
RW-sx spins 24, rounds 591, OS waits 18
Spin rounds per wait: 1.00 RW-shared, 10.84 RW-excl, 24.63 RW-sx
------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-11-03 19:41:43 0x4b78
*** (1) TRANSACTION:
TRANSACTION 5585, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 37, OS thread handle 28296, query id 21071 localhost ::1 root updating
UPDATE world.city
                 SET Population = Population + 1
               WHERE ID = 130

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 159 page no 28 n bits 248 index PRIMARY of table `world`.`city` trx id 5585 lock_mode X locks rec but not gap
Record lock, heap no 26 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
 0: len 4; hex 80000edd; asc     ;;
 1: len 6; hex 0000000015d1; asc       ;;
 2: len 7; hex 01000000f51aa6; asc        ;;
 3: len 30; hex 53616e204672616e636973636f2020202020202020202020202020202020; asc San Francisco                 ; (total 35 bytes);
 4: len 3; hex 555341; asc USA;;

 5: len 20; hex 43616c69666f726e696120202020202020202020; asc California          ;;
 6: len 4; hex 800bda1e; asc     ;;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
...
------------
TRANSACTIONS
------------
Trx id counter 5662
Purge done for trx's n:o < 5661 undo n:o < 0 state: running but idle
History list length 11
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 284075292758256, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 284075292756560, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 284075292755712, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 5661, ACTIVE 60 sec
2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 1
MySQL thread id 40, OS thread handle 2044, query id 26453 localhost ::1 root
TABLE LOCK table `world`.`city` trx id 5661 lock mode IX
RECORD LOCKS space id 160 page no 7 n bits 248 index PRIMARY of table `world`.`city` trx id 5661 lock_mode X locks rec but not gap
Record lock, heap no 41 PHYSICAL RECORD: n_fields 7; compact format; info bits 0
 0: len 4; hex 80000082; asc     ;;
 1: len 6; hex 00000000161d; asc       ;;
 2: len 7; hex 01000001790a72; asc     y r;;
 3: len 30; hex 5379646e6579202020202020202020202020202020202020202020202020; asc Sydney                        ; (total 35 bytes);
 4: len 3; hex 415553; asc AUS;;
 5: len 20; hex 4e657720536f7574682057616c65732020202020; asc New South Wales     ;;
 6: len 4; hex 8031fdb0; asc  1  ;;
...

Listing 18-16The InnoDB monitor output

靠近顶部的部分是LATEST DETECTED DEADLOCK部分,它包括最近一次死锁所涉及的事务和锁的详细信息以及它发生的时间。如果自 MySQL 最后一次重启以来没有发生死锁,则省略这一节。第 22 章将包括一个调查死锁的例子。

Note

InnoDB 监控器输出中的 deadlock 部分仅包含涉及 InnoDB 记录锁的死锁信息。对于涉及用户级锁的死锁,没有等效的信息。

输出再往下一点,是列出 InnoDB 事务的部分TRANSACTIONS。请注意,不持有任何锁的事务(例如,纯SELECT查询)不包括在内。在这个例子中,在world.city表上有一个意向排他锁,在主键等于 130 的行上有一个排他锁(第一个字段的记录锁信息中的 80000082 表示值为 0x82 的行,它与十进制表示法中的 130 相同)。

Tip

如今,InnoDB 监控器输出中的锁定信息最好从performance_schema.data_locksperformance_schema.data_lock_waits表中获取。然而,死锁信息仍然非常有用。

您可以请求每隔 15 秒将监控器输出转储到stderr。您可以通过启用innodb_status_output选项来启用转储。请注意,输出非常大,所以如果启用它,请做好错误日志快速增长的准备。InnoDB monitor 输出也很容易隐藏关于更严重问题的消息。

如果您想确保记录所有死锁,您可以启用innodb_print_all_deadlocks选项。这导致每次发生死锁时,InnoDB monitor 输出中的死锁信息都会打印到错误日志中。如果您需要调查死锁,这可能是有用的,但是建议您仅在需要时启用它,以避免错误日志变得非常大并可能隐藏其他问题。

Caution

如果启用 InnoDB 监控器的常规输出或关于所有死锁的信息,请小心。这些信息很容易隐藏错误日志中记录的重要消息。

摘要

锁是一个庞大而复杂的话题。希望这一章已经帮助你了解了为什么需要锁以及各种锁。

这一章开始询问为什么需要锁。没有锁,对模式和数据进行并发访问是不安全的。打个比方,数据库锁的工作方式与交通信号灯和停车标志在交通中的工作方式相同。它规范了对数据的访问,因此事务可以确保不会与另一个事务发生冲突而导致不一致的结果。

数据有两种访问级别:共享访问(也称为读访问)和独占访问(也称为写访问)。这些访问级别适用于各种锁粒度,从全局读锁到记录锁和间隙锁。此外,InnoDB 在表级别使用意向共享锁和意向排他锁。

努力减少应用需要的锁的数量并减少所需锁的影响是很重要的。减少锁问题的策略本质上可以归结为在事务中做尽可能少的工作,方法是使用索引,将大型事务分成较小的事务,并尽可能短时间地持有锁。对于应用中的不同任务,尝试以相同的顺序访问数据也很重要;否则,可能会出现不必要的死锁。

本章的最后一部分介绍了性能模式、sys模式、状态指标和 InnoDB 监控器中的锁监控选项。大多数监控最好使用性能模式表和sys模式视图来完成。例外情况是死锁,此时 InnoDB 监控器仍然是最佳选择。

这是第四部分的结论。现在是时候让查询分析变得更加实用了,首先要找到适合优化的查询。

十九、为优化寻找候选查询

当您遇到性能问题时,第一步是确定是什么导致了它。表现差可能有几个原因,所以在寻找原因时要保持开放的心态。本章的重点是找到可能导致性能下降的查询,或者在将来负载和数据量增加时可能成为问题的查询。尽管如此,正如在第 1 章中所讨论的,你需要考虑你的系统的所有方面,通常它可能是导致问题的因素的组合。

本章介绍了查询性能相关信息的各种来源。首先,将讨论性能模式。性能模式是本章中讨论的许多其他特性的基础。其次,sys模式的视图以及语句性能分析器特性都包括在内。第三,展示了如何使用 MySQL Workbench 为前两节中讨论的几个报告获得图形用户界面。第四,讨论了监控对于寻找优化候选的重要性。虽然本节使用 MySQL Enterprise Monitor 作为讨论的基础,但是这些原则适用于一般的监控,因此即使您使用不同的监控解决方案,也鼓励您阅读本节。第五个也是最后一个是慢速查询日志,它是查找慢速查询的传统工具。

Note

本章包括几个带有输出的例子。通常,对于包含计时和其他不确定数据的值,相同示例的输出会有所不同。

由于锁争用而性能不佳的查询将不包括在内;相反,第 22 章详细介绍了如何调查锁问题。事务包含在第 21 章中。

性能模式

性能模式是查询性能信息的金矿。这使得在讨论如何找到作为优化候选的查询时,它成为显而易见的起点。您可能最终会使用一些构建在性能模式之上的方法,但是仍然鼓励您很好地理解底层的表,这样您就知道如何访问原始数据并生成您自己的定制报告。

本节将首先讨论如何获取有关语句和预准备语句的信息,然后讨论表和文件 I/O,最后展示如何找出导致错误的原因和错误。

语句事件表

使用基于语句事件的性能模式表是寻找优化候选查询的最直接的方法。这些表将允许您获得关于在实例上执行的查询的非常详细的信息。需要注意的一点是,作为预处理语句执行的查询不包括在语句表中。

有几个包含语句信息的表。这些是

  • events_statements_current : 当前正在执行的语句或对空闲连接最新执行的查询。执行存储程序时,每个连接可能有多行。

  • events_statements_history : 每个连接的最后语句。每个连接的语句数量上限为performance_schema_events_statements_history_size(默认为 10)。当连接关闭时,连接的语句将被删除。

  • events_statements_history_long : 对实例的最新查询,不管是哪个连接执行的。该表还包括来自已关闭连接的语句。默认情况下,此表的使用者是禁用的。行数上限为performance_schema_events_statements_history_long_size(默认为 10000)。

  • events_statements_summary_by_digest : 将报表统计按默认模式分组并摘要。稍后将详细讨论该表。

  • events_statements_summary_by_account_by_event_name : 按账户和事件名称分组的报表统计。事件名显示执行的是哪种语句,例如,statement/sql/select表示直接执行的SELECT语句(不通过存储程序执行)。

  • events_statements_summary_by_host_by_event_name : 按账户主机名和事件名分组的报表统计。

  • events_statements_summary_by_program : 按执行语句的存储程序(事件、函数、过程、表或触发器)分组的语句统计信息。这有助于找到执行最多工作的存储程序。

  • events_statements_summary_by_thread_by_event_name : 按线程和事件名称分组的语句统计。仅包括当前连接的线程。

  • events_statements_summary_by_user_by_event_name : 按账户用户名和事件名称分组的报表统计。

  • events_statements_summary_global_by_event_name : 按事件名称分组的报表统计。

  • events_statements_histogram_by_digest : 按默认模式分组的直方图统计和摘要。

  • events_statements_histogram_global : 直方图统计,其中所有查询都聚集在一个直方图中。

  • threads : 实例中所有线程的信息,包括后台线程和前台线程。您可以使用此表代替SHOW PROCESSLIST命令。除了进程列表信息之外,还有显示线程是否被检测、操作系统线程 id 等等的列。

除了两个直方图表和threads表,所有列出的表都有相似的列。最常使用的表是events_statements_summary_by_digest,所以它将作为讨论的基础。events_statements_summary_by_digest表实质上是一份报告,报告了自从上次重置该表(通常是在重启 MySQL 时)以来在实例上执行的所有查询。查询按其摘要和执行时使用的默认模式进行分组。表中各列汇总在表 19-1 中。

表 19-1

events_statements_summary_by_digest表中的列

|

列名

|

描述

|
| --- | --- |
| SCHEMA_NAME | 执行查询时作为默认架构的架构。如果没有默认模式,则值为NULL。 |
| DIGEST | 规范化查询的摘要。在 MySQL 8 中,这是一个 sha256 哈希。 |
| DIGEST_TEXT | 规范化的查询。 |
| COUNT_STAR | 查询已执行的次数。 |
| SUM_TIMER_WAIT | 执行查询所花费的总时间。请注意,在执行 30 周多一点的时间后,该值会溢出。 |
| MIN_TIMER_WAIT | 执行查询的最快速度。 |
| AVG_TIMER_WAIT | 平均执行时间。这与SUM_TIMER_WAIT/COUNT_STAR相同,除非SUM_TIMER_WAIT已经溢出。 |
| MAX_TIMER_WAIT | 执行查询的最慢速度。 |
| SUM_LOCK_TIME | 等待表锁所花费的总时间。 |
| SUM_ERRORS | 执行查询时遇到的错误总数。 |
| SUM_WARNINGS | 执行查询时遇到的警告总数。 |
| SUM_ROWS_AFFECTED | 查询已修改的总行数。 |
| SUM_ROWS_SENT | 已返回(发送)到客户端的总行数。 |
| SUM_ROWS_EXAMINED | 查询已检查的总行数。 |
| SUM_CREATED_TMP_DISK_TABLES | 查询已创建的磁盘上内部临时表的总数。 |
| SUM_CREATED_TMP_TABLES | 由查询创建的内部临时表(无论是在内存中还是在磁盘上创建的)的总数。 |
| SUM_SELECT_FULL_JOIN | 由于没有联接条件的索引或联接条件,已执行全表扫描的联接总数。这与增加Select_full_join状态变量是一样的。 |
| SUM_SELECT_FULL_RANGE_JOIN | 使用全范围搜索的联接总数。这与增加Select_full_range_join状态变量是一样的。 |
| SUM_SELECT_RANGE | 查询使用范围搜索的总次数。这与增加Select_range状态变量是一样的。 |
| SUM_SELECT_RANGE_CHECK | 查询的联接总数,其中联接没有在每行之后检查索引使用情况的索引。这与增加Select_range_check状态变量是一样的。 |
| SUM_SELECT_SCAN | 查询对联接中的第一个表执行全表扫描的总次数。这与增加Select_scan状态变量是一样的。 |
| SUM_SORT_MERGE_PASSES | 为对查询结果进行排序而完成的排序合并传递的总数。这与增加Sort_merge_passes状态变量是一样的。 |
| SUM_SORT_RANGE | 使用范围进行排序的总次数。这与增加Sort_range状态变量是一样的。 |
| SUM_SORT_ROWS | 已排序的总行数。这与增加Sort_rows状态变量是一样的。 |
| SUM_SORT_SCAN | 通过扫描表进行排序的总次数。这与增加Sort_scan状态变量是一样的。 |
| SUM_NO_INDEX_USED | 未使用索引来执行查询的总次数。 |
| SUM_NO_GOOD_INDEX_USED | 使用无效索引的总次数。这意味着EXPLAIN输出中的Extra列包含“为每个记录检查的范围” |
| FIRST_SEEN | 查询第一次出现的时间。当表被截断时,第一个看到的值也被重置。 |
| LAST_SEEN | 上次看到查询的时间。 |
| QUANTILE_95 | 查询延迟的第 95 个百分位数。也就是说,95%的查询在给定的时间或更短的时间内完成。 |
| QUANTILE_99 | 查询延迟的第 99 个百分位数。 |
| QUANTILE_999 | 查询延迟的第 99.9 个百分点。 |
| QUERY_SAMPLE_TEXT | 规范化前的查询示例。您可以使用它来获取查询的查询执行计划。 |
| QUERY_SAMPLE_SEEN | 看到示例查询的时间。 |
| QUERY_SAMPLE_TIMER_WAIT | 示例查询的执行时间。 |

(SCHEMA_NAMEDIGEST)上有一个唯一的索引,用于对数据进行分组。表中最多可以有performance_schema_digests_size(动态调整大小,但通常默认为 10000)行。当插入最后一行时,schema 和 digest 都被设置为NULL,并且该行被用作一个总括行。每次使用捕获所有行时,Performance_schema_digest_lost状态变量就会递增。使用events_statements_currentevents_statements_historyevents_statements_history_long表,该表中汇总的信息也可用于单个查询。

Tip

由于数据是按SCHEMA_NAMEDIGEST分组的,所以当应用一致地设置默认模式(例如,MySQL Shell 中的\use world--schema命令行选项,或者您使用的客户端/连接器中的等效选项)时,您可以充分利用events_statements_summary_by_digest表。要么永远设置,要么永远不设置。同样,如果在引用表时有时包含模式名,有时不包含,那么相同的查询将被视为两个不同的摘要。

两组列需要更多的解释,分位数列和查询样本列。分位数列的值是基于摘要的直方图统计确定的。基本上,如果您获取给定摘要和默认模式的events_statements_histogram_by_digest表,并转到具有 95%查询执行的存储桶,那么该存储桶用于确定第 95 个百分位数。直方图表格将在稍后讨论。

对于样本查询信息,如果满足三个条件中的至少一个,则样本查询被替换:

  • 对于给定的默认模式,这是第一次遇到摘要。

  • 摘要和模式的新出现具有比当前用作样本查询的查询更高的TIMER_WAIT值(即,它更慢)。

  • 如果performance_schema_max_digest_sample_age选项的值大于 0,并且当前样本查询超过了performance_schema_max_digest_sample_age秒。

performance_schema_max_digest_sample_age的值默认为 60 秒,如果您每分钟都监控events_statements_summary_by_digest表,这就很好了。这样,监控代理将能够在每一分钟的时间间隔内获得最慢的查询,并获得最慢查询的完整历史记录。如果您的监控间隔更长,请考虑增加performance_schema_max_digest_sample_age的值。

从列的列表中可以看出,有很多机会可以查询满足某些要求的语句。诀窍是查询重要的东西。什么是重要取决于具体情况,因此不可能给出适用于所有情况的具体问题。例如,如果您从监控中知道大量内部临时表使用内存或磁盘存在问题,那么SUM_CREATED_TMP_DISK_TABLESSUM_CREATED_TMP_TABLES列是过滤的良好候选。

一些条件是普遍感兴趣的。可能需要进一步调查的一些情况包括

  • 与发送回客户端的行数或被修改的行数相比,检查的行数较多。这可能表明索引使用不当。

  • 没有使用索引或没有使用好的索引的总和很高。这可能意味着查询可以从新索引或重写查询中受益。

  • 完全连接的数量很大。这表明要么需要一个索引,要么缺少一个连接条件。

  • 范围检查的次数很多。这可能意味着您需要更改查询中表的索引。

  • 如果分位数延迟在接近更高的分位数时表现出严重的下降,这可能表明您有时在及时解决查询方面存在问题。这可能是由于实例通常过载、锁问题、某些条件触发了不良查询计划或其他原因。

  • 在磁盘中创建的内部临时表的数量很大。这可能意味着您需要考虑哪些索引用于排序和分组,内部临时表允许的内存量,或者其他可能会阻止将内部临时表写入磁盘或首先创建内部临时表的更改。

  • 排序合并的数量很大。这可能意味着该查询可以受益于更大的排序缓冲区。

  • 执行死刑的数量很大。这并不意味着查询有任何问题,但是查询执行得越频繁,查询的改进效果就越好。在某些情况下,高执行计数也可能是由不必要的查询执行造成的。

  • 错误或警告的数量很高。虽然这可能不会影响性能,但它表明有问题。请注意,有些查询总是会生成警告,例如,EXPLAIN,因为它使用警告来返回附加信息。

Caution

如果您仍在使用 MySQL 5.7,请小心增加sort_buffer_size的值,因为即使它减少了排序合并的次数,也会降低性能。在 MySQL 8 中,排序缓冲区得到了改进,更大的缓冲区的性能下降要少得多。不过,不要增加超过你需要的大小。

您应该知道,仅仅因为一个查询满足这些条件中的一个,并不意味着有什么要改变的。例如,考虑一个从表中聚合数据的查询。该查询可能会检查表的大部分,但只返回几行。在没有有意义的索引可以帮助的情况下,它甚至可能需要全表扫描。从检查的行数和发送的行数之比的角度来看,该查询的性能会很差,并且可能 no 索引计数器正在递增。然而,查询可以很好地完成返回所需结果所需的最少量的工作。如果您确定查询是一个性能问题,您将需要找到一个不同于添加索引的解决方案;例如,您可以在非高峰时段执行查询并缓存结果,或者您可以有一个单独的实例来执行类似这样的查询。

清单 19-1 展示了一个例子,它找到了默认模式和语句摘要的组合,这种组合在events_statements_summary_by_digest表被最后一次重置后被执行了最多次。

mysql> SELECT ∗
         FROM performance_schema.events_statements_summary_by_digest
        ORDER BY COUNT_STAR DESC
        LIMIT 1\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
                SCHEMA_NAME: world
                     DIGEST: b49cb8f3db720a96fb29da86437bd7809ef30463fac88e85ed4f851f96dcaa30
                DIGEST_TEXT: SELECT ∗ FROM `city` WHERE NAME = ?
                 COUNT_STAR: 102349
             SUM_TIMER_WAIT: 138758688272512
             MIN_TIMER_WAIT: 1098756736
             AVG_TIMER_WAIT: 1355485824
             MAX_TIMER_WAIT: 19321416576
              SUM_LOCK_TIME: 5125624000000
                 SUM_ERRORS: 0
               SUM_WARNINGS: 0
          SUM_ROWS_AFFECTED: 0
              SUM_ROWS_SENT: 132349
          SUM_ROWS_EXAMINED: 417481571
SUM_CREATED_TMP_DISK_TABLES: 0
     SUM_CREATED_TMP_TABLES: 0
       SUM_SELECT_FULL_JOIN: 0
 SUM_SELECT_FULL_RANGE_JOIN: 0
           SUM_SELECT_RANGE: 0
     SUM_SELECT_RANGE_CHECK: 0
            SUM_SELECT_SCAN: 102349
      SUM_SORT_MERGE_PASSES: 0
             SUM_SORT_RANGE: 0
              SUM_SORT_ROWS: 0
              SUM_SORT_SCAN: 0
          SUM_NO_INDEX_USED: 102349
     SUM_NO_GOOD_INDEX_USED: 0
                 FIRST_SEEN: 2019-06-22 10:25:18.260657
                  LAST_SEEN: 2019-06-22 10:30:12.225425
                QUANTILE_95: 2089296130
                QUANTILE_99: 2884031503
               QUANTILE_999: 3630780547
          QUERY_SAMPLE_TEXT: SELECT ∗ FROM city WHERE Name = 'San José'
          QUERY_SAMPLE_SEEN: 2019-06-22 10:29:56.81501
    QUERY_SAMPLE_TIMER_WAIT: 19321416576
1 row in set (0.0019 sec)

Listing 19-1Using the events_statements_summary_by_digest table

输出显示,按名称查询world模式中的city表是执行次数最多的查询。您应该将值COUNT_STAR与其他查询进行比较,以了解与其他查询相比,该查询的执行频率。在这个例子中,您可以看到查询平均每次执行返回 1.3 行,但是检查了 4079 行。这意味着查询对返回的每一行检查 3000 多行。由于这是一个经常执行的查询,这表明用于过滤的Name列需要一个索引。输出的底部显示了一个查询的实例,您可以使用下一章中描述的EXPLAIN来分析查询执行计划。

如上所述,MySQL 还维护语句的直方图统计。有两种直方图表格可用:events_statements_histogram_by_digestevents_statements_histogram_global。两者的区别在于,前者将直方图信息按默认模式和摘要进行分组,而后者包含分组在一起的所有查询信息。直方图信息有助于确定查询延迟的分布,类似于针对events_statements_summary_by_digest表中的分位数列所讨论的信息,但粒度更细。这些表是自动管理的。

如前所述,预处理语句不包括在语句事件表中。相反,你需要使用prepared_statements_instances表。

准备好的发言摘要

准备好的语句有助于加速在连接中重用的查询的执行。例如,如果您的应用一直使用相同的连接,那么您可以准备应用使用的语句,然后在需要时执行准备好的语句。

准备好的语句使用占位符,因此您只需在准备查询时提交查询的模板。这样,您可以为每次执行提交不同的参数。当以这种方式使用时,准备好的语句充当应用可以使用给定执行所需的参数的语句目录。

清单 19-2 显示了一个通过 SQL 接口使用准备好的语句的简单例子。在应用中,您通常会使用一个以更透明的方式处理准备好的语句的连接器。例如,对于 MySQL 连接器/Python,您告诉它您想要使用准备好的语句,连接器会在您第一次执行它时自动为您准备好语句。尽管基本原理是相同的。

mysql> SET @sql = 'SELECT ∗ FROM world.city WHERE ID = ?';
Query OK, 0 rows affected (0.0002 sec)

mysql> PREPARE stmt FROM @sql;
Query OK, 0 rows affected (0.0080 sec)

Statement prepared

mysql> SET @val = 130;
Query OK, 0 rows affected (0.0003 sec)

mysql> EXECUTE stmt USING @val\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
         ID: 130
       Name: Sydney
CountryCode: AUS
   District: New South Wales
 Population: 3276207
1 row in set (0.0023 sec)

mysql> SET @val = 3805;
Query OK, 0 rows affected (0.0003 sec)

mysql> EXECUTE stmt USING @val\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
         ID: 3805
       Name: San Francisco
CountryCode: USA
   District: California
 Population: 776733
1 row in set (0.0004 sec)

mysql> DEALLOCATE PREPARE stmt;
Query OK, 0 rows affected (0.0003 sec)

Listing 19-2Example of using prepared statements

SQL 接口使用用户变量将语句和值传递给 MySQL。第一步,准备报表;然后可以根据需要多次使用它来传递查询所需的参数。最后,释放准备好的语句。

当您想调查预准备语句的性能时,可以使用prepared_statements_instances表。该信息类似于events_statements_summary_by_digest表中的信息。清单 19-3 显示了清单 19-2 中使用的预准备语句的输出示例。

mysql> SELECT ∗
         FROM performance_schema.prepared_statements_instances\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
      OBJECT_INSTANCE_BEGIN: 1999818114352
               STATEMENT_ID: 1
             STATEMENT_NAME: stmt
                   SQL_TEXT: SELECT ∗ FROM world.city WHERE ID = ?
            OWNER_THREAD_ID: 87543
             OWNER_EVENT_ID: 20012
          OWNER_OBJECT_TYPE: NULL
        OWNER_OBJECT_SCHEMA: NULL
          OWNER_OBJECT_NAME: NULL
              TIMER_PREPARE: 369412736
            COUNT_REPREPARE: 0
              COUNT_EXECUTE: 2
          SUM_TIMER_EXECUTE: 521116288
          MIN_TIMER_EXECUTE: 247612288
          AVG_TIMER_EXECUTE: 260375808
          MAX_TIMER_EXECUTE: 273504000
              SUM_LOCK_TIME: 163000000
                 SUM_ERRORS: 0
               SUM_WARNINGS: 0
          SUM_ROWS_AFFECTED: 0
              SUM_ROWS_SENT: 2
          SUM_ROWS_EXAMINED: 2
SUM_CREATED_TMP_DISK_TABLES: 0
     SUM_CREATED_TMP_TABLES: 0
       SUM_SELECT_FULL_JOIN: 0
 SUM_SELECT_FULL_RANGE_JOIN: 0
           SUM_SELECT_RANGE: 0
     SUM_SELECT_RANGE_CHECK: 0
            SUM_SELECT_SCAN: 0
      SUM_SORT_MERGE_PASSES: 0
             SUM_SORT_RANGE: 0
              SUM_SORT_ROWS: 0
              SUM_SORT_SCAN: 0
          SUM_NO_INDEX_USED: 0
     SUM_NO_GOOD_INDEX_USED: 0
1 row in set (0.0008 sec)

Listing 19-3Using the prepared_statements_instances table

与 events 语句表的主要区别在于,没有分位数统计和查询示例,主键是OBJECT_INSTANCE_BEGIN——即准备好的语句的内存地址,而不是默认模式和摘要中的唯一键。事实上,prepared_statements_instances表中甚至没有提到默认模式和摘要。

正如主键是预准备语句的内存地址所暗示的那样,只有当预准备语句存在时,才维护预准备语句统计信息。因此,当由于连接关闭而显式或隐式释放语句时,统计信息将被清除。

对语句统计的讨论到此结束。还有更高级别的统计信息,如表 I/O 摘要。

表 I/O 摘要

性能模式中的表 I/O 信息经常被误解。表 I/O 摘要中提到的 I/O 是与表相关的输入输出的一般概念。因此,它不是指磁盘 I/O。相反,它是衡量表有多忙的一个通用指标。也就是说,一个表的磁盘 I/O 越多,花在表 I/O 上的时间也就越多。

有两个性能模式表包含表 I/O 的延迟统计信息:

  • table_io_waits_summary_by_table : 包含读取、写入、提取、插入和更新 I/O 详细信息的表的聚集信息

  • table_io_waits_summary_by_index_usage :table_io_waits_summary_by_table表中的信息相同,除了统计数据是按索引或无索引。

这些表格允许您详细了解表格的使用情况以及各种操作所用的时间。有七组活动,它们都有总延迟、最小延迟、平均延迟和最大延迟以及操作数。表 19-2 显示了基于列名的组。

表 19-2

表和索引 I/O 统计信息的延迟组

|

|

|

描述

|
| --- | --- | --- |
| 全部的 | COUNT_STAR``SUM_TIMER_WAIT``MIN_TIMER_WAIT``AVG_TIMER_WAIT``MAX_TIMER_WAIT | 整个表或索引的统计信息。 |
| 读 | COUNT_READ``SUM_TIMER_READ``MIN_TIMER_READ``AVG_TIMER_READ``MAX_TIMER_READ | 所有读取操作的聚合统计信息。目前只有一个读取操作 fetch,因此读取统计信息将与读取统计信息相同。 |
| 写 | COUNT_WRITE``SUM_TIMER_WRITE``MIN_TIMER_WRITE``AVG_TIMER_WRITE``MAX_TIMER_WRITE | 所有写操作的聚合统计信息。写操作是插入、更新和删除。 |
| 取得 | COUNT_FETCH``SUM_TIMER_FETCH``MIN_TIMER_FETCH``AVG_TIMER_FETCH``MAX_TIMER_FETCH | 获取记录的统计数据。这不被称为“select”的原因是记录可能被提取用于其他目的,而不是用于SELECT语句。 |
| 插入 | COUNT_INSERT``SUM_TIMER_INSERT``MIN_TIMER_INSERT``AVG_TIMER_INSERT``MAX_TIMER_INSERT | 插入记录的统计数据。 |
| 更新 | COUNT_UPDATE``SUM_TIMER_UPDATE``MIN_TIMER_UPDATE``AVG_TIMER_UPDATE``MAX_TIMER_UPDATE | 更新记录的统计数据。 |
| 删除 | COUNT_DELETE``SUM_TIMER_DELETE``MIN_TIMER_DELETE``AVG_TIMER_DELETE``MAX_TIMER_DELETE | 删除记录的统计数据。 |

在列表 19-4 中可以看到table_io_waits_summary_by_table表中这些列的信息示例。

mysql> SELECT ∗
         FROM performance_schema.table_io_waits_summary_by_table
        WHERE OBJECT_SCHEMA = 'world'
              AND OBJECT_NAME = 'city'\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
     OBJECT_TYPE: TABLE
   OBJECT_SCHEMA: world
     OBJECT_NAME: city
      COUNT_STAR: 418058733
  SUM_TIMER_WAIT: 125987200409940
  MIN_TIMER_WAIT: 1082952
  AVG_TIMER_WAIT: 301176
  MAX_TIMER_WAIT: 43045491156
      COUNT_READ: 417770654
  SUM_TIMER_READ: 122703207563448
  MIN_TIMER_READ: 1082952
  AVG_TIMER_READ: 293700
  MAX_TIMER_READ: 19644079288
     COUNT_WRITE: 288079
 SUM_TIMER_WRITE: 3283992846492
 MIN_TIMER_WRITE: 1937352
 AVG_TIMER_WRITE: 11399476
 MAX_TIMER_WRITE: 43045491156

     COUNT_FETCH: 417770654
 SUM_TIMER_FETCH: 122703207563448
 MIN_TIMER_FETCH: 1082952
 AVG_TIMER_FETCH: 293700
 MAX_TIMER_FETCH: 19644079288
    COUNT_INSERT: 4079
SUM_TIMER_INSERT: 209027413892
MIN_TIMER_INSERT: 10467468
AVG_TIMER_INSERT: 51244420
MAX_TIMER_INSERT: 31759300408
    COUNT_UPDATE: 284000
SUM_TIMER_UPDATE: 3074965432600
MIN_TIMER_UPDATE: 1937352
AVG_TIMER_UPDATE: 10827028
MAX_TIMER_UPDATE: 43045491156
    COUNT_DELETE: 0
SUM_TIMER_DELETE: 0
MIN_TIMER_DELETE: 0
AVG_TIMER_DELETE: 0
MAX_TIMER_DELETE: 0
1 row in set (0.0015 sec)

Listing 19-4Example of using the table_io_waits_summary_by_table table

在此输出中,除了没有删除行之外,该表有广泛的用途。还可以看出,大部分时间花在读取数据上(总共 125987200409940 皮秒中的 122703207563448 皮秒——或 97%)。

清单 19-5 显示了同一个表的输出,但是使用了table_io_waits_summary_by_index_usage表。usage 列与table_io_waits_summary_by_table表中的相同,在本例中已经被省略了,以强调两个表之间的区别。如果前面的例子中有任何额外的索引,那么将会返回更多的行。

mysql> SELECT OBJECT_TYPE, OBJECT_SCHEMA,
              OBJECT_NAME, INDEX_NAME,
              COUNT_STAR
         FROM performance_schema.table_io_waits_summary_by_index_usage
        WHERE OBJECT_SCHEMA = 'world'
              AND OBJECT_NAME = 'city'\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
  OBJECT_TYPE: TABLE
OBJECT_SCHEMA: world
  OBJECT_NAME: city
   INDEX_NAME: PRIMARY
   COUNT_STAR: 20004
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 2\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
  OBJECT_TYPE: TABLE
OBJECT_SCHEMA: world
  OBJECT_NAME: city
   INDEX_NAME: CountryCode
   COUNT_STAR: 549000
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 3\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
  OBJECT_TYPE: TABLE
OBJECT_SCHEMA: world
  OBJECT_NAME: city
   INDEX_NAME: NULL
   COUNT_STAR: 417489729
3 rows in set (0.0017 sec)

Listing 19-5Example of using the table_io_waits_summary_by_index_usage table

考虑一下COUNT_STAR的三个值。如果您将这些相加,20004+549000+417489729 = 418058733,您将得到与table_io_waits_summary_by_table表中的COUNT_STAR相同的值。这个例子显示了相同的数据,但是分布在city表上的两个索引和NULL索引中,这意味着没有使用任何索引。这使得table_io_waits_summary_by_index_usage表对于估计索引的有用性以及是否对表执行表扫描非常有用。

花一分钟时间考虑获取、插入、更新和删除计数器何时增加以及针对哪些索引是有用的。考虑一下world.city表,它在ID列中有一个主键,在CountryCode列中有一个辅助索引。这意味着您可以根据使用的索引或缺少的索引设置三种类型的过滤器:

  • 按主键:使用主键定位行,例如WHERE ID = 130

  • 通过二级索引:使用CountryCode索引定位行,例如WHERE CountryCode = 'AUS'

  • 无索引:使用全表扫描来定位行,例如WHERE Name = 'Sydney'

19-3 显示了将三个示例WHERE子句中的每一个与SELECTUPDATEDELETE语句一起使用以及执行INSERT语句的矩阵。INSERT语句没有WHERE子句,所以有点不同。对于每个受影响的索引,都会列出读取和写入的次数。列显示每个语句返回或受影响的行数。

表 19-3

各种查询对表 I/O 计数器的影响

|

查询/索引

|

|

|

|
| --- | --- | --- | --- |
| SELECT按主键PRIMARY | one | FETCH: 1 |   |
| SELECT按二级指标CountryCode | Fourteen | FETCH: 14 |   |
| SELECT按无索引NULL | one | FETCH: 4079 |   |
| UPDATE按主键PRIMARY | one | FETCH: 1 | UPDATE: 1 |
| UPDATE按二级指标CountryCode | Fourteen | FETCH: 15 | UPDATE: 14 |
| UPDATE按无索引PRIMARY``NULL | one | FETCH: 4080 | UPDATE: 1 |
| DELETE按主键PRIMARY | one | FETCH: 1 | DELETE: 1 |
| DELETE按二级指标CountryCode | Fourteen | FETCH: 15 | DELETE: 14 |
| DELETE按无索引PRIMARY``NULL | one | FETCH: 4080 | DELETE: 1 |
| INSERT``NULL | one |   | INSERT: 1 |

从该表中可以看出,对于UPDATEDELETE语句,即使它们是写语句,仍然存在读。原因是,在更改行之前,仍然必须定位这些行。另一个观察结果是,当使用辅助索引或无索引来更新或删除行时,读取的记录会比符合条件的记录多一条。最后,插入一行算作非索引操作。

What to Make of the I/O Latencies?

当您看到一个显示 I/O 延迟峰值的监控图时——无论是表 I/O 还是文件 I/O——很容易得出存在问题的结论。在你这样做之前,后退一步,考虑一下这些数据意味着什么。

从性能模式来看,I/O 延迟的增加既不是好事,也不是坏事。这是事实。这意味着有东西在做 I/O,如果有一个峰值,这意味着在此期间有比平常更多的 I/O,否则你不能从事件本身得出结论。

使用这些数据的一个更有用的方法是万一报告了一个问题。这可能是系统管理员报告磁盘利用率为 100%,或者终端用户报告系统运行缓慢。然后,你可以去看看发生了什么。如果磁盘 I/O 在那个时间点异常高,那么这可能是相关的,您可以从那里继续您的调查。另一方面,如果 I/O 正常,那么高利用率可能是由 MySQL 之外的另一个进程引起的,或者磁盘阵列中的一个磁盘正在重建,或者类似的情况。

使用table_io_waits_summary_by_tabletable_io_waits_summary_by_index_usage表中的信息,您可以确定哪些表最常用于各种工作负载。例如,如果您有一个写操作特别繁忙的表,您可能想考虑将其表空间移动到一个更快的磁盘上。在做出这样的决定之前,您还应该考虑实际的文件 I/O。

文件输入输出

与刚才讨论的表 I/O 不同,文件 I/O 统计是针对 MySQL 使用的各种文件所涉及的实际磁盘 I/O 的。这是对表 I/O 信息的很好补充。

有三个性能模式表可用于获取 MySQL 实例的文件 I/O 信息:

  • events_waits_summary_global_by_event_name : 这是按事件名称分组的汇总表。通过查询以wait/io/file/开头的事件名称,您可以获得按 I/O 类型分组的 I/O 统计信息。例如,读写二进制日志文件导致的 I/O 使用单个事件(wait/io/file/sql/binlog)。注意,设置为wait/io/table/sql/handler的事件对应于刚刚讨论过的表 I/O;通过包含表 I/O,您可以很容易地比较花在文件 I/O 上的时间和花在表 I/O 上的时间

  • file_summary_by_event_name : 这类似于events_waits_summary_global_by_event_name表,但是只包括文件 I/O,并且事件分为读取、写入和其他。

  • file_summary_by_instance : 这是一个按实际文件分组的汇总表,事件分为读取、写入和杂项。例如,对于二进制日志,每个二进制日志文件有一行。

这三个表都很有用,您需要根据您要查找的信息在它们之间进行选择。例如,如果您想要文件类型的集合,events_waits_summary_global_by_event_namefile_summary_by_event_name表是更好的选择,而研究单个文件的 I/O,file_summary_by_instance表更有用。

file_summary_by_event_namefile_summary_by_instance表将事件分为读取、写入和其他事件。读和写很容易理解。杂项 I/O 是指所有不读写的东西。这包括但不限于创建、打开、关闭、删除、刷新和获取文件的元数据。所有杂项操作都不涉及数据传输,因此没有杂项字节计数器。

清单 19-6 显示了events_waits_summary_global_by_event_name表中可用数据的一个例子。该查询查找花费在 I/O 上的总时间最多的事件

mysql> SELECT ∗
         FROM performance_schema.events_waits_summary_global_by_event_name
        WHERE EVENT_NAME LIKE 'wait/io/file/%'
        ORDER BY SUM_TIMER_WAIT DESC
        LIMIT 1\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
    EVENT_NAME: wait/io/file/innodb/innodb_log_file
    COUNT_STAR: 58175
SUM_TIMER_WAIT: 20199487047180
MIN_TIMER_WAIT: 5341780
AVG_TIMER_WAIT: 347219260
MAX_TIMER_WAIT: 18754862132
1 row in set (0.0031 sec)

Listing 19-6The file I/O event spending the most time overall

这表明对于这个实例,最活跃的事件是 InnoDB 重做日志文件。这是一个非常典型的结果。每个事件都有相应的乐器。默认情况下,所有文件等待 I/O 事件都是启用的。一个特别有趣的事件是针对 InnoDB 表空间文件的 I/O 的wait/io/file/innodb/innodb_data_file

events_waits_summary_global_by_event_name表的一个缺点是所有花费在 I/O 上的时间都被聚集到总计数器中,而不是读取和写入中。也只有计时可用。如果您使用file_summary_by_event_name表,您可以获得更多的细节。

清单 19-7 显示了在前面的例子中找到的 InnoDB 重做日志 I/O 事件的file_summary_by_event_name表的例子。

mysql> SELECT ∗
         FROM performance_schema.file_summary_by_event_name
        WHERE EVENT_NAME =
                  'wait/io/file/innodb/innodb_log_file'\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
               EVENT_NAME: wait/io/file/innodb/innodb_log_file
               COUNT_STAR: 58175
           SUM_TIMER_WAIT: 20199487047180
           MIN_TIMER_WAIT: 5341780
           AVG_TIMER_WAIT: 347219260
           MAX_TIMER_WAIT: 18754862132
               COUNT_READ: 8
           SUM_TIMER_READ: 778174704
           MIN_TIMER_READ: 5341780
           AVG_TIMER_READ: 97271660
           MAX_TIMER_READ: 409998080
 SUM_NUMBER_OF_BYTES_READ: 70656
              COUNT_WRITE: 33672
          SUM_TIMER_WRITE: 870804229376
          MIN_TIMER_WRITE: 7867956
          AVG_TIMER_WRITE: 25861264
          MAX_TIMER_WRITE: 14021439496
SUM_NUMBER_OF_BYTES_WRITE: 61617664
               COUNT_MISC: 24495
           SUM_TIMER_MISC: 19327904643100
           MIN_TIMER_MISC: 12479224
           AVG_TIMER_MISC: 789054776
           MAX_TIMER_MISC: 18754862132
1 row in set (0.0005 sec)

Listing 19-7The I/O statistics for the InnoDB redo log

请注意,当查询events_waits_summary_global_by_event_name表时,SUM_TIMER_WAIT和具有总聚集的其他列具有相同的值。(因为 I/O 经常在后台发生,所以即使在比较两个表之间不执行查询,情况也不会总是这样。)通过将 I/O 分为读取、写入和杂项,您可以更好地了解实例上的 I/O 工作负载。

如果您想要单个文件的统计数据,您需要使用file_summary_by_instance表。清单 19-8 显示了微软 Windows 上world.city表的表空间文件的一个例子。请注意,有四个反斜杠表示路径中的一个反斜杠。

mysql> SELECT ∗
         FROM performance_schema.file_summary_by_instance
        WHERE FILE_NAME LIKE '%\\\\world\\\\city.ibd'\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
                FILE_NAME: C:\ProgramData\MySQL\MySQL Server 8.0\Data\world\city.ibd
               EVENT_NAME: wait/io/file/innodb/innodb_data_file
    OBJECT_INSTANCE_BEGIN: 1999746796608
               COUNT_STAR: 380
           SUM_TIMER_WAIT: 325377148780
           MIN_TIMER_WAIT: 12277372
           AVG_TIMER_WAIT: 856255472
           MAX_TIMER_WAIT: 10778110040
               COUNT_READ: 147
           SUM_TIMER_READ: 144057058960
           MIN_TIMER_READ: 85527220
           AVG_TIMER_READ: 979979712
           MAX_TIMER_READ: 7624205292
 SUM_NUMBER_OF_BYTES_READ: 2408448
              COUNT_WRITE: 125
          SUM_TIMER_WRITE: 21938183516
          MIN_TIMER_WRITE: 12277372
          AVG_TIMER_WRITE: 175505152
          MAX_TIMER_WRITE: 5113313440
SUM_NUMBER_OF_BYTES_WRITE: 2146304
               COUNT_MISC: 108
           SUM_TIMER_MISC: 159381906304
           MIN_TIMER_MISC: 160612960
           AVG_TIMER_MISC: 1475758128
           MAX_TIMER_MISC: 10778110040
1 row in set (0.0007 sec)

Listing 19-8The file I/O for the world.city tablespace file

您可以看到,事件名称表明它是一个 InnoDB 表空间文件,I/O 被分为读取、写入和杂项。对于读取和写入,还包括总字节数。

要考虑的最后一组性能模式表是错误汇总表。

错误汇总表

虽然错误与查询调优没有直接关系,但错误确实表明有问题。导致错误的查询仍然会使用资源,但是当错误发生时,这一切都是徒劳的。因此,错误会给系统增加不必要的负载,从而间接影响查询性能。还有一些与性能更直接相关的错误,比如由于未能获得锁而导致的错误。

性能模式中有五个表,对不同分组遇到的错误进行分组。这些桌子是

  • events_errors_summary_by_account_by_error

  • events_errors_summary_by_host_by_error

  • events_errors_summary_by_thread_by_error

  • events_errors_summary_by_user_by_error

  • events_errors_summary_global_by_error

表名是不言自明的。您可以使用这些表来确定是谁在执行触发错误的查询,并将其与语句事件表(例如events_statements_summary_by_digest)结合起来,以了解是谁触发了错误以及错误是针对哪些语句的。清单 19-9 显示了一个按账户分组查询死锁发生次数的例子。

mysql> SELECT ∗
         FROM performance_schema.events_errors_summary_by_account_by_error
        WHERE ERROR_NAME = 'ER_LOCK_DEADLOCK'\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
             USER: NULL
             HOST: NULL
     ERROR_NUMBER: 1213
       ERROR_NAME: ER_LOCK_DEADLOCK
        SQL_STATE: 40001
 SUM_ERROR_RAISED: 0
SUM_ERROR_HANDLED: 0
       FIRST_SEEN: NULL
        LAST_SEEN: NULL
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 2\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
             USER: root
             HOST: localhost
     ERROR_NUMBER: 1213
       ERROR_NAME: ER_LOCK_DEADLOCK
        SQL_STATE: 40001
 SUM_ERROR_RAISED: 2
SUM_ERROR_HANDLED: 0
       FIRST_SEEN: 2019-06-16 10:58:05
        LAST_SEEN: 2019-06-16 11:07:29
2 rows in set (0.0105 sec)

Listing 19-9Using the events_errors_summary_by_account_by_error table

这表明root@localhost帐户出现了两次死锁,但都没有得到处理。用户和主机为NULL的第一行代表后台线程。

Tip

你可以在 https://dev.mysql.com/doc/refman/en/server-error-reference.html 从 MySQL 参考手册中获得错误号和名称以及 SQL 状态。

性能模式的讨论到此结束。如果您觉得性能模式表太多了,尝试使用它们是一个好主意,例如,在一个空闲的测试系统上执行一些查询,这样您就知道会发生什么。另一个选择是使用sys模式,这使得基于性能模式的报告更容易开始。

sys 架构

sys模式的主要目标之一是简化基于性能模式的报告创建。这包括可用于寻找优化候选的报告。本节中讨论的所有报告都可以通过直接查询性能模式表来生成;然而,sys模式提供了可以随意使用的报告,并设置了格式,使人们更容易阅读数据。

本节中讨论的报告是使用性能模式表作为视图创建的,本章前面已经介绍了其中的大部分内容。根据视图是否可用于查找语句或什么使用 I/O,将视图分为不同的类别。本节的最后部分将展示如何使用statement_performance_analyzer()过程来查找在监控窗口期间执行的语句。

语句视图

语句视图使查询按主机或用户分组的语句变得简单,并且可以找到匹配某些条件的语句,例如它使用全表扫描。除非另有说明,视图使用events_statements_summary_by_digest性能模式表。表 19-4 中列出了可用的视图。

表 19-4

该语句视图

|

视角

|

描述

|
| --- | --- |
| host_summary_by_statement_latency | 这个视图使用events_statements_summary_by_host_by_event_name表为每个主机名返回一行,为后台线程返回一行。每一行都包含语句的高级统计信息,如总延迟、发送的行数等。这些行按总等待时间降序排列。 |
| host_summary_by_statement_type | 这个视图使用与host_summary_by_statement_latency视图相同的性能模式表,但是除了主机名之外,它还包括语句类型。这些行首先按主机名升序排序,然后按总延迟降序排序。 |
| innodb_lock_waits | 该视图显示正在进行的 InnoDB 行锁等待。它使用data_locksdata_lock_waits表格。该视图在第 22 章中用于调查锁定问题。 |
| schema_table_lock_waits | 该视图显示正在进行的元数据和用户锁等待。它使用metadata_locks表。该视图在第 22 章中用于调查锁定问题。 |
| session | 该视图返回一个基于threadsevents_statements_current表的高级进程列表,以及来自其他性能模式表的一些附加信息。该视图包括活动连接的当前语句和空闲连接的最后执行的语句。根据进程列表时间和前一条语句的持续时间,以降序返回这些行。session的观点对于理解现在正在发生的事情特别有用。 |
| statement_analysis | 该视图是按总延迟降序排序的events_statements_summary_by_digest表的格式化版本。 |
| statements_with_errors_or_warnings | 该视图返回导致错误或警告的语句。这些行按错误数和警告数降序排列。 |
| statements_with_full_table_scans | 该视图返回包含全表扫描的语句。这些行首先按未使用索引的次数百分比排序,然后按总延迟排序,两者都按降序排序。 |
| statements_with_runtimes_in_95th_percentile | 该视图返回位于events_statements_summary_by_digest表中所有查询的第 95 个百分位数的语句。这些行按平均延迟降序排列。 |
| statements_with_sorting | 该视图返回对结果中的行进行排序的语句。这些行按总等待时间降序排列。 |
| statements_with_temp_tables | 该视图返回使用内部临时表的语句。这些行按磁盘上内部临时表和内存中内部临时表的数量降序排列。 |
| user_summary_by_statement_latency | 这个视图类似于host_summary_by_statement_latency视图,只是它按用户名分组。该视图基于events_statements_summary_by_user_by_event_name表。 |
| user_summary_by_statement_type | 该视图与user_summary_by_statement_latency视图相同,除了还包括语句类型。 |

查询视图和直接使用底层性能模式表之间的主要区别在于,您不需要添加过滤器,并且数据被格式化以使人们更容易阅读。这使得在调查性能问题时使用sys模式视图作为特别报告变得很容易。

Tip

请记住,这些视图也可以带有前缀x$,例如x$statement_analysis。如果您想要在格式化的列上添加额外的过滤器,改变排序,或者类似的事情,带有x$前缀的视图不会添加使它们变得更好的格式。

在清单 19-10 中可以看到一个使用视图的例子,其中statement_analysis视图用于查找自从性能模式表上次重置以来总体使用时间最多的语句。

mysql> SELECT ∗
         FROM sys.statement_analysis
        LIMIT 1\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
            query: UPDATE `world` . `city` SET `Population` = ? WHERE `ID` = ?
               db: world
        full_scan:
       exec_count: 3744
        err_count: 3
       warn_count: 0
    total_latency: 9.70 m
      max_latency: 51.53 s
      avg_latency: 155.46 ms
     lock_latency: 599.31 ms
        rows_sent: 0
    rows_sent_avg: 0
    rows_examined: 3741
rows_examined_avg: 1
    rows_affected: 3741
rows_affected_avg: 1
       tmp_tables: 0
  tmp_disk_tables: 0
      rows_sorted: 0
sort_merge_passes: 0
           digest: 8f3799ba6b1f47fc2d76f018eaafb6ef8a9d743a7dbe5e558e37371408a1ad5e
       first_seen: 2019-06-15 17:30:13.674383
        last_seen: 2019-06-15 17:52:42.881701
1 row in set (0.0028 sec)

Listing 19-10Finding the query using the most time executing

视图已经按总延迟降序排序,因此没有必要向查询添加任何排序。如果您回想一下本章前面使用events_statements_summary_by_digest性能模式表的示例,返回的信息是相似的,但是延迟更容易阅读,因为以皮秒为单位的值已经转换为 0 到 1000 之间的值。摘要也包括在内,因此如果需要,您可以使用它来查找关于该语句的更多信息。

其他视图也包含有用的信息。这是留给读者的一个练习,让他们查询系统上的视图并探索结果。

表 I/O 视图

表 I/O 的sys模式视图可用于查找关于表和索引使用情况的信息。这包括查找未使用的索引和执行全表扫描的表。

基于表 I/O 的视图都以schema_作为名称前缀。这些视图包括表 19-5 中汇总的视图。

表 19-5

表 I/O 视图

|

视角

|

描述

|
| --- | --- |
| schema_index_statistics | 该视图包括索引名不是NULLtable_io_waits_summary_by_index_usage表的所有行。这些行按总等待时间降序排列。该视图显示了每个索引用于选择、插入、更新和删除数据的量。 |
| schema_table_statistics | 该视图结合了来自table_io_waits_summary_by_tablefile_summary_by_instance表的数据,以返回表 I/O 和与表相关的文件 I/O。文件 I/O 统计信息只包括它们自己的表空间中的表。这些行按总表 I/O 延迟降序排列。 |
| schema_table_statistics_with_buffer | 该视图与schema_table_statistics视图相同,除了它还包括来自innodb_buffer_page信息模式表的缓冲池使用信息。要知道查询innodb_buffer_page表会有很大的开销,最好用在测试系统上。 |
| schema_tables_with_full_table_scans | 该视图在table_io_waits_summary_by_index_usage表中查询索引名为NULL的行,即没有使用索引的行,并包括读取计数大于 0 的行。在这些表中,有些行是在不使用索引的情况下读取的,即通过全表扫描。这些行按读取的总行数降序排列。 |
| schema_unused_indexes | 这个视图也使用了table_io_waits_summary_by_index_usage表,但是包含了没有被索引读取的行,并且该索引不是主键或者唯一索引。mysql模式中的表被排除在外,因为您不应该更改任何这些表的定义。这些表根据模式和表名按字母顺序排序。 |

通常这些视图与其他视图和表格结合使用。例如,您可能会发现 CPU 使用率非常高。高 CPU 使用率的一个典型原因是大型表扫描,因此您可能会查看schema_tables_with_full_table_scans视图,发现一个或多个表通过表扫描返回了大量的行。然后继续查询statements_with_full_table_scans视图,查找使用该表而不使用索引的语句。

如前所述,schema_table_statistics视图结合了表 I/O 统计和文件 I/O 统计。也有纯粹看文件 I/O 的视图。

文件 I/O 视图

探索文件 I/O 使用情况的视图遵循与按主机名或用户名分组的语句视图相同的模式。一旦确定磁盘 I/O 是一个瓶颈,这些视图最适合用来确定是什么导致了 I/O。然后,您可以向后查找相关的表。从这里,您可以确定是否可以使用表来优化查询,或者是否需要增加 I/O 容量。

文件 I/O 包括表 19-6 中的视图。

表 19-6

文件 I/O 视图

|

视角

|

描述

|
| --- | --- |
| host_summary_by_file_io | 该视图使用events_waits_summary_by_host_by_event_name表,并按帐户主机名对文件 I/O 等待事件进行分组。这些行按总等待时间降序排列。 |
| host_summary_by_file_io_type | 该视图与host_summary_by_file_io视图相同,只是它还包括文件 I/O 的事件名称。各行按主机名排序,然后按总延迟降序排序。 |
| io_by_thread_by_latency | 该视图使用events_waits_summary_by_thread_by_event_name表返回按线程分组的文件 I/O 统计信息,其中的行按总延迟降序排列。线程包括后台线程,后台线程是导致大部分写 I/O 的线程。 |
| io_global_by_file_by_bytes | 该视图使用file_summary_by_instance表返回每个文件的读写操作次数和 I/O 量(以字节为单位)。这些行按读取和写入 I/O 的总量(以字节为单位)降序排列。 |
| io_global_by_file_by_latency | 该视图与io_global_by_file_by_bytes视图相同,只是它报告了 I/O 延迟。 |
| io_global_by_wait_by_bytes | 该视图类似于io_global_by_file_by_bytes视图,除了它按 I/O 事件名称而不是文件名分组,并且它使用file_summary_by_event_name表。 |
| io_global_by_wait_by_latency | 该视图与io_global_by_wait_by_bytes视图相同,只是它报告了 I/O 延迟。 |
| user_summary_by_file_io | 该视图与host_summary_by_file_io视图相同,只是它使用了events_waits_summary_by_user_by_event_name表,并按用户名而不是主机名分组。 |
| user_summary_by_file_io_type | 该视图与user_summary_by_file_io视图相同,只是它还包括文件 I/O 的事件名称。各行按用户名排序,然后按总延迟降序排序。 |

这些视图使用起来很简单,但是仍然值得看几个例子来展示与它们相关的一些细节。清单 19-11 显示了一个背景和前景线程的io_by_thread_by_latency视图的例子。基于测试系统上可用的线程来选择线程 id。

mysql> SELECT ∗
         FROM sys.io_by_thread_by_latency
        WHERE THREAD_ID IN (19, 87543)\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
          user: log_flusher_thread
         total: 24489
 total_latency: 19.33 s
   min_latency: 56.39 us
   avg_latency: 789.23 us
   max_latency: 18.75 ms
     thread_id: 19
processlist_id: NULL
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 2\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
          user: root@localhost
         total: 40683
 total_latency: 15.48 s
   min_latency: 5.27 us
   avg_latency: 353.57 us
   max_latency: 262.23 ms
     thread_id: 87543
processlist_id: 87542
2 rows in set (0.0066 sec)

Listing 19-11Example of using the io_by_thread_by_latency view

示例中需要注意的主要事情是用户名。在第 1 行中,有一个后台线程的例子,在这种情况下,线程名称的最后一部分(使用/作为分隔符)被用作用户名。在第 2 行中,它是一个前台线程,用户是帐户的用户名和主机名,它们之间有一个@符号。这些行还包括有关性能模式线程 id 和进程列表 id(连接 id)的信息,因此您可以使用它们来查找有关线程的更多信息。

另一个例子如清单 19-12 所示,用于io_global_by_file_by_bytes视图。

mysql> SELECT ∗
         FROM sys.io_global_by_file_by_bytes
        LIMIT 1\G
∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗ 1\. row ∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗∗
         file: @@datadir\undo_001
   count_read: 15889
   total_read: 248.31 MiB
     avg_read: 16.00 KiB
  count_write: 15149
total_written: 236.70 MiB
    avg_write: 16.00 KiB
        total: 485.02 MiB
    write_pct: 48.80
1 row in set (0.0028 sec)

Listing 19-12Example of using the io_global_by_file_by_bytes view

注意这里文件名的路径是如何使用@@datadir的。这是格式的一部分,sys模式使用它来使文件的位置一目了然。数据量也被缩放。

到目前为止已经讨论过的sys模式视图都报告了自从相应的性能模式表最后一次重置以来记录的统计数据。通常,性能问题只是间歇性地出现,在这种情况下,您需要确定在此期间发生了什么。这就是您需要语句性能分析器的地方。

语句性能分析器

语句性能分析器允许您获取events_statements_summary_by_digest表的两个快照,并在一个通常直接使用events_statements_summary_by_digest表的视图中使用这两个快照之间的增量。例如,这对于确定在高峰负载期间执行哪些查询非常有用。

使用statement_performance_analyzer()程序创建快照并执行分析。它需要三个参数,如表 19-7 所示。

表 19-7

语句性能分析器()过程的参数

|

争吵

|

有效值

|

描述

|
| --- | --- | --- |
| action | Snapshot``Overall``Delta``create_tmp``create_table``save``cleanup | 您希望过程执行的操作。稍后将更详细地讨论这些操作。 |
| table | <schema>.<table> | 此参数用于需要表名的操作。格式必须是schema.table或表格名称本身。无论哪种情况,都不要使用反斜线。架构或表名中不允许有点。 |
| views | with_runtimes_in_95th_percentile``analysis``with_errors_or_warnings``with_full_table_scans``with_sorting``with_temp_tables习俗 | 用于生成报告的视图名称。允许指定多个视图。除了自定义视图之外的所有视图都在使用sys模式中的一个语句视图。对于定制视图,定制视图的视图名称是使用statement_performance_analyzer.view sys模式配置选项指定的。 |

该操作指定您希望该过程执行的操作。在生成语句执行报告的工作流程的不同阶段使用不同的操作。支持的动作在表 19-8 中列出。

表 19-8

语句性能分析器()过程的操作

|

行动

|

描述

|
| --- | --- |
| snapshot | 这将创建一个events_statements_summary_by_digest表的快照,除非给出了table参数,在这种情况下,所提供的表的内容将被用作快照。快照存储在sys模式中一个名为tmp_digests的临时表中。 |
| overall | 这将基于带有table参数的表中的内容创建一个报告。如果将 table 参数设置为NOW(),则摘要表的当前内容将用于创建新的快照。如果将表参数设置为NULL,将使用当前快照。 |
| delta | 这将使用带有table参数的表和现有快照,基于两个快照之间的差异创建一个报告。这个操作创建了一个临时表sys.tmp_digests_delta。本节稍后将展示此操作的一个示例。 |
| create_table | 用table参数给出的名称创建一个常规用户表。该表可用于存储使用save动作的快照。 |
| create_tmp | 用参数table给出的名称创建一个临时表。该表可用于存储使用save动作的快照。 |
| save | 将现有快照保存到由table参数指定的表中。 |
| cleanup | 删除已用于快照和增量计算的临时表。使用create_tablecreate_tmp动作创建的表格不会被删除。 |

该过程对于创建两个快照并计算它们之间的增量特别有用。执行增量分析的工作流程如下:

  1. 创建一个临时表来存储初始快照。这是通过使用create_tmp动作来完成的。

  2. 使用snapshot动作创建初始快照。

  3. 使用save动作将第 1 步中的初始快照保存到临时表中。

  4. 等待应该收集数据的持续时间。

  5. 使用snapshot动作创建新的快照。

  6. 使用带有一个或多个视图的delta动作来生成报告。

  7. 使用cleanup动作进行清理。

在您知道哪些查询已经执行的受控环境中尝试该过程会很有用。这样,您就知道生成的输出中会出现什么。该示例将使用一个名为monitor的模式来存储初始快照:

mysql> CREATE SCHEMA monitor;

当在第二个连接中进行监控时,您将需要执行一些查询。我们鼓励您尝试一些自己的查询。如果您想重现示例中的输出,您可以使用 MySQL Shell 并开始(在开始监控之前)将语言模式更改为 Python,并将默认模式设置为world:

\py
\use world

清单 19-13 中显示了将执行该示例的九个查询的 Python 代码。您可以在 MySQL Shell 中执行代码。该代码也可以从本书的 GitHub 库中的文件listing_19_13.py中获得。

queries = [
    ("SELECT * FROM `city` WHERE `ID` = ?", [130, 3805]),
    ("SELECT * FROM `city` WHERE `CountryCode` = ?", ['AUS', 'CHN', 'IND']),
    ("SELECT * FROM `country` WHERE CODE = ?", ['DEU', 'GBR', 'BRA', 'USA']),
]

for query in queries:
    sql = query[0]
    parameters = query[1]
    for param in parameters:
        result = session.run_sql(sql, (param,))

Listing 19-13Python code for example queries for statement analysis

具有占位符的查询被定义为元组的列表,该列表具有用于该查询的值,作为元组中的第二个元素。如果您想执行更多的查询,这允许您快速添加更多的查询和值。查询在查询和参数的双循环中执行。当您将代码粘贴到 MySQL Shell 中时,用两行新代码来结束它,告诉 MySQL Shell 多行代码块已经完成。

清单 19-14 展示了在两个快照之间创建一个大约一分钟的报告的例子。该示例使用基于报告的sys.statement_analysisanalysis视图。由于这本书的页数限制,不允许报告被很好地显示,步骤和报告的完整输出可以在这本书的 GitHub 库的listing_19_14_statement_analysis.txt文件中找到。报告中查询的顺序可能会有所不同,因为这取决于执行查询所需的时间,并且统计数据也会有所不同。

mysql> CALL sys.ps_setup_disable_thread(CONNECTION_ID());
+-------------------+
| summary           |
+-------------------+
| Disabled 1 thread |
+-------------------+
1 row in set (0.0012 sec)

Query OK, 0 rows affected (0.0012 sec)

mysql> CALL sys.statement_performance_analyzer(
              'create_tmp', 'monitor._tmp_ini', NULL);
Query OK, 0 rows affected (0.0028 sec)

mysql> CALL sys.statement_performance_analyzer(
              'snapshot', NULL, NULL);
Query OK, 0 rows affected (0.0065 sec)

mysql> CALL sys.statement_performance_analyzer(
              'save', 'monitor._tmp_ini', NULL);
Query OK, 0 rows affected (0.0017 sec)

-- Execute your queries or the Python code in Listing 19-13
-- in a second connection while the SLEEP(60) is executing.
mysql> DO SLEEP(60);
Query OK, 0 rows affected (1 min 0.0064 sec)

mysql> CALL sys.statement_performance_analyzer(
              'snapshot', NULL, NULL);
Query OK, 0 rows affected (0.0041 sec)

mysql> CALL sys.statement_performance_analyzer(
              'delta', 'monitor._tmp_ini',
              'analysis');
+------------------------------------------+
| Next Output                              |
+------------------------------------------+
| Top 100 Queries Ordered by Total Latency |
+------------------------------------------+
1 row in set (0.0049 sec)

+----------------------------------------------+-------+...
| query                                        | db    |...
+----------------------------------------------+-------+...
| SELECT * FROM `city` WHERE `CountryCode` = ? | world |...
| SELECT * FROM `country` WHERE CODE = ?       | world |...
| SELECT * FROM `city` WHERE `ID` = ?          | world |...
+----------------------------------------------+-------+...
3 rows in set (0.0049 sec)

Query OK, 0 rows affected (0.0049 sec)

mysql> CALL sys.statement_performance_analyzer(
              'cleanup', NULL, NULL);
Query OK, 0 rows affected (0.0018 sec)

mysql> DROP TEMPORARY TABLE monitor._tmp_ini;
Query OK, 0 rows affected (0.0007 sec)

mysql> CALL sys.ps_setup_enable_thread(CONNECTION_ID());
+------------------+
| summary          |

+------------------+
| Enabled 1 thread |
+------------------+
1 row in set (0.0015 sec)

Query OK, 0 rows affected (0.0015 sec)

Listing 19-14Using the statement_performance_analyzer() procedure

在示例的开头和结尾使用ps_setup_disable_thread()ps_setup_enable_thread()过程是为了禁用执行分析的线程的性能模式检测,然后在分析完成时启用检测。通过禁用检测,分析执行的查询不会包含在报告中。这在一个繁忙的系统上并不重要,但是在只有几个查询的测试中非常有用。

对于分析本身,会创建一个临时表,以便创建快照并保存到其中。之后,收集数据一分钟,然后创建新的快照,并生成报告。最后的步骤是清理用于分析的临时表。注意,临时表monitor._tmp_ini并没有被cleanup动作清理,因为它是由create_tmp动作显式创建的。

报告输出显示在监控期间执行了三条语句。在现实世界中,通常会有更多的查询,默认情况下,报告仅限于前 100 个查询。您可以配置报告中可以包含多少个查询以及一些其他设置。这是通过使用支持以下设置的sys模式配置机制来完成的:

  • debug : 当选项设置为ON时,产生调试输出。默认是OFF

  • statement_performance_analyzer.limit : 报表中包含的最大报表数。默认值为 100。

  • statement_performance_analyzer.view :与custom视图一起使用的视图。

Tip

sys模式选项既可以在sys.sys_config表中设置,也可以通过在选项名称前添加@sys.作为用户变量。比如debug变成了@sys.debug

到目前为止,一直假设通过显式地对模式视图执行查询来直接使用sys模式视图。但是这并不是你使用它们的唯一方式;这些视图也可以通过 MySQL Workbench 获得。

MySQL 工作台

如果您更喜欢使用图形用户界面而不是命令行界面,MySQL Workbench 是很好的选择。MySQL Workbench 不仅允许您执行自己的查询;它还附带了几个特性来帮助您管理和监控实例。出于讨论的目的,主要关注的是性能报告客户端连接报告。

这两个报告都可以通过 MySQL Workbench 窗口左侧的导航器来访问。一旦连接到 MySQL,导航器就可用了。图 19-1 突出显示了这些报告。

img/484666_1_En_19_Fig1_HTML.jpg

图 19-1

访问客户端连接和性能报告

本节的其余部分将更详细地讨论这两种类型的报告。

绩效报告

MySQL Workbench 中的性能报告是研究实例中发生的事情的一个很好的方式。因为性能报告是基于sys模式视图的,所以可用的信息将与通过sys模式视图时讨论的信息相同。

通过连接到您想要调查的实例并从导航器的 PERFORMANCE 部分选择 Performance Reports ,您可以获得性能报告。您可以访问大多数报告,这些报告也可以使用sys模式直接生成。图 19-2 显示了如何选择您感兴趣的报告。

img/484666_1_En_19_Fig2_HTML.jpg

图 19-2

选择性能报告

报表示例如图 19-3 所示,其中已经执行了报表统计报表。这与您使用sys.statement_analysis视图得到的报告相同。在本书的 GitHub 存储库中的文件figure_19_3_performance_report.png中可以看到包含所有列的报告示例。

img/484666_1_En_19_Fig3_HTML.jpg

图 19-3

报表统计性能报告

性能报告的一个优点是它们使用无格式的视图定义,因此您可以使用 GUI 更改排序。通过单击要作为排序依据的列的列标题,可以更改排序。每次单击列标题时,顺序都会在升序和降序之间切换。

在报告的底部,有一些按钮可以帮助您使用报告。导出… 按钮允许您将报告结果保存为 CSV 文件。复制选中的按钮将标题和选中的行以 CSV 格式复制到内存中。复制查询按钮复制用于报告的查询。这允许您编辑查询并手动执行它。对于图 19-3 中的报表,返回的查询是select ∗ from sys.x$statement_analysis``。最后一个按钮是右侧的刷新按钮,再次执行报告。

没有基于sys.session视图的性能报告。相反,您需要使用客户端连接报告。

客户端连接报告

如果您想要获得当前连接到实例的连接列表,您需要使用客户端连接报告。它不像sys.session视图包含那么多信息,但是它包含了最基本的数据。该报告基于性能模式中的threads表,此外,如果可能的话,还包括程序名。

19-4 显示了报告最左边栏的例子。要查看专栏的完整列表,请查看该书的 GitHub 资源库中的文件figure_19_4_client_connections.png

img/484666_1_En_19_Fig4_HTML.jpg

图 19-4

客户端连接报告

如果您已经打开了客户端连接报告或其中一个性能报告,您可以重新使用该连接来获取客户端连接报告。如果所有的连接都用完了,并且您需要得到一个关于连接正在做什么的报告,那么这将是非常有用的。客户端连接报告还允许您通过选择查询并使用报告右下角的一个终止按钮来终止查询或连接。

虽然 MySQL Workbench 对于调查性能问题非常有用,但它主要是针对特定的调查。为了进行适当的监控,您需要一个完整的监控解决方案。

MySQL 企业监控器

当您需要调查性能问题时,无论您是对用户投诉做出反应还是主动寻求改进,都没有什么可以替代功能齐全的监控解决方案。本节将基于 MySQL 企业监控器(MEM)进行讨论。其他监控解决方案可能提供类似的功能。

本节将讨论三个特性。第一个是查询分析器,然后是时间序列图,最后是临时报告,如进程和锁等待报告。在调查问题时,您应该结合使用各种指标。例如,如果您有一个高磁盘 I/O 使用率的报告,那么可以找到显示磁盘 I/O 的时间序列图,并确定 I/O 是如何以及何时发展的。然后,您可以使用查询分析器来调查在此期间执行了哪些查询。如果问题仍然存在,可以使用诸如进程报告或其他临时报告之类的报告来查看发生了什么。

查询分析器

当您需要调查性能问题时,MySQL Enterprise Monitor 中的查询分析器是最重要的地方之一。MySQL Enterprise Monitor 使用性能模式中的events_statements_summary_by_digest表定期收集已经执行的查询。然后,它比较连续的输出,以确定自上次数据收集以来的统计数据。这与您在使用sys模式中的语句性能分析器的示例中看到的类似,只是这是自动发生的,并且与收集的其余数据集成在一起。

通过选择左侧菜单中的查询选项,进入查询分析器,如图 19-5 所示。

img/484666_1_En_19_Fig5_HTML.jpg

图 19-5

访问查询分析器

打开查询分析器后,默认情况下,查询响应时间索引(QRTi)图位于顶部,查询列表位于下方。默认时间范围是过去的一个小时。您可以选择显示另一个图形或更改图形的数量。带有查询响应时间索引的默认图表值得考虑。

查询响应时间索引是衡量单个查询或一组查询性能的指标。它是使用 Apdex(应用性能索引)公式计算的。 1

  • 最优:当查询的执行时间少于为定义最优性能而设置的阈值时。默认阈值为 100 ms,该阈值可以配置。绿色用于最佳时间范围。

  • 可接受:当查询的执行时间超过最佳时间范围的阈值,但小于阈值的四倍时。这个框架使用黄色。

  • 不可接受:当查询比最优阈值的阈值慢四倍时。这个框架使用红色。

查询响应时间索引并不能完美地衡量实例的执行情况,但是对于各种查询的响应时间间隔大致相同的系统,它可以很好地反映系统或查询在不同时间的执行情况。如果您混合了非常快速的 OLTP 查询和慢速的 OLAP 查询,那么这不是一个很好的性能指标。

如果您在图中发现了一些有趣的东西,您可以选择该时间段,并将其用作过滤查询的新时间范围。图形右上角还有配置视图按钮,可用于设置图形和查询的时间范围、显示哪些图形、查询的过滤器等等。

查询列表是您需要用来查看实际查询的。查询示例如图 19-6 所示。

img/484666_1_En_19_Fig6_HTML.jpg

图 19-6

查询分析器中查询的概述

这些信息是高层次的,旨在帮助您缩小在给定时间段内需要仔细查看的候选查询的范围。在这个例子中,您可以看到按名称查找城市的查询已经执行了将近 160,000 次。您应该问的第一个问题是,执行这个查询的次数是否合理。这可能是意料之中的,但是高执行计数也可能是 runway 进程不断重复执行相同查询的标志,或者是您需要为查询实现缓存的标志。您还可以从绿色的圆环图中看到,所有执行都处于查询响应时间索引的最佳时间范围内。

查询区域右上角的图标,就在三个垂直点的左边,显示 MySQL Enterprise Monitor 已经标记了这个查询。要了解图标的含义,请将鼠标悬停在图标上。本例中的图标表示查询正在进行全表扫描。因此,尽管查询响应时间索引对于查询来说看起来不错,但还是值得更仔细地研究一下查询。是否可以接受进行全表扫描取决于几个因素,例如表中的行数和查询的执行频率。您还可以看到,查询延迟图在图的右端显示延迟增加,表明性能正在下降。

如果您想要更详细地调查某个查询,请单击查询区域右上角的三个垂直点,这将允许您转到该查询的详细信息屏幕。图 19-7 显示了一个查询细节的例子。该书的 GitHub 知识库中的文件figure_19_7_mem_query_details.png中提供了全尺寸截图。

img/484666_1_En_19_Fig7_HTML.jpg

图 19-7

来自查询分析器的查询详细信息

详细信息包括性能模式摘要中可用的指标。在这里,您可以看到检查的行数确实比返回的行数多得多,因此值得进一步研究是否需要索引。这些图表展示了查询执行随时间的发展。

底部是实际查询执行延迟的示例。在这种情况下,包括两个执行。第一个是图表左边的红圈。第二个是右下角的蓝绿色标记。颜色象征每次执行的查询响应时间索引。此图仅在events_statements_history_long消费者启用时可用。

查询分析器非常适合研究查询,但是要获得活动的更高层次的摘要,您需要使用时间序列图。

时间序列图表

当谈到监控系统时,人们通常会想到时间序列图。它们对于理解系统的整体负载和发现随时间的变化非常重要。然而,他们通常不善于找到问题的根本原因。为此,您需要分析查询或生成临时报告来查看问题的发生。

当您查看时间序列图时,您需要考虑一些事情;否则,你可能会得出错误的结论,并在没有问题的时候宣布进入紧急状态。首先,您需要知道图中的指标意味着什么,就像本章前面讨论的 I/O 延迟意味着什么一样。其次,请记住,指标的变化本身并不意味着有问题。这只是意味着活动发生了变化。如果您开始执行更多的查询,因为您进入了一天或一年中的高峰期,数据库活动增加是很自然的事情,反之亦然。类似地,如果您实现了一个新特性,比如在应用的开始屏幕上添加一个元素,那么预计也会增加所执行的工作量。第三,注意不要只考虑单一的图形。如果你只看监测数据而不考虑其他数据,很容易得出错误的结论。

如果你看一下图 19-8 ,这里有一个数据库和系统利用率变化的一段时间内的几个时间序列图的例子。

img/484666_1_En_19_Fig8_HTML.jpg

图 19-8

时间序列图表

如果您查看这些图表,可以看到最上面的图表中的 CPU 利用率突然增加,并在 80%以上达到峰值。为什么会这样,这是一件坏事吗?数据库查询图显示每秒钟的语句数同时增加,InnoDB 行细节图中读取的行数也增加。所以 CPU 使用率很可能是由查询活动增加引起的。从那里,您可以转到查询分析器并调查哪些查询正在运行。

可以从图表中去掉几个其他的点。如果你看一下 x 轴,这个图表只覆盖了六分钟的数据。注意不要在很短的时间内得出结论,因为这可能不代表系统的真实状态。另外就是记得看数据的尺度。是的,CPU 使用率和 InnoDB 事务锁内存会突然增加,但这是从 0 开始的。系统有多少个 CPU?如果您有 96 个 CPU,那么使用一个 CPU 的 80%真的不算什么,但是如果您在一个单 CPU 虚拟机上,那么您的扩展空间就更少了。对于事务锁内存,如果将 y 轴考虑在内,您可以看到“峰值”仅为锁内存的 1 KiB——因此不必担心。

有时您需要调查正在发生的问题,在这种情况下,时间序列图和查询分析器可能无法提供您需要的信息。在这种情况下,您需要特别报告。

临时报告

MySQL Enterprise Monitor 中提供了几个特别报告。其他监控解决方案可能有类似的或其他的报告。这些报告类似于本章前面讨论的sys模式报告中的信息。通过监控解决方案访问即席报告的一个优点是,如果应用使用了所有可用的连接,您可以重用这些连接,并且它提供了一个图形用户界面来操作报告。

这些报告包括获取进程列表、锁信息、模式统计信息等等的能力。每个视图相当于一个sys模式视图。在编写本报告时,存在以下报告:

  • 表统计:该报告根据总延迟、提取的行数、更新的行数等显示每个表的使用量。它相当于schema_table_statistics视图。

  • 用户统计:该报告显示每个用户名的活动。它相当于user_summary视图。

  • 内存使用:该报告显示每种内存类型的内存使用情况。它相当于memory_global_by_current_bytes视图。

  • 数据库文件 I/O: 该报告显示磁盘 I/O 使用情况。报告有三个选项:按文件分组,相当于io_global_by_file_by_latency视图;按等待(I/O)类型分组,相当于io_global_by_wait_by_latency视图;按线程分组,相当于io_by_thread_by_latency视图。按等待类型分组增加了与 I/O 相关的时间序列图。

  • InnoDB 缓冲池:该报告显示了哪些数据存储在 InnoDB 缓冲池中。它基于innodb_buffer_page信息模式表。由于查询该报告的信息会有很大的开销,所以建议只在测试系统上使用该报告。

  • 进程:该报告显示了 MySQL 中当前存在的前台和后台线程。它使用与session视图相同的sys.processlist视图,除了它还包括后台线程。

  • 锁等待:该报告有两个选项。您可以获得 InnoDB 锁等待(innodb_lock_waits视图)或元数据锁(schema_table_lock_waits视图)的报告。

使用报告的原理是相同的,因此只显示两个示例。第一个在图 19-9 中,InnoDB 锁等待情况显示在锁等待报告中。

img/484666_1_En_19_Fig9_HTML.jpg

图 19-9

InnoDB 行锁等待报告

报告以分页模式显示行,您可以通过单击列标题来更改排序。更改顺序不会重新加载数据。如果需要重新加载数据,使用截图顶部的重新加载按钮。

您还可以操作报告中可用的列。右上角有一个按钮,用于选择您希望在报告中显示的列。图 19-10 中的截图显示了一个如何选择要显示的列的例子。

img/484666_1_En_19_Fig10_HTML.jpg

图 19-10

选择要包含在报告中的列

当您切换是否包括列时,报告会立即更新,而无需重新加载报告。这意味着对于像锁等待这样的间歇性问题,您可以在不丢失正在查看的数据的情况下操作报告。如果通过拖动列标题来更改列的顺序,情况也是如此。

有几个报告可以在标准的基于列的输出和树形视图之间进行选择。对于 InnoDB 缓冲池报告,treemap 视图是唯一支持的格式。treemap 输出使用面积基于值的矩形,因此如果一个矩形的面积是另一个矩形的两倍,这意味着值也是两倍大。这有助于可视化数据。

19-11 显示了数据库中表的总插入延迟的树形视图示例。在这个例子中,只有三个表具有足够大的总插入延迟部分来绘制矩形。

img/484666_1_En_19_Fig11_HTML.jpg

图 19-11

总插入延迟的树形视图

当您查看 treemap 视图时,您可以立即看到将数据插入到city表所花费的时间比其他表要多得多。

即席查询都处理报告执行时的状态。另一方面,查询分析器和时间序列图处理过去发生的事情。另一个显示过去发生了什么的工具是慢速查询日志。

慢速查询日志

慢速查询日志是一个可靠的旧工具,用于查找性能不佳的查询和调查 MySQL 中过去的问题。在性能模式有如此多的选项来查询速度慢、不使用索引或满足其他标准的语句的今天,这似乎是不必要的。然而,慢速查询日志有一个主要优点,那就是它是持久化的,所以即使在 MySQL 重新启动后,您也可以回过头来使用它。

Tip

默认情况下,不启用慢速查询日志。您可以使用slow_query_log选项启用和禁用它。该日志还可以在不重启 MySQL 的情况下动态启用和禁用。

使用慢速查询日志有两种基本模式。如果您知道问题是何时发生的,那么您可以检查日志中当时的慢速查询。一种情况是,由于锁问题,查询堆积如山,而您知道问题何时结束。然后,您可以在日志中找到该时间,并查找第一个执行时间足够长的查询,这是堆积问题的一部分;该查询很可能导致与在该时间点前后完成的其他一些查询的堆积。

另一种使用模式是使用mysqldumpslow实用程序来创建慢速查询的集合。这使查询正常化,类似于性能模式所做的,因此相似的查询将聚集它们的统计信息。这种模式非常适合查找可能导致系统繁忙的查询。

您可以使用-s选项选择聚合查询的排序依据。您可以使用总计数(c排序值)来查找执行次数最多的查询。查询执行得越频繁,优化查询就越有好处。您可以以类似的方式使用总执行时间(t)。如果用户抱怨响应时间慢,平均执行时间(at)对排序很有用。如果您怀疑某些查询因为缺少筛选条件而返回过多的行,您可以根据它们返回的行数对查询进行排序(r表示总行数,ar表示平均行数)。通常,将排序选项与-r选项结合起来以颠倒顺序,而将-t选项结合起来以只包含前 N 个查询是很有用的。这样,就可以更容易地关注影响最大的查询。

您还需要记住,默认情况下,慢速查询日志不会记录所有查询,因此您无法像使用性能模式那样深入了解工作负载。您需要通过更改long_query_time配置选项来调整认为查询缓慢的阈值。可以为会话更改该选项,因此,如果预期执行时间有显著变化,可以设置全局值以匹配大多数查询,并为执行偏离正常查询的连接更改每个会话的值。如果您需要调查涉及 DDL 语句的问题,您需要确保启用了log_slow_admin_statements选项。

Caution

慢速查询日志的开销比性能模式大。当只记录几个缓慢的查询时,开销通常可以忽略不计,但是如果记录许多查询,开销可能会很大。不要通过将long_query_time设置为 0 来记录所有查询,除非是在测试系统上或在一段短时间内。

您分析mysqldumpslow报告的方式与分析性能模式和sys模式的方式非常相似,因此它将留给读者一个练习,让读者从您的系统中生成报告,并使用它们来查找候选查询以进行进一步优化。

摘要

本章探讨了可用于查找要优化的候选查询的资源。还讨论了如何寻找资源利用率,以了解在什么时间有工作负载将系统推向极限。最需要关注的是当时正在运行的查询,尽管您应该注意那些做了过多工作的查询。

讨论从性能模式开始,并考虑了哪些信息是可用的以及如何使用它。特别是在寻找可能有性能问题的查询时,events_statements_summary_by_digest表是一座金矿。然而,你不应该仅仅局限于查询。您还应该考虑表和文件 I/O,以及查询是否会导致错误。这些错误可能包括锁等待超时和死锁。

sys模式提供了一系列现成的报告,可以用来查找信息。这些报告基于性能模式,但它们包括过滤器、排序和格式,使报告易于使用,特别是在调查问题时作为临时报告。还展示了如何使用语句性能分析器来创建在感兴趣的时间段内运行的查询的报告。

MySQL Workbench 提供了基于sys模式视图的性能报告和基于性能模式中的threads表的客户端连接报告。这些功能允许您通过图形用户界面制作临时报告,从而可以轻松地更改数据的排序和导航报告。

监控是保持系统良好运行和调查性能问题的最重要工具之一。MySQL Enterprise Monitor 被用作监控讨论的基础。特别是查询分析器功能对于确定哪些查询对系统影响最大非常有用,但它应该与时间序列图结合使用,以了解系统的整体状态。您还可以创建即席查询,例如,用于调查正在发生的问题。

最后,您不应该忘记慢速查询日志,它比性能模式语句表有优势,因为它保存了慢速查询的记录。这使得调查重启前发生的问题成为可能。慢速查询日志还记录查询完成的时间,这在用户报告系统在某个时间很慢时很有用。

当您找到一个想要进一步研究的查询时,您会怎么做?第一步是分析它,这将在下一章讨论。

二十、分析查询

在上一章中,你学习了如何找到优化的候选查询。现在是采取下一步措施的时候了——分析查询以确定它们为什么没有按预期执行。分析过程中的主要工具是EXPLAIN语句,它显示了优化器将使用的查询计划。与之相关的是优化器跟踪,它可以用来调查优化器为什么最终得到查询计划。另一种可能性是使用性能模式中的语句和阶段信息来查看存储过程或查询花费时间最多的地方。本章将讨论这三个主题。

EXPLAIN语句的讨论是本章最大的部分,分为四个部分:

  • 解释用法:EXPLAIN语句的基本用法。

  • 解释格式:可以查看查询计划的每种格式的详细信息。这包括用 MySQL Workbench 使用的EXPLAIN语句和可视化解释显式选择的两种格式。

  • 解释输出:对查询计划中可用信息的讨论。

  • 解释示例:使用EXPLAIN语句讨论返回数据的一些示例。

解释用法

EXPLAIN语句返回 MySQL 优化器将用于给定查询的查询计划的概述。它同时非常简单,也是查询调优中较为复杂的工具之一。这很简单,因为您只需在想要研究的查询之前添加EXPLAIN命令,这也很复杂,因为理解这些信息需要对 MySQL 及其优化器的工作原理有所了解。您可以对您明确指定的查询和另一个连接当前正在执行的查询使用EXPLAIN。本节介绍EXPLAIN语句的基本用法。

显式查询的用法

通过在查询前面添加EXPLAIN来为查询生成查询计划,可以选择添加FORMAT选项来指定是希望结果以传统的表格格式、使用 JSON 格式还是以树样式的格式返回。支持SELECTDELETEINSERTREPLACEUPDATE语句。查询没有被执行(但是参见下一小节关于EXPLAIN ANALYZE的异常),所以获取查询计划是安全的。

如果您需要分析诸如存储过程和存储函数之类的复合查询,您将需要首先将执行分割成单独的查询,然后对每个应该分析的查询使用EXPLAIN。确定存储程序中各个查询的一种方法是使用性能模式。本章后面将给出一个实现这一点的例子。

EXPLAIN最简单的用法就是用您想要分析的查询指定EXPLAIN:

mysql> EXPLAIN <query>;

在示例中,<query>是您想要分析的查询。使用不带FORMAT选项的EXPLAIN语句返回传统表格格式的结果。如果您想指定格式,您可以通过添加FORMAT=TRADITIONAL|JSON|TREE来完成:

mysql> EXPLAIN FORMAT=TRADITIONAL <query>

mysql> EXPLAIN FORMAT=JSON <query>

mysql> EXPLAIN FORMAT=TREE <query>

哪种格式是首选取决于您的需求。当您需要查询计划的概述、使用的索引以及有关查询计划的其他基本信息时,传统格式更容易使用。JSON 格式提供了更多的细节,对于应用来说更容易使用。例如,MySQL Workbench 中的 Visual Explain 使用 JSON 格式的输出。

树格式是最新的格式,在 MySQL 8.0.16 和更高版本中受支持。它需要使用 Volcano 迭代器执行器来执行查询,在编写本文时,并不是所有的查询都支持这个执行器。树格式的一个特殊用途是用于EXPLAIN ANALYZE语句。

解释分析

EXPLAIN ANALYZE语句 1 是 MySQL 8.0.18 中新增的,是使用树格式的标准EXPLAIN语句的扩展。关键的区别在于EXPLAIN ANALYZE实际上执行了查询,并且在执行的同时,收集了执行的统计数据。执行语句时,查询的输出被禁止,因此只返回查询计划和统计信息。像树输出格式一样,需要使用 Volcano 迭代器执行器。

Note

在撰写本文时,对 Volcano 迭代器执行器的要求将您可以使用的查询限制在了SELECT语句的子集上。预计支持的查询范围将随着时间的推移而增加。

EXPLAIN ANALYZE的用法与您已经看到的EXPLAIN语句非常相似:

mysql> EXPLAIN ANALYZE <query>

EXPLAIN ANALYZE的输出将在本章后面与树形输出一起讨论。

从本质上来说,EXPLAIN ANALYZE只适用于显式查询,因为需要从头到尾监控查询。另一方面,普通的EXPLAIN语句也可以用于正在进行的查询。

连接的用法

假设您正在调查一个性能很差的问题,并且您注意到有一个查询已经运行了几个小时。您知道这是不应该发生的,所以您想分析为什么查询如此缓慢。一种选择是复制查询并对其执行EXPLAIN。但是,这可能无法提供您需要的信息,因为索引统计信息可能在慢速查询启动后发生了更改,因此现在分析查询不会显示导致性能缓慢的实际查询计划。

更好的解决方案是请求用于慢速查询的实际查询计划。您可以使用EXPLAIN语句的EXPLAIN FOR CONNECTION变体来获得这个结果。如果您想尝试一下,您需要一个长时间运行的查询,例如:

SELECT * FROM world.city WHERE id = 130 + SLEEP(0.1);

这大约需要 420 秒(在world.city表中每行 0.1 秒)。

您将需要您想要调查的查询的连接 id,并将其作为参数传递给EXPLAIN。您可以从进程列表信息中获取连接 id。例如,如果您使用sys.session视图,连接 id 可以在conn_id列中找到:

mysql> SELECT conn_id, current_statement,
              statement_latency
         FROM sys.session
        WHERE command = 'Query'
        ORDER BY time
         DESC LIMIT 1\G
*************************** 1\. row ***************************
          conn_id: 8
current_statement: SELECT * FROM world.city WHERE id = 130 + SLEEP(0.1)
statement_latency: 4.22 m
1 row in set (0.0551 sec)

为了保持输出的简单,本例中只限于感兴趣的连接。查询的连接 id 是 8。您可以使用它来获取查询的执行计划,如下所示:

mysql> EXPLAIN FOR CONNECTION 8\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 4188
     filtered: 100
        Extra: Using where
1 row in set (0.0004 sec)

您可以选择添加所需的格式,方法与显式指定查询时相同。如果您使用的是不同于 MySQL Shell 的客户端,过滤后的列可能会显示100.00。在讨论输出的含义之前,有必要熟悉一下输出格式。

解释格式

当您需要检查查询计划时,可以在几种格式之间进行选择。选择哪一个,大部分取决于你的喜好。也就是说,JSON 格式确实比传统格式和树格式包含更多的信息。如果您喜欢查询计划的可视化表示,MySQL Workbench 中的 Visual Explain 是一个很好的选择。

本节将讨论每种格式,并显示以下查询的查询计划的输出:

SELECT ci.ID, ci.Name, ci.District,
       co.Name AS Country, ci.Population
  FROM world.city ci
       INNER JOIN
         (SELECT Code, Name
            FROM world.country
           WHERE Continent = 'Europe'
           ORDER BY SurfaceArea
           LIMIT 10
         ) co ON co.Code = ci.CountryCode
 ORDER BY ci.Population DESC
 LIMIT 5;

该查询查找欧洲按面积划分的十个最小国家中最大的五个城市,并按城市人口降序排列。选择这个查询的原因是它显示了各种输出格式如何表示子查询、排序和限制。本节将不讨论由EXPLAIN语句返回的信息;这将推迟到“EXPLAIN示例”部分。

Note

EXPLAIN语句的输出取决于优化器开关的设置、索引统计信息以及mysql.engine_costmysql.server_cost表中的值,因此您可能看不到与示例中相同的内容。示例输出已经使用了默认值和一个新加载的world sample 数据库,并在加载完成后对表执行了ANALYZE TABLE,它们已经在 MySQL Shell 中创建,默认情况下会自动获取警告(但是警告仅在讨论时包含在输出中)。如果您没有使用 MySQL Shell,您将必须执行SHOW WARNINGS来检索警告。

查询计划输出非常详细。为了更容易地比较输出,本节中的示例已经与本书的 GitHub 存储库中的文件explain_formats.txt中的查询结果相结合。对于树输出格式(包括对于EXPLAIN ANALYZE),在列名和查询计划之间增加了一个额外的新行,以使树层次结构显示得更清楚:

*************************** 1\. row ***************************
EXPLAIN:
-> Limit: 5 row(s)
    -> Sort: <temporary>.Population DESC, limit input to 5 row(s) per chunk

而不是:

*************************** 1\. row ***************************
EXPLAIN: -> Limit: 5 row(s)
    -> Sort: <temporary>.Population DESC, limit input to 5 row(s) per chunk

这一约定在整个章节中使用。

传统格式

当您在没有FORMAT参数或格式设置为TRADITIONAL的情况下执行EXPLAIN命令时,输出将作为一个表返回,就像您查询一个普通的表一样。当您需要查询计划的概述,并且是由数据库管理员或开发者来检查输出时,这很有用。

Tip

表的输出可能相当宽,特别是如果有许多分区、几个可能使用的索引或者几条额外的信息。当您调用mysql命令行客户端时,您可以通过使用--vertical选项请求获得垂直格式的输出,或者您可以使用\G终止查询。

输出中有 12 列。如果字段没有任何值,则使用NULL。每一列的含义将在下一节讨论。清单 20-1 显示了示例查询的传统输出。

mysql> EXPLAIN FORMAT=TRADITIONAL
       SELECT ci.ID, ci.Name, ci.District,
              co.Name AS Country, ci.Population
         FROM world.city ci
              INNER JOIN
                (SELECT Code, Name
                   FROM world.country
                  WHERE Continent = 'Europe'
                  ORDER BY SurfaceArea
                  LIMIT 10
                ) co ON co.Code = ci.CountryCode
        ORDER BY ci.Population DESC
        LIMIT 5\G
*************************** 1\. row ***************************
           id: 1
  select_type: PRIMARY
        table: <derived2>
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10
     filtered: 100
        Extra: Using temporary; Using filesort
*************************** 2\. row ***************************
           id: 1
  select_type: PRIMARY
        table: ci
   partitions: NULL
         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 3
          ref: co.Code
         rows: 18
     filtered: 100
        Extra: NULL
*************************** 3\. row ***************************
           id: 2
  select_type: DERIVED
        table: country
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 239
     filtered: 14.285715103149414
        Extra: Using where; Using filesort
3 rows in set, 1 warning (0.0089 sec)
Note (code 1003): /* select#1 */ select `world`.`ci`.`ID` AS `ID`,`world`.`ci`.`Name` AS `Name`,`world`.`ci`.`District` AS `District`,`co`.`Name` AS `Country`,`world`.`ci`.`Population` AS `Population` from `world`.`city` `ci` join (/* select#2 */ select `world`.`country`.`Code` AS `Code`,`world`.`country`.`Name` AS `Name` from `world`.`country` where (`world`.`country`.`Continent` = 'Europe') order by `world`.`country`.`SurfaceArea` limit 10) `co` where (`world`.`ci`.`CountryCode` = `co`.`Code`) order by `world`.`ci`.`Population` desc limit 5

Listing 20-1Example of the traditional EXPLAIN output

注意第一个表是如何被称为<derived 2>的。这是针对country表上的子查询,数字 2 指的是执行子查询的id列的值。Extra列包含诸如查询是否使用临时表和文件排序之类的信息。输出的最后是优化器重写后的查询。在许多情况下,更改并不多,但在某些情况下,优化器可能会对查询进行重大更改。在重写的查询中,注意如何使用一个注释,例如/* select#1 */,来显示哪个id值用于查询的这一部分。在重写的查询中可能有其他提示来告诉查询是如何执行的。重写后的查询由SHOW WARNINGS作为注释返回(默认情况下,由 MySQL Shell 隐式执行)。

输出可能看起来非常庞大,并且很难理解如何使用这些信息来分析查询。一旦讨论了其他输出格式、选择类型和连接类型的详细信息以及额外信息,就会有一些使用EXPLAIN信息的例子。

如果您想以编程方式分析查询计划,应该怎么做?您可以像处理普通的SELECT查询一样处理EXPLAIN输出——或者您可以请求包含一些附加信息的 JSON 格式的信息。

JSON 格式

从 MySQL 5.6 开始,可以使用 JSON 格式请求EXPLAIN输出。与传统的表格格式相比,JSON 格式的一个优点是,JSON 格式增加的灵活性被用来以更符合逻辑的方式对信息进行分组。

JSON 输出的基本概念是一个查询块。查询块定义了查询的一部分,并且可以依次包括它自己的查询块。这允许 MySQL 将查询执行的细节指定给它们所属的查询块。从清单 20-2 中显示的示例查询的输出中也可以看出这一点。

mysql> EXPLAIN FORMAT=JSON
       SELECT ci.ID, ci.Name, ci.District,
              co.Name AS Country, ci.Population
         FROM world.city ci
              INNER JOIN
                (SELECT Code, Name
                   FROM world.country
                  WHERE Continent = 'Europe'
                  ORDER BY SurfaceArea
                  LIMIT 10
                ) co ON co.Code = ci.CountryCode
        ORDER BY ci.Population DESC
        LIMIT 5\G
*************************** 1\. row ***************************
EXPLAIN: {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "247.32"
    },
    "ordering_operation": {
      "using_temporary_table": true,
      "using_filesort": true,
      "cost_info": {
        "sort_cost": "180.52"
      },
      "nested_loop": [
        {
          "table": {
            "table_name": "co",
            "access_type": "ALL",
            "rows_examined_per_scan": 10,
            "rows_produced_per_join": 10,
            "filtered": "100.00",
            "cost_info": {
              "read_cost": "2.63",
              "eval_cost": "1.00",
              "prefix_cost": "3.63",
              "data_read_per_join": "640"
            },

            "used_columns": [
              "Code",
              "Name"
            ],
            "materialized_from_subquery": {
              "using_temporary_table": true,
              "dependent": false,
              "cacheable": true,
              "query_block": {
                "select_id": 2,
                "cost_info": {
                  "query_cost": "25.40"
                },
                "ordering_operation": {
                  "using_filesort": true,
                  "table": {
                    "table_name": "country",
                    "access_type": "ALL",
                    "rows_examined_per_scan": 239,
                    "rows_produced_per_join": 34,
                    "filtered": "14.29",
                    "cost_info": {
                      "read_cost": "21.99",
                      "eval_cost": "3.41",
                      "prefix_cost": "25.40",
                      "data_read_per_join": "8K"
                    },
                    "used_columns": [
                      "Code",
                      "Name",
                      "Continent",
                      "SurfaceArea"
                    ],
                    "attached_condition": "(`world`.`country`.`Continent` = 'Europe')"
                  }
                }
              }
            }
          }
        },
        {
          "table": {
            "table_name": "ci",
            "access_type": "ref",
            "possible_keys": [
              "CountryCode"
            ],
            "key": "CountryCode",
            "used_key_parts": [
              "CountryCode"
            ],
            "key_length": "3",
            "ref": [
              "co.Code"
            ],
            "rows_examined_per_scan": 18,
            "rows_produced_per_join": 180,
            "filtered": "100.00",
            "cost_info": {
              "read_cost": "45.13",
              "eval_cost": "18.05",
              "prefix_cost": "66.81",
              "data_read_per_join": "12K"
            },
            "used_columns": [
              "ID",
              "Name",
              "CountryCode",
              "District",
              "Population"
            ]

          }
        }
      ]
    }
  }
}
1 row in set, 1 warning (0.0061 sec)

Listing 20-2Example of the JSON EXPLAIN output

正如您所看到的,输出非常冗长,但是这种结构使得查看哪些信息属于一起以及查询的各个部分如何相互关联变得相对容易。在这个例子中,有一个嵌套循环,它包括两个表(coci)。co表本身包含一个新的查询块,它是使用country表的物化子查询。

JSON 格式还包括附加信息,如cost_info元素中每个零件的估计成本。成本信息可以用来查看优化器认为查询中最昂贵的部分在哪里。例如,如果您发现查询的一部分成本非常高,但是您对数据的了解意味着您知道它应该是便宜的,这可能表明索引统计信息不是最新的或者需要直方图。

使用 JSON 格式输出的最大问题是有如此多的信息和如此多的输出行。解决这个问题的一个非常方便的方法是使用 MySQL Workbench 中的可视化解释特性,在讨论了树格式的输出后会涉及到这个特性。

树形格式

树格式着重于根据查询各部分之间的关系以及各部分的执行顺序来描述查询是如何执行的。从这个意义上说,它听起来可能类似于 JSON 输出;不过树形格式更容易阅读,细节也没那么多。树格式是作为 MySQL 8.0.16 中的一个实验性特性引入的,它依赖于 Volcano 迭代器执行器。从 MySQL 8.0.18 开始,树格式也被用于EXPLAIN ANALYZER特性。

清单 20-3 显示了示例查询使用树格式的输出。此输出是非分析版本。对于同一个查询,EXPLAIN ANALYZE的输出示例将很快显示,因此您可以看到不同之处。

mysql> EXPLAIN FORMAT=TREE
       SELECT ci.ID, ci.Name, ci.District,
              co.Name AS Country, ci.Population
         FROM world.city ci
              INNER JOIN
                (SELECT Code, Name
                   FROM world.country
                  WHERE Continent = 'Europe'
                  ORDER BY SurfaceArea
                  LIMIT 10
                ) co ON co.Code = ci.CountryCode
        ORDER BY ci.Population DESC
        LIMIT 5\G
*************************** 1\. row ***************************
EXPLAIN:
-> Limit: 5 row(s)
    -> Sort: <temporary>.Population DESC, limit input to 5 row(s) per chunk
        -> Stream results
            -> Nested loop inner join
                -> Table scan on co
                    -> Materialize
                        -> Limit: 10 row(s)
                            -> Sort: country.SurfaceArea, limit input to 10 row(s) per chunk  (cost=25.40 rows=239)
                                -> Filter: (country.Continent = 'Europe')
                                    -> Table scan on country
                -> Index lookup on ci using CountryCode (CountryCode=co.`Code`)  (cost=4.69 rows=18)

Listing 20-3Example of the tree EXPLAIN output

输出很好地概述了查询是如何执行的。在某种程度上,从里到外阅读输出可以更容易理解执行。对于嵌套循环,您有两个表,其中第一个是对co的表扫描(缩进已经减少):

-> Table scan on co
  -> Materialize
    -> Limit: 10 row(s)
      -> Sort: country.SurfaceArea, limit input to 10 row(s) per chunk  (cost=25.40 rows=239)
        -> Filter: (country.Continent = 'Europe')
           -> Table scan on country

在这里,您可以看到co表是如何通过首先对country表进行表扫描,然后对洲应用过滤器,然后基于表面积排序,然后将结果限制为十行而创建的物化子查询。

嵌套循环的第二部分更简单,因为它只包括使用CountryCode索引对ci表(city表)进行索引查找:

-> Index lookup on ci using CountryCode (CountryCode=co.`Code`)  (cost=4.69 rows=18)

当使用内部联接解决了嵌套循环时,结果被流式传输(即,未具体化)到排序,并返回前五行:

-> Limit: 5 row(s)
    -> Sort: <temporary>.Population DESC, limit input to 5 row(s) per chunk
        -> Stream results
            -> Nested loop inner join

虽然这并没有像 JSON 输出那样详细,但是它仍然包含了很多关于查询计划的信息。这包括每个表的估计成本和估计行数。例如,从国家表面区域的分类步骤

(cost=25.40 rows=239)

一个好问题是,这与查询表的实际成本有什么关系。为此,您可以使用EXPLAIN ANALYZE语句。清单 20-4 展示了为示例查询生成的输出示例。

mysql> EXPLAIN ANALYZE
       SELECT ci.ID, ci.Name, ci.District,
              co.Name AS Country, ci.Population
         FROM world.city ci
              INNER JOIN
                (SELECT Code, Name
                   FROM world.country
                  WHERE Continent = 'Europe'
                  ORDER BY SurfaceArea
                  LIMIT 10
                ) co ON co.Code = ci.CountryCode
        ORDER BY ci.Population DESC
        LIMIT 5\G
*************************** 1\. row ***************************
EXPLAIN: -> Limit: 5 row(s)  (actual time=34.492..34.494 rows=5 loops=1)
    -> Sort: <temporary>.Population DESC, limit input to 5 row(s) per chunk  (actual time=34.491..34.492 rows=5 loops=1)
        -> Stream results  (actual time=34.371..34.471 rows=15 loops=1)
            -> Nested loop inner join  (actual time=34.370..34.466 rows=15 loops=1)
                -> Table scan on co  (actual time=0.001..0.003 rows=10 loops=1)
                    -> Materialize  (actual time=34.327..34.330 rows=10 loops=1)
                        -> Limit: 10 row(s)  (actual time=34.297..34.301 rows=10 loops=1)
                            -> Sort: country.SurfaceArea, limit input to 10 row(s) per chunk  (cost=25.40 rows=239) (actual time=34.297..34.298 rows=10 loops=1)
                                -> Filter: (world.country.Continent = 'Europe')  (actual time=0.063..0.201 rows=46 loops=1)
                                    -> Table scan on country  (actual time=0.057..0.166 rows=239 loops=1)
                -> Index lookup on ci using CountryCode (CountryCode=co.`Code`)  (cost=4.69 rows=18) (actual time=0.012..0.013 rows=2 loops=10)

1 row in set (0.0353 sec)

Listing 20-4Example of the EXPLAIN ANALYZE output

这是与FORMAT=TREE相同的树输出,除了每个步骤都有关于性能的信息。如果您查看ci表的行,您可以看到有两个计时,行数和循环数(重新格式化以提高可读性):

-> Index lookup on ci using CountryCode
     (CountryCode=co.`Code`)
     (cost=4.69 rows=18)
     (actual time=0.012..0.013 rows=2 loops=10)

这里,对于预期的 18 行(每个循环),估计成本是 4.69。实际统计数据显示,第一行是在 0.012 毫秒后读取的,所有行都是在 0.013 毫秒后读取的。共有 10 个循环(10 个国家各一个),每个循环平均获取两行,总共 20 行。因此,在这种情况下,估计值不是很准确(因为查询只选择了小国家)。

Note

EXPLAIN ANALYZE的行数是每个循环的平均值,四舍五入为整数。使用rows=2loops=10,,这意味着读取的总行数在 15 到 24 之间。在这个具体的例子中,使用性能模式中的table_io_waits_summary_by_table表显示读取了 15 行。

如果在 MySQL 8.0.18 和更高版本中有使用散列连接的查询,您将需要使用树格式的输出来确认何时使用散列连接算法。例如,如果使用散列连接将city表与country表连接

mysql> EXPLAIN FORMAT=TREE
       SELECT CountryCode, country.Name AS Country,
              city.Name AS City, city.District
         FROM world.country IGNORE INDEX (Primary)
              INNER JOIN world.city IGNORE INDEX (CountryCode)
                      ON city.CountryCode = country.Code\G
*************************** 1\. row ***************************
EXPLAIN:
-> Inner hash join (world.city.CountryCode = world.country.`Code`)  (cost=100125.16 rows=4314)
    -> Table scan on city  (cost=0.04 rows=4188)
    -> Hash
        -> Table scan on country  (cost=25.40 rows=239)

1 row in set (0.0005 sec)

注意连接是如何成为一个Inner hash join的,以及对country表的表扫描是如何使用散列的。

到目前为止,所有的例子都使用了基于文本的输出。特别是 JSON 格式的输出可能很难用来概括查询计划。对于视觉解释是一个更好选择。

视觉解释

可视化解释特性是 MySQL Workbench 的一部分,它通过将 JSON 格式的查询计划转换成图形表示来工作。在第 16 章中,当你研究向sakila.film表添加直方图的效果时,你已经使用了可视化解释。

如图 20-1 所示,点击闪电符号前的放大镜图标,即可得到直观的解释图。

img/484666_1_En_20_Fig1_HTML.jpg

图 20-1

获取查询的可视化解释图

如果执行查询需要很长时间或者查询修改了数据,这是生成查询计划的一种特别有用的方法。如果已经执行了查询,也可以点击结果网格右侧的执行计划图标,如图 20-2 所示。

img/484666_1_En_20_Fig2_HTML.jpg

图 20-2

从结果网格窗口中检索执行计划

可视化解释图创建为流程图,每个查询块和表有一个矩形。数据的处理使用其他形状来描述,例如连接的菱形。图 20-3 显示了视觉解释中使用的每个基本形状的示例。

img/484666_1_En_20_Fig3_HTML.jpg

图 20-3

视觉解释中使用的形状示例

在图中,查询块是灰色的,而表的两个示例(子查询中的单行查找和全表扫描)分别是蓝色和红色的。例如,在联合的情况下也使用灰色块。表格框下方的文本以标准文本显示表格名称或别名,以粗体文本显示索引名称。圆角矩形表示对行的操作,如排序、分组、不同操作等。

左上方的数字是该表、操作或查询块的相对成本。表和联接右上角的数字是估计要结转的行数。操作的颜色用于显示应用该操作的成本。表还使用基于表访问类型的颜色,主要是为了对相似的访问类型进行分组,其次是为了指示访问类型的开销。图 20-4 显示了颜色和成本之间的关系,使用了从视觉解释中估算的成本。

img/484666_1_En_20_Fig4_HTML.png

图 20-4

操作和表访问的相对成本的颜色代码

蓝色(1)最便宜;绿色(2)、黄色(3)和橙色(4)代表中低成本;最昂贵的访问类型和操作是红色的,代表高(5)到非常高(6)的成本。

颜色组之间有很多重叠。每个成本估算都考虑了一个“平均”用例,因此成本估算不应该被视为绝对真理。查询优化是复杂的,有时对于一个特定的查询,一种方法通常比另一种方法更便宜,但结果却能提供更好的性能。

Note

这本书的作者曾经决定改进一个查询,它的查询计划看起来很糟糕:内部临时表、文件排序、糟糕的访问方法等等。在长时间重写查询并验证表是否有正确的索引后,查询计划看起来很漂亮——但结果是查询的性能比原来的差。教训:总是在优化后测试查询性能,不要依赖于访问方法和操作的成本是否在纸面上有所提高。

对于表,成本与访问类型相关联,这是传统的EXPLAIN输出中的type列和 JSON 格式输出中的access_type字段的值。图 20-5 显示了 Visual Explain 如何表示目前存在的 12 种访问类型。访问类型的解释将推迟到下一节。

img/484666_1_En_20_Fig5_HTML.jpg

图 20-5

可视化说明中显示的访问类型

此外,Visual Explain 有一个“未知”访问类型,颜色为黑色,以防遇到未知的访问类型。根据访问类型的颜色和大概开销,访问类型从左到右,然后从上到下排序。

20-6 将所有这些放在一起,以显示本节中一直使用的示例查询的查询计划。

img/484666_1_En_20_Fig6_HTML.jpg

图 20-6

示例查询的可视化解释图

你从左下到右,然后向上阅读图表。因此,该图显示了首先对country表执行全表扫描的子查询,然后对物化的co表执行另一个全表扫描,并使用非唯一索引查找将行连接到ci ( city)表上。最后,使用临时表和文件排序对结果进行排序。

如果您想要比图表最初显示的更多的细节,您可以将鼠标悬停在您想要了解更多的查询计划部分上。图 20-7 显示了ci表包含的细节示例。

img/484666_1_En_20_Fig7_HTML.jpg

图 20-7

可视化说明中配置项表的详细信息

弹出框不仅显示了 JSON 输出中可用的其他细节,还提供了帮助理解数据含义的提示。所有这些都意味着 Visual Explain 是通过查询计划开始分析查询的一个很好的方式。随着经验的积累,您可能会更喜欢使用基于文本的输出,特别是如果您更喜欢使用 shell,但是不要因为您认为使用基于文本的输出格式更好就放弃 Visual Explain。即使对于专家来说,Visual Explain 也是理解查询如何执行的一个很好的工具。

希望讨论输出格式已经让你知道了EXPLAIN能给你什么信息。然而,为了充分理解和利用它,有必要更深入地了解信息的含义。

解释输出

在 explain 输出中有很多可用的信息,因此值得深入研究这些信息的含义。本节首先概述了传统输出和 JSON 格式输出中包含的字段;然后将更详细地介绍选择类型和访问类型以及额外信息。

解释字段

在工作中建设性地使用EXPLAIN语句来改进查询的第一步是了解哪些信息是可用的。这些信息包括对查询部分进行引用的 id、可用于查询的索引的详细信息、使用的索引以及应用的优化器功能。

如果你在第一次阅读定义后不能回忆起所有的细节,不要担心。大多数字段都是不言自明的,因此您可以对它们所代表的数据进行合理的猜测。当您自己分析一些查询时,您也会很快熟悉这些信息。表 20-1 列出了传统格式中包含的所有字段以及 JSON 格式中的一些常见字段。

表 20-1

解释字段

|

传统的

|

数据

|

描述

|
| --- | --- | --- |
| id | select_id | 一个数字标识符,显示表或子查询属于查询的哪一部分。顶层表有id = 1,第一个子查询有id = 2,依此类推。在联合的情况下,对于表示联合结果聚合的行,id 将是NULL,表值设置为<unionM,N>(另请参见table列)。 |
| select_type |   | 这显示了该表将如何包含在整个语句中。已知的选择类型将在“选择类型”一节中讨论。对于 JSON 格式,选择类型由 JSON 文档的结构和来自字段(如dependentcacheable)所隐含。 |
|   | dependent | 它是否是从属子查询,也就是说,它取决于查询的外部部分。 |
|   | cacheable | 子查询的结果是可以缓存,还是必须对外部查询中的每一行进行重新计算。 |
| table | table_name | 表或子查询的名称。如果指定了别名,则使用该别名。这确保了每个表名对于id列的给定值是唯一的。特殊情况包括联合、派生表和物化子查询,其中表名分别是<unionM,N><derivedN><subqueryN>,其中 N 和 M 指的是查询计划前面部分的 id。 |
| partitions | partitions | 将包含在查询中的分区。您可以使用它来确定是否按预期应用了分区修剪。 |
| type | access_type | 数据是如何被访问的。这显示了优化器是如何决定限制表中检查的行数的。这些类型将在“访问类型”一节中讨论。 |
| possible_keys | possible_keys | 表中使用的候选索引列表。使用模式<auto_key0>的键名意味着可以使用自动生成的索引。 |
| key | key | 为表选择的索引。使用模式<auto_key0>的键名意味着使用了自动生成的索引。 |
| key_len | key_length | 索引中使用的字节数。对于由多个列组成的索引,优化器可能只能使用列的子集。在这种情况下,可以使用键长度来确定有多少索引对该查询有用。如果索引中的列支持NULL值,那么与使用NOT NULL列的情况相比,长度会增加 1 个字节。 |
|   | used_key_parts | 索引中使用的列。 |
| ref | ref | 过滤所针对的对象。例如,这可以是像<table>.<column> = 'abc'这样的条件的常数,或者是另一个表中的列名(如果是连接的话)。 |
| rows | rows_examined_per_scan | 包含该表的结果的估计行数。对于联接到早期表的表,它是每次联接估计找到的行数。一种特殊情况是当引用是表上的主键或唯一键时,在这种情况下,行估计正好是 1。 |
|   | rows_produced_per_join | 联接产生的估计行数。实际上,预期的循环数乘以rows_examined_per_scan和被过滤的行的百分比。 |
| filtered | filtered | 这是对将包括多少检查行的估计。该值以百分比表示,因此对于值 100.00,将返回所有检查过的行。100.00 是最佳值,最差值为 0。注意:传统格式中的值的舍入取决于您使用的客户端。例如,MySQL Shell 将返回100,而mysql命令行客户端返回100.00。 |
|   | cost_info | 一个 JSON 对象,包含查询部分的成本明细。 |
| Extra |   | 关于优化器决策的附加信息。这可以包括有关所使用的排序算法、是否使用覆盖索引等信息。最常见的支持值将在“额外信息”一节中讨论 |
|   | message | 在 JSON 输出中没有专用字段的传统输出的Extra列中的信息。比如Impossible WHERE。 |
|   | using_filesort | 是否使用文件排序。 |
|   | using_index | 是否使用覆盖索引。 |
|   | using_temporary_table | 子查询或排序等操作是否需要内部临时表。 |
|   | attached_condition | 与查询部分相关联的WHERE子句。 |
|   | used_columns | 表中所需的列。这有助于了解您是否即将能够使用覆盖索引。 |

有些信息在 JSON 格式中初看起来是缺失的,因为该字段只存在于传统格式中。事实并非如此;相反,信息可以通过其他方式获得,例如,Extra中的几条消息在 JSON 格式中有自己的字段。其他Extra消息使用message字段。JSON 输出表中没有包括的一些字段将在本节后面讨论Extra列中的信息时提到。

一般来说,JSON 格式输出中的布尔字段会被省略,如果值是false;一个例外是cacheable,因为与可缓存的情况相比,不可缓存的子查询或联合表示更高的成本。

对于 JSON 输出,也有用于对操作信息进行分组的字段。操作范围从访问表到将几个操作组合在一起的复杂操作。一些常见操作及其触发示例如下

  • table : 访问表。这是最低级别的操作。

  • query_block : 最高级别的概念,对于传统格式,一个查询块对应一个id。所有查询都至少有一个查询块。

  • nested_loop : 一次加入操作。

  • grouping_operation : 例如,由一个GROUP BY子句产生的操作。

  • ordering_operation :例如的操作,结果为一个ORDER BY子句。

  • duplicates_removal :以操作为例,使用DISTINCT关键字时产生。

  • windowing : 使用窗口函数产生的操作。

  • materialized_from_subquery : 执行子查询并物化结果。

  • attached_subqueries : 附加到查询其余部分的子查询。例如,对于IN子句中的子查询,这种情况会发生在像IN (SELECT ...)这样的子句中。

  • union_result : 用于使用UNION组合两个或多个查询结果的查询。在union_result块中,有一个query_specifications块,其中包含联合中每个查询的定义。

20-1 中的字段和复杂操作的列表对于 JSON 格式来说并不全面,但它应该能让您对可用的信息有一个很好的了解。一般来说,字段名本身就携带信息,结合字段名出现的上下文通常就足以理解字段的含义。不过,有些字段的值值得多加注意——从选择类型开始。

选择类型

选择类型显示查询的每个部分是哪种类型的查询块。在这个上下文中,查询的一部分可以包括几个表。例如,如果有一个简单的查询连接一列表,但不使用子查询之类的结构,那么所有的表都将位于查询的相同(且唯一)部分。查询的每个部分都有自己的 JSON 输出中的select_id)。

有几种选择类型。对于它们中的大多数,JSON 输出中没有直接字段;但是,可以从结构和其他一些字段中派生出选择类型。表 20-2 显示了当前存在的选择类型,并提示了如何从 JSON 输出中派生类型。在表中,选择类型列的值是传统输出格式中用于select_type列的值。

表 20-2

解释选择类型

|

选择类型

|

数据

|

描述

|
| --- | --- | --- |
| SIMPLE |   | 对于不使用派生表、子查询、联合等的SELECT查询。 |
| PRIMARY |   | 对于使用子查询或联合的查询,主要部分是最外面的部分。 |
| INSERT |   | 对于INSERT报表。 |
| DELETE |   | 对于DELETE报表。 |
| UPDATE |   | 对于UPDATE报表。 |
| REPLACE |   | 对于REPLACE报表。 |
| UNION |   | 对于 union 语句,第二个或后面的SELECT语句。 |
| DEPENDENT UNION | dependent=true | 对于 union 语句,它依赖于外部查询的第二个或后面的SELECT语句。 |
| UNION RESULT | union_result | 聚合 union SELECT语句结果的查询部分。 |
| SUBQUERY |   | For 子查询中的SELECT语句。 |
| DEPENDENT SUBQUERY | dependent=true | 对于从属子查询,第一个SELECT语句。 |
| DERIVED |   | 派生表–通过查询创建的表,但其行为类似于普通表。 |
| DEPENDENT DERIVED | dependent=true | 依赖于另一个表的派生表。 |
| MATERIALIZED | materialized_from_subquery | 物化子查询。 |
| UNCACHEABLE SUBQUERY | cacheable=false | 一种子查询,必须对外部查询中的每一行的结果进行评估。 |
| UNCACHEABLE UNION | cacheable=false | 对于 union 语句,是不可缓存子查询的一部分的第二个或后面的SELECT语句。 |

一些选择类型可以作为信息,以便更容易理解您正在查看查询的哪一部分。例如,这包括PRIMARYUNION。但是,一些选择类型表明这是查询的一个开销很大的部分。这尤其适用于不可缓存的类型。依赖类型还意味着优化器在决定在执行计划中的何处添加表时灵活性较低。如果您的查询速度很慢,并且看到了不可缓存的或相关的部分,那么考虑一下是否可以重写这些部分或将查询分成两部分是值得的。

另一条重要的信息是如何访问表。

访问类型

在讨论可视化解释时,已经遇到了表访问类型。它们显示查询是否使用索引、扫描等方式访问表。由于与每种访问类型相关的成本变化很大,所以在EXPLAIN输出中寻找一个重要的值来确定处理查询的哪些部分以提高性能也是很重要的。

本小节的其余部分总结了 MySQL 中的访问类型。标题是传统格式的type列中使用的值。对于每种访问类型,都有一个使用该访问类型的示例。

系统

系统访问类型用于只有一行的表。这意味着该表可以被视为一个常数。视觉解释成本、消息和颜色如下:

  • 成本:非常低

  • 消息:单行(系统常数)

  • 颜色:蓝色

使用system访问类型的查询示例如下

SELECT *
  FROM (SELECT 1) my_table

system访问类型是const访问类型的特例。

常数

例如,当对主键的单个值或唯一索引进行筛选时,表最多匹配一行。视觉解释成本、消息和颜色如下:

  • 成本:非常低

  • 消息:单行(常量)

  • 颜色:蓝色

使用const访问类型的查询示例如下

SELECT *
  FROM world.city
 WHERE ID = 130;

eq_ref

该表是连接中的右侧表,其中表的条件是主键或非空唯一索引。视觉解释成本、消息和颜色如下:

  • 成本:

  • 消息:唯一键查找

  • 颜色:绿色

使用eq_ref访问类型的查询示例如下

SELECT *
  FROM world.city
       STRAIGHT_JOIN world.country
             ON CountryCode = Code;

eq_ref访问类型是ref访问类型的一个特例,每次查找只能返回一行。

裁判员

该表由非唯一的辅助索引筛选。视觉解释成本、消息和颜色如下:

  • 成本:中低

  • 消息:非唯一关键字查找

  • 颜色:绿色

使用ref访问类型的查询示例如下

SELECT *
  FROM world.city
 WHERE CountryCode = 'AUS';

ref_or_null

ref相同,但过滤后的列也可能是NULL。视觉解释成本、消息和颜色如下:

  • 成本:中低

  • 消息:关键字查找+获取空值

  • 颜色:绿色

使用ref_or_null访问类型的查询示例如下

SELECT *
  FROM sakila.payment
 WHERE rental_id = 1
       OR rental_id IS NULL;

索引 _ 合并

优化器选择两个或更多索引的组合来解析在不同索引的列之间包含一个ORAND的过滤器。视觉解释成本、消息和颜色如下:

  • 费用:中等

  • 消息:索引合并

  • 颜色:绿色

使用index_merge访问类型的查询示例如下

SELECT *
  FROM sakila.payment
 WHERE rental_id = 1
       OR customer_id = 5;

虽然成本被列为中等,但更常见的严重性能问题之一是查询通常使用单个索引或进行全表扫描,索引统计变得不准确,因此优化器选择了索引合并。如果使用索引合并的查询性能很差,请尝试告诉优化器忽略索引合并优化或使用的索引,看看这是否有助于或分析表来更新索引统计信息。或者,可以将查询重写为两个查询的并集,每个查询使用过滤器的一部分。这方面的一个例子将在第 24 章中给出。

全文

优化器选择全文索引来筛选表。视觉解释成本、消息和颜色如下:

  • 成本:

  • 消息:全文索引搜索

  • 颜色:黄色

使用fulltext访问类型的查询示例如下:

SELECT *
  FROM sakila.film_text
 WHERE MATCH(title, description)
       AGAINST ('Circus' IN BOOLEAN MODE);

唯一子查询

用于IN运算符内的子查询,其中子查询返回主键或唯一索引的值。在 MySQL 8 中,这些查询通常由优化器重写,因此unique_subquery需要禁用materializationsemijoin优化器开关。视觉解释成本、消息和颜色如下:

  • 成本:

  • 消息:查询子查询表的唯一键

  • 颜色:橙色

使用unique_subquery访问类型的查询示例如下

SET optimizer_switch = 'materialization=off,semijoin=off';

SELECT *
  FROM world.city
 WHERE CountryCode IN (
         SELECT Code
           FROM world.country
          WHERE Continent = 'Oceania');

SET optimizer_switch = 'materialization=on,semijoin=on';

对于使用主索引或惟一索引的情况,unique_subquery访问方法是index_subquery方法的特例。

索引子查询

用于IN运算符内的子查询,其中子查询返回辅助非唯一索引的值。在 MySQL 8 中,这些查询通常由优化器重写,因此unique_subquery需要禁用materializationsemijoin优化器开关。视觉解释成本、消息和颜色如下:

  • 成本:

  • 消息:查询子查询表的非唯一关键字

  • 颜色:橙色

使用index_subquery访问类型的查询示例如下

SET optimizer_switch = 'materialization=off,semijoin=off';

SELECT *
  FROM world.country
 WHERE Code IN (
         SELECT CountryCode
           FROM world.city
          WHERE Name = 'Sydney');

SET optimizer_switch = 'materialization=on,semijoin=on';

范围

当索引用于按顺序或按组查找多个值时,使用范围访问类型。它既用于像ID BETWEEN 1 AND 10这样的显式范围,也用于IN子句,或者用于同一列中由OR分隔的几个条件。视觉解释成本、消息和颜色如下:

  • 费用:中等

  • 消息:步进范围扫描

  • 颜色:橙色

使用range访问类型的查询示例如下

SELECT *
  FROM world.city
 WHERE ID IN (130, 3805);

使用范围访问的成本很大程度上取决于范围中包括多少行。在一个极端情况下,范围扫描只匹配使用主键的单个行,因此成本非常低。在另一种极端情况下,范围扫描包括使用二级索引的大部分表,在这种情况下,执行全表扫描可能会更便宜。

range访问类型与index访问类型相关,区别在于需要部分扫描还是完全扫描。

索引

优化器已选择执行完全索引扫描。这可以结合使用覆盖索引来选择。视觉解释成本、消息和颜色如下:

  • 成本:

  • 消息:全索引扫描

  • 颜色:红色

使用index访问类型的查询示例如下

SELECT ID, CountryCode
  FROM world.city;

因为索引扫描需要使用主键进行第二次查找,所以它可能会变得非常昂贵,除非该索引是查询的覆盖索引,以至于执行全表扫描会更便宜。

全部

最基本的访问类型是扫描表的所有行。它也是最昂贵的访问类型,因此该类型全部用大写字母编写。视觉解释成本、消息和颜色如下:

  • 成本:非常高

  • 消息:全表扫描

  • 颜色:红色

使用ALL访问类型的查询示例如下

SELECT *
  FROM world.city;

如果使用全表扫描看到第一个表以外的表,通常是一个红色标志,表示该表上有缺失条件或者没有可用的索引。对于第一个表来说,ALL是否是一个合理的访问类型取决于查询需要多少表;需要的表格部分越大,全表扫描就越合理。

Note

虽然全表扫描被认为是开销最大的访问类型,但它和主键查找是每行开销最小的访问类型。因此,如果您确实需要访问表的大部分或全部,全表扫描是读取行的最有效方式。

关于访问类型的讨论到此结束。在本章稍后查看EXPLAIN示例时,以及在本书稍后查看优化查询时,将再次引用访问类型,例如,在第 24 章中。同时,我们来看看Extra一栏的信息。

额外信息

传统输出格式中的Extra列是一个无所不包的信息箱,它没有自己的列。当引入 JSON 格式时,没有理由保留它,因为引入额外的字段很容易,而且没有必要为每个输出包含所有的字段。因此,JSON 格式没有一个Extra字段,而是有一系列字段。一些剩余的消息被留在了一个普通的message字段中。

Note

在某些情况下,Extra列中的可用信息依赖于存储引擎,或者仅在极少数情况下使用。此讨论将仅涵盖最常遇到的消息。有关消息的完整列表,请参考位于 https://dev.mysql.com/doc/refman/en/explain-output.html#explain-extra-information 的 MySQL 参考手册。

一些更常见的消息包括

  • Using index : 当使用覆盖索引时。对于 JSON 格式,using_index字段被设置为true

  • Using index condition : 当一个索引用于测试是否需要读取整行时。例如,当索引列上存在范围条件时,就会使用这种方法。对于 JSON 格式,index_condition字段设置了过滤条件。

  • Using where :WHERE子句应用于表而不使用索引时。这可能表明表上的索引不是最佳的。在 JSON 格式中,attached_condition字段设置了过滤条件。

  • Using index for group-by : 当松散索引扫描用于解析GROUP BYDISTINCT时。在 JSON 格式中,using_index_for_group_by字段被设置为true

  • Using join buffer (Block Nested Loop) : 这意味着在不能使用索引的地方进行连接,所以使用连接缓冲区。带有此消息的表是添加索引的候选表。对于 JSON 格式,using_join_buffer字段被设置为Block Nested Loop。需要注意的一点是,当使用散列连接时,传统的和 JSON 格式的输出仍然会显示使用了块嵌套循环。要查看它是实际的块嵌套循环连接还是散列连接,您需要使用树格式的输出。

  • Using join buffer (Batched Key Access) : 这意味着一个连接正在使用批量键访问(BKA)优化。要启用批量键访问优化,您必须启用mrr(默认为on)和batch_key_access(默认为off)并禁用mrr_cost_based(默认为on)优化器开关。优化需要一个连接索引,因此与使用块嵌套循环的连接缓冲区不同,使用批量键访问算法并不意味着访问表的开销很大。对于 JSON 格式,using_join_buffer字段被设置为Batched Key Access

  • Using MRR : 使用多范围读取(MRR)优化。这有时用于减少需要整行的辅助索引上范围条件的随机 I/O 量。优化由mrrmrr_cost_based优化器开关控制(默认情况下两者都启用)。对于 JSON 格式,using_MRR字段被设置为true

  • Using filesort : MySQL 使用一次额外的传递来决定如何以正确的顺序检索行。例如,通过二级索引进行排序时会出现这种情况;并且该索引不是覆盖索引。对于 JSON 格式,using_filesort字段被设置为true

  • Using temporary : 内部临时表用于存储子查询的结果,用于排序或分组。对于排序和分组,有时可以通过添加索引或重写查询来避免使用内部临时表。对于 JSON 格式,using_temporary_table字段被设置为true

  • sort_union(...) Using union(...) Using intersect(...) : 这三个消息与索引合并一起使用,说明如何进行索引合并。对于这两种消息,有关索引合并中涉及的索引的信息都包含在括号内。对于 JSON 格式,key字段指定了使用的方法和索引。

  • Recursive : 该表是递归公用表表达式(CTE)的一部分。对于 JSON 格式,recursive字段被设置为true

  • Range checked for each record (index map: 0x1) : 当第二个表的索引列上有一个条件依赖于第一个表的列值的连接时,会发生这种情况,例如,t2.val2 : SELECT * FROM t1 INNER JOIN t2 WHERE t2.val2 < t1.val1;上有一个索引。这是触发性能模式语句事件表中的NO_GOOD_INDEX_USED计数器递增的原因。索引映射是一个位掩码,指示哪些索引是范围检查的候选。如SHOW INDEXES所示,索引号以 1 为基。当您写出位掩码时,设置了位的索引号是候选项。对于 JSON 格式,range_checked_for_each_record字段被设置为索引映射。

  • Impossible WHERE : 当存在一个不可能为真的过滤器时,例如WHERE 1 = 0。如果过滤器中的值超出了数据类型支持的范围,这也适用,例如,tinyint数据类型为WHERE ID = 300。对于 JSON 格式,消息被添加到message字段。

  • Impossible WHERE noticed after reading const tables :Impossible WHERE相同,只是在使用systemconst访问方法解析表格后适用。一个例子是SELECT * FROM (SELECT 1 AS ID) a INNER JOIN city USING (ID) WHERE a.id = 130;对于 JSON 格式,消息被添加到message字段。

  • Impossible HAVING :Impossible WHERE相同,只是它适用于HAVING条款。对于 JSON 格式,消息被添加到message字段。

  • Using index for skip scan : 当优化器选择使用类似于松散索引扫描的多个范围扫描时。例如,它可用于覆盖索引,其中索引的第一列不用于过滤条件。这种方法在 MySQL 8.0.13 及更高版本中可用。对于 JSON 格式,using_index_for_skip_scan字段被设置为true

  • Select tables optimized away : 这条消息意味着 MySQL 能够从查询中删除该表,因为只会产生一行,而这一行可以从一组确定的行中生成。它通常发生在表中只需要索引的最小值和/或最大值时。对于 JSON 格式,消息被添加到message字段。

  • No tables used : 对于不涉及任何表的子查询,例如SELECT 1 FROM dual;对于 JSON 格式,消息被添加到message字段。

  • no matching row in const table : 对于可以使用systemconst访问类型但没有符合条件的行的表。对于 JSON 格式,消息被添加到message字段。

Tip

在编写本文时,您需要使用树格式的输出来查看不使用索引的连接是否使用散列连接算法。

关于EXPLAIN语句输出含义的讨论到此结束。剩下的就是开始使用它来检查查询计划。

解释例子

为了结束对查询计划的讨论,有必要通过几个例子来更好地了解如何将所有这些结合在一起。这里的例子只是一个介绍。更多的例子将在本书的剩余部分出现,尤其是第 24 章。

单个表格,表格扫描

作为第一个例子,考虑在world示例数据库中的city表上的一个查询,该查询在非索引列Name上有一个条件。因为没有可用的索引,所以需要全表扫描来评估查询。符合这些要求的查询示例如下

SELECT *
  FROM world.city
 WHERE Name = 'London';

清单 20-5 显示了查询的传统EXPLAIN输出。

mysql> EXPLAIN
        SELECT *
          FROM world.city
         WHERE Name = 'London'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 4188
     filtered: 10
        Extra: Using where
1 row in set, 1 warning (0.0007 sec)

Listing 20-5The EXPLAIN output for a single table with a table scan

输出将访问类型设置为ALL,这也是预期的结果,因为在有索引的列上没有条件。预计将检查 4188 行(实际数量是 4079),并且将对每一行应用来自WHERE子句的条件。预计 10%被检查的行将匹配WHERE子句(注意,根据所使用的客户端,filtered列的输出可能显示为1010.00)。回想一下第 17 章关于优化器的讨论,优化器使用默认值来估计各种条件的过滤效果,所以你不能直接使用过滤值来估计一个索引是否有用。

相应的直观解释图见图 20-8

img/484666_1_En_20_Fig8_HTML.jpg

图 20-8

带有表扫描的单个表的直观解释图

全表扫描用一个红色的全表扫描框表示,可以看出成本估计是 425.05。

该查询只返回两行(该表在英格兰有一个伦敦,在加拿大安大略有一个)。如果请求单个国家的所有城市,会发生什么情况?

单个表,索引访问

第二个例子与第一个类似,只是过滤条件被改为使用具有次要非唯一索引的CountryCode列。这应该会降低访问匹配行的成本。对于此示例,将检索所有德国城市:

SELECT *
  FROM world.city
 WHERE CountryCode = 'DEU';

清单 20-6 显示了查询的传统EXPLAIN输出。

mysql> EXPLAIN
        SELECT *
          FROM world.city
         WHERE CountryCode = 'DEU'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 3
          ref: const
         rows: 93
     filtered: 100
        Extra: NULL
1 row in set, 1 warning (0.0008 sec)

Listing 20-6The EXPLAIN output for a single table with index lookups

这一次,possible_keys列显示可以使用CountryCode索引进行查询,而key列显示使用了该索引。访问类型是ref,以反映非唯一索引用于表访问。估计将访问 93 行,这正好是优化器询问 InnoDB 匹配多少行的结果。filtered 列显示索引很好地完成了筛选表的工作。相应的直观解释图如图 20-9 所示。

img/484666_1_En_20_Fig9_HTML.jpg

图 20-9

带有索引查找的单个表的可视化说明图

尽管返回的行数是第一个示例的 45 倍以上,但成本估计只有 28.05,或者说不到全表扫描成本的十分之一。

如果只使用IDCountryCode列会怎么样?

两个表和一个覆盖索引

如果有一个索引包含了表中需要的所有列,那么这个索引就叫做覆盖索引。MySQL 将利用这一点来避免检索整行。因为 city 表的CountryCode索引是一个非唯一索引,所以它还包括ID列,因为它是主键。为了使查询更加真实,该查询还将包括 country 表,并根据大洲过滤所包括的国家。这种查询的一个例子是

SELECT ci.ID
  FROM world.country co
       INNER JOIN world.city ci
          ON ci.CountryCode = co.Code
 WHERE co.Continent = 'Asia';

清单 20-7 显示了查询的传统EXPLAIN输出。

mysql> EXPLAIN
        SELECT ci.ID
          FROM world.country co
               INNER JOIN world.city ci
                  ON ci.CountryCode = co.Code
         WHERE co.Continent = 'Asia'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: co
   partitions: NULL
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 239
     filtered: 14.285715103149414
        Extra: Using where
*************************** 2\. row ***************************
           id: 1
  select_type: SIMPLE
        table: ci
   partitions: NULL
         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 3
          ref: world.co.Code
         rows: 18
     filtered: 100
        Extra: Using index

Listing 20-7The EXPLAIN output for a simple join between two tables

查询计划显示,优化器已经选择从对co ( country)表的全表扫描开始,并对ci ( city)表的连接使用CountryCode索引。这里比较特别的是Extra列包含了Using index。因此没有必要读取city表的整行。还要注意,键的长度是 3(字节),这是CountryCode列的宽度。相应的直观解释图见图 20-10

img/484666_1_En_20_Fig10_HTML.jpg

图 20-10

两个表之间简单连接的直观解释图

key_len字段不包括索引的主键部分,即使它被使用。但是,了解多列索引的使用量是很有用的。

多列索引

countrylanguage表有一个包含CountryCodeLanguage列的主键。假设您想要查找某个国家使用的所有语言;在这种情况下,您需要过滤CountryCode,而不是Language。该索引仍然可以用于执行过滤,并且您可以使用EXPLAIN输出的key_len字段来查看使用了多少索引。可以用于查找中国所有语言的查询是

SELECT *
  FROM world.countrylanguage
 WHERE CountryCode = 'CHN';

清单 20-8 显示了查询的传统EXPLAIN输出。

mysql> EXPLAIN
        SELECT *
          FROM world.countrylanguage
         WHERE CountryCode = 'CHN'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: countrylanguage
   partitions: NULL
         type: ref
possible_keys: PRIMARY,CountryCode
          key: PRIMARY
      key_len: 3
          ref: const
         rows: 12
     filtered: 100
        Extra: NULL

Listing 20-8The EXPLAIN output using part of a multicolumn index

主键的总宽度是来自CountryLanguage列的 3 个字节和来自Language列的 30 个字节。由于key_len列显示只使用了 3 个字节,因此可以得出结论,只有索引的CountryLanguage部分用于过滤(索引使用的部分总是最左边的部分)。在 Visual Explain 中,您需要将鼠标悬停在有问题的表格上以获取扩展信息,如图 20-11 所示。

img/484666_1_En_20_Fig11_HTML.jpg

图 20-11

使用多列索引的一部分的直观说明图

在图中,在键/索引:主要下寻找使用过的关键零件标签。这直接表明只使用了索引的CountryCode列。

作为最后一个例子,让我们回到在浏览EXPLAIN格式时用作示例的查询。

带有子查询和排序的两个表

本章前面广泛使用的示例查询将用于结束关于EXPLAIN的讨论。该查询混合使用了各种功能,因此它触发了已经讨论过的信息的几个部分。这也是一个包含多个查询块的查询示例。提醒一下,这里重复了这个查询。

清单 20-9 中重复了传统EXPLAIN格式的输出。

mysql> EXPLAIN
       SELECT ci.ID, ci.Name, ci.District,
              co.Name AS Country, ci.Population
         FROM world.city ci
              INNER JOIN
                (SELECT Code, Name
                   FROM world.country
                  WHERE Continent = 'Europe'
                  ORDER BY SurfaceArea
                  LIMIT 10
                ) co ON co.Code = ci.CountryCode
        ORDER BY ci.Population DESC
        LIMIT 5\G
*************************** 1\. row ***************************
           id: 1
  select_type: PRIMARY
        table: <derived2>
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 10
     filtered: 100
        Extra: Using temporary; Using filesort
*************************** 2\. row ***************************
           id: 1
  select_type: PRIMARY
        table: ci
   partitions: NULL

         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 3
          ref: co.Code
         rows: 18
     filtered: 100
        Extra: NULL
*************************** 3\. row ***************************
           id: 2
  select_type: DERIVED
        table: country
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 239
     filtered: 14.285715103149414
        Extra: Using where; Using filesort

Listing 20-9The EXPLAIN output

when joining a subquery and a table

20-12 中重复了查询的直观解释图。在阅读对输出的分析之前,我们鼓励您自己进行研究。

img/484666_1_En_20_Fig12_HTML.jpg

图 20-12

联接子查询和表的直观说明图

查询计划从子查询开始,该子查询使用country表按地区查找十个最小的国家。该子查询被赋予了表标签<derived2>,因此您需要找到带有id = 2的行(对于其他查询可能是几行),在本例中是第 3 行。第 3 行的选择类型设置为DERIVED,所以它是一个派生表;这是一个通过查询创建的表,但在其他方面表现得像一个普通的表。使用全表扫描(type = ALL)生成派生表,对每一行应用一个WHERE子句,然后进行文件排序。得到的派生表被具体化(从 Visual Explain 中可见)并被称为co

一旦构建了派生表,它就被用作与ci ( city)表连接的第一个表。您可以从第 1 行的<derived2>和第 2 行的ci的排序中看出这一点。对于派生表中的每一行,估计将使用CountryCode索引在ci表中检查 18 行。CountryCode索引是一个不唯一的索引,可以从 Visual Explain 中表格框的标签上看到,并且type列的值为ref。据估计,该连接将返回 180 行,这些行来自派生表中的 10 行乘以ci表中每次索引查找的 18 行。

最后,使用内部临时表和文件排序对结果进行排序。查询的总开销估计为 247.32。

到目前为止,讨论的是查询计划最终是什么。如果您想知道优化器是如何到达那里的,您将需要检查优化器跟踪。

优化程序跟踪

不经常需要优化器跟踪,但是有时当您遇到意外的查询计划时,了解优化器是如何到达那里的会很有用。这就是优化器跟踪显示的内容。

Tip

最常见的情况是,当查询计划不符合您的预期时,这是因为缺少或错误的WHERE子句、缺少或错误的连接条件,或者查询中的一些其他类型的错误,或者因为索引统计不正确。在深入了解优化器决策过程的血淋淋的细节之前,检查一下这些东西。

通过将optimizer_trace选项设置为1来启用优化器跟踪。这使得优化器记录后续查询的跟踪信息(直到optimizer_trace再次被禁用),并且该信息通过information_schema.OPTIMIZER_TRACE表可用。保留的最大跟踪数量由optimizer_trace_limit选项配置(默认为 1)。

您可以选择执行需要优化器跟踪的查询,或者使用EXPLAIN来获得查询计划。后者非常有用,因为它为您提供了查询计划和优化器跟踪。获取查询的优化器跟踪的典型工作流如下:

  1. 启用会话的optimizer_trace选项。

  2. 对您想要调查的查询执行EXPLAIN

  3. 再次禁用optimizer_trace选项。

  4. information_schema.OPTIMIZER_TRACE表中检索优化器跟踪。

information_schema.OPTIMIZER_TRACE表包括四列:

  • QUERY : 原查询。

  • TRACE : 一个带有跟踪信息的 JSON 文档。很快会有更多关于追踪的消息。

  • MISSING_BYTES_BEYOND_MAX_MEM_SIZE : 记录的 trace 的大小(以字节为单位)受限于optimizer_trace_max_mem_size选项的值(在 MySQL 8 中默认为 1 MiB)。这一列显示了记录完整轨迹还需要多少内存。如果该值大于 0,用该值增加optimizer_trace_max_mem_size选项。

  • INSUFFICIENT_PRIVILEGES : 您是否缺少生成优化程序跟踪的权限。

该表是作为临时表创建的,因此跟踪对于会话是唯一的。

清单 20-10 展示了一个获取查询的优化器跟踪的例子(与前面几节中的循环示例查询相同)。优化器跟踪输出在这里被截断,因为它超过了 15000 个字符,几乎有 500 行长。类似地,EXPLAIN语句的输出已经被省略,因为它与前面显示的相同,并且对本讨论不重要。完整的输出包含在文件listing_20_10.txt中,跟踪本身包含在本书 GitHub 资源库的listing_20_10.json中。

mysql> SET SESSION optimizer_trace = 1;
Query OK, 0 rows affected (0.0003 sec)

mysql> EXPLAIN
       SELECT ci.ID, ci.Name, ci.District,
              co.Name AS Country, ci.Population
         FROM world.city ci
              INNER JOIN
                (SELECT Code, Name
                   FROM world.country
                  WHERE Continent = 'Europe'
                  ORDER BY SurfaceArea
                  LIMIT 10
                ) co ON co.Code = ci.CountryCode
        ORDER BY ci.Population DESC
        LIMIT 5\G
...

mysql> SET SESSION optimizer_trace = 0;
Query OK, 0 rows affected (0.0002 sec)

mysql> SELECT * FROM information_schema.OPTIMIZER_TRACE\G
*************************** 1\. row ***************************
                            QUERY: EXPLAIN
SELECT ci.ID, ci.Name, ci.District,
       co.Name AS Country, ci.Population
  FROM world.city ci
       INNER JOIN
         (SELECT Code, Name
            FROM world.country
           WHERE Continent = 'Europe'
           ORDER BY SurfaceArea
           LIMIT 10
         ) co ON co.Code = ci.CountryCode
 ORDER BY ci.Population DESC
 LIMIT 5
                            TRACE: {
...
}
MISSING_BYTES_BEYOND_MAX_MEM_SIZE: 0
          INSUFFICIENT_PRIVILEGES: 0
1 row in set (0.0436 sec)

Listing 20-10Obtaining the optimizer trace for a query

该轨迹是结果中最有趣的。虽然有很多可用的信息,但幸运的是,这些信息很大程度上是不言自明的,如果您已经熟悉了 JSON 格式的EXPLAIN输出,就会发现有一些相似之处。大部分信息是关于执行查询的各个部分的成本估计。当有多个可能的选项时,优化器会计算每个选项的成本,并选择最便宜的选项。这个跟踪的一个例子是访问ci ( city)表。这可以通过CountryCode索引或表格扫描来完成。清单 20-11 中显示了这个决定的跟踪部分(缩进已经减少)。

"table": "`city` `ci`",
"best_access_path": {
  "considered_access_paths": [
    {
      "access_type": "ref",
      "index": "CountryCode",
      "rows": 18.052,
      "cost": 63.181,
      "chosen": true
    },
    {
      "rows_to_scan": 4188,
      "filtering_effect": [
      ],
      "final_filtering_effect": 1,
      "access_type": "scan",
      "using_join_cache": true,
      "buffers_needed": 1,
      "resulting_rows": 4188,
      "cost": 4194.3,
      "chosen": false
    }
  ]
},

Listing 20-11The optimizer trace for choosing the access type for the ci table

这表明,当使用成本为 63.181 的CountryCode索引("access_type": "ref")时,估计平均将检查 18 行多一点。对于全表扫描("access_type": "scan"),预计需要检查 4188 行,总开销为 4194.3。"chosen"元素表示已经选择了ref访问类型。

虽然很少需要深入研究优化器如何得到查询计划的细节,但是了解优化器的工作方式是很有用的。有时,查看查询计划的其他选项的估计成本也很有用,这样可以了解为什么没有选择它们。

Tip

如果你有兴趣学习更多关于使用优化器跟踪的知识,你可以在 https://dev.mysql.com/doc/internals/en/optimizer-tracing.html 阅读更多 MySQL 内部手册。

到目前为止,整个讨论——除了EXPLAIN ANALYZE——都是关于在执行之前的阶段分析查询。如果要考察实际表现,EXPLAIN ANALYZE通常是最佳选择。另一种选择是使用性能模式。

性能模式事件分析

性能模式允许您分析每个被检测的事件花费了多少时间。您可以使用它来分析执行查询时花费的时间。本节将研究如何使用性能模式来分析存储过程,以了解过程中哪些语句花费的时间最长,以及如何使用阶段事件来分析单个查询。在本节的最后,将展示如何使用sys.ps_trace_thread()过程来创建一个线程所做工作的图表,以及如何使用ps_trace_statement_digest()来收集具有给定摘要的语句的统计信息。

检查存储过程

检查一个存储过程所做的工作是很有挑战性的,因为你不能直接在这个过程中使用EXPLAIN,而且也不清楚这个过程将执行哪些查询。相反,您可以使用性能模式。它记录执行的每条语句,并在events_statements_history表中维护历史记录。

除非您需要在每个线程中存储十个以上的查询,否则您不需要做任何事情来开始分析。如果该过程生成十个以上的语句事件,您将需要增加performance_schema_events_statements_history_size选项的值(需要重启),使用events_statements_history_long表,或者使用sys.ps_trace_thread()过程,如下所述。本讨论的剩余部分假设您可以使用events_statements_history表。

作为检查由存储过程执行的查询的例子,考虑清单 20-12 中的过程。该过程也可在文件listing_20_12.sql中获得,该文件可来源于任何模式。

CREATE SCHEMA IF NOT EXISTS chapter_20;

DELIMITER $$

CREATE PROCEDURE chapter_20.testproc()
    SQL SECURITY INVOKER
    NOT DETERMINISTIC
    MODIFIES SQL DATA
BEGIN
    DECLARE v_iter, v_id int unsigned DEFAULT 0;
    DECLARE v_name char(35) CHARSET latin1;

    SET v_id = CEIL(RAND()*4079);
    SELECT Name
      INTO v_name
      FROM world.city
     WHERE ID = v_id;

    SELECT *
      FROM world.city
     WHERE Name = v_name;
END$$

DELIMITER ;

Listing 20-12An example procedure

该过程执行三个查询。第一个查询将v_id变量设置为 1 到 4079 之间的一个整数(world.city表中可用的ID值)。第二个查询获取具有该 id 的城市名称。第三个查询查找与第二个查询同名的所有城市。

如果在连接中调用此过程,则可以随后分析由该过程触发的查询以及这些查询的性能。例如:

mysql> SELECT PS_CURRENT_THREAD_ID();
+------------------------+
| PS_CURRENT_THREAD_ID() |
+------------------------+
|                     83 |
+------------------------+
1 row in set (0.00 sec)

mysql> CALL chapter_20.testproc();
+------+--------+-------------+----------+------------+
| ID   | Name   | CountryCode | District | Population |
+------+--------+-------------+----------+------------+
| 2853 | Jhelum | PAK         | Punjab   |     145800 |
+------+--------+-------------+----------+------------+
1 row in set (0.0019 sec)
Query OK, 0 rows affected (0.0019 sec)

该过程的输出是随机的,因此每次执行都会有所不同。然后,您可以使用通过PS_CURRENT_THREAD_ID()函数找到的线程 id(在 MySQL 8.0.15 和更早版本中使用sys.ps_thread_id(NULL))来确定执行了哪些查询。

清单 20-13 展示了如何进行这种分析。您必须在不同的连接中进行分析,将THREAD_ID = 83更改为使用您找到的线程 id,并将第二个查询中的NESTING_EVENT_ID = 64更改为使用第一个查询中的事件 id。已经从输出中删除了一些细节,以关注最感兴趣的值。

mysql> SELECT *
         FROM performance_schema.events_statements_history
        WHERE THREAD_ID = 83
              AND EVENT_NAME = 'statement/sql/call_procedure'
        ORDER BY EVENT_ID DESC
        LIMIT 1\G
*************************** 1\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 64
           END_EVENT_ID: 72
             EVENT_NAME: statement/sql/call_procedure
                 SOURCE: init_net_server_extension.cc:95
            TIMER_START: 533823963611947008
              TIMER_END: 533823965937460352
             TIMER_WAIT: 2325513344
              LOCK_TIME: 129000000
               SQL_TEXT: CALL testproc()
                 DIGEST: 72fd8466a0e05fe215308832173a3be50e7edad960408c70078ef94f8ffb52b2
            DIGEST_TEXT: CALL `testproc` ( )
...
1 row in set (0.0008 sec)

mysql> SELECT *
         FROM performance_schema.events_statements_history
        WHERE THREAD_ID = 83
              AND NESTING_EVENT_ID = 64
        ORDER BY EVENT_ID\G
*************************** 1\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 65
           END_EVENT_ID: 65
             EVENT_NAME: statement/sp/set
...
*************************** 2\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 66
           END_EVENT_ID: 66
             EVENT_NAME: statement/sp/set
...
*************************** 3\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 67
           END_EVENT_ID: 67
             EVENT_NAME: statement/sp/set
...
*************************** 4\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 68
           END_EVENT_ID: 68
             EVENT_NAME: statement/sp/set
...
*************************** 5\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 69
           END_EVENT_ID: 70
             EVENT_NAME: statement/sp/stmt
                 SOURCE: sp_head.cc:2166
            TIMER_START: 533823963993029248
              TIMER_END: 533823964065598976
             TIMER_WAIT: 72569728
              LOCK_TIME: 0
               SQL_TEXT: SELECT Name
      INTO v_name
      FROM world.city
     WHERE ID = v_id
                 DIGEST: NULL
            DIGEST_TEXT: NULL
         CURRENT_SCHEMA: db1
            OBJECT_TYPE: PROCEDURE
          OBJECT_SCHEMA: db1
            OBJECT_NAME: testproc
  OBJECT_INSTANCE_BEGIN: NULL
            MYSQL_ERRNO: 0
      RETURNED_SQLSTATE: 00000
           MESSAGE_TEXT: NULL
                 ERRORS: 0
               WARNINGS: 0
          ROWS_AFFECTED: 1
              ROWS_SENT: 0
          ROWS_EXAMINED: 1
CREATED_TMP_DISK_TABLES: 0
     CREATED_TMP_TABLES: 0
       SELECT_FULL_JOIN: 0
 SELECT_FULL_RANGE_JOIN: 0
           SELECT_RANGE: 0
     SELECT_RANGE_CHECK: 0

            SELECT_SCAN: 0
      SORT_MERGE_PASSES: 0
             SORT_RANGE: 0
              SORT_ROWS: 0
              SORT_SCAN: 0
          NO_INDEX_USED: 0
     NO_GOOD_INDEX_USED: 0
       NESTING_EVENT_ID: 64
     NESTING_EVENT_TYPE: STATEMENT
    NESTING_EVENT_LEVEL: 1
           STATEMENT_ID: 25241
*************************** 6\. row ***************************
              THREAD_ID: 83
               EVENT_ID: 71
           END_EVENT_ID: 72
             EVENT_NAME: statement/sp/stmt
                 SOURCE: sp_head.cc:2166
            TIMER_START: 533823964067422336
              TIMER_END: 533823965880571520
             TIMER_WAIT: 1813149184
              LOCK_TIME: 0
               SQL_TEXT: SELECT *
      FROM world.city
     WHERE Name = v_name
                 DIGEST: NULL
            DIGEST_TEXT: NULL
         CURRENT_SCHEMA: db1
            OBJECT_TYPE: PROCEDURE
          OBJECT_SCHEMA: db1
            OBJECT_NAME: testproc
  OBJECT_INSTANCE_BEGIN: NULL
            MYSQL_ERRNO: 0
      RETURNED_SQLSTATE: NULL
           MESSAGE_TEXT: NULL
                 ERRORS: 0
               WARNINGS: 0
          ROWS_AFFECTED: 0
              ROWS_SENT: 1
          ROWS_EXAMINED: 4080
CREATED_TMP_DISK_TABLES: 0
     CREATED_TMP_TABLES: 0
       SELECT_FULL_JOIN: 0
 SELECT_FULL_RANGE_JOIN: 0
           SELECT_RANGE: 0
     SELECT_RANGE_CHECK: 0
            SELECT_SCAN: 1
      SORT_MERGE_PASSES: 0
             SORT_RANGE: 0
              SORT_ROWS: 0
              SORT_SCAN: 0
          NO_INDEX_USED: 1
     NO_GOOD_INDEX_USED: 0
       NESTING_EVENT_ID: 64
     NESTING_EVENT_TYPE: STATEMENT
    NESTING_EVENT_LEVEL: 1
           STATEMENT_ID: 25242

6 rows in set (0.0008 sec)

Listing 20-13Analyzing the queries executed by a stored procedure

该分析由两个查询组成。第一个确定过程的总体信息,这是通过查询调用过程的事件statement/sql/call_procedure的最近发生(按EVENT_ID排序)来完成的。

第二个查询请求相同线程的事件,该线程将statement/sql/call_procedure事件的事件 id 作为嵌套事件 id。这些是由过程执行的语句。通过按EVENT_ID排序,语句按执行顺序返回。

第二个查询的查询结果显示,该过程从四个SET语句开始。其中一些是预期的,但也有一些是由隐式设置变量触发的。最后两行是本次讨论中最有趣的,因为它们显示执行了两个查询。首先,通过ID列(主键)查询city表。不出所料,它检查了一行。因为结果保存在v_name变量中,所以ROWS_AFFECTED计数器增加,而不是ROWS_SENT

第二个查询执行得不太好。它也查询city表,但是在没有索引的地方按名称查询。这导致检查 4080 行以返回一行。NO_INDEX_USED列被设置为 1,以反映执行了全表扫描。

使用这种方法检查存储过程的一个缺点是——如您所见——它可以快速使用历史表中的所有十行。一种替代方法是启用events_statements_history_long消费者并在空闲的测试系统上测试该过程,或者禁用其他连接的历史记录。这允许您分析执行多达 10000 个语句事件的过程。另一种方法是使用sys.ps_trace_thread()过程,它也使用长历史记录,但是在过程执行时支持轮询,因此即使表不够大,无法在过程持续期间保存所有事件,它也可以收集事件。

这个例子已经使用了语句事件来分析性能。有时,您需要知道在更细粒度的级别上发生了什么,在这种情况下,您需要开始查看阶段事件。

分析舞台事件

如果您需要获得查询花费时间的更细粒度的细节,第一步是查看阶段事件。或者,您还可以包括等待事件。由于处理等待事件的步骤本质上与处理阶段事件的步骤相同,因此它被留给读者作为分析查询的等待事件的练习。

Caution

您检查的事件越细,它们的开销就越大。因此,在生产系统上启用登台和等待事件时要小心。一些等待事件,尤其是与互斥体相关的事件,也可能对查询产生足够大的影响,从而影响分析的结论。使用等待事件来分析查询通常是只有使用 MySQL 源代码的性能架构师和开发者需要做的事情。

生成的阶段事件的数量远大于语句事件的数量。这意味着,为了避免阶段事件从历史表中消失,建议在空闲测试系统上进行分析,并使用events_stages_history_long表。默认情况下,此表被禁用;要启用它,请启用相应的使用者:

mysql> UPDATE performance_schema.setup_consumers
          SET ENABLED = 'YES'
        WHERE NAME IN ('events_stages_current',
                       'events_stages_history_long');
Query OK, 2 rows affected (0.0008 sec)

Rows matched: 2  Changed: 2  Warnings: 0

events_stages_history_long消费者依赖于events_stages_current消费者,因此您需要同时启用两者。默认情况下,仅启用与进度信息相关的阶段事件。对于一般分析,您需要启用所有阶段事件:

mysql> UPDATE performance_schema.setup_instruments
          SET ENABLED = 'YES',
              TIMED = 'YES'
        WHERE NAME LIKE 'stage/%';
Query OK, 125 rows affected (0.0011 sec)

Rows matched: 125  Changed: 109  Warnings: 0

此时,分析可以以与分析存储过程时相同的方式进行。例如,考虑由性能模式线程 id 等于 83 的连接执行的以下查询:

SELECT *
  FROM world.city
 WHERE Name = 'Sydney';

假设这是最后一次执行的查询,您可以得到每个阶段花费的时间,如清单 20-14 所示。您需要执行这是一个单独的连接,并更改SET @thread_id = 83以使用您的连接的线程 id。除了时间明显不同之外,您的查询所经历的阶段列表也可能不同。

mysql> SET @thread_id = 83;
Query OK, 0 rows affected (0.0004 sec)

mysql> SELECT EVENT_ID,
              SUBSTRING_INDEX(EVENT_NAME, '/', -1) AS Event,
              FORMAT_PICO_TIME(TIMER_WAIT) AS Latency
         FROM performance_schema.events_stages_history_long
        WHERE THREAD_ID = @thread_id
              AND NESTING_EVENT_ID = (
                  SELECT EVENT_ID
                    FROM performance_schema.events_statements_history
                   WHERE THREAD_ID = @thread_id
                   ORDER BY EVENT_ID DESC
                   LIMIT 1);
+----------+--------------------------------------+-----------+
| EVENT_ID | Event                                | Latency   |
+----------+--------------------------------------+-----------+
|     7193 | Executing hook on transaction begin. | 200.00 ns |
|     7194 | cleaning up                          | 4.10 us   |
|     7195 | checking permissions                 | 2.60 us   |
|     7196 | Opening tables                       | 41.50 us  |
|     7197 | init                                 | 3.10 us   |
|     7198 | System lock                          | 6.50 us   |
|     7200 | optimizing                           | 5.30 us   |
|     7201 | statistics                           | 15.00 us  |
|     7202 | preparing                            | 12.10 us  |
|     7203 | executing                            | 1.18 ms   |
|     7204 | end                                  | 800.00 ns |
|     7205 | query end                            | 500.00 ns |
|     7206 | waiting for handler commit           | 6.70 us   |
|     7207 | closing tables                       | 3.30 us   |
|     7208 | freeing items                        | 70.30 us  |
|     7209 | cleaning up                          | 300.00 ns |
+----------+--------------------------------------+-----------+

16 rows in set (0.0044 sec)

Listing 20-14Finding the stages for the last statement of a connection

events_stages_history_long表中选择事件 id、阶段名(为简洁起见,去掉了完整事件名的前两个部分)和用FORMAT_PICO_TIME()函数格式化的延迟(在 MySQL 8.0.15 和更早版本中使用sys.format_time()函数)。WHERE子句根据执行查询的连接的线程 id 和嵌套事件 id 进行过滤。对于线程 id 等于 83 的连接,嵌套事件 id 被设置为最近执行的语句的事件 id。结果显示,查询最慢的部分是Sending data,这是存储引擎查找和发送行的阶段。

以这种方式分析查询的主要问题是,要么受到默认情况下每个线程保存 10 个事件的限制,要么在检查完长历史记录表之前就冒着事件被从长历史记录表中删除的风险。创建sys.ps_trace_thread()程序就是为了帮助解决这个问题。

使用 sys.ps_trace_thread()过程进行分析

当您需要分析一个复杂的查询或执行多条语句的存储程序时,使用一个随着执行过程自动收集信息的工具会有所帮助。从sys模式中做这件事的一个选项是ps_trace_thread()过程。

该过程在一段时间内循环轮询长历史记录表,查找新的事务、语句、阶段和等待事件。或者,该过程还可以设置性能模式以包含所有事件,并允许使用者记录事件。但是,由于包含所有事件通常太多,所以建议您自己设置性能模式,以检测和使用您的分析感兴趣的事件。

另一个可选特性是在监控开始时重置性能模式表。如果删除长历史表的内容是可以接受的,那么这将是一件好事。

调用该过程时,必须提供以下参数:

  • 线程 ID: 您要监控的性能模式线程 ID。

  • Out File: 将结果写入的文件。结果是使用点图描述语言创建的。 2 这要求secure_file_priv选项已经设置为允许将文件写入目标目录,并且该文件不存在,并且执行该过程的用户具有FILE权限。

  • 最大运行时间:监控的最大时间,以秒为单位。支持以 1/100 秒的精度指定值。如果该值设置为NULL,则运行时间设置为 60 秒。

  • 轮询间隔:轮询历史表的间隔。该值可以设置为 1/100 秒的精度。如果该值被设置为NULL,那么轮询间隔将被设置为一秒。

  • 刷新:一个布尔值,表示是否重置用于分析的性能模式表。

  • 自动设置:一个布尔值,表示是否启用程序可以使用的所有仪器和用户。启用后,当前设置会在程序完成时恢复。

  • Debug: 一个布尔值,表示是否包含附加信息,例如事件在源代码中的什么位置被触发。这在包含等待事件时非常有用。

在清单 20-15 中可以看到使用ps_trace_thread()程序的例子。当程序执行时,先前的testproc()程序从被监控的线程中被调用。该示例假设您从默认的性能模式设置开始。

Connection 1> UPDATE performance_schema.setup_consumers
                 SET ENABLED = 'YES'
               WHERE NAME = 'events_statements_history_long';
Query OK, 1 row affected (0.0074 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Find the Performance Schema thread id for the
-- thread that will be monitored.
Connection 2> SELECT PS_CURRENT_THREAD_ID();
+-----------------+
| PS_THREAD_ID(9) |
+-----------------+
|              32 |
+-----------------+
1 row in set (0.0016 sec)

-- Replace the first argument with the thread id
-- just found.
--
-- Once the procedure returns
-- "Data collection starting for THREAD_ID = 32"
-- (replace 32 with your thread id) invoke the
-- chapter_20.testproc() chapter from connection 2.
-- The example is set to poll for 10 seconds. If you
-- need more time, change the third argument to the
-- number of seconds you need.
Connection 1> CALL sys.ps_trace_thread(
                  32,
                  '/mysql/files/thread_32.gv',
                  10, 0.1, False, False, False);
+-------------------+
| summary           |
+-------------------+
| Disabled 1 thread |
+-------------------+
1 row in set (0.0316 sec)
+---------------------------------------------+
| summary                                     |
+---------------------------------------------+
| Data collection starting for THREAD_ID = 32 |
+---------------------------------------------+
1 row in set (0.0316 sec)
-- Here, sys.ps_trace_id() blocks – execute the
-- query you want to trace. The output is random.
Connection 2> CALL chapter_20.testproc();
+------+--------+-------------+----------+------------+
| ID   | Name   | CountryCode | District | Population |
+------+--------+-------------+----------+------------+
| 3607 | Rjazan | RUS         | Rjazan   |     529900 |
+------+--------+-------------+----------+------------+
1 row in set (0.0023 sec)

Query OK, 0 rows affected (0.0023 sec)

-- Back in connection 1, wait for the sys.ps_trace_id()
-- procedure to complete.
+--------------------------------------------------+
| summary                                          |
+--------------------------------------------------+
| Stack trace written to /mysql/files/thread_32.gv |
+--------------------------------------------------+
1 row in set (0.0316 sec)
+----------------------------------------------------------+
| summary                                                  |
+----------------------------------------------------------+
| dot -Tpdf -o /tmp/stack_32.pdf /mysql/files/thread_32.gv |
+----------------------------------------------------------+
1 row in set (0.0316 sec)
+----------------------------------------------------------+
| summary                                                  |
+----------------------------------------------------------+
| dot -Tpng -o /tmp/stack_32.png /mysql/files/thread_32.gv |
+----------------------------------------------------------+
1 row in set (0.0316 sec)
+------------------+
| summary          |
+------------------+
| Enabled 1 thread |
+------------------+

1 row in set (0.0316 sec)
Query OK, 0 rows affected (0.0316 sec)

Listing 20-15Using the ps_trace_thread() procedure

在这个例子中,只有events_statements_history_long消费者被启用。这将允许记录调用testproc()过程产生的所有语句事件,就像之前手动完成的一样。将被监控的线程 id 是使用PS_CURRENT_THREAD_ID()函数获得的(在 MySQL 8.0.15 和更早的版本中,使用sys.ps_thread_id(NULL))。

为线程 id 32 调用ps_trace_thread()过程,输出写入/mysql/files/thread_32.gv。该过程在 10 秒内每 0.1 秒轮询一次,所有可选功能都被禁用。

你需要一个能理解点格式的程序来把它转换成图像。一个选项是 Graphviz 工具集,它可以通过包存储库从几个 Linux 发行版中获得。也可以从项目主页 www.graphviz.org/ 下载,适用于 Linux、微软 Windows、macOS、Solaris、FreeBSD。该过程的输出显示了如何将带有点阵图定义的文件转换为 PDF 或 PNG 文件的示例。图 20-13 显示了为CALL testproc()语句生成的图形。

img/484666_1_En_20_Fig13_HTML.jpg

图 20-13

调用 testproc()语句的语句图

语句图包含与手动分析过程时相同的信息。对于像testproc()这样简单的过程来说,生成图形的优势是有限的,但是对于更复杂的过程或者分析启用了低级事件的查询来说,这是可视化执行流程的好方法。

另一个可以帮助您分析查询的sys模式过程是ps_trace_statement_digest()过程。

使用 ps_trace_statement_digest()过程进行分析

作为使用性能模式分析查询的最后一个例子,将演示来自sys模式的ps_trace_statement_digest()过程。它获取一个摘要,然后监控events_statements_history_longevents_stages_history_long表中与该摘要语句相关的事件。分析结果包括摘要数据和详细信息,例如运行时间最长的查询的查询计划。

该过程有五个参数,它们都是强制的。这些论点是

  • 摘要:要监控的摘要。如果语句的摘要与提供的摘要相匹配,则不管默认模式如何,语句都将受到监控。

  • 运行时间:以秒为单位监控多长时间。不允许有小数。

  • 轮询间隔:轮询历史表的间隔。该值可以设置为 1/100 秒的精度,并且必须小于 1 秒。

  • 刷新:一个布尔值,表示是否重置用于分析的性能模式表。

  • 自动设置:一个布尔值,表示是否启用程序可以使用的所有仪器和用户。启用后,当前设置会在程序完成时恢复。

例如,您可以使用sys.ps_trace_statement_digest()程序开始监控,并在监控过程中执行以下查询(监控示例如下):

SELECT * FROM world.city WHERE CountryCode = 'AUS';
SELECT * FROM world.city WHERE CountryCode = 'USA';
SELECT * FROM world.city WHERE CountryCode = 'CHN';
SELECT * FROM world.city WHERE CountryCode = 'ZAF';
SELECT * FROM world.city WHERE CountryCode = 'BRA';
SELECT * FROM world.city WHERE CountryCode = 'GBR';
SELECT * FROM world.city WHERE CountryCode = 'FRA';
SELECT * FROM world.city WHERE CountryCode = 'IND';
SELECT * FROM world.city WHERE CountryCode = 'DEU';
SELECT * FROM world.city WHERE CountryCode = 'SWE';
SELECT * FROM world.city WHERE CountryCode = 'LUX';
SELECT * FROM world.city WHERE CountryCode = 'NZL';
SELECT * FROM world.city WHERE CountryCode = 'KOR';

这些查询中哪一个最慢可能因执行而异。

清单 20-16 展示了一个使用该过程来监控一个查询的例子,该查询选择给定国家的所有城市。在示例中,使用STATEMENT_DIGEST()函数找到了摘要,但是您也可以通过基于events_statements_summary_by_digest表的监控找到它。这将留给过程来启用所需的工具和消费者,并且被监控的表将被重置以避免包括在监控开始之前执行的语句的出现。轮询频率设置为每 0.5 秒轮询一次。为了减少输出的宽度,舞台事件名称已经去掉了前缀stage/sql/,并且EXPLAIN输出的虚线也变短了。未修改的输出可以在本书的 GitHub 库的文件listing_20_16.txt中找到。

mysql> SET @digest = STATEMENT_DIGEST('SELECT * FROM world.city WHERE CountryCode = ''AUS''');
Query OK, 0 rows affected (0.0004 sec)

-- Execute your queries once the procedure has started.
mysql> CALL sys.ps_trace_statement_digest(@digest, 60, 0.5, TRUE, TRUE);
+-------------------+
| summary           |
+-------------------+
| Disabled 1 thread |
+-------------------+
1 row in set (1 min 0.0861 sec)

+--------------------+
| SUMMARY STATISTICS |
+--------------------+
| SUMMARY STATISTICS |
+--------------------+
1 row in set (1 min 0.0861 sec)

+------------+-----------+-----------+-----------+---------------+---------------+------------+------------+
| executions | exec_time | lock_time | rows_sent | rows_affected | rows_examined | tmp_tables | full_scans |
+------------+-----------+-----------+-----------+---------------+---------------+------------+------------+

|         13 | 7.29 ms   | 1.19 ms   |      1720 |             0 |          1720 |          0 |          0 |
+------------+-----------+-----------+-----------+---------------+---------------+------------+------------+
1 row in set (1 min 0.0861 sec)

+--------------------------------------+-------+-----------+
| event_name                           | count | latency   |
+--------------------------------------+-------+-----------+
| Sending data                         |    13 | 2.99 ms   |
| freeing items                        |    13 | 2.02 ms   |
| statistics                           |    13 | 675.37 us |
| Opening tables                       |    13 | 401.50 us |
| preparing                            |    13 | 100.28 us |
| optimizing                           |    13 | 66.37 us  |
| waiting for handler commit           |    13 | 64.18 us  |
| closing tables                       |    13 | 54.70 us  |
| System lock                          |    13 | 54.34 us  |
| cleaning up                          |    26 | 45.22 us  |
| init                                 |    13 | 29.54 us  |
| checking permissions                 |    13 | 23.34 us  |
| end                                  |    13 | 10.21 us  |
| query end                            |    13 | 8.02 us   |
| executing                            |    13 | 4.01 us   |
| Executing hook on transaction begin. |    13 | 3.65 us   |
+--------------------------------------+-------+-----------+
16 rows in set (1 min 0.0861 sec)

+---------------------------+
| LONGEST RUNNING STATEMENT |
+---------------------------+
| LONGEST RUNNING STATEMENT |
+---------------------------+
1 row in set (1 min 0.0861 sec)

+-----------+-----------+-----------+-----------+---------------+---------------+------------+-----------+
| thread_id | exec_time | lock_time | rows_sent | rows_affected | rows_examined | tmp_tables | full_scan |
+-----------+-----------+-----------+-----------+---------------+---------------+------------+-----------+
|        32 | 1.09 ms   | 79.00 us  |       274 |             0 |           274 |          0 |         0 |
+-----------+-----------+-----------+-----------+---------------+---------------+------------+-----------+
1 row in set (1 min 0.0861 sec)

+----------------------------------------------------+
| sql_text                                           |
+----------------------------------------------------+
| SELECT * FROM world.city WHERE CountryCode = 'USA' |
+----------------------------------------------------+
1 row in set (59.91 sec)

+--------------------------------------+-----------+
| event_name                           | latency   |
+--------------------------------------+-----------+
| Executing hook on transaction begin. | 364.67 ns |
| cleaning up                          | 3.28 us   |
| checking permissions                 | 1.46 us   |
| Opening tables                       | 27.72 us  |
| init                                 | 2.19 us   |
| System lock                          | 4.01 us   |
| optimizing                           | 5.11 us   |
| statistics                           | 46.68 us  |
| preparing                            | 7.66 us   |
| executing                            | 364.67 ns |
| Sending data                         | 528.41 us |
| end                                  | 729.34 ns |
| query end                            | 729.34 ns |
| waiting for handler commit           | 4.38 us   |
| closing tables                       | 16.77 us  |
| freeing items                        | 391.29 us |
| cleaning up                          | 364.67 ns |
+--------------------------------------+-----------+
17 rows in set (1 min 0.0861 sec)

+--------------------------------------------------+
| EXPLAIN                                          |
+--------------------------------------------------+
| {
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "46.15"
    },
    "table": {
      "table_name": "city",
      "access_type": "ref",
      "possible_keys": [
        "CountryCode"
      ],
      "key": "CountryCode",
      "used_key_parts": [
        "CountryCode"
      ],
      "key_length": "3",
      "ref": [
        "const"
      ],
      "rows_examined_per_scan": 274,
      "rows_produced_per_join": 274,
      "filtered": "100.00",
      "cost_info": {
        "read_cost": "18.75",
        "eval_cost": "27.40",
        "prefix_cost": "46.15",
        "data_read_per_join": "19K"
      },
      "used_columns": [
        "ID",
        "Name",
        "CountryCode",
        "District",
        "Population"
      ]
    }
  }
} |
+--------------------------------------------------+
1 row in set (1 min 0.0861 sec)

+------------------+
| summary          |
+------------------+
| Enabled 1 thread |
+------------------+
1 row in set (1 min 0.0861 sec)

Query OK, 0 rows affected (1 min 0.0861 sec)

Listing 20-16Using the ps_trace_statement_digest() procedure

输出以分析过程中发现的所有查询的摘要开始。总共用了 7.29 毫秒检测到 13 次执行。总体摘要还包括各个阶段所用时间的总和。输出的下一部分是 13 次执行中最慢的一次的详细信息。对于最慢的查询,输出以 JSON 格式的查询计划结束。

对于生成查询计划,您应该知道一个限制。执行EXPLAIN语句时,将默认模式设置为与执行过程时相同的模式。这意味着,如果查询在不同的模式中执行,并且不使用完全限定的表名(即,包括模式名),那么EXPLAIN语句将失败,并且该过程不输出查询计划。

摘要

本章介绍了如何分析您认为可能需要优化的查询。这一章的大部分内容集中在分析查询的主要工具EXPLAIN语句上。本章的其余部分介绍了优化器跟踪以及如何使用性能模式来分析查询。

EXPLAIN语句支持几种不同的格式,帮助您以最适合您的格式获得查询计划。传统格式使用标准表输出,JSON 格式返回详细的 JSON 文档,而树格式显示相对简单的执行树。只有 MySQL 8.0.16 和更高版本支持树格式,并且要求使用 Volcano 迭代器执行器来执行查询。MySQL Workbench 中的可视化解释特性使用 JSON 格式来创建查询计划的图表。

EXPLAIN输出中有大量关于查询计划的信息。讨论了传统格式的字段以及 JSON 中最常见的字段。这包括详细讨论选择类型和访问类型以及额外信息。最后,用一系列例子来说明如何使用这些信息。

优化器跟踪可以用来获得关于优化器如何以EXPLAIN语句返回的查询计划结束的信息。对于最终用户来说,通常没有必要使用优化器跟踪,但是它们对于了解有关优化器和导致查询计划的决策过程的更多信息非常有用。

这一章的最后一部分展示了如何使用性能模式事件来确定什么占用了一条语句的时间。首先展示了如何将一个存储过程分解成单独的语句,然后展示了如何将一个语句分解成多个阶段。最后,ps_trace_thread()过程用于自动化分析并创建事件图,而ps_trace_statement_digest()过程用于收集给定语句摘要的统计数据。

本章分析了查询。有时有必要考虑整个事务。下一章将展示如何分析事务。

二十一、事务

事务是报表的老大哥。它们将多个更改组合在一起,无论是在单个语句中还是在几个语句中,因此它们作为一个单元被应用或放弃。大多数情况下,事务只是事后的想法,只是在需要将几个语句一起应用时才考虑。这不是考虑事务的好方法。它们对于确保数据完整性非常重要,如果使用不当,会导致严重的性能问题。

本章通过回顾事务对锁和性能的影响,开始讨论为什么需要从性能的角度认真对待事务。本章的其余部分集中于分析事务,首先使用信息模式中的INNODB_TRX表,然后是 InnoDB 监控器、InnoDB 指标,最后是性能模式。

事务的影响

如果您将事务视为用于分组查询的容器,那么事务可能看起来是一个简单的概念。然而,重要的是要理解,因为事务为查询组提供原子性,所以事务活动的时间越长,与查询相关联的资源被占用的时间就越长,并且事务中完成的工作越多,需要的资源就越多。提交事务之前一直在使用的查询使用了哪些资源?主要的两个是锁和撤销日志。

Tip

InnoDB 支持比读写事务开销更低的只读事务。对于自动提交的单语句事务,InnoDB 将尝试自动确定该语句是否是只读的。对于多语句事务,可以在启动时明确指定它是只读事务:START TRANSACTION READ ONLY;

当查询执行时,它获取锁,并且当您使用默认的事务隔离级别–REPEATABLE READ时,所有的锁都被保留,直到事务被提交。当您使用READ COMMITTED事务隔离级别时,一些锁可能会被释放,但至少那些涉及已更改记录的锁会被保留。锁本身就是一种资源,但是它也需要内存来存储关于锁的信息。对于正常的工作负载来说,您可能不认为这有什么了不起,但是巨大的事务最终会使用如此多的内存,以至于事务失败,并出现ER_LOCK_TABLE_FULL错误:

ERROR: 1206: The total number of locks exceeds the lock table size

从错误日志中记录的警告消息可以看出(更简短地说),锁所需的内存来自缓冲池。因此,持有的锁越多、时间越长,可用于缓存数据和索引的内存就越少。

Caution

因为使用了所有的锁内存而中止一个事务是四重打击。首先,更新足够多的行以使用足够多的锁内存来触发错误需要一些时间。那项工作被浪费了。第二,由于所需更改的数量,回滚事务可能需要很长时间。第三,当锁内存被使用时,InnoDB 实际上处于只读模式(一些小的事务是可能的),并且直到回滚完成后锁内存才被释放。第四,缓冲池中几乎没有空间来缓存数据和索引。

该错误之前,错误日志中有一条警告,指出超过 67%的缓冲池用于锁或自适应哈希索引:

2019-07-06T03:23:04.345256Z 10 [Warning] [MY-011958] [InnoDB] Over 67 percent of the buffer pool is occupied by lock heaps or the adaptive hash index! Check that your transactions do not set too many row locks. Your buffer pool size is 7 MB. Maybe you should make the buffer pool bigger?. Starting the InnoDB Monitor to print diagnostics, including lock heap and hash index sizes.

该警告之后是 InnoDB monitor 的定期重复输出,因此您可以确定哪些事务是罪魁祸首。事务的 InnoDB monitor 输出将在“InnoDB Monitor”部分讨论。

一种在事务中经常被忽略的锁类型是元数据锁。当一个语句查询一个表时,会获取一个共享的元数据锁,并且该元数据锁会一直保持到事务结束。当一个表上有一个元数据锁时,任何连接都不能对该表执行任何 DDL 语句——包括OPTIMIZE TABLE。如果一个 DDL 语句被一个长时间运行的事务阻塞,它将依次阻塞所有使用该表的新查询。第 22 章将展示一个调查此类问题的例子,包括使用本章中的一些方法。

当事务处于活动状态时,锁被持有。但是,即使事务已经通过撤消日志完成,它仍然会产生影响。

撤消日志

如果您选择回滚事务,则还必须根据需要存储事务期间所做的更改。这很容易理解。更令人惊讶的是,即使一个事务没有进行任何更改,也会使来自其他事务的撤销信息保留下来。当事务需要读视图(一致快照)时会发生这种情况,当使用REPEATABLE READ事务隔离级别时,在事务持续期间就是这种情况。读取视图意味着无论其他事务是否更改数据,事务都将返回与事务开始时间相对应的行数据。为了能够实现这一点,有必要保留在事务生命周期中发生变化的行的旧值。具有读视图的长时间运行的事务是导致巨大撤销日志的最常见原因,在 MySQL 5.7 和更早版本中,这可能意味着ibdata1文件变大了。(在 MySQL 8 中,撤消日志总是存储在单独的可以被截断的撤消表空间中。)

Tip

READ COMMITTED事务隔离级别不太容易出现大的撤销日志,因为读取视图只在查询期间维护。

撤销日志活动部分的大小在历史列表长度中测量。历史列表长度是尚未清除撤消日志的已提交事务的数量。这意味着您不能使用历史列表长度来衡量行更改的总量。它告诉您的是在执行查询时必须考虑的变更链表中有多少个旧行单元(每个事务一个单元)。这个链表越长,找到每一行的正确版本的代价就越大。最后,如果您有一个很大的历史列表,它会严重影响所有查询的性能。

Note

历史列表长度的问题是使用逻辑备份工具创建大型数据库备份的最大问题之一,例如使用单个事务获得一致备份的mysqlpumpmysqldump。如果在备份过程中提交了许多事务,备份可能会导致历史列表变得非常长。

什么构成了一个大的历史列表长度?这方面没有严格的规则,只是越小越好。通常,当列表有几千到一百万个事务时,性能问题就开始出现了,但是当历史列表很长时,它成为瓶颈的点取决于撤消日志中提交的事务和工作负载。

当不再需要最旧的部件时,InnoDB 会在后台自动清除历史列表。有两个选项可以控制清洗,也有两个选项可以影响清洗无法进行时会发生什么。这些选项包括

  • innodb_purge_batch_size : 每批清除的撤消日志页数。该批次在清除线程之间划分。该选项不应在生产系统上更改。默认值为 300,有效值介于 1 和 5000 之间。

  • innodb_purge_threads : 并行使用的清除线程数。如果数据更改跨越多个表,那么更高的并行度会很有用。另一方面,如果所有更改都集中在少数几个表上,则首选低值。更改清除线程的数量需要重启 MySQL。默认值为 4,有效值介于 1 和 32 之间。

  • innodb_max_purge_lag : 当历史列表长度大于innodb_max_purge_lag的值时,会给更改数据的操作增加一个延迟,以降低历史列表的增长速度,但代价是语句延迟增加。默认值为 0,这意味着永远不会添加延迟。有效值为 0–4294967295。

  • innodb_max_purge_lag_delay : 当历史列表长度大于innodb_max_purge_lag时,可以添加到 DML 查询的最大延迟。

通常没有必要更改这些设置;但是,在特殊情况下,它可能是有用的。如果清除线程跟不上,您可以尝试根据被修改的表的数量来更改清除线程的数量;修改的表越多,清除线程就越有用。当您更改清除线程的数量时,从更改前的基线开始监控效果非常重要,这样您就可以看到更改是否带来了改进。

最大清除延迟选项可用于降低修改数据的 DML 语句的速度。当写入仅限于特定的连接,并且延迟不会导致创建额外的写入线程以保持相同的吞吐量时,此功能非常有用。

您如何监控事务有多长时间,锁使用了多少内存,以及历史列表有多长?您可以使用信息模式、InnoDB 监控器和性能模式来获取这些信息。

INNODB_TRX(消歧义)

信息模式中的INNODB_TRX表是关于 InnoDB 事务的最专门的信息源。它包括诸如事务何时开始、修改了多少行以及持有多少锁之类的信息。INNODB_TRX表也被sys.innodb_lock_waits视图用来提供一些关于锁等待问题中所涉及的事务的信息。表 21-1 汇总了表中的栏目。

表 21-1

信息架构中的列。INNODB_TRX 表

|

列/数据类型

|

描述

|
| --- | --- |
| trx_id``varchar(18) | 事务记录 id。这在引用事务或与 InnoDB 监控器的输出进行比较时非常有用。否则,id 应该被视为纯内部的,没有任何意义。该 id 仅分配给已修改数据或锁定行的事务;仅执行只读SELECT语句的事务将有一个伪 id,如 421124985258256,如果事务开始修改或锁定记录,该 id 将会改变。 |
| trx_state``varchar(13) | 事务的状态。这可以是RUNNINGLOCK WAITROLLING BACKCOMMITTING中的一个。 |
| trx_started``datetime | 使用系统时区启动事务的时间。 |
| trx_requested_lock_id``varchar(105) | 当trx_stateLOCK WAIT时,该列显示事务正在等待的锁的 id。 |
| trx_wait_started``datetime | 当trx_stateLOCK WAIT时,该列使用系统时区显示锁定等待开始的时间。 |
| trx_weight``bigint unsigned | 根据修改的行数和持有的锁数,衡量事务完成了多少工作。这是用于确定在死锁情况下回滚哪个事务的权重。重量越大,做功越多。 |
| trx_mysql_thread_id``bigint unsigned | 执行事务的连接的连接 id(与性能模式threads表中的PROCESSLIST_ID列相同)。 |
| trx_query``varchar(1024) | 事务当前执行的查询。如果事务空闲,则查询为NULL。 |
| trx_operation_state``varchar(64) | 事务执行的当前操作。即使查询正在执行,这也可能是NULL。 |
| trx_tables_in_use``bigint unsigned | 事务使用的表的数量。 |
| trx_tables_locked``bigint unsigned | 事务持有行锁的表的数量。 |
| trx_lock_structs``bigint unsigned | 事务创建的锁结构的数量。 |
| trx_lock_memory_bytes``bigint unsigned | 事务持有的锁使用的内存量(以字节为单位)。 |
| trx_rows_locked``bigint unsigned | 事务持有的记录锁的数量。虽然被称为行锁,但它也包括索引锁。 |
| trx_rows_modified``bigint unsigned | 事务修改的行数。 |
| trx_concurrency_tickets``bigint unsigned | 当innodb_thread_concurrency不为 0 时,在事务必须允许另一个事务执行工作之前,会给该事务分配innodb_concurrency_tickets个可以使用的票证。一张票对应于访问一行。这一栏显示还剩多少票。 |
| trx_isolation_level``varchar(16) | 用于事务的事务隔离级别。 |
| trx_unique_checks``int | 连接是否启用了unique_checks变量。 |
| trx_foreign_key_checks``int | 连接是否启用了foreign_key_checks变量。 |
| trx_last_foreign_key_error``varchar(256) | 事务遇到的最后一个(如果有)外键错误的错误消息。 |
| trx_adaptive_hash_latched``int | 事务是否锁定了自适应哈希索引的一部分。总共有innodb_adaptive_hash_index_parts个零件。该列实际上是一个布尔值。 |
| trx_adaptive_hash_timeout``bigint unsigned | 是否在多个查询中保持对自适应哈希索引的锁定。如果自适应散列索引只有一部分,并且没有争用,那么超时倒计时,当超时达到 0 时,锁被释放。当存在争用或有多个部分时,每次查询后锁总是被释放,超时值为 0。 |
| trx_is_read_only``int | 该事务是否为只读事务。通过显式声明,事务可以是只读的,或者对于启用了autocommit的单语句事务,InnoDB 可以检测到查询将只读取数据。 |
| trx_autocommit_non_locking``int | 当事务是单语句非锁定的SELECT并且autocommit选项被启用时,该列被设置为 1。当这个列和trx_is_read_only都为 1 时,InnoDB 可以优化事务以减少开销。 |

INNODB_TRX表中获得的信息可以确定哪些事务具有最大的影响。清单 21-1 显示了两个事务返回信息的例子。

mysql> SELECT *
         FROM information_schema.INNODB_TRX\G
*************************** 1\. row ***************************
                    trx_id: 5897
                 trx_state: RUNNING
               trx_started: 2019-07-06 11:11:12
     trx_requested_lock_id: NULL
          trx_wait_started: NULL
                trx_weight: 4552416
       trx_mysql_thread_id: 10
                 trx_query: UPDATE db1.t1 SET val1 = 4
       trx_operation_state: updating or deleting
         trx_tables_in_use: 1
         trx_tables_locked: 1
          trx_lock_structs: 7919
     trx_lock_memory_bytes: 1417424
           trx_rows_locked: 4552415
         trx_rows_modified: 4544497
   trx_concurrency_tickets: 0
       trx_isolation_level: REPEATABLE READ
         trx_unique_checks: 1
    trx_foreign_key_checks: 1
trx_last_foreign_key_error: NULL
 trx_adaptive_hash_latched: 0
 trx_adaptive_hash_timeout: 0
          trx_is_read_only: 0
trx_autocommit_non_locking: 0
*************************** 2\. row ***************************
                    trx_id: 421624759431440
                 trx_state: RUNNING
               trx_started: 2019-07-06 11:46:55
     trx_requested_lock_id: NULL
          trx_wait_started: NULL
                trx_weight: 0
       trx_mysql_thread_id: 8
                 trx_query: SELECT COUNT(*) FROM db1.t1
       trx_operation_state: counting records
         trx_tables_in_use: 1
         trx_tables_locked: 0
          trx_lock_structs: 0
     trx_lock_memory_bytes: 1136
           trx_rows_locked: 0
         trx_rows_modified: 0
   trx_concurrency_tickets: 0
       trx_isolation_level: REPEATABLE READ
         trx_unique_checks: 1
    trx_foreign_key_checks: 1
trx_last_foreign_key_error: NULL
 trx_adaptive_hash_latched: 0
 trx_adaptive_hash_timeout: 0
          trx_is_read_only: 1
trx_autocommit_non_locking: 1
2 rows in set (0.0023 sec)

Listing 21-1Example output of the INNODB_TRX table

第一行显示了修改数据的事务示例。在检索信息时,已经修改了 4,544,497 行,并且还有一点记录锁。您还可以看到事务仍然在主动执行一个查询(一个UPDATE语句)。

第二行是在启用了autocommit的情况下执行的SELECT语句的示例。由于启用了自动提交,事务中只能有一个语句(显式的START TRANSACTION禁用自动提交)。trx_query列显示它是一个没有任何锁定子句的SELECT COUNT(*)查询,因此它是一个只读语句。这意味着 InnoDB 可以跳过一些事情,比如为事务准备锁定和撤销信息,从而减少事务的开销。trx_autocommit_non_locking列被设置为 1 以反映这一点。

您应该担心哪些事务取决于系统上的预期工作负载。如果您有一个 OLAP 工作负载,预计会有相对长时间运行的SELECT查询。对于纯粹的 OLTP 工作负载,任何运行时间超过几秒钟并修改多行的事务都可能是出现问题的迹象。例如,要查找超过一分钟的事务,可以使用以下查询:

SELECT *
  FROM information_schema.INNODB_TRX
 WHERE trx_started < NOW() - INTERVAL 1 MINUTE;

INNODB_TRX表相关的是 InnoDB 监控器中的事务列表。

InnoDB 监控器

InnoDB monitor 是 InnoDB information 的一种瑞士军刀,也包含有关事务的信息。InnoDB 监控器输出中的TRANSACTIONS部分专用于事务信息。该信息不仅包括事务列表,还包括历史列表长度。清单 21-2 显示了 InnoDB monitor 的一个摘录,其中的事务部分的示例取自INNODB_TRX表的上一个输出之后。

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2019-07-06 11:46:58 0x7f7728f69700 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 6 seconds
...
------------
TRANSACTIONS
------------
Trx id counter 5898
Purge done for trx's n:o < 5894 undo n:o < 0 state: running but idle
History list length 3
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 421624759429712, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 421624759428848, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 5897, ACTIVE 2146 sec updating or deleting
mysql tables in use 1, locked 1
7923 lock struct(s), heap size 1417424, 4554508 row lock(s), undo log entries 4546586
MySQL thread id 10, OS thread handle 140149617817344, query id 25 localhost 127.0.0.1 root updating
UPDATE db1.t1 SET val1 = 4

Listing 21-2Transaction information from the InnoDB monitor

TRANSACTIONS部分的顶部显示了事务 id 计数器的当前值,后面是已经从撤销日志中清除的信息。它显示事务 id 小于 5894 的撤消日志已被清除。该清除越晚,历史列表的长度(在该部分的第三行)就越长。从 InnoDB monitor 输出中读取历史列表长度是获取历史列表长度的传统方法。在下一节中,将展示如何在用于监控目的时以更好的方式获取值。

该部分的其余部分是事务列表。注意,虽然输出是用与在INNODB_TRX中找到的相同的两个活动事务生成的,但是事务列表只包括一个活动事务(用于UPDATE语句的事务)。在 MySQL 5.7 和更高版本中,只读非锁定事务不包括在 InnoDB monitor 事务列表中。因此,如果需要包含所有活动的事务,最好使用INNODB_TRX表。

如前所述,还有一种方法可以获得历史列表的长度。为此,您需要使用 InnoDB 指标。

INNODB_METRICS 和 sys.metrics

InnoDB monitor 报告对于数据库管理员了解 InnoDB 中正在发生的事情非常有用,但是对于监控来说,它的用处就没有那么大了,因为它需要进行解析,以监控可以使用的方式获取数据。在本章的前面,您已经看到了如何从information_schema.INNODB_TRX表中获得关于事务的信息,但是像历史列表长度这样的指标又如何呢?

InnoDB 指标系统包括几个指标,在information_schema.INNODB_METRICS视图中显示关于事务的信息。这些指标都位于事务子系统中。清单 21-3 显示了一个事务度量的列表,无论它们是否默认启用,以及一个简短的注释来解释度量的内容。

mysql> SELECT NAME, COUNT, STATUS, COMMENT
         FROM information_schema.INNODB_METRICS
        WHERE SUBSYSTEM = 'transaction'\G
*************************** 1\. row ***************************
   NAME: trx_rw_commits
  COUNT: 0
 STATUS: disabled
COMMENT: Number of read-write transactions  committed
*************************** 2\. row ***************************
   NAME: trx_ro_commits
  COUNT: 0
 STATUS: disabled
COMMENT: Number of read-only transactions committed
*************************** 3\. row ***************************
   NAME: trx_nl_ro_commits
  COUNT: 0
 STATUS: disabled
COMMENT: Number of non-locking auto-commit read-only transactions committed
*************************** 4\. row ***************************
   NAME: trx_commits_insert_update
  COUNT: 0
 STATUS: disabled
COMMENT: Number of transactions committed with inserts and updates
*************************** 5\. row ***************************
   NAME: trx_rollbacks
  COUNT: 0
 STATUS: disabled
COMMENT: Number of transactions rolled back
*************************** 6\. row ***************************
   NAME: trx_rollbacks_savepoint
  COUNT: 0
 STATUS: disabled
COMMENT: Number of transactions rolled back to savepoint
*************************** 7\. row ***************************
   NAME: trx_rollback_active
  COUNT: 0
 STATUS: disabled
COMMENT: Number of resurrected active transactions rolled back
*************************** 8\. row ***************************
   NAME: trx_active_transactions
  COUNT: 0
 STATUS: disabled
COMMENT: Number of active transactions
*************************** 9\. row ***************************
   NAME: trx_on_log_no_waits
  COUNT: 0
 STATUS: disabled
COMMENT: Waits for redo during transaction commits
*************************** 10\. row ***************************
   NAME: trx_on_log_waits
  COUNT: 0
 STATUS: disabled
COMMENT: Waits for redo during transaction commits
*************************** 11\. row ***************************
   NAME: trx_on_log_wait_loops

  COUNT: 0
 STATUS: disabled
COMMENT: Waits for redo during transaction commits
*************************** 12\. row ***************************
   NAME: trx_rseg_history_len
  COUNT: 45
 STATUS: enabled
COMMENT: Length of the TRX_RSEG_HISTORY list
*************************** 13\. row ***************************
   NAME: trx_undo_slots_used
  COUNT: 0
 STATUS: disabled
COMMENT: Number of undo slots used
*************************** 14\. row ***************************
   NAME: trx_undo_slots_cached
  COUNT: 0
 STATUS: disabled
COMMENT: Number of undo slots cached
*************************** 15\. row ***************************
   NAME: trx_rseg_current_size
  COUNT: 0
 STATUS: disabled
COMMENT: Current rollback segment size in pages
15 rows in set (0.0403 sec)

Listing 21-3InnoDB metrics related to transactions

这些指标中最重要的是trx_rseg_history_len,它是历史列表长度。这也是默认情况下启用的唯一指标。与提交和回滚相关的指标可用于确定您拥有多少读写、只读和非锁定只读事务,以及它们提交和回滚的频率。许多回滚表明存在问题。如果您怀疑重做日志是一个瓶颈,那么可以使用trx_on_log_%指标来衡量在事务提交期间有多少事务在等待重做日志。

Tip

使用innodb_monitor_enable选项启用 InnoDB 指标,使用innodb_monitor_disable禁用它们。这可以动态完成。

查询 InnoDB 指标的另一种方便的方法是使用sys.metrics视图,其中也包括全局状态变量。清单 21-4 展示了一个使用sys.metrics视图获取当前值以及指标是否启用的例子。

mysql> SELECT Variable_name AS Name,
              Variable_value AS Value,
              Enabled
         FROM sys.metrics
        WHERE Type = 'InnoDB Metrics - transaction';
+---------------------------+-------+---------+
| Name                      | Value | Enabled |
+---------------------------+-------+---------+
| trx_active_transactions   | 0     | NO      |
| trx_commits_insert_update | 0     | NO      |
| trx_nl_ro_commits         | 0     | NO      |
| trx_on_log_no_waits       | 0     | NO      |
| trx_on_log_wait_loops     | 0     | NO      |
| trx_on_log_waits          | 0     | NO      |
| trx_ro_commits            | 0     | NO      |
| trx_rollback_active       | 0     | NO      |
| trx_rollbacks             | 0     | NO      |
| trx_rollbacks_savepoint   | 0     | NO      |
| trx_rseg_current_size     | 0     | NO      |
| trx_rseg_history_len      | 45    | YES     |
| trx_rw_commits            | 0     | NO      |
| trx_undo_slots_cached     | 0     | NO      |
| trx_undo_slots_used       | 0     | NO      |
+---------------------------+-------+---------+
15 rows in set (0.0152 sec)

Listing 21-4Using the sys.metrics view to get the transaction metrics

这表明历史列表长度为 45,这是一个很低的值,因此撤销日志几乎没有开销。其余指标被禁用。

迄今为止,关于事务信息的讨论一直是关于所有事务或单个事务的汇总统计数据。如果您想更深入地了解事务完成了什么工作,您需要使用性能模式。

性能模式事务

性能模式支持 MySQL 5.7 和更高版本中的事务监控,并且在 MySQL 8 中默认启用。在性能模式中,除了与 XA 事务和保存点相关的事务细节之外,没有多少事务细节是不能从信息模式中的INNODB_TRX表获得的。但是,Performance Schema 事务事件的优势在于,您可以将它们与其他事件类型(如语句)相结合,以获取有关事务所做工作的信息。这是本节的主要重点。此外,性能模式提供了带有聚合统计信息的汇总表。

事务事件及其声明

性能模式中用于调查事务的主要表是事务事件表。有三个表格记录当前或最近的事务:events_transactions_currentevents_transactions_historyevents_transactions_history_long。它们具有表 21-2 中总结的列。

表 21-2

非汇总事务事件表的列

|

列/数据类型

|

描述

|
| --- | --- |
| THREAD_ID``bigint unsigned | 执行事务的连接的性能架构线程 id。 |
| EVENT_ID``bigint unsigned | 事件的事件 id。您可以使用事件 id 对线程的事件进行排序,或者将事件 id 作为外键与事件表之间的线程 id 一起使用。 |
| END_EVENT_ID``bigint unsigned | 事务完成时的事件 id。如果事件 id 为NULL,则事务仍在进行。 |
| EVENT_NAME``varchar(128) | 事务事件名称。目前,该列的值始终为transaction。 |
| STATE``enum | 事务的状态。可能的值有ACTIVECOMMITTED,ROLLED BACK。 |
| TRX_ID``bigint unsigned | 这是当前未使用的,将始终是NULL。 |
| GTID``varchar(64) | 事务记录的 GTID。当自动确定 GTID 时(通常),返回AUTOMATIC。这与执行事务的连接的gtid_next变量相同。 |
| XID_FORMAT_ID``int | 对于 XA 事务,格式 id。 |
| XID_GTRID``varchar(130) | 对于 XA 事务,是 gtrid 值。 |
| XID_BQUAL``varchar(130) | 对于 XA 事务,bqual 值。 |
| XA_STATE``varchar(64) | 对于 XA 事务,是事务的状态。这可以是ACTIVEIDLEPREPAREDROLLED BACKCOMMITTED。 |
| SOURCE``varchar(64) | 记录事件的源代码文件和行号。 |
| TIMER_START``bigint unsigned | 事件开始的时间,以皮秒为单位。 |
| TIMER_END``bigint unsigned | 事件完成的时间,以皮秒为单位。如果事务尚未完成,则该值对应于当前时间。 |
| TIMER_WAIT``bigint unsigned | 执行事件所用的总时间(皮秒)。如果事件尚未完成,则该值对应于事务处于活动状态的时间。 |
| ACCESS_MODE``enum | 事务处于只读(READ ONLY)还是读写(READ WRITE)模式。 |
| ISOLATION_LEVEL``varchar(64) | 事务的事务隔离级别。 |
| AUTOCOMMIT``enum | 事务是否基于autocommit选项自动提交,以及显式事务是否已经开始。可能的值是NOYES。 |
| NUMBER_OF_SAVEPOINTS``bigint unsigned | 事务中创建的保存点数。 |
| NUMBER_OF_ROLLBACK_TO_SAVEPOINT``bigint unsigned | 事务回滚到保存点的次数。 |
| NUMBER_OF_RELEASE_SAVEPOINT``bigint unsigned | 事务释放保存点的次数。 |
| OBJECT_INSTANCE_BEGIN``bigint unsigned | 该字段目前未被使用,并且总是被设置为NULL。 |
| NESTING_EVENT_ID``bigint unsigned | 触发事务的事件的事件 id。 |
| NESTING_EVENT_TYPE``enum | 触发事务的事件的事件类型。 |

如果您正在处理 XA 事务,那么当您需要恢复一个事务时,事务事件表是非常有用的,因为格式 id、gtrid 和 bqual 值可以直接从表中获得,这与必须解析输出的XA RECOVER语句不同。同样,如果您使用保存点,您可以获得保存点使用情况的统计数据。除此之外,该信息与INNODB_TRX表中的信息非常相似。

举一个使用events_transactions_current表的例子,您可以启动两个事务。第一个事务是更新几个城市人口的普通事务:

START TRANSACTION;
UPDATE world.city SET Population = 5200000 WHERE ID = 130;
UPDATE world.city SET Population = 4900000 WHERE ID = 131;
UPDATE world.city SET Population = 2400000 WHERE ID = 132;
UPDATE world.city SET Population = 2000000 WHERE ID = 133;

第二个事务是 XA 事务:

XA START 'abc', 'def', 1;
UPDATE world.city SET Population = 900000 WHERE ID = 3805;

清单 21-5 显示了events_transactions_current表的输出示例,列出了当前活动的事务。

mysql> SELECT *
         FROM performance_schema.events_transactions_current
        WHERE STATE = 'ACTIVE'\G
*************************** 1\. row ***************************
                      THREAD_ID: 54
                       EVENT_ID: 39
                   END_EVENT_ID: NULL
                     EVENT_NAME: transaction
                          STATE: ACTIVE
                         TRX_ID: NULL
                           GTID: AUTOMATIC
                  XID_FORMAT_ID: NULL
                      XID_GTRID: NULL
                      XID_BQUAL: NULL
                       XA_STATE: NULL
                         SOURCE: transaction.cc:219
                    TIMER_START: 488967975158077184
                      TIMER_END: 489085567376530432
                     TIMER_WAIT: 117592218453248
                    ACCESS_MODE: READ WRITE
                ISOLATION_LEVEL: REPEATABLE READ
                     AUTOCOMMIT: NO
           NUMBER_OF_SAVEPOINTS: 0
NUMBER_OF_ROLLBACK_TO_SAVEPOINT: 0
    NUMBER_OF_RELEASE_SAVEPOINT: 0
          OBJECT_INSTANCE_BEGIN: NULL
               NESTING_EVENT_ID: 38
             NESTING_EVENT_TYPE: STATEMENT
*************************** 2\. row ***************************
                      THREAD_ID: 57
                       EVENT_ID: 10
                   END_EVENT_ID: NULL
                     EVENT_NAME: transaction
                          STATE: ACTIVE
                         TRX_ID: NULL
                           GTID: AUTOMATIC

                  XID_FORMAT_ID: 1
                      XID_GTRID: abc
                      XID_BQUAL: def
                       XA_STATE: ACTIVE
                         SOURCE: transaction.cc:219
                    TIMER_START: 488977176010232448
                      TIMER_END: 489085567391481984
                     TIMER_WAIT: 108391381249536
                    ACCESS_MODE: READ WRITE
                ISOLATION_LEVEL: REPEATABLE READ
                     AUTOCOMMIT: NO
           NUMBER_OF_SAVEPOINTS: 0
NUMBER_OF_ROLLBACK_TO_SAVEPOINT: 0
    NUMBER_OF_RELEASE_SAVEPOINT: 0
          OBJECT_INSTANCE_BEGIN: NULL
               NESTING_EVENT_ID: 9
             NESTING_EVENT_TYPE: STATEMENT
2 rows in set (0.0007 sec)

Listing 21-5Using the events_transactions_current table

第 1 行中的事务是常规事务,而第 2 行中的事务是 XA 事务。两个事务都是由一个语句启动的,这可以从嵌套事件类型中看出。如果想找到触发事务的语句,可以使用它来查询events_statements_history表,如下所示

mysql> SELECT SQL_TEXT
         FROM performance_schema.events_statements_history
        WHERE THREAD_ID = 54
              AND EVENT_ID = 38\G
*************************** 1\. row ***************************
SQL_TEXT: START TRANSACTION
1 row in set (0.0009 sec)

这表明由THREAD_ID = 54执行的事务是使用START TRANSACTION语句开始的。因为events_statements_history表只包括连接的最后十条语句,所以不能保证启动事务的语句仍然在历史表中。当autocommit被禁用时,如果您正在查看一个单语句事务或第一条语句(当它仍在执行时),您将需要查询events_statements_current表。

事务和语句之间的关系也是相反的。给定一个事务事件 id 和线程 id,您可以使用语句事件历史和当前表来查询为该事务执行的最后十条语句。清单 21-6 显示了THREAD_ID = 54和事务EVENT_ID = 39的示例(来自清单 21-5 的第 1 行),其中包含了开始事务的语句和后续语句。

mysql> SET @thread_id = 54,
           @event_id = 39,
           @nesting_event_id = 38;

mysql> SELECT EVENT_ID, SQL_TEXT,
              FORMAT_PICO_TIME(TIMER_WAIT) AS Latency,
              IF(END_EVENT_ID IS NULL, 'YES', 'NO') AS IsCurrent
         FROM ((SELECT EVENT_ID, END_EVENT_ID,
                       TIMER_WAIT,
                       SQL_TEXT, NESTING_EVENT_ID,
                       NESTING_EVENT_TYPE
                  FROM performance_schema.events_statements_current
                 WHERE THREAD_ID = @thread_id
               ) UNION (
                SELECT EVENT_ID, END_EVENT_ID,
                       TIMER_WAIT,
                       SQL_TEXT, NESTING_EVENT_ID,
                       NESTING_EVENT_TYPE
                  FROM performance_schema.events_statements_history
                 WHERE THREAD_ID = @thread_id
               )
              ) events
        WHERE (NESTING_EVENT_TYPE = 'TRANSACTION'
               AND NESTING_EVENT_ID = @event_id)
              OR EVENT_ID = @nesting_event_id
        ORDER BY EVENT_ID DESC\G
*************************** 1\. row ***************************
 EVENT_ID: 43
 SQL_TEXT: UPDATE city SET Population = 2000000 WHERE ID = 133
  Latency: 291.01 us
IsCurrent: NO
*************************** 2\. row ***************************
 EVENT_ID: 42
 SQL_TEXT: UPDATE city SET Population = 2400000 WHERE ID = 132
  Latency: 367.59 us

IsCurrent: NO
*************************** 3\. row ***************************
 EVENT_ID: 41
 SQL_TEXT: UPDATE city SET Population = 4900000 WHERE ID = 131
  Latency: 361.03 us
IsCurrent: NO
*************************** 4\. row ***************************
 EVENT_ID: 40
 SQL_TEXT: UPDATE city SET Population = 5200000 WHERE ID = 130
  Latency: 399.32 us
IsCurrent: NO
*************************** 5\. row ***************************
 EVENT_ID: 38
 SQL_TEXT: START TRANSACTION
  Latency: 97.37 us
IsCurrent: NO
9 rows in set (0.0012 sec)

Listing 21-6Finding the last ten statements executed in a transaction

子查询(一个派生表)从events_statements_currentevents_statements_history表中找到线程的所有语句事件。有必要包括当前事件,因为可能有正在进行的事务报表。通过作为事务的子事务或事务的嵌套事件来过滤语句(EVENT_ID = 38)。这将包括从启动事务的语句开始的所有语句。如果有正在进行的陈述,则最多有 11 个陈述,否则最多有 10 个。

END_EVENT_ID用于确定语句当前是否正在执行,使用EVENT_ID对语句进行反向排序,因此最新的语句在第 1 行,最老的(START TRANSACTION语句)在第 5 行。

这种类型的查询不仅对调查仍在执行查询的事务有用。当您遇到一个空闲事务,并且想知道该事务在被放弃之前做了什么时,它也非常有用。寻找活动事务的另一种相关方法是使用sys.session视图,该视图使用events_transactions_current表来包含每个连接的事务状态信息。清单 21-7 显示了一个查询活动事务的例子,不包括执行查询的连接行。

mysql> SELECT *
         FROM sys.session
        WHERE trx_state = 'ACTIVE'
              AND conn_id <> CONNECTION_ID()\G
*************************** 1\. row ***************************
                thd_id: 54
               conn_id: 16
                  user: mysqlx/worker
                    db: world
               command: Sleep
                 state: NULL
                  time: 690
     current_statement: UPDATE world.city SET Population = 2000000 WHERE ID = 133
     statement_latency: NULL
              progress: NULL
          lock_latency: 281.76 ms
         rows_examined: 341
             rows_sent: 341
         rows_affected: 0
            tmp_tables: 0
       tmp_disk_tables: 0
             full_scan: NO
        last_statement: UPDATE world.city SET Population = 2000000 WHERE ID = 133
last_statement_latency: 391.80 ms

        current_memory: 2.35 MiB
             last_wait: NULL
     last_wait_latency: NULL
                source: NULL
           trx_latency: 11.49 m
             trx_state: ACTIVE
        trx_autocommit: NO
                   pid: 23376
          program_name: mysqlsh
*************************** 2\. row ***************************
                thd_id: 57
               conn_id: 18
                  user: mysqlx/worker
                    db: world
               command: Sleep
                 state: NULL
                  time: 598
     current_statement: UPDATE world.city SET Population = 900000 WHERE ID = 3805
     statement_latency: NULL
              progress: NULL
          lock_latency: 104.00 us
         rows_examined: 1
             rows_sent: 0
         rows_affected: 1
            tmp_tables: 0
       tmp_disk_tables: 0
             full_scan: NO
        last_statement: UPDATE world.city SET Population = 900000 WHERE ID = 3805
last_statement_latency: 40.21 ms
        current_memory: 344.76 KiB
             last_wait: NULL
     last_wait_latency: NULL
                source: NULL
           trx_latency: 11.32 m
             trx_state: ACTIVE
        trx_autocommit: NO
                   pid: 25836
          program_name: mysqlsh
2 rows in set (0.0781 sec)

Listing 21-7Finding active transactions with sys.session

这表明第一行中的事务已经活动了 11 分钟以上,并且距离上次执行查询已经过去了 690 秒(11.5 分钟)(您的值会有所不同)。last_statement可以用来确定连接执行的最后一个查询。这是一个被放弃的事务的例子,它阻止了 InnoDB 清除它的撤销日志。放弃事务的最常见原因是数据库管理员交互地启动了一个事务,然后分心了,或者是autocommit被禁用了,没有意识到一个事务已经启动了。

Caution

如果您禁用了autocommit,请始终注意在工作结束时提交或回滚。一些连接器默认禁用autocommit,所以请注意您的应用可能没有使用服务器默认设置。

您可以回滚事务以避免更改任何数据。对于第一笔(正常)事务:

mysql> ROLLBACK;
Query OK, 0 rows affected (0.0841 sec)

对于 XA 事务:

mysql> XA END 'abc', 'def', 1;
Query OK, 0 rows affected (0.0003 sec)

mysql> XA ROLLBACK 'abc', 'def', 1;
Query OK, 0 rows affected (0.0759 sec)

性能模式表对分析事务有用的另一种方式是使用汇总表来获得聚合数据。

事务汇总表

与可以用来获得所执行语句的报告的语句汇总表一样,也可以使用事务汇总表来分析事务的使用情况。虽然它们不像它们的对应物那样有用,但是它们确实提供了对以不同方式使用事务的连接和账户的洞察。

共有五个事务摘要表,可以按帐户、主机、线程或用户对数据进行全局分组。所有摘要也按事件名称分组,但由于目前只有一个事务事件(transaction),所以它是一个零操作。这些桌子是

  • events_transactions_summary_global_by_event_name : 汇总所有事务。该表中只有一行。

  • events_transactions_summary_by_account_by_event_name : 按用户名和主机名分组的事务。

  • events_transactions_summary_by_host_by_event_name : 按账户主机名分组的事务。

  • events_transactions_summary_by_thread_by_event_name : 按线程分组的事务。仅包括当前存在的线程。

  • events_transactions_summary_by_user_by_event_name : 按账户用户名部分分组的事件。

每个表都包括对事务统计信息进行分组的列和三组列:总计、读写事务和只读事务。对于这三组列中的每一组,都有事务总数以及总延迟、最小延迟、平均延迟和最大延迟。清单 21-8 显示了来自events_transactions_summary_global_by_event_name表的数据示例。

mysql> SELECT *
         FROM performance_schema.events_transactions_summary_global_by_event_name\G
*************************** 1\. row ***************************
          EVENT_NAME: transaction
          COUNT_STAR: 1274
      SUM_TIMER_WAIT: 13091950115512576
      MIN_TIMER_WAIT: 7293440
      AVG_TIMER_WAIT: 10276255661056
      MAX_TIMER_WAIT: 11777025727144832
    COUNT_READ_WRITE: 1273
SUM_TIMER_READ_WRITE: 13078918924805888
MIN_TIMER_READ_WRITE: 7293440
AVG_TIMER_READ_WRITE: 10274091697408
MAX_TIMER_READ_WRITE: 11777025727144832
     COUNT_READ_ONLY: 1
 SUM_TIMER_READ_ONLY: 13031190706688
 MIN_TIMER_READ_ONLY: 13031190706688
 AVG_TIMER_READ_ONLY: 13031190706688
 MAX_TIMER_READ_ONLY: 13031190706688
1 row in set (0.0005 sec)

Listing 21-8The events_transactions_summary_global_by_event_name table

当您研究输出中有多少事务,尤其是读写事务时,您可能会感到惊讶。请记住,在查询 InnoDB 表时,即使您没有明确指定事务,所有事情都是事务。因此,即使一个简单的查询单行的SELECT语句也算作一个事务。关于读写事务和只读事务之间的分布,只有当您显式地以只读方式启动事务时,性能模式才会将其视为只读:

START TRANSACTION READ ONLY;

当 InnoDB 确定自动提交的单语句事务可以被视为只读事务时,它仍然会计入性能模式中的读写统计数据。

摘要

事务是数据库中的一个重要概念。它们有助于确保您可以将更改作为一个单元应用到几行,并且可以选择是应用更改还是回滚更改。

本章一开始讨论了为什么了解如何使用事务很重要。虽然它们本身可以被认为是更改的容器,但锁会一直保持到事务被提交或回滚,并且它们可以阻止撤消日志被清除。锁和大量撤消日志都会影响查询的性能,即使它们不是在导致大量锁或大量撤消日志的事务中执行的。锁使用来自缓冲池的内存,因此可用于缓存数据和索引的内存较少。根据历史列表长度来衡量,大量的撤销日志意味着在 InnoDB 执行语句时必须考虑更多的行版本。

本章的其余部分讨论了如何分析正在进行的和过去的事务。信息模式中的INNODB_TRX表是正在进行的事务的最佳信息源。InnoDB monitor 和 InnoDB metrics 对此进行了补充。对于 XA 事务和使用保存点的事务,或者当您需要调查哪些语句作为事务的一部分被执行时,您需要使用性能模式事务事件表。性能模式还包括一些汇总表,您可以使用这些表来获得关于谁在读写和只读事务上花费时间的更多信息。

锁在事务讨论中扮演了重要的角色。下一章将展示如何分析一系列的锁问题。

二十二、诊断锁的争用

在第 18 章中,你被介绍到了 MySQL 中的锁的世界。如果你还没有读过第 18 章的话,强烈建议你现在就去读,因为这一章是紧密相关的。如果你已经读过一段时间了,你甚至可能想刷新一下你的记忆。锁定问题是性能问题的常见原因之一,其影响可能非常严重。在最坏的情况下,查询可能会失败,连接会堆积起来,因此无法建立新的连接。因此,了解如何调查锁定问题并修复这些问题非常重要。

本章将讨论四类锁问题:

  • 清空锁

  • 元数据和模式锁

  • 记录级锁,包括间隙锁

  • 僵局

每一类锁都使用不同的技术来确定锁争用的原因。当您阅读示例时,您应该记住,可以使用类似的技术来调查与示例不完全匹配的锁问题。

对于每个锁类别,讨论分为六个部分:

  • 症状:这描述了您如何识别遇到了这种锁定问题。

  • 原因:您遇到这种锁定问题的根本原因。这与第 18 章中对锁的一般性讨论有关。

  • 设置:这包括设置锁定问题的步骤,如果你想自己尝试的话。因为锁争用需要多个连接,所以提示符,例如Connection 1>,用于告诉哪个连接应该用于哪个语句。如果您希望在调查过程中获得的信息不会比在真实案例中获得的更多,那么您可以跳过这一部分,在完成调查后再回头查看。

  • 调查:调查的细节。这利用了第 18 章的“监控锁”一节。

  • 解决方案:如何解决即时锁定问题,从而最大限度地减少由此导致的停机。

  • 预防:讨论如何减少遇到问题的机会。这与第 18 章中的“减少锁定问题”一节密切相关。

说得够多了,首先要讨论的锁类别是齐平锁。

清空锁

MySQL 中常见的锁问题之一是刷新锁。当这个问题发生时,用户通常会抱怨查询没有返回,监控可能会显示查询越积越多,最终 MySQL 将耗尽连接。关于刷新锁的问题有时也是最难调查的锁问题之一。

症状

flush lock 问题的主要症状是数据库陷入停滞,所有使用部分或全部表的新查询都要等待 flush lock。要寻找的迹象包括:

  • 新查询的查询状态是“等待表刷新”这可能发生在所有新查询中,也可能只发生在访问特定表的查询中。

  • 越来越多的连接被创建。

  • 最终,由于 MySQL 失去连接,新的连接会失败。新连接收到的错误为ER_CON_COUNT_ERROR:使用经典 MySQL 协议(默认端口 3306)时“错误 1040 (HY000):连接过多”或使用 X 协议(默认端口 33060)时“MySQL 错误 5011:无法打开会话”。

  • 至少有一个查询的运行时间晚于最早的刷新锁请求。

  • 进程列表中可能会有一个FLUSH TABLES语句,但并不总是这样。

  • FLUSH TABLES语句等待lock_wait_timeout时,出现ER_LOCK_WAIT_TIMEOUT错误:ERROR: 1205: Lock wait timeout exceeded; try restarting transaction。因为lock_wait_timeout的默认值是 365 天,所以只有在超时时间减少的情况下,这种情况才有可能发生。

  • 如果您使用默认模式集连接到mysql命令行客户端,那么在您到达提示符之前,连接可能会挂起。如果在连接打开的情况下更改默认模式,也会发生同样的情况。

Tip

如果您使用禁用收集自动完成信息的-A选项启动客户端,则不会出现mysql命令行客户端阻塞的问题。更好的解决方案是使用 MySQL Shell,它以一种不会因刷新锁而阻塞的方式获取自动完成信息。

如果您看到这些症状,是时候了解是什么导致了锁定问题。

原因

当一个连接请求刷新一个表时,它要求关闭对该表的所有引用,这意味着没有活动查询可以使用该表。因此,当刷新请求到达时,它必须等待所有使用要刷新的表的查询完成。请注意,除非您明确指定要刷新哪些表,否则必须完成的只是查询,而不是整个事务。显然,所有表都被刷新的情况是最严重的,例如由于FLUSH TABLES WITH READ LOCK,因为这意味着所有活动查询必须在 flush 语句可以继续之前完成。

当等待刷新锁成为一个问题时,这意味着有一个或多个查询阻止了FLUSH TABLES语句获得刷新锁。由于FLUSH TABLES语句需要一个排他锁,因此它会阻止后续查询获取它们需要的共享锁。

在备份过程需要刷新所有表并获得读锁以创建一致备份的情况下,此问题经常出现。

FLUSH TABLES语句超时或被终止,但后续查询没有继续进行时,可能会出现一种特殊情况。出现这种情况是因为低级表定义缓存(TDC)版本锁没有被释放。这种情况可能会引起混淆,因为后续查询仍在等待表刷新的原因并不明显。

设置

将要调查的锁定情况涉及三个连接(不包括用于调查的连接)。第一个连接执行慢速查询,第二个连接使用读锁刷新所有表,最后一个连接执行快速查询。这些声明是

Connection 1> SELECT city.*, SLEEP(180) FROM world.city WHERE ID = 130;

Connection 2> FLUSH TABLES WITH READ LOCK;

Connection 3> SELECT * FROM world.city WHERE ID = 3805;

在第一个查询中使用SLEEP(180)意味着您有三分钟(180 秒)的时间来执行另外两个查询并执行调查。如果你想要更长的时间,你可以增加睡眠的持续时间。你现在可以开始调查了。

调查

对刷新锁的调查要求您查看实例上运行的查询列表。与其他锁争用不同,没有性能模式表或 InnoDB monitor 报告可用于直接查询阻塞查询。

清单 22-1 显示了使用sys.session视图的输出示例。使用获取查询列表的替代方法将产生类似的结果。线程和连接 id 以及语句延迟会有所不同。

mysql> SELECT thd_id, conn_id, state,
              current_statement,
              statement_latency
         FROM sys.session
        WHERE command = 'Query'\G
*************************** 1\. row ***************************
           thd_id: 30
          conn_id: 9
            state: User sleep
current_statement: SELECT city.*, SLEEP(180) FROM city WHERE ID = 130
statement_latency: 49.97 s
*************************** 2\. row ***************************
           thd_id: 53
          conn_id: 14
            state: Waiting for table flush
current_statement: FLUSH TABLES WITH READ LOCK
statement_latency: 44.48 s
*************************** 3\. row ***************************
           thd_id: 51
          conn_id: 13
            state: Waiting for table flush
current_statement: SELECT * FROM world.city WHERE ID = 3805
statement_latency: 41.93 s
*************************** 4\. row ***************************
           thd_id: 29
          conn_id: 8
            state: NULL
current_statement: SELECT thd_id, conn_id, state, ... ession WHERE command = 'Query'
statement_latency: 56.13 ms
4 rows in set (0.0644 sec)

Listing 22-1Investigating flush lock contention using sys.session

输出中有四个查询。默认情况下,sys.sessionsys.processlist视图根据执行时间以降序对查询进行排序。这使得调查类似围绕刷新锁的争用这样的问题变得容易,在查找原因时,查询时间是要考虑的主要因素。

您开始寻找FLUSH TABLES语句(稍后将讨论没有FLUSH TABLES语句的情况)。在这种情况下,那就是thd_id = 53(第二排)。注意,FLUSH语句的状态是“等待表刷新”然后查找已经运行了较长时间的查询。在这种情况下,只有一个查询:带有thd_id = 30的查询。这是阻止FLUSH TABLES WITH READ LOCK完成的查询。通常,可能有不止一个查询。

剩下的两个查询是被FLUSH TABLES WITH READ LOCK阻塞的查询和获取输出的查询。前三个查询一起构成了一个长时间运行的查询阻塞一个FLUSH TABLES语句的典型例子,该语句又阻塞了其他查询。

您还可以从 MySQL Workbench 获取进程列表,在某些情况下,还可以从您的监控解决方案中获取。图 22-1 展示了如何从 MySQL Workbench 获取进程列表。

img/484666_1_En_22_Fig1_HTML.jpg

图 22-1

显示 MySQL Workbench 中的客户端连接

要在 MySQL Workbench 中获得进程列表报告,请在屏幕左侧的导航器窗格中选择管理下的客户端连接项。您不能选择要包括哪些列,并且为了使文本可读,屏幕截图中只包括报告的一部分。 Id 列对应sys.session输出中的conn_id,而线程(最右边一列)对应thd_id。完整的截图作为figure_22_1_workbench_flush_lock.png收录在本书的 GitHub 知识库中。

22-2 显示了来自 MySQL 企业监控器(MEM)的进程报告的例子。

img/484666_1_En_22_Fig2_HTML.jpg

图 22-2

MEM 冲水闸调查流程报告

在各个实例的指标菜单项下可以找到进程报告。您可以选择要在输出中包含的列。在本书的 GitHub 知识库中可以找到一个包含更多细节的报告示例figure_22_2_mem_flush_lock.png

类似 MySQL Workbench 和 MySQL Enterprise Monitor 中的报告的一个优点是,它们使用现有的连接来创建报告。在锁问题导致所有连接都被使用的情况下,使用监控解决方案获得查询列表是非常宝贵的。

如前所述,FLUSH TABLES语句可能并不总是出现在查询列表中。仍然有查询等待刷新表的原因是低级 TDC 版本锁。调查的原则保持不变,但它似乎令人困惑。清单 22-2 展示了这样一个例子,使用相同的设置,但是在调查之前杀死执行 flush 语句的连接(Ctrl+C 可以在 MySQL Shell 中用于执行FLUSH TABLES WITH READ LOCK的连接)。

mysql> SELECT thd_id, conn_id, state,
              current_statement,
              statement_latency
         FROM sys.session
        WHERE command = 'Query'\G
*************************** 1\. row ***************************
           thd_id: 30
          conn_id: 9
            state: User sleep
current_statement: SELECT *, SLEEP(180) FROM city WHERE ID = 130
statement_latency: 24.16 s
*************************** 2\. row ***************************
           thd_id: 51
          conn_id: 13
            state: Waiting for table flush
current_statement: SELECT * FROM world.city WHERE ID = 3805
statement_latency: 20.20 s
*************************** 3\. row ***************************
           thd_id: 29
          conn_id: 8
            state: NULL
current_statement: SELECT thd_id, conn_id, state, ... ession WHERE command = 'Query'
statement_latency: 47.02 ms
3 rows in set (0.0548 sec)

Listing 22-2Flush lock contention without a FLUSH TABLES statement

这种情况与前一种情况相同,只是没有了FLUSH TABLES语句。在这种情况下,查找等待时间最长且状态为“等待表刷新”的查询运行时间超过该查询等待时间的查询会阻止 TDC 版本锁被释放。在这种情况下,这意味着thd_id = 30是阻塞查询。

一旦您确定了问题和涉及的主要查询,您需要决定如何处理该问题。

解决方案

解决这个问题有两个层次。首先,您需要解决查询不执行的直接问题。其次,你需要努力避免将来出现这种问题。本小节将讨论即时解决方案,下一小节将考虑如何减少问题发生的几率。

要解决眼前的问题,您可以选择等待查询完成或开始终止查询。如果您可以在刷新锁争用正在进行时重定向应用以使用另一个实例,那么通过让长时间运行的查询完成,您也许能够让这种情况自行解决。如果在那些正在运行或等待的查询中有数据更改查询,在这种情况下,您确实需要考虑在所有查询完成后,它是否会使系统保持一致的状态。一种选择是以只读模式继续在不同的实例上执行读取查询。

如果您决定终止查询,您可以尝试终止FLUSH TABLES语句。如果这行得通,这是最简单的解决方案。然而,正如所讨论的那样,这并不总是有帮助的,在这种情况下,唯一的解决方案是终止那些阻止FLUSH TABLES语句完成的查询。如果长时间运行的查询看起来像失控的查询,并且执行它们的应用/客户端不再等待它们,那么您可能想要杀死它们,而不是试图首先杀死FLUSH TABLES语句。

在终止查询时,一个重要的考虑因素是有多少数据被更改。对于一个纯粹的SELECT查询(不涉及存储的例程),那总是没什么,从所做工作的角度来看,杀死它是安全的。然而,对于INSERTUPDATEDELETE和类似的查询,如果查询被终止,则更改的数据必须回滚。回滚更改通常比一开始就进行更改需要更长的时间,所以如果有很多更改,请准备好等待很长时间才能回滚。您可以使用information_schema.INNODB_TRX表,通过查看trx_rows_modified列来估计完成的工作量。如果有大量工作要回滚,通常最好让查询完成。

Caution

当 DML 语句被终止时,它所做的工作必须回滚。回滚通常比创建变更花费更长的时间,有时甚至更长。如果你考虑终止一个长时间运行的 DML 语句,你需要考虑到这一点。

当然,最理想的情况是完全防止问题发生。

预防

刷新锁争用的发生是因为长时间运行的查询和一个FLUSH TABLES语句的组合。因此,为了防止这个问题,你需要看看你能做些什么来避免这两种情况同时出现。

查找、分析和处理长时间运行的查询将在整本书的其他章节中讨论。一个特别有趣的选项是为查询设置超时。使用max_execution_time系统变量和MAX_EXECUTION_TIME(N)优化器提示的SELECT语句支持这一点,这是防止失控查询的一个好方法。一些连接器还支持超时查询。

Tip

为了避免长时间运行的SELECT查询,您可以配置max_execution_time选项或者设置MAX_EXECUTION_TIME(N)优化器提示。这将使SELECT语句在指定的时间段后超时,并有助于防止类似刷新锁等待的问题。

无法阻止某些长时间运行的查询。这可能是一项报告作业、构建缓存表或其他必须访问大量数据的任务。在这种情况下,您能做的最好的事情就是尽量避免它们运行,同时也有必要刷新表。一种选择是将长时间运行的查询安排在不同于需要刷新表的时间运行。另一种选择是让长时间运行的查询在不同于需要刷新表的作业的实例上运行。

需要刷新表的一个常见任务是进行备份。在 MySQL 8 中,可以通过使用备份和日志锁来避免这个问题。例如,MySQL Enterprise Backup (MEB)在版本 8.0.16 和更高版本中执行此操作,因此 InnoDB 表永远不会被刷新。或者,您可以在使用率较低的时段执行备份,这样潜在的冲突会更低,或者您甚至可以在系统处于只读模式时执行备份,从而完全避免FLUSH TABLES WITH READ LOCK

另一种经常引起混淆的锁类型是元数据锁。

元数据和模式锁

在 MySQL 5.7 和更早的版本中,元数据锁经常是混淆的来源。问题是谁持有元数据锁并不明显。在 MySQL 5.7 中,元数据锁的检测被添加到性能模式中,而在 MySQL 8.0 中,它是默认启用的。启用该工具后,就可以很容易地确定是谁阻塞了试图获取锁的连接。

症状

元数据锁争用的症状类似于刷新锁争用的症状。在典型的情况下,会有一个长时间运行的查询或事务、一个等待元数据锁定的 DDL 语句,以及可能堆积起来的查询。要注意的症状如下:

  • DDL 语句和其他可能的查询都停留在“等待表元数据锁定”状态。

  • 查询可能会堆积如山。等待中的查询都使用同一个表。(如果有多个表的 DDL 语句在等待元数据锁,则可能有不止一组查询在等待。)

  • 当 DDL 语句已经等待lock_wait_timeout时,出现一个ER_LOCK_WAIT_TIMEOUT错误:ERROR: 1205: Lock wait timeout exceeded; try restarting transaction。由于lock_wait_timeout的默认值是 365 天,只有在超时时间减少的情况下,这种情况才有可能发生。

  • 有一个长时间运行的查询或长时间运行的事务。在后一种情况下,事务可能处于空闲状态,或者正在执行一个不使用 DDL 语句所作用的表的查询。

使这种情况变得潜在混乱的是最后一点:可能没有任何长时间运行的查询是导致锁问题的明确候选。那么元数据锁争用的原因是什么呢?

原因

请记住,元数据锁的存在是为了保护模式定义(以及与显式锁一起使用)。只要事务处于活动状态,模式保护就会一直存在,因此当事务查询表时,元数据锁定将持续到事务结束。因此,您可能看不到任何长时间运行的查询。事实上,持有元数据锁的事务可能根本不做任何事情。

简而言之,元数据锁的存在是因为一个或多个连接可能依赖于给定表的模式不变,或者它们已经使用LOCK TABLESFLUSH TABLES WITH READ LOCK语句显式锁定了该表。

设置

元数据锁的示例调查使用了三个连接,就像前面的示例一样。第一个连接正在进行事务处理,第二个连接尝试向事务处理使用的表添加索引,第三个连接尝试对同一个表执行查询。这些查询是

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> SELECT * FROM world.city WHERE ID = 3805\G *************************** 1\. row ***************************
         ID: 3805
       Name: San Francisco
CountryCode: USA
   District: California
 Population: 776733
1 row in set (0.0006 sec)

Connection 1> SELECT Code, Name FROM world.country WHERE Code = 'USA'\G
*************************** 1\. row ***************************
Code: USA
Name: United States
1 row in set (0.0020 sec)

Connection 2> ALTER TABLE world.city ADD INDEX (Name);

Connection 3> SELECT * FROM world.city WHERE ID = 130;

此时,可以开始调查了。这种情况不会自行解决(除非你的lock_wait_timeout值很低,或者你准备等一年),所以你有足够的时间。当您想要解决这个阻塞时,您可以开始终止连接 2 中的ALTER TABLE语句,以避免修改world.city表。然后提交或回滚连接 1 中的事务。

调查

如果启用了wait/lock/metadata/sql/mdl性能模式工具(MySQL 8 中的默认设置),那么调查元数据锁定问题就很简单了。您可以使用性能模式中的metadata_locks表来列出授予的和挂起的锁。然而,获得锁情况摘要的一个更简单的方法是使用sys模式中的schema_table_lock_waits视图。

作为一个例子,考虑在清单 22-3 中可以看到的元数据锁定等待问题,其中涉及三个连接。选择了WHERE子句,以便只包含该调查感兴趣的行。

mysql> SELECT thd_id, conn_id, state,
              current_statement,
              statement_latency
         FROM sys.session
        WHERE command = 'Query' OR trx_state = 'ACTIVE'\G
*************************** 1\. row ***************************
           thd_id: 30
          conn_id: 9
            state: NULL
current_statement: SELECT Code, Name FROM world.country WHERE Code = 'USA'
statement_latency: NULL
*************************** 2\. row ***************************
           thd_id: 7130
          conn_id: 7090
            state: Waiting for table metadata lock
current_statement: ALTER TABLE world.city ADD INDEX (Name)
statement_latency: 19.92 m
*************************** 3\. row ***************************
           thd_id: 51
          conn_id: 13
            state: Waiting for table metadata lock
current_statement: SELECT * FROM world.city WHERE ID = 130
statement_latency: 19.78 m
*************************** 4\. row ***************************
           thd_id: 107
          conn_id: 46
            state: NULL
current_statement: SELECT thd_id, conn_id, state, ... Query' OR trx_state = 'ACTIVE'
statement_latency: 56.77 ms
3 rows in set (0.0629 sec)

Listing 22-3A metadata lock wait issue

两个连接正在等待元数据锁定(在world.city表上)。包括第三个连接(conn_id = 9),它是空闲的,这可以从语句延迟的NULL中看出(在 8.0.18 之前的一些版本中,您也可以看到当前语句是NULL)。在这种情况下,查询列表仅限于具有活动查询或活动事务的查询,但通常您会从完整的进程列表开始。然而,为了便于关注重要的部分,输出被过滤。

一旦您知道存在元数据锁定问题,您可以使用sys.schema_table_lock_waits视图来获取关于锁定争用的信息。清单 22-4 显示了与刚才讨论的进程列表相对应的输出示例。

mysql> SELECT *
         FROM sys.schema_table_lock_waits\G
*************************** 1\. row ***************************
               object_schema: world
                 object_name: city
           waiting_thread_id: 7130
                 waiting_pid: 7090
             waiting_account: root@localhost
           waiting_lock_type: EXCLUSIVE
       waiting_lock_duration: TRANSACTION
               waiting_query: ALTER TABLE world.city ADD INDEX (Name)
          waiting_query_secs: 1219
 waiting_query_rows_affected: 0
 waiting_query_rows_examined: 0
          blocking_thread_id: 7130
                blocking_pid: 7090
            blocking_account: root@localhost
          blocking_lock_type: SHARED_UPGRADABLE
      blocking_lock_duration: TRANSACTION
     sql_kill_blocking_query: KILL QUERY 7090
sql_kill_blocking_connection: KILL 7090
*************************** 2\. row ***************************
               object_schema: world
                 object_name: city
           waiting_thread_id: 51
                 waiting_pid: 13
             waiting_account: root@localhost
           waiting_lock_type: SHARED_READ
       waiting_lock_duration: TRANSACTION
               waiting_query: SELECT * FROM world.city WHERE ID = 130
          waiting_query_secs: 1210
 waiting_query_rows_affected: 0
 waiting_query_rows_examined: 0
          blocking_thread_id: 7130
                blocking_pid: 7090
            blocking_account: root@localhost
          blocking_lock_type: SHARED_UPGRADABLE
      blocking_lock_duration: TRANSACTION
     sql_kill_blocking_query: KILL QUERY 7090
sql_kill_blocking_connection: KILL 7090
*************************** 3\. row ***************************
               object_schema: world
                 object_name: city
           waiting_thread_id: 7130
                 waiting_pid: 7090
             waiting_account: root@localhost
           waiting_lock_type: EXCLUSIVE
       waiting_lock_duration: TRANSACTION
               waiting_query: ALTER TABLE world.city ADD INDEX (Name)
          waiting_query_secs: 1219
 waiting_query_rows_affected: 0
 waiting_query_rows_examined: 0
          blocking_thread_id: 30
                blocking_pid: 9
            blocking_account: root@localhost
          blocking_lock_type: SHARED_READ
      blocking_lock_duration: TRANSACTION
     sql_kill_blocking_query: KILL QUERY 9
sql_kill_blocking_connection: KILL 9
*************************** 4\. row ***************************
               object_schema: world
                 object_name: city
           waiting_thread_id: 51
                 waiting_pid: 13
             waiting_account: root@localhost
           waiting_lock_type: SHARED_READ
       waiting_lock_duration: TRANSACTION
               waiting_query: SELECT * FROM world.city WHERE ID = 130
          waiting_query_secs: 1210
 waiting_query_rows_affected: 0
 waiting_query_rows_examined: 0
          blocking_thread_id: 30
                blocking_pid: 9
            blocking_account: root@localhost
          blocking_lock_type: SHARED_READ
      blocking_lock_duration: TRANSACTION
     sql_kill_blocking_query: KILL QUERY 9
sql_kill_blocking_connection: KILL 9
4 rows in set (0.0024 sec)

Listing 22-4Finding metadata lock contention

输出显示有四种查询等待和阻塞的情况。这可能令人惊讶,但它确实发生了,因为涉及到几个锁,并且有一系列等待。每一行都是一对等待和阻塞连接。输出使用“pid”作为进程列表 id,这与早期输出中使用的连接 id 相同。这些信息包括锁是什么、等待连接的详细信息、阻塞连接的详细信息以及可用于终止阻塞查询或连接的两个查询。

第一行显示了等待自身的进程列表 id 7090。这听起来像是一个僵局,但事实并非如此。原因是ALTER TABLE首先获取了一个可以升级的共享锁,然后试图获取正在等待的独占锁。因为没有关于哪个现有锁实际上阻塞了新锁的明确信息,所以该信息最终被包括在内。

第二行显示SELECT语句正在等待进程列表 id 7090,即ALTER TABLE。这就是当 DDL 语句需要一个独占锁时,连接会开始堆积的原因,所以它会阻塞对共享锁的请求。

第三和第四行揭示了锁争用的潜在问题。进程列表 id 9 阻塞了其他两个连接,这表明这是阻塞 DDL 语句的罪魁祸首。因此,当您调查类似这样的问题时,请查找正在等待被另一个连接阻塞的独占元数据锁的连接。如果输出中有大量的行,您还可以查找导致最多阻塞的连接,并以此为起点。清单 22-5 展示了如何做到这一点的例子。

mysql> SELECT *
         FROM sys.schema_table_lock_waits
        WHERE waiting_lock_type = 'EXCLUSIVE'
              AND waiting_pid <> blocking_pid\G
*************************** 1\. row ***************************
               object_schema: world
                 object_name: city
           waiting_thread_id: 7130
                 waiting_pid: 7090
             waiting_account: root@localhost
           waiting_lock_type: EXCLUSIVE
       waiting_lock_duration: TRANSACTION
               waiting_query: ALTER TABLE world.city ADD INDEX (Name)
          waiting_query_secs: 4906
 waiting_query_rows_affected: 0
 waiting_query_rows_examined: 0
          blocking_thread_id: 30
                blocking_pid: 9
            blocking_account: root@localhost
          blocking_lock_type: SHARED_READ
      blocking_lock_duration: TRANSACTION
     sql_kill_blocking_query: KILL QUERY 9
sql_kill_blocking_connection: KILL 9
1 row in set (0.0056 sec)

mysql> SELECT blocking_pid, COUNT(*)
         FROM sys.schema_table_lock_waits
        WHERE waiting_pid <> blocking_pid
        GROUP BY blocking_pid
        ORDER BY COUNT(*) DESC;
+--------------+----------+
| blocking_pid | COUNT(*) |
+--------------+----------+
|            9 |        2 |
|         7090 |        1 |
+--------------+----------+
2 rows in set (0.0028 sec)

Listing 22-5Looking for the connection causing the metadata lock block

第一个查询寻找对独占元数据锁的等待,其中阻塞进程列表 id 不是它本身。在这种情况下,这会立即导致主块争用。第二个查询确定每个进程列表 id 触发的阻塞查询的数量。这可能不像这个例子中显示的那么简单,但是使用这里显示的查询将有助于缩小锁争用的范围。

一旦确定了锁争用的来源,就需要确定事务正在做什么。在这种情况下,锁争用的根源是连接 9。回到进程列表输出,您可以看到在这种情况下它没有做任何事情:

*************************** 1\. row ***************************
           thd_id: 30
          conn_id: 9
            state: NULL
current_statement: SELECT Code, Name FROM world.country WHERE Code = 'USA'
statement_latency: NULL

这个连接做了什么来获取元数据锁?没有涉及world.city表的当前语句这一事实表明该连接有一个活动的事务打开。在这种情况下,事务是空闲的(如statement_latency = NULL所示),但也可能有一个与world.city表上的元数据锁无关的查询正在执行。无论哪种情况,您都需要确定事务在当前状态之前正在做什么。为此,您可以使用性能模式和信息模式。清单 22-6 展示了一个调查事务状态和最近历史的例子。

mysql> SELECT *
         FROM information_schema.INNODB_TRX
        WHERE trx_mysql_thread_id = 9\G
*************************** 1\. row ***************************
                    trx_id: 283529000061592
                 trx_state: RUNNING
               trx_started: 2019-06-15 13:22:29
     trx_requested_lock_id: NULL
          trx_wait_started: NULL
                trx_weight: 0
       trx_mysql_thread_id: 9
                 trx_query: NULL
       trx_operation_state: NULL
         trx_tables_in_use: 0
         trx_tables_locked: 0
          trx_lock_structs: 0
     trx_lock_memory_bytes: 1136
           trx_rows_locked: 0
         trx_rows_modified: 0
   trx_concurrency_tickets: 0
       trx_isolation_level: REPEATABLE READ
         trx_unique_checks: 1
    trx_foreign_key_checks: 1
trx_last_foreign_key_error: NULL
 trx_adaptive_hash_latched: 0
 trx_adaptive_hash_timeout: 0
          trx_is_read_only: 0
trx_autocommit_non_locking: 0
1 row in set (0.0006 sec)

mysql> SELECT *
         FROM performance_schema.events_transactions_current
        WHERE THREAD_ID = 30\G
*************************** 1\. row ***************************
                      THREAD_ID: 30
                       EVENT_ID: 113
                   END_EVENT_ID: NULL
                     EVENT_NAME: transaction
                          STATE: ACTIVE
                         TRX_ID: NULL
                           GTID: AUTOMATIC
                  XID_FORMAT_ID: NULL
                      XID_GTRID: NULL
                      XID_BQUAL: NULL
                       XA_STATE: NULL
                         SOURCE: transaction.cc:219
                    TIMER_START: 12849615560172160
                      TIMER_END: 18599491723543808
                     TIMER_WAIT: 5749876163371648
                    ACCESS_MODE: READ WRITE
                ISOLATION_LEVEL: REPEATABLE READ
                     AUTOCOMMIT: NO
           NUMBER_OF_SAVEPOINTS: 0
NUMBER_OF_ROLLBACK_TO_SAVEPOINT: 0
    NUMBER_OF_RELEASE_SAVEPOINT: 0
          OBJECT_INSTANCE_BEGIN: NULL
               NESTING_EVENT_ID: 112
             NESTING_EVENT_TYPE: STATEMENT
1 row in set (0.0008 sec)

mysql> SELECT EVENT_ID, CURRENT_SCHEMA,
              SQL_TEXT
         FROM performance_schema.events_statements_history
        WHERE THREAD_ID = 30
              AND NESTING_EVENT_ID = 113
              AND NESTING_EVENT_TYPE = 'TRANSACTION'\G
*************************** 1\. row ***************************
      EVENT_ID: 114
CURRENT_SCHEMA: world
      SQL_TEXT: SELECT * FROM world.city WHERE ID = 3805
*************************** 2\. row ***************************
      EVENT_ID: 115
CURRENT_SCHEMA: world
      SQL_TEXT: SELECT * FROM world.country WHERE Code = 'USA'
2 rows in set (0.0036 sec)

mysql> SELECT ATTR_NAME, ATTR_VALUE
         FROM performance_schema.session_connect_attrs
        WHERE PROCESSLIST_ID = 9;
+-----------------+------------+
| ATTR_NAME       | ATTR_VALUE |
+-----------------+------------+
| _pid            | 23256      |
| program_name    | mysqlsh    |
| _client_name    | libmysql   |
| _thread         | 20164      |
| _client_version | 8.0.18     |
| _os             | Win64      |
| _platform       | x86_64     |
+-----------------+------------+
7 rows in set (0.0006 sec)

Listing 22-6Investigating a transaction

第一个查询使用信息模式中的INNODB_TRX表。例如,它显示事务开始的时间,因此您可以确定它已经活动了多长时间。如果决定回滚事务,那么trx_rows_modified列对于了解事务更改了多少数据也很有用。注意,InnoDB 所谓的 MySQL 线程 id(trx_mysql_thread_id列)实际上是连接 id。

第二个查询使用性能模式中的events_transactions_current表来获取更多的事务信息。您可以使用TIMER_WAIT列来确定事务的年龄。该值以皮秒为单位,因此使用FORMAT_PICO_TIME()函数可以更容易地理解该值:

mysql> SELECT FORMAT_PICO_TIME(5749876163371648) AS Age;
+--------+
| Age    |
+--------+
| 1.60 h |
+--------+
1 row in set (0.0003 sec)

如果您使用的是 MySQL 8.0.15 或更早版本,请使用sys.format_time()函数。

第三个查询使用events_statements_history表来查找之前在事务中执行的查询。NESTING_EVENT_ID列被设置为来自events_transactions_current表输出的EVENT_ID的值,而NESTING_EVENT_TYPE列被设置为匹配一个事务。这确保了只返回正在进行的事务的子事件。结果由EVENT_ID(语句的)排序,按照执行的顺序得到语句。默认情况下,events_statements_history表将包含最多十个最新的连接查询。

在这个例子中,调查显示事务执行了两个查询:一个从world.city表中选择,另一个从world.country表中选择。这是导致元数据锁争用的第一个查询。

第四个查询使用session_connect_attrs表来查找连接提交的属性。并非所有客户端和连接器都提交属性,或者它们可能被禁用,因此这些信息并不总是可用。当属性可用时,它们有助于找出违规事务是从哪里执行的。在这个例子中,您可以看到连接来自 MySQL Shell ( mysqlsh)。如果您想提交一个空闲事务,这可能很有用。

解决方案

对于元数据锁争用,您基本上有两种选择来解决问题:完成阻塞事务或终止 DDL 语句。要完成阻塞事务,您需要提交或回滚它。如果您终止连接,将触发事务回滚,因此您需要考虑需要回滚多少工作。为了提交事务,您必须找到执行连接的位置,并以这种方式提交它。您不能提交由不同连接拥有的事务。

终止 DDL 语句将允许其他查询继续进行,但从长远来看,如果锁被一个已放弃但仍处于活动状态的事务持有,这并不能解决问题。对于持有元数据锁的被放弃的事务,可以选择终止 DDL 语句和与被放弃的事务的连接。这样可以避免 DDL 语句在事务回滚时继续阻塞后续查询。回滚完成后,您可以重试 DDL 语句。

预防

避免元数据锁争用的关键是避免长时间运行的事务,同时您需要为事务使用的表执行 DDL 语句。例如,当您知道没有长时间运行的事务时,可以执行 DDL 语句。您还可以将lock_wait_timeout选项设置为一个较低的值,这将使 DDL 语句在lock_wait_timeout秒后放弃。虽然这不能避免锁问题,但它通过避免 DDL 语句停止其他查询的执行来缓解这个问题。然后,您可以找到根本原因,而不必担心大部分应用无法工作。

您还可以致力于缩短事务的活动时间。如果不要求所有操作都作为一个原子单元来执行,一种选择是将一个大的事务分成几个较小的事务。您还应该确保在事务处于活动状态时,您没有进行交互工作、文件 I/O、向最终用户传输数据等,从而确保事务不会保持不必要的长时间打开。

长时间运行事务的一个常见原因是应用或客户端根本不提交或回滚事务。禁用autocommit选项时,这种情况尤其容易发生。当autocommit被禁用时,任何查询——即使是普通的只读SELECT语句——都会在没有活动事务的情况下启动一个新事务。这意味着一个看似无辜的查询可能会启动一个事务,如果开发者不知道autocommit被禁用,那么开发者可能不会考虑显式结束事务。在 MySQL Server 中默认情况下,autocommit设置是启用的,但是一些连接器默认情况下禁用它。

关于研究元数据锁的讨论到此结束。下一级锁是记录锁。

记录级锁

记录锁争用是最常遇到的,但通常也是最不具干扰性的,因为默认的锁等待超时只有 50 秒,所以不存在查询堆积的可能性。也就是说,在某些情况下——正如将要展示的那样——记录锁会导致 MySQL 嘎然而止。本节将研究 InnoDB 记录锁的一般问题,以及更详细的锁等待超时问题。对死锁细节的研究将推迟到下一节。

症状

InnoDB 记录锁争用的症状通常非常微妙,不容易识别。在严重的情况下,您会得到锁等待超时或死锁错误,但在许多情况下,可能没有直接的症状。更确切地说,症状是查询比正常情况下慢。这可能从慢几分之一秒到慢很多秒不等。

对于存在锁等待超时的情况,您将看到类似于以下示例中的ER_LOCK_WAIT_TIMEOUT错误:

ERROR: 1205: Lock wait timeout exceeded; try restarting transaction

当查询比没有锁争用时要慢时,最有可能检测到问题的方法是通过监控,要么使用类似于 MySQL Enterprise Monitor 中的查询分析器,要么使用sys.innodb_lock_waits视图检测锁争用。图 22-3 显示了查询分析器中的一个查询示例。在讨论记录锁争用的调查时,将使用sys模式视图。该图在本书的 GitHub 知识库中以figure_22_3_quan.png的形式提供。

img/484666_1_En_22_Fig3_HTML.jpg

图 22-3

查询分析器中检测到的锁争用示例

在图中,请注意查询的延迟图是如何在接近周期结束时增加,然后又突然下降的。规范化查询的右侧还有一个红色图标,该图标表示查询返回了错误。在这种情况下,错误是锁等待超时,但是从图中看不到。规范化查询左侧的环形图表还显示了一个红色区域,指示查询的查询响应时间索引有时被认为很差。顶部的大图显示了一个小的下降,表明实例中有足够多的问题导致实例的性能普遍下降。

还有几个实例级指标显示实例发生了多少锁定。这对于监控一段时间内的一般锁争用非常有用。清单 22-7 使用sys.metrics视图显示了可用的指标。

mysql> SELECT Variable_name,
              Variable_value AS Value,
              Enabled
         FROM sys.metrics
        WHERE Variable_name LIKE 'innodb_row_lock%'
              OR Type = 'InnoDB Metrics - lock';
+-------------------------------+--------+---------+
| Variable_name                 | Value  | Enabled |
+-------------------------------+--------+---------+
| innodb_row_lock_current_waits | 0      | YES     |
| innodb_row_lock_time          | 595876 | YES     |
| innodb_row_lock_time_avg      | 1683   | YES     |
| innodb_row_lock_time_max      | 51531  | YES     |
| innodb_row_lock_waits         | 354    | YES     |
| lock_deadlocks                | 0      | YES     |
| lock_rec_lock_created         | 0      | NO      |
| lock_rec_lock_removed         | 0      | NO      |
| lock_rec_lock_requests        | 0      | NO      |
| lock_rec_lock_waits           | 0      | NO      |
| lock_rec_locks                | 0      | NO      |
| lock_row_lock_current_waits   | 0      | YES     |
| lock_table_lock_created       | 0      | NO      |
| lock_table_lock_removed       | 0      | NO      |
| lock_table_lock_waits         | 0      | NO      |
| lock_table_locks              | 0      | NO      |
| lock_timeouts                 | 1      | YES     |
+-------------------------------+--------+---------+
17 rows in set (0.0203 sec)

Listing 22-7InnoDB lock metrics

对于这个讨论,innodb_row_lock_%lock_timeouts指标是最有趣的。三个时间变量以毫秒为单位。可以看到,有一个锁等待超时,这本身并不一定是一个问题。您还可以看到有 354 种情况下锁不能被立即授予(innodb_row_lock_waits),并且等待时间超过 51 秒(innodb_row_lock_time_max)。当锁争用的总体水平增加时,您将看到这些指标也在增加。

甚至比手动监控指标更好的是,确保您的监控解决方案记录指标,并可以在时间序列图中绘制它们。图 22-4 显示了针对图 22-3 中发现的同一事件绘制的指标示例。

img/484666_1_En_22_Fig4_HTML.jpg

图 22-4

InnoDB 行锁指标的时间序列图

图表显示了锁定的总体增加。锁等待的数量有两个阶段,随着锁等待的增加,然后再次下降。行锁定时间图显示了类似的模式。这是间歇性锁定问题的典型迹象。

原因

InnoDB 在行数据、索引记录、间隙和插入意图锁上使用共享锁和排他锁。当有两个事务试图以冲突的方式访问数据时,一个查询将不得不等待,直到所需的锁可用。简而言之,可以同时允许两个对共享锁的请求,但是一旦有了独占锁,任何连接都不能在同一个记录上获得锁。

由于排他锁最有可能导致锁争用,因此通常 DML 查询会更改导致 InnoDB 记录锁争用的数据。另一个来源是SELECT语句通过添加FOR SHARE(或LOCK IN SHARE MODEFOR UPDATE子句来进行抢先锁定。

设置

这个示例只需要两个连接来设置正在研究的场景,第一个连接有一个正在进行的事务,第二个连接试图更新第一个连接持有锁的行。因为等待 InnoDB 锁的默认超时是 50 秒,所以您可以选择增加第二个连接的超时时间,这将会阻塞,以便您有更多的时间来执行调查。设置是

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)

Connection 1> UPDATE world.city
                 SET Population = 5000000
               WHERE ID = 130;
Query OK, 1 row affected (0.0005 sec)

Rows matched: 1  Changed: 1  Warnings: 0

Connection 2> SET SESSION innodb_lock_wait_timeout = 300;
Query OK, 0 rows affected (0.0003 sec)

Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 2> UPDATE world.city SET Population = Population * 1.10 WHERE CountryCode = 'AUS';

在本例中,连接 2 的锁定等待超时设置为 300 秒。Connection 2 的START TRANSACTION不是必需的,但是允许您在完成后回滚两个事务,以避免对数据进行更改。

调查

调查记录锁与调查元数据锁非常相似。您可以查询性能模式中的data_locksdata_lock_waits表,它们将分别显示原始锁数据和挂起的锁。还有一个sys.innodb_lock_waits视图,它查询两个表来寻找一个被另一个阻塞的锁对。

Note

MySQL 8 中新增了data_locksdata_lock_waits表。在 MySQL 5.7 和更早的版本中,信息模式中有两个相似的表,分别名为INNODB_LOCKSINNODB_LOCK_WAITS。使用innodb_lock_waits视图的一个优点是它在不同的 MySQL 版本上工作是一样的(但是在 MySQL 8 中有一些额外的信息)。

在大多数情况下,使用innodb_lock_waits视图开始调查是最容易的,并且只在需要时深入性能模式表。清单 22-8 显示了锁等待情况下innodb_lock_waits的输出示例。

mysql> SELECT * FROM sys.innodb_lock_waits\G
*************************** 1\. row ***************************
                wait_started: 2019-06-15 18:37:42
                    wait_age: 00:00:02
               wait_age_secs: 2
                locked_table: `world`.`city`
         locked_table_schema: world
           locked_table_name: city
      locked_table_partition: NULL
   locked_table_subpartition: NULL
                locked_index: PRIMARY
                 locked_type: RECORD
              waiting_trx_id: 3317978
         waiting_trx_started: 2019-06-15 18:37:42
             waiting_trx_age: 00:00:02
     waiting_trx_rows_locked: 2
   waiting_trx_rows_modified: 0
                 waiting_pid: 4172
               waiting_query: UPDATE city SET Population = P ... 1.10 WHERE CountryCode = 'AUS'
             waiting_lock_id: 1999758099664:525:6:131:1999728339632
           waiting_lock_mode: X,REC_NOT_GAP
             blocking_trx_id: 3317977
                blocking_pid: 9
              blocking_query: NULL
            blocking_lock_id: 1999758097920:525:6:131:1999728329336
          blocking_lock_mode: X,REC_NOT_GAP
        blocking_trx_started: 2019-06-15 18:37:40
            blocking_trx_age: 00:00:04
    blocking_trx_rows_locked: 1
  blocking_trx_rows_modified: 1
     sql_kill_blocking_query: KILL QUERY 9
sql_kill_blocking_connection: KILL 9
1 row in set (0.0145 sec)

Listing 22-8Retrieving lock information from the innodb_lock_waits view

根据列名的前缀,输出中的列可以分为五个部分。这些群体是

  • wait_: 这些列显示了锁等待时间的一些一般信息。

  • locked_: 这些列显示了从模式到索引以及锁类型的锁定内容。

  • waiting_: 这些列显示等待授予锁的事务的详细信息,包括查询和请求的锁模式。

  • blocking_: 这些列显示了阻塞锁请求的事务的详细信息。注意,在这个例子中,阻塞查询是NULL。这意味着在生成输出时事务是空闲的。即使列出了阻塞查询,该查询也可能与存在争用的锁没有任何关系——除了该查询是由持有锁的同一事务执行的。

  • sql_kill_: 这两列提供了可用于终止阻塞查询或连接的KILL查询。

Note

blocking_query是阻塞事务当前执行的查询(如果有的话)。这并不意味着查询本身必然会导致锁请求阻塞。

blocking_query列为NULL的情况是常见情况。这意味着阻塞事务当前没有执行查询。这可能是因为它在两个查询之间。如果这段时间很长,则表明应用正在做理想情况下应该在事务之外完成的工作。更常见的情况是,事务没有执行查询,因为它被遗忘了,要么是在交互会话中,人们忘记了结束事务,要么是在应用流中,不能确保事务被提交或回滚。

解决方案

解决方案取决于锁等待的程度。如果有几个查询的锁等待时间很短,那么让受影响的查询等待锁变得可用也是可以接受的。请记住,锁是为了确保数据的完整性,所以锁本身不是问题。只有当锁对性能造成重大影响或者导致查询失败到无法重试的程度时,锁才会成为问题。

如果锁定情况持续很长时间——特别是如果阻塞事务已经被放弃——您可以考虑终止阻塞事务。和往常一样,如果阻塞事务执行了大量工作,您需要考虑回滚可能会花费大量时间。

对于由于锁等待超时错误而失败的查询,应用应该重试它们。请记住,默认情况下,锁等待超时仅回滚超时发生时正在执行的查询。事务的其余部分与查询前一样。因此,处理超时失败可能会使未完成的事务带有自己的锁,这可能会导致进一步的锁问题。是只回滚查询还是回滚整个事务由innodb_rollback_on_timeout选项控制。

Caution

处理锁等待超时是非常重要的,否则它可能会使事务带有未释放的锁。如果发生这种情况,其他事务可能无法获得它们需要的锁。

预防

防止重大的记录级锁争用主要遵循第 18 章的“减少锁定问题”一节中讨论的指南。概括一下讨论,减少锁等待争用的方法主要是减少事务的大小和持续时间,使用索引来减少被访问的记录的数量,并可能将事务隔离级别切换到READ COMMITTED来更早地释放锁并减少间隙锁的数量。

僵局

数据库管理员最担心的锁问题之一是死锁。这一部分是因为它的名字,另一部分是因为它们不像讨论的其他锁问题那样总是会导致错误。然而,与其他锁定问题相比,没有什么特别担心死锁的。相反,它们导致错误意味着您能更快地知道它们,并且锁问题会自行解决。

症状

症状很明显。死锁的受害者收到一个错误,并且lock_deadlocks InnoDB 度量增加。将返回给 InnoDB 选择作为受害者的事务的错误是ER_LOCK_DEADLOCK:

ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction

这个指标对于观察死锁发生的频率非常有用。跟踪lock_deadlocks的值的一种简便方法是使用sys.metrics视图:

mysql> SELECT *
         FROM sys.metrics
        WHERE Variable_name = 'lock_deadlocks'\G
*************************** 1\. row ***************************
 Variable_name: lock_deadlocks
Variable_value: 42
          Type: InnoDB Metrics - lock
       Enabled: YES
1 row in set (0.0087 sec)

您还可以检查 InnoDB 监控器输出中的LATEST DETECTED DEADLOCK部分,例如,通过执行SHOW ENGINE INNODB STATUS.这将显示上一次死锁发生的时间,因此您可以使用它来判断死锁发生的频率。如果您启用了innodb_print_all_deadlocks选项,错误锁将有许多死锁信息的输出。在讨论了死锁的原因和设置之后,将在“调查”中详细介绍死锁的 InnoDB 监控器输出。

原因

死锁是由两个或多个事务以不同的顺序获得锁引起的。每个事务最终都持有另一个事务需要的锁。该锁可以是记录锁、间隙锁、谓词锁或插入意图锁。图 22-5 显示了一个触发死锁的循环依赖的例子。

img/484666_1_En_22_Fig5_HTML.png

图 22-5

触发死锁的循环锁依赖关系

图中显示的死锁是由于表主键上的两个记录锁造成的。这是可能发生的最简单的死锁之一。如图所示,在调查死锁时,循环可能比这更复杂。

设置

本例使用了两个连接,但这一次两个连接都在连接 1 阻塞之前进行了更改,直到连接 2 因出错而回滚其更改。连接 1 用 10%更新澳大利亚及其城市的人口,而连接 2 用达尔文市的人口更新澳大利亚人口并添加城市。这些声明是

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0001 sec)

Connection 1> UPDATE world.city SET Population = Population * 1.10 WHERE CountryCode = 'AUS';
Query OK, 14 rows affected (0.0010 sec)

Rows matched: 14  Changed: 14  Warnings: 0

Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 2> UPDATE world.country SET Population = Population + 146000 WHERE Code = 'AUS';
Query OK, 1 row affected (0.0317 sec)

Rows matched: 1  Changed: 1  Warnings: 0

-- Blocks
Connection 1> UPDATE world.country SET Population = Population * 1.1 WHERE Code = 'AUS';

Connection 2> INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000);
ERROR: 1213: Deadlock found when trying to get lock; try restarting transaction

Connection 2> ROLLBACK;
Query OK, 0 rows affected (0.0003 sec)

Connection 1> ROLLBACK;
Query OK, 0 rows affected (0.3301 sec)

关键是这两个事务都更新了citycountry表,但是顺序相反。设置通过显式回滚这两个事务来完成,以确保表保持不变。

调查

分析死锁的主要工具是 InnoDB monitor 输出中有关最新检测到的死锁的信息部分。如果您启用了innodb_print_all_deadlocks选项(默认情况下为OFF,您还可以从错误日志中获得死锁信息;然而,信息是相同的,因此它不改变分析。

死锁信息包含描述死锁和结果的四个部分。这些零件是

  • 当死锁发生时。

  • 死锁中涉及的第一个事务的信息。

  • 死锁所涉及的第二个事务的信息。

  • 哪个事务被回滚。当innodb_print_all_deadlocks启用时,该信息不包括在错误日志中。

两个事务的编号是任意的,主要目的是能够引用一个事务或另一个事务。包含事务信息的两个部分是最重要的部分。它们包括事务处于活动状态的时间长度、关于事务大小的一些统计信息(根据所使用的锁和撤销日志条目等)、正在阻塞等待锁的查询,以及关于死锁中所涉及的锁的信息。

锁信息不像使用data_locksdata_lock_waits表以及sys.innodb_lock_waits视图时那么容易解释。然而,一旦你尝试进行几次分析,这并不太难。

Tip

在测试系统中故意创建一些死锁,并研究由此产生的死锁信息。然后通过信息来确定死锁发生的原因。因为您知道查询,所以更容易解释锁数据。

对于这个死锁调查,考虑清单 22-9 中显示的 InnoDB 监控器的死锁部分。清单相当长,行也很宽,所以信息也可以在本书的 GitHub 存储库中作为listing_22_9_deadlock.txt获得,所以您可以在自己选择的文本编辑器中打开输出。

mysql> SHOW ENGINE INNODB STATUS\G
...
------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-11-06 18:29:07 0x4b78
*** (1) TRANSACTION:
TRANSACTION 6260, ACTIVE 62 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 6 lock struct(s), heap size 1136, 30 row lock(s), undo log entries 14
MySQL thread id 61, OS thread handle 22592, query id 39059 localhost ::1 root updating
UPDATE world.country SET Population = Population * 1.1 WHERE Code = 'AUS'

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 160 page no 14 n bits 1368 index CountryCode of table `world`.`city` trx id 6260 lock_mode X locks gap before rec
Record lock, heap no 652 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 3; hex 415554; asc AUT;;
 1: len 4; hex 800005f3; asc     ;;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 161 page no 5 n bits 128 index PRIMARY of table `world`.`country` trx id 6260 lock_mode X locks rec but not gap waiting
Record lock, heap no 16 PHYSICAL RECORD: n_fields 17; compact format; info bits 0
 0: len 3; hex 415553; asc AUS;;
 1: len 6; hex 000000001875; asc      u;;
 2: len 7; hex 0200000122066e; asc     " n;;
 3: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia                     ; (total 52 bytes);
 4: len 1; hex 05; asc  ;;
 5: len 26; hex 4175737472616c696120616e64204e6577205a65616c616e6420; asc Australia and New Zealand ;;
 6: len 4; hex 483eec4a; asc H> J;;
 7: len 2; hex 876d; asc  m;;
 8: len 4; hex 812267c0; asc  "g ;;
 9: len 4; hex 9a999f42; asc    B;;
 10: len 4; hex c079ab48; asc  y H;;
 11: len 4; hex e0d9bf48; asc    H;;
 12: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia                     ; (total 45 bytes);
 13: len 30; hex 436f6e737469747574696f6e616c204d6f6e61726368792c204665646572;asc Constitutional Monarchy, Feder; (total 45 bytes);
 14: len 30; hex 456c69736162657468204949202020202020202020202020202020202020; asc Elisabeth II                  ; (total 60 bytes);
 15: len 4; hex 80000087; asc     ;;
 16: len 2; hex 4155; asc AU;;

*** (2) TRANSACTION:
TRANSACTION 6261, ACTIVE 37 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 2
MySQL thread id 62, OS thread handle 2044, query id 39060 localhost ::1 root update
INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 161 page no 5 n bits 128 index PRIMARY of table `world`.`country` trx id 6261 lock_mode X locks rec but not gap
Record lock, heap no 16 PHYSICAL RECORD: n_fields 17; compact format; info bits 0
 0: len 3; hex 415553; asc AUS;;
 1: len 6; hex 000000001875; asc      u;;
 2: len 7; hex 0200000122066e; asc     " n;;
 3: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia                     ; (total 52 bytes);
 4: len 1; hex 05; asc  ;;
 5: len 26; hex 4175737472616c696120616e64204e6577205a65616c616e6420; asc Australia and New Zealand ;;
 6: len 4; hex 483eec4a; asc H> J;;
 7: len 2; hex 876d; asc  m;;
 8: len 4; hex 812267c0; asc  "g ;;
 9: len 4; hex 9a999f42; asc    B;;
 10: len 4; hex c079ab48; asc  y H;;
 11: len 4; hex e0d9bf48; asc    H;;
 12: len 30; hex 4175737472616c6961202020202020202020202020202020202020202020; asc Australia                     ; (total 45 bytes);
 13: len 30; hex 436f6e737469747574696f6e616c204d6f6e61726368792c204665646572; asc Constitutional Monarchy, Feder; (total 45 bytes);
 14: len 30; hex 456c69736162657468204949202020202020202020202020202020202020; asc Elisabeth II                  ; (total 60 bytes);
 15: len 4; hex 80000087; asc     ;;
 16: len 2; hex 4155; asc AU;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 160 page no 14 n bits 1368 index CountryCode of table `world`.`city` trx id 6261 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 652 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 3; hex 415554; asc AUT;;
 1: len 4; hex 800005f3; asc     ;;

*** WE ROLL BACK TRANSACTION (2)

Listing 22-9Example of a detected deadlock

死锁发生在 2019 年 11 月 6 日,服务器时区 18:29:07。您可以使用此信息来查看该信息是否与用户报告的死锁相同。

有趣的部分是两个事务的信息。您可以看到,事务 1 正在用Code = 'AUS'更新国家的人口:

UPDATE world.country SET Population = Population * 1.1 WHERE Code = 'AUS'

事务 2 试图插入一个新的城市:

INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000)

这是一个死锁涉及多个表的情况。虽然这两个查询在不同的表上工作,但它本身并不能证明涉及到更多的查询,因为外键可以触发一个查询在两个表上获取锁。不过在本例中,Code列是country表的主键,唯一涉及的外键是从city表的CountryCode列到country表的Code列(显示这是留给使用world示例数据库的读者的一个练习)。所以两个查询不太可能自己死锁。

Note

死锁输出来自 MySQL 8.0.18,它向输出添加了额外的信息。本讨论仅使用了以前版本中也提供的信息。但是,如果您仍在使用早期版本,升级将使调查死锁变得更加容易。

接下来要观察的是正在等待什么锁。事务 1 等待对country表的主键的排他锁:

RECORD LOCKS space id 161 page no 5 n bits 128 index PRIMARY of table `world`.`country` trx id 6260 lock_mode X locks rec but not gap waiting

主键的值可以在该信息后面的信息中找到。由于 InnoDB 包含了与记录相关的所有信息,这看起来有点让人不知所措。因为它是主键记录,所以包含整行。这有助于理解行中的数据,特别是如果主键本身不包含这些信息,但是当您第一次看到它时,可能会感到困惑。country表的主键是表的第一列,所以它是记录信息的第一行,包含锁请求的主键的值:

 0: len 3; hex 415553; asc AUS;;

InnoDB 以十六进制表示法包含该值,但也试图将其解码为一个字符串,因此这里很明显该值是“AUS”,这并不奇怪,因为它也在查询的WHERE子句中。这并不总是那么明显,所以您应该总是确认锁输出的值。您还可以从信息中看到,该列在索引中是按升序排序的。

事务 2 等待对city表的CountryCode索引的插入意图锁:

RECORD LOCKS space id 160 page no 14 n bits 1368 index CountryCode of table `world`.`city` trx id 6261 lock_mode X locks gap before rec insert intention waiting

您可以看到锁定请求在记录之前包含一个间隙。在这种情况下,锁信息更简单,因为CountryCode索引中只有两列,即CountryCode列和主键(ID列),因为CountryCode索引是非唯一的二级索引。该索引实际上是(CountryCode, ID),记录前的差距值如下:

 0: len 3; hex 415554; asc AUT;;
 1: len 4; hex 800005f3; asc     ;;

这表明CountryCode的值是“AUT ”,这并不奇怪,因为当按字母升序排序时,它是“AUS”之后的下一个值。ID列的值是十六进制值 0x5f3,十进制值是 1523。如果您查询带有CountryCode = AUT的城市,并按照CountryCode索引的顺序对它们进行排序,您可以看到ID = 1523是找到的第一个城市:

mysql> SELECT *
         FROM world.city
        WHERE CountryCode = 'AUT'
        ORDER BY CountryCode, ID
        LIMIT 1;
+------+------+-------------+----------+------------+
| ID   | Name | CountryCode | District | Population |
+------+------+-------------+----------+------------+
| 1523 | Wien | AUT         | Wien     |    1608144 |
+------+------+-------------+----------+------------+
1 row in set (0.0006 sec)

目前为止一切顺利。因为事务正在等待这些锁,所以当然可以推断出另一个事务持有锁。在 8.0.18 及更高版本中,InnoDB 包含了两个事务持有的锁的完整列表;在早期版本中,InnoDB 只为其中一个事务显式地包含这个查询,所以您需要确定事务还执行了哪些其他查询。

根据现有的信息,你可以做出一些有根据的猜测。例如,INSERT语句被CountryCode索引上的间隙锁阻塞。使用条件CountryCode = 'AUS'的查询就是一个使用该间隙锁的查询示例。死锁信息还包括关于拥有事务的两个连接的信息,这些信息可能对您有所帮助:

MySQL thread id 61, OS thread handle 22592, query id 39059 localhost ::1 root updating

MySQL thread id 62, OS thread handle 2044, query id 39060 localhost ::1 root update

您可以看到这两个连接都是使用root@localhost帐户建立的。如果您确保每个应用和角色有不同的用户,该帐户可以帮助您缩小执行事务的用户范围。

如果连接仍然存在,您还可以使用性能模式中的events_statements_history表来查找连接执行的最新查询。这可能不是死锁所涉及的那些人,这取决于该连接是否被用于更多的查询,但是仍然可以提供该连接用途的线索。如果连接不再存在,原则上您可以在events_statements_history_long表中找到查询,但是您需要将“MySQL 线程 id”(连接 ID)映射到 Performance Schema 线程 ID,这是很难做到的。另外,events_statements_history_long消费者在默认情况下是不启用的。

在这种特殊情况下,两个连接仍然存在,除了回滚事务之外,它们没有做任何事情。清单 22-10 展示了如何找到事务中涉及的查询。请注意,查询可能会返回比这里显示的更多的行,这取决于您使用的客户端以及在连接中执行的其他查询。

mysql> SELECT SQL_TEXT, NESTING_EVENT_ID,
              NESTING_EVENT_TYPE
         FROM performance_schema.events_statements_history
        WHERE THREAD_ID = PS_THREAD_ID(61)
        ORDER BY EVENT_ID\G
*************************** 1\. row ***************************
          SQL_TEXT: START TRANSACTION
  NESTING_EVENT_ID: NULL
NESTING_EVENT_TYPE: NULL
*************************** 2\. row ***************************
          SQL_TEXT: UPDATE world.city SET Population = Population * 1.10 WHERE CountryCode = 'AUS'
  NESTING_EVENT_ID: 37
NESTING_EVENT_TYPE: TRANSACTION
*************************** 3\. row ***************************
          SQL_TEXT: UPDATE world.country SET Population = Population * 1.1 WHERE Code = 'AUS'
  NESTING_EVENT_ID: 37
NESTING_EVENT_TYPE: TRANSACTION
*************************** 4\. row ***************************
          SQL_TEXT: ROLLBACK
  NESTING_EVENT_ID: 37
NESTING_EVENT_TYPE: TRANSACTION
4 rows in set (0.0007 sec)

mysql> SELECT SQL_TEXT, MYSQL_ERRNO,
              NESTING_EVENT_ID,
              NESTING_EVENT_TYPE
         FROM performance_schema.events_statements_history
        WHERE THREAD_ID = PS_THREAD_ID(62)
        ORDER BY EVENT_ID\G
*************************** 1\. row ***************************
          SQL_TEXT: START TRANSACTION
       MYSQL_ERRNO: 0
  NESTING_EVENT_ID: NULL
NESTING_EVENT_TYPE: NULL
*************************** 2\. row ***************************
          SQL_TEXT: UPDATE world.country SET Population = Population + 146000 WHERE Code = 'AUS'
       MYSQL_ERRNO: 0
  NESTING_EVENT_ID: 810
NESTING_EVENT_TYPE: TRANSACTION
*************************** 3\. row ***************************
          SQL_TEXT: INSERT INTO world.city VALUES (4080, 'Darwin', 'AUS', 'Northern Territory', 146000)
       MYSQL_ERRNO: 1213
  NESTING_EVENT_ID: 810
NESTING_EVENT_TYPE: TRANSACTION
*************************** 4\. row ***************************
          SQL_TEXT: SHOW WARNINGS
       MYSQL_ERRNO: 0
  NESTING_EVENT_ID: NULL
NESTING_EVENT_TYPE: NULL
*************************** 5\. row ***************************
          SQL_TEXT: ROLLBACK
       MYSQL_ERRNO: 0
  NESTING_EVENT_ID: NULL
NESTING_EVENT_TYPE: NULL
10 rows in set (0.0009 sec)

Listing 22-10Finding the queries involved in the deadlock

注意,对于连接 id 62(第二个事务),包含了 MySQL 错误号,第三行将其设置为 1213——这是一个死锁。当遇到错误时,MySQL Shell 自动执行一个SHOW WARNINGS语句,即第 4 行中的语句。还要注意,嵌套事件是事务 2 的ROLLBACKNULL,而不是事务 1 的ROLLBACK。这是因为死锁触发了整个事务的回滚(所以事务 2 的ROLLBACK没有做任何事情)。

死锁是由事务 1 首先更新city表的填充,然后更新country表的填充触发的。事务 2 首先更新了country表的人口,然后试图将一个新的城市插入到city表中。这是两个工作流以不同顺序更新记录的典型例子,因此容易出现死锁。

总结调查,它包括两个步骤:

  1. 分析来自 InnoDB 的死锁信息,以确定死锁中涉及的锁,并获得尽可能多的关于连接的信息。

  2. 使用其他来源(如性能模式)来查找有关事务中查询的更多信息。通常有必要分析应用以获得查询列表。

现在您已经知道是什么触发了死锁,那么解决这个问题需要什么呢?

解决方案

死锁是最容易解决的锁情况,因为 InnoDB 会自动选择一个事务作为受害者并回滚它。在前面讨论的死锁中,事务 2 被选为受害者,这可以从死锁输出中看出:

*** WE ROLL BACK TRANSACTION (2)

这意味着对于事务 1,没有什么可做的。事务 2 回滚后,事务 1 可以继续并完成其工作。

对于事务 2,InnoDB 已经回滚了整个事务,所以您需要做的就是重试该事务。记住再次执行所有查询,而不是依赖第一次尝试时返回的值;否则,您可能会使用过时的值。

Tip

时刻准备处理死锁和锁等待超时。对于死锁或当事务在锁等待超时后回滚时,请重试整个事务。对于仅回滚查询的锁等待超时,重试查询可能会增加延迟。

如果死锁相对很少发生,您实际上不需要做更多的事情。死锁是生活中的现实,所以不要因为遇到一些死锁而惊慌。如果死锁造成了重大影响,您需要考虑进行一些更改来防止某些死锁。

预防

减少死锁与减少记录锁争用非常相似,只是在整个应用中以相同的顺序获取锁非常重要。建议再次阅读第 18 章中的“减少锁定问题”一节。减少死锁的要点是减少锁的数量和持有锁的时间,并按照相同的顺序使用锁:

  • 通过将大型事务分成几个较小的事务,并添加索引以减少锁的数量,来减少每个事务所做的工作。

  • 如果事务隔离级别适合于您的应用来减少锁的数量和它们被持有的时间,那么可以考虑使用它。

  • 确保事务只在尽可能短的时间内保持开放。

  • 以相同的顺序访问记录,如果需要的话,可以通过执行SELECT ... FOR UPDATESELECT ... FOR SHARE查询来抢占锁。

关于如何研究锁的讨论到此结束。您可能会遇到与本章中讨论的情况不完全匹配的锁情况;然而,调查这些问题的技术是相似的。

摘要

本章向您展示了如何使用 MySQL 中的可用资源来研究与锁相关的问题。本章包括了调查四种不同类型的锁问题的例子:刷新锁、元数据锁、记录锁和死锁。每个问题类型使用 MySQL 的不同特性,包括进程列表、性能模式中的锁表和 InnoDB monitor 输出。

还有许多其他类型的锁会导致锁等待问题。本章中讨论的方法对于调查由其他锁类型引起的问题也大有帮助。最后,成为研究锁的专家的唯一方法是经验,但是本章的技术提供了一个很好的起点。

关于查询分析的第五部分到此结束。第六部分是关于改进查询的,首先讨论如何通过配置来提高性能。

二十三、配置

在本书的第四部分中,有几个影响 MySQL 行为的配置选项的例子。这些选项包括字符集和排序规则的选择、如何创建索引统计信息、优化器应该如何工作等等。还有其他直接或间接影响查询性能的选项。本章将考虑其他地方没有涉及到的最常用的选项,以及配置 MySQL 时的一些一般注意事项。

本章从一些关于更改配置的“最佳实践”开始。接下来的部分是关于 InnoDB、查询缓冲区和内部临时表的。

最佳实践

当您着手进行配置更改时,有必要记住一些原则,这些原则可以让您更成功地进行配置更改。将讨论的最佳实践包括以下内容:

  • 警惕最佳实践。

  • 使用监控来验证效果。

  • 一次更改一个选项。

  • 做出相对较小的增量变化。

  • 越少越好。

  • 请确保您了解该选项的作用。

  • 考虑一下副作用。

最佳实践列表的第一项是对最佳实践保持警惕,这听起来可能有点保守。意思是,当你看到一些建议时,你不应该直接跳到前面去应用。

没有两个系统是完全相同的,因此,虽然一个建议通常可能是好的,但您仍然需要考虑它是否也适用于您的系统。另一个问题是查看适用于旧版本 MySQL 或 8 GiB 内存太多的时候的建议。如果你用谷歌搜索一些设置,你可能会看到很多年前写的推荐。类似地,一段时间前对您的系统有效的建议可能会由于应用工作负载的变化而不再有效。最后,即使某个建议会提高系统的性能,也可能会有副作用,比如丢失已提交的更改的风险,这是您无法接受的。

Tip

警惕最佳实践的建议也适用于本书中的建议。始终考虑它们如何应用于您的系统。

那么,您应该如何处理配置变更呢?应用第 2 章中描述的原则。图 23-1 概括了这些步骤。

img/484666_1_En_23_Fig1_HTML.jpg

图 23-1

性能调整生命周期

您首先定义问题是什么,然后通过您的监控系统或通过定时查询或类似方式收集基线。基线也可以是可观测量的组合。然后你可以定义优化的目标。很重要的一点是,你要定义什么是足够好的,否则你永远也做不完。接下来的步骤是确定原因,并据此找到解决方案。最后,您实施解决方案,并通过与基线进行比较来验证效果。如果问题没有解决,或者你已经确定了多个问题,你可以重新开始。

监控在这个过程中非常重要,因为它既用于定义问题,收集基线,又用于验证效果。如果跳过这些步骤,您就不知道您的解决方案是否有效,也不知道它是否会影响其他查询。

当你决定一个解决方案时,尽可能小的改变。这既适用于您打开旋钮的配置选项的数量,也适用于您旋转旋钮的程度。如果您一次更改多个选项,您将无法衡量每个更改的效果。例如,两个变化可能会相互抵消,所以当其中一个变化非常有效,而另一个使情况变得更糟时,您认为解决方案不起作用。

配置选项通常也有一个最佳点。如果设置太小,选项所代表的功能就不能充分发挥作用。如果设置太大,特性的开销会变得比好处更糟。在这两者之间,您可以在开销有限的情况下获得特性优势的最佳组合。如图 23-2 所示。

img/484666_1_En_23_Fig2_HTML.png

图 23-2

期权价值和业绩之间的典型关系

通过微小的增量变化,你可以最大化找到最佳点的机会。

这与下一点有关:小往往更好。例如,仅仅因为您有足够的内存来增加每个查询或每个连接的缓冲区,并不意味着增加缓冲区大小会使查询更快。这当然取决于这一原则适用范围的选择。对于 InnoDB 缓冲池的大小,最好使用相对较大的缓冲区,因为它有助于减少磁盘 I/O 并从内存中提供数据。关于缓冲池需要记住的一个关键点是,内存分配只在 MySQL 启动和动态增加缓冲池大小时才会发生。但是,对于像连接缓冲区这样的缓冲区,可能会为单个查询分配多次,分配缓冲区的巨大开销可能会成为一个问题。这将在“查询缓冲区”一节中进一步讨论在所有情况下,对于与资源相关的选项,您需要记住分配给一个功能的资源不能用于其他功能。

“越少越好”的概念既适用于配置选项的最佳值,也适用于您要优化的选项数量。您在配置文件中设置的选项越多,您的配置文件就变得越混乱,并且越难保持对已更改内容及其原因的概述。(这也有助于按功能对设置进行分组,例如,将所有 InnoDB 设置放在一起。)如果您习惯于包含设置为默认值的选项,最好还是不要包含这些选项,因为包含这些选项意味着您将错过对默认值的更改,这些更改是作为优化默认配置的一部分来实现的,以反映 MySQL 内部的更改或标准硬件的更改。

Note

在 MySQL 5.6 和更高版本中,已经做了大量工作来改进 MySQL 配置选项的默认值。基于开发团队的测试以及 MySQL 支持团队、客户和社区成员的反馈,这些变化主要发生在主要版本之间。

建议开始时设置尽可能少的选项。您很可能想要设置 InnoDB 缓冲池、重做日志以及可能的表缓存的大小。您可能还希望设置一些路径和端口,并且可能要求启用一些功能,如全局事务标识符(GTIDs)或组复制。除此之外,只根据观察做出改变。

Tip

从最小配置开始,只设置 InnoDB 缓冲池和重做日志、路径和端口的大小,并启用所需的功能。否则,仅根据观察结果进行配置更改。

列表中的最后两点是相关的:确保您理解选项的作用,并考虑副作用。了解该选项的作用有助于您确定该选项对您的案例是否有用,以及该选项可能具有哪些其他效果。作为一个例子,考虑sync_binlog选项。这表明二进制日志的更新应该多久同步到磁盘。在 MySQL 8 中,默认情况下是每次提交时都进行同步,这对于同步性能较差的磁盘来说会显著影响查询性能。因此,很容易将sync_binlog设置为 0,这将禁用强制同步;但是,副作用可以接受吗?如果您不同步这些更改,那么它们只会存在于内存中,直到有其他东西(例如内存被其他人使用)强制进行同步。这意味着如果 MySQL 崩溃,那么更改就会丢失,如果您有一个副本,您将不得不重建它。这可以接受吗?

即使您可以接受潜在的丢失二进制日志事件,使用sync_binlog = 0还有一个更微妙的副作用。仅仅因为事务提交时没有发生同步并不意味着它永远不会发生。二进制日志的最大大小是 1gb(max_binlog_size选项)加上最后一个事务的大小,旋转二进制日志意味着旧的二进制日志被刷新到磁盘。如今,这通常意味着 MySQL 将结束写 1 GiB,然后一次全部刷新。即使在高速磁盘上,写出一千兆字节的数据也需要相当长的时间。与此同时,MySQL 不能执行任何提交,因此任何发出提交的连接(无论是隐式的还是显式的)都将停止,直到同步完成。这可能会让人感到意外,而且拖延的时间可能会长到让最终用户(可能是客户)感到不安。本书的作者已经看到了在几秒到半分钟的范围内由二进制日志旋转引起的提交延迟。简而言之,sync_binlog = 0提供了总体最高的吞吐量和平均提交延迟,但是sync_binlog = 1提供了最佳的数据安全性和最可预测的提交延迟。

本章的其余部分提供了一些与查询调优相关的选项的建议,这些选项最常需要更改。

InnoDB 概述

假设所有涉及表的查询都与 InnoDB 存储引擎交互,那么花些时间查看 InnoDB 参数的配置是很重要的。这些包括 InnoDB 缓冲池的大小和重做日志的大小——这两个配置需要针对大多数生产系统进行调整。

在讨论配置选项之前,有必要回顾一下数据如何在表空间和缓冲池之间流动,以及如何通过重做日志系统返回表空间。图 23-3 显示了该流程的简单概述。

img/484666_1_En_23_Fig3_HTML.jpg

图 23-3

InnoDB 数据流

当查询请求数据时,总是从缓冲池中读取数据。如果数据不在缓冲池中,就从表空间中获取。InnoDB 将缓冲池分为两部分:旧块子列表和新块子列表。数据总是被读入整页的旧块子列表的头(顶部)。如果再次需要来自同一页面的数据,该数据将被移动到新块子列表中。这两个子列表都使用最近最少使用的 (LRU)原则来确定在需要为新页面腾出空间时应该删除哪些页面。页面从旧块子列表的缓冲池中被逐出。由于新页面在被提升到新的块子列表之前会在旧的块子列表中花费时间,这意味着如果一个页面被使用过一次,但随后没有被使用,那么它将很快被再次从缓冲池中清除。这可以防止大型的罕见扫描(如备份)污染缓冲池。

当查询更新更改时,更改被写入内存中的日志缓冲区,并从那里写入,稍后刷新到至少由两个文件组成的重做日志。重做日志文件以循环的方式使用,所以写操作从一个文件的开头开始,然后填满该文件,当该文件填满时,InnoDB 继续处理下一个文件。这些文件的大小和数量是固定的。当日志到达最后一个文件的末尾时,InnoDB 会返回到第一个文件的开头。

这些更改还被写回缓冲池,并被标记为脏,直到它们可以被刷新到表空间文件。InnoDB 使用双写缓冲区来确保在崩溃的情况下可以检测到写操作是否成功。双写缓冲区是必要的,因为大多数文件系统不保证原子写入,因为 InnoDB 页面大于文件系统块大小。在撰写本文时,唯一可以安全禁用双写缓冲区的文件系统是 ZFS。

Caution

即使文件系统应该处理 InnoDB 页面的原子写入,它在实践中也可能不起作用。这方面的一个例子是启用了日志功能的 EXT4 文件系统,它在理论上应该是安全的,没有双写缓冲区,但实际上可能会导致数据损坏。

下一节将讨论的配置选项围绕数据的生命周期。

InnoDB 缓冲池

InnoDB 缓冲池是 InnoDB 缓存数据和索引的地方。由于所有数据请求都要经过缓冲池,从性能角度来看,它自然成为 MySQL 的一个非常重要的部分。这里将讨论缓冲池的几个重要参数。

23-1 总结了与缓冲池相关的配置选项,您很可能需要更改这些选项来优化查询性能。

表 23-1

缓冲池的重要配置选项

|

选项名称

|

缺省值

|

评论

|
| --- | --- | --- |
| innodb_buffer_pool_size | 128 兆字节 | InnoDB 缓冲池的总大小。 |
| innodb_buffer_pool_instances | 自动调整大小 | 缓冲池分成多少部分。如果总大小小于 1gb,默认值为 1,否则为 8。对于 32 位 Windows,缺省值为 1.3 GiB 以下的 1;否则,每个实例为 128 MiB。最大实例数为 64。 |
| innodb_buffer_pool_dump_pct | Twenty-five | 转储缓冲池内容(备份缓冲池内容)时,缓冲池中最近使用的页面所占的百分比。 |
| innodb_old_blocks_time | One thousand | 在新的页面读取将其提升到新的块子列表之前,该页面必须在旧的块子列表中驻留多长时间(毫秒)。 |
| innodb_old_blocks_pct | Thirty-seven | 旧块子列表占整个缓冲池的百分比应该有多大。 |
| innodb_io_capacity | Two hundred | 在非紧急情况下,允许 InnoDB 每秒进行多少次 I/O 操作。 |
| innodb_io_capacity_max | Two thousand | 在紧急情况下,允许 InnoDB 每秒进行多少次 I/O 操作。 |
| innodb_flush_method | unbuffered或者fsync | InnoDB 用于将更改写入磁盘的方法。在 Microsoft Windows 上默认为unbuffered,在 Linux/Unix 上默认为fsync。 |

这些选项将在本节的剩余部分更详细地讨论,从与缓冲池大小相关的选项开始。

Note

选项key_buffer_size与缓存 InnoDB 索引无关。该选项在 MySQL 早期获得了它的名字,当时 MyISAM 存储引擎是主要的存储引擎,所以不需要在选项前面加上前缀mysiam。除非使用 MyISAM 表,否则没有理由配置key_buffer_size

缓冲池大小

这些选项中最重要的是缓冲池的大小。128 MiB 的缺省大小很适合在您的笔记本电脑上设置一个测试实例,而不会耗尽它的内存(这也是为什么缺省值这么小的原因),但是对于生产系统,您很可能希望分配更多的内存。您可以从增加大小中受益,直到您的工作数据集适合缓冲池。工作数据集是执行查询所需的数据。通常,这是整个数据集的子集,因为一些数据是不活动的,例如,因为它涉及过去的事件。

Tip

如果您有一个大的缓冲池并且启用了核心转储,那么禁用innodb_buffer_pool_in_core_file选项以避免在发生核心转储时转储整个缓冲池。该选项在 MySQL 8.0.14 和更高版本中可用。

您可以使用下面的公式获得缓冲池命中率——即不从磁盘读取而直接从缓冲池完成页面请求的频率:$$ Hit\ Rate=100-\left(\frac{Innodb_ pages_ read}{Innodb_ buffer_ pool_ read_ requests}\right) $$。两个变量Innodb_pages_readInnodb_buffer_pool_read_requests是状态变量。清单 23-1 展示了一个如何计算缓冲池命中率的例子。

mysql> SELECT Variable_name, Variable_value
         FROM sys.metrics
        WHERE Variable_name IN
                ('Innodb_pages_read',
                 'Innodb_buffer_pool_read_requests')\G
*************************** 1\. row ***************************
 Variable_name: innodb_buffer_pool_read_requests
Variable_value: 141319
*************************** 2\. row ***************************
 Variable_name: innodb_pages_read
Variable_value: 1028
2 rows in set (0.0089 sec)

mysql> SELECT 100 - (100 * 1028/141319) AS HitRate;
+---------+
| HitRate |
+---------+
| 99.2726 |
+---------+
1 row in set (0.0003 sec)

Listing 23-1Calculating the buffer pool hit rate

在这个例子中,99.3%的页面请求都是从缓冲池中完成的。这个数字适用于所有缓冲池实例。如果要确定给定期间的命中率,需要收集期间开始和结束时状态变量的值,并在计算中使用它们之间的差值。您还可以从信息模式中的INNODB_BUFFER_POOL_STATS视图或 InnoDB 监控器中获取速率。在这两种情况下,比率都是按照每千个请求返回的。清单 23-2 展示了这样的例子。您需要确保已经执行了一些查询来生成一些缓冲池活动,以获得有意义的结果。

mysql> SELECT POOL_ID, NUMBER_PAGES_READ,
              NUMBER_PAGES_GET, HIT_RATE FROM information_schema.INNODB_BUFFER_POOL_STATS\G
*************************** 1\. row ***************************
          POOL_ID: 0
NUMBER_PAGES_READ: 1028
 NUMBER_PAGES_GET: 141319
         HIT_RATE: 1000
1 row in set (0.0004 sec)

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
=================================================
2019-07-20 19:33:12 0x7550 INNODB MONITOR OUTPUT
=================================================
...
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137363456
Dictionary memory allocated 536469
Buffer pool size   8192
Free buffers       6984
Database pages     1190
Old database pages 428
Modified db pages  0
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 38, not young 0
0.00 youngs/s, 0.00 non-youngs/s
Pages read 1028, created 237, written 1065
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 0 / 1000 not 0 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 1190, unzip_LRU len: 0
I/O sum[6]:cur[0], unzip sum[0]:cur[0]
...

Listing 23-2Getting the buffer pool hit rate directly from InnoDB

重要的是要认识到,InnoDB 直接返回的命中率是自上次检索缓冲池统计数据以来的一段时间内的命中率,并且是针对每个缓冲池实例的。如果你想完全控制某个时间段的命中率,你需要自己计算,要么使用状态变量,要么使用INNODB_BUFFER_POOL_STATS视图中的NUMBER_PAGES_READNUMBER_PAGES_GET

您的目标应该是让缓冲池命中率尽可能接近 100%或 1000/1000。也就是说,在某些情况下,这是不可能的,因为数据量不可能适合内存。在这种情况下,缓冲池命中率仍然很有用,因为它允许您监控缓冲池随时间推移的有效性,并与一般的查询统计数据进行比较。如果缓冲池命中率随着查询性能的下降而开始下降,那么您应该考虑做一些准备,以便可以增加缓冲池的大小。

缓冲池实例

MySQL 从版本 5.5 开始就支持多个缓冲池实例。引入它的原因是典型的数据库工作负载有越来越多的查询并行运行,每台主机的 CPU 越来越多。这导致在访问缓冲池中的数据时出现互斥争用。

减少争用的解决方案之一是允许将缓冲池分成多个实例,每个实例使用不同的互斥体。实例的数量由innodb_buffer_pool_instances选项控制。用innodb_buffer_pool_size指定的缓冲池总量在实例之间平均分配。除了在 32 位 Windows 上,默认情况下,小于 1gb 的缓冲池有一个实例。对于较大的缓冲池,缺省值是八个实例。最大实例数为 64。

对于单线程工作负载,最佳方案是将所有内存放在一个缓冲池中。您的工作负载越并行,越多的额外实例有助于减少争用。增加缓冲池数量的确切效果取决于并行查询对存储在不同页面中的数据的请求程度。如果所有请求都是针对不同页面的,那么您可以从增加实例的数量和并发查询的数量中获益。如果所有查询都在同一个页面中请求数据,那么使用更多实例并没有什么好处。一般来说,注意不要让每个缓冲池实例太小。如果没有监控数据来证明,对于至少 8gb 大的缓冲池,允许每个实例为 1gb 或更大。

转储缓冲池

数据库重启的一个常见问题是,在缓存预热之前,缓存暂时无法正常工作。这可能导致非常差的查询性能和最终用户满意度。对此的解决方案是在关机时在缓冲池中存储一个最常用页面的列表,并在重启后立即将这些页面读入缓冲池,即使还没有查询请求它们。

默认情况下,这个特性是启用的,要考虑的主要问题是要在转储中包含多少缓冲池。这是由innodb_buffer_pool_dump_pct选项控制的,该选项接受要包含的页面百分比。默认值为 25%。这些页面是从新块子列表的头部读取的,因此包含的是最近使用的页面。

转储只包括对应该读取的页面的引用,所以转储的大小大约是每页 8 个字节。如果您有一个 128 GiB 的缓冲池,并且正在使用 16 个 KiB 页面,那么缓冲池中有 8,388,608 个页面。如果对缓冲池转储使用默认值 25%,那么转储大约为 16 MiB。转储存储在数据目录中的文件ib_buffer_pool中。

Tip

当您通过复制表空间文件创建备份(物理或原始备份)时,也要备份ib_buffer_pool文件。您可以使用innodb_buffer_pool_dump_now选项创建最近使用的页面的新副本。例如,这是由 MySQL 企业备份自动完成的。然而,对于逻辑备份(数据导出为 SQL 或 CSV 文件),ib_buffer_pool文件没有用。

如果在重新启动后遇到查询速度慢的问题,可以考虑增加innodb_buffer_pool_dump_pct以在转储中包含更大部分的缓冲池。增加该选项的主要缺点是,随着更多的页面引用被导出,关闭需要更长的时间,ib_buffer_pool文件变得更大,重启后加载页面需要更长的时间。将页面加载回缓冲池是在后台进行的,但是如果包含更多的页面,那么在将所有最重要的页面恢复到缓冲池中之前,可能需要更长的时间。

旧块子列表

如果您有一个大于缓冲池的数据集,一个潜在的问题是大型扫描可能会拉入仅用于该扫描的数据,然后在很长一段时间内不再使用。当这种情况发生时,您会面临更频繁使用的数据被从缓冲池中排除的风险,并且需要这些数据的查询会受到影响,直到扫描完成并且恢复了平衡。由mysqlpumpmysqldump进行的逻辑备份就是触发该问题的作业的很好例子。备份过程需要扫描所有数据,但在下次备份之前不再需要这些数据。

为了避免这个问题,缓冲池被分成两个子列表:新的和旧的块子列表。当从表空间中读取页面时,它们首先被“隔离”在旧块子列表中,只有当页面在缓冲池中的时间超过innodb_old_blocks_time毫秒并被再次使用时,它才会被移动到新块子列表中。这有助于防止缓冲池扫描,因为单个表扫描只会快速连续地读取一页中的行,然后不会再次使用该页。这使得 InnoDB 可以在扫描完成后自由地删除页面。

innodb_old_blocks_time的默认值是 1000 毫秒,对于大多数工作负载来说,这足以避免扫描污染缓冲池。如果您有正在进行扫描的作业,其中该作业在短时间(但长于一秒)后再次返回到相同的行,那么如果您不希望后续访问将页面提升到新的块子列表,则可以考虑增加innodb_old_blocks_time

旧块子列表的大小由innodb_old_blocks_pct选项设置,该选项指定应该用于旧块子列表的缓冲池的百分比。默认使用 37%。如果您有一个大的缓冲池,您可能想要减少innodb_old_blocks_pct以避免新加载的页面占用太多的缓冲池。旧块子列表的最佳大小还取决于将临时页面加载到缓冲池中的速率。

您可以监控新旧块子列表的使用情况,类似于如何找到命中率。清单 23-3 显示了使用INNODB_BUFFER_POOL_STATS视图和 InnoDB 监控器的示例输出。

mysql> SELECT PAGES_MADE_YOUNG,
              PAGES_NOT_MADE_YOUNG,
              PAGES_MADE_YOUNG_RATE,
              PAGES_MADE_NOT_YOUNG_RATE,
              YOUNG_MAKE_PER_THOUSAND_GETS,
              NOT_YOUNG_MAKE_PER_THOUSAND_GETS
         FROM information_schema.INNODB_BUFFER_POOL_STATS\G
*************************** 1\. row ***************************
                PAGES_MADE_YOUNG: 98
            PAGES_NOT_MADE_YOUNG: 354
           PAGES_MADE_YOUNG_RATE: 0.00000000383894451752074
       PAGES_MADE_NOT_YOUNG_RATE: 0
    YOUNG_MAKE_PER_THOUSAND_GETS: 2
NOT_YOUNG_MAKE_PER_THOUSAND_GETS: 10
1 row in set (0.0005 sec)

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
===============================================
2019-07-21 12:06:49 0x964 INNODB MONITOR OUTPUT
===============================================
...
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137363456
Dictionary memory allocated 463009
Buffer pool size   8192
Free buffers       6974
Database pages     1210
Old database pages 426
Modified db pages  0
Pending reads      0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 98, not young 354
0.00 youngs/s, 0.00 non-youngs/s
Pages read 996, created 223, written 430
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
Buffer pool hit rate 1000 / 1000, young-making rate 2 / 1000 not 10 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 1210, unzip_LRU len: 0
I/O sum[217]:cur[0], unzip sum[0]:cur[0]
...

Listing 23-3Obtaining information about the new and old blocks sublists

Pages make young表示位于旧块子列表中的页面被移动到新块子列表中。一个页面不年轻意味着它停留在旧块子列表中。这两个速率列是自上次获取数据以来的每秒速率。每千页获取数是每千页请求中保留在旧块子列表中的年轻页数;这也是自上次报告以来的情况。

您可能需要配置旧块子列表的一个可能迹象是扫描进行时缓冲池命中率下降。如果使页面年轻的比率很高,并且同时有大量扫描,则应该考虑增加innodb_old_blocks_time以防止后续读取使页面年轻。或者,考虑减少innodb_old_blocks_pct以在旧块子列表中经过较短时间后从扫描中驱逐页面。

反之亦然,如果您扫描的次数很少,并且页面停留在旧块子列表中(非年轻的 making stats 很高),那么您应该考虑减少innodb_old_blocks_time以更快地提升页面,或者增加innodb_old_blocks_pct以允许页面在被驱逐之前在旧块子列表中保留更长时间。

刷新页面

InnoDB 需要平衡将更改合并到表空间文件的难度。如果它太懒惰,重做日志最终会满,需要强制刷新,但是如果它太努力,会影响系统其他部分的性能。不用说,得到正确的等式是复杂的。除了在崩溃恢复期间或在恢复物理备份(如使用 MySQL Enterprise Backup 创建的备份)之后,合并是通过将脏页从缓冲池刷新到表空间文件来完成的。

在最近的 MySQL 版本中,只要有足够的重做日志,您通常不需要做太多事情,因为 InnoDB 使用的自适应刷新算法擅长取得良好的平衡。主要有三个选项需要考虑:两个用于设置系统的 I/O 容量,一个用于设置刷新方法。

I/O 容量的两个选项是innodb_io_capacityinnodb_io_capacity_maxinnodb_io_capacity选项在正常刷新更改时使用,应该设置为 InnoDB 每秒允许使用的 I/O 操作数。在实践中,不太容易知道使用什么值。默认值为 200,大致相当于低端 SSD。通常高端存储可以受益于将容量设置为几千。最好从一个相对较低的值开始,如果您的监控显示刷新落后并且有备用 I/O 容量,则增加该值。

Note

innodb_io_capacityinnodb_io_capacity_max选项不仅用于确定 InnoDB 将脏页刷新到表空间文件的速度。还包括其他 I/O 活动,如从更改缓冲区合并数据。

innodb_io_capacity_max选项告知如果冲洗落后,允许 InnoDB 多用力。默认值是最小值 2000 和两倍的值innodb_io_capacity。在大多数情况下,默认值工作良好,但如果您有一个低端磁盘,您应该考虑将设置减少到 1000 以下。如果您遇到异步刷新(这将在重做日志中讨论),并且您的监控显示 InnoDB 没有使用足够的 I/O 容量,请增加innodb_io_capacity_max的值。

Caution

将 I/O 容量设置得太高会严重影响系统的性能。

脏页的刷新可以通过几种方式来执行,例如,使用操作系统 I/O 缓存或避免它。这由innodb_flush_method选项控制。在 Microsoft Windows 上,您可以在值unbuffered(默认和推荐)和normal之间进行选择。在支持以下值的 Linux 和 Unix 上,选择更加困难:

  • fsync : 这是默认值。InnoDB 使用fsync()系统调用。数据也将缓存在操作系统 I/O 缓存中。

  • O_DSYNC : InnoDB 在打开重做日志文件(同步写入)时使用O_SYNC选项,并对数据文件使用fsync。之所以用O_SYNC代替O_DSYNC,是因为O_DSYNC已经被证明太不安全,所以用O_SYNC代替。

  • O_DIRECT : 这和fsync类似,但是绕过了操作系统 I/O 缓存。它只适用于表空间文件。

  • O_DIRECT_NO_FSYNC : 除了跳过fsync()系统调用之外,与O_DIRECT相同。由于 EXT4 和 XFS 文件系统中的错误,在 MySQL 8.0.14 实现这些错误的解决方案之前,使用这种方法是不安全的。如果重做日志文件与表空间文件位于不同的文件系统上,那么应该使用O_DIRECT而不是O_DIRECT_NO_FSYNC。在大多数生产系统中,这是最好的选择。

此外,有几个实验性的刷新方法应该只用于性能测试。 1 这里不涉及这些实验方法。

哪种冲洗方法将提供最佳性能是非常复杂的。由于 InnoDB 自己缓存数据,并且比操作系统做得更好(因为 InnoDB 知道数据是如何使用的),很自然地认为O_DIRECT选项中的一个会工作得最好。这也是通常的情况;但是,生活更复杂,在某些情况下,fsync更快。因此,您需要在您的系统上进行测试,以确定哪种冲洗方法效果最好。还有一点就是,在不重启操作系统的情况下重启 MySQL 时,如果使用fsync flush 方法,那么 InnoDB 在第一次读取数据时就可以受益于 I/O 缓存。

在数据流的另一端是重做日志。

重做日志

重做日志用于保存提交的更改,同时提供顺序 I/O 以尽可能提高性能。为了提高性能,在将更改写入日志文件之前,会先将其写入内存中的日志缓冲区。

然后,后台进程通过双写缓冲区将缓冲池中的更改合并到表空间中。尚未合并到表空间文件中的页面不能从缓冲池中收回,因为它们被认为是脏的。页面是脏的意味着它的内容与表空间中的相同页面不同,因此在合并更改之前,不允许 InnoDB 从表空间中读取页面。

23-2 总结了重做日志相关的配置选项,您很可能需要更改这些选项来优化查询性能。

表 23-2

重做日志的重要配置选项

|

选项名称

|

缺省值

|

评论

|
| --- | --- | --- |
| innodb_log_buffer_size | 16 兆字节 | 日志缓冲区的大小,重做日志事件在写入磁盘上的重做日志文件之前存储在内存中。 |
| innodb_log_file_size | 48 兆字节 | 重做日志中每个文件的大小。 |
| innodb_log_files_in_group | Two | 重做日志中的文件数。必须至少有两个文件。 |

本节的剩余部分将介绍这些选项。

日志缓冲区

日志缓冲区是一个内存缓冲区,InnoDB 使用它来缓冲重做日志事件,然后将它们写入磁盘。这允许事务将更改保留在内存中,直到缓冲区满或更改被提交。日志缓冲区的默认大小是 16 MiB。

如果有大型事务或大量较小的并发事务,建议增加日志缓冲区的大小。使用innodb_log_buffer_size选项设置日志缓冲区的大小。在 MySQL 8 中(与旧版本不同),可以动态改变大小。理想情况下,缓冲区应该足够大,这样 InnoDB 只需在提交更改时写出更改;然而,这当然应该与内存的其他用途进行权衡。如果单个事务在缓冲区中有大量更改,也会降低提交速度,因为此时所有数据都必须写入重做日志,所以对于非常大的日志缓冲区大小,这是另一个需要考虑的问题。

一旦日志缓冲区已满或事务被提交,重做日志事件将被写入重做日志文件。

日志文件

重做日志的大小是固定的,由许多文件(至少两个)组成,每个文件的大小都相同。配置重做日志时的主要考虑是确保它们足够大,不会变得“满”实际上,满意味着触发异步刷新时容量的 75%。异步刷新会阻塞触发刷新的线程,而原则上其他线程可以继续工作。在实践中,异步刷新是如此凶猛,以至于它通常会导致系统突然停止。还有一个同步刷新,它在容量达到 90%时触发,并阻塞所有线程。

您可以通过两个选项innodb_log_file_sizeinnodb_log_files_in_group来控制尺寸。总重做日志大小是这两个值的乘积。建议将文件大小设置为 1–2 GiB,并调整文件数量,以获得最少两个文件的所需总大小。不让每个重做日志文件变得非常大的原因是它们被缓冲在操作系统 I/O 缓存中(即使使用innodb_flush_method = O_DIRECT),文件越大,重做日志就越有可能使用 I/O 缓存中的大量内存。重做日志的总大小不允许超过 512 GiB,最多可以有 100 个文件。

Note

重做日志越大,可以存储的尚未从缓冲池刷新到表空间的更改就越多。这可能会增加崩溃时的恢复时间以及执行正常关机所需的时间。

确定重做日志有多大的最佳方法是通过监控解决方案来监控日志随着时间的推移有多满。图 23-4 显示了显示重做日志文件的 I/O 速率和通过检查点延迟测量的重做日志使用情况的图表示例。如果您想创建类似的东西,您需要执行密集的写工作日志;employees数据库对此很有用。具体需要什么取决于硬件、配置、哪些其他进程使用这些资源等等。

img/484666_1_En_23_Fig4_HTML.jpg

图 23-4

重做日志的时间序列图表

确保重做日志中没有检查点的部分没有 75%的标记。在本例中,最高峰值出现在重做日志的 96 MiB(14:37)中的大约 73 MiB 处,这意味着几乎 76%的重做日志用于脏页。这意味着在那段时间有一个异步刷新,这会影响当时运行的查询。您可以使用重做日志文件的 I/O 率来了解文件系统对重做日志进行 I/O 的压力有多大。

手动检查当前重做日志使用情况的最佳方式是启用log_lsn_currentlog_lsn_last_checkpoint InnoDB 指标,它们允许您查询当前日志序列号和创建最后一个检查点时的日志序列号。检查点延迟占总重做日志的百分比计算为$$ Lag\ Pct=100\ast \frac{\log _ lsn_ last_ checkpoint-\log _ lsn_ current}{#\log file s\ast \log file\ size} $$

您可以从information_schemasys.metrics视图的INNODB_METRICS表中获取当前值。或者,也可以从 InnoDB 监控器的LOG部分获取日志序列号,而不管指标是否已启用。清单 23-4 展示了一个使用这些资源确定检查点延迟的例子。

mysql> SET GLOBAL innodb_monitor_enable = 'log_lsn_current',
           GLOBAL innodb_monitor_enable = 'log_lsn_last_checkpoint';
Query OK, 0 rows affected (0.0004 sec)

mysql> SELECT *
         FROM sys.metrics
        WHERE Variable_name IN ('log_lsn_current',
                                'log_lsn_last_checkpoint')\G
*************************** 1\. row ***************************
 Variable_name: log_lsn_current
Variable_value: 1678918975
          Type: InnoDB Metrics - log
       Enabled: YES
*************************** 2\. row ***************************
 Variable_name: log_lsn_last_checkpoint
Variable_value: 1641343518
          Type: InnoDB Metrics - log
       Enabled: YES
2 rows in set (0.0078 sec)

mysql> SELECT ROUND(
                100 * (
                  (SELECT COUNT
                     FROM information_schema.INNODB_METRICS
                    WHERE NAME = 'log_lsn_current')
                - (SELECT COUNT
                     FROM information_schema.INNODB_METRICS
                    WHERE NAME = 'log_lsn_last_checkpoint')
                ) / (@@global.innodb_log_file_size
                     * @@global.innodb_log_files_in_group
              ), 2) AS LogUsagePct;
+-------------+
| LogUsagePct |
+-------------+
|       39.25 |
+-------------+
1 row in set (0.0202 sec)

mysql> SHOW ENGINE INNODB STATUS\G
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
===============================================
2019-07-21 17:04:09 0x964 INNODB MONITOR OUTPUT
===============================================
...
---
LOG
---
Log sequence number          1704842995
Log buffer assigned up to    1704842995
Log buffer completed up to   1704842235
Log written up to            1704842235
Log flushed up to            1696214896
Added dirty pages up to      1704827409
Pages flushed up to          1668546370
Last checkpoint at           1665659636
5360916 log i/o's done, 23651.73 log i/o's/second
...

Listing 23-4Querying the redo log usage

首先启用所需的 InnoDB 指标。启用它们的开销非常小,所以让它们保持启用状态是没问题的。然后从sys.metrics视图中查询指标值,然后使用INNODB_METRICS表直接计算滞后。最后,日志序列号也可以在 InnoDB monitor 输出中找到。日志序列号变化非常快,因此即使您快速连续地查询它们,如果有任何工作正在进行,它们也会发生变化。这些值反映了 InnoDB 中已经完成的工作量(以字节为单位),因此它们在任何两个系统上都是不同的。

并行查询执行

从 MySQL 8.0.14 开始,InnoDB 对并行执行查询的支持变得有限。这是通过使用多个读取线程对聚集索引或分区执行扫描来实现的。在 8.0.17 中,实现得到了很大的改进,这也是本文考虑的内容。

并行扫描会根据将要扫描的索引子树的数量自动进行。通过设置innodb_parallel_read_threads选项,您可以配置 InnoDB 可以为所有连接的并行执行创建的最大线程数。这些线程是作为后台线程创建的,只在需要时才出现。如果所有并行线程都在使用中,InnoDB 将恢复单线程执行任何额外的查询,直到线程再次可用。

从 MySQL 8.0.18 开始,并行扫描用于没有任何过滤条件的SELECT COUNT(*)(允许多个表)以及由CHECK TABLE执行的两次扫描中的第二次。

通过查找名为thread/innodb/parallel_read_thread的线程,您可以从performance_schema.threads表中看到并行线程的当前使用情况。如果您想尝试这个特性,您可以在 MySQL Shell 中使用 Python 模式来继续计算employees.salaries表中的行数:

Py> for i in range(100): session.run_sql('SELECT COUNT(*) FROM employees.salaries')

带有innodb_parallel_read_threads = 4(默认)的performance_schema.threads的输出示例如下

mysql> SELECT THREAD_ID, TYPE, THREAD_OS_ID
         FROM performance_schema.threads
        WHERE NAME = 'thread/innodb/parallel_read_thread';
+-----------+------------+--------------+
| THREAD_ID | TYPE       | THREAD_OS_ID |
+-----------+------------+--------------+
|        91 | BACKGROUND |        12488 |
|        92 | BACKGROUND |         5232 |
|        93 | BACKGROUND |        13836 |
|        94 | BACKGROUND |        24376 |
+-----------+------------+--------------+
4 rows in set (0.0005 sec)

您可以尝试使用较小的表,比如在world数据库中的表,并查看后台线程数量的差异。

如果您看到所有配置的读取线程大部分时间都在使用,并且您有空闲的 CPU,那么您可以考虑增加innodb_parallel_read_threads的值。支持的最大值是 256。记住为单线程查询留下足够的 CPU 资源。

如果您看到信号量等待,并且对 CPU 的监控表明在存在许多并行读取线程的情况下存在 CPU 资源争用,那么您可以考虑减少innodb_parallel_read_threads来降低查询的并行性。

查询缓冲区

MySQL 在查询执行期间使用几个缓冲区。这些包括存储连接中使用的列值、用于排序的缓冲区等等。人们很容易认为这些缓冲越多越好,但通常情况并非如此。相反,往往越少越好。本节讨论为什么会这样。

当 MySQL 需要为查询或查询的一部分使用缓冲区时,有几个因素决定了对查询的影响。这些因素包括以下内容:

  • 对于所需的工作,缓冲区是否足够大?

  • 内存够吗?

  • 分配缓冲区的成本是多少?

如果缓冲区不够大,算法就不能以最佳状态运行,因为需要更多的迭代,或者需要溢出到磁盘。但是,在某些情况下,缓冲区的配置值用作最小大小,而不是最大大小。例如,大小由join_buffer_size设置的连接缓冲区就是这种情况。最小大小始终是分配的,如果在使用它进行连接时,它不足以容纳单行中所需的列,那么它将根据需要进行扩展。

关于记忆的问题也很相关。大概 MySQL 崩溃最常见的原因就是操作系统内存不足,操作系统杀死了 MySQL。对于单个查询来说,各种缓冲区所需的内存量看起来并不多,但是如果您将所有并发执行的查询相乘,并加上空闲连接和全局分配所需的内存,您可能会突然发现内存不足。这也可能导致交换,这是一个主要的性能杀手。

最后一点对大多数人来说更令人惊讶。分配内存是有成本的,通常你需要的内存越多,每字节的成本就越高。例如,在 Linux 上,分配方法的改变有不同的阈值。这些阈值取决于 Linux 发行版,但可能是 256 KiB 和 2 MiB。如果超过其中一个阈值,分配方法就会变得更加昂贵。这是选项join_buffer_sizesort_buffer_sizeread_rnd_buffer_size的默认值为 256 KiB 的部分原因。这意味着有时缓冲区太小会更好,因为优化缓冲区大小的好处不足以提高性能来补偿分配更多内存的开销。

Tip

缓冲区的分配是需要改进的地方之一,因此在某些情况下,升级可以让您使用更大的缓冲区,而没有传统的缺点。例如,在 MySQL 8.0.12 和更高版本中,使用了排序缓冲区的新算法。这意味着在 Linux/Unix 上,对于 Windows 上的非并发排序,内存是以增量方式分配的,这使得为sort_buffer_size设置一个较大的值更安全。尽管如此,您仍然需要考虑单个查询允许使用多少内存。

结论是,最好保守地使用在查询期间分配的缓冲区。保持较小的全局设置(默认值是一个很好的起点),并且只在您可以证明增加设置会带来显著改善的查询中增加设置。

内部临时表

当一个查询需要存储一个子查询的结果,组合UNION语句的结果,等等,它使用一个内部临时表。MySQL 8 采用了新的TempTable存储引擎,当在内存中保存表时,它大大优于以前版本中使用的MEMORY引擎,因为它支持可变宽度列(从 8.0.13 版本开始支持 blob 和 text 列)。此外,TempTable引擎支持使用 mmap 溢出到磁盘,因此如果表不适合内存,可以避免存储引擎转换。

对于 MySQL 8 中的内部临时表,主要有两个设置需要考虑:TempTable引擎允许使用多少内存,以及如果需要溢出到磁盘时会发生什么。

您可以使用temptable_max_ram选项配置内部临时表使用的最大内存量。这是一个全局设置,默认为 1 GiB。这些内存由所有需要内部临时表的查询共享,因此很容易限制总的内存使用量。temptable_max_ram选项可以动态设置。

如果内存不足,有必要开始在磁盘上存储临时表。如何完成由 8.0.16 版本中引入的temptable_use_mmap选项控制。默认值是ON,这意味着TempTable引擎为磁盘上的数据分配空间,作为内存映射的临时文件。这也是 8.0.16 之前使用的方法。如果该值设置为OFF,则使用 InnoDB 磁盘上的内部临时表。除非内存映射文件出现问题,否则建议使用默认设置。

您可以使用memory/temptable/physical_rammemory/temptable/physical_disk性能模式事件来监控TempTable的内存使用情况。物理 RAM 事件显示了TempTable引擎内存部分的内存使用情况,而物理磁盘事件显示了temptable_use_mmap = ON时的内存映射部分。清单 23-5 展示了查询两个内存事件的内存使用情况的三个例子。

mysql> SELECT *
         FROM sys.memory_global_by_current_bytes
        WHERE event_name
                IN ('memory/temptable/physical_ram',
                    'memory/temptable/physical_disk')\G
*************************** 1\. row ***************************
       event_name: memory/temptable/physical_ram
    current_count: 14
    current_alloc: 71.00 MiB
current_avg_alloc: 5.07 MiB
       high_count: 15
       high_alloc: 135.00 MiB
   high_avg_alloc: 9.00 MiB
*************************** 2\. row ***************************
       event_name: memory/temptable/physical_disk
    current_count: 1
    current_alloc: 64.00 MiB
current_avg_alloc: 64.00 MiB
       high_count: 1
       high_alloc: 64.00 MiB
   high_avg_alloc: 64.00 MiB
2 rows in set (0.0012 sec)

mysql> SELECT *
         FROM performance_schema.memory_summary_global_by_event_name
        WHERE EVENT_NAME
                IN ('memory/temptable/physical_ram',
                    'memory/temptable/physical_disk')\G
*************************** 1\. row ***************************
                  EVENT_NAME: memory/temptable/physical_disk
                 COUNT_ALLOC: 2

                  COUNT_FREE: 1
   SUM_NUMBER_OF_BYTES_ALLOC: 134217728
    SUM_NUMBER_OF_BYTES_FREE: 67108864
              LOW_COUNT_USED: 0
          CURRENT_COUNT_USED: 1
             HIGH_COUNT_USED: 1
    LOW_NUMBER_OF_BYTES_USED: 0
CURRENT_NUMBER_OF_BYTES_USED: 67108864
   HIGH_NUMBER_OF_BYTES_USED: 67108864
*************************** 2\. row ***************************
                  EVENT_NAME: memory/temptable/physical_ram
                 COUNT_ALLOC: 27
                  COUNT_FREE: 13
   SUM_NUMBER_OF_BYTES_ALLOC: 273678336
    SUM_NUMBER_OF_BYTES_FREE: 199229440
              LOW_COUNT_USED: 0
          CURRENT_COUNT_USED: 14
             HIGH_COUNT_USED: 15
    LOW_NUMBER_OF_BYTES_USED: 0
CURRENT_NUMBER_OF_BYTES_USED: 74448896
   HIGH_NUMBER_OF_BYTES_USED: 141557760
2 rows in set (0.0004 sec)

mysql> SELECT *
         FROM performance_schema.memory_summary_by_thread_by_event_name
        WHERE EVENT_NAME
                IN ('memory/temptable/physical_ram',
                    'memory/temptable/physical_disk')
          AND COUNT_ALLOC > 0\G
*************************** 1\. row ***************************
                   THREAD_ID: 29
                  EVENT_NAME: memory/temptable/physical_disk
                 COUNT_ALLOC: 2
                  COUNT_FREE: 1
   SUM_NUMBER_OF_BYTES_ALLOC: 134217728
    SUM_NUMBER_OF_BYTES_FREE: 67108864
              LOW_COUNT_USED: 0
          CURRENT_COUNT_USED: 1
             HIGH_COUNT_USED: 1
    LOW_NUMBER_OF_BYTES_USED: 0
CURRENT_NUMBER_OF_BYTES_USED: 67108864
   HIGH_NUMBER_OF_BYTES_USED: 67108864
1 row in set (0.0098 sec)

Listing 23-5Querying the TempTable memory usage

前两个查询请求全局使用,而第三个查询请求每个线程的使用。第一个查询使用了sys.memory_global_by_current_bytes视图,该视图返回当时具有大于 0 的current_alloc的事件。这表明TempTable引擎正在使用中,一部分数据已经使用内存映射文件溢出到磁盘。第二个查询使用性能模式,即使当前没有为其分配内存,也将始终返回这两个事件的数据。第三个查询显示了哪些线程已经分配了TempTable内存。由于TempTable溢出的实现方式,使用性能模式不可能看到哪些线程在磁盘上有文件。

摘要

本章介绍了配置 MySQL 实例的一般注意事项以及最常需要调整的选项。当您考虑对配置进行更改时,最重要的是您要考虑为什么要进行更改,它应该解决什么问题,为什么它会解决这个问题,并且您要确认它是否有效。您可以通过一次对一个选项进行少量的增量更改来最好地确认这一点。

最有可能受益于非默认值的三个选项是用于设置 InnoDB 缓冲池大小的innodb_buffer_pool_size,以及用于设置重做日志大小的innodb_log_file_sizeinnodb_log_files_in_group选项。讨论的其他 InnoDB 选项控制缓冲池实例的数量、转储时包含多少缓冲池、旧块子列表、如何刷新页面以及重做日志缓冲区的大小。

在 MySQL8.0.14 和更高版本中,支持并行执行一些查询。您可以使用innodb_parallel_read_threads选项限制并行度,从 8.0.17 开始,该选项指定 InnoDB 将跨所有连接创建的最大并行线程总数。并行执行线程被视为后台线程,仅在并行执行查询时存在。

您的查询也可能受益于更大的每个查询缓冲区,但您必须小心,因为较大的值不一定比较小的值更好。建议对这些缓冲区使用缺省值,只有在测试证明有明显好处的情况下才增加它们。

最后,讨论了内部临时表。在 MySQL 8 中,它们使用TempTable引擎,当达到全局最大内存使用量时,该引擎支持溢出到磁盘。将内部临时表存储在磁盘上时,也可以将其转换为 InnoDB。

下一章将探讨如何改变查询以获得更好的性能。

二十四、更改查询计划

性能不佳的查询不能按预期工作有几个可能的原因。这包括从查询明显错误到低级原因,如非最佳查询计划或资源争用。本章将讨论一些常见的情况和解决方案。

本章首先介绍了本章中大多数示例使用的测试数据,并讨论了过度全表扫描的症状。然后讲述了查询中的错误如何会导致严重的性能问题,以及为什么即使存在索引也不总是可以使用。本章的中间部分介绍了通过改进索引使用或重写复杂查询来改进查询的各种方法。最后一部分讨论了如何使用SKIP LOCKED子句实现队列系统,以及如何处理带有多个OR条件或带有多个值的IN ()子句的查询。

测试数据

本章主要使用专门为本章中的示例创建的测试数据。如果您想亲自尝试这些示例,本书的 GitHub 资源库中的文件chapter_24.sql包含了必要的表定义和数据。该脚本将删除chapter_24模式,并用表创建它。

您可以使用 MySQL Shell 中的\source命令或mysql命令行客户端中的SOURCE命令来执行该脚本。例如:

mysql shell> \source chapter_24.sql
...
mysql shell> SHOW TABLES FROM chapter_24;
+----------------------+
| Tables_in_chapter_24 |
+----------------------+
| address              |
| city                 |
| country              |
| jobqueue             |
| language             |
| mytable              |
| payment              |
| person               |
+----------------------+
8 rows in set (0.0033 sec)

该脚本要求在获取chapter_24.sql脚本之前安装world示例数据库。

Note

由于索引统计数据是使用对索引的随机深入来确定的,因此在每次分析之后,它们的值不会相同。因此,在尝试本章中的例子时,不要期望得到相同的输出。

过度全表扫描的症状

最严重的性能问题的原因之一是全表扫描,特别是当涉及到连接并且全表扫描不在查询块的第一个表上时。它会给 MySQL 带来太多的工作,还会影响其他连接。当 MySQL 无法使用索引进行查询时,就会发生全表扫描,这是因为没有过滤条件或者没有针对当前条件的索引。全表扫描的一个副作用是,大量数据被拉入缓冲池,可能永远不会返回给应用。这可能会使磁盘 I/O 量急剧增加,从而导致进一步的性能问题。

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

检测过度全表扫描的最佳方法是使用您的监控。直接的方法是寻找在性能模式中被标记为使用全表扫描的查询,并比较检查的行数与返回的或受影响的行数之比,如第 19 章所述。您还可以查看 timeseries 图来发现被访问的行太多或 CPU 使用太多的模式。图 24-1 显示了在一个 MySQL 实例上进行全表扫描期间的监控图示例。(employees数据库在您想要模拟这种情况时非常有用,因为它有足够大的表来允许一些相对较大的扫描。)

img/484666_1_En_24_Fig1_HTML.jpg

图 24-1

当存在带有全表扫描的查询时监控图形

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

Tip

在 MySQL 8.0.18 和更高版本中,与连接相关的全表扫描并不是一个大问题,因为散列连接可以用于等价连接。也就是说,散列连接仍然会将比所需更多的数据拉入缓冲池。

最大的问题是什么时候 CPU 使用率过高,访问的行数过多,不幸的是,答案是“视情况而定”如果您考虑 CPU 的使用情况,那么它真正能告诉您的是正在进行的工作,对于正在访问的行数和访问速率,这些指标只是告诉您应用正在请求数据。问题是当做了太多的工作,并且为应用需要答案的问题访问了太多的行时。在某些情况下,优化查询可能会增加这些指标中的一些,而不是减少它们——这只是因为带有优化查询的 MySQL 能够做更多的工作。

这是基线如此重要的一个例子。考虑度量标准的变化通常比查看它们的快照更有用。类似地,综合考虑这些指标——比如比较返回的行数和访问的行数——比单独考虑它们会得到更多。

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

错误的查询

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

在极端情况下,缺少过滤条件的查询会使应用超时查询(但不会终止查询)并重试,因此 MySQL 会不断执行越来越多性能非常差的查询。这反过来会使 MySQL 失去连接。

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

这可能看起来很极端,在大多数情况下,它不会变得那么糟糕。然而,由于生成查询的框架中的一个 bug,这本书的作者确实经历了这种情况。鉴于 MySQL 实例现在通常位于云中的虚拟机中,可能具有有限的可用资源,如 CPU 和网络,也更有可能是一个糟糕的查询可能会耗尽资源。

作为缺少连接条件的查询和查询计划的一个例子,可以考虑列出连接citycountry表的 24-1

mysql> EXPLAIN
        SELECT ci.CountryCode, ci.ID, ci.Name,
               ci.District, co.Name AS Country,
               ci.Population
          FROM world.city ci
               INNER JOIN world.country co\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: co
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 239
     filtered: 100
        Extra: NULL
*************************** 2\. row ***************************
           id: 1
  select_type: SIMPLE
        table: ci
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 4188
     filtered: 100
        Extra: Using join buffer (Block Nested Loop)
2 rows in set, 1 warning (0.0008 sec)

mysql> EXPLAIN ANALYZE
        SELECT ci.CountryCode, ci.ID, ci.Name,
               ci.District, co.Name AS Country,
               ci.Population
          FROM world.city ci
               INNER JOIN world.country co\G ************ 1\. row *********
EXPLAIN:
-> Inner hash join  (cost=100125.15 rows=1000932) (actual time=0.194..80.427 rows=974881 loops=1)
    -> Table scan on ci  (cost=1.78 rows=4188) (actual time=0.025..2.621 rows=4079 loops=1)
    -> Hash
        -> Table scan on co  (cost=25.40 rows=239) (actual time=0.041..0.089 rows=239 loops=1)

1 row in set (0.4094 sec)

Listing 24-1Query that is missing a join condition

注意这两个表的访问类型是如何设置为ALL的,以及连接是如何在块嵌套循环中使用连接缓冲区的。通常具有类似症状的原因是正确的查询,但是该查询不能使用索引。EXPLAIN ANALYZE输出显示在 8.0.18 版本中使用了散列连接。它还显示总共返回了差不多一百万行!查询的直观解释图如图 24-2 所示。

img/484666_1_En_24_Fig2_HTML.jpg

图 24-2

缺少联接条件的查询的可视化说明

请注意这两个(红色)全表扫描是如何脱颖而出的,以及查询成本估计如何超过 100,000。

多个全表扫描的组合、非常高的估计返回行数和非常高的成本估计是您需要寻找的警示信号。

产生类似症状的查询性能差的一个原因是 MySQL 不能为过滤和连接条件使用索引。

没有使用索引

当查询需要在表中查找行时,它基本上可以通过两种方式完成:在全表扫描中直接访问行,或者通过索引。在有高选择性过滤器的情况下,通过索引访问行通常比通过表扫描要快得多。

显然,如果过滤器适用的列上没有索引,MySQL 别无选择,只能使用表扫描。您可能会发现,即使有索引,也无法使用。出现这种情况的三个常见原因是:列不是多列索引中的第一列,数据类型与比较不匹配,以及对带有索引的列使用了函数。本节将讨论这些原因。

Tip

与全表扫描相比,优化器认为索引的选择性不足以使其值得使用,这种情况也可能发生。这种情况在“改进索引使用”一节中处理,同时还有 MySQL 使用错误索引的例子。

不是索引的左前缀

要使用索引,必须使用索引的左前缀。例如,如果一个索引包含三列作为(a, b, c),那么列b上的条件只能在列a上也有相等条件时使用过滤器。

可以使用索引的条件示例如下

WHERE a = 10 AND b = 20 AND c = 30
WHERE a = 10 AND b = 20 AND c > 10
WHERE a = 10 AND b = 20
WHERE a = 10 AND b > 20
WHERE a = 10

不能有效使用索引的一个例子是WHERE b = 20。在 MySQL 8.0.13 和更高版本中,如果a是一个NOT NULL列,MySQL 可以使用跳过扫描范围优化来使用索引。如果a允许NULL值,则该指标不能使用。条件WHERE c = 20在任何情况下都不能使用索引。

类似地,对于条件WHERE a > 10 AND b = 20,索引将仅用于对a列进行过滤。当查询仅使用索引中列的子集时,索引中列的顺序与应用的筛选器相对应是很重要的。如果其中一列有范围条件,请确保该列是索引中使用的最后一列。例如,考虑清单 24-2 中的表和查询。

mysql> SHOW CREATE TABLE chapter_24.mytable\G
*************************** 1\. row ***************************
       Table: mytable
Create Table: CREATE TABLE `mytable` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `a` int(11) NOT NULL,
  `b` int(11) DEFAULT NULL,
  `c` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `abc` (`a`,`b`,`c`)
) ENGINE=InnoDB AUTO_INCREMENT=16385 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0004 sec)

mysql> EXPLAIN
        SELECT *
          FROM chapter_24.mytable
         WHERE a > 10 AND b = 20\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: mytable
   partitions: NULL
         type: range
possible_keys: abc
          key: abc
      key_len: 4
          ref: NULL
         rows: 8326
     filtered: 10
        Extra: Using where; Using index
1 row in set, 1 warning (0.0007 sec)

Listing 24-2Query that cannot use the index effectively due to column order

注意在EXPLAIN输出中,key_len只有 4 个字节,然而如果索引同时用于ab列,它应该是 9 个字节。输出还显示,估计只有 10%被检查的行将被包括在内。图 24-3 直观解释了同一个例子。

img/484666_1_En_24_Fig3_HTML.jpg

图 24-3

索引中非最佳列顺序的直观解释

请注意,使用的关键零件(靠近盒子底部,有附加细节)只列出了列a。但是,如果您更改索引中列的顺序,使列b在列a之前建立索引,那么该索引可以用于这两列上的条件。清单 24-3 展示了添加新索引(b, a, c)后查询计划的变化。

mysql> ALTER TABLE chapter_24.mytable
         ADD INDEX bac (b, a, c);
Query OK, 0 rows affected (1.4098 sec)

Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN
       SELECT *
         FROM chapter_24.mytable
        WHERE a > 10 AND b = 20\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: mytable
   partitions: NULL
         type: range
possible_keys: abc,bac
          key: bac
      key_len: 9
          ref: NULL
         rows: 160
     filtered: 100
        Extra: Using where; Using index
1 row in set, 1 warning (0.0006 sec)

Listing 24-3Query plan with the index in optimal order

注意key_len列现在如何返回 9 个字节,并且filtered列显示 100%被检查的行将包含在表中。同样的情况也反映在视觉解释中,如图 24-4 所示。

img/484666_1_En_24_Fig4_HTML.jpg

图 24-4

当存在最优排序的索引时,进行可视化解释

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

数据类型不匹配

您需要注意的另一件事是,条件的两端使用相同的数据类型,并且对字符串使用相同的排序规则。如果不是这样,MySQL 可能无法使用索引。

当查询由于数据类型或排序规则不匹配而无法以最佳方式工作时,一开始可能很难意识到问题出在哪里。查询是正确的,但是 MySQL 拒绝使用您期望的索引。除了查询计划与您预期的不同之外,查询结果也可能是错误的。这可能是由于铸造造成的,例如:

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

请注意字符串“a130”是如何被视为等于整数 0 的。发生这种情况是因为字符串以非数字字符开头,因此被转换为值 0。同样,字符串“130a131”被视为等于整数 130,因为该字符串的前导数字部分被转换为整数 130。当对一个WHERE子句或一个连接条件使用强制转换时,也会出现同样的意外匹配。在这种情况下,检查查询的警告有时有助于发现问题。

如果您考虑本章测试模式中的countryworld表(表定义将在讨论示例时显示),您可以看到一个不使用索引的连接示例,此时这两个表使用CountryId列连接。清单 24-4 展示了一个查询及其查询计划的例子。

mysql> EXPLAIN
        SELECT ci.ID, ci.Name, ci.District,
               co.Name AS Country, ci.Population
          FROM chapter_24.city ci
               INNER JOIN chapter_24.country co
                     USING (CountryId)
         WHERE co.CountryCode = 'AUS'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: co
   partitions: NULL
         type: const
possible_keys: PRIMARY,CountryCode
          key: CountryCode
      key_len: 12
          ref: const
         rows: 1
     filtered: 100
        Extra: NULL
*************************** 2\. row ***************************
           id: 1
  select_type: SIMPLE
        table: ci
   partitions: NULL
         type: ALL
possible_keys: CountryId
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 4079
     filtered: 10
        Extra: Using where

2 rows in set, 3 warnings (0.0009 sec)
Warning (code 1739): Cannot use ref access on index 'CountryId' due to type or collation conversion on field 'CountryId'
Warning (code 1739): Cannot use range access on index 'CountryId' due to type or collation conversion on field 'CountryId'
Note (code 1003): /* select#1 */ select `chapter_24`.`ci`.`ID` AS `ID`,`chapter_24`.`ci`.`Name` AS `Name`,`chapter_24`.`ci`.`District` AS `District`,'Australia' AS `Country`,`chapter_24`.`ci`.`Population` AS `Population` from `chapter_24`.`city` `ci` join `chapter_24`.`country` `co` where ((`chapter_24`.`ci`.`CountryId` = '15'))

Listing 24-4Query not using an index due to mismatching data types

注意,ci ( city)表的访问类型是ALL。这个查询既不会使用块嵌套循环,也不会使用散列连接,因为co ( country)表是一个常量。这里包含了警告(如果您不使用启用了警告的 MySQL Shell,您将需要执行SHOW WARNINGS来获取警告),因为它们提供了一个有价值的提示,说明为什么不能使用索引,例如:由于字段‘country id’上的类型或排序规则转换,不能对索引‘country id’使用 ref 访问。因此,有一个索引是候选索引,但由于数据类型或排序规则发生了变化,因此无法使用该索引。图 24-5 显示了使用可视化解释的相同查询计划。

img/484666_1_En_24_Fig5_HTML.jpg

图 24-5

直观解释数据类型不匹配的地方

这是您需要基于文本的输出来获得所有细节的情况之一,因为 Visual Explain 不包括警告。当您看到这样的警告时,请返回并检查表定义。这些如清单 24-5 所示。

CREATE TABLE `chapter_24`.`city` (
  `ID` int unsigned NOT NULL AUTO_INCREMENT,
  `Name` varchar(35) NOT NULL DEFAULT ",
  `CountryCode` char(3) NOT NULL DEFAULT ",
  `CountryId` char(3) NOT NULL,
  `District` varchar(20) NOT NULL DEFAULT ",
  `Population` int unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`ID`),
  KEY `CountryCode` (`CountryCode`),
  KEY `CountryId` (`CountryId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

CREATE TABLE `chapter_24`.`country` (
  `CountryId` int unsigned NOT NULL AUTO_INCREMENT,
  `CountryCode` char(3) NOT NULL,
  `Name` varchar(52) NOT NULL,
  `Continent` enum('Asia','Europe','North America','Africa','Oceania','Antarctica','South America') NOT NULL DEFAULT 'Asia',
  `Region` varchar(26) DEFAULT NULL,
  PRIMARY KEY (`CountryId`),
  UNIQUE INDEX `CountryCode` (`CountryCode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

Listing 24-5The table definitions for the city and country tables

这里很明显,city表的CountryId列是一个char(3)列,但是国家表的CountryId是一个整数。这就是为什么当city表是连接中的第二个表时,不能使用city.CountryId列的索引。

Note

如果连接以另一种方式进行,第一个表是city表,第二个表是country表,那么city.CountryId仍然被转换为整数,而country.CountryId没有改变,因此可以使用country.CountryId上的索引。

还要注意,这两个表的排序规则是不同的。city表使用utf8mb4_general_ci排序规则(MySQL 5.7 和更早版本中的默认utf8mb4排序规则),而country表使用utf8mb4_0900_ai_ci(MySQL 8 中的默认utf8mb4排序规则)。不同的字符集或排序规则甚至会阻止查询一起执行:

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

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

数据类型不匹配的问题是在过滤器中使用函数的特例,就像 MySQL 进行隐式转换一样。一般来说,在过滤器中使用函数可以防止索引的使用。

功能依赖性

不使用索引的最后一个常见原因是对列应用了函数,例如:WHERE MONTH(birth_date) = 7。在这种情况下,您需要重写条件以避免该函数,或者您需要添加一个函数索引。

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

mysql> SHOW CREATE TABLE chapter_24.person\G
*************************** 1\. row ***************************
       Table: person
Create Table: CREATE TABLE `person` (
  `PersonId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `FirstName` varchar(50) DEFAULT NULL,
  `Surname` varchar(50) DEFAULT NULL,
  `BirthDate` date NOT NULL,
  `AddressId` int(10) unsigned DEFAULT NULL,
  `LanguageId` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`PersonId`),
  KEY `BirthDate` (`BirthDate`),
  KEY `AddressId` (`AddressId`),
  KEY `LanguageId` (`LanguageId`)
) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0012 sec)

mysql> EXPLAIN
         SELECT *
           FROM chapter_24.person
          WHERE YEAR(BirthDate) = 1970\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: person
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 1000
     filtered: 100
        Extra: Using where
1 row in set, 1 warning (0.0006 sec)

Listing 24-6The person table and finding persons born in 1970

这个查询使用YEAR()函数来确定这个人出生的年份。另一种方法是寻找 1970 年 1 月 1 日至 1971 年 12 月 31 日(包括这两天)之间出生的所有人,这相当于同一件事。清单 24-7 显示在这种情况下使用了birthdate列上的索引。

mysql> EXPLAIN
        SELECT *
          FROM chapter_24.person
         WHERE BirthDate BETWEEN '1970-01-01'
                             AND '1970-12-31'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: person
   partitions: NULL
         type: range
possible_keys: BirthDate
          key: BirthDate
      key_len: 3
          ref: NULL
         rows: 6
     filtered: 100
        Extra: Using index condition
1 row in set, 1 warning (0.0009 sec)

Listing 24-7Rewriting the YEAR() function to a date range condition

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

Note

使用LIKE操作符重写日期或日期时间范围条件是很有诱惑力的,例如:WHERE birthdate LIKE '1970-%'。这将不允许 MySQL 使用查询,并且不鼓励这样做。请改用合适的范围。

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

Note

MySQL 8.0.13 及更高版本支持函数索引。如果您使用早期版本,建议您进行升级。如果这是不可能的,或者您还需要函数返回的值,您可以通过添加带有函数表达式的虚拟列并在虚拟列上创建索引来模拟函数索引。

例如,考虑一个查询,该查询查找在给定月份生日的所有人,例如,因为您想向他们发送生日祝福。原则上,这可以通过使用范围来实现,但是每年需要一个范围,这既不实际也不太有效。相反,您可以使用MONTH()函数提取月份的数值(一月是 1,十二月是 12)。清单 24-8 展示了如何添加一个函数索引,该索引可以与一个查询一起使用,该查询查找chapter_24.person表中所有生日在当月的人。

mysql> ALTER TABLE chapter_24.person
         ADD INDEX ((MONTH(BirthDate)));
Query OK, 0 rows affected (0.4845 sec)

Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN
         SELECT *
           FROM chapter_24.person
          WHERE MONTH(BirthDate) = MONTH(NOW())\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: person
   partitions: NULL
         type: ref
possible_keys: functional_index
          key: functional_index
      key_len: 5
          ref: const
         rows: 88
     filtered: 100
        Extra: NULL
1 row in set, 1 warning (0.0006 sec)

Listing 24-8Using a functional index

添加了MONTH(BirthDate)上的函数索引后,查询计划显示使用的索引是functional_index

关于如何为当前没有使用索引的查询添加索引支持的讨论到此结束。还有几个与使用索引相关的重写。这些将在下一节讨论。

改进索引的使用

前一节考虑了没有索引用于 join 或WHERE子句的查询。在某些情况下,使用了一个索引,但是您可以改进该索引,或者另一个索引提供了更好的性能,或者由于过滤器的复杂性而无法有效地使用索引。本节将介绍一些使用索引改进查询的例子。

添加覆盖索引

在某些情况下,当您查询一个表时,过滤是由索引执行的,但是您已经请求了几个其他列,所以 MySQL 需要检索整行。在这种情况下,将这些额外的列添加到索引中会更有效,这样索引就包含了查询所需的所有列。

考虑一下chapter_24样本数据库中的city表:

CREATE TABLE `city` (
  `ID` int unsigned NOT NULL AUTO_INCREMENT,
  `Name` varchar(35) NOT NULL DEFAULT ",
  `CountryCode` char(3) NOT NULL DEFAULT ",
  `CountryId` char(3) NOT NULL,
  `District` varchar(20) NOT NULL DEFAULT ",
  `Population` int unsigned NOT NULL DEFAULT '0',
  PRIMARY KEY (`ID`),
  KEY `CountryCode` (`CountryCode`),
  KEY `CountryId` (`CountryId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

如果您想查找所有带有CountryCode = 'USA'的城市的名称和地区,那么您可以使用CountryCode索引来查找这些行。这是高效的,如清单 24-9 所示。

mysql> EXPLAIN
        SELECT Name, District
          FROM chapter_24.city
         WHERE CountryCode = 'USA'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ref
possible_keys: CountryCode
          key: CountryCode
      key_len: 12
          ref: const
         rows: 274
     filtered: 100
        Extra: NULL
1 row in set, 1 warning (0.0376 sec)

Listing 24-9Querying cities by a non-covering index

请注意,索引使用了 12 个字节(3 个字符,每个字符最多 4 个字节宽),并且Extra列不包括Using index。如果创建一个新索引,将CountryCode作为第一列,将DistrictName作为剩余列,那么索引中就有了查询所需的所有列。选择DistrictName的顺序,因为它们最有可能与过滤器中的CountryCode以及ORDER BYGROUP BY子句一起使用。如果同样可能在过滤器中使用列,则在索引中选择NameDistrict之前,因为城市名称比地区名称更具选择性。清单 24-10 展示了一个这样的例子以及新的查询计划。

mysql> ALTER TABLE chapter_24.city
       ALTER INDEX CountryCode INVISIBLE,
         ADD INDEX Country_District_Name
                  (CountryCode, District, Name);
Query OK, 0 rows affected (1.6630 sec)

Records: 0  Duplicates: 0  Warnings: 0

mysql> EXPLAIN
        SELECT Name, District
          FROM chapter_24.city
         WHERE CountryCode = 'USA'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: city
   partitions: NULL
         type: ref
possible_keys: Country_District_Name
          key: Country_District_Name
      key_len: 12
          ref: const
         rows: 274
     filtered: 100
        Extra: Using index
1 row in set, 1 warning (0.0006 sec)

Listing 24-10Querying cities by a covering index

当添加新索引时,刚好覆盖CountryCode列的旧索引是不可见的。这样做是因为新索引也可以用于旧索引的所有用途,所以通常没有理由保留两个索引。(假设CountryCode列上的索引比新索引小,一些查询可能会受益于旧索引。通过使它不可见,您可以在删除它之前验证它是不需要的。)

密钥长度仍然返回为 12 个字节,因为这是用于过滤的。然而,Extra列现在包含了Using index,表明正在使用覆盖索引。

错误的索引

当 MySQL 可以在几个索引之间进行选择时,优化器必须根据两个查询计划的估计成本来决定使用哪个。由于索引统计和成本估计不准确,MySQL 可能会选择错误的索引。特殊情况是优化器选择不使用索引,即使可以使用它,或者优化器选择使用索引,这样做可以更快地进行表扫描。无论哪种方式,您都需要使用索引提示。

Tip

正如第 17 章所讨论的,索引提示也可以用来影响一个索引是用于排序还是分组。有必要使用索引提示的一个例子是,当查询选择使用索引进行排序而不是过滤时,这会导致性能下降,反之亦然。可能发生相反情况的一种情况是,当您有一个LIMIT子句并且使用索引进行排序时,可能会允许查询提前停止查询。

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

mysql> SHOW CREATE TABLE chapter_24.person\G
*************************** 1\. row ***************************
       Table: person
Create Table: CREATE TABLE `person` (
  `PersonId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `FirstName` varchar(50) DEFAULT NULL,
  `Surname` varchar(50) DEFAULT NULL,
  `BirthDate` date NOT NULL,
  `AddressId` int(10) unsigned DEFAULT NULL,
  `LanguageId` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`PersonId`),
  KEY `BirthDate` (`BirthDate`),
  KEY `AddressId` (`AddressId`),
  KEY `LanguageId` (`LanguageId`),
  KEY `functional_index` ((month(`BirthDate`)))
) ENGINE=InnoDB AUTO_INCREMENT=1001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0007 sec)

mysql> SHOW CREATE TABLE chapter_24.address\G

*************************** 1\. row ***************************
       Table: address
Create Table: CREATE TABLE `address` (
  `AddressId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `City` varchar(35) NOT NULL,
  `District` varchar(20) NOT NULL,
  `CountryCode` char(3) NOT NULL,
  PRIMARY KEY (`AddressId`),
  KEY `CountryCode` (`CountryCode`,`District`,`City`)
) ENGINE=InnoDB AUTO_INCREMENT=4096 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0007 sec)

mysql> SHOW CREATE TABLE chapter_24.language\G
*************************** 1\. row ***************************
       Table: language
Create Table: CREATE TABLE `language` (
  `LanguageId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `Language` varchar(35) NOT NULL,
  PRIMARY KEY (`LanguageId`),
  KEY `Language` (`Language`)
) ENGINE=InnoDB AUTO_INCREMENT=512 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0005 sec)

mysql> UPDATE mysql.innodb_index_stats
          SET stat_value = 1000
        WHERE database_name = 'chapter_24'
              AND table_name = 'person'
              AND index_name = 'LanguageId'
              AND stat_name = 'n_diff_pfx01';
Query OK, 1 row affected (0.0920 sec)

Rows matched: 1  Changed: 1  Warnings: 0

mysql> FLUSH TABLE chapter_24.person;
Query OK, 0 rows affected (0.0686 sec)

mysql> EXPLAIN
        SELECT PersonId, FirstName,
               Surname, BirthDate
          FROM chapter_24.person
               INNER JOIN chapter_24.address

                    USING (AddressId)
               INNER JOIN chapter_24.language
                    USING (LanguageId)
         WHERE BirthDate BETWEEN '2000-01-01'
                             AND '2000-12-31'
               AND CountryCode = 'JPN'
               AND Language = 'English'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: language
   partitions: NULL
         type: ref
possible_keys: PRIMARY,Language
          key: Language
      key_len: 142
          ref: const
         rows: 1
     filtered: 100
        Extra: Using index
*************************** 2\. row ***************************
           id: 1
  select_type: SIMPLE
        table: person
   partitions: NULL
         type: ref
possible_keys: BirthDate,AddressId,LanguageId
          key: LanguageId
      key_len: 5
          ref: chapter_24.language.LanguageId
         rows: 1
     filtered: 5
        Extra: Using where
*************************** 3\. row ***************************
           id: 1
  select_type: SIMPLE
        table: address
   partitions: NULL
         type: eq_ref
possible_keys: PRIMARY,CountryCode
          key: PRIMARY
      key_len: 4
          ref: chapter_24.person.AddressId
         rows: 1
     filtered: 6.079921722412109
        Extra: Using where
3 rows in set, 1 warning (0.0008 sec)

mysql> EXPLAIN FORMAT=TREE
        SELECT PersonId, FirstName,
               Surname, BirthDate
          FROM chapter_24.person
               INNER JOIN chapter_24.address

                    USING (AddressId)
               INNER JOIN chapter_24.language
                    USING (LanguageId)
         WHERE BirthDate BETWEEN '2000-01-01'
                             AND '2000-12-31'
               AND CountryCode = 'JPN'
               AND Language = 'English'\G
*************************** 1\. row ***************************
EXPLAIN:
-> Nested loop inner join  (cost=0.72 rows=0)
    -> Nested loop inner join  (cost=0.70 rows=0)
        -> Index lookup on language using Language...
        -> Filter: ((person.BirthDate between '2000-01-01' and '2000-12-31') and (person.AddressId is not null))...
            -> Index lookup on person using LanguageId...
    -> Filter: (address.CountryCode = 'JPN')  (cost=0.37 rows=0)
        -> Single-row index lookup on address using PRIMARY...

1 row in set (0.0006 sec)

Listing 24-11Finding information about the countries where English is spoken

本例中的关键表是与languageaddress表连接的person表。UPDATEFLUSH语句用于通过更新mysql.innodb_index_stats表并刷新该表以使新的索引统计生效,来模拟索引统计已经过时。

查询可以使用BirthDateAddressIdLanguageId索引。当优化器向存储引擎请求每个条件的行数时,三个WHERE子句(每个表上一个)的有效性被非常精确地确定。优化器的困难在于根据连接条件的有效性以及每个连接使用哪个索引来确定最佳连接顺序。根据EXPLAIN的输出,优化器选择从language表开始,使用LanguageId索引连接到person表,最后连接到address表。

如果您怀疑查询使用了错误的索引(在这种情况下,使用LanguageId连接到person表并不是最佳选择,因为索引统计数据是“错误的”),首先要做的是更新索引统计数据。其结果如清单 24-12 所示。

mysql> ANALYZE TABLE
               chapter_24.person,
               chapter_24.address,
               chapter_24.language;
+---------------------+---------+----------+----------+
| Table               | Op      | Msg_type | Msg_text |
+---------------------+---------+----------+----------+
| chapter_24.person   | analyze | status   | OK       |
| chapter_24.address  | analyze | status   | OK       |
| chapter_24.language | analyze | status   | OK       |
+---------------------+---------+----------+----------+
3 rows in set (0.2634 sec)

mysql> EXPLAIN
        SELECT PersonId, FirstName,
               Surname, BirthDate
          FROM chapter_24.person
               INNER JOIN chapter_24.address
                    USING (AddressId)
               INNER JOIN chapter_24.language
                    USING (LanguageId)
         WHERE BirthDate BETWEEN '2000-01-01'
                             AND '2000-12-31'
               AND CountryCode = 'JPN'
               AND Language = 'English'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: language
   partitions: NULL
         type: ref
possible_keys: PRIMARY,Language
          key: Language
      key_len: 142
          ref: const
         rows: 1
     filtered: 100
        Extra: Using index
*************************** 2\. row ***************************
           id: 1
  select_type: SIMPLE
        table: person
   partitions: NULL
         type: range
possible_keys: BirthDate,AddressId,LanguageId
          key: BirthDate
      key_len: 3
          ref: NULL
         rows: 8
     filtered: 10
        Extra: Using index condition; Using where; Using join buffer (Block Nested Loop)
*************************** 3\. row ***************************
           id: 1
  select_type: SIMPLE
        table: address
   partitions: NULL
         type: eq_ref
possible_keys: PRIMARY,CountryCode
          key: PRIMARY
      key_len: 4
          ref: chapter_24.person.AddressId
         rows: 1
     filtered: 6.079921722412109
        Extra: Using where
3 rows in set, 1 warning (0.0031 sec)

mysql> EXPLAIN FORMAT=TREE
        SELECT PersonId, FirstName,
               Surname, BirthDate
          FROM chapter_24.person
               INNER JOIN chapter_24.address
                    USING (AddressId)
               INNER JOIN chapter_24.language
                    USING (LanguageId)
         WHERE BirthDate BETWEEN '2000-01-01'
                             AND '2000-12-31'
               AND CountryCode = 'JPN'
               AND Language = 'English'\G
*************************** 1\. row ***************************
EXPLAIN:
-> Nested loop inner join  (cost=7.01 rows=0)
    -> Inner hash join...
        -> Filter: (person.AddressId is not null)...
            -> Index range scan on person using BirthDate...
        -> Hash
            -> Index lookup on language using Language...
    -> Filter: (address.CountryCode = 'JPN')...
        -> Single-row index lookup on address using PRIMARY...

1 row in set (0.0009 sec)

Listing 24-12Updating the index statistics to change the query plan

这极大地改变了查询计划(为了可读性,只包括了树格式查询计划的一部分),通过比较树格式查询计划可以很容易地看出这一点。这些表仍然以相同的顺序连接,但是现在使用散列连接来连接语言和人员表。这是有效的,因为只需要语言表中的一行,所以对person表进行表扫描并根据出生日期进行过滤是一个不错的选择。在大多数使用错误索引的情况下,更新索引统计信息可以解决问题,可能是在更改 InnoDB 对表进行索引潜水的次数之后。

Caution

ANALYZE TABLE为被分析的表触发一个隐式的FLUSH TABLES。如果您有使用被分析的表的长时间运行的查询,则在长时间运行的查询完成之前,不能启动需要访问这些表的其他查询。

在某些情况下,不可能通过更新索引统计信息来解决性能问题。在这种情况下,您可以使用索引提示(IGNORE INDEXUSE INDEXFORCE INDEX)来影响 MySQL 将使用的索引。清单 24-13 展示了一个在将索引统计数据改回过时状态后,对与之前相同的查询执行此操作的示例。

mysql> UPDATE mysql.innodb_index_stats
          SET stat_value = 1000
        WHERE database_name = 'chapter_24'
              AND table_name = 'person'
              AND index_name = 'LanguageId'
              AND stat_name = 'n_diff_pfx01';
Query OK, 1 row affected (0.0920 sec)

Rows matched: 1  Changed: 1  Warnings: 0

mysql> FLUSH TABLE chapter_24.person;
Query OK, 0 rows affected (0.0498 sec)

mysql> EXPLAIN
        SELECT PersonId, FirstName,
               Surname, BirthDate
          FROM chapter_24.person USE INDEX (BirthDate)
               INNER JOIN chapter_24.address
                    USING (AddressId)
               INNER JOIN chapter_24.language
                    USING (LanguageId)
         WHERE BirthDate BETWEEN '2000-01-01'
                             AND '2000-12-31'
               AND CountryCode = 'JPN'
               AND Language = 'English'\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: language
   partitions: NULL
         type: ref
possible_keys: PRIMARY,Language
          key: Language
      key_len: 142
          ref: const
         rows: 1
     filtered: 100
        Extra: Using index
*************************** 2\. row ***************************
           id: 1
  select_type: SIMPLE
        table: person
   partitions: NULL
         type: range
possible_keys: BirthDate
          key: BirthDate
      key_len: 3
          ref: NULL
         rows: 8
     filtered: 0.625
        Extra: Using index condition; Using where; Using join buffer (Block Nested Loop)
*************************** 3\. row ***************************
           id: 1
  select_type: SIMPLE
        table: address
   partitions: NULL
         type: eq_ref
possible_keys: PRIMARY,CountryCode
          key: PRIMARY
      key_len: 4
          ref: chapter_24.person.AddressId
         rows: 1
     filtered: 6.079921722412109
        Extra: Using where
3 rows in set, 1 warning (0.0016 sec)

Listing 24-13Improving the query plan using an index hint

这一次为person表添加了USE INDEX (BirthDate)索引提示,它给出了与索引统计信息更新时相同的查询计划。注意,person表的可能键只包括BirthDate。这种方法的缺点是,如果数据发生变化,优化器不具备更改查询计划的灵活性,因此BirthDate索引不再是最佳的。

这个例子在person表上有三个不同的条件(生日的日期范围和两个连接条件)。在某些情况下,当一个表上有多个条件时,对查询进行一些更广泛的重写是有益的。

重写复杂的索引条件

在某些情况下,查询变得非常复杂,以至于优化器不可能提出一个好的查询计划,因此有必要重写查询。重写有助于在同一个表上包含多个筛选器的情况下,索引合并算法无法有效地使用。

考虑以下查询:

mysql> EXPLAIN FORMAT=TREE
        SELECT *
          FROM chapter_24.person
         WHERE BirthDate < '1930-01-01'
            OR AddressId = 3417\G
*************************** 1\. row ***************************
EXPLAIN:
-> Filter: ((chapter_24.person.BirthDate < DATE'1930-01-01') or (chapter_24.person.AddressId = 3417))  (cost=88.28 rows=111)
    -> Index range scan on person using sort_union(BirthDate,AddressId)  (cost=88.28 rows=111)

1 row in set (0.0006 sec)

对于BirthDateAddressId列都有索引,但是没有跨越这两列的索引。一种可能是使用索引合并,如果优化器认为好处足够大,它将默认选择索引合并。通常,这是执行查询的首选方式,但是对于某些查询(尤其是比本例更复杂的查询),将两个条件拆分成两个查询并使用 union 合并结果会有所帮助:

mysql> EXPLAIN FORMAT=TREE
       (SELECT *
          FROM chapter_24.person
         WHERE BirthDate < '1930-01-01'
       ) UNION DISTINCT (
        SELECT *
          FROM chapter_24.person
         WHERE AddressId = 3417
       )\G
*************************** 1\. row ***************************
EXPLAIN:
-> Table scan on <union temporary>  (cost=2.50 rows=0)
    -> Union materialize with deduplication
        -> Index range scan on person using BirthDate, with index condition: (chapter_24.person.BirthDate < DATE'1930-01-01')  (cost=48.41 rows=107)
        -> Index lookup on person using AddressId (AddressId=3417)  (cost=1.40 rows=4)

1 row in set (0.0006 sec)

一个UNION DISTINCT(也是默认的 union)用于确保满足两个标准的行不会被包含两次。图 24-6 并排显示了两个查询计划。

img/484666_1_En_24_Fig6_HTML.jpg

图 24-6

原始查询和重写查询的查询计划

左边是使用索引合并的原始查询(sort_union算法),右边是手工编写的联合。

重写复杂查询

优化器在 MySQL 8 中添加了几个转换规则,因此它可以将查询重写为性能更好的形式。这意味着,随着优化器知道越来越多的转换,重写复杂查询的需求不断减少。例如,在 8 . 0 . 17 版本中,增加了对将NOT IN(子查询)NOT EXISTS(子查询)IN(子查询)IS NOT TRUEEXISTS(子查询)IS NOT TRUE重写为反联接的支持,这意味着子查询被移除。

也就是说,考虑如何重写查询仍然是好的,这样您就可以在优化器没有达到最佳解决方案或者不知道如何自己重写的情况下帮助优化器。还有一些情况下,您可以利用对通用表表达式(CTEs 也称为with语法)和窗口函数的支持,使查询更有效、更易读。本节将首先考虑常见的表表达式和窗口函数,然后使用IN(子查询)将查询重写为一个连接并使用两个查询。

Common Table Expressions and Window Functions

深入讨论使用常用表表达式和窗口函数的细节已经超出了本书的范围。本章将包括几个例子来说明如何使用这些特性。一个很好的概述起点是由丹尼尔·巴塞洛缪(Daniel Bartholomew)揭示并由 Apress ( www.apress.com/gp/book/9781484231197 )出版的)的 MariaDB 和 MySQL 公共表表达式和窗口函数。

Guilhem Bichot(在 MySQL 中实现常用表表达式的 MySQL 开发者)在特性刚开发出来的时候也写过一个关于常用表表达式的博客系列,分为四个部分: https://mysqlserverteam.com/?s=common+table+expressions 。还有其他 MySQL 开发者的两篇关于窗口功能的博客: https://mysqlserverteam.com/?s=window+functions

关于最新的信息,最好的来源是 MySQL 参考手册。 https://dev.mysql.com/doc/refman/en/with.html 中描述了常用的表格表达式。根据函数是常规函数还是聚合函数,窗口函数分为两部分: https://dev.mysql.com/doc/refman/en/window-functions.html ,其中还包括对窗口函数的一般性讨论,以及 https://dev.mysql.com/doc/refman/en/group-by-functions.html ,用于聚合窗口函数。

常用表表达式

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

例如,考虑一个针对sakila数据库的查询,该数据库计算负责租赁的每个员工每月的销售额:

SELECT DATE_FORMAT(r.rental_date,
                   '%Y-%m-01'
       ) AS FirstOfMonth,
       r.staff_id,
       SUM(p.amount) as SalesAmount
  FROM sakila.payment p
       INNER JOIN sakila.rental r
            USING (rental_id)
 GROUP BY FirstOfMonth, r.staff_id;

如果你想知道每月的销售额变化有多大,那么你需要将一个月的销售额与前一个月的销售额进行比较。要做到这一点而不使用公共表表达式,您要么需要将查询结果存储在一个临时表中,要么将其复制为两个子查询。清单 24-14 显示了后者的一个例子。

SELECT current.staff_id,
       YEAR(current.FirstOfMonth) AS Year,
       MONTH(current.FirstOfMonth) AS Month,
       current.SalesAmount,
       (current.SalesAmount
          - IFNULL(prev.SalesAmount, 0)
       ) AS DeltaAmount
  FROM (
         SELECT DATE_FORMAT(r.rental_date,
                            '%Y-%m-01'
                ) AS FirstOfMonth,
                r.staff_id,
                SUM(p.amount) as SalesAmount
           FROM sakila.payment p
                INNER JOIN sakila.rental r
                     USING (rental_id)
          GROUP BY FirstOfMonth, r.staff_id
       ) current
       LEFT OUTER JOIN (
         SELECT DATE_FORMAT(r.rental_date,
                            '%Y-%m-01'
                ) AS FirstOfMonth,
                r.staff_id,
                SUM(p.amount) as SalesAmount

           FROM sakila.payment p
                INNER JOIN sakila.rental r
                     USING (rental_id)
          GROUP BY FirstOfMonth, r.staff_id
       ) prev ON prev.FirstOfMonth
                    = current.FirstOfMonth
                      - INTERVAL 1 MONTH
             AND prev.staff_id = current.staff_id
 ORDER BY current.staff_id,
          current.FirstOfMonth;

Listing 24-14The monthly sales and change in sales without CTEs

这很难成为最容易阅读和理解的查询。这两个子查询是相同的,并且与用于查找每个员工每月销售额的子查询相同。通过比较同一名工作人员的当月和上月,将两个衍生表连接起来。最后,结果按工作人员和当月排序。结果如清单 24-15 所示。

+----------+------+-------+-------------+-------------+
| staff_id | Year | Month | SalesAmount | DeltaAmount |
+----------+------+-------+-------------+-------------+
|        1 | 2005 |     5 |     2340.42 |     2340.42 |
|        1 | 2005 |     6 |     4832.37 |     2491.95 |
|        1 | 2005 |     7 |    14061.58 |     9229.21 |
|        1 | 2005 |     8 |    12072.08 |    -1989.50 |
|        1 | 2006 |     2 |      218.17 |      218.17 |
|        2 | 2005 |     5 |     2483.02 |     2483.02 |
|        2 | 2005 |     6 |     4797.52 |     2314.50 |
|        2 | 2005 |     7 |    14307.33 |     9509.81 |
|        2 | 2005 |     8 |    11998.06 |    -2309.27 |
|        2 | 2006 |     2 |      296.01 |      296.01 |
+----------+------+-------+-------------+-------------+
10 rows in set (0.1406 sec)

Listing 24-15The result of the monthly sales query

从结果中需要注意的一点是,2005 年 9 月到 2006 年 1 月这几个月没有销售数据。该查询假设该期间的销售额为 0。当重写该查询以使用窗口函数时,将展示如何添加缺少的月份。

24-7 显示了这个版本的查询的查询计划。

img/484666_1_En_24_Fig7_HTML.jpg

图 24-7

非 CTE 查询的可视化解释

查询计划显示子查询被评估了两次;然后,在名为 current 的子查询上使用全表扫描执行连接,并在嵌套循环中使用索引(和自动生成的索引)进行连接,以形成按文件排序的结果。

如果使用公用表表达式,只需定义一次子查询并引用两次即可。这简化了查询并使其性能更好。使用公共表表达式的查询版本如清单 24-16 所示。

WITH monthly_sales AS (
  SELECT DATE_FORMAT(r.rental_date,
                     '%Y-%m-01'
         ) AS FirstOfMonth,
         r.staff_id,
         SUM(p.amount) as SalesAmount
    FROM sakila.payment p
         INNER JOIN sakila.rental r
              USING (rental_id)
   GROUP BY FirstOfMonth, r.staff_id
)
SELECT current.staff_id,
       YEAR(current.FirstOfMonth) AS Year,
       MONTH(current.FirstOfMonth) AS Month,
       current.SalesAmount,
       (current.SalesAmount
          - IFNULL(prev.SalesAmount, 0)
       ) AS DeltaAmount
  FROM monthly_sales current
       LEFT OUTER JOIN monthly_sales prev
               ON prev.FirstOfMonth
                     = current.FirstOfMonth
                       - INTERVAL 1 MONTH
              AND prev.staff_id = current.staff_id
 ORDER BY current.staff_id,
          current.FirstOfMonth;

Listing 24-16The monthly sales and change in sales using CTE

公共表表达式首先用关键字WITH定义,并命名为monthly_sales。查询的主要部分中的表列表可以引用monthly_sales。该查询的执行时间大约是原始查询的一半。一个额外的好处是,如果业务逻辑发生变化,您只需要在一个地方更新它,这降低了查询中出现错误的可能性。图 24-8 显示了使用公共表表达式的查询版本的查询计划。

img/484666_1_En_24_Fig8_HTML.jpg

图 24-8

使用公用表表达式时的直观解释

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

您也可以使用窗口函数来解决这个问题。

窗口功能

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

您可以使用LAG() window 函数来获取前一行中某一列的值。清单 24-17 展示了如何使用它来重写月度销售查询,以使用LAG()窗口函数以及添加没有销售额的月份。

WITH RECURSIVE
  month AS
  (SELECT MIN(DATE_FORMAT(rental_date,
                          '%Y-%m-01'
          )) AS FirstOfMonth,
          MAX(DATE_FORMAT(rental_date,
                          '%Y-%m-01'
          )) AS LastMonth
     FROM sakila.rental
    UNION
   SELECT FirstOfMonth + INTERVAL 1 MONTH,
          LastMonth
     FROM month
    WHERE FirstOfMonth < LastMonth
),
  staff_member AS (
  SELECT staff_id
    FROM sakila.staff
),
  monthly_sales AS (
  SELECT month.FirstOfMonth,
         s.staff_id,
         IFNULL(SUM(p.amount), 0) as SalesAmount
    FROM month
         CROSS JOIN staff_member s
         LEFT OUTER JOIN sakila.rental r
                 ON r.rental_date >=
                       month.FirstOfMonth
                AND r.rental_date < month.FirstOfMonth
                                    + INTERVAL 1 MONTH
                AND r.staff_id = s.staff_id
         LEFT OUTER JOIN sakila.payment p
              USING (rental_id)
   GROUP BY FirstOfMonth, s.staff_id
)
SELECT staff_id,
       YEAR(FirstOfMonth) AS Year,
       MONTH(FirstOfMonth) AS Month,
       SalesAmount,
       (SalesAmount
          - LAG(SalesAmount, 1, 0) OVER w_month
       ) AS DeltaAmount
  FROM monthly_sales
WINDOW w_month AS (ORDER BY staff_id, FirstOfMonth)
 ORDER BY staff_id, FirstOfMonth;

Listing 24-17Combing CTEs and the LAG() window function

这个查询乍一看似乎很复杂;但是,这样做的原因是,前两个常用表表达式用于将第一个月和最后一个月之间的每个月的销售数据与租赁数据相加。monthstaff_member表之间的叉积(注意如何使用一个显式的CROSS JOIN来表明交叉连接是有意的)被用作monthly_sales表的基础,并在rentalpayment表上进行外部连接。

主查询现在变得简单了,因为所有需要的信息都可以在monthly_sales表中找到。通过staff_idFirstOfMonth,对销售数据进行排序来定义窗口,并且在该窗口上使用LAG()窗口功能。清单 24-18 显示了结果。

+----------+------+-------+-------------+-------------+
| staff_id | Year | Month | SalesAmount | DeltaAmount |
+----------+------+-------+-------------+-------------+
|        1 | 2005 |     5 |     2340.42 |     2340.42 |
|        1 | 2005 |     6 |     4832.37 |     2491.95 |
|        1 | 2005 |     7 |    14061.58 |     9229.21 |
|        1 | 2005 |     8 |    12072.08 |    -1989.50 |
|        1 | 2005 |     9 |        0.00 |   -12072.08 |
|        1 | 2005 |    10 |        0.00 |        0.00 |
|        1 | 2005 |    11 |        0.00 |        0.00 |
|        1 | 2005 |    12 |        0.00 |        0.00 |
|        1 | 2006 |     1 |        0.00 |        0.00 |
|        1 | 2006 |     2 |      218.17 |      218.17 |
|        2 | 2005 |     5 |     2483.02 |     2264.85 |
|        2 | 2005 |     6 |     4797.52 |     2314.50 |
|        2 | 2005 |     7 |    14307.33 |     9509.81 |
|        2 | 2005 |     8 |    11998.06 |    -2309.27 |
|        2 | 2005 |     9 |        0.00 |   -11998.06 |
|        2 | 2005 |    10 |        0.00 |        0.00 |
|        2 | 2005 |    11 |        0.00 |        0.00 |
|        2 | 2005 |    12 |        0.00 |        0.00 |
|        2 | 2006 |     1 |        0.00 |        0.00 |
|        2 | 2006 |     2 |      296.01 |      296.01 |
+----------+------+-------+-------------+-------------+

Listing 24-18The result of the sales query using the LAG() function

请注意没有销售数据的月份是如何添加销售金额为 0 的。

Note

窗口不需要数据排序所依据的值。如果省略monthstaff_member表达式,2006 年 2 月的滞后时间将变成 2005 年 8 月。这很可能是您想要的——但是与清单 24-14 中的原始查询找到的解决方案相比,这是一个不同的结果。这是留给读者的一个练习,让他们更改查询并查看不同之处。

将子查询重写为连接

当有子查询时,可以选择将子查询更改为连接。在可能的情况下,优化器通常会自己执行这种重写,但是偶尔,在过程中帮助优化器也是有用的。

例如,考虑以下查询:

SELECT *
  FROM chapter_24.person
 WHERE AddressId IN (
         SELECT AddressId
           FROM chapter_24.address
          WHERE CountryCode = 'AUS'
                AND District = 'Queensland');

该查询查找居住在澳大利亚昆士兰州的所有人。它也可以写成personaddress表之间的连接:

SELECT person.*
  FROM chapter_24.person
       INNER JOIN chapter_24.address
             USING (AddressId)
 WHERE CountryCode = 'AUS'
       AND District = 'Queensland';

事实上,MySQL 进行了完全相同的重写(除了优化器选择地址表作为第一个表,因为过滤器就在那里)。这是半连接优化的一个例子。如果您遇到优化器无法重写查询的查询,您可以像这样进行重写。通常,越接近只包含连接的查询,查询的性能就越好。然而,查询调优的过程要比这复杂得多,有时反其道而行之可以提高查询性能。教训是永远要考验。

您可以使用的另一个选项是将查询分成几个部分,然后分步执行。

将查询拆分成多个部分

最后一种选择是将查询分成两个或更多部分。由于 MySQL 8 支持通用表表达式和窗口函数,这种类型的重写不像 MySQL 的旧版本那样频繁。然而,记住这一点是有用的。

Tip

不要低估将一个复杂的查询拆分成两个或更多更简单的查询并逐渐生成查询结果的能力。

例如,考虑与前面讨论中相同的查询,其中您查找居住在澳大利亚昆士兰州的所有人。您可以将子查询作为一个单独的查询来执行,然后将结果放回IN()操作符中。这种重写最适用于应用能够以编程方式生成下一个查询的应用。为了简单起见,这个讨论将只显示所需的 SQL。清单 24-19 显示了这两个查询。

mysql> SET SESSION transaction_isolation = 'REPEATABLE-READ';
Query OK, 0 rows affected (0.0002 sec)

mysql> START TRANSACTION;
Query OK, 0 rows affected (0.0400 sec)

mysql> SELECT AddressId
         FROM chapter_24.address
        WHERE CountryCode = 'AUS'
              AND District = 'Queensland';
+-----------+
| AddressId |
+-----------+
|       132 |
|       143 |
|       136 |
|       142 |
+-----------+
4 rows in set (0.0008 sec)

mysql> SELECT *
         FROM chapter_24.person
        WHERE AddressId IN (132, 136, 142, 143)\G
*************************** 1\. row ***************************
  PersonId: 79
 FirstName: Dimitra
   Surname: Turner
 BirthDate: 1937-11-16
 AddressId: 132
LanguageId: 110
*************************** 2\. row ***************************
  PersonId: 356
 FirstName: Julian
   Surname: Serrano
 BirthDate: 2017-07-30
 AddressId: 132
LanguageId: 110
2 rows in set (0.0005 sec)

mysql> COMMIT;
Query OK, 0 rows affected (0.0003 sec)

Listing 24-19Splitting a query into two steps

使用具有REPEATABLE-READ事务隔离级别的事务来执行查询,这意味着两个SELECT查询将使用相同的读取视图,从而以相同的方式对应于相同的时间点,就像您将问题作为一个查询来执行一样。对于这样简单的查询,使用多个查询没有任何好处;然而,在真正复杂的查询的情况下,将查询的一部分分离出来(可能包括一些连接)可能是一个优势。将查询分成几部分的另一个好处是,在某些情况下,您可以提高缓存效率。对于本例,如果有其他查询使用相同的子查询来查找昆士兰的地址,缓存可以让您将结果重复用于多种用途。

队列系统:跳过锁定

与数据库相关的一个常见任务是处理存储在队列中的一些任务列表。一个例子是在商店里处理订单。所有任务都被处理并且只被处理一次是很重要的,但是哪个应用线程处理每个任务并不重要。SKIP LOCKED条款非常适合这种情况。

考虑清单 24-20 中定义的表jobqueue

mysql> SHOW CREATE TABLE chapter_24.jobqueue\G
*************************** 1\. row ***************************
       Table: jobqueue
Create Table: CREATE TABLE `jobqueue` (
  `JobId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `SubmitDate` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `HandledDate` datetime DEFAULT NULL,
  PRIMARY KEY (`JobId`),
  KEY `HandledDate` (`HandledDate`,`SubmitDate`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0004 sec)

mysql> SELECT *
         FROM chapter_24.jobqueue;
+-------+---------------------+-------------+
| JobId | SubmitDate          | HandledDate |
+-------+---------------------+-------------+
|     1 | 2019-07-01 19:32:30 | NULL        |
|     2 | 2019-07-01 19:32:33 | NULL        |
|     3 | 2019-07-01 19:33:40 | NULL        |
|     4 | 2019-07-01 19:35:12 | NULL        |
|     5 | 2019-07-01 19:40:24 | NULL        |
|     6 | 2019-07-01 19:40:28 | NULL        |
+-------+---------------------+-------------+
6 rows in set (0.0005 sec)

Listing 24-20The jobqueue table

and data

HandledDateNULL时,则任务尚未处理,待处理。例如,如果您的应用设置为获取最早的未处理任务,并且您希望依靠 InnoDB 行锁来防止两个线程执行同一任务,那么您可以使用SELECT ... FOR UPDATE(在现实世界中,该语句将是更大事务的一部分):

SELECT JobId
  FROM chapter_24.jobqueue
 WHERE HandledDate IS NULL
 ORDER BY SubmitDate
 LIMIT 1
   FOR UPDATE;

这对于第一个请求很有效,但是下一个请求将被阻塞,直到发生锁等待超时或者第一个任务已经被处理,因此任务处理是序列化的。诀窍是确保在您过滤和排序的列上有一个索引,然后使用SKIP LOCKED子句。那么第二个连接将简单地跳过锁定的行,找到满足搜索条件的第一个非锁定的行。清单 24-21 显示了两个连接的例子,每个连接从队列中获取一个任务。

Connection 1> START TRANSACTION;
Query OK, 0 rows affected (0.0002 sec)

Connection 1> SELECT JobId
                FROM chapter_24.jobqueue
               WHERE HandledDate IS NULL
               ORDER BY SubmitDate
               LIMIT 1
                 FOR UPDATE
                SKIP LOCKED;
+-------+
| JobId |
+-------+
|     1 |
+-------+
1 row in set (0.0004 sec)

Connection 2> START TRANSACTION;
Query OK, 0 rows affected (0.0003 sec)

Connection 2> SELECT JobId
                FROM chapter_24.jobqueue
               WHERE HandledDate IS NULL
               ORDER BY SubmitDate
               LIMIT 1
                 FOR UPDATE
                SKIP LOCKED;
+-------+
| JobId |
+-------+
|     2 |
+-------+
1 row in set (0.0094 sec)

Listing 24-21Fetching tasks with SKIP LOCKED

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

您可以使用性能模式中的data_locks表来查看哪个连接拥有每个锁(锁的顺序取决于线程 id,线程 id 对您来说是不同的):

mysql> SELECT THREAD_ID, INDEX_NAME, LOCK_DATA
         FROM performance_schema.data_locks
        WHERE OBJECT_SCHEMA = 'chapter_24'
              AND OBJECT_NAME = 'jobqueue'
              AND LOCK_TYPE = 'RECORD'
        ORDER BY THREAD_ID, EVENT_ID;
+-----------+------------+-----------------------+
| THREAD_ID | INDEX_NAME | LOCK_DATA             |
+-----------+------------+-----------------------+
|     21705 | PRIMARY    | 1                     |
|     21705 | SubmitDate | NULL, 0x99A383381E, 1 |
|     25101 | PRIMARY    | 2                     |
|     25101 | SubmitDate | NULL, 0x99A3833821, 2 |
+-----------+------------+-----------------------+
4 rows in set (0.0008 sec)

十六进制值是SubmitDate列的编码日期时间值。从输出中可以看出,每个连接在二级索引中持有一个记录锁,在主键中持有一个记录锁,正如从SELECT查询返回的JobId值中所预期的那样。

许多 OR 或 IN 条件

一种可能导致性能混乱的查询类型是具有许多范围条件的查询。当有许多OR条件或者IN ()操作符有许多值时,这通常会是一个问题。在某些情况下,对条件的微小更改可能会完全改变查询计划。

当优化器在索引列上遇到范围条件时,它有两种选择:它可以假设索引中的所有值出现的频率相同,或者它可以要求存储引擎进行索引搜索以确定每个范围的频率。前者最便宜,但后者准确得多。要决定使用哪种方法,有eq_range_index_dive_limit选项(默认值为 200)。如果有eq_range_index_dive_limit或更多的范围,优化器将只查看索引的基数,并假设所有值以相同的频率出现。如果范围较少,将要求存储引擎提供每个范围。

当每个值出现频率相等的假设不成立时,可能会出现性能问题。在这种情况下,当超过由eq_range_index_dive_limit,设置的阈值时,匹配条件的估计行数可能会突然发生显著变化,从而导致完全不同的查询计划。(当在IN ()操作符中有许多值时,真正重要的是匹配所包含值的平均行数接近从索引统计中获得的估计值。因此,列表中的值越多,包含的代表性样本就越多。)

清单 24-22 展示了一个payment表的例子,它有一个带索引的列ContactId。大多数行的ContactId设置为NULL,,索引的基数为 21。

mysql> SHOW CREATE TABLE chapter_24.payment\G
*************************** 1\. row ***************************
       Table: payment
Create Table: CREATE TABLE `payment` (
  `PaymentId` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `Amount` decimal(5,2) NOT NULL,
  `ContactId` int(10) unsigned DEFAULT NULL,
  PRIMARY KEY (`PaymentId`),
  KEY `ContactId` (`ContactId`)
) ENGINE=InnoDB AUTO_INCREMENT=32798 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0004 sec)

mysql> SELECT COUNT(ContactId), COUNT(*)
         FROM chapter_24.payment;
+------------------+----------+
| COUNT(ContactId) | COUNT(*) |
+------------------+----------+
|               20 |    20000 |
+------------------+----------+
1 row in set (0.0060 sec)

mysql> SELECT CARDINALITY
         FROM information_schema.STATISTICS
        WHERE TABLE_SCHEMA = 'chapter_24'
              AND TABLE_NAME = 'payment'
              AND INDEX_NAME = 'ContactId';
+-------------+
| CARDINALITY |
+-------------+
|          21 |
+-------------+
1 row in set (0.0009 sec)

mysql> SET SESSION eq_range_index_dive_limit=5;
Query OK, 0 rows affected (0.0003 sec)

mysql> EXPLAIN
        SELECT *
          FROM chapter_24.payment
         WHERE ContactId IN (1, 2, 3, 4)\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: range
possible_keys: ContactId

          key: ContactId
      key_len: 5
          ref: NULL
         rows: 4
     filtered: 100
        Extra: Using index condition
1 row in set, 1 warning (0.0006 sec)

Listing 24-22Query with many range conditions

在示例中,eq_range_index_dive_limit被设置为 5,以避免需要指定一长串值。对于四个值,优化器已经请求了这四个值中每一个值的统计信息,估计的行数是 4。但是,如果您将值列表变长,事情就会发生变化:

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

突然,估计有 4785 行匹配,而不是真正匹配的 5 行。仍然使用索引,但是如果连接中涉及到带有这个条件的付款表,那么优化器很可能会选择非最佳的连接顺序。如果您让值列表变得更长,优化器将完全停止使用索引,并进行全表扫描,因为它认为索引非常有效:

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

这个查询只返回 7 行,所以索引是高度选择性的。那么,如何提高优化器的理解能力呢?根据不良估计原因的确切性质,有各种可能的行动。对于这个特殊的问题,您有以下选择:

  • 增加eq_range_index_dive_limit

  • 更改innodb_stats_method选项。

  • 强制 MySQL 使用索引。

最简单的解决方法就是增加eq_range_index_dive_limit。默认值为 200,这是一个很好的起点。如果您有一个候选查询,您可以使用不同的值eq_range_index_dive_limit进行测试,并确定执行索引挖掘所增加的成本是否值得从获得更好的行估计中节省下来的成本。测试查询的新值eq_range_index_dive_limit的一个好方法是在SET_VAR()优化器提示中设置该值:

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

在这种情况下,依赖基数导致如此糟糕的行估计的原因是,几乎所有的行都将ContactId设置为NULL。默认情况下,InnoDB 认为索引中具有NULL值的所有行都具有相同的值。这就是为什么在这个例子中基数只有 21。如果您将innodb_stats_method切换到nulls_ignored,基数将只基于非NULL值进行计算,如清单 24-23 所示。

mysql> SET GLOBAL innodb_stats_method = nulls_ignored;
Query OK, 0 rows affected (0.0003 sec)

mysql> ANALYZE TABLE chapter_24.payment;
+--------------------+---------+----------+----------+
| Table              | Op      | Msg_type | Msg_text |
+--------------------+---------+----------+----------+
| chapter_24.payment | analyze | status   | OK       |
+--------------------+---------+----------+----------+
1 row in set (0.1411 sec)

mysql> SELECT CARDINALITY
         FROM information_schema.STATISTICS
        WHERE TABLE_SCHEMA = 'chapter_24'
              AND TABLE_NAME = 'payment'
              AND INDEX_NAME = 'ContactId';
+-------------+
| CARDINALITY |
+-------------+
|       20107 |
+-------------+
1 row in set (0.0009 sec)

mysql> EXPLAIN
        SELECT *
          FROM chapter_24.payment
         WHERE ContactId IN (1, 2, 3, 4, 5, 6, 7)\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: range
possible_keys: ContactId
          key: ContactId
      key_len: 5
          ref: NULL
         rows: 7
     filtered: 100
        Extra: Using index condition
1 row in set, 1 warning (0.0011 sec)

Listing 24-23Using innodb_stats_method = nulls_ignored

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

mysql> SET GLOBAL innodb_stats_method = DEFAULT;
Query OK, 0 rows affected (0.0004 sec)

mysql> SELECT @@global.innodb_stats_method\G
*************************** 1\. row ***************************
@@global.innodb_stats_method: nulls_equal
1 row in set (0.0003 sec)

mysql> ANALYZE TABLE chapter_24.payment;
+--------------------+---------+----------+----------+
| Table              | Op      | Msg_type | Msg_text |
+--------------------+---------+----------+----------+
| chapter_24.payment | analyze | status   | OK       |
+--------------------+---------+----------+----------+
1 row in set (0.6683 sec)

最后一个选项是使用索引提示来强制 MySQL 使用索引。您将需要清单 24-24 中所示的FORCE INDEX变体。

mysql> EXPLAIN
        SELECT *
          FROM chapter_24.payment FORCE INDEX (ContactId)
         WHERE ContactId IN (1, 2, 3, 4, 5, 6, 7)\G
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: payment
   partitions: NULL
         type: range
possible_keys: ContactId
          key: ContactId
      key_len: 5
          ref: NULL
         rows: 6699
     filtered: 100
        Extra: Using index condition
1 row in set, 1 warning (0.0007 sec)

Listing 24-24Using FORCE INDEX to force MySQL to use the index

这将使查询的执行速度更快,就好像它有更准确的统计数据一样。但是,如果payment表是具有相同WHERE子句的连接的一部分,那么行估计仍然是错误的(估计的 6699 行与实际的 7 行相比),因此查询计划可能仍然是错误的,在这种情况下,您需要告诉优化器最佳的连接顺序是什么。

摘要

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

也可能出现使用索引的情况,但使用情况可以改善。这可以是转换索引以覆盖查询所需的所有列,使用错误的索引,或者用复杂的条件重写查询可以改进查询计划。

重写复杂的查询也很有用。MySQL 8 支持常用的表表达式和窗口函数,它们既可以用来简化查询,也可以使查询性能更好。在其他情况下,它可以帮助进行一些优化器通常会做的重写,或者将查询分成多个部分。

最后,讨论了两种常见的情况。第一个是使用一个队列,其中可以使用SKIP LOCKED子句有效地访问第一个非锁定行。第二种情况是有一个很长的OR条件列表或者一个有很多值的IN ()操作符,当范围的数量达到由eq_range_index_dive_limit选项设置的数量时,这可能会导致查询计划发生令人惊讶的变化。

下一章着眼于提高 DDL 和批量数据装载的性能。

二十五、DDL 和批量数据加载

有时,需要执行模式更改或将大量数据导入表中。这可能是为了适应新功能、恢复备份、导入由第三方流程生成的数据,或者类似的目的。虽然原始磁盘写性能自然非常重要,但是您也可以在 MySQL 端做一些事情来提高这些操作的性能。

Tip

如果您遇到恢复备份需要太长时间的问题,请考虑切换到直接复制数据文件(物理备份)的备份方法,如使用 MySQL Enterprise Backup。物理备份的一个主要优点是,它们比逻辑备份(包含作为INSERT语句或 CSV 文件的数据)的恢复速度快得多。

本章从讨论模式变化开始,然后转到加载数据的一些一般考虑事项。这些注意事项也适用于一次插入单行的情况。本章的其余部分将介绍如何通过按主键顺序插入来提高数据加载性能,缓冲池和辅助索引如何影响性能、配置以及调整语句本身。最后演示了 MySQL Shell 的并行导入特性。

模式更改

当需要对模式进行更改时,存储引擎可能需要做大量的工作,可能需要制作一个全新的表副本。这一节将讨论如何加速这个过程,首先是模式更改支持的算法,然后是配置等其他考虑因素。

Note

虽然OPTIMIZE TABLE没有对表的模式进行任何更改,但 InnoDB 将其实现为一个ALTER TABLE后跟一个ANALYZE TABLE。所以本节的讨论也适用于OPTIMIZE TABLE

算法

MySQL 支持几种用于ALTER TABLE的算法,这些算法决定了如何执行模式更改。一些模式更改可以通过更改表定义“立即”完成,而另一方面,一些更改需要将整个表复制到新表中。

按照所需工作量的顺序,这些算法是

  • INSTANT : 只对表格定义进行修改。虽然变化并不十分迅速,但却非常快。MySQL 8.0.12 及更高版本中提供了INSTANT算法。

  • INPLACE : 通常在现有的表空间文件中进行更改(表空间 id 不变),但也有一些例外,如ALTER TABLE <table name> FORCE(由OPTIMIZE TABLE使用),它更像COPY算法,但允许并发数据更改。这可能是一个相对廉价的操作,但也可能涉及复制所有数据。

  • COPY : 将现有数据复制到新的表空间文件中。这是影响最大的算法,因为它通常需要更多的锁,导致更多的 I/O,并且花费更长的时间。

通常,INSTANTINPLACE算法允许并发数据更改,这减少了对其他连接的影响,而COPY至少需要一个读锁。MySQL 将根据请求的更改选择影响最小的算法,但您也可以显式请求特定的算法。例如,如果您希望确保 MySQL 不会继续进行更改,如果您选择的算法不受支持,这将非常有用。使用ALGORITHM关键字指定算法,例如:

mysql> ALTER TABLE world.city
         ADD COLUMN Council varchar(50),
             ALGORITHM=INSTANT;

如果无法使用请求的算法执行更改,语句将失败,并显示一个ER_ALTER_OPERATION_NOT_SUPPORTED错误(错误号 1845),例如:

mysql> ALTER TABLE world.city
        DROP COLUMN Council,
             ALGORITHM=INSTANT;
ERROR: 1845: ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=COPY/INPLACE.

如果你能使用INSTANT算法,你显然会得到最好的ALTER TABLE性能。在编写时,允许使用INSTANT算法进行以下操作:

  • 添加一个新列作为表中的最后一列。

  • 添加生成的虚拟列。

  • 删除生成的虚拟列。

  • 为现有列设置默认值。

  • 删除现有列的默认值。

  • 更改数据类型为enumset的列所允许的值列表。一个要求是该列的存储大小不变。

  • 更改是否为现有索引显式设置索引类型(如BTREE)。

还有一些限制值得注意:

  • 行格式不能是COMPRESSED

  • 该表不能有全文索引。

  • 不支持临时表。

  • 数据字典中的表不能使用即时算法。

Tip

例如,如果您需要向现有的表中添加一列,请确保将其作为最后一列添加,以便可以“立即”添加

就性能而言,就地更改通常(但不总是)比复制更改更快。此外,当在线进行模式更改(LOCK=NONE)时,InnoDB 必须跟踪模式更改执行期间所做的更改。这增加了开销,并且在操作结束时应用模式更改期间所做的更改需要时间。如果您能够在表上使用共享锁(LOCK=SHARED)或排他锁(LOCK=EXCLUSIVE),那么与允许并发更改相比,您通常可以获得更好的性能。

其他考虑因素

由于就地或复制ALTER TABLE所做的工作是非常磁盘密集型的,所以对性能的最大影响是磁盘的速度以及在模式更改期间有多少其他写活动。这意味着,从性能角度来看,最好选择在实例和主机上几乎没有其他写入活动时,执行需要复制或移动大量数据的模式更改。这包括备份本身可能是非常 I/O 密集型的。

Tip

您可以使用性能模式监控 InnoDB 表的ALTER TABLEOPTIMIZE TABLE的进度。最简单的方法是使用sys.session视图并查看progress列,它显示了总工作百分比的大致进度。默认情况下,该功能处于启用状态。

如果您的ALTER TABLE包括创建或重建二级索引(这包括OPTIMIZE TABLE和重建表的其他语句),您可以使用innodb_sort_buffer_size选项来指定每个排序缓冲区可以使用多少内存。请注意,单个ALTER TABLE将创建多个缓冲区,因此注意不要将值设置得太大。默认值为 1 MiB,最大允许值为 64 MiB。在某些情况下,较大的缓冲区可能会提高性能。

当您创建全文索引时,您可以使用innodb_ft_sort_pll_degree选项来指定 InnoDB 将使用多少个线程来构建搜索索引。默认值为 2,支持的值介于 1 和 32 之间。如果您在大型表上创建全文索引,增加innodb_ft_sort_pll_degree的值可能是一个优势。

需要考虑的一个特殊 DDL 操作是删除或截断表。

删除或截断表

似乎没有必要考虑删除表的性能优化。似乎所有需要做的就是删除表空间文件并删除对表的引用。实际上,事情并不那么简单。

删除或截断表时的主要问题是缓冲池中对表数据的所有引用。特别是,自适应散列索引会引起问题。因此,在删除或截断大型表时,可以通过在操作期间禁用自适应散列索引来大大提高性能,例如:

mysql> SET GLOBAL innodb_adaptive_hash_index = OFF;
Query OK, 0 rows affected (0.1008 sec)

mysql> DROP TABLE <name of large table>;

mysql> SET GLOBAL innodb_adaptive_hash_index = ON;
Query OK, 0 rows affected (0.0098 sec)

禁用自适应散列索引将使受益于散列索引的查询运行得更慢,但是对于大小为几百吉字节或更大的表,禁用自适应散列索引的相对较小的减速通常优于潜在的停顿,因为删除对被删除或截断的表的引用的开销。

关于执行模式更改的讨论到此结束。本章的其余部分讨论加载数据。

一般数据加载注意事项

在讨论如何提高大容量插入的性能之前,有必要进行一个小测试并讨论结果。在测试中,200,000 行被插入到两个表中。其中一个表使用自动递增计数器作为主键,另一个表使用随机整数作为主键。两个表的行大小相同。

Tip

本节和下一节中的讨论同样适用于非批量插入。

数据加载完成后,清单 25-1 中的脚本可以用来确定表空间文件中每个页面的年龄,这是根据日志序列号(LSN)来测量的。日志序列号越高,页面被修改的时间越近。这个脚本的灵感来自杰瑞米·科尔 1 的 innodb_ruby,它生成了一个类似于 innodb_ruby space-lsn-age-illustrate-svg命令的地图。但是 innodb_ruby 还不支持 MySQL 8,所以单独开发了一个 Python 程序。该程序已经过 Python 2.7 (Linux)和 3.6 (Linux 和微软 Windows)的测试。它也可以在本书的 GitHub 存储库中的 listing_25_1.py 文件中找到。

'''Read a MySQL 8 file-per-table tablespace file and generate an
SVG formatted map of the LSN age of each page.

Invoke with the --help argument to see a list of arguments and
Usage instructions.'''

import sys
import argparse
import math
from struct import unpack

# Some constants from InnoDB
FIL_PAGE_OFFSET = 4          # Offset for the page number
FIL_PAGE_LSN = 16            # Offset for the LSN
FIL_PAGE_TYPE = 24           # Offset for the page type
FIL_PAGE_TYPE_ALLOCATED = 0  # Freshly allocated page

def mach_read_from_2(page, offset):
    '''Read 2 bytes in big endian. Based on the function of the same
    name in the InnoDB source code.'''
    return unpack('>H', page[offset:offset + 2])[0]

def mach_read_from_4(page, offset):
    '''Read 4 bytes in big endian. Based on the function of the same
    name in the InnoDB source code.'''
    return unpack('>L', page[offset:offset + 4])[0]

def mach_read_from_8(page, offset):
    '''Read 8 bytes in big endian. Based on the function of the same
    name in the InnoDB source code.'''
    return unpack('>Q', page[offset:offset + 8])[0]

def get_color(lsn, delta_lsn, greyscale):
    '''Get the RGB color of a relative lsn.'''
    color_fmt = '#{0:02x}{1:02x}{2:02x}'

    if greyscale:
        value = int(255 * lsn / delta_lsn)
        color = color_fmt.format(value, value, value)
    else:
        # 0000FF -> 00FF00 -> FF0000 -> FFFF00
        # 256 + 256 + 256 values
        value = int((3 * 256 - 1) * lsn / delta_lsn)
        if value < 256:
            color = color_fmt.format(0, value, 255 - value)
        elif value < 512:
            value = value % 256
            color = color_fmt.format(value, 255 - value, 0)
        else:
            value = value % 256
            color = color_fmt.format(255, value, 0)

    return color

def gen_svg(min_lsn, max_lsn, lsn_age, args):
    '''Generate an SVG output and print to stdout.'''
    pages_per_row = args.width
    page_width = args.size
    num_pages = len(lsn_age)
    num_rows = int(math.ceil(1.0 * num_pages / pages_per_row))
    x1_label = 5 * page_width + 1
    x2_label = (pages_per_row + 7) * page_width
    delta_lsn = max_lsn - min_lsn

    print('<?xml version="1.0"?>')
    print('<svg xmlns:="http://www.w3.org/2000/svg" version="1.1">')
    print('<text x="{0}" y="{1}" font-family="monospace" font-size="{2}" '
          .format(x1_label, int(1.5 * page_width) + 1, page_width) +
          'font-weight="bold" text-anchor="end">Page</text>')

    page_number = 0
    page_fmt = '  <rect x="{0}" y="{1}" width="{2}" height="{2}" fill="{3}" />'
    label_fmt = '  <text x="{0}" y="{1}" font-family="monospace" '
    label_fmt += 'font-size="{2}" text-anchor="{3}">{4}</text>'
    for i in range(num_rows):
        y = (i + 2) * page_width
        for j in range(pages_per_row):
            x = 6 * page_width + j * page_width
            if page_number >= len(lsn_age) or lsn_age[page_number] is None:
                color = 'black'
            else:
                relative_lsn = lsn_age[page_number] - min_lsn
                color = get_color(relative_lsn, delta_lsn, args.greyscale)

            print(page_fmt.format(x, y, page_width, color))
            page_number += 1

        y_label = y + page_width
        label1 = i * pages_per_row
        label2 = (i + 1) * pages_per_row
        print(label_fmt.format(x1_label, y_label, page_width, 'end', label1))
        print(label_fmt.format(x2_label, y_label, page_width, 'start', label2))

    # Create a frame around the pages
    frame_fmt = '  <path stroke="black" stroke-width="1" fill="none" d="'
    frame_fmt += 'M{0},{1} L{2},{1} S{3},{1} {3},{4} L{3},{5} S{3},{6} {2},{6}'
    frame_fmt += ' L{0},{6} S{7},{6} {7},{5} L{7},{4} S{7},{1} {0},{1} Z" />'
    x1 = int(page_width * 6.5)
    y1 = int(page_width * 1.5)
    x2 = int(page_width * 5.5) + page_width * pages_per_row
    x2b = x2 + page_width
    y1b = y1 + page_width
    y2 = int(page_width * (1.5 + num_rows))
    y2b = y2 + page_width
    x1c = x1 - page_width
    print(frame_fmt.format(x1, y1, x2, x2b, y1b, y2, y2b, x1c))

    # Create legend
    x_left = 6 * page_width
    x_right = x_left + pages_per_row * page_width
    x_mid = x_left + int((x_right - x_left) * 0.5)
    y = y2b + 2 * page_width
    print('<text x="{0}" y="{1}" font-family="monospace" '.format(x_left, y) +
          'font-size="{0}" text-anchor="start">{1}</text>'.format(page_width,
                                                                  min_lsn))
    print('<text x="{0}" y="{1}" font-family="monospace" '.format(x_right, y) +
          'font-size="{0}" text-anchor="end">{1}</text>'.format(page_width,
                                                                  max_lsn))
    print('<text x="{0}" y="{1}" font-family="monospace" '.format(x_mid, y) +
          'font-size="{0}" font-weight="bold" text-anchor="middle">{1}</text>'
          .format(page_width, 'LSN Age'))

    color_width = 1
    color_steps = page_width * pages_per_row
    y = y + int(page_width * 0.5)
    for i in range(color_steps):
        x = 6 * page_width + i * color_width
        color = get_color(i, color_steps, args.greyscale)
        print('<rect x="{0}" y="{1}" width="{2}" height="{3}" fill="{4}" />'
              .format(x, y, color_width, page_width, color))

    print('</svg>')

def analyze_lsn_age(args):
    '''Read the tablespace file and find the LSN for each page.'''
    page_size_bytes = int(args.page_size[0:-1]) * 1024
    min_lsn = None
    max_lsn = None
    lsn_age = []
    with open(args.tablespace, 'rb') as fs:
        # Read at most 1000 pages at a time to avoid storing too much
        # in memory at a time.
        chunk = fs.read(1000 * page_size_bytes)
        while len(chunk) > 0:
            num_pages = int(math.floor(len(chunk) / page_size_bytes))
            for i in range(num_pages):
                # offset is the start of the page inside the
                # chunk of data
                offset = i * page_size_bytes
                # The page number, lsn for the page, and page
                # type can be found at the FIL_PAGE_OFFSET,
                # FIL_PAGE_LSN, and FIL_PAGE_TYPE offsets
                # relative to the start of the page.
                page_number = mach_read_from_4(chunk, offset + FIL_PAGE_OFFSET)
                page_lsn = mach_read_from_8(chunk, offset + FIL_PAGE_LSN)
                page_type = mach_read_from_2(chunk, offset + FIL_PAGE_TYPE)

                if page_type == FIL_PAGE_TYPE_ALLOCATED:
                    # The page has not been used yet
                    continue

                if min_lsn is None:
                    min_lsn = page_lsn
                    max_lsn = page_lsn
                else:
                    min_lsn = min(min_lsn, page_lsn)
                    max_lsn = max(max_lsn, page_lsn)

                if page_number == len(lsn_age):
                    lsn_age.append(page_lsn)
                elif page_number > len(lsn_age):
                    # The page number is out of order - expand the list first
                    lsn_age += [None] * (page_number - len(lsn_age))
                    lsn_age.append(page_lsn)
                else:
                    lsn_age[page_number] = page_lsn

            chunk = fs.read(1000 * page_size_bytes)

    sys.stderr.write("Total # Pages ...: {0}\n".format(len(lsn_age)))
    gen_svg(min_lsn, max_lsn, lsn_age, args)

def main():
    '''Parse the arguments and call the analyze_lsn_age()
    function to perform the analysis.'''
    parser = argparse.ArgumentParser(
        prog='listing_25_1.py',
        description='Generate an SVG map with the LSN age for each page in an' +
        ' InnoDB tablespace file. The SVG is printed to stdout.')

    parser.add_argument(
        '-g', '--grey', '--greyscale', default=False,
        dest='greyscale', action="store_true",
        help='Print the LSN age map in greyscale.')

    parser.add_argument(
        '-p', '--page_size', '--page-size', default="16k",
        dest='page_size',
        choices=['4k', '8k', '16k', '32k', '64k'],
        help='The InnoDB page size. Defaults to 16k.')

    parser.add_argument(
        '-s', '--size', default=16, dest="size",
        choices=[4, 8, 12, 16, 20, 24], type=int,
        help='The size of the square representing a page in the output. ' +
        'Defaults to 16.')

    parser.add_argument(
        '-w', '--width', default=64, dest="width",
        type=int,
        help='The number of pages to include per row in the output. ' +
        'The default is 64.')

    parser.add_argument(
        dest='tablespace',
        help='The tablespace file to analyze.')

    args = parser.parse_args()
    analyze_lsn_age(args)

if __name__ == '__main__':
    main()

Listing 25-1Python program to map the LSN age of InnoDB pages

在由每页的FIL_PAGE_OFFSETFIL_PAGE_LSNFIL_PAGE_TYPE常量定义的位置(以字节为单位)提取页码、日志序列号和页面类型。如果页面类型具有常量FIL_PAGE_TYPE_ALLOCATED的值,这意味着它还没有被使用,因此可以跳过它——这些页面在日志序列号映射中被涂成黑色。

Tip

如果您想探索页面标题中可用的信息,源代码中的文件storage/innobase/include/fil0types.h ( https://github.com/mysql/mysql-server/blob/8.0/storage/innobase/include/fil0types.h )和 MySQL 内部手册中 fil 标题的描述( https://dev.mysql.com/doc/internals/en/innodb-fil-header.html )是很好的起点。

您可以通过使用--help参数调用程序来获得使用程序的帮助。唯一必需的参数是要分析的表空间文件的路径。除非您已经将innodb_page_size选项设置为 16384 字节以外的值,否则您只需要可选参数的默认值,除非您想要更改生成的地图的尺寸和大小。

Caution

不要在生产系统上使用该程序!程序中有最少的错误检查以使它尽可能的简单,并且它本质上是实验性的。

您现在可以生成测试表了。清单 25-2 展示了如何创建table_autoinc表。这是带有自动递增主键的表。

mysql-sql> CREATE SCHEMA chapter_25;
Query OK, 1 row affected (0.0020 sec)

mysql-sql> CREATE TABLE chapter_25.table_autoinc (
             id bigint unsigned NOT NULL auto_increment,
             val varchar(36),
             PRIMARY KEY (id)
           );
Query OK, 0 rows affected (0.3382 sec)

mysql-sql> \py
Switching to Python mode...

mysql-py> for i in range(40):
              session.start_transaction()
              for j in range(5000):
                  session.run_sql("INSERT INTO chapter_25.table_autoinc (val) VALUES (UUID())")
              session.commit()

Query OK, 0 rows affected (0.1551 sec)

Listing 25-2Populating a table with an auto-incrementing primary key

该表有一个bigint主键和一个用 UUIDs 填充的varchar(36),以创建一些随机数据。MySQL Shell 的 Python 语言模式用于插入数据。8.0.17 及更高版本中提供了session.run_sql()方法。最后,您可以执行listing_25_1.py脚本来生成可伸缩矢量图形(SVG)格式的表空间年龄图:

shell> python listing_25_1.py <path to datadir>\chapter_25\table_autoinc.ibd > table_autoinc.svg
Total # Pages ...: 880

程序的输出显示,表空间中有 880 个页面,文件末尾可能还有一些未使用的页面。

25-1 显示了table_autoinc表的日志序列号年龄图。

img/484666_1_En_25_Fig1_HTML.jpg

图 25-1

按主键顺序插入时每页的 LSN 年龄

在图中,左上角代表表空间的第一页。当您从左到右、从上到下浏览该图时,页面越来越深入到表空间文件中,右下方表示最后的页面。该图显示,除了第一页之外,这些页的年龄模式遵循与该图底部的 LSN 年龄标度相同的模式。这意味着随着您在表空间中的前进,页的年龄会变得越来越年轻。前几页是例外,例如,它们包括表空间头。

这个模式显示了数据被顺序地插入到表空间中,使得表空间尽可能紧凑。这也使得如果一个查询从逻辑上连续的几个页中读取数据,那么它们在表空间文件中也是物理上连续的。

如果你随机插入,会是什么样子呢?随机顺序插入的一个常见示例是将 UUID 作为主键,但是为了确保两个表的行大小相同,使用了一个随机整数。清单 25-3 展示了如何填充table_random表。

mysql-py> \sql
Switching to SQL mode... Commands end with ;

mysql-sql> CREATE TABLE chapter_25.table_random (
             id bigint unsigned NOT NULL,
             val varchar(36),
             PRIMARY KEY (id)
           );
Query OK, 0 rows affected (0.0903 sec)

mysql-sql> \py
Switching to Python mode...

mysql-py> import random
mysql-py> import math
mysql-py> maxint = math.pow(2, 64) - 1
mysql-py> random.seed(42)

mysql-py> for i in range(40):
              session.start_transaction()
              for j in range(5000):
                  session.run_sql("INSERT INTO chapter_25.table_random VALUE ({0}, UUID())".format(random.randint(0, maxint)))
              session.commit()

Query OK, 0 rows affected (0.0185 sec)

Listing 25-3Populating a table with a random primary key

Python random模块用于生成 64 位随机无符号整数。种子是显式设置的,因为已知(通过实验)种子为 42 会在一行中生成 200,000 个不同的数字,因此不会出现重复的键错误。当表被填充后,执行listing_25_1.py脚本:

shell> python listing_25_1.py <path to datadir>\chapter_25\table_random.ibd > table_random.svg
Total # Pages ...: 1345

listing_25_1.py脚本的输出显示这个表空间中有 1345 个页面。生成的年龄图如图 25-2 所示。

img/484666_1_En_25_Fig2_HTML.jpg

图 25-2

以随机顺序插入时每页的 LSN 年龄

这一次,日志序列号年龄模式完全不同。除了未使用的页面之外,所有页面的年龄颜色对应于最新日志序列号的颜色。这意味着所有包含数据的页面都是在同一时间最后更新的,或者说,它们都是在大容量装载结束之前写入的。包含数据的页数是 1345,而包含自动递增主键的表中使用了 880 页。这就增加了超过 50%的页面。

以随机顺序插入数据会导致相同数量的数据有更多的页面,这是因为 InnoDB 在插入数据时会填满页面。当按顺序主键顺序插入数据时,这意味着下一行将总是在前一行之后,因此当行按主键顺序排序时,这种方法很有效。如图 25-3 所示。

img/484666_1_En_25_Fig3_HTML.png

图 25-3

按顺序插入时添加新行的示例

该图显示了插入的两行新行。id = 1005 的行刚好可以放入第 N 页,所以当插入 id = 1006 的行时,它会被插入到下一页。在这个场景中,一切都很好,很紧凑。

当行以随机顺序到达时,有时需要将行插入到已经满得没有空间容纳新行的页面中。在这种情况下,InnoDB 将现有页面一分为二,这两个页面中的每一个页面都有原始页面的一半数据,因此有空间容纳新行。如图 25-4 所示。

img/484666_1_En_25_Fig4_HTML.png

图 25-4

随机插入导致的页面分割示例

在这种情况下,id = 3500 的行被插入,但是在逻辑上它所属的页面 N 中没有更多空间。因此,第 N 页被分成第 N 页和第 N+1 页,每页大约有一半的数据。

页面分割有两个直接后果。首先,以前占用一个页面的数据现在使用了两个页面,这就是为什么随机插入最终会多占用 50%的页面,这也意味着相同的数据在缓冲池中需要更多的空间。额外页面的一个显著副作用是,B 树索引最终会有更多的叶页面和树中潜在的更多层,并且考虑到树中的每一层都意味着访问页面时的额外寻道,这会导致额外的 I/O。

其次,以前一起读入内存的行现在位于磁盘上不同位置的两个页面中。当 InnoDB 增加表空间文件的大小时,它是通过在页面大小为 16 KiB 或更小时分配一个 1 MiB 的新区来实现的。这有助于提高磁盘 I/O 的顺序性(在某种程度上,新的扩展区可以在磁盘上获得连续的扇区)。发生的页拆分越多,页就越多,不仅在一个扩展区内,而且在多个扩展区之间,从而导致更多的随机磁盘 I/O。当由于页拆分而创建新页时,它很可能位于磁盘上完全不同的部分,因此在读取页时,随机 I/O 的数量会增加。如图 25-5 所示。

img/484666_1_En_25_Fig5_HTML.png

图 25-5

磁盘上页面位置的示例

在图中描绘了三个范围。为简单起见,每个区段中只显示了五个页面(默认页面大小为 16 KiB,每个区段有 64 个页面)。属于页面分割一部分的页面会突出显示。第 11 页是在最后一页是第 13 页时被拆分的,因此第 11 页和第 12 页仍然相对靠近。然而,当创建了几个额外的页面时,第 15 页被拆分,这意味着第 16 页在下一个区段中结束。

更深的 B 树、占用缓冲池空间的更多页面和更多随机 I/O 的组合意味着以随机主键顺序插入行的表的性能不如以主键顺序插入数据的表。性能差异不仅适用于插入数据;它也适用于数据的后续使用。因此,按主键顺序插入数据对于优化性能非常重要。接下来将讨论如何实现这一点。

按主键顺序插入

正如前面的讨论所示,按主键顺序插入数据有很大的优势。实现这一点最简单的方法是通过使用一个无符号整数并声明自动递增的列来自动生成主键值。或者,您需要确保数据是按照主键顺序插入的。本节将调查这两种情况。

自动递增主键

确保数据按主键顺序插入的最简单方法是允许 MySQL 通过使用自动递增的主键来自己赋值。您可以通过在创建表时为主键列指定auto_increment属性来做到这一点。也可以结合多列主键使用自动递增列;在这种情况下,自动递增列必须是索引中的第一列。

清单 25-4 展示了一个创建两个表的例子,这两个表使用一个自动增加的列以主键顺序插入数据。

mysql> \sql
Switching to SQL mode... Commands end with ;

mysql> DROP SCHEMA IF EXISTS chapter_25;
Query OK, 0 rows affected, 1 warning (0.0456 sec)

mysql> CREATE SCHEMA chapter_25;
Query OK, 1 row affected (0.1122 sec)

mysql> CREATE TABLE chapter_25.t1 (
         id int unsigned NOT NULL auto_increment,
         val varchar(10),
         PRIMARY KEY (id)
       );
Query OK, 0 rows affected (0.4018 sec)

mysql> CREATE TABLE chapter_25.t2 (
         id int unsigned NOT NULL auto_increment,
         CreatedDate datetime NOT NULL
                              DEFAULT CURRENT_TIMESTAMP(),
         val varchar(10),
         PRIMARY KEY (id, CreatedDate)
       );
Query OK, 0 rows affected (0.3422 sec)

Listing 25-4Creating tables with an auto-increment primary key

t1表只有一个主键列,值是自动递增的。使用无符号整数而不是有符号整数的原因是自动增量值总是大于 0,因此使用无符号整数在用尽可用值之前允许两倍的值。这些示例使用了一个 4 字节的整数,如果使用所有的值,它允许的行数略少于 43 亿。如果这还不够,您可以将该列声明为bigint unsigned,它使用 8 个字节,允许 1.8E19 行。

t2表向主键添加了一个datetime列,例如,如果您希望在创建行时进行分区,这个列会很有用。自动递增的id列仍然确保用唯一的主键创建行,并且因为id列是主键中的第一列,所以即使主键中的后续列本质上是随机的,行仍然按主键顺序插入。

当您使用自动递增主键时,您可以使用sys模式中的schema_auto_increment_columns视图来检查自动递增值的使用,并监控是否有任何表接近耗尽它们的值。清单 25-5 显示了sakila.payment表的输出。

mysql> SELECT *
         FROM sys.schema_auto_increment_columns
        WHERE table_schema = 'sakila'
              AND table_name = 'payment'\G
*************************** 1\. row ***************************
        table_schema: sakila
          table_name: payment
         column_name: payment_id
           data_type: smallint
         column_type: smallint(5) unsigned
           is_signed: 0
         is_unsigned: 1
           max_value: 65535
      auto_increment: 16049
auto_increment_ratio: 0.2449
1 row in set (0.0024 sec)

Listing 25-5Using the sys.schema_auto_increment_columns view

您可以从输出中看到,该表使用了一个用于自动增量值的smallint unsigned列,其最大值为65535,该列被命名为payment_id。下一个自动增量值是 16049,因此使用了可用值的 24.49%。

如果从外部源插入数据,可能已经为主键列分配了值(即使使用自动递增主键)。让我们看看在这种情况下你能做什么。

插入现有数据

无论您需要插入由某个进程生成的数据、恢复备份,还是使用不同的存储引擎转换表,最好在插入之前确保它处于主键顺序。如果您生成数据或者数据已经存在,那么您可以考虑在插入数据之前对其进行排序。或者,在导入完成后,使用OPTIMIZE TABLE语句重建表。

重建chapter_25.t1表的一个例子是

mysql> OPTIMIZE TABLE chapter_25.t1\G
*************************** 1\. row ***************************
   Table: chapter_25.t1
      Op: optimize
Msg_type: note
Msg_text: Table does not support optimize, doing recreate + analyze instead
*************************** 2\. row ***************************
   Table: chapter_25.t1
      Op: optimize
Msg_type: status
Msg_text: OK
2 rows in set (0.6265 sec)

对于大型表,重建可能需要大量时间,但是除了在开始和结束时需要锁以确保一致性的短暂时间之外,该过程是在线的。

如果您使用mysqldump程序创建备份,您可以添加--order-by-primary选项,这使得mysqldump添加一个包含主键中的列的ORDER BY子句(mysqlpump没有等价的选项)。如果备份是使用存储引擎(使用所谓的堆组织数据,如 MyISAM)创建的表,目的是将其恢复到 InnoDB 表(使用数据的索引组织),这将非常有用。

Tip

虽然在使用不带ORDER BY子句的查询时,通常不应该依赖于返回行的顺序,但 InnoDB 的索引组织行意味着,即使省略了ORDER BY子句,全表扫描通常(但不保证)会按主键顺序返回行。一个值得注意的例外是,当表包含一个覆盖所有列的二级索引,并且优化器选择将该索引用于查询时。

如果将数据从一个表复制到另一个表,也可以使用相同的原则。清单 25-6 展示了一个将world.city表中的行复制到world.city_new表中的例子。

mysql> CREATE TABLE world.city_new
         LIKE world.city;
Query OK, 0 rows affected (0.8607 sec)

mysql> INSERT INTO world.city_new
       SELECT *
         FROM world.city
        ORDER BY ID;
Query OK, 4079 rows affected (2.0879 sec)

Records: 4079  Duplicates: 0  Warnings: 0

Listing 25-6Ordering data by the primary key when copying it

作为最后一种情况,考虑当您有一个 UUID 作为主键。

UUID 主键

例如,如果您的主键仅限于一个 UUID,因为您无法更改应用来支持自动递增主键,那么您可以通过交换 UUID 组件并将 uuid 存储在二进制列中来提高性能。

一个 UUID (MySQL 使用 UUID 版本 1)由一个时间戳和一个序列号(如果时间戳向后移动,例如在夏令时更改期间,以保证唯一性)以及 MAC 地址组成。

Caution

在某些情况下,泄露 MAC 地址可能会被认为是一个安全问题,因为它可以用来识别计算机和潜在的用户。

时间戳是一个 60 位值,使用 UTC,从 1582 年 10 月 15 日午夜(公历开始使用时)开始,时间间隔为 100 纳秒。 2 它被分成三部分,最低有效部分在前,最高有效部分在后。(对于 UUID 版本,时间戳的高位字段也包括四位。UUID 的组成也如图 25-6 所示。)

img/484666_1_En_25_Fig6_HTML.png

图 25-6

UUID 版本 1 的五个部分

时间戳的较低部分代表 100 纳秒或不到 430 秒的最多 4,294,967,295 (0xffffffff)个间隔。这意味着,从排序的角度来看,时间戳的低位部分每隔 7 分钟略少于 10 秒滚动一次,使 UUID 重新开始。这就是为什么普通 UUIDs 不能很好地用于索引组织的数据,因为这意味着插入很大程度上是在主键树中的随机位置。

MySQL 8 包含两个新函数来操作 UUIDs,使它们更适合作为 InnoDB 中的主键:UUID_TO_BIN()BIN_TO_UUID()。这些函数分别将 UUID 从十六进制表示形式转换为二进制表示形式,然后再转换回来。它们接受相同的两个参数:要转换的 UUID 值和是否交换时间戳的高低部分。清单 25-7 展示了一个使用函数插入和检索数据的例子。

mysql> CREATE TABLE chapter_25.t3 (
         id binary(16) NOT NULL,
         val varchar(10),
         PRIMARY KEY (id)
       );
Query OK, 0 rows affected (0.4413 sec)

mysql> INSERT INTO chapter_25.t3
       VALUES (UUID_TO_BIN(
                 '14614d6e-b5a8-11e9-ae6e-080027b7c106',
                 TRUE
              ), 'abc');
Query OK, 1 row affected (0.2166 sec)

mysql> SELECT BIN_TO_UUID(id, TRUE) AS id, val
         FROM chapter_25.t3\G
*************************** 1\. row ***************************
 id: 14614d6e-b5a8-11e9-ae6e-080027b7c106
val: abc
1 row in set (0.0004 sec)

Listing 25-7Using the UUID_TO_BIN() and BIN_TO_UUID() functions

这种方法有两个优点。因为 UUID 交换了低时间和高时间分量,所以它变得单调递增,这使得它更适合于按索引组织的行。二进制存储意味着 UUID 只需要 16 个字节的存储,而不是十六进制版本的 36 个字节,用破折号来分隔 UUID 的各个部分。请记住,由于数据是按主键组织的,主键被添加到辅助索引中,因此可以从索引转到行,因此存储主键所需的字节越少,辅助索引就越小。

InnoDB 缓冲池和二级索引

对于批量数据加载的性能来说,最重要的一个因素是 InnoDB 缓冲池的大小。本节讨论为什么缓冲池对于大容量数据装载很重要。

当您向表中插入数据时,InnoDB 需要能够将数据存储在缓冲池中,直到数据被写入表空间文件。缓冲池中存储的数据越多,InnoDB 将脏页刷新到表空间文件的效率就越高。然而,还有第二个原因是维护二级索引。

在插入数据时,需要维护辅助索引,但是辅助索引的排序顺序与主键不同,所以在插入数据时,它们会不断地重新排列。只要可以在内存中维护索引,插入率就可以保持很高,但是当索引不再适合缓冲池时,维护索引的成本会突然变得更高,插入率会显著下降。图 25-7 说明了性能如何依赖于处理二级索引的缓冲池的可用性。

img/484666_1_En_25_Fig7_HTML.png

图 25-7

与缓冲池中的索引大小相比的插入性能

该图显示了插入速率在一段时间内大致保持不变,而在这段时间内,越来越多的缓冲池用于辅助索引。当缓冲池中无法存储更多的索引时,插入速率会突然下降。在极端情况下,将数据加载到一个只有一个二级索引的表中,该索引包含整行,而没有其他内容,当二级索引使用了将近一半的缓冲池(剩余的用于主键)时,就会出现这种情况。

您可以使用information_schema.INNODB_BUFFER_PAGE表来确定一个索引在缓冲池中使用了多少空间。例如,通过world.city表上的CountryCode索引来查找缓冲池中使用的内存量

mysql> SELECT COUNT(*) AS NumPages,
              IFNULL(SUM(DATA_SIZE), 0) AS DataSize,
              IFNULL(SUM(IF(COMPRESSED_SIZE = 0,
                            @@global.innodb_page_size,
                            COMPRESSED_SIZE
                           )
                        ),
                     0
                    ) AS CompressedSize
         FROM information_schema.INNODB_BUFFER_PAGE
        WHERE TABLE_NAME = '`world`.`city`'
              AND INDEX_NAME = 'CountryCode';
+----------+----------+----------------+
| NumPages | DataSize | CompressedSize |
+----------+----------+----------------+
|        3 |    27148 |          49152 |
+----------+----------+----------------+
1 row in set (0.1027 sec)

结果将取决于你使用了多少索引,所以一般来说你的结果会有所不同。这个查询最好用在测试系统上,因为查询INNODB_BUFFER_PAGE表会有很大的开销。

Caution

在您的生产系统上查询INNODB_BUFFER_PAGE表时要小心,因为开销可能很大,尤其是当您有一个包含许多表和索引的大型缓冲池时。

当辅助索引无法放入缓冲池时,有三种策略可以避免性能下降,如下所示:

  • 增加缓冲池的大小。

  • 插入数据时删除辅助索引。

  • 给桌子分区。

在进行大容量装载时增加缓冲池大小是最明显的策略,但也是最不可能有用的策略。它主要用于将数据插入到已经有大量数据的表中,并且您知道在数据加载期间,您可以将其他进程需要的一些内存用于缓冲池。在这种情况下,支持动态调整缓冲池的大小非常有用。例如,将缓冲池大小设置为 256 MiB

mysql> SET GLOBAL innodb_buffer_pool_size = 256 * 1024 * 1024;
Query OK, 0 rows affected (0.0003 sec)

数据加载完成后,可以将缓冲池大小设置回通常的值(如果使用默认值,则为 134217728)。

如果要插入到一个空表中,一个非常有用的策略是在加载数据之前删除所有的辅助索引(可能为数据验证留下唯一的索引),然后再添加索引。在大多数情况下,这比在加载数据时试图维护索引更有效,如果您使用它来创建备份,这也是mysqlpump实用程序所做的。

最后一个策略是对表进行分区。这很有帮助,因为索引是分区的本地索引(这就是分区键必须是所有唯一索引的一部分的原因),所以如果您按分区顺序插入数据,InnoDB 只需维护当前分区中数据的索引。这使得每个索引更小,所以它们更容易放入缓冲池。

配置

您可以通过配置执行加载的会话来影响加载性能。这包括考虑关闭约束检查、如何生成自动增量 id 等等。

25-1 总结了除缓冲池大小之外与批量数据性能相关的最重要的配置选项。范围是该选项是可以在会话级别更改,还是仅在全局可用。

表 25-1

影响数据加载性能的配置选项

|

选项名称

|

范围

|

描述

|
| --- | --- | --- |
| foreign_key_checks | 会议 | 指定是否检查新行是否违反外键。禁用此选项可以提高具有外键的表的性能。 |
| unique_checks | 会议 | 指定是否检查新行是否违反唯一约束。禁用此选项可以提高具有唯一索引的表的性能。 |
| innodb_autoinc_lock_mode | 全球的 | 指定 InnoDB 如何确定下一个自动增量值。将该选项设置为 MySQL 8 中的默认值——需要binlog_format = ROW)可以获得最佳性能,但代价是可能会出现不连续的自动增量值。需要重启 MySQL。 |
| innodb_flush_log_at_trx_commit | 全球的 | 确定 InnoDB 刷新对数据文件所做更改的频率。如果使用许多小事务导入数据,将此选项设置为 0 或 2 可以提高性能。 |
| sql_log_bin | 会议 | 当设置为 0 或OFF时,禁用二进制日志。这将大大减少写入的数据量。 |
| transaction_isolation | 会议 | 设置事务隔离级别。如果您没有读取 MySQL 中的现有数据,可以考虑将隔离级别设置为READ UNCOMMITTED。 |

所有选项都有副作用,所以请仔细考虑更改设置是否适合您。例如,如果您将数据从现有实例导入到新实例,并且您知道外键和唯一键约束没有问题,那么您可以为导入数据的会话禁用foreign_key_checksunique_checks选项。另一方面,如果您从一个不确定数据完整性的源进行导入,最好启用约束检查以确保数据质量,即使这意味着较慢的加载性能。

对于innodb_flush_log_at_trx_commit选项,您需要考虑丢失最后一秒左右已提交事务的风险是否可以接受。如果您的数据加载进程是实例上唯一的事务,并且很容易重做导入,那么您可以将innodb_flush_log_at_trx_commit设置为 0 或 2,以减少刷新次数。这种改变对小额事务非常有用。如果导入每秒提交的次数少于一次,那么更改带来的好处很少。如果您更改了innodb_flush_log_at_trx_commit,那么在导入后记得将该值设置回 1。

对于二进制日志,禁用写入导入的数据很有用,因为这大大减少了必须写入磁盘的数据更改量。如果二进制日志与重做日志和数据文件位于同一个磁盘上,这将特别有用。如果您不能修改导入过程来禁用sql_log_bin,您可以考虑使用skip-log-bin选项重新启动 MySQL 来完全禁用二进制日志,但是请注意,这也会影响系统上的所有其他事务。如果在导入过程中禁用了二进制日志记录,则在导入后立即创建完整备份会很有用,这样您就可以再次使用二进制日志进行时间点恢复。

Tip

如果您使用复制,请考虑在禁用sql_log_bin的情况下,在拓扑中的每个实例上单独进行数据导入。不过请注意,只有当 MySQL 不生成自动递增主键时,它才会起作用,并且只有当您需要导入大量数据时,才值得增加复杂性。对于 MySQL 8.0.17 中的初始加载,您可以只填充复制的源,并使用克隆插件 3 来创建副本。

您还可以通过选择导入数据的语句以及如何使用事务来提高加载性能。

事务和加载方法

一个事务表示一组更改,InnoDB 在提交事务之前不会完全应用这些更改。每次提交都涉及到将数据写入重做日志,并包括其他开销。如果您有非常小的事务(比如一次插入一行),这种开销会显著影响加载性能。

最佳事务规模没有金科玉律。对于较小的行大小,通常几千行就足够了,对于较大的行大小,选择较少的行。最终,您将需要在您的系统上进行测试,并使用您的数据来确定最佳的事务大小。

对于加载方法,主要有两种选择:INSERT语句或LOAD DATA [LOCAL] INFILE语句。总的来说,LOAD DATAINSERT语句执行得更好,因为解析更少。对于INSERT语句,使用扩展的 insert 语法有一个优点,即使用一个语句而不是多个单行语句插入多行。

Tip

当使用mysqlpump进行备份时,可以将--extended-insert选项设置为每个INSERT语句包含的行数,默认值为 250。对于mysqldump--extended-insert选项作为开关工作。启用时(默认),mysqldump将自动决定每条语句的行数。

使用LOAD DATA加载数据的一个优点是 MySQL Shell 可以自动并行加载。

MySQL Shell 并行加载数据

将数据加载到 MySQL 时可能会遇到的一个问题是,单个线程无法将 InnoDB 推到它所能承受的极限。如果将数据分成几批,并使用多线程加载数据,可以提高整体加载速率。自动完成这项工作的一个选项是使用 MySQL Shell 8.0.17 和更高版本的并行数据加载特性。

通过 Python 模式下的util.import_table()实用程序和 JavaScript 模式下的util.importTable()方法可以获得并行加载特性。这个讨论将假设您正在使用 Python 模式。第一个参数是文件名,第二个(可选)参数是带有可选参数的字典。您可以使用util.help()方法获得import_table()实用程序的帮助文本,比如

mysql-py> util.help('import_table')

帮助文本包括所有设置的详细描述,这些设置可以通过第二个参数中指定的字典给出。

MySQL Shell 禁用重复键和外键检查,并将执行导入的连接的事务隔离级别设置为READ UNCOMMITTED,以尽可能减少导入过程中的开销。

默认情况下,将数据插入到当前模式的一个表中,该表与不带扩展名的文件同名。例如,如果文件名为t_load.csv,默认的表名为t_load。清单 25-8 显示了一个将文件D:\MySQL\Files\t_load.csv加载到表chapter_25.t_load中的简单示例。t_load.csv文件可以从这本书的 GitHub 库中以t_load.csv.zip的名称获得。

mysql> \sql
Switching to SQL mode... Commands end with ;

mysql-sql> CREATE SCHEMA IF NOT EXISTS chapter_25;
Query OK, 1 row affected, 1 warning (0.0490 sec)

mysql-sql> DROP TABLE IF EXISTS chapter_25.t_load;
Query OK, 0 rows affected (0.3075 sec)

mysql-sql> CREATE TABLE chapter_25.t_load (
             id int unsigned NOT NULL auto_increment,
             val varchar(40) NOT NULL,
             PRIMARY KEY (id),
             INDEX (val)
           );
Query OK, 0 rows affected (0.3576 sec)

mysql> SET GLOBAL local_infile = ON;
Query OK, 0 rows affected (0.0002 sec)

mysql> \py
Switching to Python mode...

mysql-py> \use chapter_25
Default schema set to `chapter_25`.

mysql-py> util.import_table('D:/MySQL/Files/t_load.csv')
Importing from file 'D:/MySQL/Files/t_load.csv' to table `chapter_25`.`t_load` in MySQL Server at localhost:3306 using 2 threads
[Worker000] chapter_25.t_load: Records: 721916  Deleted: 0  Skipped: 0  Warnings: 0
[Worker001] chapter_25.t_load: Records: 1043084  Deleted: 0  Skipped: 0  Warnings: 0
100% (85.37 MB / 85.37 MB), 446.55 KB/s
File 'D:/MySQL/Files/t_load.csv' (85.37 MB) was imported in 1 min 52.1678 sec at 761.13 KB/s
Total rows affected in chapter_25.t_load: Records: 1765000  Deleted: 0  Skipped: 0  Warnings: 0

Listing 25-8Using the util.import_table() utility with default settings

创建chapter_25模式时的警告取决于您之前是否创建了该模式。请注意,您必须启用local_infile选项,该实用程序才能工作。

该示例最有趣的部分是导入的执行。当您没有指定任何内容时,MySQL Shell 会将文件分割成 50 MB 的块,并使用多达八个线程。在本例中,文件大小为 85.37 MB (MySQL Shell 使用文件大小度量标准–85.37 MB 与 81.42 MiB 相同),因此它提供了两个块,其中第一个是 50 MB,第二个是 35.37 MB。这不是一个可怕的好分布。

Tip

在调用util.import_table()实用程序之前,您必须在服务器端启用local_infile

你可以选择做的是告诉 MySQL Shell 以多大的尺寸分割。最佳情况是每个线程最终处理相同数量的数据。例如,如果您想要划分 85.37 MB 的数据,请将块大小设置为略大于一半,例如 43 MB。如果为大小指定了一个小数值,则向下舍入。还有几个其他选项可以设置,清单 25-9 显示了设置其中一些选项的示例。

mysql-py> \sql TRUNCATE TABLE chapter_25.t_load
Query OK, 0 rows affected (1.1294 sec)

mysql-py> settings = {
              'schema': 'chapter_25',
              'table': 't_load',
              'columns': ['id', 'val'],
              'threads': 4,
              'bytesPerChunk': '21500k',
              'fieldsTerminatedBy': '\t',
              'fieldsOptionallyEnclosed': False,
              'linesTerminatedBy': '\n'
          }

mysql-py> util.import_table('D:/MySQL/Files/t_load.csv', settings)
Importing from file 'D:/MySQL/Files/t_load.csv' to table `chapter_25`.`t_load` in MySQL Server at localhost:3306 using 4 threads
[Worker001] chapter_25.t_load: Records: 425996  Deleted: 0  Skipped: 0  Warnings: 0
[Worker002] chapter_25.t_load: Records: 440855  Deleted: 0  Skipped: 0  Warnings: 0
[Worker000] chapter_25.t_load: Records: 447917  Deleted: 0  Skipped: 0  Warnings: 0
[Worker003] chapter_25.t_load: Records: 450232  Deleted: 0  Skipped: 0  Warnings: 0
100% (85.37 MB / 85.37 MB), 279.87 KB/s
File 'D:/MySQL/Files/t_load.csv' (85.37 MB) was imported in 2 min 2.6656 sec at 695.99 KB/s
Total rows affected in chapter_25.t_load: Records: 1765000  Deleted: 0  Skipped: 0  Warnings: 0

Listing 25-9Using util.import_table() with several custom settings

在这种情况下,目标模式、表和列是显式指定的,文件被分成四个大致相等的块,线程数设置为四。CSV 文件的格式也包括在设置中(指定的值是默认值)。

根据硬件、数据和运行的其他查询,最佳线程数量会有很大的不同。您需要进行试验,为您的系统找到最佳设置。

摘要

本章讨论了决定 DDL 语句和大容量数据装载性能的因素。第一个主题是关于ALTER TABLEOPTIMIZE TABLE的模式变化。当您更改模式时,支持三种不同的算法。性能最好的算法是INSTANT算法,该算法可用于在行尾添加列和一些元数据更改。第二好的算法是INPLACE,它在大多数情况下会修改现有表空间文件中的数据。最后,也是通常最昂贵的算法是COPY

在无法使用INSTANT算法的情况下,将会有大量的 I/O,因此磁盘性能很重要,需要磁盘 I/O 的其他工作越少越好。它也有助于锁定表,因此 MySQL 不需要跟踪数据更改并在模式更改结束时应用它们。

对于插入数据,我们讨论了按主键顺序插入的重要性。如果插入顺序是随机的,则会导致更大的表、聚集索引的更深的 B 树索引、更多的磁盘寻道和更多的随机 I/O。以主键顺序插入数据的最简单方法是使用自动递增主键,并让 MySQL 确定下一个值。对于 UUID,MySQL 8 添加了UUID_TO_BIN()BIN_TO_UUID()函数,允许您将 UUID 所需的存储减少到 16 个字节,并交换时间戳的低阶和高阶部分,以使 uuid 单调增加。

当您插入数据时,插入速率突然变慢的一个典型原因是辅助索引不再适合缓冲池。如果插入到一个空表中,在导入过程中删除索引是一个优势。分区也有帮助,因为它将索引分成每个分区一部分,所以一次只需要索引的一部分。

在某些情况下,您可以禁用约束检查,减少重做日志的刷新,禁用二进制日志记录,并将事务隔离降低到READ UNCOMMITTED。这些配置更改都将有助于减少开销;但是,所有这些都有副作用,所以您必须仔细考虑这些更改是否能被您的系统接受。您还可以通过调整事务大小来平衡提交开销的减少和处理大型事务的开销,从而影响性能。

对于批量插入,您有两个加载数据的选项。可以使用常规的INSERT语句,也可以使用LOAD DATA语句。后者通常是首选方法。它还允许您使用 MySQL Shell 8.0.17 和更高版本的并行表导入功能。

在下一章中,您将了解如何提高复制的性能。

二十六、复制

MySQL 这些年来如此受欢迎的一个特性是支持复制,这允许您拥有一个 MySQL 实例,该实例自动从其来源接收更新并应用它们。通过快速事务和低延迟网络,复制可以接近实时,但请注意,由于除了 NDB 集群之外,MySQL 中没有同步复制,因此仍然存在潜在的巨大延迟。数据库管理员的一项经常性任务是提高复制的性能。多年来,MySQL 复制有了许多改进,包括一些可以帮助您提高复制性能的改进。

Note

本章重点介绍传统的异步复制。MySQL 8 还支持组复制及其衍生的 InnoDB 集群。深入研究组复制的细节超出了本书的范围;然而,讨论仍然普遍适用。关于组复制的详细信息,推荐查尔斯·贝尔(Apress) ( www.apress.com/gp/book/9781484238844 )的《?? 介绍 InnoDB 集群》一书,以及最新更新的 MySQL 参考手册( https://dev.mysql.com/doc/refman/en/group-replication.html )。

本章将首先提供复制的高级概述,目的是介绍将用于复制监控部分的术语和测试设置。本章的另一半讨论了如何提高连接和应用线程的性能,以及如何使用复制将工作转移给副本。

复制概述

在开始提高复制性能之前,讨论一下复制的工作原理是很重要的。这将有助于就术语达成一致,并为本章剩余部分的讨论提供参考点。

Note

传统上,术语被用来描述 MySQL 复制的源和目标。最近,术语已经转向使用单词复制品。同样,在副本上,用于处理复制事件的两种线程类型传统上被称为 I/O 线程SQL 线程,而当前的术语是连接线程应用线程。本书将尽最大可能使用新术语;然而,旧的术语在某些上下文中仍然存在。

复制的工作方式是记录在复制源上所做的更改,然后将这些更改发送到副本,在副本中,连接线程存储数据,一个或多个应用线程应用这些数据。图 26-1 显示了复制的简化概述,省略了与存储引擎和实施细节相关的所有内容。

img/484666_1_En_26_Fig1_HTML.png

图 26-1

复制概述

当事务提交其更改时,这些更改将被写入 InnoDB 特定文件(重做日志和数据文件)和二进制日志。二进制日志由一系列文件和一个索引文件组成,该索引文件列出了二进制日志文件。一旦事件被写入二进制日志文件,它们就被发送到副本服务器。可能有多个副本,在这种情况下,事件会发送到所有副本。

在副本上,连接线程接收事件并将它们写入中继日志。中继日志的工作方式与二进制日志相同,只是它被用作临时存储,直到应用线程可以应用事件。可以有一个或多个施放器螺纹。也可能是副本从多个源复制(称为多源复制),在这种情况下,每个复制通道有一组一个连接线程和一个或多个应用线程。(也就是说,最常见的是每个副本一个源。)可选地,副本将更改写入其自己的二进制日志,这使其能够成为复制链更下游的副本的源。在这种情况下,通常称之为中继实例。图 26-2 显示了一个设置的例子,一个副本从两个源接收更新,其中一个是中继实例。

img/484666_1_En_26_Fig2_HTML.png

图 26-2

具有两个复制流的复制拓扑

这里源 1 复制到中继实例,后者又复制到副本实例。源 2 也复制到副本实例。每个通道都有一个名称来区分它们,在多源复制中,每个通道都必须有一个唯一的名称。默认通道名称是一个空字符串。在讨论监控时,将使用如图所示的复制设置。

监控

当您遇到复制性能问题时,第一步是确定延迟是在前面部分描述的一系列步骤中引入的。如果您已经在 MySQL 的早期版本中使用了复制,您可以跳转到SHOW SLAVE STATUS命令来检查复制的健康状况;然而,在 MySQL 8 中,这是最后一个需要检查的监控信息源。

在 MySQL 8 中,复制监控信息的主要来源是性能模式,它包含几个表,描述副本上每个复制步骤的复制配置和状态。性能模式表的一些优点如下:

  • 状态表包括关于复制延迟的更详细的信息,其形式为时间戳,对于复制过程中的每个步骤具有微秒分辨率,并且具有来自原始和即时源的时间戳。

  • 您可以使用SELECT语句查询这些表。这允许您查询您最感兴趣的信息,并且您可以操纵数据。当您有多个复制通道时,这是一个特别的优势,在这种情况下,当在控制台中检查时,SHOW SLAVE STATUS的输出会很快变得难以使用,因为输出会滚动到屏幕之外。

  • 数据被分成逻辑组,每组一个表。配置和应用流程有单独的表,配置和状态也有单独的表。

Note

SHOW SLAVE STATUS中的Seconds_Behind_Master列传统上用于测量复制延迟。它实际上显示了自事务在原始源上开始以来已经过去了多长时间。这意味着,只有当所有事务都非常快并且没有中继实例时,它才真正起作用。即使这样,它也不提供延迟原因的任何信息。如果您仍然使用Seconds_Behind_Master来监控复制延迟,那么建议您开始切换到性能模式表。

当您第一次开始使用 Performance Schema 复制表时,很难描绘出表之间的关系以及它们与复制流的关系。图 26-3 显示了单个复制通道的复制流程,并添加了与其包含的信息相对应的复制表。图 26-3 中的表格也可用于组复制设置,在这种情况下,group_replication_applier通道用于节点在线时的事务处理,而group_replication_recovery通道用于恢复期间。

img/484666_1_En_26_Fig3_HTML.png

图 26-3

复制过程及其监控表

事件从直接来源到达图的顶部,并由具有两个表replication_connection_configurationreplication_connection_status的连接线程处理。连接线程将事件写入中继日志,应用在应用复制过滤器时从中继日志中读取事件。复制过滤器可以在replication_applier_filtersreplication_applier_global_filters表中找到。总体敷贴器配置和状态可在replication_applier_configurationreplication_applier_status表中找到。

在并行复制的情况下(也称为多线程从属),协调器处理事务,并使它们对工作器可用。可以通过replication_applier_status_by_coordinator表监控协调器。如果副本使用单线程复制,则跳过协调器步骤。

最后一步是应用工人。在并行复制的情况下,每个复制通道有slave_parallel_workers个线程,每个线程在replication_applier_status_by_worker表中都有一行,其中包含其状态。

本节的其余部分将介绍连接和应用的性能模式复制表,以及日志状态和组复制表。

连接表

当复制事件到达复制副本时,第一步是将它们写入中继日志。这是由连接线程处理的。

有两个性能模式表提供与连接相关的信息:

  • replication_connection_configuration : 每个复制通道的配置。

  • replication_connection_status : 复制通道的状态。这包括显示最后一个和当前队列事务最初提交时间、在直接源实例上提交时间以及写入中继日志时间的时间戳。每个通道有一行。

复制连接表包括与到直接上游源的连接相关的信息以及在原始源上提交最新接收的事件时的时间戳。在简单的复制设置中,直接源和原始源是相同的,但是在链式复制中,这两者是不同的。清单 26-1 显示了上一节讨论的复制设置中relay通道的两个连接表的内容示例。输出已被重新格式化,以提高本书的可读性。包含用于source2复制通道的行的原始格式化输出包含在文件listing_26_1.txt中。

mysql> SELECT *
         FROM performance_schema.replication_connection_configuration
        WHERE CHANNEL_NAME = 'relay'\G
*************************** 1\. row ***************************
                 CHANNEL_NAME: relay
                         HOST: 127.0.0.1
                         PORT: 3308
                         USER: root
            NETWORK_INTERFACE:
                AUTO_POSITION: 1
                  SSL_ALLOWED: YES
                  SSL_CA_FILE:
                  SSL_CA_PATH:
              SSL_CERTIFICATE:
                   SSL_CIPHER:
                      SSL_KEY:
SSL_VERIFY_SERVER_CERTIFICATE: NO
                 SSL_CRL_FILE:
                 SSL_CRL_PATH:
    CONNECTION_RETRY_INTERVAL: 60
       CONNECTION_RETRY_COUNT: 86400
           HEARTBEAT_INTERVAL: 30
                  TLS_VERSION:
              PUBLIC_KEY_PATH:
               GET_PUBLIC_KEY: NO
            NETWORK_NAMESPACE:
        COMPRESSION_ALGORITHM: uncompressed
       ZSTD_COMPRESSION_LEVEL: 3
1 row in set (0.0006 sec)

mysql> SELECT *
         FROM performance_schema.replication_connection_status
        WHERE CHANNEL_NAME = 'relay'\G
*************************** 1\. row ***************************
                                    CHANNEL_NAME: relay
                                      GROUP_NAME:
                                     SOURCE_UUID: cfa645e7-b691-11e9-a051-ace2d35785be
                                       THREAD_ID: 44
                                   SERVICE_STATE: ON
                       COUNT_RECEIVED_HEARTBEATS: 26
                        LAST_HEARTBEAT_TIMESTAMP: 2019-08-11 10:26:16.076997
                        RECEIVED_TRANSACTION_SET: 4d22b3e5-a54f-11e9-8bdb-ace2d35785be:23-44
                               LAST_ERROR_NUMBER: 0
                             LAST_ERROR_MESSAGE:
                            LAST_ERROR_TIMESTAMP: 0000-00-00 00:00:00
                         LAST_QUEUED_TRANSACTION: 4d22b3e5-a54f-11e9-8bdb-ace2d35785be:44
 LAST_QUEUED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP: 2019-08-11 10:27:09.483703
LAST_QUEUED_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP: 2019-08-11 10:27:10.158297
     LAST_QUEUED_TRANSACTION_START_QUEUE_TIMESTAMP: 2019-08-11 10:27:10.296164
       LAST_QUEUED_TRANSACTION_END_QUEUE_TIMESTAMP: 2019-08-11 10:27:10.299833

                              QUEUEING_TRANSACTION:
  QUEUEING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP: 0000-00-00 00:00:00
  QUEUEING_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP: 0000-00-00 00:00:00
       QUEUEING_TRANSACTION_START_QUEUE_TIMESTAMP: 0000-00-00 00:00:00
1 row in set (0.0006 sec)

Listing 26-1The replication connection tables

配置表很大程度上对应于您在使用CHANGE MASTER TO语句设置复制时可以给出的选项,并且数据是静态的,除非您显式地更改配置。状态表主要包含随着事件的处理而快速变化的易变数据。

状态表中的时间戳特别重要。有两组,第一组显示最后排队事件的时间戳,第二组显示当前排队事件的时间戳。事件正在排队意味着它正在被写入中继日志。例如,考虑最后一个排队事件的时间戳:

  • LAST_QUEUED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP : 事件在原始源上提交的时间(源 1 )。

  • LAST_QUEUED_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP : 事件在即时源上发生的时间(中继)。

  • LAST_QUEUED_TRANSACTION_START_QUEUE_TIMESTAMP : 此实例开始对事件进行排队的时间,即收到事件并且连接线程开始将事件写入中继日志的时间。

  • LAST_QUEUED_TRANSACTION_END_QUEUE_TIMESTAMP : 连接线程完成将事件写入中继日志的时间。

时间戳的分辨率为微秒级,因此它允许您详细了解事件从原始源到中继日志的时间。零时间戳('0000-00-00 00:00:00')意味着没有要返回的数据;例如,当连接线程完全最新时,这可能发生在当前排队的时间戳上。应用表提供了关于事件在副本中经历的更多细节。

应用表

应用线程更复杂,因为它们都处理事件过滤和应用事件,并且支持并行应用。

在撰写本文时,存在以下包含应用线程信息的性能模式表:

  • replication_applier_configuration : 此表显示了每个复制通道的应用线程的配置。目前唯一的设置是已配置的复制延迟。每个通道有一行。

  • replication_applier_filters : 每个复制通道的复制过滤器。这些信息包括过滤器的配置位置和激活时间。

  • replication_applier_global_filters : 适用于所有复制通道的复制过滤器。这些信息包括过滤器的配置位置和激活时间。

  • replication_applier_status : 申请人的整体状态,包括服务状态、剩余延迟(当配置了所需的延迟时)、事务的重试次数。每个通道有一行。

  • replication_applier_status_by_coordinator : 使用并行复制时,协调器线程看到的应用状态。最后处理的事务和当前处理的事务都有时间戳。每个通道有一行。对于单线程复制,此表为空。

  • replication_applier_status_by_worker : 每个工人的申请人状态。有上一次应用的事务和当前正在应用的事务的时间戳。当配置并行复制时,每个通道有一个工作线程(工作线程的数量用slave_parallel_workers配置)行。对于单线程复制,每个通道有一行。

在高层次上,应用表遵循与连接表相同的模式,增加了过滤器配置表和对并行应用的支持。清单 26-2 显示了relay复制通道的replication_applier_status_by_worker表的内容示例。为了提高可读性,输出已被重新格式化。输出也可以在本书的 GitHub 库的文件listing_26_2.txt中找到。

mysql> SELECT *
         FROM performance_schema.replication_applier_status_by_worker
        WHERE CHANNEL_NAME = 'relay'\G
*************************** 1\. row ***************************
                                           CHANNEL_NAME: relay
                                              WORKER_ID: 1
                                              THREAD_ID: 54
                                          SERVICE_STATE: ON
                                      LAST_ERROR_NUMBER: 0
                                     LAST_ERROR_MESSAGE:
                                    LAST_ERROR_TIMESTAMP: 0000-00-00 00:00:00
                               LAST_APPLIED_TRANSACTION:
     LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP: 0000-00-00 00:00:00
    LAST_APPLIED_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP: 0000-00-00 00:00:00
         LAST_APPLIED_TRANSACTION_START_APPLY_TIMESTAMP: 0000-00-00 00:00:00
           LAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP: 0000-00-00 00:00:00
                                   APPLYING_TRANSACTION:
         APPLYING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP: 0000-00-00 00:00:00
        APPLYING_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP: 0000-00-00 00:00:00
             APPLYING_TRANSACTION_START_APPLY_TIMESTAMP: 0000-00-00 00:00:00
                 LAST_APPLIED_TRANSACTION_RETRIES_COUNT: 0
   LAST_APPLIED_TRANSACTION_LAST_TRANSIENT_ERROR_NUMBER: 0
  LAST_APPLIED_TRANSACTION_LAST_TRANSIENT_ERROR_MESSAGE:
 LAST_APPLIED_TRANSACTION_LAST_TRANSIENT_ERROR_TIMESTAMP: 0000-00-00 00:00:00
                     APPLYING_TRANSACTION_RETRIES_COUNT: 0
       APPLYING_TRANSACTION_LAST_TRANSIENT_ERROR_NUMBER: 0
      APPLYING_TRANSACTION_LAST_TRANSIENT_ERROR_MESSAGE:
     APPLYING_TRANSACTION_LAST_TRANSIENT_ERROR_TIMESTAMP: 0000-00-00 00:00:00

*************************** 2\. row ***************************
                                           CHANNEL_NAME: relay
                                              WORKER_ID: 2
                                              THREAD_ID: 55
                                          SERVICE_STATE: ON
                                      LAST_ERROR_NUMBER: 0
                                     LAST_ERROR_MESSAGE:
                                   LAST_ERROR_TIMESTAMP: 0000-00-00 00:00:00
                               LAST_APPLIED_TRANSACTION: 4d22b3e5-a54f-11e9-8bdb-ace2d35785be:213
     LAST_APPLIED_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP: 2019-08-11 11:29:36.1076
    LAST_APPLIED_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP: 2019-08-11 11:29:44.822024
         LAST_APPLIED_TRANSACTION_START_APPLY_TIMESTAMP: 2019-08-11 11:29:51.910259
           LAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP: 2019-08-11 11:29:52.403051
                                   APPLYING_TRANSACTION: 4d22b3e5-a54f-11e9-8bdb-ace2d35785be:214
         APPLYING_TRANSACTION_ORIGINAL_COMMIT_TIMESTAMP: 2019-08-11 11:29:43.092063
        APPLYING_TRANSACTION_IMMEDIATE_COMMIT_TIMESTAMP: 2019-08-11 11:29:52.685928
             APPLYING_TRANSACTION_START_APPLY_TIMESTAMP: 2019-08-11 11:29:53.141687
                 LAST_APPLIED_TRANSACTION_RETRIES_COUNT: 0
   LAST_APPLIED_TRANSACTION_LAST_TRANSIENT_ERROR_NUMBER: 0
  LAST_APPLIED_TRANSACTION_LAST_TRANSIENT_ERROR_MESSAGE:
LAST_APPLIED_TRANSACTION_LAST_TRANSIENT_ERROR_TIMESTAMP: 0000-00-00 00:00:00
                     APPLYING_TRANSACTION_RETRIES_COUNT: 0
       APPLYING_TRANSACTION_LAST_TRANSIENT_ERROR_NUMBER: 0
      APPLYING_TRANSACTION_LAST_TRANSIENT_ERROR_MESSAGE:
    APPLYING_TRANSACTION_LAST_TRANSIENT_ERROR_TIMESTAMP: 0000-00-00 00:00:00

Listing 26-2The replication_applier_status_by_worker table

时间戳遵循的模式与您之前看到的上次处理的事务和当前事务的信息相同。注意,对于第一行,所有时间戳都是零,这表明应用不能利用并行复制。

对于第二行中最后应用的全局事务标识符为 4d 22 B3 e 5-a54f-11e 9-8 BDB-ace2d 35785 be:213 的事务,可以看到,该事务于 11:29:36.1076 在原始源上提交,于 11:29:44.822024 在即时源上提交,于 11:29:51.910259 开始在该实例上执行,并于 11:29 完成执行这表明每个实例增加了大约 8 秒的延迟,但是事务本身只花了半秒钟就执行了。您可以得出结论,复制延迟不是由应用单个大型事务引起的,而是由于中继和复制副本实例处理事务的速度不如原始源,延迟是由早期长时间运行的事件引起的,复制尚未跟上,或者延迟是由复制链的其他部分引起的。

日志状态

与复制相关的一个表是log_status表,它提供关于二进制日志、中继日志和 InnoDB 重做日志的信息,使用日志锁返回对应于同一时间点的数据。该表是在考虑备份的情况下引入的,因此查询该表需要有BACKUP_ADMIN特权。清单 26-3 展示了一个使用JSON_PRETTY()函数的示例输出,以便于阅读作为 JSON 文档返回的信息。

mysql> SELECT SERVER_UUID,
              JSON_PRETTY(LOCAL) AS LOCAL,
              JSON_PRETTY(REPLICATION) AS REPLICATION,
              JSON_PRETTY(STORAGE_ENGINES) AS STORAGE_ENGINES
         FROM performance_schema.log_status\G
*************************** 1\. row ***************************
    SERVER_UUID: 4d46199b-bbc9-11e9-8780-ace2d35785be
          LOCAL: {
  "gtid_executed": "4d22b3e5-a54f-11e9-8bdb-ace2d35785be:1-380,\ncbffdc28-bbc8-11e9-9aac-ace2d35785be:1-190",
  "binary_log_file": "binlog.000003",
  "binary_log_position": 199154947
}
    REPLICATION: {
  "channels": [
    {
      "channel_name": "relay",
      "relay_log_file": "relay-bin-relay.000006",
      "relay_log_position": 66383736
    },
    {
      "channel_name": "source2",
      "relay_log_file": "relay-bin-source2.000009",
      "relay_log_position": 447
    }
  ]
}
STORAGE_ENGINES: {
  "InnoDB": {
    "LSN": 15688833970,
    "LSN_checkpoint": 15688833970
  }
}
1 row in set (0.0005 sec)

Listing 26-3The log_status table

LOCAL列包含关于已执行的全局事务标识符和二进制日志文件以及在该实例上的位置的信息。REPLICATION列显示与每个通道一个对象的复制过程相关的中继日志数据。STORAGE_ENGINES列包含关于 InnoDB 日志序列号的信息。

组复制表

如果您使用组复制,那么有两个额外的表可以用来监控复制。一个表包含组成员的高级信息,另一个表包含成员的各种统计信息。

这两个表是

  • replication_group_members : 成员的高层概述。每个成员对应一行,数据包括当前状态以及它是主要成员还是次要成员。

  • replication_group_member_stats : 较低级别的统计数据,如队列中的事务数量、所有成员上提交的事务、本地或远程发起的事务数量等等。

replication_group_members表对于验证成员的状态非常有用。replication_group_member_stats表可用于查看每个节点如何看待已经完成的工作,以及是否存在高比率的冲突和回滚。这两个表都包含集群中所有节点的信息。

现在您已经知道如何监控复制,您可以开始优化连接和应用线程了。

这种联系

连接线程处理到直接复制源的出站连接,接收复制事件,并将事件保存到中继日志。这意味着优化连接过程围绕着复制事件、网络、维护关于已收到哪些事件的信息以及写入中继日志。

复制事件

当使用基于行的复制时(默认和推荐),事件包括有关已更改的行和新值(映像之前和之后)的信息。默认情况下,更新和删除事件包括“完成之前”图像。这使得副本可以应用事件,即使源和副本具有不同顺序的列或者具有不同的主键定义。但是,它确实会使二进制日志变得更大,从而也使中继日志变得更大,这意味着更多的网络流量、内存使用和磁盘 I/O。

如果您不需要完整的前图像,您可以将binlog_row_image选项配置为minimalnoblob。值minimal表示只有识别行所需的列包含在之前的图像中,之后的图像只包含被事件改变的列。使用noblob时,除了blobtext列之外的所有列都包含在之前的图像中,而blobtext列只有在它们的值改变时才包含在之后的图像中。使用minimal对性能来说是最佳的,但是要确保在生产系统上做出改变之前进行彻底的测试。

Caution

在生产中进行配置更改之前,确保您已经验证了您的应用可以与binlog_row_image = minimal一起工作。如果应用不使用该设置,将导致复制副本上的复制失败。

还可以在会话范围内设置binlog_row_image选项,因此可以根据需要更改选项。

网络

MySQL 中用于复制的网络的主要调优选项是使用的接口和是否启用压缩。如果网络过载,它会很快使复制落后。避免这种情况的一种方法是为复制流量使用专用的网络接口和路由。另一种选择是启用压缩,这可以减少传输的数据量,但代价是增加 CPU 负载。这两种解决方案都是使用CHANGE MASTER TO命令实现的。

当您定义如何连接到复制源时,您可以使用MASTER_BIND选项来指定用于连接的接口。例如,如果您想使用副本服务器上 IP 地址为 192.0.2.102 的接口从 192.0.2.101 的源进行复制,那么您可以使用MASTER_BIND='192.0.2.102':

CHANGE MASTER TO MASTER_BIND='192.0.2.102',
                 MASTER_HOST='192.0.2.101',
                 MASTER_PORT=3306,
                 MASTER_AUTO_POSITION=1,
                 MASTER_SSL=1;

根据需要替换地址和其他信息。

Caution

不启用 SSL 来提高网络性能可能很诱人。如果您这样做,包括认证信息和您的数据在内的通信将在不加密的情况下传输,任何访问网络的人都可以读取这些数据。因此,对于任何处理生产数据的设置来说,所有通信都是安全的是非常重要的——对于复制来说,这意味着启用 SSL。

MySQL 8.0.18 和更高版本使用MASTER_COMPRESSION_ALGORITHMS选项启用压缩,该选项采用一组允许的算法。支持的算法有

  • uncompressed : 禁用压缩。这是默认设置。

  • zlib : 使用 zlib 压缩算法。

  • zstd : 使用 ztd 1.3 版压缩算法。

如果包含了zstd算法,那么可以使用MASTER_ZSTD_COMPRESSION_LEVEL选项来指定压缩级别。支持的级别为 1–22(包括 1 和 22),默认值为 3。将复制连接配置为使用压缩级别为 5 的zlibzstd算法的示例如下

CHANGE MASTER TO MASTER_COMPRESSION_ALGORITHMS='zlib,zstd',
                 MASTER_ZSTD_COMPRESSION_LEVEL=5;

在 MySQL 8.0.18 之前,您使用slave_compressed_protocol选项指定是否使用压缩。如果源和副本都支持该算法,将选项设置为1ON会使复制连接使用zlib压缩。

Tip

如果您在 MySQL 8.0.18 或更高版本中启用了slave_compressed_protocol选项,它将优先于MASTER_COMPRESSION_ALGORITHMS。建议禁用slave_compressed_protocol并使用CHANGE MASTER TO命令来配置压缩,因为它允许您使用zstd算法,并使压缩配置在replication_connection_configuration性能模式表中可用。

维护来源信息

副本需要跟踪它从源接收到的信息。这是通过mysql.slave_master_info表完成的。也可以将信息存储在文件中,但从 8.0.18 开始,这种做法已被否决,并且不被鼓励。使用文件还会降低副本从崩溃中恢复的弹性。

关于维护这些信息的性能,那么重要的选项是sync_master_info。这指定了信息更新的频率,默认值为每 10000 个事件更新一次。您可能认为与复制的源端的sync_binlog类似,在每个事件之后同步数据是很重要的;然而,事实并非如此。

Caution

没有必要设置sync_master_info = 1,这样做是复制延迟的一个常见来源。

不需要非常频繁地更新信息的原因是,通过丢弃中继日志并从应用到达的点开始获取所有内容,可以从信息丢失中恢复。因此,默认值 10000 是好的,很少有理由更改它。

Tip

复制可以从崩溃中恢复的确切规则非常复杂,并且会随着新改进的加入而不断变化。您可以在 https://dev.mysql.com/doc/refman/en/replication-solutions-unexpected-slave-halt.html 中查看最新信息。

编写中继日志

中继日志是接收复制事件的连接和应用处理它们之间的复制事件的中间存储。主要有两个因素影响中继日志的写入速度:磁盘性能和中继日志同步到磁盘的频率。

您需要确保写入中继日志的磁盘有足够的 I/O 容量来支持读写活动。一种选择是将中继日志存储在单独的存储器上,以便其他活动不会干扰中继日志的写入和读取。

中继日志同步到磁盘的频率由sync_relay_log选项控制,该选项相当于sync_binlog的中继日志。默认设置是每 10000 个事件同步一次。除非对并行应用线程使用基于位置的复制(GTID 禁用或MASTER_AUTO_POSITION=0),否则没有理由更改sync_relay_log的值,因为可以恢复中继日志。对于基于位置的并行复制,您将需要sync_relay_log = 1,除非在操作系统崩溃的情况下重建副本是可以接受的。

这意味着从性能角度来看,建议启用全局事务标识符,并在执行CHANGE MASTER TO时设置MASTER_AUTO_POSITION=1。否则,保留与主信息和中继日志相关的其他设置的默认值。

该施放器

应用是复制滞后的最常见原因。主要问题是,在源上所做的更改通常是高度并行工作负载的结果。相比之下,默认情况下,applier 是单线程的,因此单线程必须跟上数据源上潜在的数十或数百个并发查询。这意味着对抗由应用引起的复制延迟的主要工具是启用并行复制。此外,还将讨论主键的重要性、放宽数据安全设置的可能性以及复制过滤器的使用。

Note

当您为中继日志存储库使用一个表并为mysql.slave_relay_log_info表使用 InnoDB 时,更改sync_relay_log_info设置没有任何效果(两者都是默认的和推荐的)。在这种情况下,该设置实际上被忽略,并且在每次事务处理后更新信息。

并行应用

将应用配置为使用几个线程来并行应用事件是提高复制性能的最有效的方法。然而,这并不像将slave_parallel_workers选项设置为大于 1 的值那么简单。在源和复制副本上还有其他选项需要考虑。

26-1 总结了影响并行复制的配置选项,包括该选项应设置在源上还是副本上。

表 26-1

与并行复制相关的配置选项

|

选项名称和配置位置

|

描述

|
| --- | --- |
| binlog_transaction_dependency_tracking在源上设置 | 要在二进制日志中包含哪些关于事务间依赖关系的信息。 |
| binlog_transaction_dependency_history_size在源上设置 | 上次更新行时信息保留的时间。 |
| transaction_write_set_extraction在源上设置 | 如何提取写集合信息? |
| binlog_group_commit_sync_delay在源上设置 | 等待更多事务在组提交功能中组合在一起的延迟。 |
| slave_parallel_workers在副本上设置 | 为每个通道创建多少个应用线程 |
| slave_parallel_type在副本上设置 | 是通过数据库还是逻辑时钟实现并行化。 |
| slave_pending_jobs_size_max在副本上设置 | 有多少内存可用于保存尚未应用的事件。 |
| slave_preserve_commit_order在副本上设置 | 是否确保复制副本按照与源相同的顺序将事务写入其二进制日志。启用此功能需要将slave_parallel_workers设置为LOGICAL_CLOCK。 |
| slave_checkpoint_group在副本上设置 | 检查点操作之间要处理的最大事务数。 |
| slave_checkpoint_period在副本上设置 | 检查点操作之间的最长时间(毫秒)。 |

最常用的选项是源上的binlog_transaction_dependency_trackingtransaction_write_set_extraction以及副本上的slave_parallel_workersslave_parallel_type

源上的二进制日志事务相关性跟踪和写集提取选项是相关的。transaction_write_set_extraction选项指定如何提取写集合信息(关于哪些行受事务影响的信息)。写集也是组复制用于冲突检测的对象。将此设置为XXHASH64,这也是组复制所需的值。

binlog_transaction_dependency_tracking选项指定二进制日志中有哪些事务依赖信息。这对于并行复制来说非常重要,因为它能够知道哪些事务可以安全地并行应用。默认情况下,使用提交顺序并依赖提交时间戳。为了在根据逻辑时钟进行并行化时提高并行复制性能,请将binlog_transaction_dependency_tracking设置为WRITESET

binlog_transaction_dependency_history_size选项指定了行散列的数量,这些散列提供了关于哪个事务最后修改了给定行的信息。默认值 25000 通常已经足够大了;但是,如果对不同行的修改率非常高,那么增加依赖关系历史记录的大小是值得的。

在副本服务器上,使用slave_parallel_workers选项启用并行复制。这是将为每个复制通道创建的应用工作线程的数量。将这个值设置得足够高,以使复制能够跟上,但不要设置得太高,以至于您最终会有空闲的工作线程,或者您会看到来自过于并行的工作负载的争用。

在副本上更新通常需要的另一个选项是slave_parallel_type选项。这指定了事件应该如何在应用工作器之间拆分。缺省值是DATABASE,顾名思义,它根据更新所属的模式来分割更新。另一种方法是LOGICAL_CLOCK,它使用二进制日志中的组提交信息或写集信息来确定哪些事务一起应用是安全的。除非您有几层副本,并且在二进制日志中不包含写集信息,否则,LOGICAL_CLOCK通常是最佳选择。

如果在未启用写集的情况下使用LOGICAL_CLOCK并行化类型,则可以在源上增加binlog_group_commit_sync_delay,以便在组提交特性中将更多的事务组合在一起,代价是提交延迟更长。这将为并行复制提供更多的事务,以便在工作线程之间进行分配,从而提高效率。

复制滞后的另一个主要原因是缺少主键。

主键

当您使用基于行的复制时,处理事件的应用工作器必须找到必须更改的行。如果有一个主键,这是非常简单和有效的——只需一个主键查找。但是,如果没有主键,则有必要检查所有行,直到找到所有列的值都与复制事件的 before 映像中的值相同的行。

如果表很大,这样的搜索代价很高。如果事务修改了一个相对较大的表中的许多行,在最坏的情况下,它可能会使复制看起来好像已经停止了。MySQL 8 使用了一种优化,它使用哈希来匹配表中的一组行;但是,有效性取决于一个事件中修改的行数,它永远不会像主键查找那样高效。

强烈建议您向所有表添加一个显式主键(或一个非唯一键)。没有主键不会节省磁盘空间或内存,因为如果您自己不添加主键,InnoDB 会添加一个隐藏的主键(不能用于复制)。隐藏主键是一个 6 字节的整数,并使用一个全局计数器,所以如果您有许多带有隐藏主键的表,计数器会成为一个瓶颈。此外,如果您想要使用组复制,严格要求所有表都有一个显式主键或一个 not- NULL惟一索引。

Tip

启用sql_require_primary_key选项,要求所有表都有一个主键。该选项在 MySQL 8.0.13 和更高版本中可用。

如果不能向某些表添加主键,那么每个复制事件中包含的行数越多,哈希搜索算法的效果就越好。通过增加复制的源实例上的binlog_row_event_max_size的大小,可以增加事务处理在同一表中修改大量行时组合在一起的行数。

放松数据安全

当事务被提交时,它必须被保存在磁盘上。在 InnoDB 中,通过重做日志来保证持久性,通过二进制日志来保证复制的持久性。在某些情况下,在副本上放松对更改已被持久化的保证可能是可以接受的。这种优化的代价是,如果操作系统崩溃,您将需要重建副本。

InnoDB 使用选项innodb_flush_log_at_trx_commit来确定每次提交事务时是否刷新重做日志。默认(也是最安全的设置)是在每次提交后刷新(innodb_flush_log_at_trx_commit = 1)。刷新是一项昂贵的操作,甚至一些 SSD 驱动器也难以跟上繁忙系统所需的刷新。如果您能承受丢失一秒钟的已提交事务,您可以将innodb_flush_log_at_trx_commit设置为 0 或 2。如果您愿意进一步推迟刷新,您可以增加innodb_flush_log_at_timeout,它设置刷新重做日志之间的最大时间间隔(以秒为单位)。默认值和最小值是 1 秒。这意味着如果发生灾难性故障,您可能需要重建副本,但好处是应用线程可以比源线程更便宜地提交更改,因此更容易跟上。

二进制日志同样使用sync_binlog选项,该选项也默认为 1,这意味着在每次提交后刷新二进制日志。如果您不需要副本上的二进制日志(注意,对于组复制,必须在所有 notes 上启用二进制日志),您可以考虑完全禁用它,或者降低日志同步的频率。通常,在这种情况下,最好将sync_binlog设置为 100 或 1000,而不是 0,因为 0 通常会导致整个二进制日志在旋转时被一次刷新。刷新 1gb 可能需要几秒钟;与此同时,有一个互斥锁阻止提交事务。

Note

如果放松复制副本上的数据安全设置,确保在将复制副本提升为复制源时(例如,如果需要执行维护),将它们重新设置为更严格的值。

复制筛选器

如果不需要副本上的所有数据,可以使用复制筛选器来减少应用线程所需的工作,并减少磁盘和内存需求。这也有助于副本与源保持同步。有六个选项可以设置复制过滤器。选项可分为三组,一组是选项,一组是忽略选项,如表 26-2 所示。

表 26-2

复制筛选器选项

|

选项名称

|

描述

|
| --- | --- |
| replicate-do-db``replicate-ignore-db | 是否包含作为值给出的模式(数据库)的更改。 |
| replicate-do-table``replicate-ignore-table | 是否包含作为值给出的表的更改。 |
| replicate-wild-do-table``replicate-wild-ignore-table | 类似于replicate-do-tablereplicate-ignore-table选项,但是支持_%通配符,就像编写LIKE子句一样。 |

当您指定其中一个选项时,您可以选择性地在纲要/表格前加上规则应该套用的频道名称和冒号。例如,忽略对source2通道的world模式的更新

[mysqld]
replicate-do-db = source2:world

这些选项只能在 MySQL 配置文件中设置,并且需要重启 MySQL 才能生效。您可以多次指定每个选项来添加多个规则。如果您需要动态地更改配置,您可以使用CHANGE REPLICATION FILTER语句来配置过滤器,例如:

mysql> CHANGE REPLICATION FILTER
              REPLICATE_IGNORE_DB = (world)
              FOR CHANNEL 'source2';
Query OK, 0 rows affected (0.0003 sec)

如果需要包含多个数据库,可以指定一个列表,因此需要用括号将world括起来。如果多次指定同一规则,则适用后者,忽略前者。

Tip

要查看CHANGE REPLICATION FILTER的完整规则,请参见 https://dev.mysql.com/doc/refman/en/change-replication-filter.html

复制筛选器最适合基于行的复制,因为很清楚哪个表受某个事件的影响。当您有一个语句时,该语句可能会影响多个表,因此对于基于语句的复制,并不总是清楚过滤器是否应该允许该语句。应该特别注意replicate-do-dbreplicate-ignore-db,因为对于基于语句的复制,它们使用默认模式来决定是否允许使用语句。更糟糕的是将复制过滤器与行和语句事件混合使用(binlog_format = MIXED),因为过滤器的效果可能取决于变更复制的格式。

Tip

当您使用复制过滤器时,最好使用binlog_format = row(默认值)。有关评估复制过滤器的完整规则,请参见 https://dev.mysql.com/doc/refman/en/replication-rules.html

关于如何提高复制性能的讨论到此结束。还有一个主题与迄今为止讨论的主题相反——如何通过使用副本来提高源的性能。

将工作卸载到副本

如果一个实例因读取查询而过载,提高性能的一个常用策略是将一些工作卸载到一个或多个副本上。一些常见的情况是将副本用于读取扩展,将副本用于报告或备份。本节将对此进行探讨。

Note

使用复制(例如,使用组复制的多主模式)不是一种横向扩展写入的方式,因为所有更改仍必须应用于所有节点。例如,对于写入横向扩展,您需要对数据进行分片,就像在 MySQL NDB 集群中所做的那样。分片解决方案超出了本书的范围。

读取横向扩展

复制最常见的用途之一是允许读取查询使用副本,这样可以减少复制源的负载。这是可能的,因为副本与源具有相同的数据。需要注意的主要事情是,即使在最好的情况下,从在源上提交事务到副本发生更改,也会有一个小的延迟。

如果您的应用对读取陈旧数据很敏感,那么可以选择组复制或 InnoDB 集群,后者在 8.0.14 版和更高版本中支持一致性级别,因此您可以确保应用使用所需的一致性级别。

Tip

为了更好地解释如何使用组复制一致性级别,组复制开发者强烈推荐位于 https://lefred.be/content/mysql-innodb-cluster-consistency-levels/ 的 Lefred 的博客以及博客顶部的博客链接。

使用副本读取还可以帮助您使应用和 MySQL 更接近最终用户,从而减少往返延迟,因此用户可以获得更好的体验。

任务分离

复制副本的另一个常见用途是在复制副本上执行一些高影响任务,以减少复制源上的负载。两个典型的任务是报告和备份。

当您使用复制副本进行报告查询时,您可能会受益于以不同于源的方式配置复制副本,从而针对其所用于的特定工作负载对其进行优化。也可以使用复制筛选器来避免包含来自源的所有数据和更新。更少的数据意味着副本必须应用更少的事务和写入更少的数据,并且您可以将更大比例的数据读入缓冲池。

使用副本进行备份也很常见。如果复制副本专用于备份,那么只要复制副本能够在下一次备份之前赶上,您就不必担心由于磁盘 I/O 或缓冲池污染而导致的锁定和性能下降。您甚至可以考虑在备份过程中关闭副本并执行冷备份。

摘要

本章介绍了复制的工作原理,如何监控和提高复制过程的性能,以及如何使用复制在几个实例之间分配工作。

本章开头提供了复制概述,包括术语介绍,并显示了在何处可以找到复制的监控信息。在 MySQL 8 中,监控复制的最佳方式是使用一系列性能模式表,这些表根据线程类型以及是配置还是状态来划分信息。还有专用于日志状态和组复制的表。

可以通过在复制事件中只包含有关更新行的 before 值的最少信息来减小复制事件的大小,从而优化连接线程。然而,这并不适用于所有的应用。您还可以对网络和中继日志的编写进行更改。建议使用启用了自动定位的基于 GTID 的复制,这允许您放松中继日志的同步。

对应用性能最重要的两件事是启用并行复制和确保所有表都有一个主键。并行复制可以通过更新影响的模式进行,也可以通过逻辑时钟进行。后者通常表现最佳,但也有例外,因此您需要根据您的工作负载进行验证。

最后,我们讨论了如何使用副本来分担原本必须在复制源上执行的工作。您可以将复制用于读取扩展,因为您可以将副本用于读取数据,并将源专用于需要写入数据的任务。您还可以将副本用于高度密集的工作,如报告和备份。

最后一章将通过使用缓存来减少工作量。

二十七、缓存

最便宜的查询是那些您根本不执行的查询。本章研究如何使用缓存来避免执行查询或降低查询的复杂性。首先,我们将讨论高速缓存是如何无处不在,以及如何存在不同类型的高速缓存。然后讲述了如何使用缓存表和近似值在 MySQL 中使用缓存。接下来的两节考虑了两个提供缓存的流行产品: MemcachedProxySQL 。最后,讨论了一些缓存技巧。

缓存无处不在

即使您认为您没有实现缓存,您也已经在几个地方使用了缓存。这些缓存是透明的,在硬件、操作系统或 MySQL 级别维护。这些缓存中最明显的是 InnoDB 缓冲池。

27-1 展示了高速缓存如何存在于整个系统中的例子,以及如何添加自定义高速缓存的例子。这幅图——包括交互——绝不是完整的,但它足以说明缓存是多么普遍,以及它可以在多少地方出现。

img/484666_1_En_27_Fig1_HTML.jpg

图 27-1

可以进行缓存的示例

左下角是 CPU,它有几个级别的缓存,缓存用于 CPU 指令的指令和数据。操作系统实现了一个 I/O 缓存,InnoDB 有自己的缓冲池。所有这些缓存都是返回最新数据的缓存的例子。

还有一些缓存可能会提供稍微陈旧的数据。这包括在 MySQL 中实现缓存表,在 ProxySQL 中缓存查询结果,或者直接在应用中缓存数据。在这些情况下,您通常会定义一个时间段来考虑数据是否足够新,当它达到给定的年龄时—生存时间(TTL)——缓存条目就会失效。Memcached 解决方案很特别,因为它有两个版本。常规的 Memcached 守护进程使用生存时间或一些依赖于应用的逻辑来清除太旧的数据;然而,还有一个特殊的 MySQL 版本,它作为一个插件工作,可以从 InnoDB 缓冲池中获取数据,并将数据写回缓冲池,因此数据永远不会过时。

在应用中使用可能过期的数据似乎是错误的。然而,在许多情况下,这完全没问题,因为不需要精确的数据。如果您有一个显示销售数字仪表板的应用,如果数据是执行查询时的最新数据还是几分钟前的数据,会有多大的区别?当用户读完这些图时,它们可能已经有点过时了。重要的是销售数字是一致的,并且定期更新。

Tip

仔细考虑您的应用的要求是什么,并记住,与说服用户他们不再能得到最新的结果相比,从放宽对数据必须是最新的要求开始更容易,如果需要的话,可以变得更严格。如果使用不会自动更新为最新值的缓存数据,可以考虑存储数据的当前时间并显示给用户,这样用户就知道数据上次刷新的时间。

接下来的三个部分将介绍更多具体的缓存示例,从在 MySQL 中实现自己的缓存开始。

MySQL 内部的缓存

实现缓存的逻辑位置是在 MySQL 内部。如果缓存的数据与其他表一起使用,这尤其有用。缺点是它仍然需要从应用到数据库的往返来查询数据,并且需要执行查询。本节介绍了在 MySQL 中缓存数据的两种方法:缓存表和直方图统计。

缓存表

缓存表可用于预先计算数据,例如,用于报告或仪表板。它主要用于经常需要的复杂聚合。

有几种方法可以使用缓存表。您可以选择创建一个表来存储与其配合使用的要素的结果。这使得它使用起来很便宜,但也相对不灵活,因为它只能用于这一个功能。或者,您可以创建需要连接在一起的构建块,以便它们可以用于多种功能。这使得查询稍微贵了一点,但是您可以重用缓存的数据并避免复制数据。这取决于您的应用,哪种方法是最好的,您可能最终会选择一种混合方法,其中一些表可以单独使用,而其他的表可以结合在一起使用。

填充缓存表有两种主要策略。您可以定期完全重建表,也可以使用触发器持续更新数据。完全重建表的最佳方式是创建缓存表的新副本,并在重建结束时使用RENAME TABLE交换表,因为这样可以避免删除事务中潜在的大量行,并避免碎片随着时间的推移而累积。或者,当缓存数据所依赖的数据发生变化时,您可以使用触发器来更新缓存数据。如果使用不完全最新的数据是可以接受的,则在大多数情况下,重建缓存表是首选,因为这样不容易出错,并且刷新是在后台完成的。

Tip

如果通过删除事务中的现有数据来就地重建缓存表,那么要么禁用索引统计信息的自动重新计算,并在重建结束时使用ANALYZE TABLE,要么启用innodb_stats_include_delete_marked选项。

一种特殊的情况是包含在不缓存数据的表中的缓存列。缓存列很有用的一个例子是存储属于某个组的最新事件的时间、状态或 id。假设您的应用支持发送文本消息,并且您为每条消息存储了历史记录,例如它在应用中的创建时间、发送时间以及接收者确认消息的时间。在大多数情况下,只需要最新的状态和到达状态的时间,因此您可能希望将其与消息记录本身一起存储,而不是必须显式地查询它。在这种情况下,您可以使用两个表来存储状态:

CREATE TABLE message (
  message_id bigint unsigned NOT NULL auto_increment,
  message_text varchar(1024) NOT NULL,
  cached_status_time datetime(3) NOT NULL,
  cached_status_id tinyint unsigned NOT NULL,
  PRIMARY KEY (message_id)
);

CREATE TABLE message_status_history (
  message_status_id bigint unsigned NOT NULL auto_increment,
  message_id bigint unsigned NOT NULL,
  status_time datetime(3) NOT NULL,
  status_id tinyint unsigned NOT NULL,
  PRIMARY KEY (message_status_id)
);

在现实世界中,可能有更多的列和外键,但是对于这个例子,这些信息就足够了。当消息的状态改变时,会在message_status_history表中插入一行。您可以查找消息的最新行来找到最新状态,但是这里已经创建了一个业务规则来用最新状态和更改时间更新消息表中的cached_status_timecached_status_id。这样,要返回到消息的应用细节(除非需要历史记录),您只需要查询message表。您可以通过应用或触发器更新缓存的列,或者如果您不需要缓存的状态完全是最新的,您可以使用后台作业。

Tip

使用一种命名方案,明确哪些数据被缓存,哪些没有被缓存。例如,您可以用cached_作为缓存表和列的前缀。

另一个可以考虑缓存的例子是直方图统计。

直方图统计

回想一下第 16 章,直方图统计是对一列中每个值出现的频率的统计。您可以利用这一点,将直方图统计用作缓存。如果一个列最多有 1024 个唯一值,这是非常有用的,因为这是支持的最大存储桶数,所以 1024 是可用于单一直方图的最大值数。

清单 27-1 展示了一个使用直方图返回world数据库中印度(CountryCode = IND)城市数量的例子。

-- Create the histogram on the CountryCode
-- column of the world.city table.
mysql> ANALYZE TABLE world.city
        UPDATE HISTOGRAM on CountryCode
          WITH 1024 BUCKETS\G
*************************** 1\. row ***************************
   Table: world.city
      Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'CountryCode'.
1 row in set (0.5909 sec)

mysql> SELECT Bucket_Value, Frequency
         FROM (
           SELECT (Row_ID - 1) AS Bucket_Number,
                  SUBSTRING_INDEX(Bucket_Value, ':', -1)
                     AS Bucket_Value,
                  (Cumulative_Frequency
                   - LAG(Cumulative_Frequency, 1, 0)
                         OVER (ORDER BY Row_ID))
                     AS Frequency

             FROM information_schema.COLUMN_STATISTICS
                  INNER JOIN JSON_TABLE(
                     histogram->'$.buckets',
                     '$[*]' COLUMNS(
                          Row_ID FOR ORDINALITY,
                          Bucket_Value varchar(42) PATH '$[0]',
                          Cumulative_Frequency double PATH '$[1]'
                     )
                  ) buckets
            WHERE SCHEMA_NAME = 'world'
                  AND TABLE_NAME = 'city'
                  AND COLUMN_NAME = 'CountryCode'
         ) stats
        WHERE Bucket_Value = 'IND';
+--------------+---------------------+
| Bucket_Value | Frequency           |
+--------------+---------------------+
| IND          | 0.08359892130424124 |
+--------------+---------------------+
1 row in set (0.0102 sec)

mysql> SELECT TABLE_ROWS
         FROM information_schema.TABLES
        WHERE TABLE_SCHEMA = 'world'
              AND TABLE_NAME = 'city';
+------------+
| TABLE_ROWS |
+------------+
|       4188 |
+------------+
1 row in set (0.0075 sec)

mysql> SELECT 0.08359892130424124*4188;
+--------------------------+
| 0.08359892130424124*4188 |
+--------------------------+
|    350.11228242216231312 |
+--------------------------+
1 row in set (0.0023 sec)

mysql> SELECT COUNT(*)
         FROM world.city
        WHERE CountryCode = 'IND';
+----------+
| COUNT(*) |
+----------+
|      341 |
+----------+
1 row in set (0.0360 sec)

Listing 27-1Using histograms as a cache

如果您认为对COLUMN_STATITICS的查询看起来很熟悉,那么它是从第 16 章中列出单一直方图的存储桶信息时使用的查询派生而来的。有必要在子查询中收集直方图信息,否则无法计算频率。

您还需要总行数。您可以使用来自information_schema.TABLES视图的近似值,或者缓存表格的SELECT COUNT(*)结果。在这个例子中,估计city表有 4188 行(您的估计可能不同),加上印度的频率,表明表中大约有 350 个印度城市。精确的计算显示有 341 个。偏差来自于总行数估计值(在city表中有 4079 行)。

对于包含最多 1024 个唯一值的大型表,使用直方图作为缓存非常有用,尤其是在该列上没有索引的情况下。这意味着它并不匹配所有的用例。然而,它确实展示了一个跳出框框思考的例子——当您试图寻找缓存解决方案时,这是非常有用的。

对于更高级的缓存解决方案,您需要查看第三方解决方案或在应用中实现自己的解决方案。

Memcached

Memcached 是一个简单但高度可伸缩的内存键值存储,是一种流行的缓存工具。传统上,它主要用于 web 服务器,但也可以用于任何类型的应用。Memcached 的一个优点是它可以分布在多个主机上,这允许您创建一个大的缓存。

Note

Memcached 只在 Linux 和 Unix 上得到官方支持。

在 MySQL 中使用 Memcached 有两种方法。您可以使用常规的独立 Memcached,也可以使用 MySQL InnoDB Memcached 插件。本节将展示一个使用这两者的简单示例。有关完整的 Memcached 文档,请参见位于 https://memcached.org/ 的官方主页和位于 https://github.com/memcached/memcached/wiki 的官方 wiki。

独立 Memcached

独立的 Memcached 是来自 https://memcached.org/ 的官方守护进程。它允许您将其用作分布式缓存,或者让缓存非常靠近应用(可能在同一台主机上),从而降低查询缓存的成本。

安装 Memcached 有几个选项,包括使用操作系统的包管理器和从源代码编译。最简单的是在 Oracle Linux、Red Hat Enterprise Linux 和 CentOS 7 上使用您的软件包管理器:

shell$ sudo yum install memcached libevent

根据memcached的要求,包含了libevent包。在 Ubuntu Linux 上,这个包叫做libevent-dev。你可能已经安装了libevent和/或memcached,在这种情况下,软件包管理器会让你知道没什么可做的。

您可以使用memcached命令启动守护进程。例如,使用所有默认选项启动它

shell$ memcached

如果您在生产中使用它,您应该配置systemd或您正在使用的任何服务管理器,以便在操作系统启动和关闭时启动和停止守护程序。对于测试来说,只从命令行启动就可以了。

Caution

Memcached 中没有安全支持。将缓存的数据限制为不敏感的数据,并确保 Memcached 实例只在内部网络中可用,并使用防火墙来限制访问。一种选择是将 Memcached 部署在与应用相同的主机上,并阻止远程连接。

现在,您可以通过将从 MySQL 检索的数据存储在缓存中来使用 Memcached。有几种编程语言支持 Memcached。对于这个讨论,Python 将与pymemcache模块 1 和 MySQL 连接器/Python 一起使用。清单 27-2 显示了如何使用pip安装模块。根据您正在使用的 Python 的确切版本和您已经安装的内容,输出可能会有所不同,Python 命令的名称取决于您的系统。在撰写本文时,pymemcache支持 Python 2.7、3.5、3.6 和 3.7。该示例使用作为额外软件包安装在 Oracle Linux 7 上的 Python 3.6。

shell$ python3 -m pip install --user pymemcache
Collecting pymemcache
  Downloading https://files.pythonhosted.org/packages/20/08/3dfe193f9a1dc60186fc40d41b7dc59f6bf2990722c3cbaf19cee36bbd93/pymemcache-2.2.2-py2.py3-none-any.whl (44kB)
     |████████████████████████████████| 51kB 3.3MB/s
Requirement already satisfied: six in /usr/local/lib/python3.6/site-packages (from pymemcache) (1.11.0)
Installing collected packages: pymemcache
Successfully installed pymemcache-2.2.2

shell$ python36 -m pip install --user mysql-connector-python
Collecting mysql-connector-python
  Downloading https://files.pythonhosted.org/packages/58/ac/a3e86e5df84b818f69ebb8c89f282efe6a15d3ad63a769314cdd00bccbbb/mysql_connector_python-8.0.17-cp36-cp36m-manylinux1_x86_64.whl (13.1MB)
     |████████████████████████████████| 13.1MB 5.6MB/s
Requirement already satisfied: protobuf>=3.0.0 in /usr/local/lib64/python3.6/site-packages (from mysql-connector-python) (3.6.1)
Requirement already satisfied: setuptools in /usr/local/lib/python3.6/site-packages (from protobuf>=3.0.0->mysql-connector-python) (39.0.1)
Requirement already satisfied: six>=1.9 in /usr/local/lib/python3.6/site-packages (from protobuf>=3.0.0->mysql-connector-python) (1.11.0)
Installing collected packages: mysql-connector-python
Successfully installed mysql-connector-python-8.0.17

Listing 27-2Installing the Python pymemcache module

在您的应用中,您可以通过键查询 Memcached。如果找到了键,Memcached 返回与键一起存储的值,如果没有找到,您需要查询 MySQL 并将结果存储在缓存中。清单 27-3 展示了一个查询world.city表的简单例子。该程序也可以在本书的 GitHub 库中包含的文件listing_27_3.py中找到。如果你想执行这个程序,你需要更新connect_args中的连接参数来反映连接到你的 MySQL 实例的设置。

from pymemcache.client.base import Client
import mysql.connector

connect_args = {
    "user": "root",
    "password": "password",
    "host": "localhost",
    "port": 3306,
}
db = mysql.connector.connect(**connect_args)
cursor = db.cursor()
memcache = Client(("localhost", 11211))

sql = "SELECT CountryCode, Name FROM world.city WHERE ID = %s"
city_id = 130
city = memcache.get(str(city_id))
if city is not None:
    country_code, name = city.decode("utf-8").split("|")
    print("memcached: country: {0} - city: {1}".format(country_code, name))
else:
    cursor.execute(sql, (city_id,))
    country_code, name = cursor.fetchone()
    memcache.set(str(city_id), "|".join([country_code, name]), expire=60)
    print("MySQL: country: {0} - city: {1}".format(country_code, name))

memcache.close()
cursor.close()
db.close()

Listing 27-3Simple Python program using memcached and MySQL

该程序首先创建一个到 MySQL 和memcached守护进程的连接。在这种情况下,要查询的连接参数和 id 是硬编码的。在真实的程序中,您应该从配置文件或类似文件中读取连接参数。

Caution

不要在应用中存储连接详细信息。尤其不要硬编码密码。在应用中存储连接细节既不灵活也不安全。

然后程序尝试从 Memcached 中获取数据;请注意,当 Memcached 使用字符串作为键时,整数是如何转换为字符串的。如果找到了键,则通过在|字符处拆分字符串,从缓存的值中提取国家代码和名称。如果在缓存中找不到该键,则从 MySQL 获取城市数据并存储在缓存中,将缓存中的值保持为 60 秒。为每个案例添加了打印语句,以显示数据是从哪里获取的。

每次重启memcached后第一次执行程序时,它会查询 MySQL:

shell$ python3 listing_27_3.py
MySQL: country: AUS - city: Sydney

在长达一分钟的后续执行中,将在缓存中找到数据:

shell$ python3 listing_27_3.py
memcached: country: AUS - city: Sydney

当您测试完 Memcached 后,您可以在运行memcached的会话中使用 Ctrl+C 停止它,或者向它发送一个SIGTEM (15)信号,例如:

shell$ kill -s SIGTERM $(pidof memcached)

在这个例子中直接使用 Memcached 的好处是,您可以拥有一个守护进程池,并且可以在靠近应用的地方运行守护进程,甚至可以在与应用相同的主机上运行。缺点是您必须自己维护缓存。另一种方法是使用 MySQL 提供的memcached插件,它将为您管理缓存,甚至自动将写入保存到缓存中。

MySQL InnoDB Memcached 插件

MySQL 5.6 中引入了 InnoDB Memcached 插件,作为一种无需解析 SQL 语句的开销就能访问 InnoDB 数据的方法。该插件的主要用途是让 InnoDB 通过缓冲池处理缓存,并使用 Memcached 作为查询数据的机制。以这种方式使用插件的一些好处是,对插件的写入被写入底层 InnoDB 表,数据总是最新的,并且您可以同时使用 SQL 和 Memcached 来访问数据。

Note

在安装 MySQL InnoDB Memcached 插件之前,请确保您已经停止了独立的 Memcached 进程,因为它们默认使用相同的端口。如果不这样做,您将继续连接到独立进程。

在安装 MySQL memcached守护进程之前,必须确保像独立 Memcached 安装一样安装libevent包。一旦安装了libevent,就需要安装innodb_memcache模式,其中包括用于配置的表。您可以通过获取 MySQL 发行版中包含的share/innodb_memcached_config.sql文件来执行安装。该文件相对于 MySQL 基本目录,可以通过系统变量basedir找到,例如:

mysql> SELECT @@global.basedir AS basedir;
+---------+
| basedir |
+---------+
| /usr/   |
+---------+
1 row in set (0.00 sec)

如果您已经使用来自 https://dev.mysql.com/downloads/ 的 RPM 安装了 MySQL,命令是

mysql> SOURCE /usr/share/mysql-8.0/innodb_memcached_config.sql

Note

请注意,该命令在 MySQL Shell 中不起作用,因为该脚本包含不带分号的USE命令,而 MySQL Shell 在脚本中不支持分号。

该脚本还创建了test.demo_test表,该表将在接下来的讨论中使用。

innodb_memcache模式由三个表组成:

  • cache_policies : 定义缓存应该如何工作的缓存策略的配置。默认情况下,将它留给 InnoDB。这通常是推荐的方法,可以确保您永远不会读取过时的数据。

  • config_options : 插件的配置选项。这包括在为值和表映射分隔符返回多个列时使用哪个分隔符。

  • containers : 映射到 InnoDB 表的定义。您必须为所有想要与 InnoDB memcached插件一起使用的表添加一个映射。

containers桌是你会用的最多的桌子。默认情况下,该表包含一个对test.demo_test表的映射:

mysql> SELECT * FROM innodb_memcache.containers\G
*************************** 1\. row ***************************
                  name: aaa
             db_schema: test
              db_table: demo_test
           key_columns: c1
         value_columns: c2
                 flags: c3
            cas_column: c4
    expire_time_column: c5
unique_idx_name_on_key: PRIMARY
1 row in set (0.0007 sec)

在查询表格时,您可以使用name来引用由db_schemadb_table定义的表格。key_columns列定义了 InnoDB 表中用于键查找的列。您可以在value_columns列中指定希望包含在查询结果中的列。如果包含多列,则使用在config_options表中带有name = separator的行中配置的分隔符(默认为|)来分隔列名。

很少需要cas_columnexpire_time_column列,这里不再进一步讨论。最后一列unique_idx_name_on_key是表中唯一索引的名称,最好是主键。

Tip

这些表格的详细描述及其用途可以在 https://dev.mysql.com/doc/refman/en/innodb-memcached-internals.html 中找到。

您现在已经准备好安装插件本身了。您可以使用INSTALL PLUGIN命令来完成(记住这在 Windows 上不起作用):

mysql> INSTALL PLUGIN daemon_memcached soname "libmemcached.so";
Query OK, 0 rows affected (0.09 sec)

这个语句必须使用传统的 MySQL 协议(默认端口 3306)来执行,因为 X 协议(默认端口 33060)不允许您安装插件。就这样——InnoDBmemcached插件现在已经准备好测试了。最简单的测试方法是使用telnet客户端。清单 27-4 显示了一个显式指定容器并使用默认容器的简单例子。

shell$ telnet localhost 11211
Trying ::1...
Connected to localhost.
Escape character is '^]'.

get @@aaa.AA
VALUE @@aaa.AA 8 12
HELLO, HELLO
END

get AA
VALUE AA 8 12
HELLO, HELLO
END

Listing 27-4Testing InnoDB memcached with telnet

为了便于查看这两个命令,在每个命令之前都插入了一个空行。第一个命令使用@@在键值前指定容器名。第二个命令依赖于使用默认容器的 Memcached(按容器名的字母升序排序时的第一个条目)。您可以通过按 Ctrl+]然后按quit命令退出 telnet:

^]
telnet> quit
Connection closed.

默认情况下,守护进程使用端口 11211 作为独立的 Memcached 实例。如果您想更改端口或任何其他 Memcached 选项,您可以使用daemon_memcached_option选项,该选项带有一个带有memcached选项的字符串。例如,将端口设置为 22222

[mysqld]
daemon_memcached_option = "-p22222"

该选项只能在 MySQL 配置文件或命令行中设置,因此需要重启 MySQL 才能使更改生效。

如果您向containers表添加新条目或更改现有条目,您将需要重启memcached插件,使其再次读取定义。您可以通过重启 MySQL 或卸载并安装插件来实现:

mysql> UNINSTALL PLUGIN daemon_memcached;
Query OK, 0 rows affected (4.05 sec)

mysql> INSTALL PLUGIN daemon_memcached soname "libmemcached.so";
Query OK, 0 rows affected (0.02 sec)

实际上,你将主要使用应用中的插件。如果您习惯于使用 Memcached,用法很简单。作为一个例子,考虑清单 27-5 ,它展示了一些使用pymemcache模块的 Python 命令。请注意,该示例假设您已经将端口重新设置为 11211。

shell$ python3
Python 3.6.8 (default, May 16 2019, 05:58:38)
[GCC 4.8.5 20150623 (Red Hat 4.8.5-36.0.1)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from pymemcache.client.base import Client
>>> client = Client(('localhost', 11211))
>>> client.get('@@aaa.AA')
b'HELLO, HELLO'
>>> client.set('@@aaa.BB', 'Hello World')
True
>>> client.get('@@aaa.BB')
b'Hello World'

Listing 27-5Using the InnoDB memcached plugin with Python

交互式 Python 环境用于通过memcached插件查询test.demo_test表。创建连接后,使用get()方法查询现有行,并使用set()方法插入新行。在这种情况下,不需要设置超时,因为set()方法最终会直接写入 InnoDB。最后,再次检索新行。请注意,与需要自己维护缓存的常规 Memcached 相比,这个示例是多么简单。

您可以通过在 MySQL 中查询来验证新行是否真的插入到表中:

mysql> SELECT * FROM test.demo_test;
+----+--------------+----+----+----+
| c1 | c2           | c3 | c4 | c5 |
+----+--------------+----+----+----+
| AA | HELLO, HELLO |  8 |  0 |  0 |
| BB | Hello World  |  0 |  1 |  0 |
+----+--------------+----+----+----+
2 rows in set (0.0032 sec)

使用 MySQL InnoDB Memcached 插件还有更多内容。如果您打算使用它,建议您在 https://dev.mysql.com/doc/refman/en/innodb-memcached.html 阅读参考手册中的“InnoDB memcached 插件”部分。

另一个支持缓存的流行工具是 ProxySQL。

ProxySQL

ProxySQL 项目 2 由 René Cannaò创建,是一个高级代理,支持负载平衡、基于查询规则的路由、缓存等。缓存功能基于查询规则进行缓存,例如,您可以设置想要缓存具有给定摘要的查询。根据您为查询规则设置的生存时间值,缓存会自动过期。

你从 https://github.com/sysown/proxysql/releases/ 下载 ProxySQL。在撰写本文时,最新的版本是 2.0.8 版,这是示例中使用的版本。

Note

ProxySQL 仅正式支持 Linux。有关支持的发行版的完整文档,包括安装说明,请参见 https://github.com/sysown/proxysql/wiki

清单 27-6 展示了使用 ProxySQL GitHub 存储库中的 RPM 在 Oracle Linux 上安装 ProxySQL 2.0.8 的示例。在其他 Linux 发行版上,安装过程是类似的,使用的是发行版的 package 命令(当然,根据使用的 package 命令,输出会有所不同)。安装完成后,将启动 ProxySQL。

shell$ wget https://github.com/sysown/proxysql/releases/download/v2.0.8/proxysql-2.0.8-1-centos7.x86_64.rpm
...
Length: 9340744 (8.9M) [application/octet-stream]
Saving to: 'proxysql-2.0.8-1-centos7.x86_64.rpm'

100%[===========================>] 9,340,744   2.22MB/s   in 4.0s

2019-11-24 18:41:34 (2.22 MB/s) - 'proxysql-2.0.8-1-centos7.x86_64.rpm' saved [9340744/9340744]

shell$ sudo yum install proxysql-2.0.8-1-centos7.x86_64.rpm
Loaded plugins: langpacks, ulninfo
Examining proxysql-2.0.8-1-centos7.x86_64.rpm: proxysql-2.0.8-1.x86_64
Marking proxysql-2.0.8-1-centos7.x86_64.rpm to be installed
Resolving Dependencies
--> Running transaction check
---> Package proxysql.x86_64 0:2.0.8-1 will be installed
--> Finished Dependency Resolution

Dependencies Resolved

==============================================================
 Package  Arch   Version Repository                       Size
==============================================================
Installing:
 proxysql x86_64 2.0.8-1 /proxysql-2.0.8-1-centos7.x86_64  35 M

Transaction Summary
==============================================================
Install  1 Package

Total size: 35 M
Installed size: 35 M
Is this ok [y/d/N]: y
Downloading packages:
Running transaction check
Running transaction test
Transaction test succeeded
Running transaction
  Installing : proxysql-2.0.8-1.x86_64                    1/1
warning: group proxysql does not exist - using root
warning: group proxysql does not exist - using root
Created symlink from /etc/systemd/system/multi-user.target.wants/proxysql.service to /etc/systemd/system/proxysql.service.
  Verifying  : proxysql-2.0.8-1.x86_64                    1/1

Installed:
  proxysql.x86_64 0:2.0.8-1

Complete!

shell$ sudo systemctl start proxysql

Listing 27-6Installing and starting ProxySQL

您只能通过其管理界面来配置 ProxySQL。这使用了mysql命令行客户端,对 MySQL 管理员来说有一种熟悉的感觉。默认情况下,ProxySQL 使用端口 6032 作为管理接口,管理员用户名为admin,密码设置为admin。清单 27-7 展示了一个连接到管理界面并列出可用模式和表的例子。

shell$ mysql --host=127.0.0.1 --port=6032 \
             --user=admin --password \
             --default-character-set=utf8mb4 \
             --prompt='ProxySQL> '
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 1
Server version: 5.5.30 (ProxySQL Admin Module)

Copyright (c) 2000, 2019, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

ProxySQL> SHOW SCHEMAS;
+-----+---------------+-------------------------------------+
| seq | name          | file                                |
+-----+---------------+-------------------------------------+
| 0   | main          |                                     |
| 2   | disk          | /var/lib/proxysql/proxysql.db       |
| 3   | stats         |                                     |
| 4   | monitor       |                                     |
| 5   | stats_history | /var/lib/proxysql/proxysql_stats.db |
+-----+---------------+-------------------------------------+
5 rows in set (0.00 sec)

ProxySQL> SHOW TABLES;
+--------------------------------------------+
| tables                                     |
+--------------------------------------------+
| global_variables                           |
| mysql_aws_aurora_hostgroups                |
| mysql_collations                           |
| mysql_galera_hostgroups                    |
| mysql_group_replication_hostgroups         |
| mysql_query_rules                          |
| mysql_query_rules_fast_routing             |
| mysql_replication_hostgroups               |
| mysql_servers                              |
| mysql_users                                |
| proxysql_servers                           |
| runtime_checksums_values                   |
| runtime_global_variables                   |
| runtime_mysql_aws_aurora_hostgroups        |
| runtime_mysql_galera_hostgroups            |
| runtime_mysql_group_replication_hostgroups |
| runtime_mysql_query_rules                  |
| runtime_mysql_query_rules_fast_routing     |
| runtime_mysql_replication_hostgroups       |
| runtime_mysql_servers                      |
| runtime_mysql_users                        |
| runtime_proxysql_servers                   |
| runtime_scheduler                          |
| scheduler                                  |
+--------------------------------------------+
24 rows in set (0.00 sec)

Listing 27-7The administration interface

当表在模式中分组时,您可以直接访问表,而无需引用模式。SHOW TABLES的输出显示了main模式中与 ProxySQL 的配置相关联的表。

配置过程分为两个阶段,首先准备新配置,然后应用它。应用更改意味着将它们保存到磁盘,如果您想要持久化它们并将其加载到运行时线程中的话。

名称中带有runtime_前缀的表是用于推送到运行时线程的配置的。配置 ProxySQL 的一种方式是使用类似于在 MySQL 中设置系统变量的SET语句,但是您也可以使用UPDATE语句。第一步应该是更改管理员密码(也可以选择管理员用户名),这可以通过设置清单 27-8 中所示的admin-admin_credentials变量来完成。

ProxySQL> SET admin-admin_credentials = 'admin:password';
Query OK, 1 row affected (0.01 sec)

ProxySQL> SAVE ADMIN VARIABLES TO DISK;
Query OK, 32 rows affected (0.02 sec)

ProxySQL> LOAD ADMIN VARIABLES TO RUNTIME;
Query OK, 0 rows affected (0.00 sec)

ProxySQL> SELECT @@admin-admin_credentials;
+---------------------------+
| @@admin-admin_credentials |
+---------------------------+
| admin:password            |
+---------------------------+
1 row in set (0.00 sec)

Listing 27-8Setting the password for the administrator account

admin-admin_credentials选项的值是用冒号分隔的用户名和密码。SAVE ADMIN VARIABLES TO DISK语句保存更改,LOAD ADMIN VARIABLES TO RUNTIME命令将更改应用到运行时线程。有必要将变量加载到运行时线程中,因为出于性能原因,ProxySQL 会在每个线程中保存变量的副本。您可以像在 MySQL 中查询系统变量一样查询当前值(无论是已应用的还是待定的)。

您可以配置 MySQL 后端实例,ProxySQL 可以使用这些实例来定向mysql_servers表中的查询。对于此讨论,将使用与 ProxySQL 在同一主机上的单个实例。清单 27-9 展示了如何将它添加到 ProxySQL 可以路由到的服务器列表中。

ProxySQL> SHOW CREATE TABLE mysql_servers\G
*************************** 1\. row ***************************
       table: mysql_servers
Create Table: CREATE TABLE mysql_servers (
    hostgroup_id INT CHECK (hostgroup_id>=0) NOT NULL DEFAULT 0,
    hostname VARCHAR NOT NULL,
    port INT CHECK (port >= 0 AND port <= 65535) NOT NULL DEFAULT 3306,
    gtid_port INT CHECK (gtid_port <> port AND gtid_port >= 0 AND gtid_port <= 65535) NOT NULL DEFAULT 0,
    status VARCHAR CHECK (UPPER(status) IN ('ONLINE','SHUNNED','OFFLINE_SOFT', 'OFFLINE_HARD')) NOT NULL DEFAULT 'ONLINE',
    weight INT CHECK (weight >= 0 AND weight <=10000000) NOT NULL DEFAULT 1,
    compression INT CHECK (compression IN(0,1)) NOT NULL DEFAULT 0,
    max_connections INT CHECK (max_connections >=0) NOT NULL DEFAULT 1000,
    max_replication_lag INT CHECK (max_replication_lag >= 0 AND max_replication_lag <= 126144000) NOT NULL DEFAULT 0,
    use_ssl INT CHECK (use_ssl IN(0,1)) NOT NULL DEFAULT 0,
    max_latency_ms INT UNSIGNED CHECK (max_latency_ms>=0) NOT NULL DEFAULT 0,
    comment VARCHAR NOT NULL DEFAULT ",
    PRIMARY KEY (hostgroup_id, hostname, port) )
1 row in set (0.01 sec)

ProxySQL> INSERT INTO mysql_servers
                      (hostname, port, use_ssl)
          VALUES ('127.0.0.1', 3306, 1);
Query OK, 1 row affected (0.01 sec)

ProxySQL> SAVE MYSQL SERVERS TO DISK;
Query OK, 0 rows affected (0.36 sec)

ProxySQL> LOAD MYSQL SERVERS TO RUNTIME;
Query OK, 0 rows affected (0.01 sec)

Listing 27-9Adding a MySQL instance to the list of servers

这个例子展示了如何使用SHOW CREATE TABLE来获取关于mysql_servers表的信息。表定义包括有关可以包含的设置和允许值的信息。除主机名外,所有设置都有默认值。清单的剩余部分在localhost端口 3306 上为 MySQL 实例插入一行,要求使用 SSL。然后,更改被保存到磁盘,并加载到运行时线程中。

Note

SSL 只能从 ProxySQL 到 MySQL 实例使用,不能在客户端和 ProxySQL 之间使用。

您还需要指定哪些用户可以使用该连接。首先,在 MySQL 中创建一个用户:

mysql> CREATE USER myuser@'127.0.0.1'
              IDENTIFIED WITH mysql_native_password
              BY 'password';
Query OK, 0 rows affected (0.0550 sec)

mysql> GRANT ALL ON world.* TO myuser@'127.0.0.1';
Query OK, 0 rows affected (0.0422 sec)

ProxySQL 目前不支持caching_sha2_password身份验证插件,这是 MySQL 8 中的默认设置,当您使用 MySQL Shell 连接时(但是使用mysql命令行客户端有支持),因此您需要使用mysql_native_password插件创建用户。然后在 ProxySQL 中添加用户:

ProxySQL> INSERT INTO mysql_users
                     (username,password)
          VALUES ('myuser', 'password');
Query OK, 1 row affected (0.00 sec)

ProxySQL> SAVE MYSQL USERS TO DISK;
Query OK, 0 rows affected (0.06 sec)

ProxySQL> LOAD MYSQL USERS TO RUNTIME;
Query OK, 0 rows affected (0.00 sec)

现在可以通过 ProxySQL 连接到 MySQL 了。默认情况下,SQL 接口使用端口 6033。除了端口号和可能的主机名之外,通过 ProxySQL 的连接方式与平常一样:

shell$ mysqlsh --user=myuser --password \
               --host=127.0.0.1 --port=6033 \
               --sql --table \
               -e "SELECT * FROM world.city WHERE ID = 130;"
+-----+--------+-------------+-----------------+------------+
| ID  | Name   | CountryCode | District        | Population |
+-----+--------+-------------+-----------------+------------+
| 130 | Sydney | AUS         | New South Wales |    3276207 |
+-----+--------+-------------+-----------------+------------+

ProxySQL 以类似于性能模式的方式收集统计信息。您可以查询stats_mysql_query_digeststats_mysql_query_digest_reset表中的统计数据。这两个表的不同之处在于,后者只包含自上次查询该表以来的摘要。例如,获取按总执行时间排序的查询

ProxySQL> SELECT count_star, sum_time,
                 digest, digest_text
            FROM stats_mysql_query_digest_reset
           ORDER BY sum_time DESC\G
*************************** 1\. row ***************************
 count_star: 1
   sum_time: 577149
     digest: 0x170E9EDDB525D570
digest_text: select @@sql_mode;
*************************** 2\. row ***************************
 count_star: 1
   sum_time: 5795
     digest: 0x94656E0AA2C6D499
digest_text: SELECT * FROM world.city WHERE ID = ?
2 rows in set (0.01 sec)

如果您看到一个想要缓存其结果的查询,您可以添加一个基于查询摘要的查询规则。假设您想要缓存通过ID(摘要0x94656E0AA2C6D499)查询world.city表的结果,您可以添加如下规则:

ProxySQL> INSERT INTO mysql_query_rules
                     (active, digest, cache_ttl, apply)
          VALUES (1, '0x94656E0AA2C6D499', 60000, 1);
Query OK, 1 row affected (0.01 sec)

ProxySQL> SAVE MYSQL QUERY RULES TO DISK;
Query OK, 0 rows affected (0.09 sec)

ProxySQL> LOAD MYSQL QUERY RULES TO RUNTIME;
Query OK, 0 rows affected (0.01 sec)

active列指定在评估可以使用的规则时,ProxySQL 是否应该考虑该规则。digest是您想要缓存的查询的摘要,cache_ttl指定在结果被认为过期之前应该使用多长时间,并且结果被刷新。生存时间被设置为 60000 毫秒(1 分钟),以便在缓存失效之前有时间执行几次查询。将apply设置为 1 意味着当查询匹配这个规则时,将不会评估后面的规则。

如果您在一分钟内执行了几次查询,您可以查询表stats_mysql_global中的缓存统计信息,以查看缓存是如何使用的。输出的一个例子是

ProxySQL> SELECT *
            FROM stats_mysql_global
           WHERE Variable_Name LIKE 'Query_Cache%';
+--------------------------+----------------+
| Variable_Name            | Variable_Value |
+--------------------------+----------------+
| Query_Cache_Memory_bytes | 3659           |
| Query_Cache_count_GET    | 6              |
| Query_Cache_count_GET_OK | 5              |
| Query_Cache_count_SET    | 1              |
| Query_Cache_bytes_IN     | 331            |
| Query_Cache_bytes_OUT    | 1655           |
| Query_Cache_Purged       | 0              |
| Query_Cache_Entries      | 1              |
+--------------------------+----------------+
8 rows in set (0.01 sec)

您的数据很可能会有所不同。它显示缓存使用了 3659 个字节,对缓存进行了六次查询,其中五次查询的结果都是从缓存返回的。六个查询中的最后一个需要对 MySQL 后端执行查询。

您可以设置两个选项来配置缓存。这些是

  • mysql-query_cache_size_MB : 缓存的最大大小,以兆为单位。这是一个软限制,清除线程使用它来决定从缓存中清除多少个查询。因此内存使用量可能会暂时大于配置的大小。默认值为 256。

  • mysql-query_cache_stores_empty_result : 是否缓存没有行的结果集。默认值为 true。这也可以在查询规则表中针对每个查询进行配置。

您可以像前面更改管理员密码一样更改配置。例如,将查询缓存限制为 128 兆字节

ProxySQL> SET mysql-query_cache_size_MB = 128;
Query OK, 1 row affected (0.00 sec)

ProxySQL> SAVE MYSQL VARIABLES TO DISK;
Query OK, 121 rows affected (0.04 sec)

ProxySQL> LOAD MYSQL VARIABLES TO RUNTIME;
Query OK, 0 rows affected (0.00 sec)

这首先准备配置更改,然后将其保存到磁盘,最后将 MySQL 变量加载到运行时线程中。

如果你想使用 ProxySQL,建议你在 https://github.com/sysown/proxysql/wiki 查阅 ProxySQL GitHub 项目的 wiki。

缓存提示

如果您决定为 MySQL 实例实现缓存,有几件事情需要考虑。本节研究一些通用的缓存技巧。

最重要的考虑是缓存什么。本章前面的缓存单行主键查找结果的例子并不是从缓存中获益最多的查询类型的好例子。一般来说,查询越复杂和昂贵,查询执行得越频繁,查询就越适合。使缓存更有效的一个方法是将复杂的查询分成更小的部分。这样,您可以分别缓存复杂查询的每个部分的结果,这使得它更有可能被重用。

您还应该考虑查询返回多少数据。如果查询返回一个很大的结果集,您可能最终会使用所有可用于缓存单个查询的内存。

另一个考虑是在哪里有缓存。缓存离应用越近,效率就越高,因为它减少了花费在网络通信上的时间。缺点是,如果您有多个应用实例,您将不得不在复制缓存和拥有远程共享缓存之间做出选择。例外情况是,如果您需要将缓存的数据用于其他 MySQL 表。在这种情况下,最好将缓存以缓存表或类似的形式保存在 MySQL 中。

摘要

本章概述了 MySQL 的缓存。它首先描述了从 CPU 内部到专用缓存进程,缓存是如何无处不在的。然后讨论了如何在 MySQL 中使用缓存表和直方图进行缓存。

这两个主要部分讨论了如何使用 Memcached 和 ProxySQL 进行缓存。Memcached 是一个内存中的键值存储,可以在应用中使用,也可以使用 MySQL 中包含的特殊版本,该版本允许您直接与 InnoDB 交互。ProxySQL 结合了路由和缓存机制,根据您定义的查询规则透明地存储结果集。

最后,介绍了一些关于缓存的注意事项。执行查询越频繁,执行的代价越大,缓存带来的好处就越多。第二个需要考虑的问题是,缓存离应用越近越好。

MySQL 8 查询性能调优之旅的最后一章到此结束。希望这是一次有收获的旅程,你觉得已经准备好在工作中使用这些工具和技术了。请记住,您对查询调优练习得越多,就越擅长。查询调优愉快。

第一部分:开始

第二部分:信息源

第三部分:工具

第四部分:注意事项和查询优化器

第五部分:查询分析

第六部分:改进查询

posted @ 2024-10-01 20:55  绝不原创的飞龙  阅读(217)  评论(0)    收藏  举报