Python-高性能指南-全-
Python 高性能指南(全)
原文:
zh.annas-archive.org/md5/01763e0deeacb05b0b1dcfc7cb4722be译者:飞龙
前言
MySQL 文献中存在着基本 MySQL 知识和高级 MySQL 性能之间的差距。前者有几本书,后者只有一本:《高性能 MySQL》,第四版,作者 Silvia Botros 和 Jeremy Tinley(O'Reilly 出版社)。这是弥合这一差距的第一本书。
这个差距的存在是因为 MySQL 非常复杂,而不解决这个复杂性就很难教授性能问题——这就像房间里的大象一样显而易见。但是使用 MySQL 而不是管理 MySQL 的工程师不应该需要成为 MySQL 专家才能实现出色的 MySQL 性能。为了弥合这个差距,这本书效率非凡——不要关注大象;它很友好。
高效的 MySQL 性能意味着专注:只学习和应用那些直接影响出色 MySQL 性能的最佳实践和技术。专注显著减少了 MySQL 复杂性的范围,并允许我向你展示通过 MySQL 性能广阔而复杂的领域的更简单和更快速的路径。这个旅程从第一章的第一句开始,“性能即查询响应时间”。从那里开始,我们迅速探讨索引、数据、访问模式等等。
在一个从一到五的评级中——其中一表示适合任何人,五表示深入探讨的专家级——本书的评级范围从三到四:深入,但远非底线。我假设你是一个有经验的工程师,具有关系数据库(MySQL 或其他)的基本知识和经验,所以我不解释 SQL 或数据库基础知识。我假设你是一位经验丰富的程序员,负责一个或多个使用 MySQL 的应用程序,所以我持续提到应用程序,并相信你了解你的应用程序的细节。我还假设你对计算机有一定了解,所以我可以自由地讨论硬件、软件、网络等等。
由于本书侧重于使用 MySQL 的工程师的 MySQL 性能,而不是管理 MySQL,因此在必要时会提到几个 MySQL 配置,但不进行解释。如果需要帮助配置 MySQL,请问问你工作的 DBA。如果你没有 DBA,可以雇佣一个 MySQL 顾问——有很多价格合理的顾问可供选择。你也可以通过阅读MySQL 参考手册来学习。MySQL 手册非常好,并且专家们经常使用它,所以你不用担心。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
固定宽度
用于程序清单,以及段落内引用程序元素,如变量或函数名、数据库、数据类型、环境变量、语句和关键字。
固定宽度粗体
显示用户应该直接输入的命令或其他文本。
固定宽度斜体
显示应由用户提供的值或由上下文确定的值替换的文本。
提示
这个元素表示提示或建议。
注意
这个元素表示一般注意事项。
警告
这个元素表示警告或注意事项。
使用代码示例
附加材料(代码示例、练习等)可从https://github.com/efficient-mysql-performance下载。
如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com。
本书旨在帮助您完成工作。通常情况下,如果本书提供示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分,否则无需征得我们的许可。例如,编写一个使用本书多个代码片段的程序并不需要许可。销售或分发 O’Reilly 图书示例代码需要许可。引用本书并引用示例代码回答问题不需要许可。将本书大量示例代码整合到产品文档中需要许可。
我们感谢,但通常不需要署名。署名通常包括书名、作者、出版商和 ISBN。例如:“Efficient MySQL Performance by Daniel Nichter (O’Reilly). Copyright 2022 Daniel Nichter, 978-1-098-10509-9.”
如果您认为您使用的代码示例超出了合理使用范围或上述授权,请随时与我们联系至permissions@oreilly.com。
O’Reilly Online Learning
注意
超过 40 年来,O’Reilly Media提供技术和商业培训,知识和见解,帮助企业成功。
我们独特的专家和创新者网络通过书籍、文章以及我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的实时培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的广泛文本和视频。更多信息,请访问http://oreilly.com。
如何联系我们
请将有关本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
1005 Gravenstein Highway North
-
Sebastopol, CA 95472
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为本书设置了一个网页,列出勘误、示例和任何额外信息。您可以访问https://oreil.ly/efficient-mysql-performance查看此页面。
电子邮件bookquestions@oreilly.com用于评论或就本书提出技术问题。
了解更多关于我们的书籍和课程的新闻和信息,请访问http://oreilly.com。
在 Twitter 上关注我们:http://twitter.com/oreillymedia
在 YouTube 上关注我们:http://youtube.com/oreillymedia。
致谢
感谢那些审阅本书的 MySQL 专家:Vadim Tkachenko、Frédéric Descamps 和 Fernando Ipar。感谢审阅本书部分内容的 MySQL 专家:Marcos Albe、Jean-François Gagné和 Kenny Gryp。感谢多年来帮助、教导我并为我提供机会的许多其他 MySQL 专家:Peter Zaitsev、Baron Schwartz、Ryan Lowe、Bill Karwin、Emily Slocombe、Morgan Tocker、Shlomi Noach、Jeremy Cole、Laurynas Biveinis、Mark Callaghan、Domas Mituzas、Ronald Bradford、Yves Trudeau、Sveta Smirnova、Alexey Kopytov、Jay Pipes、Stewart Smith、Aleksandr Kuzminsky、Alexander Rubin、Roman Vynar,还有再次提到的 Vadim Tkachenko。
感谢 O’Reilly 和我的编辑们:Corbin Collins、Katherine Tozer、Andy Kwan,以及所有在幕后工作的人员。
也感谢我的妻子 Moon,在我写作这本书的耗时过程中一直支持我。
第一章:查询响应时间
性能即查询响应时间。
本书从不同角度探讨这一理念,但其单一意图是帮助你实现显著的 MySQL 性能。高效的 MySQL 性能意味着专注于直接影响 MySQL 性能的最佳实践和技术——无需 DBA 和专家深入的细节或内部。我假设你是一个忙碌的专业人士,正在使用 MySQL 而不是管理它,并且你需要最小的努力获得最大的结果。这不是懒惰,这是效率。因此,本书直截了当,重点突出。到最后,你将能够实现显著的 MySQL 性能。
MySQL 的性能是一个复杂而多方面的主题,但要实现显著的性能,你并不需要成为专家。我通过关注基础内容来缩小 MySQL 复杂性的范围。MySQL 性能始于查询响应时间。
查询响应时间是 MySQL 执行查询所需的时间。同义词包括:响应时间、查询时间、执行时间以及(不准确的)查询延迟。^(1) 计时从 MySQL 接收查询开始,直到发送结果集给客户端结束。查询响应时间包括多个阶段(查询执行过程中的步骤)和等待(锁等待、I/O 等待等),但不需要也不可能进行完整和详细的分解。与许多系统一样,基本故障排除和分析揭示了大多数问题。
注意
性能随查询响应时间的减少而提升。改善查询响应时间与减少查询响应时间是同义的。
本章是基础。它详述查询响应时间,以便在后续章节中,你能学习如何改善它。本章分为七个主要部分。第一部分是一个真实故事,以激励和娱乐。第二部分讨论为什么查询响应时间是 MySQL 性能的北极星。第三部分概述了如何将查询指标转化为有意义的报告:查询报告。第四部分讨论了查询分析:使用查询指标和其他信息来理解查询执行。第五部分规划了改善查询响应时间的旅程:查询优化。第六部分提供了一个诚实和谦逊的优化查询的时间表。第七部分讨论了为什么 MySQL 不能简单地变得更快——为什么需要查询优化。
一个真实故事:虚假性能
2004 年,我在一个数据中心的夜班工作——下午 2 点到午夜。这是一个极好的工作,原因有两个。首先,晚上 5 点后,数据中心里只有少数几位工程师在监控和管理成千上万台物理服务器,为数不明的客户和网站服务——可能是数以万计的网站。对工程师来说,这是一个梦想。其次,总是有无数的 MySQL 服务器需要解决问题。这是一个学习和机会的金矿。但那时,关于 MySQL 几乎没有书籍、博客或工具。(尽管同一年,O'Reilly 出版了High Performance MySQL的第一版。)因此,“解决”MySQL 性能问题的技术“向客户推销更多内存”成了当时的行业标准。对于销售和管理来说,这总是奏效的,但对于 MySQL 来说,结果却不一致。
有一个晚上,我决定不再向客户推销更多的内存,而是进行技术深入挖掘,找出并修复他们 MySQL 性能问题的真正根本原因。他们的数据库用于支持一个公告板,由于其成功而陷入了瘫痪——这在今天仍然是一个常见问题,几乎 20 年后。长话短说,我发现了一个关键索引丢失的查询。在为查询正确创建索引之后,性能显著提高,网站得以挽救。对客户而言,这一切都是零成本。
并非所有的性能问题及解决方案都如此直接和迷人。然而,几乎二十年来与 MySQL 的经验教会了我(以及许多其他人),MySQL 性能问题往往可以通过本书中的最佳实践和技术得以解决。
北极星
我是一个 MySQL DBA,同时也是一名软件工程师,所以我知道作为后者使用 MySQL 的感受。尤其是在性能方面,我们(软件工程师)只希望(MySQL)能正常工作。在推送功能和解决问题之间,谁有时间处理 MySQL 的性能问题呢?当 MySQL 性能不佳时——或者更糟糕的是:当它突然变得不佳时——前进的道路可能会变得难以捉摸,因为有太多的考虑因素:我们从哪里开始?我们需要更多内存吗?更快的 CPU?更多的存储 IOPS?问题是最近的代码更改吗?(事实上:过去部署的代码更改有时会导致未来几天甚至几天后出现性能问题。)问题是因为“吵闹的邻居”吗?DBA 们在数据库上做了什么?应用程序已经热门了吗?这是一种好问题吗?
作为一个专业应用程序而非 MySQL 的工程师,这种情况可能会让人感到不知所措。要自信地前进,首先看一下查询响应时间是很有意义和可以采取行动的。这些是导致真正解决方案的强大特性:
有意义的
查询响应时间是任何人真正关心的唯一指标,因为说实话,当数据库运行快速时,没有人会查看它或提问。为什么?因为查询响应时间是我们体验到的唯一指标。当一个查询需要 7.5 秒才能执行时,我们经历了 7.5 秒的不耐烦。同样的查询可能检查了一百万行,但我们并没有体验一百万行的检查。我们的时间是宝贵的。
可操作的
你可以做很多事情来提高查询响应时间,使所有人都再次感到满意,你正在看一本关于它的书。(未来人们还会拿着书吗?我希望如此。)查询响应时间是可以直接操作的,因为你拥有代码,所以可以更改查询。即使你不拥有代码(或者没有访问权限),你仍然可以间接优化查询响应时间。“优化查询响应时间”讨论了直接和间接的查询优化。
专注于提高查询响应时间——MySQL 性能的北极星。不要从硬件问题开始。首先使用查询指标确定 MySQL 正在做什么,然后分析和优化慢查询以减少响应时间,然后重复。性能会提升。
查询报告
查询指标为查询执行提供宝贵的见解:响应时间、锁定时间、检查的行数等等。但是,查询指标和其他指标一样,都是原始值,需要以对工程师有意义和可读的方式进行收集、聚合和报告。这就是本节的内容概述:查询指标工具如何将查询指标转化为查询报告。但是,查询报告只是达到目的的手段,如“查询分析”中所讨论的。
展望未来,查询分析才是真正的工作:分析查询指标(如报告所述)和其他信息,目标是理解查询执行。要提高 MySQL 性能,必须优化查询。要优化查询,必须了解它们的执行方式。为了理解这一点,必须使用相关信息进行分析,包括查询报告和元数据。
但首先,你需要理解查询报告,因为它代表着提供对查询执行宝贵见解的查询指标宝库。接下来的三节将教你以下内容:
-
来源:查询指标来自两个来源,并且根据 MySQL 的分发和版本而有所不同。
-
聚合:通过标准化的 SQL 语句对查询指标值进行分组和聚合。
-
报告:查询报告按高级概要和特定查询报告进行组织。
那么,你已经准备好进行“查询分析”了。
注意
这不是一本关于数据库管理的书籍,因此本节不讨论在 MySQL 中设置和配置查询指标。我假设这已经完成或将完成。如果没有,不用担心:询问你的数据库管理员,聘请顾问,或者通过阅读 MySQL 手册来学习。
来源
查询指标来源于慢查询日志或性能模式。正如名称所示,前者是一个磁盘上的日志文件,而后者是一个同名的数据库:performance_schema。尽管在性质上完全不同(磁盘上的日志文件与数据库中的表),它们都提供查询指标。重要的区别在于它们提供的指标数量:除了两者都提供的查询响应时间外,指标数量从 3 个到 20 多个不等。
注意
名称慢查询日志有其历史背景。很久以前,MySQL 只记录执行时间超过 N 秒的查询,其中 N 的最小值为 1。旧版本的 MySQL 不会记录执行时间为 900 毫秒的查询,因为那时候这被认为是“快速”的。慢查询日志确实因其名而闻名。如今,最小值为零,精确到微秒。当设置为零时,MySQL 记录每个执行的查询。因此,名称有点误导,但现在你知道原因了。
综上所述,性能模式是查询指标的最佳数据源,因为它存在于当前 MySQL 的每个版本和发行版中,在本地和云中都可以使用,提供了“查询指标”中的全部九个指标,并且是最一致的。此外,性能模式还包含丰富的其他数据,可用于深入分析 MySQL,因此其实用性远远超出了查询指标。慢查询日志也是一个不错的数据源,但其变化很大:
MySQL
截至 MySQL 8.0.14,启用系统变量log_slow_extra,慢查询日志提供了“查询指标”中九个指标中的六个,仅缺少Rows_affected、Select_scan和Select_full_join。虽然仍然是一个良好的数据源,但尽可能使用性能模式。
在 MySQL 8.0.14 之前,包括 MySQL 5.7 在内,慢查询日志非常简陋,仅提供Query_time、Lock_time、Rows_sent和Rows_examined。你仍然可以分析这些四个指标的查询,但分析的深度大大降低。因此,在 MySQL 8.0.14 之前,尽量避免使用慢查询日志,而是使用性能模式。
Percona Server
Percona Server在配置系统变量log_slow_verbosity时,在慢查询日志中提供了大量的指标:覆盖了“查询指标”中的全部九个指标及更多。此外,当配置系统变量log_slow_rate_limit时,它还支持查询抽样(记录一定百分比的查询),对于繁忙的服务器非常有帮助。这些特性使得 Percona Server 慢查询日志成为一个很好的数据源。详细信息请参阅 Percona Server 手册中的“慢查询日志”。
MariaDB Server
MariaDB 服务器 10.x 使用了 Percona Server 的慢查询日志增强功能,但有两个显著的区别:系统变量log_slow_verbosity在 MariaDB 中配置不同,并且不提供指标Rows_affected。除此之外,它基本上是相同的,并且也是一个很好的信息来源。详细信息请参阅 MariaDB 知识库中的“慢查询日志扩展统计”。
默认情况下,慢查询日志处于禁用状态,但可以动态启用它(无需重新启动 MySQL)。性能模式应默认启用,尽管某些云提供商默认禁用它。与慢查询日志不同,性能模式无法动态启用 - 您必须重新启动 MySQL 才能启用它。
确保使用和正确配置最佳的查询指标来源。向您的数据库管理员询问,聘请顾问,或通过阅读 MySQL 手册来学习。
警告
当long_query_time设置为零时,慢查询日志可以记录所有查询,但请注意:在繁忙的服务器上,这可能会增加磁盘 I/O 并使用大量磁盘空间。
聚合
查询指标按查询分组和聚合。这听起来显而易见,因为它们称为查询指标,但一些查询指标工具可以按用户名、主机名、数据库等分组。这些备选分组异常罕见,并产生不同类型的查询分析,因此我在本书中不予讨论。由于查询响应时间是 MySQL 性能的指南星,按查询分组查询指标是查看哪些查询具有最慢响应时间的最佳方式,这构成了查询报告和分析的基础。
有一个小问题:如何唯一标识查询以确定它们所属的组?例如,系统指标(CPU、内存、存储等)按主机名分组,因为主机名是唯一且有意义的。但查询没有像主机名那样具有唯一标识属性。解决方案是:对规范化的 SQL 语句进行 SHA-256 哈希处理。示例 1-1 展示了如何规范化 SQL 语句。
示例 1-1. SQL 语句规范化
SELECT col FROM tbl WHERE id=1 
SELECT `col` FROM `tbl` WHERE `id` = ? 
f49d50dfab1c364e622d1e1ff54bb12df436be5d44c464a4e25a1ebb80fc2f13 
SQL 语句(示例)
摘要文本(规范化的 SQL 语句)
摘要哈希(摘要文本的 SHA-256 哈希值)
MySQL 将 SQL 语句规范化为摘要文本,然后计算摘要文本的 SHA-256 哈希以生成摘要哈希值。(了解规范化的完整过程并不是必要的;知道规范化将所有值替换为?并将多个空格折叠为单个空格即可)。由于摘要文本是唯一的,摘要哈希值也是唯一的(哈希冲突除外)。
注意
MySQL 手册在使用术语 digest 时含糊地指代 digest text 或 digest hash。由于 digest hash 是从 digest text 计算而来的,这种含糊只是语言上的歧义,而非技术错误。请允许我也模棱两可地使用 digest 来指代 digest text 或 digest hash,在技术上没有区别时。
在查询指标的语境中,术语发生了重要的术语转变:术语 query 变为 digest text 的同义词。这种术语转变与关注重点的转变一致:按查询分组的指标。要按查询分组,query 必须是唯一的,这只对 digest 成立。
SQL 语句也称为 查询样本(或简称为 样本),它们可能会被报告,也可能不会。出于安全考虑,大多数查询指标工具默认丢弃样本(因为它们包含真实值),并仅报告 digest texts 和 hashes。样本对于查询分析至关重要,因为您可以 EXPLAIN 它们,这会生成理解查询执行所必需的元数据。一些查询指标工具会对样本进行 EXPLAIN,然后丢弃它,并报告 EXPLAIN 计划(EXPLAIN 的输出)。其他工具仅报告样本,这仍然非常方便:复制粘贴到 EXPLAIN。如果两者都没有,那么就需要从源代码手动提取样本,或者在需要时手动编写样本。
关于术语的另外两点澄清,然后我保证我们会继续更加有趣的内容。首先,根据查询指标工具的不同,术语会有广泛的变化,如 表 1-1 所示。
表 1-1. 查询指标术语
| 官方(MySQL) | 备选项 |
|---|---|
| SQL 语句 | 查询 |
| 示例 | 查询 |
| Digest text | 类别、家族、指纹、查询 |
| 摘要散列 | 类别 ID、查询 ID、签名 |
其次,另一个源自 Percona 的术语是 查询摘要:一个高度抽象化的 SQL 语句,简化为其 SQL 命令和表列表。示例 1-2 是 SELECT col FROM tbl WHERE id=1 的查询摘要。
示例 1-2. 查询摘要
SELECT tbl
查询摘要并非唯一,但它们很有用,因为它们简洁。通常,开发人员只需看一下查询摘要就能知道它所代表的完整查询。
简洁是智慧的灵魂。
威廉·莎士比亚
重要的是要理解 SQL 语句的标准化,因为你写的查询和你看到的查询不一样。大多数情况下,这不是问题,因为 digest text 很接近 SQL 语句。但标准化的过程提出了另一个重要的观点:不要基于用户输入动态生成相同逻辑的查询,否则会标准化为不同的 digest,并报告为不同的查询。例如,在根据用户输入改变 WHERE 子句的程序生成查询的情况下:
SELECT name FROM captains WHERE last_name = 'Picard'
SELECT name FROM captains WHERE last_name = 'Picard' AND first_name = 'Jean-Luc'
对你和应用程序而言,这两个查询可能在逻辑上是相同的,但在报告上它们是不同的查询,因为它们归一化到不同的摘要。据我所知,没有任何查询度量工具允许你合并查询。并且单独报告这些查询是技术上正确的,因为每个条件——尤其是在WHERE子句中——都会影响查询执行和优化。
关于查询规范化的一点:值被移除,因此以下两个查询规范化为相同的摘要:
-- SQL statements
SELECT `name` FROM star_ships WHERE class IN ('galaxy')
SELECT `name` FROM star_ships WHERE class IN ('galaxy', 'intrepid')
-- Digest text
SELECT `name` FROM `star_ships` WHERE `class` IN (...)
由于摘要对两个查询相同,因此两个查询的度量值被分组、汇总并报告为一个查询。
足够了解术语和规范化了。我们来谈谈报告。
报告
报告是一个挑战,也是一种艺术形式,因为单个应用程序可以有数百个查询。每个查询有许多指标,每个指标有几个统计数据:最小值、最大值、平均值、百分位数等等。此外,每个查询还有元数据:样本、解释计划、表结构等等。存储、处理和呈现所有这些数据是一项挑战。几乎每个查询度量工具都以两级层次结构呈现数据:查询概要和查询报告。这些术语因查询度量工具而异,但当你看到它们时,你会轻松辨认出每一个。
查询概要
查询概要显示慢查询。这是查询报告的顶层组织,通常是查询度量工具中看到的第一件事情。它展示查询摘要和一组有限的查询度量指标,因此被称为概要。
慢是相对于排序指标的:通过排序指标对查询指标进行聚合值排序。第一个有序查询即使排序指标不是查询时间(或任何时间),仍然称为最慢。例如,如果排序指标是平均发送的行数,则第一个有序查询仍然被称为最慢查询。
尽管任何查询指标都可以是排序指标,查询时间是普遍的默认排序指标。当你减少查询执行时间时,你可以释放出时间,使得 MySQL 可以完成更多工作,或者可能更快地完成其他工作。按查询时间对查询进行排序可以告诉你从哪里开始:最慢、耗时最长的查询。
查询时间如何汇总并非普遍适用。最常见的汇总值包括:
查询总时间
总查询时间是执行时间(每个查询)的总和。这是最常见的聚合值,因为它回答了一个重要问题:MySQL 在执行哪个查询时花费了最多的时间?为了回答这个问题,一个查询度量工具会累加 MySQL 执行每个查询所花费的时间。总时间最长的查询是最慢、耗时最长的查询。这里有一个重要的例子。假设查询A的响应时间为 1 秒,执行了 10 次,而查询B的响应时间为 0.1 秒,执行了 1,000 次。查询A的响应时间较慢,但查询B耗时更长:分别为 10 秒总时间与 100 秒总时间。在按总查询时间排序的查询分析中,查询B是最慢的查询。这很重要,因为通过优化查询B,你可以为 MySQL 释放出最多的时间。
百分比执行时间
百分比执行时间是总查询时间(每个查询)除以总执行时间(所有查询)。例如,如果查询C的总查询时间为 321 毫秒,查询D的总查询时间为 100 毫秒,则总执行时间为 421 毫秒。单独计算,查询C占总执行时间的比例为(321 毫秒 / 421 毫秒)× 100 = 76.2%,而查询D占总执行时间的比例为(100 毫秒 / 421 毫秒)× 100 = 23.8%。换句话说,MySQL 花费了 421 毫秒来执行查询,其中 76.2%用于执行查询C。在按百分比执行时间排序的查询分析中,查询C是最慢的查询。百分比执行时间被一些查询度量工具使用,但并非所有都使用。
查询负载
查询负载是总查询时间(每个查询)除以时钟时间,其中时钟时间是时间范围内的秒数。如果时间范围是 5 分钟,则时钟时间为 300 秒。例如,如果查询E的总查询时间为 250.2 秒,则它的负载为 250.2 秒 / 300 秒 = 0.83;如果查询F的总查询时间为 500.1 秒,则它的负载为 500.1 秒 / 300 秒 = 1.67。在按查询负载排序的查询分析中,查询F是最慢的查询,因为它的负载最大。
负载相对于时间,但也微妙地显示了并发性:同时执行的多个查询实例。负载小于 1.0 意味着平均而言查询不会并发执行。负载大于 1.0 表示查询并发性。例如,负载为 3.5 意味着无论何时查看,可能会看到 3.5 个查询实例(实际上是 3 或 4 个查询实例,因为不能有 0.5 个查询实例)。负载越高,如果查询访问相同或附近的行,则发生争用的可能性越大。负载大于 10 是高负载,可能是一个慢查询,但也有例外。正如我写这篇文章时,我正在查看一个负载为 5,962 的查询。这是怎么可能的?我在《数据访问》中揭示了答案。
当排序度量使用非时间查询度量,例如发送的行数时,根据你要诊断的内容可能会有不同的聚合值(平均值、最大值等等)。这比总查询时间要少见得多,但偶尔会发现一些值得优化的有趣查询。
查询报告
查询报告向你展示了关于一个查询的所有信息。它是查询报告的第二级组织,通常通过选择查询慢的查询来访问查询概要。它呈现所有的查询度量标准和元数据。而查询概要仅凭一瞥即可告诉你一些信息(哪些查询最慢),查询报告则是用于查询分析的组织信息转储。因此,信息越多越好,因为它帮助你理解查询执行。
查询报告根据查询度量工具的不同而有很大差异。最基本的报告包括来自源的所有查询度量标准和这些度量标准的基本统计数据:最小值、最大值、平均值、百分位数等等。彻底的报告包括元数据:查询样本、EXPLAIN 计划、表结构等等。由于包含实际值,有些样本出于安全考虑可能已禁用。少数查询度量工具通过添加额外信息进一步扩展:度量图、直方图(分布)、异常检测、时间偏移比较(现在与上周)、开发者注释、SQL 注释键值提取等等。
查询分析仅需要报告中的查询度量标准。元数据可以手动收集。如果你使用的查询度量工具仅报告查询度量标准,不要担心:这是一个开始,但你至少需要手动收集 EXPLAIN 计划和表结构。
拿着一个象征性的查询报告,你已经准备好进行查询分析了。
查询分析
查询分析的目标是理解查询执行,而不是解决响应时间慢的问题。这可能会让你感到惊讶,但解决响应时间慢的问题发生在查询分析之后,在查询优化期间。首先,你需要理解你想要改变的是什么:查询执行。
查询执行就像一个有起点、中间和终点的故事:你需要读懂这三部分才能理解整个故事。一旦理解了 MySQL 如何执行查询,你就会明白如何优化它。通过分析理解,然后通过优化行动。
提示
我已经帮助许多工程师分析查询,主要困难不在于理解度量标准,而在于分析过程中的困境:深入研究数字,等待启示。不要陷入困境。仔细审查所有的度量和元数据——通读整个故事——然后把注意力转向查询优化,目标是提高响应时间。
以下部分讨论了高效和洞察力查询分析的关键方面。有时候,慢响应时间的原因显而易见,分析看起来更像是一条推文而不是一个故事。但当它不是这样的时候 —— 当分析看起来像一篇关于法国存在主义的研究论文时 —— 理解这些方面将帮助你找到原因并确定解决方案。
查询指标
根据“来源”,你知道查询指标因来源、MySQL 发行版和 MySQL 版本而异。所有的查询指标都很重要,因为它们帮助你理解查询执行,但接下来详细描述的九个指标对每个查询分析都是必不可少的。
性能模式提供了所有九个关键的查询指标。
注意
查询指标名称也因来源而异。在慢查询日志中,查询时间是Query_time;但在性能模式中,它是TIMER_WAIT。我没有使用任何一种约定。相反,我使用人类友好的名称,如查询时间和发送行数。查询报告几乎总是使用人类友好的名称。
查询时间
查询时间是最重要的指标 —— 你已经知道这一点。你可能不知道的是,查询时间还包括另一个指标:锁定时间。
锁定时间是查询时间的固有部分,所以查询时间包括锁定时间并不令人意外。令人意外的是,查询时间和锁定时间是唯一的两个基于时间的查询指标,有一个例外:Percona Server 慢查询日志中有关于 InnoDB 读取时间、行锁等待时间和队列等待时间的指标。锁定时间很重要,但遗憾的是,有一个技术上的陷阱:它只在慢查询日志中才是准确的。稍后再详谈。
使用性能模式,你可以看到查询执行的许多部分(但不是所有)。这是不相关的,并超出本书的范围,但这是一个很好的意识,因此你知道如果需要深入挖掘,该去哪里查看。MySQL 记录了大量 事件,手册将其定义为“服务器进行的任何花费时间的操作,并已经进行了仪器化,以便收集时间信息。” 事件按层次结构组织:
transactions
└── statements
└── stages
└── waits
事务
事务是顶层事件,因为每个查询都在事务中执行(第八章讨论了事务)。
语句
语句是查询,适用查询指标。
阶段
阶段是“语句执行过程中的步骤,如解析语句、打开表或执行文件排序操作。”
等待
等待是“花费时间的事件”。(这个定义让我觉得很有趣。它在简洁中又有些自洽和令人满意的复杂性。)
示例 1-3 显示了单个UPDATE语句的阶段(截至 MySQL 8.0.22)。
示例 1-3. 单个UPDATE语句的阶段
+----------------------------------+----------------------------------+-----------+
| stage | source:line | time (ms) |
+----------------------------------+----------------------------------+-----------+
| stage/sql/starting | init_net_server_extension.cc:101 | 0.109 |
| stage/sql/Executing hook on trx | rpl_handler.cc:1120 | 0.001 |
| stage/sql/starting | rpl_handler.cc:1122 | 0.008 |
| stage/sql/checking permissions | sql_authorization.cc:2200 | 0.004 |
| stage/sql/Opening tables | sql_base.cc:5745 | 0.102 |
| stage/sql/init | sql_select.cc:703 | 0.007 |
| stage/sql/System lock | lock.cc:332 | 0.072 |
| stage/sql/updating | sql_update.cc:781 | 10722.618 |
| stage/sql/end | sql_select.cc:736 | 0.003 |
| stage/sql/query end | sql_parse.cc:4474 | 0.002 |
| stage/sql/waiting handler commit | handler.cc:1591 | 0.034 |
| stage/sql/closing tables | sql_parse.cc:4525 | 0.015 |
| stage/sql/freeing items | sql_parse.cc:5007 | 0.061 |
| stage/sql/logging slow query | log.cc:1640 | 0.094 |
| stage/sql/cleaning up | sql_parse.cc:2192 | 0.002 |
+----------------------------------+----------------------------------+-----------+
实际输出更为复杂;我简化了以便易读。在 15 个阶段执行了UPDATE语句。UPDATE的实际执行是第八阶段:stage/sql/updating。有 42 个等待,但我将它们从输出中删除,因为它们离题太远。
性能模式事件(事务、语句、阶段和等待)是查询执行的细节。查询指标适用于语句。如果需要深入了解查询,请查看性能模式。
效率是我们的工作方式,所以在你可能永远不需要的情况下,不要陷入性能模式的迷宫中。查询时间足够了。
锁等待时间
锁等待时间 是查询执行过程中获取锁所花费的时间。理想情况下,锁等待时间应该是查询时间的极小部分,但实际值是相对的(参见“相对值”)。例如,在我管理的一个极度优化的数据库中,最慢的查询的锁等待时间占查询时间的 40%到 50%。听起来很糟糕,对吧?但实际上不是:最慢的查询的最大查询时间为 160 微秒,最大锁等待时间为 80 微秒,而数据库每秒执行超过 20,000 个查询(QPS)。
尽管值是相对的,但我可以肯定地说,锁等待时间超过查询时间的 50%是一个问题,因为 MySQL 应该花费绝大部分时间在执行工作,而不是等待。理论上完美的查询执行会有零等待时间,但由于系统中的共享资源、并发性和延迟,这是不可能的。不过,我们可以梦想。
还记得之前提到的不幸技术绊脚石吗?在这里:性能模式中的锁等待时间不包括行锁等待,仅包括表锁和元数据锁等待。行锁等待是锁等待时间中最重要的部分,这使得性能模式中的锁等待时间几乎无用。相比之下,慢查询日志中的锁等待时间包括所有类型的锁等待:元数据、表和行。来自任一来源的锁等待时间都不指示等待的锁类型。来自性能模式的情况,肯定是元数据锁等待;而来自慢查询日志的情况,可能是行锁等待,但也有可能是元数据锁等待。
警告
性能模式中的锁等待时间不包括行锁等待。
锁定主要用于写入操作(INSERT、UPDATE、DELETE、REPLACE),因为必须在写入之前锁定行。 写入的响应时间取决于锁定时间的部分。 获取行锁所需的时间取决于并发性:同时有多少查询访问相同行(或附近行)。 如果一行的并发性为零(仅由一个查询访问),则锁定时间将几乎为零。 但是如果一行是“热的”——术语用于非常频繁地访问——那么锁定时间可能占响应时间的显著百分比。 并发性是几种数据访问模式之一(参见 “数据访问模式” 在第四章)。
对于读取(SELECT),有非锁定和锁定读取。 区分很容易,因为只有两种锁定读取:SELECT…FOR UPDATE 和 SELECT…FOR SHARE。 如果不是这两者之一,则 SELECT 是非锁定的,这是正常情况。
尽管 SELECT…FOR UPDATE 和 SELECT…FOR SHARE 是唯一的锁定读取,但不要忘记带有可选 SELECT 的写入操作。 在以下 SQL 语句中,SELECT 会在表 s 上获取共享行锁:
-
INSERT…SELECT FROM s -
REPLACE…SELECT FROM s -
UPDATE…WHERE…(SELECT FROM s) -
CREATE TABLE…SELECT FROM s
严格来说,这些 SQL 语句是写入操作,而不是读取操作,但是可选的 SELECT 会在表 s 上获取共享行锁。 有关详细信息,请参阅 MySQL 手册中的 “InnoDB 中由不同 SQL 语句设置的锁”。
应避免使用锁定读取,特别是 SELECT…FOR UPDATE,因为它们不具备可伸缩性,往往会引起问题,并且通常有非锁定解决方案可以实现相同的结果。 关于锁定时间,请注意 SELECT…FOR SHARE:共享锁与其他共享锁兼容,但与排他锁不兼容,这意味着共享锁会阻止对同一行(或附近行)的写入操作。
对于非锁定读取,即使不会获取行锁,锁定时间也不为零,因为会获取元数据和表锁。 但是获取这两者应该非常快:少于 1 毫秒。 例如,我管理的另一个数据库执行超过 34,000 QPS,但最慢的查询是一个非锁定的 SELECT,每次执行都会进行全表扫描,读取六百万行,具有非常高的并发性:168 查询负载。 尽管存在这些大值,其最大锁定时间为 220 微秒,平均锁定时间为 80 微秒。
非锁定读并不意味着非阻塞。对于所有访问的表,SELECT 查询必须获取共享的元数据锁(MDL)。与通常的锁定一样,共享的 MDL 可以与其他共享的 MDL 兼容,但是一个排他的 MDL 会阻止所有其他的 MDL。ALTER TABLE 是通常会获取排他 MDL 的操作。即使使用 ALTER TABLE…ALGORITHM=INPLACE, LOCK=NONE 或第三方在线模式更改工具如 pt-online-schema-change 和 gh-ost,在最后交换旧表结构为新表时仍需获取排他 MDL。虽然表交换非常快速,但是当 MySQL 负载较重时可能会导致显著的中断,因为在持有排他 MDL 时会阻塞所有表访问。尤其对于 SELECT 语句来说,这个问题会显示为锁定时间的瞬间波动。
警告
SELECT 可能会因为等待元数据锁而阻塞。
锁定可能是 MySQL 中最复杂且细微妙的方面。为了避免深入探讨,让我列出五个要点,暂时不做解释。仅仅了解这些要点就大大增加了你的 MySQL 技能:
-
锁定时间可以显著超过
innodb_lock_wait_timeout,因为这个系统变量适用于每个行锁。 -
锁定和事务隔离级别有关。
-
InnoDB 会锁定它访问的每一行,包括它不写入的行。
-
锁定会在事务提交或回滚时释放,有时在查询执行期间释放。
-
InnoDB 有不同类型的锁:记录锁、间隙锁、下一个键锁等等。
“行锁定” 进行了详细讨论。现在,让我们把所有内容整合起来,看看查询时间包括的锁定时间。图 1-1 展示了查询执行期间获取和释放的锁定。

图 1-1. 查询执行期间的锁定时间
标签 1 到 10 标记了关于锁定事件和细节的内容:
-
获取表上的共享元数据锁定
-
获得意向排他(IX)表锁
-
获得行锁 1
-
更新(写入)行 1
-
获得行锁 2
-
释放行锁 2
-
获得行锁 3
-
更新(写入)行 3
-
提交事务
-
释放所有锁定
两个关键点:
-
来自性能模式的锁定时间仅包括标签
1和2。从慢查询日志中,它包括标签1、2、3、5和7。 -
尽管行 2 被锁定(标签
5),但它没有被写入,并且它的锁定在事务提交(标签9)之前就被释放了(标签6)。这种情况可能发生,但并非总是如此。它取决于查询和事务隔离级别。
刚才关于锁定时间和锁定的信息很多,但现在你已经具备了理解查询分析中的锁定时间的能力。
检查的行数
检查的行数 是 MySQL 访问以找到匹配行的行数。它表示查询和索引的选择性。两者的选择性越高,MySQL 浪费在检查非匹配行的时间就越少。这适用于读取和写入,除非是 INSERT(如果是 INSERT…SELECT 语句则不适用)。
要理解检查的行数,让我们看两个例子。首先,让我们使用以下表 t1 和三行:
CREATE TABLE `t1` (
`id` int NOT NULL,
`c` char(1) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
+----+---+
| id | c |
+----+---+
| 1 | a |
| 2 | b |
| 3 | c |
+----+---+
列 id 是主键,而列 c 则未建立索引。
查询 SELECT c FROM t1 WHERE c = 'b' 匹配了一行,但检查了三行,因为列 c 上没有唯一索引。因此,MySQL 不知道有多少行与 WHERE 子句匹配。我们可以看到只有一行匹配,但 MySQL 没有眼睛,它有索引。相比之下,查询 SELECT c FROM t1 WHERE id = 2 只匹配并检查了一行,因为列 id 上有唯一索引(主键),并且表条件使用了整个索引。现在 MySQL 可以形象地看到只有一行匹配,因此它只检查了一行。第二章 讲解了索引和索引技术,这解释了表条件和更多内容。
对于第二个例子,让我们使用以下表 t2 和七行:
CREATE TABLE `t2` (
`id` int NOT NULL,
`c` char(1) NOT NULL,
`d` varchar(8) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
+----+------+--------+
| id | c | d |
+----+------+--------+
| 1 | a | apple |
| 2 | a | ant |
| 3 | a | acorn |
| 4 | a | apron |
| 5 | b | banana |
| 6 | b | bike |
| 7 | c | car |
+----+------+--------+
列 id 与之前相同(主键)。列 c 有一个 非唯一 索引。列 d 没有建立索引。
查询 SELECT d FROM t2 WHERE c = 'a' AND d = 'acorn' 将会检查多少行?答案是:四行。MySQL 使用列 c 上的非唯一索引来查找满足条件 c = 'a' 的行,这匹配了四行。然后,为了匹配另一个条件 d = 'acorn',MySQL 检查了这四行中的每一行。因此,这个查询检查了四行,但只匹配(并返回)了一行。
发现查询检查的行数比预期多并不罕见。原因通常是查询的选择性或索引(或两者)导致的,但有时也因为表的增长超出预期,因此需要检查的行数更多。第三章 进一步讨论了这一点(顺带说一句)。
只检查行数只能讲述一半的故事。另一半是发送的行数。
发送的行数
发送的行数 是返回给客户端的行数,即结果集的大小。发送的行数与检查的行数之间的关系最有意义。
发送的行数 = 检查的行数
理想情况是,发送的行数与检查的行数相等且该值相对较小,尤其是作为总行数的百分比,而查询响应时间可接受。例如,从一百万行的表中提取 1,000 行是合理的 0.1%。如果响应时间可接受,这是理想情况。但是,从只有 10,000 行的表中提取 1,000 行即使响应时间可接受,也是一个值得怀疑的 10%。无论百分比如何,如果发送的行数和检查的行数相等且该值异常高,则强烈表明查询导致表扫描,这通常对性能非常不利——“表扫描”解释了原因。
Rows sent < Rows examined
发送的行数少于检查的行数是查询或索引选择性差的可靠标志。如果差异极大,这很可能解释了慢响应时间。例如,发送 1,000 行而检查 100,000 行虽然不是很大的值,但意味着 99%的行未匹配——查询导致 MySQL 浪费了大量时间。即使响应时间可接受,索引也可以大幅减少浪费的时间。
Rows sent > Rows examined
发送的行数大于检查的行数是可能的,但很少见。这种情况发生在特殊条件下,比如 MySQL 可以“优化掉”查询时。例如,在前一节的表上执行 SELECT COUNT(id) FROM t2 可以发送一个包含 COUNT(id) 值的行,但不检查任何行。
仅凭发送的行数本身很少会出现问题。现代网络速度快,MySQL 协议高效。如果您的 MySQL 分布版和版本在慢查询日志中具有bytes sent指标(性能模式不提供此查询指标),您可以以两种方式使用它。首先,最小值、最大值和平均值揭示了字节大小的结果集。通常这很小,但如果查询返回BLOB或JSON列,它可能很大。其次,总发送字节数可以转换为网络吞吐量(Mbps 或 Gbps),以揭示查询的网络利用率,这通常也非常小。
Rows affected
Rows affected 是插入、更新或删除的行数。工程师们非常小心地只影响正确的行。当更改错误的行时,这是一个严重的错误。从这个角度看,受影响行数的值始终是正确的。但是,一个出乎意料的大值可能表明新的或修改过的查询影响了比预期更多的行。
查看受影响行的另一种方法是作为批量操作的批处理大小。批量 INSERT、UPDATE 和 DELETE 是几个问题的常见源头:复制延迟、历史列表长度、锁定时间以及整体性能下降。同样普遍的问题是:“批处理大小应该设置多大?” 没有通用的正确答案。相反,你必须确定 MySQL 和应用程序可以维持的批处理大小及速率,而不会影响查询响应时间。我在 “批处理大小” 中进行了解释,该章节主要关注 DELETE,但也适用于 INSERT 和 UPDATE。
选择扫描
选择扫描 是访问的第一个表上的全表扫描次数。(如果查询访问了两个或更多表,则应用下一个指标:选择全连接。)这通常对性能不利,因为意味着查询未使用索引。在学习索引和索引后,在 第二章 之后,添加索引以修复表扫描应该很容易。如果选择扫描不为零,则强烈建议进行查询优化。
可能会发生但非常罕见的是,某个查询有时会导致表扫描,但并非总是如此。要确定原因,需要查询样本和两者的 EXPLAIN 计划:一个导致表扫描的查询样本,以及一个不会导致表扫描的查询样本。一个可能的原因是 MySQL 估计查询将检查的行数相对于索引基数(索引中的唯一值数)、表中总行数和其他成本。 (MySQL 查询优化器使用成本模型。)估计并不完美,有时 MySQL 错误,导致表扫描或子优化执行计划,但再次强调:这种情况非常罕见。
很可能,选择扫描要么全为零,要么全为一(这是一个二元值)。如果为零,那就很高兴。如果不为零,则应优化查询。
选择全连接
选择全连接 是加入的表上的全表扫描次数。这类似于选择扫描,但更糟糕——稍后我会解释为什么。选择全连接应该始终为零;如果不是,则实际上需要进行查询优化。
当你使用多表查询进行 EXPLAIN 时,MySQL 会从顶部(第一个表)到底部(最后一个表)打印表连接顺序。选择扫描仅适用于第一个表。选择全连接仅适用于第二个及后续的表。
表连接顺序由 MySQL 确定,而不是查询本身。^(2) 示例 1-4 展示了 SELECT…FROM t1, t2, t3 的 EXPLAIN 计划:MySQL 确定了与查询中隐含的三表连接不同的连接顺序。
示例 1-4. 三表连接的 EXPLAIN 计划
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: t3
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 3
filtered: 100.00
Extra: NULL
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: t1
partitions: NULL
type: range
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: NULL
rows: 2
filtered: 100.00
Extra: Using where
*************************** 3\. row ***************************
id: 1
select_type: SIMPLE
table: t2
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 7
filtered: 100.00
Extra: NULL
MySQL 首先读取表 t3,然后连接表 t1,然后连接表 t2。这种连接顺序与查询 (FROM t1, t2, t3) 不同,这就是为什么必须使用 EXPLAIN 命令来查看其连接顺序。
提示
总是使用 EXPLAIN 命令查看查询的连接顺序。
选择扫描适用于表t3,因为它是连接顺序中的第一个表,并导致表扫描(由type: ALL表示)。如果表t1导致表扫描,选择全连接将适用于它,但实际上并不会:MySQL 使用主键的范围扫描进行表连接(分别由type: range和key: PRIMARY表示)。选择全连接适用于表t2,因为 MySQL 使用全表扫描进行表连接(由type: ALL表示)。
表t2上的表扫描称为全连接,因为 MySQL 在连接时扫描了整个表。选择全连接比选择扫描更糟糕,因为在查询执行过程中在表上发生的全连接次数等于前面表的行数的乘积。MySQL 估计表t3有三行(由rows: 3表示)和表t1有两行(由rows: 2表示)。因此,在查询执行过程中,表t2上会有 3 × 2 = 6 次全连接。但选择全连接的度量值将为 1,因为它在执行计划中计算全连接,而不是在查询执行过程中计算,这已足够,因为即使一个全连接也太多。
注意
截至 MySQL 8.0.18,哈希连接优化改进了某些连接的性能,但避免全连接仍然是最佳实践。请参阅“表连接算法”简要了解哈希连接。
创建临时磁盘表
Created tmp disk tables是在磁盘上创建的临时表的数量。查询在内存中创建临时表是正常的;但当内存中的临时表变得过大时,MySQL 会将其写入磁盘。这可能会影响响应时间,因为磁盘访问比内存访问慢几个数量级。
然而,临时磁盘表并不是常见问题,因为 MySQL 会尽量避免它们。过多的“tmp disk tables”表明可以优化查询,或者(也许)系统变量tmp_table_size设置得太小。始终首先优化查询。最后才更改系统变量——尤其是影响内存分配的变量。
更多信息请参阅 MySQL 手册中的“MySQL 中的内部临时表使用”。
查询计数
查询计数是查询执行的次数。该值是任意的,除非非常低且查询很慢。“低速”和“慢”是一个值得调查的奇怪组合。
当我写下这些话时,我正在查看一个完美的查询分析文件:最慢的查询只执行了一次,但占用了 44%的执行时间。其他指标包括:
-
响应时间:16 秒
-
锁定时间:110 微秒
-
扫描的行数:132,000
-
发送的行数:13
这不是每天都能见到的查询。看起来像是工程师手动执行的查询,但从摘要文本中可以看出它是程序生成的。这个查询背后的故事是什么?要找出答案,我必须问问应用程序开发人员。
元数据与应用程序
查询分析不仅仅是查询指标:还有元数据。事实上,你无法完成查询分析而没有至少两个元数据:EXPLAIN 计划(也称为查询执行计划)和每个表的表结构。一些查询指标工具会自动收集元数据并在查询报告中显示。如果你的查询指标工具没有这个功能,不用担心:收集元数据很容易。EXPLAIN和SHOW CREATE TABLE分别报告 EXPLAIN 计划和表结构。
元数据对于查询分析、查询优化和 MySQL 性能至关重要。EXPLAIN是你 MySQL 工具箱中的重要工具。我在“EXPLAIN:查询执行计划”中对其进行了解释,并在本书中广泛使用。
查询分析不仅仅是查询指标和元数据:还有应用程序。对于任何查询分析来说,指标和元数据是必不可少的,但只有当你知道查询的目的是什么时,故事才算完整:应用程序为什么要执行这个查询?了解这一点可以让你评估对应用程序的更改,这是第四章的重点。不止一次,我看到工程师意识到一个查询可以简单得多,甚至可以完全移除。
查询指标、元数据和应用程序应该完整地呈现故事。但我不得不提到,有时候,MySQL 和应用程序之外的问题会影响故事的发展,而且通常不是为了更好。“吵闹的邻居”是一个经典案例。如果响应时间很慢,但彻底的查询分析没有揭示原因,那么考虑外部问题。但不要太快就得出这个结论;外部问题应该是例外,而不是规律。
相对值
对于每个查询指标,唯一客观良好的值是零,因为俗话说,做某事的最快方式就是不做。非零值总是相对于查询和应用程序的。例如,一千行发送通常是可以接受的,但如果查询应该返回的只有一行,那就可能很糟糕。相对值在考虑整个故事时是有意义的:指标、元数据和应用程序。
另一个真实故事,用来说明价值观是相对而有意义的完整故事。我继承了一个应用程序,随着时间推移变得越来越慢。这是一个内部应用程序,不是客户使用的,因此修复它并非优先考虑,直到它变得无法忍受的慢。在查询分析中,最慢的查询是检查并返回超过一万行——不是全表扫描,只是很多行。我没有固守在数值上,而是深入源代码挖掘,并发现执行查询的函数仅仅是计算行数,而没有使用这些行。它之所以慢是因为它不必要地访问和返回了数千行,随着数据库增长,行数增加,它变得越来越慢。有了完整的故事,优化变得显而易见和简单:SELECT COUNT(*)。
平均值、百分位和最大值
通常我们谈论查询响应时间时会将其看作单一值,但实际上并非如此。从“聚合”中可以了解到,查询指标是按查询进行分组和聚合的。因此,查询指标被报告为单一的统计值:最小值、最大值、平均值和百分位。您肯定熟悉这些无处不在的“统计”,但关于查询响应时间,以下几点可能会让您感到意外:
-
平均值过于乐观
-
百分位是一个假设
-
最大值是最好的表示
让我解释一下:
平均值
不要被平均值所愚弄:如果查询计数较少,几个非常大或非常小的值可能会使平均响应时间(或任何指标)偏离。此外,如果不知道值的分布情况,我们就无法知道平均值代表了值的百分比。例如,如果平均值等于中位数,那么平均值代表了底部 50%的值,即更好(更快)的响应时间。在这种情况下,平均值过于乐观。(如果忽略最差的一半,大多数值都过于乐观。)平均值只是告诉您,一眼看去,查询通常在微秒、毫秒或秒内执行。不要把它看得太复杂。
百分位
百分位解决了平均值的问题。不详细解释百分位,P95 是小于或等于 95%样本值的值。例如,如果 P95 等于 100 毫秒,则 95%的值小于或等于 100 毫秒,5%的值大于 100 毫秒。因此,P95 代表了 95%的值,这比平均值更客观地代表了——并且更不乐观。使用百分位的另一个原因是:忽略掉的小部分值被视为异常值。例如,网络抖动和突发事件可能导致少量查询执行时间超过正常时间。由于这不是 MySQL 的错,我们将这些执行时间忽略为异常值。
百分位数是标准做法,但它们也是一种假设。是的,可能存在异常值,但它们应该被证明,而不是假设。在未能证明前 N% 不是异常值之前,它们是最有趣的值,因为它们不正常。是什么导致了它们? 这很难回答,这就是为什么百分位数是标准做法的原因:忽略前 N% 的值比深入挖掘并找到答案要容易。
最佳百分位数是 P999(99.9%),因为丢弃 0.1%的值是可以接受的权衡,认为它们是异常值和实际存在异常值之间的权衡。^(4)
最大
最大查询时间解决了百分位数的问题:不要丢弃任何值。最大值不是像平均值那样的神话或统计幻觉。世界上某个地方的某个应用程序用户体验到了最大查询响应时间,或者在几秒钟后放弃了并离开。你应该想知道为什么,你可以找到答案。而解释前 N% 的值很困难,因为有许多值,因此可能有许多不同的答案,解释最大值就是一个单一的值和答案。查询度量工具通常使用具有最大响应时间的查询作为样本,这使得解释几乎是微不足道的,因为你有所谓的铁证。有了这个样本,会发生两件事情中的一件:要么它复制了问题,那么你继续分析;要么它不复制问题,那么你已经证明它是一个可以忽略的异常值。
这是另一个前例的真实故事。一个本来不错的应用程序会随机响应非常缓慢。最小、平均和 P99 查询时间都是毫秒级,但最大查询时间却是秒级。与其忽视最大值,我收集了正常和最大执行时间的查询样本。区别在于 WHERE 子句中的 IN 列表的大小:正常查询时间的值有数百个,而最大查询时间有数千个。获取更多值需要更长时间执行,但是毫秒到秒对于数千个值来说并不正常。EXPLAIN 提供了答案:正常查询时间使用了索引,但最大查询时间导致全表扫描。MySQL 可以切换查询执行计划(参见“It’s a Trap! (When MySQL Chooses Another Index)”),这解释了 MySQL,但是应用程序呢?长话短说,查询用于欺诈检测数据查找,偶尔会一次查找数千行,这导致 MySQL 切换查询执行计划。通常情况下,查询是完全正常的,但深入研究最大响应时间不仅揭示了 MySQL 的陷阱,还提供了通过更有效地处理大型查找来改善应用程序和用户体验的机会。
平均值、百分位数和最大值都是有用的,只是要注意它们所代表的和不代表的。
还要考虑最小值和最大值之间的值分布。如果幸运的话,查询报告会包含直方图,但不要指望:为任意时间范围计算直方图是困难的,因此几乎没有查询度量工具会这样做。基本统计数据(最小值、最大值、平均值和百分位数)足以显示分布的大部分情况,以确定查询是否稳定:度量在每次执行时大致相同。在 第 6 章 中,我将回到稳定性的概念。参见 “正常和稳定:最好的数据库是一种无聊的数据库”。不稳定的查询使分析变得复杂:是什么导致查询的执行方式不同?原因很可能在 MySQL 之外,这使得查找更加困难,但必须找到,因为稳定的查询更容易分析、理解和优化。
提高查询响应时间
提高查询响应时间是一个称为查询优化 的旅程。我称之为旅程是为了设定适当的期望。查询优化需要时间和精力,并且有一个目标:更快的查询响应时间。为了使旅程高效——而不是浪费时间和精力——有两个部分:直接查询优化和间接查询优化。
直接查询优化
直接查询优化 涉及查询和索引的改变。这些改变解决了大量性能问题,这也是为什么优化的旅程始于直接查询优化。而由于这些改变如此强大,优化的旅程通常也在此结束。
让我用一个现在有点简化但稍后会更有洞察力的类比。把一个查询想象成一辆车。当车子跑得不顺畅时,技工们有工具来修理它。有些工具是常见的(比如扳手),而其他的则是专用的(比如双顶置凸轮锁)。一旦技工打开引擎盖找到问题,他们就知道需要哪些工具来修理它。同样,工程师在查询运行缓慢时也有工具来修复它。常见的工具包括查询分析、EXPLAIN 和索引。而专用工具则是特定于查询的优化。举几个例子来自于 MySQL 手册中的 “优化 SELECT 语句”:
-
范围优化
-
索引合并优化
-
散列连接优化
-
索引条件下推优化
-
多范围读优化
-
常量折叠优化
-
IS NULL优化 -
ORDER BY优化 -
GROUP BY优化 -
DISTINCT优化 -
LIMIT查询优化
在这本书中,我不会解释特定于查询的优化,因为《优化》第八章在 MySQL 手册中已经详细解释了,并且它是权威的并定期更新的。此外,特定于查询的优化因 MySQL 版本和发行版而异。相反,我会在第二章教授索引和索引技术:这是知道在修复慢查询时要使用哪些特定查询优化以及如何使用它们的基础。在第二章之后,你将能够像一位高级机械师使用“索引条件下推优化”等专业工具。
时不时地,我会和一些工程师交谈,他们对他们辛勤应用的查询优化未能解决问题感到惊讶和有些不高兴。直接查询优化是必要的,但不总是足够的。优化过的查询可能在不同情况下会成为问题或者变得有问题。当你无法进一步优化查询(或者根本无法优化它,因为你无法访问源代码)时,你可以围绕查询进行优化,这将引导我们进入第二部分旅程:间接查询优化。
间接查询优化
间接查询优化是对数据和访问模式的更改。与其更改查询,你可以更改查询访问的内容和方式:分别是它的数据和访问模式。这些更改间接优化了查询,因为查询、数据和访问模式在性能方面是不可分割的。其中一个的变化会影响其他的。这点容易证明。
假设你有一个慢查询。数据大小和访问模式对于这个证明并不重要,所以你可以想象任何你喜欢的情景。我可以将查询响应时间减少到接近零。 (比如说接近零是 1 微秒。对于计算机来说是很长的时间,但对于人类来说几乎是不可察觉的。)间接的“优化”是:TRUNCATE TABLE。没有数据时,MySQL 可以在接近零的时间内执行任何查询。这是作弊,但它无论如何证明了一个观点:减少数据大小可以提高查询响应时间。
让我们重新思考一下汽车的类比。间接查询优化类似于更改汽车的主要设计元素。例如,重量是燃油效率的一个因素:减少重量可以增加燃油效率。(数据类似于重量,这就是为什么 TRUNCATE TABLE 可以显著提高性能的原因,但不要使用这种“优化”方法。)减少重量不是一个直接的(直接)变化,因为工程师们不能神奇地使零件重量减少。相反,他们必须进行重大的更改,比如从钢材到铝材的转换,这可能会影响许多其他设计元素。因此,这些变化需要更多的努力。
更大程度的努力是为什么间接查询优化是旅程的第二部分。如果直接查询优化解决了问题,那么停止——效率至上。(并祝贺你。)如果没有解决问题,并且你确信查询无法进一步优化,那么现在是改变数据和访问模式的时候了,第三章和 4 章介绍了这部分内容。
何时优化查询
当你修复一个慢查询时,另一个会顶替它的位置。总会有慢查询存在,但你不应总是优化它们,因为这不是高效利用你的时间。相反,请记住“北极星”并问:查询响应时间是否可接受?如果不是,请继续优化查询。如果是,请暂时告一段落,因为当数据库运行快速时,没人会再去关注或提问。
作为数据库管理员,我希望你每周审查查询指标(从“查询概要”开始)并在必要时优化最慢的查询,但作为软件工程师,我知道这是不切实际的,几乎从不发生。而是在这里,有三个场合你应该优化查询。
性能影响客户
当性能影响到客户时,工程师有责任优化查询。我不认为有任何工程师会反对;相反,工程师渴望提高性能。有人可能会说这是个坏建议,因为它是被动而非主动的,但我极力认为工程师(甚至是数据库管理员)在客户报告应用程序过慢或超时之前不会查看查询指标。只要查询指标始终保持开启并准备就绪,这是优化查询的一个绝佳时机,因为客户需要更好的性能同样真实。
代码更改之前和之后
大多数工程师不会反对在代码更改之前和之后优先考虑查询优化,但我的经验是,他们也没有这样做。我恳请你避免这种常见模式:代码进行了看似无害的更改,在演绎环境中经过验证,部署到生产环境后,性能开始“旋转碗”(这是一个与马桶相关的色彩丰富的隐喻,意思是“变得更糟”)。发生了什么?通常原因是查询和访问模式的更改,这两者密切相关。第二章开始解释原因;第三章和 4 章完整解释。目前的重点是:如果在代码更改之前和之后查看查询指标,你将成为英雄。
每个月一次
即使你的代码和查询不改变,它们周围的至少两件事情在改变:数据和访问模式。希望你的应用取得巨大成功,随着用户数量的增长,“上升并向右移动”,它存储的数据越来越多。随着数据和访问模式的改变,查询响应时间也会随之改变。幸运的是,这些变化相对较慢,通常在几周或几个月的时间尺度上。即使是经历超级增长的应用(例如,每天向现有数百万用户添加成千上万的新用户),MySQL 在扩展方面表现非常出色,使得查询响应时间保持稳定——但没有什么能永远持续下去(即使星星也会灭亡)。总会有一个好的查询变坏的时刻。在第 3 和 4 章之后,这一现实变得清晰。目前的重点是:如果你每个月审查一次查询指标,你可能会从英雄变成传奇——也许还会有歌曲和故事以你为主题。
MySQL:加速
没有任何魔法或秘密可以让 MySQL 在不改变查询或应用程序的情况下显著加快速度。这里有另一个真实故事来说明我的意思。
一个开发团队得知他们的应用将被名人提及。他们预计会有大量的流量,因此他们提前计划确保 MySQL 和应用程序能够生存。团队中的一名工程师请我帮忙增加 MySQL 的吞吐量(QPS)。我问:“增加多少?”她说:“增加 100 倍。”我说:“当然。你有一年的时间和重新架构应用程序的意愿吗?”她说:“不,我们只有一天。”
我理解工程师的想法:如果我们显著升级硬件——更多的 CPU 核心、更多的内存、更多的 IOPS,MySQL 能处理多少吞吐量?这并不是一个简单或单一的答案,因为它取决于这本书在即将探讨的许多因素。但有一件事是肯定的:时间是一个硬限制。
一秒钟有 1,000 毫秒——多一毫秒不少。如果一个查询执行需要 100 毫秒,那么它的最坏情况吞吐量是每个 CPU 核心 10 个 QPS:1,000 毫秒 / 100 毫秒/查询 = 10 QPS。(实际吞吐量可能更高——稍后会详细说明。)如果什么都不变,那么就没有更多时间来以更高的吞吐量执行查询了。
要让 MySQL 在相同的时间内执行更多工作,你有三个选择:
-
改变时间的本质
-
减少响应时间
-
增加负载
选项一超出了本书的范围,所以让我们专注于选项二和选项三。
减少响应时间释放出 MySQL 可用来执行更多工作的时间。这是简单的数学问题:如果 MySQL 每秒钟忙碌 999 毫秒,那么它就有一毫秒的空闲时间来做更多工作。如果这还不够的话,那么你必须减少当前工作消耗的时间。实现这一目标的最佳方法是:直接的查询优化。如果无法实现这一点:间接的查询优化。最后:更好、更快的硬件。接下来的章节将教会你如何做到这一点。
增加负载——并发执行的查询数量——往往是首先发生的,因为它不需要任何查询或应用程序更改:只需同时执行更多查询(并发执行),MySQL 会通过使用更多 CPU 核心来响应。这是因为一个 CPU 核心执行一个线程,该线程执行一个查询。最坏的情况是,MySQL 使用 N 个 CPU 核心并发执行 N 个查询。但实际上最坏的情况是几乎不存在,因为响应时间不是 CPU 时间。响应时间中有非零的 CPU 时间,其余的是非 CPU时间等待磁盘 I/O。例如,响应时间可能是 CPU 时间的 10 毫秒和磁盘 I/O 等待的 90 毫秒。因此,一个执行时间为 100 毫秒的查询的最坏情况吞吐量为每 CPU 核心 10 个 QPS,但实际吞吐量应该更高,因为最坏情况实际上几乎不存在。听起来不错,对吧?只需更加努力地推动 MySQL,然后就能获得更好的性能。但你知道故事的结局:过度推动 MySQL,它会停止工作,因为每个系统都有有限的容量。MySQL 可以轻松推动大多数现代硬件的极限,但在阅读“性能在极限处不稳定”之前不要尝试这样做。
要明确一点:MySQL 不能简单地变快。要让 MySQL 变快,你必须进行直接和间接的查询优化之旅。
总结
本章详述了查询时间,以便在随后的章节中学习如何改进它。主要的要点是:
-
性能即查询响应时间:MySQL 执行查询所需的时间。
-
查询响应时间是 MySQL 性能的北极星,因为它是有意义且可操作的。
-
查询指标来自于慢查询日志或性能模式。
-
性能模式是查询指标的最佳来源。
-
查询指标按摘要分组和聚合:标准化的 SQL 语句。
-
查询配置文件显示慢查询;慢是相对于排序度量的。
-
查询报告显示一个查询的所有可用信息;用于查询分析。
-
查询分析的目标是理解查询执行过程,而不是解决响应时间慢的问题。
-
查询分析使用查询指标(按报告)、元数据(解释计划、表结构等)和应用程序知识。
-
每个查询分析都需要九个查询指标:查询时间、锁定时间、检查的行数、发送的行数、受影响的行数、选择扫描、选择全连接、创建的临时磁盘表和查询计数。
-
改善查询响应时间(查询优化)是一个两部分的过程:直接查询优化,然后间接查询优化。
-
直接查询优化是对查询和索引的更改。
-
间接查询优化是对数据和访问模式的更改。
-
-
至少在性能影响客户之前和之后的代码更改前,以及每月一次,查看查询配置文件并优化慢查询。
-
要使 MySQL 运行更快,您必须减少响应时间(用于执行更多工作的空闲时间)或增加负载(推动 MySQL 更加努力)。
下一章将教授 MySQL 索引和索引——直接查询优化。
实践:识别慢查询
此实践的目标是使用pt-query-digest识别慢查询:这是一个从慢查询日志生成查询概要和查询报告的命令行工具。
警告
使用开发或演示 MySQL 实例——不要使用生产环境,除非您确信不会引起问题。慢查询日志本质上是安全的,但在繁忙的服务器上启用它可能会增加磁盘 I/O。
如果有管理 MySQL 的 DBA,请要求他们启用和配置慢查询日志。或者,您可以通过阅读 MySQL 手册中的“慢查询日志”来学习如何操作(您需要一个具有SUPER权限的 MySQL 用户帐户来配置 MySQL)。如果您在云中使用 MySQL,请阅读云服务提供商的文档,了解如何启用和访问慢查询日志。
MySQL 的配置各不相同,但配置和启用慢查询日志的最简单方法是:
SET GLOBAL long_query_time=0;
SET GLOBAL slow_query_log=ON;
SELECT @@GLOBAL.slow_query_log_file;
+-------------------------------+
| @@GLOBAL.slow_query_log_file |
+-------------------------------+
| /usr/local/var/mysql/slow.log |
+-------------------------------+
在第一条语句中,SET GLOBAL long_query_time=0;会导致 MySQL 记录每个查询。请注意:在繁忙的服务器上,这可能会增加磁盘 I/O 并使用大量磁盘空间。如果需要,请使用稍大一些的值,如0.0001(100 微秒)或0.001(1 毫秒)。
注意
Percona Server 和 MariaDB Server 支持慢查询日志抽样:设置系统变量log_slow_rate_limit以记录每 N 个查询中的第 N 个。例如,log_slow_rate_limit = 100表示每 100 个查询记录一次,相当于所有查询的 1%。随着时间的推移,结合long_query_time = 0,这将创建一个代表性的样本。在使用此功能时,请确保查询度量工具能够考虑抽样,否则它会报告不准确的值。pt-query-digest已考虑了抽样。
最后一条语句,SELECT @@GLOBAL.slow_query_log_file;,输出您需要作为pt-query-digest第一个命令行参数的慢查询日志文件名。如果需要,您可以动态更改此变量以将日志记录到不同的文件中。
其次,使用pt-query-digest将慢查询日志文件名作为第一个命令行参数运行。该工具会输出大量内容;但现在,只需查看输出顶部附近的Profile:
# Profile
# Rank Query ID Response time Calls
# ==== =================================== =============== =====
# 1 0x95FD3A847023D37C95AADD230F4EB56A 1000.0000 53.8% 452 SELECT tbl
# 2 0xBB15BFCE4C9727175081E1858C60FD0B 500.0000 26.9% 10 SELECT foo bar
# 3 0x66112E536C54CE7170E215C4BFED008C 50.0000 2.7% 5 INSERT tbl
# MISC 0xMISC 310.0000 16.7% 220 <2 ITEMS>
上述输出是一个基于文本的表格,列出了慢查询日志中最慢的查询。在本例中,SELECT tbl(一个查询摘要)是最慢的查询,占总执行时间的 53.8%。(默认情况下,pt-query-digest按百分比执行时间对查询进行排序。)在查询概要下方,为每个查询打印了一个查询报告。
探索pt-query-digest的输出。其手册详细记录了输出内容,在互联网上有大量信息,因为这个工具被广泛使用。还要查看Percona 监控与管理:这是一个综合性的数据库监控解决方案,使用Grafana报告查询指标。这两个工具都是免费的、开源的,并得到Percona的支持。
通过审查慢查询,您可以准确知道哪些查询需要优化以获得最高效的性能提升。更重要的是,您已经开始像专家一样练习 MySQL 性能:专注于查询,因为性能就是查询响应时间。
^(1) 延迟是系统固有的延迟。查询响应时间不是 MySQL 中固有的延迟;它包括各种延迟:网络延迟、存储延迟等等。
^(2) 除非使用STRAIGHT_JOIN—但不要使用这个选项。让 MySQL 查询优化器选择最佳的查询执行顺序。它几乎总是正确的,所以除非能证明它错了,否则要相信它。
^(3) 欲了解百分位数的详细解释,请参阅HackMySQL。
^(4) P95、P99 和 P999 是常规的百分位数。我从未见过 MySQL 使用其他百分位数的情况——中位数(P50)和最大值(P100)除外。
第二章:索引和索引技术
许多因素决定了 MySQL 的性能,但索引是特殊的,因为没有它们无法实现性能。可以删除其他因素——查询、架构、数据等等,仍然可以实现性能,但删除索引会限制性能到粗暴力:依赖于硬件的速度和容量。如果这本书标题是Brute Force MySQL Performance,其内容就像标题一样长:“购买更好、更快的硬件。”你笑了,但就在几天前,我与一组开发人员会议时,他们一直通过购买更快的硬件来改善云端性能,直到成本飙升,迫使他们询问:“我们如何进一步提升性能?”
MySQL 在访问数据时利用硬件、优化和索引来提高性能。硬件是显而易见的杠杆,因为 MySQL 运行在硬件上:硬件越快,性能越好。而较不明显且可能更令人惊讶的是,硬件提供的杠杆最小。我马上会解释为什么。优化是指众多技术、算法和数据结构,使 MySQL 能够有效地利用硬件。优化使硬件的能力更加明显。而集中则是灯泡和激光之间的区别。因此,优化比硬件提供了更多的杠杆。如果数据库很小,硬件和优化将足以满足需求。但是增加数据规模会减少硬件和优化的好处。没有索引,性能将严重受限。
为了说明这些观点,将 MySQL 想象成一个杠杆,利用硬件、优化和索引来象征性地提升数据,如图 2-1 所示。

图 2-1. 没有索引的 MySQL 性能
没有索引(右侧),MySQL 在相对较小的数据集上的性能有限。但是添加索引到平衡中,如图 2-2 所示,MySQL 在大数据集上可以达到高性能。

图 2-2. 有索引的 MySQL 性能
索引提供了最多和最佳的杠杆效应。对于任何非微不足道的数据量,它们都是必需的。MySQL 的性能需要适当的索引和索引,本章详细介绍了这两者的使用方法。
几年前,我设计并实现了一个存储大量数据的应用程序。最初,我估计最大的表不会超过一百万行。但由于数据存档代码中存在一个 bug,允许表达到十亿行。多年来,没有人注意到,因为响应时间一直很好。为什么?好的索引。
注意
通常说 MySQL 每个表只使用一个索引,但这并不完全正确。例如,索引合并优化可以使用两个索引。在本书中,我专注于正常情况:一个查询、一个表、一个索引。
本章讲解 MySQL 索引和索引。它有五个主要部分。第一部分论述了为什么不应该被硬件或 MySQL 调优分心。这是一个必要的离题讨论,以便完全理解为什么硬件和 MySQL 调优不是提高 MySQL 性能的有效解决方案。第二部分是 MySQL 索引的视觉介绍:它们是什么以及它们如何工作。第三部分教授索引——通过像 MySQL 一样思考应用索引以获得最大的效益。第四部分涵盖索引失效(效益减少)的常见原因。第五部分简要介绍了 MySQL 表连接算法,因为有效的连接依赖于有效的索引。
性能的误导
红鲱鱼是一个指代分散注意力的习语。在追踪改善 MySQL 性能的解决方案时,两个常见的误导工具是更快的硬件和 MySQL 调优。
更好、更快的硬件!
当 MySQL 的性能不可接受时,不要立即通过升级(使用更好、更快的硬件)来“看看是否有帮助”。如果显著扩展,可能确实会有所帮助,但你不会学到任何东西,因为这只证明了你已经知道的事实:计算机在更快的硬件上运行更快。更好、更快的硬件是性能的一个误导,因为你会错过学习慢性能的真正原因和解决方案。
有两种合理的例外情况。首先,如果硬件明显不足,则升级到合理的硬件是必要的。例如,使用 1 GB 内存处理 500 GB 数据显然不足。升级到 32 GB 或 64 GB 内存是合理的。相比之下,升级到 384 GB 内存肯定有帮助,但是不合理。其次,如果应用正在经历超级增长(用户、使用率和数据的大幅增加),并且扩展是保持应用运行的权宜之计,那么就这样做。保持应用程序运行始终是合理的。
否则,为了改善 MySQL 的性能,扩展是最后的选择。专家们一致认为:首先优化查询、数据、访问模式和应用程序。如果所有这些优化都无法提供足够的性能,则才考虑扩展。扩展是最后发生的,原因如下。
通过扩展你不会学到任何东西,你只是用更快的硬件解决了问题。因为你是工程师,而不是住在洞穴里的原始人,你通过学习和理解解决问题——而不是简单地解决问题。诚然,学习和理解更加困难且耗时,但它们更加有效和可持续,这导致了下一个原因。
升级不是一种可持续的方法。升级物理硬件并不简单。有些升级相对快速且简单,但这取决于书籍范围之外的许多因素。然而,足以说,如果你频繁更换硬件,你会让自己或硬件工程师发疯。疯狂的工程师是不可持续的。此外,公司经常使用同样的硬件多年,因为采购过程漫长且复杂。因此,云端的易于扩展硬件是其吸引力之一。在云中,你可以在几分钟内扩展(或减少)CPU 核心、内存和存储。但这种便利比物理硬件显著昂贵。云成本可能会呈指数级增长。例如,亚马逊 RDS 的成本从一个实例大小翻倍到下一个实例大小——硬件加倍,价格也加倍。指数增长的成本是不可持续的。
一般来说,MySQL 可以充分利用其所获得的所有硬件。(在第四章中有相关限制。)真正的问题是:应用程序能否充分利用 MySQL?假设的答案是肯定的,但不能保证。更快的硬件有助于 MySQL,但它不会改变应用程序如何使用 MySQL。例如,增加内存可能不会提高性能,如果应用程序导致表扫描。只有当应用程序工作负载也可以扩展时,扩展才能有效提高性能。并非所有工作负载都可以扩展。
注意
工作负载是查询、数据和访问模式的组合。
但让我们想象一下,你成功地将工作负载扩展到可以充分利用 MySQL 的最快硬件。当应用程序继续增长,其工作负载继续增加时会发生什么?这让我想起了一句禅宗谚语:“当你到达山顶时,继续攀登。”虽然我鼓励你在此上冥想,但对你的应用程序而言,这提出了一个不那么令人愉快的困境。没有其他地方可去,唯一的选择是做本应该首先做的事情:优化查询、数据、访问模式和应用程序。
MySQL 调优
在电视系列星际迷航中,工程师们可以修改飞船以增加引擎、武器、护盾、传感器、传送器、牵引光束——所有设备的功率。MySQL 比星舰更难操作,因为没有这样的修改。但这并不能阻止工程师们尝试。
首先,让我们澄清三个术语。
调优
调优是为研究与开发(R&D)调整 MySQL 系统变量。这是具有特定目标和标准的实验性工作。常见的是基准测试:调整系统变量以测量对性能的影响。著名 MySQL 专家 Vadim Tkachenko 的博客文章 “MySQL Challenge: 100k Connections” 是极端调优的一个例子。由于调优是研究与开发,结果不一定普遍适用;而是扩展我们对 MySQL 的集体知识和理解,尤其是在其当前限制方面。调优会影响未来 MySQL 的发展和最佳实践。
配置
配置是将系统变量设置为适合硬件和环境的值。目标是合理配置,相对于需要更改的几个默认值。配置 MySQL 通常在 MySQL 实例被提供或硬件变更时进行。例如,从 10 GB 到 100 GB 数据量增加一个数量级时,重新配置也是必要的。配置会影响 MySQL 的整体运行。
优化
优化是通过减少工作负载或使其更高效来提高 MySQL 的性能——通常是后者,因为应用程序使用往往会增加。目标是更快的响应时间和更大的容量利用现有硬件。优化会影响 MySQL 和应用程序的性能。
无疑你会在 MySQL 文献、视频、会议等中遇到这些术语。重要的是描述而不是术语本身。例如,如果你读到一个博客帖子使用了 优化 这个术语,但描述的是这里定义为 调优 的内容,那么这里是调优。
这些术语的区别很重要,因为工程师会做这三种工作,但只有优化(如此处定义)才是你时间的有效利用。^(1)
MySQL 的调优是性能的一个误导,有两个原因。首先,通常不作为受控实验室实验,这使得结果不可靠。总体而言,MySQL 的性能是复杂的,实验必须仔细控制。其次,结果不太可能对性能产生显著影响,因为 MySQL 已经高度优化。调优 MySQL 就像从萝卜中榨血一样。
回到本节的第一个段落,我意识到我们都钦佩《星际迷航:下一代》中的首席工程师乔迪·拉·福奇中尉。当舰长呼唤更多的能量时,我们感到有义务通过应用神秘的服务器参数使之成真。或者,在地球上,当应用程序需要更多能量时,我们希望通过精巧的 MySQL 重新配置来提升吞吐量和并发性能达到 50%。干得好,拉·福奇!不幸的是,MySQL 8.0 版本通过启用innodb_dedicated_server引入了自动配置。由于 MySQL 5.7 版本不久后即将到达生命周期末尾(EOL),让我们继续前进,建设未来。不过,拉·福奇的工作仍然值得称赞。
优化是你需要做的一切,因为调优是一个误导,而配置在 MySQL 8.0 版本后是自动的。本书的核心是优化。
MySQL 索引:视觉介绍
索引是提升性能的关键,如果你回忆起“直接查询优化”,对查询和索引的修改能够解决大量的性能问题。优化查询的旅程需要对 MySQL 索引有扎实的理解,这正是本节详细展示的内容,配有大量插图。
尽管本节内容详细且相对较长,但我将其称为介绍,因为还有更多内容需要学习。但这一节是打开 MySQL 查询优化宝藏箱的关键。
下面九个章节仅适用于 InnoDB 表的标准索引——通过简单的PRIMARY KEY或[UNIQUE] INDEX表定义创建的索引类型。MySQL 支持其他专门的索引类型,但本书不涵盖它们,因为标准索引是性能的基础。
在深入探讨 MySQL 索引的细节之前,我首先揭示了关于 InnoDB 表的一个重要信息,这将改变你对索引以及大部分 MySQL 性能的看法。
InnoDB 表就是索引
示例 2-1 展示了表elem(简称元素)的结构及其包含的 10 行内容。本章所有示例都涉及表elem——只有一个明确标注的例外——因此请花些时间来研究它。
示例 2-1. 表elem
CREATE TABLE `elem` (
`id` int unsigned NOT NULL,
`a` char(2) NOT NULL,
`b` char(2) NOT NULL,
`c` char(2) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_a_b` (`a`,`b`)
) ENGINE=InnoDB;
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 1 | Ag | B | C |
| 2 | Au | Be | Co |
| 3 | Al | Br | Cr |
| 4 | Ar | Br | Cd |
| 5 | Ar | Br | C |
| 6 | Ag | B | Co |
| 7 | At | Bi | Ce |
| 8 | Al | B | C |
| 9 | Al | B | Cd |
| 10 | Ar | B | Cd |
+----+------+------+------+
表elem有两个索引:在列id上的主键索引和在列a, b上的非唯一次要索引。列id的值是单调递增的整数。列a, b和c的值是对应于列名字母的原子符号:“Ag”(银)对应列a,“B”(硼)对应列b,依此类推。行值是随机且无意义的;这只是一个用于示例的简单表格。
图 2-3 展示了表elem的典型视图——为了简洁起见,只显示了前四行。

图 2-3. 表elem:视觉模型
表 elem 没什么特别的,对吧?它很简单,可以说是基本的。但如果我告诉你,它并不是一张表,而是一个索引呢?把“F”(氟)扔出去吧!图 2-4 显示了表 elem 的真实结构,作为一个 InnoDB 表。

图 2-4. 表 elem:InnoDB B 树索引
InnoDB 表是按主键组织的 B 树索引。行是存储在索引结构叶节点中的索引记录。每个索引记录都有元数据(用“…”表示),用于行锁定、事务隔离等。
图 2-4 是表 elem 的 B 树索引的高度简化描述。四个索引记录(在底部)对应于前四行。主键列值(1, 2, 3, 和 4)显示在每个索引记录的顶部。其他列值(“Ag,” “B,” “C,” 等)显示在每个索引记录的元数据下方。
你不需要了解 InnoDB B 树索引的技术细节,才能理解或达到出色的 MySQL 性能。只有两点是重要的:
-
主键查找非常快速和高效。
-
主键对于 MySQL 的性能至关重要。
第一个观点是正确的,因为 B 树索引本质上快速和高效,这也是为什么许多数据库服务器使用它们的原因之一。第二个观点在接下来的章节和章节中将变得越来越清晰。
想了解数据库内部的迷人世界,包括索引,请阅读 Database Internals 一书,作者是 Alex Petrov(O’Reilly,2019 年出版)。要深入了解 InnoDB 内部,包括其 B 树实现,请取消所有会议并查看著名 MySQL 专家 Jeremy Cole 的网站。
注意
InnoDB 主键是一个聚簇索引。MySQL 手册偶尔将主键称为 聚簇索引。
索引提供了最大和最佳的杠杆,因为表 就是 一个索引。主键对性能至关重要。这一点尤其重要,因为二级索引包含主键值。图 2-5 显示了在列 a, b 上的二级索引。

图 2-5. 列 a, b 的二级索引
二级索引也是 B 树索引,但叶节点存储主键值。当 MySQL 使用二级索引查找行时,它会在主键上进行第二次查找,以读取完整行。让我们将这两者结合起来,以及通过查询 SELECT * FROM elem WHERE a='Au' AND b='Be' 进行二级索引查找的步骤:

图 2-6. 值为 “Au, Be” 的二级索引查找
图 2-6 显示了顶部的二级索引(列 a, b)和底部的主键(列 id)。六个标注点(编号圆圈)显示了使用二级索引查找值“Au, Be”的过程:
-
索引查找从根节点开始;分支右到内部节点,值为“Au, Be.”。
-
在内部节点,向右分支到值为“Au, Be.”的叶节点。
-
二级索引值为“Au, Be”的叶节点包含相应的主键值:2。
-
从根节点开始主键查找;向左分支到值为 2 的内部节点。
-
在内部节点,向右分支到值为 2 的叶节点。
-
一级键值为 2 的叶节点包含匹配“Au, Be.”的完整行。
注意
表只有一个主键。所有其他索引都是次要索引。
这一部分非常重要,因为正确的模型为理解索引及更多提供了基础。例如,如果你回想起“Lock time”,你可能会以新的视角看待它,因为行实际上是主键中的叶节点。知道 InnoDB 表是其主键等同于知道在太阳系中,日心说而不是地心说是正确的模型。在 MySQL 的世界中,一切围绕主键展开。
表访问方法
使用索引查找行是三种表访问方法之一。由于表是索引,索引查找是最好且最常见的访问方法。但有时,根据查询,索引查找是不可能的唯一选择,而唯一的补救方法是索引扫描或表扫描,即其他访问方法。了解 MySQL 用于查询的访问方法至关重要,因为性能要求索引查找。避免索引扫描和表扫描。“EXPLAIN:查询执行计划”展示了如何查看访问方法。但首先,让我们澄清并可视化每种方法。
注意
MySQL 手册使用术语访问方法、访问类型和连接类型。EXPLAIN使用名为type或access_type的字段来指代这些术语。在 MySQL 中,这些术语密切相关但等效使用。
在本书中,为了精确性和一致性,我只使用两个术语:访问方法和访问类型。有三种访问方法:索引查找、索引扫描和表扫描。对于索引查找,有几种访问类型:ref、eq_ref、range等。
索引查找
通过利用索引的有序结构和算法访问,索引查找可以找到特定行或行范围。这是最快的访问方法,因为索引正是为此设计的:快速高效地访问大量数据。因此,索引查找对于直接查询优化至关重要。性能要求几乎每个查询都对每个表使用索引查找。索引查找的访问类型有几种,我将在接下来的章节中详细介绍,比如“WHERE”。
在前一节的图 2-6 中显示了使用次要索引进行索引查找。
索引扫描
当无法进行索引查找时,MySQL 必须使用蛮力方法查找行:读取所有行并过滤出不匹配的行。在 MySQL 采用读取每一行使用主键之前,它尝试使用次要索引读取行。这称为索引扫描。
有两种类型的索引扫描。第一种是完整索引扫描,意味着 MySQL 按索引顺序读取所有行。通常读取所有行对性能来说很糟糕,但当索引顺序与查询的 ORDER BY 匹配时,按顺序读取可以避免对行进行排序。
图 2-7 展示了查询 SELECT * FROM elem FORCE INDEX (a) ORDER BY a, b 的完整索引扫描。FORCE INDEX 子句是必需的,因为由于表 elem 很小,MySQL 更高效地扫描主键并对行进行排序,而不是扫描次要索引并按顺序获取行。 (有时候糟糕的查询可以作为好的例子。)
图 2-7 包含八个标注(编号圆圈),显示了行访问的顺序:
-
读取次要索引(SI)的第一个值:“Ag, B.”
-
查找主键(PK)中对应的行。
-
读取 SI 的第二个值:“Al, Br.”
-
查找主键中对应的行。
-
读取 SI 的第三个值:“Ar, Br.”
-
查找主键(PK)中对应的行。
-
读取 SI 的第四个值:“Au, Be.”
-
查找主键(PK)中对应的行。

图 2-7. 次要索引的完整索引扫描
图 2-7 中有一个微妙但重要的细节:按顺序扫描次要索引可能是顺序读取,但主键查找几乎肯定是随机读取。按索引顺序访问行并不能保证顺序读取;很可能会产生随机读取。
注意
顺序访问(读取和写入)比随机访问更快。
第二种索引扫描类型是仅索引扫描:MySQL 从索引中读取列值(而不是完整行)。这需要一个覆盖索引,稍后会介绍(这里的“覆盖索引”)。它应该比完整索引扫描更快,因为它不需要主键查找来读取完整行;它只从次要索引中读取列值,这也是为什么它需要一个覆盖索引的原因。
除非唯一的选择是全表扫描,否则不要优化为索引扫描。否则,避免索引扫描。
表扫描
(完整)表扫描按主键顺序读取所有行。当 MySQL 无法进行索引查找或索引扫描时,表扫描是唯一的选择。这通常对性能很差,但通常很容易修复,因为 MySQL 擅长使用索引并具有许多基于索引的优化。基本上每个带有 WHERE、GROUP BY 或 ORDER BY 子句的查询都可以使用索引——即使只是索引扫描——因为这些子句使用列,而列可以被索引。因此,几乎没有不可修复的表扫描的理由。
图 2-8 展示了一个全表扫描:按照主键顺序读取所有行。其中有四个标记显示了行访问的顺序。表 elem 很小,这里只显示了四行,但想象一下 MySQL 在真实表中处理成千上万行或百万行的情况。
一般建议和最佳实践是避免表扫描。但为了全面和平衡的讨论,有两种情况下表扫描可能是可接受的或(令人惊讶地)更好:
-
当表格很小且访问不频繁时
-
当表选择性非常低时(参见 “极端选择性”)

图 2-8. 全表扫描
但不要认为任何表扫描都是理所当然的:它们通常对性能不利。在极少数情况下,MySQL 可能会错误地选择表扫描,而实际上可以使用索引查找,详见 “陷阱!(当 MySQL 选择另一个索引)”。
最左前缀要求
要使用索引,查询必须使用索引的最左前缀:即索引定义中指定的最左边的一个或多个索引列。最左前缀是必需的,因为底层索引结构是按照索引列顺序排序的,只能按照这个顺序进行遍历(搜索)。
注意
使用 SHOW CREATE TABLE 或 SHOW INDEX 查看索引定义。
图 2-9 展示了一个在列 a, b, c 上的索引和 WHERE 子句使用每个最左前缀的情况:列 a;列 a, b;和列 a, b, c。

图 2-9. 三列索引的最左前缀
图 2-9 中的顶部 WHERE 子句使用了列 a,它是索引的最左列。中间的 WHERE 子句使用了列 a 和 b,它们一起形成了索引的最左前缀。底部的 WHERE 子句使用了整个索引:所有三列。使用索引列可以在其他 SQL 子句中使用,正如在接下来的几节中的许多示例所示。
提示
要使用索引,查询必须使用索引的最左前缀。
最左前缀要求有两个逻辑结果:
-
索引
(a, b)和(b, a)是不同的索引。它们索引相同的列但顺序不同,导致了不同的最左前缀。然而,一个查询如果同时使用了两个列(例如WHERE a = 'Au' AND b = 'Be'),可以选择使用任一索引,但这并不意味着这些索引在性能上是等效的。MySQL 会通过计算多个因素选择更好的一个。 -
MySQL 很可能可以使用索引
(a, b, c)替代索引(a)和(a, b),因为后两者是第一个索引的最左前缀。在这种情况下,索引(a)和(a, b)是重复的,可以被删除。使用 pt-duplicate-key-checker 来查找并报告重复的索引。
每个辅助索引的末尾(最右端)隐藏着主键。对于表 elem(示例 2-1),辅助索引实际上是 (a, b, id),但最右端的 id 是隐藏的。MySQL 不显示主键附加到辅助索引;你必须想象它。
注意
主键附加到每个辅助索引:(S, P),其中 S 是辅助索引列,P 是主键列。
在 MySQL 的术语中,我们说,“主键附加到辅助索引”即使实际上并非如此。(你可以通过创建索引 (a, b, id) 来实现这种“附加”,但最好不要这样做。)“附加到”实际上意味着辅助索引叶子节点包含主键值,正如前文 图 2-5 所示。这一点很重要,因为它增加了每个辅助索引的大小:主键值在辅助索引中重复出现。更大的索引需要更多的内存,这意味着更少的索引可以放入内存中。保持主键的大小小而辅助索引的数量合理。就在前几天,我的同事们正在帮助一个数据库团队,其数据库的辅助索引占据了 693 GB 的空间,而数据只有 397 GB(主键)。
最左前缀要求既是一种祝福,也是一种限制。限制相对容易通过额外的辅助索引来解决,但等到你读完 “过多、重复和未使用的索引” 之后再做决定。在面对连接表的特殊挑战时,最左前缀要求尤为重要,不过我在 “连接表” 中有所讨论。我鼓励你将最左前缀要求视为一种祝福。关于索引的查询优化并不简单,但最左前缀要求是旅程中一个简单且熟悉的起点。
EXPLAIN:查询执行计划
MySQL 的 EXPLAIN 命令显示了一个 查询执行计划(或者 EXPLAIN 计划),描述了 MySQL 计划如何执行查询:表的连接顺序、表的访问方法、索引使用以及其他重要细节。
EXPLAIN 输出非常广泛且多样化。此外,它完全依赖于查询本身。在查询中改变一个字符可以显著改变其 EXPLAIN 计划。例如,WHERE id = 1 和 WHERE id > 1 的查询计划显著不同。并且更复杂的是,EXPLAIN 继续发展演变。MySQL 手册中的 “EXPLAIN 输出格式” 是必读的,即使是对于专家来说也是如此。幸运的是,基本原理几十年来基本保持不变,这对我们的理解是有利的。
为了说明索引的使用情况,接下来的五个部分解释了 MySQL 可以使用索引的每种情况下的查询:
-
查找匹配的行:“WHERE”
-
分组行:“GROUP BY”
-
排序行:“ORDER BY”
-
避免读取行:“覆盖索引”
-
连接表:“连接表”
还有其他特定情况,如 MIN() 和 MAX(),但这五种情况是索引使用的基础。
但首先,我需要通过查看 示例 2-2 中显示的 EXPLAIN 输出字段的含义来设定舞台。
示例 2-2. EXPLAIN 输出(传统格式)
EXPLAIN SELECT * FROM elem WHERE id = 1\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: const
rows: 1
filtered: 100.00
Extra: NULL
在此介绍中,我们忽略 id、select_type、partitions、key_len 和 filtered 字段;但示例包含它们是为了让你习惯这些输出。其余的七个字段传达了组成查询执行计划的大量信息:
table
table 字段是表名(或别名)或子查询引用。表按照 MySQL 确定的连接顺序列出,而不是查询中出现的顺序。顶部表是第一个表,底部表是最后一个表。
type
type 字段是表访问方法或索引查找访问类型,详见 “表访问方法” 的第一个注释进行澄清。ALL 表示全表扫描(参见 “表扫描”)。index 表示索引扫描(参见 “索引扫描”)。其他任何值,如 const、ref、range 等,都是索引查找的访问类型(参见 “索引查找”)。
possible_keys
possible_keys 字段列出 MySQL 可以使用的索引,因为查询使用了最左前缀。如果一个索引没有列在这个字段中,则未满足最左前缀的要求。
key
key 字段是 MySQL 将使用的索引名称,如果无法使用索引则为 NULL。MySQL 根据多种因素选择最佳索引,其中一些因素在 Extra 字段中指示。在执行查询时,MySQL 很可能会使用该索引(EXPLAIN 不执行查询),但参见 “陷阱!(当 MySQL 选择另一个索引)”。
ref
ref 字段列出用于在索引中查找行的值来源(key 字段)。
对于单表查询或连接中的第一个表,ref 常常是 const,指的是一个或多个索引列上的常量条件。常量条件 是等号(= 或 <=> [NULL-safe equal])与字面值相等。例如,a = 'Au' 是一个只等于一个值的常量条件。
对于连接多个表的查询,ref 是在连接顺序中前一个表的列引用。MySQL 使用索引来查找与前一个表中列 ref 中的值匹配的行,当前表(table 字段)通过 “连接表” 显示这一过程。
rows
rows字段是 MySQL 将检查以查找匹配行的估计行数。MySQL 使用索引统计信息来估计行数,因此实际数值——“检查的行数”——可能会接近但不同。
Extra
Extra字段提供了关于查询执行计划的额外信息。这个字段很重要,因为它指示了 MySQL 可以应用的查询优化。
注意
本书中所有的EXPLAIN输出都采用传统格式:表格输出(EXPLAIN query;)或列表输出(EXPLAIN query\G)。其他格式包括JSON(EXPLAIN FORMAT=JSON query)以及从 MySQL 8.0.16 开始的树形(EXPLAIN FORMAT=TREE query)。JSON 和树形格式与传统格式完全不同,但所有格式都表达了查询执行计划。
不要期望在没有上下文的情况下从这些字段中获取太多信息:表、索引、数据和查询。在接下来的几节中,所有的插图都涉及到表elem(示例 2-1),它的两个索引以及它的十行数据。
WHERE
MySQL 可以使用索引来查找与表条件在WHERE子句中匹配的行。我要小心地说 MySQL 可以 使用索引,而不是 MySQL 将 使用索引,因为索引的使用取决于几个因素,主要包括表条件、索引和最左前缀要求(参见“最左前缀要求”)。(还有其他因素,如索引统计信息和优化器成本,但超出了本书的范围。)
表条件是匹配、分组、聚合或排序行的列及其值(如果有)。在WHERE子句中,表条件也称为谓词。
图 2-10 展示了基于列id的主键和一个带有单个条件的WHERE子句:id = 1。

图 2-10. WHERE:主键查找
一个实心框划定了一个表条件和一个索引列(也称为索引部分),MySQL 可以使用它,因为前者(表条件)是后者(索引)的最左前缀。箭头从表条件指向它使用的索引列。稍后,我们将看到 MySQL 无法使用的表条件和索引列的示例。
在图 2-10 中,MySQL 可以使用主键列id查找满足条件id = 1的行。示例 2-3 是完整查询的 EXPLAIN 计划。
示例 2-3. 主键查找的 EXPLAIN 计划
EXPLAIN SELECT * FROM elem WHERE id = 1\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: const
rows: 1
filtered: 100.00
Extra: NULL
在示例 2-3 中,key: PRIMARY确认 MySQL 将使用主键——一个索引查找。相应地,访问类型(type字段)不是ALL(全表扫描)或index(索引扫描),这在简单的主键查找中是预期的。次要索引未列在possible_keys字段中,因为 MySQL 无法在此查询中使用它:列id不是列a, b上次要索引的最左前缀。
访问类型 const 是一个特例,只有当主键或唯一辅助索引的所有索引列上有常量条件 (ref: const) 时才会发生。结果是一个常量行。这对于入门来说有点深奥,但既然我们在这里,就让我们继续学习。考虑到表数据(示例 2-1)和列 id 是主键,标识为 id = 1 的行可以视为常量,因为在执行查询时,id = 1 只能匹配一行(或零行)。MySQL 读取那一行,并将其值视为常量,这对于响应时间非常有利:const 访问非常快速。
Extra: NULL 是比较罕见的,因为实际查询比这些示例更复杂。但在这里,Extra: NULL 表示 MySQL 不需要匹配行。为什么?因为常量行只能匹配一行(或零行)。但匹配行是正常情况,所以让我们通过将表条件更改为 id > 3 AND id < 6 AND c = 'Cd' 来看一个更现实的示例,如图 2-11 和相应的 EXPLAIN 计划中所示的 示例 2-4。

图 2-11. WHERE: 使用主键进行范围访问
Example 2-4. 使用主键进行范围访问的解释计划
EXPLAIN SELECT * FROM elem WHERE id > 3 AND id < 6 AND c = 'Cd'\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: range
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
> ref: NULL
> rows: 2
filtered: 10.00
> Extra: Using where
注
为了突出 EXPLAIN 计划的变化,我在发生变化的关键字段前面加上 > 字符。这些突出显示不是 EXPLAIN 的一部分。
通过将表条件更改为 id > 3 AND id < 6 AND c = 'Cd',EXPLAIN 计划从示例 2-3 变更为更适合单表查询的 示例 2-4。查询仍然使用主键(key: PRIMARY),但访问类型更改为范围扫描(type: range):使用索引读取值在 3 到 6 之间的行。ref 字段为 NULL,因为列 id 上的条件不是常量(这是单表查询,因此没有前置表可以引用)。条件 c = 'Cd' 是常量,但它不用于索引查找(范围扫描),因此 ref 不适用。MySQL 估计将在范围内检查两行(rows: 2)。对于这个简单的示例来说是正确的,但请记住:rows 是一个估算值。
“Using where” 在 Extra 字段中非常常见,这是预期的。它表示 MySQL 将使用 WHERE 条件来找到匹配的行:对于每一行读取,如果所有 WHERE 条件都为真,则行匹配。由于列 id 上的条件定义了范围,因此 MySQL 实际上只会使用列 c 上的条件来匹配范围内的行。回顾示例 2-1,一行匹配所有 WHERE 条件:
+----+------+------+------+
| id | a | b | c |
+----+------+------+------+
| 4 | Ar | Br | Cd |
+----+------+------+------+
具有 id = 5 的行在范围内,因此 MySQL 检查该行,但其列 c 的值(“Cd”)与 WHERE 子句不匹配,因此 MySQL 不返回该行。
为了说明其他查询执行计划,让我们使用次要索引的左前缀,如图 2-12 所示,并在示例 2-5 中使用相应的 EXPLAIN 计划。

图 2-12. WHERE: 次要索引查找
示例 2-5. 次要索引查找的 EXPLAIN 计划
EXPLAIN SELECT * FROM elem WHERE a = 'Au'\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: ref
possible_keys: idx_a_b
> key: idx_a_b
key_len: 3
ref: const
rows: 1
filtered: 100.00
Extra: NULL
EXPLAIN SELECT * FROM elem WHERE a = 'Au' AND b = 'Be'\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: ref
possible_keys: idx_a_b
> key: idx_a_b
key_len: 6
ref: const,const
rows: 1
filtered: 100.00
Extra: NULL
对于示例 2-5 中的每个 EXPLAIN 计划,key: idx_a_b确认 MySQL 使用次要索引,因为条件满足左前缀要求。第一个WHERE子句仅使用第一个索引部分:列a。第二个WHERE子句使用两个索引部分:列a和b。仅使用列b不会满足左前缀要求—稍后我会展示这一点。
与以前的 EXPLAIN 计划相比,新的重要访问类型是:ref。简单来说,ref访问类型是索引左前缀(key字段)上的等值(=或<=>)查找。与任何索引查找一样,只要估计的要检查的行数(rows字段)合理,ref访问非常快速。
虽然条件是固定的,但由于索引(key: idx_a_b)不唯一,所以不可能使用const访问类型,因此查找可以匹配多行。即使 MySQL 估计每个WHERE子句只检查一行(rows: 1),在执行查询时可能会改变。
Extra: NULL再次出现,因为 MySQL 可以仅使用索引找到匹配的行,而没有非索引列的条件—因此让我们添加一个。图 2-13 展示了在列a和c上带有条件的WHERE子句,而示例 2-6 是相应的 EXPLAIN 计划。

图 2-13. WHERE: 索引查找和非索引列
Example 2-6. 索引查找和非索引列的解释计划
EXPLAIN SELECT * FROM elem WHERE a = 'Al' AND c = 'Co'\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: ref
possible_keys: idx_a_b
key: idx_a_b
key_len: 3
ref: const
> rows: 3
filtered: 10.00
> Extra: Using where
在图 2-13 中,条件c = 'Co'周围没有框,因为索引不覆盖列c。MySQL 仍然使用次要索引(key: idx_a_b),但是对列c的条件阻止 MySQL 仅使用索引匹配行。相反,MySQL 使用索引查找和读取列a的行,然后匹配列c的条件(Extra: Using where)。
再次回顾示例 2-1,你会注意到零行匹配此WHERE子句,但EXPLAIN报告rows: 3。为什么?在列a上的索引查找匹配了三行,其中a = 'Al'为真:行id值为 3、8 和 9。但这些行中没有一行同时匹配c = 'Co'。查询检查了三行但匹配了零行。
提示
EXPLAIN输出的rows是 MySQL 执行查询时将检查的行数的估计值,而不是将匹配所有表条件的行数。
最后,作为索引、WHERE 和 EXPLAIN 的示例,让我们不满足左前缀的要求,正如 图 2-14 和 示例 2-7 所示。

图 2-14. 没有左前缀的 WHERE
示例 2-7. 没有左前缀的 WHERE 的 EXPLAIN 计划
EXPLAIN SELECT * FROM elem WHERE b = 'Be'\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: ALL
possible_keys: NULL
> key: NULL
key_len: NULL
ref: NULL
rows: 10
filtered: 10.00
Extra: Using where
虚线框和缺少箭头标明了一个表条件和一个索引列,MySQL 无法使用它们,因为它们不满足左前缀的要求。
在 图 2-14 中,列 a 没有任何条件,因此索引不能用于列 b 的条件。EXPLAIN 计划 (示例 2-7) 证实了这一点:possible_keys: NULL 和 key: NULL。没有索引,MySQL 必须执行全表扫描:type: ALL。同样,rows: 10 反映了总行数,Extra: Using where 表明 MySQL 读取并过滤不符合 b = 'Be' 的行。
示例 2-7 是最糟糕的 EXPLAIN 计划的示例。每当你看到 type: ALL、possible_keys: NULL 或 key: NULL,请停下当前的工作并分析这个查询。
尽管这些示例很简单,它们代表了关于索引和 WHERE 子句的 EXPLAIN 的基础。实际查询可能有更多的索引和 WHERE 条件,但基础原理不会改变。
GROUP BY
MySQL 可以使用索引优化 GROUP BY,因为值按照索引顺序隐式分组。对于二级索引 idx_a_b(在列 a, b 上),有五个不同的列 a 值分组,如 示例 2-8 所示。
示例 2-8. 列 a 值的不同分组
SELECT a, b FROM elem ORDER BY a, b;
+------+------+
| a | b |
+------+------+
| Ag | B | -- Ag group
| Ag | B |
| Al | B | -- Al group
| Al | B |
| Al | Br |
| Ar | B | -- Ar group
| Ar | Br |
| Ar | Br |
| At | Bi | -- At group
| Au | Be | -- Au group
+------+------+
我在 示例 2-8 中用空行分隔了分组,并对每组的第一行进行了注释。带有 GROUP BY a 的查询可以使用索引 idx_a_b,因为列 a 是左前缀,索引隐式地按列 a 的值分组。示例 2-9 是最简单类型的 GROUP BY 优化的代表性 EXPLAIN 计划。
示例 2-9. GROUP BY a 的 EXPLAIN 计划
EXPLAIN SELECT a, COUNT(*) FROM elem GROUP BY a\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: index
possible_keys: idx_a_b
key: idx_a_b
key_len: 6
ref: NULL
rows: 10
filtered: 100.00
> Extra: Using index
key: idx_a_b 确认 MySQL 使用索引优化了 GROUP BY。由于索引是有序的,MySQL 可以确保每个新的列 a 值都是一个新的分组。例如,在读取最后一个 "Ag" 值后,索引顺序保证不会再读取更多的 "Ag" 值,因此 "Ag" 组是完整的。
在 Extra 字段中的 “Using index” 表示 MySQL 只从索引中读取列 a 值;它没有从主键读取完整的行。我在 “覆盖索引” 中介绍了这种优化。
此查询使用了索引,但不是为了索引查找:type: index 表示索引扫描(见 “索引扫描”)。由于没有 WHERE 子句来过滤行,MySQL 会读取所有行。如果添加了 WHERE 子句,MySQL 仍然可以用于 GROUP BY,但左前缀要求仍然适用。在这种情况下,查询正在使用左前缀索引部分(列 a),因此 WHERE 条件必须在列 a 或 b 上才能满足左前缀要求。让我们先在列 a 上添加一个 WHERE 条件,如 图 2-15 和 示例 2-10 所示。

图 2-15. 在同一索引列上的 GROUP BY 和 WHERE
示例 2-10. 在同一索引列上的 GROUP BY 和 WHERE 的 EXPLAIN 计划
EXPLAIN SELECT a, COUNT(a) FROM elem WHERE a != 'Ar' GROUP BY a\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: range
possible_keys: idx_a_b
key: idx_a_b
key_len: 3
ref: NULL
rows: 7
filtered: 100.00
> Extra: Using where; Using index
“Using where” 在 Extra 字段中指的是 WHERE a != 'Ar'。有趣的变化是 type: range。范围访问类型与不等操作符 (!= 或 <>) 一起工作。你可以将它想象成 WHERE a < 'Ar' AND a > 'Ar',如 图 2-16 所示。
WHERE 子句中对列 b 的条件仍然可以使用索引,因为条件虽然在不同的 SQL 子句中,但仍满足左前缀要求。图 2-17 展示了这一点,而 示例 2-11 显示了 EXPLAIN 计划。

图 2-16. 不等号范围

图 2-17. 不同索引列上的 GROUP BY 和 WHERE
示例 2-11. 不同索引列上的 GROUP BY 和 WHERE 的 EXPLAIN 计划
EXPLAIN SELECT a, b FROM elem WHERE b = 'B' GROUP BY a\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: range
possible_keys: idx_a_b
key: idx_a_b
key_len: 6
ref: NULL
rows: 6
filtered: 100.00
> Extra: Using where; Using index for group-by
示例 2-11 中的查询有两个重要细节:WHERE 子句中对列 b 的等式条件以及在 SELECT 子句中选择列 a 和 b。这些细节使得在 Extra 字段中揭示了特殊的“用于 GROUP BY 的索引优化”。例如,如果将等式 (=) 改为不等 (!=),查询优化将丧失。在涉及到这样的查询优化时,细节至关重要。你必须阅读 MySQL 手册以了解和应用这些细节。MySQL 手册中的 “GROUP BY 优化” 有详细说明。
在 图 2-18 和 示例 2-12 中的最终 GROUP BY 示例可能会让您感到惊讶。

图 2-18. 没有左前缀的 GROUP BY
示例 2-12. 没有左前缀的 GROUP BY 的 EXPLAIN 计划
EXPLAIN SELECT b, COUNT(*) FROM elem GROUP BY b\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
> type: index
possible_keys: idx_a_b
key: idx_a_b
key_len: 6
ref: NULL
rows: 10
filtered: 100.00
> Extra: Using index; Using temporary
注意 key: idx_a_b:尽管查询条件中没有涉及列 a,MySQL 仍然使用了索引。左前缀要求是怎么实现的?因为 MySQL 正在扫描列 a 上的索引 (type: index)。你可以想象一个条件,如 a = a,在列 a 上总是为真。
MySQL 对于 GROUP BY c 仍然会在列 a 上进行索引扫描吗?不会,它会执行全表扫描。图 2-18 之所以有效,是因为索引具有列 b 的值,而不具有列 c 的值。
在 Extra 字段中的“Using temporary”是没有严格的最左前缀条件的一个副作用。当 MySQL 从索引中读取列 a 的值时,它会在一个临时表中(内存中)收集列 b 的值。在读取所有列 a 的值后,它会扫描临时表以进行 COUNT(*) 的分组和聚合。
关于 GROUP BY 与索引以及查询优化还有很多内容需要学习,但这些示例是基础知识。与 WHERE 子句不同,GROUP BY 子句往往更简单。挑战在于创建一个能够优化 GROUP BY 加其他 SQL 子句的索引。当制定查询执行计划时,MySQL 面临相同的挑战,因此即使可能也不一定会优化 GROUP BY。MySQL 几乎总是选择最佳的查询执行计划,但如果您想尝试不同的计划,请阅读 MySQL 手册中的“索引提示”。
ORDER BY
毫不奇怪,MySQL 可以使用有序索引来优化 ORDER BY。这种优化避免了对行进行排序,通过按顺序访问行来节省一点时间。如果没有这种优化,MySQL 将读取所有匹配行,对它们进行排序,然后返回排序后的结果集。当 MySQL 对行进行排序时,在 EXPLAIN 计划的 Extra 字段中会打印“Using filesort”。Filesort 意味着 排序行。这是一个历史悠久(现在有些误导性的)术语,但在 MySQL 术语中仍然很流行。
对于工程师来说,Filesort 是一个让人头疼的问题,因为它以慢速著称。排序行是额外的工作,因此它不会提高响应时间,但通常不是响应时间缓慢的根本原因。在本节的最后,我使用 EXPLAIN ANALYZE,这是自 MySQL 8.0.18 以来的新功能,来测量 Filesort 的实时性能影响。(剧透:排序行非常快。)但首先,让我们看看如何使用索引来优化 ORDER BY。
有三种方法可以使用索引来优化 ORDER BY。第一种和最简单的方法是使用索引的最左前缀来对 ORDER BY 子句进行排序。对于表 elem,即:
-
ORDER BY id -
ORDER BY a -
ORDER BY a, b
第二种方法是将索引的最左部分保持不变,并按照下一个索引列排序。例如,保持列 a 不变,并按列 b 排序,如图 2-19 所示,其对应的 EXPLAIN 计划见示例 2-13。

图 2-19. 不同索引列上的 ORDER BY 和 WHERE
示例 2-13. 不同索引列上的 ORDER BY 和 WHERE 的 EXPLAIN 计划
EXPLAIN SELECT a, b FROM elem WHERE a = 'Ar' ORDER BY b\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: ref
possible_keys: idx_a_b
key: idx_a_b
key_len: 3
ref: const
rows: 3
filtered: 100.00
Extra: Using index
WHERE a = 'Ar' ORDER BY b 可以使用索引 (a, b),因为对第一个索引部分(列 a)的 WHERE 条件是常量,所以 MySQL 直接跳到 a = 'Ar' 的位置,然后按顺序读取列 b 的值。 示例 2-14 是结果集,虽然没有什么特别之处,但显示了列 a 是常量(值为“Ar”),而列 b 是排序的。
示例 2-14. WHERE a = 'Ar' ORDER BY b 的结果集
+------+------+
| a | b |
+------+------+
| Ar | B |
| Ar | Br |
| Ar | Br |
+------+------+
如果表 elem 在列 a, b, c 上有索引,那么像 WHERE a = 'Au' AND b = 'Be' ORDER BY c 这样的查询可以使用索引,因为对列 a 和 b 的条件满足了索引的最左前缀要求。
第三种方式是第二种的特例。在展示解释它的图之前,看看能否确定为什么 示例 2-15 的查询 不 导致文件排序(为什么 Extra 字段没有报告“Using filesort”)。
示例 2-15. ORDER BY id 的 EXPLAIN 计划
EXPLAIN SELECT * FROM elem WHERE a = 'Al' AND b = 'B' ORDER BY id\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: ref
possible_keys: idx_a_b
key: idx_a_b
key_len: 16
ref: const,const
rows: 2
filtered: 100.00
> Extra: Using index condition
可以理解查询使用索引 idx_a_b,因为 WHERE 条件是最左前缀,但是 ORDER BY id 不应该导致文件排序吗?图 2-20 揭示了答案。

图 2-20. 使用附加到次要索引的主键的 ORDER BY
“最左前缀要求” 开头的段落说:“在每个次要索引的末尾(最右端)潜藏着主键。” 这正是 图 2-20 中发生的:围绕索引列 id 的深色框显示了附加到次要索引的“隐藏”主键。这种 ORDER BY 优化在像 elem 这样的小表中可能看起来不那么有用,但在真实的表中却非常有用——值得记住。
为了证明“隐藏”的主键允许 ORDER BY 避免文件排序,让我们去除对列 b 的条件以使优化失效,如 图 2-21 中所示,并跟随结果的 EXPLAIN 计划在 示例 2-16 中展示。

图 2-21. 没有最左前缀的 ORDER BY
示例 2-16. 没有最左前缀的 ORDER BY 的 EXPLAIN 计划
EXPLAIN SELECT * FROM elem WHERE a = 'Al' ORDER BY id\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: ref
possible_keys: idx_a_b
key: idx_a_b
key_len: 8
ref: const
rows: 3
filtered: 100.00
> Extra: Using index condition; Using filesort
通过移除列 b 的条件,次要索引上不再有最左前缀,MySQL 无法使用“隐藏”主键来优化 ORDER BY。因此,对于这个特定查询,“Using filesort” 出现在 Extra 字段中。
新的优化是“使用索引条件”,称为索引条件下推。索引条件下推意味着存储引擎使用索引来匹配WHERE条件的行。通常,存储引擎只读取和写入行,MySQL 处理匹配行的逻辑。这是关注点的清晰分离(这是软件设计的优点),但当行不匹配时效率低下:MySQL 和存储引擎都浪费时间读取非匹配的行。对于 示例 2-16 中的查询,索引条件下推意味着存储引擎(InnoDB)使用索引 idx_a_b 来匹配条件 a = 'Al'。索引条件下推有助于提高响应时间,但不必费力优化它,因为 MySQL 在可能时会自动使用它。要了解更多,请阅读 MySQL 手册中的 “索引条件下推优化”。
所有ORDER BY优化都受一个重要细节的影响:索引默认按升序排序,而ORDER BY col意味着升序:ORDER BY col ASC。对所有列只能进行一个方向的ORDER BY优化:ASC(升序)或DESC(降序)。因此,ORDER BY a, b DESC不起作用,因为列a是隐式的ASC排序,与b DESC不同。
注意
MySQL 8.0 支持降序索引。
文件排序的真正时间成本是多少?在 MySQL 8.0.18 之前,它既没有被测量也没有被报告。但从 MySQL 8.0.18 开始,EXPLAIN ANALYZE对其进行了测量和报告。仅对 示例 2-17,我必须使用另一个表。
示例 2-17. Sysbench 表sbtest
CREATE TABLE `sbtest1` (
`id` int NOT NULL AUTO_INCREMENT,
`k` int NOT NULL DEFAULT '0',
`c` char(120) NOT NULL DEFAULT '',
`pad` char(60) NOT NULL DEFAULT '',
PRIMARY KEY (`id`),
KEY `k_1` (`k`)
) ENGINE=InnoDB;
那是一个标准的sysbench表;我加载了 100 万行数据。让我们使用一个随机的、无意义的查询,带有大量结果集和ORDER BY:
SELECT c FROM sbtest1 WHERE k < 450000 ORDER BY id;
-- Output omitted
68439 rows in set (1.15 sec)
查询花费 1.15 秒来排序并返回超过 68,000 行。但这并不是一个糟糕的查询;看看它的 EXPLAIN 计划:
EXPLAIN SELECT c FROM sbtest1 WHERE k < 450000 ORDER BY id\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: sbtest1
partitions: NULL
type: range
possible_keys: k_1
key: k_1
key_len: 4
ref: NULL
rows: 133168
filtered: 100.00
Extra: Using index condition; Using MRR; Using filesort
那个 EXPLAIN 计划中唯一的新信息是“使用 MRR”在Extra字段中,它指的是 “多范围读取优化”。否则,该 EXPLAIN 计划报告的信息已在本章中涵盖。
文件排序是否使得这个查询变慢?EXPLAIN ANALYZE揭示了答案,尽管是神秘的:
EXPLAIN ANALYZE SELECT c FROM sbtest1 WHERE k < 450000 ORDER BY id\G
*************************** 1\. row ***************************
1 -> Sort: sbtest1.id (cost=83975.47 rows=133168)
2 (actual time=1221.170..1229.306 rows=68439 loops=1)
3 -> Index range scan on sbtest1 using k_1, with index condition: (k<450000)
4 (cost=83975.47 rows=133168) (actual time=40.916..1174.981 rows=68439)
EXPLAIN ANALYZE 的实际输出更宽,但我为打印的易读性和参考包装和编号了行。 EXPLAIN ANALYZE 的输出密集且需要实践才能理解;现在,让我们直奔主题——或尽可能直接,因为输出并非按顺序阅读。在第 4 行,1174.981(毫秒)表示索引范围扫描(第 3 行)花费了 1.17 秒(四舍五入)。在第 2 行,1221.170..1229.306 表示文件排序(第 1 行)开始于 1,221 毫秒,结束于 1,229 毫秒,这意味着文件排序花费了 8 毫秒。总执行时间为 1.23 秒:95% 的时间用于读取行,不到 1% 的时间用于排序行。剩下的 4%——大约 49 毫秒——花费在其他阶段:准备、统计、日志记录、清理等等。
答案是否定的:filesort 不会 使得这个查询变慢。问题在于数据访问:68,439 行并不是一个小的结果集。对于必须遍历索引、管理事务等的关系数据库来说,读取 68,439 行是相当大的工作量。要优化这样的查询,重点放在 “数据访问” 上。
最后一个要解决的问题是:为什么 filesort 被认为是慢的?因为 MySQL 在排序数据超过 sort_buffer_size 时使用磁盘上的临时文件,而硬盘比内存慢几个数量级。这在几十年前尤为明显,当时旋转硬盘是常态;但今天,SSD 是常态,总体存储速度相当快。对于高吞吐量(QPS)的查询,Filesort 可能是个问题,但使用 EXPLAIN ANALYZE 来测量和验证。
警告
EXPLAIN ANALYZE 执行查询。为了安全起见,在只读复制品上使用 EXPLAIN ANALYZE,而不是源。
现在回到表 elem(示例 2-1)和 MySQL 可以使用索引的下一个案例:覆盖索引。
覆盖索引
覆盖索引 包括查询中引用的所有列。图 2-22 展示了一个用于 SELECT 语句的覆盖索引。

图 2-22. 覆盖索引
WHERE 条件的列 a 和 b 如往常一样指向相应的索引列,但这些索引列也指向 SELECT 子句中相应的列,表示这些列的值是从索引中读取的。
通常情况下,MySQL 从主键读取完整行(回顾 “InnoDB Tables Are Indexes”)。但是通过覆盖索引,MySQL 只能从索引中读取列值。这在处理次要索引时尤其有帮助,因为它避免了主键查找。
MySQL 自动使用覆盖索引优化,并在 EXPLAIN 中将其报告为“Using index”在 Extra 字段中。“Using index for group-by” 是一个类似于 GROUP BY 和 DISTINCT 的优化,如GROUP BY中演示的那样。但是,“Using index condition” 和 “Using index for skip scan” 是完全不同且无关的优化。
索引扫描(type: index)加上覆盖索引(Extra: Using index)是仅索引扫描(参见“索引扫描”)。在“GROUP BY”中有两个示例:示例 2-9 和 示例 2-12。
覆盖索引很吸引眼球,但实际上很少实用,因为现实查询涉及太多列、条件和子句,一个索引很难覆盖。不要花时间尝试创建覆盖索引。在设计或分析使用非常少列的简单查询时,可以考虑一下是否适合使用覆盖索引。如果适合,那么恭喜你。如果不适合,没关系;没有人期望有覆盖索引。
连接表格
MySQL 使用索引来连接表,这种用法与用索引做其他事情基本相同。主要区别在于每个表连接条件中使用的值的来源。当可视化后更加清晰,但首先我们需要第二个表进行连接。示例 2-18 显示了表 elem_names 的结构及其包含的 14 行。
示例 2-18. 表 elem_names
CREATE TABLE `elem_names` (
`symbol` char(2) NOT NULL,
`name` varchar(16) DEFAULT NULL,
PRIMARY KEY (`symbol`)
) ENGINE=InnoDB;
+--------+-----------+
| symbol | name |
+--------+-----------+
| Ag | Silver |
| Al | Aluminum |
| Ar | Argon |
| At | Astatine |
| Au | Gold |
| B | Boron |
| Be | Beryllium |
| Bi | Bismuth |
| Br | Bromine |
| C | Carbon |
| Cd | Cadmium |
| Ce | Cerium |
| Co | Cobalt |
| Cr | Chromium |
+--------+-----------+
表 elem_name 有一个索引:在列 symbol 上的主键。列 symbol 中的值与表 elem 的列 a、b 和 c 中的值相匹配。因此,我们可以在这些列上连接表 elem 和 elem_names。
图 2-23 显示了一个连接表 elem 和 elem_names 的 SELECT 语句,以及每个表的条件和索引的视觉表示。
在前面的图表中,只有一个索引和 SQL 子句对,因为只有一个表。但是图 2-23 有两对,每个表用大右指向的尖括号分隔,并在每个表中注释表名:/* elem */ 和 /* elem_names */。像 EXPLAIN 一样,这些图表按连接顺序列出表格:从上到下。表 elem(顶部)是连接顺序中的第一个表,表 elem_names(底部)是第二个表。

图 2-23. 在主键查找中连接表
在表 elem 上使用索引并不新鲜或特别:MySQL 使用索引来进行条件 a IN (…)。到目前为止,一切顺利。
表 elem_names 上的索引使用与前一张表连接基本相同,有两个小区别。首先,WHERE 子句是 JOIN…ON 子句的重写——稍后详细讨论。其次,列 symbol 条件的值来自前一张表 elem。为了表示这一点,箭头指向从前一张表到角括号中的列引用:<elem.a>。在连接时,MySQL 使用来自表 elem 中匹配行的列 a 值来查找表 elem_names 中的行,用于列 symbol 的连接条件。在 MySQL 的术语中,我们会说,“symbol 等于表 elem 中列 a 的值。”给定前一张表的值,对列 symbol 的主键查找没有什么新鲜或特别之处:如果匹配到行,则返回并与前一张表的行连接。
示例 2-19 展示了在 Figure 2-23 中 SELECT 语句的 EXPLAIN 计划。
示例 2-19. 主键查找连接表的 EXPLAIN 计划
EXPLAIN SELECT name
FROM elem JOIN elem_names ON (elem.a = elem_names.symbol)
WHERE a IN ('Ag', 'Au', 'At')\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: range
possible_keys: idx_a_b
key: idx_a_b
key_len: 3
ref: NULL
rows: 4
filtered: 100.00
Extra: Using where; Using index
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: elem_names
partitions: NULL
> type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
> ref: test.elem.a
rows: 1
filtered: 100.00
Extra: NULL
在每张表的基础上,示例 2-19 中的EXPLAIN 计划并不新鲜,但连接在第二张表 elem_names 中揭示了两个新细节。第一个是 eq_ref 访问类型:使用主键或唯一非空的次要索引进行单行查找。(在此上下文中,非空 表示所有次要索引列都定义为 NOT NULL。)关于 eq_ref 访问类型的更多内容在下一段。第二个是 ref: test.elem.a,你可以理解为“参考列 elem.a”。(数据库名称为 test,因此带有 test. 前缀。)为了连接表 elem_names,通过参考列 elem.a 的值来通过主键(key: PRIMARY)查找行,这覆盖了连接列 symbol。这对应于 JOIN 条件:ON (elem.a = elem_names.symbol)。
提示
在每张表的基础上,连接不会改变索引的使用方式。主要区别在于连接条件的值来自前一张表。
MySQL 可以使用任何访问方法连接表(参见 “表访问方法”),但是使用 eq_ref 访问类型的索引查找是最好且最快的,因为它仅匹配一行。eq_ref 访问类型有两个要求:主键或唯一非空次要索引 和 所有索引列上的等式条件。这两个要求一起保证了 eq_ref 查找最多匹配一行。如果这两个要求没有同时满足,则 MySQL 可能会使用 ref 索引查找,本质上与 eq_ref 相同,但匹配任意数量的行。
回到 Figure 2-23,我如何知道将 JOIN…ON 子句重写为 elem_names 表的 WHERE 子句?如果在 EXPLAIN 之后立即 SHOW WARNINGS,MySQL 将打印出它如何重写查询的输出。这是 SHOW WARNINGS 的摘要输出:
/* select#1 */ select
`test`.`elem_names`.`name` AS `name`
from
`test`.`elem`
join `test`.`elem_names`
where
((`test`.`elem_names`.`symbol` = `test`.`elem`.`a`)
and (`test`.`elem`.`a` in ('Ag','Au','At')))
现在你可以看到 Figure 2-23 中的 /* elem_names */ WHERE symbol = <elem.a> 是正确的。
有时,在 EXPLAIN 之后立即运行 SHOW WARNINGS,查看 MySQL 如何重写查询是必要的,以了解表连接顺序和 MySQL 选择的索引。
注意
SHOW WARNINGS 显示的重写 SQL 语句并不打算是有效的。它们只是展示了 MySQL 如何解释和重写 SQL 语句。不要执行它们。
表连接顺序至关重要,因为 MySQL 以最佳可能的顺序连接表,而不是 查询中表的顺序。您必须使用 EXPLAIN 来查看表连接顺序。EXPLAIN 按从顶部(第一个表)到底部(最后一个表)的连接顺序打印表格。默认的连接算法,嵌套循环连接,遵循连接顺序。我在本章末尾概述了连接算法:“表连接算法”。
永远不要猜测或假设表连接顺序,因为查询的微小变化可能导致显著不同的表连接顺序或查询执行计划。为了证明这一点,在 图 2-24 中的 SELECT 语句几乎与 图 2-23 中的 SELECT 语句相同,只有一个微小的不同 — 你能找到它吗?

图 2-24. 二级索引查找连接表
这里有个提示:它既不是黄金也不是银. 微小的差异导致了一个显著不同的查询执行计划,如 例 2-20 所示。
例 2-20. 二级索引查找连接表的 EXPLAIN 计划
EXPLAIN SELECT name
FROM elem JOIN elem_names ON (elem.a = elem_names.symbol)
WHERE a IN ('Ag', 'Au')\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem_names
partitions: NULL
type: range
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: NULL
rows: 2
filtered: 100.00
Extra: Using where
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: ref
possible_keys: idx_a_b
key: idx_a_b
key_len: 3
ref: test.elem_names.symbol
rows: 2
filtered: 100.00
Extra: Using index
语法上,图 2-23 和 2-24 中的 SELECT 语句是相同的,但执行计划(例 2-19 和 2-20)却有显著不同。发生了什么变化呢?在 图 2-24 中,从 IN() 列表中移除了一个单值:“At.” 这是一个很好的例子,展示了一个看似无害的变化如何触发 MySQL 查询执行计划器中的某些内容,然后,新的和不同的 EXPLAIN 计划就出来了。让我们一起分析 例 2-20 中的表格。
第一个表是 elem_names,与查询写法 elem JOIN elem_names 不同。MySQL 决定表的连接顺序,而不是 JOIN 子句。^(2) type 和 key 字段表明了对主键的范围扫描,但值从哪里来呢?ref 字段为 NULL,在这个表上没有 WHERE 条件。MySQL 必须已经重写了查询;这是 SHOW WARNINGS 的摘要输出:
/* select#1 */ select
`test`.`elem_names`.`name` AS `name`
from
`test`.`elem` join `test`.`elem_names`
where
((`test`.`elem`.`a` = `test`.`elem_names`.`symbol`)
and (`test`.`elem_names`.`symbol` in ('Ag','Au')))
是的,在最后一行看到了:MySQL 重写查询,使用 IN() 列表作为 elem_names.symbol 的值,而不是最初在查询中写的 elem.a。现在您可以看到(或者想象到),在 elem_names.symbol 表上的索引使用是一个范围扫描,用于查找两个值:“Ag” 和 “Au”。使用主键,这将是一个非常快速的索引查找,仅匹配 MySQL 将用于连接第二个表的两行。
第二个表是 elem,并且解释计划很熟悉:使用索引 idx_a_b 查找匹配条件(因为 Extra: Using index)的索引值,而不是行。该条件的值来自前一个表中匹配行,如 ref: test.elem_names.symbol 所示。
提示
MySQL 会按照最佳顺序连接表,而不是查询中表的书写顺序。
尽管 MySQL 可以改变连接顺序并重写查询,但连接的索引使用基本上与本章中先前展示和解释的一样——在每个表的基础上。使用 EXPLAIN 和 SHOW WARNINGS,并逐个表从顶部到底部考虑执行计划。
MySQL 可以在没有索引的情况下连接表。这被称为完全连接,是查询中最糟糕的事情。单表查询的表扫描是糟糕的,但完全连接更糟,因为在连接表中,对连接表的表扫描不会发生一次,而是对前一个表的每个匹配行都会发生一次。示例 2-21 展示了第二个表的完全连接。
示例 2-21. 完全 JOIN 的 EXPLAIN 计划
EXPLAIN SELECT name
FROM elem STRAIGHT_JOIN elem_names IGNORE INDEX (PRIMARY)
ON (elem.a = elem_names.symbol)\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: index
possible_keys: idx_a_b
key: idx_a_b
key_len: 6
ref: NULL
rows: 10
filtered: 100.00
Extra: Using index
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: elem_names
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 14
filtered: 7.14
Extra: Using where; Using join buffer (hash join)
通常情况下,MySQL 不会选择这个查询执行计划,这就是为什么我不得不使用 STRAIGHT_JOIN 和 IGNORE INDEX (PRIMARY) 强制它执行。对第一个表 elem 进行的仅索引扫描返回了十行。^(3) 对于每一行,MySQL 通过执行全表扫描 (type: ALL) 来查找匹配行,从而将第二个表 elem_names 加入。因为这是一个连接表(不是连接顺序中的第一个表),所以表扫描被视为完全连接。完全连接是查询中最糟糕的事情,因为它发生在前一个表的每一行上:对表 elem_names 的十次全表扫描。每当在连接表中看到 type: ALL 时,立即停止当前操作并修复它。有一个关于完全连接的查询指标:“选择完全连接”。
“Using join buffer (hash join)” 在 Extra 字段中指的是哈希连接算法,自 MySQL 8.0.18 起推出。我在本章末尾概述了它(以及其他连接算法):“表连接算法”。预览一下,简短的解释是:哈希连接构建一个内存中的哈希表来查找行,而不是进行重复的表扫描。哈希连接是巨大的性能提升。尽管如此,避免完全连接仍然是最佳实践。
注意
在 MySQL 8.0 之前,示例 2-21 中的查询在 Extra 字段中报告“Using join buffer (Block Nested Loop)”,因为它使用了不同的连接算法:块嵌套循环。“表连接算法” 概述了这种连接算法。
乍一看,连接表似乎是一种不同类别的索引使用,但实际上并非如此。连接涉及更多的表和索引,但基于每个表,索引使用和要求是相同的。甚至左前缀要求也是相同的。主要区别在于,对于连接的表,连接条件的值来自前面的表。
自从第一个例子“WHERE”以来已经阅读了很长时间。现在你已经看到了许多完整的上下文示例,涵盖了 MySQL 索引、查询和 EXPLAIN 计划的技术细节和机制。这些信息是直接查询优化的基础,下一节将在此基础上展开。
索引:如何像 MySQL 一样思考
索引和索引是不同的主题。前一节介绍了索引:InnoDB 表的标准 B 树索引适用于 WHERE、GROUP BY、ORDER BY、覆盖索引和表连接。本节介绍了索引:应用索引以最大程度地发挥其作用。你不能简单地为每一列都创建索引以实现出色的性能。如果那样简单,就不会有 DBA 了。要实现最大的效益,你必须为 MySQL 在执行查询时可以访问的行数最少的列创建索引。用比喻的方式来说:最大的效益是一种索引,告诉 MySQL 究竟在哪里找到大海捞针。
根据我的经验,工程师在索引方面往往感到困惑,因为他们将如何思考查询与 MySQL 如何“思考”查询混淆了。作为工程师,我们在应用程序的上下文中思考查询:应用程序的哪个部分执行查询、为什么(业务逻辑)以及正确的结果集。但是 MySQL 不知道也不关心任何这些。MySQL 在一个更小、更简单的上下文中思考:索引和表条件。在幕后,MySQL 更复杂,但它成功隐藏了这种复杂性,这也是它迷人之处之一。
我们如何知道 MySQL 怎么考虑索引和表条件? EXPLAIN。而 EXPLAIN 报告的主要信息是什么?表(按连接顺序)、表访问方法、索引以及与这些索引相关的Extra信息。
以 MySQL 的方式思考使索引更容易,因为它是一个确定性的机器——算法和启发式。人类的思维被琐碎的细节所缠绕。清理你的思绪,准备好像机器一样思考。接下来的四节将通过一个简单的四步过程进行讲解。
了解查询
学习像 MySQL 一样思考的第一步是了解你正在优化的查询的基本信息。首先收集每个表的以下元数据:
-
SHOW CREATE TABLE -
SHOW TABLE STATUS -
SHOW INDEXES
如果查询已经在生产中运行,请获取其查询报告(参见“Query report”)并熟悉当前的值。
然后回答以下问题:
查询
-
查询应该访问多少行?
-
查询应该返回多少行?
-
选择了哪些列(返回)?
-
GROUP BY、ORDER BY和LIMIT子句是什么? -
是否有子查询?(如果有,请对每个子查询重复此过程。)
表访问(每个表)
-
表条件是什么?
-
查询应该使用哪个索引?
-
其他哪些索引可以查询使用?
-
每个索引的基数是多少?
-
表的大小有多大——数据大小和行数?
这些问题帮助您在脑中解析查询,因为这就是 MySQL 所做的:解析查询。这对于以更简单的术语看待复杂查询尤为有用:表、表条件、索引和 SQL 子句。
这些信息帮助您拼凑一个谜题,一旦完成,就能揭示查询响应时间。要改善响应时间,您需要更改一些部分。但在此之前,下一步是利用EXPLAIN组装当前的部分。
用EXPLAIN解释
第二步是理解EXPLAIN报告的当前查询执行计划。考虑每个表及其条件与其索引的关系,从 MySQL 选择的索引开始:EXPLAIN输出中的key字段。查看表条件,看它们如何满足此索引的最左前缀要求。如果possible_keys字段列出其他索引,请考虑 MySQL 如何使用这些索引访问行——始终牢记最左前缀要求。如果Extra字段有信息(通常有),则参考 MySQL 手册中的“EXPLAIN 输出”以了解其含义。
提示
总是要EXPLAIN查询。养成这个习惯,因为没有EXPLAIN就无法进行直接的查询优化。
查询及其响应时间是一个谜题,但您拥有所有的部分:执行计划、表条件、表结构、表大小、索引基数和查询指标。不断连接这些部分,直到谜题完成——直到您可以看到 MySQL 如何解释它的查询工作。查询执行计划总有其原因。[⁴] 有时 MySQL 非常聪明,使用了一个不明显的查询优化,通常在Extra字段中提到。如果您遇到SELECT语句的情况,请参阅 MySQL 手册中的“优化 SELECT 语句”。
如果遇到困难,有三个不断增强的支持级别:
-
自 MySQL 8.0.16 起,
EXPLAIN FORMAT=TREE以树状输出打印更精确和描述性的查询执行计划。这是与传统格式完全不同的输出,因此您需要学习如何解释它,但这是值得的努力。 -
使用优化器跟踪报告一个极其详细的查询执行计划,包括成本、考虑因素和原因。这是一个非常高级的功能,学习曲线陡峭,所以如果您时间紧张,可能更喜欢第三个选项。
-
询问您的 DBA 或聘请专家。
优化查询
第三步是直接查询优化:修改查询、其索引或两者。这是所有有趣的地方,而且目前没有风险,因为这些更改是在开发或测试环境中进行的,不是生产环境。确保你的开发或测试环境具有生产环境的代表性数据,因为数据大小和分布会影响 MySQL 选择索引的方式。
起初,可能会觉得查询不能修改,因为它检索了正确的行,所以查询写得正确。一个查询就是一个查询,对吧?不总是这样;不同的方法可以达到相同的结果。一个查询有一个结果——确切地说,一个结果集——和获取该结果的方法。这两者密切相关但独立。当考虑如何修改查询时,了解这一点非常有帮助。首先澄清查询的预期结果。清晰的结果可以让你探索写入查询的新方法,以达到相同的结果。
提示
可以有多种方法编写查询,它们的执行方式不同但返回相同的结果。
例如,不久前我帮助一位工程师优化一个慢查询。他向我提出的问题很技术化——关于GROUP BY和索引的某些问题——但我问他:“这个查询做了什么?它应该返回什么?”他说:“哦!它返回每个组的最大值。”在澄清了查询的预期结果后,我意识到他并不需要每个组的最大值,他只需要整体的最大值。因此,查询完全被重写为使用ORDER BY col DESC LIMIT 1优化。
当一个查询非常简单,比如SELECT col FROM tbl WHERE id = 1,可能真的没有办法重写它。但查询越简单,就越不需要重写它。如果一个简单的查询很慢,解决方案很可能是改变索引而不是查询本身。(如果索引更改无法解决问题,那么旅程将继续:间接查询优化,在第三章和第四章中讨论。)
添加或修改索引是在访问方法和特定查询优化之间权衡的结果。例如,你会为了范围扫描交换ORDER BY优化吗?不要陷入权衡利弊的困境;MySQL 会为你处理。^(5) 你的任务很简单:添加或修改一个你认为会为 MySQL 提供更大优势的索引,然后使用EXPLAIN来查看 MySQL 是否通过使用新索引来达成一致意见。重复此过程,直到你和 MySQL 就写入、索引和执行查询的最优方式达成一致。
警告
不要在生产环境中修改索引,直到你彻底验证了在测试环境中的更改。
部署和验证
最后一步是部署更改并验证其是否改善了响应时间。但首先要知道如何回滚部署——并且随时准备做到这一点——以防更改带来意外副作用。这发生的原因有很多;两个例子是:在生产中运行但在演示中未运行的查询使用了索引,或者生产数据与演示数据显著不同。大多数情况下都会没事,但要为可能不好做好准备。
提示
总是要知道如何——并且随时准备——回滚到生产部署。
部署后,使用查询指标和 MySQL 服务器指标验证更改。如果查询优化有显著影响,MySQL 服务器指标将反映出来。(第六章详细讨论了 MySQL 服务器指标。)这种情况发生时非常棒,但如果没有发生也不要感到惊讶或灰心,因为最重要的改变是查询响应时间——回顾“北极星”。
等待五到十分钟(最好更长时间),然后在查询概要和查询报告中检查响应时间。 (参见“查询概要”和“查询报告”。)如果响应时间有所改善,那么恭喜你:你正在做和完成 MySQL 专家所做的事情;有了这个技能,你可以实现卓越的 MySQL 性能。如果响应时间没有改善,不要担心,也不要放弃:即使是 MySQL 专家也会遇到需要费力的查询。重复这个过程,并考虑寻求另一位工程师的帮助,因为有些查询需要费力解决。如果确定查询无法进一步优化,那么就该进行旅程的第二部分了:间接查询优化。第三章讨论了数据的变化,第四章讨论了应用的变化。
直到……依然是一个好的索引
如果什么都不改变,一个好的索引将一直是一个好的索引,直到时间的尽头。(但如果真的什么都不变,时间会结束吗?)现实情况是,总会有变化,使得一个好的索引变坏,并降低性能。以下是导致这种遗憾(但是可以避免和纠正的)下降的常见原因。
查询发生变化
当查询发生变化——这种情况经常发生——最左前缀需求可能会丢失。最糟糕的情况是 MySQL 没有其他可用的索引,因此退而求其次:全表扫描。但表通常有很多索引,MySQL 会努力使用一个索引,因此更可能的情况是查询响应时间因为其他索引不如原索引而明显变差。查询分析和 EXPLAIN 计划能够快速揭示这种情况。假设查询变更是必要的,这是一个合理的假设,解决方案是为新的查询变种重新建立索引。
过多、重复和未使用的
索引对于性能是必需的,但有时工程师们会过分依赖它们,导致过多的索引、重复的索引(dupes)和未使用的索引。
多少索引才算太多?比需要的多一个。过多的索引会引起两个问题。第一个问题已在“左前缀要求”中提到:增加了索引大小。更多的索引使用更多的 RAM,具有讽刺意味的是,这会减少每个索引可用的 RAM。第二个问题是写入性能的降低,因为当 MySQL 写入数据时,它必须检查、更新和可能重新组织(内部 B 树结构的)每个索引。过多的索引会严重降低写入性能。
当您创建重复索引时,用于创建它的ALTER语句会生成一个警告,但您必须SHOW WARNINGS来查看它。要查找现有的重复索引,使用pt-duplicate-key-checker:它安全地查找和报告重复索引。
未使用的索引更难识别,因为例如,如果索引每周只被长时间运行的分析查询使用一次,那么怎么办呢?除了这种边缘情况外,执行以下查询以列出未使用的索引:
SELECT * FROM sys.schema_unused_indexes
WHERE object_schema NOT IN ('performance_schema');
该查询使用MySQL sys Schema,这是一组现成视图的集合,返回各种信息。视图sys.schema_unused_indexes查询性能模式和信息模式表,以确定自 MySQL 启动以来未使用的索引。 (执行SHOW CREATE VIEW sys.schema_unused_indexes以查看此视图的工作原理。)必须启用性能模式;如果尚未启用,请与您的 DBA(或管理 MySQL 的其他人员)联系,因为启用它需要重新启动 MySQL。
删除索引时要小心。从 MySQL 8.0 开始,请使用invisible indexes来验证是否在删除之前未使用或不需要该索引:将索引设为不可见,等待并验证性能是否受影响,然后再删除索引。不可见索引非常适合此目的,因为一旦犯错,使索引可见几乎是瞬间完成的,而重新添加索引可能需要数分钟(或数小时)在大表上,如果错误导致应用停机,这将感觉像是漫长的永恒。在 MySQL 8.0 之前,谨慎是唯一的解决方案:与团队讨论,搜索应用程序代码,并利用您对应用程序的了解仔细彻底地验证该索引是否未使用或不需要。
警告
删除(移除)索引时要小心。如果一个被删除的索引被查询使用,并且 MySQL 无法使用另一个索引,则查询将返回到全表扫描。如果删除的索引影响多个查询,这并不罕见,它可能会导致性能下降的连锁反应,最终导致应用程序停机。
极端选择性
基数 是索引中唯一值的数量。在值为 a, a, b, b 的索引上,基数为 2:a 和 b 是两个唯一值。使用 SHOW INDEX 查看索引基数。
选择性 是基数除以表中行数。使用相同的示例 a, a, b, b,每个值为一行,索引选择性为 2 / 4 = 0.5. 选择性范围从 0 到 1,其中 1 是唯一索引:每行一个值。MySQL 不显示索引选择性;您必须使用 SHOW INDEX 获取基数和 SHOW TABLE STATUS 获取行数来手动计算它。
具有极低选择性的索引提供的影响很小,因为每个唯一值可能匹配大量行。经典示例是仅有两个可能值的列上的索引:是或否、真或假、咖啡或茶等等。如果表中有 100,000 行,则选择性几乎为零:2 / 100,000 = 0.00002. 这是一个索引,但不是一个好的索引,因为每个值可能匹配许多行。有多少?反过来除:100,000 行 / 2 个唯一值 = 每个值 50,000 行。如果 MySQL 使用此索引(可能性很小),单个索引查找可能匹配 50,000 行。这假设值均匀分布,但如果 99,999 行的值为 咖啡,只有 1 行的值为 茶 呢?那么此索引对于茶来说效果很好,但对于咖啡则效果很差。
如果查询使用具有极低选择性的索引,请查看是否可以创建更好、更有选择性的索引;或者考虑重写查询以使用更有选择性的索引;或者考虑更改架构以更好地组织数据,以适应访问模式 —— 更多信息请参见第四章。
具有极高选择性的索引可能被过度利用。随着非唯一次要索引的选择性接近 1,这就开始提出是否应该使索引唯一,或者甚至更好的是是否可以重写查询以使用主键。这样的索引不会影响性能,但值得探索替代方案。
如果有许多具有极高选择性的次要索引,这可能表明访问模式通过不同的标准或维度查看或搜索整个表(假设索引被使用且不存在重复)。例如:想象一个产品库存表,应用程序通过许多不同的标准搜索,每个标准都需要满足最左前缀的索引要求。在这种情况下,Elasticsearch 可能比 MySQL 更好地服务访问模式。
这是一个陷阱!(当 MySQL 选择另一个索引时)
在非常罕见的情况下,MySQL 可能会选择错误的索引。这种情况很少见,如果 MySQL 使用了索引但查询响应时间不合理慢,这应该是最后怀疑的原因。这种情况可能发生的几个原因。一个常见的原因是,在更新大量行时,数值刚好未触发索引“stats”的自动更新。由于索引统计信息是影响 MySQL 选择索引的多种因素之一,如果索引统计信息与实际情况有显著偏差,可能会导致 MySQL 选择错误的索引。需要明确的是:索引本身永远不会不准确;只有索引的 统计数据 不准确。
索引统计信息是关于索引中数值分布的估计。MySQL 对索引进行随机深入,以对页面进行采样。 (页面 是逻辑存储的 16 KB 单元。几乎所有内容都存储在页面中。)如果索引值均匀分布,则少数随机深入能准确代表整个索引。
当 MySQL 更新表的索引统计信息时:
-
首次打开表
-
运行
ANALYZE TABLE -
表自上次更新后修改了 1/16
-
innodb_stats_on_metadata已启用并且发生以下情况之一:-
运行
SHOW INDEX或SHOW TABLE STATUS -
INFORMATION_SCHEMA.TABLES或INFORMATION_SCHEMA.STATISTICS被查询
-
运行 ANALYZE TABLE 是安全且通常非常快速的,但在繁忙的服务器上要小心:它需要一个刷新锁(在 Percona Server 除外),可能会阻塞所有访问该表的查询。
表连接算法
简要介绍 MySQL 表连接算法可以帮助您在分析和优化 JOIN 时考虑索引和索引化。默认的表连接算法称为 nested-loop join(NLJ),它的操作类似于代码中的嵌套 foreach 循环。例如,假设查询使用 JOIN 子句连接三个表,如下:
FROM
t1 JOIN t2 ON t1.A = t2.B
JOIN t3 ON t2.B = t3.C
假设 EXPLAIN 报告连接顺序为 t1、t2 和 t3。嵌套循环连接算法的工作方式类似于 Example 2-22 中的伪代码。
示例 2-22. NLJ 算法
func find_rows(table, index, conditions) []rows {
// Return array of rows in table matching conditions,
// using index for lookup or table scan if NULL
}
foreach find_rows(t1, some_index, "WHERE ...") {
foreach find_rows(t2, index_on_B, "WHERE B = <t1.A>") {
return find_rows(t3, NULL, "WHERE C = <t2.B>")
}
}
使用 NLJ 算法,MySQL 首先使用 some_index 在最外层表 t1 中查找匹配行:对于表 t1 中的每个匹配行,MySQL 使用连接列上的索引 index_on_B 查找与 t1.A 匹配的表 t2 中的行。对于表 t2 中的每个匹配行,MySQL 使用相同的过程连接表 t3,但是——只是为了好玩——假设连接列 t3.C 上没有索引:结果是一个完整连接。 (回顾 “Select full join” 和 Example 2-21。)
当表 t3 中没有更多行与表 t2 的连接列值匹配时,将使用表 t2 中下一个匹配行。当表 t2 中没有更多行与表 t1 的连接列值匹配时,将使用表 t1 中下一个匹配行。当表 t1 中没有更多行匹配时,查询完成。
嵌套循环连接算法简单有效,但存在一个问题:内部表访问非常频繁,完全连接使得该访问非常慢。 在这个例子中,表 t3 在每个匹配的 t1 行乘以每个匹配的 t2 行时都被访问。 如果 t1 和 t2 都有 10 行匹配,那么 t3 就会被访问 100 次。 块嵌套循环 连接算法解决了这个问题。 匹配行中的连接列值从 t1 和 t2 中保存在连接缓冲区中。 (连接缓冲区大小由系统变量join_buffer_size设置。) 当连接缓冲区满时,MySQL 扫描 t3 并连接每个 t3 行,该行与连接缓冲区中的连接列值匹配。 虽然连接缓冲区被访问多次(对于每个 t3 行),但它很快因为它在内存中——比 NLJ 算法需要的 100 次表扫描要快得多。
自 MySQL 8.0.20 起,哈希连接算法取代了块嵌套循环连接算法。^(6) 哈希连接 在内存中创建连接表的哈希表,就像本示例中的表 t3 一样。 MySQL 使用哈希表在连接表中查找行,这是非常快的,因为哈希表查找是常数时间操作。有关详细信息,请阅读 MySQL 手册中的“哈希连接优化”。
提示
EXPLAIN通过在Extra字段中打印“Using join buffer (hash join)”来指示哈希连接。
MySQL 连接还有更多细节和微妙之处,但这个简要概述帮助您像 MySQL 一样思考连接:一次处理一张表和每张表一次一个索引。
摘要
本章介绍了 MySQL 的索引和索引。 关键要点是:
-
索引提供了 MySQL 性能的最大和最佳杠杆。
-
在耗尽其他选项之前,不要扩展硬件以改善性能。
-
调整 MySQL 不是必要的,以合理的配置提高性能即可。
-
InnoDB 表是按主键组织的 B 树索引。
-
MySQL 通过索引查找、索引扫描或全表扫描来访问表—索引查找是最佳访问方法。
-
要使用索引,查询必须使用索引的最左前缀—最左前缀要求。
-
MySQL 使用索引查找匹配
WHERE的行,为GROUP BY分组行,为ORDER BY排序行,避免读取行(覆盖索引)和连接表。 -
EXPLAIN打印一个查询执行计划(或EXPLAIN 计划),详细说明 MySQL 如何执行查询。 -
索引需要像 MySQL 一样思考来理解查询执行计划。
-
优秀的索引可能因多种原因失效。
-
MySQL 使用三种算法来连接表:NLJ、块嵌套循环和哈希连接。
下一章开始讨论关于数据的间接查询优化。
实践:查找重复索引
此实践的目标是使用pt-duplicate-key-checker来识别重复索引:一个命令行工具,打印重复索引。
这种练习简单而有用:下载并运行 pt-duplicate-key-checker。默认情况下,它会检查所有表,并打印每个重复索引的报告,如下所示:
# ####################################################################
# db_name.table_name
# ####################################################################
# idx_a is a left-prefix of idx_a_b
# Key definitions:
# KEY `idx_a` (`a`),
# KEY `idx_a_b` (`a`,`b`)
# Column types:
# `a` int(11) default null
# `b` int(11) default null
# To remove this duplicate index, execute:
ALTER TABLE `db_name`.`table_name` DROP INDEX `idx_a`;
对于每个索引及其重复,报告包括:
-
一个原因:为什么一个索引与另一个索引重复
-
两个索引定义
-
索引覆盖的列定义
-
一个
ALTER TABLE语句以删除重复索引
pt-duplicate-key-checker 是成熟且经过充分测试的,但在删除索引之前,请务必深思熟虑,尤其是在生产环境中。
像 “练习:识别慢查询” 这样的练习很简单,但你会惊讶于有多少工程师从不检查重复索引。检查并移除重复索引是像专家一样练习 MySQL 性能的方法。
^(1) 除非你是瓦迪姆·特卡琴科,如果是这样的话:请继续调优。
^(2) 除非使用 STRAIGHT_JOIN,但不要使用这个。让 MySQL 查询优化器选择最佳的查询执行计划的连接顺序。
^(3) 严格来说,对 elem 表的仅索引扫描返回十个值,而不是行数,因为不需要完整的行:只需列 a 的值。
^(4) 即使存在极为罕见的查询优化器错误也是如此。
^(5) 如果你感到无聊,可以尝试智胜 MySQL,但不要期望能够赢。它曾在猎户座大熊座附近的黑暗中看见攻击船在肩上燃烧。它看着 C 梁在坦哈伊泽门附近的黑暗中闪烁。
^(6) 哈希连接自 MySQL 8.0.18 起存在,但从 MySQL 8.0.20 起替代块嵌套循环。
第三章:数据
本章开始了第二部分旅程:间接查询优化。如 “优化查询响应时间” 中所述,直接查询优化解决了许多问题,但并非所有问题。即使你已经超越了第二章中关于直接查询优化的知识和技能,你仍然会遇到简单且适当索引但仍然缓慢的查询。这时你开始优化环绕查询,从它访问的数据开始。为了理解原因,让我们想想岩石。
想象一下,你的工作是搬运岩石,你有三堆不同大小的岩石。第一堆是小石子:非常轻,不大于你的拇指。第二堆是鹅卵石:重但足够轻便拾起,不大于你的头。第三堆是巨石:太大太重,无法抬起;你需要杠杆或机器来移动它们。你的任务是将一堆从山脚移到山顶(无论为何;但如果有帮助的话,可以想象你是西西弗斯)。你会选择哪一堆?
我假设你会选择小石子,因为它们轻而容易搬动。但有一个关键的细节可能会改变你的决定:重量。小石子堆重两公吨(相当于中型 SUV 的重量)。鹅卵石堆重一公吨(相当于一个非常小的汽车的重量)。而只有一个巨石,重半公吨(相当于十个成年人的重量)。现在你会选择哪一堆?
一方面,小石子要移动起来简单得多。你可以用铲子铲进推车里,然后推上山。只是小石子多(不是推车多)。大石头重量比较小,但是由于其独特的大小使其难以控制。需要特殊设备将其搬上山,但这只需完成一次任务。决定很难。第五章 提供了答案和解释,但在那章之前我们还有很多内容要覆盖。
数据类似于一堆岩石,执行查询类似于将岩石推上山。当数据量较小时,通常直接查询优化就足够了,因为数据很容易处理——就像手里拿着一把小石子走(或跑)上山。但随着数据量的增加,间接查询优化变得越来越重要——就像拖着一块重的鹅卵石爬山,并在半路上停下来问:“我们能做点什么处理这些岩石吗?”
第一章 提供了一个“证明”,即数据大小影响性能:TRUNCATE TABLE 显著提高性能,但不要使用这种“优化”。这是一个玩笑,但也证明了一个经常被忽视的观点:数据越少性能越好。这是一个口号;完整的陈述是:你可以通过减少数据来提高性能,因为较少的数据需要更少的系统资源(CPU、内存、存储等等)。
到目前为止,您可能已经发现本章将主张减少数据量。但是,更多数据不是驱使工程师学习性能优化的现实和理由吗?是的,第五章讨论了规模化的 MySQL,但首先必须学会在数据相对较小且问题可解时减少和优化数据。最紧张的学习时机是当您忽视数据大小直到它压垮应用程序时。
本章讨论数据与性能的关系,并主张减少数据访问和存储是一种技术——间接查询优化——以提高性能。有三个主要部分。第一部分揭示了 MySQL 性能的三个秘密。第二部分介绍了我所称的最少数据原则及其众多含义。第三部分介绍了如何快速且安全地删除或归档数据。
三个秘密
保守秘密是隐藏真相。以下真相在关于 MySQL 性能的书籍中并不总是透露出来,有两个原因。首先,它们使事情变得复杂。在不提及警告和注意事项的情况下写作和解释性能要容易得多。其次,它们是反直觉的。这并不意味着它们是错误的,但确实使它们难以澄清。尽管如此,以下真相对于 MySQL 性能至关重要,所以让我们以开放的心态深入探讨细节。
索引可能无法帮助
具有讽刺意味的是,您可以预期大多数慢查询使用索引查找。这有两个原因是讽刺的。首先,索引是性能的关键,但即使有很好的索引,查询也可能很慢。其次,在学习索引和索引技术之后(如第二章所讨论的),工程师变得擅长避免索引扫描和表扫描,只剩下索引查找,这是一个好问题,但仍然具有讽刺意味。
性能不可达到无索引,但这并不意味着索引为无限数据大小提供无限杠杆。不要对索引失去信心,但要注意以下情况,索引可能无法帮助。对于每种情况,假设查询及其索引无法进一步优化,则下一步是间接查询优化。
索引扫描
随着表的增长,索引扫描提供的杠杆减少,因为索引也增长:表行数越多,索引值也越多。^(1)(相比之下,只要索引适合内存,索引查找提供的杠杆几乎永远不会减少。)即使仅索引扫描通常也不会扩展,因为它几乎肯定会读取大量值——这是一个安全的假设,因为如果可能的话,MySQL 会进行索引查找以读取较少行。索引扫描只会推迟必然发生的事情:随着表中行数的增加,使用索引扫描的查询的响应时间也会增加。
查找行
当我优化使用索引查找的慢查询时,我首先检查的是行数(查看“行数检查”)。查找匹配行是查询的基本目的,但即使使用了良好的索引,查询也可能检查过多行。太多是响应时间变得不可接受的点(而根本原因不是其他因素,比如内存不足或磁盘 IOPS 不足)。这是因为几种索引查找访问类型可以匹配许多行。只有表 3-1 中列出的访问类型匹配最多一行。
表 3-1. 匹配最多一行的索引查找访问类型
| ☐ | system |
|---|---|
| ☐ | const |
| ☐ | eq_ref |
| ☐ | unique_subquery |
如果 EXPLAIN 计划中的type字段不是表 3-1 中列出的访问类型之一,则要密切关注rows字段和查询指标行数检查(查看“行数检查”)。检查非常多的行无论索引查找如何都很慢。
注意
“EXPLAIN 输出格式”在 MySQL 手册中列出了访问类型,它称之为连接类型,因为 MySQL 将每个查询视为连接。在本书中,为了精确性和一致性,我只使用两个术语:访问方法和访问类型,如第 2 章中所述。
非常低的索引选择性很可能是一个罪魁祸首。回想一下“极端选择性”:索引选择性是基数除以表中的行数。MySQL 不太可能选择具有非常低选择性的索引,因为它可以匹配太多行。由于次要索引需要在主键中进行第二次查找以读取行,所以放弃具有极低选择性的索引并进行全表扫描可能更快——假设没有更好的索引。在 EXPLAIN 计划中,如果访问方法是表扫描(type: ALL),但存在 MySQL 可以使用的索引(possible_keys),则可以检测到这一点。要查看 MySQL 未选择的执行计划,请使用FORCE INDEX强制使用possible_keys字段中列出的索引。最有可能的执行计划将是索引扫描(type: index),其中有大量的rows,这就是 MySQL 选择表扫描的原因。
提示
回想一下“陷阱!(当 MySQL 选择其他索引时)”:在非常罕见的情况下,MySQL 会选择错误的索引。如果查询检查了太多行,但您确信有一个更好的索引 MySQL 应该使用,那么索引统计可能有误的可能性很小,这导致 MySQL 不选择更好的索引。运行ANALYZE TABLE来更新索引统计信息。
记住,索引选择性是基于基数和表中行数的函数。如果基数保持不变但行数增加,则选择性会降低。因此,在表很小时有帮助的索引,在表很大时可能无济于事。
连接表
当连接表时,每个表中的少数几行很快就会影响性能。如果您回忆起 “表连接算法”,嵌套循环连接(NLJ)算法(示例 2-22)意味着连接访问的总行数是每个表访问的行数的乘积。换句话说,在 EXPLAIN 计划中将rows的值相乘。一个每个表只有一百行的三表连接可以访问一百万行:100 × 100 × 100 = 1,000,000。为了避免这种情况,每个表连接的索引查找应该只匹配一行——在 表 3-1 中列出的访问类型之一是最佳选择。
MySQL 几乎可以按任何顺序连接表。利用这一点:有时解决不良连接的方案是在另一张表上创建更好的索引,以便 MySQL 改变连接顺序。
没有索引查找,表连接注定失败。结果是全连接,如 “选择全连接” 中预示的那样。但即使有索引,如果索引与单行不匹配,表连接也会遇到困难。
工作集大小
只有当索引在内存中时才有用。如果查询查找的索引值不在内存中,则 MySQL 会从磁盘读取它们。(更准确地说,构成索引的 B 树节点存储在 16KB 页中,MySQL 根据需要在内存和磁盘之间交换页面。)从磁盘读取比从内存读取慢几个数量级,这是一个问题,但主要问题是索引竞争内存。
如果内存有限但索引数众多且频繁用于查找大比例的值(相对于表大小),则索引使用可能会增加存储 I/O,因为 MySQL 试图保持频繁使用的索引值在内存中。这种情况可能发生,但很少,有两个原因。首先,MySQL 非常擅长保持频繁使用的索引值在内存中。其次,频繁使用的索引值及其引用的主键行被称为工作集,通常只占表大小的一小部分。例如,数据库可能有 500GB 大小,但应用程序经常只访问 1GB 的数据。考虑到这一事实,MySQL DBA 通常仅为总数据大小的 10%分配内存,通常舍入到标准内存值(64GB、128GB 等)。500GB 的 10%为 50GB,因此 DBA 可能会谨慎地舍入到 64GB 的内存。这种方法效果非常好,是一个很好的起点。
提示
作为起点,为总数据大小分配 10%的内存。工作集大小通常只占总数据大小的一小部分。
当工作集大小显著大于可用内存时,索引可能没有帮助。相反,就像一团炽热的火焰,水不是灭火剂,索引使用会对存储 I/O 施加压力,使一切变慢。更多内存是一个快速解决方案,但请记住 “更好、更快的硬件!”:扩展不是一种可持续的方法。最佳解决方案是解决导致大工作集的数据大小和访问模式。如果应用程序确实需要存储和访问如此多的数据,以至于工作集大小无法在单个 MySQL 实例的合理内存量内容纳,那么解决方案是分片,详见第五章。
数据越少越好
经验丰富的工程师不会为一个巨大的数据库而欢呼,他们应对它。当数据大小显著减少时,他们会庆祝,因为数据越少越好。对什么好?一切:性能、管理、成本等等。处理 100 GB 的数据比在单个 MySQL 实例上处理 100 TB 要快得多、更容易、更便宜。前者如此之小,以至于智能手机都能处理。后者则需要专门处理:优化性能更具挑战性,管理数据可能存在风险(备份和恢复时间是多少?),找到价格合理的硬件为 100 TB 数据难度很大。保持数据大小合理比应对一个巨大的数据库更容易。
任何真正需要的数据都值得优化和管理。问题不在于数据大小,而在于无节制的数据增长。工程师们经常囤积数据:存储所有可能的数据。如果你在想,“不是我。我不会囤积数据”,那太好了。但你的同事可能不具备你令人称赞的数据苦行僧精神。如果是这样,在数据大小成为问题之前,提出无节制的数据增长的问题。
提示
不要让一个难以管理的数据库令你措手不及。监控数据大小(参见 “数据大小”),并根据当前的增长率,估计未来四年的数据大小。如果未来的数据大小在当前硬件和应用设计下不可行,则在问题变成问题之前解决这个问题。
QPS 越少越好
也许你永远找不到另一本书或者工程师会说更低的 QPS 更好。珍惜这一刻。
我意识到这个秘密是反直觉的,甚至可能不受欢迎。要看到它的真理和智慧,考虑三个关于 QPS 较少争议的观点:
QPS 只是一个数字——原始吞吐量的测量。
它并未揭示任何关于查询或总体性能的定性内容。一个应用程序在 10,000 QPS 时可以有效处于空闲状态,而另一个在半数吞吐量时可能过载并处于宕机状态。即使在相同的 QPS 下,也存在许多定性差异。在 1,000 QPS 下执行SELECT 1几乎不需要系统资源,但在同样的 QPS 下执行复杂查询可能会对所有系统资源造成很大压力。而且无论 QPS 有多高,其也只有查询响应时间那么好。
QPS 值没有客观意义
它们既不好也不坏,既不高也不低,既不典型也不非典型。QPS 值只有与一个应用程序相关时才有意义。如果一个应用程序平均每秒 2,000 次请求,则 100 次请求可能意味着宕机。但是,如果另一个应用程序平均每秒 300 次请求,则 100 次请求可能是正常波动。QPS 还可以与外部事件相关:一天中的时间、一周中的日期、季节、假期等等。
增加 QPS 是困难的
相比之下,数据大小可以相对容易地从 1 GB 增加到 100 GB——增加了 100 倍。但是,增加 QPS 100 倍(除了极低的值,如从 1 QPS 到 100 QPS)却非常困难。即使是 QPS 的 2 倍增加也可能非常具有挑战性。相对于应用程序的最大 QPS 增加更具挑战性,因为您无法购买更多的 QPS,而存储和内存则不同。
总结这些观点:QPS 不是定性的,只是相对于一个应用程序,而且难以增加。具体说来:QPS 对你没有帮助。它更像是一种负担而非资产。因此,更少的 QPS 才更好。
经验丰富的工程师们在 QPS 减少(有意)时庆祝,因为较少的 QPS 意味着更多的增长空间。
最少数据原则
我将最少数据原则定义为:仅存储和访问所需的数据。这在理论上听起来显而易见,但在实践中远非常态。这也是为何接下来的两个部分有许多细节之处的原因。
常识并不常见。
伏尔泰
数据访问
不要访问比所需更多的数据。访问指的是 MySQL 执行查询时的所有工作:查找匹配行、处理匹配行,并返回结果集,无论是读取(SELECT)还是写入。高效的数据访问尤为重要,因为增加写入的规模更为困难。
表 3-2 是一个清单,你可以应用于每一个查询——希望是每一个查询——以验证其数据访问效率。
表 3-2. 高效数据访问检查清单
| ☐ | 仅返回必要的列 |
|---|---|
| ☐ | 减少查询复杂性 |
| ☐ | 限制行访问 |
| ☐ | 限制结果集 |
| ☐ | 避免对行进行排序 |
公平和平衡地说,忽略单个检查项不太可能影响性能。例如,第五条项目——避免对行进行排序——通常会被忽略,而不会影响性能。这些项目是最佳实践。如果你实践它们直到变成习惯,你将比完全忽略它们的工程师取得更大的成功和 MySQL 的性能。
在我解释表 3-2 中的每一项之前,让我们花一段时间来重新讨论一个在第一章中推迟到这一章的例子。
或许你还记得“查询概要”中的这个例子:“在我写这篇文章的时候,我正在查看一个查询负载为 5,962 的查询。这是怎么可能的?”这种查询负载之所以可能,归功于极其高效的数据访问和一个非常忙碌的应用程序。这个查询类似于SELECT col1, col2 WHERE pk_col = 5:一个主键查找,只返回单行的两列。当数据访问如此高效时,MySQL 函数几乎像是一个内存缓存,并以令人难以置信的 QPS 和查询负载执行查询。几乎,但不完全是,因为每个查询都是一个包含开销的事务。(第八章专注于事务。)要优化这样的查询,你必须改变访问模式,因为查询不能进一步优化,数据大小也不能减少。我在第四章中再次讨论这个查询。
只返回所需列
查询应该只返回所需的列。
不要使用SELECT *。如果表中有任何BLOB、TEXT或JSON列,这一点尤为重要。
你可能之前听过这个最佳实践,因为数据库行业(不仅仅是 MySQL)已经在强调这一点数十年了。我想不起最后一次在生产环境中看到SELECT *了,但这一点非常重要,所以不断重复是必要的。
减少查询复杂性
查询应尽可能简单。
查询复杂性指的是构成查询的所有表、条件和 SQL 子句。在这个上下文中,复杂性只相对于一个查询而言,而不是工程师。查询SELECT col FROM tbl WHERE id = 1比一个涉及五个表和许多WHERE条件的查询更简单。
复杂的查询是工程师的问题,而不是 MySQL 的问题。查询越复杂,分析和优化就越困难。如果你幸运的话,一个复杂的查询可能运行良好,从不出现作为慢查询的情况(参见“查询概要”)。但幸运不是最佳实践。从一开始(首次编写时)保持查询简单,并在可能时减少查询复杂性。
关于数据访问,简单查询倾向于访问较少的数据,因为它们有较少的表、条件和 SQL 子句——MySQL 的工作量更少。但要小心:错误的简化可能会产生更糟的 EXPLAIN 计划。例如,第 2 章 中的 图 2-21 展示了删除条件如何取消 ORDER BY 优化,导致(稍微)更差的 EXPLAIN 计划。始终确认简化的查询是否具有等效或更好的 EXPLAIN 计划,以及相同的结果集。
限制行访问
查询应尽可能访问尽少的行。
访问太多行通常会令人惊讶;这不是工程师有意为之的事情。随着时间的推移,数据的增长是一个常见的原因:一个快速查询开始时只访问几行,但几年后和几千兆字节后,它变成了一个慢查询,因为它访问了太多行。简单的错误是另一个原因:工程师编写了一个他们认为会访问少数行的查询,但他们错了。在数据增长和简单错误的交汇处是最重要的原因:不限制范围和列表。像 col > 75 这样的无限范围如果 MySQL 在 col 上进行范围扫描,可以访问无数行。即使这是有意为之的,因为假定表很小,请注意随着表的增长,行访问几乎没有界限,特别是如果 col 上的索引是非唯一的。
LIMIT 子句并不限制行访问,因为 LIMIT 适用于匹配行后的结果集。唯一的例外是 ORDER BY…LIMIT 优化:如果 MySQL 可以按索引顺序访问行,则当找到 LIMIT 数量的匹配行时,它会停止读取行。但有趣的是:EXPLAIN 不会报告是否使用了此优化。您必须从 EXPLAIN 报告的内容和未报告的内容推断出是否使用了该优化。让我们花点时间看看这个优化是如何起作用的,并证明它限制了行访问。
使用表 elem(示例 2-1)来自第 2 章,让我们首先执行一个没有 LIMIT 子句的查询。示例 3-1 显示该查询返回了八行。
示例 3-1. 没有 LIMIT 的查询行
SELECT * FROM elem WHERE a > 'Ag' ORDER BY a;
+----+----+----+----+
| id | a | b | c |
+----+----+----+----+
| 8 | Al | B | Cd |
| 9 | Al | B | Cd |
| 3 | Al | Br | Cr |
| 10 | Ar | B | Cd |
| 4 | Ar | Br | Cd |
| 5 | Ar | Br | C |
| 7 | At | Bi | Ce |
| 2 | Au | Be | Co |
+----+----+----+----+
8 rows in set (0.00 sec)
没有 LIMIT 子句,该查询访问(并返回)了八行。因此,即使有 LIMIT 2 子句,EXPLAIN 报告的是 rows: 8,正如 示例 3-2 中所示,因为 MySQL 无法知道在执行查询之前有多少行在范围内不匹配。最糟糕的情况是 MySQL 读取所有行,因为没有一行匹配。但对于这个简单的示例,我们可以看到前两行(id 值为 8 和 9)将匹配唯一的表条件。如果我们是正确的,查询指标将报告检查了两行,而不是八行。但首先,让我们看看如何从 示例 3-2 的 EXPLAIN 计划中推断出这种优化。
示例 3-2. ORDER BY…LIMIT 优化的 EXPLAIN 计划
EXPLAIN SELECT * FROM elem WHERE a > 'Ag' ORDER BY a LIMIT 2\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: elem
partitions: NULL
type: range
possible_keys: a
key: a
key_len: 8
ref: NULL
rows: 8
filtered: 100.00
Extra: Using index condition
你可以推断 MySQL 使用了ORDER BY…LIMIT优化来访问仅两行(LIMIT 2),因为:
-
查询使用了一个索引(
type: range) -
ORDER BY列是该索引的最左前缀(key: a) -
Extra字段并不报告“使用文件排序”。
证据显示在示例 3-3 中:MySQL 执行查询后的慢查询日志片段。
示例 3-3。ORDER BY…LIMIT优化的查询指标
# Query_time: 0.000273 Lock_time: 0.000114 Rows_sent: 2 Rows_examined: 2
SELECT * FROM elem WHERE a > 'Ag' ORDER BY a LIMIT 2;
在第一行末尾的Rows_examined: 2证明了 MySQL 使用了ORDER BY…LIMIT优化,仅访问了两行而不是所有八行。要了解更多关于这种查询优化的信息,请阅读 MySQL 手册中的“LIMIT 查询优化”。
关于限制范围和列表,有一个重要因素需要验证:应用程序是否限制了查询中使用的输入? 早在“平均值、百分位数和最大值”中,我讲述了一个故事:“长话短说,这个查询用于查找欺诈检测数据,有时一个大案例会一次查找几千行,这导致 MySQL 切换查询执行计划。” 在那种情况下,解决方案很简单:每个请求限制应用程序输入至一千个值。该案例还突显了一个事实:人类可以输入大量值。通常情况下,工程师在用户是另一个计算机时会小心限制输入,但当用户是另一个人类时,他们的警惕性会放松,因为他们认为人类不会或不能输入太多值。但他们错了:通过复制粘贴和迫在眉睫的截止日期,普通人可以使任何计算机超负荷运行。
对于写入操作,限制行访问至关重要,因为通常情况下,InnoDB 在更新匹配行之前会锁定它访问的每一行。因此,InnoDB 可能会锁定比您预期更多的行。“行锁定”对此进行了详细说明。
对于表连接,限制行访问同样至关重要:回想一下从“连接表”中得知,对每个表进行连接时,少量行很快就会导致性能严重下降。在那一节中,我指出如果没有索引查找,表连接注定会失败。在这一节中,我要指出,除非也访问了非常少的行,否则表连接也注定会失败。记住:在非唯一索引上的索引查找可以访问任意数量的重复行。
了解您的访问模式:对于每个查询,限制行访问是什么?使用EXPLAIN查看预估行访问(rows字段),并监视检查的行数(参见“检查的行数”),以避免访问过多行的意外情况。
限制结果集
查询应尽可能返回尽少的行。
这比在查询上放置LIMIT子句更为复杂,尽管这确实有帮助。它指的是应用程序不使用整个结果集:查询返回的行。这个问题有三种变化。
第一种变体发生在应用程序使用了一些行,但并非全部。这可能是有意或无意的。无意中,它表明WHERE子句需要更好(或更多)的条件才能匹配仅需要的行。您可以在过滤行而不是使用WHERE条件的应用程序代码中发现这一点。如果发现了这种情况,请与您的团队讨论以确保这不是有意的。有意地,应用程序可能选择更多的行以避免通过将行匹配从 MySQL 转移到应用程序来复杂化查询。这种技术仅在减少响应时间时才有用,类似于 MySQL 在少数情况下选择表扫描。
第二种变体发生在查询具有ORDER BY子句且应用程序使用有序的行子集时。行的顺序对第一种变体并不重要,但对第二种变体却是定义性特征。例如,一个查询返回 1000 行,但应用程序只按顺序使用前 20 行。在这种情况下,解决方案可能很简单,只需在查询中添加LIMIT 20子句。
应用程序对剩余的 980 行做了什么?如果这些行从未被使用过,那么查询肯定不应该返回它们——请添加LIMIT 20子句。但是如果这些行被使用了,那么应用程序很可能在进行分页:每次使用 20 行(例如,每页显示 20 个结果)。在这种情况下,使用LIMIT 20 OFFSET N按需获取页面可能更快、更高效——其中 N = 20 ×(页码 - 1)——前提是可以使用ORDER BY...LIMIT优化(请参见前一节,“限制行访问”)。这种优化是必需的,因为如果没有它,MySQL 必须在应用LIMIT子句的OFFSET部分之前找到并排序所有匹配行——这会浪费大量工作,只为返回 20 行。但即使没有这种优化,也有另一个解决方案:一个大但合理的LIMIT子句。例如,如果您测量应用程序的使用情况并发现大多数请求只使用前五页,则使用LIMIT 100子句获取前五页并为大多数请求减少结果集大小 90%。
第三种变体发生在应用程序仅聚合结果集时。如果应用程序聚合结果集并使用单独的行,则是可以接受的。反模式是仅聚合结果集而不使用 SQL 聚合函数,这会限制结果集。表 3-3 列出了四种反模式及其相应的 SQL 解决方案。
表 3-3. 应用程序中的四种结果集反模式
| 应用程序中的反模式 | SQL 中的解决方案 |
|---|---|
| 添加列值 | SUM(column) |
| 计算行数 | COUNT(*) |
| 计算值的数量 | COUNT(column)…GROUP BY column |
| 计算不同值的数量 | COUNT(DISTINCT column) |
| 提取不同的值 | DISTINCT |
添加列值适用于其他统计函数:AVG()、MAX()、MIN()等等。让 MySQL 进行计算,而不是返回行。
计算行数是一个极端的反模式,但我见过这种情况,所以我确信还有其他应用程序在不必要的行上悄悄浪费网络带宽。永远不要仅仅用于计数行数的应用程序;在查询中使用COUNT(*)。
注意
截至 MySQL 8.0.14 版本,SELECT COUNT(*) FROM table(没有WHERE子句)使用多个线程并行读取主键。这不是并行查询执行;MySQL 手册称其为“并行聚簇索引读取”。
计算值的数量对程序员来说可能比在 SQL GROUP BY子句中表达更容易,但应该使用后者来限制结果集。再次使用表elem(示例 2-1),示例 3-4 演示了如何使用COUNT(column)…GROUP BY column来计算列的值的数量。
示例 3-4. 计算值的数量
SELECT a, COUNT(a) FROM elem GROUP BY a;
+----+----------+
| a | COUNT(a) |
+----+----------+
| Ag | 2 |
| Al | 3 |
| Ar | 3 |
| At | 1 |
| Au | 1 |
+----+----------+
对于表elem中的列a,有两行的值为“Ag”,三行的值为“Al”,依此类推。SQL 解决方案返回五行,而反模式将返回所有十行。这些数字并不激增——五行与十行——但它们说明了一个观点:查询可以通过 SQL 聚合来限制其结果集,而不是通过应用程序代码。
提取不同的值——在应用程序中使用关联数组去重复列值很简单;但 MySQL 也可以通过DISTINCT来实现,这限制了结果集(DISTINCT因为是GROUP BY的特例而被视为聚合函数)。DISTINCT在处理单列时尤为清晰和有用。例如,SELECT DISTINCT a FROM elem返回列a的唯一值列表。(如果你好奇,列a有五个唯一值:“Ag”,“Al”,“Ar”,“At”和“Au”。)使用DISTINCT的一个注意事项是它适用于所有列。SELECT DISTINCT a, b FROM elem返回具有列a和b值的唯一行列表。想了解更多,请参阅 MySQL 手册中的“DISTINCT 优化”。
避免对行进行排序。
查询应避免对行进行排序。
在应用程序中排序行而不是在 MySQL 中排序可以通过去除ORDER BY子句来减少查询复杂性,并通过将工作分配给应用程序实例来更好地扩展,后者比 MySQL 更容易扩展。
没有LIMIT子句的ORDER BY子句是可以去除的明显迹象,应用程序可以对行进行排序。(它可能也是前一节讨论的问题的第二个变体。)查找具有ORDER BY子句但没有LIMIT子句的查询,然后确定应用程序是否可以代替 MySQL 对行进行排序——答案应该是肯定的。
数据存储
不要存储比所需更多的数据。
尽管数据对你很有价值,但对 MySQL 来说是无用的。表格 3-4 是一个高效数据存储的检查清单。
我强烈建议您审查一下您的数据存储,因为惊喜很容易发现。我在第二章开头提到了一个这样的惊喜:我创建的应用程序意外地存储了一 十亿 行数据。
表格 3-4. 高效数据存储检查清单
| ☐ | 仅存储所需的行 |
|---|---|
| ☐ | 每一列都被使用 |
| ☐ | 每一列都是紧凑和实用的 |
| ☐ | 每个值都是紧凑和实用的 |
| ☐ | 每个次要索引都被使用且不重复 |
| ☐ | 仅保留所需的行 |
如果您能勾选所有六个项目,那么您将非常适合将数据扩展到任何规模。但这并不容易:有些项目比实施更容易被忽略,特别是当数据库很小时。但不要拖延:发现和纠正存储效率低下的最佳时机是在数据库很小的时候。在规模化时,每秒钟和地球一天中的所有 86,400 秒乘以高吞吐量时,一个字节或两个字节可能会产生很大的差异。为规模设计,为成功计划。
仅存储所需的行
当应用程序发生变化和增长时,工程师可能会忘记它存储了什么。如果数据存储不是问题,工程师就没有理由查看或询问存储了什么。如果很长时间以来你或其他人都没有审查过应用程序存储的内容,或者你是团队或应用程序的新成员,那就来看看吧。例如,我曾见过,已经忘记的服务写入了(至少几年来)没有人在使用的数据。
每一列都被使用
比仅存储所需行更深入的是仅保留所需列。同样,随着应用程序的变化和增长,工程师可能会忘记列,特别是在使用对象关系映射(ORM)时。
不幸的是,MySQL 中没有工具或自动化方法来找出未使用的列。MySQL 跟踪哪些数据库、表和索引被使用,但它不跟踪列的使用情况。没有比未使用的列更隐秘的东西了。唯一的解决方案是手动审查:将应用程序查询使用的列与表中存在的列进行比较。
每一列都是紧凑和实用的
除了仅存储所需行之外,再深一层次的是使每一列都紧凑而实用。紧凑 意味着使用最小的数据类型来存储值。实用 意味着不要使用太小的数据类型,以至于对你或应用程序来说是繁琐或容易出错。例如,使用无符号的 INT 作为位域是紧凑的(没有比位更小的东西),但通常不实用。
提示
熟悉所有MySQL 数据类型。
经典反模式是数据类型VARCHAR(255)。这种特定的数据类型和大小是许多程序和工程师的常见但低效的默认选择,他们很可能是从另一个程序或工程师那里复制了这种做法。你会看到它被用来存储任何东西,这就是为什么它是低效的。
例如,让我们重用elem表(Example 2-1)。原子符号是一个或两个字符。列定义atomic_symbol VARCHAR(255)在技术上是紧凑的——VARCHAR是可变长度的,所以它只会使用一个或两个字符——但它允许垃圾进、垃圾出:无效值,比如“Carbon”而不是“C”,这可能对应用程序产生未知的后果。一个更好的列定义是atomic_symbol CHAR(2),既紧凑又实用。
列定义atomic_symbol ENUM(…)在elem表中更好吗?ENUM比CHAR(2)更紧凑,但在超过一百个原子符号的情况下更实用吗?这是一个你可以决定的权衡;任何选择都显然比VARCHAR(255)更好。
提示
ENUM是高效数据存储中的一位默默无闻的英雄之一。
警惕列字符集。如果没有明确定义,它默认为表字符集,如果表字符集也没有明确定义,则默认为服务器字符集。截至 MySQL 8.0,默认服务器字符集为utf8mb4。对于 MySQL 5.7 及更早版本,默认服务器字符集为latin1。根据字符集,像é这样的单个字符可能存储为多个字节。例如,使用latin1字符集,MySQL 将é存储为一个字节:0xE9。但是使用utf8mb4字符集,MySQL 将é存储为两个字节:0xC3A9。(表情符号每个字符使用四个字节。)字符集是一个特殊且博学的领域,超出了大多数书籍的范围。现在,你需要知道的是:一个字符可能需要多个字节的存储空间,这取决于字符和字符集。在大表中,字节很快累积起来。
对于BLOB、TEXT和JSON数据类型要非常保守。不要将它们用作倾倒场所、万能容器或通用桶。例如,不要将图像存储在BLOB中——你可以这样做,它能工作,但最好不要。有远比这更好的解决方案,比如Amazon S3。
紧凑且实用一直延伸到位级别。另一个令人惊讶的常见但易于避免的列存储效率低下是浪费整数数据类型的高阶位。例如,使用INT而不是INT UNSIGNED:最大值分别约为 20 亿和 40 亿。如果值不能为负数,则使用无符号数据类型。
注意
截至 MySQL 8.0.17,UNSIGNED对于FLOAT、DOUBLE和DECIMAL数据类型已经不推荐使用。
在软件工程的世界中,像这样的细节可能被视为微优化或过早优化,这是不被赞同的,但在架构设计和数据库性能的世界中,它们是最佳实践。
每个值都是紧凑和实用的
比仅存储所需行更深一层次的是使每个值都紧凑和实用。实用的含义与前一节中定义的相同,但紧凑意味着值的最小表示。紧凑的值在应用程序如何使用它们方面具有很高的依赖性。例如,考虑一个具有一个前导空格和一个尾随空格的字符串:“ and ”。Table 3-5 列出了一个应用程序可以压缩此字符串的六种方式。
表 3-5。六种压缩字符串“ and ”的方式
| 紧凑值 | 可能的用途 |
|---|---|
“and” |
去除所有空白。这在字符串中很常见。 |
“ and” |
去除尾随空白。在许多语法中(如 YAML 和 Markdown),前导空格在语法上是重要的。 |
“and ” |
去除前导空白。虽然较少见,但仍有可能。有时程序用于连接空格分隔的参数(如命令行参数)。 |
“” |
删除该值(空字符串)。也许该值是可选的,如FROM table AS table_alias中的AS,可以写作FROM table table_alias。 |
“&” |
用等效符号替换字符串。在书面语言中,和符号字符在语义上等同于单词“and”。 |
NULL |
无值。也许该值完全是多余的,可以删除,结果是没有值(甚至不是空字符串,技术上也是一个值)。 |
Table 3-5 中的转换代表了压缩值的三种方式:最小化、编码和去重。
最小化
要最小化一个值,需要删除多余和无用的数据:空白、注释、头部等等。让我们考虑在 Example 3-5 中更为复杂而又熟悉的值。
Example 3-5。格式化的 SQL 语句(未经最小化处理)
SELECT
/*!40001 SQL_NO_CACHE */
col1,
col2
FROM
tbl1
WHERE
/* comment 1 */
foo = ' bar '
ORDER BY col1
LIMIT 1; — comment 2
如果一个应用程序仅存储 Example 3-5 中 SQL 语句的功能部分,那么它可以通过折叠关键字之间的空白(而不是值内部的空白)并删除最后两个注释(而不是第一个)来最小化值。Example 3-6 是最小化(紧凑)值。
Example 3-6。最小化的 SQL 语句
SELECT /*!40001 SQL_NO_CACHE */ col1, col2 FROM tbl1 WHERE foo=' bar ' LIMIT 1
示例 3-5 和 3-6 在功能上是等效的(相同的 EXPLAIN 计划),但最小化值的数据大小几乎减少了50%(48.9%):分别从 137 字节减少到 70 字节。对于长期数据增长来说,50%的减少——甚至只有 25%——都是显著且有影响力的。
SQL 语句的最小化说明了一个重要观点:最小化一个值并不总是简单的。SQL 语句不是毫无意义的字符串:它是一种需要语法意识正确最小化的语法。第一个注释不能删除,因为它是功能性的(参见 MySQL 手册中的 “Comments”)。同样,引号值 ' bar ' 中的空格是有功能性的:' bar ' 不等于 'bar'。而且你可能已经注意到一个细节:末尾的分号被移除了,因为在这个上下文中它没有功能性,但在其他上下文中它是有功能性的。
在考虑如何最小化一个值时,首先要考虑其数据格式。数据格式的语法和语义决定了哪些数据是多余和不必要的。例如,在 YAML 中,像 # like this 的注释是纯粹的注释(不像某些 SQL 注释),如果应用程序不需要它们,可以删除它们。即使您的数据格式是定制的,它也必须具有一些语法和语义,否则应用程序无法以编程方式读取和写入它。了解数据格式以正确最小化一个值是必要的。
最小的值是没有值:NULL。我知道处理 NULL 可能是个挑战,但有一个优雅的解决方案,强烈建议您使用:COALESCE()。例如,如果 middle_name 列是可空的(不是所有人都有中间名),则使用 COALESCE(middle_name, '') 来返回设置的值,否则返回空字符串。这样,您可以享受 NULL 存储的好处——只需一个比特——而无需在应用程序中处理空字符串(或指针)。在实际情况下,使用 NULL 而不是空字符串、零值和魔法值需要一些额外的工作,但这是最佳实践。
警告
NULL 和 NULL 是唯一的;也就是说,两个空值是唯一的。避免在可空列上使用唯一索引,或确保应用程序正确处理带有 NULL 值的重复行。
如果您确实想避免使用 NULL,上述警告是您的技术原因。这两组值是唯一的:(1, NULL) 和 (1, NULL)。这不是打印错误。对于人类来说,这些值看起来是相同的,但对于 MySQL 来说它们是唯一的,因为 NULL 与 NULL 的比较是未定义的。请查看 MySQL 手册中的 “Working with NULL Values”。它以一种谦逊的承认开始:“直到你习惯了它,NULL 的值可能会让人惊讶。”
编码
要对值进行编码,将其从人类可读转换为机器编码。数据可以编码并以一种方式存储在计算机中,以另一种方式解码并显示给人类。在计算机上存储数据的最有效方法是为计算机编码它。
提示
为机器存储,为人类显示。
典型的示例和反模式是将 IP 地址存储为字符串。例如,将127.0.0.1作为字符串存储在CHAR(15)列中。 IP 地址是四字节无符号整数,这是真正的机器编码。(如果您感兴趣,127.0.0.1是十进制值 2130706433。)要编码和存储 IP 地址,请使用数据类型INT UNSIGNED和函数INET_ATON()和INET_NTOA()分别进行字符串转换。如果编码 IP 地址不切实际,则数据类型CHAR(15)是可接受的替代方案。
另一个类似的示例和反模式是将 UUID 存储为字符串。 UUID 是表示为字符串的多字节整数。由于 UUID 的字节长度不同,您需要使用数据类型BINARY(N),其中N是字节长度,并使用函数HEX()和UNHEX()来转换值。或者,如果您使用的是 MySQL 8.0(或更新版本)和 RFC 4122 UUID(MySQL UUID()生成的),则可以使用函数UUID_TO_BIN()和BIN_TO_UUID()。如果编码 UUID 不切实际,至少使用数据类型CHAR(N)存储字符串表示,其中N是字符长度。
存储数据的更紧凑的计算机编码方法是压缩。但这是一种极端方法,涉及到空间和速度权衡的灰色区域,这超出了本书的范围。我还没有看到需要性能或规模化的情况下使用压缩的案例。高效数据存储检查表(表 3-4)的严格应用使数据扩展到如此之大,以至于其他问题变成阻碍:备份和恢复时间等。如果您认为需要压缩以提高性能,请咨询专家以验证。
顺便说一下编码的问题,有一个重要的最佳实践,我会插入到这一节中:只将日期和时间存储为 UTC。仅在显示(或打印)时将日期和时间转换为本地时间(或适当的时区)。还要注意,MySQL 的TIMESTAMP数据类型将在 2038 年 1 月 19 日结束。如果您在 2037 年 12 月收到这本书作为节日礼物,并且您的数据库有TIMESTAMP列,您可能希望稍早些回到工作。
重复项去除
要去重值,请将列规范化为另一个具有一对一关系的表。这种方法完全是应用程序特定的,因此让我们考虑一个具体的例子。想象一个过于简单的书籍目录,存储在仅有两列title和genre的表中。(让我们关注数据,忽略数据类型和索引等细节。)示例 3-7 显示了一个包含五本书和三个唯一流派的表。
示例 3-7. 具有重复genre值的图书目录
+--------------------------------+-----------+
| title | genre |
+--------------------------------+-----------+
| Efficient MySQL Performance | computers |
| TCP/IP Illustrated | computers |
| The C Programming Language | computers |
| Illuminations | poetry |
| A Little History of the World | history |
+--------------------------------+-----------+
列genre具有重复值:三个computers值的实例。为了去重,将该列规范化到另一个具有一对一关系的表中。示例 3-8 展示了顶部的新表和底部修改后的原始表。这两个表在列genre_id上有一对一的关系。
示例 3-8. 规范化的书目目录
+----------+-----------+
| genre_id | genre |
+----------+-----------+
| 1 | computers |
| 2 | poetry |
| 3 | history |
+----------+-----------+
+--------------------------------+-----------+
| title | genre_id |
+--------------------------------+-----------+
| Efficient MySQL Performance | 1 |
| TCP/IP Illustrated | 1 |
| The C Programming Language | 1 |
| Illuminations | 2 |
| A Little History of the World | 3 |
+--------------------------------+-----------+
原始表(底部)仍然具有列genre_id的重复值,但在规模上数据尺寸大大减少。例如,“computers”字符串需要 9 个字节存储,而使用SMALLINT UNSIGNED数据类型只需要 2 个字节存储整数 1,允许有 65,536 个唯一的流派(可能足够)。这是数据尺寸减少了 77.7%:从 9 个字节到 2 个字节。
以这种方式去重值通过数据库规范化完成:根据逻辑关系(一对一、一对多等)将数据分成表。然而,去重数据并不是数据库规范化的目标或目的。
注意
数据库规范化超出了本书的范围,因此我不会进一步解释它。关于这个主题有许多书籍,因此你不会有任何麻烦找到一本很好的书来学习数据库规范化。
从这个例子可以看出,数据库规范化导致值的去重,但这并不完全正确。示例 3-7 中的单个表在技术上是第一、第二和第三正规形式(假设有主键)—完全规范化,只是设计不良。更准确地说,去重值是数据库规范化的常见(且期望的)副作用。而且由于你应该在任何情况下都对数据库进行规范化,因此你可能会避免重复值。
这里有一个有趣的反面:反规范化。反规范化是规范化的反面:将相关数据组合到一个表中。示例 3-7 中的单个表可能是一个反规范化表,如果这是其设计的意图的话。反规范化是一种通过消除表连接和相关复杂性来提高性能的技术。但不要急于反规范化你的模式,因为这涉及到超出本书范围的细节和权衡。实际上,反规范化是更多数据的相反,因为它有意地复制数据以换取速度。
提示
安全和最佳实践是数据库规范化和减少数据。使用这两者可以实现令人难以置信的规模和性能。
每个二级索引都被使用且不重复。
在高效数据存储检查表 (Table 3-4) 中倒数第二:每个次要索引都在使用且不是重复的。避免未使用的索引和重复的索引始终是一个好主意,但对于数据大小尤为重要,因为索引是数据的副本。确实,次要索引比完整表(主键)要小得多,因为它们只包含索引列值和相应的主键列值,但随着表的增长,这些会累积。
放弃未使用和重复的次要索引是减少数据大小的简单方法,但要小心。如 “Excessive, Duplicate, and Unused” 中所述,查找未使用的索引很棘手,因为某个索引可能不经常使用,因此务必检查足够长的时间段内的索引使用情况。相比之下,重复索引更容易找到:使用 pt-duplicate-key-checker。再次强调:删除索引时要小心。
放弃索引只能恢复与索引大小相等的数据大小。有三种方法可以查看索引大小。让我们使用 employees sample database,因为它有几兆字节的索引数据。查看索引大小的首选方法是查询表 INFORMATION_SCHEMA.TABLES,如 Example 3-9 所示。
示例 3-9. 员工示例数据库的索引大小 (INFORMATION_SCHEMA)
SELECT
TABLE_NAME, DATA_LENGTH, INDEX_LENGTH
FROM
INFORMATION_SCHEMA.TABLES
WHERE
TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA = 'employees';
+--------------+-------------+--------------+
| TABLE_NAME | DATA_LENGTH | INDEX_LENGTH |
+--------------+-------------+--------------+
| departments | 16384 | 16384 |
| dept_emp | 12075008 | 5783552 |
| dept_manager | 16384 | 16384 |
| employees | 15220736 | 0 |
| salaries | 100270080 | 0 |
| titles | 20512768 | 0 |
+--------------+-------------+--------------+
TABLE_NAME 是 employees 示例数据库中的表名——仅有六个表。(该数据库有一些视图,根据条件 TABLE_TYPE = 'BASE TABLE' 进行了过滤。)DATA_LENGTH 是主键的大小(以字节为单位)。INDEX_LENGTH 是所有次要索引的大小(以字节为单位)。最后四个表没有次要索引,只有一个主键。
查看索引大小的第二种历史(但仍广泛使用)方法是 SHOW TABLES STATUS。您可以添加一个 LIKE 子句,仅显示一个表,如 Example 3-10 所示。
示例 3-10. 表 employees.dept_emp 的索引大小 (SHOW TABLE STATUS)
SHOW TABLE STATUS LIKE 'dept_emp'\G
*************************** 1\. row ***************************
Name: dept_emp
Engine: InnoDB
Version: 10
Row_format: Dynamic
Rows: 331143
Avg_row_length: 36
Data_length: 12075008
Max_data_length: 0
Index_length: 5783552
Data_free: 4194304
Auto_increment: NULL
Create_time: 2021-03-28 11:15:15
Update_time: 2021-03-28 11:15:24
Check_time: NULL
Collation: utf8mb4_0900_ai_ci
Checksum: NULL
Create_options:
Comment:
SHOW TABLE STATUS 输出中的 Data_length 和 Index_length 字段与 INFORMATION_SCHEMA.TABLES 中的相同列和值。最好查询 INFORMATION_SCHEMA.TABLES,因为您可以在 SELECT 子句中使用函数,例如 ROUND(DATA_LENGTH / 1024 / 1024) 将字节转换并四舍五入为其他单位的值。
查看索引大小的第三种方法目前是唯一的方法:查询表 mysql.innodb_index_stats,如 Example 3-11 中显示的 employees.dept_emp 表。
示例 3-11. 表 employees.dept_emp 上每个索引的大小 (mysql.innodb_index_stats)
SELECT
index_name, SUM(stat_value) * @@innodb_page_size size
FROM
mysql.innodb_index_stats
WHERE
stat_name = 'size'
AND database_name = 'employees'
AND table_name = 'dept_emp'
GROUP BY index_name;
+------------+----------+
| index_name | size |
+------------+----------+
| PRIMARY | 12075008 |
| dept_no | 5783552 |
+------------+----------+
表 employees.dept_emp 有两个索引:一个主键和一个名为 dept_no 的次要索引。size 列包含每个索引的大小(以字节为单位),实际上是索引页数乘以 InnoDB 页大小(默认为 16 KB)。
employees 示例数据库并不是次级索引大小的壮观展示,但现实世界的数据库可能会因大量次级索引而溢出,这些次级索引占据了总数据大小的相当部分。定期检查索引使用情况和索引大小,并通过仔细删除未使用的和重复的索引来减少总数据大小。
只保留所需行
效率数据存储清单的最后一项(表 3-4):只保留所需行。这一项将我们带回全循环,与第一项闭环:“只存储所需行”。存储时可能需要一行,但这种需求随时间变化而变化。删除(或归档)不再需要的行。听起来很明显,但通常会发现包含遗忘或被弃用数据的表格。我已经不记得多少次看到团队删除完全被遗忘的整个表格了。
删除(或归档)数据远比说起来容易,接下来的部分将解决这一挑战。
删除或归档数据
我希望本章能让您渴望删除或归档数据。太多的数据已经让我从太多愉快的梦中醒来:就好像 MySQL 有自己的思想,等到凌晨 3 点再填满磁盘。我曾经在三个不同的时区(因世界各地的会议而改变了我的时区)被应用程序叫醒。但是关于我,够了,让我们谈谈如何在不对应用程序造成负面影响的情况下删除或归档数据。
为简洁起见,我仅指出删除数据,而不是删除或归档数据,因为挑战几乎完全在前者:删除数据。归档数据需要首先复制数据,然后删除数据。复制数据应使用非锁定的SELECT语句以避免影响应用程序,然后将复制的行写入应用程序无法访问的另一个表格或数据存储中。即使使用非锁定的SELECT语句,您也必须对复制过程进行速率限制,以避免增加超过 MySQL 和应用程序可处理的 QPS。 (从“QPS 越少越好”回顾,QPS 相对于应用程序而言,很难增加。)
工具
您将不得不编写自己的工具来删除或归档数据。很抱歉带来不好的消息,但这是事实。好消息是删除和归档数据并不难——与您的应用程序相比可能微不足道。至关重要 的部分是限制执行 SQL 语句的循环速率。绝对不要这样做:
for {
rowsDeleted = execute(“DELETE FROM table LIMIT 1000000”)
if rowsDeleted == 0 {
break
}
}
LIMIT 1000000 子句可能过大,而for循环在语句之间没有延迟。那段伪代码很可能导致应用程序停机。 批处理大小 是安全且有效的数据归档工具的关键。
批处理大小
首先,可能允许您跳过阅读本节直到需要时的快捷方式:如果行数少于 1,000 行(没有BLOB、TEXT或JSON列),并且 MySQL 负载不重,可以安全地手动以单个DELETE语句删除 1,000 行或更少。手动意味着逐个执行每个DELETE语句(依次执行),而不是并行执行。不要编写程序来执行DELETE语句。大多数人类对于 MySQL 来说太慢了,所以无论您多快,都不能手动执行足够快以过载 MySQL 的DELETE…LIMIT 1000语句。谨慎使用此快捷方式,并请另一位工程师审查任何手动删除操作。
注意
本节描述的方法侧重于DELETE,但通常适用于INSERT和UPDATE。对于INSERT,批处理大小由插入的行数控制,而不是LIMIT子句。
您可以快速且安全地删除行的速率取决于 MySQL 和应用程序能够维持的批处理大小,而不影响查询响应时间或复制延迟。第七章详细介绍了复制延迟。批处理大小是每个DELETE语句删除的行数,由LIMIT子句控制,并在必要时通过简单的延迟进行限制。
批处理大小根据执行时间进行校准;500 毫秒是一个良好的起点。这意味着每个DELETE语句执行时间不应超过 500 毫秒。这对两个原因至关重要:
复制延迟
源 MySQL 实例上的执行时间会导致复制 MySQL 实例上的复制延迟。如果源上的DELETE语句执行时间为 500 毫秒,则在复制实例上执行时间也为 500 毫秒,这会导致 500 毫秒的复制延迟。您无法避免复制延迟,但必须尽量减少,因为复制延迟会导致数据丢失。(目前,我对复制的许多细节进行了概述,稍后在第七章中详细说明。)
限流
在某些情况下,可以安全地执行没有延迟的DELETE语句——没有限流——因为校准的批处理大小限制了查询执行时间,从而限制了每秒查询率(QPS)。一个执行时间为 500 毫秒的查询只能以串行方式执行 2 QPS。但这些不是普通的查询:它们是专门设计的,用于尽可能访问和写入(删除)尽可能多的行。没有限流,批量写入可能会干扰其他查询并影响应用程序。
在删除数据时,限流至关重要:始终在DELETE语句之间加入延迟,并监视复制延迟。^(2)
提示
始终在批量操作中加入限流。
要将批处理大小校准为 500 毫秒执行时间(或您选择的任何执行时间),从批处理大小 1000(LIMIT 1000)和DELETE语句之间的 200 毫秒延迟开始:200 毫秒是一个长延迟,但在校准批处理大小后可以减少它。在监控复制延迟和 MySQL 稳定性的同时运行至少 10 分钟——不要让 MySQL 延迟或不稳定。(复制延迟和 MySQL 稳定性分别在第七章和第六章中讨论。)使用查询报告(参见“报告”)来检查DELETE语句的最大执行时间,或直接在您的数据存档工具中进行测量。如果最大执行时间远低于目标值——500 毫秒——那么将批处理大小加倍,并再次运行 10 分钟。不断加倍批处理大小——或进行较小的调整——直到最大执行时间稳定在目标值之下为止。完成后,记录校准的批处理大小和执行时间,因为删除旧数据应该是一个经常发生的事件。
要使用校准的批处理大小设置节流,请通过每次 10 分钟重复运行逐渐减少延迟的过程重复上述过程。根据 MySQL 和应用程序的情况,您可能会达到零(无节流)。一旦出现复制延迟或 MySQL 不稳定的首个迹象,请停止,并将延迟增加到以前没有引起任何问题的值。完成后,出于相同的原因记录延迟:删除旧数据应该是一个经常发生的事件。
在校准了批处理大小并设置了节流后,最终可以计算速率:每秒可以删除多少行而不影响查询响应时间:批处理大小 * DELETE QPS。(使用查询报告来检查DELETE语句的 QPS,或直接在您的数据存档工具中测量。)预期速率会在一天中变化。如果应用程序在工作时间非常繁忙,唯一可持续的速率可能是零。如果你是一个雄心勃勃、一路高飞的人,醒来时数据库安静时可以尝试更高的速率:更大的批处理大小、更低的延迟或两者兼而有之。只需记住,在太阳升起并且数据库负载增加之前重新设置批处理大小和延迟。
警告
MySQL 备份几乎总是在深夜运行。即使应用程序在深夜非常安静,数据库可能也很忙。
行锁争用
对于写入密集的工作负载,批量操作可能导致增加的行锁竞争:查询等待获取同一行(或附近行)的行锁。这个问题主要影响INSERT和UPDATE语句,但DELETE语句也可能受到影响,特别是如果删除的行与保留的行交错。问题在于批处理大小太大,即使在校准时间内执行也是如此。例如,MySQL 可能能够在 500 毫秒内删除 100,000 行,但如果这些行的锁与应用程序正在更新的行重叠,那么就会引起行锁竞争。
解决方案是通过校准更小的执行时间来减少批处理大小 —— 例如 100 毫秒。在极端情况下,您可能需要增加延迟:小批处理大小,长延迟。这样可以减少行锁竞争,对应用程序有利,但会使数据归档变慢。对于这种极端情况,没有神奇的解决方案;最好避免使用更少的数据和更少的 QPS。
空间与时间
删除数据并不会释放磁盘空间。行删除是逻辑操作,而非物理操作,在许多数据库中是一种常见的性能优化。当你删除 500 GB 的数据时,并不会获得 500 GB 的磁盘空间,而是获得 500 GB 的空闲页。内部细节更为复杂,超出了本书的范围,但总体概念是正确的:删除数据会释放空闲页,而非空闲磁盘空间。
空闲页不会影响性能,当插入新行时,InnoDB 会重用空闲页。如果删除的行将很快被新行替换,并且磁盘空间不受限制,那么空闲页和未索取的磁盘空间就不是问题。但请注意您的同事:如果您的公司运行自己的硬件,并且为您的应用程序的 MySQL 与其他应用程序的 MySQL 共享磁盘空间,则不要浪费其他应用程序可以使用的磁盘空间。在云中,存储是需要花钱的,因此不要浪费钱:回收磁盘空间。
从 InnoDB 中回收磁盘空间的最佳方法是通过执行一个空操作 ALTER TABLE…ENGINE=INNODB 语句来重建表格。这是一个已解决的问题,有三个优秀的解决方案:
每种解决方案工作方式不同,但它们有一个共同点:所有这些解决方案都能在生产环境中在线重建巨大的 InnoDB 表格,而不会影响应用程序。阅读每个文档以决定哪一个对您最合适。
注意
使用 ALTER TABLE…ENGINE=INNODB 重新构建表格时,请将 … 替换为表名。不要进行其他更改。
删除大量数据需要时间。您可能会读到或听到 MySQL 写入数据有多快,但那通常是基准测试的结果(参见“MySQL 调整”)。在实验室研究的光辉世界中,当然:MySQL 会利用您能提供的每一个时钟周期和磁盘 IOP。但在您和我经历的日常世界中,必须谨慎地删除数据,以避免对应用程序造成影响。坦率地说:这将比您想象的要花费更多时间。好消息是:如果操作正确执行——如“批量大小”中详细说明的那样——那么时间是站在您这边的。一个良好校准的、可持续的批量操作可以运行数天甚至数周。这包括您用于从 InnoDB 回收磁盘空间的解决方案,因为重建表格只是另一种批量操作。删除行需要时间,而回收磁盘空间还需要额外的时间。
二进制日志悖论
删除数据会生成数据。这种悖论发生在数据更改被写入二进制日志的情况下。虽然可以禁用二进制日志记录,但生产中从不这样做,因为二进制日志对于复制是必需的,而没有理智的生产系统会在没有副本的情况下运行。
如果表中包含大量的BLOB、TEXT或JSON列,则由于 MySQL 系统变量binlog_row_image默认为full,二进制日志的大小可能会显著增加。该变量决定了如何将行图像写入二进制日志;它有三个设置:
full
写入每一列的值(完整行)。
minimal
写入已更改的列的值以及用于识别行的列。
noblob
写入除了不需要的BLOB和TEXT列之外的每一列的值。
如果没有依赖于二进制日志中完整行图像的外部服务(例如将更改流式传输到数据湖或大数据存储的数据流水线服务),那么使用minimal(或noblob)是安全且推荐的。
如果您使用pt-online-schema-change或gh-ost来重建表格,这些工具会复制表格(安全且自动),并且该复制过程会将更多数据更改写入二进制日志。然而,ALTER TABLE…ENGINE=INNODB默认为原地更改,不会复制表格。
警告
删除大量数据时,由于二进制日志记录和删除数据并不释放磁盘空间,磁盘使用量会增加。
其矛盾之处在于,您必须确保服务器有足够的空闲磁盘空间来删除数据并重建表格。
总结
本章探讨了与性能相关的数据,并提出减少数据访问和存储是一种提高性能的技术——一种间接查询优化。主要的要点是:
-
较少的数据产生更好的性能。
-
较少的 QPS 更好,因为它是一种负担,而非资产。
-
索引对于最大化 MySQL 性能是必要的,但在某些情况下,索引可能无法帮助。
-
最小数据原则意味着:仅存储和访问所需的数据。
-
确保查询尽可能少地访问行。
-
不要存储比所需更多的数据:数据对你来说很有价值,但对 MySQL 而言只是多余的负担。
-
删除或归档数据非常重要,并且可以提高性能。
下一章将集中讨论访问模式,确定如何更改应用程序以有效使用 MySQL。
实践:审核查询数据访问
这一实践的目标是审核查询,以提高数据访问效率。这是高效数据访问检查表(表 3-2):
-
☐ 仅返回所需列
-
☐ 减少查询复杂性
-
☐ 限制行访问
-
☐ 限制结果集
-
☐ 避免对行进行排序
将检查表应用于前 10 个慢查询。(要获取慢查询,请参考“查询概要”和“实践:识别慢查询”。)修复一个简单的方法是任何SELECT *:显式选择所需的列。还要特别注意带有ORDER BY子句的查询:是否使用索引?是否有LIMIT?应用程序是否可以对行进行排序?
与“实践:识别慢查询”和“实践:查找重复索引”不同,没有工具来审核查询数据访问。但检查表只有五项,因此手动审核查询不会花费太多时间。仔细和有条不紊地审核查询,以实现最佳数据访问是专家级别的 MySQL 性能实践。
^(1) MySQL 不支持稀疏或部分索引。
^(2) 查看 GitHub Engineering 的freno:一个用于 MySQL 的开源限流工具。
第四章:访问模式
访问模式描述了应用程序如何使用 MySQL 访问数据。改变访问模式对 MySQL 性能有很大影响,但通常需要比其他优化更多的努力。这就是为什么它是“提高查询响应时间”中规划的最后一步:首先优化查询、索引和数据,然后再优化访问模式。在我们开始之前,让我们再次考虑来自第三章的岩石。
假设你有一辆卡车,类比为 MySQL。如果使用得当,卡车可以轻松地将任何一堆岩石搬上坡。但是如果使用不当,卡车就几乎没有价值,甚至可能比必要的工作时间更长。例如,你可以用卡车一次把卵石搬上山。这对你(和卡车)来说很容易,但效率极低且耗时。一个卡车的用处取决于使用者的能力。同样,MySQL 的用处也取决于使用它的应用程序。
有时,工程师会困惑于为什么 MySQL 运行得不够快。例如,当 MySQL 执行 5000 次查询每秒时,工程师想知道为什么它不能执行 9000 次查询每秒。或者当 MySQL 使用 50%的 CPU 时,工程师想知道为什么它不能使用 90%的 CPU。工程师很难找到答案,因为他们关注的是影响(MySQL),而不是原因:应用程序。像 QPS 和 CPU 使用率这样的指标对于 MySQL 几乎没有什么意义,它们只反映了应用程序如何使用 MySQL。
提示
MySQL 的速度和效率取决于使用它的应用程序。
应用程序可能超出单个 MySQL 实例的容量,但再次强调:这更多地反映了应用程序本身而非 MySQL,因为有数不清的大型高性能应用程序仅使用一个 MySQL 实例。毫无疑问,MySQL 对于应用程序来说速度足够快。真正的问题在于:应用程序是否有效地使用了 MySQL?多年来与 MySQL 共事,涉及数百个不同的应用程序和成千上万个不同的 MySQL 实例后,我可以向你保证:MySQL 的性能受限于应用程序,而不是反过来。
本章重点讨论数据访问模式,确定如何改变应用程序以有效使用 MySQL。本章包括六个主要部分。第一部分阐明了 MySQL 在应用程序之外的功能及其重要性。第二部分证明了数据库性能不是线性扩展的;相反,存在一定极限后性能不稳定。第三部分思考了为什么法拉利比丰田更快,尽管两个品牌的汽车原理大致相同。答案解释了为什么有些应用程序在 MySQL 上表现出色,而其他应用程序则无法起步。第四部分列举了数据访问模式。第五部分介绍了几种改进或修改数据访问模式的应用程序变更。第六部分重新审视了一个老朋友:更好、更快的硬件。
MySQL 什么也不做
当应用程序空闲时,MySQL 也空闲。当应用程序忙于执行查询时,MySQL 忙于执行这些查询。MySQL 有几个后台任务(比如“页面刷新”),但它们只是忙于为那些查询读取和写入数据。事实上,后台任务通过允许前台任务—执行查询—推迟或避免缓慢的操作来提高性能。因此,如果 MySQL 运行缓慢且没有外部问题,原因只能是驱动 MySQL 的东西:应用程序。
提示
QPS 直接且仅归因于应用程序。没有应用程序,QPS 为零。
一些数据存储有机器中的幽灵:内部进程可以随时运行,并且如果在数据存储繁忙执行查询的最差时机运行,会降低性能。(压缩和清理是两个例子—MySQL 没有这些。)MySQL 没有机器中的幽灵—除非应用程序执行你不知道的查询。知道这一点有助于你避免寻找不存在的原因,更重要的是,专注于 MySQL 正忙于做什么:执行查询。来自第一章,你知道如何查看:“查询概要”。查询概要显示的不仅是慢查询,还显示 MySQL 正忙于做什么。
查询会影响其他查询。这个通用术语叫做查询争用:当查询竞争并等待共享资源时。有特定类型的争用:行锁争用,CPU 争用等等。查询争用可能会让 MySQL 看起来忙于做其他事情,但不要被误导:MySQL 只是忙于执行应用程序查询。
几乎不可能看到或证明查询争用,因为 MySQL 仅报告一种类型的争用:行锁争用。(即使行锁争用也难以精确看到,因为行锁定是复杂的。)而且,争用是瞬息万变的—几乎察觉不到—因为这个问题是高 QPS(其中高是相对于应用程序而言)固有的。查询争用就像交通堵塞:它需要大量的车辆上路。虽然几乎不可能看到或证明,但你需要意识到它,因为它可能解释那些莫名其妙的慢查询。
当性能被推至极限时,查询争用扮演着重要角色。
在极限处性能不稳定
在“MySQL:加快速度”的结尾,我说 MySQL 可以轻松推动大多数现代硬件到极限。这是真的,但极限可能会让你感到惊讶。图 4-1 说明了工程师的预期:随着负载增加,数据库性能增加,直到利用 100%的系统能力—硬件和操作系统的吞吐量—然后性能保持稳定。这被称为线性扩展(或线性可伸缩性),但这是个神话。

图 4-1. 预期数据库性能(线性可扩展性)
线性扩展是每个 DBA 和工程师的梦想,但它不可能发生。相反,图 4-2 展示了数据库性能相对于负载和系统容量的现实。

图 4-2. 实际数据库性能
数据库性能随负载增加仅增长到少于系统容量的极限。实际上,数据库性能的极限是系统容量的 80%到 95%。当负载超过这个限制时,数据库性能不稳定:吞吐量、响应时间和其他指标会显著波动,有时甚至会异常。最好的情况下,某些(或大部分)查询的性能下降;最坏的情况下,可能会导致故障。
方程 4-1 显示了 Neil Gunther 提出的通用可扩展性法则:一个模型硬件和软件系统的可扩展性的方程式。
方程式 4-1. 通用可扩展性法则
表 4-1 概述了通用可扩展性法则方程中每个术语的含义。
表 4-1. 通用可扩展性法则术语
| 术语 | 代表 |
|---|---|
X |
吞吐量 |
N |
负载:并发请求、运行进程、CPU 核心、分布式系统中的节点等 |
γ |
并发性(理想的并行性) |
α |
争用:等待共享资源 |
β |
一致性:协调共享资源 |
注意
深入研究《通用可扩展性法则》超出了本书的范围,因此我将解释限制在当前主题上:数据库性能的极限。想要了解更多,请阅读《游击队容量规划》,作者是 Neil Gunther。
吞吐量是负载的函数:X(N)。并发性(γ)有助于随着负载(N)的增加而增加吞吐量。但是争用(α)和一致性(β)会随着负载的增加而减少吞吐量。这排除了线性可扩展性并限制了数据库性能。
更糟糕的是,一致性不仅限制了性能,还导致回退性能:在高负载下性能下降。术语回退有所保留。它暗示着当 MySQL 无法处理负载时,它仅仅减少了吞吐量,但实际情况比这更糟。我更倾向于使用不稳定和失稳这些术语,因为它们更真实地表达了系统正在崩溃的现实,而不仅仅是运行速度变慢。
通用可扩展性法则出人意料地很好地模拟了真实世界中 MySQL 的性能。^(1) 但作为一种模型,它仅描述和预测工作负载的可扩展性;它并不说明工作负载如何或为何扩展(或未能扩展)。通用可扩展性法则主要被专家们用来测量和拟合数据到模型中,以确定参数(γ、α 和 β),然后不知疲倦地努力减少它们。其他人只是观察图表(第 6 章涵盖了 MySQL 指标),并等待 MySQL 性能不稳定 - 这就是极限。
图 4-3 展示了一个真实的故障期间的三个图表,当应用程序将 MySQL 推到极限时。
故障分为三个阶段:
峰值(早上 6 点到 9 点)
应用程序在峰值开始时是稳定的,但其开发人员开始担心,因为显示的指标缓慢但稳定地上升。过去,应用程序曾因稳定上升的指标而出现故障。作为回应,应用程序开发人员增加了事务吞吐量以应对增长的需求。(应用程序能够限制事务吞吐量;这不是 MySQL 的特性。)峰值和响应重复,直到不再起作用:MySQL 已达到极限。

图 4-3. 数据库性能超出极限
极限(早上 9 点到中午)
应用程序在达到极限期间完全不稳定,事实上处于离线状态。尽管 CPU 使用率和 QPS 高且稳定,但运行的线程告诉了一个不同的故事。在图 4-3 中展示的线程运行的来回波动模式是 MySQL 不稳定的明显迹象。由于一个查询需要一个线程来运行,线程运行的大幅波动表明查询没有顺畅地通过系统流动。相反,查询在不均匀、不协调的打击中猛烈地冲击 MySQL。
高且稳定的 CPU 使用率和 QPS 是误导性的:稳定只有在有少量变化时才算好,就像在极限之前和之后看到的那样。没有变化的稳定状态,在极限期间看到的那样,就像平稳状态。为了理解其中的原因,这里有一个奇怪但有效的类比。想象一个管弦乐团。当管弦乐团演奏正确时,音乐的各个方面都有变化。事实上,这些变化 就是 音乐:节奏、速度、音调、音色、旋律、动态等等。一个平稳状态的度量指标类似于一个失常的单簧管手持续演奏一个音符 强音:稳定,但不是音乐。
在极限期间,应用程序开发人员不断尝试增加事务吞吐量,但没有成功。MySQL 不会使用 CPU 的最后 5%,QPS 不会增加,线程运行不会稳定。从通用可扩展性法则(公式 4-1)中,你知道原因:竞争和一致性。随着负载增加(N),事务吞吐量(X)增加,但竞争(α)和一致性(β)的限制效应也随之增加,直到 MySQL 达到极限。
修复(中午至下午 3 点)
由于增加事务吞吐量导致了它自身的失败,修复的方法是减少事务吞吐量。这似乎是违反直觉的,但数学不会说谎。在中午时分,应用程序开发人员减少了事务吞吐量,在图表中的结果显而易见:CPU 使用率降至 50%,QPS 恢复到了稳定的变化(甚至稍微增加了一点),运行的线程也恢复到了稳定的变化(有少量峰值,但 MySQL 有备用容量可以吸收)。
想象一下这是如何工作的,考虑另一个类比。想象一条高速公路。当道路上有很多车时,它们都会减速(希望如此),因为人类需要时间来思考和对其他车辆做出反应,特别是在高速公路上。当道路上的车太多时,它们会导致交通堵塞。除了增加更多车道外,唯一的解决方案是减少高速公路上的车辆数量:少量车辆可以更快地行驶。减少事务吞吐量类似于减少高速公路上的车辆数量,这使得其余车辆可以更快地行驶,交通流畅。
这个例子很好地模拟了根据通用可扩展性法则(Equation 4-1)数据库性能的极限,但这也是一个特例,因为该应用程序能够将 MySQL 和硬件推到极限。更典型的情况是,高负载会使应用程序不稳定,这阻止了它增加 MySQL 的负载。换句话说:在应用程序能够推动 MySQL 到极限之前,应用程序会失败。但在这个例子中,应用程序没有失败,它不断扩展直到推动 MySQL 到极限。
在我们转向应用程序之前,还有两点关于 MySQL 性能的极限:
-
除非硬件显然不足,否则很难达到极限。正如在“更好、更快的硬件!”中提到的,这是你应该升级到合理硬件的两个例外之一。一个应用程序很难完全且同时利用所有硬件——CPU、内存和存储。当这种情况发生时,应用程序并未达到数据库性能的极限,而只是达到了一个硬件部件的极限。
-
当高负载导致 MySQL 响应缓慢时,这并不意味着已经达到了极限。原因很简单:
γ。 Gamma (γ) 表示并发或理想并行性。回想一下通用可扩展性法则方程(Equation 4-1)中,gamma 在分子中。^(2) 慢的数据库性能并不意味着已经达到了极限,因为增加并发(γ)会提高极限。减少争用(α)也会提高极限。(一致性 [β] 不受我们控制:它是 MySQL 和操作系统固有的,但通常不是问题。)
第二个观点引出了一个问题:我们如何增加并发性,或减少争用,或两者兼而有之?这似乎是一个至关重要的问题,但实际上不是:这是误导性的,因为 MySQL 性能的北极星是查询响应时间。并发性(γ)和争用(α)的值不能直接测量。它们是通过将吞吐量和负载测量与模型拟合来确定的。专家使用通用可伸缩性法则来理解系统容量,而不是改善性能。本节已经使用它来证明性能在极限处不稳定。
丰田和法拉利
一些应用程序实现了令人难以置信的 MySQL 性能,而其他一些则在低吞吐量方面苦苦挣扎。一些应用程序可以充分利用硬件,达到极限,而其他一些则几乎没有使 CPU 变热。一些应用程序没有任何性能问题,而其他一些则不断地为慢查询而苦恼。这是一个概括性的论断,但我要声称,每个工程师都希望他们的应用程序处于“while”左侧:性能令人难以置信,充分利用硬件,并且没有问题。而那些位于“while”右侧的应用程序与左侧的应用程序之间的区别,可以通过思考为什么法拉利比丰田更快来理解。
两个汽车品牌使用大致相同的零件和设计,但丰田的最高速度通常为 130 英里每小时,而法拉利的最高速度为 200 英里每小时。[³]法拉利没有特殊的零件,使其比丰田快 70 英里每小时。那么为什么法拉利比丰田快得多?答案在于工程设计和细节的差异。
丰田不是为高速设计的。实现高速(如高性能)需要对许多细节进行仔细关注。对于汽车来说,这些细节包括:
-
引擎尺寸、配置和时机
-
变速器齿轮比、换档点和时机
-
轮胎尺寸、牵引力和旋转力
-
转向、悬架和制动
-
空气动力学
两个汽车品牌都为这些细节进行设计和工程化,但法拉利的精确细节水平解释了为什么它能够实现更高的性能。您可以在其中一个细节中看到这一点:空气动力学。法拉利独特的外部设计华丽而实用:它降低了空气动力学系数,从而提高了效率。
高性能,如高速度,并非偶然或靠蛮力就能实现。这是通过对高性能的目标进行细致工程化而实现的结果。法拉利比丰田更快,因为它在每一个细节上都被设计和工程化为更快。
您的应用程序是否在每个细节上都设计和工程化,以实现最大的 MySQL 性能?如果是,那么我想你可以跳过本章的其余部分。如果不是,这通常是答案,那么下一节将讨论将类似于丰田的应用程序与类似于法拉利的应用程序区分开来的基本技术差异:数据访问模式。
数据访问模式
数据访问模式描述了应用程序如何使用 MySQL 访问数据。
术语数据访问模式(或简称访问模式)通常被使用,但很少有解释。通过澄清关于访问模式的三个细节,让我们改变这一点:
-
讨论访问模式的复数形式非常常见,以至于它们开始模糊在一起。但重要的是要意识到它们并非一个不可区分的整体。一个应用程序有许多访问模式。为方便起见,它们以复数形式讨论。但在实践中,您是单独修改访问模式的。
-
访问模式最终是指一个查询,您更改查询(和应用程序)以更改访问模式,但查询并非重点。在Go 编程语言术语中,访问模式是一个接口,查询是一个实现。专注于接口,而非实现,使得可以设想(并可能应用)访问模式到不同的数据存储。例如,在 MySQL 上执行的某些访问模式更适合键值数据存储,但如果专注于与键值查询毫无相似之处的 SQL 查询,则很难看到这一点。在本书中,我讨论了修改访问模式,但在实践中,您修改查询(和应用程序)。
-
访问模式由名称和技术特征列表组成。名称用于与其他工程师识别和交流访问模式。(访问模式没有固有的名称。)选择一个简洁而有意义的名称。技术特征列表依赖于并且因数据存储而异。例如,MySQL 数据访问与 Redis 数据访问大不相同。本节列举并解释了 MySQL 数据访问的九个特征。
理论上,应用程序开发人员应该识别每个单独的访问模式,但让我们诚实一点:这是非常繁琐的。(我从未见过有人做到这一点,如果应用程序变化迅速,这甚至可能是不可行的。)尽管如此,这仍然是目标。以下是朝这一目标的三种合理且可实现的方法:
-
与您的团队进行头脑风暴,以识别最明显和最常见的访问模式。
-
使用查询概要文件(见“查询概要文件”)来识别顶部最慢的访问模式。
-
浏览代码以查找较少知名(或被遗忘)的访问模式。
至少需要在第一次或第二次尝试中遵循以下方法之一,以完成本章的目标:通过更改访问模式间接优化查询。
一旦您已识别(并命名)一个访问模式,请确保对以下九个特征的每一个找到值或答案。不知道特征的值或答案是学习和可能改进应用程序的一个很好的机会。不要让特征未知;找到或弄清楚特征的值或答案。
在解释每个九个特性之前,还有一个问题需要解决:如何使用访问模式?访问模式是纯粹的知识,这些知识构成了前一节和下一节之间的桥梁。前一节,“丰田和法拉利”指出高性能的 MySQL 需要一个高性能的应用程序。下一节,“应用程序变更”介绍了帮助重新设计应用程序以便高性能与数据库兼容的常见应用程序变更。访问模式有助于决定(有时候会指导)如何将应用程序从丰田重新设计为法拉利。
不多说了,让我们来详细讨论 MySQL 数据访问模式的九个特性。
读/写
访问是读取还是写入数据?
读取访问很明确:SELECT。在考虑细节时,写访问就不那么清楚了。例如,INSERT是写访问,但INSERT…SELECT是读和写访问。同样地,UPDATE和DELETE应该使用WHERE子句,这也使它们成为读和写访问。简单地说:INSERT、UPDATE和DELETE总是被视为写访问。
在内部,读取和写入并不相等:它们对 MySQL 的技术影响不同,并调用不同的内部部分。例如,INSERT和DELETE在底层是不同的写入操作——不仅仅是因为前者添加而后者移除。再次简单地说:所有读取是平等的,所有写入是平等的。
读/写 特性是最基础和普遍的之一,因为扩展读取和写入需要不同的应用程序变更。通常通过卸载读取来扩展读取,我稍后在“卸载读取”中介绍。扩展写入更加困难,但是排队写入是一种技术(见“排队写入”),而第五章则涵盖了最终解决方案:分片。
尽管这个特性相当简单,但它很重要,因为快速了解应用程序是读重还是写重能够迅速聚焦于相关的应用程序变更。例如,在写重的应用程序中使用缓存是不相关的。此外,其他数据存储为读或写进行了优化,而 MySQL 还有一个写优化的存储引擎:MyRocks。
吞吐量
数据访问的吞吐量(每秒查询数)及其变化是多少?
首先,吞吐量并不等同于性能。低吞吐量的访问——即使只有 1 QPS——也可能造成严重问题。你可能能想象出这会如何;如果不能,这里举个例子:一个SELECT…FOR UPDATE语句,它对表进行扫描并锁定每一行。找到如此糟糕的访问是罕见的,但它证明了这一点:吞吐量并不等同于性能。
尽管存在严重的访问问题,对于所有在 “少量 QPS 更好” 中详细阐述的原因,非常高的 QPS(其中 高 是相对于应用程序而言)通常是需要减少的一个问题。例如,如果应用程序执行股票交易,在美国股票交易所开盘时(东部时间上午 9:30),可能会出现大量的读写访问突发。这种吞吐量水平引发了完全不同的考虑,而不是稳定的 500 QPS。
变化 —— QPS 的增加和减少同样重要。前面的段落提到了 突发 和 稳定;另一种类型的变化是 周期性:QPS 随着时间周期性增加和减少。一个常见的周期模式是在工作时间内增加的 QPS —— 例如东部时间上午 9 点到下午 5 点 —— 和夜间较低的 QPS。一个常见的问题是在工作时间内高 QPS 阻止开发人员进行模式更改(ALTER TABLE)或回填数据。
数据时代
数据访问的年龄是多少?
年龄 是相对于访问顺序而言,并非时间。如果一个应用程序在 10 分钟内插入了一百万行数据,第一行是最老的,因为它是最后访问的行,而不是因为它已经 10 分钟了。如果应用程序更新第一行,则它变成最新的,因为它是最近访问的行。如果应用程序不再访问第一行,但继续访问其他行,则第一行会变得越来越老。
这个特征很重要,因为它影响工作集。回想一下 “工作集大小”,工作集是经常使用的索引值和它们引用的主键行 —— 这句话的意思是 经常访问的数据,通常只占表大小的一小部分。MySQL 尽可能多地将数据保存在内存中,数据的年龄影响内存中的数据是否属于工作集。通常是这样,因为 MySQL 凭借各种算法和数据结构在内存中保持工作集非常高效。 图 4-4 是这个过程的高度简化插图。
图 4-4 中的矩形代表所有数据。工作集是一小部分数据:从虚线到顶部。而内存比两者都小:从实线到顶部。在 MySQL 术语中,数据在访问时 变得年轻。当数据没有被访问时,它会变老并最终被从内存中驱逐。

图 4-4. 数据老化
由于访问数据会使其保持年轻并保留在内存中,工作集保持在内存中。这就是为什么 MySQL 在少量内存和大量数据情况下非常快速的原因。
经常访问旧数据在多个方面都是有问题的。为了解释原因,我必须深入到超出本节范围的技术细节,但我稍后在 “InnoDB” 中进行澄清。数据被加载到空页(内存中):即那些不包含数据的页。 (页是 InnoDB 内部的逻辑存储单位,每个 16 KB。) MySQL 使用所有可用的内存,但同时保留一定数量的空页。 当有空页时,这是正常的,问题仅在于从存储中读取数据会很慢。 当没有空页时,这是异常的,问题会恶化三倍。 首先,MySQL 必须驱逐旧页,这些页在最近最少使用(LRU)列表中被跟踪。 其次,如果旧页是脏的(即包含未写入磁盘的数据更改),MySQL 必须在清除之前刷新(持久化)它,而刷新操作很慢。 第三,原始问题仍然存在:从存储中读取数据很慢。 长话短说:频繁访问旧数据对性能有问题。
偶尔访问旧数据并不是问题,因为 MySQL 很聪明:驱动这一过程的算法在 图 4-4 中阻止偶尔访问旧数据干扰新(年轻)数据。 因此,一起考虑数据年龄和吞吐量:旧且缓慢的访问可能是无害的,但旧且快速的访问必然会带来麻烦。
数据年龄几乎不可能衡量。^(4) 幸运的是,你只需估计访问数据的年龄,这可以通过你对应用程序、数据和访问模式的理解来完成。例如,如果应用程序存储金融交易,你知道访问主要限于新数据:最近 90 天的交易。访问超过 90 天的数据应该很少,因为交易已经结算且不可变。相比之下,同一应用程序的另一部分管理用户配置文件,如果活跃用户的比例高,可能会频繁访问旧数据。记住:旧数据相对于访问而言,而非时间。一个一周前最后登录的用户配置文件并不一定是时间上的旧数据,但相对于其他已经被访问过的百万个配置文件而言,他们的配置文件数据确实相对较旧,这意味着他们的配置文件数据已经从内存中清除。
知道这一特性是理解 “分区数据” 和第五章中的分片的先决条件。
数据模型
访问所展示的数据模型是什么?
尽管 MySQL 是关系型数据存储,但通常与其他数据模型一起使用:键值、文档、复杂分析、图形等。您应该特别注意非关系访问,因为它不是 MySQL 的最佳匹配,因此不能提供最佳性能。MySQL 在其他数据模型方面表现出色,但仅限于某些情况。例如,MySQL 作为键值数据存储很有效,但RocksDB则更胜一筹,因为它是专门为键值数据存储而构建的。
数据模型特征不能像其他特征那样以编程方式测量。相反,您需要确定访问表现出哪种数据模型。动词表现是有意义的:访问可能仅因为 MySQL 是唯一可用的数据存储而是关系型,但在考虑所有数据存储时表现出另一个数据模型。访问通常被强行塞入可用数据存储的数据模型中。但最佳实践是反过来:确定访问的理想数据模型,然后使用为该数据模型构建的数据存储。
事务隔离
访问需要什么样的事务隔离?
隔离性是 ACID 属性之一:原子性、一致性、隔离性和耐久性。由于默认的 MySQL 存储引擎 InnoDB 支持事务,每个查询默认都在事务中执行,即使是单个SELECT语句。(第八章讨论了事务。) 因此,访问具有隔离性,无论是否需要。这一特性澄清了是否需要隔离性,以及如果需要,需要哪种级别。
当我询问工程师这个问题时,答案分为三类:
无
不,访问不需要任何隔离。它可以在非事务性存储引擎上正确执行。隔离只是无用的开销,但它不会造成任何问题或明显影响性能。
默认
可能访问需要隔离性,但尚不清楚需要哪种级别。应用程序对 MySQL 的默认事务隔离级别REPEATABLE READ可以正常工作。需要仔细考虑确定其他隔离级别是否或不需要隔离。
具体
是的,访问需要特定的隔离级别,因为它是与访问相同数据的其他事务并发执行的一部分。没有特定的隔离级别,访问可能会看到数据的不正确版本,这对应用程序将是一个严重问题。
根据我的经验,默认是最常见的类别,这是合理的,因为 MySQL 的默认事务隔离级别REPEATABLE READ对大多数情况都是正确的。但是,对这一特性的回答应该导致无或具体。如果访问不需要任何隔离性,那么可能不需要事务性数据存储。否则,如果访问需要隔离性,现在您确切地知道需要哪种隔离级别以及原因。
其他数据存储也有事务——甚至是不基于事务的数据存储。例如,文档存储 MongoDB 在版本 4.0 中引入了多文档 ACID 事务。了解所需的隔离级别及其原因可以帮助您将访问从 MySQL 移至另一个数据存储并进行翻译。
警告
其他数据存储中的事务可能与 MySQL 的事务非常不同,并且事务会影响锁定等其他方面。
读一致性
读取访问是否需要强一致性或最终一致性?
强一致性(或强一致性读)意味着读取返回最新值。在源 MySQL 实例上(非副本),读取是强一致的,但事务隔离级别确定当前值。长时间运行的事务可以读取旧值,但从技术上讲,这是事务隔离级别下的当前值。第八章 深入讨论了这些细节。暂时记住,强一致性是源 MySQL 实例的默认(也是唯一)选项。对于所有数据存储来说都不是这样。例如,Amazon DynamoDB 默认使用最终一致性读取,强一致性读取是可选的,速度较慢且更昂贵。
最终一致性(或最终一致性读)意味着读取可能返回旧值,但最终会返回当前值。由于复制延迟,MySQL 副本的读取是最终一致的:即数据在源上写入与在副本上写入(应用)之间的延迟。最终的持续时间大致等于复制延迟,应该小于一秒。用于提供读访问的副本称为读副本。(并非所有副本都提供读取;有些仅用于高可用性或其他目的。)
在 MySQL 的世界中,通常所有访问都使用源实例,这使得所有读取默认都是强一致性的。但是,当复制延迟为亚秒级时,读取通常不需要强一致性。当可以接受最终一致性时,可以进行读取卸载(参见“卸载读取”)。
并发性
数据是并发访问的吗?
零并发意味着访问不会同时读取(或写入)相同数据。如果在不同时间读取(或写入)相同数据,则也是零并发。例如,插入唯一行的访问模式具有零并发。
高并发意味着访问频繁同时读取(或写入)相同数据。
并发性指示写访问时行锁定的重要性(或麻烦)。毫不奇怪,对同一数据的写并发性越高,行锁争用就越大。只要增加的响应时间可接受,行锁争用就是可以接受的。当它导致锁等待超时时,这就变得不可接受了,这是应用程序必须处理并重试的查询错误。当这种情况开始发生时,只有两种解决方案:减少并发性(更改访问模式),或分片(参见第五章)以扩展写操作。
并发性还指示缓存对读访问的适用性。如果相同数据以高并发读取但变更不频繁,则适合缓存。我在“卸载读取”中讨论了这一点。
正如在“数据时代”中所讨论的,同时性几乎不可能测量,但您只需要估算并发性,这可以通过您对应用程序、数据和访问模式的理解来完成。
行访问
行如何被访问?有三种类型的行访问:
点访问
单行
范围访问
在两个值之间的有序行
随机访问
任意顺序的多行
使用英文字母(A至Z),点访问是任何单个字符(例如A);范围访问是顺序的任意数量字符(例如ABC,或者如果B不存在,则AC);随机访问是任意数量的随机字符(例如ASMR)。
这一特性似乎很简单,但对于写访问有两个重要原因:
-
间隙锁定:使用非唯一索引的范围和随机访问写操作加剧了由于间隙锁定而导致的行锁争用。“行锁定”详细讨论了这一点。
-
死锁:随机访问写入是死锁的前提条件,即两个事务持有对方需要的行锁。MySQL 会检测并打破死锁,但会影响性能(MySQL 终止一个事务以打破死锁),而且很恼人。
行访问在计划如何分片时也很重要。有效的分片要求访问模式使用单个分片。点访问与分片结合效果最佳:一个行对应一个分片。范围和随机访问与分片兼容,但需要仔细规划,以避免通过访问过多分片抵消分片的好处。第五章涵盖了分片。
结果集
访问组、排序或限制结果集吗?
这一特性很容易回答:访问是否有GROUP BY、ORDER BY或LIMIT子句?每个子句都会影响访问可能如何更改或在其他数据存储上运行。“数据访问”涵盖了几个变化。至少要优化分组或排序行的访问。限制行不是问题,而是一个好处,但在其他数据存储中的工作方式不同。同样,其他数据存储可能支持或不支持分组或排序行。
应用程序更改
你必须改变应用程序以改变其数据访问模式。本节介绍的更改是常见的,但不是穷尽的。它们非常有效,但也高度依赖于应用程序:有些可能有效,而其他可能不会。(除了第一个变化,"审计代码":总是有效。)因此,每一个变化都是一个需要与团队进一步讨论和规划的想法。
除了第一个之外的所有更改都有一个微妙的共同点:它们需要额外的基础设施。我指出这一点是为了心理准备你,除了代码更改,你还需要基础设施的变更。正如一开始预言的那样,《优化查询响应时间》(第一章)间接查询优化需要更大的努力。而改变数据(第三章)可能是工作,改变访问模式肯定是工作。但是这是值得的努力,因为这些变化从定义上来说是变革性的:应用程序从丰田变成法拉利。
你可能会想:如果这些变化如此强大,为什么不先做它们——在优化查询和数据之前?由于本书的重点是高效的 MySQL 性能,我计划了这个旅程以应用程序变更结束,因为这需要最多的努力。相比之下,直接查询优化(第二章)和数据更改(第三章)需要的努力要少得多,前者解决了大部分,如果不是所有的性能问题。但是如果你有时间和精力直接进行应用程序的重构,我支持你。只是要记住从第二章学到的教训:索引提供了最多且最好的杠杆。糟糕的查询破坏了出色的访问模式;或者引用著名的 MySQL 专家 Bill Karwin 的话:
你未经优化的查询正在毁掉数据库服务器。
审计代码
你可能会惊讶于代码可以在没有任何人查看的情况下存在和运行多长时间。从某种意义上说,这是良好代码的标志:它只需正常工作,不会引发问题。但是“不引发问题”并不一定意味着代码是高效的或者甚至是必需的。
你不必审计所有的代码(虽然这不是一个坏主意),只需审计访问数据库的代码。当然要看实际的查询,但也要考虑上下文:这些查询实现的业务逻辑。你可能会意识到实现同样业务逻辑的不同且更好的方法。
关于查询,请查找以下内容:
-
不再需要的查询
-
执行太频繁的查询
-
过于频繁或频率过快的重试查询
-
大型或复杂的查询 - 它们是否可以简化?
如果代码使用 ORM 或任何类型的数据库抽象层,请仔细检查其默认设置和配置。一个考虑因素是,一些数据库库在每次查询后执行SHOW WARNINGS以检查警告。通常这不是问题,但也是相当浪费的。还要仔细检查驱动程序的默认设置、配置和发布说明。例如,Go 编程语言的 MySQL 驱动程序多年来有着非常有用的发展,因此 Go 代码应该使用最新版本。
通过使用查询配置文件间接审计代码,查看应用程序执行了哪些查询——无需进行查询分析;只需将查询配置文件用作审计工具。在配置文件中经常看到未知查询是很常见的。鉴于"MySQL 无所作为"(MySQL Does Nothing),未知查询可能源自应用程序——无论是您的应用程序代码还是 ORM 等任何类型的数据库抽象层,但还有另一种可能性:运维。运维指运行和维护数据存储的人员:DBA、云服务提供商等。如果发现未知查询,并且确信应用程序未执行它们,请与运维人员联系。
小贴士
为了更容易进行查询审计,在/* SQL comments */中添加应用程序元数据到查询中。例如,SELECT…/* file:app.go line:75 */显示查询在应用程序源代码中的来源。SQL 注释会从摘要文本中删除,因此您的查询度量工具必须包含样本(参见示例 1-1)或从 SQL 注释中解析元数据。
最后,也是最容易被忽视的是:审查MySQL 错误日志。它应该是安静的:没有错误、警告等。如果它很嘈杂,请查看错误,因为它们表示各种问题:网络、认证、复制、MySQL 配置、非确定性查询等等。这些问题应该是非常罕见的,因此不要忽视它们。
卸载读取操作
默认情况下,称为源头的单个 MySQL 实例提供所有读写服务。在生产环境中,源头应该至少有一个副本:另一个 MySQL 实例,复制源头的所有写操作。第七章讨论了复制,但我在这里提到它是为了讨论如何卸载读取操作。
性能可以通过将读取操作从源头卸载来改善。这种技术使用 MySQL 副本或缓存服务器来提供读取服务。(稍后详述这两者。)它以两种方式提升性能。首先,减少了源头的负载,从而释放时间和系统资源,加快剩余查询的运行速度。其次,通过为卸载的读取操作提供服务的副本或缓存,改善了响应时间,因为这些服务没有负载写操作。这是一种双赢的技术,通常用于实现高吞吐量、低延迟的读取操作。
从副本或缓存读取的数据不保证是最新的(即最新值),因为 MySQL 复制和写入缓存中存在固有和不可避免的延迟。因此,来自副本和缓存的数据是最终一致的:在(希望是非常)短暂的延迟后变得最新。只有源上的数据是当前的(不考虑事务隔离级别)。因此,在从副本或缓存中提供读取之前,必须满足以下条件:接受读取过时(最终一致的)数据,并且它不会给应用程序或其用户造成问题。
让那个声明深思熟虑,因为我曾多次看到开发人员考虑并意识到,“是的,如果应用程序返回略过时的值,这是可以接受的。”一个常见的例子是帖子或视频的“赞”或“点赞”数量:如果当前值是 100,但缓存返回 98,这已经足够接近——尤其是如果缓存几毫秒后返回当前值。如果这个声明对你的应用程序不成立,请不要使用这种技术。
除了要求可以接受最终一致性的需求之外,卸载读取不得作为多语句事务的一部分。多语句事务必须在源上执行。
警告
请始终确保卸载读取操作可以接受最终一致性,并且不是多语句事务的一部分。
在从副本或缓存中提供读取操作之前,彻底解决这个问题:副本或缓存离线时,应用程序如何降级运行?
对于这个问题,唯一的错误答案是不知道。一旦应用程序卸载读取操作,它往往会严重依赖副本或缓存来提供这些读取操作。设计、实施和测试应用程序在副本或缓存离线时如何降级运行至关重要。降级意味着应用程序在运行,但明显变慢,限制客户端请求,或因为某些部分离线或被限流而无法完全运行。只要应用程序不是完全宕机——完全离线且无法响应且没有友好的人类可读错误消息——那么你在使应用程序在降级状态下运行方面已经做得很好了。
在讨论使用 MySQL 副本与缓存服务器之前的最后一个要点:不要卸载所有读取操作。卸载读取操作通过不在源上执行副本或缓存可以完成的工作来提高性能。因此,首先卸载慢(耗时)读取操作:在查询性能分析中显示为慢查询的读取操作。这种技术非常有效,因此逐个卸载读取操作,因为你可能只需要卸载几个读取操作就能显著提高性能。
MySQL 副本
使用 MySQL 的副本提供读取服务很常见,因为每个生产 MySQL 设置应该已经至少有一个副本,并且多于两个副本也很普遍。有了基础设施(副本)已经就位,你只需修改代码以使用副本来分担读取而不是源。
在说明为什么副本比缓存服务器更可取之前,有一个重要问题需要解决:应用程序能否使用副本?由于副本用于高可用性,管理 MySQL 的人可能并不打算让副本提供读取服务。务必弄清楚这一点,因为如果不能,副本可能会因维护而被下线而无法提前通知。
假设你的副本可以用来提供读取服务,它们比缓存服务器更可取,理由有三:
可用性
由于副本是高可用性的基础,它们应该与源具有相同的可用性 — 例如 99.95% 或 99.99% 的可用性。这使得副本几乎无需担忧:管理 MySQL 的人也在管理副本。
灵活性
在前一节中,我说过你应该从慢速(耗时)读取开始。对于缓存而言,这一点尤为重要,因为缓存服务器很可能具有有限的 CPU 和内存 — 这些资源不应浪费在琐碎的读取上。相比之下,用于高可用性的副本应该与源具有相同的硬件配置,因此它们有多余的资源。将琐碎的读取分担给副本的影响并不那么大,因此在选择何时分担时具有灵活性。如果偶然有纯粹的 读取副本 —— 也就是 不 用于高可用性的副本 —— 其硬件配置较弱,则不要浪费资源在琐碎的读取上。这在云中更为常见,因为可以轻松地为大容量存储提供读取副本,但 CPU 和内存较小(以节省成本)。
简单性
应用程序无需做任何事情来保持副本与源的同步 — 这是副本固有的特性。有了缓存,应用程序必须管理更新、失效和(可能的)驱逐。但真正的简单之处在于副本不需要任何查询更改:应用程序可以在副本上执行完全相同的 SQL 语句。
这三个理由充分说明了为什么要优先选择 MySQL 的副本而不是缓存服务器,但后者有一个重要的优势:缓存服务器比 MySQL 快得多。
缓存服务器
一个缓存服务器不受 SQL、事务或持久存储的限制。这使它比 MySQL 快得多,但在应用程序中正确使用它需要更多工作。如前文所述,应用程序必须管理缓存更新、失效以及(可能的)驱逐。此外,应用程序需要一个能与缓存配合的数据模型,通常是键值模型。这额外的工作是值得的,因为实际上没有比缓存更快的东西。Memcached 和 Redis 是两种流行且广泛使用的缓存服务器。
注意
如果你听说 MySQL 有内置的查询缓存:请忘记它,永远不要使用。自 MySQL 5.7.20 起,它已被弃用,并且在 MySQL 8.0 中移除。
缓存非常适合频繁访问但不经常更改的数据。这对于 MySQL 复制不是一个问题,因为所有更改都会复制,但缓存只存储应用程序放入其中的内容。一个不好的例子是当前 Unix 时间戳(以秒为单位):它一直在变化。在这种糟糕的情况下的例外:如果访问频率显著大于更改频率。例如,如果每秒请求一百万次当前 Unix 时间戳,那么缓存当前时间戳可能是合适的。一个好例子是当前年份:它的变化不频繁。然而,在这种良好情况下的例外:如果访问频率显著低于更改频率。例如,如果每秒仅请求一次当前年份,则缓存几乎没有价值,因为每秒 1 次对于这种数据访问没有任何影响。
在使用缓存时需要注意:决定缓存是临时的还是持久的。这对于 MySQL 复制不是一个问题,因为它们总是持久的,但某些缓存服务器可以是两者之一。如果缓存是真正的临时的,那么你应该能够对缓存数据执行类似于TRUNCATE TABLE的操作而不影响应用程序。你还需要决定如何重建临时缓存。一些应用在缓存未命中时重建缓存:当请求的数据不在缓存中时。其他应用程序有一个外部过程,从另一个数据源重新构建缓存(例如,从Amazon S3加载存储在其中的图像)。有些应用程序非常依赖缓存,或者缓存非常大,因此重建它是不可行的。对于这些应用程序,需要一个持久的缓存。无论是临时还是持久,都要测试你的决策,以验证在缓存失败和恢复时应用程序是否按预期运行。
排队写入
使用队列稳定写入吞吐量。图 4-5 展示了不稳定的—突发—写入吞吐量,其高于 30,000 QPS 并低于 10,000 QPS。

图 4-5. 不稳定的写入吞吐量
即使目前性能在不稳定的写入吞吐量下仍然可接受,但这不是成功的秘诀,因为不稳定的吞吐量在规模上会恶化——它永远不会自动稳定。(如果你还记得图 4-3 中的“性能极限时的表现”,一个平线值并不稳定。)使用队列使应用程序能够以稳定的速率处理更改(写入),如图 4-6 所示。

图 4-6. 稳定的写入吞吐量
将写入加入队列和稳定的写入吞吐量的真正威力在于它们使应用程序能够优雅且可预测地响应雷鸣群:即淹没应用程序、数据库或两者的请求洪流。例如,假设应用程序通常每秒处理 20,000 次更改。但它下线了五秒钟,结果导致有 100,000 个待处理更改。应用程序重新上线时,它将面对这 100,000 个待处理更改—一个“雷鸣群”—以及当前秒的正常 20,000 个更改。应用程序和 MySQL 将如何处理这场“雷鸣群”?
有了队列,雷鸣群不会影响 MySQL:它们进入队列,MySQL 像往常一样处理更改。唯一的区别是有些更改可能会比平时晚一些。只要写入吞吐量稳定,可以增加队列消费者的数量,以更快地处理队列。
没有队列的话,经验告诉我们会出现以下两种情况之一。要么你会非常幸运,MySQL 能处理“雷鸣群”现象,要么就不行。不要寄希望于运气。MySQL 不会限制查询执行,因此在“雷鸣群”袭击时会尝试执行所有查询。(然而,MySQL 企业版、Percona Server 和 MariaDB Server 拥有线程池,限制同时执行查询的数量,起到了一定的限流作用。)这种方法从来不奏效,因为 CPU、内存和磁盘 I/O 本质上是有限的,更不用说普适性扩展法则(Equation 4-1)。尽管如此,MySQL 总是试图做到,因为它非常雄心勃勃,也有点鲁莽。
这种技术赋予了其他优势,使得实施它变得值得。其中一个优势是它将应用程序与 MySQL 的可用性解耦:即使 MySQL 离线,应用程序也能接受更改。另一个优势是它可以用来恢复丢失或放弃的更改。假设一个更改需要各种步骤,其中一些可能是长时间运行或不可靠的。如果某个步骤失败或超时,应用程序可以重新将更改放入队列以重试。第三个优势是如果队列是事件流(如Kafka),则可以重放更改。
提示
对于写入密集型应用程序,将写入加入队列是最佳实践,几乎是必须的。投入时间学习和实现队列。
分区数据
在第三章之后,你应该不会感到意外,减少数据量能更容易提高性能。对你来说数据很重要,但对 MySQL 来说只是一种负担。如果不能删除或存档数据(见“删除或存档数据”),那么至少应该对数据进行分区(物理分离)。
首先,让我们简要讨论然后放置MySQL 分区。MySQL 支持分区,但需要特殊处理。实施或维护起来并不轻松,并且一些第三方 MySQL 工具不支持它。因此,我不建议使用 MySQL 分区。
最有用、更常见且对应用程序开发者更容易实现的数据分区类型是将热和冷数据分开:分别是频繁访问和不经常访问的数据。将热数据和冷数据分开是分区和归档的结合。它根据访问进行分区,并通过将不经常访问的(冷)数据移出经常访问的(热)数据的访问路径来进行归档。
让我们举个例子:一个存储支付信息的数据库。热数据是最近 90 天的支付,有两个原因。首先,支付通常在结算后不会更改,但像退款这样的例外情况可以稍后应用。然而,一段时间后,支付将最终确定且无法更改。其次,应用程序仅显示最近 90 天的支付。要查看较早的支付,用户必须查找过去的对账单。冷数据是 90 天后的支付。一年来,这是 275 天,大约占数据的 75%。为什么要让 75%的数据在像 MySQL 这样的事务数据存储中闲置不用呢?这是一个反问:没有充分的理由。
将热数据和冷数据分开主要是对前者进行优化。将冷数据存储在其他地方有三个直接优点:更多的热数据适合内存,查询不会浪费时间检查冷数据,并且操作(如模式更改)更快。将热数据和冷数据分开也是对后者的优化,当它具有完全不同的访问模式时。在前面的例子中,旧的支付可能会按月分组成单个数据对象,不再需要每笔支付的行。在这种情况下,文档存储或键值存储可能更适合存储和访问冷数据。
至少,您可以在同一数据库的另一个表中存档冷数据。通过控制的INSERT…SELECT语句从热表中选择并插入到冷表中是相对容易的。然后从热表中DELETE存档的冷数据。为了一致性将所有操作包装在一个事务中。参见“删除或存档数据”。
这种技术可以通过多种不同的方式实施,特别是关于冷数据存储和访问的方式和位置。但从根本上讲,它非常简单且高效:将不经常访问的(冷)数据移出频繁访问的(热)数据的访问路径,以提高后者的性能。
不要使用 MySQL
我想在当前关于应用程序变更的讨论中做一个象征性的总结:最显著的变更是在访问模式明显不适合使用 MySQL 作为最佳数据存储时不使用它。有时很容易看出 MySQL 不是最佳选择。例如,在前几章中,我提到过一个负载为 5,962 的查询。该查询用于选择图中的顶点。显然,关系型数据库不适合处理图数据;最佳选择是图数据存储。甚至键-值存储也比关系型数据库更好,因为图数据与规范化和事务等关系型数据库概念无关。另一个简单且常见的例子是时间序列数据:面向行的事务性数据库不是最佳选择;最佳选择是时间序列数据库,或者可能是列存储。
即使 MySQL 不是最佳选择,它也能出乎意料地适应各种数据和访问模式。但不要把这一点视为理所当然:作为团队中第一个说出“也许 MySQL 不是最佳选择”的工程师是可以的。没关系:如果我能这么说,你也可以。如果有人因此给你麻烦,请告诉他们我支持你为工作选择最佳工具的决定。
尽管如此,MySQL 也是非常棒的。请至少在阅读完本章和下一章 第五章 之后再考虑放弃 MySQL。
更好,更快的硬件?
“更好,更快的硬件!” 警告不要通过扩展硬件来提高性能。但是该部分的第一句话精心措辞:“当 MySQL 的性能不可接受时,请不要从扩展…” 该句中的关键词是 begin,它引导出一个关键问题:何时才是扩展硬件的正确时机?
这个问题很难回答,因为它取决于查询、索引、数据、访问模式以及这些如何利用当前的硬件。例如,假设应用程序有一个超级低效的访问模式:它将 MySQL 用作队列,并且从许多应用程序实例中非常快速地轮询它。在修复访问模式之前,我不会扩展硬件。但有时候,工程师没有足够的时间来进行这样的应用程序更改。
表 4-2 是一个检查表,用于帮助确定是否是扩展硬件的时机。当您可以检查完列 1 中的所有项目,并至少检查列 2 中的两个项目时,这明显表明是时候扩展硬件了。
表 4-2. 硬件升级清单
| 1. 检查所有 | 2. 至少检查两项 |
|---|---|
| ☐ 响应时间过高 | ☐ CPU 利用率大于 80% |
| ☐ 已优化慢查询 | ☐ 运行线程大于 CPU 核心数 |
| ☐ 数据已删除或存档 | ☐ 内存少于总数据大小的 10% |
| ☐ 已经审查和优化访问模式 | ☐ 存储 IOPS 利用率大于 80% |
第一栏不仅是对自第一章以来一切的明确重申,还是升级硬件的明确理由。第二栏至少需要两次检查,因为硬件是协同工作的。仅仅大量利用一个硬件部件并不能保证问题或性能变慢。相反,这可能是一个好迹象:你完全利用了该硬件部件。但当一个硬件部件过载时,通常会开始影响其他硬件部件。例如,当缓慢的存储导致查询积压,进而导致客户积压,因为 MySQL 试图执行过多的线程,这就是为什么第二栏需要两次检查的原因。
第二栏中的数值应始终大于或小于建议的阈值。偶尔的波动是正常的。
如果是自己的硬件,存储设备的最大 IOPS 数由存储设备确定。如果不确定,请查看设备规格或询问管理硬件的工程师。在云中,存储 IOPS 是分配或预留的,所以通常更容易告知最大值,因为你购买了 IOPS。但如果不确定,请检查 MySQL 存储设置或询问云提供商。"IOPS"显示了哪些指标报告存储 IOPS。
根据应用程序是读密集型还是写密集型(参见"读/写"),存储 IOPS 利用还需要考虑其他因素:
读取密集型
对于读取密集型访问模式,持续高 IOPS 可能是由于内存不足而不是 IOPS 不足。当 MySQL 从磁盘读取数据时,如果数据不在内存中,它通常会表现出色地保持工作集在内存中(参见"工作集大小")。但是,两个因素的组合可能会导致读取高 IOPS:工作集大小显著大于内存,并且读取吞吐量异常高(参见"吞吐量")。这种组合会导致 MySQL 在磁盘和内存之间频繁交换数据,从而以高 IOPS 的问题表现出来。这是罕见的,但可能发生。
写入密集型
对于写入密集型访问模式,持续高 IOPS 可能是由于 IOPS 不足。简而言之:存储无法快速写入数据。通常,存储通过写入缓存实现高吞吐量(IOPS),但缓存不是持久的。MySQL 需要持久存储:数据物理上存储在磁盘上,而不是在缓存中。因此,MySQL 必须刷新数据——强制将其写入磁盘。刷新严重限制了存储的吞吐量,但 MySQL 具有复杂的技术和算法来实现性能与耐久性——“页面刷新”详细介绍了这一点。在这一点上,唯一的解决方案是增加存储的 IOPS,因为您已经优化了查询、数据和访问模式。
谨慎考虑扩展硬件的建议,看起来我们已经到了尽头。无论有多少卵石、鹅卵石或巨石需要移动,我们总可以用更大的卡车来搬运它们。但如果你必须移动一座山呢?那么你就需要下一章:分片。
总结
本章重点讨论了决定如何更改应用程序以有效使用 MySQL 的数据访问模式。重要的要点是:
-
MySQL 除了执行应用程序查询外无任何操作。
-
数据库性能在硬件容量的百分之百以下的某个极限处不稳定。
-
一些应用由于每一个细节都设计为高性能而具有更高的 MySQL 性能。
-
访问模式描述应用程序如何使用 MySQL 访问数据。
-
您必须更改应用程序以改变其数据访问模式。
-
在耗尽其他解决方案之后,扩展硬件以提高性能。
下一章介绍了将 MySQL 分片以实现规模化 MySQL 的基本机制。
实践:描述一个访问模式
这项实践的目标是描述最慢查询的访问模式。(要获取慢查询,请参考“查询概况”和“实践:识别慢查询”。)对于最慢的查询,请从“数据访问模式”描述所有九种访问模式特征。正如该部分所述,访问模式是纯知识。利用这些知识考虑可以通过更改其访问模式间接优化查询的“应用变更”。即使没有可能的应用变更,了解访问模式也是专家实践,因为 MySQL 性能取决于查询、数据和访问模式。
^(1)观看由著名 MySQL 专家 Baron Schwartz 撰写的视频“通用可扩展性法则建模工作手册”,了解来自真实 MySQL 服务器的值的 USL 如何发挥作用。
^(2) 实际上,著名的 MySQL 专家巴伦·施瓦茨放在那里。尼尔·冈瑟在博客文章中写道,“USL Scalability Modeling with Three Parameters”,巴伦之所以添加第三个参数,是因为这使得 USL 能够拟合来自真实数据库的数据。
^(3) 丰田:210 Km/h;法拉利:320 Km/h。
^(4) 从技术上讲,可以通过检查 InnoDB 缓冲池中数据页的 LSN 来实现,但这样做会造成干扰,所以实际上几乎不会这样做。
第五章:分片
在单个 MySQL 实例上,性能取决于查询、数据、访问模式和硬件。当直接和间接的查询优化—认真应用后—不能提供可接受的性能时,你已经达到了应用负载下单实例 MySQL 性能的相对极限。为了超越这个相对极限,你必须将应用负载分布到多个 MySQL 实例中,以实现 MySQL 的扩展。
分片 是常用的、广泛使用的技术,用于 扩展 (或 水平扩展 ):通过在多个数据库之间分配工作负载来提高性能。相比之下, 扩展 (或 垂直扩展 )通过增加硬件容量来提高性能。分片将一个数据库分成多个数据库。每个数据库都是一个分片,每个分片通常存储在运行在独立硬件上的独立 MySQL 实例上。分片在物理上是分开的,但在逻辑上是相同的(非常大的)数据库。
MySQL 的扩展需要分片。在本章中,我将重复这句话多次,因为这是工程师不愿意接受的事实。为什么呢?因为分片不是 MySQL 的固有特性或能力。因此,分片是复杂的,并且完全依赖于应用程序,这意味着没有简单的解决方案。但不要气馁:分片是一个已解决的问题。几十年来,工程师们一直在扩展 MySQL。
本章介绍了分片的基本机制,以实现 MySQL 的扩展。有四个主要部分。第一部分解释了为什么单个数据库不可扩展—为什么分片是必要的。第二部分完成了从第三章和第四章的类比:为什么小石子(数据库分片)比大石块(巨大数据库)更好。第三部分是关系数据库分片复杂主题的简要介绍。第四部分介绍了分片的替代方案。
为什么单一数据库不可扩展
没有人质疑单个应用程序可能会使单个服务器超负荷—这就是为什么对所有类型的服务器和应用程序,不仅仅是 MySQL,都需要扩展的原因。因此,分片是必要的,因为这是 MySQL 扩展的方法:更多数据库。但合理地怀疑为什么单个 MySQL 数据库不可扩展,尤其是在有着非常强大的硬件和一些基准测试显示出色性能的情况下,是完全合理的。以下是五个原因,从最基本的开始:应用负载可能会显著超过单服务器硬件的速度和容量。
应用负载
图 5-1 是在单个服务器上没有负载的硬件容量的简单说明。

图 5-1. 无负载的硬件
图 5-1 故意简单——但不简单——因为它微妙地传达了一个非常重要的观点:硬件容量是有限的。圆圈表示硬件的极限。假设硬件专门用于运行单个应用的 MySQL 实例——没有虚拟化、加密货币挖矿或其他负载。运行在硬件上的所有内容必须适合圆圈内。由于这是专用硬件,唯一运行的是显示在 图 5-2 中的应用工作负载:查询、数据和访问模式。

图 5-2. 标准 MySQL 工作负载
查询 指的是不是偶然的 第二章,数据 指的是 第三章,访问模式 指的是 第四章。这些构成了应用的工作负载:所有导致 MySQL 负载的因素,进而影响硬件(CPU 利用率、磁盘 I/O 等)。方框的大小很重要:方框越大,负载越重。在 图 5-2 中,工作负载在硬件容量内,并且还有一些空余,因为操作系统也需要硬件资源。
查询、数据和访问模式与性能密切相关。(我在 “间接查询优化” 中用 TRUNCATE TABLE 证明了这一点。)数据大小是扩展的常见原因,因为正如 图 5-3 所示,它使工作负载超出了单个服务器的容量。

图 5-3. 数据过多的硬件
数据大小的增加最终会影响查询和访问模式。购买更大的硬盘不会解决问题,因为正如 图 5-3 所示,虽然数据的容量充足,但数据并非工作负载的唯一部分。
图 5-4 描述了一种常见的误解,即工程师们认为单个数据库可以扩展到最大数据大小,目前单个 InnoDB 表的最大大小为 64 TB。

图 5-4. 只有数据的硬件(缩放误解)
数据仅是工作负载的一部分,另外两部分(查询和访问模式)也不可忽视。实际上,为了在单个服务器上处理大量数据以获得可接受的性能,工作负载必须类似于 图 5-5。

图 5-5. 数据量大的硬件
如果查询简单且具有异常良好的索引,并且访问模式是微不足道的(例如,非常低吞吐量的读取),那么单个服务器可以存储大量数据。这不仅仅是一个巧妙的插图;真实的应用程序具有类似 图 5-5 的工作负载。
这五个例子揭示了单个数据库无法扩展的原因,因为应用程序工作负载——包括查询、数据和访问模式——必须适应硬件的容量。在“更好、更快的硬件!”和“更好、更快的硬件?”之后,您已经知道硬件无法解决这个问题。
MySQL 在规模化时需要分片,因为应用程序工作负载可能远远超过单台服务器硬件的速度和容量。
基准测试是合成的。
基准测试使用合成(虚假)查询、数据和访问模式。这些是必然虚假的,因为它们不是真正的应用程序,当然也不是您的应用程序。因此,基准测试不能告诉您——甚至也不能建议——您的应用程序将如何执行和扩展——即使在相同的硬件上。此外,基准测试主要集中在一个或多个访问模式(参见“数据访问模式”),这会产生一个类似于图 5-6 中所示的工作负载。

图 5-6. 带有基准工作负载的硬件
大多数应用程序的工作负载并不是性能由一个或多个访问模式主导的情况。但基准测试却很常见,因为它允许 MySQL 专家对 MySQL 的特定方面进行压力测试和测量。例如,如果一个 MySQL 专家想要评估新页面刷新算法的有效性,他们可能会使用一个完全优化的写入工作负载,包含几个完美优化的查询和非常少的数据。
但让我非常清楚:对于 MySQL 专家和 MySQL 行业来说,基准测试非常重要且必不可少。(如“MySQL 调优”中提到,基准测试是实验室工作。)基准测试用于以下目的:
-
比较硬件(比较一个存储设备和另一个)
-
比较服务器优化(比较一个刷新算法和另一个)
-
比较不同的数据存储(MySQL 与 PostgreSQL——经典竞争)
-
在极限条件下测试 MySQL(见“性能在极限条件下不稳定”)
这项工作对 MySQL 非常重要,也是 MySQL 能够实现惊人性能的原因。但在那份清单中显著缺失的是与您的应用程序及其特定工作负载相关的内容。因此,无论您在基准测试中读到或听到的 MySQL 性能有多惊人,都不会转化为您的应用程序,而且制作这些基准测试的专家也会告诉您:MySQL 在规模化时需要分片。
写入
单个 MySQL 实例上的写入因多种原因而难以扩展:
单个可写实例(源)。
为了实现高可用性,MySQL 在生产环境中使用多个实例连接成复制拓扑。但写入实际上限制在单个 MySQL 实例,以避免写入冲突:同时向同一行进行多次写入。MySQL 支持多个可写实例,但很难找到使用此功能的人,因为写入冲突太麻烦了。
事务和锁定
事务使用锁定来保证一致性——ACID 兼容数据库中的C。写入必须获取行锁定,有时它们会锁定比预期更多的行——“行锁定”解释了其中的原因。锁定会导致锁争用,这使得访问模式特征“并发性”成为评估写入扩展性的关键因素。如果工作负载在相同数据上写入较多,即使使用世界上最好的硬件也无济于事。
页面刷新(持久性)
页面刷新是 MySQL 将更改(来自写入)延迟持久化到磁盘的过程。整个过程过于复杂,无法在本节中详细解释,但关键点在于:页面刷新是写入性能的瓶颈。尽管 MySQL 非常高效,但这个过程本质上很慢,因为它必须确保数据是持久的:持久化到磁盘。没有持久性,由于缓存,写入速度非常快,但持久性是必需的,因为所有硬件最终都会崩溃。
写入放大
写入放大是指需要更多写入的写入过程。次要索引是最简单的例子。如果一个表有 10 个次要索引,一次写入可能需要写入额外的 10 次以更新这些索引。页面刷新(持久性)会导致额外的写入,复制甚至会导致更多的写入。这不仅仅适用于 MySQL;其他数据存储系统也会受到影响。
复制
备份对高可用性至关重要,因此所有写操作必须复制到其他 MySQL 实例(副本)。第七章讨论了复制,但以下是关于扩展写入的几个关键点。MySQL 支持异步复制、半同步复制和Group Replication。异步复制对写入性能影响较小,因为数据更改在事务提交时写入和刷新到二进制日志,但此后没有影响。半同步复制对写入性能影响更大:它通过每次提交必须由至少一个副本确认来减弱事务吞吐量到网络延迟。由于网络延迟以毫秒计,这对写入性能的影响是显著的,但这是一个值得的权衡,因为它保证了没有提交的事务会丢失,而异步复制则不能保证。Group Replication 更为复杂,扩展写入更为困难。由于在第七章中解释的各种原因,本书不涵盖 Group Replication。
这五个原因对于在单个 MySQL 实例上扩展写入是巨大的挑战,即使对于 MySQL 专家也是如此。在规模化的 MySQL 中,需要分片来克服这些挑战并扩展写入性能。
模式更改
模式更改不仅仅是例行事务;实际上,它们几乎是必须的。此外,最大的表经常发生变化并不罕见,因为它们的大小反映了它们的使用情况,而使用情况则导致开发,从而导致变化。即使您设法克服所有其他障碍并将单个表扩展到极大的大小,更改该表所需的时间也将是不可行的。需要多长时间?更改大表可能需要数天甚至数周。
对 MySQL 或应用程序来说,长时间等待不是问题,因为在线模式更改(OSC)工具如pt-online-schema-change和gh-ost,以及某些内置的在线 DDL 操作可以运行数天或数周,同时允许应用程序正常运行—这就是为什么称之为在线。但对于开发应用程序的工程师来说,等待时间过长是个问题,因为这样做不仅不会被忽视,而且还会成为越来越让人恼火的障碍,影响你、其他工程师,甚至可能影响其他团队。
例如,就在几周前,我帮助一个团队修改了几个表,每个表都有十亿行数据,尝试了近两周仍未成功(由于各种与 MySQL 无关的技术原因)。这个阻碍不仅仅影响了表或团队:长话短说,它阻碍了组织级别的目标——数月来其他几个团队的工作。幸运的是,需要的模式变更恰好是一个即时的在线 DDL 操作。但即时的模式变更极为罕见,所以不要指望它们。相反,不要让一个表变得如此庞大,以至于你无法在合理的时间内进行修改——无论你、你的团队和你的公司认为什么是“合理”的。
MySQL 的扩展需要分片,因为工程师不能等待几天或几周来修改模式。
操作
如果您精确而严谨地优化查询,您可以将单个数据库扩展到人们看了都不会相信的大小。但在“应用工作负载”中的硬件和工作负载的例子并未描述以下操作(或通常被称为ops):
-
备份和恢复
-
重建失败的实例
-
升级 MySQL
-
MySQL 的关闭、启动和崩溃恢复
数据库越大,这些操作就需要越长时间。作为应用程序开发人员,您可能不会管理这些操作中的任何一个,但除非管理数据库的工程师异常熟练并且深刻致力于零停机操作,否则它们将影响到您。例如,云提供商既不熟练也不致力于零停机操作;它们只试图将数据库的停机时间最小化,这可能意味着从 20 秒到几小时的数据库离线时间。
MySQL 的扩展需要分片来有效管理数据,这引导我们来到下一部分:鹅卵石,而非巨石。
鹅卵石,而非巨石
移动鹅卵石比移动巨石要容易得多。我反复强调这个类比是因为它非常贴切:MySQL 的扩展是通过使用许多小实例来实现的。(想要回顾这个类比,请阅读第 3 和第四章的介绍部分。)
在这个语境中,“小”有两个意思:
-
应用工作负载在硬件上以可接受的性能运行。
-
标准操作(包括 OSC)需要合理的时间。
乍一看,这使得“小”看起来如此相对而毫无用处,但在实践中,有限的硬件容量显著缩小了范围,几乎成为客观衡量的标准。例如,在撰写本文时,我建议工程师将单个 MySQL 实例的总数据大小限制在 2 或 4 TB:
2 TB
对于平均查询和访问模式,通用硬件足以提供可接受的性能,并且操作在合理的时间内完成。
4 TB
对于经过特别优化的查询和访问模式,中高端硬件足以保证可接受的性能,但操作可能比预期稍慢一些。
这些限制仅反映了您可以随时购买的硬件容量(2021 年 12 月)。多年前,限制显著较低。(还记得磁盘物理旋转并发出咔哒声的时候吗?奇怪。)多年后,限制将显著增加。
一旦数据库被分片,应用程序对分片数量的访问是程序化的。但对于运维人员——尤其是操作 MySQL 实例的工程师来说——分片的大小至关重要:管理一个 500GB 数据库比管理一个 7TB 数据库要容易得多。由于运维是自动化的,管理任意数量的小数据库也很容易。
MySQL 在进行分片和操作许多小数据库(鹅卵石而非巨石)时的性能确实是无限的。
分片:简介
分片的解决方案和实施与应用程序工作负载紧密相关。即使是下一节提到的替代解决方案“替代方案”,情况也是如此。因此,没有人可以告诉您如何进行分片,并且没有完全自动化的解决方案。做好漫长但值得的旅程准备。
分片有两种从想法到实施的路径:
设计用于分片的新应用程序
第一种也是最罕见的路径是从一开始就为分片设计应用程序。如果您正在开发新应用程序,我强烈建议您选择如果需要采用这种方法,因为从一开始进行分片比以后迁移要容易得多。
要确定是否需要分片,请估算未来四年的数据大小和增长情况。如果估算的四年后数据大小仍在您今天硬件的容量范围内,则可能不需要分片。我称之为四年适配。还要尝试估算应用工作负载的另外两个方面的四年适配:查询和访问模式。对于新应用程序来说,这些很难估算(而且可能会变化),但您应该有一些想法和期望,因为它们是设计和实施应用程序的必要部分。
还要考虑数据集是有界还是无界。有界数据集具有固有的最大大小或固有的缓慢增长。例如,每年发布的新智能手机数量非常少,其增长本质上很慢,因为没有理由认为制造商会每年发布成千上万款新手机。无界数据集没有固有限制。例如,图片是无界的:人们可以发布无限数量的图片。由于硬件容量有限,应用程序应始终为无界数据集定义和施加外部限制。永远不要让数据无限增长。无界数据集强烈表明需要分片,除非经常删除或存档旧数据(参见“删除或存档数据”)。
将现有应用程序迁移到分片
第二条更常见的路径是将现有的数据库和应用程序迁移到分片。这条路径显著更加困难、耗时和风险,因为到达这一点时,数据库已经很大——MySQL 正在推着一个巨石上山。与一队经验丰富的开发人员一起,计划迁移可能需要一年甚至更长时间。
在本书中,我无法涵盖如何将单个数据库迁移到分片数据库,因为这是一个定制过程:它取决于分片解决方案和应用程序工作负载。但有一点是确定的:您将从原始(单个)数据库复制数据到新的分片中——可能多次——因为初始迁移本质上是第一次重新分片,这是“重新分片”中讨论的挑战。
对于任何一种路径,分片都是一个复杂的过程。首先,选择一个分片键和策略,并了解将面临的挑战。这些知识为旅程设定了目标:一个您可以相对轻松操作的分片数据库。然后制定从一个数据库到达那个目标的路径。
分片键
要将 MySQL 进行分片,应用程序必须以编程方式将数据映射到分片。因此,最基本的决定是分片键:数据按照其列(或列)进行分片。分片键与分片策略(在下一节讨论)一起使用,将数据映射到分片中。应用程序负责根据分片键映射和访问数据,因为 MySQL 没有内置的分片概念——MySQL 对分片一无所知。
注意
术语分片可在数据库或存储数据库的 MySQL 实例中互换使用。
理想的分片键具有三个特性:
高基数
理想的分片键具有高基数(参见“极端选择性”),以便数据在分片中均匀分布。一个很好的例子是允许您观看视频的网站:它可以为每个视频分配一个类似dQw4w9WgXcQ的唯一标识符。存储该标识符的列是理想的分片键,因为每个值都是唯一的,因此基数是最大的。
引用应用实体
理想的分片键引用应用实体,以便访问模式不跨越分片。一个很好的例子是存储支付的应用程序:尽管每笔支付是唯一的(最大基数),但客户是应用实体。因此,该应用程序的主要访问模式是按客户而不是按支付。按客户分片是理想的,因为单个客户的所有支付应位于同一个分片上。
小
理想的分片键尽可能小,因为它被广泛使用:大多数——如果不是全部——查询都包括分片键,以避免分散查询——多个“挑战”之一。
毫无疑问,但为了确保已经说过:理想的分片键,与分片策略结合使用,避免或减轻“挑战”,尤其是事务和连接。
花费足够的时间来识别或创建适合你的应用的理想分片键。这个决策是基础的一半:另一半是使用分片键的分片策略。
策略
分片策略通过分片键值将数据映射到分片。应用实现分片策略以将查询路由到具有与分片键值相对应数据的分片。这个决策是基础的另一半。一旦实现了分片键和策略,就极为难以更改,所以选择非常谨慎。
有三种常见的策略:哈希、范围和查找(或目录)。所有三种都被广泛使用。最佳选择取决于应用访问模式,尤其是行访问(见“行访问”),如下三节所述。
哈希
哈希分片使用哈希算法(生成整数哈希值)、取模运算符(mod)和分片数(N)将哈希键值映射到分片。图 5-7 描述了从顶部开始使用哈希键值并沿着实线箭头到达底部的策略。

图 5-7. 哈希分片
哈希算法使用分片键值作为输入输出哈希值。哈希值(一个整数)mod分片数(N)返回分片编号:介于 0 和 N – 1 之间的整数。在图 5-7 中,哈希值75482 mod 3 = 2,所以分片键值对应的数据位于分片2。
注意
如何将分片编号映射到 MySQL 实例是你的选择。例如,你可以部署一个将分片编号映射到 MySQL 主机名的映射表,并与每个应用实例一起使用。或者,应用程序可以查询像etcd这样的服务,以发现分片编号如何映射到 MySQL 实例。
如果您在想,“改变分片数(N)不会影响数据映射到分片吗?”您是正确的。例如,75483 mod 3 = 0,但如果将分片数增加到五,相同的分片键值将映射到新的分片号码:75483 mod 5 = 3。幸运的是,这是一个已解决的问题:一致性哈希算法输出独立于N的一致哈希值。关键词是一致性:当分片变化时哈希值可能会改变,但可能性要小得多。由于分片可能会变化,您应选择一种一致性哈希算法。
哈希分片适用于所有分片键,因为它将值抽象为整数。这并不意味着它更好或更快,只是因为哈希算法自动映射所有分片键值,所以更容易。然而,“自动化”也是它的缺点,正如“重新平衡”所讨论的,手动重新定位数据几乎是不可能的。
点访问(参见“行访问”)与哈希分片很搭配,因为一个行只能映射到一个分片。相比之下,范围访问可能在哈希分片中不可行——除非范围非常小——因为“跨分片查询”(其中一个常见挑战)的原因。随机访问可能也不可行,原因相同。
范围
范围分片定义了连续的键值范围,并将每个范围映射到一个分片,如图 5-8 所示。

图 5-8. 范围分片
您必须事先定义键值范围。这样做可以在将数据映射到分片时提供灵活性,但需要对数据分布有深入的了解,以确保数据均匀分布在分片之间。由于数据分布会发生变化,预计需要处理重新分片(参见“重新分片”)。范围分片的一个好处是,与哈希分片不同,您可以更改(重新定义)范围,这有助于手动重新定位数据。
所有数据都可以排序并分成范围,但对于一些数据,如随机标识符,这是没有意义的。而一些看似随机的数据,经过仔细检查后实际上是接近有序的。例如,这里是 MySQL 生成的三个 UUID:
f15e7e66-b972-11ab-bc5a-62c7db17db19
f1e382fa-b972-11ab-bc5a-62c7db17db19
f25f1dfc-b972-11ab-bc5a-62c7db17db19
你能发现其中的区别吗?这三个 UUID 看起来随机,但很可能根据范围大小会排序到同一个范围内。在大规模情况下,这将导致大部分数据映射到同一个分片,从而失去了分片的目的。(UUID 算法各不相同:有些故意生成接近有序的值,而其他则故意生成随机排序的值。)
范围分片在以下情况下效果最佳:
-
分片键值范围是有界的
-
您可以确定范围(最小和最大值)
-
您了解值的分布,并且它大部分是均匀的
-
范围和分布不太可能改变
例如,股票数据可以根据从AAAA到ZZZZ的股票符号进行分片。尽管在Z范围内分布可能较少,总体上确保每个分片不会显著大于或比其他分片访问频率更高。
点访问(参见“行访问”)与范围分片结合使用时效果良好,只要行访问在范围内分布均匀,避免热分片——这是“重平衡”讨论的常见挑战。范围访问与范围分片结合使用时,只要行范围在分片范围内,如果不在,则“跨分片查询”会成为一个问题。由于同样的原因,随机访问可能不可行:跨分片查询。
查找
查找(或目录)分片是将分片键值自定义映射到分片的过程。图 5-9 描述了一个查找表,将国家代码顶级域名映射到分片上。

图 5-9. 查找(目录)分片
查找分片是最灵活的,但需要维护一个查找表。查找表作为键值映射功能:分片键值是键,数据库分片是值。您可以将查找表实现为数据库表、持久性缓存中的数据结构、应用程序部署的配置文件等等。
查找表中的键可以是单个值(如图 5-9 所示)或范围。如果键是范围,则本质上是范围分片,但查找表使您可以更好地控制这些范围。但这种控制有成本:更改范围意味着重新分片——这是常见的挑战之一。如果键是单个值,则当唯一分片键值的数量可管理时,查找分片是合理的选择。例如,存储美国公共健康统计数据的网站可以根据州和县名进行分片,因为总共不到 3500 个县,它们几乎不会更改。^(1) 查找分片具有一个优势,使其成为此示例的良好选择:可以轻松将所有人口非常少的县映射到一个分片,而哈希或范围分片不可能实现这种自定义映射。
所有三种行访问模式(参见“行访问”)都适用于查找分片,但它们的效果取决于您需要创建和维护的查找表的大小和复杂性,以将分片键值映射到数据库分片上。特别提到的是随机访问:查找分片允许您映射(或重新映射)分片键值以缓解随机访问引起的跨分片查询问题,而这几乎是使用哈希和范围分片几乎不可能做到的。
挑战
如果分片是完美的,您只需分片一次,每个分片的数据大小和访问都相等。这可能在您首次进行分片时是成立的,但以后不会再是这样。以下挑战会影响您的应用程序和分片数据库,因此请提前规划:了解如何避免或减轻它们。
事务
事务无法跨分片工作。这比挑战更像是一个阻碍,因为除了在应用程序中实施两阶段提交外,没有其他解决方案,而这种方式又十分危险,远超出本书的范围。
我强烈建议您避免这种阻碍。审查您的应用程序事务(参见“报告”),以及它们访问的数据。然后根据事务访问数据的方式选择适合的分片键和策略。
连接
SQL 语句无法在分片之间连接表。解决方案是 跨分片连接:应用程序连接在多个分片上执行的多个查询的结果。这不是一个简单的解决方案——甚至可能是复杂的,取决于连接的复杂性——但是它是可行的。除了复杂性,主要关注点是一致性:由于事务无法跨分片工作,每个分片的结果不是所有数据的一致视图。
跨分片连接是一种特殊目的的跨分片查询(连接结果是特殊目的);因此,它同样容易受到相同的挑战影响。
跨分片查询
跨分片查询 要求应用程序访问多个分片。该术语指的是应用程序访问,而非字面上的查询,因为单个查询无法在多个 MySQL 实例上执行。(更准确的术语应为 跨分片应用程序访问。)
跨分片查询会产生延迟:这是访问多个 MySQL 实例时固有的延迟。分片在跨分片查询作为异常而非常规情况时效果最佳。
如果分片是完美的,每个应用程序请求只会访问 一个 分片。这是目标,但不要因为某些应用程序即使经过有效分片,也必须访问多个分片才能完成某些请求而感到苦恼。点对点支付应用程序就是一个很好的例子。每个客户都是一个明确的应用程序实体:与客户相关的所有数据都应位于同一个分片上,这意味着数据按客户分片。但客户通过发送和接收资金进行互动。不可避免地,应用程序将至少访问两个分片:一个用于发送资金的客户,另一个用于接收资金的客户。应尽量减少跨分片查询,但同样地,请不要因为某些请求的应用逻辑要求而试图消除它们而感到疯狂。
相关挑战之一是分散查询(或分散-聚合查询):需要应用程序访问许多(或所有)分片的查询。这里的“分散”是指应用程序访问,而不是字面上的查询。跨分片查询的适度数量是不可避免且可接受的,但分散查询与分片的目的和优势背道而驰。因此,你应该预防并消除分散查询。如果无法做到 —— 如果应用程序需要分散查询 —— 那么分片可能不是正确的解决方案,或者访问模式需要更改(参见“数据访问模式”)。
重新分片
重新分片(或分片拆分)将一个分片划分为两个或更多新分片。重新分片是为了适应数据增长,也可以用来在分片之间重新分配数据。是否需要进行重新分片取决于容量规划:预计的数据增长速度以及最初创建了多少分片。例如,我见过一个团队将数据库分割成四个分片,然后不到两年后就重新分片,因为数据大小增长速度远远超过了预期。相比之下,我见过一个团队将数据库分割成 64 个分片,以应对超过五年预期的数据增长。如果在初始分片时你有能力承担额外的分片,那么至少创建足够支持四年数据增长的分片数 —— 不要过度估计,但要大方地估计。
这是分片的暗藏秘密:分片会引发更多分片。如果你在想,“我可以分片一次就完事了吗?”答案可能是“可能不行”。因为你的数据库已经发展到需要分片的程度,它可能会继续增长,并且需要更多的分片 —— 除非你深信少数据才是更好的理念(参见“少数据更好”)。
重新分片是一个挑战,因为它需要从旧分片向新分片进行数据迁移。描述如何迁移数据超出了本书的范围,但我会指出三个高层次的要求:
-
从旧分片到新分片的初始批量数据复制
-
将旧分片上的更改同步到新分片(在数据复制期间和之后)
-
切换到新分片的过程
安全且正确地迁移数据需要深厚的 MySQL 专业知识。由于数据迁移与应用程序和基础设施相关,你不会在任何书籍或其他资源中找到详细的过程。如果需要,可以聘请 MySQL 顾问来帮助设计流程。还可以查看由 Shopify 工程师编写的Ghostferry,他们是 MySQL 分片领域的专家。
重新平衡
重新平衡 重新定位数据以更均匀地分配访问。重新平衡是处理热分片所必需的:比其他分片有显着更多访问的分片。尽管分片键和分片策略决定了数据的分布方式,但应用程序及其用户决定了数据的访问方式。如果一个分片(热分片)包含所有最频繁访问的数据,那么性能就不会均匀分布,这违背了扩展的目的。目标是在所有分片上实现平等的访问和性能。
重新平衡取决于分片策略:
Hash
使用哈希分片几乎不可能重新定位数据,因为哈希算法会自动将数据映射到分片。一种解决方案(或解决方法)是使用包含重新定位分片键的查找表。应用程序首先检查查找表:如果存在分片键,则使用查找表指示的分片;否则,使用哈希算法。
范围
使用范围分片重新定位数据是可能的(但不是微不足道的),方法是重新定义范围以将热分片划分为更小的独立分片。这与重新分片的过程相同。
查找
用查找分片重新定位数据相对容易,因为您控制数据到分片的映射。因此,您更新查找表以重新映射与热数据对应的分片键值。
物理上重新定位热数据需要与用于重新分片的数据迁移过程相同(或类似)。
在线模式更改
在一个数据库上更改表很容易,但如何在每个分片上更改呢?您在每个分片上运行 OSC,但这不是挑战。挑战是自动化 OSC 流程以在多个分片上运行,并跟踪已更改的分片。对于 MySQL,在撰写本文时没有开源解决方案;您必须开发一个解决方案。(然而,在下一节中的一些 MySQL 替代方案中有解决方案。)这是分片中最不复杂的挑战,但它仍然是一个挑战。不能忽视这一点,因为模式更改是例行公事。
替代方案
分片是复杂的,对用户或客户并不直接有价值。对于应用程序来说,保持扩展是有价值的,但对工程师来说是苛刻的工作。不足为奇,替代解决方案越来越受欢迎和稳健。但是,不要太快将您的数据信任给新技术。MySQL 极其可靠且深入理解——这是一项非常成熟的技术——这使得它成为一个安全和合理的选择。
NewSQL
NewSQL 是指具有内置支持扩展的关系型、ACID 兼容的数据存储。换句话说,它是一个不需要分片的 SQL 数据库。如果你在想:“哇!那为什么还要使用 MySQL 呢?”以下五点解释了为什么 MySQL——无论是否分片——仍然是全球最流行的开源数据库:
成熟度
SQL 起源于 1970 年代,MySQL 起源于 1990 年代。数据库成熟意味着两件事:你可以信任数据存储不会丢失或损坏你的数据,并且对数据存储的每个方面都有深入的了解。要特别注意 NewSQL 数据存储的成熟度:首个真正稳定的 GA(普遍可用)版本是何时发布的?自那时以来发布的节奏和质量如何?公开可用的深度和权威知识是什么?
SQL 兼容性
NewSQL 数据存储使用 SQL(毕竟名字中就有),但兼容性差异很大。不要指望任何 NewSQL 数据存储可以直接替代 MySQL。
复杂操作
内置的支持通过分布式系统实现横向扩展。通常涉及多个不同但协调的组件。(如果 MySQL 是独奏的萨克斯手,那么 NewSQL 就是五人乐队。)如果 NewSQL 数据存储完全托管,那么也许它的复杂性并不重要。但如果你需要管理它,那么阅读其文档以了解其运行方式是很重要的。
分布式系统性能
回想一下通用可扩展性定律(Equation 4-1):
N代表软件负载(并发请求、运行进程等),或者硬件处理器,或分布式系统中的节点。如果应用程序需要的查询响应时间少于 10 毫秒,NewSQL 数据存储可能不适合,因为分布式系统固有的延迟。但这个响应时间级别并非 NewSQL 解决的更大、更常见的问题:内置的横向扩展到大数据量(相对于单个实例)与合理的响应时间(例如 75 毫秒)。
性能特征
查询的响应时间(性能)是由什么组成?对于 MySQL,高级组成部分包括索引、数据、访问模式和硬件——这些都在前面的四章中。再加上一些低级细节——比如“最左前缀要求”、“工作集大小”和“MySQL 什么也不做”——你就能理解 MySQL 的性能及其改进方法。NewSQL 数据存储将具有新的和不同的性能特征。例如,索引总是提供最大和最好的 leverage,但它们在 NewSQL 数据存储中可能以不同的方式工作,这是因为数据在分布式系统中的存储和访问方式不同。同样,对 MySQL 有效的某些访问模式在 NewSQL 上可能不好,反之亦然。
这五点是一个免责声明:NewSQL 是一项有前途的技术,您应该将其视为替代分片 MySQL 的选择,但 NewSQL 并非 MySQL 的轻松替代品。
在撰写本文时,仅有两个可行的开源 NewSQL 解决方案与 MySQL 兼容:TiDB 和 CockroachDB。 这两个解决方案对于数据存储来说都是非常新的:CockroachDB v1.0 GA 于 2017 年 5 月 10 日发布;而 TiDB v1.0 GA 于 2017 年 10 月 16 日发布。 因此,使用 TiDB 和 CockroachDB 时请谨慎小心,至少到 2027 年之前——即使 MySQL 在 2000 年初被广泛采用时也已经有 10 年历史。 如果您使用 TiDB 或 CockroachDB,请写下您所学到的内容,并且如果可能,为这些开源项目做出贡献。
中间件
中间件解决方案位于应用程序和 MySQL 分片之间。 它试图隐藏或抽象分片的细节,或者至少使分片变得更容易。 当直接、手动分片太困难,而 NewSQL 不可行时,中间件解决方案可以帮助填补这一空白。 两个领先的开源解决方案分别是 Vitess 和 ProxySQL,它们完全不同。 ProxySQL 可以 分片,而 Vitess 是 分片。
ProxySQL,顾名思义,是通过多种机制支持分片的代理。 想要了解它的工作原理,请阅读 “在 ProxySQL 中进行分片” 和 “使用 ProxySQL 进行 MySQL 分片”。 在 MySQL 前使用代理类似于经典的 Vim 对 Emacs 的分歧,不过没有所有的恶意:工程师们在这两个编辑器上都做了大量优秀的工作;这只是个人偏好的问题。 同样地,有公司成功使用和不使用代理;这只是个人偏好的问题。
Vitess 是一种专为 MySQL 分片而构建的解决方案。 由于分片是复杂的,Vitess 本身也不乏复杂性,但其最大优势在于它解决了所有挑战,特别是重新分片和重新平衡。 此外,Vitess 是由 YouTube 的 MySQL 专家团队创建的,他们对大规模 MySQL 有深入理解。
在分片之前,请务必评估 ProxySQL 和 Vitess。 任何中间件解决方案都需要额外的基础设施来学习和维护,但是其好处可能超过成本,因为手动分片 MySQL 也会耗费大量的工程时间、精力和平静。
微服务
分片关注一个应用程序(或服务)及其数据,特别是数据大小和访问。但有时真正的问题是应用程序本身:它具有太多的数据或访问,因为它服务于太多目的或业务功能。避免单片应用是标准的工程设计和实践,但这并不意味着总能实现。在进行分片之前,需审查应用程序设计及其数据,确保部分不能拆分为独立的微服务要容易得多,因为新的微服务及其数据库完全独立——不需要分片键或策略。新的微服务可能也具有完全不同的访问模式(见“数据访问模式”),使其可以在存储更多数据的同时使用更少的硬件——或者新的微服务根本不需要关系型数据存储。
不要使用 MySQL
与“不要使用 MySQL”类似,对 MySQL 分片的替代方案进行完全诚实的评估必须得出结论:如果其他数据存储或技术效果更好,就不要使用 MySQL。如果您的路径是为分片设计新应用程序,那么绝对要评估其他解决方案。MySQL 的分片问题已经解决,但这绝不是一个快速或简单的解决方案。如果您的路径是将现有应用程序迁移到分片,那么您仍应考虑将 MySQL 分片的权衡与迁移到其他解决方案的权衡。这听起来在规模上是繁重的,而且确实如此,但公司经常这样做,你也可以。
总结
本章介绍了分片 MySQL 的基本机制,以实现 MySQL 的扩展。其关键要点如下:
-
MySQL 通过分片进行扩展。
-
分片将一个数据库分成多个数据库。
-
单个数据库主要因为查询、数据和访问模式的组合——即应用程序工作负载——远远超过单服务器硬件的速度和容量而不能扩展。
-
管理许多小数据库(分片)比管理一个巨大的数据库要容易得多——小石子,而不是大石头。
-
数据通过分片键分片(分割),您必须谨慎选择。
-
使用分片策略与分片键一起映射数据(按分片键)到分片。
-
最常见的分片策略是哈希(哈希算法)、范围和查找(目录)。
-
分片面临多个必须解决的挑战。
-
存在替代分片的选择,你应该评估。
下一章将深入研究 MySQL 服务器指标。
实践:四年适合
这个练习的目标是确定数据大小的四年适配性。从 “分片:简介” 中,四年适配是将四年后的数据大小或访问量估计应用到今天硬件的容量上。如果估计的数据大小或访问量(比喻地)符合今天的硬件容量,则可能不需要分片。(参考 “应用工作负载” 讨论硬件适配性。)
你将需要历史数据大小来完成这个练习。如果你还没有在测量和记录数据大小,那么可以直接跳转到 “数据大小” 了解如何进行。
最简单的计算已经足够了。例如,如果一个数据库历史上每个月增加 10 GB,那么四年后数据库将增加 12 个月 × 4 年 × 10 GB/月 = 480 GB 更大 ——如果没有数据被删除或归档(参见 “删除或归档数据”)。如果数据库今天是 100 GB,那么四年后的 580 GB 是合适的:你不需要很快进行分片(尽管需要四年内适应访问负载),因为今天的 MySQL 在硬件上轻松处理 580 GB 的数据。
如果你的四年数据大小适配表明可能需要分片,那么请认真对待并深入研究以确定确切的情况:数据库是否正在稳定地变得过大以至于单个 MySQL 实例无法处理?如果是,那么早点分片,因为分片本质上是一个复杂的数据迁移过程;因此,数据越少,这个过程就越容易。如果不是,那么恭喜:确保系统将来几年继续扩展是所有工程领域的专家实践。
^(1) 县名在一个州内是唯一的,这就是为什么需要州名的原因。
第六章:服务器指标
MySQL 指标与 MySQL 性能密切相关——这是显而易见的。毕竟,任何系统中指标的目的都是测量和报告系统的运行情况。不明显的是它们如何相关。如果你当前看到 MySQL 指标如图 6-1 所示:MySQL 是一个黑匣子,其中的指标以某种方式指示 MySQL 的一些情况,那也不是没有道理。

第六章图示 1:MySQL 作为黑匣子:指标无法揭示
这种观点并不无理(或者不常见),因为 MySQL 指标经常被讨论但从未被教授。即使在我的 MySQL 职业生涯中,我也从未阅读过或听说过关于 MySQL 指标的阐述——我曾与创建这些指标的人一起工作。对 MySQL 指标缺乏教学的原因是一种错误的假设,即指标不需要理解或解释,因为它们的含义是不言自明的。这种假设在考虑单个指标时似乎是正确的,比如Threads_running;它是运行线程的数量——还有什么需要知道的?但孤立是错误的:MySQL 的性能通过一系列 MySQL 指标揭示出来。
把 MySQL 想象成一个棱镜。应用程序象征性地将工作负载投射到 MySQL 中。这个工作负载在物理上与 MySQL 及其运行的硬件相互作用。指标是通过这种象征性的工作负载通过 MySQL 折射出的光谱,如图 6-2 所示。

第六章图示 2:MySQL 作为棱镜:指标揭示工作负载性能
在物理科学中,这种技术称为光谱学:通过其与光的相互作用来理解物质。对于 MySQL 而言,这不仅仅是一个巧妙的类比,而是 MySQL 指标与 MySQL 服务器性能之间的实际关系,并有两个证明:
-
当你通过真实的棱镜照射光线时,产生的颜色光谱揭示了光的属性,而不是棱镜本身。同样地,当你在 MySQL 上运行工作负载时,产生的指标揭示了工作负载的属性,而不是 MySQL 的。
-
鉴于前几章——特别是“MySQL 什么也不做”——性能直接归因于工作负载:查询、数据和访问模式。没有工作负载,所有的指标值都是零(一般而言)。
通过这种方式看,MySQL 指标可以被以一种新的方式教授,这正是本章的重点。
这个类比还有另一个教育上的用途:将 MySQL 指标分解成光谱(光谱的复数)。这非常有用,因为 MySQL 指标非常广泛且杂乱(分散在 MySQL 中的几百个指标),但有效的教学需要焦点和组织。因此,“光谱”部分,将超过 70 个指标分为 11 个光谱,占据了本章的大部分内容。
在我们为 MySQL 开绿灯之前的最后一条注意事项:理解和分析 MySQL 服务器性能仅需关注一小部分指标。其余指标的相关性和重要性差异很大:
-
一些只是噪音
-
一些是历史性的
-
一些默认情况下是禁用的
-
一些非常技术特定
-
有些仅在特定情况下有用
-
一些只是信息性的,而不是合适的度量标准
-
有些被微不足道的凡人所难解
本章分析了 MySQL 指标的光谱,这些指标对理解工作负载如何影响 MySQL 服务器性能至关重要。本章包括六个主要部分。第一部分区分了查询性能和服务器性能。之前的章节侧重于前者,而本章侧重于后者。第二部分比较枯燥——你会明白为什么。第三部分列出了快速衡量 MySQL 性能的关键绩效指标(KPI)。第四部分探讨了指标的领域:一个更深入理解指标如何描述和与 MySQL 服务器性能相关联的模型。第五部分介绍了 MySQL 指标的光谱:超过 70 个 MySQL 指标组织成 11 个光谱——一次史诗般的旅程,带领你深入了解 MySQL 的内部工作原理,之后你将以新的视角看待 MySQL。第六部分讨论了与监控和警报相关的重要主题。
查询性能与服务器性能对比
MySQL 性能有两个方面:查询性能和服务器性能。之前的章节涉及查询性能:通过优化工作负载来提高响应时间。本章涉及服务器性能:分析 MySQL 作为执行工作负载功能的性能。
注意
在本章中,MySQL 性能意味着服务器性能。
简单来说,工作负载是输入,服务器性能是输出,如图 6-3 所示。
如果你将优化的工作负载输入 MySQL,你将获得高性能的 MySQL 输出。服务器性能几乎总是与工作负载有关,而不是 MySQL 本身。为什么?因为 MySQL 在执行各种工作负载时非常出色。MySQL 是一个经过充分优化的成熟数据存储系统——世界级数据库专家调优了几十年。这就是为什么本书的前五章赞扬查询性能,而仅有一章(本章)分析服务器性能的原因。

图 6-3. 查询和服务器性能
有三个原因需要分析服务器性能:
并发和争用
并发导致争用,降低查询性能。单独执行的查询与其他查询同时执行时表现不同。回顾方程式 4-1 中的普适可扩展性定律:争用(α)位于方程式的分母,这意味着随着负载增加,它会降低吞吐量。除非你生活在不同于我们其他人的宇宙中,否则并发和争用是不可避免的。
分析服务器性能最有用且最常见的目的是看看 MySQL 在所有查询(并发)竞争共享和有限系统资源(争用)时如何处理工作负载。某些工作负载几乎没有争用,而其他工作负载则尽管 MySQL 尽力也会导致查询和服务器性能下降。访问模式特征“并发性”在争用中是一个重要因素,但所有访问模式特征也同样重要。分析服务器性能揭示了工作负载中的查询如何相互影响。作为负责这些查询的工程师,我们需要确保它们能良好协作。
调优
服务器性能直接但不完全归因于工作负载。服务器性能还受三个因素影响:MySQL、操作系统和硬件。在查询性能方面,假设 MySQL、操作系统和硬件都已正确配置并适合工作负载。尽管存在问题(如故障硬件)和错误,但相较于工作负载,这三个因素对性能的影响要小得多,因为我们生活在一个丰盛的时代:MySQL 非常成熟且高度优化,操作系统先进且复杂,硬件快速且价格合理。
在“MySQL 调优”中讨论的事项仍然适用:调优 MySQL 就像从萝卜中榨血一样困难。你很可能永远不需要调优 MySQL。但如果你确实需要,它需要用已知且稳定的工作负载分析服务器性能;否则,你无法确定任何性能增益是否是调优的结果。这是基础科学:控制变量、可变性、可重复性和可反驳性。
性能回归
本书中我一直在赞扬 MySQL,但如果我不至少明确一次地说:有时候,MySQL 是错的,那我就有失职了。但 MySQL 并非凭空成为全球最流行的开源关系型数据库。它通常是正确的,在确保查询性能、MySQL 调优和故障硬件不是问题之后,怀疑性能回归(或错误)是专家的最后手段。
MySQL 专家 Vadim Tkachenko 的博客文章“MySQL 和 MariaDB 中的检查点”和“InnoDB MySQL 8 中的更多检查点”提供了分析服务器性能以揭示性能回归的完美示例。Vadim 进行这类工作是很正常的;我们其他人则在处理更简单的问题,比如索引和午餐前是否喝第三杯咖啡。
并发和争用是本章的隐含重点,因为它们是负责执行查询的应用程序维护工程师的责任。调整和性能退化是 MySQL DBA 和专家的责任。学习分析服务器性能对前者(并发和争用)是优秀的训练,因为两者的区别主要是关注的焦点。我希望前者引起对后者的兴趣,因为 MySQL 行业需要更多的 DBA 和专家。
正常和稳定:最好的数据库是一个无聊的数据库
大多数情况下,普通和稳定对于工程师来说一旦熟悉应用程序及其在 MySQL 上的工作负载运行方式就很直观了。人类擅长模式识别,因此很容易看出任何指标图表是否异常。因此,我不会详细解释通常为人所理解的术语,但我需要澄清两点以确保我们理解一致,以及解决工程师偶尔询问“什么是正常?”关于 MySQL 性能时的情况:
正常
每个应用程序、工作负载、MySQL 配置和环境都是不同的。因此,正常是 MySQL 在你的应用程序上典型工作日正常运行时表现出来的性能。那个正常——你的正常——是确定性能某个方面是否高于或低于、更快或更慢、更好或更差的基准。就是这么简单。
当我说一个假定的规范如“Threads_running少于 50 是正常的”时,这只是语言的缩写,简而言之是“根据我的经验,Threads_running的稳定值应小于 50,以及当前硬件通常具有少于 48 个 CPU 核心,并且基准测试显示 MySQL 性能目前无法有效扩展超过 64 个运行线程。”但如果 60 个线程运行对于你的应用程序是正常和稳定的,那太好了:你已经取得了非凡的性能。
稳定
在追求更高性能时不要失去稳定性能的视线。“性能在极限处不稳定”说明并解释了为什么从 MySQL 中挤取最大性能不是目标:在极限处,性能会不稳定,那时你会面临比性能更大的问题。稳定性不限制性能;它确保性能——在任何水平上——都是可持续的,因为这才是我们真正想要的:MySQL 始终快速,而不仅仅是有时候。
有时,MySQL 的性能是迷人的——高潮、低谷、尖叫的粉丝和挤满的体育场——但真正的艺术是将数据库优化到完美的无聊:所有查询快速响应,所有指标稳定和正常,所有用户都满意。
关键性能指标
四个指标快速评估 MySQL 性能:
响应时间
响应时间不足为奇:正如在“北极星”中指出的那样,它是任何人真正关心的唯一指标。即使响应时间很好,你也必须考虑其他关键绩效指标。例如,如果每个查询都失败并显示错误,响应时间可能非常好(接近零),但这并不正常。目标是正常和稳定的响应时间,较低更好。
错误
错误是错误率。哪些错误?至少是查询错误,但理想情况下应该是所有错误:查询、连接、客户端和服务器。不要期望零错误率,因为例如,如果客户端中止连接,你、应用程序或 MySQL 都无能为力。目标是正常和稳定的错误率,较低(接近零)更好。
QPS
每秒查询也不足为奇:执行查询是 MySQL 的主要目的和工作。QPS 指示性能,但并不等同于性能。异常高的 QPS 例如可能会信号问题。目标是正常和稳定的 QPS,而值则是任意的。
运行线程
运行线程评估 MySQL 为实现 QPS 而努力的程度。一个线程执行一个查询,因此你必须考虑这两个指标,因为它们密切相关。目标是正常和稳定的运行线程,较低更好。
我在“光谱”中详述了这些指标。在这里,重点是这四个指标是 MySQL 的关键绩效指标:当这四个值都正常时,MySQL 性能基本上也是正常的。始终监控响应时间、错误、QPS 和运行线程。是否在它们上发出警报将在“用户体验和目标限制上发出警报”中讨论。
将复杂系统的性能简化为少数几个指标并不是 MySQL 或计算机的特有现象。例如,你有生命体征(希望如此):身高、体重、年龄、血压和心率。五个生物指标简洁而准确地衡量你的健康。同样,四个 MySQL 指标简洁而准确地衡量服务器性能。这很巧妙,但真正有见地的是所有指标都位于的指标领域中。
指标领域
每个 MySQL 指标都属于图 6-4 中显示的六个类之一。总体上,我称之为指标领域。

图 6-4. 指标领域
分析孤立的指标不能充分理解 MySQL 的性能,因为性能不是孤立的属性。性能是许多因素的结果,这些因素有许多相关的指标。指标领域是一个模型,用于理解指标如何相关。这些关系将谚语点(指标)连接起来,完成 MySQL 性能的复杂图景。
响应时间
响应时间指标表示 MySQL 响应所需的时间。它们处于领域的顶层,因为它们包含(或隐藏)来自较低层的详细信息。
查询响应时间当然是最重要的,并且是唯一经常监控的。MySQL 在阶段中执行语句,可以对阶段进行计时。这些也是响应时间指标,但它们是围绕查询执行而不是在查询内部。实际的查询执行只是许多阶段中的一个阶段。如果你回想一下例子 1-3 在第一章中,执行实际的UPDATE语句只是 15 个阶段中的 1 个。因此,阶段响应时间主要由 MySQL 专家用于调查深层服务器性能问题。
响应时间指标很重要,但也完全不透明:MySQL 在这段时间内究竟做了什么?为了回答这个问题,我们必须深入探讨该领域。
速率
速率指标显示了 MySQL 完成离散任务的速度。每秒查询数(QPS)是普遍而广为人知的数据库速率指标。大多数 MySQL 指标都是速率,因为——不奇怪——MySQL 执行许多离散任务。
当一个速率增加时,它可能会增加相关的利用率。有些速率是无害的,并不会增加利用率,但重要且常被监控的速率确实会增加利用率。
速率与利用率的关系假定没有其他变化。这意味着,只要你改变了某个速率或其影响的利用率,你就可以增加速率而不增加利用率。通常情况下,改变速率比改变利用率要容易,因为速率是关系中的原因。例如,当整体 QPS 增加时,CPU 利用率可能会增加,因为更多查询需要更多的 CPU 时间。(增加 QPS 可能会增加其他利用率;CPU 只是一个例子。)为了避免或减少 CPU 利用率的增加,你应优化查询,使其执行时需要的 CPU 时间更少。或者,你可以通过扩展硬件来增加 CPU 核心数,但是"更好,更快的硬件!"和"更好,更快的硬件?"解决了这种方法的缺点。
速率与利用率的关系并不是一个新颖的见解——你可能已经知道了,但需要强调的是,这是统一领域关系序列的开端。不要为利用率感到抱歉:它是一种推动力。
利用率
利用率指标显示了 MySQL 使用有限资源的程度。利用率指标在计算机中无处不在:CPU 使用率,内存使用率,磁盘使用率等等。由于计算机是有限的机器,几乎所有东西都可以表达为利用率,因为没有什么是无限的——即使是云。
有界速率可以表示为利用率。如果存在最大速率,则速率是有界的。例如,磁盘 I/O 通常表示为速率(IOPS),但每个存储设备都有一个最大速率。因此,磁盘 I/O 利用率是当前速率除以最大速率。相比之下,无界速率无法表示为利用率,因为没有最大速率:例如,QPS,发送和接收的字节数等。
当利用率增加时,它可能会减少相关速率。我敢打赌你之前见过或经历过这样的情况:一个恶意查询导致 100%磁盘 I/O 利用率,从而导致 QPS 急剧下降,引发停机。或者,MySQL 使用了 100%的内存并被操作系统内核终止,这会导致最终速率降至零。这种关系是 USL 的表达(回想方程式 4-1),因为利用率增加争用(α)和一致性(β),这些因素都是方程式的除数。
在接近或达到 100%利用率时会发生什么?MySQL 等待。在图 6-4 中,箭头指示利用率和等待之间的关系——利用率-等待关系。箭头标记为停滞,因为查询执行会等待,然后恢复——也许会发生多次。我强调或接近,因为正如在“性能在极限时不稳定”中讨论的那样,停滞可以发生在 100%利用率之前。
停滞是反稳定的,但由于两个原因是不可避免的:MySQL 负载通常大于硬件容量;延迟在所有系统中都是固有的,特别是在硬件中。第一个原因可以通过减少负载(优化工作负载)或增加硬件容量来缓解。第二个原因难以解决,但并非不可能。例如,如果仍在使用旋转磁盘,升级到 NVMe 存储将显著减少存储延迟。
等待
等待指标表示查询执行过程中的空闲时间。当由于争用和一致性而导致查询执行停滞时,就会发生等待。 (等待也可能由于 MySQL 的错误或性能退化而发生,但这些情况极为罕见,不值得担忧。)
等待指标根据速率或响应时间(取决于指标)进行计算,但它们值得一个单独的类别,因为它们揭示了 MySQL 何时未工作(空闲),这与性能相反。未工作是为什么图 6-4 中的等待类别更暗的原因:MySQL 已经暗淡。
等待是不可避免的。消除等待不是目标;目标是减少和稳定等待时间。当等待时间稳定且减少到可接受的水平时,它们实际上会消失,在查询执行的响应时间中融入作为一个固有部分。
当 MySQL 等待时间过长时,它会超时——等待-错误的关系。最重要的高级 MySQL 等待具有可配置的超时时间:
-
MAX_EXECUTION_TIME(SQL 语句优化提示)
不要依赖这些方法,因为比如,试着猜一下lock_wait_timeout的默认值。lock_wait_timeout的默认值是 31,536,000 秒—365 天。设定默认值并不容易,因此我们必须给 MySQL 一些余地,但哇—365 天。因此,应用程序应始终使用代码级的超时机制。长时间运行的事务和查询是一个常见问题,因为 MySQL 虽然快,但可能太宽容了。
错误
错误指标显示错误。(在本书中,我允许自己做一个同义反复的声明;这就是它。)等待超时是一种错误类型,还有许多其他错误(详见“MySQL 错误消息参考”)。我不需要列举 MySQL 的错误,因为就服务器性能和 MySQL 指标而言,要点是简单明了的:异常错误率是不好的。像等待一样,错误也被计算为速率,但它们值得一个单独的类别,因为它们表明 MySQL 或客户端(应用程序)失败了,这就是为什么图 6-4 中的错误类别更加黑暗的原因。
再次强调关于来自“关键性能指标”的错误的一点:不要期望零错误率,因为比如,如果客户端中止连接,你、应用程序或 MySQL 都无能为力。
访问模式
访问模式指标显示应用程序如何使用 MySQL。这些指标与“数据访问模式”相关。例如,MySQL 为每种类型的 SQL 语句(Com_select、Com_insert等)都有指标,与“读/写”有关。
如图 6-4 所示,访问模式指标构成更高层次的指标。Com_select访问模式指标统计执行的SELECT语句的数量。这可以表示为速率(SELECT QPS)或利用率(% SELECT);无论哪种方式,它都揭示了关于服务器性能更深层次的信息,有助于解释更高级别的指标。例如,如果响应时间糟糕,而访问模式指标Select_full_join很高,那就是一个罪魁祸首(参见“Select full join”)。
内部
图 6-5 显示的有第七类指标:内部指标。

图 6-5. 带有内部指标的度量字段
我在“指标领域”的开头没有提到这一类,因为作为 MySQL 的工程师和用户,我们不应该知道或关心它。但这是最有趣的—如果不是神秘的—领域的一部分,我希望你完全了解,以防您需要或想要深入了解 MySQL 的深度。在这里,事情就有点深奥了。
当然,深奥 是主观的。我认为是内部度量的指标可能是另一位工程师最喜欢和有用的速率度量。但像 buffer_page_read_index_ibuf_non_leaf 这样的指标充分说明了内部度量类的重要性。该指标表示变更缓冲区中读取的非叶子索引页数。并不是你每天都会接触到的内容。
光谱
为另一次旅程做好准备:进入 MySQL 指标的半影区域。本节探讨了分成 11 个光谱的 70 多个 MySQL 指标,其中一些有子光谱。我将 MySQL 指标分为光谱有两个原因:
-
光谱提供了旅程的路标。没有它们,我们面对的将是一个庞大而无序的宇宙,涌动着来自不同来源的近 一千 种指标,这些指标因 MySQL 版本、发行版和配置的不同而异。
-
光谱揭示了 MySQL 性能方面重要的领域。
即使有光谱照亮黑暗中的路径,我们也需要一个度量命名约定来清晰而准确地讨论 MySQL 指标和系统变量,这些组成了每个光谱。原因很简单:MySQL 没有度量命名约定,行业标准也不存在。表 6-1 是我在本书中使用的 MySQL 度量命名约定。
表 6-1. MySQL 度量命名约定
| 示例 | 参考对象 |
|---|---|
Threads_running |
全局状态变量 |
var.max_connections |
全局系统变量 |
innodb.log_lsn_checkpoint_age |
InnoDB 度量 |
replication lag |
派生度量 |
大多数指标都是全局状态变量,通过执行 SHOW GLOBAL STATUS 你可能已经看到或使用过:Aborted_connects, Queries, Threads_running 等等。在 MySQL 和本书中,全局状态变量名称以单个大写字母开头,后跟小写字母,即使第一个单词是缩写:Ssl_client_connects,不是 SSL_client_connects。(这是 MySQL 度量中一致的一个方面。)相比之下,全局系统变量是小写的;为了使它们更加独特,我在它们前面加上 var.,这在考虑到下一个约定时很重要。InnoDB 的度量也是小写的,例如 lock_timeouts。由于这可能看起来像全局系统变量,我在 InnoDB 度量前面加上 innodb.,例如 innodb.lock_timeouts。派生度量在监控中普遍存在,但不是 MySQL 本身的特性。例如,Replication lag 是几乎每个监视器都会输出的度量,但具体的度量名称取决于监视器,这就是为什么我使用一个描述性名称而不是特定的技术名称来描述它,而不带有下划线字符。
注意
本节中的 InnoDB 指标需要启用特定的计数器或模块。例如,使用innodb_monitor_enable=module_log,module_buffer,module_trx启动 MySQL。在 MySQL 手册的var.innodb_monitor_enable和“InnoDB INFORMATION_SCHEMA Metrics Table”中查看更多信息。
倒数第二点心理准备:全局指的是整个 MySQL 服务器:所有客户端、所有用户、所有查询等等——合在一起。相比之下,还有会话和摘要指标。会话指标是限定于单个客户端连接的全局指标。摘要指标通常是全局指标的一个子集,涵盖各种方面:账户、主机、线程、事务等等。本章仅关注全局指标,因为它们是所有指标的基础。(全局指标也是最初的:在古老的时代,MySQL 只有全局指标;后来它增加了会话指标;然后增加了摘要指标。)
在我们开始旅程之前,最后一点心理准备:大多数 MySQL 指标都是简单的计数器,只有少数是计量器。我明确指出计量器;否则,默认是计数器。让我们开始吧!
查询响应时间
全局查询响应时间是四个“关键性能指标”之一。令人惊讶的是,在 MySQL 8.0 之前,MySQL 并没有这个指标。从 MySQL 8.0.1 开始,你可以通过在性能模式中执行示例 6-1 中的查询来获取 95th 百分位(P95)全局查询响应时间(以毫秒计)。
示例 6-1. 全局 95th 百分位查询响应时间
SELECT
ROUND(bucket_quantile * 100, 1) AS p,
ROUND(BUCKET_TIMER_HIGH / 1000000000, 3) AS ms
FROM
performance_schema.events_statements_histogram_global
WHERE
bucket_quantile >= 0.95
ORDER BY bucket_quantile LIMIT 1;
那个查询返回的百分位非常接近于但不完全等于 P95:例如,是 95.2%而不是 95.0%。这种差异微乎其微,不会影响监控。
你可以将查询中的0.95替换为返回不同百分位数:0.99表示 P99,或0.999表示 P999。基于“平均值、百分位数和最大值”中所述的原因,我更推荐使用 P999。
本节其余部分适用于 MySQL 5.7 及更早版本,如果你使用的是 MySQL 8.0 或更新版本,请跳过此部分。
MySQL 5.7 及更早版本
MySQL 5.7 及更早版本不公开全局查询响应时间指标。只有查询指标包括响应时间(见“查询时间”),但那是每个查询的响应时间。要计算全局响应时间,你需要从每个查询中聚合它。虽然这是可能的,但有两个更好的选择:升级到 MySQL 8.0;或者切换到 Percona Server 或 MariaDB,它们有一个插件用于捕获全局响应时间。
Percona Server 5.7
早在 2010 年,Percona Server引入了一个用于捕获全局响应时间的插件,名为响应时间分布。安装插件很容易,但配置和使用需要一些工作,因为它是响应时间范围的直方图,这意味着你需要设置var.query_response_time_range_base——一个插件创建的全局系统变量,来配置直方图桶范围,然后从桶计数计算百分位数。MySQL 8.0 全局响应时间也是一个直方图,但桶范围和百分位数是预设和预计算的,这就是为什么示例 6-1 中的查询可以直接使用。设置起来并不难;听起来复杂而已。拥有全局响应时间的好处绝对值得努力。
MariaDB 10.0
MariaDB使用与 Percona 相同的插件,但名称略有不同:查询响应时间插件。虽然在 MariaDB 10.0 中引入,但直到 MariaDB 10.1 才标记为稳定版。
在 MySQL 8.0 之前,获取全局查询响应时间并不是一件简单的事情,但如果你在运行 Percona Server 或 MariaDB,这个努力是值得的。如果你在云中运行 MySQL,请检查云提供商的指标,因为一些提供了响应时间指标(云提供商可能称为延迟)。如果没有其他办法,经常审查查询配置文件以监控响应时间。
错误
错误是四个“关键绩效指标”之一。从 MySQL 8.0.0 开始,通过在性能模式中执行示例 6-2 中的查询,轻松获取所有错误的计数。
示例 6-2. 全局错误计数
SELECT
SUM(SUM_ERROR_RAISED) AS global_errors
FROM
performance_schema.events_errors_summary_global_by_error
WHERE
ERROR_NUMBER NOT IN (1287);
注意
错误编号 1287,在示例 6-2 的WHERE子句中被排除,用于弃用警告:当查询使用被弃用的功能时,MySQL 会发出警告。包含此错误号可能会使全局错误计数过于吵闹,这就是我排除它的原因。
由于 MySQL 有很多错误和警告,无法预测全局错误率会是多少。不要期望或试图达到零错误率。这基本上是不可能的,因为客户端可能会引发错误,你、应用程序或 MySQL 都无法阻止。目标是为应用程序建立正常的错误率。如果示例 6-2 中的查询太吵闹——这意味着它产生了高错误率,但你确信应用程序正常运行——那就通过排除其他错误号码来微调查询。MySQL 错误代码在“MySQL 错误消息参考”中有文档。
在 MySQL 8.0 之前,你无法从 MySQL 中获取全局错误计数,但可以通过执行示例 6-3 中的查询,获取查询错误的计数,来自性能模式。
示例 6-3. 查询错误计数
SELECT
SUM(sum_errors) AS query_errors
FROM
performance_schema.events_statements_summary_global_by_event_name
WHERE
event_name LIKE 'statement/sql/%';
自 MySQL 5.6 起在所有发行版中都有效,没有理由不监控所有查询错误。当然,应用程序也应报告查询错误;但如果应用程序在错误发生时也重试,它可能会隐藏一定数量的错误。相比之下,这将暴露所有查询错误,可能会揭示应用程序重试所掩盖的问题。
最后的错误指标是客户端连接错误:
-
Aborted_clients -
Aborted_connects -
Connection_errors_%
前两个指标通常被监控,以确保连接或已连接到 MySQL 时没有问题。这种措辞非常精确:如果应用程序无法与 MySQL 建立网络连接,则 MySQL 不会看到客户端,也不会报告客户端连接错误,因为从 MySQL 的角度来看,此时还没有客户端连接。低级网络连接问题应由应用程序报告。但是,如果应用程序无法连接,则可能会看到其他三个关键绩效指标(QPS、正在运行的线程和响应时间)的下降,因为应用程序没有执行查询。
注意
Connection_errors_%中的%字符是 MySQL 通配符;存在几个以Connection_errors_为前缀的指标。要列出它们,请执行SHOW GLOBAL STATUS LIKE *Connection_errors_%*;。
在继续到下一个范围之前,让我们解决一个同样不是问题的问题——至少对 MySQL 来说不是问题。如果应用程序开始输出错误,但 MySQL 没有输出,并且其他三个关键绩效指标正常,那么问题可能出现在应用程序或网络上。MySQL 有很多怪癖,但虚报不是其中之一。如果 MySQL 的关键绩效指标一切正常,那么可以相信 MySQL 正在正常工作。
Queries
与查询相关的指标显示了 MySQL 的工作速度和工作类型——在非常高的层面上。这些指标揭示了两种访问模式的特征:吞吐量和读写(参见“吞吐量”和“读/写”)。
QPS
QPS 是四个“关键绩效指标”之一。底层度量标准的名称非常恰当:
Queries
那个指标是一个计数器,但 QPS 是一个速率,因此技术上 QPS 等于两次Queries测量之间的差异除以测量之间的秒数:QPS = (T1 时的 Queries - T0 时的 Queries)/ (T1 - T0),其中 T0 是第一次测量的时间,T1 是第二次测量的时间。度量图系统(如Grafana)默认将计数器转换为速率。因此,您不需要将Queries或任何其他计数器转换为速率。只需注意,大多数 MySQL 指标都是计数器,但它们会被转换为速率并表示出来。
注意
度量图系统默认将计数器转换为速率。
QPS 受到关注是因为它表示 MySQL 的总体吞吐量——MySQL 执行查询的速度——但不要过度关注它。正如在“少量 QPS 更好”中提到的,QPS 对查询或性能总体质量并没有任何定性的影响。如果 QPS 非常高但响应时间也非常高,则 QPS 表明存在问题,而不是良好的性能。其他指标比 QPS 更多地揭示了 MySQL 性能。
当一切正常运行时,QPS 随应用使用情况波动。当出现问题时,QPS 的波动与其他指标相关。为了分析性能或诊断问题,我会看一眼 QPS,看看它在图表中的值是否异常。然后,我会将这段时间(图表 X 轴上的时间)与光谱中的其他更具体的指标相关联。作为关键绩效指标,QPS 表明存在问题,但其他指标则能够更精确定位问题。
所有 QPS 中的异常变化都值得怀疑并值得调查。大多数工程师知道 QPS 下降是不好的,但 QPS 异常增加同样糟糕甚至更糟。同样不好但更少见的是 QPS 的平线化——几乎恒定的 QPS 值——因为轻微波动是正常的。当 QPS 异常变化时,通常的第一个问题是:是什么原因?我稍后在本章中解决这个问题(参见“因果关系”)。
MySQL 还公开了另一个密切相关的度量标准:Questions。 (术语question仅用于此度量标准;在 MySQL 内部没有其他用途。) Questions仅计算客户端发送的查询,而不计算由存储程序执行的查询。例如,触发器执行的查询不会计入Questions,因为客户端没有发送它们;但它们会计入Queries。由于Questions是Queries的子集,所以二者之间的差异仅仅是信息上的区别,监控Questions是可选的。对于 QPS,请始终使用Queries。
TPS
如果应用程序依赖于显式的多语句事务,则每秒事务数(TPS)与 QPS 同样重要。对于一些应用程序,数据库事务代表应用程序中的一个工作单元,因此 TPS 比 QPS 更能反映出应用程序工作单元的速率,因为应用程序的工作单元是“全有或全无”,这就是为什么它在显式事务中执行。
注意
隐式事务是一个启用autocommit的单个 SQL 语句,这是默认设置。显式事务从BEGIN或START TRANSACTION开始,并以COMMIT或ROLLBACK结束,不论autocommit如何。
在 MySQL 中,显式事务吞吐量由三个度量标准揭示:
-
Com_begin -
Com_commit -
Com_rollback
通常情况下,Com_begin和Com_commit的速率是相同的,因为每个事务必须开始,并且成功的事务必须提交。当出现导致事务停滞的问题(“常见问题”之一)时,Com_begin的速率超过了另外两个度量标准。
使用Com_commit来衡量 TPS,因为事务吞吐量意味着成功的事务。
事务回滚通常表示错误,因为事务要么全部成功,要么全部失败,但ROLLBACK语句通常用于清理:它确保在开始下一个事务之前关闭前一个(如果有)。因此,回滚率可能不为零。与大多数指标一样,正常和稳定是目标(参见“正常和稳定:最好的数据库是无聊的数据库”)。
另一个指标是innodb.trx_active_transactions,指示当前活动事务的数量。
BEGIN开始一个事务,但事务通常在查询访问表后才会激活。例如,BEGIN; SELECT NOW();开始一个事务,但没有查询访问表,因此事务不会激活。
读/写
有九个读/写指标,按照 SQL 语句类型命名:
-
Com_select -
Com_delete -
Com_delete_multi -
Com_insert -
Com_insert_select -
Com_replace -
Com_replace_select -
Com_update -
Com_update_multi
例如,Com_select是SELECT语句数量的计数器。Com_delete_multi和Com_update_multi中的_multi后缀指的是涉及多个表的查询。多表DELETE仅增加Com_delete_multi,而单表DELETE仅更新Com_delete。对于UPDATE语句也是如此,涉及Com_update_multi和Com_update。
读/写指标显示构成Queries的查询类型和吞吐量的重要类型。这些指标并不完全涵盖Queries,它们只是在性能方面最重要的指标。
作为Queries的单独速率和百分比监视这些指标:
-
Com_select表示工作负载的读取百分比:(Com_select / Queries)× 100。
-
其他八个指标的总和表示工作负载的写入百分比。^(2)
读取和写入百分比之和不会等于 100%,因为Queries包括其他类型的 SQL 语句:SHOW、FLUSH、GRANT等等。如果剩余百分比异常高(超过 20%),可能不会影响性能,但值得调查:检查其他Com_指标以考虑其他类型的 SQL 语句。
管理
管理指标通常是仅由数据库管理员调用的命令:
-
Com_flush -
Com_kill -
Com_purge -
Com_admin_commands
前三个指标分别是FLUSH、KILL和PURGE。这些命令可能会影响性能,但应该非常罕见。如果不是,请询问您的 DBA 或云服务提供商他们在做什么。
最后一个指标 Com_admin_commands 有些奇怪。它指的是其他管理员命令,这些命令没有特定的 Com_ 状态变量。例如,MySQL 协议有一个 ping 命令,MySQL 客户端驱动程序通常用它来测试连接。适度使用是无害的,但如果使用过度可能会导致问题。不要期望 Com_admin_commands 会显示任何问题,但监控它仍然是最佳实践。
SHOW
MySQL 有超过 40 个 SHOW 命令,其中大部分对应 Com_show_ 指标。SHOW 命令不会改变 MySQL 或修改数据,因此在这个意义上它们是无害的。但它们是查询命令,这意味着它们会使用 MySQL 中的线程、时间和资源。SHOW 命令也可能会造成停顿。例如,SHOW GLOBALS STATUS 在繁忙服务器上可能需要一整秒或更多时间。因此,最佳实践是至少监控以下 10 个指标:
-
Com_show_databases -
Com_show_engine_status -
Com_show_errors -
Com_show_processlist -
Com_show_slave_status -
Com_show_status -
Com_show_table_status -
Com_show_tables -
Com_show_variables -
Com_show_warnings
注意
从 MySQL 8.0.22 开始,应监控 Com_show_replica_status 而不是 Com_show_slave_status。
不要期望 SHOW 指标会显示任何问题,但如果有问题,也不要感到意外,因为这并不是第一次。
线程和连接
Threads_running 是四个“关键性能指标”之一。它显示 MySQL 正在工作的强度,因为它直接与活动查询执行相关联(当客户端连接不执行查询时,其线程处于空闲状态),并且受 CPU 核心数量的限制。让我们在查看相关指标后再回到 Threads_running。
线程和连接是一个谱系,因为它们直接相关:MySQL 对每个客户端连接运行一个线程。线程和连接的四个最重要指标是:
-
Connections -
Max_used_connections -
Threads_connected -
Threads_running
Connections 是对 MySQL 进行的连接尝试次数,包括成功和失败的。它显示应用程序连接池到 MySQL 的稳定性。通常,应用程序与 MySQL 的连接是长期存在的,长期 至少是几秒,如果不是几分钟或几小时。长期存在的连接避免了建立连接的开销。当应用程序和 MySQL 在同一个本地网络上时,开销可以忽略不计:1 毫秒或更少。但是,应用程序和 MySQL 之间的网络延迟会随连接数的增加而迅速累积,再乘以连接速率。(Connections 是一个计数器,但表达为速率:连接数/秒。) MySQL 可以轻松处理每秒数百个连接,但如果这个指标显示连接速率异常高,需要找出并修复根本原因。
作为 var.max_connections 的百分比,Max_used_connections 显示了连接的利用率。var.max_connections 的默认值是 151,对于大多数应用程序来说可能太低了,但并不是因为应用程序需要更多连接来提高性能。应用程序只需要更多连接是因为每个应用程序实例都有自己的连接池。(我假设应用程序是扩展的。)如果连接池大小是 100,且有 3 个应用程序实例,则应用程序(所有实例)可以创建 300 个连接到 MySQL。这就是为什么 151 最大连接数不够的主要原因。
一个常见的误解是应用程序需要数千个连接到 MySQL 以提高性能或支持数千个用户。这是完全不正确的。限制因素是线程,而不是连接——稍后详细介绍 Threads_running。单个 MySQL 实例可以轻松处理数千个连接。我曾在生产环境中见过 4000 个连接,以及更多的基准测试。但对于大多数应用程序来说,总共几百个连接已经足够了。如果您的应用程序明显需要数千个连接,那么您需要分片(参见 第五章)。
监控和避免的真正问题是 100% 的连接利用率。如果 MySQL 的可用连接耗尽,应用程序将出现故障。如果连接利用率突然上升,接近 100%,原因总是外部问题,或者 bug,或者两者兼有。(MySQL 无法连接到自身,因此原因必定是外部的。)在响应外部问题,比如网络问题时,应用程序会创建比正常情况更多的连接。或者,bug 导致应用程序不关闭连接——通常被称为 连接泄漏。或者,外部问题触发应用程序中的 bug——我曾见过这种情况发生。不管怎样,根本原因总是外部的:外部的东西连接到 MySQL 并使用了所有连接。
当客户端连接和断开连接时,MySQL 会增加和减少 Threads_connected 这个度量标准。这个度量标准的名称有点误导,因为连接的是客户端,而不是线程,但它反映了 MySQL 每个客户端连接运行一个线程的事实。
Threads_running 是一个度量仪表和相对于 CPU 核心数量的隐式利用率。虽然 Threads_running 可以突然增加到数百甚至数千,但性能在较低值时会急剧下降:大约是 CPU 核心数量的两倍。原因很简单:一个 CPU 核心运行一个线程。当运行的线程数超过 CPU 核心数时,这意味着一些线程处于停滞状态,等待 CPU 时间。这类似于交通高峰期:成千上万辆汽车在高速公路上堵塞,发动机运行但几乎不动。(或者对于电动汽车来说:电池运行但几乎不动。)因此,Threads_running 通常会非常低:少于 30。使用良好的硬件和优化的工作负载,可能会出现持续时间为秒或更短的突发情况,但持续的(正常和稳定的)Threads_running 应尽可能低。就像在 “QPS 越少越好” 中一样,Threads_running 越少越好。
高吞吐量(QPS),同时线程运行非常低,这是效率高的一个强烈指标,因为只有一种方式可以同时实现两者:非常快的查询响应时间。表 6-2 列出了来自五个真实(且不同)应用程序的线程运行和 QPS。
表 6-2. 运行的线程数和 QPS
| 线程运行 | QPS |
|---|---|
| 4 | 8,000 |
| 8 | 6,000 |
| 8 | 30,000 |
| 12 | 23,000 |
| 15 | 33,000 |
第二行和第三行突出显示应用程序工作负载对性能的深远影响:在一种工作负载下,6,000 QPS 需要 8 个运行的线程;但另一种工作负载使用相同数量的线程实现了 5 倍 QPS(30,000)。对于最后一行,33,000 QPS 并不是异常高,但该数据库是分片的:跨所有分片的总 QPS 超过一百万。经验性地,即使线程数很少,高吞吐量也是可能的。
临时对象
临时对象是 MySQL 用于各种目的的临时文件和表:排序行,大连接等等。三个度量标准计算了在磁盘上的临时表数量,内存中的临时表数量以及创建的临时文件(在磁盘上):
-
Created_tmp_disk_tables -
Created_tmp_tables -
Created_tmp_files
这些指标很少为零,因为临时对象很常见,只要比率稳定,就无害。最有影响力的指标是 Created_tmp_disk_tables,它是 Created_tmp_tables 的倒数。当 MySQL 需要一个临时表来执行查询(例如 GROUP BY),它从内存中的临时表开始,并增加 Created_tmp_tables。这不应影响性能,因为它在内存中。但是,如果该临时表增长超过 var.tmp_table_size——决定内存中临时表大小的系统变量——那么 MySQL 会将临时表写入磁盘,并增加 Created_tmp_disk_tables。适度情况下,这可能不会影响性能,但肯定也不会帮助,因为存储显著比内存慢。对于 Created_tmp_files,情况也是一样:适度接受,但不利于性能。
警告
在 MySQL 8.0 中,Created_tmp_disk_tables 不 计算在磁盘上创建的临时表。这是由于内部临时表 TempTable 使用的新存储引擎:TempTable。相应的指标是 Performance Schema 内存仪表:memory/temptable/physical_disk。(一个相关的仪表是 memory/temptable/physical_ram,用于跟踪内存中临时表 TempTable 的内存分配。)如果您正在使用 MySQL 8.0,请与您的 DBA 确保此指标被正确收集和报告。
由于临时对象是查询的副作用,这些指标在一个变化与关键绩效指标(KPIs)变化相关时最具启示性。例如,Created_tmp_disk_tables 突然增加并伴随着响应时间的突然增加时,会大声呼喊“看看我!”^(3)
准备语句
准备语句是一把双刃剑:正确使用可以提高效率;但如果错误使用(或者无意中使用),会增加浪费。使用准备语句的正确和最有效的方式是准备一次,多次执行,这由两个指标统计:
-
Com_stmt_prepare -
Com_stmt_execute
Com_stmt_execute 应显著大于 Com_stmt_prepare。如果不是,则准备语句因额外的准备和关闭语句查询而增加了浪费。最糟糕的情况是这些指标一对一或接近一对一,因为单个查询会导致两次 MySQL 的多余往返:一次是准备语句,另一次是关闭语句。当 MySQL 和应用程序在同一个本地网络上时,两个额外的往返可能不明显,但它们纯粹是 QPS 倍增的浪费。例如,以 1,000 QPS 为基础,额外的 1 毫秒就是浪费的一秒——在这一秒内本可以执行另外 1,000 次查询。
除了性能影响之外,您应该监视这些预准备语句的度量,因为应用程序可能无意中使用预准备语句。例如,Go 编程语言的 MySQL 驱动程序默认使用预准备语句以防止 SQL 注入漏洞。乍一看(或多次查看),您可能不认为 Go 代码中的示例 6-4 使用了预准备语句,但实际上确实使用了。
示例 6-4. 隐藏的预准备语句
id := 75
db.QueryRow("SELECT col FROM tbl WHERE id = ?", id)
检查应用程序使用的 MySQL 驱动程序的文档。如果未明确说明是否以及何时使用预准备语句,则手动验证:在 MySQL 的开发实例(例如您的笔记本电脑)上启用通用查询日志,并编写一个测试程序,使用应用程序使用的相同方法和函数调用执行 SQL 语句。通用日志会指示何时使用预准备语句:
2022-03-01T00:06:51.164761Z 32 Prepare SELECT col FROM tbl WHERE id=?
2022-03-01T00:06:51.164870Z 32 Execute SELECT col FROM tbl WHERE id=75
2022-03-01T00:06:51.165127Z 32 Close stmt
最后,开放的预准备语句数量限制为var.max_prepared_stmt_count,默认为 16,382。(即使对于一个应用程序而言,1,000 个预准备语句也是很多,除非该应用程序是通过程序生成语句。)此计量度指标报告当前开放的预准备语句数量:
Prepared_stmt_count
不要让Prepared_stmt_count达到var.max_prepared_stmt_count,否则应用程序将停止工作。如果发生这种情况,这是由于泄漏(未关闭)预准备语句导致的应用程序错误。
不良 SELECT
四个度量指标统计通常对性能不利的SELECT语句出现的次数:^(4)
-
Select_scan -
Select_full_join -
Select_full_range_join -
Select_range_check
第一章描述了Select_scan和Select_full_join:分别为“Select scan”和“Select full join”。这两个度量的唯一区别在于这两个度量适用于全局(所有查询)。
Select_full_range_join是Select_full_join的次优选择:MySQL 使用索引而不是完整表扫描来进行范围扫描。可能范围受限且SELECT的响应时间可接受,但已经足够糟糕以至于需要它自己的度量。
Select_range_check 类似于但比 Select_full_range_join 更糟。可以通过一个简单的查询来最容易地解释:SELECT * FROM t1, t2 WHERE t1.id > t2.id。当 MySQL 连接表 t1 和 t2(按此顺序)时,会对 t2 进行范围检查:对于 t1 的每个值,MySQL 检查是否可以使用 t2 上的索引进行范围扫描或索引合并。由于查询时 MySQL 无法预先知道 t1 的值,因此需要重新检查每个 t1 的值。但是,与执行最差执行计划 Select_full_join 不同,MySQL 会继续尝试使用 t2 上的索引。在 EXPLAIN 输出中,t2 的 Extra 字段列出了“Range checked for each record”,并且对表增加了一次 Select_range_check。该指标不会在每个范围更改时增加;它只会在连接表时发出一次信号,表明进行了范围检查。
不良的 SELECT 指标应该为零或接近零(四舍五入)。Select_scan 或 Select_full_range_join 有一些是不可避免的,但另外两个——Select_full_join 和 Select_range_check——如果不是零,应立即找出并修复。
网络吞吐量
MySQL 协议非常高效,很少使用大量网络带宽。通常,是网络影响 MySQL 而不是 MySQL 影响网络。尽管如此,监控 MySQL 记录的网络吞吐量仍然是一件好事:
-
Bytes_sent -
Bytes_received
由于这些指标计算网络发送和接收的字节数,因此将值转换为网络单位:Mbps 或 Gbps,与运行 MySQL 的服务器的链路速度匹配。即使在云中,千兆位链路最为普遍。
注意
指标图形系统默认将计数器转换为速率,但您可能需要将这些指标乘以八(每字节 8 位),并将图形单位设置为位,以显示为 Mbps 或 Gbps。
我曾经见过 MySQL 仅仅一次饱和了网络。原因与通常不会成为问题的系统变量有关:var.binlog_row_image。这个系统变量与复制有关,第七章 更详细地介绍了这个问题,但简短的版本是:这个系统变量控制是否在二进制日志和复制中记录和复制 BLOB 和 TEXT 列。默认值为 full,即记录和复制 BLOB 和 TEXT 列。通常情况下,这不是问题,但某个应用程序同时具备以下所有属性时会造成严重问题:
-
使用 MySQL 作为队列
-
大
BLOB值 -
写入密集型
-
高吞吐量
这些访问模式结合在一起,复制了大量数据,导致主要的复制延迟。解决方案是将 var.binlog_row_image 更改为 noblob,停止复制不需要复制的 BLOB 值。这个真实的故事引出了下一个问题:复制。
复制
滞后是复制的祸根:源 MySQL 实例上的写入与在副本 MySQL 实例上应用该写入之间的延迟。当复制(和网络)正常工作时,复制延迟是次秒级的,仅受网络延迟限制。
注意
在 MySQL 8.0.22 之前,副本滞后度量和命令分别为Seconds_Behind_Master和SHOW SLAVE STATUS。从 MySQL 8.0.22 开始,度量和命令变为Seconds_Behind_Source和SHOW REPLICA STATUS。本书中使用当前的度量和命令。
MySQL 有一个臭名昭著的度量标准用于复制滞后:Seconds_Behind_Source。这个度量标准之所以臭名昭著,是因为它并不是错的,但也不是你期望的那样。它可以在零和一个高值之间跳跃,这既令人愉悦又令人困惑。因此,最佳实践是忽略此度量标准,而是使用像pt-heartbeat这样的工具来测量真正的复制延迟。然后,您必须配置您的 MySQL 监控软件(或服务)以从pt-heartbeat测量和报告复制滞后。由于pt-heartbeat已经存在很长时间,一些 MySQL 监视器本地支持它;而且管理您的 MySQL 实例的工程师很有可能已经在使用它。
MySQL 公开了一个与复制相关的度量标准,这并非臭名昭著:Binlog_cache_disk_use。第七章详细说明了以下细节;目前,高层次的解释已经足够。对于每个客户端连接,内存中的二进制日志缓存在将写入写入二进制日志文件之前缓冲写操作——从中写操作复制到副本。如果二进制日志缓存过小而无法容纳事务的所有写入,则将更改写入磁盘并增加Binlog_cache_disk_use。适度情况下,这是可以接受的,但不应该频繁发生。如果频繁发生,可以通过增加二进制日志缓存大小var.binlog_cache_size来缓解。
从前一节的例子中,我们知道var.binlog_row_image也会影响二进制日志缓存:如果表中有BLOB或TEXT列,完整行图像可能需要大量空间。
数据大小
第三章解释了为什么数据越少性能越好。监控数据大小很重要,因为数据库通常会比预期的更大。如果数据增长是由应用程序增长导致的——应用程序变得越来越受欢迎——那么这是一个好问题,但仍然是一个问题。
它也很容易被忽视,因为 MySQL 的性能随着数据增长而轻松扩展,但并非无限。如果查询和访问模式优化良好,数据库可以从 10 GB 增长到 300 GB,即使不会遇到性能问题。但再增加 30 倍到 9 TB?不可能。即使是增加 3 倍到 900 GB 也是要求过高的——如果访问模式非常有利,这种情况可能会发生,但不要赌这种可能性。
MySQL 在一个信息模式表information_schema.tables中公开了表的大小(和其他表的元数据):information_schema.tables。在示例 6-5 中的查询返回每个数据库的大小(以 GB 为单位)。
示例 6-5. 数据库大小(GB)
SELECT
table_schema AS db,
ROUND(SUM(data_length + index_length) / 1073741824 , 2) AS 'size_GB'
FROM
information_schema.tables
GROUP BY table_schema;
在示例 6-6 中的查询返回每个表的大小(以 GB 为单位)。
示例 6-6. 表大小(GB)
SELECT
table_schema AS db,
table_name as tbl,
ROUND((data_length + index_length) / 1073741824 , 2) AS 'size_GB'
FROM
information_schema.tables
WHERE
table_type = 'BASE TABLE'
AND table_schema != 'performance_schema';
没有关于数据库和表大小度量的标准。根据您的需求查询和聚合information_schema.tables中的值。至少每小时收集数据库大小(示例 6-5)。更精确地收集表大小,最好每 15 分钟进行一次。
确保无论您在何处存储或发送 MySQL 度量数据,您都能至少保留一年的数据大小度量。短期数据增长趋势用于估计磁盘何时会耗尽空间;或者在云中,用于估计何时需要更多的存储空间。长期数据增长趋势用于估计何时需要分片(第五章),如“实践:四年合适”中所述。
InnoDB
InnoDB是复杂的。
然而,由于它是默认的 MySQL 存储引擎,我们必须做好接受它的准备。深入挖掘并不是必要的——甚至在这本书的限制内也不可能。尽管本节内容很长,但几乎没有深入到 InnoDB 内部的表面。然而,以下 InnoDB 度量指标揭示了负责读写数据的存储引擎的一些内部工作原理。
历史列表长度(度量)
历史列表长度(HLL)是一个奇怪的度量,因为每个使用 MySQL 的工程师都知道它意味着什么,但很少有人知道它是什么。当 HLL 在几分钟或几小时内显著增加时,意味着 InnoDB 保存了大量旧行版本而未清除,因为一个或多个长时间运行的事务未提交或由于未检测到的客户端连接丢失而被放弃。我在“历史列表长度”后面解释的所有这些内容都由一个计量指标揭示:
innodb.trx_rseg_history_len
innodb.trx_rseg_history_len的正常值应小于 1,000。如果 HLL 大于 100,000,则应监视并发出警报。与“野鸡追”(阈值)和“用户体验和客观限制的警报”相反,这是一个可靠的阈值和可操作的警报。措施是:查找并终止长时间运行或被丢弃的事务。
历史列表长度并不直接影响性能,但它是麻烦的前兆——不要忽视它。问题与事实相关,因为 InnoDB 是一个事务性存储引擎,每个对 InnoDB 表的查询都是一个事务。事务会产生开销,而 HLL 指标显示当一个长时间运行或被放弃的事务导致 InnoDB 处理过多开销时。一些开销是必要的——甚至是有益的——但过多会导致浪费,而浪费是与性能对立的。
有关事务和 HLL 的内容,这本书还有一个单独的章节:第八章。现在,让我们把焦点放在指标上,因为我们刚刚开始接触 InnoDB。
死锁
死锁发生在两个(或更多)事务持有另一个事务所需的行锁时。例如,事务 A 持有行 1 的锁并需要行 2 的锁,而事务 B 持有行 2 的锁并需要行 1 的锁。MySQL 自动检测并回滚一个事务以解决死锁,并增加一个指标:
innodb.lock_deadlocks
死锁不应该发生。死锁的普遍性与并发访问模式特征相关(参见“并发性”)。高并发数据访问必须经过设计(在应用程序中),以避免由于不同事务访问相同行(或附近行)而导致死锁,确保这些事务大致以相同的顺序检查行。在前面关于事务 A 和事务 B 的示例中,它们以相反的顺序访问相同的两行,当事务同时执行时可能导致死锁。要了解更多关于死锁的信息,请阅读 MySQL 手册中的“InnoDB 中的死锁”。
行锁
行锁指标显示锁争用:查询获取行锁以写入数据的速度(或不速度)。最基本的行锁指标包括:
-
innodb.lock_row_lock_time -
innodb.lock_row_lock_current_waits -
innodb.lock_row_lock_waits -
innodb.lock_timeouts
第一个指标 innodb.lock_row_lock_time 是一种罕见类型:用于获取行锁的总毫秒数。它属于响应时间指标类别(参见“响应时间”),但不像“查询响应时间”,它是作为累积总和而不是直方图进行收集的。因此,无法将 innodb.lock_row_lock_time 报告为百分位数,这本应是理想的。将其作为速率报告(参见“速率”)也是荒谬的:每秒毫秒。相反,必须将此指标报告为差异:如果 T1 时为 500 毫秒,T2 时为 700 毫秒,则报告 T2 值 - T1 值 = 200 毫秒。(在图表聚合函数中使用最大值。不要对数据点进行平均处理,因为最好看到最坏的情况。)作为响应时间指标,数值越低越好。innodb.lock_row_lock_time 的值不能为零(除非工作负载是只读且从不需要获取单个行锁),因为获取锁需要一定的时间。因此,目标始终是指标正常和稳定。当它不正常时,其他行锁指标也不会正常。
innodb.lock_row_lock_current_waits 是当前等待获取行锁的查询数量的计量指标。innodb.lock_row_lock_waits 是等待获取行锁的查询数量的计数器和速率。这两个变量本质上是相同的:前者是当前的计量,后者是历史计数器和速率。当行锁等待率增加时,这是一个明确的问题信号,因为 MySQL 不会无缘无故等待:一定是有什么原因导致了等待。在这种情况下,原因将是并发查询访问相同(或附近)的行。
innodb.lock_timeouts 在行锁等待超时时会增加。默认的行锁等待超时是 50 秒,由 var.innodb_lock_wait_timeout 配置,并且适用于每个行锁。对于任何正常应用程序来说,等待时间太长了;我建议设置更低的值:10 秒或更短。
InnoDB 的锁定机制非常复杂和微妙。因此,除非工作负载展示了三种特定的访问模式,否则锁竞争并不是一个常见问题:
-
写入密集型(“读写”)
-
高吞吐量(“吞吐量”)
-
高并发性(“并发性”)
这将是一个非常特定的应用程序和工作负载。但是锁竞争对于任何应用程序和工作负载都可能成为问题(即使吞吐量和并发性较低),因此始终监视行锁指标。
数据吞吐量
数据吞吐量以每秒字节数来衡量,有两个指标:
-
Innodb_data_read -
Innodb_data_written
数据吞吐量很少成为问题:SSD 速度快;PCIe 和 NVMe 使其更快。无论如何,监控数据吞吐量是一种最佳实践,因为存储吞吐量受限,特别是在云中。不要期望实现发布的存储吞吐量速率,因为这些速率是在理想条件下测量的:直接数据到(或从)磁盘。InnoDB 非常快速和高效,但它仍然是数据和磁盘之间的复杂软件层,从根本上阻止了实现发布的存储吞吐量速率。
警告
要注意云端的吞吐量:存储通常不会是本地连接的,这会限制吞吐量为网络速度。1 Gbps 相当于 125 MB/s,这是类似于旋转硬盘的吞吐量。
IOPS
InnoDB 与存储 I/O 容量有着深入且有时复杂的关系,以 IOPS 衡量。但首先,让我们先来看简单的部分:InnoDB 的读写 IOPS 分别由两个度量标准来计算:
-
innodb.os_data_reads -
innodb.os_data_writes
这些度量标准是计数器,因此像其他计数器一样,它们会被度量图系统转换并表达为速率。确保为每个度量标准图设置单位为 IOPS。
InnoDB 的性能核心在于优化和减少存储 I/O。尽管从工程角度来看高 IOPS 令人印象深刻,但对性能来说却是一大难题,因为存储速度较慢。然而,存储是必须的,用于数据持久性,即将数据更改持久化到磁盘上,因此 InnoDB 努力实现快速和持久性。因此,就像在 “少量 QPS 更好” 中提到的,更少的 IOPS 更好。
但也不要低估 IOPS 的使用。如果您的公司使用自己的硬件,存储 IOPS 的最大数量由存储设备确定——请查看设备规格或询问管理硬件的工程师。在云端,存储 IOPS 是分配或预留的,因此通常更容易知道最大值,因为您购买了这些 IOPS——请检查存储设置或询问云服务提供商。例如,如果 InnoDB 从未使用超过 2,000 IOPS,那么不要购买(或预留)40,000 IOPS:InnoDB 简单地不会使用多余的 IOPS。相反,如果 InnoDB 不断使用最大数量的存储 IOPS,那么要么需要优化应用工作负载以减少存储 I/O(参见第 1–5 章),要么 InnoDB 确实需要更多的 IOPS。
InnoDB 后台任务 的 I/O 容量主要由var.innodb_io_capacity和var.innodb_io_capacity_max两个系统变量配置,默认值分别为 200 和 2,000 IOPS。(还有其他变量,但我必须略过它们以便专注于度量。要了解更多,请阅读 MySQL 手册中的“配置 InnoDB I/O 容量”。)后台任务包括页面刷新、变更缓冲区合并等。在本书中,我仅涵盖页面刷新,这可谓是最重要的后台任务之一。限制后台任务的存储 I/O 确保 InnoDB 不会过载服务器。它还允许 InnoDB 优化和稳定存储 I/O,而不是向存储设备发送不规律的访问。相比之下,前台任务没有任何可配置的 I/O 容量或限制:它们使用必要和可用的 IOPS。主要的前台任务是执行查询,但这并不意味着查询使用高或过多的 IOPS,因为请记住,InnoDB 的性能目的是优化和减少存储 I/O。对于读取操作,缓冲池有意优化和减少 IOPS。对于写入操作,页面刷新算法和事务日志有意优化和减少存储 I/O。接下来的几节将揭示其详细内容。
InnoDB 可以实现高 IOPS,但应用程序呢?可能不行,因为应用程序与 IOPS 之间有许多层,这些层阻碍了前者实现较高的后者数量。根据我的经验,应用程序使用数百至数千个 IOPS,而“热门”优化的应用程序在单个 MySQL 实例上推动大约 10,000 个 IOPS。最近,我在云中进行了 MySQL 的基准测试,并在 40,000 个 IOPS 处达到了一个上限。云服务提供商发布的最大值为 80,000 个 IOPS,并允许我进行配置,但他们的存储系统限制在 40,000 个 IOPS。重点是:InnoDB 可以实现高 IOPS,但其周围的一切又是另一回事。
本节仅是 InnoDB I/O 的入门,因为它构成了消耗 IOPS 的最后三个 InnoDB 光谱的基础:“缓冲池效率”,“页面刷新”和“事务日志”。
要了解更多关于 InnoDB I/O 的信息,请先阅读 MySQL 手册中的“配置 InnoDB I/O 容量”。要真正深入了解 InnoDB I/O 的细枝末节,请阅读著名的 MySQL 专家 Yves Trudeau 和 Francisco Bordenave 撰写的启发性三部曲博客文章:“给你的 SSD 多点爱:减少 innodb_io_capacity_max!”,“Percona Server for MySQL 中的 InnoDB 刷新实战”,以及“为写入密集型工作负载调整 MySQL/InnoDB 刷新”。但请先完成本章内容,因为它为这些博客文章奠定了良好的基础。
InnoDB 在内存中处理数据,而不是在磁盘上。必要时从磁盘读取数据,并将数据写入磁盘以使更改持久化,但这些是较低级别的操作,将在接下来的三个部分中详细探讨。在更高的层次上,InnoDB 在内存中处理数据,因为存储速度太慢——即使有百万 IOPS。因此,查询、行和 IOPS 之间没有直接的对应关系。写入始终消耗 IOPS(用于持久性)。读取可以在不消耗任何 IOPS 的情况下执行,但这取决于缓冲池效率。
缓冲池效率
InnoDB 缓冲池是表数据和其他内部数据结构的内存缓存。来自“InnoDB 表即索引”的信息表明,缓冲池包含索引页——更多关于页的内容将在下一节讨论。InnoDB 确实理解行,但内部更关注页。在 MySQL 性能的这个深度上,焦点从行转向页。
在高层次上,InnoDB 通过缓冲池中的页访问(读取和写入)所有数据。(低级别写入更复杂,在最后一个 InnoDB 部分中详细介绍:“事务日志”。)如果访问时数据不在缓冲池中,InnoDB 将从存储器中读取并保存到缓冲池中。
缓冲池效率是从内存访问的数据百分比,由两个指标计算:
-
Innodb_buffer_pool_read_request -
Innodb_buffer_pool_reads
Innodb_buffer_pool_read_request 计算所有访问缓冲池中数据的请求。如果请求的数据不在内存中,InnoDB 将增加 Innodb_buffer_pool_reads 并从磁盘加载数据。缓冲池效率等于 (Innodb_buffer_pool_read_request / Innodb_buffer_pool_reads) × 100。
注意
这些指标中的 read 不意味着 SELECT。InnoDB 为所有查询(INSERT、UPDATE、DELETE 和 SELECT)从缓冲池中读取数据。例如,在 UPDATE 时,InnoDB 从缓冲池中读取行。如果不在缓冲池中,则从磁盘加载行到缓冲池中。
当 MySQL 启动时,缓冲池效率会非常低。这是正常现象;称为冷缓冲池。加载数据会使缓冲池变暖——就像往火上扔木头一样。通常需要几分钟才能完全使缓冲池变暖,这在缓冲池效率达到正常稳定值时得到体现。
缓冲池效率应该非常接近于 100% —— 理想情况下是 99.0%或更高,但不要固守于数值。从技术上讲,这个指标是一个缓存命中率,但并不是它的使用方式。缓冲池效率揭示了 InnoDB 在平衡速度和耐久性时,能够将频繁访问的数据——工作集——保持在内存中的能力。形象地说,缓冲池效率是 InnoDB 在飓风中保持火柴点燃的能力。工作集是火焰;耐久性是雨水(它减少了吞吐量);^(5) 应用程序是风。
注意
在过去,性能等同于缓存命中率。如今,情况已非如此:性能是查询响应时间。如果缓冲池效率非常低但响应时间很好,那么性能很好。这种情况可能不会发生,但重点不是失去焦点——回想“北极星”。
如果总数据大小小于可用内存,则所有数据可以一次性适应缓冲池。(缓冲池大小由var.innodb_buffer_pool_size配置。或者,从 MySQL 8.0.3 开始,启用var.innodb_dedicated_server会自动配置缓冲池大小和其他相关系统变量。)在这种情况下,缓冲池效率不是问题,性能瓶颈(如果有的话)将出现在 CPU 或存储器中(因为所有数据都在内存中)。但这种情况是例外,不是常态。常态是总数据大小远远大于可用内存。在这种(正常)情况下,缓冲池效率有三个主要影响因素:
数据访问
数据访问将数据带入缓冲池。数据年龄访问模式特性(参见“数据年龄”)是主要影响因素,因为只有新数据需要加载到缓冲池中。
页面刷新
页面刷新允许从缓冲池中驱逐数据。页面刷新对于将新数据加载到缓冲池中是必要的。下一节将详细介绍。
可用内存
InnoDB 在内存中保留的数据越多,就越不需要加载或刷新数据。在前面提到的例外情况中,当所有数据都适应内存时,缓冲池效率不是问题。
缓冲池效率显示了这三种影响的综合效果。作为综合效果,它不能准确指出一个原因。如果其值低于正常水平,原因可能是其中一个、两个或所有三个影响因素。您必须分析所有三个因素,以确定哪一个是最主要的或最可改变的。例如,如第四章所述,更改访问模式是改善性能的最佳实践,但如果您深入 MySQL 性能,可能已经做过了。在这种情况下,增加内存或更快的存储(更多的 IOPS)可能更可行,并且更有理由,因为您已经优化了工作负载。尽管缓冲池效率不能给出答案,但它告诉您应该从哪里着手:访问模式(特别是“数据年龄”)、页面刷新和内存大小。
InnoDB 缓冲池效率只是冰山一角。在其下面,页面刷新是保持其运行的内部机制。
页面刷新
这个光谱非常广泛和复杂,因此进一步细分为页面和刷新,二者密不可分。
页面
如前一节所述,缓冲池包含索引页面。有四种类型的页面:
空闲页面
这些不包含数据;InnoDB 可以将新数据加载到其中。
数据页
这些包含尚未修改的数据;也称为干净页。
脏页
这些包含尚未刷新到磁盘的修改数据。
杂项页面
这些包含本书未涵盖的各种内部数据。
由于 InnoDB 保持缓冲池充满数据,监控数据页的数量并不必要。空闲和脏页在性能方面尤为重要,特别是在下一节中查看刷新指标时。三个仪表和一个计数器(最后一个指标)显示缓冲池中流动的空闲和脏页的数量:
-
innodb.buffer_pool_pages_total-
innodb.buffer_pool_pages_dirty -
innodb.buffer_pool_pages_freeinnodb.buffer_pool_wait_free
-
innodb.buffer_pool_pages_total是缓冲池中页面的总数(总页面数),这取决于缓冲池的大小(var.innodb_buffer_pool_size)。 (从技术上讲,这是一个仪表指标,因为从 MySQL 5.7.5 开始,InnoDB 缓冲池大小是动态的。但经常更改缓冲池大小并不常见,因为它根据系统内存大小调整,这种调整不能很快进行——即使云实例也需要几分钟来调整大小。)总页面数计算空闲和脏页的百分比:innodb.buffer_pool_pages_free和innodb.buffer_pool_pages_dirty分别除以总页面数。这两个百分比是仪表指标,并且由于页面刷新而频繁变化。
为确保在需要时有空闲页面可用,InnoDB 维护了一个非零的自由页面余额,我称之为自由页面目标。自由页面目标等于两个系统变量的乘积:系统变量var.innodb_lru_scan_depth乘以var.innodb_buffer_pool_instances。前一个系统变量的名称有些误导,但它配置了 InnoDB 在每个缓冲池实例中维护的自由页面数量;默认为 1024 个自由页面。到目前为止,我已经写了关于缓冲池作为 InnoDB 的一个逻辑部分。在内部,缓冲池被分成多个缓冲池实例,每个实例都有自己的内部数据结构,在重负载下减少竞争。var.innodb_buffer_pool_instances的默认值为 8(如果缓冲池大小小于 1 GB,则为 1)。因此,使用两个系统变量的默认值,InnoDB 维护了 1024 × 8 = 8192 个自由页面。自由页面应该保持在自由页面目标周围。
提示
减少var.innodb_lru_scan_depth是最佳实践,因为默认值下,它会产生 134 MB 的自由页面大小:8192 个自由页面 × 16 KB/页面 = 134 MB。鉴于行通常是数百字节,这显然过于高效。最好尽可能降低自由页面,避免达到零并遭受自由页面等待(在下一段解释)。了解这一点很好,但这是 MySQL 调整,超出本书的范围。默认设置不会影响性能;MySQL 专家只是讨厌低效率。
如果自由页面持续接近零(低于自由页面目标),只要innodb.buffer_pool_wait_free保持为零,这没问题。当 InnoDB 需要一个空闲页面但没有可用时,它会增加innodb.buffer_pool_wait_free并等待。这称为自由页面等待,应该极其罕见——即使缓冲池充满数据——因为 InnoDB 积极维护自由页面目标。但在非常重载的情况下,它可能无法快速刷新和释放页面。简而言之:InnoDB 读取新数据的速度比刷新旧数据的速度快。假设工作负载已经优化,解决自由页面等待有三种方法:
增加自由页面目标
如果您的存储可以提供更多 IOPS(或者您可以在云中配置更多 IOPS),则增加var.innodb_lru_scan_depth会导致 InnoDB 刷新和释放更多页面,这需要更多的 IOPS(参见“IOPS”)。
更好的存储系统
如果您的存储无法提供更多 IOPS,请升级到更好的存储,然后增加自由页面目标。
更多内存
内存越多,缓冲池越大,内存中可以容纳更多页面,而无需刷新和驱逐旧页面以加载新页面。关于自由页面等待还有一个细节,在解释 LRU 刷新时我稍后会澄清。
注意
请记住“缓冲池效率”中所述:读并不意味着SELECT。InnoDB 为所有查询(INSERT、UPDATE、DELETE和SELECT)从缓冲池中读取新数据。当数据访问但不在缓冲池中(在内存中),InnoDB 从磁盘中读取它。
如果空闲页持续远高于空闲页目标,或者从未降至目标值,则缓冲池过大。例如,50 GB 的数据仅填充了 128 GB RAM 的 39%。MySQL 优化为仅使用其需要的内存,因此提供过多的内存不会增加性能——MySQL 简单地不会使用多余的内存。不要浪费内存。
默认情况下,脏页作为总页数的百分比在 10%到 90%之间变化。尽管脏页包含未刷新到磁盘的修改数据,但数据变更已在事务日志中刷新到磁盘——接下来的两个部分将进一步详细说明。即使有 90%的脏页,所有数据变更也保证持久化到磁盘。高比例脏页是完全正常的。实际上,除非工作负载特别读重(回顾访问模式特性“读/写”),且几乎不经常修改数据,否则是预期的(在这种情况下,我会考虑是否有其他数据存储更适合该工作负载)。
由于预期有高比例的脏页,此指标用于验证与页面刷新(下一节)、事务日志(“事务日志”)和磁盘 I/O(“IOPS”)相关的其他指标。例如,写入数据会导致脏页增加,因此脏页的激增与 IOPS 和事务日志指标的激增相一致。但是,IOPS 的激增没有相应的脏页激增,则不能由写入引起;这必须是另一个问题——可能是工程师手动执行了一个检索大量旧数据的临时查询,现在 InnoDB 在 IOPS 的旋涡中从磁盘中读取它。最终,脏页随页面刷新的温和潮汐而起伏。
页面刷新
页面刷新通过将数据修改写入磁盘来清除脏页面。页面刷新有三个紧密相关的目的:持久性、检查点和页面驱逐。为简单起见,本节重点讨论与页面驱逐相关的页面刷新。“事务日志”阐明了页面刷新如何提供持久性和检查点功能。
从“缓冲池效率”可知,页面刷新为新数据在缓冲池中加载空间。更具体地说,页面刷新将脏页变为干净页,而干净页可以从缓冲池中驱逐出去。因此,页面的生命周期循环完成:
-
当数据加载时,空闲页变为干净(数据)页
-
当数据修改时,干净页变为脏页
-
当数据修改被刷新时,一个脏页面重新变成了一个干净的页面。
-
当页面从缓冲池中被驱逐时,一个干净的页面重新变成了一个空闲的页面。
页面刷新的实现是复杂的,并且在不同的发行版中有所不同(Oracle MySQL、Percona Server 和 MariaDB Server),因此您可能希望重新阅读以下信息,以充分吸收其中的许多复杂细节。图 6-6 描述了 InnoDB 页面刷新从事务日志中提交事务(顶部)到从缓冲池刷新和驱逐页面(底部)的高级组件和流程。
比喻地说,InnoDB 页面刷新在 图 6-6 中自顶向下工作,但我将从底向上解释。在缓冲池中,脏页面是深色的,干净(数据)页面是白色的,而空闲页面则有虚线边框。

图 6-6. InnoDB 页面刷新
脏页面在两个内部列表中记录(每个缓冲池实例):
刷新列表
来自事务日志中已提交写入的脏页面。
LRU 列表
缓冲池中根据数据年龄排序的干净和脏页面。
严格来说,LRU 列表跟踪所有具有数据的页面,这些页面包括脏页面;而刷新列表显式地只跟踪脏页面。无论如何,MySQL 使用这两个列表来查找要刷新的脏页面。(在 图 6-6 中,LRU 列表与 [追踪] 只连接了一个脏页面,但这只是为了避免线条混乱的简化描述。)
每秒钟一次,后台线程从两个列表中刷新脏页面,这些线程被称为 页面清理器线程。默认情况下,InnoDB 使用四个页面清理器线程,由 var.innodb_page_cleaners 配置。每个页面清理器刷新两个列表;但为了简单起见,图 6-6 显示一个页面清理器刷新一个列表。
两种刷新算法主要负责刷新列表和 LRU 列表的刷新:
自适应刷新
自适应刷新确定页面清理器从刷新列表中刷新脏页面的速率。[⁶] 该算法是 自适应 的,因为它根据事务日志写入的速率变化调整页面刷新率。写入速度越快,刷新页面也越快。该算法响应写入负载,但也经过精心调整,以在不同的写入负载下产生稳定的页面刷新速率。
页面清理由页面清理器执行的后台任务,因此页面刷新速率受到配置的 InnoDB I/O 容量限制的限制,具体解释请参见 “IOPS”,特别是:var.innodb_io_capacity 和 var.innodb_io_capacity_max。自适应刷新出色地保持了刷新率(按 IOPS 计算)在这两个值之间。
自适应刷新的目的是允许检查点在事务日志中回收空间。(实际上,这是刷新列表刷新的一般目的;算法只是实现它的不同方法。)我在“事务日志”中解释了检查点,但我在这里提到它是为了澄清,尽管刷新使页面变得干净并成为驱逐的候选对象,但这不是自适应刷新的目的。
自适应刷新算法的复杂细节超出了本书的范围。重要的是:自适应刷新响应事务日志写入,从刷新列表中刷新脏页。
LRU 刷新
LRU 刷新从 LRU 列表的尾部刷新脏页,其中包含最老的页面。简而言之:LRU 刷新从缓冲池中刷新和驱逐旧页面。
LRU 刷新发生在后台和前台。当用户线程(执行查询的线程)需要空闲页,但没有空闲页时,就会发生前台 LRU 刷新。这对性能不利,因为它会增加查询响应时间。当它发生时,MySQL 会增加innodb.buffer_pool_wait_free,这是前面提到的关于空闲页等待的一个更多细节。
页面清理器处理后台 LRU 刷新(因为页面清理器是后台线程)。当页面清理器从 LRU 列表中刷新一个脏页时,它也通过将其添加到空闲列表来释放页面。这主要是 InnoDB 维护空闲页目标(参见“页面”)并避免空闲页等待的方法。
虽然后台 LRU 刷新是一个后台任务,但并未受到配置的 InnoDB I/O 容量(var.innodb_io_capacity和var.innodb_io_capacity_max)的限制。^(7) 它的限制效果(每个缓冲池实例)由var.innodb_lru_scan_depth确定。出于本书范围之外的各种原因,这在后台存储 I/O 方面并非问题。
LRU 刷新的目的是刷新并释放(驱逐)最老的页面。老,如“数据年龄”中详细说明的那样,意味着最近最少使用的页面,因此是LRU。关于 LRU 刷新、LRU 列表以及它与缓冲池的关系的复杂细节超出了本书的范围;但如果你感兴趣,可以从 MySQL 手册中的“缓冲池”开始阅读。重要的是,LRU 刷新释放页面,其最大速率是空闲页目标(每秒),而不是配置的 InnoDB I/O 容量。
经过对 InnoDB 刷新的速成课程,以下四个指标现在可以说清楚了:
-
innodb.buffer_flush_batch_total_pages -
innodb.buffer_flush_adaptive_total_pages -
innodb.buffer_LRU_batch_flush_total_pages -
innodb.buffer_flush_background_total_pages
所有四个指标都是计数器,当转换为速率时,会显示每个算法的页面刷新速率。innodb.buffer_flush_batch_total_pages 是所有算法的总页面刷新速率。作为 InnoDB 的关键绩效指标,这是一个高级别速率:总页面刷新速率应该是正常和稳定的。如果不是这样,其中一个指标会指出 InnoDB 的哪个部分没有正常刷新。
innodb.buffer_flush_adaptive_total_pages 是自适应刷新刷新的页数。innodb.buffer_LRU_batch_flush_total_pages 是后台 LRU 刷新的页数。根据前面对这些刷新算法的解释,您知道它们反映了 InnoDB 的哪些部分:分别是事务日志和空闲页。
innodb.buffer_flush_background_total_pages 被包括是为了完整性:它是其他算法刷新的页面数,描述在“空闲刷新和传统刷新”中。如果后台页面刷新速率有问题,您需要咨询 MySQL 专家,因为这不应该发生。
尽管不同的刷新算法有不同的速率,但存储系统支持所有这些,因为刷新需要 IOPS。例如,如果您在旋转硬盘上运行 MySQL,存储系统(包括存储总线和存储设备)根本提供不了多少 IOPS。如果您在高端存储上运行 MySQL,那么 IOPS 可能永远不是一个潜在问题。如果您在云中运行 MySQL,您可以按需提供尽可能多的 IOPS,但云使用网络附加存储,速度较慢。还要记住,IOPS 具有延迟,特别是在云中,延迟范围从微秒到毫秒。这是接近专家级内部的深层知识,但让我们继续进行学习,因为这是值得学习的强大知识。
事务日志
最后,也许是最重要的光谱:事务日志,也被称为重做日志。简称日志,在这里上下文清晰且无歧义。
事务日志保证耐久性。当事务提交时,所有数据更改都记录在事务日志中并刷新到磁盘上,这使数据更改具有耐久性,并且相应的脏页保留在内存中。(如果 MySQL 崩溃并带有脏页,则数据更改不会丢失,因为它们已经在事务日志中刷新到磁盘。)事务日志刷新不是页面刷新。这两个过程是分开的但又不可分割的。
InnoDB 事务日志是磁盘上的固定大小环形缓冲区,如图 6-7 所示。默认情况下,它由两个物理日志文件组成。每个文件的大小由var.innodb_log_file_size配置。或者,从 MySQL 8.0.3 开始,启用var.innodb_dedicated_server会自动配置日志文件大小和其他相关系统变量。
事务日志包含数据更改(技术上是重做日志),而不是页面;但数据更改与缓冲池中的脏页面相连。当事务提交时,其数据更改被写入事务日志的头部并被刷新(同步)到磁盘,这将使头部顺时针前进,并将相应的脏页面添加到之前在图 6-6 中显示的刷新列表中。在图 6-7 中,头部和尾部顺时针移动,但这仅仅是一种插图。除非您使用旋转硬盘,否则事务日志不会实际移动。新写入的数据更改会覆盖已刷新相应页面的旧数据更改。

图 6-7. InnoDB 事务日志
注意
简化的 InnoDB 事务日志插图和解释使其看起来像是串行的。但这只是简化复杂过程的一个结果。实际的低级实现是高度并发的:许多用户线程并行提交更改到事务日志中。
检查点年龄是头部和尾部之间事务日志的长度(以字节计)。检查点通过从缓冲池刷新脏页面来回收事务日志中的空间,从而使尾部可以前进。一旦脏页面被刷新,事务日志中的相应数据更改就可以被新的数据更改覆盖。适应性刷新在 InnoDB 中实现了检查点,这就是为什么检查点年龄是显示在图 6-6 中自适应刷新算法的输入。
注意
默认情况下,事务日志中的所有数据更改(重做日志)是持久的(刷新到磁盘),但缓冲池中相应的脏页面直到被检查点刷新才是持久的。
检查点通过将尾部推进来确保检查点年龄不会变得太老(这实际上意味着太大,因为它是以字节计量的,但太老是更常见的说法)。但如果检查点年龄变得太老以至于头部遇到尾部会发生什么?由于事务日志是一个固定大小的环形缓冲区,如果写入速率持续超过刷新速率,头部会绕回并遇到尾部。InnoDB 不会让这种情况发生。在图 6-7 中显示了两个保障点称为async和sync。Async是 InnoDB 开始异步刷新的点:允许写入,但页面刷新速率增加到接近最大。虽然允许写入,但刷新将使用大量 InnoDB I/O 容量,您可以(而且应该)预期整体服务器性能显著下降。Sync是 InnoDB 开始同步刷新的点:所有写入停止,页面刷新接管。不用说,这对性能非常不利。
InnoDB 公开了检查点年龄和异步刷新点的指标:
-
innodb.log_lsn_checkpoint_age -
innodb.log_max_modified_age_async
innodb.log_lsn_checkpoint_age 是以字节为单位的一个度量指标,但其原始值对人类来说毫无意义(范围从零到日志文件大小)。对人类有意义并且是监控的关键是检查点年龄接近异步刷新点的情况,我称之为事务日志利用率:
(innodb.log_lsn_checkpoint_age / innodb.log_max_modified_age_async)× 100
由于异步刷新点在日志文件大小的 6/8(75%)处,事务日志利用率是保守的。因此,在 100%事务日志利用率时,有 25%的日志空闲以记录新的写入数据,但请记住:在异步刷新点时,服务器性能显著下降。监控并了解何时达到此点非常重要。如果你想冒险,InnoDB 提供了同步刷新点的度量指标(在日志文件大小的 7/8 [87.5%] 处),你可以替换异步刷新点的度量指标(或同时监控两者):innodb.log_max_modified_age_sync。
关于查询如何记录数据变更到事务日志,有一个小但重要的细节:数据变更首先被写入到内存中的日志缓冲区(不要与指代实际磁盘上的事务日志的日志文件混淆),然后日志缓冲区被写入到日志文件,并进行同步。我略过了无数细节,但重点是:有一个内存中的日志缓冲区。如果日志缓冲区太小而查询必须等待空闲空间,InnoDB 就会递增:
innodb.log_waits
innodb.log_waits 应该为零。如果不是,则表示日志缓冲区大小由 var.innodb_log_buffer_size 配置。通常默认的 16 MB 足以满足需求。
由于事务日志在磁盘上由两个物理文件组成(两个文件,但一个逻辑日志),将数据变更写入磁盘并将其同步是最基本的任务。两个度量指标报告了有多少这些任务是待处理的,即等待完成:
-
innodb.os_log_pending_writes -
innodb.os_log_pending_fsyncs
由于写入和同步应该极快地发生——几乎所有写入性能都依赖于此——这些度量指标应始终为零。如果不是,则表明 InnoDB 或更可能是存储系统存在低级问题——假设其他度量指标正常或在待处理写入和同步之前正常。不要期望在这个深度出现问题,但要监控它。
最后但同样重要的是,一个简单但重要的度量指标,记录写入事务日志的字节数:
innodb.os_log_bytes_written
监控每小时写入的总日志字节数作为确定日志文件大小的基础是最佳实践。日志文件大小是系统变量var.innodb_log_file_size和var.innodb_log_files_in_group的乘积。或者,从 MySQL 8.0.14 开始,启用var.innodb_dedicated_server会自动配置这两个系统变量。默认的日志文件大小只有 96 MB(两个日志文件每个 48 MB)。作为使用 MySQL 的工程师,而不是 DBA,我假设管理你的 MySQL 的人已经正确配置了这些系统变量,但最好还是进行验证。
我们做到了:InnoDB 指标的结束。InnoDB 指标的范围比这里呈现的要广泛和深入得多;这些只是分析 MySQL 性能最关键的 InnoDB 指标。此外,从 MySQL 5.7 到 8.0,InnoDB 进行了重大改动。例如,从 MySQL 8.0.11 开始,事务日志的内部实现进行了重写和改进。这里没有涵盖的 InnoDB 的其他部分包括:双写缓冲区、更改缓冲区、自适应哈希索引等等。我鼓励你深入了解 InnoDB,因为它是一个引人入胜的存储引擎。你可以从 MySQL 手册的“InnoDB 存储引擎”开始这段旅程。
监控和警报
MySQL 指标展示了 MySQL 性能的广谱,它们也非常适合在半夜唤醒工程师,即监控和警报。
监控和警报是外部的 MySQL 功能,因此它们无法影响其性能,但我必须讨论以下四个主题,因为它们与指标相关,并且对 MySQL 的成功非常重要。
分辨率
分辨率表示采集和报告指标的频率:1 秒,10 秒,30 秒,5 分钟等等。更高的分辨率意味着更高的频率:1 秒的分辨率比 30 秒的分辨率更高。就像电视机一样,分辨率越高,看到的细节就越多。而且“眼见为实”,让我们来看看相同数据在 30 秒内的三张图表。第一张图表,图 6-8,展示了最高分辨率下的 QPS 值:1 秒。

图 6-8. 1 秒分辨率下的 QPS
在前 20 秒内,QPS 正常稳定,在 100 到 200 QPS 之间波动。从第 20 秒到第 25 秒,出现了 5 秒的停顿(盒子中显示的 5 个数据点低于 100 QPS)。最后五秒,QPS 急剧上升到异常高的数值,这在停顿后是常见的情况。这张图表不太戏剧化,但却很现实,并且开始阐明一个观点,接下来的两张图表将更加明确。
第二张图表,图 6-9,是相同数据,但分辨率为 5 秒。

图 6-9. 5 秒分辨率下的 QPS
在 5 秒分辨率下,一些细微的细节丢失了,但关键的细节保留了下来:前 20 秒正常和稳定的 QPS;大约 25 秒左右的停顿;以及停顿后的波动。这种图表对日常监控是可以接受的——尤其考虑到以 1 秒分辨率收集、存储和绘制度量是如此困难,几乎从未完成。
第三个图表,图 6-10,是完全相同的数据,但以 10 秒分辨率。
在 10 秒分辨率下,几乎所有的细节都丢失了。根据图表,QPS 是稳定和正常的,但这是误导性的:QPS 不稳定,并且在 10 秒内(五秒的停顿和五秒的波动)不正常。
至少,以 5 秒的分辨率收集关键绩效指标(参见“关键绩效指标”)。如果可能,也要以 5 秒的分辨率收集大部分“光谱”中的度量,以下几个例外除外:管理、SHOW 和不良的SELECT度量可以较慢地收集(10、20 或 30 秒),而数据大小可以非常慢地收集(5、10 或 20 分钟)。
力求达到尽可能高的分辨率,因为与记录的查询度量不同,MySQL 度量要么被收集,要么永远消失。

图 6-10。10 秒分辨率下的 QPS
野鸭追逐(阈值)
阈值是一个静态值,超过该值会触发监控警报,通常会呼叫值班工程师。阈值似乎是一个好主意,合理的想法,但并不起作用。这是一个非常强烈的说法,但比相反的说法——声称阈值有效——更接近真相。
问题在于阈值还需要一个持续时间:指标值必须超过阈值多久才能触发警报。考虑前一节中(1 秒分辨率的 QPS)图表图 6-8。没有持续时间,QPS 小于 100 的阈值在 30 秒内会触发七次:五秒的停顿和第三第十三数据点。这在监控和警报的术语中被称为“太嘈杂”,那么 QPS 小于 50 的阈值呢?毫无疑问,QPS 从 100 QPS 降至 50 QPS 的 50%下降会向人类发出警报。抱歉,警报从未触发:最低数据点是 50 QPS,不小于50 QPS。
这个例子似乎是刻意构造的,但实际情况并非如此,情况比这更糟。假设您向警报添加了 5 秒的持续时间,并将阈值重置为 QPS 小于 100。现在,警报仅在五秒的停顿后触发。但是如果停顿不是真正的停顿呢?如果在这五秒内有网络中断导致数据包丢失,问题既不是 MySQL 也不是应用程序造成的?那么被警报的值班人员就像在一场野鸭追逐中一样。
我知道看起来我正在量身定制这个例子以符合我的观点,但开玩笑归开玩笑:阈值很难完美,完美意味着它仅在真正的合法问题上发出警报——没有误报。
在用户体验和客观限制上发出警报
有两个经过验证的解决方案可以替代阈值:
-
根据用户体验发出警报
-
根据客观限制发出警报
从“北极星”和“关键绩效指标”出发,用户体验到的 MySQL 指标仅有响应时间和错误。这些信号之所以可靠,不仅是因为用户体验到了它们,还因为它们不可能是误报。QPS 的变化可能是用户流量的合理变化。但是响应时间的变化只能用响应时间的变化来解释。错误也是如此。
注意
对于微服务,用户可能是另一个应用程序。在这种情况下,正常的响应时间可能非常低(数十毫秒),但监控和警报原则是相同的。
对于响应时间和错误,阈值和持续时间也更简单,因为我们可以想象超过阈值的异常条件。例如,假设一个应用程序的正常 P99 响应时间为 200 毫秒,正常的错误率为每秒 0.5 个。如果 P99 响应时间在一分钟内增加到 1 秒(或更长时间),这是否会导致糟糕的用户体验?如果是,则将其设为阈值和持续时间。如果错误率在 20 秒内增加到每秒 10 个,这是否会导致糟糕的用户体验?如果是,则将其设为阈值和持续时间。
举个更具体的例子,让我们澄清之前例子的实施情况,其中 200 毫秒是正常的 P99 响应时间。每五秒测量和报告 P99 响应时间(见“查询响应时间”)。对于触发条件的度量创建一个滚动的一分钟警报,当最后 12 个值大于一秒时触发。(因为指标每五秒报告一次,所以每分钟有 60 / 5 秒 = 12 个值/分钟。)从技术角度来看,查询响应时间的持续 5 倍增加是激烈的,并且值得调查——这可能是一个更大问题即将到来的早期警告,如果被忽视,将导致应用程序的中断。但是警报的目的更实际而非技术性:如果用户习惯于从应用程序获得亚秒级的响应,则一秒钟的响应显然会感到迟钝。
客观限制是 MySQL 无法超越的最小或最大值。这些是 MySQL 外部常见的客观限制:
-
零剩余磁盘空间
-
零空闲内存
-
100% CPU 利用率
-
100%存储 IOPS 利用率
-
100%网络利用率
MySQL 有许多max系统变量,但以下是最常见的几个会影响应用程序的:
MySQL 有一个限制值可能会让不少工程师感到意外:AUTO_INCREMENT 的最大值。MySQL 没有本地度量或方法来检查 AUTO_INCREMENT 列是否接近其最大值。相反,常见的 MySQL 监控解决方案通过执行类似于示例 6-7 的 SQL 语句来创建一个度量,该示例由著名的 MySQL 专家 Shlomi Noach 在 “使用单个查询检查 AUTO_INCREMENT 容量” 中编写。
示例 6-7. 检查最大 AUTO_INCREMENT 的 SQL 语句
SELECT
TABLE_SCHEMA,
TABLE_NAME,
COLUMN_NAME,
DATA_TYPE,
COLUMN_TYPE,
IF(
LOCATE('unsigned', COLUMN_TYPE) > 0,
1,
0
) AS IS_UNSIGNED,
(
CASE DATA_TYPE
WHEN 'tinyint' THEN 255
WHEN 'smallint' THEN 65535
WHEN 'mediumint' THEN 16777215
WHEN 'int' THEN 4294967295
WHEN 'bigint' THEN 18446744073709551615
END >> IF(LOCATE('unsigned', COLUMN_TYPE) > 0, 0, 1)
) AS MAX_VALUE,
AUTO_INCREMENT,
AUTO_INCREMENT / (
CASE DATA_TYPE
WHEN 'tinyint' THEN 255
WHEN 'smallint' THEN 65535
WHEN 'mediumint' THEN 16777215
WHEN 'int' THEN 4294967295
WHEN 'bigint' THEN 18446744073709551615
END >> IF(LOCATE('unsigned', COLUMN_TYPE) > 0, 0, 1)
) AS AUTO_INCREMENT_RATIO
FROM
INFORMATION_SCHEMA.COLUMNS
INNER JOIN INFORMATION_SCHEMA.TABLES USING (TABLE_SCHEMA, TABLE_NAME)
WHERE
TABLE_SCHEMA NOT IN ('mysql', 'INFORMATION_SCHEMA', 'performance_schema')
AND EXTRA='auto_increment'
;
那么另外两个关键的性能指标如何:QPS 和正在运行的线程?监控 QPS 和正在运行的线程是最佳实践,但不建议对它们进行警报。这些度量在调查由响应时间或错误信号的真正问题时至关重要,但否则它们的波动太大,不能作为可靠的信号。
如果这种方法看起来很激进,请记住:这些是针对使用 MySQL 的工程师的警报,而不是 DBA。
因果关系
我直言不讳:当 MySQL 响应缓慢时,应用程序在大多数情况下(也许 80%)是原因——根据我的经验——因为应用程序驱动 MySQL。没有应用程序,MySQL 就空闲。如果应用程序不是原因,MySQL 性能缓慢的其他常见原因有几个。另一个应用程序——任何应用程序,不仅仅是 MySQL——可能有 10% 的概率成为罪魁祸首,正如我在 “吵闹邻居” 中讨论的那样。硬件,包括网络,在现代硬件非常可靠的情况下只占 5% 的问题,尤其是企业级硬件,它的持久性(和成本)比消费者级硬件更长。最后且最少:我估计 MySQL 自身是其缓慢的根本原因的可能性仅为 1%。
一旦确定,原因被认为是根本原因,而不是某种之前未见的副作用。例如,应用程序的原因假设类似于在生产中部署后立即在 MySQL 中引发问题的查询编写不佳。或者,硬件原因假设类似于工作但比平常慢得多的退化存储系统,这会导致 MySQL 响应缓慢。当这种假设是错误的——确定的原因不是根本原因时,尤其危险。考虑以下事件序列:
-
持续 20 秒的网络问题导致严重的数据包丢失或低级别网络重试。
-
网络问题导致查询错误或超时(分别由于数据包丢失或重试)。
-
应用程序和 MySQL 都记录错误(查询错误和客户端错误)。
-
应用程序重试查询。
-
在重试旧查询的同时,应用程序继续执行新查询。
-
由于执行新旧查询,QPS 增加。
-
由于 QPS 增加,利用率也在增加。
-
由于利用率增加,等待时间也在增加。
-
由于等待时间增加,超时也在增加。
-
应用程序再次重试查询,从而创建一个反馈循环。
当您进入这种情况时,问题是显而易见的,但根本原因并不明确。您知道问题发生之前一切都是正常和稳定的:没有应用程序变更或部署;MySQL 的关键性能指标是正常和稳定的;DBA 确认他们这边没有做任何工作。这使得这种情况特别具有阴险性:据您所知,事情不应该发生,但不可否认它确实发生了。
从技术上讲,所有原因都是可以知道的,因为计算机是有限且离散的。但从实际角度来看,原因只能知道到监控和日志记录的允许程度。在这个例子中,如果您拥有异常好的网络监控和应用程序日志记录(以及访问 MySQL 错误日志的权限),您可以找出根本原因:20 秒的网络故障。但这比做起来要困难得多,因为在这种情况下——您的应用程序停止运行,客户在打电话,并且现在是星期五下午四点半——工程师们专注于解决问题,而不是阐明其根本原因。在专注于解决问题时,很容易将 MySQL 视为需要修复的原因:让 MySQL 运行更快,应用程序就会恢复正常。但从这个意义上讲,没有办法修复 MySQL——回想一下,“MySQL 什么都不做”。因为在问题发生之前一切正常,目标是恢复到那种正常状态,从应用程序开始,因为它驱动着 MySQL。正确的解决方案取决于应用程序,但常见的策略包括:重新启动应用程序、限制传入的应用程序请求和禁用应用程序功能。
我并不偏袒 MySQL。简单的事实是,MySQL 是一个成熟的数据库,在这个领域已经超过 20 年了。此外,作为一个开源数据库,它已经受到来自世界各地工程师的严格审查。在 MySQL 充满传奇色彩的生命周期的这个时刻,固有的慢不是它的弱点。与其问为什么 MySQL 慢,一个更强大有效的问题是,“是什么导致 MySQL 运行缓慢?”
概要
本章分析了 MySQL 指标的光谱,这些指标对于理解工作负载的本质至关重要,这些指标决定了 MySQL 的性能。关键的领悟点是:
-
MySQL 性能有两个方面:查询性能和服务器性能。
-
查询性能是输入;服务器性能是输出。
-
正常和稳定是 MySQL 在您的应用程序上表现出来的一切,就像一天中的典型工作日一样,一切都正常运行。
-
稳定性不限制性能;它确保任何水平的性能都是可持续的。
-
MySQL 的关键性能指标包括响应时间、错误、QPS 和正在运行的线程。
-
指标领域包括六类指标:响应时间、速率、利用率、等待、错误和访问模式(如果计算内部指标,则为七类)。
-
指标类相关联:速率增加利用率;利用率推动降低速率;高(最大)利用率引发等待;等待超时引发错误。
-
MySQL 指标的光谱是广泛的;请参阅 “光谱”。
-
分辨率 意味着收集和报告指标的频率。
-
高分辨率指标(5 秒或更短)揭示了低分辨率指标中丢失的重要性能细节。
-
警报用户体验(如响应时间)和客观限制。
-
应用程序问题(无论是您的应用程序还是其他应用程序)是导致 MySQL 性能缓慢的最可能原因。
-
MySQL 服务器性能通过一系列指标展现出来,这些指标在某种程度上反映了工作负载通过 MySQL 的折射。
下一章将调查复制延迟。
实践:审查关键性能指标
这项实践的目标是了解 MySQL 的四个关键性能指标的正常和稳定数值,如 “关键性能指标” 所述。为了使这项实践更有趣,首先写下您认为的应用程序的 KPI 值。您可能对 QPS 有一个很好的了解;那响应时间(P99 或 P999)、错误和运行中的线程呢?
如果尚未开始,开始收集这四个 “关键性能指标”。您的方法取决于您用来收集 MySQL 指标的软件(或服务)。任何像样的 MySQL 监控器应该都能收集所有四个指标;如果您当前的解决方案没有,严肃考虑更好的 MySQL 监控器,因为如果它不能收集关键性能指标,它不太可能收集 “光谱” 中详细描述的许多指标。
审查至少一整天的关键性能指标。实际数值是否接近您的预期?如果响应时间高于预期,那么您知道从哪里开始:“查询概要”。如果错误率高于预期,则查询表 performance_schema.events_errors_summary_global_by_error 查看正在发生的错误编号。使用 “MySQL 错误消息参考” 查找错误代码。如果运行线程高于预期,则诊断较为复杂,因为单个线程执行不同的查询(假定应用程序使用连接池)。从查询概要中开始处理最慢的查询。如果您的查询指标工具报告查询负载,请关注负载最高的查询;否则,请关注总查询时间最长的查询。如有必要,使用 Performance Schema threads 表 进行调查。
审查一天中不同时段的关键性能指标。数值整天稳定吗,还是在午夜时分有所下降?是否存在数值异常的时段?总体来看,您的应用程序的正常和稳定的关键性能指标是什么?
实践:审查警报和阈值
这项实践的目标是帮助你晚上能够安心入睡。虽然 MySQL 指标的图表处于前台和中心位置,但警报及其配置通常被隐藏起来。因此,工程师,特别是新入职的工程师,不知道哪些警报潜伏在黑暗中,等待他们在睡觉时呼叫。花一个早晨或下午的时间来照亮所有的警报及其配置情况——它们的阈值,如果有的话。并且,在此期间:记录警报(或更新当前的文档)。查看 “荒谬的追逐(阈值)” 和 “基于用户体验和目标限制的警报”,并调整或移除多余的警报。
警报的目标很简单:每一页都是合法的并且可操作的。合法 意味着某些东西已经损坏,或者很快会损坏,并且需要立即修复。可操作 意味着工程师(被呼叫的)具有修复它所需的知识、技能和访问权限。这对 MySQL 来说是可能的。对荒谬的追逐说“不”,对一个好的夜间睡眠说“是”。
^(1) MySQL 工作日志 5384 解释了性能模式中如何实现响应时间分位数。
^(2) Com_insert_select 和 Com_replace_select 在技术上既是读操作又是写操作,但为简单起见,我将它们视为写操作。
^(3) “我是米斯克先生,看着我!”
^(4) 查看我的博文 “MySQL Select 和 Sort 状态变量” ,详细解释了所有 Select_% 和 Sort_% 指标。
^(5) 你可以禁用耐久性,但那是个糟糕的主意。
^(6) MySQL 自适应刷新算法是由著名的 MySQL 专家 Yasufumi Kinoshita 在 2008 年在 Percona 工作时创建的。查看他的博文 “自适应检查点”。
^(7) 想要证明并深入了解,请阅读我的博文 “MySQL LRU 刷新和 I/O 容量”。
^(8) 遗留刷新 也被称为脏页百分比刷新,但我更喜欢我的术语,因为它更简单,并且更准确地描述它:遗留 暗示它不再是当前的,这是真实的。
第七章:复制延迟
复制延迟 是源 MySQL 实例上发生写入的时间与该写入在副本 MySQL 实例上应用的时间之间的延迟。复制延迟是所有数据库服务器固有的问题,因为跨网络的复制会产生网络延迟。
作为一个使用 MySQL 的工程师,我很高兴你不必设置、配置和维护 MySQL 复制拓扑,因为 MySQL 复制变得复杂了。相反,本章节关注性能相关的复制延迟问题:是什么、为什么会发生、带来了哪些风险,以及你可以采取什么措施。
从技术上讲,是的,复制会降低性能,但你不希望在没有它的情况下运行 MySQL。毫不夸张地说,复制阻止了企业因数据丢失而破产的可能性。MySQL 无处不在,从医院到银行,复制让宝贵的数据在不可避免的故障中保持安全。尽管复制会降低性能并存在延迟风险,但这些成本被复制带来的巨大好处抵消了。
本章节探讨了复制延迟。包括六个主要部分。第一部分介绍了基本的 MySQL 复制术语,并追溯了复制延迟的技术起源——即使在快速数据库和网络的背景下,为什么它会发生。第二部分讨论了复制延迟的主要原因。第三部分解释了复制延迟的风险:数据丢失。第四部分提供了一个启用多线程副本的保守配置,显著减少延迟。第五部分介绍了如何使用高精度监控复制延迟。第六部分解释了为什么复制延迟恢复缓慢。
基础
MySQL 有两种复制类型:
源到副本
源到副本复制 是 MySQL 已经使用了 20 多年的基本复制类型。它的古老地位意味着 MySQL 复制 就是指源到副本的复制。MySQL 复制虽然年代久远,但毫无疑问:它快速、可靠,今天仍被广泛使用。
集群复制
集群复制 是 MySQL 自 MySQL 5.7.17 版本(2016 年 12 月 12 日发布)开始支持的新型复制。集群复制创建了一个由主副本实例组成的 MySQL 集群,使用组共识协议同步(复制)数据变更并管理组成员资格。简而言之,集群复制就是 MySQL 集群,它是 MySQL 复制和高可用性的未来。
本章仅涵盖传统的 MySQL 复制:源到副本。Group Replication 是未来的趋势,但由于在撰写本文时,我和我认识的任何数据库管理员都没有大规模操作 Group Replication 的经验,所以我将推迟对其进行详细讨论。此外,建立在 Group Replication 之上的另一项创新正在成为标准:InnoDB 集群。
此外,Percona XtraDB Cluster和MariaDB Galera Cluster是类似于 MySQL Group Replication 的数据库集群解决方案,但实现方式不同。我推迟对这些解决方案的详细介绍,但如果您正在运行 Percona 或 MariaDB 版本的 MySQL 并寻找数据库集群解决方案,可以考虑这些选项。
MySQL 源到副本复制是无处不在的。虽然本书不涉及复制的内部工作原理,但了解其基础可以阐明复制滞后的原因、它带来的风险以及如何减少这些风险。
注意
MySQL 8.0.22 和 8.0.26(分别于 2020 年和 2021 年发布)发布后,复制术语发生了变化。有关变更的摘要,请参阅“MySQL 术语更新”。本书中使用当前的术语、度量标准、变量和命令。
源到副本
图 7-1 展示了 MySQL 源到副本复制的基础。

图 7-1. MySQL 源到副本复制的基础
源 MySQL 实例(简称源)是应用程序向其写入数据的任何 MySQL 服务器。 MySQL 复制支持多个可写源,但由于处理写冲突的难度,这种情况很少见。因此,单个可写源是正常情况。
副本 MySQL 实例(简称副本)是从源复制数据更改的任何 MySQL 服务器。 数据更改包括对行、索引、模式等的修改。为避免脑裂(参见“脑裂是最大的风险”),副本应始终保持只读。通常,一个副本从单个源复制,但多源复制也是一种选择。
图 7-1 中的箭头表示数据更改从源流向副本的过程:
-
在事务提交期间,数据更改会写入源上的二进制日志(或简称binlogs):这些是记录二进制日志事件的磁盘文件(参见“二进制日志事件”)。
-
在副本上,I/O 线程从源的二进制日志中转储(读取)二进制日志事件。(源上的binlog dump 线程专门用于此目的。)
-
在副本上,I/O 线程将二进制日志事件写入副本上的中继日志:这些是源二进制日志的本地副本的磁盘文件。
-
SQL 线程(或 应用程序线程)从中继日志中读取二进制日志事件。
-
SQL 线程将二进制日志事件应用于复制品数据。
-
复制品将数据更改(由 SQL 线程应用)写入其二进制日志。
默认情况下,MySQL 复制是异步的:在源端,事务在步骤 1 完成后,其余步骤是异步发生的。MySQL 支持半同步复制:在源端,事务在步骤 3 完成后才提交。这不是打字错误:MySQL 半同步复制在步骤 3 后提交;它不等待步骤 4 或 5。“半同步复制”提供了更详细的信息。
复制品不需要写入二进制日志(步骤 6),但这是高可用性的标准做法,因为它允许复制品成为源。这是数据库故障转移的工作方式:当源数据库死机或因维护而关闭时,复制品被提升为新的源。让我们称这些实例为旧源和新源。最终,DBA 将恢复旧源(或克隆一个新实例来替换它),并使其从新源复制。在旧源中,以前处于空闲状态的 I/O 线程、中继日志和 SQL 线程(在图 7-1 中深色阴影部分)开始工作。(旧源中的 I/O 线程将连接到新源,这将激活其以前处于空闲状态的 binlog dump 线程。)从新源的二进制日志中,旧源复制它在离线期间错过的写入。在这样做的同时,旧源报告复制延迟,但这是一个在“故障后重建”中解决的特殊情况。这就是故障转移的核心内容;当然,在实践中它更为复杂。
二进制日志事件
二进制日志事件是一个低级别的细节,你可能不会遇到(即使是数据库管理员也不经常在二进制日志中操作),但它们是应用程序执行的事务的直接结果。因此,理解应用程序试图通过复制的管道刷新的内容是非常重要的。
注意
以下假设使用基于行的复制(RBR),这是 MySQL 5.7.7 版本以来的默认binlog_format。
复制侧重于事务和二进制日志事件,而不是单个写入操作,因为数据更改在事务提交时提交到二进制日志,此时写入操作已经完成。从高层次来看,侧重于事务是因为它们对应用程序是有意义的。从低层次来看,侧重于二进制日志事件是因为它们对复制是有意义的。事务在二进制日志中逻辑上表示和界定为事件,这是多线程复制可以并行应用它们的方式——更多细节请参见“减少延迟:多线程复制”。为了说明,让我们使用一个简单的事务:
BEGIN;
UPDATE t1 SET c='val' WHERE id=1 LIMIT 1;
DELETE FROM t2 LIMIT 3;
COMMIT;
表模式和数据并不重要。重要的是UPDATE在表t1中更改一行,而DELETE从表t2中删除了三行。图 7-2 说明了该事务如何在二进制日志中提交。
四个连续的事件构成了该事务:
-
一个
BEGIN事件 -
一个带有一个行图像的
UPDATE语句事件 -
一个带有三行图像的
DELETE语句事件 -
一个
COMMIT事件
在这个低级别上,SQL 语句基本上消失了,复制是一系列事件和行图像(对于修改行的事件)。行图像是修改前后行的二进制快照。这是一个重要的细节,因为单个 SQL 语句可以生成无数行图像,从而产生一个大事务,可能在复制过程中引起延迟。

图 7-2. 一个事务的二进制日志事件
让我们在这里停下,因为对于这本书来说我们深入到 MySQL 内部的内容已经足够。虽然简短,但是对二进制日志事件的介绍使得接下来的章节更易理解,因为现在你知道复制管道中流动的内容以及事务和二进制日志事件的重点是什么。
复制延迟
参考图 7-1,当在副本上应用变更(步骤 5)比源上提交变更(步骤 1)的速度慢时,复制延迟就会发生。中间的步骤很少会成为问题(当网络正常工作时),因为 MySQL 二进制日志、MySQL 网络协议和典型网络非常快速和高效。
注意
应用变更简称为应用事务或应用事件,取决于上下文。
在副本上的 I/O 线程可以以很高的速率将二进制日志事件写入其中继日志,因为这是一个相对简单的过程:从网络读取,顺序写入磁盘。但是,SQL 线程有一个更加困难且耗时的过程:应用这些变更。因此,I/O 线程超过了 SQL 线程,复制延迟看起来像图 7-3。

图 7-3. MySQL 复制延迟
严格来说,单个 SQL 线程并不会导致复制延迟,它只是一个限制因素。在本例中,问题的根本原因是源头上的高事务吞吐量,这对于忙碌的应用程序来说是一个好问题,但也是一个问题。关于原因的更多信息请参见下一节。解决方案是增加更多的 SQL 线程,这将在"减少延迟:多线程复制"一节中讨论。
半同步复制既不能解决也不能预防复制延迟。启用半同步复制时,对于每个事务,MySQL 等待副本确认已将事务的二进制日志事件写入其中继日志——见图 7-1 的第 3 步(#repl-foundation-img)。在本地网络上,如图 7-3 所示(#repl-lag-img),仍可能发生复制延迟。如果半同步减少复制延迟,那只是网络延迟的副作用,会限制源端的事务吞吐量。详细内容请参见“半同步复制”(#repl-semi-sync)。
延迟是复制过程中固有的,但不要误解:MySQL 复制速度非常快。单个 SQL 线程可以轻松处理成千上万个事务每秒。第一个原因很简单:副本不执行与源相同的全部工作负载。特别是,副本不执行读操作(假设副本不用于提供读取服务)。第二个原因需要几行来解释。如“二进制日志事件”中所述,本章假定使用基于行的复制(RBR)。因此,副本不执行 SQL 语句:它们应用二进制日志事件。这节省了副本大量时间,因为它们只需处理最终结果——数据变更——并告知如何应用这些变更。这比查找匹配的行以进行更新要快得多,而这是源必须做的事情。由于这两个原因,即使源非常忙碌,副本也几乎可以处于空闲状态。尽管如此,复制也可能受到三个原因的影响。
原因
复制延迟有三个主要原因:事务吞吐量、故障后的重建以及网络问题。接下来分别介绍每一个原因。
事务吞吐量
当源端速率高于副本 SQL(应用程序)线程应用更改的速率时,事务吞吐量会导致复制延迟。当应用程序因合理繁忙而出现这种情况时,通常不可能减少源端的速率。解决方法是通过运行更多 SQL(应用程序)线程来增加副本的速率。专注于通过调整多线程复制来提高副本性能,详见“减少延迟:多线程复制”(#repl-mtr)。
大型事务 —— 修改大量行数的事务 —— 对副本的影响比源端更大。在源端,例如执行需要两秒的大型事务,通常不会阻塞其他事务,因为它可以并行运行(并提交)。但是在单线程副本上,这种大型事务会阻塞所有其他事务两秒钟(或在副本上执行所需的时间,可能因争用较少而较短)。在多线程副本上,其他事务可以继续执行,但是这种大型事务仍然会阻塞一个线程两秒钟。解决方案是减少事务的大小。更多信息请参见“大型事务(事务大小)”。
事务吞吐量并非总是由应用程序驱动:回填、删除和归档数据是常见操作,如果不控制批处理大小,则可能导致大规模复制延迟,如“批处理大小”中预警的那样。除了适当的批处理大小,这些操作应监控复制延迟,并在副本开始落后时减速。操作花费一天的时间总比使副本落后一秒钟更好。“风险:数据丢失”解释了其中的原因。
在某个时刻,事务吞吐量将超过单个 MySQL 实例的容量 —— 无论是源端还是副本。要增加事务吞吐量,必须通过分片数据库来进行扩展(参见第五章)。
故障后重建
当 MySQL 或硬件发生故障时,将修复实例并将其放回复制拓扑中。或者从现有实例克隆一个新实例,并取代失败的实例。无论哪种方式,都会重建复制拓扑以恢复高可用性。
注意
副本用于多种目的,但本章仅讨论用于高可用性的副本。
固定(或新)实例将花费几分钟、几小时或几天来追赶:复制它离线时错过的所有二进制日志事件。从技术上讲,这就是复制延迟,但在实践中,您可以忽略它,直到修复的实例追赶上来。一旦追赶上,任何延迟都是合理的。
由于故障不可避免且追赶需要时间,唯一的解决方案是意识到复制延迟是由于故障后的重建而等待。
网络问题
网络问题 通过延迟源端到副本的二进制日志事件传输 —— 图 7-1 中的第 2 步 —— 导致复制延迟。从技术上讲,是网络而不是复制在滞后,但对语义上的纠缠不会改变最终结果:副本落后于源端 —— 一种长时间说 落后 的方式。在这种情况下,您必须征求网络工程师来修复根本原因:网络。
网络问题带来的风险通过沟通和团队合作得以缓解:与网络工程师交流,确保他们了解数据库在网络问题时可能面临的风险——他们可能并不清楚,因为他们不是 DBA 或使用 MySQL 的工程师。
风险:数据丢失
复制滞后即数据丢失。
这对 MySQL 来说是默认情况,因为默认是异步复制。幸运的是,半同步复制是一个选项,它不会丢失任何已提交的事务。让我们首先分析一下异步复制的风险,然后清楚地了解半同步复制是如何缓解风险的。
注意
正如在“基础”中所述,我将组复制推迟到未来。此外,组复制的同步性需要仔细解释。^(1)
异步复制
图 7-4 显示了源崩溃的时间点。

图 7-4. MySQL 源在异步复制中的崩溃
在崩溃之前,源已经将五个事务提交到其二进制日志中。但是当它崩溃时,复制的 I/O 线程仅获取了前三个事务。最后两个事务是否丢失取决于两个因素:崩溃的原因以及是否需要 DBA 进行故障切换。
如果 MySQL 导致崩溃(最有可能是由于错误引起),那么它将自动重启,执行崩溃恢复,并恢复正常操作。(默认情况下,副本会自动重新连接并恢复复制。)而且,只要 MySQL 在正确配置时是真正持久的,已提交的事务 4 和 5 将不会丢失。只有一个问题:崩溃恢复可能需要数分钟甚至数小时才能完成——这取决于本书范围之外的多个因素。如果可以等待,崩溃恢复是理想的解决方案,因为不会丢失任何已提交的事务。
如果硬件或操作系统导致崩溃,或者因为任何原因无法快速恢复崩溃的 MySQL 实例,那么 DBA 将进行故障切换——提升一个副本为源——事务 4 和 5 将会丢失。这不是一个理想的解决方案,但这是标准做法,因为另一种选择更糟糕:在恢复崩溃的 MySQL 实例时出现长时间的停机时间,这需要精确的数据取证,可能需要数小时甚至数天。
注意
维护(运维)时如果 DBA 进行故障切换,则不会丢失数据。而且由于没有发生故障,一些 DBA 称之为成功切换。
这个示例并非刻意证明复制滞后即数据丢失的观点;由于所有硬件和软件(包括 MySQL)最终都会失败,异步复制是不可避免的。
唯一的缓解方法是严格遵守最小化复制延迟。例如,不要忽视 10 秒的复制延迟为“没有太大差距”。而应该将其视为“我们面临着丢失最后 10 秒客户数据的风险”。MySQL 或硬件在最坏的时刻——复制延迟时——不会失败,但我要讲一个关于硬件故障的警示故事。
有一周,当我值班时,我在早上 9 点左右收到了警报。那不算太早;我已经喝完了第一杯咖啡。一个警报迅速变成了数千个。数据库服务器到处都在失败——分布在多个地理位置的数据中心——情况非常糟糕,以至于我立刻意识到:问题不是硬件或 MySQL,因为同时发生这么多但不相关的故障的概率是微乎其微的。长话短说,公司最有经验的工程师之一那天早上没有喝咖啡。他编写并运行的自定义脚本出了大问题。该脚本不仅随机重启了服务器,而是将它们关掉了。(在数据中心,服务器的电源是通过称为智能平台管理接口的背板进行程序控制的。)切断电源就相当于硬件故障。
这个故事的寓意是:失败可能是由人为错误造成的。做好准备。
异步复制不是最佳实践,因为几乎无法减少的数据丢失与持久数据存储的目的背道而驰。全球无数公司在 20 多年来都成功使用异步复制。 (但“常规做法”并不一定意味着“最佳实践”。)如果您使用异步复制,只要满足以下三个条件,MySQL DBA 和专家就不会嗤之以鼻:
-
您可以通过心跳监控复制延迟(参见 “监控”)。
-
当复制延迟过高时,您将在任何时间(不仅仅是工作时间)收到警报。
-
您将复制延迟视为数据丢失,并立即修复它。
许多成功的公司使用异步 MySQL 复制,但还有更高的标准可以追求:半同步复制。
半同步复制
当启用半同步(或 semisync)复制时,源头等待至少一个复制副本确认每个事务。确认 意味着复制副本已将事务的二进制日志事件写入其中继日志。因此,事务已经安全地写入副本的磁盘,但尚未应用。 (因此,如在 “复制延迟” 中提到的,半同步复制仍会出现复制延迟。)接收到确认时,而非应用时,才称为 半 同步,而非完全同步。
让我们重现 “异步复制” 中的源头崩溃,但现在启用了半同步复制。图 7-5 显示源头崩溃的时间点。

图 7-5. 使用半同步复制的 MySQL 源端崩溃
使用半同步复制,每个已提交的事务都保证至少复制到一个副本。在这里,“已提交的事务”指的是客户端执行的COMMIT语句已返回——从客户端的角度看,事务已完成。这是已提交事务的通常高层次理解,但在复制的内部实现中,技术细节有所不同。启用二进制日志和半同步复制时,事务提交的极简化步骤如下:
-
准备事务提交
-
刷新数据更改到二进制日志
-
等待至少一个副本的确认
-
提交事务
InnoDB 事务提交是一个两阶段提交。在两个阶段之间(步骤 1 和步骤 4 之间),数据更改被写入并刷新到二进制日志,并且 MySQL 等待至少一个副本确认事务。^(2)
在图 7-5 中,第四个事务的虚线轮廓表明至少有一个副本未确认。在第 2 步后源端崩溃,因此事务在二进制日志中,但提交未完成。客户端的COMMIT语句将返回一个错误(不是来自 MySQL,因为 MySQL 已经崩溃;它可能会收到一个网络错误)。
是否丢失第四个事务取决于与之前相同的两个因素(“异步复制”):崩溃的原因,以及是否需要进行故障切换。重要的区别在于,在启用半同步复制时,每个连接仅能丢失一个未提交的事务。由于事务未完成且客户端收到错误,未提交事务的潜在丢失不那么令人担忧。关键词是不那么令人担忧:有一些边缘情况意味着你不能简单地忽略丢失的事务。例如,如果一个副本在事务被确认后,源端在接收确认之前崩溃了会怎么样?答案将会更深入地涉及到复制的细节,但我们并不需要那么深入。关键在于:半同步复制确保所有已提交的事务至少复制到一个副本,每个连接只能在失败时丢失一个未提交的事务。
持久数据存储的基本目的是持久化数据,而不是丢失数据。那么为什么半同步不是 MySQL 的默认设置呢?这很复杂。
有些成功的公司使用半同步复制来运行规模较大的 MySQL。一个著名的公司是 GitHub,这家公司是著名的 MySQL 专家 Shlomi Noach 的前雇主,他写了一篇关于他们使用半同步复制的博客文章:“GitHub 上的 MySQL 高可用性”。
半同步复制 降低 可用性—这不是打字错误。虽然它保护事务,但这种保护意味着每个连接的当前事务可能会在 COMMIT 上陷入停顿、超时或失败。相比之下,异步复制的 COMMIT 本质上是瞬时的,并且只要源端的存储工作正常就是有保证的。
默认情况下,当没有足够的副本或源端等待确认时超时时,半同步复制会有效地退回到异步模式。这可以通过配置有效地禁用,但最佳实践是允许它,因为另一种选择更糟糕:完全的故障(应用无法写入源端)。
使用半同步复制的性能要求源端和副本位于快速的本地网络上,因为网络延迟隐式地限制了源端的事务吞吐量。这是否成为问题取决于运行 MySQL 的本地网络。本地网络应具有亚毫秒的延迟,但必须进行验证和监控,否则事务吞吐量将受网络延迟的影响。
异步复制可以在没有任何特殊配置的情况下运行,而半同步复制则需要特定的配置和调优。对于数据库管理员来说,这两者都不是负担,但它们仍需小心地进行工作。
提示
我认为半同步复制是最佳实践,因为数据丢失是不可接受的—没有争议。我建议你了解更多关于半同步复制的信息,在你的网络上进行测试和验证,并在可能的情况下使用它。首先阅读 MySQL 手册中的 “Semisynchronous Replication”。或者,如果你想为未来做好准备,可以了解 Group Replication 和 InnoDB Cluster:它们是 MySQL 复制和高可用性的未来。尽管半同步复制和 Group Replication 在 MySQL 专家中引发争议,但有一点是普遍认同的:预防数据丢失是一种美德。
减少滞后:多线程复制
默认情况下,MySQL 复制是异步 且 单线程的:副本上有一个 SQL 线程。即使是半同步复制,默认情况下也是单线程的。单个 SQL 线程不会导致复制滞后—“Causes” 是三个主要的原因—但它是限制因素。解决方案是 多线程复制(或 并行复制):多个 SQL 线程并行应用事务。在多线程副本上,SQL 线程被称为 应用者线程。^(3) 如果你愿意,仍然可以称它们为 SQL 线程—这些术语是同义的—但 MySQL 手册在多线程复制的上下文中使用 应用者。
对于我们作为使用 MySQL 的工程师来说,解决方案很简单,但对于 MySQL 来说并不简单。正如你所想象的,事务不能以随机顺序应用:事务之间可能存在依赖关系。例如,如果一个事务插入了一行新记录,第二个事务更新了该行,显然第二个事务必须在第一个事务之后运行。事务依赖跟踪是确定哪些事务(从序列化记录(二进制日志)中)可以并行应用的艺术和科学(以及魔法)。这既迷人又令人印象深刻,但超出了本书的范围,因此我鼓励你观看著名 MySQL 专家 Jean-François Gagné 的视频“MySQL 并行复制(LOGICAL_CLOCK):所有 5.7 版本(以及部分 8.0 版本)的细节”。
严格来说,有一个系统变量可以启用多线程复制,但我怀疑当我告诉你时你不会感到惊讶:实际操作中更为复杂。配置 MySQL 复制超出了本书的范围,但多线程复制太重要了,不给你一个保守的起点是不行的。保守的起点意味着以下配置可能无法发挥多线程复制的全部性能。因此,你(或 DBA)必须调整多线程复制,以最大化其潜力,同时考虑并行复制的各种影响。
警告
本节的其余部分是非常复杂的 MySQL 配置,只能由有经验在高性能、高可用性环境中配置 MySQL 的工程师完成。表 7-1 中的系统变量不会以任何方式影响数据完整性或持久性,但它们会影响源实例和副本实例的性能。请注意:
-
复制会影响高可用性。
-
配置 MySQL 需要提升的 MySQL 权限。
-
系统变量在 MySQL 版本和发行版之间会发生变化。
-
MariaDB 使用不同的系统变量:请参阅 MariaDB 文档中的“并行复制”。
在配置 MySQL 时要非常小心,并仔细阅读您所使用的 MySQL 版本和发行版的手册中相关部分。
表 7-1 列出了三个系统变量作为启用和配置多线程复制的保守起点。随着 MySQL 8.0.26 的变化,变量名称也发生了变化,因此该表列出了旧变量名称和新变量名称,然后是推荐值。我不建议在早于 5.7.22 的 MySQL 版本中使用多线程复制,因为某些来自 8.0 版本的复制功能已被回溯到这个版本中。
表 7-1. 启用多线程复制的系统变量
| MySQL 5.7.22 到 8.0.25 | MySQL 8.0.26 及更新版本 | 值 |
|---|---|---|
slave_parallel_workers |
replica_parallel_workers |
4 |
slave_parallel_type |
replica_parallel_type |
LOGICAL_CLOCK |
slave_preserve_commit_order |
replica_preserve_commit_order |
1 |
在用于高可用性(可以晋升为源的)复制拓扑中的所有 MySQL 实例上设置这三个变量。
将 replica_parallel_workers 设置为大于零是唯一启用多线程复制的系统变量。四个应用线程是一个很好的起点;您必须进行调整,以找到适合您的工作负载和硬件的优化应用线程数。但是,就像施展魔法咒语一样,必须使用 replica_parallel_type 并发起多线程复制的全部性能。即使在 MySQL 8.0.26 中,replica_parallel_type 的默认值仍然是 DATABASE,这仅适用于并行应用不同数据库的事务——实际上每个数据库只有一个应用线程。这是历史遗留问题:这是第一种类型的并行化。但是今天,最佳实践是将 replica_parallel_type = LOGICAL_CLOCK,因为当启用 replica_preserve_commit_order 时,它没有任何缺点,并且提供更好的并行化,因为它无论数据库如何都可以并行应用事务。
replica_preserve_commit_order 默认情况下是禁用的,但我认为这不是最佳实践,因为它允许多线程复制无序提交:在副本上按不同于源上提交顺序提交事务。例如,在源上按顺序提交的事务 1、2、3 在副本上可能按顺序 3、1、2 提交。只有当安全时(即没有事务之间的有序依赖关系时),多线程复制才会无序提交,并且表数据(最终)是相同的,但无序提交具有后果,您和特别是管理 MySQL 的数据库管理员必须理解和处理。 MySQL 手册中的 “复制和事务不一致性” 记录了这些后果。当启用 replica_preserve_commit_order 时,事务仍然是并行应用的,但某些事务可能需要等待较早的事务先提交——这就是保留提交顺序的方式。尽管 replica_preserve_commit_order 降低了并行化的效率,但直到您和数据库管理员验证其后果是可以接受和处理的,这仍然是最佳实践。
注意
对于组复制,多线程复制的工作方式相同。
由于表 7-1 是启用多线程复制的保守起点,因此它不启用最新的事务依赖跟踪:WRITESET。MySQL 事务依赖跟踪由系统变量binlog_transaction_dependency_tracking确定。默认是COMMIT_ORDER,但最新的是WRITESET。基准测试显示,WRITESET比COMMIT_ORDER实现了更大的并行化。在撰写本文时,WRITESET还不到四年:它是在 2018 年 4 月 19 日正式推出的 MySQL 8.0 中引入的。从技术角度来看,你应该使用WRITESET,因为它在多线程副本上实现了更好的性能。但作为政策问题,是否成熟足以在生产中使用一个功能,这是你(或你的 DBA)决定的。要在 MySQL 5.7 上使用WRITESET,必须启用系统变量transaction_write_set_extraction。在 MySQL 8.0 上,此系统变量默认启用,但自 MySQL 8.0.26 起已弃用。
提示
创建一个新的副本来测试和调整多线程副本。新副本几乎没有风险,因为它不提供应用程序或高可用性服务。
还有一个系统变量你应该尝试一下:binlog_group_commit_sync_delay。默认情况下,这个变量是禁用的(零),因为顾名思义,它会给组提交增加人为延迟。延迟通常对性能有害,但组提交延迟是个例外——有时。在源端,事务以组的形式提交到二进制日志中,这是一种内部优化,被称为组提交。为组提交增加延迟会创建更大的组:每组提交更多的事务。多线程复制不依赖于组提交,但可以从更大的组提交中受益,因为一次提交更多的事务有助于事务依赖跟踪找到更多并行化的机会。要尝试调整binlog_group_commit_sync_delay,可以从10000开始:单位是微秒,所以是 10 毫秒。这会使源端的事务提交响应时间增加 10 毫秒,但也应该会提高副本的事务吞吐量。由于缺乏 MySQL 指标,调整组提交大小以配合多线程副本应用程序的事务吞吐量并不容易。如果选择这条路,请阅读由著名的 MySQL 专家 Jean-François Gagné撰写的“用于调整 MySQL 5.7 并行复制的指标”。
多线程复制是最佳实践,但需要复杂的 MySQL 配置和可能的调优来达到最大性能。基准测试和实际结果各不相同,但多线程复制可以使副本的事务吞吐量增加一倍以上。对于这样的性能增益,付出的努力是非常值得的。但最重要的是:多线程复制显著减少了复制延迟,在使用异步复制时至关重要。
监控
监控复制延迟的最佳实践是使用专门设计的工具。但首先,让我们来看看 MySQL 中臭名昭著的复制延迟度量标准:Seconds_Behind_Source,如 SHOW REPLICA STATUS 所报告的。
注
在 MySQL 8.0.22 之前,复制延迟度量和命令分别是 Seconds_Behind_Master 和 SHOW SLAVE STATUS。从 MySQL 8.0.22 开始,度量和命令是 Seconds_Behind_Source 和 SHOW REPLICA STATUS。本书中使用当前的度量和命令。
Seconds_Behind_Source 等于副本上的当前时间减去 SQL 线程正在执行的二进制日志事件的时间戳。[⁴] 如果副本上的当前时间是 T = 100,而 SQL 线程正在执行时间戳为 T = 80 的二进制日志事件,则 Seconds_Behind_Source = 20。当一切正常工作时(尽管存在复制延迟),Seconds_Behind_Source 相对准确,但它以三个问题而臭名昭著:
-
第一个问题发生在一切都不工作的情况下。由于
Seconds_Behind_Source仅依赖于二进制日志事件的时间戳,它在字面上看不到(或不关心)二进制日志事件到达之前的任何问题。如果源或网络出现问题导致二进制日志事件无法到达或到达缓慢,那么 SQL 线程会应用所有二进制日志事件,Seconds_Behind_Source报告零延迟,因为从 SQL 线程的角度来看,这在技术上是正确的:零事件,零延迟。但从我们的角度来看,我们知道这是错误的:不仅存在复制延迟,还存在副本之前的问题。 -
第二个问题是,
Seconds_Behind_Source经常在零和非零值之间波动。例如,一会儿Seconds_Behind_Source报告说落后 500 秒,下一刻报告说没有落后,再过一会儿又报告说落后 500 秒。这个问题与第一个问题相关:由于在副本之前出现问题导致事件缓慢进入中继日志,SQL 线程明显地在工作(应用最新事件)和等待(等待下一个事件)之间摆动。这导致Seconds_Behind_Source在数值(SQL 线程正在工作)和零(SQL 线程正在等待)之间波动。 -
第三个问题是,
Seconds_Behind_Source并没有准确回答工程师们真正想知道的问题:副本何时会追上?副本何时的滞后会有效地降为零,因为它正在应用来自源端的最新事务?假设一切正常(尽管存在复制延迟),Seconds_Behind_Source的值只表示当前正在应用的事件在源端执行多久之前;它不精确表示副本追上源端还需要多长时间。原因在于副本的事务应用速率与源端不同。例如,假设源端并发执行了 10 个事务,每个事务需要 1 秒钟。总执行时间为 1 秒钟,速率为 10 TPS,因为这些事务在源端并发执行。在单线程副本上,每个事务都按顺序应用,最坏情况下的总执行时间和速率可能是10 秒和 1 TPS,分别。我强调可能是,因为副本也可能会更快地应用所有 10 个事务,原因是副本没有承担完整的工作负载,并且不执行 SQL 语句(它应用二进制日志事件)。如果源端每个事务的 1 秒执行时间是由于一个糟糕的
WHERE子句,访问了百万行但只匹配并更新了一行,那么幸运的副本几乎没有时间更新那一行。在多线程副本(参见“减少延迟:多线程复制”)上,总执行时间和速率根据至少两个因素变化:应用线程的数量以及事务是否可以并行应用。无论如何,关键在于:副本的事务应用速率与源端不同,而且由于无法知道差异,Seconds_Behind_Source不能——也不会——精确地指示副本何时会追上。
尽管存在这些问题,Seconds_Behind_Source还是有价值的:它提供了一个大概估计,即副本追上源端还需要多长时间:几秒钟、几分钟、几小时、几天?更多关于恢复时间的内容将在下一节讨论。
MySQL 8.0 引入了显著改进的 MySQL 复制可见性,包括复制延迟。只有一个小问题:它提供了原始构件,而不是像Seconds_Behind_Source这样的即用型度量指标。如果你在使用 MySQL 8.0,请与你的 DBA 讨论Performance Schema 复制表,它会提供关于 MySQL 复制的新信息。否则,监控复制延迟的最佳实践是使用专门的工具。工具不依赖于二进制日志事件的时间戳,而是使用自己的时间戳。工具会定期向表写入时间戳,然后报告复制延迟,即从副本当前时间减去表中最新时间戳的差值。从根本上讲,这种方法与 MySQL 计算Seconds_Behind_Source的方式类似,但使用工具时有三个重要的不同点:
-
工具会定期写入时间戳,这意味着它不容易受到
Seconds_Behind_Source的第一个问题的影响。如果在二进制日志事件到达之前存在任何问题,从工具中的复制延迟将立即开始增加,因为其时间戳(写入表中)停止递增。 -
工具消除了
Seconds_Behind_Source的第二个问题:工具的复制延迟不会波动;如果其时间戳等于当前时间(实际上)时,复制延迟只能为零。 -
工具可以测量复制延迟,并以次秒级间隔(例如每 200 毫秒)写入时间戳。对于高性能应用程序或使用异步复制的任何应用程序,单个秒的复制延迟都太多了。
提示
监控 MySQL 复制的事实上的工具是pt-heartbeat。(由复制延迟监控工具写入的时间戳称为心跳。)这个老牌工具已经使用和成功了十多年,因为它简单而有效。使用它开始监控复制延迟,或者使用它来学习如何编写自己的工具。
恢复时间
当副本有显著的延迟时,最紧迫的问题通常是“它何时会恢复?”副本何时才能赶上源,以便执行(应用)最新的事务?没有确切的答案。但是复制延迟总是在原因修复后恢复。我将在本节末尾回到这个概念。在那之前,还有一个复制延迟的特征需要了解。
复制延迟的另一个常见且重要的特征是增加延迟与副本开始恢复(减少延迟)之间的拐点。在图 7-6 中,拐点由时间 75 处的虚线标记。
当复制延迟开始时,随着延迟的增加,情况看起来越来越严重。但这是正常的。假设副本没有损坏,SQL 线程正在努力工作,但原因尚未修复,因此二进制日志事件的积压继续增加。只要原因持续存在,复制延迟将增加。但同样:这是正常的。原因一旦修复,谚语般的潮水将很快转向,在复制延迟图表中创建一个拐点,如图 7-6 所示,在第 75 时刻。副本仍然滞后,但它正在比 I/O 线程将其倒入中继日志更快地应用二进制日志事件。拐点后,副本延迟通常会以显著且令人满意的速度减少。

图 7-6. 复制延迟图中的拐点
在拐点之前,恢复时间并不是很有意义,因为理论上,如果原因从未修复,那么副本永远不会恢复。当复制延迟稳步增加(前-拐点),不要被值所分散注意力;而是专注于修复原因。延迟将一直增加,直到原因修复为止。
在拐点之后,恢复时间比Seconds_Behind_Source或工具报告的值更有意义,并且通常更快。如在“监控”中解释的那样,尽管有复制延迟,单个 SQL 线程非常快,因为副本不必执行源的全部工作负载。因此,副本通常比源更快地应用事务,这也是副本最终能够追上的原因。
根据我的经验,如果复制延迟以天计算,通常在几小时内就会恢复(后-拐点)—也许是很多小时,但无论如何都是几个小时。同样,几个小时的延迟通常在几个小时内恢复,几分钟的延迟通常在你喝完一杯咖啡之前就能恢复。
回到没有确切答案和延迟总是会恢复的观念,最终结果是确切的恢复时间并不像看起来那么有用或有意义。即使你能知道副本恢复的确切时间,你也只能等待。MySQL 复制非常坚韧。只要副本没有崩溃,MySQL 一定会 恢复。尽快修复原因,等待拐点,那么复制延迟指示的最坏情况恢复时间:MySQL 通常会因为 SQL 线程快而恢复得更快。
总结
本章调查了 MySQL 复制延迟。复制是 MySQL 高可用性的基础,而复制延迟意味着数据丢失。主要要点如下:
-
MySQL 有三种类型的复制:异步、半同步和群组复制。
-
异步(async)复制是默认设置。
-
异步复制在失败时可能会丢失大量事务。
-
半同步复制在失败时不会丢失任何已提交的事务,只会丢失每个客户端连接的一个未提交的事务。
-
组复制是 MySQL 复制和高可用性的未来(但不包括在本章或书籍中):它将 MySQL 实例转变为一个集群。
-
MySQL 异步和半同步复制的基础是将事务以二进制日志事件的形式从源端发送到复制副本。
-
半同步复制使得源端的事务提交等待至少一个复制副本确认接收并保存(而非应用)该事务。
-
一个复制副本有一个 I/O 线程,用于从源端获取二进制日志事件,并将其存储在本地中继日志中。
-
默认情况下,一个复制副本有一个 SQL 线程,用于执行来自本地中继日志的二进制日志事件。
-
可以启用多线程复制以运行多个 SQL 线程(应用程序线程)。
-
复制延迟有三个主要原因:源端的(高)事务吞吐量、MySQL 实例在故障后的赶上和重建,或者网络问题。
-
SQL(应用程序)线程是复制延迟的限制因素:更多的 SQL 线程通过并行应用事务来减少延迟。
-
半同步复制可能会产生复制延迟。
-
复制延迟是数据丢失的表现,尤其是在异步复制中。
-
启用多线程复制是减少复制延迟的最佳方法。
-
MySQL 的复制延迟度量指标
Seconds_Behind_Source可能具有误导性;避免依赖它。 -
使用专门的工具以亚秒间隔测量和报告 MySQL 复制延迟。
-
从复制延迟中恢复的时间是不精确且难以计算的。
-
MySQL 最终会恢复,一旦问题解决,它总是会恢复。
下一章将详细讨论 MySQL 事务。
实践:监控亚秒延迟
这种做法的目标是监控亚秒级的复制延迟,并确定:你的复制副本是否超过了Seconds_Behind_Source可以报告的 1 秒分辨率?例如,你的复制副本是否滞后了 800 毫秒(远远大于网络延迟)?需要工具来监控亚秒级的延迟:pt-heartbeat。
要完成这个实践,你需要:
-
需要一个计算实例来运行
pt-heartbeat,该实例可以连接到源端和一个复制副本。 -
MySQL 具有
SUPER或GRANT OPTION权限来创建用户;或者请你的数据库管理员创建该用户。 -
MySQL
CREATE权限来创建数据库;或者请你的数据库管理员创建该数据库。
每个 MySQL 配置和环境都是不同的,因此根据需要调整以下示例。
-
为
pt-heartbeat创建一个要使用的数据库:CREATE DATABASE IF NOT EXISTS `percona`;你可以使用不同的数据库名称;我只是选择了
percona作为示例。如果更改数据库名称,请确保在以下命令中也进行更改。 -
为
pt-heartbeat创建一个 MySQL 用户,并授予它所需的权限:CREATE USER 'pt-heartbeat'@'%' IDENTIFIED BY 'percona'; GRANT CREATE, INSERT, UPDATE, DELETE, SELECT ON `percona`.`heartbeat` TO 'pt-heartbeat'@'%'; GRANT REPLICATION CLIENT ON *.* TO 'pt-heartbeat'@'%';您可以使用不同的 MySQL 用户名和密码;我只是选择了
pt-heartbeat和percona(分别)作为示例。如果在生产环境中运行此命令,则绝对应该更改密码。(密码由IDENTIFIED BY子句设置。) -
以更新模式运行
pt-heartbeat,将心跳写入percona数据库中的表:pt-heartbeat \ --create-table \ --database percona \ --interval 0.2 \ --update \ h=SOURCE_ADDR,u=pt-heartbeat,p=percona对这些命令行参数的快速解释:
--create-table如果需要,在指定的数据库中自动创建
heartbeat表。第一个GRANT语句允许pt-heartbeat用户CREATE表。如果不使用此选项,请阅读pt-heartbeat文档以了解如何手动创建heartbeat表。--database指定要使用的数据库。
pt-heartbeat需要此选项。--interval每 200 毫秒写入一个心跳。此选项确定
pt-heartbeat的最大分辨率,即它能够检测到的最小延迟量。默认值为 1.0 秒,不是亚秒。最大分辨率为 0.01 秒(10 毫秒)。因此,0.2 秒有点保守,因此可以尝试更低的值(更高的分辨率)。--update每隔
--interval秒将心跳写入--database中的heartbeat表。h=SOURCE_ADDR,u=pt-heartbeat,p=percona用于连接到 MySQL 的数据源名称(DSN)。
h指定主机名。将SOURCE_ADDR更改为源实例的主机名。u指定用户名。p指定密码。阅读
pt-heartbeat文档以获取有关命令行选项和 DSN 的更多详细信息。如果成功运行该命令,则不打印任何内容并且静默运行。否则,它会打印错误并退出。
-
运行
pt-heartbeat以监视模式再次运行以打印复制延迟:pt-heartbeat \ --database percona \ --interval 0.5 \ --monitor \ h=REPLICA_ADDR,u=pt-heartbeat,p=percona将 DSN 中的
REPLICA_ADDR更改为副本实例的主机名。
在监视模式下,--interval 是检查和打印复制延迟的频率。 pt-heartbeat 的更新模式实例每 0.2 秒(200 毫秒)写入一个心跳,但监视模式实例以稍慢一些的速度(每 0.5 秒)检查和打印复制延迟,以便于阅读。
如果第四步中的命令成功运行,则打印如下行:
0.00s [ 0.00s, 0.00s, 0.00s ]
0.20s [ 0.00s, 0.00s, 0.00s ]
0.70s [ 0.01s, 0.00s, 0.00s ]
0.00s [ 0.01s, 0.00s, 0.00s ]
第一个字段是当前的复制延迟。括号之间的三个字段是复制延迟的最后 1、5 和 15 分钟的移动平均值。
在这个例子中,第一行显示零延迟。然后我故意将我的复制品延迟了 1.1 秒。因此,第二行显示了 200 毫秒的复制延迟,这是因为pt-heartbeat的更新模式实例运行时设定了--interval 0.2,这是最大分辨率。半秒钟后(因为监控模式实例的pt-heartbeat运行时设定了--interval 0.5),该工具报告了第三行的 0.7 秒(700 毫秒)的复制延迟。但是随后我假的 1.1 秒的延迟结束了,所以最后(第四行)正确地报告了零延迟。
这个例子是人为构造的,但它展示了pt-heartbeat如何监控和报告亚秒级的复制延迟。在你的网络上试试吧,这个工具是安全的。
^(1) “MySQL Group Replication…同步或异步复制?” 是由著名的 MySQL 专家 Frédéric Descamps 撰写,解释了组复制的同步性。
^(2) 我推测sync_binlog = 1。
^(3) 在 MySQL 手册中,完整术语是applier worker thread,但我认为worker是多余的,因为每个线程都是某种类型的工作线程。
^(4) 严格来说,它是事件时间戳加上其执行时间。此外,当SHOW REPLICA STATUS报告时,源和副本之间的时钟偏移被减去了Seconds_Behind_Source。
第八章:事务
MySQL 有非事务性存储引擎,如 MyISAM,但 InnoDB 是默认的和推定的标准。因此,实际上,每个 MySQL 查询默认情况下都在一个事务中执行,即使是单个SELECT语句也是如此。
注意
如果您使用的是 Aria 或 MyRocks 等其他存储引擎,则本章不适用。但更可能的情况是,您正在使用 InnoDB,这种情况下:每个 MySQL 查询都是一个事务。
从我们作为工程师的角度来看,事务是概念性的:BEGIN,执行查询,然后COMMIT。然后我们信任 MySQL(和 InnoDB)来维护 ACID 特性:原子性、一致性、隔离性和持久性。当应用工作负载——查询、索引、数据和访问模式——优化良好时,事务在性能方面不成问题(当工作负载优化良好时,大多数数据库主题不成问题)。但在幕后,事务调用了一整套新的考虑因素,因为在维护性能的同时维护 ACID 特性并不容易。幸运的是,MySQL 在执行事务时表现出色。
与上一章节的复制延迟一样,事务内部工作超出了本书的范围,但理解一些基本概念对于避免把事务从 MySQL 的最低级别提升到工程师头脑的顶峰是至关重要的。稍微的理解可以避免许多问题。
本章讨论了 MySQL 事务,特别是避免常见问题。共有五个主要部分。第一个部分深入探讨了与事务隔离级别相关的行锁定。第二部分讨论了 InnoDB 如何在保证 ACID 特性的同时管理并发数据访问:MVCC 和撤消日志。第三部分描述了历史列表长度及其如何指示有问题的事务。第四部分列举了要避免的事务常见问题。第五部分则是探讨在 MySQL 中报告事务详细信息的一次冒险。
行锁定
读操作不会锁定行(除了SELECT…FOR SHARE和SELECT…FOR UPDATE),但写操作总是锁定行。这很简单和预期的,但棘手的问题是:哪些行必须被锁定?当然,正在写入的行必须被锁定。但在REPEATABLE READ事务中,InnoDB 可能锁定比其写入的行要多得多。本节说明并解释了为什么会这样。但首先,我们必须将术语转换为 InnoDB 数据锁定的俗语。
由于表格是索引(回想一下“InnoDB Tables Are Indexes”),行就是索引记录。InnoDB 行锁定讨论的是记录锁,而不是行锁定,因为存在索引记录间隙。间隙是两个索引记录之间的数值范围,如图 8-1 所示:一个主键有两个记录,两个伪记录(infimum 和 supremum),以及三个间隙。

图 8-1. 索引记录间隙
记录以实心正方形表示,其中包含索引值:在本例中为 2 和 5。伪记录以实心箭头表示在索引的每一端:infimum和supremum。每个 InnoDB B-tree 索引都有这两个伪记录:infimum 代表小于最小记录(本例中为 2)的所有索引值;supremum 代表大于最大记录(本例中为 5)的所有索引值。索引记录并不从 2 开始,也不在 5 结束;从技术上讲,它们从 infimum 和 supremum 开始和结束,本节的示例显示了这一细节的重要性。间隙以虚线正方形表示,没有索引值。如果主键是单个无符号四字节整数,则三个间隙为(区间表示):
-
[0, 2) -
(2, 5) -
(5, 4294967295]
在讨论行锁时,术语record用于代替row,因为记录中存在间隙,但称行存在间隙可能会误导。例如,如果应用程序有两行的值分别为 2 和 5,则这并不意味着行之间存在 3 和 4 的间隙,因为这些值可能对应用程序无效。但是就索引而言,在记录值 2 和 5 之间,值 3 和 4 构成一个有效的记录间隙(假设是整数列)。简而言之:应用程序处理行,而 InnoDB 行锁处理记录。本节中的示例显示间隙锁意外地普遍存在,并且可以认为比单个记录锁更重要。
术语data locks指代所有类型的锁定。有许多种数据锁定,但是 Table 8-1 列出了基本的 InnoDB 数据锁。
Table 8-1. 基本的 InnoDB 数据锁
| 锁类型 | 缩写 | 锁定间隙 | 锁定 |
|---|---|---|---|
| Record lock | REC_NOT_GAP |
锁定单个记录 | |
| Gap lock | GAP |
✓ | 锁定记录之前(小于)的间隙 |
| Next-key lock | ✓ | 锁定单个记录及其之前的间隙 | |
| Insert intention lock | INSERT_INTENTION |
允许在间隙中进行INSERT操作 |
理解基本的 InnoDB 数据锁最好的方法是通过真实的事务、真实的锁和插图。
注意
从 MySQL 8.0.16 开始,可以使用性能模式表data_locks和data_lock_waits轻松检查数据锁。以下示例使用这些性能模式表。
在 MySQL 5.7 及更早版本中,您必须首先SET GLOBAL innodb_status_output_locks=ON,这需要SUPER MySQL 特权,然后执行SHOW ENGINE INNODB STATUS并浏览输出以找到相关的事务和锁定。这并不容易——即使是专家也难以仔细解析输出。由于 MySQL 5.7 不是当前版本,我在本节中没有使用其输出;但由于 MySQL 5.7 仍然广泛使用,请参考我的博客文章“MySQL Data Locks: Mapping 8.0 to 5.7”以获取从 MySQL 5.7 到 MySQL 8.0 的数据锁输出映射的图解指南。
让我们重新使用经过验证的且实用的表elem,但简化如示例 8-1 所示。
示例 8-1. 简化后的表elem
CREATE TABLE `elem` (
`id` int unsigned NOT NULL,
`a` char(2) NOT NULL,
`b` char(2) NOT NULL,
`c` char(2) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_a` (`a`)
) ENGINE=InnoDB;
+----+-----+----+----+
| id | a | b | c |
+----+-----+----+----+
| 2 | Au | Be | Co |
| 5 | Ar | Br | C |
+----+-----+----+----+
表elem与之前几乎相同,但现在非唯一索引idx_a仅涵盖列a,并且仅有两行,形成两个主键值,如前文图 8-1 所示。由于行锁实际上是索引记录锁,并且在列b和c上没有索引,您可以忽略这两列;它们仅用于完整性和对更简单章节的怀旧,例如第二章时,行锁只是行锁。
由于默认启用了autocommit,以下示例从BEGIN开始以启动显式事务。当事务结束时释放锁定;因此,事务保持活动状态——没有COMMIT或ROLLBACK——以检查 SQL 语句在BEGIN之后获取(或等待获取)的数据锁。每个示例结束时,通过查询表performance_schema.data_locks打印数据锁。
记录锁和下一个键锁
在默认事务隔离级别REPEATABLE READ下,使用主键更新表elem的行会获取四个数据锁:
BEGIN;
UPDATE elem SET c='' WHERE id BETWEEN 2 AND 5;
SELECT index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'elem';
+------------+-----------+---------------+-------------+-----------------------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+-----------------------+
| NULL | TABLE | IX | GRANTED | NULL |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| PRIMARY | RECORD | X | GRANTED | supremum pseudo-record|
| PRIMARY | RECORD | X | GRANTED | 5 |
+------------+-----------+---------------+-------------+-----------------------+
在说明和解释这些数据锁之前,我将简要描述每一行的含义:
-
第一行是表锁,由
lock_type列指示。InnoDB 是行级锁定存储引擎,但 MySQL 也需要表锁——请参阅“锁定时间”。每个查询事务引用的表都将有一个表锁。出于完整性考虑,我包括表锁,但我们专注于记录锁,因此忽略它们。 -
第二行是主键值为 2 的记录锁,如所有列所示。神秘的列是
lock_mode:X表示独占锁(S[未显示] 表示共享锁),REC_NOT_GAP表示记录锁。 -
第三行是超穷记录上的下一个键锁。在
lock_mode列中,孤立的X或S表示独占或共享下一个键锁,分别视为X,NEXT_KEY。 -
第四行是主键值为 5 的下一个键锁。再次,在
lock_mode列中的孤立X表示独占下一个键锁。将其视为X,NEXT_KEY。
图 8-2 说明了这些数据锁的影响。

图 8-2. 主键上的记录锁和下一个键锁,REPEATABLE READ事务
锁定记录被阴影覆盖;未锁定的记录是白色的。主键值为 2 的记录由于其对应的行匹配了表条件:id BETWEEN 2 AND 5,因此被锁定并且阴影深色显示。
主键值为 5 的下一个键锁是中暗色阴影,其前的间隙则为轻微浅色阴影。这条记录被锁定,因为其对应的行符合表条件。这条记录前的间隙也被锁定,因为这是一个下一个键锁。这个间隙包括不存在的主键值 3 和 4(没有对应的行)。
类似地,至上伪记录的下一个键锁被中暗色阴影阴影,其前的间隙则为轻微浅色阴影。这个间隙包含所有大于 5 的主键值。有趣的问题是:为什么要锁定至上伪记录,它包含所有大于 5 的主键值,而表条件排除大于 5 的主键值?答案同样耐人寻味,但我必须推迟到“间隙锁”。
让我们确认这些间隙被锁定,通过尝试插入一行(使用另一个启用自动提交的事务):
mysql> INSERT INTO elem VALUES (3, 'Au', 'B', 'C');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
+------------+-----------+------------------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+------------------------+-------------+-----------+
| PRIMARY | RECORD | X,GAP,INSERT_INTENTION | WAITING | 5 |
....
mysql> INSERT INTO elem VALUES (6, 'Au', 'B', 'C');
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
+------------+-----------+--------------------+-------------+--------------- ----+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+--------------------+-------------+--------------------+
| PRIMARY | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo... |
...
第一个 INSERT 因尝试在值为 2 和 5 之间的间隙上获取插入意图锁而超时,这是新值(3)将被插入的地方。虽然列 lock_data 列出值 5,但这条记录未被锁定,因为这不是记录或下一个键锁:它是插入意图锁,是一种特殊类型的间隙锁(用于 INSERT);因此,它锁定了值 5 前的间隙。关于插入意图锁的更多信息,请参见“插入意图锁”。
第二个 INSERT 因尝试在至上伪记录上获取下一个键锁而超时,因为新值 6 大于当前最大值 5,因此它将被插入在最大记录和至上伪记录之间。
这些INSERT语句证明了 图 8-2 不是错误的:几乎整个索引被锁定,除了小于 2 的值。为什么 InnoDB 使用锁定间隙而不是记录锁呢?因为事务隔离级别是REPEATABLE READ,但这只是部分答案。完整的答案并不简单,请稍候。通过在影响的记录之前锁定间隙,next-key 锁定隔离了查询访问的整个记录范围,这是 ACID 中的I:隔离。这样可以防止后期事务读取先前未读取的行,称为幻影行(或幻读)。新行是幻影,因为像幽灵一样神秘地出现。(幻影是 ANSI SQL-92 标准的实际术语。)幻影行违反了隔离原则,这就是为什么某些事务隔离级别禁止它们的原因。现在解释的真正神秘部分:ANSI SQL-92 标准允许在REPEATABLE READ中存在幻影行,但 InnoDB 使用 next-key 锁来阻止它们。但我们不要深入询问为什么 InnoDB 在REPEATABLE READ中阻止幻影行。了解原因并不改变事实,数据库服务器实现事务隔离级别通常与标准不同。^(1) 为了完整起见,了解 ANSI SQL-92 标准仅在最高事务隔离级别SERIALIZABLE中禁止幻影行。InnoDB 支持SERIALIZABLE,但本章未涵盖,因为它不常用。MySQL 中默认使用REPEATABLE READ,InnoDB 使用 next-key 锁来阻止REPEATABLE READ中的幻影行。
事务隔离级别READ COMMITTED禁用间隙锁定,包括 next-key 锁定。为了证明这一点,将事务隔离级别更改为READ COMMITTED:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
UPDATE elem SET c='' WHERE id BETWEEN 2 AND 5;
SELECT index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'elem';
+------------+-----------+---------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+-----------+
| NULL | TABLE | IX | GRANTED | NULL |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 5 |
+------------+-----------+---------------+-------------+-----------+
注
SET TRANSACTION 仅适用于接下来的一次事务。在下一次事务之后,后续的事务将使用默认的事务隔离级别。详情请参阅 SET TRANSACTION。
在READ COMMITTED事务中,相同的UPDATE语句仅对匹配的行获取记录锁,如 图 8-3 所示。

图 8-3. 主键上的记录锁,READ COMMITTED事务
为什么不使用READ COMMITTED?这个问题涉及访问模式特征(“事务隔离”)的应用程序特定性,甚至查询特定性。在事务中,READ COMMITTED有两个重要的副作用:
-
如果重新执行相同的读取语句,可能返回不同的行。
-
如果重新执行相同的写入语句,可能会影响不同的行。
这些副作用解释了为什么 InnoDB 不需要对读取使用一致性快照,也不需要对写入锁定间隙:READ COMMITTED允许事务在不同时间读取或写入不同记录(对已提交更改)。(“MVCC 和 Undo 日志”定义了一致性快照。)请仔细考虑这些副作用,以便于您的应用程序。如果您确信它们不会导致事务读取、写入或返回不正确的数据,那么READ COMMITTED会减少锁和撤消日志,从而有助于提高性能。
间隙锁
Gap locks are purely prohibitive: they prevent other transactions from inserting rows into the gap. That’s all they do.
多个事务可以锁定同一个间隙,因为所有间隙锁都与其他间隙锁兼容。但由于间隙锁阻止其他事务将行插入到间隙中,当只有一个事务锁定间隙时,只有一个事务可以将行插入到间隙中。对同一个间隙的两个或更多锁会阻止所有事务将行插入到间隙中。
间隙锁的目的很狭窄:阻止其他事务将行插入到间隙中。但间隙锁的创建范围很广:任何访问间隙的查询。即使读取空内容也可以创建一个阻止插入行的间隙锁:
BEGIN;
SELECT * FROM elem WHERE id = 3 FOR SHARE;
SELECT index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'elem';
+------------+-----------+-----------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+-----------+-------------+-----------+
| NULL | TABLE | IS | GRANTED | NULL |
| PRIMARY | RECORD | S,GAP | GRANTED | 5 |
+------------+-----------+-----------+-------------+-----------+
表面上看,这个SELECT似乎是无害的:在REPEATABLE READ中的SELECT使用一致性快照,并且FOR SHARE只创建共享锁,因此不会阻塞其他读操作。更重要的是,这个SELECT并不匹配任何行:表elem的主键值是 2 和 5,而不是 3。没有行,就没有锁——对吗?错了。通过使用READ REPEATABLE和SELECT…FOR SHARE访问间隙,您会召唤一个孤立的间隙锁:图 8-4。

图 8-4. 孤立间隙锁
我称之为孤立间隙锁,因为它不伴随下一个键锁或插入意图锁;它是独立存在的。所有间隙锁(共享或排他)都会阻止其他事务将行插入到间隙中。这个看似无害的SELECT语句实际上是一个阻止INSERT的恶意操作。间隙越大,阻碍越大,下一节将通过一个次要索引进行说明。
The easy creation of gap locks by any access to the gap is part of the answer to the intriguing question in “记录和下一个键锁”:为什么在表条件排除主键大于 5 的情况下,要锁定包括所有主键值大于 5 的超界伪记录?首先,让我把好奇心提升到最高点。这是原始查询及其数据锁:
BEGIN;
UPDATE elem SET c='' WHERE id BETWEEN 2 AND 5;
+------------+-----------+---------------+-------------+------------------------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+------------------------+
| NULL | TABLE | IX | GRANTED | NULL |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| PRIMARY | RECORD | X | GRANTED | supremum pseudo-record |
| PRIMARY | RECORD | X | GRANTED | 5 |
+------------+-----------+---------------+-------------+------------------------+
现在,这里是相同的查询,但使用IN子句而不是BETWEEN子句:
BEGIN;
UPDATE elem SET c='' WHERE id IN (2, 5);
+------------+-----------+---------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+-----------+
| NULL | TABLE | IX | GRANTED | NULL |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 5 |
+------------+-----------+---------------+-------------+-----------+
两个事务都是REPEATABLE READ,两个查询具有完全相同的 EXPLAIN 计划:主键的范围访问。这是什么魔法?图 8-5 显示了每个查询的情况。

图 8-5. BETWEEN 与 IN 的范围访问,REPEATABLE READ 事务
BETWEEN 的行访问如您所预期的那样:从 2 到 5 和之间的所有内容。简单地说,BETWEEN 的行访问顺序是:
-
读取索引值为 2 的行
-
行匹配:记录锁
-
下一个索引值:5
-
从 2 到 5 的间隙遍历
-
读取索引值为 5 的行
-
行匹配:下一键锁
-
下一个索引值:最大值
-
从 5 到最大值的间隙遍历
-
索引结束:下一键锁
但 IN 的行访问顺序要简单得多:
-
读取索引值为 2 的行
-
行匹配:记录锁
-
读取索引值为 5 的行
-
行匹配:记录锁
尽管具有相同的 EXPLAIN 计划并匹配相同的行,但查询以不同的方式访问行。原始查询(BETWEEN)访问间隙,因此使用下一键锁来锁定间隙。新查询(IN)不访问间隙,因此使用记录锁。但不要误解:IN 子句并不排除间隙锁定。如果新查询表条件为 IN (2, 3, 5),那么访问值 2 和 5 之间的间隙并导致间隙锁定(而不是下一键锁):
BEGIN;
UPDATE elem SET c='' WHERE id IN (2, 3, 5);
+------------+-----------+---------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+-----------+
| NULL | TABLE | IX | GRANTED | NULL |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 5 |
| PRIMARY | RECORD | X,GAP | GRANTED | 5 |
+------------+-----------+---------------+-------------+-----------+
你有一个单独的间隙锁:X,GAP。但请注意:在伪记录最大值上没有下一键锁,因为 IN (2, 3, 5) 不访问该间隙。注意间隙。
通过使用 READ COMMITTED 可轻松禁用间隙锁定。READ COMMITTED 事务不需要间隙锁(或下一键锁),因为间隙中的记录允许更改,并且每个查询在执行时访问最新的更改(已提交的行)。即使 SELECT * FROM elem WHERE id = 3 FOR SHARE 召唤的孤立间隙锁也被 READ COMMITTED 扫除。
二级索引
二级索引引入了关于行锁定的潜在广泛后果,特别是对于非唯一索引。回想一下简化表 elem(示例 8-1)在列 a 上有一个非唯一二级索引。考虑到这一点,让我们看看以下 REPEATABLE READ 事务中的 UPDATE 如何锁定二级索引和主键记录:
BEGIN;
UPDATE elem SET c='' WHERE a BETWEEN 'Ar' AND 'Au';
SELECT index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'elem'
ORDER BY index_name;
+------------+-----------+---------------+-------------+------------------------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+------------------------+
| NULL | TABLE | IX | GRANTED | NULL |
| a | RECORD | X | GRANTED | supremum pseudo-record |
| a | RECORD | X | GRANTED | 'Au', 2 |
| a | RECORD | X | GRANTED | 'Ar', 5 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 5 |
+------------+-----------+---------------+-------------+------------------------+
图 8-6 说明了这六条记录锁定:四条在二级索引上,两条在主键上。

图 8-6. 二级索引上的下一键锁,REPEATABLE READ 事务
UPDATE 只匹配两行,但锁定整个二级索引,这阻止了插入任何值。二级索引上的锁与 图 8-2 中的类似。但现在,在二级索引记录的第一个记录上有一个下一键锁:元组 ('Ar', 5),其中 5 是对应的主键值。这个下一键锁隔离了新重复的 “Ar” 值的范围。例如,它阻止插入排序在 ('Ar', 5) 之前的元组 ('Ar', 1)。
通常,InnoDB 不会锁定整个次要索引。在这些例子中只有两个索引记录(分别在主键和非唯一次要索引中)。但请回忆“极端选择性”:选择性越低,间隙越大。例如,如果一个非唯一索引在 10 万行中有 5 个均匀分布的唯一值,那么每行有 20000 条记录(100000 行/5 基数),或者每个间隙有 20000 条记录。
提示
索引选择性越低,记录间隙越大。
READ COMMITTED避免了间隙锁定,即使是对于非唯一次要索引,因为只有匹配行会使用记录锁定。但让我们不要对自己太宽容;让我们继续检查 InnoDB 在非唯一次要索引上不同数据更改的数据锁定。
在上一节结束时,将BETWEEN子句更改为IN子句可避免间隙锁定,但在非唯一索引中不起作用。事实上,在这种情况下,InnoDB 会添加一个间隙锁:
BEGIN;
UPDATE elem SET c='' WHERE a IN ('Ar', 'Au');
SELECT index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'elem'
ORDER BY index_name;
+------------+-----------+---------------+-------------+------------------------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+------------------------+
| a | RECORD | X,GAP | GRANTED | 'Au', 2 |
...
我从输出中删除了原始数据锁(它们是相同的),以突出元组('Au', 2)上的新间隙锁。严格来说,这个间隙锁与同一元组上的下一个键锁重复,但这并不会导致错误的锁定或数据访问。因此,就让它存在吧,永远不要忘记:InnoDB 充满了奇迹和神秘。生活中少了这些,还有什么意思呢?
检查数据锁是很重要的,因为 InnoDB 充满了惊喜。尽管这一部分详细而细致,但它几乎在表面之下——InnoDB 的锁定是深奥的,深处隐藏着秘密。例如,如果将“Au”更改为“Go”,InnoDB 可能需要什么数据锁呢?让我们来检查这种变化的数据锁定:
BEGIN;
UPDATE elem SET a = 'Go' WHERE a = 'Au';
+------------+-----------+---------------+-------------+------------------------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+------------------------+
| NULL | TABLE | IX | GRANTED | NULL |
| a | RECORD | X | GRANTED | supremum pseudo-record |
| a | RECORD | X | GRANTED | 'Au', 2 |
| a | RECORD | X,GAP | GRANTED | 'Go', 2 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
+------------+-----------+---------------+-------------+------------------------+
图 8-7 可视化了这四个数据锁。

图 8-7. 更新非唯一次要索引值,REPEATABLE READ事务
“Au”值已经消失——更改为“Go”,但 InnoDB 仍然保持元组('Au', 2)的下一个键锁。新的“Go”没有记录锁或下一个键锁,只有在元组之前的间隙锁:('Go', 2)。那么是什么锁定了新的“Go”记录?这是某种REPEATABLE READ的副作用吗?让我们更改事务隔离级别并重新检查数据锁定:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
UPDATE elem SET a = 'Go' WHERE a = 'Au';
+------------+-----------+---------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+---------------+-------------+-----------+
| NULL | TABLE | IX | GRANTED | NULL |
| a | RECORD | X,REC_NOT_GAP | GRANTED | 'Au', 2 |
| PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2 |
+------------+-----------+---------------+-------------+-----------+
切换到READ COMMITTED会按预期禁用间隙锁定,但新的“Go”值的锁定——任何锁定——在哪里呢?“写入始终锁定行”,至少在“行锁定”开始时是这样说的。然而,InnoDB 对此写入未报告任何锁定……
如果我告诉你,InnoDB 如此优化,以至于可以在不锁定的情况下进行锁定呢?让我们使用插入意向锁,深入探索 InnoDB 锁定,并解决这个谜团。
插入意向锁
插入意向锁是一种特殊类型的间隙锁,表示当间隙未被其他事务锁定时,事务将向该间隙插入一行。只有间隙锁会阻止插入意向锁。(记住:间隙锁 包括 next-key 锁,因为后者是记录锁和间隙锁的组合。)插入意向锁与其他插入意向锁兼容(不会阻止)。这对于 INSERT 的性能非常重要,因为它允许多个事务同时向同一间隙插入不同的行。InnoDB 如何处理重复键?在演示插入意向锁的其他方面使答案更加清晰后,我会回答这个问题。
提示
间隙锁 阻止 INSERT。插入意向锁 允许 INSERT。
插入意向锁有三个特殊之处:
-
插入意向锁不会锁定间隙,因为如术语 意向 所示,它们代表的是一个未来的动作:在没有其他事务持有间隙锁时插入一行。
-
只有当插入意向锁与其他事务持有的间隙锁冲突时,才会创建和报告插入意向锁;否则,插入意向锁不会由插入行的事务创建或报告。
-
如果创建了插入意向锁,它会被使用一次,并在授予后立即释放;但是 InnoDB 在事务完成之前会继续报告它。
在某种意义上,插入意向锁不是锁,因为它们不会阻塞访问。它们更像是 InnoDB 用来信号传递的等待条件,用于指示事务何时可以进行 INSERT。授予插入意向锁就是信号。但是如果一个事务不必等待,因为没有冲突的间隙锁,那么它就不会等待,并且你看不到插入意向锁,因为没有创建。
让我们看看插入意向锁的实际操作。首先锁定主键值为 2 和 5 之间的间隙;然后,在第二个事务中,尝试插入主键值为 3 的行:
-- First transaction
BEGIN;
UPDATE elem SET c='' WHERE id BETWEEN 2 AND 5;
-- Second transaction
BEGIN;
INSERT INTO elem VALUES (3, 'As', 'B', 'C');
+------------+-----------+------------------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+------------------------+-------------+-----------+
| PRIMARY | RECORD | X,GAP,INSERT_INTENTION | WAITING | 5 |
...
lock_mode 列中的 X,GAP,INSERT_INTENTION 是一个插入意向锁。当在最大记录值和超越伪记录之间的间隙进行锁定和插入时,它也被列为 X,INSERT_INTENTION(未显示)。
第一个事务在主键值为 5 之前锁定了间隙。该间隙锁阻止了第二个事务向间隙插入行,因此它创建了一个插入意向锁并等待。一旦第一个事务提交(或回滚),间隙解锁,插入意向锁被授予,第二个事务插入行:
-- First transaction
COMMIT;
-- Second transaction
-- INSERT executes
+------------+-----------+------------------------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+------------------------+-------------+-----------+
| NULL | TABLE | IX | GRANTED | NULL |
| PRIMARY | RECORD | X,GAP,INSERT_INTENTION | GRANTED | 5 |
+------------+-----------+------------------------+-------------+-----------+
正如前面所述,InnoDB 继续报告插入意图锁,即使一旦授予,它只被使用一次并立即释放。因此,看起来像是间隙被锁定,但这是一个幻觉——InnoDB 用来引诱我们更深入的策略。你可以通过在主键值为 4 的间隙插入另一行来证明这是一个幻觉;它不会阻塞。为什么 InnoDB 继续报告一个实际上不存在的插入意图锁呢?少数凡人知晓,但这并不重要。超越幻觉,看到它曾经是什么:在过去,事务在插入行到间隙之前被阻塞。
为了完整性和深入了解 InnoDB 锁定的更深层次,特别是与插入意图锁相关的内容,这里是当INSERT在间隙锁上不阻塞时你所看到的情况:
BEGIN;
INSERT INTO elem VALUES (9, 'As', 'B', 'C'); -- Does not block
+------------+-----------+-----------+-------------+-----------+
| index_name | lock_type | lock_mode | lock_status | lock_data |
+------------+-----------+-----------+-------------+-----------+
| NULL | TABLE | IX | GRANTED | NULL |
+------------+-----------+-----------+-------------+-----------+
根本没有记录锁。这就是插入意图锁在表面上的工作方式,但我们来到这里是为了深入研究 InnoDB 锁定,所以让我们通过提出导致我们到此的问题来更深入:为什么新插入行上没有记录(或下一个键)锁?这是前一节中同样的谜团:对新“Go”值没有锁。
这是秘密所在:InnoDB 拥有显式和隐式锁,并且只报告显式锁。[²] 显式锁存在于内存中的锁结构中;因此,InnoDB 可以报告它们。但隐式锁并不存在:没有锁结构;因此,InnoDB 没有什么可以报告的。
在前面的例子中,INSERT INTO elem VALUES (9, 'As', 'B', 'C'),新行的索引记录存在,但行未提交(因为事务尚未提交)。如果另一个事务尝试锁定该行,则会检测到三个条件:
-
行未提交。
-
该行属于另一个事务。
-
行并未显式锁定。
然后奇迹发生了:请求锁的事务——试图锁定记录的事务——代表创建记录的事务将隐式锁转换为显式锁。是的,这意味着一个事务为另一个事务创建了锁——但这并不让人困惑。由于请求锁的事务创建了它试图获取的锁,乍一看 InnoDB 似乎报告该事务正在等待它持有的锁——该事务被自己阻塞。有一种方法可以看穿这种幻觉,但我们已经深入得太多了。
我希望作为一个使用 MySQL 的工程师,你永远不需要深入到 InnoDB 锁定的这个深度,以实现 MySQL 的出色性能。但我带领我们来到这里有两个原因。首先,尽管有幻觉,但关于事务隔离级别的 InnoDB 行锁定的基础是可解的和适用的。现在你已经准备好处理每一个常见的 InnoDB 行锁定问题,甚至更多。其次,InnoDB 让我这样做是因为我盯着它看了太久;当一切模糊成一团时,我知道自己已经从悬崖上跌落,再也无法回头。不要问为什么它锁定了表条件范围之外的最高伪记录。不要问为什么它有冗余的间隙锁。不要问为什么它转换隐式锁。不要问;否则问题永远不会停止。继续前行;拯救自己。
MVCC 和撤销日志
InnoDB 使用多版本并发控制(MVCC)和撤销日志来实现 ACID 的 A、C 和 I 特性。(为了实现 D,InnoDB 使用事务日志—参见“事务日志”。)多版本并发控制 意味着对行的更改会创建行的新版本。MVCC 不是 InnoDB 独有的;许多数据存储使用的是一种常见方法。当行首次创建时,它是版本 1。当它首次更新时,它是版本 2。MVCC 的基础是简单的,但很快变得更加复杂和有趣。
注意
使用术语 撤销日志 是有意简化的,因为撤销日志的完整结构是复杂的。术语 撤销日志 足够精确,可以了解它的作用以及它如何影响性能。
撤销日志 记录如何回滚到先前行版本的更改。图 8-8 显示了一个具有五个版本和五个撤销日志的单行,这些撤销日志允许 MySQL 回滚到先前的行版本的更改。
那行回溯到“InnoDB 表是索引” 在第二章中:它是表 elem 中主键值为 2 的行,显示为主键叶节点。为简洁起见,我仅包括主键值(2)、行版本(v1 到 v5)和列 a 的值(v5 为“Au”);其他两列,b 和 c,未显示。
版本 5(在 Figure 8-8 的右下角)是所有新事务将读取的当前行,但让我们从头开始。该行被创建为铁(“Fe”):版本 1 在左上角。版本 1 有一个撤消日志,因为 INSERT 创建了行的第一个版本。然后修改了列 a(UPDATE)将铁更改为钛(“Ti”):版本 2。创建版本 2 时,MySQL 还创建了一个撤消日志,记录如何撤销版本 2 的更改,从而恢复版本 1。(在下一段中,我会解释为什么版本 1 有坚实的轮廓 [和相机图标],但版本 2 有虚线轮廓。)然后修改了列 a 将钛更改为银(“Ag”):版本 3。MySQL 创建了一个撤消日志,记录如何撤销版本 3 的更改,并且这个撤消日志链接到之前的撤消日志,这样 MySQL 可以在需要时回滚和恢复版本 2。发生了另外两次行更新:银到锎(“Cf”)对应版本 4,锎到金(“Au”)对应版本 5。

Figure 8-8. 一行有五个版本和五个撤消日志
Note
有两组撤消日志:插入撤消日志 用于 INSERT 和 更新撤消日志 用于 UPDATE 和 DELETE。为简单起见,我只提到撤消日志,它包括这两组。
Version 1 因为一个活跃的事务(未显示)在数据库历史的这一点上保持了一致的快照,所以具有坚实的轮廓和相机图标。让我解释一下这句话。InnoDB 支持四种事务隔离级别,但通常仅使用两种:REPEATABLE READ(默认)和 READ COMMITTED。
在 REPEATABLE READ 事务中,第一次读取建立了一个一致性快照(或简称快照):数据库(所有表)在执行 SELECT 时的虚拟视图。快照保持到事务结束,并且所有后续读取都使用这个快照来访问数据库历史的这一点上的行。在这一点之后其他事务所做的更改在原始事务内部是不可见的。假设其他事务正在修改数据库,则原始事务的快照变成越来越旧的数据库视图,而事务仍然活动(未 COMMIT 或 ROLLBACK)。就像原始事务卡在 1980 年代一样,它唯一听的音乐家是 Pat Benatar、Stevie Nicks 和 Taylor Dayne:老但仍然很棒。
由于版本 5 是当前行,新事务会从其在数据库历史中的点建立快照,这就是它具有坚实轮廓和相机图标的原因。重要的问题是:为什么版本 2、3 和 4 仍然存在,当它们在数据库历史中的各自点上没有事务持有快照?它们存在是为了维护版本 1 的快照,因为 MySQL 使用撤消日志来重建旧的行版本。
Tip
MySQL 使用撤销日志来重建快照的旧行版本。
可以轻松重建 图 8-8。首先,在 图 8-8 中插入行后,立即启动事务并通过执行 SELECT 语句在行版本 1 上建立快照:
BEGIN;
SELECT a FROM elem WHERE id = 2;
-- Returns row version 1: 'Fe'
由于没有 COMMIT,因此该事务仍处于活动状态,并保持其对整个数据库的快照,即在本示例中简单地是行版本 1。我们称之为 原始事务。
然后更新行四次以创建版本 5:
-- autocommit enabled
UPDATE elem SET a = 'Ti' WHERE id = 2;
UPDATE elem SET a = 'Ag' WHERE id = 2;
UPDATE elem SET a = 'Cf' WHERE id = 2;
UPDATE elem SET a = 'Au' WHERE id = 2;
在 MySQL 中,默认情况下启用了 autocommit,这就是为什么第一个(活动的)事务需要显式的 BEGIN,但四个 UPDATE 语句不需要。现在 MySQL 处于 图 8-8 表示的状态。
如果原始事务再次执行 SELECT a FROM elem WHERE id = 2,它将读取版本 5(这不是打字错误),但(比喻地)看到该版本比其快照建立的数据库历史点更新。因此,MySQL 使用撤销日志回滚该行并重建版本 1,这与第一个 SELECT 语句建立的快照一致。当原始事务提交,并且假设没有其他活动事务保持旧的快照时,MySQL 可以清除所有相关的撤销日志,因为新的事务始终从当前行版本开始。当事务正常工作时,整个过程对性能没有影响。但你已经知道:问题事务可能会对整个过程的性能产生负面影响。“常见问题” 将详细探讨原因和如何解决;但在此之前,还有更多关于 MVCC 和撤销日志的细节需要了解。
在 READ COMMITTED 事务中,每次读取都会建立一个新的快照。因此,每次读取都会访问最新提交的行版本,因此称为 READ COMMITTED。由于使用了快照,因此仍会创建撤销日志,但在 READ COMMITTED 中几乎从不成为问题,因为每个快照仅在读取期间保持。如果读取时间很长 且 数据库上存在重要的写入吞吐量,可能会注意到重做日志的累积(作为历史列表长度的增加)。否则,READ COMMITTED 几乎不涉及撤销日志。
快照只影响读取(SELECT)操作,从不影响写入。写入操作始终会秘密地读取当前行,即使事务通过SELECT无法“看见”它们。这种双重视角避免了混乱。例如,假设另一个事务插入了主键值为 11 的新行。如果原始事务试图插入相同主键值的行,MySQL 将返回重复键值的错误,因为主键值已经存在,即使通过SELECT事务看不到它。此外,快照非常一致:在事务中,无法将快照前进到数据库历史的新点。如果执行事务的应用程序需要更新的快照,则必须提交事务并启动新事务以建立新快照。
写入生成的撤消日志会保留到事务结束——无论事务隔离级别如何。到目前为止,我已经重点介绍了撤消日志,关于为快照重建旧行版本,但它们也在ROLLBACK时用于恢复写入造成的更改。
关于 MVCC 的最后一点需要知道的是:撤消日志保存在 InnoDB 缓冲池中。你可能还记得“页面刷新”中提到,“杂项页包含本书未涵盖的各种内部数据。”杂项页包括撤消日志(以及许多其他内部数据结构)。由于撤消日志驻留在缓冲池页面中,它们使用内存,并定期刷新到磁盘中。
与撤消日志相关的系统变量和度量有几个;作为使用 MySQL 的工程师,你只需要了解和监控其中一个:HLL,首次引入于“历史列表长度(度量)”,并在下一节进一步解释。除此之外,只要应用程序避免所有“常见问题”,MVCC 和撤消日志的工作都很完美。其中一个问题是未完成的事务,因此让我们通过提交原始事务来避免这种情况:
COMMIT;
再见,一致性快照。再见,撤消日志。你好,历史列表长度……
历史列表长度
历史列表长度(HLL)衡量未清除或未刷新的旧行版本数量。
历史上(没有捉弄),HLL 很难定义,因为撤消日志的完整结构很复杂:
Rollback segments
└── Undo slots
└── Undo log segments
└── Undo logs
└── Undo log records
这种复杂性掩盖了撤消日志和 HLL 之间的任何简单关系,包括度量单位。HLL 最简单的功能单位(尽管不是技术上正确的)是更改。如果 HLL 值为 10,000,那么可以理解为 10,000 次更改。通过理解“MVCC 和撤消日志”,你会知道更改保留在内存中(而不是刷新)以重建旧行版本。因此,可以准确地说 HLL 衡量了未清除或未刷新的旧行版本数量。
HLL 大于 100,000 是一个问题——不要忽视它。即使对于 MySQL 专家来说,HLL 的真正技术性质也是难以捉摸的,但其实用性显而易见:HLL 是事务相关问题的先驱。始终监控 HLL(参见 “历史列表长度(指标)”),当其过高(大于 100,000)时发出警报,并修复这个问题,这无疑是下一节讨论的常见问题之一。
尽管我在 “野鹅追逐(阈值)” 中警告不要在阈值上进行警报,但 HLL 是一个例外:当 HLL 大于 100,000 时进行警报是可靠且可操作的。
提示
对于 HLL 大于 100,000 进行警报。
理论上,HLL 有一个最大值,但在这个值之前 MySQL 的性能肯定会崩溃。^(3) 例如,就在我写作这段文字的几周前,一个在云中的 MySQL 实例在达到 HLL 200,000 后崩溃,导致一个长时间运行的事务积累四个小时才崩溃 MySQL,并导致两小时的故障。
由于撤销日志非常高效,因此在 HLL 的值使得 MySQL 的性能降低或者最坏情况下会崩溃之前,HLL 有很大的余地。我见过 MySQL 在 200,000 时崩溃,但也见过它在远超过 200,000 时表现良好。有一点是肯定的:如果 HLL 持续增加,将 会造成问题:要么是明显的性能下降,要么是 MySQL 崩溃。
我希望你成为历史上第一个使用 MySQL 却从未遇到 HLL 问题的工程师。这是一个远大的目标,但我鼓励你去追求。为此,我故意向 MySQL 实例灌输大量的 UPDATE 语句以增加 HLL——积累数千个旧行版本。表 8-2 显示了在活动的 REPEATABLE READ 事务中针对单个行点选择 SELECT * FROM elem WHERE id=5 的 HLL 对查询响应时间的影响。
表 8-2. HLL 对查询响应时间的影响
| HLL | 响应时间(ms) | 基线增加(%) |
|---|---|---|
| 0 | 0.200 ms | |
| 495 | 0.612 ms | 206% |
| 1,089 | 1.012 ms | 406% |
| 2,079 | 1.841 ms | 821% |
| 5,056 | 3.673 ms | 1,737% |
| 11,546 | 8.527 ms | 4,164% |
此示例并不意味着 HLL 会像显示的那样增加查询响应时间;它只证明了 HLL 可以增加查询响应时间。从 “MVCC 和撤销日志” 及本节,你知道原因:活动的 REPEATABLE READ 事务中的 SELECT 对行 5(id=5)有一致的快照,但对该行的 UPDATE 语句生成新的行版本。每次执行 SELECT,它都会通过撤销日志来重构原始行版本的一致快照,这种工作增加了查询响应时间。
增加查询响应时间已经足够证明,但作为专业人士,让我们以无可辩驳的方式证明它。在“MVCC 和 Undo 日志”的结尾,我提到 Undo 日志存储为 InnoDB 缓冲池中的页。因此,SELECT应该访问大量页面。为了证明这一点,我使用Percona Server,因为它增强了慢查询日志,在配置log_slow_verbosity = innodb时打印访问的不同页面数量:
# Query_time: 0.008527
# InnoDB_pages_distinct: 366
通常,这个例子中的SELECT只访问一个页面来查找一个主键行。但是当SELECT的一致性快照过时(并且 HLL 很大)时,InnoDB 会通过数百个撤消日志页面来重建旧行。
MVCC、撤消日志和 HLL 都是正常且良好的权衡:一点性能换取大量并发性。只有当 HLL 异常大——超过 100,000 个时,您应该采取措施来修复原因,这几乎普遍是以下常见问题之一。
常见问题
事务问题源于构成事务的查询,应用程序执行这些查询的速度以及应用程序提交事务的速度。虽然启用autocommit的单个查询在技术上是可能导致以下问题的事务(除了“废弃事务”),但主要关注的是以BEGIN(或START TRANSACTION)开头的多语句事务,执行几个查询,然后以COMMIT(或ROLLBACK)结束。多语句事务的性能影响可能大于构成事务的部分——因为锁和撤消日志在事务提交(或回滚)之前都被保持。记住:MySQL 非常有耐心——几乎太有耐心了。如果应用程序不提交事务,MySQL 甚至会等待直到这个活动事务的后果。
幸运的是,这些问题都不难检测或修复。HLL 是大多数事务问题的前兆,这就是为什么你应该始终监控它:参见“历史列表长度(度量)”和“历史列表长度”。为了保持每个问题的细节清晰,我解释了如何查找和报告有问题的事务在“报告”。
大事务(事务大小)
一个大型事务修改了大量行数。什么是大量?这是相对的,但工程师总是能看得出来。例如,如果你看到一个事务修改了 25 万行,而且你知道整个数据库只有 50 万行,那就是大量(或者至少是一个可疑的访问模式:参见“结果集”)。
注意
通常,事务大小 指修改的行数:修改的行数越多,事务就越大。对于MySQL Group Replication,事务大小 有略微不同的含义:请参阅 MySQL 手册中的“Group Replication Limitations”。
如果事务在默认隔离级别REPEATABLE READ中运行,则可以安全地假设由于间隙锁定,它已锁定了比修改的行数更多的记录,如“Row Locking”所述。如果事务在READ COMMITTED隔离级别中运行,则只为每个修改的行获取记录锁定。无论哪种方式,大事务都是锁争用的主要来源,可以严重降低写入吞吐量和响应时间。
不要忘记复制(参见第七章):大事务是复制滞后的主要原因之一(参见“Transaction Throughput”),并且降低了多线程复制的效率(参见“Reducing Lag: Multithreaded Replication”)。
大事务在提交(或回滚)时可能明显变慢,正如之前在“MVCC and the Undo Logs”,“Binary Log Events”,以及 Figure 6-7 中所提到的。修改行数据很快很容易,因为数据变更发生在内存中,但提交时 MySQL 会进行大量工作以保持和复制数据变更。
更小的事务更好。多小?这也是相对的,因为正如我刚才提到的,事务在提交时会有一个大量的工作,这意味着你必须校准几个子系统。(当你考虑到云时,事情会更复杂,因为云倾向于限制和微调诸如 IOPS 之类的细节。)除了需要校准批量操作的批处理大小(参见“Batch Size”)之外,通常不需要校准事务大小,因为尽管这个问题很常见,但通常是一次性的问题:找到、修复并且暂时不会再次出现。“Reporting”向你展示如何找到大事务。
解决方法是找出在事务中修改了过多行的查询(或查询),并将其修改为修改更少行数的方式。但这完全取决于查询本身,在应用程序中的目的以及为何修改了太多行。不管原因如何,第一章至第四章会让你理解和修复这个查询问题。
最后,如果你严格遵循最少数据原则(参见“Principle of Least Data”),事务大小可能永远不会成为问题。
长时间运行的事务
长时间运行的事务需要太长时间才能完成(提交或回滚)。多长时间算是太长?这取决于:
-
对于应用程序或用户来说,时间过长是不可接受的。
-
事务长度过长(可能引起争用问题)可能会影响其他事务。
-
足以引起历史列表长度警报
除非您主动解决性能问题,否则第二点和第三点更有可能引起您对长时间运行事务的关注。
假设应用程序在查询之间不等待(这是下一个问题:“挂起事务”),长时间运行的事务有两个原因:
-
构成事务的查询速度太慢。
-
应用程序在事务中执行的查询太多。
你可以通过 第一章 到 第五章 中的技术来修复第一个原因。请记住:事务中所有查询的撤消日志和行锁都会一直保留,直到事务提交。好处是,通过优化慢查询来修复长时间运行的事务具有副作用好处:单个查询更快且整个事务更快,这可以增加整体事务吞吐量。缺点是,长时间运行的事务可能对应用程序来说速度足够快,但对其他事务来说太长。例如,假设一个事务执行需要一秒钟,这对应用程序来说还可以接受,但在这一秒钟内,它占用了另一个更快事务所需的行锁。这会导致一个棘手的问题,因为快速事务可能在生产环境中运行缓慢,但在实验室(例如您的笔记本电脑上)分析时却运行得很快。当然,不同之处在于生产中的事务并发和争用在实验室中大部分或完全不存在。在这种情况下,您必须调试数据锁竞争问题,这并不容易,其中最小的问题之一是数据锁是瞬息的。请参阅表 8-1 后面的注释,并与您的 DBA 或 MySQL 专家讨论。
你可以通过修改应用程序在事务中执行的查询来修复第二个原因。当应用程序尝试执行批量操作或在事务内部程序化地生成查询而没有限制查询数量时会发生这种情况。无论哪种方式,解决方法都是减少或限制事务中的查询数量。即使事务不是长时间运行的,这也是一种最佳实践,可以确保它不会意外地变得长时间运行。例如,也许当应用程序是新的时候,每个事务只插入 5 行数据;但多年后,当应用程序有数百万用户时,每个事务插入 500 行数据,因为从一开始就没有设定限制。
“报告” 展示了如何找到长时间运行的事务。
挂起事务
挂起事务在BEGIN之后、查询之间或COMMIT之前等待时间太长。挂起事务很可能是长时间运行的事务,但原因不同:等待查询之间的时间(挂起),而不是等待查询完成的时间(长时间运行)。
注意
在实践中,一个停滞的事务看起来像一个长时间运行的事务,因为最终结果是一样的:事务响应时间缓慢。需要分析事务以确定响应时间是由于停滞还是慢查询。在没有进行分析的情况下,工程师(和 MySQL 专家)通常将任何缓慢的事务称为长时间运行的事务。
当然,查询之间总会有一些等待时间(至少由于发送查询和接收结果集所需的网络延迟),但如同前两个问题一样,当您看到停滞的事务时,您将知道这是一个停滞的事务。用比喻说:整体远大于其各部分之和。从技术角度来说:从BEGIN到COMMIT的事务响应时间远大于查询响应时间之和。
由于停滞的事务在查询之间等待(包括BEGIN之后和COMMIT之前),MySQL 不应负责:等待是由应用程序引起的,原因多种多样。一个常见的原因是在事务活动期间执行耗时的应用程序逻辑,而不是在事务之前或之后执行。但有时这是无法避免的;考虑以下示例:
BEGIN;
SELECT <row>
--
-- Time-consuming application logic based on the row
--
UPDATE <row>
COMMIT;
在这种情况下的解决方案取决于应用程序逻辑。我首先会问最基本的问题:这些查询是否需要成为一个事务?在读取后和更新前行是否会改变?如果行发生变化,是否会破坏逻辑?如果没有其他办法,是否可以使用READ COMMITTED隔离级别来禁用间隙锁定?工程师很聪明,能找到解决这类问题的方法;首先找到它们是第一步,这在“Reporting”中有所涵盖。
被废弃的事务
被废弃的事务是没有活动客户端连接的活动事务。被废弃的事务有两个主要原因:
-
应用程序连接泄漏
-
半关闭的连接
应用程序中的一个 bug 可能会泄漏数据库连接(就像泄漏内存或线程一样):代码级别的连接对象超出作用域,因此不再使用,但其他代码仍在引用它,因此既未关闭也未释放(可能导致小内存泄漏)。除了应用程序级别的性能分析、调试或泄漏检测来直接验证这个 bug 外,如果重新启动应用程序可以修复(关闭)被废弃的事务,也可以间接验证它。在 MySQL 中,您可以查看可能被废弃的事务(如“Reporting”所示),但您无法在 MySQL 中验证这个 bug,因为 MySQL 不知道连接已被废弃。
在正常情况下,半关闭的连接不会发生,因为 MySQL 在客户端连接因 MySQL 或操作系统可检测的任何原因关闭时会回滚事务。但在 MySQL 和操作系统之外的问题可能会导致客户端连接关闭而未关闭 MySQL 端,这就是所谓的半关闭连接。MySQL 特别容易发生半关闭连接,因为它的网络协议几乎完全是命令和响应的形式:客户端发送命令,MySQL 发送响应。(如果你好奇的话,客户端通过COM_QUERY数据包向 MySQL 发送查询。)在命令和响应之间,客户端和 MySQL 保持完全静默——没有任何字节被传输。尽管听起来很安静,但这意味着半关闭连接直到等待wait_timeout秒过去才被注意到,默认值为 28,800 秒(8 小时)。
无论是应用程序缺陷导致的连接泄漏,还是被误认为是冥想网络静默的半关闭连接,结果都是一样的,如果这两者发生在事务处于活跃状态(未提交状态)时:事务保持活跃。任何一致的快照或数据锁也会保持活跃,因为 MySQL 不知道事务已被放弃。
说实话,MySQL 喜欢这种宁静;我也一样。但我们是为了工作而被付费的,因此让我们看看如何找到并报告所有四种事务问题。
报告
MySQL 性能模式使得详细的事务报告成为可能;但截至本文撰写时,尚无能简化此过程的工具。我希望能告诉你使用现有的开源工具,但实际上并没有。以下 SQL 语句代表了当前的最新技术水平。一旦有新技术出现,我会在MySQL 事务报告通知你。在那之前,我们还是老老实实地复制粘贴吧。
活跃事务:最新
示例 8-2 中的 SQL 语句报告了所有运行时间超过 1 秒的活跃事务的最新查询。该报告回答了以下问题:哪些事务运行时间较长,它们当前在做什么?
示例 8-2. 报告运行时间超过 1 秒的活跃事务的最新查询
SELECT
ROUND(trx.timer_wait/1000000000000,3) AS trx_runtime,
trx.thread_id AS thread_id,
trx.event_id AS trx_event_id,
trx.isolation_level,
trx.autocommit,
stm.current_schema AS db,
stm.sql_text AS query,
stm.rows_examined AS rows_examined,
stm.rows_affected AS rows_affected,
stm.rows_sent AS rows_sent,
IF(stm.end_event_id IS NULL, 'running', 'done') AS exec_state,
ROUND(stm.timer_wait/1000000000000,3) AS exec_time
FROM
performance_schema.events_transactions_current trx
JOIN performance_schema.events_statements_current stm USING (thread_id)
WHERE
trx.state = 'ACTIVE'
AND trx.timer_wait > 1000000000000 * 1\G
要增加时间,请更改\G之前的数字 1。性能模式计时器使用皮秒,所以1000000000000 * 1就是一秒。
示例 8-2 的输出如下所示:
*************************** 1\. row ***************************
trx_runtime: 20729.094
thread_id: 60
trx_event_id: 1137
isolation_level: REPEATABLE READ
autocommit: NO
db: test
query: SELECT * FROM elem
rows_examined: 10
rows_affected: 0
rows_sent: 10
exec_state: done
exec_time: 0.038
以下是关于示例 8-2 字段(列)的更多信息:
trx_runtime
事务已运行的时间(活跃时间)以毫秒精度计算。例如,这个事务我忘记了,所以在示例中已经运行了近六个小时。
thread_id
执行事务的客户连接的线程 ID。这在“活动事务:历史”中使用。性能模式事件使用线程 ID 和事件 ID 来链接数据到客户连接和事件,线程 ID 不同于 MySQL 其他部分常见的进程 ID。
trx_event_id
事务事件 ID。这在“活动事务:历史”中使用。
isolation_level
事务隔离级别:READ REPEATABLE或READ COMMITTED。(另外两个隔离级别,SERIALIZABLE和READ UNCOMMITTED,很少使用;如果您看到它们,可能是应用程序错误。)回想一下“行锁定”:事务隔离级别影响行锁定以及SELECT是否使用一致性快照。
autocommit
如果YES,则autocommit已启用且是单语句事务。如果NO,则事务是通过BEGIN(或START TRANSACTION)启动的,很可能是多语句事务。
db
query的当前数据库。当前数据库意味着USE db。查询可以使用数据库限定的表名访问其他数据库,例如db.table。
query
事务中执行或正在执行的最新查询。如果exec_state = running,则query当前正在事务中执行。如果exec_state = done,则query是事务执行的最后一个查询。在这两种情况下,事务是活动的(未提交),但在后一种情况下,它在执行查询方面是空闲的。
rows_examined
query查询的总行数。这不包括事务中执行的过去查询。
rows_examined
query修改的总行数。这不包括事务中执行的过去查询。
rows_sent
query发送的总行数(结果集)。这不包括事务中执行的过去查询。
exec_state
如果done,则事务在执行查询方面处于空闲状态,并且query是它执行的最后一个查询。如果running,则事务当前正在执行query。在这两种情况下,事务是活动的(未提交)。
exec_time
query以秒为单位的执行时间(毫秒精度)。
性能模式表events_transactions_current和events_statements_current包含更多字段,但此报告仅选择关键字段。
此报告是一个真正的工作马,因为它可以揭示所有四个“常见问题”:
大事务
查看rows_affected(修改的行数)和rows_sent以查看事务大小(按行计算)。尝试添加条件,如trx.rows_affected > 1000。
长时间运行的事务
调整条件trx.timer_wait > 1000000000000 * 1的末尾1以过滤长时间运行的查询。
挂起的事务
如果exec_state = done并且保持这种状态一段时间,则交易停滞。由于此报告仅列出活跃交易的最新查询,查询应快速更改—exec_state = done应是短暂的。
被丢弃的交易
如果exec_state = done长时间保持不变,则可能是交易被丢弃,因为提交后不再报告。
此报告的输出应该是不稳定的,因为活跃交易应该是短暂的。如果它报告了一个长时间足以让你多次看到它的交易,则该交易可能展示了“常见问题”之一。在这种情况下,使用其thread_id和statement_event_id(如“活跃交易:历史”中所述)来报告其历史—过去的查询—这有助于揭示交易存在问题的原因。
活跃交易:总结
示例 8-3 中的 SQL 语句报告了所有活跃时间超过 1 秒的交易执行的查询摘要。此报告回答了一个问题:哪些交易运行时间长且做了多少工作?
示例 8-3. 报告交易总结
SELECT
trx.thread_id AS thread_id,
MAX(trx.event_id) AS trx_event_id,
MAX(ROUND(trx.timer_wait/1000000000000,3)) AS trx_runtime,
SUM(ROUND(stm.timer_wait/1000000000000,3)) AS exec_time,
SUM(stm.rows_examined) AS rows_examined,
SUM(stm.rows_affected) AS rows_affected,
SUM(stm.rows_sent) AS rows_sent
FROM
performance_schema.events_transactions_current trx
JOIN performance_schema.events_statements_history stm
ON stm.thread_id = trx.thread_id AND stm.nesting_event_id = trx.event_id
WHERE
stm.event_name LIKE 'statement/sql/%'
AND trx.state = 'ACTIVE'
AND trx.timer_wait > 1000000000000 * 1
GROUP BY trx.thread_id\G
要增加时间,请更改\G之前的 1。字段与“活跃交易:最新”中的相同,但此报告对每个交易聚合过去的查询。一个停滞的交易(当前未执行查询)可能在过去做了大量工作,这在此报告中会显示出来。
注意
查询完成后,它会被记录在表performance_schema.events_statements_history中,但也会保留在表performance_schema.events_statements_current中。因此,报告仅包括已完成的查询,并且不应与后者表连接,除非筛选掉活跃查询。
此报告更适合查找大型交易—“大型交易(交易大小)”—因为它包括过去的查询。
活跃交易:历史
示例 8-4 中的 SQL 语句报告了单个交易执行的查询历史。此报告回答了一个问题:每个查询交易做了多少工作?你必须用来自示例 8-2 输出的thread_id和trx_event_id值替换零。
示例 8-4. 报告交易历史
SELECT
stm.rows_examined AS rows_examined,
stm.rows_affected AS rows_affected,
stm.rows_sent AS rows_sent,
ROUND(stm.timer_wait/1000000000000,3) AS exec_time,
stm.sql_text AS query
FROM
performance_schema.events_statements_history stm
WHERE
stm.thread_id = 0
AND stm.nesting_event_id = 0
ORDER BY stm.event_id;
将零替换为来自示例 8-2 输出的值:
-
将
stm.thread_id = 0中的零替换为thread_id。 -
将
stm.nesting_event_id = 0中的零替换为trx_event_id。
示例 8-4 的输出看起来像:
+---------------+---------------+-----------+-----------+---------------------+
| rows_examined | rows_affected | rows_sent | exec_time | query |
+---------------+---------------+-----------+-----------+---------------------+
| 10 | 0 | 10 | 0.000 | SELECT * FROM elem |
| 2 | 1 | 0 | 0.003 | UPDATE elem SET ... |
| 0 | 0 | 0 | 0.002 | COMMIT |
+---------------+---------------+-----------+-----------+---------------------+
除了开始事务的BEGIN之外,这个事务执行了两个查询,然后COMMIT。第一个查询是SELECT,第二个查询是UPDATE。这不是一个引人入胜的例子,但它展示了事务的查询执行历史,以及基本的查询指标。在调试有问题的事务时,历史记录是非常宝贵的,因为你可以看到哪些查询速度慢(exec_time)或者大(行数),以及应用程序停滞的时刻(当你知道事务将执行更多查询时)。
已提交的事务:总结
前三个报告是关于活动事务的,但已提交的事务也很有启发性。示例 8-5 中的 SQL 语句报告了已提交(完成)事务的基本指标。这就像是事务的慢查询日志。
示例 8-5. 报告已提交事务的基本指标
SELECT
ROUND(MAX(trx.timer_wait)/1000000000,3) AS trx_time,
ROUND(SUM(stm.timer_end-stm.timer_start)/1000000000,3) AS query_time,
ROUND((MAX(trx.timer_wait)-SUM(stm.timer_end-stm.timer_start))/1000000000, 3)
AS idle_time,
COUNT(stm.event_id)-1 AS query_count,
SUM(stm.rows_examined) AS rows_examined,
SUM(stm.rows_affected) AS rows_affected,
SUM(stm.rows_sent) AS rows_sent
FROM
performance_schema.events_transactions_history trx
JOIN performance_schema.events_statements_history stm
ON stm.nesting_event_id = trx.event_id
WHERE
trx.state = 'COMMITTED'
AND trx.nesting_event_id IS NOT NULL
GROUP BY
trx.thread_id, trx.event_id;
示例 8-5 的字段包括:
trx_time
总事务时间,以毫秒为单位,微秒精度。
query_time
总查询执行时间,以毫秒为单位,微秒精度。
idle_time
事务时间减去查询时间,以毫秒为单位,微秒精度。空闲时间指示应用程序在执行事务中执行查询时的停滞时间。
query_count
事务中执行的查询数量。
rows_*
所有执行的事务中检查、影响和发送的行的总数(分别)。
示例 8-5 的输出如下所示:
+----------+----------+-----------+---------+-----------+-----------+-----------+
| trx_time | qry_time | idle_time | qry_cnt | rows_exam | rows_affe | rows_sent |
+----------+----------+-----------+---------+-----------+-----------+-----------+
| 5647.892 | 1.922 | 5645.970 | 2 | 10 | 0 | 10 |
| 0.585 | 0.403 | 0.182 | 2 | 10 | 0 | 10 |
+----------+----------+-----------+---------+-----------+-----------+-----------+
对于这个例子,我执行了相同的事务两次:首先是手动执行,然后是复制粘贴。手动执行花费了 5.6 秒(5647.892 毫秒),主要是由于打字导致的空闲时间。但是通过程序执行的事务应该主要是查询执行时间,正如第二行所示:403 微秒的执行时间,只有 182 微秒的空闲时间。
总结
本章讨论了关于 MySQL 事务的常见问题避免。主要的要点是:
-
事务隔离级别影响行锁定(数据锁)。
-
InnoDB 的基本数据锁包括:记录锁(锁定单个索引记录)、next-key 锁(锁定单个索引记录及其前面的记录间隙)、间隙锁(锁定两个记录之间的范围)、以及插入意图锁(允许在间隙中进行
INSERT;更像是等待条件而非锁)。 -
默认的事务隔离级别,
REPEATABLE READ,使用间隙锁定以隔离访问的行范围。 -
READ COMMITTED事务隔离级别禁用了间隙锁定。 -
InnoDB 在
REPEATABLE READ事务中使用一致性快照,使得读取(SELECT)返回相同的行,尽管其他事务对这些行进行了更改。 -
一致性快照要求 InnoDB 将行更改保存在撤消日志中,以重建旧的行版本。
-
历史列表长度(HLL)衡量未清除或刷新的旧行版本数量。
-
HLL 是灾难的先兆:始终监控并警报超过 100,000 的 HLL。
-
当一个事务结束时(使用
COMMIT或ROLLBACK),数据锁和撤销日志会被释放。 -
四个常见问题困扰着事务:大事务(修改了太多行)、长时间运行的事务(从
BEGIN到COMMIT的响应时间慢)、停滞的事务(查询之间的不必要等待)和被放弃的事务(客户端连接在活动事务期间消失)。 -
MySQL 性能模式使得详细的事务报告成为可能。
-
事务性能与查询性能一样重要。
下一章列举了常见的 MySQL 挑战及其缓解方法。
实践:警报历史列表长度
这个实践的目标是警报历史列表长度(HLL)大于 100,000。(参见“History List Length”)。这取决于你的系统用于监控(收集度量)和警报,但从根本上说,它与警报其他度量标准没有什么不同。因此,需要的工作是双重的:
-
收集并报告 HLL 的值。
-
创建一个超过 100,000 的 HLL 警报。
所有的 MySQL 监控应该能够收集和报告 HLL。如果你目前的监控不能做到这一点,认真考虑更好的监控,因为 HLL 是一个基本的度量标准。阅读你的监控文档,了解如何使其收集和报告 HLL。HLL 可以快速变化,但在 MySQL 由于高 HLL 而面临风险之前有一定的余地。因此,你可以慢慢地报告 HLL:每分钟一次。
一旦你的监控系统收集并报告 HLL,设定一个在 20 分钟内 HLL 大于 100,000 的警报。但请注意“Wild Goose Chase(阈值)”:你可能需要调整 20 分钟的阈值,但注意 HLL 大于 100,000 超过 20 分钟是非常异常的。
如果需要手动查询 HLL 的值:
SELECT name, count
FROM information_schema.innodb_metrics
WHERE name = 'trx_rseg_history_len';
历史上,HLL 是从SHOW ENGINE INNODB STATUS的输出中解析出来的:在 MySQL 的“TRANSACTIONS”部分标题下寻找“History list length”。
我希望你永远不会因为 HLL 而警觉,但是设置警报是最佳实践,它已经拯救了许多应用避免了停机。HLL 警报就像是一个朋友。
实践:检查行锁
这个实践的目标是检查来自你的应用程序的真实查询的行锁,并尽可能理解为什么查询获取每个锁。如果可能是一个必要的免责声明,因为 InnoDB 行锁可能令人费解。
使用 MySQL 的开发或暂存实例;不要使用生产环境。此外,使用 MySQL 8.0.16 或更新版本,因为它在性能模式表data_locks中提供了最佳的数据锁定报告,如在“行锁定”中所示。如果只能使用 MySQL 5.7,则需要使用SHOW ENGINE INNODB STATUS来检查数据锁定:请参阅MySQL 数据锁,以便从 MySQL 5.7 的数据锁输出映射到 MySQL 8.0 的详细指南。
使用真实的表定义和尽可能多的真实数据(行)。如果可能的话,从生产环境中导出数据并加载到您的开发或暂存 MySQL 实例中。
如果有特定的查询或事务让您感兴趣,请先检查它们的数据锁定。否则,请从慢查询开始——回顾“查询概要”。
由于锁在事务完成时释放,您需要使用显式事务,如在“行锁定”中所示:
BEGIN;
--
-- Execute one or several queries
--
SELECT index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'elem';
用您的表名替换elem,并记得执行COMMIT或ROLLBACK以释放锁定。
要更改下一个(仅限下一个)事务的隔离级别,请在BEGIN之前执行SET TRANSACTION ISOLATION LEVEL READ COMMITTED。
这是专家级的实践,所以任何努力和理解都是一种成就。祝贺。
^(1) 要深入研究,请参阅“对 ANSI SQL 隔离级别的批判”:这是关于 ANSI SQL-92 隔离级别的经典阅读。
^(2) 感谢 Jakub Łopuszański 揭示和教授我这个秘密。
^(3) 在 MySQL 8.0 源代码的storage/innobase/trx/trx0purge.cc中,当 HLL 大于 2,000,000 时,调试块会记录警告。
第九章:其他挑战
本章是一个短小但重要的关于常见 MySQL 挑战的清单及其缓解方法。这些挑战不适合其他章节,因为大多数与性能无直接关系。但不要低估它们:例如,前两个挑战可以毁掉一个数据库。更重要的是,这些挑战并不是仅在恒星对齐和命运共谋的情况下才发生的特殊情况。这些都是常见的挑战。认真对待它们,并期望面对它们。
脑裂是最大的风险
脑裂需要同时在同一复制拓扑中发生两个条件:
-
多于一个 MySQL 实例是可写的(
read_only=0) -
在多个 MySQL 实例上发生写入
两者都不应该同时发生——尤其是在同一时间——但生活充满了惊喜,你无法永远避免错误或意外。一旦发生,就称之为脑裂:而不是所有 MySQL 实例都具有相同的数据,它们被象征性地分裂,因为数据不再在每个实例上一致(一致)。不一致的数据不仅根本是错误的,它还可能破坏复制或更糟——产生连锁反应,导致更多数据变得不一致,从而带来下一个挑战:数据漂移。
注意
脑裂不适用于有意设计为具有多个可写实例的 MySQL 复制拓扑。
如果发生了脑裂,必须立即检测并停止它。为什么?因为一次写操作可能影响任意数量的行。短短几秒钟的脑裂可能导致一系列不一致的数据,导致数周的数据取证和对账工作。
要停止脑裂,禁用所有实例上的写入操作:SET GLOBAL read_only=1。不要让一个实例保持可写状态;那会使问题变得更糟。如果无法禁用写入操作,那就关闭 MySQL 或服务器——严肃点。数据完整性比数据可用性更重要。
提示
数据完整性比数据可用性更重要。
理想情况下,应该将整个数据库离线,直到找到并协调所有不一致的数据。但现实情况是,如果长时间的数据库停机会毁掉业务,并且您绝对确定读取可能不正确的数据不会造成进一步的损害,那么您可以在修复数据时以只读模式运行 MySQL(read_only=1),同时使用super_read_only模式。
找到不一致行的唯一两种方法是运行pt-table-sync,或者手动检查。手动检查包括根据您对应用程序、数据及可能在脑裂期间发生的更改的理解,尽力进行比较和验证行。pt-table-sync是一个可以找到、打印和同步两个 MySQL 实例之间数据差异的开源工具,但要小心使用,因为任何更改数据的工具本质上都存在风险。
警告
pt-table-sync 是一个危险的工具,除非你小心操作。不要使用其--execute选项:只使用--print,并彻底阅读其手册。
调和行是困难的部分,你应该与 MySQL 专家合作,确保正确执行。如果幸运的话,你会确定一个 MySQL 实例是权威的——所有行都有正确的数据——那么你可以重建而不是调和:从权威实例重新建立所有副本。如果不幸的话,那么与 MySQL 专家合作确定你的选择。
数据漂移是真实但看不见的
数据漂移指的是不一致的数据:同一复制拓扑中不同 MySQL 实例上的一个或多个行具有不同的值。(漂移比喻为随着不一致数据的更改导致进一步的不一致,这些值漂离得越来越远。)而来自分裂脑场景的不一致数据是预期的,来自数据漂移的不一致数据是意外的:你不知道,也没有任何理由怀疑存在不一致的数据。虽然从表面上看,数据漂移似乎不会造成问题,但它仍然是一个真实的问题,因为应用可能返回错误的值。
幸运的是,数据漂移很容易检测:运行pt-table-checksum。这个工具很安全:它只是读取和比较数据。不幸的是,数据漂移和由于分裂脑导致的不一致数据一样难以调和。但这可能不是问题,因为数据漂移往往是有限和局部的,不会像不一致数据的雪崩一样——因为它不是由像分裂脑这样的严重故障造成的。
数据漂移令人着迷的一面在于,据我所知,在野外(即真实的生产数据库中),从未有人找到或证明过数据漂移的根本原因。理论上,它是由于非确定性查询和基于语句的复制,或者在副本上的写入引起的。在实验室中,这两者肯定会导致数据漂移,但在野外似乎从未是原因。相反,工程师和数据库管理员一致认为没有做任何事情来导致或允许数据漂移。但它确实存在。
提示
每隔几个月(或至少每年一次)运行pt-table-checksum检查数据漂移。如果发现了数据漂移,不要担心:调和这些行,并在一个月后再次检查。如果数据持续漂移(这种情况非常罕见),那么你就有一个值得详细调查并修复根本原因的特殊问题。
不要信任 ORM
对象关系映射(ORM)的目的是通过将数据访问抽象为编程术语和对象来帮助程序员。ORM 本身并不坏或低效,但你应该验证 ORM 库生成的查询,因为性能并不是它的目的。例如,由于 ORM 将行视为对象,ORM 库可能会选择所有列,这与你在高效数据访问检查表(表 3-2)中看到的情况相反。另一个例子:一些 ORM 库在实际应用查询之前或之后执行其他查询(例如SHOW WARNINGS)。在追求最大性能时,每个查询都很重要;其他查询是不可接受的浪费。
有一些高性能应用使用 ORM,但工程师们小心地不信任 ORM:他们验证 ORM 生成的查询在查询分析和查询报告中(分别参见“查询分析”和“查询报告”)。如果 ORM 生成的查询效率太低,可以阅读 ORM 库文档,了解如何配置以生成更高效的查询。
架构总是在变化。
你可能已经了解到这个挑战,但如果你刚接触任何关系数据库生活的话,我们来详细谈一谈:架构总是在变化。(更具体地说,表定义总是在变化,但表构成了一个架构。)挑战在于进行在线架构更改(OSC):在使用过程中改变架构,而不影响应用程序。正如前几章所述,MySQL 有三个很好的解决方案:
每个解决方案的工作方式都非常不同,但它们都可以在线更改表定义,而不影响应用程序。阅读每个文档以决定哪一个最适合你。
这个挑战还有另一个方面:将架构更改集成到软件开发过程中。你可以手动运行 OSC,但工程团队不会这样做,因为像其他代码更改一样,架构更改需要成为开发过程的一部分,因此需要审核、批准、在测试环境中测试等等。由于开发流程是团队特定的,你的团队将不得不创建自己的解决方案。但目前有一个开源解决方案:Skeema。要深入了解,如何在 GitHub 上解决这个挑战的著名 MySQL 专家 Shlomi Noach,请阅读他的博客文章“使用 GitHub Actions 等自动化 MySQL 架构迁移”。
MySQL 扩展了标准 SQL
如果您只使用 MySQL,那么也许您可以跳过这个挑战。但如果您来自(或去往)另一个关系型数据库,那么请注意,MySQL 在 MySQL 手册中列举了许多标准 SQL 的扩展,详见“MySQL 对标准 SQL 的扩展”。MySQL 不支持一些标准 SQL 功能,比如全外连接。还有其他限制和限制在名为“MySQL 限制和限制”的节选中列出,您将在 MySQL 手册中找到其他提及和奇特之处。
任何具有像 MySQL 这样悠久而丰富历史的数据库都必定同样充满异国风情。关于 MySQL 的独特之处是专家们自然而然地了解和信任的东西,很少有人指出:MySQL 手册是全面而权威的。软件文档可能稀缺、过时或不存在,但 MySQL 手册不是。关于 MySQL 的一些神秘信息不在手册中,但除此之外,MySQL 专家们严重依赖 MySQL 手册——你也应该如此。
嘈杂邻居
在物理服务器上,嘈杂邻居是通过使用过多系统资源而降低其他程序性能的程序。例如,如果服务器运行了 20 个独立的 MySQL 实例,但其中一个使用了所有的 CPU 和磁盘 I/O,那么它就是一个嘈杂邻居。这是一个常见的挑战,因为共享服务器(或多租户)是正常的:在单个物理服务器上运行多个虚拟化环境。相反,专用服务器(或单租户)是罕见且昂贵的,尤其是在云中。嘈杂邻居是一个令人困惑的挑战,因为性能影响不是您的错,但却是您的问题。
如果您的公司使用自己的硬件,那么问题是可以解决的:测量您怀疑存在嘈杂邻居的共享服务器上每个程序或虚拟环境的资源使用情况。嘈杂邻居很容易被发现,因为它们很吵闹。然后将嘈杂邻居(或您的数据库)迁移到另一个更安静的服务器上。如果这不可能,那么为嘈杂邻居购买另一本这本书,让他们学习如何优化 MySQL 性能。
在云中,您无法看到或证明存在嘈杂邻居。出于安全考虑,云提供商在共享服务器上严格保持租户(像您这样的客户)的分离。他们不太可能承认存在嘈杂邻居,因为这将意味着他们没有平衡服务器负载,这应该包含在成本中。因此,当您怀疑存在嘈杂邻居时,标准做法是重新配置云数据库。一些公司在使用云资源之前对其进行基准测试,只有在性能达到基线时才保留它;否则,资源将被销毁,另一个资源将被配置,这个过程会重复,直到—偶然地—资源被配置在一个安静的服务器上。
应用程序无法优雅地失败
Netflix 发起了 混乱工程:有意引入问题和故障到系统中,以测试其弹性,并迫使工程师设计用于故障的解决方案。这种哲学和实践是大胆的,因为它 真正 测试了应用程序的实力。编写软件在周围一切正常运行时也能正确工作,这是一种基本和显而易见的期望,但它毫无意义。挑战在于编写软件,即使在周围一切都在失败的情况下也能工作。作为工程师,我们经常认为在软件中考虑了故障,但在真正发生故障之前我们怎么知道呢?而且,并非所有故障都是二进制的:工作或不工作。最阴险的问题不是明显的故障,而是边缘情况和异常情况:这种问题需要一个故事来解释,而不是简单的故障声明,比如“硬盘死机”。
对于 MySQL 的应用程序也是如此。但是,在 MySQL 行业中,混乱工程不是标准做法,因为玩弄数据库是有风险的,很少有工程师如此大胆。但幸运总是眷顾大胆者,因此这里有 12 种数据库混乱场景,可以测试您的应用程序的实力:
-
MySQL 离线
-
MySQL 响应非常慢
-
MySQL 是只读的
-
MySQL 刚刚启动(冷缓冲池)
-
读取副本离线或非常缓慢
-
在同一地区的故障转移
-
故障转移到不同的地区
-
数据库备份正在运行
-
DNS 解析非常慢
-
网络速度慢(高延迟)或饱和
-
RAID 阵列中的一个硬盘已降级
-
SSD 上的空闲磁盘空间低于 5%
这些 12 种数据库混乱场景中的一些可能不适用于您的基础设施,但大多数都是标准的,并根据应用程序产生有趣的结果。如果您从未进行过混乱工程设计,那么我鼓励您开始,因为混乱不会等到你准备好。
高性能 MySQL 非常困难
如果您真诚地应用了本书中的所有最佳实践和技术,我相信您将在 MySQL 中取得卓越的性能。但这并不意味着它会快速或轻松。高性能 MySQL 需要实践,因为资源——书籍、博客、视频、会议等——教给您的是理论,而不是现实。因此,当您开始将本书中学到的东西应用到您的应用程序中时,您可能会遇到以下两个挑战。
第一个挑战是,真实应用的查询通常比这些页面上零散的简短示例更复杂。加之一次性记住和应用如此多的知识的额外挑战:查询指标、索引和索引、EXPLAIN输出、查询优化、表定义等等。一开始可能会感到不知所措,但逐个查询来处理,并记住“北极星”和“索引:如何像 MySQL 一样思考”。即使是专家也需要时间来揭示并理解查询的完整故事。
第二个挑战是,真实应用的性能很少仅取决于工作负载的某一个方面。修复慢查询无疑会有所帮助,但可能不够。你需要从整个工作负载着手优化:每个查询、所有数据以及每种访问模式。最终,你将需要应用这本书每一章的知识。(除非你不在云端使用 MySQL,否则忽略第十章。)从第一章到第四章开始小步前进,但务必学习并应用这本书中的所有内容,因为你会需要它们。
MySQL 的性能远不止本书中我所介绍的内容,但我向你保证:这些章节中所传授的知识是全面且有效的。此外,并没有仅专家才知道的秘密能够极大地提升 MySQL 的性能。我从自己的经验中知道这一点,也从与世界上许多顶级 MySQL 专家的合作中了解到这一点。而且,开源软件很难保守秘密。
实践:识别防止分裂脑的保护措施
这个实践的目标是识别防止分裂脑的保护措施。它有两个部分:详细说明保护措施,以便每个工程师都理解它们的作用、位置(可能在工具中)以及它们的工作原理,然后仔细审查管理或更改 MySQL 实例的工具,特别是故障转移工具。
如果你不管理 MySQL,那么请安排时间与管理 MySQL 的工程师会面,让他们详细说明他们在运营中如何防止分裂脑,特别是故障转移时。这应该是一个简单的请求,因为防止分裂脑是管理 MySQL 的基本要求。
如果在云中使用 MySQL,情况会有所不同。云服务提供商根据内部设置和管理 MySQL 的方式采取未公开的方法来防止脑裂。例如,使用标准的 Amazon RDS for MySQL 多可用区实例理论上不可能出现脑裂,因为虽然是多可用区,但多个 MySQL 实例不会同时运行。(在一个可用区 [AZ] 中有一个运行中的 MySQL 实例。如果该实例失败,另一个实例将在另一个 AZ 中启动。)但是,如果添加读取副本,则在相同复制拓扑中有多个运行中的 MySQL 实例,Amazon 对于与读取副本相关的脑裂不作任何保证。在云中,假设您负责防止脑裂的护栏,但也要了解云提供商何时会或不会防止脑裂。
如果您在自己的硬件上管理 MySQL,则建议您聘请 MySQL 专家帮助您确定防止脑裂的护栏。(这不应花费太长时间,所以应该是一个简短而经济实惠的合同。)有一个基本的护栏必须实施:在其 my.cnf 文件中配置 MySQL 以只读模式启动:read_only=1。始终以只读模式启动 MySQL。从这个基础上,其他护栏详细说明了如何切换只读模式,以确保一次只有一个实例处于非只读模式(MySQL 可写)。
小贴士
始终以只读模式启动 MySQL (read_only=1)。
一旦工程师理解了护栏,第二部分是仔细审查管理或更改 MySQL 实例的工具,特别是故障转移工具,以确保护栏得到实施并按预期工作。当然,所有代码都应该经过单元测试,但是防止脑裂如此重要,以至于需要进行手动代码审查。在识别护栏时可能存在的代码问题包括:竞争条件、重试和错误处理。最后一点——错误处理——尤为重要:工具是否能(或者应该)在出错时回滚更改?记住:数据完整性比数据可用性更重要。当切换 MySQL 为只读时,工具应该谨慎行事:如果某个操作有非零概率导致脑裂,就不要这样做;让 MySQL 保持只读模式,失败时让人工解决。
底线:对于防止脑裂的护栏要非常清楚。
实践:检查数据漂移
这种做法的目的是使用 pt-table-checksum 检查数据漂移。您很幸运:这个工具专门设计得非常简单和自动化。只需下载并运行该工具,它将在大多数情况下自动完成其余工作。如果不行,快速阅读其文档将解答任何问题。
注意
大多数 MySQL 工具需要特殊配置才能与云中的 MySQL 配合工作。
pt-table-checksum 只做一件事情:检查并报告数据漂移。根据数据大小和访问负载的不同,它可能会运行数小时或数天。默认情况下,它运行缓慢以避免干扰生产访问。因此,请务必在 screen 或 tmux 会话中运行它。
当 pt-table-checksum 完成对表的检查时,它会打印表的一行结果。输出如下:
TS ERRORS DIFFS ROWS DIFF_ROWS CHUNKS SKIPPED TIME TABLE
10-21T08:36:55 0 0 200 0 1 0 0.005 db1.tbl1
10-21T08:37:00 0 0 603 0 7 0 0.035 db1.tbl2
10-21T08:37:10 0 2 1600 3 21 0 1.003 db2.tbl3
输出的最后一行显示出数据漂移的表,因为列 DIFFS 的值不为零。如果任何表有数据漂移,请使用 --replicate-check-only 选项重新运行以打印与源不同的副本和分块。(分块 是由索引的上限和下限边界值界定的行范围 [通常是主键]。pt-table-checksum 通过检查分块中的行来验证,因为逐行检查效率太低且效率低下。) 您将需要制定计划来隔离和调整不一致的行。如果不多,您可以尝试手动隔离和调整它们。如果不行,那么我建议您与 MySQL 专家合作,确保正确完成操作。
实践:混沌
这种实践的目标是测试您的应用程序的能力。混沌工程不适合胆小者,因此请从您的演示数据库开始。
警告
这种实践会导致停机。
在接下来的混沌测试中,MySQL 和应用程序应该正常运行,并且有一定的负载。您应该能够充分记录和分析它们的运行状况。
我建议以下混沌操作,但根据您的风险承受能力进行选择:
重启 MySQL
重启 MySQL 测试应用程序在 MySQL 脱机时的响应情况,以及在 MySQL 缓冲区冷启动时的响应情况(特别是 InnoDB 缓冲池)。冷启动需要进行磁盘 I/O 将数据读入内存,导致响应时间比平时慢。这还能让您了解三件事情:MySQL 关闭所需的时间、MySQL 启动所需的时间以及缓冲区热身所需的时间。
启用只读模式
在源实例上设置 SET GLOBAL read_only=1 以启用只读模式,并测试应用程序在可以读取但不能写入数据时的响应。工程师们通常认为应用程序在读取方面会继续工作,并且对写入进行优雅失败,但混沌充满了意外。这也有效地模拟了失败的故障转移,这种情况永远不应发生(因为这意味着高可用性的失败),但“永远不应该发生”也包含在混沌的范围内。
停止 MySQL 1 小时
大多数应用程序可以在几秒或几分钟内经受住风暴的考验,甚至可以是十几分钟,但在某个时刻,队列会填满,重试次数会用尽,指数回退时间会变得非常长,速率限制会重置,用户会放弃并转向竞争对手。MySQL 在正常管理的情况下,不应该离线超过几秒钟,但再次:混沌。
2004 年,当我在一个数据中心工作时,在我开始下午 2 点到午夜班之前,一位工程师意外地按到了紧急关机按钮——关掉了整个数据中心。在混乱中保持冷静是唯一的答案,所以我先拿了杯咖啡,然后坐下来帮助重新启动数据中心。
第十章:MySQL 在云中
云中的 MySQL 本质上与您熟悉(或者说您了解并接受)的 MySQL 相同。在云中,前九章中详细介绍的最佳实践和技术不仅是真实的,而且是极为真实的,因为云服务提供商对每个字节和每毫秒工作时间都收费。在云中,性能就是金钱。对前九章的总结如下:
-
性能是查询响应时间(第一章)。
-
索引是性能的关键(第二章)。
-
少数据既能节省存储,也便于访问(第三章)。
-
访问模式允许或抑制性能(第四章)。
-
分片是扩展写入和存储的必要手段(第五章)。
-
服务器指标显示工作负载如何影响 MySQL(第六章)。
-
复制滞后是数据丢失,必须避免(第七章)。
-
事务会影响行锁定和撤销日志(第八章)。
-
即使在云中,还存在其他挑战(第九章)。
如果你接受并应用所有这些细节,MySQL 将在任何地方(云中、本地或其他任何地方)以显著的性能执行应用负载。
为了节省您的时间,我希望问题如此简单——优化工作负载,然后就万事大吉——但是,在云中使用 MySQL 提出了独特的考虑因素。目标是了解并减轻这些云计算中的考虑因素,以便您可以专注于 MySQL,而不是云计算。毕竟,云计算并不特别:在虚拟幕布背后,它是在数据中心运行像 MySQL 这样的物理服务器上运行程序。
本章重点介绍在云中使用 MySQL 时需要注意的事项。有四个主要部分。第一个警告兼容性问题:当 MySQL 不再是 MySQL 时。第二部分是关于云中不同级别的 MySQL 管理的快速讨论。第三部分讨论网络延迟及其与存储 I/O 的关系。第四部分是关于性能和成本的讨论。
兼容性
云中的 MySQL 可能不是 MySQL,或者可能是高度修改过的(专有)版本的 MySQL。 MySQL 在云中的兼容性有两个方面:代码兼容性和功能兼容性。
注意
MySQL 指的是由 Oracle 发布的 MySQL:官方的、开源的 MySQL 源代码。我也指的是由 Percona 发布的 Percona Server,以及由 MariaDB 基金会发布的 MariaDB Server:它们都是广泛使用、安全稳定的,被视为通用的 MySQL。
代码兼容性 涉及的是 MySQL 是否是由 Oracle、Percona 或 MariaDB 发布的相同开源代码。以下九个词语和短语通常用于产品描述和文档,以暗示 MySQL 并非代码兼容,而是稍微(或显著)不同的东西:
-
基于
-
模拟
-
兼容
-
客户端兼容
-
协议兼容
-
线兼容
-
替代
-
插拔兼容
-
与现有工作
代码兼容性很重要,因为 MySQL 很复杂且微妙,我们委托它存储无价的数据。在本书中,我专注于讨论缩小 MySQL 复杂性的范围,但像 “页面刷新” 和 “行锁定” 这样的章节暗示了问题的深度。当任何公司修改 MySQL 源代码时,风险是四倍的:数据丢失、性能退步、错误和不兼容性。修改越大,风险越大。在云端,我见过后三者;幸运的是,我没有看到云服务提供商丢失数据。
提示
如果你对 MySQL 在云端是否与代码兼容有任何疑问,请向云服务提供商询问,“这是否与由 Oracle 发布的开源 MySQL 相同?”
为了全面呈现论点,而不仅仅是负面(风险),云服务提供商修改 MySQL 以提供额外的价值:改进性能,修复错误,并添加客户所需的功能。一些修改是有价值且值得风险的。但如果你在云中使用不与代码兼容的 MySQL,你需要了解修改的程度。对于在云中使用 MySQL 的专业工程师来说,这是基本的尽职调查。
给出足够多的眼球,所有的 bug 都是浅显的。
Eric S. Raymond
功能兼容性 是 MySQL 是否包含在云提供商或 MySQL 发行版之外不可用的功能。例如,Oracle 发布了两个发行版:MySQL 社区版和 MySQL 企业版。前者是开源的;后者包含专有功能。Oracle Cloud Infrastructure (OCI) 使用后者,这是好事:为云服务的金钱提供更多价值。但这也意味着,如果你依赖于 MySQL 企业版特有的功能,你不能直接迁移到另一个云提供商或 MySQL 发行版。对于 Percona Server 和 MariaDB Server 也是如此:这些 MySQL 的发行版有独特的功能,这是好事,但它也使迁移到其他云提供商或 MySQL 发行版变得复杂。
功能兼容性之所以重要,与开源软件重要的原因相同:自由更改。软件,包括 MySQL 在内,应该赋予工程师和用户权力,而不是将我们锁定在特定的云提供商或供应商中。这种推理比技术更具哲学意义,这也是为什么我将再次全面呈现整个论点:一些功能是有价值且不值得更改以保留的。但如果你选择使用在云服务提供商或 MySQL 发行版之外不可用的功能,你需要记录原因,以便未来的工程师可以理解其中的风险(以及需要替换的内容),如果他们使用其他云提供商或 MySQL 发行版。对于在云中使用 MySQL 的专业工程师来说,这也是基本的尽职调查。
管理(DBA)
我们从本书的第一页起就成功地避开了 MySQL 管理(DBA 工作),所以我们现在也不会失败,但是云中的 MySQL 引发了一个你需要了解和解决的问题:谁来管理 MySQL?表面上看,云提供商管理 MySQL,但事实并不简单,因为管理 MySQL 涉及许多操作。做好准备:我将把本书引向接近 DBA 工作的危险领域,以便解释清楚。
表 10-1 是 DBA 操作的部分列表,指明了谁来管理它们:您还是云。
表 10-1. DBA 操作列表
| 操作 | 您 | 云 |
|---|---|---|
| 配置 | ✓ | |
| 配置 | ✓ | |
| MySQL 用户 | ✓ | |
| 服务器指标 | ✓ | |
| 查询指标 | ✓ | |
| 在线架构变更(OSC) | ✓ | |
| 失败恢复 | ✓ | |
| 灾难恢复(DR) | ✓ | |
| 高可用性(HA) | ✓ | †^(a) |
| 升级 | ✓ | |
| 备份和恢复 | ✓ | |
| 变更数据捕获(CDC) | ✓ | |
| 安全性 | ✓ | |
| 帮助 | ✓ | |
| 成本 | ✓ | |
| ^(a) 指示一些管理。 |
让我快速浏览一下表 10-1 中的 15 个操作,因为即使是在高层次上了解全部范围,也有助于避免 MySQL 管理中的漏洞,如果不加以解决将成为问题。也被称为CYA:保护你的管理工作。
提供 MySQL 是云服务提供商必须提供的基本操作:在计算机上运行 MySQL 的最底层操作。云服务提供商使用了一个合理的 MySQL 配置,但请务必再次确认,因为没有默认配置能够适合每一个客户。除了一个根用户用来给你对 MySQL 服务器的初始控制权限外,云服务提供商不管理 MySQL 用户。服务器和查询指标也是你的责任来收集和报告。尽管一些云服务提供商暴露了基本的服务器指标,但没有一个能够接近第六章中详细介绍的所有指标。在云中运行 ALTER 语句而不影响工作负载完全是你的责任,并且由于书外技术原因,这些操作在云中通常会更加困难。云服务提供商会处理故障转移:当硬件或 MySQL 宕机时,云服务提供商将执行故障转移以恢复可用性。但是云服务提供商不处理灾难恢复:当整个区域发生故障时,必须通过在不同地理位置运行 MySQL 来恢复可用性。考虑到前两种操作,高可用性(HA)在云中的管理是混合的(因此†在云列中)。在这里涵盖 MySQL 在云中高可用性的全面讨论过于细致;我们只能说云提供了一定程度的高可用性。云服务提供商会升级 MySQL,这非常好,因为这一操作在大规模时非常繁琐。云服务提供商备份 MySQL,并提供长期备份保留以及恢复备份的方法——这些都非常重要。你负责变更数据捕获(CDC),通常需要另一个工具或服务来充当副本,从 MySQL 中转储(或流式传输)二进制日志到另一个数据存储(通常是大数据存储或数据湖)。在云中保护 MySQL 的安全性是你的责任——云本身不是安全的。云服务提供商会帮助你一般性地运行 MySQL,但不要期望得到太多(或任何)有关 MySQL 性能的帮助,除非你的公司为此支付了支持费用。最后,你必须管理成本:云因成本超出工程师预期而臭名昭著。
警告
三大云服务提供商——亚马逊、谷歌和微软——为 MySQL(作为托管服务)提供了 99.95% 或 99.99% 的可用性 SLA,但请仔细阅读细则和法律详情。例如,维护窗口通常不计入 SLA。或者,如果 MySQL 没有由你正确配置,SLA 可能会失效。云服务提供商的高可用性和 SLA 总是有细节和注意事项。
表 10-1 是描述性的,而非规定性的,因为不同的云服务提供商和第三方公司在云中提供不同级别的 MySQL 管理。例如,一些公司在云中(或本地)完全管理 MySQL。作为一个使用 MySQL 而不是管理 MySQL 的工程师,你只需要知道所有操作都是被管理的——所有的方框都是被勾选的,这样它们就不会干扰你的工作。一旦你知道了这一点,请忘记你在本节中读到的一切,否则你会在不知不觉中成为一个 MySQL 数据库管理员,二十年过去了,加入你团队的下一个工程师在你忙于解决一个无法解释的点版本升级后的性能回归问题时还是个婴儿。
网络和存储……延迟
当在本地(在公司租赁的数据中心空间内)运行 MySQL 时,本地网络永远不应该是你考虑或担心的问题,假设它是由胜任的专业网络工程师设计和布线的。本地网络具有亚毫秒级的延迟,速度快且稳定。本地网络应该比数据库更无聊(回想“正常和稳定:最好的数据库是一种无聊的数据库”)。
但是云是全球化的,广域网络具有较高的延迟和较低的稳定性(延迟和吞吐量波动较大)。例如,旧金山与纽约之间的网络往返时间(RTT)约为 60 毫秒,加减 10 毫秒。如果你在旧金山(或者美国西海岸的任何地方)运行 MySQL,而应用程序在纽约市(或者美国东海岸的任何地方),最小的查询响应时间约为 60 毫秒。这比本地网络慢 60 倍。^(1) 你会注意到这种慢速,但它不会在查询响应时间中显示出来,因为延迟超出了 MySQL 的范围。例如,查询配置文件(参见“查询配置文件”)显示一个查询执行需要 800 微秒,但你的应用程序性能监控(APM)显示查询执行需要 60.8 毫秒:MySQL 需要 800 微秒,网络延迟需要 60 毫秒。
长距离的网络延迟受到光速的物理限制和中间路由的恶化影响。因此,你无法克服这种延迟,只能绕过它。例如,参考“写入入队”:在本地进行入队,远程写入——其中远程是任何导致高网络延迟的进程。
转回本地网络,幸好它们速度飞快且稳定,因为云提供商通常将 MySQL 数据存储在网络附加存储上:通过本地网络连接到服务器的硬盘驱动器。相比之下,本地附加存储(或本地存储)是直接连接到服务器的硬盘驱动器。云提供商出于本书范围之外的各种原因使用网络附加存储。重要的是要知道,网络附加存储比本地存储慢得多且不稳定。三大云提供商——亚马逊、谷歌和微软——发布了关于网络附加存储(使用 SSD)的“单位时间内延迟为一位数毫秒”^(2),除了亚马逊的 io2 Block Express 拥有亚毫秒级延迟。总之,在云中使用 MySQL 时,可以预期存储具有单位时间内延迟,这相当于旋转磁盘。
网络附加存储与本地存储(使用 SSD;不使用旋转磁盘)的速度相差一个数量级,但这是你应该解决的问题吗?如果你从具有高端本地存储(SSD)的裸金属硬件迁移到云中的 MySQL,并且应用程序大量且持续地利用本地存储的 IOPS(见“IOPS”),那么是的:验证网络附加存储的增加延迟是否导致性能下降的连锁反应(因为 IOPS 会产生延迟)。(IOPS 的大量和持续利用是写入密集工作负载的标志:参见“读/写”。)但如果你已经在云中,或者在云中启动新的应用程序,那么不用担心或考虑云中的存储延迟。相反,建立高度优化的查询(索引)、数据和访问模式的基础——分别在第 2、3 和 4 章中讨论——云中的存储延迟可能永远不会成为问题。
如果云中的存储延迟成为问题,则需要进一步优化工作负载、分片(第五章)或购买更好(更昂贵)的云存储。记住:Netflix 以及其他非常大型和成功的公司都在云中运行。在云中,MySQL 的性能潜力几乎是无限的。问题是:你能负担得起吗?
性能即金钱
与本书的开头——“一个关于虚假性能的真实故事”——形成了相似性。但在云中,客户会为了“修复”MySQL 性能而购买更多的 RAM。三大云提供商之一的工程师告诉我,大多数 MySQL 实例都被过度配置:客户为应用程序所需或利用的容量支付了更多费用^(3)。
行业是否已经完全回到云的可伸缩性,而性能只是更大的实例?不,绝对不是:性能是查询响应时间;在云中,每个字节和毫秒的性能都按小时计费,这使得本书中的所有最佳实践和技术比以往任何时候都更加重要。
如果您曾使用过云服务,下面的信息可能不会让您感到意外。但如果您是云计算新手,那么请让我第一个告诉您:云计算定价复杂,几乎难以掌控,并且经常低估(这意味着超出预算)。即使工程师们竭力估算和控制云计算成本,这一点仍然如此;当他们不这样做时,我曾见过六位数的惊喜:超过预算的 10 万美元。以下是在云中使用 MySQL 时避免计费意外的三个最重要的事项。
第一件要知道的事情是,价格每升一级都会翻倍,因为底层计算资源(运行 MySQL 的虚拟服务器)的资源(虚拟 CPU 计数和内存大小)在每个级别上都会翻倍。例如,如果最低级别的计算是 2 个虚拟 CPU 和 8 GB RAM,则上一级是 4 个虚拟 CPU 和 16 GB RAM——价格也会翻倍。有几个例外,但期望翻倍。因此,您无法逐步增加成本;每个您扩展的计算级别的成本都会翻倍。从工程角度来看,从 2 个虚拟 CPU 扩展到 8 个虚拟 CPU 仍然是非常小的计算量,但价格却会翻倍。为了形象化地描述,想象一下,如果您的每月抵押贷款或租金翻倍,或者您的汽车贷款翻倍,或者您的学生贷款翻倍。您可能会感到不安,这是理所当然的。
第二件要知道的事情是,在云中,一切都是需要花钱的。计算成本只是开始。以下列表包括在云中 MySQL 的常见费用,除了计算成本外:
-
存储类型(IOPS)
-
数据存储(大小)
-
备份(大小和保留时间)
-
日志(大小和保留时间)
-
高可用性(副本)
-
跨区域数据传输(大小)
-
加密密钥(用于加密数据)
-
保密(存储密码)
此外,这些费用是按实例计费的。例如,如果您创建了五个读取副本,则每个副本都将计费用于数据存储、备份等。我希望事情能更简单些,但这是现实:在使用云中的 MySQL 时,您需要调查、理解和估算所有成本。
警告
一些云中的 MySQL 的专有版本(参见“兼容性”)具有额外的费用,或者完全不同的定价模型。
第三个也是最后一个要了解的是,云服务提供商提供折扣。不要付全价。至少,通过一年或三年的承诺,可以显著降低成本,而不是按月支付。其他折扣因云服务提供商而异:寻找(或询问)保留实例、承诺使用和批量折扣。如果您的公司依赖云,那么它很可能已经与云服务提供商签订了合同。找出是否是这种情况,以及合同定价细节是否影响云中 MySQL 的成本。如果幸运的话,合同可能会减少并且简化成本,从而让您专注于使用 MySQL 的有趣细节。
摘要
本章强调了在云中使用 MySQL 时需要了解的内容。重要的要点是:
-
在云中,MySQL 的代码和功能兼容性各不相同。
-
您的尽职调查是了解与开源 MySQL 相比的任何代码或功能不兼容性。
-
MySQL 可以由云服务提供商或第三方公司部分或完全管理。
-
在广域网上的网络延迟会增加查询响应时间数十至数百毫秒。
-
云中的 MySQL 数据通常存储在网络附加存储上。
-
网络附加存储的延迟为个位数毫秒,相当于旋转磁盘。
-
云服务对所有内容收费,成本可能(而且经常)超出预算。
-
云服务提供商提供折扣;不要付全价。
-
在云中的性能是查询响应时间。
这是最后一章,但不要\q:还有一个练习。
实践:尝试云中的 MySQL
这个实践的目标是在云中尝试 MySQL——只是为了看看它是如何运作的,不需要进行任何 DBA 工作。一方面,我不想为以下五个云服务提供商做免费营销——本书严格是技术性的。但另一方面,使用云中的 MySQL 越来越普遍,因此我希望你能做好准备并取得成功。此外,这是一次免费试用:以下五个云服务提供商都有免费层或初始账户信用额。暂时不要支付任何费用:云服务提供商必须通过向您证明其服务的价值来赢得您的业务和资金。
尝试使用任意一个(或多个)云服务提供商创建和使用 MySQL:
-
MySQL 数据库服务 by Oracle
-
SkySQL by MariaDB
-
关系型数据库服务(RDS) by Amazon
-
Azure 数据库服务 MySQL 版 by Microsoft
-
Cloud SQL by Google
如果发现其中一个易于使用且可能有价值,请调查其定价模型和额外成本。我特意使用动词调查,因为正如我在“性能即金钱”中提到的:云定价是复杂的,几乎难以解决,并经常被低估(这意味着超出预算)。
注意
在免费试用期结束或初始账户余额耗尽之前,请不要忘记销毁您的云中 MySQL 实例。
这是本书中的最后一个实践,但我鼓励你继续学习和实践,因为 MySQL 不断发展,云服务也是如此。因此,即使是 MySQL 专家也必须继续学习和实践,这让我想起了一句禅宗谚语,正是以此结束本书:
砍柴。挑水。
^(1) 从技术上讲,所有网络的速度都是一样的:光速。问题在于物理距离和长距离中间路由。
^(2) 参见Amazon EBS 功能,Google 的块存储性能,以及Microsoft Azure 的高级存储。
^(3) 由于保密协议,我无法引用来源。


浙公网安备 33010602011771号