高性能-MySQL-第四版-GPT-重译--全-
高性能 MySQL 第四版(GPT 重译)(全)
前言
由 Oracle 维护的官方文档为您提供了安装、配置和与 MySQL 交互所需的知识。本书作为该文档的伴侣,帮助您了解如何最好地利用 MySQL 作为强大的数据平台来满足您的用例需求。
本版还扩展了合规性和安全性在操作数据库足迹中的日益重要作用。隐私法律和数据主权等新现实已经改变了公司产品构建的方式,这自然地引入了技术架构如何演变的新复杂性。
本书适合对象
本书首先面向希望提升在运行 MySQL 方面的专业知识的工程师。本版假设读者熟悉为什么要使用关系数据库管理系统(RDBMS)的基本原则。我们还假设读者具有一些一般系统管理、网络和操作系统的经验。
我们将为您提供在现代架构和更为更新的工具和实践下运行 MySQL 的经验丰富的策略。
最终,我们希望您从本书中对 MySQL 内部和扩展策略的知识中获益,帮助您在组织中扩展数据存储层。我们希望您新获得的见解将帮助您学习和实践一种系统化的方法来设计、维护和故障排除基于 MySQL 构建的架构。
这个版本有何不同
高性能 MySQL已经成为数据库工程社区多年的一部分,之前的版本分别在 2004 年、2008 年和 2012 年发布。在这些先前的版本中,目标始终是通过专注于深度内部设计,解释各种调整设置的含义,并为用户提供改变这些设置的知识,教导开发人员和管理员如何优化 MySQL 以获得最佳性能。本版保持了相同的目标,但侧重点不同。
自第三版以来,MySQL 生态系统发生了许多变化。发布了三个新的主要版本。工具景观大大扩展,超越了 Perl 和 Bash 脚本,进入了完整的工具解决方案。全新的开源项目已经建立,改变了组织如何管理扩展 MySQL 的方式。
甚至传统的数据库管理员(DBA)角色也发生了变化。行业中有一个老笑话说 DBA 代表“别烦问”。DBA 因为数据库没有像周围的软件开发生命周期(SDLC)一样快速发展而被认为是软件开发生命周期中的绊脚石,这并不是因为他们有什么脾气暴躁的态度,而只是因为数据库的发展速度没有跟上周围 SDLC 的步伐。
像 Laine Campbell 和 Charity Majors(O'Reilly)合著的数据库可靠性工程:设计和运营弹性数据库系统这样的书籍,已经成为技术组织将数据库工程师视为业务增长的推动者而不是所有数据库的唯一运营者的新现实。曾经 DBA 的主要日常工作涉及模式设计和查询优化,现在他们负责教导开发人员这些技能,并管理允许开发人员快速安全地部署自己模式更改的系统。
通过这些变化,重点不再是优化 MySQL 以获得几个百分点的速度。我们认为高性能 MySQL现在是为人们提供他们需要的信息,以便就如何最好地使用 MySQL 做出明智决策。这始于理解 MySQL 的设计,然后理解 MySQL 擅长和不擅长的地方。¹ 现代版本的 MySQL 提供合理的默认设置,除非您遇到非常具体的扩展问题,否则几乎不需要进行调整。现代团队现在正在处理模式更改、合规问题和分片。我们希望高性能 MySQL成为现代公司如何大规模运行 MySQL 的全面指南。
本书使用的约定
本书使用以下排版约定:
斜体
表示新术语、URL、电子邮件地址、文件名和文件扩展名。
常量宽度
用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
常量宽度粗体
显示用户应按照字面意义输入的命令或其他文本。
常量宽度斜体
显示应替换为用户提供的值或由上下文确定的值的文本。
提示
此图标表示提示或建议。
注意
此图标表示一般说明。
警告
此图标表示警告或注意事项。
第四版致谢
来自 Silvia
首先,我要感谢我的家人。我的父母为了把我和我的兄弟带到美国,牺牲了在埃及的稳定工作和生活。我的丈夫 Armea,在我接受挑战的过去几年中一直支持我,最终实现了这一成就。
我作为一个移民从中东的大学时代离开,实现了移居美国的梦想。在加利福尼亚州的一所州立大学获得学位后,我在纽约市找到了一份工作,我记得这本书的第二版是我用自己的钱买的第一本不是教科书的技术书籍。我要感谢前几版的作者教给我许多基本的经验,为我在职业生涯中管理数据库做好准备。
我感激我职业生涯中与之合作过的许多人的支持。他们的鼓励让我写下了这本书的这一版,这本书在我职业生涯的早期教会了我很多。我要感谢 Tim Jenkins,SendGrid 的前首席技术官,他雇佣了我这份终身职位,尽管我在面试中告诉他他错误地使用了 MySQL 复制,但他还是信任我,结果证明这是一艘火箭。
我要感谢所有在科技领域的了不起的女性,她们是我的支持网络和啦啦队。特别感谢 Camille Fournier 和 Nicole Forsgren 博士,因为她们写的两本书影响了我过去几年的职业生涯,改变了我对日常工作的看法。
感谢我在 Twilio 团队的同事们。感谢 Sean Kilgore 让我成为一个更优秀的工程师,关心的不仅仅是数据库。感谢 John Martin 是我曾经合作过的最乐观的人。感谢 Laine Campbell 及其 PalominoDB 团队(后来被 Pythian 收购)在我最艰难的岁月中给予的支持和教导,以及 Baron Schwartz 鼓励我写下我的经历。
最后,感谢 Virginia Wilson 是一位出色的编辑,帮助我将我的思绪转化为通顺的句子,并在整个过程中给予我如此多的支持和优雅。
来自 Jeremy
当 Silvia 找我帮忙写这本书时,正值大多数人生活中异常紧张的时期——全球大流行,始于 2020 年。我不确定是否想要给自己的生活增加更多压力。我的妻子 Selena 告诉我,如果我不接受,我会后悔的,而我知道不应该和她争论。她一直支持我,鼓励我成为我能成为的最好的人。我将永远爱她,感激她为我所做的一切。
致我的家人、同事和社区朋友们:没有你们,我绝对不可能走到今天这一步。你们教会了我如何成为今天的自己。我的职业生涯是与你们共同经历的总和。你们教会了我如何接受批评,如何以身作则领导,如何失败并重新站起,最重要的是,团队的力量胜过个人的能力。
最后,我要感谢 Silvia,她信任我为这本书带来共同的理解但不同的视角。我希望我达到了你的期望。
致技术审阅者
作者还要感谢帮助将这本书推向今天这一地步的技术审阅者们:Aisha Imran、Andrew Regner、Baron Schwartz、Daniel Nichter、Hayley Anderson、Ivan Mora Perez、Jam Leoni、Jaryd Remillard、Jennifer Davis、Jeremy Cole、Keith Wells、Kris Hamoud、Nick Vyzas、Shubheksha Jalan、Tom Krouper 和 Will Gunty。感谢你们的时间和努力。
¹ 众所周知,人们经常将 MySQL 用作队列,然后才发现这样做是错误的。最常见的原因是轮询新队列操作的开销、锁定记录以进行处理的管理,以及随着数据增长而变得笨重的队列表格。
第一章:MySQL 架构
MySQL 的架构特点使其适用于各种用途。虽然它并非完美,但足够灵活,可以在小型和大型环境中都能很好地运行。从个人网站到大型企业应用程序都适用。要充分利用 MySQL,您需要了解其设计,以便与之合作,而不是对抗它。
本章概述了 MySQL 服务器架构的高层概述,存储引擎之间的主要区别以及这些区别的重要性。我们试图通过简化细节并展示示例来解释 MySQL。这个讨论对于那些对数据库服务器新手以及对其他数据库服务器是专家的读者都将有用。
MySQL 的逻辑架构
对 MySQL 组件如何协同工作有一个清晰的心理图像将有助于您理解服务器。图 1-1 展示了 MySQL 架构的逻辑视图。
最顶层的层级是客户端,包含的服务并非 MySQL 独有。这些服务是大多数基于网络的客户端/服务器工具或服务器所需的服务:连接处理、身份验证、安全等。
第二层是事情变得有趣的地方。MySQL 的大部分智慧都在这里,包括查询解析、分析、优化以及所有内置功能的代码(例如日期、时间、数学和加密)。在这个层面提供的任何功能都跨存储引擎:存储过程、触发器和视图,例如。
第三层包含存储引擎。它们负责存储和检索 MySQL 中存储的所有数据。就像 GNU/Linux 中提供的各种文件系统一样,每个存储引擎都有其自己的优点和缺点。服务器通过存储引擎 API 与它们通信。该 API 隐藏了存储引擎之间的差异,并在查询层面上使它们基本透明。它还包含几十个低级函数,执行诸如“开始事务”或“获取具有此主键的行”等操作。存储引擎不解析 SQL¹,也不相互通信;它们只是响应服务器的请求。

图 1-1 MySQL 服务器架构的逻辑视图
连接管理和安全
默认情况下,每个客户端连接在服务器进程内部都有自己的线程。连接的查询在该单个线程内执行,该线程又位于一个核心或 CPU 上。服务器维护一个准备好使用的线程缓存,因此它们不需要为每个新连接创建和销毁。²
当客户端(应用程序)连接到 MySQL 服务器时,服务器需要对其进行身份验证。身份验证基于用户名、来源主机和密码。也可以在传输层安全(TLS)连接中使用 X.509 证书。一旦客户端连接,服务器会验证客户端是否对其发出的每个查询具有权限(例如,客户端是否被允许发出访问 world 数据库中 Country 表的 SELECT 语句)。
优化和执行
MySQL 解析查询以创建内部结构(解析树),然后应用各种优化。这些优化包括重写查询、确定读取表的顺序、选择使用哪些索引等。您可以通过查询中的特殊关键字向优化器传递提示,影响其决策过程。您还可以要求服务器解释优化的各个方面。这让您了解服务器正在做出的决策,并为重新调整查询、模式和设置提供参考,使一切尽可能高效地运行。在第八章中有更详细的内容。
优化器并不真正关心特定表使用的存储引擎是什么,但存储引擎确实会影响服务器优化查询的方式。优化器向存储引擎询问一些能力以及某些操作的成本,还会请求表数据的统计信息。例如,某些存储引擎支持对某些查询有帮助的索引类型。您可以在第六章和第七章中了解更多关于模式优化和索引的内容。
在旧版本中,MySQL 利用内部查询缓存来查看是否可以从中提供结果。然而,随着并发性的增加,查询缓存成为一个臭名昭著的瓶颈。截至 MySQL 5.7.20,查询缓存正式被废弃为 MySQL 的一个特性,并在 8.0 版本中,查询缓存被完全移除。尽管查询缓存不再是 MySQL 服务器的核心部分,但缓存频繁提供的结果集是一个好的实践。虽然超出了本书的范围,但一个流行的设计模式是在 memcached 或 Redis 中缓存数据。
并发控制
每当多个查询需要同时更改数据时,就会出现并发控制问题。对于本章的目的,MySQL 必须在两个级别进行并发控制:服务器级别和存储引擎级别。我们将为您简要介绍 MySQL 如何处理并发读取和写入,以便您在本章的其余部分中获得所需的背景知识。
为了说明 MySQL 如何处理对同一组数据的并发工作,我们将以传统的电子表格文件为例。电子表格由行和列组成,就像数据库表一样。假设文件在您的笔记本电脑上,并且只有您可以访问它。没有潜在的冲突;只有您可以对文件进行更改。现在,想象您需要与同事共同使用该电子表格。它现在位于您和同事都可以访问的共享服务器上。当您和同事同时需要对此文件进行更改时会发生什么?如果我们有一个整个团队的人正在积极尝试编辑、添加和删除此电子表格中的单元格,会发生什么?我们可以说他们应该轮流进行更改,但这并不高效。我们需要一种允许高容量电子表格并发访问的方法。
读/写锁
从电子表格中读取并不那么麻烦。多个客户端同时读取同一文件没有问题;因为他们没有进行更改,所以不太可能出错。如果有人尝试在其他人正在读取电子表格时删除A25单元格会发生什么?这取决于情况,但读者可能会得到损坏或不一致的数据视图。因此,即使从电子表格中读取也需要特别小心。
如果您将电子表格视为数据库表,很容易看出在这种情况下问题是相同的。在许多方面,电子表格实际上只是一个简单的数据库表。修改数据库表中的行与删除或更改电子表格文件中的单元格内容非常相似。
解决这个经典的并发控制问题相当简单。处理并发读/写访问的系统通常实现由两种锁类型组成的锁定系统。这些锁通常被称为共享锁和排他锁,或读锁和写锁。
不用担心实际的锁定机制,我们可以描述概念如下。对于资源的读锁是共享的,或者说是相互非阻塞的:许多客户端可以同时从资源中读取,而不会相互干扰。另一方面,写锁是排他的——也就是说,它们会阻止读锁和其他写锁——因为唯一安全的策略是在给定时间内只允许单个客户端向资源写入,并在客户端写入时阻止所有读取。
在数据库世界中,锁定一直在发生:MySQL 必须防止一个客户端在另一个客户端更改数据时读取数据。如果数据库服务器表现得符合要求,那么锁定的管理速度足够快,以至于客户端几乎察觉不到。我们将在第八章中讨论如何调整查询以避免由锁定引起的性能问题。
锁定粒度
提高共享资源并发性的一种方法是更加选择性地锁定你要锁定的内容。而不是锁定整个资源,只锁定包含你需要更改的数据的部分。更好的是,只锁定你计划更改的确切数据片段。在任何给定时间最小化你锁定的数据量,让对给定资源的更改可以同时发生,只要它们不相互冲突。
不幸的是,锁并不是免费的——它们会消耗资源。每个锁操作——获取锁、检查锁是否空闲、释放锁等——都有开销。如果系统花费太多时间管理锁而不是存储和检索数据,性能可能会受到影响。
锁定策略是锁定开销和数据安全之间的一种折衷,这种折衷会影响性能。大多数商用数据库服务器并不给你太多选择:在你的表中,你得到的是所谓的行级锁定,有各种复杂的方式来提供许多锁的良好性能。锁是数据库如何实现一致性保证的方式。一个数据库的专家操作员必须深入阅读源代码,以确定最适合的一组调整配置,以优化速度与数据安全之间的这种权衡。
另一方面,MySQL 提供了选择。它的存储引擎可以实现自己的锁定策略和锁定粒度。锁管理是存储引擎设计中非常重要的决定;将粒度固定在某个级别可以提高某些用途的性能,但使该引擎不太适合其他用途。因为 MySQL 提供了多个存储引擎,它不需要一个单一的通用解决方案。让我们看看两种最重要的锁定策略。
表锁
MySQL 中最基本的锁定策略,也是开销最低的策略是表锁。表锁 类似于前面描述的电子表格锁:它锁定整个表。当客户端希望写入表(插入、删除、更新等)时,它会获取写锁。这会阻止所有其他读取和写入操作。当没有人在写入时,读者可以获取读锁,这些读锁不会与其他读锁冲突。
表锁在特定情况下有改进性能的变体。例如,READ LOCAL 表锁允许某些类型的并发写操作。写锁和读锁队列是分开的,写队列完全比读队列的优先级高。³
行锁
提供最大并发性(并带来最大开销)的锁定风格是使用行锁。回到电子表格的类比,行锁 就像只锁定电子表格中的行一样。这种策略允许多人同时编辑不同的行,而不会相互阻塞。这使服务器能够进行更多并发写入,但代价是需要跟踪谁拥有每个行锁,它们已经打开多久,以及它们是什么类型的行锁,以及在不再需要时清理锁。
行锁是在存储引擎中实现的,而不是在服务器中。服务器大部分时间⁴ 对在存储引擎中实现的锁定是不知情的,正如你将在本章和整本书中看到的,存储引擎都以自己的方式实现锁定。
事务
在深入研究数据库系统的更高级功能之前,你会发现事务的存在。事务是一组 SQL 语句,被视为一个原子单元的工作。如果数据库引擎可以将整组语句应用到数据库中,它会这样做,但如果由于崩溃或其他原因无法完成其中任何一个,那么所有语句都不会被应用。要么全部成功,要么全部失败。
这一部分与 MySQL 无关。如果你已经熟悉 ACID 事务,请随时跳到“MySQL 中的事务”。
银行应用程序是为什么需要事务的经典例子。想象一个银行的数据库有两个表:支票和储蓄。要将$200 从简的支票账户转移到她的储蓄账户,你需要至少执行三个步骤:
-
确保她的支票账户余额大于$200。
-
从她的支票账户余额中减去$200。
-
给她的储蓄账户余额加上$200。
整个操作应该包裹在一个事务中,这样如果任何一个步骤失败,已完成的步骤可以被回滚。
你可以使用START TRANSACTION语句开始一个事务,然后使用COMMIT使其更改永久化,或者使用ROLLBACK放弃更改。因此,我们示例事务的 SQL 可能如下所示:
1 START TRANSACTION;
2 SELECT balance FROM checking WHERE customer_id = 10233276;
3 UPDATE checking SET balance = balance - 200.00 WHERE customer_id = 10233276;
4 UPDATE savings SET balance = balance + 200.00 WHERE customer_id = 10233276;
5 COMMIT;
事务本身并不是全部。如果数据库服务器在执行第 4 行时崩溃会发生什么?谁知道呢?客户可能刚刚损失了$200。如果另一个进程在第 3 行和第 4 行之间出现并移除整个支票账户余额会发生什么?银行已经给客户提供了$200 的信用,甚至自己都不知道。
在这个操作序列中还有很多失败的可能性。你可能会遇到连接中断、超时,甚至在操作中途数据库服务器崩溃。这通常是为什么高度复杂和缓慢的两阶段提交系统存在的原因:以减轻各种故障场景。
事务并不足够,除非系统通过 ACID 测试。ACID 代表原子性(atomicity)、一致性(consistency)、隔离性(isolation)和持久性(durability)。这些是数据安全事务处理系统必须满足的紧密相关标准:
原子性
事务必须作为一个单一不可分割的工作单元运行,以便整个事务要么被应用,要么永远不被提交。当事务是原子的时,不存在部分完成的事务:要么全部成功,要么全部失败。
一致性
数据库应该始终从一个一致的状态转移到下一个一致的状态。在我们的示例中,一致性确保在第 3 行和第 4 行之间发生崩溃时,支票账户中不会消失$200。如果事务从未提交,事务的任何更改都不会反映在数据库中。
隔离性
事务的结果通常对其他事务是不可见的,直到事务完成。这确保如果在我们的示例中的第 3 行和第 4 行之间运行银行账户摘要,它仍然会看到支票账户中的$200。当我们在本章后面讨论隔离级别时,你会明白为什么我们说“通常不可见”。
持久性
一旦提交,事务的更改就是永久的。这意味着更改必须被记录,以防止在系统崩溃时丢失数据。然而,持久性是一个稍微模糊的概念,因为实际上有许多级别。一些持久性策略提供比其他更强的安全保证,而且没有什么是 100%持久的(如果数据库本身真的是持久的,那么备份如何增加持久性呢?)。
ACID 事务和 InnoDB 引擎特别提供的保证是 MySQL 中最强大和最成熟的功能之一。虽然它们会带来一定的吞吐量折衷,但当适当应用时,它们可以避免在应用层实现大量复杂逻辑。
隔离级别
隔离性比看起来更复杂。ANSI SQL 标准定义了四个隔离级别。如果您是数据库领域的新手,我们强烈建议您在阅读有关具体 MySQL 实现之前熟悉 ANSI SQL 的一般标准⁶。该标准的目标是定义更改在事务内外何时可见和何时不可见的规则。较低的隔离级别通常允许更高的并发性并具有较低的开销。
注意
每个存储引擎对隔离级别的实现略有不同,并且不一定与您习惯于其他数据库产品时所期望的相匹配(因此,在本节中我们不会详细介绍)。您应该阅读您决定使用的任何存储引擎的手册。
让我们快速看一下四个隔离级别:
READ UNCOMMITTED
在READ UNCOMMITTED隔离级别中,事务可以查看未提交事务的结果。在这个级别,除非您真的非常了解自己在做什么并且有充分的理由这样做,否则可能会发生许多问题。这个级别在实践中很少使用,因为其性能并不比其他级别好多少,而其他级别有许多优势。读取未提交的数据也被称为脏读。
READ COMMITTED
大多数数据库系统(但不包括 MySQL!)的默认隔离级别是READ COMMITTED。它满足先前使用的隔离的简单定义:事务将继续看到在其开始后提交的事务所做的更改,并且其更改在提交之前对其他人不可见。这个级别仍然允许所谓的不可重复读。这意味着您可以两次运行相同的语句并看到不同的数据。
REPEATABLE READ
REPEATABLE READ解决了READ UNCOMMITTED允许的问题。它保证事务读取的任何行在同一事务内的后续读取中“看起来相同”,但理论上仍允许另一个棘手的问题:幻读。简而言之,当您选择某些行的范围时,另一个事务将新行插入到该范围中,然后再次选择相同范围时,您将看到新的“幻影”行。InnoDB 和 XtraDB 通过多版本并发控制解决了幻读问题,我们稍后在本章中解释。
REPEATABLE READ是 MySQL 的默认事务隔离级别。
SERIALIZABLE
最高级别的隔离是SERIALIZABLE,通过强制事务按顺序排列以避免可能发生冲突来解决幻读问题。简而言之,SERIALIZABLE在读取每一行时都会放置一个锁。在这个级别,可能会发生很多超时和锁争用。我们很少看到人们使用这种隔离级别,但您的应用程序需求可能迫使您接受降低的并发性以换取数据安全性。
表 1-1 总结了各种隔离级别及与每个级别相关的缺点。
表 1-1. ANSI SQL 隔离级别
| 隔离级别 | 是否可能出现脏读 | 是否可能出现不可重复读 | 是否可能出现幻读 | 锁定读取 |
|---|---|---|---|---|
READ UNCOMMITTED |
是 | 是 | 是 | 否 |
READ COMMITTED |
否 | 是 | 是 | 否 |
REPEATABLE READ |
否 | 否 | 是 | 否 |
SERIALIZABLE |
否 | 否 | 否 | 是 |
死锁
死锁是指两个或多个事务相互持有并请求相同资源上的锁,从而创建了依赖循环。当事务尝试以不同顺序锁定资源时,就会发生死锁。无论何时多个事务锁定相同资源,都可能发生死锁。例如,考虑这两个针对StockPrice表运行的事务,该表具有主键(stock_id, date):
事务 1
START TRANSACTION;
UPDATE StockPrice SET close = 45.50 WHERE stock_id = 4 and date = ‘2020-05-01’;
UPDATE StockPrice SET close = 19.80 WHERE stock_id = 3 and date = ‘2020-05-02’;
COMMIT;
事务 2
START TRANSACTION;
UPDATE StockPrice SET high = 20.12 WHERE stock_id = 3 and date = ‘2020-05-02’;
UPDATE StockPrice SET high = 47.20 WHERE stock_id = 4 and date = ‘2020-05-01’;
COMMIT;
每个事务将执行其第一个查询并更新一行数据,将该行在主键索引中锁定,并在此过程中锁定其所属的任何其他唯一索引。然后,每个事务将尝试更新其第二行,只能发现它已被锁定。除非有某种干预来打破死锁,否则这两个事务将永远等待对方完成。我们在第七章中进一步介绍索引如何在架构演变过程中影响查询的性能。
为了解决这个问题,数据库系统实现了各种形式的死锁检测和超时。更复杂的系统,如 InnoDB 存储引擎,将注意到循环依赖关系并立即返回错误。这可能是一件好事——否则,死锁将表现为非常慢的查询。其他系统在查询超过锁等待超时后会放弃,这并不总是好事。InnoDB 目前处理死锁的方式是回滚具有最少独占行锁的事务(这是一个近似指标,哪个事务最容易回滚)。
锁行为和顺序是存储引擎特定的,因此一些存储引擎可能会在某些语句序列上发生死锁,即使其他存储引擎不会。死锁具有双重性质:一些是由于真实数据冲突而不可避免的,一些是由存储引擎的工作方式引起的。⁷
一旦发生死锁,就无法在不部分或完全回滚其中一个事务的情况下解除死锁。在事务系统中,死锁是生活中的一个事实,您的应用程序应设计为处理它们。许多应用程序可以简单地从头开始重试它们的事务,除非遇到另一个死锁,否则它们应该成功。
事务日志
事务日志有助于使事务更高效。存储引擎可以在每次更改发生时更新磁盘上的表之前更改其内存中的数据副本。这是非常快的。然后,存储引擎可以将更改记录写入事务日志,该日志位于磁盘上,因此是持久的。这也是一个相对快速的操作,因为追加日志事件涉及磁盘上一个小区域的顺序 I/O,而不是在许多地方进行随机 I/O。然后,在以后的某个时间,一个进程可以更新磁盘上的表。因此,大多数使用这种技术(称为预写式日志记录)的存储引擎最终会将更改写入磁盘两次。
如果在更新写入事务日志后但在更改数据本身之前发生崩溃,则存储引擎仍然可以在重新启动时恢复更改。恢复方法因存储引擎而异。
MySQL 中的事务
存储引擎是驱动数据如何存储和从磁盘检索的软件。虽然 MySQL 传统上提供了许多支持事务的存储引擎,但 InnoDB 现在是金标准和推荐使用的引擎。这里描述的事务基元将基于 InnoDB 引擎中的事务。
理解 AUTOCOMMIT
默认情况下,单个INSERT、UPDATE或DELETE语句会隐式包装在一个事务中并立即提交。这被称为AUTOCOMMIT模式。通过禁用此模式,您可以在事务中执行一系列语句,并在结束时COMMIT或ROLLBACK。
你可以通过使用SET命令为当前连接启用或禁用AUTOCOMMIT变量。值1和ON是等效的,0和OFF也是如此。当你运行时AUTOCOMMIT=0,你总是处于一个事务中,直到你发出COMMIT或ROLLBACK。然后 MySQL 立即开始一个新的事务。此外,启用AUTOCOMMIT后,你可以使用关键字BEGIN或START TRANSACTION开始一个多语句事务。改变AUTOCOMMIT的值对非事务表没有影响,这些表没有提交或回滚更改的概念。
在打开事务期间发出某些命令会导致 MySQL 在执行之前提交事务。这些通常是进行重大更改的 DDL 命令,如ALTER TABLE,但LOCK TABLES和其他一些语句也有这种效果。查看你版本的文档以获取自动提交事务的完整命令列表。
MySQL 允许你使用SET TRANSACTION ISOLATION LEVEL命令设置隔离级别,该命令在下一个事务开始时生效。你可以在配置文件中为整个服务器设置隔离级别,也可以仅为你的会话设置:
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
最好在服务器级别设置你最常用的隔离级别,并仅在明确的情况下更改它。MySQL 识别所有四个 ANSI 标准隔离级别,而 InnoDB 支持它们全部。
在事务中混合存储引擎
MySQL 不会在服务器级别管理事务。相反,底层存储引擎自己实现事务。这意味着你不能可靠地在单个事务中混合不同的引擎。
如果你在一个事务中混合使用事务表和非事务表(例如,InnoDB 和 MyISAM 表),如果一切顺利,事务将正常工作。然而,如果需要回滚,对非事务表的更改无法撤消。这将使数据库处于一个不一致的状态,可能很难恢复,并使事务的整个目的变得无意义。这就是为什么非常重要为每个表选择正确的存储引擎,并尽量避免在应用逻辑中混合存储引擎。
如果你在非事务表上执行事务操作,MySQL 通常不会警告你或引发错误。有时回滚事务会生成警告:“一些非事务更改的表无法回滚”,但大多数情况下,你不会得到任何指示你正在使用非事务表。
警告
最好的做法是不要在应用程序中混合存储引擎。失败的事务可能导致不一致的结果,因为某些部分可以回滚,而其他部分则无法回滚。
隐式和显式锁定
InnoDB 使用两阶段锁定协议。它可以在事务期间的任何时候获取锁,但直到COMMIT或ROLLBACK才会释放锁。它同时释放所有锁。前面描述的锁定机制都是隐式的。InnoDB 根据你的隔离级别自动处理锁。
然而,InnoDB 也支持显式锁定,SQL 标准根本没有提到:⁸^,⁹
SELECT ... FOR SHARE
SELECT ... FOR UPDATE
MySQL 还支持LOCK TABLES和UNLOCK TABLES命令,这些命令在服务器中实现,而不是在存储引擎中。如果你需要事务,请使用事务性存储引擎。LOCK TABLES是不必要的,因为 InnoDB 支持行级锁定。
提示
LOCK TABLES和事务之间的交互是复杂的,在某些服务器版本中存在意外行为。因此,我们建议无论使用哪种存储引擎,都不要在事务中使用LOCK TABLES。
多版本并发控制
MySQL 的大多数事务性存储引擎不使用简单的行级锁定机制。相反,它们与一种称为多版本并发控制(MVCC)的增加并发性技术结合使用行级锁定。MVCC 并不是 MySQL 独有的:Oracle、PostgreSQL 和一些其他数据库系统也使用它,尽管存在重大差异,因为 MVCC 应如何工作没有标准。
您可以将 MVCC 视为对行级锁定的一种变通方法;在许多情况下,它避免了锁定的需要,并且开销要低得多。根据实现方式,它可以允许非锁定读取,同时仅在写入操作期间锁定必要的行。
MVCC 通过使用数据在某个时间点存在的快照来工作。这意味着事务可以看到数据的一致视图,无论它们运行多长时间。这也意味着不同的事务可以同时在相同的表中看到不同的数据!如果您以前从未经历过这种情况,可能会感到困惑,但随着熟悉度的增加,您会更容易理解。
每个存储引擎都以不同方式实现 MVCC。一些变体包括乐观和悲观并发控制。我们通过解释 InnoDB 的行为来说明 MVCC 的一种工作方式,形式为图 1-2 中的序列图。
InnoDB 通过为每个启动的事务分配事务 ID 来实现 MVCC。该 ID 是在事务第一次读取任何数据时分配的。当在该事务内修改记录时,将向撤销日志写入解释如何撤消该更改的撤销记录,并且事务的回滚指针指向该撤销日志记录。这就是事务可以找到回滚的方法的方式。

图 1-2。处理不同事务中一行的多个版本的序列图
当不同会话读取群集键索引记录时,InnoDB 会比较记录的事务 ID 与该会话的读取视图。如果记录在当前状态下不应可见(更改它的事务尚未提交),则会跟随并应用撤销日志记录,直到会话达到可以可见的事务 ID。这个过程可以一直循环到一个完全删除此行的撤销记录,向读取视图发出此行不存在的信号。
通过在记录的“信息标志”中设置“删除”位来删除事务中的记录。这也在撤销日志中跟踪为“删除标记”。
值得注意的是,所有撤销日志写入也都会被重做记录,因为撤销日志写入是服务器崩溃恢复过程的一部分,并且是事务性的。[¹¹] 这些重做和撤销日志的大小也在高并发事务执行中扮演着重要角色。我们将在第五章中更详细地介绍它们的配置。
所有这些额外的记录保留的结果是,大多数读取查询从不获取锁。它们只是尽可能快地读取数据,确保只选择符合条件的行。缺点是存储引擎必须在每行存储更多数据,在检查行时做更多工作,并处理一些额外的管理操作。
MVCC 仅适用于REPEATABLE READ和READ COMMITTED隔离级别。READ UNCOMMITTED不兼容 MVCC,因为查询不会读取适合其事务版本的行版本;无论如何,它们都会读取最新版本。SERIALIZABLE不兼容 MVCC,因为读取会锁定它们返回的每一行。
复制
MySQL 设计用于在任何给定时间接受一个节点上的写入。这在管理一致性方面具有优势,但在需要将数据写入多个服务器或多个位置时会产生折衷。MySQL 提供了一种本地方法来将一个节点接受的写入分发到其他节点。这被称为复制。在 MySQL 中,源节点每个副本都有一个线程作为复制客户端登录,当发生写入时会唤醒,发送新数据。在图 1-3 中,我们展示了这种设置的简单示例,通常称为源和副本设置中的多个 MySQL 服务器的拓扑树。

图 1-3. MySQL 服务器复制拓扑的简化视图
对于在生产环境中运行的任何数据,您应该使用复制,并至少有三个以上的副本,最好分布在不同位置(在云托管环境中称为区域)以进行灾难恢复规划。
多年来,MySQL 中的复制变得更加复杂。全局事务标识符、多源复制、副本上的并行复制和半同步复制是一些主要更新。我们在第九章中详细介绍了复制。
数据文件结构
在 8.0 版本中,MySQL 将表元数据重新设计为包含在表的.ibd文件中的数据字典。这使得关于表结构的信息支持事务和原子数据定义更改。在操作期间检索表定义和元数据不再仅依赖于information_schema,我们引入了字典对象缓存,这是一个基于最近最少使用(LRU)的内存缓存,其中包含分区定义、表定义、存储程序定义、字符集和校对信息。服务器访问表的元数据的这一重大变化减少了 I/O,尤其是如果一部分表是最活跃的并且因此最常见于缓存中的话,这是有效的。.ibd 和 .frm 文件被替换为每个表的序列化字典信息(.sdi)。
InnoDB 引擎
InnoDB 是 MySQL 的默认事务存储引擎,也是最重要和最广泛使用的引擎。它设计用于处理许多短暂事务,这些事务通常会完成而不是回滚。其性能和自动崩溃恢复使其在非事务性存储需求中也很受欢迎。如果您想研究存储引擎,深入学习 InnoDB 是值得的,以尽可能多地了解它,而不是平等地研究所有存储引擎。
注意
最佳实践是将 InnoDB 存储引擎作为任何应用程序的默认引擎。MySQL 通过几个主要版本之前将 InnoDB 设为默认引擎,使这一点变得容易。
InnoDB 是默认的 MySQL 通用存储引擎。默认情况下,InnoDB 将其数据存储在一系列数据文件中,这些文件统称为表空间。表空间本质上是 InnoDB 自行管理的一个黑盒。
InnoDB 使用 MVCC 实现高并发,并实现了所有四个 SQL 标准隔离级别。它默认使用REPEATABLE READ隔离级别,并具有防止在此隔离级别中出现幻读的 next-key 锁定策略:InnoDB 不仅锁定您在查询中触及的行,还锁定索引结��中的间隙,防止插入幻影。
InnoDB 表是建立在聚簇索引上的,我们将在第八章中详细讨论架构设计时进行介绍。InnoDB 的索引结构与大多数其他 MySQL 存储引擎非常不同。因此,它提供非常快速的主键查找。但是,次要索引(非主键的索引)包含主键列,因此如果您的主键很大,其他索引也会很大。如果您将在表上有许多索引,应该努力使主键尽可能小。
InnoDB 具有各种内部优化。这些包括从磁盘读取数据的预测性读取,自适应哈希索引自动在内存中构建哈希索引以进行非常快速的查找,以及插入缓冲区以加快插入速度。我们将在本书的第四章中介绍这些内容。
InnoDB 的行为非常复杂,如果你正在使用 InnoDB,我们强烈建议阅读 MySQL 手册中的“InnoDB 锁定和事务模型”部分。由于其 MVCC 架构,建议在使用 InnoDB 构建应用程序之前,您应该了解许多微妙之处。
作为事务性存储引擎,InnoDB 通过各种机制支持真正的“热”在线备份,包括 Oracle 的专有 MySQL 企业备份和开源 Percona XtraBackup。我们将在第十章中详细讨论备份和恢复。
从 MySQL 5.6 开始,InnoDB 引入了在线 DDL,在最初的版本中有限的用例在 5.7 和 8.0 版本中得到扩展。原地模式更改允许进行特定表更改而无需完全锁定表,也无需使用外部工具,这极大地提高了 MySQL InnoDB 表的操作性。我们将在第六章中涵盖在线模式更改的选项,包括本机和外部工具。
JSON 文档支持
JSON 类型是作为 5.7 版本的一部分首次引入 InnoDB 的,它具有 JSON 文档的自动验证以及优化的存储,可以快速读取访问,这对于旧式二进制大对象(BLOB)存储工程师过去常常使用的权衡来说是一个重大改进。除了新的数据类型支持外,InnoDB 还引入了支持 JSON 文档的 SQL 函数。MySQL 8.0.7 中的进一步改进增加了在 JSON 数组上定义多值索引的能力。这个功能可以通过将常见访问模式与能够映射 JSON 文档值的函数匹配,进一步加快对 JSON 类型的读取访问查询。我们将在第六章中的“JSON 数据”中讨论 JSON 数据类型的使用和性能影响。
数据字典更改
MySQL 8.0 的另一个重大变化是删除基于文件的表元数据存储,并转而使用 InnoDB 表存储的数据字典。这一变化将 InnoDB 的所有崩溃恢复事务性优势带到了表更改等操作中。这一变化虽然极大地改进了 MySQL 中数据定义的管理,但也需要对 MySQL 服务器的操作进行重大更改。特别值得注意的是,以前依赖表元数据文件的备份过程现在必须查询新数据字典以提取表定义。
原子 DDL
最后,MySQL 8.0 引入了原子数据定义更改。这意味着数据定义语句现在要么完全成功完成,要么完全回滚。这通过创建一个专门用于 DDL 的撤销和重做日志成为可能,InnoDB 依赖于此来跟踪变化——这是 InnoDB 成熟设计被扩展到 MySQL 服务器操作的另一个地方。
摘要
MySQL 有分层架构,顶部是服务器范围的服务和查询执行,底部是存储引擎。尽管有许多不同的插件 API,但存储引擎 API 是最重要的。如果您理解 MySQL 通过在存储引擎 API 上来回传递行来执行查询,那么您已经掌握了服务器架构的基本原理。
在过去几个主要版本中,MySQL 已将 InnoDB 定为其主要开发重点,并在多年后将其内部账务处理、身份验证和授权移至 MyISAM。Oracle 对 InnoDB 引擎的增加投资导致了诸如原子 DDL、更强大的在线 DDL、更好的抗崩溃能力以及更适合安全部署的操作性等重大改进。
InnoDB 是默认存储引擎,几乎可以覆盖所有用例。因此,在谈论功能、性能和限制时,以下章节将重点关注 InnoDB 存储引擎,很少会涉及其他存储引擎。
¹ 唯一的例外是 InnoDB,因为 MySQL 服务器尚未实现外键定义,所以 InnoDB 解析外键定义。
² MySQL 5.5 及更新版本支持一个可以接受线程池插件的 API,尽管并不常用。线程池的常见做法是在访问层完成的,我们在第五章中讨论过。
³ 我们强烈建议阅读关于独占锁与共享锁、意向锁和记录锁的文档。
⁴ 在处理表名更改或模式更改时会使用元数据锁,而在 8.0 中我们引入了“应用级锁定功能”。在日常数据更改过程中,内部锁定留给了 InnoDB 引擎。
⁵ 尽管这是一个常见的学术练习,但大多��银行实际上依赖每日对账,而不是在白天依赖严格的事务操作。
⁶ 欲了解更多信息,请阅读 Adrian Coyler 撰写的 ANSI SQL 摘要和 Kyle Kingsbury 撰写的一篇关于一致性模型的解释。
⁷ 正如您将在本章后面看到的,一些存储引擎锁定整个表,而其他一些实现更复杂的基于行的锁定。所有这些逻辑在很大程度上存在于存储引擎层。
⁸ 这些锁定提示经常被滥用,通常应该避免使用。
⁹ SELECT…FOR SHARE 是 MySQL 8.0 的一个新特性,取代了之前版本中的 SELECT…LOCK IN SHARE MODE。
¹⁰ 我们建议阅读 Jeremy Cole 的这篇博文,以更深入地了解 InnoDB 中的记录结构。
¹¹ 想要了解 InnoDB 如何处理其记录的多个版本,建议阅读 Jeremy Cole 的这篇博文。
¹² 没有正式的标准定义 MVCC,因此不同的引擎和数据库实现方式大不相同,没有人能说其中任何一种是错误的。
第二章:在可靠性工程世界中进行监控
监控系统是一个广泛的主题,在过去几年中受到了《站点可靠性工程:谷歌如何运行生产系统》(O'Reilly)及其后续作品《站点可靠性工作手册:实施 SRE 的实用方法》(O'Reilly)的重要工作的��响。自这两本书出版以来,站点可靠性工程(SRE)已成为开放职位招聘中的热门趋势。一些公司甚至已经将现有员工的职称更改为某种“可靠性工程”。
站点可靠性工程改变了团队对运营工作的看法。这是因为它包含一组原则,使我们更容易回答诸如以下问题:
-
我们是否提供了可接受的客户体验?
-
我们应该专注于可靠性和弹性工作吗?
-
我们如何在新功能和琐事之间取得平衡?
本章希望读者了解这些原则是什么。如果您没有阅读上述任何一本书,我们建议从《站点可靠性工作手册》中的这些章节作为速成课程:
-
第一章提供了更深入理解如何朝着在生产中实现服务水平性能管理的哲学的方向发展。
-
第二章涵盖了如何实施服务水平目标(SLO)。
-
第五章涵盖了对 SLO 的警报。
有人可能会认为 SRE 实施并不严格属于高性能 MySQL 的一部分,但我们不同意。在她的书《加速》中,尼古拉·福斯格伦博士说:“我们的衡量应该关注结果,而不是产出。”有效 MySQL 管理的一个关键方面是对数据库健康状况进行良好的监控。传统监控是一条相对铺好的道路。由于 SRE 是一个新领域,如何实施 SRE 原则来应对 MySQL 还不太清楚。随着 SRE 原则的不断获得认可,DBA 的传统角色将发生变化,包括 DBA 如何考虑监控他们的系统。
可靠性工程对 DBA 团队的影响
多年来,监控数据库性能依赖于对单个服务器性能的深入研究。这仍然具有很大的价值,但更多地倾向于是关于反应性测量,比如对性能不佳的服务器进行分析。在门户守卫 DBA 团队的时代,这是标准操作程序,当时其他人不被允许知道数据库的运行方式。
进入谷歌对可靠性工程的介绍。DBA 的角色变得更加复杂,演变成了更多的站点可靠性工程师(SRE)或数据库可靠性工程师(DBRE)。团队必须优化他们的时间。服务水平帮助您定义客户何时感到不满意,并允许您更好地平衡您的时间,解决性能问题和扩展挑战,以及处理内部工具的工作。让我们讨论您需要监视 MySQL 的不同方式,以确保成功的客户体验。
定义服务水平目标
在进入如何衡量客户对数据库集群性能是否满意之前,我们必须首先了解我们的目标是什么,并就描述这些目标的共同语言达成一致。以下是一些问题,可以作为组织中的对话开端,以定义这些目标:
-
什么是适合衡量成功的指标?
-
这些指标的哪些值对客户和我们的业务需求是可接受的?
-
在何时我们被认为处于降级状态?
-
何时我们完全处于失败状态并需要尽快进行补救?
有些问题有明显的答案(例如,源数据库宕机,我们不接受任何写入,因此业务停滞)。有些问题则不那么明显,比如定期任务有时会占用所有数据库磁盘 I/O,突然其他所有操作变慢。在整个组织中对我们正在衡量的内容和原因有共享理解,可以帮助指导优先级对话。通过组织内持续对话达成共识,有助于指导您是否可以将工程工作投入新功能,或者是否需要更多投入于性能改进或稳定性。
在 SRE 实践中,关于客户满意度的讨论将使团队对于服务水平指标(SLIs)、SLOs 和服务水平协议(SLAs)在业务方面的健康状况达成一致。让我们首先定义这些术语的含义:
服务水平指标(SLI)
用非常简单的术语来说,SLI 回答了这个问题,“我如何衡量我的客户是否满意?”答案代表了用户角度的健康系统。SLIs 可以是业务级别的指标,比如“面向客户的 API 的响应时间”,或者更基本的“服务是否正常”。您可能会发现,根据数据的上下文以及与产品的关系,您需要不同的指标或度量标准。
服务水平目标(SLO)
SLO 回答了这个问题,“为了确保我的客户满意,我可以允许我的 SLI 的最低值是多少?” SLO 是我们希望在给定 SLI 下达到的目标范围,以被视为健康服务。如果您认为正常运行时间是 SLI,那么您希望在给定时间段内正常运行的次数就是 SLO。必须将 SLO 定义为在给定时间范围内的值,以确保每个人对 SLO 的含义达成一致。 SLI 加上 SLO 形成了了解客户是否满意的基本方程。
服务水平协议(SLA)
SLA 提供了这个问题的答案,“我愿意同意什么样的 SLO 并承担后果?” SLA 是一个 SLO,已包含在与业务的一个或多个客户(付费客户,而不是内部利益相关者)的协议中,如果未达到该 SLA 则会有财务或其他惩罚。重要的是要注意,SLA 是可选的。
我们在本章中不会过多涉及 SLAs,因为它们往往需要更多的业务讨论而不是工程讨论。这种决定主要取决于业务期望如果在合同中承诺 SLA 会得到什么销售额,以及如果 SLA 被违反是否值得冒险损失收入。希望这样的决定是基于我们在这里涵盖的关于选择 SLIs 和匹配 SLOs 的内容。
定义这些 SLI、SLO 和 SLA 不仅指导业务的健康状况,还指导工程团队内的规划。如果一个团队没有达到其约定的 SLO,那么就不应继续进行新功能的工作。对于数据库工程团队也是如此。如果我们在本章讨论的潜在 SLO 之一没有达到,那就应该引发为什么没有达到的讨论。当您拥有数据来解释为什么客户体验不佳时,您可以就团队优先事项进行更有意义的对话。
使客户满意需要什么?
在选择一组指标作为您的 SLIs 后,可能会有诱惑将目标设定为 100%。然而,您必须抵制这种冲动。请记住,选择指标和目标的目的是随时通过客观指标评估您的团队是否可以通过新功能进行创新,或者稳定性是否有可能降至客户可接受水平以下,因此需要更多关注和资源。目标是定义使客户满意的绝对最低要求。如果客户对您的页面在两秒内加载感到满意,那么没有必要设定页面在 750 毫秒内加载的目标。这可能会给工程团队带来不合理的负担。
以正常运行时间作为指标和目标值的例子,我们可以宣称“我们不会有任何停机时间”,但在实施和跟踪是否达到目标时,这意味着什么?达到三个九的可用性并不是一件小事。一整年的三个九仅相当于八个多小时,换算成每周仅为 10 分钟。你承诺的九越多,这就越困难,团队将不得不花费更多昂贵的工程时间来实现这样的承诺。表 2-1 是亚马逊网络服务展示挑战的有用数据表。
表 2-1. 各种可用时间
| 可用性 | 每年停机时间 | 每月停机时间 | 每周停机时间 | 每日停机时间 |
|---|---|---|---|---|
| 99.999% | 5 分钟,15.36 秒 | 26.28 秒 | 6.06 秒 | 0.14 秒 |
| 99.995% | 26 分钟,16.8 秒 | 2 分钟,11.4 秒 | 30.3 秒 | 4.32 秒 |
| 99.990% | 52 分钟,33.6 秒 | 4 分钟,22.8 秒 | 1 分钟,0.66 秒 | 8.64 秒 |
| 99.950% | 4 小时,22 分钟,48 秒 | 31 分钟,54 秒 | 5 分钟,3 秒 | 43 秒 |
| 99.900% | 8 小时,45 分钟,36 秒 | 43 分钟,53 秒 | 10 分钟,6 秒 | 1 分钟,26 秒 |
| 99.500% | 43 小时,48 分钟,36 秒 | 3 小时,39 分钟 | 50 小时,32 分钟,17 秒 | 7 分钟,12 秒 |
| 99.250% | 65 小时,42 分钟 | 5 小时,34 分钟,30 秒 | 1 小时,15 分钟,48 秒 | 10 分钟,48 秒 |
| 99.000% | 3 天,15 小时,54 分钟 | 7 小时,18 分钟 | 1 小时,41 分钟,5 秒 | 14 分钟,24 秒 |
因为工程时间是有限资源,选择服务水平目标时不要追求完美。产品中并非所有功能都需要这些九来满足客户,因此随着产品功能集的增长,你会发现根据特定功能影响或其带来的收入,你将有不同的服务水平指标和目标。这是可以预期的,也是一个深思熟虑过程的标志。你在这里有一个关键任务:检测数据集何时成为不同利益相关者的瓶颈,危及性能。这也意味着找到一种方法来区分这些不同利益相关者的需求,以便为他们提供合理的服务水平指标和目标。
这些指标和目标也��产品和工程之间具有统一语言的有效方式,指导在“将工程时间花在新功能上”与“将时间花在弹性和解决问题上”之间做出决策。这也是一种决定,从我们想要实现的事情清单中,基于客户体验来确定哪个最重要的方式。你可以使用服务水平指标和目标来指导工作优先级的对话,否则很难达成一致。
应该衡量什么
假设有一家公司,其产品是一个在线商店。由于增加了在线购物,公司看到了更多的流量,基础设施团队需要确保数据库层能够处理增加的需求。在本节中,我们将讨论作为虚构基础设施团队时应该如何衡量的内容。
定义服务水平指标和目标
定义一个良好的服务水平指标和相匹配的服务水平目标的核心在于简洁地解释如何为客户提供愉快的用户体验。我们不会花费大量时间在抽象层面上解释如何创建有意义的服务水平指标和目标。² 在 MySQL 的背景下,它需要是一个定义了三个主要主题的表示:可用性、延迟和关键错误缺失。
对于我们的在线商店示例,这意味着页面加载速度要快,至少在一个月内 99.5% 的时间内快于几百毫秒。这还意味着一个可靠的结账流程,在给定日历月内只允许 1% 的时间发生间歇性故障。请注意这些指标和目标的定义。我们没有将 100% 定义为要求,因为我们生活在一个失败不可避免的世界中。我们使用时间跨度,以便团队可以准确平衡其在新功能和弹性之间的工作。
“我期望我的数据库请求中有 99.5% 在两毫秒内无错误地提供服务”既是一个具有明确 SLO 的充分 SLI,又不简单。您无法通过一个指标来确认所有这些。这是对数据库层行为的单句表述,以提供可接受的客户体验。
那么在我们的在线商店中,可以构建这种客户体验画面的度量标准是什么?从在生产环境中对页面加载进行采样负载率的合成测试开始。这对于作为一个一致的信号表明“一切正常”是有用的。但这只是一个开始。让我们讨论跟踪不同信号的各个方面以构建画面。随着我们通过这些示例,我们将把它与我们的在线商店联系起来,帮助您可视化这些不同的度量标准如何创建一个良好的客户体验画面。首先,让我们谈谈跟踪查询响应时间。
监控解决方案
在 SLIs 和 SLOs 的背景下进行查询分析和监控查询延迟需要关注客户体验。这意味着依赖可以在查询响应时间超过约定阈值时尽快向您发出警报的工具。让我们讨论一下您可以采取的几种路径来实现这种监控水平。
商业选项
这是一个例子,支付一个竞争优势在于 MySQL 性能分析的供应商可以让您的组织获得丰厚回报。像SolarWinds 数据库性能管理这样的工具可以大大简化查询性能分析的自动化,并让您的工程团队中的大部分人都能够访问。
开源选项
一个成熟的开源选项是Percona 监控与管理,简称 PMM。它作为一个客户端/服务器对运行。您在数据库实例上安装客户端,它收集并发送指标到服务器部分。服务器端还有一组仪表板,允许您查看与性能相关的图表。PMM 的一个主要优点是,仪表板的组织受到 Percona 社区长期监控 MySQL 性能经验的指导。这��其成为一个极好的资源,让新手工程师熟悉如何监控 MySQL 性能。
您可以采取的另一种方法是将数据库慢日志和 MySQL 性能模式输出发送到一个集中位置,您可以使用像pt-query-digest这样的知名工具,它是 Percona Toolkit 包的一部分,来分析日志并更深入地了解数据库实例花费时间的情况。虽然有效,但这个过程可能会很慢,并且如果使用不当可能会影响客户。理想情况下,您希望在客户注意到问题之前发现问题。在发生问题后被动地检查日志,您会面临因为发现性能退化需要花费很长时间以及挖掘各种事后证据的风险,从而磨损客户信任。
最后,使用性能模式来分析 MySQL 性能可能非常有帮助,正如您将在第三章中看到的那样。您可以使用它来找出瓶颈,使您的实例在相同规格下做更多事情,节省基础设施成本,或回答“为什么这个操作花费这么长时间?”这不是一个确定您是否符合服务可靠性承诺的工具,因为它深入到 MySQL 的内部。对于服务水平性能评估,我们需要一种新的关于性能的思考方式。
现在让我们深入了解一些额外的指标,帮助您进一步了解您在线商店的客户体验。您应该考虑从 MySQL 中获取的指标,而不是输出。我们还将涵盖一些单凭 MySQL 指标无法衡量的事例。
监控可用性
一个间歇性下线的在线商店会冒着侵蚀购物者信心的风险。这就是为什么可用性作为一个独立的指标,以及作为您对客户体验的看法的一部分,是如此重要。
可用性是能够在没有错误的情况下响应客户请求。用标准的 HTTP 术语来表述,这可能是一个明确成功的响应,比如 200 响应代码,或者是成功接受请求并承诺异步完成相关工作的响应,比如 202 accepted。在单体主机系统时代,可用性曾经是一个简单的指标。如今,大多数架构要复杂得多。可用性的概念也演变成对分布式系统故障的更微妙的反映。在尝试将可用性转化为数据库架构的 SLI 和 SLO 时,考虑进一步讨论更多细节(以及我们在线商店的示例),例如以下内容:
-
在处理不可避免的灾难性故障时,哪些功能是不可妥协的,哪些功能是“nice to have”(例如,客户是否可以继续使用现有的购物车并结账,但在此故障期间可能无法添加新商品)?
-
我们将哪些类型的故障定义为“灾难性”(例如,列表搜索失败可能不是灾难性,但结账操作失败就是)?
-
“降级功能”是什么样子的(例如,我们是否可以在需要时加载通用推荐而不是基于过去购买历史的定制推荐)?
-
在一组可能的故障场景中,我们可以为核心功能承诺的最短平均恢复时间(MTTR)是多少(例如,如果支持购物车结账系统的数据库写入失败,我们可以安全地多快地切换到新的源节点)?
在选择一组代表可用性的指标时,您希望与客户支持团队设定期望,“100%的正常运行时间”是不合理的,重点是在一个理解和接受组件故障不可避免的世界中提供尽可能最佳的客户体验。
验证可用性的首选方法是来自客户端或远程端点。如果您可以访问客户端的数据库访问日志,这可以被动地完成。明确地说,这意味着如果您的应用程序是 PHP 并且在 Apache 下运行,您需要访问 Apache 日志以确定 PHP 是否发出任何连接到数据库的错误。您也可以主动验证可用性。如果您的环境被隔离并且无法访问客户端日志,考虑设置远程代码来执行对数据库的操作以确保其可用性。这可以是一些简单的操作,比如一个SELECT 1查询,用于验证 MySQL 是否接收并解析您的查询,但不访问存储层。或者这可以更复杂,比如从表中读取实际数据或执行写入和随后读取以验证写入是否成功。来自网络中其他位置的这种合成事务可以让您了解您的应用程序是否可用。
远程验证可用性对于跟踪可用性目标非常有用。它无法帮助您在问题出现之前获得洞察。用作可用性问题的领先指标的一个 MySQL 指标是Threads_running。它跟踪当前在给定数据库主机上正在运行的查询数量。当运行的线程数量快速增长且没有显示任何下降迹象时,这表明查询完成速度不够快,因此正在堆积并消耗资源。允许这个指标增长通常会导致数据库主机在源节点上��起完全的 CPU 锁定或强烈的内存负载,这可能导致操作系统关闭整个 MySQL 进程。如果这种情况发生在源节点上,显然会导致重大故障,因此您应该努力获得领先指标。监视的起点是检查您有多少 CPU 核心,如果Threads_running超过了这个值,这可能表明您的服务器正处于危险状态。除此之外,您还可以监视接近max_connections时的情况,这是另一个检查工作进度过载的数据点。
“安全设置”部分在第五章中提供了关于如何设置 MySQL 线程的制动器的见解。
监控查询延迟
MySQL 引入了许多长期需要的增强功能来跟踪查询运行时间,随着应用代码的变化,您应该绝对使用监控堆栈来跟踪这些趋势。然而,这仍然不能完全反映客户体验,特别是考虑到现代软件架构的设计方式。除了内部跟踪的延迟之外,您还需要了解应用程序如何感知延迟以及当感知延迟增加时会发生什么。这意味着除了直接跟踪数据库服务器的查询延迟之外,您还应该通过工具让客户端报告查询完成时间,以便尽可能接近客户体验。从客户端摄取所有这些样本指标(特别是当您的基础设施规模扩大时)可以使用付费工具如 Datadog 或 SolarWinds Database Performance Monitor,甚至使用开源工具如 PMM。这是一个需要与组织的应用开发人员密切合作的领域。您需要了解应用团队如何从应用程序角度衡量这一点,并使用跟踪工具如 Honeycomb 或 Lightstep 来增加对异常值的洞察力。
监控错误
您是否需要跟踪并警报每次发生的错误?这取决于情况。
在运行服务中的 MySQL 客户端存在错误并不意味着一定有什么东西出了问题。在分布式系统的世界中,有许多情况下客户端可能遇到间歇性错误,并且在许多情况下,通过简单重试失败的查询可以解决。然而,发生错误的速率,跨越处理基础设施中数据库查询的服务群体,可能是潜在问题的关键指标。以下是一些客户端错误的例子,通常可能只是噪音,但如果它们的速率加快,则可能是问题的迹象:
锁等待超时
你的客户报告这种错误急剧增加可能是源节点上的行锁争用升级的迹象,事务不断重试仍然失败。这可能是写入停机的前兆。
中止连接
客户报告突然出现大量中止连接可能是你在客户端和数据库实例之间的任何访问层存在问题的指标。不追踪这些问题可能导致大量客户端重试,消耗资源。
MySQL 服务器跟踪的一个可以帮助你的东西是名为Connection_errors_xxx的服务器变量集,其中xxx是不同类型的连接错误。任何这些计数器的突然增加都可能是一个强烈的指示,告诉你当前有一些新的异常情况。
是否有错误,一个单个实例意味着有问题需要处理?是的。
例如,收到 MySQL 实例正在以只读模式运行的错误是一个问题的迹象,即使这些错误并不经常发生。这可能意味着你刚刚将一个复制实例提升为源,但它仍在只读模式下运行(你确实在只读模式下运行复制实例,对吧?),这意味着对于你的集群来说写入的停机时间。或者这可能意味着在发送写流量到复制实例的访问层中存在一些问题。在这两种情况下,这不是一个通过重试解决的间歇性问题的迹象。
另一个服务器端错误,表明存在重大问题的标志是“连接过多”或操作系统级别的“无法创建新线程”。这些是你的应用层创建并保留的连接数超过了数据库服务器配置允许的数量的迹象,无论是在服务器max_connections变量还是 MySQL 进程允许打开的线程数方面。这些错误会立即转换为 5xx 错误传递给你的应用程序,并且根据你的应用程序设计,也可能对你的客户产生影响。
正如你所看到的,衡量性能并选择围绕 SLIs 构建哪些错误,这既是一个技术问题,也是一个沟通和社交问题,所以你应该做好准备。
主动监控
正如我们所说,SLO 监控侧重于你的客户是否满意。这有助于让你专注于在客户不满意时改善他们的体验,以及在其他任务上,比如减少重复劳动时。这忽略了一个关键领域:主动监控。
如果我们回到我们的在线商店示例以及我们如何设想监控客户体验,我们可以进一步阐述。想象一下,你没有遇到任何组件的重大故障,但你注意到有越来越多的客户支持票证报告“缓慢”或偶尔出现的错误,似乎会自行消失。你如何追踪这样的行为?如果你不已经对多个信号的基准性能有一个很好的想法,这可能是一项非常困难的任务。你用来触发值班警报的仪表板和脚本可以称为稳态监控。这让你知道在给定系统中是否发生了意外情况,无论是否有变化。它们是在客户经历故障之前为你提供领先指标的重要工具。
在监控中需要取得平衡的一点是,它始终需要是可操作的,同时也需要是真正的领先指标。对于数据库磁盘空间已满时发出警报已经太晚了,因为服务已经停止了,但是在 80%时发出警报可能太慢,或者如果增长速率不那么快,则可能不够可操作。
让我们谈谈你可以监控的有用信号,这些信号与实际客户影响没有直接关联。
磁盘增长
跟踪磁盘增长是一种你可能不会考虑直到它成为问题的指标。一旦它成为问题,解决问题可能会耗费时间并影响你的业务。了解如何跟踪它,制定缓解计划,并知道适当的警报阈值肯定是更好的选择。
有许多策略可以用来监控磁盘增长。让我们从最理想到最低限来分解它们。
如果你的监控工具允许的话,跟踪磁盘空间使用量的增长速率可能非常有用。总会有一些情况,可用磁盘空间会相对迅速减少,使你的可用性受到威胁。长时间运行的具有大型撤消日志或更改表的事务是为什么你可能会迅速接近磁盘满的例子。有许多事故故事表明,过多的日志记录或给定数据集的插入模式的更改直到“数据库”耗尽磁盘空间才被发现。然后才会触发各种警报。
如果跟踪增长速率不可行(并非所有监控工具都提供此功能),你可以设置多个阈值,较低的警告只在工作时间触发,较高的更关键值作为非工作时间值班的警报。这使团队在工作时间之前有一个预警,以避免事情变得严重到需要叫醒某人。
如果你既不能监控增长速率,也不能为同一指标定义多个阈值,那么你至少需要确定一个磁盘空间使用量的单一阈值,在达到该阈值时向你的值班工程师发出警报。这个阈值需要足够低,以便在团队评估触发原因并考虑长期缓解措施时采取一些行动并释放磁盘空间。考虑评估磁盘的最大吞吐量(MB/s),并利用这一点来帮助计算在最大流量吞吐量下填满磁盘需要多长时间。你需要这么长的前期时间来避免事件发生。
我们在第四章中讨论了与 MySQL 如何使用磁盘空间以及在这些决策中考虑磁盘空间增长相关的操作系统和硬件配置。应该预期,希望你的业务会发展到某个程度,以至于你无法将所有数据存储在一组服务器集群中。即使你在一个可以为你扩展卷的云环境中运行,你仍然需要对此进行规划,因此你总是希望有一个空闲磁盘空间的阈值,以便你有时间进行规划并进行所需的扩展而不会惊慌。
这里的要点是确保你对磁盘空间增长有一些监控,即使你认为现在还为时过早,还不需要。这是几乎每个人都准备不足的增长轴之一。
连接增长
随着业务的增长,一个常见的线性增长层是你的应用层。你将需要更多的实例来支持登录、购物车、处理请求,或者产品背景可能是什么。所有这些添加的实例开始向数据库主机打开越来越多的连接。你可以通过添加副本、使用复制作为扩展措施,甚至使用中间件层如 ProxySQL 来将前端的增长与直接在数据库上的连接负载分离来缓解这种增长。
在流量增长的同时,数据库服务器可以支持有限数量的连接池,这是通过服务器设置max_connections配置的。一旦连接到服务器的总数达到最大值,你的数据库将不允许任何新连接,这是导致无法再向数据库打开新连接的常见原因之一,从而增加用户的错误。
监控连接增长是为了确保资源不会耗尽,从而危及数据库的可用性。这种风险可能以两种不同的方式出现:
-
应用层打开了很多未使用的连接,无故增加连接风险。这种情况的明显迹象是看到连接计数(
threads_connected)很高,但threads_running仍然很低。 -
应用层正在积极使用大量连接,有可能过载数据库。你可以通过看到
threads_connected和threads_running都很高(数百?数千?)且不断增加来区分这种状态。
在设置连接计数监控时需要考虑的一个有用的事情是依赖于百分比而不是绝对数字。threads_connected/max_connections的百分比显示了你的应用节点数量增长将带你接近数据库允许的最大连接池。这有助于监控连接增长问题的第一个阶段。
另外,你应该跟踪和警报数据库主机的繁忙程度,正如我们之前解释的,可以通过threads_running的值来看到。通常,如果这个值增长到一百以上的线程,你会开始看到增加的 CPU 使用率和内存使用率,这是数据库主机负载高的一般迹象。这对于数据库的可用性是一个直接的关注点,因为它可能升级到 MySQL 进程被操作系统杀死。一个常见的快速解决方案是使用 kill 进程命令或自动化使用它的工具,比如pt-kill,战术性地减轻负载,然后通过查询分析来查明数据库陷入这种状态的原因,这是我们之前描述过的。
警告
连接风暴是生产系统中的情况,应用层感知到查询延迟增加,并响应地向数据库层打开更多连接。这可能会给数据库增加大量负载,因为它处理大量新连接的涌入,这会消耗资源,无法满足查询请求。连接风暴可能导致max_connections中可用连接数量突然减少,增加数据库可用性风险。
复制延迟
MySQL 具有一种本地复制功能,可以将数据从一个服务器(源)发送到一个或多个额外的服务器,称为副本。数据在源上写入和在副本上可用之间的延迟称为复制延迟。如果你的应用从副本读取数据,延迟可能会导致数据看起来不一致,因为你向尚未赶上所有更改的副本发送读取请求。在社交媒体的例子中,用户可能会评论其他人发布的内容。这些数据被写入源,然后复制到副本。当用户尝试查看他们的回复时,如果应用发送请求到一个滞后的服务器,副本可能尚未有数据。这可能会让用户感到困惑,认为他们的评论没有保存。我们在第九章中更详细地介绍了对抗复制延迟的策略。
延迟是那些可能触发事件的急性 SLI 指标之一。它也是需要更多架构变化的长期趋势指示。在长期情况下,即使你从未遇到影响客户体验的复制延迟,它仍然表明,至少间歇性地,源节点的写入量超过了副本在当前配置下的写入量。它可以成为你写入容量的煤矿中的警报。如果听取建议,它可以防止未来发生全面事件。
警告
警惕将有关复制延迟的信息告知他人。立即可行的纠正措施并不总是可能的。同样,如果你不从副本中读取数据,请考虑监控系统对此情况如何积极地提醒他人。尤其是在非工作时间接收到的警报应始终是可操作的。
复制延迟是那些既影响即时又战术决策的指标之一,但长期关注其趋势可以帮助你避免更大的业务影响,并使你走在增长曲线的前面。
I/O 利用率
数据库工程师永无止境的努力之一是“尽可能多地在内存中完成工作,因为这样更快。”虽然这确实是准确的,但我们也知道我们不可能 100%地做到这一点,因为那意味着我们的数据完全适合内存,而在这种情况下,“规模”还不是我们需要花费精力的事情。
随着数据库基础设施的扩展和数据不再适合内存,你会意识到下一个最好的方法是不要从磁盘中读取太多数据,以至于查询被卡在那些宝贵的 I/O 周期中等待它们的轮次。即使在几乎所有东西都运行在固态驱动器上的这个时代,这仍然是真实的。随着数据规模的增长和查询需要扫描更多数据来满足请求,你会发现 I/O 等待可能会成为你的流量增长的瓶颈。
监控磁盘 I/O 活动有助于在影响客户之前提前了解性能下降。有一些事项可以监控以实现这一目标。像 iostat 这样的工具可以帮助你监控 I/O 等待。你希望监控并提醒如果你的数据库服务器有很多线程处于 IOwait 中,这表明它们在排队等待某些磁盘资源可用。你可以通过跟踪 IOutil 作为一个有意义的时间段的运行图,比如一天或两天,甚至一周。IOutil 报告为整个系统磁盘访问容量的百分比。在一个不运行备份的主机上,如果持续时间接近 100%,这可能表明存在全表扫描和低效查询。你还想监控你的磁盘 I/O 容量的整体利用率作为一个百分比,因为这可以预警你的磁盘访问成为未来数据库性能的瓶颈。
自增空间
在使用 MySQL 时较少为人知的一个雷区是,自增主键默认创建为有符号整数,并且可能会用尽键空间。当你进行了足够多的插入操作时,自增键达到了其数据类型的最大可能值。在长期基础上计划应该监控哪些指标时,监控使用自增作为主键的任何表的剩余整数空间是一个简单的操作,几乎肯定会在未来为你节省一些重大事件的痛苦,因为你可以提前预测到需要更大的键空间。
如何监控这个关键空间?您有几个选项。如果您已经使用 PMM 及其 Prometheus 导出器,这是内置的,您只需要打开标志-collect.auto_increment.columns。如果您的团队不使用 Prometheus,您可以使用以下查询,可以将其修改为度量生产者或警报,告诉您何时任何表接近可能的最大关键空间。此查询依赖于information_schema,其中包含有关数据库实例中表的所有元数据:
SELECT
t.TABLE_SCHEMA AS `schema`,
t.TABLE_NAME AS `table`,
t.AUTO_INCREMENT AS `auto_increment`,
c.DATA_TYPE AS `pk_type`,
(
t.AUTO_INCREMENT /
(CASE DATA_TYPE
WHEN 'tinyint'
THEN IF(COLUMN_TYPE LIKE '%unsigned',
255,
127
)
WHEN 'smallint'
THEN IF(COLUMN_TYPE LIKE '%unsigned',
65535,
32767
)
WHEN 'mediumint'
THEN IF(COLUMN_TYPE LIKE '%unsigned',
16777215,
8388607
)
WHEN 'int'
THEN IF(COLUMN_TYPE LIKE '%unsigned',
4294967295,
2147483647
)
WHEN 'bigint'
THEN IF(COLUMN_TYPE LIKE '%unsigned',
18446744073709551615,
9223372036854775807
)
END / 100)
) AS `max_value`
FROM information_schema.TABLES t
INNER JOIN information_schema.COLUMNS c
ON t.TABLE_SCHEMA = c.TABLE_SCHEMA
AND t.TABLE_NAME = c.TABLE_NAME
WHERE
t.AUTO_INCREMENT IS NOT NULL
AND c.COLUMN_KEY = 'PRI'
AND c.DATA_TYPE LIKE '%int'
;
在一般情况下以及专门管理自动增量时,选择主键时需要考虑很多微妙和上下文,我们将在第六章中进行讨论。
备份创建/恢复时间
长期规划不仅涉及业务正常运行时的增长,还涉及在可接受的时间范围内进行恢复。我们将在第十章中更深入地讨论如何考虑灾难恢复,以及在第十三章中讨论它如何成为您的合规控制职责的一部分,但我们在这里提到它是为了指出一个好的灾难恢复计划只有在您重新审视并调整其目标时才能起作用。
如果您的数据库达到一个恢复备份所需时间超过可接受时间以恢复业务的关键功能的大小,即使其他一切都正常运行,您也需要考虑调整 MTTR 目标,更改“关键功能”的定义,或找到缩短备份恢复时间的方法。在制定灾难恢复计划时需要考虑的一些事项:
-
对于这个恢复目标,要非常具体,并且如果需要的话,要查看支持该功能子集的数据是否需要在一个单独的集群中,以实现这一期望的现实性。
-
如果将数据在多个较小的实例中进行功能分区不可行,则整个数据集现在都处于通过备份恢复的目标下。从备份中恢复所需时间最长的数据集将驱动此恢复过程完成时间。
-
确保有自动化的测试方法(我们将在第十章中涵盖一些示例)。监视从文件恢复备份到已经赶上自备份创建以来的所有更改的运行数据库所需的时间,并将该指标存储在一个足够长的保留期内,以查看长期(至少一年)的趋势。如果不自动化监控,这是一个可能被忽视并且变得令人惊讶地长的指标之一。
在我们即将描述的许多示例长期指标中,您会发现我们几乎总是指出需要对数据进行功能分片或水平分片。这里的目标是明确指出,如果在容量问题是主要贡献原因的情况下考虑分片,那么您很可能考虑得太晚了。将数据分解为可管理的部分的工作并不是在您的数据对一个集群来说太大时才开始,而是在您仍在确定为提供成功的客户体验而设定目标时。
了解恢复数据所需的时间可以帮助设定在真正灾难发生时该做什么的期望。它还可以让您意识到可能需要比业务希望的时间更长。这是需要分片的前兆。
测量长期性能
选择日常运营的服务水平指标(SLIs)和服务水平目标(SLOs)只是一个开始。你需要确保自己没有把森林误认为是树木,而是专注于具体的主机指标而不是检查整体系统性能和客户体验结果。在这一部分,我们将介绍您可以使用的策略来思考系统的整体长期健康状况。
了解您的业务节奏
了解您的业务流量节奏非常重要,因为这将始终是您的所有 SLOs 都经受最严格测试并受到最重要客户最严格审查的时候。业务节奏可能意味着高峰流量时间比“平均”高出数倍,如果您的数据库基础设施没有准备好,这将产生许多后果。在数据库基础设施的背景下,这可能意味着每秒要处理数倍的请求,应用服务器的连接负载更大,或者如果写操作间歇性失败,收入影响更大。以下是一些业务节奏的示例,这些示例应该帮助您了解您的公司所处的业务周期:
电子商务网站
11 月底至年底是许多国家最繁忙的时期,在线商店可能会看到销售额增加数倍。这意味着更多的购物车,更多的同时销售,以及同一年的任何其他时间相比更多的收入影响。
人力资源软件
在美国,11 月通常是许多员工在被称为“开放选项”的时间内进行福利选举的时候,这将带来更多的流量。
在线鲜花供应商
情人节将是一年中最忙碌的时候,会有更多人订购花束送货。
正如您所看到的,这些业务周期可以根据业务填充的客户需求而有很大的变化。对于您的业务周期和其对业务收入、声誉以及您应该做出多少准备以满足需求而不影响您负责运行的系统的稳定性的影响,您必须意识到这一点至关重要。
当衡量支撑业务的数据库基础设施的性能时,重要的是不要将性能测量与工程组织正在跟踪的其他重要指标分开。数据库性能应该是关于技术堆栈性能的更大对话的一部分,而不应被视为特例。尽可能使用与您的工程组织其余部分相同的工具。您希望依赖于确定数据库层性能的指标和仪表板与应用层指标一样易于访问,甚至在同一仪表板上。无论您使用什么技术或供应商,这种思维方式都将在创造一个每个人都投入到整个堆栈性能并减少工程师可能感受到的功能编写和支持它们的数据库之间的隔阂的环境中发挥作用。
有效跟踪您的指标
在进行业务的长期规划时,有许多事情需要考虑,其中包括但不限于:
-
规划未来的容量
-
预见何时需要进行重大改进以及何时足够进行渐进式变化
-
规划运行基础设施的成本增加
您需要能够不仅在某一特定时间点测量数据存储基础设施的健康状况,还要在长期基础上趋势性能的改善或恶化。这意味着不仅要确定 SLIs 和 SLOs,还要找出哪些 SLIs 和 SLOs 在长期趋势中仍然是有价值的、高信号的指标。您可能会发现,并非所有可用于短期值班决策的指标也适用于长期业务规划。
在深入讨论哪些指标对长期规划至关重要之前,让我们谈谈一些能够支持长期趋势监控的工具。
使用监控工具检查性能
在即时“我们当前是否处于事故”意义上和长期跟踪和趋势意义上,衡量性能都很重要。保存您关心的指标的工具与指标本身一样重要。如果选择了一个好的 SLI,但随后无法适当地查看其随时间的趋势,以一种与组织其他指标相关的方式,那又有什么用呢?
监控工具领域正在迅速发展,对于如何进行监控有很多不同的看法。这里的目标是增加透明度,关注跟踪结果而不是产出。在确保基础架构成功的领域中,追踪成功是一个团队运动。
在这里不讨论具体的工具,而是列出一些在考虑一个工具是否适合这种长期趋势时需要考虑的重要特性和方面。
拒绝平均值
无论您是作为工程组织自行管理指标解决方案,还是使用软件即服务(SaaS),都要注意您的指标解决方案如何对长期存储的数据进行归一化处理。许多解决方案默认将长期数据聚合为平均值(Graphite 是最早这样做的之一),这是一个大问题。如果您需要查看一个指标在几周以上时间段内的趋势,平均值将会平滑下降峰值,这意味着如果您想知道您的磁盘 I/O 利用率是否会在接下来的一年内翻倍,平均数据点的图表很可能会给您一种虚假的安全感。在趋势化几个月的数据时,始终查看峰值,这样您就可以保持偶发性峰值在视图中的准确性。
百分位数是您的朋友
百分位数依赖于对给定时间跨度内的数据点进行排序,并根据目标百分位数(即,如果您寻找第 95 个百分位数,则删除前 5%)来删除最高值。这是使您查看的数据在视��上更类似于我们查看 SLIs 和 SLOs 的一种绝佳方式。如果您可以使显示您的查询响应时间的图表显示第 95 个百分位数,那么您可以更容易地将其与您希望实现的应用请求完成的 SLO 相匹配,并使数据库指标对您的客户支持团队和工程师团队等人员有意义,而不仅仅是对数据库工程团队有意义。
长期保留期和性能
这似乎是显而易见的,但是当尝试显示长时间跨度时,监控工具的性能很重要。如果您正在评估用于业务指标趋势的解决方案,您需要确保在要求越来越长时间跨度的数据时,用户体验如何变化。一个指标解决方案只有在能够提供数据的可用性方面才算好,而不仅仅是摄入速度或数据保留时间。
现在我们已经描述了长期监控工具应该是什么样子,让我们讨论一下到目前为止我们所涵盖的所有内容如何指导您的数据架构选择 SLIs 和 SLOs。
使用 SLOs 指导您的整体架构
在您的业务不断增长的同时保持一致且良好的客户体验绝非易事。随着业务规模的增长,即使保持相同的 SLOs,更不用说设定更雄心勃勃的目标,也变得越来越困难。以可用性为例:每个人都希望数据的读写都能保持尽可能多的连续运行时间。但是,您想要实现的 SLOs 越严格,工作就会变得越昂贵,因为您的数据库每秒事务数或其规模也会成倍增长。
使用我们已经讨论过的 SLIs 和 SLOs,您可以找到增长点,从而有意义地开始将数据分割为功能性分片或数据分区。我们将在第十一章中更详细地讨论使用分片来扩展 MySQL,但这里要强调的重要一点是,告诉您系统当前表现如何的相同 SLIs 和 SLOs 也可以指导您知道何时是投资扩展 MySQL 的时机,以便个别集群在保持维护客户体验的 SLOs 范围内仍然可管理。
拥有一个可以处理短期和长期指标,并能以有用的方式趋势变化的度量解决方案是跟踪战术绩效指标以及数据库基础设施长期影响趋势的一个非常重要的部分。
摘要
在将可靠性工程概念应用于监控数据库基础设施的过程中,不断改进和重新审视您的指标和目标非常重要。它们并不是在您第一次定义一些 SLIs 和 SLOs 后就一成不变的。随着业务的增长,您将更深入地了解客户的体验,这应该推动您改进 SLIs 和 SLOs。
在选择指标并为其分配目标时,请意识到您始终专注于代表客户体验。此外,不要将所有精力都集中在显示事故发生时的指标上,而是花一些时间监控可以帮助您预防事故的事项。这一切都是为了积极主动地保护客户体验。
我们建议在三个关键领域提前设定目标:延迟、可用性和错误。这三个领域可以很好地表明您的客户是否满意。此外,请确保您还在连接增长、磁盘空间、磁盘 I/O 和延迟方面进行积极监控。
我们希望本章能帮助您成功地将可靠性工程应用于监控 MySQL,随着公司规模的扩大。
¹ Nicole Forsgren,加速:精益软件和 DevOps 的科学(IT Revolution Press,2018)。https://oreil.ly/Bfvda
² 我们强烈推荐阅读实施服务水平目标 by Alex Hidalgo(O’Reilly)。
第三章. 性能模式
由 Sveta Smirnova 贡献
在高负载下调整数据库性能是一个迭代循环。每次您进行更改以调整数据库性能时,您需要了解更改是否产生了影响。您的查询是否比以前运行得更快?锁是否减慢了应用程序,或者它们完全消失了?内存使用量是否改变?等待磁盘的时间是否改变?一旦您了解如何回答这些问题,您将能够更快速、更自信地评估和应对日常情况。
性能模式是一个存储回答这些问题所需数据的数据库。本章将帮助您了解性能模式的工作原理、其局限性以及如何最好地使用它——以及其伴随的sys模式——来揭示 MySQL 内部发生的常见信息。
性能模式简介
性能模式提供了 MySQL 服务器内部运行操作的低级度量标准。为了解释性能模式的工作原理,我需要提前介绍两个概念。
第一个是工具。工具指的是我们想要捕获信息的 MySQL 代码的任何部分。例如,如果我们想要收集关于元数据锁的信息,我们需要启用wait/lock/metadata/sql/mdl工具。
第二个概念是消费者,它只是一个存储有关哪些代码被检测的信息的表。如果我们检测查询,消费者将记录关于执行次数、未使用索引次数、花费的时间等信息。消费者是大多数人与性能模式紧密相关的内容。
性能模式的一般功能如图 3-1 所示。

图 3-1. 数据库中运行查询的流程,展示了performance_schema如何收集和聚合数据,然后呈现给数据库管理员
当应用用户连接到 MySQL 并执行一个被检测的指令时,performance_schema将每个检查的调用封装成两个宏,然后将结果记录在相应的消费者表中。这里的要点是启用工具会调用额外的代码,这意味着工具会消耗 CPU。
工具元素
在performance_schema中,setup_instruments表包含所有支持的工具列表。所有工具的名称都由斜杠分隔的部分组成。我将使用以下示例来帮助您理解这些名称是如何命名的:
-
statement/sql/select -
wait/synch/mutex/innodb/autoinc_mutex
工具名称的最左边部分表示工具的类型。因此,statement表示该工具是一个语句,wait表示它是一个等待,依此类推。
名称字段中的其余元素从左到右表示从一般到具体的子系统。在上面的示例中,select是sql子系统的一部分,属于statement类型。或者autoinc_mutex属于innodb,是更通用的mutex类的一部分,而mutex又是wait类型的更通用的sync工具的一部分。
大多数工具名称都是自描述的。如示例中所示,statement/sql/select是一个SELECT查询,而wait/synch/mutex/innodb/autoinc_mutex是 InnoDB 在自增列上设置的互斥体。setup_instruments表中还有一个DOCUMENTATION列,其中可能包含更多细节:
mysql> SELECT * FROM performance_schema.setup_instruments
-> WHERE DOCUMENTATION IS NOT NULL LIMIT 5, 5\G
*************************** 1\. row ***************************
NAME: statement/sql/error
ENABLED: YES
TIMED: YES
PROPERTIES:
VOLATILITY: 0
DOCUMENTATION: Invalid SQL queries (syntax error).
*************************** 2\. row ***************************
NAME: statement/abstract/Query
ENABLED: YES
TIMED: YES
PROPERTIES: mutable
VOLATILITY: 0
DOCUMENTATION: SQL query just received from the network. At this point, the
real statement type is unknown, the type will be refined after SQL parsing.
*************************** 3\. row ***************************
NAME: statement/abstract/new_packet
ENABLED: YES
TIMED: YES
PROPERTIES: mutable
VOLATILITY: 0
DOCUMENTATION: New packet just received from the network. At this point,
the real command type is unknown, the type will be refined after reading
the packet header.
*************************** 4\. row ***************************
NAME: statement/abstract/relay_log
ENABLED: YES
TIMED: YES
PROPERTIES: mutable
VOLATILITY: 0
DOCUMENTATION: New event just read from the relay log. At this point, the
real statement type is unknown, the type will be refined after parsing the event.
*************************** 5\. row ***************************
NAME: memory/performance_schema/mutex_instances
ENABLED: YES
TIMED: NULL
PROPERTIES: global_statistics
VOLATILITY: 1
DOCUMENTATION: Memory used for table performance_schema.mutex_instances
5 rows in set (0,00 sec)
不幸的是,对于许多工具,DOCUMENTATION列可能为NULL,因此您需要使用工具名称、直觉和对 MySQL 源代码的了解来理解特定工具检查的内容。
消费者组织
正如我之前提到的,消费者是仪器发送信息的目的地。性能模式将仪器结果存储在许多表中;事实上,MySQL Community 8.0.25 中包含了 110 个performance_schema表。要理解它们的用途,最好将它们分组。
当前和历史数据
事件被放入以以下方式结尾的表中:
*_current
目前在服务器上发生的事件
*_history
每个线程的最后 10 个已完成事件
*_history_long
每个线程全局最后 10,000 个已完成事件
*_history和*_history_long表的大小是可配置的。
可用的当前和历史数据包括:
events_waits
低级服务器等待,例如获取互斥锁
events_statements
SQL 语句
events_stages
概要信息,例如创建临时表或发送数据
events_transactions
事务
摘要表和摘要
摘要表包含有关表建议的聚合信息。例如,memory_summary_by_thread_by_event_name表包含每个 MySQL 线程的用户连接或任何后台线程的聚合内存使用情况。
摘要是通过消除查询中的变体来聚合查询的一种方式。看以下查询示例:
SELECT user,birthdate FROM users WHERE user_id=19;
SELECT user,birthdate FROM users WHERE user_id=13;
SELECT user,birthdate FROM users WHERE user_id=27;
此查询的摘要将是:
SELECT user,birthdate FROM users WHERE user_id=?
这使得性能模式能够跟踪摘要的延迟等指标,而无需保留每个查询的各种变体。
实例
实例指的是 MySQL 安装中可用的对象实例。例如,file_instances表包含文件名以及访问这些文件的线程数。
设置
设置表用于运行时设置performance_schema。
其他表
还有其他表的名称不遵循严格的模式。例如,metadata_locks表保存有关元数据锁的数据。在讨论performance_schema可以帮助解决的问题时,我将在本章稍后介绍其中的一些。
资源消耗
性能模式收集的数据保存在内存中。您可以通过设置消费者的最大大小来限制其使用的内存量。performance_schema中的一些表支持自动缩放。这意味着它们在启动时分配最小内存量,并根据需要调整其大小。但是,一旦分配了内存,即使禁用了特定仪器并截断了表,也永远不会释放这些内存。
正如我之前提到的,每个仪器调用都会添加两个宏调用来存储数据��performance_schema中。这意味着您仪器化越多,CPU 使用率就会越高。对 CPU 利用率的实际影响取决于具体的仪器。例如,与查询期间仅调用一次的与语句相关的仪器不同,等待仪器可能会更频繁地调用。例如,要扫描具有一百万行的 InnoDB 表,引擎将需要设置并释放一百万行锁。如果您仪器化锁定,CPU 使用率可能会显著增加。但是,如果启用语句仪器,同一查询将需要一个调用来确定它是statement/sql/select。因此,如果启用语句仪器,您不会注意到 CPU 负载的增加。内存或元数据锁仪器也是如此。
限制
在讨论如何设置和使用performance_schema之前,了解其局限性是很重要的:
它必须由 MySQL 组件支持。
例如,假设您正在使用内存仪器来计算哪个 MySQL 组件或线程使用了大部分内存。您发现使用最多内存的组件是一个不支持内存仪器的存储引擎。在这种情况下,您将无法找到内存去向。
仅在特定仪器和消费者启用后才收集数据。
例如,如果您启动了一个禁用了所有仪器的服务器,然后决定对内存使用进行仪器化,您将无法知道由全局缓冲区(例如 InnoDB 缓冲池)分配的确切数量,因为在启用内存仪器化之前它已经被分配。
释放内存很困难。
您可以在启动时限制消费者的大小,或者让它们自动调整大小。在后一种情况下,它们在启动时不分配内存,而只有在启用数据收集时才分配内存。然而,即使您稍后禁用特定的工具或消费者,除非重新启动服务器,否则内存不会被释放。
在本章的其余部分,我将假设您已经了解这些限制,因此我不会特别关注它们。
系统模式
自 MySQL 5.7 版本以来,标准 MySQL 发行版包括一个名为sys模式的performance_schema数据的伴随模式。该模式仅由performance_schema上的视图和存储过程组成。虽然它旨在使您与performance_schema的体验更加顺畅,但它本身不存储任何数据。
注意
sys模式非常方便,但您需要记住它只访问存储在performance_schema表中的数据。如果您需要sys模式中不可用的数据,请检查它是否存在于performance_schema中的基础表中。
理解线程
MySQL 服务器是多线程软件。它的每个组件都使用线程。例如,可能��由主线程或存储引擎创建的后台线程,也可能是为用户连接创建的前台线程。每个线程至少有两个唯一标识符:一个操作系统线程 ID,例如,在 Linux 的ps -eLf命令的输出中可见,以及一个内部 MySQL 线程 ID。在performance_schema的大多数表中,这个内部 MySQL 线程 ID 称为THREAD_ID。此外,每个前台线程都有一个分配的PROCESSLIST_ID:连接标识符,在SHOW PROCESSLIST命令输出中可见,或者在使用 MySQL 命令行客户端连接时的“Your MySQL connection id is”字符串中可见。
警告
THREAD_ID不等于PROCESSLIST_ID!
performance_schema中的threads表包含服务器中存在的所有线程:
mysql> SELECT NAME, THREAD_ID, PROCESSLIST_ID, THREAD_OS_ID
-> FROM performance_schema.threads;
+------------------------+-----------+----------------+--------------+
| NAME | THREAD_ID | PROCESSLIST_ID | THREAD_OS_ID |
+------------------------+-----------+----------------+--------------+
| thread/sql/main | 1 | NULL | 797580 |
| thread/innodb/io_ib... | 3 | NULL | 797583 |
| thread/innodb/io_lo... | 4 | NULL | 797584 |
...
| thread/sql/slave_io | 42 | 5 | 797618 |
| thread/sql/slave_sql | 43 | 6 | 797619 |
| thread/sql/event_sc... | 44 | 7 | 797620 |
| thread/sql/signal_h... | 45 | NULL | 797621 |
| thread/mysqlx/accep... | 46 | NULL | 797623 |
| thread/sql/one_conn... | 27823 | 27784 | 797695 |
| thread/sql/compress... | 48 | 9 | 797624 |
+------------------------+-----------+----------------+--------------+
44 rows in set (0.00 sec)
除了线程编号信息外,threads表包含与SHOW PROCESSLIST输出相同的数据以及一些附加列,例如RESOURCE_GROUP或PARENT_THREAD_ID。
警告
性能模式在各处使用THREAD_ID,而PROCESSLIST_ID仅在threads表中可用。如果您需要获取PROCESSLIST_ID,例如为了终止持有锁的连接,您需要查询threads表以获取其值。
threads表可以与许多其他表连接,以提供有关正在运行的查询的附加信息(例如,查询数据,锁定,互斥锁或打开的表实例)。
在本章的其余部分,我希望您熟悉这个表以及THREAD_ID的含义。
配置
性能模式的一些部分只能在服务器启动时更改:启用或禁用性能模式本身以及与收集数据的内存使用和限制相关的变量。性能模式仪器和消费者可以动态启用或禁用。
提示
您可以启动性能模式,所有消费者和仪器都被禁用,并且只在您期望问题发生之前启用那些需要解决特定问题的仪器。这样,您将不会在不需要的地方花费任何资源在性能模式上,也不会因为过度仪器化而使系统陷入困境。
启用和禁用性能模式
要启用或禁用性能模式,请将变量performance_schema相应地设置为ON或OFF。这是一个只读变量,只能在配置文件中或在 MySQL 服务器启动时通过命令行参数更改。
启用和禁用仪器
仪器可以启用或禁用。要查看仪器的状态,可以查询setup_instruments表:
mysql> SELECT * FROM performance_schema.setup_instruments
-> WHERE NAME='statement/sql/select'\G
*************************** 1\. row ***************************
NAME: statement/sql/select
ENABLED: NO
TIMED: YES
PROPERTIES:
VOLATILITY: 0
DOCUMENTATION: NULL
1 row in set (0.01 sec)
正如我们所见,ENABLED是NO;这告诉我们我们目前没有对SELECT查询进行仪器化。
有三种选项可以启用或禁用performance_schema仪器:
-
使用
setup_instruments表。 -
调用
sys模式中的ps_setup_enable_instrument存储过程。 -
使用启动参数
performance-schema-instrument。
更新语句
第一种方法是使用UPDATE语句更改列值:
mysql> UPDATE performance_schema.setup_instruments
-> SET ENABLED='YES' WHERE NAME='statement/sql/select';
Query OK, 1 rows affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
由于这是标准 SQL,您也可以使用通配符来启用所有 SQL 语句的仪器:
mysql> UPDATE performance_schema.setup_instruments
-> SET ENABLED='YES' WHERE NAME LIKE statement/sql/%';
Query OK, 167 rows affected (0.00 sec)
Rows matched: 167 Changed: 167 Warnings: 0
此方法在重新启动之间不会持久化。
存储过程 sys
sys模式提供了两个存储过程—ps_setup_enable_instrument和ps_setup_disable_instrument—它们通过参数传递启用和禁用仪器。这两个例程都支持通配符。如果要启用或禁用所有支持的仪器,请使用通配符'%':
mysql> CALL sys.ps_setup_enable_instrument('statement/sql/select');
+----------------------+
| summary |
+----------------------+
| Enabled 1 instrument |
+----------------------+
1 row in set (0.01 sec)
此方法实际上与前一种方法完全相同,包括在重新启动之间不会持久化。
启动选项
如前所述,这两种方法都允许您在线更改performance_schema配置,但不会在服务器重新启动之间存储该更改。如果要在重新启动之间保存特定仪器的选项,请使用配置参数performance-schema-instrument。
此变量支持performance-schema-instrument='instrument_name=value'语法,其中instrument_name是仪器名称,value为启用仪器的ON、TRUE或1;禁用的为OFF、FALSE或0;对于计数而不是TIMED的为COUNTED。您可以多次指定此选项以启用或禁用不同的仪器。该选项还支持通配符:
performance-schema-instrument='statement/sql/select=ON'
警告
如果指定了多个选项,则较长的仪器字符串优先于较短的,无论顺序如何。
启用和禁用消费者
与仪器一样,消费者可以通过以下方式启用或禁用:
-
更新性能模式中的
setup_consumers表 -
在
sys模式中使用存储过程ps_setup_enable_consumer和ps_setup_disable_consumer -
设置
performance-schema-consumer配置参数
有 15 个可能的消费者。其中一些具有相当自明的名称,但有一些消费者的名称需要更多解释,列在表 3-1 中。
表 3-1. 消费者及其目的
| 消费者 | 描述 |
|---|---|
events_stages_[current|history|history_long] |
分析详细信息,如“创建临时表”,“统计”或“缓冲池加载” |
events_statements_[current|history|history_long] |
语句统计 |
events_transactions_[current|history|history_long] |
事务 |
events_waits_[current|history|history_long] |
等待 |
global_instrumentation |
启用或禁用全局仪器化。如果禁用,则不会检查任何单独的参数,也不会维护全局或每个线程的数据。不会收集任何单独的事件。 |
thread_instrumentation |
每个线程的仪器化。仅在全局仪器化已启用时才会检查。如果禁用,则不会收集每个线程或单个事件数据。 |
statements_digest |
语句摘要 |
为仪器给出的示例对于消费者是可重复的,使用所述方法。
为特定对象调整监控
性能模式允许您为特定对象类型、模式和名称启用和禁用监控。这是在setup_objects表中完成的。
OBJECT_TYPE列可能具有五个值之一:EVENT、FUNCTION、PROCEDURE、TABLE和TRIGGER。此外,您可以指定OBJECT_SCHEMA和OBJECT_NAME。支持通配符。
例如,要禁用test数据库中触发器的performance_schema,请使用以下语句:
mysql> INSERT INTO performance_schema.setup_objects
-> (OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME, ENABLED)
-> VALUES ('TRIGGER', 'test', '%', 'NO');
如果要为名为my_trigger的触发器添加异常,请使用以下语句:
mysql> INSERT INTO performance_schema.setup_objects
-> (OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME, ENABLED)
-> VALUES ('TRIGGER', 'test', 'my_trigger', 'YES');
当performance_schema决定是否需要对特定对象进行仪器化时,首先搜索更具体的规则,然后退而求其次。例如,如果用户在触发test.my_trigger的表上运行查询,它将检查触发器触发的语句。但如果用户在触发名为test.some_other_trigger的触发器的表上运行查询,则不会检查触发器。
对于对象没有配置文件选项。如果需要在重新启动期间保留对此表的更改,您需要编写这些INSERT语句到一个 SQL 文件中,并使用init_file选项在启动时加载 SQL 文件。
调整线程监视
setup_threads表包含一个可以监视的后台线程列表。ENABLED列指定特定线程的仪器化是否已启用。HISTORY列指定特定线程的仪器化事件是否也应存储在_history和_history_long表中。
例如,要禁用事件调度程序(thread/sql/event_scheduler)的历史记录,请运行:
mysql> UPDATE performance_schema.setup_threads SET HISTORY='NO'
-> WHERE NAME='thread/sql/event_scheduler';
setup_threads表不存储用户线程的设置。为此,存在setup_actors表,其中包含表 3-2 中描述的列。
表 3-2。setup_actors表中包含的列
| 列名 | 描述 |
|---|---|
HOST |
主机,例如 localhost,%,my.domain.com 或 199.27.145.65 |
USER |
用户名,例如sveta或% |
ROLE |
未使用 |
ENABLED |
如果线程已启用 |
HISTORY |
如果启用在_history和_history_long表中存储数据 |
要为特定帐户指定规则,请使用以下命令:
mysql> INSERT INTO performance_schema.setup_actors
-> (HOST, USER, ENABLED, HISTORY)
-> VALUES ('localhost', 'sveta', 'YES', 'NO'),
-> ('example.com', 'sveta', 'YES', 'YES'),
-> ('localhost', '%', 'NO', 'NO');
此语句启用了sveta@localhost和sveta@example.com的仪器化,禁用了sveta@localhost的历史记录,并禁用了所有其他从localhost连接的用户的仪器化和历史记录。
与对象监视一样,线程和参与者没有配置文件选项。如果需要在重新启动期间保留对此表的更改,您需要将这些INSERT语句写入 SQL 文件,并使用init_file选项在启动时加载 SQL 文件。
调整 Performance Schema 的内存大小
Performance Schema 将数据存储在使用PERFORMANCE_SCHEMA引擎的表中。此引擎将数据存储在内存中。默认情况下,performance_schema表中的一些表是自动调整大小的;其他表具有固定数量的行。您可以通过更改启动变量来调整这些选项。变量的名称遵循模式performance_schema_object_[size|instances|classes|length|handles],其中object可以是消费者、设置表或特定事件的仪器化实例。例如,配置变量performance_schema_��events_stages_history_size定义了performance_schema_events_stages_history表将存储的每个线程的阶段数。变量performance_schema_max_memory_classes定义了可以使用的内存仪器的最大数量。
默认值
MySQL 不同部分的默认值随版本而变化;因此,在依赖于此处描述的值之前,最好先查阅用户参考手册。但是,对于 Performance Schema,它们会影响服务器的整体性能,因此我想涵盖重要的部分。
自版本 5.7 以来,默认情况下启用了 Performance Schema,大多数仪器被禁用。只有全局、线程、语句和事务仪器被启用。自版本 8.0 以来,默认还额外启用了元数据锁和内存仪器。
mysql、information_schema和performance_schema数据库未被仪器化。所有其他对象、线程和执行者都被仪器化。
大多数实例、句柄和设置表都是自动调整大小的。对于_history表,每个线程存储最后的 10 个事件。对于_history_long表,每个线程存储最新的 10,000 个事件。最大存储的 SQL 文本长度为 1,024 字节。最大的 SQL 摘要长度也是 1,024 字节。超出长度的部分会被右侧修剪。
使用性能模式
现在我已经介绍了性能模式的配置方式,我想提供一些示例来帮助您解决常见的故障排除情况。
检查 SQL 语句
正如我在“仪器元素”中提到的,性能模式支持一套丰富的仪器,用于检查 SQL 语句的性能。您将找到用于标准准备语句和存储例程的工具。通过performance_schema,您可以轻松找到哪个查询导致性能问��以及原因。
要启用语句仪表化,您需要启用类型为statement的仪器,如表 3-3 中所述。
表 3-3。Statement工具及其描述
| 仪器类 | 描述 |
|---|---|
statement/sql |
SQL 语句,如SELECT或CREATE TABLE |
statement/sp |
存储过程控制 |
statement/scheduler |
事件调度器 |
statement/com |
命令,如quit、KILL、DROP DATABASE或Binlog Dump。有些命令对用户不可用,由mysqld进程自身调用。 |
statement/abstract |
四个命令的类:clone、Query、new_packet和relay_log |
常规 SQL 语句
性能模式将语句指标存储在events_statements_current、events_statements_history和events_statements_history_long表中。这三个表具有相同的结构。
直接使用 performance_schema
这是一个event_statement_history条目的示例:
THREAD_ID: 3200
EVENT_ID: 22
END_EVENT_ID: 23
EVENT_NAME: statement/sql/select
SOURCE: init_net_server_extension.cc:94
TIMER_START: 878753511280779000
TIMER_END: 878753544491277000
TIMER_WAIT: 33210498000
LOCK_TIME: 657000000
SQL_TEXT: SELECT film.film_id, film.description FROM sakila.film INNER JOIN
( SELECT film_id FROM sakila.film ORDER BY title LIMIT 50, 5 )
AS lim USING(film_id)
DIGEST: 2fdac27c4a9434806da3b216b9fa71aca738f70f1e8888a581c4fb00a349224f
DIGEST_TEXT: SELECT `film` . `film_id` , `film` . `description` FROM `sakila` .
`film` INNER JOIN ( SELECT `film_id` FROM `sakila` . `film` ORDER BY
`title` LIMIT?, ... ) AS `lim` USING ( `film_id` )
CURRENT_SCHEMA: sakila
OBJECT_TYPE: NULL
OBJECT_SCHEMA: NULL
OBJECT_NAME: NULL
OBJECT_INSTANCE_BEGIN: NULL
MYSQL_ERRNO: 0
RETURNED_SQLSTATE: NULL
MESSAGE_TEXT: NULL
ERRORS: 0
WARNINGS: 0
ROWS_AFFECTED: 0
ROWS_SENT: 5
ROWS_EXAMINED: 10
CREATED_TMP_DISK_TABLES: 0
CREATED_TMP_TABLES: 1
SELECT_FULL_JOIN: 0
SELECT_FULL_RANGE_JOIN: 0
SELECT_RANGE: 0
SELECT_RANGE_CHECK: 0
SELECT_SCAN: 2
SORT_MERGE_PASSES: 0
SORT_RANGE: 0
SORT_ROWS: 0
SORT_SCAN: 0
NO_INDEX_USED: 1
NO_GOOD_INDEX_USED: 0
NESTING_EVENT_ID: NULL
NESTING_EVENT_TYPE: NULL
NESTING_EVENT_LEVEL: 0
STATEMENT_ID: 25
这些列在官方文档中有解释,所以我不会逐一介绍它们。表 3-4 列出了可用作识别需要优化查询的指标的列。并非所有这些列都是相等的。例如,大多数情况下CREATED_TMP_DISK_TABLES是一个糟糕优化查询的迹象,而四个与排序相关的列可能只是表明查询结果需要排序。列的重要性表示指标的严重程度。
表 3-4。event_statement_history中可用作优化指标的列
| 列 | 描述 | 重要性 |
|---|---|---|
CREATED_TMP_DISK_TABLES |
查询创建了这么多基于磁盘的临时表。您有两种解决此问题的选择:优化查询或增加内存临时表的最大大小。 | 高 |
CREATED_TMP_TABLES |
查询创建了这么多基于内存的临时表。使用内存临时表本身并不是坏事。但是,如果底层表增长,它们可能会转换为基于磁盘的表。最好提前为这种情况做好准备。 | 中 |
SELECT_FULL_JOIN |
如果JOIN执行了全表扫描,因为没有好的索引来解决查询。除非表很小,否则您需要重新考虑您的索引。 |
高 |
SELECT_FULL_RANGE_JOIN |
如果JOIN使用了引用表的范围搜索。 |
中 |
SELECT_RANGE |
如果JOIN使用范围搜索来解决第一个表中的行。这通常不是一个大问题。 |
低 |
SELECT_RANGE_CHECK |
如果JOIN没有索引,每行后都会检查键。这是一个非常糟糕的症状,如果这个值大于零,您需要重新考虑表索引。 |
高 |
SELECT_SCAN |
如果 JOIN 对第一个表进行了全扫描。如果表很大,这是一个问题。 |
中等 |
SORT_MERGE_PASSES |
排序执行的合并次数。如果值大于零且查询性能较慢,可能需要增加 sort_buffer_size。 |
低 |
SORT_RANGE |
如果排序是通过范围完成的。 | 低 |
SORT_ROWS |
排序行数。与返回行数的值进行比较。如果排序行数较高,可能需要优化查询。 | 中等(见描述) |
SORT_SCAN |
如果排序是通过扫描表来完成的。这是一个非常糟糕的迹象,除非您��意选择表中的所有行而不使用索引。 | 高 |
NO_INDEX_USED |
未使用索引解析查询。 | 高,除非表很小 |
NO_GOOD_INDEX_USED |
用于解析查询的索引不是最佳的。如果此值大于零,则需要重新考虑索引。 | 高 |
要找出哪些语句需要优化,您可以选择任何列并将其与零进行比较。例如,要查找所有不使用良好索引的查询,请运行以下操作:
SELECT THREAD_ID, SQL_TEXT, ROWS_SENT, ROWS_EXAMINED, CREATED_TMP_TABLES,
NO_INDEX_USED, NO_GOOD_INDEX_USED
FROM performance_schema.events_statements_history_long
WHERE NO_INDEX_USED > 0 OR NO_GOOD_INDEX_USED > 0;
要查找所有创建临时表的查询,请运行:
SELECT THREAD_ID, SQL_TEXT, ROWS_SENT, ROWS_EXAMINED, CREATED_TMP_TABLES,
CREATED_TMP_DISK_TABLES
FROM performance_schema.events_statements_history_long
WHERE CREATED_TMP_TABLES > 0 OR CREATED_TMP_DISK_TABLES > 0;
您可以使用这些列中的值来单独显示潜在问题。例如,要查找所有返回错误的查询,使用条件 WHERE ERRORS > 0;要查找执行时间超过五秒的所有查询,使用条件 WHERE TIMER_WAIT > 5000000000;等等。
或者,您可以创建一个查询,通过长条件查找所有存在问题的语句,如下所示:
WHERE ROWS_EXAMINED > ROWS_SENT
OR ROWS_EXAMINED > ROWS_AFFECTED
OR ERRORS > 0
OR CREATED_TMP_DISK_TABLES > 0
OR CREATED_TMP_TABLES > 0
OR SELECT_FULL_JOIN > 0
OR SELECT_FULL_RANGE_JOIN > 0
OR SELECT_RANGE > 0
OR SELECT_RANGE_CHECK > 0
OR SELECT_SCAN > 0
OR SORT_MERGE_PASSES > 0
OR SORT_RANGE > 0
OR SORT_ROWS > 0
OR SORT_SCAN > 0
OR NO_INDEX_USED > 0
OR NO_GOOD_INDEX_USED > 0
使用 sys schema
sys schema 提供了可用于查找存在问题的语句的视图。例如,statements_with_errors_or_warnings 列出了所有带有错误和警告的语句,而 statements_with_full_table_scans 列出了所有需要执行全表扫描的语句。sys schema 使用摘要文本而不是查询文本,因此您将获得摘要查询文本,而不是在访问原始 performance_schema 表时获得的 SQL 或摘要文本:
mysql> SELECT query, total_latency, no_index_used_count, rows_sent,
-> rows_examined
-> FROM sys.statements_with_full_table_scans
-> WHERE db='employees' AND
-> query NOT LIKE '%performance_schema%'\G
********************** 1\. row **********************
query: SELECT COUNT ( 'emp_no' ) FROM ... 'emp_no' )
WHERE 'title' = ?
total_latency: 805.37 ms
no_index_used_count: 1
rows_sent: 1
rows_examined: 397774
…
其他可用于找到需要优化的语句的视图在 Table 3-5 中有描述。
Table 3-5. 可用于找到需要优化的语句的视图
| 视图 | 描述 |
|---|---|
statement_analysis |
一个带有聚合统计信息的标准化语句视图,按照标准化语句的总执行时间排序。类似于 events_statements_summary_by_digest 表,但更简略。 |
statements_with_errors_or_warnings |
所有引发错误或警告的标准化语句。 |
statements_with_full_table_scans. |
所有执行全表扫描的标准化语句。 |
statements_with_runtimes_in_95th_percentile |
所有平均执行时间位于前 95% 的标准化语句。 |
statements_with_sorting |
所有执行排序的标准化语句。该视图包括所有类型的排序。 |
statements_with_temp_tables |
所有使用临时表的标准化语句。 |
预处理语句
prepared_statements_instances 表包含服务器中存在的所有预处理语句。它具有与 events_statements_[current|history|history_long] 表相同的统计信息,此外还包含拥有预处理语句的线程信息以及语句执行次数。与 events_statements_[current|history|history_long] 表不同,统计数据是累加的,表中包含所有语句执行的总次数。
警告
COUNT_EXECUTE列包含语句执行的次数,因此您可以通过将总值除以此列中的数字来获得每个语句的平均统计信息。但请注意,任何平均统计信息可能是不准确的。例如,如果您执行了 10 次语句,而列SUM_SELECT_FULL_JOIN中的值为 10,则平均值将是每个语句一个完全连接。如果您然后添加一个索引并再次执行该语句,SUM_SELECT_FULL_JOIN将保持为 10,因此平均值将为 10/11 = 0.9。这并不表明问题现在已解决。
要启用准备语句的仪器,您需要启用表 3-6 中描述的仪器。
表 3-6. 用于准备语句仪器的启用
| 仪器类别 | 描述 |
|---|---|
statement/sql/prepare_sql |
在文本协议中的PREPARE语句(通过 MySQL CLI 运行时) |
statement/sql/execute_sql |
在文本协议中的EXECUTE语句(通过 MySQL CLI 运行时) |
statement/com/Prepare |
在二进制协议中的PREPARE语句(如果通过 MySQL C API 访问) |
statement/com/Execute |
在二进制协议中的EXECUTE语句(如果通过 MySQL C API 访问) |
一旦启用,您可以准备一个语句并执行几次:
mysql> PREPARE stmt FROM
-> 'SELECT COUNT(*) FROM employees WHERE hire_date > ?';
Query OK, 0 rows affected (0.00 sec)
Statement prepared
mysql1> SET @hd='1995-01-01';
Query OK, 0 rows affected (0.00 sec)
mysql1> EXECUTE stmt USING @hd;
+----------+
| count(*) |
+----------+
| 34004 |
+----------+
1 row in set (1.44 sec)
-- Execute a few more times with different values
然后您可以检查诊断信息:
mysql2> SELECT statement_name, sql_text, owner_thread_id,
-> count_reprepare, count_execute, sum_timer_execute
-> FROM prepared_statements_instances\G
*************************** 1\. row ***************************
statement_name: stmt
sql_text: select count(*) from employees where hire_date > ?
owner_thread_id: 22
count_reprepare: 0
count_execute: 3
sum_timer_execute: 4156561368000
1 row in set (0.00 sec)
请注意,只有在服务器中存在时,您才会在prepared_statements_instances表中看到语句。一旦它们被删除,您将无法再访问它们的统计信息:
mysql1> DROP PREPARE stmt;
Query OK, 0 rows affected (0.00 sec)
mysql2> SELECT * FROM prepared_statements_instances\G
Empty set (0.00 sec)
存储过程
使用performance_schema,您可以检索有关存储过程执行情况的信息:例如,IF … ELSE流程控制语句的哪个分支被选择,或者是否调用了错误处理程序。
要启用存储过程仪器,您需要启用遵循模式'statement/sp/%'的仪器。statement/sp/stmt仪器负责例程内调用的语句,而其他仪器负责跟踪事件,例如进入或离开过程、循环或任何其他控制指令。
为了演示存储过程仪器的工作原理,使用存储过程:
CREATE DEFINER='root'@'localhost' PROCEDURE 'sp_test'(val int)
BEGIN
DECLARE CONTINUE HANDLER FOR 1364, 1048, 1366
BEGIN
INSERT IGNORE INTO t1 VALUES('Some string');
GET STACKED DIAGNOSTICS CONDITION 1 @stacked_state = RETURNED_SQLSTATE;
GET STACKED DIAGNOSTICS CONDITION 1 @stacked_msg = MESSAGE_TEXT;
END;
INSERT INTO t1 VALUES(val);
END
然后用不同的值调用它:
mysql> CALL sp_test(1);
Query OK, 1 row affected (0.07 sec)
mysql> SELECT THREAD_ID, EVENT_NAME, SQL_TEXT
-> FROM EVENTS_STATEMENTS_HISTORY
-> WHERE EVENT_NAME LIKE 'statement/sp%';
+-----------+-------------------------+----------------------------+
| THREAD_ID | EVENT_NAME | SQL_TEXT |
+-----------+-------------------------+----------------------------+
| 24 | statement/sp/hpush_jump | NULL |
| 24 | statement/sp/stmt | INSERT INTO t1 VALUES(val) |
| 24 | statement/sp/hpop | NULL |
+-----------+-------------------------+----------------------------+
3 rows in set (0.00 sec)
在这种情况下,错误处理程序没有被调用,而存储过程将参数值(1)插入到表中:
mysql> CALL sp_test(NULL);
Query OK, 1 row affected (0.07 sec)
mysql> SELECT THREAD_ID, EVENT_NAME, SQL_TEXT
-> FROM EVENTS_STATEMENTS_HISTORY
-> WHERE EVENT_NAME LIKE 'statement/sp%';
+-----------+-------------------------+------------------------------+
| THREAD_ID | EVENT_NAME | SQL_TEXT |
+-----------+-------------------------+------------------------------+
| 24 | statement/sp/hpush_jump | NULL |
| 24 | statement/sp/stmt | INSERT INTO t1 VALUES(val) |
| 24 | statement/sp/stmt | INSERT IGNORE INTO t1
VALUES(‘Some str... |
| 24 | statement/sp/stmt | GET STACKED DIAGNOSTICS
CONDITION 1 @s... |
| 24 | statement/sp/stmt | GET STACKED DIAGNOSTICS
CONDITION 1 @s... |
| 24 | statement/sp/hreturn | NULL |
| 24 | statement/sp/hpop | NULL |
+-----------+-------------------------+------------------------------+
7 rows in set (0.00 sec)
然而,在第二次调用中,events_statements_history表的内容不同:它包含了来自错误处理程序的调用以及替换错误语句的 SQL 语句。
虽然存储过程本身的返回值没有改变,但我们清楚地看到它已经以不同的方式执行。了解例程执行流程中的这些差异可以帮助理解为什么同一个例程如果被调用一次几乎立即完成,而另一次调用时可能需要更长的时间。
语句分析
events_stages_[current|history|history_long]表包含了诸如 MySQL 在创建临时表、更新或等待锁时花费的时间等分析信息。要启用分析,您需要启用相应的消费者以及遵循模式'stage/%'的仪器。一旦启用,您可以找到答案,比如“查询执行的哪个阶段花费了非常长的时间?”以下示例搜索了花费超过一秒的阶段:
mysql> SELECT eshl.event_name, sql_text,
-> eshl.timer_wait/10000000000 w_s
-> FROM performance_schema.events_stages_history_long eshl
-> JOIN performance_schema.events_statements_history_long esthl
-> ON (eshl.nesting_event_id = esthl.event_id)
-> WHERE eshl.timer_wait > 1*10000000000\G
*************************** 1\. row ***************************
event_name: stage/sql/Sending data
sql_text: SELECT COUNT(emp_no) FROM employees JOIN salaries
USING(emp_no) WHERE hire_date=from_date
w_s: 81.7
1 row in set (0.00 sec)
使用events_stages_[current|history|history_long]表的另一种技术是关注那些在已知会导致性能问题的阶段中花费超过一定阈值的语句。表 3-7 列出了��些阶段。
表 3-7. 表现问题的指标阶段
| 阶段类别 | 描述 |
|---|---|
stage/sql/%tmp% |
与临时表相关的所有内容。 |
stage/sql/%lock% |
与锁相关的所有内容。 |
stage/%/Waiting for% |
一切等待资源的内容。 |
stage/sql/Sending data |
这个阶段应该与语句统计中的 ROWS_SENT 数量进行比较。如果 ROWS_SENT 很小,一个在这个阶段花费大量时间的语句可能意味着它必须创建一个临时文件或表来解决中间结果。这通常会在向客户端发送数据之前对行进行过滤。这通常是一个查询优化不佳的症状。 |
stage/sql/freeing items``stage/sql/cleaning up``stage/sql/closing tables``stage/sql/end |
这些是清理资源的阶段。不幸的是,它们的细节不够详细,每个阶段包含的任务不止一个。如果你发现你的查询在这些阶段花费了很长时间,很可能是由于高并发导致资源争用。你需要检查 CPU、I/O 和内存使用情况,以及你的硬件和 MySQL 选项是否能够处理应用程序创建的并发。 |
非常重要的一点是,性能分析仅适用于一般服务器阶段。存储引擎不支持使用 performance_schema 进行性能分析。因此,诸如 stage/sql/update 这样的阶段意味着作业在存储引擎内部,并且可能包括不仅仅是更新本身,还包括等待存储引擎特定锁或其他争用问题。
检查读写性能
在 Performance Schema 中的语句仪表化非常有用,可以帮助理解你的工作负载是读取还是写入受限。你可以从统计语句的类型开始:
mysql> SELECT EVENT_NAME, COUNT(EVENT_NAME)
-> FROM events_statements_history_long
-> GROUP BY EVENT_NAME;
+----------------------+-------------------+
| EVENT_NAME | COUNT(EVENT_NAME) |
+----------------------+-------------------+
| statement/sql/insert | 504 |
| statement/sql/delete | 502 |
| statement/sql/select | 6987 |
| statement/sql/update | 1007 |
| statement/sql/commit | 500 |
| statement/sql/begin | 500 |
+----------------------+-------------------+
6 rows in set (0.03 sec)
在这个示例中,SELECT 查询的数量大于任何其他查询的数量。这表明在这个设置中,大多数查询都是读取查询。
如果想了解语句的延迟,可以按 LOCK_TIME 列进行聚合:
mysql> SELECT EVENT_NAME, COUNT(EVENT_NAME),
-> SUM(LOCK_TIME/1000000) AS latency_ms
-> FROM events_statements_history
-> GROUP BY EVENT_NAME ORDER BY latency_ms DESC;
+----------------------------------+-------------------+------------+
| EVENT_NAME | COUNT(EVENT_NAME) | latency_ms |
+----------------------------------+-------------------+------------+
| statement/sql/select | 194 | 7362.0000 |
| statement/sql/update | 33 | 1276.0000 |
| statement/sql/insert | 16 | 599.0000 |
| statement/sql/delete | 16 | 470.0000 |
| statement/sql/show_status | 2 | 176.0000 |
| statement/sql/begin | 4 | 0.0000 |
| statement/sql/commit | 2 | 0.0000 |
| statement/com/Ping | 2 | 0.0000 |
| statement/sql/show_engine_status | 1 | 0.0000 |
+----------------------------------+-------------------+------------+
9 rows in set (0.01 sec)
你可能还想了解读取和写入的字节数和行数。为此,使用全局状态变量 Handler_*:
mysql> WITH rows_read AS (SELECT SUM(VARIABLE_VALUE) AS rows_read
-> FROM global_status
-> WHERE VARIABLE_NAME IN ('Handler_read_first', 'Handler_read_key',
-> 'Handler_read_next', 'Handler_read_last', 'Handler_read_prev',
-> 'Handler_read_rnd', 'Handler_read_rnd_next')),
-> rows_written AS (SELECT SUM(VARIABLE_VALUE) AS rows_written
-> FROM global_status
-> WHERE VARIABLE_NAME IN ('Handler_write'))
-> SELECT * FROM rows_read, rows_written\G
*************************** 1\. row ***************************
rows_read: 169358114082
rows_written: 33038251685
1 row in set (0.00 sec)
检查元数据锁
元数据锁用于保护数据库对象定义免受修改。任�� SQL 语句都会设置共享元数据锁:SELECT、UPDATE 等。它们不会影响其他需要共享元数据锁的语句。但是,它们会阻止那些改变数据库对象定义的语句(如 ALTER TABLE 或 CREATE INDEX)启动,直到锁被释放。虽然大多数由元数据锁冲突引起的问题影响表,但锁本身是为任何数据库对象设置的,如 SCHEMA、EVENT、TABLESPACE 等。
元数据锁会一直保持直到事务结束。如果使用多语句事务,这会使故障排除变得更加困难。哪个语句正在等待锁通常是明确的:DDL 语句会隐式提交事务,因此它们是新事务中唯一的语句,并且你会在进程列表中找到它们处于"等待元数据锁"状态。然而,持有锁的语句可能会在进程列表中消失,如果它是仍然打开的多语句事务的一部分。
performance_schema 中的 metadata_locks 表保存了不同线程当前设置的锁的信息,还保存了等待锁的锁请求信息。这样,你可以轻松地识别哪个线程不允许你的 DDL 请求启动,并决定是否要终止此语句或等待其执行完成。
要启用元数据锁仪表化,需要启用 wait/lock/metadata/sql/mdl 仪表。
以下示例显示了一个线程,在进程列表中以 ID 5 可见,持有了线程 processlist_id=4 正在等待的锁:
mysql> SELECT processlist_id, object_type,
-> lock_type, lock_status, source
-> FROM metadata_locks JOIN threads ON (owner_thread_id=thread_id)
-> WHERE object_schema='employees' AND object_name='titles'\G
*************************** 1\. row ***************************
processlist_id: 4
object_type: TABLE
lock_type: EXCLUSIVE
lock_status: PENDING -- waits
source: mdl.cc:3263
*************************** 2\. row ***************************
processlist_id: 5
object_type: TABLE
lock_type: SHARED_READ
lock_status: GRANTED -- holds
source: sql_parse.cc:5707
检查内存使用情况
要在 performance_schema 中启用内存仪表化,需要启用 memory 类的仪表。一旦启用,你可以找到有关 MySQL 内部结构如何使用内存的详细信息。
直接使用 performance_schema
Performance Schema 将内存使用统计信息存储在以memory_summary_前缀开头的摘要表中。内存使用聚合在 Table 3-8 中描述。
表 3-8. 内存使用的聚合参数
| 聚合参数 | 描述 |
|---|---|
global |
每个事件名称的全局 |
thread |
每个线程:包括后台线程和用户线程 |
account |
用户账户 |
host |
主机 |
user |
用户名 |
例如,要找到使用大部分内存的 InnoDB 结构,请执行以下查询:
mysql> SELECT EVENT_NAME,
-> CURRENT_NUMBER_OF_BYTES_USED/1024/1024 AS CURRENT_MB,
-> HIGH_NUMBER_OF_BYTES_USED/1024/1024 AS HIGH_MB
-> FROM performance_schema.memory_summary_global_by_event_name
-> WHERE EVENT_NAME LIKE 'memory/innodb/%'
-> ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 10;
+----------------------------+--------------+--------------+
| EVENT_NAME | CURRENT_MB | HIGH_MB |
+----------------------------+--------------+--------------+
| memory/innodb/buf_buf_pool | 130.68750000 | 130.68750000 |
| memory/innodb/ut0link_buf | 24.00006104 | 24.00006104 |
| memory/innodb/buf0dblwr | 17.07897949 | 24.96951294 |
| memory/innodb/ut0new | 16.07891273 | 16.07891273 |
| memory/innodb/sync0arr | 6.25006866 | 6.25006866 |
| memory/innodb/lock0lock | 4.85086060 | 4.85086060 |
| memory/innodb/ut0pool | 4.00003052 | 4.00003052 |
| memory/innodb/hash0hash | 3.69776917 | 3.69776917 |
| memory/innodb/os0file | 2.60422516 | 3.61988068 |
| memory/innodb/memory | 1.23812866 | 1.42373657 |
+----------------------------+--------------+--------------+
10 rows in set (0,00 sec)
使用 sys 模式
sys模式具有视图,允许您以更好的方式获取内存统计信息。它们还支持按host、user、thread或global进行聚合。视图memory_global_total包含一个单一值,显示了被检测内存的总量:
mysql> SELECT * FROM sys.memory_global_total;
+-----------------+
| total_allocated |
+-----------------+
| 441.84 MiB |
+-----------------+
1 row in set (0,09 sec)
聚合视图将字节转换为需要的千字节、兆字节和千兆字节。视图memory_by_thread_by_current_bytes有一个user列,可能取以下值之一:
NAME@HOST
常规用户账户,比如sveta@oreilly.com。
系统用户,比如sql/main或innodb/*
此类“用户名”的数据来自threads表,当您需要了解特定线程在做什么时非常方便。
视图memory_by_thread_by_current_bytes中的行按照当前分配的内存量降序排序,因此您将轻松找到占用大部分内存的线程:
mysql> SELECT thread_id tid, user,
-> current_allocated ca, total_allocated
-> FROM sys.memory_by_thread_by_current_bytes LIMIT 9;
+-----+----------------------------+------------+-----------------+
| tid | user | ca | total_allocated |
+-----+----------------------------+------------+-----------------+
| 52 | sveta@localhost | 1.36 MiB | 10.18 MiB |
| 1 | sql/main | 1.02 MiB | 4.95 MiB |
| 33 | innodb/clone_gtid_thread | 525.36 KiB | 24.04 MiB |
| 44 | sql/event_scheduler | 145.72 KiB | 4.23 MiB |
| 43 | sql/slave_sql | 48.74 KiB | 142.46 KiB |
| 42 | sql/slave_io | 20.03 KiB | 232.23 KiB |
| 48 | sql/compress_gtid_table | 13.91 KiB | 17.06 KiB |
| 25 | innodb/fts_optimize_thread | 1.92 KiB | 2.00 KiB |
| 34 | innodb/srv_purge_thread | 1.56 KiB | 1.64 KiB |
+-----+----------------------------+------------+-----------------+
9 rows in set (0,03 sec)
上面的示例是在笔记本电脑上进行的;因此,数字并不描述生产服务器的情况。仍然清楚的是,本地连接使用了大部分内存,其次是主服务器进程。
当你需要找到占用最多内存的用户线程时,内存工具非常方便。在下面的示例中,一个用户连接分配了 36 GB 的 RAM,即使在现代高内存系统中也相当巨大:
mysql> SELECT * FROM sys.memory_by_thread_by_current_bytes
-> ORDER BY current_allocated desc\G
*************************** 1\. row ***************************
thread_id: 152
user: lj@127.0.0.1
current_count_used: 325
current_allocated: 36.00 GiB
current_avg_alloc: 113.43 MiB
current_max_alloc: 36.00 GiB
total_allocated: 37.95 GiB
...
检查变量
Performance Schema 将变量检测提升到一个新水平。它为以下内容提供了检测:
-
服务器变量
-
全局
-
会话,适用于所有当前打开的会话
-
来源,所有当前变量值的来源
-
-
状态变量
-
全局
-
会话,适用于所有当前打开的会话
-
按照聚合
-
主机
-
用户
-
账户
-
线程
-
-
-
用户变量
警告
在 5.7 版本之前,服务器和状态变量在information_schema中被检测。这种检测是有限的:它只允许跟踪全局和当前会话值。其他会话中的变量和状态信息,以及用户变量的信息是不可访问的。然而,出于向后兼容性的原因,MySQL 5.7 使用information_schema来跟踪变量。要启用对变量的performance_schema支持,您需要将配置变量show_compatibility_56设置为0。这个要求,以及information_schema中的变量表,在 8.0 版本中不再存在。
全局变量值存储在表global_variables中。当前会话的会话变量存储在表session_variables中。这两个表只有两列,列名自明:VARIABLE_NAME和VARIABLE_VALUE。
variables_by_thread表有一个额外的列,THREAD_ID,指示变量所属的线程。这使您可以找到将会话变量值设置为与默认配置不同的线程。
在下面的示例中,具有THREAD_ID=84的线程将变量tx_isolation设置为SERIALIZABLE,这可能导致事务获取的锁比使用默认级别时更多:
mysql> SELECT * FROM variables_by_thread
-> WHERE VARIABLE_NAME='tx_isolation';
+-----------+---------------+-----------------+
| THREAD_ID | VARIABLE_NAME | VARIABLE_VALUE |
+-----------+---------------+-----------------+
| 71 | tx_isolation | REPEATABLE-READ |
| 83 | tx_isolation | REPEATABLE-READ |
| 84 | tx_isolation | SERIALIZABLE |
+-----------+---------------+-----------------+
3 rows in set, 3 warnings (0.00 sec)
下面的示例找到所有具有与当前活动会话不同的会话变量值的线程:
mysql> SELECT vt2.THREAD_ID AS TID, vt2.VARIABLE_NAME,
-> vt1.VARIABLE_VALUE AS MY_VALUE,
-> vt2.VARIABLE_VALUE AS OTHER_VALUE
-> FROM performance_schema.variables_by_thread vt1
-> JOIN performance_schema.threads t USING(THREAD_ID)
-> JOIN performance_schema.variables_by_thread vt2
-> USING(VARIABLE_NAME)
-> WHERE vt1.VARIABLE_VALUE != vt2.VARIABLE_VALUE
-> AND t.PROCESSLIST_ID=@@pseudo_thread_id;
+-----+--------------------+-------------------+--------------------+
| TID | VARIABLE_NAME | MY_VALUE | OTHER_VALUE |
+-----+--------------------+-------------------+--------------------+
| 42 | max_allowed_packet | 67108864 | 1073741824 |
| 42 | pseudo_thread_id | 22715 | 5 |
| 42 | timestamp | 1626650242.678049 | 1626567255.695062 |
| 43 | gtid_next | AUTOMATIC | NOT_YET_DETERMINED |
| 43 | pseudo_thread_id | 22715 | 6 |
| 43 | timestamp | 1626650242.678049 | 1626567255.707031 |
+-----+--------------------+-------------------+--------------------+
6 rows in set (0,01 sec)
全局和当前会话状态值分别存储在表global_status和session_status中。它们也只有两列:VARIABLE_NAME和VARIABLE_VALUE。
状态变量可以按用户帐户、主机、用户和线程进行聚合。在我看来,最有趣的聚合是按线程进行的,因为它可以快速识别哪个连接在服务器上造成了大部分资源压力。例如,以下代码片段清楚地显示了THREAD_ID=83的连接正在进行大部分写操作:
mysql> SELECT * FROM status_by_thread
-> WHERE VARIABLE_NAME='Handler_write';
+-----------+---------------+----------------+
| THREAD_ID | VARIABLE_NAME | VARIABLE_VALUE |
+-----------+---------------+----------------+
| 71 | Handler_write | 94 |
| 83 | Handler_write | 4777777777 | -- Most writes
| 84 | Handler_write | 101 |
+-----------+---------------+----------------+
3 rows in set (0.00 sec)
用户定义变量是通过SET @my_var = 'foo'创建的,并在表user_variables_by_thread中进行跟踪:
mysql> SELECT * FROM user_variables_by_thread;
+-----------+---------------+----------------+
| THREAD_ID | VARIABLE_NAME | VARIABLE_VALUE |
+-----------+---------------+----------------+
| 71 | baz | boo |
| 84 | foo | bar |
+-----------+---------------+----------------+
2 rows in set (0.00 sec)
当您需要找出内存消耗的来源时,此工具非常有用,因为每个变量都需要字节来保存其值。您还可以使用此信息解决与持久��接、使用用户定义变量相关的棘手问题。最后但同样重要的是,此表是唯一的方法来查找您在自己会话中定义的变量。
表variables_info不包含任何变量值。相反,它包含有关服务器变量来源以及其他文档的信息,例如变量的默认最小值和最大值。SET_TIME列包含最新变量更改的时间戳。SET_HOST和SET_USER列标识设置变量的用户帐户。例如,要查找自服务器启动以来动态更改的所有变量,请运行:
mysql> SELECT * FROM performance_schema.variables_info
-> WHERE VARIABLE_SOURCE = 'DYNAMIC'\G
*************************** 1\. row ***************************
VARIABLE_NAME: foreign_key_checks
VARIABLE_SOURCE: DYNAMIC
VARIABLE_PATH:
MIN_VALUE: 0
MAX_VALUE: 0
SET_TIME: 2021-07-18 03:14:15.560745
SET_USER: NULL
SET_HOST: NULL
*************************** 2\. row ***************************
VARIABLE_NAME: sort_buffer_size
VARIABLE_SOURCE: DYNAMIC
VARIABLE_PATH:
MIN_VALUE: 32768
MAX_VALUE: 18446744073709551615
SET_TIME: 2021-07-19 02:37:11.948190
SET_USER: sveta
SET_HOST: localhost
2 rows in set (0,00 sec)
可能的VARIABLE_SOURCE值包括:
COMMAND_LINE
在命令行上设置的变量
COMPILED
编译默认值
PERSISTED
从特定服务器的mysqld-auto.cnf选项文件设置
也有许多变量选项,设置在不同的选项文件中。我不会讨论它们全部:它们要么是自描述的,要么可以在用户参考手册中轻松查找。细节的数量也随着版本的增加而增加。
检查最频繁的错误
除了特定的错误信息,performance_schema还提供摘要表,通过用户、主机、帐户、线程以及全局按错误编号聚合错误。所有聚合表的结构与events_errors_summary_global_by_error表中使用的结构类似:
mysql> USE performance_schema;
mysql> SHOW CREATE TABLE events_errors_summary_global_by_error\G
*************************** 1\. row ***************************
Table: events_errors_summary_global_by_error
Create Table: CREATE TABLE `events_errors_summary_global_by_error` (
`ERROR_NUMBER` int DEFAULT NULL,
`ERROR_NAME` varchar(64) DEFAULT NULL,
`SQL_STATE` varchar(5) DEFAULT NULL,
`SUM_ERROR_RAISED` bigint unsigned NOT NULL,
`SUM_ERROR_HANDLED` bigint unsigned NOT NULL,
`FIRST_SEEN` timestamp NULL DEFAULT '0000-00-00 00:00:00',
`LAST_SEEN` timestamp NULL DEFAULT '0000-00-00 00:00:00',
UNIQUE KEY `ERROR_NUMBER` (`ERROR_NUMBER`)
) ENGINE=PERFORMANCE_SCHEMA DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0,00 sec)
列ERROR_NUMBER、ERROR_NAME和SQL_STATE标识错误。SUM_ERROR_RAISED是错误被引发的次数。SUM_ERROR_HANDLED是错误被处理的次数。FIRST_SEEN和LAST_SEEN是错误首次和最后出现的时间戳。
特定的聚合表具有额外的列。因此,表events_errors_summary_by_thread_by_error有一个名为THREAD_ID的列,用于标识引发错误的线程,表events_errors_summary_by_host_by_error有一个名为HOST的列,依此类推。
例如,要查找所有运行导致错误超过 10 次的语句的帐户,请运行:
mysql> SELECT * FROM
-> performance_schema.events_errors_summary_by_account_by_error
-> WHERE SUM_ERROR_RAISED > 10 AND USER IS NOT NULL
-> ORDER BY SUM_ERROR_RAISED DESC\G
*************************** 1\. row ***************************
USER: sveta
HOST: localhost
ERROR_NUMBER: 3554
ERROR_NAME: ER_NO_SYSTEM_TABLE_ACCESS
SQL_STATE: HY000
SUM_ERROR_RAISED: 60
SUM_ERROR_HANDLED: 0
FIRST_SEEN: 2021-07-18 03:14:59
LAST_SEEN: 2021-07-19 02:50:13
1 row in set (0,01 sec)
错误摘要表对于查找哪些用户帐户、主机、用户或线程发送了最多错误查询并执行操作可能很有用。它们还可以帮助处理像ER_DEPRECATED_UTF8_ALIAS这样的错误,这可能表明一些经常使用的查询是为以前的 MySQL 版本编写的,需要更新。
检查性能模式本身
您可以使用与自己模式相同的工具和消费者检查性能模式本身。只需注意,默认情况下,如果将performance_schema设置为默认数据库,则不会跟踪对其的查询。如果需要检查对performance_schema的查询,首先需要更新setup_actors表。
一旦更新了setup_actors表,所有工具都可以使用。例如,要查找在performance_schema中分配了大部分内存的前 10 个消费者,请运行:
mysql> SELECT SUBSTRING_INDEX(EVENT_NAME, '/', -1) AS EVENT,
-> CURRENT_NUMBER_OF_BYTES_USED/1024/1024 AS CURRENT_MB,
-> HIGH_NUMBER_OF_BYTES_USED/1024/1024 AS HIGH_MB
-> FROM performance_schema.memory_summary_global_by_event_name
-> WHERE EVENT_NAME LIKE 'memory/performance_schema/%'
-> ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 10;
+----------------------------------------+-------------+-------------+
| EVENT | CURRENT_MB | HIGH_MB |
+----------------------------------------+-------------+-------------+
| events_statements_summary_by_digest | 39.67285156 | 39.67285156 |
| events_statements_history_long | 13.88549805 | 13.88549805 |
| events_errors_summary_by_thread_by_... | 11.81640625 | 11.81640625 |
| events_statements_summary_by_thread... | 9.79296875 | 9.79296875 |
| events_statements_history_long.dige... | 9.76562500 | 9.76562500 |
| events_statements_summary_by_digest... | 9.76562500 | 9.76562500 |
| events_statements_history_long.sql_... | 9.76562500 | 9.76562500 |
| memory_summary_by_thread_by_event_name | 7.91015625 | 7.91015625 |
| events_errors_summary_by_host_by_error | 5.90820313 | 5.90820313 |
| events_errors_summary_by_account_by... | 5.90820313 | 5.90820313 |
+----------------------------------------+-------------+-------------+
10 rows in set (0,00 sec)
或使用sys模式:
mysql> SELECT SUBSTRING_INDEX(event_name, '/', -1), current_alloc
-> FROM sys.memory_global_by_current_bytes
-> WHERE event_name LIKE 'memory/performance_schema/%' LIMIT 10;
+---------------------------------------------------+---------------+
| SUBSTRING_INDEX(event_name, '/', -1) | current_alloc |
+---------------------------------------------------+---------------+
| events_statements_summary_by_digest | 39.67 MiB |
| events_statements_history_long | 13.89 MiB |
| events_errors_summary_by_thread_by_error | 11.82 MiB |
| events_statements_summary_by_thread_by_event_name | 9.79 MiB |
| events_statements_history_long.digest_text | 9.77 MiB |
| events_statements_summary_by_digest.digest_text | 9.77 MiB |
| events_statements_history_long.sql_text | 9.77 MiB |
| memory_summary_by_thread_by_event_name | 7.91 MiB |
| events_errors_summary_by_host_by_error | 5.91 MiB |
| events_errors_summary_by_account_by_error | 5.91 MiB |
+---------------------------------------------------+---------------+
10 rows in set (0,00 sec)
performance_schema还支持SHOW ENGINE PERFORMANCE_SCHEMA STATUS语句:
mysql> SHOW ENGINE PERFORMANCE_SCHEMA STATUS\G
*************************** 1\. row ***************************
Type: performance_schema
Name: events_waits_current.size
Status: 176
*************************** 2\. row ***************************
Type: performance_schema
Name: events_waits_current.count
Status: 1536
*************************** 3\. row ***************************
Type: performance_schema
Name: events_waits_history.size
Status: 176
*************************** 4\. row ***************************
Type: performance_schema
Name: events_waits_history.count
Status: 2560
…
*************************** 244\. row ***************************
Type: performance_schema
Name: (pfs_buffer_scalable_container).count
Status: 17
*************************** 245\. row ***************************
Type: performance_schema
Name: (pfs_buffer_scalable_container).memory
Status: 1904
*************************** 246\. row ***************************
Type: performance_schema
Name: (max_global_server_errors).count
Status: 4890
*************************** 247\. row ***************************
Type: performance_schema
Name: (max_session_server_errors).count
Status: 1512
*************************** 248\. row ***************************
Type: performance_schema
Name: performance_schema.memory
Status: 218456400
248 rows in set (0,00 sec)
在其输出中,您将找到诸如存储在消费者中的特定事件数量或特定指标的最大值等细节。最后一行包含性能模式当前占用的字节数。
总结
性能模式是一个经常受到批评的功能。MySQL 的早期版本实现不够优化,导致资源消耗较高。通常建议只需关闭它。
它也被认为难以理解。启用一个仪器只是在服务器中启用一个额外的代码片段,记录数据并将其提交给消费者。消费者只是存储在内存中的表,您需要使用标准 SQL 向表提出正确的问题,以找到您要查找的内容。了解性能模式如何管理自己的内存将帮助您意识到 MySQL 并非内存泄漏;它只是将消费者数据保留在内存中,并且只在重新启动时释放该内存。
我在这里的建议很简单:您应该保持性能模式启用,动态启用仪器和消费者,以帮助您解决可能存在的任何问题——查询性能、锁定、磁盘 I/O、错误等。您还应该利用sys模式作为解决最常见问题的捷径。这样做将为您提供一种直接从 MySQL 内部测量性能的可访问方式。
第四章:操作系统和硬件优化
你的 MySQL 服务器的性能只能和它最弱的环节一样好,而运行 MySQL 的操作系统和硬件通常是限制因素。磁盘大小、可用内存和 CPU 资源、网络以及连接它们的所有组件都限制了系统的最终容量。因此,你需要仔细选择硬件,并适当配置硬件和操作系统。例如,如果你的工作负载受到 I/O 限制,一种方法是设计你的应用程序以最小化 MySQL 的 I/O 工作负载。然而,升级 I/O 子系统、安装更多内存或重新配置现有磁盘通常更明智。如果你在云托管环境中运行,本章的信息仍然非常有用,特别是为了了解文件系统限制和 Linux I/O 调度程序。
什么限制了 MySQL 的性能?
许多不同的硬件组件可以影响 MySQL 的性能,但我们经常看到的最常见的瓶颈是 CPU 耗尽。当 MySQL 尝试并行执行太多查询或较少数量的查询在 CPU 上运行时间过长时,CPU 饱和就会发生。
I/O 饱和仍然可能发生,但发生频率要比 CPU 耗尽低得多。这在很大程度上是因为过渡到使用固态硬盘(SSD)。从历史上看,不再在内存中工作而转向硬盘驱动器(HDD)的性能惩罚是极端的。SSD 通常比 SSH 快 10 到 20 倍。如今,如果查询需要访问磁盘,你仍然会看到它们的性能不错。
内存耗尽仍然可能发生,但通常只会在尝试为 MySQL 分配过多内存时发生。我们在“配置内存使用”中讨论了防止这种情况发生的最佳配置设置,在第五章中。
如何为 MySQL 选择 CPU
当升级当前硬件或购买新硬件时,你应该考虑你的工作负载是否受 CPU 限制。你可以通过检查 CPU 利用率来确定工作负载是否受 CPU 限制,但不要只看整体 CPU 负载有多重,而是要看你最重要的查询的 CPU 使用率和 I/O 的平衡,并注意 CPU 是否均匀负载。
广义上说,你的服务器有两个目标:
低延迟(快速响应时间)
要实现这一点,你需要快速的 CPU,因为每个查询只会使用一个 CPU。
高吞吐量
如果你可以同时运行多个查询,你可能会从多个 CPU 为查询提供服务中受益。
如果你的工作负载没有利用所有的 CPU,MySQL 仍然可以利用额外的 CPU 执行后台任务,如清理 InnoDB 缓冲区、网络操作等。然而,与执行查询相比,这些工作通常较小。
平衡内存和磁盘资源
拥有大量内存的主要原因并不是为了能够在内存中保存大量数据:最终目的是为了避免磁盘 I/O,因为磁盘 I/O 比在内存中访问数据慢几个数量级。关键是平衡内存和磁盘大小、速度、成本和其他特性,以便为你的工作负载获得良好的性能。
缓存、读取和写入
如果你有足够的内存,你可以完全隔离磁盘免受读取请求。如果所有数据都适合内存,一旦服务器的缓存被热起来,每次读取都会是缓存命中。仍然会有来自内存的逻辑读取,但没有来自磁盘的物理读取。然而,写入是另一回事。写入可以像读取一样在内存中执行,但迟早它必须写入磁盘以便永久保存。换句话说,缓存可以延迟写入,但不能像读取那样消除写入。
实际上,除了允许延迟写入外,缓存还可以以两种重要的方式将它们分组在一起:
多写一次刷新
一条数据可以在内存中多次更改,而不需要将所有新值都写入磁盘。当数据最终刷新到磁盘时,自上次物理写入以来发生的所有修改都是永久的。例如,许多语句可以更新一个内存中的计数器。如果计数器递增了一百次然后写入磁盘,一百次修改已经被合并为一次写入。
I/O 合并
许多不同的数据可以在内存中被修改,并且修改可以被收集在一起,以便可以将物理写入作为单个磁盘操作执行。
这就是为什么许多事务系统使用预写式日志策略。预写式日志允许它们在内存中对页面进行更改而不刷新更改到磁盘,这通常涉及随机 I/O 并且非常慢。相反,它们将更改的记录写入顺序日志文件,这样做要快得多。后台线程可以稍后将修改的页面刷新到磁盘;当它这样做时,它可以优化写入。
写入受益于缓冲,因为它将随机 I/O 转换为更多的顺序 I/O。异步(缓冲)写入通常由操作系统处理,并且会被批处理,以便更优化地刷新到磁盘。同步(非缓冲)写入必须在完成之前写入磁盘。这就是为什么它们受益于在冗余磁盘阵列(RAID)控制器的电池支持写回缓存中进行缓冲(我们稍后会讨论 RAID)。
你的工作集是什么?
每个应用程序都有一个“工作集”数据,即它真正需要完成工作的数据。许多数据库还有很多不在工作集中的数据。你可以把数据库想象成一个带有文件抽屉的办公桌。工作集包括你需要放在桌面上以完成工作的文件。在这个类比中,桌面代表主内存,而文件抽屉代表硬盘。就像你不需要把每一张纸都放在桌面上才能完成工作一样,你不需要整个数据库都适合内存以获得最佳性能——只需要工作集。
在处理 HDD 时,寻找有效的内存到磁盘比例是一个好的做法。这在很大程度上是由于 HDD 的较慢的延迟和低的每秒输入/输出操作数(IOPS)。使用 SSD 时,内存到磁盘比例变得不那么重要。
固态存储
固态(闪存)存储是大多数数据库系统的标准,特别是在线事务处理(OLTP)。只有在非常大的数据仓库或传统系统中才会通常找到 HDD。这种转变是因为 2015 年左右 SSD 的价格显著下降。
固态存储设备使用由单元组成的非易失性闪存存储芯片,而不是磁盘盘片。它们也被称为非易失性随机存取存储器(NVRAM)。它们没有移动部件,这使它们的行为与硬盘非常不同。
以下是闪存性能的简要总结。高质量的闪存设备具有:
与硬盘相比,随机读写性能要好得多
闪存设备通常在读取方面比写入更好。
比硬盘更好的顺序读写性能
然而,与随机 I/O 相比,并没有那么显著的改进,因为硬盘在随机 I/O 方面比顺序 I/O 慢得多。
比硬盘更好的并发性支持
闪存设备可以支持更多的并发操作,事实上,只有在有很多并发时它们才能真正达到最高吞吐量。
最重要的是随机 I/O 和并发性能的改进。闪存给您提供了在高并发情况下非常好的随机 I/O 性能。
闪存存储概述
有旋转盘片和摆动磁头的硬盘具有固有的限制和特性,这些特性是物理学所涉及的结果。固态存储也是如此,它是建立在闪存之上的。不要以为固态存储很简单。在某些方面,它实际上比硬盘更复杂。闪存的限制相当严重且难以克服,因此典型的固态设备具有复杂的架构,包含许多抽象、缓存和专有的“魔法”。
闪存的最重要特性是它可以快速多次读取小单位,但写入要困难得多。一个单元不能在没有特殊擦除操作的情况下重写,并且只能在大块中擦除,例如 512 KB。擦除周期很慢,最终会使块磨损。一个块可以容忍的擦除周期数量取决于它使用的基础技术——稍后会详细介绍。
写入的限制是固态存储复杂性的原因。这就是为什么一些设备提供稳定、一致的性能,而其他设备则不提供。这些“魔法”都在专有的固件、驱动程序和其他组件中,使固态设备运行起来。为了使写入操作性能良好并避免过早磨损闪存块,设备必须能够重新定位页面并执行垃圾回收和所谓的磨损均衡。术语写入放大用于描述由于将数据从一个地方移动到另一个地方而导致的额外写入,由于部分块写入而多次写入数据和元数据。
垃圾回收
垃圾回收是很重要的。为了保持一些块的新鲜度并为新的写入做好准备,设备会回收块。这需要设备上的一些空闲空间。设备要么会有一些您看不到的内部保留空间,要么您需要通过不完全填满设备来自行保留空间;这因设备而异。无论哪种方式,随着设备填满,垃圾收集器必须更加努力地保持一些块的清洁,因此写入放大因子会增加。
因此,许多设备在填满时会变慢。每个供应商和型号的减速程度各不相同,这取决于设备的架构。一些设备即使在相当满时也设计为高性能,但总的来说,100 GB 文件在 160 GB SSD 上的表现与在 320 GB SSD 上的表现不同。减速是由于在没有空闲块时必须等待擦除完成。写入到空闲块需要几百微秒,但擦除速度要慢得多——通常是几毫秒。
RAID 性能优化
存储引擎通常将它们的数据和/或索引保存在单个大文件中,这意味着 RAID 通常是存储大量数据的最可行选项。RAID 可以帮助提高冗余性、存储容量、缓存和速度。但与我们一直在研究的其他优化一样,RAID 配置有许多变体,选择适合您需求的配置非常重要。
我们不会在这里涵盖每个 RAID 级别,也不会详细介绍不同 RAID 级别如何存储数据的具体细节。相反,我们专注于 RAID 配置如何满足数据库服务器的需求。以下是最重要的 RAID 级别:
RAID 0
RAID 0 是最便宜且性能最高的 RAID 配置,至少在您简单地衡量成本和性能时是这样(例如,如果包括数据恢复,它开始看起来更昂贵)。由于它不提供冗余性,我们认为 RAID 0 在生产数据库上永远不合适,但如果您真的想要节省成本,它可以是开发环境中的选择,其中完整服务器故障不会变成事故。
再次注意,RAID 0 不提供任何冗余性,尽管“冗余”是 RAID 首字母缩略词中的 R。事实上,RAID 0 阵列失败的概率实际上高于任何单个磁盘失败的概率,而不是低于!
RAID 1
RAID 1 对于许多场景提供了良好的读取性能,并且它会在磁盘之间复制您的数据,因此具有良好的冗余性。对于读取来说,RAID 1 比 RAID 0 稍快一点。它适用于处理日志和类似工作负载的服务器,因为顺序写入很少需要许多底层磁盘才能表现良好(与随机写入相反,后者可以从并行化中受益)。对于需要冗余但只有两个硬盘的低端服务器来说,这也是一个典型选择。
RAID 0 和 RAID 1 非常简单,通常可以很好地在软件中实现。大多数操作系统都可以让您轻松创建软件 RAID 0 和 RAID 1 卷。
RAID 5
RAID 5 曾经对数据库系统来说是相当可怕的,主要是由于性能影响。随着 SSD 变得普遍,现在它是一个可行的选择。它将数据分布在许多磁盘上,并使用分布式奇偶校验块,因此如果任何一个磁盘故障,数据可以从奇偶校验块重建。如果两个磁盘故障,整个卷将无法恢复。从每单位存储空间的成本来看,这是最经济的冗余配置,因为整个阵列只损失一个磁盘的存储空间。
RAID 5 最大的“坑”是如果一个磁盘故障时阵列的性能如何。这是因为数据必须通过读取所有其他磁盘来重建。这在 HDD 上严重影响了性能,这就是为什么通常不鼓励使用。如果您有很多磁盘,情况会更糟。如果您尝试在重建过程中保持服务器在线,不要指望重建或阵列的性能会很好。其他性能成本包括由于奇偶校验块的限制而导致的有限可扩展性——RAID 5 在超过 10 个磁盘左右时性能不佳——以及缓存问题。良好的 RAID 5 性能严重依赖于 RAID 控制器的缓存,这可能会与数据库服务器的需求发生冲突。正如我们之前提到的,SSD 在 IOPS 和吞吐量方面提供了显着改进的性能,而随机读/写性能不佳的问题也消失了。
RAID 5 的一个缓解因素是它非常受欢迎。因此,RAID 控制器通常针对 RAID 5 进行了高度优化,尽管存在理论限制,但使用缓存良好的智能控制器有时可以在某些工作负载下表现得几乎与 RAID 10 控制器一样好。这实际上可能反映出 RAID 10 控制器的优化程度较低,但无论原因是什么,这就是我们看到的情况。
RAID 6
RAID 5 的最大问题是丢失两个磁盘将是灾难性的。阵列中的磁盘越多,磁盘故障的概率就越高。RAID 6 通过添加第二个奇偶校验磁盘来帮助遏制故障可能性。这使您可以承受两个磁盘故障并仍然重建阵列。不足之处在于计算额外的奇偶校验会使写入速度比 RAID 5 慢。
RAID 10
RAID 10 对于数据存储是一个非常好的选择。它由镜像对组成,这些镜像对是条带化的,因此它既能很好地扩展读取又能扩展写入。与 RAID 5 相比,它重建速度快且容易。它也可以在软件中实现得相当好。
当一个硬盘故障时,性能损失仍然可能很显著,因为该条带可能成为瓶颈。根据工作负载的不同,性能可能会降低高达 50%。要注意的一件事是,某些 RAID 控制器使用“串联镜像”实现 RAID 10。这是次优的,因为缺乏条带化:您最常访问的数据可能只放在一对磁盘上,而不是分布在许多磁盘上,因此性能会很差。
RAID 50
RAID 50 由条带化的 RAID 5 阵列组成,如果你有很多硬盘,它可以在 RAID 5 的经济性和 RAID 10 的性能之间取得很好的折衷。这主要适用于非常大的数据集,比如数据仓库或极大型的 OLTP 系统。
表 4-1 总结了各种 RAID 配置。
表 4-1. RAID 级别比较
| 级别 | 摘要 | 冗余性 | 所需硬盘 | 更快读取 | 更快写入 |
|---|---|---|---|---|---|
| RAID 0 | 便宜,快速,危险 | 否 | N | 是 | 是 |
| RAID 1 | 快速读取,简单,安全 | 是 | 2(通常) | 是 | 否 |
| RAID 5 | 便宜,与 SSD 一起快速 | 是 | N + 1 | 是 | 取决于 |
| RAID 6 | 类似于 RAID 5 但更具弹性 | 是 | N + 2 | 是 | 取决于 |
| RAID 10 | 昂贵,快速,安全 | 是 | 2N | 是 | 是 |
| RAID 50 | 用于非常大型数据存储 | 是 | 2(N + 1) | 是 | 是 |
RAID 故障、恢复和监控
RAID 配置(除了 RAID 0)提供冗余性。这很重要,但很容易低估同时硬盘故障的可能性。你不应该认为 RAID 是数据安全的强有力保证。
RAID 并不能消除——甚至不能减少——备份的需求。当出现问题时,恢复时间将取决于你的控制器、RAID 级别、阵列大小、硬盘速度以及在重建阵列时是否需要保持服务器在线。
硬盘同时发生故障的可能性是存在的。例如,电力波动或过热很容易导致两个或更多硬盘损坏。然而,更常见的是两个硬盘故障发生在较短的时间内。许多这样的问题可能不会被注意到。一个常见的原因是很少访问的物理介质上的损坏,这可能会在几个月内不被发现,直到你尝试读取数据或另一个硬盘故障并且 RAID 控制器尝试使用损坏的数据重建阵列。硬盘越大,这种情况发生的可能性就越大。
这就是为什么监视你的 RAID 阵列很重要。大多数控制器提供一些软件来报告阵列的状态,你需要跟踪这些信息,否则你可能完全不知道硬盘故障。你可能会错过恢复数据的机会,只有当第二个硬盘故障时才发现问题,那时已经太迟了。你应该配置一个监控系统,在硬盘或卷更改为降级或失败状态时通知你。
你可以通过定期主动检查阵列的一致性来减轻潜在损坏的风险。一些控制器的背景巡逻读取功能可以在所有硬盘在线时检查损坏的介质并修复它,也可以帮助避免这些问题。与恢复一样,非常大的阵列可能检查速度较慢,因此在创建大型阵列时一定要做好计划。
你还可以添加一个热备用硬盘,它是未使用的,并配置为控制器自动用于恢复的待机硬盘。如果你依赖每台服务器,这是一个好主意。对于只有少量硬盘的服务器来说,这是昂贵的,因为拥有一个空闲硬盘的成本相对较高,但如果你有很多硬盘,不配置热备用几乎是愚蠢的。请记住,随着硬盘数量的增加,硬盘故障的概率会迅速增加。
除了监视驱动器故障,你还应该监视 RAID 控制器的电池备份单元和写缓存策略。如果电池故障,默认情况下大多数控制器会通过将缓存策略更改为写穿透而不是写回来禁用写缓存。这可能会导致性能严重下降。许多控制器还会定期通过学习过程循环电池,在此期间缓存也被禁用。你的 RAID 控制器管理实用程序应该让你查看和配置学习周期何时安排,以免让你措手不及。新一代的 RAID 控制器通过使用使用 NVRAM 存储未提交写入的闪存支持缓存来避免这种情况,而不是使用电池支持的缓存。这避免了学习周期的整个痛苦。
你可能还想使用写穿透的缓存策略对系统进行基准测试,这样你就会知道可以期待什么。首选的方法是在低流量时段安排电池学习周期,通常在晚上或周末。如果在任何时候使用写穿透时性能严重下降,你也可以在学习周期开始之前切换到另一台服务器。作为最后的手段,你可以通过更改innodb_flush_log_at_trx_commit和sync_binlog变量来重新配置服务器,以降低耐久性设置。这将减少写穿透期间的磁盘利用率,并可能提供可接受的性能;然而,这真的应该作为最后的手段。降低耐久性会对在数据库崩溃期间可能丢失的数据量以及恢复数据的能力产生重大影响。
RAID 配置和缓存
通常可以通过在机器的引导序列期间输入其设置实用程序或通过从命令提示符运行来配置 RAID 控制器本身。尽管大多数控制器提供了许多选项,但我们关注的两个是条带阵列的块大小和控制器缓存(也称为RAID 缓存;我们可以互换使用这些术语)。
RAID 条带块大小
最佳条带块大小是与工作负载和硬件特定的。理论上,对于随机 I/O,拥有较大的块大小是有好处的,因为这意味着更多的读取可以从单个驱动器中满足。
要了解为什么会这样,请考虑你的工作负载的典型随机 I/O 操作的大小。如果块大小至少与该大小相同,并且数据不跨越块之间的边界,只需要一个驱动器参与读取。但是,如果块大小小于要读取的数据量,就无法避免多个驱动器参与读取。
理论就到此为止。实际上,许多 RAID 控制器不适用于大块。例如,控制器可能将块大小用作其缓存中的缓存单元,这可能是浪费的。控制器还可能匹配块大小、缓存大小和读取单元大小(单次操作中读取的数据量)。如果读取单元太大,其缓存可能不太有效,并且可能会读取比实际需要的数据量更多,即使是对于微小的请求。
也很难知道任何给定数据是否会跨越多个驱动器。即使块大小为 16 KB,与 InnoDB 的页面大小相匹配,你也不能确定所有读取是否都对齐在 16 KB 边界上。文件系统可能会使文件碎片化,并且通常会将碎片对齐在文件系统块大小上,通常为 4 KB。一些文件系统可能更智能,但你不应该指望它。
RAID 缓存
RAID 缓存是物理安装在硬件 RAID 控制器上的(相对较小的)内存量。它可用于在数据在磁盘和主机系统之间传输时作为缓冲区。以下是 RAID 卡可能使用缓存的一些原因:
缓存读取
在控制器从磁盘中读取一些数据并将其发送到主机系统后,它可以存储数据;这将使它能够在不再需要再次访问磁盘的情况下满足对相同数据的未来请求。
这通常是 RAID 缓存的非常糟糕的用法。为什么?因为操作系统和数据库服务器有自己更大的缓存。如果其中一个缓存中有缓存命中,RAID 缓存中的数据将不会被使用。反之,如果高级别缓存中有缓存未命中,那么 RAID 缓存中有缓存命中的机会几乎为零。由于 RAID 缓存要小得多,它几乎肯定已经被刷新并填充了其他数据。无论从哪个角度看,将读取缓存到 RAID 缓存中都是一种浪费内存的行为。
缓存预读数据
如果 RAID 控制器注意到对数据的顺序请求,它可能会决定进行预读操作——即预取它预测很快会需要的数据。但在数据被请求之前,它必须有地方存放数据。它可以使用 RAID 缓存来实现这一点。这种操作的性能影响可能会有很大的变化,你应该检查以确保它确实有帮助。如果数据库服务器正在执行自己的智能预读操作(如 InnoDB 所做的),预读操作可能不会有帮助,并且可能会干扰同步写入的重要缓冲。
缓存写入
RAID 控制器可以在其缓存中缓冲写入并安排它们在稍后执行。这样做的优点是双重的:首先,它可以比实际在物理磁盘上执行写入更快地向主机系统返回“成功”,其次,它可以累积写入并更有效地执行它们。
内部操作
一些 RAID 操作非常复杂——特别是 RAID 5 写入,它们必须计算可以用于在发生故障时重建数据的奇偶校验位。控制器需要为这种类型的内部操作使用一些内存。这是 RAID 5 在某些控制器上性能不佳的原因之一:它需要将大量数据读入缓存以获得良好的性能。一些控制器无法平衡缓存写入和 RAID 5 奇偶校验操作的缓存。
一般来说,RAID 控制器的内存是一种稀缺资源,你应该明智地使用它。将其用于读取通常是浪费,但将其用于写入是提高 I/O 性能的重要方式。许多控制器允许你选择如何分配内存。例如,你可以选择将多少内存用于缓存写入,将多少用于读取。对于 RAID 0、RAID 1 和 RAID 10,你可能应该将控制器内存的 100% 用于缓存写入。对于 RAID 5,你应该保留一些控制器内存用于其内部操作。这通常是一个好建议,但并不总是适用——不同的 RAID 卡需要不同的配置。
当你使用 RAID 缓存进行写入缓存时,许多控制器允许你配置延迟写入的时间(一秒、五秒等)。延迟时间更长意味着更多的写入可以被分组并优化地刷新到磁盘上。缺点是你的写入将更“突发”。这并不是一件坏事,除非你的应用程序恰好在控制器缓存填满时发出一堆写入请求,即将刷新到磁盘上。如果没有足够的空间来处理应用程序的写入请求,它将不得不等待。保持延迟时间较短意味着你将有更多的写入操作,它们将更不高效,但它可以平滑地处理尖峰,并帮助保持更多的缓存空闲以处理应用程序的突发请求。(我们在这里进行了简化——控制器通常具有复杂的、供应商特定的平衡算法,所以我们只是试图涵盖基本原则。)
写缓存对于同步写入非常有帮助,例如在事务日志上发出fsync()调用和启用sync_binlog创建二进制日志,但除非您的控制器有电池备份单元(BBU)或其他非易失性存储,否则不应启用它。在没有 BBU 的情况下缓存写入可能会导致数据库甚至事务性文件系统在断电时损坏。然而,如果您有 BBU,启用写缓存可以提高性能,例如对于执行大量日志刷新操作的工作负载,例如在事务提交时刷新事务日志。
最后一个考虑因素是许多硬盘都有自己的写缓存,可以通过欺骗控制器向物理介质写入数据来“伪造”fsync()操作。直接连接的硬盘(而不是连接到 RAID 控制器)有时可以让它们的缓存由操作系统管理,但这也并不总是有效的。这些缓存通常会在fsync()时被刷新,并且在同步 I/O 时被绕过,但是硬盘可能会撒谎。你应该确保这些缓存在fsync()时被刷新,或者禁用它们,因为它们没有备用电源。操作系统或 RAID 固件未正确管理的硬盘已经导致了许多数据丢失的情况。
出于这个原因和其他原因,当您安装新硬件时,进行真正的崩溃测试(从墙上拔下电源插头)总是一个好主意。这通常是发现微妙的配置错误或狡猾的硬盘行为的唯一方法。可以在在线找到一个方便的脚本。
要测试您是否真的可以依赖 RAID 控制器的 BBU,请确保将电源线拔掉一段现实时间。一些设备在没有电源的情况下的持续时间可能不如预期的长。在这里,一个糟糕的环节可能使您整个存储组件链变得无用。
网络配置
就像延迟和吞吐量对硬盘是限制因素一样,延迟和带宽对网络连接也是限制因素。对大多数应用程序来说,最大的问题是延迟;典型应用程序进行大量小型网络传输,每次传输的轻微延迟会累积起来。
网络运行不正确也是一个主要的性能瓶颈。数据包丢失是一个常见问题。即使 1%的丢包足以导致显著的性能下降,因为协议栈中的各个层将尝试使用策略来解决问题,例如等待一段时间然后重新发送数据包,这会增加额外的时间。另一个常见问题是破损或缓慢的 DNS 解析。
DNS 足以成为一个致命弱点,因此在生产服务器上启用skip_name_resolve是一个好主意。破损或缓慢的 DNS 解析对许多应用程序都是一个问题,但对于 MySQL 来说尤为严重。当 MySQL 收到连接请求时,它会进行正向和反向 DNS 查找。有很多原因可能导致这种情况出错。一旦出错,将导致连接被拒绝,连接到服务器的过程变慢,并且通常会造成混乱,甚至包括拒绝服务攻击。如果启用skip_name_resolve选项,MySQL 将不执行任何 DNS 查找。但是,这也意味着您的用户帐户在host列中必须只有 IP 地址、“localhost”或 IP 地址通配符。任何在host列中具有主机名的用户帐户将无法登录。
调整设置以有效处理大量连接和小查询通常更为重要。其中一个更常见的调整是更改本地端口范围。Linux 系统有一系列可用的本地端口。当连接返回给调用者时,它使用本地端口。如果有许多同时连接,您可能会用完本地端口。
这是一个配置为默认值的系统:
$ cat /proc/sys/net/ipv4/ip_local_port_range
32768 61000
有时你可能需要将这些值更改为更大的范围。例如:
$ echo 1024 65535 > /proc/sys/net/ipv4/ip_local_port_range
TCP 协议允许系统排队接收连接,就像一个桶。如果桶装满了,客户端就无法连接。你可以通过以下方式允许更多连接排队:
$ echo 4096 > /proc/sys/net/ipv4/tcp_max_syn_backlog
对于仅在本地使用的数据库服务器,你可以缩短在关闭套接字后的超时时间,以防对等方断开连接但不关闭连接的情况。在大多数系统上,默认值是一分钟,这相当长:
$ echo <value> > /proc/sys/net/ipv4/tcp_fin_timeout
大多数情况下,这些设置可以保持默认值不变。通常只有在发生异常情况时才需要更改它们,比如网络性能极差或连接数量非常大。在互联网上搜索“TCP 变量”会找到很多关于这些变量和更多变量的好文章。
选择文件系统
你的文件系统选择在很大程度上取决于你的操作系统。在许多系统中,比如 Windows,你实际上只有一两个选择,而且只有一个(NTFS)是真正可行的。另一方面,GNU/Linux 支持许多文件系统。
许多人想知道哪种文件系统在 GNU/Linux 上为 MySQL 提供最佳性能,甚至更具体地说,哪种选择对 InnoDB 最好。实际的基准测试显示,它们在大多数方面都非常接近,但是寻求文件系统性能实际上是一个干扰。文件系统的性能非常依赖于工作负载,并且没有一个文件系统是万能的。大多数情况下,一个给定的文件系统不会比其他文件系统表现明显更好或更差。唯一的例外是如果你遇到某些文件系统限制,比如它如何处理并发性、处理许多文件、碎片化等等。
总的来说,最好使用一个日志文件系统,比如 ext4、XFS 或 ZFS。如果不这样做,在崩溃后进行文件系统检查可能需要很长时间。
如果你使用 ext3 或其后继者 ext4,你有三个选项来记录数据的方式,你可以将它们放在/etc/fstab挂载选项中:
data=writeback
这个选项意味着只有元数据写入被记录。元数据写入不与数据写入同步。这是最快的配置,通常与 InnoDB 一起使用是安全的,因为它有自己的事务日志。唯一的例外是,在 MySQL 的 8.0 版本之前,如果在恰当的时机发生崩溃,可能会导致.frm文件损坏。
这里有一个示例,说��这种配置可能会导致问题。假设一个程序决定扩展一个文件使其更大。元数据(文件的大小)将在实际写入数据到(现在更大的)文件之前被记录和写入。结果是文件的尾部——新扩展区域——包含垃圾。
data=ordered
这个选项也只记录元数据,但通过在写入数据之前写入数据来提供一些一致性,以保持一致性。它只比writeback选项稍慢一点,但在发生崩溃时更安全。在这种配置下,如果我们再次假设一个程序想要扩展一个文件,文件的元数据在数据写入新扩展区域之前不会反映文件的新大小。
data=journal
此选项提供原子日志行为,将数据写入日志后再写入最终位置。通常情况下这是不必要的,并且比其他两个选项的开销要大得多。然而,在某些情况下,它可以提高性能,因为日志记录使文件系统能够延迟将数据写入最终位置。
无论文件系统如何,最好禁用一些特定选项,因为它们不提供任何好处,而且可能会增加相当多的开销。最著名的是记录访问时间,即使您只是读取文件或目录,也需要写入。要禁用此选项,请将noatime,nodiratime挂载选项添加到您的/etc/fstab;这有时可以提高性能 5%–10%,具体取决于工作负载和文件系统(尽管在其他情况下可能没有太大差异)。以下是我们提到的 ext3 选项的示例/etc/fstab行:
/dev/sda2 /usr/lib/mysql ext3 noatime,nodiratime,data=writeback 0 1
您还可以调整文件系统的预读行为,因为这可能是多余的。例如,InnoDB 会进行自己的预读预测。在 Solaris 的 UFS 上禁用或限制预读对性能特别有益。使用innodb_flush_method=O_DIRECT会自动禁用预读。
一些文件系统不支持您可能需要的功能。例如,如果您正在使用 InnoDB 的O_DIRECT刷新方法,对于直接 I/O 的支持可能很重要。此外,一些文件系统比其他文件系统更好地处理大量底层驱动器;例如,XFS 在这方面通常比 ext3 好得多。最后,如果您计划使用逻辑卷管理器(LVM)快照来初始化副本或进行备份,您应该验证您选择的文件系统和 LVM 版本是否能很好地配合。
表 4-2 总结了一些常见文件系统的特性。
表 4-2. 常见文件系统特性
| 文件系统 | 操作系统 | 日志记录 | 大目录 |
|---|---|---|---|
| ext3 | GNU/Linux | 可选 | 可选/部分 |
| ext4 | GNU/Linux | 是 | 是 |
| 日志文件系统 (JFS) | GNU/Linux | 是 | 否 |
| NTFS | Windows | 是 | 是 |
| ReiserFS | GNU/Linux | 是 | 是 |
| UFS (Solaris) | Solaris | 是 | 可调 |
| UFS (FreeBSD) | FreeBSD | 否 | 可选/部分 |
| UFS2 | FreeBSD | 否 | 可选/部分 |
| XFS | GNU/Linux | 是 | 是 |
| ZFS | GNU/Linux, Solaris, FreeBSD | 是 | 是 |
我们通常建议使用 XFS 文件系统。ext3 文件系统有太多严重的限制,比如每个 inode 只有一个互斥锁,以及不好的行为,比如在fsync()上刷新整个文件系统中的所有脏块,而不仅仅是一个文件的脏块。ext4 文件系统是一个可以接受的选择,尽管在特定内核版本中可能存在性能瓶颈,您在承诺之前应该调查一下。
在考虑为数据库选择任何文件系统时,考虑它已经可用多久,它有多成熟,以及在生产环境中它已经被证明。文件系统位是数据库中最低级别的数据完整性。
选择磁盘队列调度程序
在 GNU/Linux 上,队列调度程序确定请求发送到底层设备的顺序。默认值是完全公平队列,或cfq。对于笔记本电脑和台式机的日常使用,它可以防止 I/O 饥饿,但对于服务器来说很糟糕。因为它会不必要地使一些请求在队列中停滞。
您可以使用以下命令查看可用的调度程序以及哪个是活动的:
$ cat /sys/block/sda/queue/scheduler
noop deadline [cfq]
你应该用感兴趣的磁盘设备名称替换sda。在我们的示例中,方括号表示此设备正在使用哪种调度程序。另外两个选择适用于服务器级硬件,在大多数情况下它们的效果差不多。noop调度程序适用于在后台进行自己调度的设备,例如硬件 RAID 控制器和存储区域网络(SAN),而deadline适用于直接连接的 RAID 控制器和磁盘。我们的基准测试显示这两者之间几乎没有区别。最重要的是使用除了cfq之外的任何调度程序,因为它可能导致严重的性能问题。
内存和交换
MySQL 在分配给它大量内存时表现最佳。正如我们在第一章中学到的,InnoDB 使用内存作为缓存以避免磁盘访问。这意味着内存系统的性能直接影响查询服务的速度。即使在今天,确保更快的内存访问的最佳方法之一是用外部内存分配器(glibc)替换内置内存分配器,如tcmalloc或jemalloc。许多基准测试²表明,与glibc相比,这两者都提供了改进的性能和减少的内存碎片化。
当操作系统将一些虚拟内存写入磁盘因为没有足够的物理内存来保存时,就会发生交换。对于运行在操作系统上的进程,交换是透明的。只有操作系统知道特定虚拟内存地址是在物理内存中还是在磁盘上。
在使用 SSD 时,性能损失不像以前使用 HDD 那样严重。你仍然应该积极避免交换,即使只是为了避免不必要的写入可能缩短磁盘的整体寿命。你也可以考虑采用不使用交换的方法,这样可以避免潜在的问题,但会使你处于内存耗尽可能导致进程终止的情况。
在 GNU/Linux 上,你可以使用vmstat来监视交换(我们在下一节中展示了一些示例)。你需要查看交换 I/O 活动,报告在si和so列中,而不是交换使用情况,报告在swpd列中。swpd列可能显示已加载但未使用的进程,这并不是真正的问题。我们希望si和so列的值为0,它们肯定应该小于每秒 10 个块。
在极端情况下,过多的内存分配可能导致操作系统的交换空间耗尽。如果发生这种情况,由于虚拟内存的缺乏,MySQL 可能会崩溃。但即使不会耗尽交换空间,非常活跃的交换也可能导致整个操作系统无响应,甚至无法登录和终止 MySQL 进程。有时候当操作系统耗尽交换空间时,Linux 内核甚至会完全挂起。我们建议你完全不使用交换空间来运行数据库。磁盘仍然比 RAM 慢一个数量级,这样可以避免这里提到的所有问题。
在极端虚拟内存压力下经常发生的另一件事是内存不足(OOM)杀手进程会启动并终止某些进程。这经常是 MySQL,但也可能是另一个进程,比如 SSH,这可能导致你的系统无法从网络访问。你可以通过设置 SSH 进程的oom_adj或oom_score_adj值来防止这种情况发生。在使用专用数据库服务器时,我们强烈建议你识别任何关键进程,如 MySQL 和 SSH,并主动调整 OOM 杀手分数,以防止它们被首先选择终止。
您可以通过正确配置 MySQL 缓冲区来解决大多数交换问题,但有时操作系统的虚拟内存系统决定无论如何交换 MySQL,有时与 Linux 中的非统一内存访问(NUMA)的工作方式有关³。这通常发生在操作系统看到 MySQL 的大量 I/O 时,因此它试图增加文件缓存以容纳更多数��。如果内存不足,必须交换出某些内容,而这些内容可能是 MySQL 本身。一些较旧的 Linux 内核版本还具有不当的优先级,会在不应该交换时交换内容,但在较新的内核中已经有所缓解。
操作系统通常允许对虚拟内存和 I/O 进行一些控制。我们在 GNU/Linux 上提到了一些控制它们的方法。最基本的是将/proc/sys/vm/swappiness的值更改为低值,例如0或1。这告诉内核除非对虚拟内存的需求极端,否则不要交换。例如,这是如何检查当前值的方法:
$ cat /proc/sys/vm/swappiness
60
显示的值为 60,是默认的 swappiness 设置(范围从 0 到 100)。这对服务器来说是非常糟糕的默认值。这只适用于笔记本电脑。服务器应设置为0:
$ echo 0 > /proc/sys/vm/swappiness
另一个选项是更改存储引擎读取和写入数据的方式。例如,使用innodb_flush_method=O_DIRECT可以减轻 I/O 压力。直接 I/O 不会被缓存,因此操作系统不会将其视为增加文件缓存大小的原因。此参数仅适用于 InnoDB。
另一个选项是使用 MySQL 的memlock配置选项,将 MySQL 锁定在内存中。这将避免交换,但可能会很危险:如果没有足够的可锁定内存剩余,当 MySQL 尝试分配更多内存时,MySQL 可能会崩溃。如果锁定了太多内存,而操作系统没有足够的内存剩余,也可能会引起问题。
许多技巧特定于内核版本,因此要小心,特别是在升级时。在某些工作负载中,很难使操作系统表现得明智,您的唯一选择可能是将缓冲区大小降低到次优值。
操作系统状态
您的操作系统提供了工具,帮助您了解操作系统和硬件正在做什么。在本节中,我们将向您展示如何使用两个广泛可用的工具iostat和vmstat的示例。如果您的系统没有提供这两个工具中的任何一个,那么很可能会提供类似的工具。因此,我们的目标不是让您成为iostat或vmstat的专家,而只是向您展示在尝试使用这些工具诊断问题时要寻找什么。
除了这些工具,您的操作系统可能提供其他工具,如mpstat或sar。如果您对系统的其他部分感兴趣,例如网络,您可能想使用ifconfig(显示发生了多少网络错误等)或netstat等工具。
默认情况下,vmstat和iostat只生成一个报告,显示自服务器启动以来各种计数器的平均值,这并不是很有用。但是,您可以为这两个工具提供一个间隔参数。这使它们生成增量报告,显示服务器当前正在执行的操作,这更加相关。(第一行显示自系统启动以来的统计信息;您可以忽略此行。)
如何阅读 vmstat 输出
让我们先看一个vmstat的示例。要使其每五秒打印一个新报告,以兆字节为单位报告大小,请使用以下命令:
$ vmstat -SM 5
procs -------memory------- -swap- -----io---- ---system---- ------cpu-----
r b swpd free buff cache si so bi bo in cs us sy id wa st
11 0 0 2410 4 57223 0 0 9902 35594 122585 150834 10 3 85 1 0
10 2 0 2361 4 57273 0 0 23998 35391 124187 149530 11 3 84 2 0
您可以使用 Ctrl-C 停止vmstat。您看到的输出取决于您的操作系统,因此您可能需要阅读手册页以弄清楚。
正如前面所述,尽管我们要求增量输出,但第一行的值显示了自服务器启动以来的平均值。第二行显示了当前的情况,随后的行将显示每隔五秒发生的情况。这些列按以下其中一个标题分组:
procs
r 列显示有多少进程正在等待 CPU 时间。b 列显示有多少进程处于不可中断的睡眠状态,这通常意味着它们正在等待 I/O(磁盘、网络、用户输入等)。
memory
swpd 列显示了被交换到磁盘(分页)的块数。剩下的三列显示了多少块是 free(未使用的)、多少块用于缓冲区(buff),以及多少块用于操作系统的 cache。
swap
这些列显示了交换活动:操作系统每秒交换进(从磁盘)和交换出(到磁盘)的块数。它们比 swpd 列更重要。我们希望大部分时间看到 si 和 so 为 0,绝对不希望看到超过 10 块每秒。突发也是不好的。
io
这些列显示每秒从块设备读入的块数(bi)和写出的块数(bo)。这通常反映了磁盘 I/O。
system
这些列显示每秒中断数(in)和每秒上下文切换数(cs)。
cpu
这些列显示了总 CPU 时间的百分比,用于运行用户(非内核)代码、运行系统(内核)代码、空闲以及等待 I/O。可能的第五列(st)显示了如果您使用虚拟化,则从虚拟机“窃取”的百分比。这指的是在虚拟机上有东西可以运行的时间,但是 hypervisor 选择运行其他东西的时间。如果虚拟机不想运行任何东西,而 hypervisor 运行其他东西,那就不算是被窃取的时间。
vmstat 输出是系统相关的,因此如果您的输出与我们展示的示例不同,您应该阅读您系统的 vmstat(8) 手册页。
如何阅读 iostat 输出
现在让我们转向 iostat。默认情况下,它显示了与 vmstat 相同的一些 CPU 使用信息。不过,我们通常只对 I/O 统计感兴趣,因此我们使用以下命令仅显示扩展设备统计信息:
$ iostat -dxk 5
Device: rrqm/s wrqm/s r/s w/s rkB/s wkB/s
sda 0.00 0.00 1060.40 3915.00 8483.20 42395.20
avgrq-sz avgqu-sz await r_await w_await svctm %util
20.45 3.68 0.74 0.57 0.78 0.20 98.22
与 vmstat 一样,第一个报告显示了自服务器启动以来的平均值(我们通常省略以节省空间),随后的报告显示了增量平均值。每个设备一行。
有各种选项可以显示或隐藏列。官方文档有点混乱,我们不得不深入源代码中找出到底显示了什么。以下是每列显示的内容:
rrqm/s 和 wrqm/s
每秒排队的合并读写请求数。合并 意味着操作系统从队列中获取多个逻辑请求,并将它们组合成一个实际设备的单个请求。
r/s 和 w/s
每秒发送到设备的读取和写入请求数。
rkB/s 和 wkB/s
每秒读取和写入的千字节数。
avgrq-sz
请求大小(以扇区为单位)。
avgqu-sz
在设备队列中等待的请求数。
await
在磁盘队列中花费的毫秒数。
r_await 和 w_await
发送到设备的读取请求的平均时间(以毫秒为单位),分别为读取和写入。这包括请求在队列中花费的时间以及为其提供服务的时间。
svctm
服务请求所花费的毫秒数,不包括队列时间。
%util⁴
至少有一个请求处于活动状态的时间百分比。这个名字非常令人困惑。如果您熟悉排队理论中利用率的标准定义,那么这就是设备的利用率。具有多个硬盘驱动器(如 RAID 控制器)的设备应该能够支持比 1 更高的并发性,但是%util永远不会超过 100%,除非在计算中存在四舍五入误差。因此,与文档所说的相反,它并不是设备饱和的良好指标,除非您正在查看单个物理硬盘的特殊情况。
您可以使用输出推断有关机器 I/O 子系统的一些事实。一个重要的指标是同时服务的请求数。由于读取和写入是每秒进行的,而服务时间的单位是千分之一秒,您可以使用 Little's law 推导出设备正在服务的并发请求数的以下公式:
concurrency = (r/s + w/s) * (svctm/1000)
将前面的样本数字插入并发公式中得到约 0.995 的并发性。这意味着在采样间隔期间,设备平均服务的请求少于一个。
其他有用的工具
我们展示了vmstat和iostat,因为它们是广泛可用的工具,而vmstat通常默认安装在许多类 Unix 操作系统上。然而,这些工具各有其局限性,比如单位混乱、采样间隔与操作系统更新统计数据的时间不对应,以及无法一次看到所有指标。如果这些工具不符合您的需求,您可能会对dstat或collectl感兴趣。
我们也喜欢使用mpstat来监视 CPU 统计信息;它提供了关于 CPU 如何单独运行的更好的想法,而不是将它们全部分组在一起。在诊断问题时,这有时非常重要。当您检查磁盘 I/O 使用情况时,您可能会发现blktrace也很有帮助。
Percona 编写了自己的iostat替代工具称为pt-diskstats。它是 Percona Toolkit 的一部分。它解决了一些关于iostat的抱怨,比如它如何将读取和写入汇总以及对并发性的可见性不足。它还是交互式的,通过按键驱动,因此您可以放大和缩小,更改聚合,过滤设备,显示和隐藏列。这是一个很好的方式来切分和分析磁盘统计数据的样本,即使您没有安装该工具,也可以通过简单的 shell 脚本收集磁盘活动的样本并通过电子邮件或保存以供以后分析。
最后,Linux 分析器perf是检查操作系统级别发生的情况的宝贵工具。您可以使用perf检查有关操作系统的一般信息,比如为什么内核使用 CPU 这么多。您还可以检查特定的进程 ID,从而查看 MySQL 如何与操作系统交互。检查系统性能是一个非常深入的过程,因此我们推荐 Brendan Gregg 的《Systems Performance, Second Edition》(Pearson)作为优秀的后续阅读。
总结
选择和配置 MySQL 的硬件,并为硬件配置 MySQL,并不是一门神秘的艺术。一般来说,您需要与大多数其他目的相同的技能和知识。然而,有一些 MySQL 特定的事项您应该知道。
我们通常建议大多数人在性能和成本之间找到一个良好的平衡。 首先,我们喜欢使用商品服务器,有很多原因。 例如,如果您的服务器出现问题,您需要将其停机以诊断问题,或者如果您只是想尝试用另一台服务器替换它作为诊断的一种形式,那么使用价值 5000 美元的服务器比使用价值 50000 美元或更高的服务器要容易得多。 MySQL 通常也更适合于商品硬件,无论是从软件本身还是从典型的工作负载来看。
MySQL 需要的四个基本资源是 CPU、内存、磁盘和网络资源。 网络很少会成为严重瓶颈,但 CPU、内存和磁盘确实会。 速度和数量的平衡取决于工作负载,您应该根据预算的允许程度努力实现快速和多样的平衡。 你期望的并发越多,你就应该更多地依赖更多的 CPU 来适应你的工作负载。
CPU、内存和磁盘之间的关系错综复杂,一个领域的问题通常会在其他地方显现出来。 在向问题投入资源之前,问问自己是否应该将资源投入到另一个问题上。 如果你受到 I/O 限制,你需要更多的 I/O 容量,还是只需要更多的内存? 答案取决于工作集大小,即在给定时间内最常需要的数据集。
固态设备非常适合提高服务器整体性能,现在通常应该成为数据库的标准,特别是 OLTP 工作负载。 继续使用 HDD 的唯一理由是在极度预算受限的系统中或者需要大量磁盘空间的情况下,例如在数据仓库情况下需要 PB 级别的磁盘空间。
在操作系统方面,有一些关键的事项需要正确处理,主要涉及存储、网络和虚拟内存管理。 如果您使用 GNU/Linux,正如大多数 MySQL 用户所做的那样,我们建议使用 XFS 文件系统,并将 swappiness 和磁盘队列调度器设置为适合服务器的值。 有一些可能需要更改的网络参数,您可能希望调整其他一些参数(例如禁用 SELinux),但这些更改是个人偏好的问题。
¹ 流行的俳句:这不是 DNS。 不可能是 DNS。 就是 DNS。
² 参见博客文章“内存分配器对 MySQL 性能的影响”和“MySQL(或 Percona)内存使用测试”进行比较。
³ 更多信息请参见此博客文章。
⁴ 软件 RAID,如 MD/RAID,可能不会显示 RAID 阵列本身的利用率。
第五章:优化服务器设置
在本章中,我们将解释一个过程,通过这个过程你可以为你的 MySQL 服务器创建一个合适的配置文件。这是一个迂回的旅程,有许多有趣的地方和风景名胜。这些旁支旅程是必要的。确定适当配置的最短路径并不是从研究配置选项和询问应该设置哪些选项或如何更改它们开始。也不是从检查服务器行为和询问是否有任何配置选项可以改进它开始。最好从理解 MySQL 的内部机制和行为开始。然后你可以将这些知识用作如何配置 MySQL 的指南。最后,你可以将期望的配置与当前配置进行比较,并纠正任何重要且有价值的差异。
人们经常问:“对于拥有 32GB RAM 和 12 个 CPU 核心的服务器,最佳配置文件是什么?”不幸的是,事情并不那么简单。你应该根据工作负载、数据和应用程序要求来配置服务器,而不仅仅是硬件。MySQL 有许多设置可以更改,但你不应该这样做。通常最好正确配置基本设置(在大多数情况下只有少数几个是重要的),并花更多时间在模式优化、索引和查询设计上。在正确设置 MySQL 的基本配置选项之后,进一步更改的潜在收益通常很小。
另一方面,随意更改配置的潜在风险是巨大的。MySQL 的默认设置是有充分理由的。不明确地了解影响就进行更改可能导致崩溃、持续停顿或性能下降。因此,你永远不应该盲目相信像 MySQL 论坛或 Stack Overflow 这样的热门帮助网站上的某人报告的最佳配置。始终通过阅读相关手册条目并仔细测试来审查任何更改。
那么你应该做什么呢?你应该确保像 InnoDB 缓冲池和日志文件大小这样的基本设置是合适的。然后,如果你想要防止不良行为,你应该设置一些安全选项(但请注意,这些通常不会提高性能,它们只会避免问题)。然后就让其他设置保持不变。如果你遇到问题,首先要仔细诊断。如果你的问题是由服务器的某个部分造成的,而这个部分的行为可以通过配置选项进行更正,那么你可能需要进行更改。
有时候你可能还需要设置特定的配置选项,这些选项在特殊情况下可能会对性能产生显著影响。然而,这些选项不应该是基本服务器配置文件的一部分。只有在发现它们解决的具体性能问题时才应该设置它们。这就是为什么我们不建议你通过寻找需要改进的坏事来处理配置选项。如果有什么需要改进的,它应该在查询响应时间中显示出来。最好从查询和它们的响应时间开始搜索,而不是从配置选项开始。这可以为你节省大量时间并避免许多问题。
另一个节省时间和麻烦的好方法是除非你知道你不应该,否则使用默认设置。人多力量大,很多人都在使用默认设置。这使得它们成为经过最彻底测试的设置。不必要更改事物时可能会出现意外错误。
MySQL 的配置工作原理
我们将首先解释 MySQL 的配置机制,然后再讨论你应该在 MySQL 中配置什么。MySQL 通常对其配置相当宽容,但遵循这些建议可能会为你节省大量工作和时间。
首先要了解的是 MySQL 从哪里获取配置信息:从命令行参数和配置文件中的设置。在类 Unix 系统上,配置文件通常位于 /etc/my.cnf 或 /etc/mysql/my.cnf。如果您使用操作系统的启动脚本,这通常是您指定配置设置的唯一位置。如果您手动启动 MySQL,可能在运行测试安装时会这样做,您也可以在命令行上指定设置。服务器实际上会读取配置文件的内容,删除任何注释行和换行符,然后与命令行选项一起处理。
警告
您决定永久使用的任何设置都应放入全局配置文件中,而不是在命令行中指定。否则,您可能会意外地启动服务器而没有这些设置。另外,将所有配置文件放在一个地方也是个好主意,这样您可以轻松检查它们。
请确保知道服务器的配置文件位于何处!我们曾见过一些人试图使用服务器不读取的文件进行配置,例如 Debian 服务器上的 /etc/my.cnf,而这些服务器会在 /etc/mysql/my.cnf 中查找配置。有时会有文件位于多个配置,也许是因为以前的系统管理员也感到困惑。如果您不知道服务器读取哪些文件,可以询问它:
$ which mysqld
/usr/sbin/mysqld
$ */usr/sbin/mysqld --verbose --help | grep -A 1 'Default options'*
Default options are read from the following files in the given order:
/etc/mysql/my.cnf ~/.my.cnf /usr/etc/my.cnf
配置文件采用标准的 INI 格式,并分为多个部分,每个部分以包含部分名称的方括号开头的行开始。MySQL 程序通常会读取与该程序同名的部分,许多客户端程序也会读取 client 部分,这为您提供了一个放置常见设置的地方。服务器通常会读取 mysqld 部分。请确保将设置放在文件中的正确部分,否则它们将不起作用。
语法、作用域和动态性
配置设置以全小写形式编写,单词之间用下划线或破折号分隔。以下是等效的写法,在命令行和配置文件中可能会看到这两种形式:
/usr/sbin/mysqld --auto-increment-offset=5
/usr/sbin/mysqld --auto_increment_offset=5
我们建议您选择一种风格并保持一致。这样可以更容易地在文件中搜索设置。
配置设置可以具有多个作用域。一些设置是服务器范围的(全局作用域),其他设置对每个连接都不同(会话作用域),还有一些是针对每个对象的。许多会话作用域变量都有全局等效变量,您可以将其视为默认值。如果更改会话作用域变量,则仅影响更改它的连接,并且在连接关闭时更改将丢失。以下是您应该了解的各种行为的一些示例:
-
max_connections变量是全局作用域的。 -
sort_buffer_size变量具有全局默认值,但您也可以为每个会话设置它。 -
join_buffer_size变量具有全局默认值,并且可以为每个会话设置,但是一个查询连接多个表可能会为每个连接分配一个连接缓冲区,因此可能会有多个连接缓冲区。
除了在配置文件中设置变量外,您还可以在服务器运行时更改许多(但不是全部)变量。MySQL 将这些称为动态配置变量。以下语句展示了动态更改 sort_buffer_size 的会话和全局值的不同方法:
SET sort_buffer_size = <value>;
SET GLOBAL sort_buffer_size = <value>;
SET @@sort_buffer_size := <value>;
SET @@session.sort_buffer_size := <value>;
SET @@global.sort_buffer_size := <value>;
如果动态设置变量,请注意当 MySQL 关闭时这些设置将丢失。如果要保留设置,您必须更新配置文件。
提示
如果您在服务器运行时设置变量的全局值,则当前会话和任何其他现有会话的值不受影响。如果您的客户端依赖于持久性数据库连接,请记住这一点。这是因为当连接创建时,会话值是从全局值初始化的。您应该在每次更改后检查SHOW GLOBAL VARIABLES的输出,以确保它产生了预期的效果。
您还可以使用SET命令为变量分配一个特殊值:关键字DEFAULT。将此值分配给会话作用域变量会将该变量设置为相应全局作用域变量的值。这对于将会话作用域变量重置为打开连接时的值非常有用。我们建议您不要将其用于全局变量,因为它可能不会达到您想要的效果——也就是说,它不会将值设置回您启动服务器时的值,甚至不会设置为配置文件中指定的值;它将变量设置为编译时的默认值。
持久化系统变量
如果所有这些变量作用域和配置业务还不够复杂,您还必须意识到,如果重新启动 MySQL,它将恢复到配置文件中的内容,即使您使用SET GLOBAL更改全局变量。这意味着您必须管理一个配置文件和MySQL 的运行时配置,并确保它们彼此保持同步。如果您想增加服务器的max_connections,您必须在每个运行实例上发出SET GLOBAL max_connections命令,然后跟着编辑配置文件以反映您的新配置。
MySQL 8.0 引入了一个名为持久化系统变量的新功能,有助于使这个过程变得稍微简单一些。新的语法SET PERSIST现在允许您为运行时设置值一次,MySQL 将把这个设置写入磁盘,使其能够在下次重启时使用。
设置变量的副作用
动态设置变量可能会产生意想不到的副作用,比如刷新缓冲区中的脏块。在线更改哪些设置时要小心,因为这可能会导致服务器做大量工作。
有时您可以从变量的名称推断出其行为。例如,max_heap_table_size的功能就如其名:它指定了隐式内存临时表允许增长的最大大小。然而,命名约定并不完全一致,因此您不能总是通过查看名称来猜测变量的功能。
让我们看一下一些常用变量及更改它们动态的影响:
table_open_cache
设置此变量没有立即效果:效果会延迟到下次线程打开表时。当这种情况发生时,MySQL 会检查变量的值。如果值大于缓存中的表数,线程可以将新打开的表插入缓存中。如果值小于缓存中的表数,MySQL 会从缓存中删除未使用的表。
thread_cache_size
设置此变量没有立即效果:效果会延迟到下次连接关闭时。此时,MySQL 将检查缓存中是否有空间来存储线程。如果有,它会将线程缓存以便将来由另一个连接重用。如果没有,它会杀死线程而不是将其缓存。在这种情况下,缓存中的线程数以及线程缓存使用的内存量不会立即减少;只有当新连接从缓存中移除线程以使用它时,它才会减少。(MySQL 仅在连接关闭时添加线程到缓存中,并且仅在创建新连接时从缓存中删除线程。)
read_buffer_size
MySQL 不会为这个缓冲区分配任何内存,直到查询需要它,但然后它��即分配这里指定的整个内存块。
read_rnd_buffer_size
MySQL 不会为这个缓冲区分配任何内存,直到查询需要它,然后它只会分配所需的内存。(max_read_rnd_buffer_size这个名称更准确地描述了这个变量。)
官方的 MySQL 文档详细解释了这些变量的作用,这并不是一个详尽的列表。我们在这里的目标只是向你展示当你更改一些常见变量时可以期望的行为。
除非你知道这样做是正确的,否则不要全局提高每个连接设置的值。有些缓冲区即使不需要也会一次性分配,因此一个很大的全局设置可能是一个巨大的浪费。相反,当一个查询需要时,你可以提高这个值。
规划你的变量更改
在设置变量时要小心。更多并不总是更好,如果你将值设置得太高,你很容易引起问题:你可能会耗尽内存或导致服务器交换。
参考第二章,监控你的 SLOs 以确保你的更改不会影响客户体验。基准测试并不足够,因为它们不是真实的。如果你不测量服务器的实际性能,你可能会在不知情的情况下损害性能。我们看到许多情况下,有人更改了服务器的配置并认为它提高了性能,而实际上由于不同时间或不同日期的不同工作负载,服务器的性能整体上恶化了。
理想情况下,你正在使用版本控制系统跟踪对配置文件的更改。这种策略可以非常有效地将性能变化或 SLO 违规与特定配置更改相关联。只是要注意,默认情况下更改配置文件并不会做任何事情——你必须同时更改运行时设置。
在开始更改配置之前,你应该优化你的查询和模式,至少解决一些明显的问题,比如添加索引。如果你深入调整配置,然后更改查询或模式,你可能需要重新评估配置。请记住,除非你的硬件、工作负载和数据完全静态,否则你很可能需要稍后重新审视你的配置。事实上,大多数人的服务器甚至一天中的工作负载都不是完全稳定的——这意味着上午中间的“完美”配置并不适合下午中午!显然,追求神话般的“完美”配置是完全不切实际的。因此,你不需要从服务器中挤出每一丝性能;事实上,这样投入时间的回报可能非常小。我们建议你专注于优化你的高峰工作负载,然后在“足够好”的地方停下,除非你有理由相信你正在放弃重大的性能改进。
不要做什么
在开始服务器配置之前,我们想鼓励你避免一些我们发现有风险或实际上不值得努力的常见做法。警告:下面有抱怨!
你可能期望(或者认为你被期望)建立一个基准测试套件,并通过迭代更改其配置来“调整”服务器以寻找最佳设置。这通常不是我们建议大多数人做的事情。这需要很多工作和研究,而在大多数情况下潜在回报是如此之小,以至于可能是一种巨大的时间浪费。你可能最好将那些时间花在其他事情上,比如检查你的备份,监控查询计划的变化等等。
你不应该“按比率调优”。经典的“调优比率”是一个经验法则,即你的 InnoDB 缓冲池命中率应该高于某个百分比,如果命中率太低,你应该增加缓存大小。这是非常错误的建议。不管别人告诉你什么,缓存命中率与缓存是太大还是太小无关。首先,命中率取决于工作负载——有些工作负载无论缓存有多大都无法缓存,其次,缓存命中是毫无意义的,我们稍后会解释原因。有时候当缓存太小时,命中率较低,增加缓存大小会增加命中率。然而,这只是一个偶然的相关性,并不表示任何关于性能或正确缓存大小的信息。
有时候看起来正确的相关性的问题在于人们开始相信它们将永远正确。Oracle DBA 多年前就放弃了基于比率的调优,我们希望 MySQL DBA 能够效仿他们的做法。我们更加热切地希望人们不要编写“调优脚本”,将这些危险的做法编码化并传授给成千上万的人。这导致了我们下一个建议:不要使用调优脚本!互联网上有几个非常流行的调优脚本。最好还是将它们忽略掉。
我们还建议你避免使用调优这个词,我们在过去几段中大量使用了这个词。我们更倾向于使用配置或优化(只要你确实在做这个)。调优这个词让人联想到一个无纪律的新手,调整服务器然后看看发生了什么。我们在前一节中建议这种做法最好留给那些正在研究服务器内部的人。“调优”你的服务器可能是一种令人惊讶的时间浪费。
在相关主题上,搜索互联网上的配置建议并不总是一个好主意。你可以在博客、论坛等地方找到很多错误的建议。尽管许多专家在线贡献他们所知道的东西,但很难判断谁是合格的。当然,我们无法对在哪里找到真正专家给出公正的建议。但我们可以说,可信赖的、声誉良好的 MySQL 服务提供商通常比简单的互联网搜索结果更可靠,因为拥有满意客户的人可能做对了一些事情。然而,即使是他们的建议,如果没有测试和理解,应用起来也可能是危险的,因为它可能是针对一个你不理解的与你的情况不同的情况。
最后,不要相信流行的内存消耗公式——是的,就是 MySQL 自己在崩溃时打印出来的那个。(我们不会在这里重复它。)这个公式来自一个古老的时代。这不是一个可靠甚至有用的了解 MySQL 在最坏情况下可以使用多少内存的方法。你可能在互联网上看到一些关于这个公式的变体。这些同样存在缺陷,即使它们添加了原始公式没有的更多因素。事实是你无法对 MySQL 的内存消耗设定上限。它不是一个严格控制内存分配的数据库服务器。
创建一个 MySQL 配置文件
正如我们在本章开头提到的,我们没有适用于比如说一个有 4 个 CPU、16GB 内存和 12 个硬盘的服务器的“最佳配置文件”。你确实需要开发自己的配置,因为即使一个良好的起点也会根据你如何使用服务器而有很大的不同。
最小配置
我们为本书创建了一个最小的示例配置文件,你可以将其用作你自己服务器的良好起点。你必须为一些设置选择值;我们稍后会在本章解释这些设置。我们的基础文件,围绕 MySQL 8.0 构建,看起来像这样:
[mysqld]
# GENERAL
datadir = /var/lib/mysql
socket = /var/lib/mysql/mysql.sock
pid_file = /var/lib/mysql/mysql.pid
user = mysql
port = 3306
# INNODB
innodb_buffer_pool_size = <value>
innodb_log_file_size = <value>
innodb_file_per_table = 1
innodb_flush_method = O_DIRECT
# LOGGING
log_error = /var/lib/mysql/mysql-error.log
log_slow_queries = /var/lib/mysql/mysql-slow.log
# OTHER
tmp_table_size = 32M
max_heap_table_size = 32M
max_connections = <value>
thread_cache_size = <value>
table_open_cache = <value>
open_files_limit = 65535
[client]
socket = /var/lib/mysql/mysql.sock
port = 3306
这与您习惯看到的可能太简单了,但实际上已经超出了许多人的需求。还有一些其他类型的配置选项,您可能也会经常使用,比如二进制日志记录;我们将在本章和其他章节中详细介绍这些内容。
我们配置的第一件事是数据的位置。我们选择了/var/lib/mysql,因为这是许多 Unix 变体上的常用位置。选择其他位置也没有问题;由您决定。我们将.pid文件放在相同位置,但许多操作系统可能希望将其放在/var/run中。这也可以。我们只是需要为这些设置配置一些内容。顺便说一句,不要让套接字和.pid文件根据服务器的编译默认位置放置;各种 MySQL 版本中可能会出现一些错误,可能会导致问题。最好明确设置这些位置。(我们不建议选择不同的位置;我们只建议确保my.cnf文件明确提到这些位置,这样在升级服务器时它们不会更改并破坏事情。)
我们还指定了mysqld应以操作系统上的mysql用户帐户运行。您需要确保此帐户存在,并且拥有数据目录和其中的所有文件。端口设置为默认的3306,但有时您可能需要更改。
在 MySQL 8.0 中,引入了一个新的配置选项,innodb_dedicated_server。此选项会检查服务器上的可用内存,并适当配置四个附加变量(innodb_buffer_pool_size、innodb_log_file_size、innodb_log_files_in_group和innodb_flush_method)以用于专用数据库服务器,这简化了计算和更改这些值。在云环境中,这可能特别有用,您可能会运行具有 128 GB RAM 的虚拟机(VM),然后重新启动以扩展到 256 GB RAM。MySQL 在这里将自动配置,您无需管理更改配置文件中的值。这通常是管理这四个设置的最佳方法。
我们示例文件中的大多数其他设置都相当容易理解,其中许多是主观判断的问题。我们将在本章的其余部分探讨其中几个。我们还将在本章后面讨论一些安全设置,这些设置可以帮助使您的服务器更加健壮,并有助于防止糟糕的数据和其他问题。我们这里不展示这些设置。
这里要解释的一个设置是open_files_limit选项。在典型的 Linux 系统上,我们将其设置为尽可能大。在现代操作系统上,打开文件句柄非常便宜。如果此设置不够大,您将看到错误 24,“打开文件过多”。
跳到最后,配置文件中的最后一节是用于mysql和mysqladmin等客户端程序的,只是让它们知道如何连接到服务器。您应该设置客户端程序的值与您为服务器选择的值相匹配。
检查 MySQL 服务器状态变量
有时,您可以使用SHOW GLOBAL STATUS的输出作为配置的输入,以帮助更好地为您的工作负载定制设置。为了获得最佳结果,最好同时查看绝对值和值随时间变化的情况,最好在高峰和低峰时间进行多次快照。您可以使用以下命令每 60 秒查看状态变量的增量变化:
$ mysqladmin extended-status -ri60
当我们解释各种配置设置时,我们经常会提到随时间变化的状态变量的变化。通常我们期望您检查类似我们刚刚展示的命令的输出。其他有用的工具,可以提供紧凑的状态计数器变化显示的有 Percona Toolkit 的pt-mext或pt-mysql-summary。
现在我们已经向你展示了一些基础知识,我们将带你参观一些服务器内部,并交替提供配置建议。这将为你选择适当的配置选项值提供所需的背景知识,当我们稍后返回示例配置文件时。
配置内存使用
使用innodb_dedicated_server通常会使用 50%–75%的 RAM。这至少为每个连接的内存分配、操作系统开销和其他内存设置留出了 25%。我们将在接下来的部分详细介绍每一个,并然后更详细地查看各种 MySQL 缓存的需求。
每个连接的内存需求
MySQL 需要一小部分内存来保持连接(通常与一个关联的专用线程)的打开状态。它还需要一定的内存来执行任何给定的查询。你需要为 MySQL 在高负载时段执行查询留出足够的内存。否则,你的查询将因内存不足而运行不佳或失败。
了解 MySQL 在高峰使用期间将消耗多少内存是有用的,但某些使用模式可能会意外地消耗大量内存,这使得难以预测。准备好的语句就是一个例子,因为你可以同时打开许多这样的语句。另一个例子是 InnoDB 数据字典(稍后会详细介绍)。
在尝试预测峰值内存消耗时,你不需要假设最坏情况。例如,如果你配置 MySQL 允许最多一百个连接,理论上可能同时在所有一百个连接上运行大型查询,但实际上这可能不会发生。使用许多大型临时表或复杂存储过程的查询是高每个连接内存消耗的最有可��的原因。
为操作系统保留内存
就像查询一样,你需要为操作系统保留足够的内存来完成其工作。这涉及运行任何本地监控软件、配置管理工具、定期作业等。操作系统有足够内存的最好指标是它没有主动将虚拟内存交换(分页)到磁盘。
InnoDB 缓冲池
InnoDB 缓冲池需要比其他任何东西都更多的内存,因为它通常是性能的最重要变量。InnoDB 缓冲池不仅仅缓存索引:它还保存行数据、自适应哈希索引、更改缓冲区、锁定和其他内部结构。InnoDB 还使用缓冲池来帮助延迟写入,这样它可以合并许多写入并按顺序执行它们。简而言之,InnoDB 严重依赖于缓冲池,你应该确保为其分配足够的内存。你可以使用SHOW命令的变量或诸如innotop之类的工具来监视你的 InnoDB 缓冲池的内存使用情况。
如果你没有太多数据,并且知道你的数据不会快速增长,那么你不需要为缓冲池过度分配内存。将其大小远远大于它将容纳的表和索引的大小并不真正有益。当然,提前规划一个快速增长的数据库也没有错,但有时我们会看到一个微不足道的数据量却有着巨大的缓冲池。这是不必要的。
大型缓冲池带来一些挑战,例如长时间的关闭和热身时间。如果缓冲池中有许多脏(修改的)页面,InnoDB 在关闭时可能需要很长时间,因为它会在关闭时将脏页写入数据文件。你可以强制它快速关闭,但然后它只需在重新启动时执行更多的恢复,因此实际上无法加快关闭和重新启动周期时间。如果你事先知道需要关闭的时间,可以在运行时将innodb_max_dirty_pages_pct变量更改为较低的值,等待刷新线程清理缓冲池,然后在脏页数量变少时关闭。你可以通过观察innodb_buffer_pool_pages_dirty服务器状态变量或使用innotop监视SHOW INNODB STATUS来监视脏页的数量。你还可以使用变量innodb_fast_shutdown来调整关闭的方式。
降低innodb_max_dirty_pages_pct变量的值并不能保证 InnoDB 在缓冲池中保留较少的脏页。相反,它控制了 InnoDB 停止“懒惰”的阈值。InnoDB 的默认行为是使用后台线程刷新脏页,将写操作合并在一起并按顺序执行以提高效率。这种行为被称为“懒惰”,因为它允许 InnoDB 延迟刷新缓冲池中的脏页,除非需要为其他数据使用空间。当脏页的百分比超过阈值时,InnoDB 会尽快刷新页面,以尝试保持较低的脏页计数。这些页面清理操作已经从以前的行为中得到了很大的优化,包括能够配置多个线程执行刷新。
当 MySQL 再次启动时,缓冲池缓存为空,也称为冷缓存。现在,将所有行和页面放入内存的好处都消失了。幸运的是,默认情况下,配置选项innodb_buffer_pool_dump_at_shutdown和innodb_buffer_pool_load_at_startup一起在启动时使服务器变热。启动时的加载需要时间,但它可以比等待自然填充更快地提高服务器的性能。
线程缓存
线程缓存保存着当前没有与连接关联但准备为新连接提供服务的线程。当缓存中有一个线程且创建了新连接时,MySQL 会将线程从缓存中移除并分配给新连接。当连接关闭时,如果有空间,MySQL 会将线程放回缓存中。如果没有空间,MySQL 会销毁线程。只要 MySQL 在缓存中有空闲线程,它就可以快速响应连接请求,因为它不必为每个连接创建新线程。
thread_cache_size变量指定 MySQL 可以在缓存中保留的线程数。除非你的服务器收到许多连接请求,否则你可能不需要将其从默认值-1或自动大小更改。要检查线程缓存是否足够大,请观察Threads_created状态变量。通常我们尝试保持线程缓存足够大,以便每秒创建的新线程少于 10 个,但通常很容易将这个数字降低到每秒不到一个。
一个好的方法是观察Threads_connected变量并尝试将thread_cache_size设置为足够大以处理工作负载的典型波动。例如,如果Threads_connected通常保持在 100 到 120 之间,你可以将缓存大小设置为 20。如果保持在 500 到 700 之间,200 个线程缓存应该足够大。可以这样想:在 700 个连接时,可能没有线程在缓存中;在 500 个连接时,有 200 个缓存线程准备在负载再次增加到 700 时使用。
使线程缓存非常大可能对大多数用途来说并不是必要的,但保持较小的线程缓存并不能节省太多内存,因此这样做几乎没有什么好处。每个在线程缓存中或正在休眠的线程通常使用大约 256 KB 的内存。与连接在积极处理查询时线程可以使用的内存量相比,这并不多。一般来说,你应该保持线程缓存足够大,以便Threads_created不会经常增加。然而,如果这是一个非常大的数字(例如,成千上万的线程),你可能希望将其设置得更低,因为一些操作系统即使大多数线程处于休眠状态时也无法很好地处理非常大的线程数量。
配置 MySQL 的 I/O 行为
一些配置选项会影响 MySQL 如何将数据同步到磁盘并执行恢复操作。这些选项可能会对性能产生显著影响,因为它们涉及 I/O 操作。它们也代表了性能和数据安全之间的权衡。一般来说,确保数据立即和一致地写入磁盘是很昂贵的。如果你愿意冒磁盘写入可能不会真正到达永久存储的风险,你可以增加并发性和/或减少 I/O 等待时间,但你必须自己决定可以容忍多少风险。
InnoDB 允许你控制它的恢复方式以及如何打开和刷新其数据,这对恢复和整体性能有很大影响。InnoDB 的恢复过程是自动的,并且总是在 InnoDB 启动时运行,尽管你可以影响它采取的行动。撇开恢复不谈,假设从不崩溃或出现问题,对于 InnoDB 仍有很多配置要做。它有一个复杂的缓冲区和文件链设计用于提高性能并保证 ACID 属性,每个链的部分都是可配置的。图 5-1 说明了这些文件和缓冲区。
对于正常使用来说,需要更改的一些最重要的事项是 InnoDB 日志文件大小、InnoDB 如何刷新其日志缓冲区以及 InnoDB 如何执行 I/O。

图 5-1。InnoDB 的缓冲区和文件
InnoDB 事务日志
InnoDB 使用其日志来降低提交事务的成本。它不是在每个事务提交时将缓冲池刷新到磁盘,而是记录事务。事务对数据和索引所做的更改通常映射到表空间中的随机位置,因此将这些更改刷新到磁盘将需要随机 I/O。InnoDB 假设它正在使用传统磁盘,其中随机 I/O 比顺序 I/O 更昂贵,因为寻找正确位置并等待所需部分磁盘旋转到磁头下的时间更长。
InnoDB 使用��日志将这种随机磁盘 I/O 转换为顺序 I/O。一旦日志安全地存储在磁盘上,事务就是永久的,即使更改尚未写入数据文件。如果发生不良事件(例如断电),InnoDB 可以重放日志并恢复已提交的事务。
当然,InnoDB 最终必须将更改写入数据文件,因为日志的大小是固定的。它以循环方式写入日志:当它到达日志末尾时,它会回到开头。如果尚未将其中包含的更改应用于数据文件,它不能覆盖日志记录,因为这将擦除已提交事务的唯一永久记录。
InnoDB 使用后台线程智能地将更改刷新到数据文件。该线程可以将写入组合在一起,并使数据写入顺序以提高效率。实际上,事务日志将随机数据文件 I/O 转换为主要是顺序的日志文件和数据文件 I/O。将刷新移到后台使查询更快完成,并帮助缓冲 I/O 系统免受查询负载的波动影响。
日志文件的整体大小由innodb_log_file_size和innodb_log_files_in_group控制,对写入性能非常重要。如果您遵循我们之前的建议并使用innodb_dedicated_server,则根据系统内存量来管理这些设置。
日志缓冲区
当 InnoDB 更改任何数据时,它会将更改记录写入其保存在内存中的日志缓冲区。当缓冲区变满、事务提交或每秒一次时,InnoDB 会将缓冲区刷新到磁盘上的日志文件。增加缓冲区大小(默认为 1 MB)可以帮助减少 I/O,特别是对于大型事务。控制缓冲区大小的变量称为innodb_log_buffer_size。
通常不需要使缓冲区非常大。推荐的范围是 1-8 MB,这通常足够,除非您写入大量巨大的BLOB记录。与 InnoDB 的正常数据相比,日志条目非常紧凑。它们不是基于页面的,因此不会浪费空间一次存储整个页面。InnoDB 还尽可能地使日志条目短小。有时甚至将它们存储为几个整数,指示记录的操作类型和该操作所需的任何参数!
InnoDB 如何刷新日志缓冲区
当 InnoDB 将日志缓冲区刷新到磁盘上的日志文件时,它会使用互斥锁锁定缓冲区,将其刷新到所需点,然后将任何剩余条目移动到缓冲区的前面。当互斥锁被释放时,可能会有多个事务准备刷新其日志条目。InnoDB 使用组提交功能,可以将所有这些事务一次性提交到日志中。
必须将日志缓冲区刷新到持久存储以确保已提交的事务完全持久。如果您更关心性能而不是持久性,可以更改innodb_flush_log_at_trx_commit以控制何时以及多频繁刷新日志缓冲区。
可能的设置如下:
0
将日志缓冲区写入日志文件并每秒刷新一次日志文件,但在事务提交时不执行任何操作。
1
将日志缓冲区写入日志文件并在每次事务提交时刷新到持久存储。这是默认(也是最安全)的设置;它保证您不会丢失任何已提交的事务,除非磁盘或操作系统“伪造”刷新操作。
2
在每次提交时将日志缓冲区写入日志文件,但不要刷新它。InnoDB 每秒调度一次刷新。与0设置最重要的区别是,如果 MySQL 进程崩溃,2不会丢失任何事务。但是,如果整个服务器崩溃或断电,您仍然可能会丢失事务。
重要的是要知道将日志缓冲区写入日志文件和将日志刷新到持久存储之间的区别。在大多数操作系统中,将缓冲区写入日志只是将数据从 InnoDB 的内存缓冲区移动到操作系统的缓存中,该缓存也位于内存中。它实际上并没有将数据写入持久存储。因此,设置0和2通常会导致在崩溃或停电时最多丢失一秒钟的数据,因为数据可能仅存在于操作系统的缓存中。我们说“通常”是因为 InnoDB 会尝试无论如何每秒刷新一次日志文件到磁盘,但在某些情况下可能会丢失超过一秒钟的事务,例如刷新被阻塞时。
有时硬盘控制器或操作系统通过将数据放入另一个缓存中(例如硬盘自己的缓存)来伪造刷新。这样做更快,但非常危险,因为如果驱动器断电,数据可能仍然会丢失。这比将innodb_flush_log_at_trx_commit设置为1更糟糕,因为它可能导致数据损坏,而不仅仅是丢失事务。
将innodb_flush_log_at_trx_commit设置为除1之外的任何值可能会导致您丢失事务。但是,如果您不关心耐久性(ACID 中的 D),则可能会发现其他设置有用。也许您只想要 InnoDB 的其他一些功能,例如聚集索引、抗数据损坏和行级锁定。
高性能事务需求的最佳配置是将innodb_flush_log_at_trx_commit设置为1,并将日志文件放在具有带电池支持写缓存和固态硬盘的 RAID 卷上。这既安全又非常快速。事实上,我们敢说,任何预计要处理严重工作负载的生产数据库服务器都需要具有这种硬件。
如何打开和刷新 InnoDB 的日志文件和数据文件
innodb_flush_method选项允许您配置 InnoDB 实际与文件系统的交互方式。尽管它的名称是这样,但它也影响 InnoDB 读取数据的方式,而不仅仅是写入数据的方式。
警告
更改 InnoDB 执行 I/O 操作的方式可能会极大地影响性能,因此在更改任何内容之前,请确保您了解自己在做什么!
这是一个稍微令人困惑的选项,因为它既影响日志文件又影响数据文件,并且有时对每种类型的文件执行不同的操作。希望有一个配置选项用于日志和另一个用于数据文件,但它们被合并在一起。
如果您使用类 Unix 操作系统,并且您的 RAID 控制器具有带电池支持写缓存,我们建议您使用O_DIRECT。如果没有,无论是默认设置还是O_DIRECT都可能是最佳选择,这取决于您的应用程序。如果您选择使用我们之前提到的innodb_dedicated_server,此选项将自动为您设置。
InnoDB 表空间
InnoDB 将其数据保存在一个表空间中,这实质上是一个跨越磁盘上一个或多个文件的虚拟文件系统。InnoDB 使用表空间不仅用于存储表和索引,还用于许多其他目的。它在表空间中保存其撤销日志(重新创建旧行版本所需的信息)、更改缓冲区、双写缓冲区和其他内部结构。
配置表空间
您可以使用innodb_data_file_path配置选项指定表空间文件。所有文件都包含在由innodb_data_home_dir给定的目录中。以下是一个示例:
innodb_data_home_dir = /var/lib/mysql/
innodb_data_file_path = ibdata1:1G;ibdata2:1G;ibdata3:1G
这将创建一个 3 GB 的表空间,分为三个文件。有时人们会想知道是否可以使用多个文件将负载分布到不同的驱动器上,就像这样:
innodb_data_file_path = /disk1/ibdata1:1G;/disk2/ibdata2:1G;...
尽管确实将文件放置在不同目录中,这些目录在此示例中代表不同的驱动器,但 InnoDB 会将文件端对端连接起来。因此,通常你不会通过这种方式获得太多好处。InnoDB 会填满第一个文件,然后在第一个文件满时填满第二个文件,依此类推;负载并没有以你需要的方式分布以获得更高的性能。RAID 控制器是一种更智能的负载分布方式。
如果表空间空间不足而需要增长,您可以使最后一个文件自动扩展,如下所示:
...ibdata3:1G:autoextend
默认行为是创建一个单个 10 MB 的自动扩展文件。如果使文件自动扩展,最好设置表空间大小的上限,以防止其变得非常大,因为一旦增长,就不会缩小。例如,以下限制了自动扩展文件为 2 GB:
...ibdata3:1G:autoextend:max:2G
管理单个表空间可能会很麻烦,特别是如果它自动扩展并且您想要回收空间(因此,我们建议禁用自动扩展功能,或者至少设置一个合理的空间上限)。回收空间的唯一方法是转储数据,关闭 MySQL,删除所有文件,更改配置,重新启动,让 InnoDB 创建新的空文件,并恢复数据。InnoDB 对其表空间非常严格:您不能简单地删除文件或更改其大小。如果破坏了其表空间,它将拒绝启动。它对其日志文件也非常严格。如果您习惯于像 MyISAM 一样随意移动文件,要小心!
innodb_file_per_table选项允许您配置 InnoDB 使用每个表一个文件。它将数据存储在数据库目录中的tablename.ibd文件中。这样在删除表时更容易回收空间。然而,将数据放在多个文件中实际上可能导致整体浪费更多空间,因为它将单个 InnoDB 表空间中的内部碎片换成了.ibd文件中的浪费空间。
即使启用了innodb_file_per_table选项,您仍然需要主表空间来存储撤销日志和其他系统数据。如果不将所有数据存储在其中,则其大小会更小。
有些人喜欢使用innodb_file_per_table仅仅是因为它给您带来额外的可管理性和可见性。例如,通过检查单个文件来查找表的大小要比使用SHOW TABLE STATUS更快,后者必须执行更复杂的工作来确定为表分配了多少页。
警告
innodb_file_per_table一直存在一个阴暗面:DROP TABLE性能慢。这可能严重到足以导致整个服务器出现明显的停顿,原因有两个。
删除表会在文件系统级别取消链接(删除)文件,在某些文件系统上可能会非常慢(ext3,我们在看你)。您可以通过文件系统上的技巧缩短此过程的持续时间:将.ibd文件链接到一个大小为零的文件,然后手动删除文件,而不是等待 MySQL 执行此操作。
当您启用此选项时,每个表在 InnoDB 内部都有自己的表空间。事实证明,删除表空间实际上需要 InnoDB 锁定并扫描缓冲池,同时查找属于该表空间的页面,在具有大缓冲池的服务器上非常慢。如果使用innodb_buffer_pool_instances将缓冲池分成多个部分,这将得到改善。
在 MySQL 的各个版本中已经应用了几个修复程序。截至 8.0.23,这不应再是一个问题。
最终的建议是什么?我们建议您使用innodb_file_per_table并限制共享表空间的大小,以使您的生活更轻松。如果遇到任何使这变得痛苦的情况,如前所述,请考虑我们建议的其中一种修复方法。
旧的行版本和表空间
在写入密集的环境中,InnoDB 的表空间可能会变得非常大。如果事务保持打开状态很长时间(即使它们没有执行任何工作),并且它们使用默认的REPEATABLE READ事务隔离级别,InnoDB 将无法删除旧的行版本,因为未提交的事务仍需要能够查看它们。InnoDB 将旧版本存储在表空间中,因此随着更新更多数据,它将继续增长。清除过程是多线程的,但如果您遇到清除滞后问题(innodb_purge_threads和innodb_purge_batch_size),可能需要对工作负载进行调整。
SHOW INNODB STATUS的输出可以帮助您准确定位问题。查看TRANSACTIONS部分中的历史列表长度;它显示了撤销日志的大小:
------------
TRANSACTIONS
------------
Trx id counter 1081043769321
Purge done for trx's n:o < 1081041974531 undo n:o < 0 state: running but idle
History list length 697068
如果您有一个大的撤销日志,并且您的表空间因此而增长,您可以强制 MySQL 减慢速度,以便 InnoDB 的清除线程跟得上。这听起来可能不那么吸引人,但没有其他选择。否则,InnoDB 将继续写入数据并填满您的磁盘,直到磁盘耗尽空间或表空间达到您定义的限制。
要限制写入速度,将innodb_max_purge_lag变量设置为非0值。此值表示在 InnoDB 开始延迟更新数据的进一步查询之前,可以等待清除的最大事务数。您需要了解您的工作负载以决定一个好的值。举个例子,如果您的平均事务影响 1 KB 的行,并且您的表空间可以容忍 100 MB 的未清除行,您可以将该值设置为100000。
请记住,未清除的行版本会影响所有查询,因为它们实际上会使您的表和索引变得更大。如果清除线程无法跟上,性能可能会下降。设置innodb_max_purge_lag变量也会降低性能,但这是两害相权取其轻的选择。
其他 I/O 配置选项
sync_binlog选项控制 MySQL 将二进制日志刷新到磁盘的方式。其默认值为1,这意味着 MySQL 将执行刷新并保持二进制日志持久和安全。这是推荐的设置,我们警告您不要将其设置为其他任何值。
如果您不将sync_binlog设置为1,很可能会导致崩溃使您的二进制日志与事务数据不同步。这很容易破坏复制并使恢复变得不可能,特别是如果您的数据库正在使用全局事务 ID(更多信息请参见第九章)。保持此设置为1提供的安全性远远超过产生的 I/O 性能惩罚。
我们在第四章中更深入地讨论了 RAID,但在这里值得重申,具有设置为使用写回策略的带电池支持写缓存的高质量 RAID 控制器可以处理成千上万次写入,并仍然为您提供持久性存储。数据被写入一个带电池的快速缓存中,因此即使系统断电,数据也会存活。当电源恢复时,RAID 控制器将从缓存中将数据写入磁盘,然后使磁盘可供使用。因此,具有足够大的带电池支持写缓存的良好 RAID 控制器可以显着提高性能,并且是非常值得投资的。当然,固态存储也是目前推荐的解决方案,可以显著提高 I/O 性能。
配置 MySQL 并发性
当您在高并发工作负载中运行 MySQL 时,您可能会遇到在其他情况下不会遇到的瓶颈。本节解释了如何在发生这些问题时检测这些问题,并如何在这些工作负载下获得最佳性能。
如果您在 InnoDB 并发性方面遇到问题,并且您的 MySQL 版本低于 5.7,解决方案通常是升级服务器。旧版本仍存在许多高并发性可扩展性挑战。所有排队在全局互斥体上,如缓冲池互斥体,服务器实际上几乎停滞不前。如果您升级到较新版本的 MySQL,大多数情况下不需要限制并发性。
如果发现自己遇到了这个瓶颈,最好的选择是对数据进行分片。如果分片不是可行的解决方案,可能需要限制并发性。InnoDB 有自己的“线程调度器”,控制着线程如何进入其内核以访问数据以及它们在内核内部可以做什么。限制并发性的最基本方法是使用innodb_thread_concurrency变量,它限制了同时可以在内核中的线程数量。值为0表示线程数量没有限制。如果在较旧的 MySQL 版本中遇到 InnoDB 并发问题,那么这个变量是最重要的一个需要配置的。
MySQL 的在线文档提供了这里配置的最佳指南。您将不得不进行实验,找到适合您系统的最佳值,但我们建议从将innodb_thread_concurrency设置为可用 CPU 核心数量开始,然后根据需要进行调整。
如果已经有超过允许数量的线程在内核中,线程就无法进入内核。InnoDB 使用两阶段过程尝试让线程尽可能高效地进入。两阶段策略减少了由操作系统调度程序引起的上下文切换开销。线程首先休眠innodb_thread_sleep_delay微秒,然后再次尝试。如果仍然无法进入,它将进入等待线程队列,并让出给操作系统。
第一阶段的默认睡眠时间为 10,000 微秒。在高并发环境下,当 CPU 未充分利用且有大量线程处于“进入队列前休眠”状态时,更改此值可能有所帮助。如果有大量小查询,那么默认值可能过大,因为它会增加查询延迟。
一旦线程进入内核,它就有一定数量的“票”,让它可以“免费”地重新进入内核,而无需进行任何并发检查。这限制了它在必须重新排队与其他等待线程之前可以完成的工作量。innodb_concurrency_tickets选项控制票的数量。除非有大量运行时间极长的查询,否则很少需要更改。票据是按查询而不是按事务授予的。一旦查询完成,未使用的票据将被丢弃。
除了缓冲池和其他结构中的瓶颈外,在提交阶段还存在另一个并发瓶颈,这主要是由于刷新操作而导致的 I/O 绑定。innodb_commit_concurrency变量控制着同时可以提交的线程数量。如果即使将innodb_thread_concurrency设置为较低值时仍然存在大量线程抖动,配置此选项可能会有所帮助。
安全设置
在基本配置设置完成后,您可能希望启用一些使服务器更安全和可靠的设置。其中一些会影响性能,因为安全性和可靠性通常更昂贵。但有些只是明智的:它们防止插入荒谬数据到服务器中。还有一些在日常运营中没有影响,但可以防止在边缘情况下发生糟糕的事情。
让我们首先看一些通用服务器行为的有用选项集:
max_connect_errors
如果您��网络出现问题一小段时间,存在应用程序或配置错误,或者有其他问题导致连接在短时间内无法成功完成,客户端可能会被阻塞,并且无法再次连接,直到刷新主机缓存。此选项的默认设置(100)太小,以至于这个问题可能会太容易发生。您可能希望增加它,实际上,如果您知道服务器已充分防范了暴力攻击,您可以将其设置得非常大,以有效地禁用由于连接错误而阻止主机的功能。但是,如果启用了skip_name_resolve,max_connect_errors选项将不起作用,因为其行为取决于主机缓存,而skip_name_resolve已禁用了主机缓存。
max_connections
此设置就像一个紧急刹车,防止您的服务器被应用程序的连接激增所淹没。如果应用程序表现不佳或服务器遇到问题,如停顿,可能会打开大量新连接。但如果无法执行查询,那么打开连接是没有用的,因此被拒绝并显示“连接过多”错误是一种快速失败和廉价失败的方式。
将max_connections设置得足够高,以容纳您认为会经历的常规负载以及一个安全裕度,以便登录和管理服务器。例如,如果您认为在正常运作中会有大约 300 个连接,您可能会将其设置为 500 左右。如果您不知道会有多少连接,500 也不是一个不合理的起点。默认值为 151,但对许多应用程序来说这是不够的。
还要注意可能导致连接限制的意外情况。例如,如果重新启动应用程序服务器,它可能不会干净地关闭其连接,而 MySQL 可能不会意识到这些连接已关闭。当应用程序服务器重新启动并尝试打开到数据库的连接时,可能会因尚未超时的死连接而被拒绝。如果您不使用持久连接,且您的应用程序没有正常断开连接,这也可能发生。服务器将保留连接直到达到 TCP 超时或者在最坏的情况下,直到使用wait_timeout配置的秒数。
随着时间的推移,观察max_used_connections状态变量。这是一个高水位标记,显示服务器是否在某个时间点出现了连接激增。如果达到max_connections,很有可能至少有一个客户端被拒绝过。
skip_name_resolve
此设置禁用了另一个与网络和身份验证相关的陷阱:DNS 查找。DNS 是 MySQL 连接过程中的一个弱点。当您连接到服务器时,默认情况下会尝试确定您连接的主机名,并将其用作身份验证凭据的一部分(也就是说,您的凭据是您的用户名、主机名和密码,而不仅仅是用户名和密码)。但要验证您的主机名,服务器需要执行一个正向确认的反向 DNS 查找(或“双重反向 DNS 查找”),在接受连接之前需要进行反向和正向 DNS 查找。这一切都很好,直到 DNS 开始出现问题,这在某个时间点几乎是肯定的。当发生这种情况时,一切都会积累起来,最终连接会超时。为了防止这种情况发生,我们强烈建议您设置此选项,该选项在身份验证期间禁用 DNS 查找。但是,如果您这样做,您将需要将所有基于主机名的授权转换为使用 IP 地址、通配符或特殊主机名“localhost”,因为基于主机名的帐户将被禁用。
sql_mode
此设置可以接受多种修改服务器行为的选项。我们不建议仅仅出于好玩而更改这些设置;最好让 MySQL 保持 MySQL 的大部分特性,不要试图使其像其他数据库服务器一样运行。(许多客户端和 GUI 工具期望 MySQL 具有自己的 SQL 风格,例如,如果您将其更改为使用更符合 ANSI 标准的 SQL,可能会导致某些功能出现问题。)但是,其中几个设置非常有用,某些情况下可能值得考虑。过去,MySQL 通常对sql_mode非常宽松,但在后续版本中变得更加严格。
但是,请注意,对于现有应用程序更改这些设置可能不是一个好主意,因为这样做可能会使服务器与应用程序的期望不兼容。例如,人们很常见地无意中编写查询,引用了GROUP BY子句中不存在的列或使用聚合函数,因此,如果您想启用ONLY_FULL_GROUP_BY选项,最好先在开发或分段服务器上执行,只有在确保一切正常运行后才在生产环境中部署。
此外,在计划升级数据库时,请务必检查默认sql_mode的更改。对此变量的更改可能与您现有的应用程序不兼容,您需要提前进行测试。我们在附录 A 中更详细地讨论升级问题。
sysdate_is_now
这是另一个可能与应用程序期望不兼容的设置。但是,如果您不明确希望SYSDATE()函数具有非确定性行为,这可能会破坏复制并使来自备份的时间点恢复不可靠,您可能希望启用此选项并使其行为确定性。
read_only和super_read_only
read_only选项防止非特权用户在副本上进行更改,副本应该仅通过复制接收更改,而不是来自应用程序。我们强烈建议将副本设置为只读模式。
还有一个更为严格的只读选项,super_read_only,即使具有SUPER特权的用户也无法写入数据。启用此选项后,唯一可以向数据库写入更改的是复制。我们还强烈建议启用super_read_only。这将防止您意外使用管理员帐户向只读副本写入数据,使其不同步。
高级 InnoDB 设置
一些 InnoDB 选项对服务器性能非常重要,还有一些安全选项:
innodb_autoinc_lock_mode
此选项控制 InnoDB 如何生成自增主键值,这在某些情况下可能成为瓶颈,例如高并发插入。如果有许多事务在等待自增锁(您可以在SHOW ENGINE INNODB STATUS中看到此信息),则应该调查此设置。我们不会重复手册对选项及其行为的解释。
innodb_buffer_pool_instances
此设置在 MySQL 5.5 及更高版本中将缓冲池分成多个段,可能是改善 MySQL 在具有高并发工作负载的多核机器上的可伸缩性的最重要方法之一。多个缓冲池将工作负载分区,以便一些全局互斥锁不会成为如此热点争用点。
innodb_io_capacity
InnoDB 过去被硬编码为假定其在能够进行一百次 I/O 操作的单个硬盘上运行。这是一个糟糕的默认设置。现在您可以告知 InnoDB 可用的 I/O 容量。有时 InnoDB 需要将此设置设置得非常高(例如在极快的存储设备上,如 PCIe 闪存设备上设置为数万次)以稳定地刷新脏页,原因相当复杂,难以解释。⁴
innodb_read_io_threads和innodb_write_io_threads
这些选项控制了可用于 I/O 操作的后台线程数量。在 MySQL 的最新版本中,默认设置为四个读线程和四个写线程,对于许多服务器来说已经足够了,尤其是自 MySQL 5.5 以来提供了本机异步 I/O。如果您有许多硬盘和高并发工作负载,并且发现线程难以跟上,您可以增加线程数量,或者您可以简单地将它们设置为您用于 I/O 的物理磁盘数量(即使它们位于 RAID 控制器后面)。
innodb_strict_mode
此设置使 InnoDB 在某些情况下(尤其是无效或可能危险的CREATE TABLE选项)抛出错误而不是警告。如果您启用此选项,请务必检查所有的CREATE TABLE选项,因为它可能不允许您创建一些以前可以的表。有时它有点悲观和过于限制性。您不希望在尝试恢复备份时才发现这一点。
innodb_old_blocks_time
InnoDB 有一个两部分��冲池 LRU 列表,旨在防止临时查询驱逐长期多次使用的页面。一次性查询(例如 mysqldump 发出的查询)通常会将页面带入缓冲池 LRU 列表,从中读取行,并继续下一个页面。理论上,两部分 LRU 列表将防止此页面驱逐需要长期存在的页面,将其放入“年轻”子列表中,并在多次访问后才将其移至“老”子列表。但默认情况下,InnoDB 未配置为防止这种情况发生,因为页面有多行,因此多次访问以读取行将导致其立即移至“老”子列表,对需要长寿命的页面施加压力。此变量指定页面从 LRU 列表的“年轻”部分移至“老”部分之前必须经过的毫秒数。默认设置为0,将其设置为一个小值,例如1000(一秒),在我们的基准测试中已被证明非常有效。
摘要
在您完成本章后,您应该拥有比默认配置好得多的服务器配置。您的服务器应该快速稳定,除非遇到异常情况,否则您不需要调整配置。
总结一下,我们建议您从我们的示例配置文件开始,为您的服务器和工作负载设置基本选项,并根据需要添加安全选项。这确实是您需要做的全部。
如果您正在运行专用数据库服务器,那么您可以设置的最佳选项是innodb_dedicated_server,它处理了您 90% 的性能配置。如果您无法使用此选项,则最重要的选项是这两个:
-
innodb_buffer_pool_size -
innodb_log_file_size
恭喜您——您刚刚解决了我们见过的绝大多数真实世界配置问题!
我们还提出了很多关于不要做什么的建议。其中最重要的是不要“调整”您的服务器,也不要使用比率、公式或“调整脚本”作为设置配置变量的依据。
¹ 例如,如果关闭 MySQL 的耐久性设置,它可以运行得非常快,但也会在崩溃时使您的数据容易丢失。
² 如果您还不相信“按比率调整”是不好的,请阅读 优化 Oracle 性能 一书,作者是 Cary Millsap 和 Jeff Holt(O’Reilly)。他们甚至在附录中专门讨论了这个主题,并提供了一个工具,可以人为生成任何您希望的缓存命中率,无论您的系统表现如何糟糕!当然,这都是为了说明比率是多么无用。
³ 请注意,MySQL 的各个版本会移除、弃用和更改一些选项;请查看文档以获取详细信息。
⁴ 有关后续阅读,请参阅 Percona 博客文章“给你的 SSD 一些爱—减少 innodb_io_capacity_max”,“Percona Server for MySQL 中的 InnoDB 刷新实践”,以及“针对写入密集型工作负载调整 MySQL/InnoDB 刷新”。
第六章:模式设计与管理
良好的逻辑和物理设计是高性能的基石,您必须为您将运行的特定查询设计模式。这通常涉及权衡。例如,反规范化的模式可以加快某些类型的查询,但会减慢其他查询。添加计数器和汇总表是优化查询的好方法,但维护它们可能很昂贵。MySQL 的特定功能和实现细节在很大程度上影响这一点。
同样,您的模式将随着时间的推移而发展—这是由于您了解如何存储和访问数据以及您的业务需求随时间变化的结果。这意味着您应该计划模式更改作为频繁事件。在本章后面,我们将指导您如何避免这一活动成为组织的运营瓶颈。
本章节以及接下来专注于索引的章节,涵盖了 MySQL 特定的模式设计部分。我们假设您知道如何设计数据库,因此这不是一个介绍性的章节,甚至不是一个关于数据库设计的高级章节。作为一章关于 MySQL 数据库设计的章节,它关于在使用 MySQL 而不是其他关系数据库管理系统(RDBMS)设计数据库时有何不同。如果您需要学习数据库设计的基础知识,我们建议阅读 Clare Churcher 的书籍Beginning Database Design(Apress)。
本章是接下来两章的准备。在这三章中��我们将探讨逻辑设计、物理设计和查询执行的互动。这需要一个全局的方法以及对细节的关注。您需要了解整个系统,以了解每个部分如何影响其他部分。在阅读关于索引的第七章和关于查询优化的第八章之后,您可能会发现重新阅读本章很有用。许多讨论的主题不能孤立考虑。
选择最佳数据类型
MySQL 支持各种各样的数据类型,选择正确的类型来存储您的数据对于获得良好的性能至关重要。以下简单的准则可以帮助您做出更好的选择,无论您存储的是什么类型的数据:
通常情况下,较小的通常更好
一般来说,尽量使用可以正确存储和表示您的数据的最小数据类型。较小的数据类型通常更快,因为它们在磁盘、内存和 CPU 缓存中占用的空间更少。它们通常也需要更少的 CPU 周期来处理。
但是,请确保不要低估您需要存储的值的范围,因为在模式的多个位置增加数据类型范围可能是一个痛苦且耗时的操作。如果您对使用哪种数据类型最好感到犹豫,请选择您认为不会超出的最小数据类型。(如果系统不是非常繁忙或存储的数据不多,或者如果您处于设计过程的早期阶段,您可以稍后轻松更改。)
简单就是好
处理简单数据类型通常需要较少的 CPU 周期。例如,整数比字符更便宜,因为字符集和排序规则使字符比较复杂。以下是两个例子:您应该将日期和时间存储在 MySQL 的内置类型中,而不是作为字符串,您应该使用整数存储 IP 地址。我们稍后会进一步讨论这些主题。
尽量避免NULL
许多表包括可空列,即使应用程序不需要存储NULL(值的缺失),仅仅因为这是默认值。通常最好将列指定为NOT NULL,除非您打算在其中存储NULL。对于引用可空列的查询,MySQL 更难优化,因为它们使索引、索引统计信息和值比较变得更加复杂。可空列使用更多的存储空间,并且需要 MySQL 内部的特殊处理。将NULL列更改为NOT NULL的性能改进通常很小,因此除非知道它们会引起问题,否则不要将其视为现有模式中要查找和更改的优先事项。
决定为给定列使用什么数据类型的第一步是确定适当的一般类型类别:数字、字符串、时间戳。这通常很简单,但我们提到了一些选择不直观的特殊情况。
下一步是选择具体类型。MySQL 的许多数据类型可以存储相同类型的数据,但在它们可以存储的值范围、允许的精度或所需的物理空间(在磁盘和内存中)方面有所不同。一些数据类型还具有特殊的行为或属性。
例如,DATETIME和TIMESTAMP列可以存储相同类型的数据:日期和时间,精确到一秒。然而,TIMESTAMP仅使用一半的存储空间,具有时区感知能力,并具有特殊的自动更新功能。另一方面,它的可允许值范围要小得多,有时其特殊功能可能成为一种障碍。
我们在这里讨论基本数据类型。MySQL 支持许多别名以实现兼容性,例如INTEGER(映射到INT)、BOOL(映射到TINYINT)和NUMERIC(映射到DECIMAL)。这些只是别名。它们可能会让人困惑,但不会影响性能。如果您使用别名数据类型创建表,然后检查SHOW CREATE TABLE,您会看到 MySQL 报告基本类型,而不是您使用的别名。
整数
有两种数字:整数和实数(带有小数部分的数字)。如果您要存储整数,请使用整数类型之一:TINYINT、SMALLINT、MEDIUMINT、INT或BIGINT。它们分别需要 8、16、24、32 和 64 位的存储空间。它们可以存储从−2^((N – 1))到 2^((N – 1)) − 1 的值,其中N是它们使用的存储空间的位数。
整数类型可以选择具有UNSIGNED属性,该属性不允许负值,并且可以将您可以存储的正值的上限大约加倍。例如,TINYINT UNSIGNED可以存储范围从 0 到 255 的值,而不是从−128 到 127。
有符号和无符号类型使用相同的存储空间并具有相同的性能,因此使用适合您数据范围的类型。
您的选择决定了 MySQL 如何存储数据,无论是在内存中还是在磁盘上。然而,整数计算通常使用 64 位的BIGINT整数。(有一些聚合函数的例外,它们使用DECIMAL或DOUBLE执行计算。)
MySQL 允许您为整数类型指定“宽度”,例如INT(11)。对于大多数应用程序来说,这是没有意义的:它不限制合法值的范围,而只是指定 MySQL 交互工具(如命令行客户端)为显示目的保留的字符数。对于存储和计算目的,INT(1)与INT(20)是相同的。
实数
实数是具有小数部分的数字。但它们不仅仅适用于小数;您还可以使用DECIMAL存储太大以至于不适合BIGINT的整数。MySQL 支持精确和不精确类型。
FLOAT 和 DOUBLE 类型支持使用标准浮点数运算进行近似计算。如果您需要准确了解浮点数结果是如何计算的,您需要研究您平台的浮点数实现。
您可以通过几种方式指定浮点列的所需精度,这可能会导致 MySQL 在存储值时选择不同的数据类型或在存储值时对其进行四舍五入。这些精度限定符是非标准的,因此我们建议您指定您想要的类型,但不指定精度。
浮点类型通常使用比DECIMAL更少的空间来存储相同范围的值。FLOAT列使用 4 个字节的存储空间。DOUBLE使用 8 个字节,具有比FLOAT更高的精度和更大的值范围。与整数一样,您只是选择存储类型;MySQL 在浮点类型的内部计算中使用DOUBLE。
由于额外的空间要求和计算成本,仅当您需要对分数进行精确计算时才应使用DECIMAL,例如,当存储财务数据时。但在某些高交易量情况下,实际上更合理的是使用BIGINT,并将数据存储为您需要处理的最小货币分数的某个倍数。假设您需要将财务数据存储到千分之一美分。您可以将所有美元金额乘以一百万,并将结果存储在BIGINT中,避免浮点存储的不精确性和精确DECIMAL数学的成本。
字符串类型
MySQL 支持多种字符串数据类型,每种类型都有许多变体。每个字符串列都可以有自己的字符集和该字符集的排序规则,或者排序规则。
VARCHAR 和 CHAR 类型
两种主要的字符串类型是VARCHAR和CHAR,它们存储字符值。不幸的是,很难准确解释这些值在磁盘和内存中是如何存储的,因为实现取决于存储引擎。我们假设您正在使用 InnoDB;如果不是,请阅读您存储引擎的文档。
让我们看看VARCHAR和CHAR值通常是如何存储在磁盘上的。请注意,存储引擎可能会以不同于内存中存储CHAR或VARCHAR值的方式来存储该值在磁盘上的方式,并且服务器在从存储引擎检索值时可能会将该值转换为另一种存储格式。以下是这两种类型的一般比较:
VARCHAR
VARCHAR 存储可变长度的字符字符串,是最常见的字符串数据类型。它可能需要比固定长度类型更少的存储空间,因为它只使用所需的空间(即,存储较短值时使用的空间较少)。
VARCHAR 使用 1 或 2 个额外字节来记录值的长度:如果列的最大长度为 255 字节或更少,则使用 1 个字节,如果超过 255 字节,则使用 2 个字节。假设使用latin1字符集,VARCHAR(10)将使用最多 11 个字节的存储空间。VARCHAR(1000)最多可以使用 1,002 个字节,因为它需要 2 个字节来存储长度信息。
VARCHAR 有助于性能,因为它节省空间。但是,由于行是可变长度的,当您更新它们时,它们可能会增长,这可能会导致额外的工作。如果一行增长并且不再适合其原始位置,则行为取决于存储引擎。例如,InnoDB 可能需要拆分页面以将行放入其中。其他存储引擎可能根本不会在原地更新数据。
当最大列长度远大于平均长度时,很少更新字段,因此碎片化不是问题时,以及使用复杂字符集(如 UTF-8)时,通常值得使用VARCHAR。
对于 InnoDB 来说情况就比较棘手了,它可以将长VARCHAR值存储为 BLOB。我们稍后会讨论这个问题。
CHAR
CHAR 是固定长度的:MySQL 总是为指定数量的字符分配足够的空间。在存储 CHAR 值时,MySQL 会移除任何尾随空格。根据需要填充值以进行比较。
如果您希望存储非常短的字符串或所有值几乎相同长度,则 CHAR 是一个不错的选择。例如,对于用户密码的 MD5 值,它们始终是相同长度,CHAR 是一个不错的选择。对于经常更改的数据,CHAR 也比 VARCHAR 更好,因为固定长度的行不容易出现碎片化。对于非常短的列,CHAR 也比 VARCHAR 更有效率;一个设计为仅容纳 Y 和 N 值的 CHAR(1) 在单字节字符集中只会使用 1 个字节,但 VARCHAR(1) 会使用 2 个字节,因为长度字节。
这种行为可能有点令人困惑,因此我们将通过一个示例来说明。首先,我们创建一个具有单个 CHAR(10) 列的表,并将一些值存储在其中:
mysql> CREATE TABLE char_test( char_col CHAR(10));
mysql> INSERT INTO char_test(char_col) VALUES
-> ('string1'), (' string2'), ('string3 ');
当我们检索这些值时,尾随空格已被去除:
mysql> SELECT CONCAT("'", char_col, "'") FROM char_test;
+----------------------------+
| CONCAT("'", char_col, "'") |
+----------------------------+
| 'string1' |
| ' string2' |
| 'string3' |
+----------------------------+
如果我们将相同的值存储在 VARCHAR(10) 列中,我们在检索时会得到以下结果,其中 string3 上的尾随空格未被移除:
mysql> SELECT CONCAT("'", varchar_col, "'") FROM varchar_test;
+-------------------------------+
| CONCAT("'", varchar_col, "'") |
+-------------------------------+
| 'string1' |
| ' string2' |
| 'string3 ' |
+-------------------------------+
CHAR 和 VARCHAR 的兄弟类型是 BINARY 和 VARBINARY,它们存储二进制字符串。二进制字符串与传统字符串非常相似,但它们存储的是字节而不是字符。填充也不同:MySQL 使用 \0(零字节)而不是空格填充 BINARY 值,并且在检索时不会去除填充值。
当您需要存储二进制数据并希望 MySQL 将值作为字节而不是字符进行比较时,这些类型非常有用。按字节进行比较的优势不仅仅是大小写不敏感的问题。MySQL 实际上是逐个字节比较 BINARY 字符串,根据每个字节的数值进行比较。因此,二进制比较比字符比较要简单得多,因此更快。
BLOB 和 TEXT 类型
BLOB 和 TEXT 是设计用于分别存储大量数据的字符串数据类型,作为二进制或字符字符串。
实际上,它们各自是数据类型系列:字符类型有 TINYTEXT、SMALLTEXT、TEXT、MEDIUMTEXT 和 LONGTEXT,而二进制类型有 TINYBLOB、SMALLBLOB、BLOB、MEDIUMBLOB 和 LONGBLOB。BLOB 是 SMALLBLOB 的同义词,TEXT 是 SMALLTEXT 的同义词。
与所有其他数据类型不同,MySQL 将每个 BLOB 和 TEXT 值视为具有自己身份的对象。存储引擎通常会对它们进行特殊存储;当它们很大时,InnoDB 可能会为它们使用单独的“外部”存储区域。每个值在行中需要占用 1 到 4 个字节的存储空间,并且在外部存储中需要足够的空间来实际保存该值。
BLOB 和 TEXT 族之间唯一的区别是,BLOB 类型存储没有排序规则或字符集的二进制数据,而 TEXT 类型具有字符集和排序规则。
MySQL 对 BLOB 和 TEXT 列的排序与其他类型不同:它只对这些列的前 max_sort_length 字节进行排序,而不是对整个字符串进行排序。如果需要按照前几个字符进行排序,可以减少 max_sort_length 服务器变量。
MySQL 无法对这些数据类型的完整长度进行索引,也无法使用索引进行排序。
使用 ENUM 而不是字符串类型
有时您可以使用 ENUM 列代替传统的字符串类型。ENUM 列可以存储一组预定义的不同字符串值。MySQL 将它们非常紧凑地存储在 1 或 2 个字节中,具体取决于列表中值的数量。它将每个值内部存储为表示其在字段定义列表中位置的整数。以下是一个示例:
mysql> CREATE TABLE enum_test(
-> e ENUM('fish', 'apple', 'dog') NOT NULL
-> );
mysql> INSERT INTO enum_test(e) VALUES('fish'), ('dog'), ('apple');
这三行实际上存储的是整数,而不是字符串。您可以在数字上下文中检索它们,看到值的双重性质:
mysql> SELECT e + 0 FROM enum_test;
+-------+
| e + 0 |
+-------+
| 1 |
| 3 |
| 2 |
+-------+
如果您为 ENUM 常量指定数字,例如 ENUM('1', '2', '3'),这种二义性可能会让人困惑。我们建议您不要这样做。
另一个令人惊讶的是,ENUM 字段按内部整数值排序,而不是按字符串本身排序:
mysql> SELECT e FROM enum_test ORDER BY e;
+-------+
| e |
+-------+
| fish |
| apple |
| dog |
+-------+
您可以通过按照希望排序的顺序指定 ENUM 成员来解决此问题。您还可以在查询中明确指定排序顺序使用 FIELD(),但这会阻止 MySQL 使用索引进行排序:
mysql> SELECT e FROM enum_test ORDER BY FIELD(e, 'apple', 'dog', 'fish');
+-------+
| e |
+-------+
| apple |
| dog |
| fish |
+-------+
如果我们按字母顺序定义值,我们就不需要这样做了。
因为 MySQL 将每个值存储为整数并必须进行查找以将其转换为其字符串表示形式,所以 ENUM 列具有一些开销。通常,它们的较小大小可以抵消这种开销,但并非总是如此。特别是,将 CHAR 或 VARCHAR 列连接到 ENUM 列可能比连接到另一个 CHAR 或 VARCHAR 列慢。
为了说明,我们对我们的一个应用程序中的表执行了 MySQL 执行此类连接的速度基准测试。该表具有相当宽的主键:
CREATE TABLE webservicecalls (
day date NOT NULL,
account smallint NOT NULL,
service varchar(10) NOT NULL,
method varchar(50) NOT NULL,
calls int NOT NULL,
items int NOT NULL,
time float NOT NULL,
cost decimal(9,5) NOT NULL,
updated datetime,
PRIMARY KEY (day, account, service, method)
) ENGINE=InnoDB;
该表包含约 110,000 行,仅约 10 MB,因此完全适合内存。service 列包含 5 个不同的值,平均长度为 4 个字符,而 method 列包含 71 个值,平均长度为 20 个字符。
我们复制了此表,并将 service 和 method 列转换为 ENUM,如下所示:
CREATE TABLE webservicecalls_enum (
... omitted ...
service ENUM(...values omitted...) NOT NULL,
method ENUM(...values omitted...) NOT NULL,
... omitted ...
) ENGINE=InnoDB;
然后,我们通过主键列测量了连接表的性能。以下是我们使用的查询:
mysql> SELECT SQL_NO_CACHE COUNT(*)
-> FROM webservicecalls
-> JOIN webservicecalls USING(day, account, service, method);
我们修改了此查询以连接不同组合的 VARCHAR 和 ENUM 列。表 6-1 显示了结果。³
表 6-1. 连接 VARCHAR 和 ENUM 列的速度
| 测试 | 每秒查询数 |
|---|---|
VARCHAR 连接到 VARCHAR |
2.6 |
VARCHAR 连接到 ENUM |
1.7 |
ENUM 连接到 VARCHAR |
1.8 |
ENUM 连接到 ENUM |
3.5 |
在将列转换为 ENUM 后,连接速度更快,但将 ENUM 列连接到 VARCHAR 列则较慢。在这种情况下,转换这些列看起来是一个好主意,只要它们不必连接到 VARCHAR 列。在设计中,通常使用“查找表”与整数主键来避免在连接中使用基于字符的值。
然而,将列转换为 ENUM 还有另一个好处:根据 SHOW TABLE STATUS 中的 Data_length 列,将这两列转换为 ENUM 使表的大小减小了约三分之一。在某些情况下,即使 ENUM 列必须连接到 VARCHAR 列,这也可能是有益的。此外,转换后的主键本身大小仅为转换前的一半。由于这是一个 InnoDB 表,如果此表上有其他索引,减小主键大小也会使它们变得更小。
警告
虽然 ENUM 类型在存储值方面非常高效,但更改可以在 ENUM 中的有效值总是需要模式更改。如果您尚未拥有像我们稍后在本章中描述的自动化模式更改的强大系统,这种操作需求可能会成为一个主要不便,如果您的 ENUM 经常更改。我们稍后还会提到模式设计中的“太多 ENUM”反模式。
日期和时间类型
MySQL 有许多类型用于各种日期和时间值,例如 YEAR 和 DATE。MySQL 可以存储的时间的最细粒度是微秒。大多数时间类型没有替代方案,因此没有哪一个是最佳选择的问题。唯一的问题是当您需要同时存储日期和时间时该怎么办。MySQL 为此目的提供了两种非常相似的数据类型:DATETIME 和 TIMESTAMP。对于许多应用程序,任何一个都可以工作,但在某些情况下,一个比另一个更好。让我们来看一下:
DATETIME
这种类型可以存储大范围的值,从公元 1000 年到公元 9999 年,精度为一微秒。它将日期和时间打包成一个整数,格式为 YYYYMMDDHHMMSS,与时区无关。这使用了 8 个字节的存储空间。
默认情况下,MySQL 以可排序、明确的格式显示DATETIME值,例如 2008-01-16 22:37:08。这是表示日期和时间的 ANSI 标准方式。
TIMESTAMP
如其名称所示,TIMESTAMP类型存储了自 1970 年 1 月 1 日格林尼治时间(GMT)午夜以来经过的秒数,与 Unix 时间戳相同。TIMESTAMP只使用 4 个字节的存储空间,因此其范围比DATETIME要小得多:从 1970 年到 2038 年 1 月 19 日。MySQL 提供了FROM_UNIXTIME()和UNIX_TIMESTAMP()函数来将 Unix 时间戳转换为日期,反之亦然。
TIMESTAMP显示的值也取决于时区。MySQL 服务器、操作系统和客户端连接都有时区设置。
因此,存储值0的TIMESTAMP实际上显示为 1969-12-31 19:00:00 在东部标准时间(EST),它与 GMT 相差五个小时。值得强调的是:如果你从多个时区存储或访问数据,TIMESTAMP和DATETIME的行为将会有很大的不同。前者保留相对于使用的时区的值,而后者保留日期和时间的文本表示。
TIMESTAMP还具有DATETIME没有的特殊属性。默认情况下,当你插入一行而没有为第一个TIMESTAMP列指定值时,MySQL 会将第一个TIMESTAMP列设置为当前时间。MySQL 还默认情况下在更新行时更新第一个TIMESTAMP列的值,除非你在UPDATE语句中明确赋值。你可以为任何TIMESTAMP列配置插入和更新行为。最后,TIMESTAMP列默认为NOT NULL,这与其他每种数据类型都不同。
位压缩数据类型
MySQL 有一些存储类型,使用值内的单个位来紧凑存储数据。所有这些类型在技术上都是字符串类型,无论底层存储格式和操作如何:
BIT
你可以使用BIT列在单个列中存储一个或多个真/假值。BIT(1)定义一个包含单个位的字段,BIT(2)存储 2 位,依此类推;BIT列的最大长度为 64 位。InnoDB 将每个列存储为足以包含位的最小整数类型,因此你不会节省任何存储空间。
MySQL 将BIT视为字符串类型,而不是数值类型。当你检索一个BIT(1)值时,结果是一个字符串,但内容是二进制值 0 或 1,而不是 ASCII 值“0”或“1”。但是,如果你在数值上下文中检索值,结果将是位字符串转换为的数字。如果你将值b'00111001'(这是 57 的二进制等价值)存储到一个BIT(8)列中并检索它,你将得到包含字符代码 57 的字符串。这恰好是 ASCII 字符代码“9”。但在数值上下文中,你将得到值57:
mysql> CREATE TABLE bittest(a bit(8));
mysql> INSERT INTO bittest VALUES(b'00111001');
mysql> SELECT a, a + 0 FROM bittest;
+------+-------+
| a | a + 0 |
+------+-------+
| 9 | 57 |
+------+-------+
这可能会非常令人困惑,因此我们建议您谨慎使用BIT。对于大多数应用程序,我们认为最好避免使用这种类型。
如果你想在一个存储空间中存储一个真/假值,另一个选项是创建一个可空的CHAR(0)列。这个列可以存储值的缺失(NULL)或零长度值(空字符串)。这在实践中是可行的,但可能会让其他人在使用数据库中的数据时感到晦涩,使编写查询变得困难。除非你非常专注于节省空间,否则我们仍建议使用TINYINT。
SET
如果您需要存储许多真/假值,请考虑将许多列合并为一个列,使用 MySQL 的原生SET数据类型,MySQL 在内部表示为一组位的紧凑集合。它使用存储效率高,MySQL 有函数如FIND_IN_SET()和FIELD(),使其在查询中易于使用。
整数列上的位运算
一个替代SET的方法是使用整数作为一组位的紧凑集合。例如,您可以在TINYINT中打包 8 位,并使用位运算符进行操作。您可以通过在应用程序代码中为每个位定义命名常量来简化此过程。
与SET相比,这种方法的主要优势在于您可以在不使用ALTER TABLE的情况下更改字段表示的“枚举”。缺点是您的查询更难编写和理解(当第 5 位设置时意味着什么?)。有些人习惯于位操作,有些人则不习惯,因此您是否想尝试这种技术在很大程度上取决于个人口味。
一个紧凑位的示例应用是存储权限的访问控制列表(ACL)。每个位或SET元素代表一个值,例如CAN_READ、CAN_WRITE或CAN_DELETE。如果您使用SET列,您将让 MySQL 在列定义中存储位到值的映射;如果您使用整数列,您将在应用程序代码中存储映射。以下是使用SET列的查询示例:
mysql> CREATE TABLE acl (
-> perms SET('CAN_READ', 'CAN_WRITE', 'CAN_DELETE') NOT NULL
-> );
mysql> INSERT INTO acl(perms) VALUES ('CAN_READ,CAN_DELETE');
mysql> SELECT perms FROM acl WHERE FIND_IN_SET('CAN_READ', perms);
+---------------------+
| perms |
+---------------------+
| CAN_READ,CAN_DELETE |
+---------------------+
如果您使用整数,可以将该示例写成如下形式:
mysql> SET @CAN_READ := 1 << 0,
-> @CAN_WRITE := 1 << 1,
-> @CAN_DELETE := 1 << 2;
mysql> CREATE TABLE acl (
-> perms TINYINT UNSIGNED NOT NULL DEFAULT 0
-> );
mysql> INSERT INTO acl(perms) VALUES(@CAN_READ + @CAN_DELETE);
mysql> SELECT perms FROM acl WHERE perms & @CAN_READ;
+-------+
| perms |
+-------+
| 5 |
+-------+
我们使用变量来定义值,但您可以在代码中使用常量代替。
JSON 数据
越来越普遍地使用 JSON 作为系统之间交换数据的格式。MySQL 有一个原生的 JSON 数据类型,使得直接在表内部操作 JSON 结构的部分变得容易。纯粹主义者可能会建议在数据库中存储原始 JSON 是一种反模式,因为理想情况下,模式是 JSON 中字段的表示。新手可能会看到 JSON 数据类型,并通过避免创建和管理独立字段来看到一条捷径。哪种方法更好在很大程度上是主观的,但我们将客观地通过呈现一个示例用例并比较查询速度和数据大小来进行比较。
我们的示例数据是由 NASA 提供的发现的 202 个近地小行星和彗��的列表。测试是在一个四核、16 GB RAM 虚拟机上的 MySQL 8.0.22 上进行的。数据示例:
[
{
"designation":"419880 (2011 AH37)",
"discovery_date":"2011-01-07T00:00:00.000",
"h_mag":"19.7",
"moid_au":"0.035",
"q_au_1":"0.84",
"q_au_2":"4.26",
"period_yr":"4.06",
"i_deg":"9.65",
"pha":"Y",
"orbit_class":"Apollo"
}
]
这些数据代表了一个指定,发现日期,以及关于实体的收集的数据,包括数字和文本字段。
首先,我们将 JSON 中的数据集转换为每个条目一行。这导致了一个看起来相对简单的模式:
mysql> DESC asteroids_json;
+-----------+------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+-----------+------+------+-----+---------+-------+
| json_data | json | YES | | NULL | |
+-----------+------+------+-----+---------+-------+
其次,我们将此 JSON 数据转换为列,使用适当的数据类型进行转换。这导致了以下模式:
mysql> DESC asteroids_sql;
+----------------+-------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+----------------+-------------+------+-----+---------+-------+
| designation | varchar(30) | YES | | NULL | |
| discovery_date | date | YES | | NULL | |
| h_mag | float | YES | | NULL | |
| moid_au | float | YES | | NULL | |
| q_au_1 | float | YES | | NULL | |
| q_au_2 | float | YES | | NULL | |
| period_yr | float | YES | | NULL | |
| i_deg | float | YES | | NULL | |
| pha | char(3) | YES | | NULL | |
| orbit_class | varchar(30) | YES | | NULL | |
+----------------+-------------+------+-----+---------+-------+
第一个比较是数据大小:
mysql> SHOW TABLE STATUS\G
*************************** 1\. row ***************************
Name: asteroids_json
Engine: InnoDB
Version: 10
Row_format: Dynamic
Rows: 202
Avg_row_length: 405
Data_length: 81920
Max_data_length: 0
Index_length: 0
*************************** 2\. row ***************************
Name: asteroids_sql
Engine: InnoDB
Version: 10
Row_format: Dynamic
Rows: 202
Avg_row_length: 243
Data_length: 49152
Max_data_length: 0
Index_length: 0
我们的 SQL 版本使用了三个 16 KB 页面,而我们的 JSON 版本使用了五个 16 KB 页面。这并不令人感到意外。JSON 数据类型将使用更多空间来存储用于定义 JSON 的额外字符(大括号、方括号、冒号等)以及空格。在这个小例子中,通过将 JSON 转换为特定数据类型,数据存储的大小可以得到改善。
可能存在数据大小不那么重要的有效用例。这两者之间的查询延迟如何?
要在 SQL 中选择单个列的所有内容,我们的语法很简单:
SELECT designation FROM asteroids_sql;
在我们第一次运行此查询时,未被 InnoDB 缓冲池缓存,我们得到了 1.14 毫秒(ms)的结果。第二次执行,将其放入内存中,我们得到了 0.44 毫秒。
对于 JSON,我们能够访问 JSON 结构内的字段:
SELECT json_data->'$.designation' FROM asteroids_json
同样,我们的第一次执行,未缓存,执行时间为 1.13 毫秒。后续执行时间约为 0.80 毫秒。在这个执行速度下,我们预计会有一定的变化——我们在一个虚拟机环境中谈论数百微秒的差异。在我们看来,这两个查询都执行得相当快,尽管值得注意的是 JSON 查询仍然比 SQL 查询长大约两倍。
针对特定行的访问呢?对于单行查找,我们利用使用索引:
ALTER TABLE asteroids_sql ADD INDEX ( designation );
当我们进行单行查找时,我们的 SQL 版本运行时间为 0.33 毫秒,而我们的 JSON 版本运行时间为 0.58 毫秒,给予 SQL 版本优势。这很容易解释:我们的索引允许 InnoDB 返回 1 行而不是 202 行。
将索引查询与全表扫描进行比较是不公平的。为了公平竞争,我们需要使用生成列功能提取指定,并创建针对该虚拟生成列的索引:
ALTER TABLE asteroids_json ADD COLUMN designation VARCHAR(30) GENERATED ALWAYS AS
(json_data->"$.designation"), ADD INDEX ( designation );
这给我们的 JSON 表上的模式看起来像这样:
mysql> DESC asteroids_json;
+-------------+-------------+------+-----+---------+-------------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+-------------+------+-----+---------+-------------------+
| json_data | json | YES | | NULL | |
| designation | varchar(30) | YES | MUL | NULL | VIRTUAL GENERATED |
+-------------+-------------+------+-----+---------+-------------------+
我们的模式现在从json_data列生成一个虚拟列用于指定,并对其进行索引。现在,我们重新运行我们的单行查找,使用索引列而不是 JSON 列路径运算符(->)。由于字段数据在 JSON 中被引用,我们需要在 SQL 中也引用它:
SELECT * FROM asteroids_json WHERE designation='"(2010 GW62)"';
这个查询在 0.4 毫秒内执行,与我们的 SQL 版本 0.33 毫秒相当接近。
从我们之前的简单测试案例中,使用的表空间量似乎是为什么你会使用 SQL 列而不是存储原始 JSON 文档的主要原因。速度仍然比 SQL 列更快。总的来说,选择使用本机 SQL 还是 JSON 取决于在数据库中存储 JSON 的便利性是否超过性能。如果你每天访问这些数据数百万次或数十亿次,速度差异将会累积。
选择标识符
一般来说,标识符是指引用行的方式,通常也是使其唯一的方式。例如,如果你有一个关于用户的表,你可能想为每个用户分配一个数字 ID 或一个唯一的用户名。这个字段可能是你的PRIMARY KEY的一部分或全部。
选择标识符列的良好数据类型非常重要。你更有可能将这些列与其他值进行比较(例如,在连接中),并将它们用于查找而不是其他列。你还可能在其他表中将它们用作外键,因此当你为标识符列选择数据类型时,你可能也在相关表中选择该类型。(正如我们在本章前面演示的那样,最好在相关表中使用相同的数据类型,因为你可能会用它们进行连接。)
在选择标识符列的类型时,你需要考虑的不仅是存储类型,还有 MySQL 如何在该类型上执行计算和比较。例如,MySQL 在内部将ENUM和SET类型存储为整数,但在字符串上下文中进行比较时将它们转换为字符串。
一旦选择了类型,请确保在所有相关表中使用相同的类型。类型应完全匹配,包括UNSIGNED等属性。⁵混合不同的数据类型可能会导致性能问题��即使没有问题,隐式类型转换在比较过程中也可能导致难以发现的错误。这些错误甚至可能在很久之后才出现,当你忘记你正在比较不同的数据类型时。
选择可以容纳所需值范围的最小大小,并在必要时留出未来增长的空间。例如,如果你有一个存储美国州名的state_id列,你不需要成千上万或数百万的值,所以不要使用INT。一个TINYINT应该足够,而且比较小 3 个字节。如果你在其他表中将这个值用作外键,3 个字节可能会产生很大的差异。这里我们给出一些建议。
整数类型
整数通常是标识符的最佳选择,因为它们快速且可以与AUTO_INCREMENT一起使用。AUTO_INCREMENT是一个为每一行生成新整数类型的列属性。例如,一个计费系统可能需要为每个客户生成一个新的发票。使用AUTO_INCREMENT意味着生成的第一张发票是 1,第二张是 2,依此类推。请注意,你应该确保为你预期的数据增长选择正确的整数大小。有不止一个关于由于意外耗尽整数而导致系统停机的故事。
ENUM 和 SET
ENUM和SET类型通常不适合作为标识符的选择,尽管它们可以用于包含状态或“类型”值的静态“定义表”。ENUM和SET列适合保存像订单状态或产品类型这样的信息。
举个例子,如果你使用ENUM字段来定义产品的类型,你可能希望有一个主键为相同ENUM字段的查找表。在这种情况下,你会想要将ENUM用作标识符,但对于大多数情况,你应该避免这样做。
字符串类型
如果可能的话,避免使用字符串类型作为标识符,因为它们占用大量空间,通常比整数类型慢。
你还应该非常小心完全“随机”的字符串,比如由MD5()、SHA1()或UUID()生成的字符串。你用它们生成的每个新值都会以任意方式分布在一个大空间中,这可能会减慢INSERT和某些类型的SELECT查询:⁶
-
它们会减慢
INSERT查询,因为插入的值必须放在索引中的随机位置。这会导致页面分裂、随机磁盘访问以及聚集索引碎片化对于聚集存储引擎。 -
它们会减慢
SELECT查询,因为逻辑上相邻的行在磁盘和内存中会被广泛分散。 -
随机值会导致缓存在所有类型的查询中表现不佳,因为它们破坏了引用局部性,这是缓存工作的方式。如果整个数据集都是“热点”,那么在内存中缓存任何特定部分的数据都没有优势,如果工作集不适合内存,缓存将有很多刷新和未命中。
如果你存储通用唯一标识符(UUID)值,你应该去掉破折号,或者更好的是,使用UNHEX()将 UUID 值转换为 16 字节数字,并将其存储在BINARY(16)列中。你可以使用HEX()函数以十六进制格式检索值。
特殊数据类型
有些数据类型与可用的内置类型不直接对应。一个很好的例子是 IPv4 地址。人们经常使用VARCHAR(15)列来存储 IP 地址。然而,它们实际上是无符号 32 位整数,而不是字符串。点分四进制表示法只是一种让人类更容易阅读的写法。你应该将 IP 地址存储为无符号整数。MySQL 提供了INET_ATON()和INET_NTOA()函数来在这两种表示法之间转换。使用无符号 32 位整数,空间使用量从VARCHAR(15)的约 16 字节缩减到 4 字节。如果你担心数据库中的可读性,并且不想继续使用函数查看行数据,记住 MySQL 有视图,你可以使用它们更轻松地查看数据。
MySQL 中的模式设计陷阱
尽管有普遍的好坏设计原则,但也存在由于 MySQL 的实现方式而引起的问题,这意味着你也可能犯 MySQL 特定的错误。本节讨论了我们在 MySQL 模式设计中观察到的问题。这可能有助于你避免这些错误,并选择与 MySQL 特定实现更好配合的替代方案。
太多列
MySQL 的存储引擎 API 通过在行缓冲格式中在服务器和存储引擎之间复制行;然后服务器将缓冲区解码为列。将行缓冲区转换为具有解码列的行数据结构可能是昂贵的。InnoDB 的行格式总是需要转换的。这种转换的成本取决于列的数量。当我们调查一个具有极宽表(数百列)的客户的高 CPU 消耗问题时,我们发现这可能会变得昂贵,尽管实际上只使用了少数列。如果您计划使用数百列,请注意服务器的性能特性将有所不同。
太多的连接
所谓的实体-属性-值(EAV)设计模式是一个经典的普遍糟糕的设计模式,特别在 MySQL 中效果不佳。MySQL 对于每个连接有 61 个表的限制,而 EAV 数据库需要许多自连接。我们看到许多 EAV 数据库最终超过了这个限制。然而,即使比 61 少得多的连接,规划和优化查询的成本对于 MySQL 来说可能会成为问题。作为一个粗略的经验法则,如果您需要查询以非常快的速度和高并发性执行,最好每个查询有十几个或更少的表。
万能的 ENUM
谨防过度使用ENUM。这是我们看到的一个例子:
CREATE TABLE ... (
country enum(','0','1','2',...,'31')
模式中大量使用了这种模式。在任何具有枚举值类型的数据库中,这可能是一个值得怀疑的设计决定,因为它实际上应该是一个整数,可以作为“字典”或“查找”表的外键。
伪装的 ENUM
ENUM允许列保存来自一组定义值中的一个值。SET允许列保存来自一组定义值中的一个或多个值。有时这些可能很容易混淆。这里有一个例子:
CREATE TABLE ...(
is_default set('Y','N') NOT NULL default 'N'
那几乎肯定应该是一个ENUM而不是一个SET,假设它不能同时为真和假。
NULL 并非在这里发明
我们之前提到避免使用NULL的好处,确实,我们建议在可能的情况下考虑替代方案。即使您需要在表中存储“无值”事实时,也许您并不需要使用NULL。也许您可以使用零、一个特殊值或空字符串代替。
然而,您也可以走向极端。当您需要表示未知值时,不要太害怕使用NULL。在某些情况下,使用NULL比使用神奇的常量更好。从受限类型的域中选择一个值,比如使用−1 表示未知整数,可能会使您的代码变得复杂,引入错误,并且总体上会搞得一团糟。处理NULL并不总是容易的,但通常比其他替代方案更好。
这是我们经常看到的一个例子:
CREATE TABLE ... (
dt DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00'
那个虚假的全零值可能会引起很多问题。(您可以配置 MySQL 的SQL_MODE来禁止无意义的日期,这对于尚未创建满是错误数据的新应用程序来说是一个特别好的做法。)
在相关主题上,MySQL 确实对NULL进行索引,而 Oracle 不会在索引中包含非值。
现在我们已经讨论了很多关于数据类型、如何选择它们以及不应该做什么的实用建议,让我们继续讨论另一个好的、迭代的模式设计:模式管理。
模式管理
运行模式更改是数据库工程师必须承担的最常见任务之一。当你开始运行数十甚至数百个具有不同业务背景和不断发展功能的数据库实例时,你希望小心处理这些模式更改不会成为整个组织的瓶颈,但仍然安全地进行,并且不会干扰运营。本节将介绍如何将模式更改管理视为“数据存储平台”的一部分,应该指导这一策略的核心价值观,你可以引入哪些工具来实现这一策略,以及如何将其与更大的软件交付生命周期结合起来。
模式管理作为数据存储平台的一部分
如果你与任何快速增长组织的工程领导人交谈,你会发现工程师速度和从特性设计到在生产环境中运行的时间是他们优化事项的首要任务。在这种情况下,你在规划规模化管理模式时的任务是不允许模式管理成为一个手动过程,这会成为整个工程组织进展的瓶颈,只有一个或几个人。
为成功设置合作团队
随着组织中依赖 MySQL 实例的团队数量增加,你希望始终成为这些团队成功的推动者,而不是他们需要通过才能完成工作的门槛。这也适用于模式更改,这意味着你希望创建一条路径来部署模式更改,而不是“只有数据库团队才能做到”。
集成模式管理与持续集成
在我们讨论了一些能够实现规模化模式管理的工具之后,我们将谈论如何将它们与 CI 管道集成。但现在我们想强调的是,如果你从这样一个前提开始,即模式更改将由特性团队而不仅仅是数据库团队来管理,那么你需要尽可能接近这些团队已经部署代码更改的工作流程。科学已经证明,将模式管理视为代码部署的团队经历了更积极的特性交付过程,并看到团队速度的提高。我们将讨论能够支持这种迭代的工具,考虑到软件交付实践。
模式更改的源代码控制
我们都在部署代码时使用源代码控制,对吧?那么为什么不也用于数据库模式应该是什么样子呢?在规模化模式管理的第一步是确保你有支持和跟踪所做更改的源代码控制。这不仅仅是一件好事™,而且在许多情况下,你的合规团队要求这样做,正如你将在第十三章中看到的。让我们来看看一些能够迭代数据库模式的工具。
注意
对于你的组织最大的价值,使用工程团队用于代码部署的相同 CI 工具。
付费选项
过去几年,作为企业工具的数据库模式管理领域发展迅速,特别是对于 MySQL 安装的支持增加。如果你正在寻找一个现成的解决方案来帮助你的组织管理模式更改,这里有一些你应该考虑的事项:
成本
成本模型各不相同,因此如果你选择的解决方案会按目标(要管理的模式)收费,那么你应该小心,因为这可能会很快累积起来。
在线模式管理
截至目前,付费解决方案(如Flyway)尚无明确的路径以非阻塞方式为您运行模式更改,尽管其竞争对手Liquibase为 Percona 的在线模式更改提供了一个得到良好支持的插件。您需要了解每个供应商为您做出的权衡,并了解这些权衡对您的可用性意味着什么,特别是如果您计划使用这些供应商来管理大型数据库(磁盘上有多个 TB)的模式更改。
开箱即用的集成
大多数这些工具都对您公司内部软件使用的语言以及为与现有软件交付流程集成提供的挂钩做出了假设。如果您的公司是高度多语言的,或者正在更改主要软件语言,这可能会排除一些供应商。我们将在下一节中介绍在实施模式源控制管理时需要“自己动手”时该怎么做。
使用开源
如果购买付费工具超出了您的能力范围,或者如果您有正当理由认为当前的解决方案都不适合您的组织,您可以使用现有的开源工具和您组织的 CI 管道来实现相同的结果。
一个显著的开源解决方案,用于在多个环境中通过版本控制管理模式更改的是Skeema。Skeema 本身不会在生产环境中为您运行模式更改——我们将很快介绍如何做到这一点——但它是一个很好的工具,用于跟踪每个数据库集群中的源代码控制存储库中的更改以及跨多个环境。其 CLI 实现在与您选择的 CI 解决方案集成时提供了很大的灵活性。如何直接将 Skeema 与您的 CI 解决方案集成将需要考虑 CI 解决方案具有的功能。Twilio Sendgrid 团队的这篇博客文章解释了他们如何将 Skeema 与 Buildkite 集成,以实现寻求管理其数据库更改的功能团队的自主权。
请注意,无论这个解决方案如何与您的 CI 集成,它还需要访问所有环境(包括生产环境)来运行模式更改。这也意味着与您的安全团队合作,以确保您正在创建正确的访问控制,以实现使用持续集成自动化模式部署模式的好处。
注意
如果您已经在使用 Vitess 扩展您的数据库基础设施的道路上,您应该知道 Vitess 也会为您管理模式更改。确保检查文档的特定部分。
在过去几年中,具有自动化和合规性思维的跨环境管理模式更改领域已经显著增长。以下是您在做出选择时的一些最终收获:
-
尽可能接近现有的软件部署工具和工作流程。您希望这对您更大的工程组织来说是熟悉的。
-
使用一个可以集成基本模式更改的基本检查的工具,以确保满足一些基本要求。如果新表不使用正确的字符集或者如果您决定不允许外键,则您的解决方案应自动失败拉取请求。
-
如果您所在的组织是多语言并且正在快速增长,请确保您不会意外引入人为瓶颈,例如一个存储库用于所有数据库和所有模式更改。请记住这里的目标是工程团队速度。
在生产环境中运行模式更改
现在我们已经介绍了跟踪和管理为您的组织部署模式更改的选项,让我们讨论如何在生产环境中运行这些更改,而不会影响您的数据库的正常运行时间或依赖于它们的服务。
本地 DDL 语句
MySQL 在 5.6 版本中引入了非阻塞模式的模式更改,但在该主要版本中,该功能带有一些注意事项,实际上只能用于非常特定的模式更改类型。
到 8.0 版本成为 GA 时,MySQL 中对本机 DDL 的支持大大扩展,尽管仍不是普遍的。更改主键、更改字符集、打开逐表加密以及添加或删除外键都是您仍然无法使用INPLACE alter 本地进行的模式更改的示例。⁷我们强烈建议您通过文档熟悉使用INPLACE或INSTANT算法所允许的更改,这是在 MySQL 中进行模式更改而无需停机的首选本地方式。
然而,即使您需要的更改在 8.0 及更高版本中得到了技术上的支持,如果正在更改的表非常庞大,如果 InnoDB 内部保留的表更改日志文件太大,您可能会遇到回滚,从而撤销数小时或数天的工作。您可能需要使用外部工具的另一个原因是,如果您强烈希望使用节流机制控制表更改发生的速度。这是您可以通过即将讨论的外部工具来管理的事项。
使用外部工具运行模式更改
如果您尚无法运行最新且最强大的 MySQL 版本,并且具有所有模式更改的灵活性,您仍然可以将 CI 工具与可用的开源工具结合使用,在不影响服务的情况下自动在生产环境中运行模式更改。实现此目标的两个主要选项是 Percona 的pt-online-schema-change和 GitHub 的gh-ost。两者的文档都包含了您学习如何安装和使用工具所需的所有信息,因此我们将重点讨论如何选择使用哪种工具,应该考虑的主要权衡以及如何提高在生产中作为自动化模式部署流程的一部分使用任一工具的安全性。
警告
需要注意的一点是:任何运行模式更改的外部工具都需要制作您正在更改的表的完整副本。该工具仅使该过程影响较小,不需要破坏性写锁定,但只有 MySQL 中的本机 DDL 才能在不进行完整表复制的情况下更改表模式。
pt-online-schema-change的主要吸引力在于其稳定性以及在 MySQL 社区中的长期使用。它主要利用触发器来实现对各种大小的表进行模式更改,对数据库可用性的影响非常小,当切换到新表版本时。但其核心设计也伴随着权衡。在学习如何使用pt-online-schema-change来支持您的模式部署流程时,请记住以下几点:
触发器有限制
在 MySQL 8.0 之前,您不能在同一表上有多个具有相同操作的触发器。这意味着什么?如果您有一个名为sales的表,并且您已经需要在其上维护一个插入时间触发器,MySQL 在 8.0 之前不允许在该表上添加另一个插入触发器。如果尝试对其运行pt-online-schema-change模式更改,则当尝试添加所需的触发器时,该工具将产生错误。尽管我们通常强烈反对将表触发器作为业务逻辑的一部分,但仍会有情况下,遗留选择会创建约束,并且这将成为您选择模式更改机制时的权衡计算的一部分。
触发器会影响性能
Percona 进行了一些优秀的基准测试,显示即使在表上定义触发器也会产生性能影响。对于大多数安装来说,这种性能惩罚可能是看不见的,但如果您恰好在非常高的每秒事务吞吐率下运行数据库实例,您可能需要更仔细地观察 pt-online-schema-change 引入的触发器的影响,并调整以更保守地中止。
运行并发迁移
由于其使用触发器以及在 MySQL 8.0 之前触发器的限制,您会发现无法使用 pt-online-schema-change 在同一张表上运行多个模式更改。这可能最初是一个小小的不便,但如果将该工具整合到一个完全自动化的模式迁移流水线中,它可能会成为团队的瓶颈。
外键约束
尽管该工具在存在外键的情况下对模式更改有一定程度的支持,但您需要仔细阅读文档,并确定哪种权衡对您的数据和事务吞吐量影响最小。
gh-ost 是 GitHub 的数据工程团队专门为管理模式更改过程而创建的解决方案,旨在在不使用触发器的情况下管理模式更改过程,同时也不会对服务产生影响。在表复制阶段不使用触发器跟踪更改,而是作为副本连接到您的集群副本之一,并将基于行的复制日志作为更改日志消耗。
使用 gh-ost 进行模式更改时需要仔细考虑的一件事是您的现有数据库是否使用外键。虽然 pt-online-schema-change 试图支持父表或子表在外键关系中的模式更改,但这是一个复杂的选择,充满了权衡。 (我们牺牲一致性来保证运行时间吗?还是冒一些可能不一致的风险?)另一方面,如果您要修改的表中存在外键,gh-ost 大多数情况下会为您做出选择并完全退出。作为 gh-ost 的主要贡献者,Shlomi Noach 在一篇长篇但非常有用的博文中解释了使用外键和在线模式更改工具的环境,这些工具最终仍然是外部于数据库引擎,这种权衡很难理解,他建议如果您还需要在线模式更改,则根本不要使用外键。
如果您和您的团队对此任务还很陌生,并且正在为组织的模式更改 CI 铺平道路,我们认为 gh-ost 是更好的解决方案,只要您也有纪律不引入外键。考虑到它使用二进制日志而不是触发器来跟踪更改,我们认为它是更安全的选择,您不必担心触发器的性能损失,它更加不受您运行的 MySQL 版本的影响(即使在某些情况下它甚至可以与基于语句的复制一起工作),并且它已经在大规模部署中得到验证。
什么时候 pt-online-schema-change 是首选选项?如果您运行一些旧数据库,其中已经存在外键并且删除它们是一个困难的提议,您会发现 pt-online-schema-change 尝试更广泛地支持外键,但您必须承担选择对数据完整性和运行时间最安全的选项的认知负担。此外,gh-ost 利用二进制日志来执行其工作,因此如果由于某种原因该工具无法访问这些日志,则 pt-online-schema-change 仍然是一个可行的选择。
理想情况下,总有一天我们都可以在 MySQL 中本地执行在线模式更改,但那一天还没有到来。在那之前,开源生态系统在使模式更改变得更容易自动化方面已经取得了长足的进步。让我们讨论如何将所有这些工具组合在一起,为模式更改的完全成熟的 CI/CD 流水线。
用于模式更改的 CI/CD 流水线
现在我们已经涵盖了许多工具,从帮助管理模式定义版本控制的工具到在生产中进行更改并最小化停机时间的工具,您可以看到我们拥有完整的模式更改持续集成和部署的部件,这可以消除您组织中工程师生产力的一个巨大瓶颈。让我们把它整合起来:
组织您的模式源控制
首先,您必须通过将每个数据库集群的模式定义分开存储在一个存储库中来开始。如果这里的目标是为不同团队以不同速度运行其更改提供灵活性,那么将所有数据库的所有模式定义合并到一个存储库中是没有意义的。这种分离还允许每个团队在存储库中定义不同的 linting 检查。一些团队可能需要非常特定的字符集和排序规则,而其他团队可能对默认设置感到满意。这里合作伙伴团队的灵活性至关重要。
确保记录工程团队成员如何从他们的笔记本电脑上的模式更改到在所有环境上运行并在进入生产之前运行测试的工作流程。在这里,拉取请求模型可以非常有用,帮助每个团队定义请求模式更改时要运行的测试,以自动化方式在推广并在更多环境或生产中运行更改之前。
安全性的基线配置
为您选择的在线模式更改工具定义基线配置。您是为依赖您提供灵活、可扩展但也安全解决方案的合作伙伴团队提供工具的团队。在考虑如何实现在线模式更改工具时,可能是时候对需要成为测试模式更改拉取请求的模式设计考虑发表意见了。例如,如果您决定更喜欢gh-ost的安全性和无触发器设计,这意味着您必须成为一个不包含外键的数据库平台。不去讨论这种选择的权衡,如果最终决定“消灭外键”,那么您应该确保这一点被编码到您如何在预提交挂钩或您的 Skeema 存储库中测试模式更改的方式中,以便避免在环境层次结构中引入不良模式更改。同样,您应该为您的在线模式更改工具决定一个基本配置,为生产中的更改提供基本安全网。您可能希望在这种配置中引入的示例包括最大的 MySQL 线程运行或最大允许的系统负载。存储库模板可以是一个强大的工具,使正确的事情成为任何功能团队创建新数据库并希望跟踪和管理模式更改的存储库时的简单事情。
每个团队的管道灵活性
当您在存储库中为每个数据库组织模式定义时,您允许每个拥有该数据库的团队决定其管道应该是自动化还是人为管理的最大灵活性。一个团队可能仍处于新产品迭代阶段,只要定义的测试通过,他们就可以自动推广模式拉取请求。另一个团队可能拥有更加关键的数据库,并需要更加谨慎的方法,更倾向于在 CI 系统可以将其推广到下一个环境之前,要求操作员批准拉取请求。
当您设计组织如何实现可扩展的模式更改部署时,保持目标在眼前:速度与安全性相结合,使您不断增长的工程组织在从想法到生产功能的公司移动中,数据库工程团队不成为瓶颈。
总结
良好的模式设计是相当普遍的,但当然 MySQL 有特殊的实现细节需要考虑。简而言之,尽可能保持事物简单和小型是个好主意。MySQL 喜欢简单,与您的数据库一起工作的人也会喜欢。请记住以下准则:
-
设计时避免极端情况,比如会导致查询非常复杂或表格有大量列的模式。(一个“oodles”介于 scad 和 gazillion 之间。)
-
使用小型、简单、适当的数据类型,并避免使用
NULL,除非它实际上是模拟数据现实的正确方式。 -
尽量使用相同的数据类型来存储相似或相关的值,特别是如果它们将用于连接条件。
-
注意变长字符串,可能会导致对临时表和排序进行悲观的全长内存分配。
-
如果可以的话,尽量使用整数作为标识符。
-
避免使用传统的 MySQL 特性,比如为浮点数指定精度或为整数指定显示宽度。
-
对
ENUM和SET要小心。它们很方便,但有时可能会被滥用,而且有时会很棘手。最好避免使用BIT。
数据库设计是一门科学。如果您非常关注数据库设计,请考虑使用专门的源材料。⁸
还要记住,您的模式将随着业务需求和从用户那里学到的东西而发展,这意味着具有管理模式更改的健壮软件生命周期是使这种演变对您的组织安全和可扩展的关键部分。
¹ 记住长度是以字符为单位指定的,而不是字节。多字节字符集可能需要超过 1 个字节来存储每个字符。
² 如果值在检索后必须保持不变,请小心处理BINARY类型。MySQL 会用\0填充到所需长度。
³ 时间是用于相对比较的,因为 CPU、内存和其他硬件的速度随时间变化。
⁴ TIMESTAMP行为的规则复杂,并且在各个 MySQL 版本中有所更改,因此您应该验证您得到的行为是否符合您的要求。通常在对TIMESTAMP列进行更改后,检查SHOW CREATE TABLE的输出是个好主意。
⁵ 如果使用 InnoDB 存储引擎,除非数据类型完全匹配,否则可能无法创建外键。由此产生的错误消息“ERROR 1005 (HY000): Can’t create table”可能会令人困惑,具体情况取决于上下文,关于此问题的问题经常出现在 MySQL 邮件列表上。(奇怪的是,你可以在长度不同的VARCHAR列之间创建外键。)
⁶ 另一方面,对于一些有很多写入者的非常大的表,这种伪随机值实际上可以帮助消除“热点”。
⁷ 查看MySQL 文档以获取更多信息。
⁸ 想要深入阅读的话,可以考虑阅读迈克尔·J·赫尔南德斯(Pearson)的Database Design for Mere Mortals。
第七章:高性能索引
索引(在 MySQL 中也称为键)是存储引擎用来快速查找行的数据结构。它们还具有几个其他有益的属性,我们将在本章中探讨。
索引对于良好的性能至关重要,并随着数据量的增长变得更加重要。小型、负载轻的数据库通常即使没有适当的索引也能表现良好,但随着数据集的增长,性能可能会迅速下降。¹不幸的是,索引经常被遗忘或误解,因此糟糕的索引是真实世界性能问题的主要原因之一。这就是为什么我们将这些材料放在书的前面,甚至比我们讨论查询优化更早。
索引优化可能是提高查询性能的最有效方法。索引可以将性能提高数个数量级,而最佳索引有时可以比仅仅“好”的索引提高大约两个数量级的性能。创建真正最佳的索引通常需要你重写查询,因此本章和下一章密切相关。
本章依赖于使用示例数据库,比如来自 MySQL 网站的Sakila 示例数据库。Sakila 是一个模拟租赁商店的示例数据库,包含演员、电影、客户等。
索引基础知识
理解 MySQL 中索引工作原理最简单的方法是将其想象成一本书的索引。要查找书中讨论特定主题的位置,你查看索引,它告诉你该术语出现的页码。
在 MySQL 中,存储引擎以类似的方式使用索引。它在索引的数据结构中搜索一个值。当找到匹配时,它可以找到包含匹配的行。假设你运行以下查询:
SELECT first_name FROM sakila.actor WHERE actor_id = 5;
actor_id列上有一个索引,因此 MySQL 将使用该索引查找actor_id为5的行。换句话说,它在索引中查找值并返回包含指定值的任何行。
索引包含表中一个或多个列的值。如果索引多于一列,列的顺序非常重要,因为 MySQL 只能在索引的最左前缀上高效搜索。在两列上创建索引与创建两个单独的单列索引不同,你将看到。
索引类型
有许多类型的索引,每种类型都设计用于不同的目的。索引是在存储引擎层实现的,而不是在服务器层。因此,它们没有标准化:在每个引擎中,索引的工作方式略有不同,并非所有引擎都支持所有类型的索引。即使多个引擎支持相同的索引类型,它们在内部可能以不同的方式实现。鉴于本书假定你在所有表中使用 InnoDB 作为引擎,我们将专门讨论 InnoDB 中的索引实现。
话虽如此,让我们看看 MySQL 目前支持的两种最常用的索引类型,它们的优点和缺点。
B-tree 索引
当人们谈论索引而没有提及类型时,他们可能指的是B-tree 索引,它通常使用 B-tree 数据结构来存储其数据。² MySQL 的大多数存储引擎支持这种索引类型。
我们将这些索引称为B-tree,因为这是 MySQL 在CREATE TABLE和其他语句中使用的术语。然而,存储引擎可能在内部使用不同的存储结构。例如,NDB Cluster 存储引擎使用 T-tree 数据结构来存储这些索引,即使它们被标记为BTREE,而 InnoDB 使用 B+树。这些结构和算法的变化超出了本书的范围。
B 树的一般思想是所有值按顺序存储,每个叶子页距离根节点的距离相同。图 7-1 显示了 B 树索引的抽象表示,大致对应于 InnoDB 的索引工作原理。

图 7-1。建立在 B 树(技术上是 B+树)结构上的索引
B 树索引加快了数据访问,因为存储引擎不必扫描整个表以找到所需的数据。相反,它从根节点开始(在此图中未显示)。根节点中的插槽保存指向子节点的指针,存储引擎遵循这些指针。它通过查看节点页中的值来找到正确的指针,这些值定义了子节点中值的上限和下限。最终,存储引擎要么确定所需值不存在,要么成功到达叶子页。
叶子页很特殊,因为它们指向索引数据而不是指向其他页面的指针。(不同的存储引擎有不同类型的“指针”指向数据。)我们的示例只显示了一个节点页及其叶子页,但根和叶子之间可能有许多级别的节点页。树的深度取决于表的大小。
因为 B 树按顺序存储索引列,所以它们对于搜索数据范围很有用。例如,对于文本字段的索引,沿着树向下移动会按字母顺序经过值,因此查找“名字以 I 至 K 开头的所有人”是高效的。
假设你有以下表格:
CREATE TABLE People (
last_name varchar(50) not null,
first_name varchar(50) not null,
dob date not null,
key(last_name, first_name, dob)
);
索引将包含表中每行的last_name、first_name和dob列的值。图 7-2 说明了索引如何排列存储的数据。
请注意,索引根据在CREATE TABLE语句中给出的列的顺序对值进行排序。看看最后两个条目:有两个名字相同但出生日期不同的人,它们按出生日期排序。

图 7-2。B 树(技术上是 B+树)索引的示例条目
自适应哈希索引
InnoDB 存储引擎具有一种称为自适应哈希索引的特殊功能。当 InnoDB 注意到某些索引值被频繁访问时,它会在 B 树索引之上在内存中为它们构建一个哈希索引。这使其 B 树索引具有哈希索引的某些属性,例如非常快速的哈希查找。这个过程是完全自动的,您无法控制或配置它,尽管您可以完全禁用自适应哈希索引。
可以使用 B 树索引的查询类型
B 树索引适用于通过完整键值、键范围或键前缀进行查找。只有在查找使用索引的最左前缀时才有用。³我们在前一节中展示的索引将对以下类型的查询有用:
匹配完整值
完整键值的匹配指定了索引中所有列的值。例如,此索引可以帮助您找到一个名为 Cuba Allen 且出生于 1960-01-01 的人。
匹配最左前缀
此索引可以帮助您找到所有姓 Allen 的人。这仅使用索引中的第一列。
匹配列前缀
您可以匹配列值的第一部分。此索引可以帮助您找到所有��J 开头的人。这仅使用索引中的第一列。
匹配一系列值
此索引可以帮助您找到姓 Allen 和 Barrymore 之间的人。这也仅使用第一列。
精确匹配一部分并在另一部分上匹配范围
此索引可以帮助您找到姓 Allen 且名字以 K 开头(Kim,Karl 等)的所有人。这是对last_name的精确匹配和对first_name的范围查询。
仅索引查询
B 树索引通常可以支持仅索引查询,这些查询仅访问索引,而不访问行存储。我们在“覆盖索引”中讨论了这种优化。
因为树的节点是排序的,它们可以用于查找值和ORDER BY查询(按排序顺序查找值)。一般来说,如果 B 树可以帮助你以特定方式查找行,它也可以帮助你按照相同的标准对行进行排序。因此,我们的索引将有助于满足我们刚刚列出的所有类型查找的ORDER BY子句。
这里是 B 树索引的一些限制:
-
如果查找不是从索引列的最左侧开始的话,它们就没有用处。例如,这个索引无法帮助你找到所有名为 Bill 的人或所有出生于特定日期的人,因为这些列不是索引中的最左侧列。同样,你无法使用索引找到姓氏以特定字母结尾的人。
-
你不能跳过索引中的列,也就是��,你无法找到所有姓氏为 Smith 且出生于特定日期的人。如果不为
first_name列指定值,MySQL 只能使用索引的第一列。 -
存储引擎无法优化访问第一个范围条件右侧的任何列。例如,如果你的查询是
WHERE last_name="Smith" AND first_name LIKE 'J%' AND dob='1976-12-23',索引访问将仅使用索引中的前两列,因为LIKE是一个范围条件(服务器可以将其余列用于其他目的)。对于具有有限值的列,通常可以通过指定相等条件而不是范围条件来解决这个问题。
现在你知道为什么我们说列顺序非常重要:这些限制都与列顺序有关。为了获得最佳性能,您可能需要以不同顺序创建相同列的索引以满足您的查询。
一些限制并非是 B 树索引固有的,而是 MySQL 查询优化器和存储引擎使用索引的结果。其中一些限制可能会在未来被移除。
全文索引
FULLTEXT是一种特殊类型的索引,它在文本中查找关键词,而不是直接将值与索引中的值进行比较。全文搜索与其他类型的匹配完全不同。它有许多微妙之处,如停用词、词干、复数形式和布尔搜索。它更类似于搜索引擎的工作方式,而不是简单的WHERE参数匹配。
在同一列上拥有全文索引并不会消除对该列的 B 树索引的价值。全文索引用于MATCH AGAINST操作,而不是普通的WHERE子句操作。
索引的好处
索引使服务器能够快速导航到表中所需的位置,但这并不是它们的全部用途。正如你现在可能已经了解的那样,索引还有几个额外的好处,这些好处基于用于创建它们的数据结构的属性。
B 树索引是你将使用的最常见类型,通过按排序顺序存储数据,MySQL 可以利用它来处理带有ORDER BY和GROUP BY子句的查询。由于数据是预先排序的,B 树索引还将相关值存储在一起。最后,索引实际上存储了值的副本,因此某些查询可以仅从索引中满足。这些属性带来了三个主要好处:
-
索引减少了服务器需要检查的数据量。
-
索引帮助服务器避免排序和临时表。
-
索引将随机 I/O 转换为顺序 I/O。
这个主题真的值得一本整书。对于那些想深入了解的人,我们推荐 Tapio Lahdenmaki 和 Mike Leach 的关系数据库索引设计和优化器(Wiley)。它解释了如何计算索引的成本和收益,如何估计查询速度,以及如何确定索引是否比提供的好处更昂贵。
Lahdenmaki 和 Leach 的书还介绍了一个三星系统,用于评估索引对查询的适用性。如果索引将相关行相邻放置,则获得一颗星,如果其行按查询所需的顺序排序,则获得第二颗星,如果包含查询所需的所有列,则获得最后一颗星。我们将在本章中回顾这些原则。
高性能的索引策略
创建正确的索引并正确使用它们对于良好的查询性能至关重要。我们介绍了不同类型的索引并探讨了它们的优势和劣势。现在让我们看看如何真正发挥索引的力量。
有许多有效选择和使用索引的方法,因为有许多特殊情况的优化和专门的行为。确定何时使用以及评估选择的性能影响是您随着时间学会的技能。接下来的章节将帮助您了解如何有效使用索引。
前缀索引和索引选择性
您通常可以通过索引前几个字符而不是整个值来节省空间并获得良好的性能。这使得您的索引使用的空间更少,但也使它们的选择性更低。索引选择性是索引值的不同值数(基数)与表中总行数(#T)的比率,范围从 1/#T到 1。高度选择性的索引很好,因为它让 MySQL 在查找匹配项时过滤更多行。唯一索引的选择性为 1,这是最好的选择。
列的前缀通常具有足够的选择性以提供良好的性能。如果您正在为BLOB或TEXT列,或者非常长的VARCHAR列建立索引,或者必须定义前缀索引,因为 MySQL 不允许索引其完整长度。
诀窍在于选择一个足够长以提供良好选择性但又足够短以节省空间的前缀。前缀应该足够长,使索引几乎与如果您对整个列进行索引时一样有用。换句话说,您希望前缀的基数接近完整列的基数。
要确定一个好的前缀长度,找到最频繁出现的值并与最频繁前缀列表进行比较。在 Sakila 示例数据库中没有一个好的表来展示这一点,所以我们从city表中派生一个,这样我们就有足够的数据可以使用:
CREATE TABLE sakila.city_demo(city VARCHAR(50) NOT NULL);
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city;
-- Repeat the next statement five times:
INSERT INTO sakila.city_demo(city) SELECT city FROM sakila.city_demo;
-- Now randomize the distribution (inefficiently but conveniently):
UPDATE sakila.city_demo
SET city = (SELECT city FROM sakila.city ORDER BY RAND() LIMIT 1);
现在我们有一个示例数据集。结果并不真实分布,并且我们使用了RAND(),所以你的结果会有所不同,但这对这个练习并不重要。首先,我们找到出现频率最高的城市:
mysql> SELECT COUNT(*) AS c, city
-> FROM sakila.city_demo
-> GROUP BY city ORDER BY c DESC LIMIT 10;
+-----+----------------+
| c | city |
+-----+----------------+
| 65 | London |
| 49 | Hiroshima |
| 48 | Teboksary |
| 48 | Pak Kret |
| 48 | Yaound |
| 47 | Tel Aviv-Jaffa |
| 47 | Shimoga |
| 45 | Cabuyao |
| 45 | Callao |
| 45 | Bislig |
+-----+----------------+
注意每个值大约有 45 到 65 次出现。现在我们找到最频繁出现的城市名前缀,从三个字母的前缀开始:
mysql> SELECT COUNT(*) AS c, LEFT(city, 3) AS pref
-> FROM sakila.city_demo GROUP BY pref ORDER BY cc DESC LIMIT 10;
+-----+------+
| c | pref |
+-----+------+
| 483 | San |
| 195 | Cha |
| 177 | Tan |
| 167 | Sou |
| 163 | al- |
| 163 | Sal |
| 146 | Shi |
| 136 | Hal |
| 130 | Val |
| 129 | Bat |
+-----+------+
每个前缀的出现次数更多,因此唯一前缀比唯一完整城市名称要少得多。这个想法是增加前缀长度,直到前缀几乎与列的完整长度一样具有选择性。一点实验表明7是一个不错的值:
mysql> SELECT COUNT(*) AS c, LEFT(city, 7) AS pref
-> FROM sakila.city_demo GROUP BY pref ORDER BY c DESC LIMIT 10;
+-----+---------+
| c | pref |
+-----+---------+
| 70 | Santiag |
| 68 | San Fel |
| 65 | London |
| 61 | Valle d |
| 49 | Hiroshi |
| 48 | Teboksa |
| 48 | Pak Kre |
| 48 | Yaound |
| 47 | Tel Avi |
| 47 | Shimoga |
+-----+---------+
另一种计算好前缀长度的方法是计算完整列的选择性,并尝试使前缀的选择性接近该值。以下是如何找到完整列的选择性:
mysql> SELECT COUNT(DISTINCT city)/COUNT(*) FROM sakila.city_demo;
+-------------------------------+
| COUNT(DISTINCT city)/COUNT(*) |
+-------------------------------+
| 0.0312 |
+-------------------------------+
如果我们的目标选择性接近 0.031,那么平均而言,前缀将是相当不错的(不过这里有一个警告)。在一个查询中评估许多不同长度是可能的,这对于非常大的表格非常有用。以下是如何在一个查询中找到几个前缀长度的选择性:
mysql> SELECT COUNT(DISTINCT LEFT(city, 3))/COUNT(*) AS sel3,
-> COUNT(DISTINCT LEFT(city, 4))/COUNT(*) AS sel4,
-> COUNT(DISTINCT LEFT(city, 5))/COUNT(*) AS sel5,
-> COUNT(DISTINCT LEFT(city, 6))/COUNT(*) AS sel6,
-> COUNT(DISTINCT LEFT(city, 7))/COUNT(*) AS sel7
-> FROM sakila.city_demo;
+--------+--------+--------+--------+--------+
| sel3 | sel4 | sel5 | sel6 | sel7 |
+--------+--------+--------+--------+--------+
| 0.0239 | 0.0293 | 0.0305 | 0.0309 | 0.0310 |
+--------+--------+--------+--------+--------+
这个查询显示增加前缀长度会导致随着接近七个字符的逐渐减小的改善。
仅仅看平均选择性并不是一个好主意。警告是最坏情况选择性也很重要。平均选择性可能会让您认为四或五个字符的前缀已经足够好了,但如果您的数据非常不均匀,那可能是一个陷阱。如果您使用值4查看最常见城市名称前缀的出现次数,您将清楚地看到这种不均匀性:
mysql> SELECT COUNT(*) AS c, LEFT(city, 4) AS pref
-> FROM sakila.city_demo GROUP BY pref ORDER BY c DESC LIMIT 5;
+-----+------+
| c | pref |
+-----+------+
| 205 | San |
| 200 | Sant |
| 135 | Sout |
| 104 | Chan |
| 91 | Toul |
+-----+------+
使用四个字符,最频繁的前缀出现的频率要比最频繁的全长值要高得多。也就是说,这些值的选择性低于平均选择性。如果您的数据集比这个随机生成的样本更真实,您可能会看到这种效果更加明显。例如,在真实世界的城市名称上构建一个四字符前缀索引将导致以“San”和“New”开头的城市的选择性非常糟糕,而这样的城市有很多。
现在我们已经找到了样例数据的一个好值,以下是如何在列上创建前缀索引的方法:
ALTER TABLE sakila.city_demo ADD KEY (city(7));
前缀索引可以使索引变得更小更快,但它们也有缺点:MySQL 无法将前缀索引用于ORDER BY或GROUP BY查询,也无法将其用作覆盖索引。
我们发现前缀索引有益的一个常见情况是当使用长十六进制标识符时。我们在上一章讨论了存储这些标识符的更有效技术,但如果您使用的是无法修改的打包解决方案呢?我们经常看到这种情况发生在 vBulletin 和其他使用 MySQL 存储网站会话的应用程序上,这些应用程序以长十六进制字符串为键。在前八个字符左右添加索引通常会显著提升性能,而且对应用程序完全透明。
多列索引
多列索引经常被误解。常见的错误是单独为许多或所有列建立索引,或者以错误的顺序为列建立索引。
我们将在下一节讨论列顺序。第一个错误,单独为许多列建立索引,在SHOW CREATE TABLE中有一个独特的标志:
CREATE TABLE t (
c1 INT,
c2 INT,
c3 INT,
KEY(c1),
KEY(c2),
KEY(c3)
);
这种索引策略通常是因为人们给出模糊但听起来权威的建议,比如“在WHERE子句中出现的列上创建索引”。这个建议是非常错误的。它最多会导致一星级索引。这些索引可能比真正最佳的索引慢几个数量级。有时,当您无法设计一个三星级索引时,最好忽略WHERE子句,关注最佳行顺序或创建一个覆盖索引。
对许多列单独建立索引对于大多数查询并不能帮助 MySQL 提高性能。当 MySQL 使用一种称为索引合并的策略时,它可以在使用多个索引来定位所需行的单个表时稍微应对这种索引不良的表。它可以同时扫描这两个索引并合并结果。算法有三种变体:OR条件的并集,AND条件的交集,以及两者的组合的并集。以下查询使用了两个索引扫描的并集,您可以通过检查Extra列看到:
mysql> EXPLAIN SELECT film_id, actor_id FROM sakila.film_actor
-> WHERE actor_id = 1 OR film_id = 1\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: index_merge
possible_keys: PRIMARY,idx_fk_film_id
key: PRIMARY,idx_fk_film_id
key_len: 2,2
ref: NULL
rows: 29
filtered: 100.00
Extra: Using union(PRIMARY,idx_fk_film_id); Using where
MySQL 可以在复杂查询中使用这种技术,因此您可能会在某些查询的Extra列中看到嵌套操作。
索引合并策略有时效果非常好,但更常见的情况是实际上表现出一个索引不良的表:
-
当服务器交集索引(通常用于
AND条件)时,通常意味着您需要一个包含所有相关列的单个索引,而不是需要组合的多个索引。 -
当服务器联合索引(通常用于
OR条件)时,有时算法的缓冲、排序和合并操作会使用大量的 CPU 和内存资源。特别是如果并非所有索引都非常具有选择性,那么扫描将返回大量行给合并操作。 -
请记住,优化器不考虑这个成本-它仅优化随机页面读取的数量。这可能使其“低估”查询的成本,实际上可能比纯表扫描运行得更慢。密集的内存和 CPU 使用也倾向于影响并发查询,但在单独运行查询时您不会看到这种效果。有时,使用
UNION子句重写这样的查询更为优化。
当你在EXPLAIN中看到索引合并时,应该检查查询和表结构,看看这是否真的是你能得到的最佳结果。你可以通过optimizer_switch选项或变量禁用索引合并。你也可以使用IGNORE INDEX。
选择一个好的列顺序
我们看到的最常见的混淆原因之一是索引中列的顺序。正确的顺序取决于将使用索引的查询,并且您必须考虑如何选择索引顺序,使得行以一种有利于查询的方式排序和分组。
多列 B 树索引中的列顺序意味着索引首先按最左边的列排序,然后按下一列排序,依此类推。因此,索引可以以正向或反向顺序扫描,以满足与列顺序完全匹配的ORDER BY、GROUP BY和DISTINCT子句的查询。
因此,在多列索引中,列顺序至关重要。列顺序要么使索引能够获得 Lahdenmaki 和 Leach 的三星系统中的“星星”(请参见本章前面的“索引的好处”了解更多关于三星系统的信息)。我们将在本章的其余部分展示许多示例,说明这是如何工作的。
有一个选择列顺序的古老经验法则:在索引中首先放置最具选择性的列。这个建议有多有用呢?在某些情况下可能有帮助,但通常比避免随机 I/O 和排序要不重要得多,综合考虑所有事情。(具体情况各不相同,因此没有一刀切的规则。这一点就应该告诉你,这个经验法则可能比你想象的要不重要。)
在没有考虑排序或分组的情况下,将最具选择性的列放在前面可能是一个好主意,因此索引的目的仅仅是优化WHERE查找。在这种情况下,设计索引以尽快过滤出行可能确实有效,因此对于只在WHERE子句中指定索引前缀的查询,它更具选择性。然而,这不仅取决于列的选择性(总体基数),还取决于您用于查找行的实际值-值的分布。这与我们为选择良好的前缀长度而探讨的相同类型的考虑是一样的。您可能实际上需要选择列顺序,使其对您将运行的大多数查询具有尽可能高的选择性。
让我们以以下查询为例:
SELECT * FROM payment WHERE staff_id = 2 AND customer_id = 584;
您应该在(staff_id, customer_id)上创建索引,还是应该颠倒列顺序?我们可以运行一些快速查询来帮助检查表中值的分布,并确定哪一列具有更高的选择性。让我们将查询转换为计算WHERE子句中每个谓词的基数:
mysql> SELECT SUM(staff_id = 2), SUM(customer_id = 584) FROM payment\G
*************************** 1\. row ***************************
SUM(staff_id = 2): 7992
SUM(customer_id = 584): 30
根据经验法则,我们应该将customer_id放在索引的第一位,因为谓词在表中匹配的行数较少。然后我们可以再次运行查询,看看staff_id在由特定客户 ID 选择的行范围内的选择性如何:
mysql> SELECT SUM(staff_id = 2) FROM payment WHERE customer_id = 584\G
*************************** 1\. row ***************************
SUM(staff_id = 2): 17
使用这种技术要小心,因为结果取决于为所选查询提供的具体常数。如果为这个查询优化了索引,而其他查询表现不佳,服务器的性能可能会受到影响,或者某些查询可能会运行不稳定。
如果你正在使用来自工具(如pt-query-digest)报告的“最差”样本查询,这种技术可以是查看对你的查询和数据最有帮助的索引的有效方法。但如果你没有具体的样本要运行,也许最好使用旧的经验法则,即全面查看基数,而不仅仅是一个查询:
mysql> SELECT COUNT(DISTINCT staff_id)/COUNT(*) AS staff_id_selectivity,
-> COUNT(DISTINCT customer_id)/COUNT(*) AS customer_id_selectivity,
-> COUNT(*)
-> FROM payment\G
*************************** 1\. row ***************************
staff_id_selectivity: 0.0001
customer_id_selectivity: 0.0373
COUNT(*): 16049
customer_id的选择性更高,所以答案是将该列放在索引的第一位:
ALTER TABLE payment ADD KEY(customer_id, staff_id);
与前缀索引一样,问题通常出现在具有高于正常基数的特殊值上。例如,我们曾看到应用程序将未登录的用户视为“访客”用户,在会话表和其他记录用户活动的地方,这些用户获得特殊的用户 ID。涉及该用户 ID 的查询可能会与其他查询表现非常不同,因为通常有很多未登录的会话。有时系统帐户也会引起类似的问题。一个应用程序有一个神奇的管理帐户,不是真实用户,它与整个网站的每个用户“成为朋友”,以便发送状态通知和其他消息。该用户庞大的朋友列表导致网站性能严重问题。
这实际上是相当典型的。任何异常值,即使不是应用程序管理中糟糕决策的产物,都可能引发问题。真正拥有大量朋友、照片、状态消息等用户可能会和虚假用户一样令人头疼。
这是我们曾在产品论坛上看到的一个真实例子,用户在那里交流关于产品的故事和经验。这种特定形式的查询运行非常缓慢:
SELECT COUNT(DISTINCT threadId) AS COUNT_VALUE
FROM Message
WHERE (groupId = 10137) AND (userId = 1288826) AND (anonymous = 0)
ORDER BY priority DESC, modifiedDate DESC
这个查询似乎没有一个很好的索引,所以客户要求我们看看是否可以改进。EXPLAIN如下:
id: 1
select_type: SIMPLE
table: Message
type: ref
key: ix_groupId_userId
key_len: 18
ref: const,const
rows: 1251162
Extra: Using where
MySQL 为这个查询选择的索引是(groupId, userId),如果我们没有关于列基数的信息,这似乎是一个相当不错的选择。然而,当我们查看匹配该用户 ID 和群组 ID 的行数时,情况就有所不同:
mysql> SELECT COUNT(*), SUM(groupId = 10137),
-> SUM(userId = 1288826), SUM(anonymous = 0)
-> FROM Message\G
*************************** 1\. row ***************************
count(*): 4142217
sum(groupId = 10137): 4092654
sum(userId = 1288826): 1288496
sum(anonymous = 0): 4141934
这个群组实际上拥有表中的几乎每一行,用户有 130 万行数据——在这种情况下,根本没有办法使用索引!这是因为数据是从另一个应用程序迁移过来的,所有消息都被分配给了管理用户和群组作为导入过程的一部分。解决这个问题的方法是更改应用程序代码,以识别这个特殊情况的用户 ID 和群组 ID,并且不为该用户发出这个查询。
这个小故事的寓意是,经验法则和启发式方法可能很有用,但你必须小心,不要假设平均情况下的性能代表特殊情况下的性能。特殊情况可能破坏整个应用程序的性能。
最后,尽管选择性和基数的经验法则很有趣,但其他因素——如排序、分组以及查询的WHERE子句中是否存在范围条件——可能对查询性能产生更大的影响。
聚集索引
聚集索引⁵并不是一种单独的索引类型。相反,它们是一种数据存储方法。具体细节在不同的实现中有所不同,但 InnoDB 的聚集索引实际上将 B 树索引和行一起存储在同一结构中。
当表具有聚集索引时,其行实际上存储在索引的叶页中。术语聚集指的是具有相邻键值的行存储在彼此附近。⁶ 每个表只能有一个聚集索引,因为不能同时将行存储在两个位置。(但是,覆盖索引允许您模拟多个聚集索引;稍后详细介绍。)
因为存储引擎负责实现索引,而不是所有存储引擎都支持聚集索引。在本节中,我们专注于 InnoDB,但我们讨论的原则可能至少部分适用于任何支持聚集索引的存储引擎,无论是现在还是将来。
图 7-3 显示了聚集索引中记录的布局。请注意,叶页包含完整的行,但节点页仅包含索引列。在这种情况下,索引列包含整数值。

图 7-3. 聚集索引数据布局
一些数据库服务器允许您选择要聚集的索引,但在撰写本文时,MySQL 的内置存储引擎都不支持此功能。InnoDB 通过主键对数据进行聚集。这意味着图 7-3 中的“索引列”是主键列。
如果您没有定义主键,InnoDB 将尝试使用唯一的非空索引。如果没有这样的索引,InnoDB 将为您定义一个隐藏的主键,然后在其上进行聚集。隐藏主键的缺点是,这些主键的递增值在使用隐藏主键的所有表之间共享,导致共享键的互斥争用增加。
数据聚集具有一些非常重要的优势:
-
您可以将相关数据保持在一起。例如,在实现邮箱时,您可以按
user_id进行聚集,这样您可以通过仅从磁盘检索几个页面来检索单个用户的所有消息。如果不使用聚集,每个消息可能需要自己的磁盘 I/O。 -
数据访问速度很快。聚集索引在一个 B 树中同时保存索引和数据,因此从聚集索引中检索行通常比在非聚集索引中进行相似查找更快。
-
使用覆盖索引的查询可以使用叶节点中包含的主键值。
如果您设计表和查询以利用这些优势,这些优势可以极大地提高性能。但是,聚集索引也有一些缺点:
-
对于 I/O 密集型工作负载,聚集提供了最大的改进。如果数据适合内存,那么访问数据的顺序实际上并不重要,因此聚集并不会带来太多好处。
-
插入速度严重依赖插入顺序。按照主键顺序插入行是将数据加载到 InnoDB 表中的最快方法。如果没有按照主键顺序加载行,加载大量数据后可能需要使用
OPTIMIZE TABLE重��组织表。 -
更新聚集索引列的成本很高,因为它迫使 InnoDB 将每个更新的行移动到新位置。
-
基于聚集索引构建的表在插入新行或更新行的主键以移动行时会受到页拆分的影响。当行的键值决定必须将行放入一个已满数据的页面时,就会发生页拆分。存储引擎必须将页面拆分为两个以容纳该行。页拆分可能导致表在磁盘上使用更多空间。
-
对于全表扫描,聚集表可能会较慢,特别是如果行的密度较低或由于页拆分而存储非顺序。
-
二级(非聚集)索引可能比您预期的要大,因为它们的叶节点包含了引用行的主键列。
-
二级索引访问需要两次索引查找而不是一次。
最后一点可能有点令人困惑。为什么辅助索引需要两次索引查找?答案在于辅助索引存储的“行指针”的性质。记住,叶节点不存储指向引用行物理位置的指针;相反,它存储行的主键值。
这意味着要从辅助索引中找到一行,存储引擎首先找到辅助索引中的叶节点,然后使用存储在那里的主键值导航主键并找到行。这是双重工作:两次 B 树导航而不是一次。⁷ 在 InnoDB 中,自适应哈希索引(前面在“B 树索引”中提到)可以帮助减少这种惩罚。
InnoDB 的数据布局
为了更好地理解聚簇索引,让我们看看 InnoDB 如何布局以下表:
CREATE TABLE layout_test (
col1 int NOT NULL,
col2 int NOT NULL,
PRIMARY KEY(col1),
KEY(col2)
);
假设表填充了主键值 1 到 10,000,以随机顺序插入,然后使用OPTIMIZE TABLE进行优化。换句话说,数据在磁盘上被最佳地排列,但行可能是随机顺序的。col2的值在 1 到 100 之间随机分配,因此存在大量重复。
InnoDB 将表存储如图 7-4 所示。

图 7-4。layout_test表的 InnoDB 主键布局
聚簇索引中的每个叶节点包含主键值、事务 ID 和回滚指针,InnoDB 用于事务和 MVCC 目的,以及其余列(在本例中为col2)。如果主键在列前缀上,InnoDB 会将完整列值与其余列一起包括在内。
InnoDB 的辅助索引叶节点包含主键值,这些值作为指向行的“指针”。这种策略减少了在行移动或数据页拆分时维护辅助索引所需的工作量。使用行的主键值作为指针使索引变大,但这意味着 InnoDB 可以移动行而无需更新指向它的指针。
图 7-5 展示了示例表的col2索引。每个叶节点包含索引列(在本例中只有col2),然后是主键值(col1)。

图 7-5。layout_test表的 InnoDB 辅助索引布局
这些图表展示了 B 树叶节点,但我们有意省略了关于非叶节点的细节。InnoDB 的非叶 B 树节点每个包含索引列,以及指向下一层节点的指针(可能是另一个非叶节点或叶节点)。这适用于所有 B 树索引,包括聚簇和辅助索引。
在 InnoDB 中按主键顺序插入行
如果您正在使用 InnoDB 并且不需要任何特定的聚簇,最好定义一个代理键,这是一个主键,其值不是从应用程序数据派生的。通常使用AUTO_INCREMENT列是最简单的方法。这将确保行按顺序插入,并且将为使用主键进行连接提供更好的性能。
最好避免随机(非顺序且分布在大量值集上)的聚簇键,特别是对于 I/O 密集型工作负载。例如,使用 UUID 值在性能方面是一个糟糕的选择:它使聚簇索引插入变得随机,这是最坏的情况,并且不提供任何有用的数据聚类。
为了演示,我们对两种情况进行了基准测试。第一种是插入到具有整数 ID 的userinfo表中,定义如下:
CREATE TABLE userinfo (
id int unsigned NOT NULL AUTO_INCREMENT,
name varchar(64) NOT NULL DEFAULT '',
email varchar(64) NOT NULL DEFAULT '',
password varchar(64) NOT NULL DEFAULT '',
dob date DEFAULT NULL,
address varchar(255) NOT NULL DEFAULT '',
city varchar(64) NOT NULL DEFAULT '',
state_id tinyint unsigned NOT NULL DEFAULT '0',
zip varchar(8) NOT NULL DEFAULT '',
country_id smallint unsigned NOT NULL DEFAULT '0',
gender ('M','F')NOT NULL DEFAULT 'M',
account_type varchar(32) NOT NULL DEFAULT '',
verified tinyint NOT NULL DEFAULT '0',
allow_mail tinyint unsigned NOT NULL DEFAULT '0',
parrent_account int unsigned NOT NULL DEFAULT '0',
closest_airport varchar(3) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY email (email),
KEY country_id (country_id),
KEY state_id (state_id),
KEY state_id_2 (state_id,city,address)
) ENGINE=InnoDB
注意自增整数主键。⁸
第二种情况是一个名为userinfo_uuid的表。它与userinfo表相同,只是其主键是 UUID 而不是整数:
CREATE TABLE userinfo_uuid (
uuid varchar(36) NOT NULL,
...
我们对两种表设计进行了基准测试。首先,在具有足够内存容纳索引的服务器上向两个表中各插入一百万条记录。接下来,我们向相同的表中插入三癃万行,这使得索引比服务器的内存还要大。表 7-1 比较了基准测试结果。
表 7-1. 向 InnoDB 表中插入行的基准测试结果
| 表 | 行数 | 时间(秒) | 索引大小(MB) |
|---|---|---|---|
userinfo |
1,000,000 | 137 | 342 |
userinfo_uuid |
1,000,000 | 180 | 544 |
userinfo |
3,000,000 | 1233 | 1036 |
userinfo_uuid |
3,000,000 | 4525 | 1707 |
注意,使用 UUID 主键插入行不仅需要更长的时间,而且生成的索引要大得多。部分原因是由于较大的主键,但也有一部分无疑是由于页面分裂和随之产生的碎片化。
为了了解为什么这样做很重要,让我们看看当我们向第一个表插入数据时索引发生了什么。图 7-6 显示插入填充一页,然后继续在第二页上。

图 7-6. 向聚簇索引插入顺序索引值
如图 7-6 所示,InnoDB 将每个记录紧跟在前一个记录之后存储,因为主键值是顺序的。当页面达到其最大填充因子时(InnoDB 的初始填充因子仅为 15/16,以留出空间供以后修改),下一个记录将进入新页面。一旦数据以这种顺序方式加载,主键页面将几乎满载有顺序记录,这是非常理想的。(但是,次要索引页面可能不太可能有所不同。)
与将数据插入具有 UUID 聚簇索引的第二个表时发生的情况形成对比,如图 7-7 所示。

图 7-7. 向聚簇索引插入非顺序值
因为每个新行不一定比前一个具有更大的主键值,InnoDB 不能总是将新行放在索引末尾。它必须找到适当的位置放置行,通常在现有数据的中间附近,并为其腾出空间。这会导致大量额外工作,并导致次优化的数据布局。以下是缺点摘要:
-
目标页面可能已被刷新到磁盘并从缓存中移除,或者可能根本没有被放入缓存中,此时 InnoDB 必须找到它并从磁盘中读取它,然后才能插入新行。这会导致大量随机 I/O。
-
当插入是无序的时,InnoDB 经常需要分裂页面以为新行腾出空间。这需要移动大量数据,并修改至少三个页面,而不是一个。
-
由于分裂,页面变得稀疏且不规则填充,因此最终数据是碎片化的。
在向聚簇索引加载这样的随机值后,您可能需要执行 OPTIMIZE TABLE 来重建表并最佳地填充页面。
故事的寓意是,在使用 InnoDB 时,您应该努力按主键顺序插入数据,并尽量使用一个为每个新行提供单调递增值的聚簇键。
覆盖索引
一个常见的建议是为查询的WHERE子句创建索引,但这只是故事的一部分。索引需要为整个查询而不仅仅是WHERE子句而设计。索引确实是一种高效查找行的方法,但 MySQL 也可以使用索引检索列的数据,因此不必读取整行。毕竟,索引的叶节点包含它们索引的值;当读取索引可以提供您想要的数据时,为什么要读取行呢?包含(或“覆盖”)满足查询所需的所有数据的索引称为覆盖索引。重要的是要注意,只有 B 树索引可以用于覆盖索引。
覆盖索引可以是一个非常强大的工具,可以显著提高性能。考虑仅读取索引而不是数据的好处:
-
索引条目通常比完整行大小小得多,因此如果仅读取索引,MySQL 可以访问的数据量明显较少。这对于缓存工作负载非常重要,因为大部分响应时间来自数据的复制。对于 I/O 受限的工作负载也很有帮助,因为索引比数据小,更适合内存。
-
索引按其索引值排序(至少在页面内),因此 I/O 受限的范围访问将需要比从随机磁盘位置获取每行更少的 I/O。您甚至可以通过
OPTIMIZE表来获得完全排序的索引,这将使简单的范围查询使用完全顺序的索引访问。 -
覆盖索引对 InnoDB 表格特别有帮助,因为 InnoDB 使用聚簇索引。InnoDB 的次要索引在其叶节点上保存行的主键值。因此,覆盖查询的次要索引避免了在主键中进行另一个索引查找。
在所有这些情况下,从索引中满足查询通常比查找行要便宜得多。
当您发出一个被索引覆盖的查询(索引覆盖查询)时,在EXPLAIN中的Extra列中会看到“Using index”。例如,sakila.inventory表在(store_id, film_id)上有一个多列索引。MySQL 可以使用此索引进行仅访问这两列的查询,例如以下查询:
mysql> EXPLAIN SELECT store_id, film_id FROM sakila.inventory\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: inventory
partitions: NULL
type: index
possible_keys: NULL
key: idx_store_id_film_id
key_len: 3
ref: NULL
rows: 4581
filtered: 100.00
Extra: Using index
在大多数存储引擎中,索引只能覆盖访问索引的一部分的列的查询。然而,InnoDB 实际上可以进一步优化这一点。回想一下,InnoDB 的次要索引在其叶节点上保存主键值。这意味着 InnoDB 的次要索引实际上有“额外的列”,InnoDB 可以用来覆盖查询。
例如,sakila.actor表使用 InnoDB,并在last_name上有一个索引,因此索引可以覆盖检索主键列actor_id的查询,即使该列在技术上不是索引的一部分:
mysql> EXPLAIN SELECT actor_id, last_name
-> FROM sakila.actor WHERE last_name = 'HOPPER'\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: actor
partitions: NULL
type: ref
possible_keys: idx_actor_last_name
key: idx_actor_last_name
key_len: 182
ref: const
rows: 2
filtered: 100.00
Extra: Using index
用于排序的索引扫描
MySQL 有两种产生有序结果的方式:它可以使用排序操作,或者可以按顺序扫描索引。您可以通过查看EXPLAIN中type列中的“index”来判断 MySQL 是否计划扫描索引。(不要将此与Extra列中的“Using index”混淆。)
扫描索引本身很快,因为它只需要从一个索引条目移动到下一个。然而,如果 MySQL 没有使用索引来覆盖查询,它将不得不查找在索引中找到的每一行。这基本上是随机 I/O,因此按索引顺序读取数据通常比顺序表扫描慢,尤其是对于 I/O 受限的工作负载。
MySQL 可以同时用同一个索引进行排序和查找行。如果可能的话,最好设计您的索引,使其同时对这两个任务有用。
仅当索引的顺序与ORDER BY子句完全相同且所有列都按相同方向(升序或降序)排序时,按索引对结果排序才有效。¹⁰ 如果查询连接多个表,则仅当ORDER BY子句中的所有列都引用第一个表时才有效。ORDER BY子句也具有与查找查询相同的限制:它需要形成索引的最左前缀。在所有其他情况下,MySQL 使用排序。
如果索引的前导列有常量,那么ORDER BY子句不必指定索引的最左前缀。如果WHERE子句或JOIN子句为这些列指定了常量,它们可以“填补”索引中的空缺。
例如,标准 Sakila 示例数据库中的rental表具有一个索引(rental_date、inventory_id、customer_id):
CREATE TABLE rental (
...
PRIMARY KEY (rental_id),
UNIQUE KEY rental_date (rental_date,inventory_id,customer_id),
KEY idx_fk_inventory_id (inventory_id),
KEY idx_fk_customer_id (customer_id),
KEY idx_fk_staff_id (staff_id),
...
);
MySQL 使用rental_date索引来排序以下查询,从EXPLAIN中看到没有filesort¹¹:
mysql> EXPLAIN SELECT rental_id, staff_id FROM sakila.rental
-> WHERE rental_date = '2005-05-25'
-> ORDER BY inventory_id, customer_id\G
*************************** 1\. row ***************************
type: ref
possible_keys: rental_date
key: rental_date
rows: 1
Extra: Using where
这个例子可以工作,即使ORDER BY子句本身不是索引的最左前缀,因为我们为索引的第一列指定了相等条件。
这里有一些更多可以使用索引进行排序的查询。这个例子有效,因为查询为索引的第一列提供了一个常量,并指定了第二列的ORDER BY。这两者一起形成了索引的最左前缀:
... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC;
以下查询也有效¹²,因为ORDER BY中的两列是索引的最左前缀:
... WHERE rental_date > '2005-05-25' ORDER BY rental_date, inventory_id;
以下是一些无法使用索引进行排序的查询。
此查询使用两种不同的排序方向,但索引的列都是按升序排序的:
... WHERE rental_date = '2005-05-25' ORDER BY inventory_id DESC, customer_id ASC;
在这里,ORDER BY引用了不在索引中的列:
... WHERE rental_date = '2005-05-25' ORDER BY inventory_id, staff_id;
在这里,WHERE和ORDER BY不构成索引的最左前缀:
... WHERE rental_date = '2005-05-25' ORDER BY customer_id;
此查询在第一列上有一个范围条件,因此 MySQL 不使用索引的其余部分:
... WHERE rental_date > '2005-05-25' ORDER BY inventory_id, customer_id;
在这里,inventory_id列有多个相等。对于排序而言,这基本上与范围相同:
... WHERE rental_date = '2005-05-25' AND inventory_id IN(1,2) ORDER BY
customer_id;
这里有一个例子,MySQL 理论上可以使用索引来排序连接,但实际上并没有这样做,因为优化器将film_actor表放在连接的第二个位置:
mysql> EXPLAIN SELECT actor_id, title FROM sakila.film_actor
-> INNER JOIN sakila.film USING(film_id) ORDER BY actor_id\G
+------------+----------------------------------------------+
| table | Extra |
+------------+----------------------------------------------+
| film | Using index; Using temporary; Using filesort |
| film_actor | Using index |
+------------+----------------------------------------------+
按索引排序最重要的用途之一是具有ORDER BY和LIMIT子句的查询。
冗余和重复的索引
不幸的是,MySQL 允许您在同一列上创建重复索引。这样做只会返回一个警告,并不会阻止您这样做。MySQL 必须单独维护每个重复索引,并且在优化查询时查询优化器将考虑它们中的每一个。这可能会影响性能,还会浪费磁盘空间。
重复索引是在相同顺序的相同列集上创建的相同类型的索引。您应该尽量避免创建它们,并在发现它们时将其删除。
有时您可能会在不知情的情况下创建重复索引。例如,看看以下代码:
CREATE TABLE test (
ID INT NOT NULL PRIMARY KEY,
A INT NOT NULL,
B INT NOT NULL,
UNIQUE(ID),
INDEX(ID)
) ENGINE=InnoDB;
一个经验不足的用户可能会认为这标识了列作为主键的角色,添加了UNIQUE约束,并添加了一个索引供查询使用。实际上,MySQL 使用索引实现UNIQUE约束和PRIMARY KEY约束,因此实际上在同一列上创建了三个索引!除非您希望在同一列上有不同类型的索引以满足不同类型的查询,否则通常没有理由这样做。¹³
多余的索引与重复的索引有些不同。如果在(A, B)上有一个索引,那么在(A)上有另一个索引将是多余的,因为它是第一个索引的前缀。也就是说,(A, B)上的索引也可以作为仅在(A)上的索引使用。(这种冗余类型仅适用于 B 树索引。)然而,在(B, A)上的索引不会是多余的,(B)上的索引也不会是多余的,因为B不是(A, B)的最左前缀。此外,不管它们覆盖哪些列,不同类型的索引(如全文索引)都不会对 B 树索引产生冗余。
多余的索引通常出现在人们向表中添加索引时。例如,有人可能在(A, B)上添加索引,而不是扩展现有的(A)索引以覆盖(A, B)。另一种可能发生的情况是将索引更改为覆盖(A, ID)。ID列是主键,因此已经包含在内。
在大多数情况下,您不希望有多余的索引,为了避免它们,您应该扩展现有的索引而不是添加新的。但是,有时您会因性能原因需要多余的索引。扩展现有索引可能会使其变得更大,并降低某些查询的性能。
例如,如果您在整数列上建立了一个索引,并将其扩展为长VARCHAR列,它可能会变得明显较慢。特别是如果您的查询使用索引作为覆盖索引时,情况尤其如此。
考虑以下的userinfo表:
CREATE TABLE userinfo (
id int unsigned NOT NULL AUTO_INCREMENT,
name varchar(64) NOT NULL DEFAULT '',
email varchar(64) NOT NULL DEFAULT '',
password varchar(64) NOT NULL DEFAULT '',
dob date DEFAULT NULL,
address varchar(255) NOT NULL DEFAULT '',
city varchar(64) NOT NULL DEFAULT '',
state_id tinyint unsigned NOT NULL DEFAULT '0',
zip varchar(8) NOT NULL DEFAULT '',
country_id smallint unsigned NOT NULL DEFAULT '0',
account_type varchar(32) NOT NULL DEFAULT '',
verified tinyint NOT NULL DEFAULT '0',
allow_mail tinyint unsigned NOT NULL DEFAULT '0',
parrent_account int unsigned NOT NULL DEFAULT '0',
closest_airport varchar(3) NOT NULL DEFAULT '',
PRIMARY KEY (id),
UNIQUE KEY email (email),
KEY country_id (country_id),
KEY state_id (state_id)
) ENGINE=InnoDB
这个表包含一百万行,对于每个state_id,大约有 20,000 条记录。有一个在state_id上的索引,对于以下查询很有用。我们将这个查询称为 Q1:
SELECT count(*) FROM userinfo WHERE state_id=5;
一个简单的基准测试显示,该查询的执行速率接近每秒 115 次查询(QPS)。我们还有一个相关查询,不仅仅是计算行数,而是检索几列。这是 Q2:
SELECT state_id, city, address FROM userinfo WHERE state_id=5;
对于这个查询,结果小于 10 QPS。¹⁴ 提高其性能的简单解决方案是将索引扩展到(state_id、city、address),这样索引将覆盖查询:
ALTER TABLE userinfo DROP KEY state_id,
ADD KEY state_id_2 (state_id, city, address);
扩展索引后,Q2 运行更快,但 Q1 运行更慢。如果我们真的关心让两个查询都快速,我们应该保留两个索引,即使单列索引是多余的。表 7-2 显示了两个查询和索引策略的详细结果。
表 7-2. 使用不同索引策略的SELECT查询的 QPS 基准结果
仅 state_id |
仅 state_id_2 |
state_id 和 state_id_2 都有 |
|
|---|---|---|---|
| 查询 1 | 108.55 | 100.33 | 107.97 |
| 查询 2 | 12.12 | 28.04 | 28.06 |
拥有两个索引的缺点是维护成本。表 7-3 显示了向表中插入一百万行需要多长时间。
表 7-3. 使用不同索引策略插入一百万行的速度
仅 state_id |
state_id 和 state_id_2 都有 |
|
|---|---|---|
| InnoDB,足够的内存容纳两个索引 | 80 秒 | 136 秒 |
如您所见,向具有更多索引的表中插入新行会更慢。这在一般情况下是正确的:添加新索引可能会对INSERT、UPDATE和DELETE操作产生性能影响,特别是如果新索引导致内存限制。
处理多余和重复索引的解决方案很简单,就是删除它们,但首先您需要识别它们。您可以针对INFORMATION_SCHEMA表编写各种复杂的查询,但也有更简单的技术。您可以使用 Percona Toolkit 附带的pt-duplicate-key-checker工具,该工具分析表结构并建议重复或多余的索引。
在确定哪些索引适合删除或扩展时要小心。请记住,在 InnoDB 中,我们示例表中列(A)上的索引实际上等同于(A, ID)上的索引,因为主键附加到辅助索引叶节点。如果你有一个查询,比如WHERE A = 5 ORDER BY ID,那么索引将非常有帮助。但是如果你将索引扩展到(A, B),那么它实际上变成了(A, B, ID),查询将开始对查询的ORDER BY部分使用文件排序。最好使用诸如 Percona Toolkit 的pt-upgrade之类的工具仔细验证你计划的更改。
对于这两种情况,在删除索引之前考虑使用 MySQL 8.0 的隐形索引功能。通过这个功能,你可以发出一个ALTER TABLE语句将一个索引标记为隐形,这意味着优化器在规划查询时会忽略它。如果你发现即将删除的索引很重要,你可以很容易地将其重新设为可见,而无需重新创建索引。
未使用的索引
除了重复和冗余的索引,你可能有一些服务器根本不使用的索引。这些只是多余的负担,你应该考虑删除它们。¹⁵
识别未使用的索引的最佳方法是使用performance_schema和sys,我们在第三章中详细介绍了这两个功能。sys模式创建了一个table_io_waits_summary_by_index_usage表的视图,可以轻松告诉我们哪些索引未使用:
mysql> SELECT * FROM sys.schema_unused_indexes;
+---------------+---------------+-----------------------------+
| object_schema | object_name | index_name |
+---------------+---------------+-----------------------------+
| sakila | actor | idx_actor_last_name |
| sakila | address | idx_fk_city_id |
| sakila | address | idx_location |
| sakila | payment | fk_payment_rental |
.. trimmed for brevity ..
索引和表维护
一旦你创建了具有适当数据类型和添加索引的表,你的��作并没有结束:你仍需要维护表和索引以确保它们性能良好。表维护的三个主要目标是查找和修复损坏、维护准确的索引统计信息和减少碎片化。
查找和修复表损坏
表可能遇到的最糟糕的情况就是损坏。所有存储引擎都可能由于硬件问题或 MySQL 或操作系统中的内部错误而遇到索引损坏,尽管在 InnoDB 中很少遇到这种情况。
损坏的索引可能导致查询返回不正确的结果,在没有重复值的情况下引发重复键错误,甚至导致锁定和崩溃。如果你遇到奇怪的行为,比如一个你认为不应该发生的错误,运行CHECK TABLE查看表是否损坏。(请注意,某些存储引擎不支持此命令,而其他支持多个选项以指定他们检查表的彻底程度。)CHECK TABLE通常可以捕捉到大多数表和索引错误。
你可以使用REPAIR TABLE命令修复损坏的表,但并非所有存储引擎都支持此功能。在这些情况下,你可以执行一个“no-op” ALTER,比如修改表以使用当前使用的相同存储引擎。以下是一个针对 InnoDB 表的示例:
ALTER TABLE <table> ENGINE=INNODB;
或者,你可以导出数据并重新加载。但是,如果损坏发生在系统区域或表的“行数据”区域而不是索引中,你可能无法使用任何这些选项。在这种情况下,你可能需要从备份中恢复表,或尝试从损坏的文件中恢复数据。
如果您在 InnoDB 存储引擎中遇到损坏,那么肯定出了严重问题,您需要立即进行调查。InnoDB 不应该出现损坏。其设计使其对损坏非常有韧性。损坏表明可能存在硬件问题,如内存或磁盘故障(可能性较大),管理员错误,例如在 MySQL 外部操纵数据库文件(可能性较大),或者 InnoDB 错误(可能性较小)。通常的原因是诸如尝试使用rsync进行备份之类的错误。没有任何查询您可以执行——没有一个——是您应该避免的,因为它会损坏 InnoDB 的数据。没有任何隐藏的枪指向您的脚。如果通过针对 InnoDB 执行查询来损坏 InnoDB 的数据,那么 InnoDB 中存在错误,并且这绝不是您的错。
如果您遇到数据损坏,最重要的是尝试确定为什么会发生损坏;不要仅仅修复数据,否则损坏可能会再次出现。您可以通过使用innodb_force_recovery参数将 InnoDB 置于强制恢复模式来修复数据;有关详细信息,请参阅 MySQL 手册。
更新索引统计信息
当存储引擎向优化器提供关于查询可能检查的行数的不精确信息,或者当查询计划过于复杂以至于无法准确知道各个阶段将匹配多少行时,优化器将使用索引统计信息来估计行数。MySQL 的优化器是基于成本的,主要成本指标是查询将访问的数据量。如果统计信息从未生成过或者已经过时,优化器可能会做出错误的决定。解决方案是运行ANALYZE TABLE,这将重新生成统计信息。
您可以使用SHOW INDEX FROM命令检查索引的基数。例如:
mysql> SHOW INDEX FROM sakila.actor\G
*************************** 1\. row ***************************
Table: actor
Non_unique: 0
Key_name: PRIMARY
Seq_in_index: 1
Column_name: actor_id
Collation: A
Cardinality: 200
Sub_part: NULL
Packed: NULL
Null:
Index_type: BTREE
Comment:
*************************** 2\. row ***************************
Table: actor
Non_unique: 1
Key_name: idx_actor_last_name
Seq_in_index: 1
Column_name: last_name
Collation: A
Cardinality: 200
Sub_part: NULL
Packed: NULL
Null:
Index_type: BTREE
Comment:
这个命令提供了相当多的索引信息,MySQL 手册详细解释了这些信息。不过,我们想要提醒您关注Cardinality列。这显示存储引擎估计索引中有多少个不同的值。您也可以从INFORMATION_SCHEMA.STATISTICS表中获取这些数据。例如,您可以编写针对INFORMATION_SCHEMA表的查询,以查找具有非常低选择性的索引。但是请注意,在具有大量数据的服务器上,这些元数据表可能会给服务器带来很大的负载。
值得进一步探索 InnoDB 的统计信息。这些统计信息是通过对索引中的几个随机页面进行抽样生成的,并假设其余索引看起来类似。抽样的页面数量由innodb_stats_sample_pages变量控制。将其设置为大于默认值 8 的值理论上可以帮助生成更具代表性的索引统计信息,特别是在非常大的表上,但效果可能有所不同。
当首次打开表时,运行ANALYZE TABLE时以及表的大小发生显著变化时,InnoDB 会为索引计算统计信息。
InnoDB 还会为针对一些INFORMATION_SCHEMA表的查询、SHOW TABLE STATUS和SHOW INDEX查询以及当 MySQL 命令行客户端启用自动完成时计算统计信息。在具有大量数据或 I/O 速度慢的大型服务器上,这实际上可能成为一个相当严重的问题。导致抽样发生的客户端程序或监控工具可能会在服务器上造成大量锁定和重负载,同时让用户因启动时间过长而感到沮丧。而且,您无法观察索引统计信息而不更改它们,因为SHOW INDEX会更新统计信息。您可以禁用innodb_stats_on_metadata选项以避免所有这些问题。
减少索引和数据碎片化
B-tree 索引可能会变得碎片化,这可能会降低性能。碎片化的索引可能在磁盘上填充不良和/或非顺序。
按设计,B 树索引需要随机磁盘访问以“潜入”到叶子页面,因此随机访问是规则,而不是例外。然而,如果叶子页面是物理上连续且紧凑的,它们仍然可以表现得更好。如果不是这样,我们说它们是碎片化,范围扫描或完整索引扫描可能慢几倍。这对于索引覆盖查询尤为重要。
表的数据存储也可能变得碎片化。然而,数据存储碎片化比索引碎片化更复杂。数据碎片化有三种类型:
行碎片化
当行在多个位置以多个部分存储时,就会发生这种类型的碎片化。即使查询只需要从索引中获取一行,行碎片化也会降低性能。
行内碎片化
当逻辑上连续的页面或行在磁盘上不按顺序存储时,就会发生这种类型的碎片化。这会影响全表扫描和聚集索引范围扫描等操作,这些操作通常受益于磁盘上的顺序数据布局。
空闲空间碎片化
当数据页面中有大量空白空间时,就会发生这种类型的碎片化。这会导致服务器读取许多不需要的数据,这是一种浪费。
要对数据进行碎片整理,可以运行OPTIMIZE TABLE或转储并重新加载数据。这些方法适用于大多数存储引擎。对于不支持OPTIMIZE TABLE的存储引擎,可以使用一个空操作的ALTER TABLE重建表。只需将表更改为当前使用的引擎:
ALTER TABLE <table> ENGINE=<engine>;
摘要
正如你所看到的,索引是一个复杂的话题!MySQL 和存储引擎访问数据的方式以及索引的属性使索引成为影响数据访问的非常强大和灵活的工具,无论是在磁盘上还是在内存中。
大多数情况下,你会使用 B 树索引与 MySQL。其他类型的索引更适合特殊用途,当你应该使用它们以及它们如何提高查询响应时间时,通常是显而易见的。在本章中我们不再详细讨论它们,但最后回顾一下 B 树索引的属性和用途是值得的。
在选择索引并编写查询以利用它们时,请牢记以下三个原则:
-
单行访问速度较慢,尤其是在基于磁盘的存储中。(固态硬盘在随机 I/O 方面更快,但这一点仍然成立。)如果服务器从存储中读取一块数据,然后只访问其中的一行,那么就浪费了很多工作。最好的方法是读取包含你需要的许多行的块。
-
按顺序访问行范围是快速的,有两个原因。首先,顺序 I/O 不需要磁盘寻道,因此比随机 I/O 快,尤其是在基于磁盘的存储中。其次,如果服务器可以按照你需要的顺序读取数据,就不需要执行任何后续工作来对其进行排序,而且
GROUP BY查询也不需要对行进行排序和分组以计算聚合。 -
仅索引访问是快速的。如果一个索引包含查询所需的所有列,存储引擎就不需要通过查找表中的行来找到其他列。这避免了大量的单行访问,正如我们从第一点所知道的那样,这是缓慢的。
总之,尽量选择索引并编写查询,以避免单行查找,利用数据的固有顺序避免排序操作,并利用仅索引访问。这对应于 Lahdenmaki 和 Leach 在本章开头提到的三星级评级系统。
要为针对表的每个查询创建完美的索引将是很好的。不幸的是,有时这将需要一个不切实际的大量索引,而有时根本无法为给定查询创建三星级索引(例如,如果查询按两列排序,一列升序,另一列降序)。在这些情况下,您必须尽力而为或追求替代策略,如去规范化或摘要表。
能够理解索引工作原理并根据这种理解选择索引非常重要,而不是根据经验法则或启发式法则,如“在多列索引中将最具选择性的列放在前面”或“应该为WHERE子句中出现的所有列建立索引”。
如何知道您的模式是否已经足够好地进行了索引?像往常一样,我们建议您从响应时间的角度来提出问题。查找那些要么花费太长时间要么对服务器负载贡献过大的查询。检查需要关注的查询的模式、SQL 和索引结构。确定查询是否需要检查太多行,执行检索后排序或使用临时表,访问随机 I/O 的数据,或者查找完整行以检索未包含在索引中的列。
如果发现某个查询无法从索引的所有可能优势中受益,请查看是否可以创建更好的索引以提高性能。如果不能,也许可以重写查询,使其能够使用已经存在或可以创建的索引。这就是下一章要讨论的内容。
¹ SSD 具有不同的性能特征,我们在第四章中进行了介绍。索引原则仍然成立,但我们试图避免的惩罚在 SSD 上并不像在传统驱动器上那么大。
² 许多存储引擎实际上使用 B+树索引,其中每个叶节点包含指向下一个节点的链接,以便通过节点快速进行范围遍历。请参考计算机科学文献以获取 B 树索引的详细解释。
³ 这是特定于 MySQL 的,甚至是特定于版本的。其他一些数据库可以使用非前导索引部分,尽管使用完整前缀通常更有效。MySQL 可能会在未来提供此选项;我们将在本章后面展示解决方法。
⁴ MySQL 的优化器是一个非常神秘而强大的设备,其神秘性仅次于其强大性。由于它计算最佳查询计划的方式,您应该依靠在自己的查询和工作负载中使用EXPLAIN来确定最优策略。
⁵ Oracle 用户将熟悉术语索引组织表,其含义相同。
⁶ 这并不总是正确的,您马上就会看到。
⁷ 非聚集索引设计并不总是能够提供单操作行查找。当一行发生变化时,它可能不再适合原始位置,因此您可能会在表中得到碎片化的行或“转发地址”,这两者都会导致更多的工作来查找行。
⁸ 值得指出的是,这是一个真实的表,具有辅助索引和许多列。如果我们移��这些并仅对主键性能进行基准测试,差异将更大。
⁹ 在Extra列中找到“Using index”可能会与type列中的“index”混淆。然而,它们完全不同。type列与覆盖索引无关;它显示查询的访问类型,或者查询将如何查找行。MySQL 手册将其称为连接类型。
¹⁰ 如果需要按不同方向排序,有时候一个小技巧是存储一个反转或取反的值。
¹¹ MySQL 将其称为“filesort”,但并非总是在文件系统上使用文件。只有在内存无法对数据进行排序时才会访问磁盘。
¹² 我们应该注意,虽然这可以使用索引进行排序,在我们的测试中,8.0.25 版本的优化器直到我们使用FORCE INDEX FOR ORDER BY条件才使用索引——这是另一个提醒,优化器可能不会按照你的期望执行操作,你应该始终使用EXPLAIN进行检查。
¹³ 如果是不同类型的索引,索引并不一定是重复的;通常有很好的理由同时拥有KEY(col)和FULLTEXT KEY(col)。
¹⁴ 我们在这里使用了一个内存示例。当表变得更大且工作负载变得 I/O 受限时,数字之间的差异将会更大。使用覆盖索引后,COUNT()查询通常会快上一百倍甚至更多。
¹⁵ 一些索引充当唯一约束,因此即使某个索引未用于查询,也可能用于防止重复值。
第八章:查询性能优化
在前几章中,我们解释了模式优化和索引,这对于高性能是必要的。但这还不够——您还需要设计良好的查询。如果您的查询不好,即使是设计最佳的模式和索引也不会表现良好。
查询优化、索引优化和模式优化是相辅相成的。随着在 MySQL 中编写查询的经验增加,您将学会如何设计表和索引以支持高效的查询。同样,您所学到的关于最佳模式设计将影响您编写的查询类型。这个过程需要时间,因此我们鼓励您在学习过程中参考这三章。
本章从一般查询设计考虑开始:当查询性能不佳时,您应该首先考虑的事项。然后我们深入研究查询优化和服务器内部。我们将向您展示如何找出 MySQL 如何执行特定查询,并学习如何更改查询执行计划。最后,我们将看看 MySQL 无法很好地优化查询的一些地方,并探索有助于 MySQL 更有效地执行查询的查询优化模式。
我们的目标是帮助您深入了解 MySQL 如何真正执行查询,以便您可以思考什么是高效或低效的,利用 MySQL 的优势,避免其弱点。
查询为什么慢?
在尝试编写快速查询之前,请记住这一切都关乎响应时间。查询是任务,但它们由子任务组成,这些子任务消耗时间。要优化查询,必须通过消除它们、减少发生次数或加快发生速度来优化其子任务。
一般来说,您可以通过在脑海中跟随查询的序列图,从客户端到服务器,解析、规划和执行,然后再返回客户端,来思考查询的生命周期。执行是查询生命周期中最重要的阶段之一。它涉及大量调用存储引擎以检索行,以及后检索操作,如分组和排序。
在完成所有这些任务的同时,查询在网络、CPU 和诸如统计、规划、锁定(互斥等待)以及尤其是调用存储引擎检索行等操作上花费时间。这些调用在内存操作、CPU 操作以及特别是 I/O 操作中消耗时间,如果数据不在内存中的话。根据存储引擎的不同,可能还涉及大量的上下文切换和/或系统调用。
在每种情况下,由于操作是不必要地执行、执行次数过多或速度太慢,可能会消耗过多时间。优化的目标是通过消除或减少操作或使其更快来避免这种情况。
再次强调,这并不是查询生命周期的完整或准确图景。我们在这里的目标是展示理解查询生命周期的重要性,并从时间消耗的角度思考。有了这个理念,让我们看看如何优化查询。
慢查询基础知识:优化数据访问
查询性能不佳的最基本原因是因为它处理了太多数据。有些查询必须筛选大量数据,这是无法避免的。不过,这种情况并不常见;大多数糟糕的查询可以更改以访问更少的数据。我们发现分析性能不佳的查询有两个步骤是有用的:
-
查找应用程序是否检索了比所需更多的数据。通常这意味着它访问了太多行,但也可能访问了太多列。
-
查找MySQL 服务器是否分析了比所需更多的行。
您是否请求了不需要的数据?
有些查询请求了比所需更多的数据,然后丢弃了其中一些。这会给 MySQL 服务器增加额外的工作量,增加网络开销,并在应用程序服务器上消耗内存和 CPU 资源。
以下是一些典型的错误:
检索比所需更多的行
一个常见的错误是假设 MySQL 按需提供结果,而不是计算并返回完整的结果集。我们经常在由熟悉其他数据库系统的人设计的应用程序中看到这种情况。这些开发人员习惯于发出返回许多行的SELECT语句,然后获取前N行并关闭结果集(例如,在新闻网站上获取最近的 100 篇文章,而他们只需要在首页显示其中的 10 篇)。他们认为 MySQL 会提供这 10 行并停止执行查询,但 MySQL 实际上会生成完整的结果集。客户端库然后获取所有数据并丢弃大部分数据。最佳解决方案是在查询中添加LIMIT子句。
从多表连接中检索所有列
如果你想检索出出现在电影Academy Dinosaur中的所有演员,不要这样写查询:
SELECT * FROM sakila.actor
INNER JOIN sakila.film_actor USING(actor_id)
INNER JOIN sakila.film USING(film_id)
WHERE sakila.film.title = 'Academy Dinosaur';
这会返回三个表中的所有列。相反,将查询写成如下形式:
SELECT sakila.actor.* FROM sakila.actor...;
检索所有列
当你看到SELECT *时,你应该持怀疑态度。你真的需要所有的列吗?可能不需要。检索所有列可能会阻止优化,如覆盖索引,并为服务器增加 I/O、内存和 CPU 开销。一些数据库管理员普遍不赞成SELECT *,因为这个事实以及为了减少当有人修改表的列列表时出现问题的风险。
当然,并不总是坏事要求比实际需要的数据更多。在我们调查的许多情况下,人们告诉我们这种浪费的方法简化了开发,因为它允许开发人员在多个地方使用相同的代码片段。只要你知道这在性能方面的代价,��是一个合理的考虑。如果你的应用程序中使用某种类型的缓存,或者你有其他目的,检索比实际需要的数据更多可能也是有用的。检索和缓存完整对象可能比运行许多单独的查询检索对象的部分更可取。
重复检索相同的数据
如果不小心,很容易编写应用程序代码,从数据库服务器中重复检索相同的数据,执行相同的查询以获取它。例如,如果你想找出用户的个人资料图片 URL 以显示在评论列表旁边,你可能会为每条评论重复请求这个信息。或者你可以在第一次获取后缓存它并在以后重复使用。后一种方法更有效。
MySQL 是否检查了太多数据?
一旦确定你的查询只检索你需要的数据,你可以寻找生成结果时检查了太多数据的查询。在 MySQL 中,最简单的查询成本指标是:
-
响应时间
-
检查的行数
-
返回的行数
这些指标都不是衡量查询成本的完美方式,但它们大致反映了 MySQL 执行查询时必须内部访问多少数据,并大致转化为查询运行的速度。这三个指标都记录在慢查询日志中,因此查看慢查询日志是发现检查了太多数据的查询的最佳方法之一。
响应时间
警惕只看查询响应时间。嘿,这不是我们一直告诉你的相反吗?其实不然。响应时间仍然很重要,但有点复杂。
响应时间是服务时间和队列时间的总和。服务时间是服务器实际处理查询所需的时间。队列时间是响应时间中服务器实际上并未执行查询的部分——它在等待某些事情,比如等待 I/O 操作完成、等待行锁等。问题在于,除非你可以单独测量这些组件,否则你无法将响应时间分解为这些组件。通常,你会遇到的最常见和重要的等待是 I/O 和锁等待,但你不应该只依赖这两种,因为情况变化很大。I/O 和锁等待之所以重要,是因为它们对性能的影响最大。
因此,在不同负载条件下,响应时间并不一致。其他因素——如存储引擎锁(如行锁)、高并发和硬件——也会对响应时间产生相当大的影响。响应时间也可能是问题的症状和原因,而且并不总是明显哪种情况。
当你查看查询的响应时间时,你应该问自己查询的响应时间是否合理。我们在本书中没有详细解释的空间,但你实际上可以使用 Tapio Lahdenmaki 和 Mike Leach 的书关系数据库索引设计和优化器(Wiley)中解释的技术计算查询响应时间的快速上限估计(QUBE)。简而言之:检查查询执行计划和涉及的索引,确定可能需要多少个顺序和随机 I/O 操作,并将这些乘以硬件执行它们所需的时间。把它们加起来,你就有一个判断查询是否比可能或应当更慢的标准。
检查的行数和返回的行数
在分析查询时,考虑检查的行数是有用的,因为你可以看到查询是否高效地找到你需要的数据。然而,这并不是一个找到“坏”查询的完美指标。并非所有行访问都是相等的。较短的行访问速度更快,从内存中获取行比从磁盘中读取行要快得多。
理想情况下,检查的行数应该与返回的行数相同,但实际上这很少可能。例如,在构建连接行时,服务器必须访问多个行以生成结果集中的每一行。检查的行数与返回的行数的比率通常很小——比如,1:1 到 10:1 之间,但有时可能相差几个数量级。
扫描的行数和访问类型
当你考虑查询的成本时,考虑在表中找到单个行的成本。MySQL 可以使用多种访问方法来查找和返回行。有些需要检查许多行,但其他可能能够在不检查任何行的情况下生成结果。
访问方法出现在EXPLAIN输出的type列中。访问类型从完整表扫描到索引扫描、范围扫描、唯一索引查找和常量。每种访问类型都比前一种更快,因为它需要读取的数据更少。你不需要记住访问类型,但你应该理解扫描表、扫描索引、范围访问和单值访问的一般概念。
如果你没有得到一个好的访问类型,通常解决问题的最佳方法是添加一个适当的索引。我们在前一章讨论了索引;现在你可以看到为什么索引对查询优化如此重要。索引让 MySQL 能够以更有效的访问类型找到行,从而减少数据的检查。
例如,让我们看一个在 Sakila 示例数据库上的简单查询:
SELECT * FROM sakila.film_actor WHERE film_id = 1;
这个查询将返回 10 行,EXPLAIN显示 MySQL 使用idx_fk_film_id索引上的ref访问类型来执行查询:
mysql> EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: ref
possible_keys: idx_fk_film_id
key: idx_fk_film_id
key_len: 2
ref: const
rows: 10
filtered: 100.00
Extra: NULL
EXPLAIN 显示 MySQL 估计只需要访问 10 行。换句话说,查询优化器知道所选的访问类型可以有效地满足查询。如果查询没有合适的索引会发生什么?如果我们删除索引并再次运行查询,MySQL 将不得不使用一个不太优化的访问类型,正如我们可以看到的:
mysql> ALTER TABLE sakila.film_actor DROP FOREIGN KEY fk_film_actor_film;
mysql> ALTER TABLE sakila.film_actor DROP KEY idx_fk_film_id;
mysql> EXPLAIN SELECT * FROM sakila.film_actor WHERE film_id = 1\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 5462
filtered: 10.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
可预测的是,访问类型已经变为全表扫描(ALL),MySQL 现在估计它将需要检查 5,462 行来满足查询。Extra列中的“Using where”显示 MySQL 服务器正在使用WHERE子句在存储引擎读取行后丢弃行。
一般来说,MySQL 可以以三种方式应用WHERE子句,从最好到最差:
-
将条件应用于索引查找操作,以消除不匹配的行。这发生在存储引擎层。
-
使用覆盖索引(
Extra列中的“Using index”)避免行访问,并在从索引检索每个结果后过滤掉不匹配的行。这发生在服务器层,但不需要从表中读取行。 -
从表中检索行,然后过滤不匹配的行(“在
Extra列中使用 where”)。这发生在服务器层,需要服务器在过滤行之前从表中读取行。
这个例子说明了拥有良好索引是多么重要。良好的索引帮助你的查询获得良好的访问类型,并仅检查它们需要的行。然而,添加索引并不总是意味着 MySQL 将访问和返回相同数量的行。例如,这里有一个使用COUNT()²聚合函数的查询:
mysql> SELECT actor_id, COUNT(*)
-> FROM sakila.film_actor GROUP BY actor_id;
+----------+----------+
| actor_id | COUNT(*) |
+----------+----------+
| 1 | 19 |
| 2 | 25 |
| 3 | 22 |
.. omitted..
| 200 | 20 |
+----------+----------+
200 rows in set (0.01 sec)
此查询仅返回 200 行,但它需要读取多少行?我们可以通过EXPLAIN来检查,就像我们在上一章中讨论的那样:
mysql> EXPLAIN SELECT actor_id, COUNT(*)
-> FROM sakila.film_actor GROUP BY actor_id\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: NULL
rows: 5462
filtered: 100.00
Extra: Using index
糟糕!读取数千行只需要 200 行意味着我们做了比必要更多的工作。对于这样的查询,索引无法减少检查的行数,因为没有WHERE子句来消除行。
不幸的是,MySQL 不会告诉你它访问的行中有多少被用来构建结果集;它只告诉你它访问的总行数。这些行中的许多行可能会被WHERE子句消除,并最终不会对结果集做出贡献。在前面的例子中,删除sakila.film_actor上的索引后,查询访问了表中的每一行,而WHERE子句丢弃了除了其中的 10 行之外的所有行。只有剩下的 10 行被用来构建结果集。理解服务器访问了多少行以及它实际使用了多少行需要对查询进行推理。
如果发现为了生成相对较少的结果而检查了大量行,你可以尝试一些更复杂的修复方法:
-
使用覆盖索引,它存储数据,使得存储引擎不必检索完整的行。(我们在第七章中讨论过这些。)
-
更改模式。一个例子是使用摘要表(在第六章中讨论)。
-
重写一个复杂的查询,以便 MySQL 优化器能够最佳地执行它。(我们将在本章后面讨论这个问题。)
重构查询的方法
在优化有问题的查询时,你的目标应该是找到获取你想要的结果的替代方法,但这并不一定意味着从 MySQL 中获得相同的结果集。有时候,你可以将查询转换为返回相同结果且性能更好的等价形式。然而,你也应该考虑重写查询以检索不同的结果,如果这样做能提高效率的话。最终,通过改变应用程序代码以及查询,你可能能够完成相同的工作。在本节中,我们将解释一些技术,帮助你重构各种查询,并告诉你何时使用每种技术。
复杂查询与多个查询
一个重要的查询设计问题是是否更倾向于将复杂查询分解为几个简单查询。传统的数据库设计方法强调尽可能用尽可能少的查询来完成尽可能多的工作。这种方法在历史上更好,因为网络通信的成本和查询解析和优化阶段的开销。
然而,这个建议在 MySQL 上不太适用,因为它被设计为非常高效地处理连接和断开连接,并且对小型、简单的查询作出快速响应。现代网络也比以前快得多,减少了网络延迟。根据服务器版本,MySQL 可以在商品服务器硬件上每秒运行超过十万个简单查询,并且在千兆网络上从单个对应方每秒运行超过两千个 QPS,因此运行多个查询并不一定是一件坏事。
与 MySQL 每秒内部遍历的行数相比,连接响应仍然很慢,尽管对于内存数据,每秒可以计数为百万级。其他条件相同的情况下,尽可能使用较少的查询仍然是个好主意,但有时你可以通过分解查询并执行几个简单的查询而不是一个复杂的查询来使查询更有效。不要害怕这样做;权衡成本,选择导致工作量较少的策略。我们稍后在本章中展示了一些这种技术的例子。
使用过多查询是应用设计中的常见错误。例如,一些应用程序执行 10 个单行查询以从表中检索数据,而实际上它们可以使用一个包含 10 行的查询。我们甚至看到一些应用程序会逐个检索每一列,多次查询每一行!
分解查询
另一种分解查询的方法是分而治之,保持基本相同但在影响更少行的情况下以较小的“块”运行它。
清理旧数据是一个很好的例子。定期清理作业可能需要删除大量数据,如果在一个巨大的查询中执行此操作可能会锁定很多行很长时间,填满事务日志,占用资源,并阻塞不应被中断的小查询。分解DELETE语句并使用中等大小的查询可以显著提高性能,并在查询被复制时减少复制延迟。例如,与其运行这个庞大的查询:
DELETE FROM messages
WHERE created < DATE_SUB(NOW(),INTERVAL 3 MONTH);
您可以做类似以下伪代码:
rows_affected = 0
do {
rows_affected = do_query(
"DELETE FROM messages WHERE created < DATE_SUB(NOW(),INTERVAL 3 MONTH)
LIMIT 10000")
} while rows_affected > 0
每次删除 10,000 行通常是一个足够大的任务,使每个查询都有效,并且足够短的任务以最小化对服务器的影响³(事务存储引擎可能受益于更小的事务)。在DELETE语句之间添加一些休眠时间以分散负载并减少锁定时间也可能是个好主意。
连接分解
许多高性能应用程序使用连接分解。您可以通过运行多个单表查询而不是多表连接来分解连接,然后在应用程序中执行连接。例如,与其这样的单个查询:
SELECT * FROM tag
JOIN tag_post ON tag_post.tag_id=tag.id
JOIN post ON tag_post.post_id=post.id
WHERE tag.tag='mysql';
您可能会运行这些查询:
SELECT * FROM tag WHERE tag='mysql';
SELECT * FROM tag_post WHERE tag_id=1234;
SELECT * FROM post WHERE post.id in (123,456,567,9098,8904);
为什么要这样做?乍一看,这看起来是浪费的,因为您增加了查询的数量,却没有得到任何回报。然而,这种重组实际上可以带来显著的性能优势:
-
缓存可能更有效。许多应用程序会缓存直接映射到表的“对象”。在这个例子中,如果带有标签
mysql的对象已经被缓存,应用程序可以跳过第一个查询。如果在缓存中找到 ID 为 123、567 或 9098 的帖子,可以将它们从IN()列表中移除。 -
有时逐个执行查询可以减少锁争用。
-
在应用程序中进行连接使得通过将表放置在不同的服务器上更容易扩展数据库。
-
查询本身可能更有效率。在这个例子中,使用
IN()列表而不是连接让 MySQL 对行 ID 进行排序,并更优化地检索行,这可能比使用连接更有效。 -
您可以减少冗余的行访问。在应用程序中进行连接意味着您只检索每行一次,而在查询中进行连接本质上是一种反规范化,可能会重复访问相同的数据。出于同样的原因,这种重组也可能减少总网络流量和内存使用。
因此,在应用程序中进行连接时,如果您从先前的查询中缓存和重复使用大量数据,将数据分布在多个服务器上,将连接替换为IN()列表在大表上,或者连接多次引用同一表时,可能会更有效率。
查询执行基础知识
如果您需要从 MySQL 服务器获得高性能,最好的投资之一是学习 MySQL 如何优化和执行查询。一旦您理解了这一点,大部分查询优化都是根据原则推理,查询优化变得非常逻辑。
让我们重新审视我们之前讨论的内容:MySQL 执行查询的过程。图 8-1 展示了当您向 MySQL 发送查询时会发生什么:
-
客户端将 SQL 语句发送到服务器。
-
服务器将其解析、预处理并优化为查询执行计划。
-
查询执行引擎通过调用存储引擎 API 执行计划。
-
服务器将结果发送给客户端。

图 8-1. 查询的执行路径
每个步骤都有一些额外的复杂性,我们将在接下来的章节��讨论。我们还会解释在每个步骤中查询将处于哪些状态。查询优化过程特别复杂且重要。还有一些例外或特殊情况,比如在使用准备语句时执行路径的差异;我们将在下一章中讨论。
MySQL 客户端/服务器协议
尽管您不需要了解 MySQL 客户端/服务器协议的内部细节,但您需要在高层次上了解它是如何工作的。该协议是半双工的,这意味着在任何给定时间 MySQL 服务器可以发送或接收消息但不能同时进行两者。这也意味着没有办法截断消息。
这种协议使得 MySQL 通信简单快速,但也在某些方面限制了它。首先,这意味着没有流量控制;一旦一方发送消息,另一方必须在回复之前获取整个消息。这就像一个来回传球的游戏:任何时候只有一方拿着球,除非你拿到球,否则你无法传球(发送消息)。
客户端将查询作为单个数据包发送到服务器。这就是为什么如果您有大型查询,max_allowed_packet 配置变量很重要。⁴ 一旦客户端发送查询,它就不再控制局面;它只能等待结果。
相比之下,服务器的响应通常由许多数据包组成。当服务器响应时,客户端必须接收整个结果集。它不能简单地获取一些行然后要求服务器不再发送其余的行。如果客户端只需要返回的前几行,它要么等待所有服务器的数据包到达然后丢弃它不需要的部分,要么不正常地断开连接。这两种方式都不是好主意,这就是为什么适当使用LIMIT子句如此重要。
这里有另一种思考方式:当客户端从服务器获取行时,它认为自己是在拉它们。但事实是,MySQL 服务器在生成行时是在推行。客户端只接收被推送的行;它无法告诉服务器停止发送行。客户端就像在“从消防水龙头中喝水”,可以这么说。(是的,这是一个技术术语。)
大多数连接到 MySQL 的库都可以让您获取整个结果集并将其缓冲在内存中,或者在需要时获取每一行。默认行为通常是获取整个结果并将其缓冲在内存中。这很重要,因为在获取所有行之前,MySQL 服务器不会释放查询所需的锁和其他资源。查询将处于“发送数据”状态。当客户端库一次性获取所有结果时,它减少了服务器需要做的工作量:服务器可以尽快完成并清理查询。
大多数客户端库让您将结果集视为从服务器获取,尽管实际上您只是从库内存中的缓冲区获取。这在大多数情况下运行良好,但对于可能需要很长时间才能获取并使用大量内存的大型结果集,这不是一个好主意。如果指示库不缓冲结果,您可以使用更少的内存并更早开始处理结果。缺点是,当您的应用程序与库交互时,服务器上的锁和其他资源将保持打开状态。⁵
让我们看一个使用 PHP 的示例。这是您通常从 PHP 查询 MySQL 的方式:
<?php
$link = mysql_connect('localhost', 'user', 'p4ssword');
$result = mysql_query('SELECT * FROM HUGE_TABLE', $link);
while ( $row = mysql_fetch_array($result) ) {
// Do something with result
}
?>
代码似乎表明您只在需要时在while循环中获取行。然而,代码实际上通过mysql_query()函数调用将整个结果获取到缓冲区中。while循环只是遍历缓冲区。相比之下,以下代码不会缓冲结果,因为它使用mysql_unbuffered_query()而不是mysql_query():
<?php
$link = mysql_connect('localhost', 'user', 'p4ssword');
$result = mysql_unbuffered_query('SELECT * FROM HUGE_TABLE', $link);
while ( $row = mysql_fetch_array($result) ) {
// Do something with result
}
?>
编程语言有不同的方法来覆盖缓冲。例如,Perl 的DBD::mysql驱动程序要求您指定 C 客户端库的mysql_use_result属性(默认为mysql_buffer_result)。这是一个示例:
#!/usr/bin/perl
use DBI;
my $dbh = DBI->connect('DBI:mysql:;host=localhost', 'user', 'p4ssword');
my $sth = $dbh->prepare('SELECT * FROM HUGE_TABLE', { mysql_use_result => 1 });
$sth->execute();
while ( my $row = $sth->fetchrow_array() ) {
# Do something with result
}
注意,调用prepare()指定“使用”结果而不是“缓冲”结果。您也可以在连接时指定这一点,这将使每个语句都是非缓冲的:
my $dbh = DBI->connect('DBI:mysql:;mysql_use_result=1', 'user', 'p4ssword');
查询状态
每个 MySQL 连接,或线程,都有一个状态,显示其在任何给定时间正在做什么。有几种查看这些状态的方法,但最简单的方法是使用SHOW FULL PROCESSLIST命令(状态显示在Command列中)。随着查询在其生命周期中的进展,其状态会多次更改,有数十种状态。MySQL 手册是所有状态信息的权威来源,但我们在这里列出了一些并解释了它们的含义:
休眠
线程正在等待来自客户端的新查询。
查询
线程正在执行查询或将结果发送回客户端。
锁定
线程正在等待服务器级别授予表锁。由存储引擎实现的锁,例如 InnoDB 的行锁,不会导致线程进入Locked状态。
分析和统计
线程正在检查存储引擎统计信息并优化查询。
复制到临时表[在磁盘上]
线程正在处理查询并将结果复制到临时表,可能是为了GROUP BY,进行文件排序,或满足UNION。如果状态以“on disk”结尾,MySQL 正在将内存表转换为磁盘表。
排序结果
线程正在对结果集进行排序。
至少了解基本状态是有帮助的,这样您就可以了解查询的“谁在掌握主动权”。在非��繁忙的服务器上,您可能会看到通常很短暂的状态,例如statistics,开始占用大量时间。这通常表示出现了问题。
查询优化过程
查询生命周期中的下一步将 SQL 查询转换为查询执行引擎的执行计划。这包括几个子步骤:解析、预处理和优化。错误(例如,语法错误)可能在过程的任何时候引发。我们并不打算在这里记录 MySQL 的内部情况,因此我们将采取一些自由,例如即使它们通常为了效率而完全或部分地合并,我们也会单独描述步骤。我们的目标只是帮助您了解 MySQL 如何执行查询,以便您可以编写更好的查询。
解析器和预处理器
首先,MySQL 的解析器将查询分解为标记,并从中构建“解析树”。解析器使用 MySQL 的 SQL 语法来解释和验证查询。例如,它确保查询中的标记有效且顺序正确,并检查是否存在未终止的引号字符串等错误。
预处理器然后检查解析树的结果,以解决解析器无法解决的附加语义。例如,它检查表和列是否存在,并解析名称和别名以确保列引用不会产生歧义。
接下来,预处理器检查权限。除非您的服务器具有大量权限,否则这通常非常快。
查询优化器
解析树现在有效并准备好供优化器将其转换为查询执行计划。一个查询通常可以以许多不同的方式执行并产生相同的结果。优化器的工作是找到最佳选项。
MySQL 使用基于成本的优化器,这意味着它试图预测各种执行计划的成本并选择最便宜的。成本单位最初是一个单个随机的 4 KB 数据页读取,但现在已变得更加复杂,现在包括诸如执行WHERE子句比较的估计成本等因素。您可以通过运行查询,然后检查Last_query_cost会话变量来查看优化器估计查询的成本有多昂贵:
mysql> SELECT SQL_NO_CACHE COUNT(*) FROM sakila.film_actor;
+----------+
| count(*) |
+----------+
| 5462 |
+----------+
mysql> SHOW STATUS LIKE 'Last_query_cost';
+-----------------+-------------+
| Variable_name | Value |
+-----------------+-------------+
| Last_query_cost | 1040.599000 |
+-----------------+-------------+
这个结果意味着优化器估计执行查询需要大约 1040 个随机数据页读取。它基于统计数据:每个表或索引的页数,索引的基数(不同值的数量),行和键的长度,以及键的分布。优化器在其估计中不包括任何类型缓存的影响;它假设每次读取都会导致磁盘 I/O 操作。
优化器可能并不总是选择最佳计划,原因有很多:
-
统计数据可能不准确。服务器依赖存储引擎提供统计信息,它们的准确性可能从完全正确到极不准确。例如,InnoDB 存储引擎由于其 MVCC 架构不维护关于表中行数的准确统计信息。
-
成本度量标准并不完全等同于运行查询的真实成本,因此即使统计数据准确,查询的成本可能比 MySQL 的近似值更昂贵或更便宜。在某些情况下,读取更多页的计划实际上可能更便宜,例如当读取是顺序的时,磁盘 I/O 更快,或者当页已缓存在内存中时。MySQL 也不了解哪些页在内存中,哪些页在磁盘上,因此它实际上不知道查询会导致多少 I/O。
-
MySQL 的“最佳”概念可能与您的不同。您可能希望获得最快的执行时间,但 MySQL 实际上并不试图使查询快速;它试图最小化它们的成本,正如我们所见,确定成本并不是一门确切的科学。
-
MySQL 不考虑同时运行的其他查询,这可能会影响查询运行的速度。
-
MySQL 并不总是进行基于成本的优化。有时它只是遵循规则,例如“如果有一个全文
MATCH()子句,如果存在FULLTEXT索引,则使用它”。即使使用不同的索引和带有WHERE子句的非FULLTEXT查询更快,它也会这样做。 -
优化器不考虑不受其控制的操作的成本,例如执行存储函数或用户定义的函数。
-
正如我们将在后面看到的,优化器并不总是能够估计每种可能的执行计划,因此可能会错过最佳计划。
MySQL 的查询优化器是一个非常复杂的软件部分,它使用许多优化来将查询转换为执行计划。有两种基本类型的优化,我们称之为静态和动态。静态优化 可以通过检查解析树简单地执行。例如,优化器可以通过应用代数规则将 WHERE 子句转换为等效形式。静态优化与值无关,例如 WHERE 子句中常量的值。它们可以执行一次,并且在使用不同值重新执行查询时始终有效。您可以将其视为“编译时优化”。
相比之下,动态优化 基于上下文,并且可能取决于许多因素,例如 WHERE 子句中的值或索引中的行数。它们必须在每次执行查询时重新评估。您可以将其视为“运行时优化”。
在执行预处理语句或存储过程时,区别很重要。MySQL 可以进行静态优化一次,但必须每次执行查询时重新评估动态优化。有时,MySQL 甚至在执行过程中重新优化查询。⁶
以下是 MySQL 知道如何执行的一些优化类型:
重新排序连接
表不一定要按照查询中指定的顺序连接。确定最佳连接顺序是一项重要的优化;我们稍后在本章中深入解释。
将 OUTER JOIN 转换为 INNER JOIN
OUTER JOIN 不一定要作为 OUTER JOIN 执行。某些因素,例如 WHERE 子句和表模式,实际上可能导致 OUTER JOIN 等效于 INNER JOIN。MySQL 可以识别这一点并重写连接,从而使其有资格进行重新排序。
应用代数等价规则
MySQL 应用代数变换来简化和规范化表达式。它还可以折叠和减少常量,消除不可能的约束和常量条件。例如,术语 (5=5 AND a>5) 将简化为 a>5。类似地,(a<b AND b=c) AND a=5 变为 b>5 AND b=c AND a=5。这些规则对于编写条件查询非常有用,我们稍后在本章中讨论。
COUNT()、MIN() 和 MAX() 优化
索引和列的可空性通常可以帮助 MySQL 优化这些表达式。例如,要找到 B 树索引中最左边的列的最小值,MySQL 可以只请求索引中的第一行。它甚至可以在查询优化阶段执行此操作,并将该值视为常量用于查询的其余部分。类似地,要找到 B 树索引中的最大值,服务器会读取最后一行。如果服务器使用此优化,您将在 EXPLAIN 计划中看到“选择表已优化” 。这实际上意味着优化器已将表从查询计划中移除,并用常量替换。
评估和简化常量表达式
当 MySQL 检测到表达式可以简化为常量时,它会在优化过程中这样做。例如,如果用户定义的变量在查询中没有更改,它可以转换为常量。算术表达式是另一个例子。
也许令人惊讶的是,即使你可能认为是一个查询的东西也可以在优化阶段被减少为一个常量。一个例子是对索引的MIN()。这甚至可以扩展到对主键或唯一索引的常量查找。如果WHERE子句对这样的索引应用一个常量条件,优化器知道 MySQL 可以在查询开始时查找值。然后它将在查询的其余部分将该值视为常量。这里有一个例子:
mysql> EXPLAIN SELECT film.film_id, film_actor.actor_id
-> FROM sakila.film
-> INNER JOIN sakila.film_actor USING(film_id)
-> WHERE film.film_id = 1\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: const
rows: 1
filtered: 100.00
Extra: Using index
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: index
possible_keys: NULL
key: PRIMARY
key_len: 4
ref: NULL
rows: 5462
filtered: 10.00
Extra: Using where; Using index
MySQL 以两个步骤执行此查询,对应于输出中的两行。第一步是在film表中找到所需的行。MySQL 的优化器知道只有一行,因为film_id列上有一个主键,并且在查询优化阶段已经查询了索引以查看将找到多少行。因为查询优化器有一个已知数量(WHERE子句中的值)用于查找,所以这个表的ref类型是const。
在第二步中,MySQL 将第一步找到的film_id列视为已知数量。它可以这样做,因为优化器知道当查询到达第二步时,它将知道第一步的所有值。请注意,film_actor表的ref类型是const,就像film表的一样。
另一种你会看到常量条件应用的方式是通过从一个地方传播值的常量性到另一个地方,如果有一个WHERE、USING或ON子句将值限制为相等。在这个例子中,优化器知道USING子句强制film_id在查询中的任何地方具有相同的值;它必须等于WHERE子句中给定的常量值。
覆盖索引
当索引包含查询所需的所有列时,MySQL 有时可以使用索引来避免读取行数据。我们在上一章节中详细讨论了覆盖索引。
子查询优化
MySQL 可以将某些类型的子查询转换为更高效的替代形式,将它们减少为索引查找而不是单独的查询。
早期终止
MySQL 可以在满足查询或步骤时立即停止处理查询(或查询中的步骤)。明显的情况是LIMIT子句,但还有几种其他类型的早期终止。例如,如果 MySQL 检测到一个不可能的条件,它可以中止整个查询。你可以在以下示例中看到这一点:
mysql> EXPLAIN SELECT film.film_id FROM sakila.film WHERE film_id = −1;
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: NULL
partitions: NULL
type: NULL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: NULL
filtered: NULL
Extra: Impossible WHERE
这个查询在优化步骤中停止了,但 MySQL 也可以在其他一些情况下提前终止执行。当查询执行引擎识别到需要检索不同值或在值不存在时停止时,服务器可以使用这种优化。例如,以下查询找到所有没有任何演员的电影:⁷
SELECT film.film_id
FROM sakila.film
LEFT OUTER JOIN sakila.film_actor USING(film_id)
WHERE film_actor.film_id IS NULL;
这个查询通过消除任何有演员的电影来工作。每部电影可能有很多演员,但一旦找到一个演员,它就会停止处理当前电影并移动到下一个,因为它知道WHERE子句禁止输出该电影。类似的“Distinct/not-exists”优化可以应用于某些类型的DISTINCT、NOT EXISTS()和LEFT JOIN查询。
等式传播
MySQL 识别查询将两列视为相等时,例如在JOIN条件中,并在等效列之间传播WHERE子句。例如,在以下查询中:
SELECT film.film_id
FROM sakila.film
INNER JOIN sakila.film_actor USING(film_id)
WHERE film.film_id > 500;
MySQL 知道WHERE子句不仅适用于film表,也适用于film_actor表,因为USING子句强制两列匹配。
如果你习惯于另一个不能做到这一点的数据库服务器,你可能会被建议通过手动指定两个表的WHERE子句来“帮助优化器”,就像这样:
... WHERE film.film_id > 500 AND film_actor.film_id > 500
这在 MySQL 中是不必要的。它只会使你的查询更难维护。
IN()列表比较
在许多数据库服务器中,IN()只是多个OR子句的同义词,因为两者在逻辑上是等价的。但在 MySQL 中不是这样,它对IN()列表中的值进行排序,并使用快速二进制搜索来查看值是否在列表中。这在列表大小为 O(log n)时,而等效的OR子句系列在列表大小为 O(n)时(即对于大型列表来说要慢得多)。
前面的列表非常不完整,因为 MySQL 执行的优化比我们在整个章节中能够涵盖的要多,但它应该让您了解优化器的复杂性和智能。如果有一件事情您应该从这个讨论中记住,那就是不要试图预先聪明地超越优化器。您可能最终只是击败它或使查询变得更加复杂且难以维护,而没有任何好处。一般来说,您应该让优化器自行处理。
��然,优化器再聪明,有时候也不会给出最佳结果。有时候您可能了解一些优化器不知道的数据,比如由于应用逻辑保证为真的事实。此外,有时候优化器没有必要的功能,比如哈希索引;在其他时候,正如前面提到的,其成本估算可能更喜欢一个比另一个更昂贵的查询计划。
如果您知道优化器没有给出好的结果并且知道原因,您可以帮助它。一些选项包括向查询添加提示,重写查询,重新设计模式或添加索引。
表和索引统计信息
回想一下 MySQL 服务器架构中的各个层次,我们在图 1-1 中进行了说明。服务器层包含查询优化器,不存储数据和索引的统计信息。这是存储引擎的工作,因为每个存储引擎可能保留不同类型的统计信息(或以不同方式保留)。
因为服务器不存储统计信息,MySQL 查询优化器必须向引擎请求查询中表的统计信息。引擎提供优化器统计信息,例如每个表或索引的页数,表和索引的基数,行和键的长度,以及键分布信息。优化器可以使用这些信息来帮助它决定最佳执行计划。我们将在后面的章节中看到这些统计信息如何影响优化器的选择。
MySQL 的连接执行策略
MySQL 比您可能习惯的更广泛地使用连接这个术语。总之,它认为每个查询都是一个连接——不仅仅是从两个表中匹配行的每个查询,而是每个查询,无论是子查询还是甚至针对单个表的SELECT。因此,了解 MySQL 如何执行连接非常重要。
考虑一个UNION查询的示例。MySQL 将UNION执行为一系列单个查询,其结果被拼接到临时表中,然后再次读取出来。每个单独的查询在 MySQL 术语中都是一个连接,从结果临时表中读取也是如此。
MySQL 的连接执行策略曾经很简单:它将每个连接都视为嵌套循环连接。这意味着 MySQL 运行一个循环来查找表中的一行,然后运行一个嵌套循环来查找下一个表中的匹配行。直到在连接中的每个表中找到匹配行为止。然后根据SELECT列表中的列构建并返回一行。它尝试通过在最后一个表中查找更多匹配行来构建下一行。如果找不到任何匹配行,则回溯一个表并在那里查找更多行。它一直回溯,直到在某个表中找到另一行,然后在下一个表中查找匹配行,依此类推。
从版本 8.0.20 开始,不再使用块嵌套循环连接;取而代之的是 哈希连接。这使得连接过程的执行速度与以前一样快,甚至更快,尤其是如果其中一个数据集可以存储在内存中。
执行计划
MySQL 不会生成字节码来执行查询,就像许多其他数据库产品那样。相反,查询执行计划实际上是一个指令树¹⁰,查询执行引擎遵循该树以生成查询结果。最终计划包含足够的信息来重建原始查询。如果在查询上执行 EXPLAIN EXTENDED,然后是 SHOW WARNINGS,你将看到重建的查询。¹¹
任何多表查询在概念上都可以表示为一棵树。例如,可能可以像 图 8-2 所示的那样执行一个四表连接。

图 8-2. 多表连接的一种方式
这就是计算机科学家所说的平衡树。然而,这不是 MySQL 执行查询的方式。正如我们在前一节中描述的那样,MySQL 总是从一个表开始,并在下一个表中查找匹配的行。因此,MySQL 的查询执行计划总是采用左深树的形式,如 图 8-3 所示。

图 8-3. MySQL 如何连接多个表
连接优化器
MySQL 查询优化器中最重要的部分是连接优化器,它决定了多表查询的最佳执行顺序。通常可以以几种不同的顺序连接表并获得相同的结果。连接优化器估计各种计划的成本,并尝试选择成本最低的计划,以获得相同的结果。
这是一个查询,其表可以以不同的顺序连接而不改变结果:
SELECT film.film_id, film.title, film.release_year, actor.actor_id,
actor.first_name, actor.last_name
FROM sakila.film
INNER JOIN sakila.film_actor USING(film_id)
INNER JOIN sakila.actor USING(actor_id);
你可能会想到几种不同的查询计划。例如,MySQL 可以从 film 表开始,使用 film_actor 表中的 film_id 索引找到 actor_id 值,然后查找 actor 表的主键行。Oracle 用户可能会将其表述为“film 表是 film_actor 表的驱动表,film_actor 表是 actor 表的驱动表。” 这应该是有效的,对吧?现在让我们使用 EXPLAIN 看看 MySQL 想要如何执行查询:
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: actor
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 200
filtered: 100.00
Extra: NULL
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_fk_film_id
key: PRIMARY
key_len: 2
ref: sakila.actor.actor_id
rows: 27
filtered: 100.00
Extra: Using index
*************************** 3\. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: sakila.film_actor.film_id
rows: 1
filtered: 100.00
Extra: NULL
这与前一段中建议的计划完全不同。MySQL 希望从 actor 表开始(我们知道这是因为它在 EXPLAIN 输出中首先列出),并按相反的顺序进行。这真的更有效吗?让我们看看。STRAIGHT_JOIN 关键字强制连接按查询中指定的顺序进行。这是修改后查询的 EXPLAIN 输出:
mysql> EXPLAIN SELECT STRAIGHT_JOIN film.film_id...\G
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: film
partitions: NULL
type: ALL
possible_keys: PRIMARY
key: NULL
key_len: NULL
ref: NULL
rows: 1000
filtered: 100.00
Extra: NULL
*************************** 2\. row ***************************
id: 1
select_type: SIMPLE
table: film_actor
partitions: NULL
type: ref
possible_keys: PRIMARY,idx_fk_film_id
key: idx_fk_film_id
key_len: 2
ref: sakila.film.film_id
rows: 5
filtered: 100.00
Extra: Using index
*************************** 3\. row ***************************
id: 1
select_type: SIMPLE
table: actor
partitions: NULL
type: eq_ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 2
ref: sakila.film_actor.actor_id
rows: 1
filtered: 100.00
Extra: NULL
这说明了为什么 MySQL 希望反转连接顺序:这样做将使其能够检查第一个表中的行数更少。¹² 在这两种情况下,它将能够在第二个和第三个表中执行快速的索引查找。不同之处在于它将不得不执行多少这样的索引查找。将 film 放在第一位将需要大约一千次探测(参见 rows 字段)到 film_actor 和 actor,即第一个表中的每一行都需要一次。如果服务器首先扫描 actor 表,它只需要对后续表进行两百次索引查找。换句话说,反转的连接顺序将需要更少的回溯和重读。
这是 MySQL 的连接优化器可以重新排列查询以使其执行成本更低的简单示例。重新排序连接通常是一种非常有效的优化。然而,有时不会得到最佳计划,对于这些情况,你可以使用 STRAIGHT_JOIN 并按照你认为最佳的顺序编写查询,但这样的情况很少见。在大多数情况下,连接优化器将胜过人类。
连接优化器试图生成具有最低成本的查询执行计划树。在可能的情况下,它检查所有子树的潜在组合,从所有单表计划开始。
不幸的是,对n个表进行连接将有n阶乘的连接顺序组合要检查。这被称为所有可能查询计划的搜索空间,并且增长非常快:一个包含 10 个表的连接可以以 3,628,800 种不同的方式执行!当搜索空间增长过大时,优化查询可能需要花费太长时间,因此服务器停止进行完整分析。相反,它会采用“贪婪”搜索等快捷方式,当表的数量超过optimizer_search_depth变量指定的限制时(如果需要,您可以更改该变量)。
MySQL 拥有许多启发式方法,通过多年的研究和实验积累而来,用于加速优化阶段。这可能是有益的,但也可能意味着 MySQL 可能(在极少数情况下)错过一个最佳计划并选择一个不太优化的计划,因为它试图避免检查每个可能的查询计划。
有时查询无法重新排序,连接优化器可以利用这一事实通过消除选择来减少搜索空间。LEFT JOIN是一个很好的例子,相关子查询也是(稍后会详细介绍子查询)。这是因为一个表的结果取决于从另一个表检索的数据。这些依赖关系帮助连接优化器通过消除选择来减少搜索空间。
排序优化
对结果进行排序可能是一个昂贵的操作,因此您通常可以通过避免排序或在较少行上执行排序来提高性能。
当 MySQL 无法使用索引生成排序结果时,它必须自行对行进行排序。它可以在内存中或磁盘上执行此操作,但无论如何,它总是将此过程称为文件排序,即使实际上并未使用文件。
如果要排序的值将适合排序缓冲区,MySQL 可以完全在内存中执行排序,使用快速排序。如果 MySQL 无法在内存中执行排序,则通过对值进行分块排序在磁盘上执行排序。它使用快速排序对每个块进行排序,然后将排序的块合并到结果中。
有两种文件排序算法:
两次遍历(旧)
读取行指针和ORDER BY列,对它们进行排序,然后扫描排序列表并重新读取行以输出。
两次遍历算法可能非常昂贵,因为它从表中读取两次行,第二次读取会导致大量随机 I/O。
单次遍历(新)
读取查询所需的所有列,按ORDER BY列对它们进行排序,然后扫描排序列表并输出指定的列。
它可能更有效率,特别是对于大型 I/O 受限数据集,因为它避免了从表中两次读取行,并将随机 I/O 交换为更多的顺序 I/O。然而,它有可能使用更多的空间,因为它保存每行的所有所需列,而不仅仅是用于排序行的列。这意味着更少的元组将适合排序缓冲区,文件排序将不得不执行更多的排序合并传递。
MySQL 可能为文件排序使用比您预期的更多的临时存储空间,因为它为将要排序的每个元组分配了固定大小的记录。这些记录足够大,可以容纳最大可能的元组,包括每个VARCHAR列的完整长度。此外,如果您使用 utf8mb4,MySQL 为每个字符分配 4 个字节。因此,我们曾看到过,优化不良的模式导致用于排序的临时空间比磁盘上整个表的大小大几倍。
当对连接进行排序时,MySQL 可能在查询执行过程中的两个阶段执行文件排序。如果ORDER BY子句仅涉及连接顺序中第一个表的列,MySQL 可以对该表进行文件排序,然后继续连接。如果发生这种情况,EXPLAIN在Extra列中显示“Using filesort”。在所有其他情况下,例如对连接顺序中不是第一个表的表进行排序,或者ORDER BY子句包含多个表的列时,MySQL 必须将查询结果存储到临时表中,然后在连接完成后对临时表进行文件排序。在这种情况下,EXPLAIN在Extra列中显示“Using temporary; Using filesort”。如果有LIMIT,它将在文件排序之后应用,因此临时表和文件排序可能非常大。
查询执行引擎
解析和优化阶段输出查询执行计划,MySQL 的查询执行引擎使用该计划来处理查询。该计划是一个数据结构;它不是可执行的字节码,这是许多其他数据库执行查询的方式。
与优化阶段相比,执行阶段通常并不那么复杂:MySQL 只需按照查询执行计划中给出的指令进行操作。计划中的许多操作调用存储引擎接口实现的方法,也称为处理程序 API。查询中的每个表都由处理程序的实例表示。例如,如果查询中的表出现三次,服务器将创建三个处理程序实例。尽管我们之前略过了这一点,但 MySQL 实际上在优化阶段早期创建处理程序实例。优化器使用它们获取有关表的信息,例如它们的列名和索引统计信息。
存储引擎接口具有许多功能,但只需要十几个“构建块”操作来执行大多数查询。例如,有一个操作用于读取索引中的第一行,另一个操作用于读取索引中的下一行。这对于执行��引扫描的查询已经足够了。这种简单的执行方法使得 MySQL 的存储引擎架构成为可能,但也带来了我们讨论过的优化器限制之一。
注意
并非所有操作都是处理程序操作。例如,服务器管理表锁。处理程序可能实现自己的较低级别锁定,就像 InnoDB 使用行级锁一样,但这并不取代服务器自己的锁定实现。如第一章中所述,所有存储引擎共享的内容都在服务器中实现,例如日期和时间函数、视图和触发器。
为执行查询,服务器只需重复指令,直到没有更多行可检查为止。
向客户端返回结果
执行查询的最后一步是向客户端发送响应。即使查询不返回结果集,也会向客户端连接发送有关查询的信息,例如它影响了多少行。
服务器逐步生成并发送结果。一旦 MySQL 处理完最后一个表并成功生成一行,它就可以并且应该将该行发送给客户端。这有两个好处:它让服务器避免在内存中保存行,而且意味着客户端尽快开始获取结果。¹³结果集中的每一行在 MySQL 客户端/服务器协议中以单独的数据包发送,尽管协议数据包可以在 TCP 协议层缓冲并一起发送。
MySQL 查询优化器的限制
MySQL 对查询执行的方法并非对于优化每种类型的查询都是理想的。幸运的是,MySQL 查询优化器做得不好的情况有限,通常可以更有效地重写这些查询。
UNION 的限制
MySQL 有时无法将UNION外部的条件“推入”到内部,这些条件可以用于限制结果或启用其他优化。
如果你认为UNION中的任何一个单独查询会受益于LIMIT,或者如果你知道它们将与其他查询组合后受到ORDER BY子句的影响,那么你需要将这些子句放在每个UNION部分中。例如,如果你将两个表UNION在一起,并将结果限制为前 20 行,MySQL 将把两个表存储到临时表中,然后从中检索出 20 行:
(SELECT first_name, last_name
FROM sakila.actor
ORDER BY last_name)
UNION ALL
(SELECT first_name, last_name
FROM sakila.customer
ORDER BY last_name)
LIMIT 20;
此查询将从actor表中存储 200 行,从customer表中存储 599 行到临时表中,然后从该临时表中获取前 20 行。您可以通过在UNION中的每个查询中多余地添加LIMIT 20来避免这种情况:
(SELECT first_name, last_name
FROM sakila.actor
ORDER BY last_name
LIMIT 20)
UNION ALL
(SELECT first_name, last_name
FROM sakila.customer
ORDER BY last_name
LIMIT 20)
LIMIT 20;
现在临时表将只包含 40 行。除了性能提升外,您可能需要更正查询:从临时表中检索行的顺序是未定义的,因此在最终LIMIT之前应该有一个整体ORDER BY。
等式传播
等式传播有时可能会产生意想不到的成本。例如,考虑一个巨大的IN()列表,优化器知道它将等于其他表的某些列,这是由于WHERE,ON或USING子句将列设置为相等。
优化器将通过将列表复制到所有相关表中的相应列来“共享”列表。这通常是有帮助的,因为它为查询优化器和执行引擎提供了更多实际执行IN()检查的选项。但是当列表非常大时,它可能导致优化和执行变慢。在撰写本文时,还没有这个问题的内置解决方法 - 如果这对您是个问题,您将不得不更改源代码。 (对大多数人来说这不是问题。)
并行执行
MySQL 无法在多个 CPU 上并行执行单个查询。这是一些其他数据库服务器提供的功能,但 MySQL 不支持。我们提到这一点是为了让您不要花费大量时间来尝试如何在 MySQL 上实现并行查询执行!
在同一表上进行 SELECT 和 UPDATE
MySQL 不允许您在从表中SELECT的同时对其运行UPDATE。这实际上不是一个优化器的限制,但了解 MySQL 如何执行查询可以帮助您解决问题。这是一个被禁止的查询示例,即使它是标准 SQL。该查询将每一行更新为表中相似行的数量:
mysql> UPDATE tbl AS outer_tbl
-> SET c = (
-> SELECT count(*) FROM tbl AS inner_tbl
-> WHERE inner_tbl.type = outer_tbl.type
-> );
ERROR 1093 (HY000): You can't specify target table 'outer_tbl'
for update in FROM clause
要解决这个问题,您可以使用派生表,因为 MySQL 将其实例化为临时表。这实际上执行了两个查询:一个在子查询中执行SELECT,一个在表和子查询的连接结果上执行多表UPDATE。子查询在外部UPDATE打开表之前打开并关闭表,因此查询现在将成功:
mysql> UPDATE tbl
-> INNER JOIN(
-> SELECT type, count(*) AS c
-> FROM tbl
-> GROUP BY type
-> ) AS der USING(type)
-> SET tbl.c = der.c;
优化特定类型的查询
在本节中,我们提供了如何优化某些类型查询的建议。我们在书中的其他地方已经详细介绍了大部分这些主题,但我们想列出一些常见的优化问题,以便您可以轻松参考。
本节中的大部分建议是与版本相关的,可能在未来的 MySQL 版本中不适用。服务器未来可能会自动执行这些优化的原因是没有理由的。
优化COUNT()查询
COUNT()聚合函数以及如何优化使用它的查询,可能是 MySQL 中前 10 个最被误解的主题之一。您可以进行网络搜索,找到更多关于这个主题的错误信息,我们不想去想。
在我们深入优化之前,重要的是您了解COUNT()的真正作用。
COUNT()的作用
COUNT()是一个特殊的函数,以两种非常不同的方式工作:它计算值和行。一个值是一个非NULL表达式(NULL是值的缺失)。如果你在括号内指定列名或其他表达式,COUNT()会计算该表达式具有值的次数。这对许多人来说很令人困惑,部分原因是值和NULL很令人困惑。如果你需要了解 SQL 中的工作原理,我们建议阅读一本关于 SQL 基础的好书。(互联网在这个主题上并不一定是准确信息的好来源。)
COUNT()的另一种形式简单地计算结果中的行数。这是 MySQL 在知道括号内表达式永远不会是NULL时所做的。最明显的例子是COUNT(*),这是COUNT()的一种特殊形式,不会将*通配符扩展为表中的所有列的完整列表,正如你可能期望的那样;相反,它完全忽略列并计算行数。
我们经常看到的一个最常见的错误是在想要计算行数时在括号内指定列名。当你想知道结果中的行数时,应该始终使用COUNT(*)。这清楚地传达了你的意图,并避免了性能不佳。
简单的优化
一个常见的问题是如何在同一列中检索多个不同值的计数,只需一个查询,以减少所需的查询数量。例如,假设你想创建一个单一查询,计算每种颜色的物品数量。你不能使用OR(例如,SELECT COUNT(color = 'blue' OR color = 'red') FROM items;),因为这不会将不同颜色的计数分开。你也不能将颜色放在WHERE子句中(例如,SELECT COUNT(*) FROM items WHERE color = 'blue' AND color = 'red';),因为颜色是互斥的。以下是解决这个问题的查询:¹⁴
SELECT SUM(IF(color = 'blue', 1, 0)) AS blue,SUM(IF(color = 'red', 1, 0))
AS red FROM items;
这里有另一个等效的例子,但是不使用SUM(),而是使用COUNT(),并确保表达式在条件为假时没有值:
SELECT COUNT(color = 'blue' OR NULL) AS blue, COUNT(color = 'red' OR NULL)
AS red FROM items;
使用近似值
有时候你不需要准确的计数,所以可以使用近似值。优化器在EXPLAIN中的估计行数通常很好用。只需执行一个EXPLAIN查询,而不是真实查询。
有时,准确的计数比近似值要低效得多。一位客户要求帮助计算网站上活跃用户的数量。用户计数被缓存并显示 30 分钟,之后重新生成并再次缓存。这本质上是不准确的,所以近似值是可以接受的。查询包括几个WHERE条件,以确保不计算非活跃用户或“默认”用户,这是应用程序中的特殊用户 ID。删除这些条件只会稍微改变计数,但使查询更有效。进一步的优化是消除一个不必要的DISTINCT以消除一个文件排序。重写后的查询速度更快,几乎返回完全相同的结果。
更复杂的优化
一般来说,COUNT()查询很难优化,因为它们通常需要计算大量行(即访问大量数据)。在 MySQL 本身内部进行优化的另一种选择是使用覆盖索引。如果这不够帮助,你需要对应用程序架构进行更改。考虑使用外部缓存系统,如memcached。你可能会发现自己面临熟悉的困境,“快速、准确和简单:选择其中两个。”
优化连接查询
实际上,这个主题在大部分书中都有涉及,但我们将提到一些重点:
-
确保在
ON或USING子句中的列上有索引。在添加索引时考虑连接顺序。如果您在列c上将表A和B连接,并且查询优化器决定以B,A的顺序连接表,则不需要在表B上索引该列。未使用的索引是额外的开销。通常情况下,只需要在连接顺序中的第二个表上添加索引,除非出于其他原因需要。 -
尽量确保任何
GROUP BY或ORDER BY表达式仅引用来自单个表的列,这样 MySQL 可以尝试为该操作使用索引。 -
在升级 MySQL 时要小心,因为连接语法、运算符优先级和其他行为在不同时间发生了变化。曾经是正常连接的东西有时会变成交叉乘积,这是一种返回不同结果甚至无效语法的不同连接类型。
使用 ROLLUP 优化 GROUP BY
分组查询的变体之一是要求 MySQL 在结果中进行超级聚合。您可以使用WITH ROLLUP子句实现这一点,但可能不如您需要的那样优化。使用EXPLAIN检查执行方法,注意分组是通过文件排序还是临时表完成的;尝试移除WITH ROLLUP,看看是否得到相同的分组方法。您可能可以通过我们在本节前面提到的提示来强制分组方法。
有时在应用程序中进行超级聚合更有效,即使这意味着从服务器获取更多行。您还可以在FROM子句中嵌套子查询或使用临时表保存中间结果,然后使用UNION查询临时表。
最好的方法可能是将WITH ROLLUP功能移至应用程序代码中。
优化 LIMIT 和 OFFSET
具有LIMIT和OFFSET的查询在进行分页的系统中很常见,几乎总是与ORDER BY子句一起使用。拥有支持排序的索引是很有帮助的;否则,服务器就必须进行大量的文件排序。
一个常见的问题是偏移量值很高。如果您的查询看起来像LIMIT 10000, 20,那么它将生成 10,020 行并丢弃其中的前 10,000 行,这是非常昂贵的。假设所有页面被均匀访问,这样的查询平均扫描一半的表。为了优化它们,您可以限制分页视图中允许的页面数量,或者尝试使高偏移量更有效。
提高效率的一个简单技巧是在覆盖索引上执行偏移,而不是完整行。然后,您可以将结果与完整行连接并检索所需的其他列。这可能更有效。考虑以下查询:
SELECT film_id, description FROM sakila.film ORDER BY title LIMIT 50, 5;
如果表非常大,则最好按以下方式编写此查询:
SELECT film.film_id, film.description
FROM sakila.film
INNER JOIN (
SELECT film_id FROM sakila.film
ORDER BY title LIMIT 50, 5
) AS lim USING(film_id);
这种“延迟连接”之所以有效,是因为它让服务器在索引中检查尽可能少的数据而不访问行,然后一旦找到所需的行,就将它们与完整表进行连接以检索行中的其他列。类似的技术也适用于带有LIMIT子句的连接。
有时您还可以将限制转换为位置查询,服务器可以将其执行为索引范围扫描。例如,如果您预先计算并索引一个位置列,可以将查询重写为以下形式:
SELECT film_id, description FROM sakila.film
WHERE position BETWEEN 50 AND 54 ORDER BY position;
排名数据提出了类似的问题,但通常将GROUP BY混入其中。您几乎肯定需要预先计算和存储排名。
LIMIT和OFFSET的问题实际上是OFFSET,它表示服务器正在生成和丢弃的行。如果使用一种类似游标的方式记住您获取的最后一行的位置,您可以通过从该位置开始而不是使用OFFSET来生成下一组行。例如,如果您想从最新的租赁记录开始向后工作进行分页,您可以依赖于它们的主键始终递增。您可以像这样获取第一组结果:
SELECT * FROM sakila.rental
ORDER BY rental_id DESC LIMIT 20;
此查询返回租赁记录 16049 到 16030。下一个查询可以从那个点继续:
SELECT * FROM sakila.rental
WHERE rental_id < 16030
ORDER BY rental_id DESC LIMIT 20;
这种技术的好处是,无论您分页到表的多远,它都非常高效。
其他替代方法包括使用预先计算的摘要或与仅包含主键和您需要的ORDER BY列的冗余表连接。
优化 SQL_CALC_FOUND_ROWS
另一种常见的分页显示技术是在带有LIMIT的查询中添加SQL_CALC_FOUND_ROWS提示,这样您就会知道没有LIMIT时会返回多少行。这里似乎有一种“魔法”发生,服务器预测它会找到多少行。但不幸的是,服务器并没有真正做到这一点;它无法计算实际未找到的行数。这个选项只是告诉服务器生成并丢弃其余的结果集,而不是在达到所需行数时停止。这是非常昂贵的。
更好的设计是将分页器转换为“下一页”链接。假设每页有 20 个结果,那么查询应该使用 21 行的LIMIT,并且只显示 20 个。如果结果中存在第 21 行,则有下一页,您可以呈现“下一页”链接。
另一种可能性是获取并缓存比您需要的更多行,比如 1,000 行,然后为连续的页面从缓存中检索它们。这种策略让您的应用程序知道完整结果集有多大。如果少于 1,000 行,应用程序就知道要呈现多少页链接;如果超过 1,000 行,应用程序只需显示“找到超过 1,000 个结果”。这两种策略比重复生成整个结果并丢弃大部分结果要高效得多。
有时您也可以通过运行EXPLAIN查询并查看结果中的rows列来估计结果集的完整大小(嘿,即使 Google 也不显示确切的结果计数!)。如果无法使用这些策略,使用单独的COUNT(*)查询来查找行数可能比SQL_CALC_FOUND_ROWS快得多,如果它可以使用覆盖索引。
优化 UNION
MySQL 总是通过创建临时表并填充UNION结果来执行UNION查询。MySQL 无法对UNION查询应用您可能习惯的许多优化。您可能需要通过手动“推送”WHERE、LIMIT、ORDER BY和其他条件(即从外部查询复制到UNION中的每个SELECT中)来帮助优化器。
始终使用UNION ALL很重要,除非您需要服务器消除重复行。如果省略ALL关键字,MySQL 会向临时表添加 distinct 选项,该选项使用完整行来确定唯一性。这是非常昂贵的。请注意,ALL关键字并不消除临时表。即使不是真正必要的情况下(例如,结果可以直接返回给客户端时),MySQL 也总是将结果放入临时表,然后再次读取它们。
摘要
查询优化是模式、索引和查询设计相互交织的拼图中的最后一块,以创建高性能应用程序。要编写良好的查询,您需要了解模式和索引,反之亦然。
最终,这仍然是关于响应时间和理解查询执行的方式,以便你可以推断时间消耗的位置。通过添加一些东西,比如解析和优化过程,这只是理解 MySQL 如何访问表和索引的下一步,我们在上一章中讨论过。当你开始研究查询和索引之间的相互作用时,出现的额外维度是 MySQL 如何基于在另一个表中找到的数据访问一个表或索引。
优化始终需要三管齐下的方法:停止做某些事情,减少做的次数,以及更快地完成。
¹ 如果应用程序与服务器不在同一主机上,网络开销最严重,但即使它们在同一台服务器上,MySQL 和应用程序之间的数据传输也不是免费的。
² 请参阅本章后面的“优化 COUNT()查询”了解更多相关内容。
³ Percona Toolkit 的pt-archiver工具使这类工作变得简单且安全。
⁴ 如果查询太大,服务器将拒绝接收更多数据并抛出错误。
⁵ 你可以通过SQL_BUFFER_RESULT来解决这个问题,稍后我们会看到。
⁶ 例如,范围检查查询计划会为JOIN中的每一行重新评估索引。你可以通过在EXPLAIN中的Extra列中查找“range checked for each record”来查看这个查询计划。这个查询计划还会增加Select_full_range_join服务器变量。
⁷ 我们同意,一部没有演员的电影很奇怪,但 Sakila 示例数据库中没有列出Slacker Liaisons的演员,它描述为“一部关于鲨鱼和一名学生在古代中国必须与鳄鱼见面的快节奏故事。”
⁸ 请参阅 MySQL 手册中关于版本特定提示的“索引提示”和“优化器提示”以了解可用的提示以及如何使用它们。
⁹ 正如我们后面所展示的,MySQL 的查询执行并不是那么简单;有许多优化措施使其变得复杂。
¹⁰ 你可以在语句之前使用EXPLAIN FORMAT=TREE …来查看这一点。
¹¹ 服务器从执行计划生成输出。因此,它具有与原始查询相同的语义,但不一定具有相同的文本。
¹² 严格来说,MySQL 并不试图减少它读取的行数。相反,它试图优化更少的页面读取。但是行数通常可以让你大致了解查询的成本。
¹³ 如果需要,你可以通过SQL_BUFFER_RESULT提示来��响这种行为。请参阅官方 MySQL 手册中的“优化器提示”了解更多信息。
¹⁴ 你也可以将SUM()表达式写成SUM(color = 'blue'), SUM(color ='red')。
第九章:复制
MySQL 内置的复制是在 MySQL 之上构建大型、高性能应用程序的基础,使用所谓的“横向扩展”架构。复制允许您将一个或多个服务器配置为另一个服务器的副本,使它们的数据与源副本同步。这不仅对高性能应用程序有用——它也是许多高可用性、可扩展性、灾难恢复、备份、分析、数据仓库等任务策略的基石。
在本章中,我们的重点不在于每个功能是什么,而在于何时使用它。官方 MySQL 文档在解释诸如半同步复制、多源复制等功能方面非常详细,您在设置这些功能时应参考此文档。
复制概述
复制解决的基本问题是在同一拓扑结构内保持数据库实例之间的数据同步。它通过将修改数据或数据结构的事件写入源服务器上的日志来实现这一点。副本服务器可以从源上的日志中读取事件并重放它们。这创建了一个异步过程,其中副本的数据副本在任何给定时刻都不能保证是最新的。副本延迟——实时和副本所代表的内容之间的延迟——没有上限。大型查询可能导致副本落后源几秒、几分钟,甚至几小时。
MySQL 的复制大多是向后兼容的——也就是说,新版本的服务器通常可以成为旧版本服务器的副本而无需麻烦。但是,旧版本的服务器通常无法作为新版本的副本:它们可能不理解新版本服务器使用的新功能或 SQL 语法,复制使用的文件格式可能存在差异。例如,您无法从 MySQL 5.6 源复制到 MySQL 5.5 副本。在从一个主要或次要版本升级到另一个主要或次要版本(例如从 5.6 到 5.7 或从 5.7 到 8.0)之前,最好测试您的复制设置。在次要版本内的升级,例如从 5.7.34 到 5.7.35,预计是兼容的;阅读发布��明以了解从一个版本到另一个版本的确切变化。
复制相对于扩展读取而言效果较好,您可以将读取定向到副本,但除非设计正确,否则它不是扩展写入的好方法。将许多副本连接到源只会导致写入在每个副本上执行多次。整个系统受限于最弱部分可以执行的写入数量。
以下是复制的一些常见用途:
数据分发
MySQL 的复制通常不会占用太多带宽,尽管后面会看到,基于行的复制可能比传统的基于语句的复制使用更多带宽。您还可以随时停止和启动复制。因此,它对于在地理上较远的位置(如不同的数据中心或云区域)维护数据副本非常有用。远程副本甚至可以与间歇性(有意或无意地)的连接一起工作。但是,如果您希望副本具有非常低的复制延迟,您将需要一个稳定的、低延迟的链接。
扩展读取流量
MySQL 复制可以帮助您在多个服务器之间分发读取查询,这对于读取密集型应用程序非常有效。您可以通过简单的代码更改进行基本的负载平衡。在小规模上,您可以使用简单的方法,如硬编码主机名或轮询 DNS(将单个主机名指向多个 IP 地址)。您还可以采取更复杂的方法。标准的负载平衡解决方案,如网络负载平衡产品,可以很好地在 MySQL 服务器之间分发读取。
备份
复制是一种有助于备份的有价值的技术。然而,副本既不是备份,也不是备份的替代品。
分析和报告
为报告/分析(在线分析处理,或 OLAP)查询使用专用副本是一个很好的策略,可以将该负载与您的业务需要为外部客户请求提供的服务隔离开来。复制是实现这种隔离的一种方式。
高可用性和故障转移
复制可以帮助避免使 MySQL 成为应用程序的单点故障。涉及复制的良好故障转移系统可以显著减少停机时间。
测试 MySQL 升级
常见做法是使用升级后的 MySQL 版本设置一个副本,并在升级每个实例之前使用它来确保您的查询按预期工作。
复制的工作原理
在深入了解设置复制的细节之前,让我们快速看一下 MySQL 实际如何复制数据。在这个解释中,我们涵盖了最简单的复制拓扑结构,一个源和一个副本。
从高层次上看,复制是一个简单的三部分过程:
-
源在其二进制日志中记录其数据的更改为“二进制日志事件”。
-
副本将源的二进制日志事件复制到自己的本地中继日志。
-
副本通过在中继日志中重放事件,将更改应用于自己的数据。
图 9-1 更详细地说明了复制的最基本形式。
这种复制架构将在副本上获取和重放事件的过程解耦,这使它们可以是异步的,即 I/O 线程可以独立于 SQL 线程工作。

图 9-1. MySQL 复制的工作原理
复制内部机制
现在我们已经为您复习了复制的基础知识,让我们深入了解它。让我们看看复制实际如何工作,看看它的优点和缺点,以及检查一些更高级的复制配置选项。
选择复制格式
MySQL 为复制提供了三种不同的二进制日志格式:基于语句的、基于行的和混合的。这些是通过binlog_format配置参数控制的,该参数控制数据如何写入二进制日志。
基于语句的复制通过记录更改源数据的查询来工作。当副本从中继日志中读取事件并执行它时,它重新执行源执行的实际 SQL 查询。这种格式的主要优点是简单和紧凑。更新大量数据的查询可以在二进制日志中只有几十个字节。基于语句的最大缺点通常是它在非确定性查询方面存在问题。考虑一个删除一千行表中的一百行的语句,没有ORDER BY子句。如果行在源和副本之间以不同的方式排序,您可能在每个副本上删除不同的一百行,导致不一致性。
基于行的复制将事件写入二进制日志,指示行如何更改。这听起来非常简单,但与基于语句的复制相比,这是一个很大的改变,因为它是确定性的。使用基于行的复制,您可以查看二进制日志,看到确切哪些行发生了变化以及值变成了什么。使用基于语句的复制,SQL 在执行时被解释,服务器在执行时找到的任何行都会发生变化。基于行的缺点是为每个受影响的行写入事件可能会显��增加二进制日志的大小。
混合方法试图结合两种方法的优点,使用基于语句的格式作为默认,并在需要时切换到基于行的格式。我们说“试图”是因为虽然它非常努力,但它有很多条件¹ 需要满足何时写入每种格式,这会导致二进制日志中发生不可预测的事件。我们认为二进制日志数据应该是其中一种,而不是混合使用两种格式。
我们建议除非您有使用基于语句的临时需求,否则坚持使用基于行的复制。基于行提供了最安全的数据复制方法。
全局事务标识符
直到 MySQL 5.6,副本必须跟踪连接到源时正在读取的二进制日志文件和日志位置。例如,一个副本连接到上游源并从 binlog.000002 的位置 2749 读取数据。当副本从该二进制日志中读取事件时,每次都会推进位置。然后,灾难发生了!源崩溃了,您不得不从备份中重建数据。问题是:如果二进制日志重新开始,您如何重新连接您的副本?这是一个相当复杂的过程,需要读取事件并确定何时连接。如果您��了错误并连接得太早,您可能会重复事件,如果太晚,您会跳过事件。无论哪种方式,都很容易错误地连接副本。
为了解决这个问题,MySQL 添加了一种替代方法来跟踪复制位置:全局事务标识符(GTID)。使用 GTID,源服务器提交的每个事务都被分配一个唯一标识符。这个标识符是 server_uuid² 和递增的事务编号的组合。当事务写入二进制日志时,GTID 也会随之写入。在本章前面的复习中,您会记得副本将二进制日志事件复制到本地中继日志,并使用 SQL 线程将更改应用到本地副本。当 SQL 线程提交一个事务时,它也记录了 GTID 已经完成。
为了更好地说明这一点,让我们举个例子。假设我们的源服务器刚刚设置好,里面没有任何数据,甚至没有创建数据库。在这个源服务器上,我们的 server_uuid 也生成为 b9acac5a-7bbe-11eb-a043-42010af8001a。我们已经在我们的副本上做了同样的事情,并使用适当的命令指示我们的副本使用源服务器进行复制。
在我们的源服务器上,我们需要创建一个新的数据库:
CREATE DATABASE misc;
此事件将被写入二进制日志,以便我们的副本也可以创建数据库。在二进制日志中,我们会看到一个由 GTID 标识的单个事件:
b9acac5a-7bbe-11eb-a043-42010af8001a:1
当副本服务器应用此事件时,它会记住已经完成了事务 b9acac5a-7bbe-11eb-a043-42010af8001a:1。
在我们编造的例子中,假设我们在副本上停止了 MySQL。它已经提交了一个事务。如果我们的源继续接收写入,我们的事务列表将继续增长:2、3、4、5 等等。当我们重新启动我们的副本时,³ 它知道它已经看到了事务 1,并且可以开始处理事务 2。
GTID 解决了运行 MySQL 复制时的一个更痛苦的部分:处理日志文件和位置。我们强烈建议您始终按照官方 MySQL 文档中的指南启用 GTID 用于您的数据库。
使复制具有崩溃安全性
尽管 GTID 解决了日志文件和位置问题,但还有许多其他问题困扰着 MySQL 的管理员。在本章后面,我们将讨论常见的故障模式;然而,在此之前,有一些配置设置可以极大地改善您使用复制的体验。
为了最大程度地减少复制中断的机会,我们建议设置以下内容:
innodb_flush_log_at_trx_commit = 1
虽然不严格属于复制设置,但这确保了每个事务的日志都被写入并同步到磁盘。这是完全符合 ACID 的设置,将最大程度地保护您的数据,即使有复制。这是因为二进制日志事件首先被提交,然后事务将被提交并刷新到磁盘。将此设置为1将增加磁盘写入操作,同时确保您的数据是持久的。
sync_binlog = 1
此变量控制 MySQL 将二进制日志数据同步到磁盘的频率。将此值设置为1意味着在每个事务之前。这可以防止在服务器崩溃时丢失事务。与前面的设置一样,这将增加磁盘写入。
relay_log_info_repository = TABLE
MySQL 复制过去依赖于磁盘上的文件来跟踪复制位置。这意味着由复制完成的事务必须作为第二步同步到磁盘。如果在事务提交和同步之间发生崩溃,磁盘上的文件将具有不正确的文件和位置。该信息已经移动到 MySQL 内部的 InnoDB 表中,允许复制在同一事务中更新事务和中继日志信息。这创建了一个原子操作,并有助于崩溃恢复。
relay_log_recovery = ON
简单来说,relay_log_recovery在检测到崩溃时丢弃所有本地中继日志,并从源获取丢失的数据。这确保了在崩溃中可能发生的任何损坏或不完整的中继日志在磁盘上是可恢复的。此设置还消除了使用sync_relay_log的需要,因为在崩溃事件中,中继日志将被删除。没有必要进行额外的操作将它们同步到磁盘。
延迟复制
在某些情况下,在您的拓扑中拥有一个延迟副本可能是有利的。这种策略可以用来保持数据在线和运行,但保持其在实时之后许多小时或几天。这是通过CHANGE REPLICATION SOURCE TO语句和SOURCE_DELAY选项进行配置的。
想象一下,您正在处理大量数据,发生了意外更改:一个表被删除了。您可能需要几个小时才能从备份中恢复。通过延迟副本,您可以找到DROP TABLE语句的 GTID,并将复制追赶到该表被删除之前的点。这通常可以导致更快的补救时间。
然而,没有什么是没有取舍的。虽然延迟复制在减轻某些数据丢失场景方面非常有用,但它也给许多其他运营方面带来了复杂性。如果您决定需要使用延迟复制,您还应考虑如何正确排除这个延迟副本不成为源节点候选人(如果您的写故障转移是自动化的,这更加重要),如何监视复制以及如何处理这个特殊副本。这些只是引入延迟副本时您应该解决的一些额外复杂性。
多线程复制
复制的一个历史性挑战是,虽然您可以在源上进行并行写入,但您的副本是单线程的。现代 MySQL 版本提供了多线程复制(参见图 9-2),您可以运行多个 SQL 应用程序线程来在本地应用中继日志的更改。

图 9-2。多线程复制设置
多线程复制有两种模式:DATABASE和LOGICAL_CLOCK。DATABASE选项使用多个线程更新不同的数据库;没有两个线程会同时更新同一个数据库。如果您在 MySQL 中将数据分布在多个数据库中并且一致并发地更新它们,这种方法效果很好。另一个选项LOGICAL_CLOCK允许对同一个数据库进行并行更新,只要它们是同一个二进制日志组提交的一部分。
在大多数情况下,您可以简单地打开这个功能,并通过将replica_parallel_workers设置为非零值立即看到好处。如果您只操作一个数据库,还需要将replica_parallel_type更改为LOGICAL_CLOCK。由于多线程复制使用一个协调线程,该线程会有一些开销来管理所有其他线程的状态。此外,请确保您的副本以replica_preserve_commit_order运行,以防止乱序提交导致问题。查看官方文档中的“Gaps”部分以获取为什么这一点很重要的详细解释。
有两种方法可以确定最佳的replica_parallel_workers值。不精确的方法是停止复制,然后测量使用不同数量的线程追赶的时间��直到找到最佳设置。这种方法存在缺陷,因为它假设一致数量的数据操作语言(DML)语句被发送到复制,并且它们的执行几乎相同。实际上,这几乎不可能。
更精确的方法是查看每个应用线程在您的工作负载中有多忙,以确定您获得了多少并行性。为此,我们需要启用性能模式的消费者和工具,允许其收集一些信息,然后查看结果。
首先,我们需要启用以下内容:⁴
UPDATE performance_schema.setup_consumers SET ENABLED = 'YES'
WHERE NAME LIKE 'events_transactions%';
UPDATE performance_schema.setup_instruments SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME = 'transaction';
允许复制处理一段时间内的事件。理想情况下,您应该在最繁忙的写入工作负载期间或任何看到复制延迟增加的时候查看这一点:
mysql> USE performance_schema;
events_transactions_summary_by_thread_by_event_name.thread_id AS THREAD_ID,
events_transactions_summary_by_thread_by_event_name.count_star AS COUNT_STAR
FROM events_transactions_summary_by_thread_by_event_name
WHERE
events_transactions_summary_by_thread_by_event_name.thread_id IN (SELECT
replication_applier_status_by_worker.thread_id
FROM replication_applier_status_by_worker);
+-----------+------------+
| THREAD_ID | COUNT_STAR |
+-----------+------------+
| 1692957 | 23413 |
| 1692958 | 7150 |
| 1692959 | 1568 |
| 1692960 | 291 |
| 1692961 | 46 |
| 1692962 | 9 |
+-----------+------------+
6 rows in set (0.00 sec)
这个查询将帮助您确定每个线程处理了多少个事务。从这个样本工作负载的结果中可以看出,我们的最佳使用情况在三到四个线程之间,超过这个数量的线程几乎没有被使用。
半同步复制
当您启用半同步复制时,源数据库提交的每个事务必须得到至少一个副本的确认已接收。⁵ 这个确认表示副本已接收并成功写入到自己的中继日志(但不一定应用到本地数据)。
由于每个事务必须等待其他节点的响应,这个特性会给服务器的每个事务增加额外的延迟。这意味着您需要考虑所涉及的权衡。
这里非常重要的一点是,如果在时间范围内没有副本确认事务,MySQL 将恢复到其标准的异步复制。它不会使事务失败。这真的有助于说明半同步复制不是用来防止数据丢失的工具,而是一个更大工具集的基础,使您能够拥有更具弹性的故障转移。
鉴于回退到异步复制,我们很难找到一个好的使用案例来解释为什么要启用这个功能。逻辑上的使用案例是确认,在网络分区的情况下,孤立的源数据库是否仍在写入数据而与其副本分隔。不幸的是,该源数据库将会回退到异步并继续接受写入。因此,我们建议不依赖于这一点来保证任何数据完整性。
复制过滤器
复制过滤选项让你只复制服务器数据的一部分,这并不像你想象的那么好。有两种复制过滤器:一种是从源二进制日志中过滤事件,另一种是从副本中继日志中过滤事件。图 9-3 展示了这两种类型。
控制二进制日志过滤的选项是 binlog_do_db 和 binlog_ignore_db。除非你认为你会喜欢向老板解释为什么数据永久丢失且无法恢复,否则不应启用这些选项。
在副本上,replicate_* 选项在复制 SQL 线程从中继日志读取事件时过滤事件。你可以复制或忽略一个或多个数据库,将一个数据库重写为另一个数据库,并根据 LIKE 模式匹配语法复制或忽略表。

图 9-3. 复制过滤选项
关于这些选项最重要的理解是,*_do_db 和 *_ignore_db 选项,在源和副本上,不像你期望的那样工作。你可能认为它们是根据对象的数据库名称进行过滤,但实际上它们是根据当前默认数据库进行过滤——也就是说,如果你在源上执行以下语句:
USE test;
DELETE FROM sakila.film;
*_do_db 和 *_ignore_db 参数将在 test 上过滤 DELETE 语句,而不是在 sakila 上。这通常不是你想要的,它可能导致错误的语句被复制或忽略。*_do_db 和 *_ignore_db 参数有用处,但它们是有限的和罕见的,你应该非常小心使用它们��如果你使用这些参数,复制很容易出现不同步或失败。
binlog_do_db 和 binlog_ignore_db 选项不仅有可能破坏复制;它们还会使从备份进行时点恢复变得不可能。在大多数情况下,你应该永远不要使用它们。
一般来说,复制过滤器是一个等待发生问题的问题。例如,假设你想阻止权限更改传播到副本,这是一个相当常见的目标。(希望这种愿望可能会让你意识到你正在做错事;可能有其他方法来实现你真正的目标。)系统表上的复制过滤器肯定会阻止 GRANT 语句的复制,但它们也会阻止事件和例程的复制。这种意想不到的后果是需要小心处理过滤器的原因。也许更好的主意是阻止特定语句被复制,通常使用 SET SQL_LOG_BIN=0,尽管这种做法也有其自身的危险。总的来说,你应该非常谨慎地使用复制过滤器,只有在真正需要它们的情况下才使用,因为它们很容易破坏复制并在最不方便的时候出现问题,比如在灾难恢复期间。
话虽如此,也可能存在特定情况下复制过滤器是有益的。也许你创建了多个数据库 users_1、users_2、users_3 和 users_4,现在服务器的性能受到了太大的影响。通过恢复备份并附加复制,你可以准备将 users_3 和 users_4 的查询移动到另一台服务器。这个过程完全正常,只是你在新数据库上仍然有 users_1 和 users_2。在某个时候,你将不得不删除可能影响性能的数据。考虑这种替代方案。你恢复备份然后删除 users_1 和 users_2。然后配置一个复制规则来忽略 users_1 和 users_2 并完成复制设置。现在你的新服务器上只处理 users_3 和 users_4 的事件。一旦复制追上,你就可以开始接收生产流量了。
MySQL 手册中已经对过滤选项进行了详细说明,所以我们不会在这里重复细节。
复制故障转移
在本章的开头,我们提到复制是高可用性的基石,还有其他方面。在另一个位置持续更新的数据副本,比起备份更容易从灾难中恢复。更重要的是,有时你只需进行一些需要重新启动 MySQL 的维护工作。
在这一部分,我们想谈谈正确的方式将副本晋升为源节点。很容易出错,出错可能导致数据问题和延长的停机时间。我们想澄清“晋升副本”和“故障切换”是同义词。它们都意味着将源降级为不再接受写入,并将副本晋升为源的行为。
如何处理这个问题的更详细解释在官方的 MySQL 文档中,位于“故障切换期间切换源”部分,但考虑到这个问题的重要性,我们至少想在某个层面上提及它。
计划晋升
晋升的最常见原因是某种维护事件,包括安全补丁、内核更新,甚至只是重新启动 MySQL,因为有一些配置选项需要重新启动。这种类型的晋升被称为受控或计划晋升。
要成功执行此晋升,你需要完成以下步骤:
-
确定要晋升的副本。通常情况下,这是你确信拥有所有数据的副本。这就是你的目标。
-
检查延迟,确保你的时间在几秒钟之内。
-
通过设置
super_read_only停止在源上进行写入。⁶ -
等待复制与目标同步。比较 GTIDs 以确保一致。
-
在目标上取消
read_only。 -
将应用程序流量切换到目标。
-
将所有副本重新指向新源,包括降级的副本。这在 GTIDs 和
AUTO_POSITION=1中是微不足道的。
非计划晋升
在足够长的时间轴上,每个系统都会失败,无论是软件还是硬件的结果。当这种情况发生在正在写入的源服务器上时,会对用户体验产生很大影响。大多数应用程序将简单地返回一个错误,让用户自行重试。这是需要非计划晋升的情况。
由于你没有一个实时源来检查,这是一个简化的计划晋升,你根据已经复制的数据选择要晋升的副本:
-
确定要晋升的副本。通常情况下,这是你确信拥有所有数据的副本。这就是你的目标。
-
在目标上取消
read_only。 -
将应用程序流量切换到目标。
-
将所有副本重新指向新源,包括降级的副本当它恢复服务时。这在 GTIDs 中是微不足道的。
你还应该确保当你以前的源重新上线时,默认启用super_read_only。这将有助于防止任何意外写入。
晋升的权衡
我们不得不指出,有时候你对停机的第一反应是故障切换。因为很难知道目标可能缺少多少数据,有时候不故障切换可能是一个更好的策略。
非计划晋升并不是一个经常发生的事件,也就是说,你不经常这样做。当你被要求这样做时,你可能需要查阅文档,以确保不会漏掉任何步骤。你还需要检查其他副本,以验证哪一个是可能的候选。所有这些都需要时间。在某些情况下,等待服务器或 MySQL 进程重新上线可能更快。这样做的好处是,如果你在第五章中遵循了 ACID 合规性的步骤,你不会丢失任何数据,你的副本将从中断的地方继续。
复制拓扑
您可以为几乎任何源和副本配置设置 MySQL 复制。许多复杂的拓扑结构是可能的,但即使简单的拓扑结构也可以非常灵活。单个拓扑结构可以有许多不同的用途。您可以使用复制的各种方式很容易地填满一本书。
所有这些灵活性意味着您可以轻松设计一个难以维护的拓扑结构。我们强烈建议您尽可能简化您的复制拓扑结构,同时仍满足您的需求。话虽如此,我们推荐两种几乎可以涵盖所有用例的策略。您可能有理由偏离这些策略,但请确保在变得更复杂时问问自己是否仍在解决正确的问题。
主/被动
在主/被动拓扑中,您将所有读写指向单个源服务器。此外,您保留一小部分不主动提供任何应用程序流量的被动副本。选择此模型的主要原因是您不想担心复制延迟。由于所有读取都发送到源,您可以防止应用程序可能无法容忍的写后读问题。
图 9-4 显示了具有多个副本的这种安排。

图 9-4。具有多个副本的源
配置
在这种拓扑结构中,我们期望源和副本在 CPU、内存等方面具有相同的配置。在足够长的时间内,您将需要从当前运行的源故障切换到其中一个副本,无论是为了维护、软件升级或打补丁,甚至是硬件故障。通过在副本上具有相同的硬件和软件配置,您确保可以像之前进行故障切换前一样维持流量容量和吞吐量。
冗余
在物理硬件环境中,至少需要三台总服务器的n+2 冗余。在硬件故障的情况下,您仍然有一台额外的服务器用于故障切换。如果您不放心或无法在源上进行备份,您还可以使用其中一个副本作为备用服务器。
在云环境中,如果你的数据足够小或者可以轻松复制数据,你可以通过n+1 的冗余来实现两台总服务器。否则,需要n+2。如果选择n+1 的方式,云服务提供商的动态配置特性可以使管理变得更容易。对于像打补丁这样的维护事件,更容易在需求时提供第三个副本,执行任何必要的操作(如升级内核或应用安全更新),然后替换其他副本。然后进行故障切换并在原始源上重复该过程。目标是始终保持一个准备好成为故障切换目标的副本。
在任一情况下,您可以将其中一个副本放置在地理位置较远的位置,尽管您需要注意复制延迟并确保其可用。副本应该是可恢复的,并且在您建立的指导方针内遭受任何数据丢失。我们在“定义恢复要求”中讨论了这一点,在第十章中。
注意事项
通过选择这种模型,您明确将您的读扩展绑定到单个服务器的容量。如果达到读扩展限制,您将不得不进化到活动/读池配置,或者利用分片来减少源上的读取。
主/读池
在主/读池配置中,您将所有写入指向源。读取可以发送到源服务器或读池,具体取决于应用程序需求。读池允许您为读密集型应用程序水平扩展读取。在某个时刻,由于源上的复制需求,水平扩展将会减少。
图 9-5 显示了这种安排,其中有一个单一源和一个副本池。

图 9-5. 带有读取池的源
配置
理想情况下,您希望源和至少一个副本之间的配置相同,最好是两个副本。再次强调,您最终将需要切换到这些副本之一,并且它应具有足够的容量来跟上您的流量。
如果您看到这个池随着时间增长,您可以优化成本,并为一些成员使用不同的配置。如果是这种情况,请尝试将流量加权作为一种平衡流量的方法。如果您有 32 个核心用于故障转移目标和 8 个核心用于其他副本,尝试将流量发送到 32 核心节点的流量增加四倍,以确保您获得利用率。
冗余
在这个池中,您拥有的服务器数量应满足先前给定的要求,至少有一台服务器可以充当故障转移目标。此外,您需要足够的节点来容纳您的读取流量,再加上一小部分用于节点故障的缓冲区。在读取方面,您最有可能的利用率指标将是 CPU,因此,在池中的每个节点上的利用率应在 50%–60% 之间。随着 CPU 的增加,它在工作和延迟之间的上下文切换时间增加。尝试找到满足应用程序期望的延迟和利用率之间的正确平衡点。
注意事项
当您使用读取池时,您的应用程序必须对旧的读取具有一定的容忍度。您永远无法保证您在源上完成的写入已经被复制到副本。您可能还需要一种方法来使落后于复制的节点退出池。
读取池的大小也会影响您需要做多少管理工作以及何时应该考虑自动化。一个 16 节点的池意味着您必须进行 16 次内核更新或安全补丁。自动化此任务以优雅地使节点退出池,执行补丁,重新启动,然后重新加入池,将减少您将来手动完成的工作量。
不推荐的拓扑结构
通过使用本章中提供的两个建议中的任何一个,您可以保持拓扑结构简单且易于理解。本书早期版本中还有许多其他建议,或者您可能从其他公司的拓扑结构设置中听说过。我们在这里指出一些不推荐的原因是因为它们带来的风险和复杂性超出了我们愿意看到的范围。
Active-active 模式下的双源
双源复制(也称为双向复制)涉及两台服务器,每台服务器都配置为对方的源和副本,换句话说,是一对共同源。图 9-6 展示了这种设置。

图 9-6. Active-active 模式下的双源
乍一看,这与具有两台服务器的主备模式没有任何不同,只是复制已经以相反方向配置。真正的危险在于当您明确将写入流量发送到两侧时,因此是活动/活动部分。
Active/active 模式非常难以正确实现。一些策略涉及根据奇偶哈希选择发送到哪一侧。这确保了写入后的读取对于相同行是一致的,但包含在另一侧上的规范行的查询可能不一致。更直白地说,从一侧读取 ID 1、3 和 5 的行将始终保持一致。那么对于读取 ID 1–6 的查询怎么办?您将该查询发送到哪里?如果另一侧存在更新,但由于复制延迟在这一侧没有反映出来,会怎样?
您还需要仔细平衡容量。在共源场景中,每台服务器都是另一台服务器的副本,也是最有可能的故障切换目标。您必须以确保在将流量从一侧转移到另一侧时不会耗尽 CPU 的方式规划容量。您还在进行故障切换,并引入一个完全不同的工作数据集。InnoDB 缓冲池现在会翻转,删除条目以为新的热数据集腾出空间。
请听从我们的建议,远离这种设置。也许让一个被动服务器处理流量而不是闲置会让您感觉像在“使用”它。您最终会在应用程序中引入数据不一致,并且总是担心没有足够的容量进行故障切换。一旦失去故障切换策略,您就失去了弹性。
双源主动-被动模式
在避免我们刚刚讨论的问题的双源主动-主动模式中有一种变体。主要区别在于其中一台服务器是只读的“被动”服务器,如图 9-7 所示。

图 9-7. 双源主动-被动模式
表面上这种设置并没有什么问题。它与我们对主/备份的建议唯一的不同之处在于,复制已经预先配置回到另一个服务器。这只适用于两台服务器的配置。如果运行的服务器超过两台,您需要决定哪个节点是最适合故障切换的目标。预先配置复制只会直接将您与一个节点绑定在一起,在故障情况下不会给您灵活性。
我们坚持认为设置复制是作为我们之前讨论的复制-故障切换过程的一个简单、可自动化的步骤。这是一个不必要的配置,只会引起混乱。
具有副本的双源
更进一步混合,我们可以为每个共源添加一个或多个副本,如图 9-8 所示。

图 9-8. 具有副本的双源拓扑
这保留了双源主动-主动中的大部分问题,最重要的是如何路由流量。它解决了关于容量规划和故障切换中缓冲池翻转的问题。在故障切换中,您需要额外的步骤将其中一个源指向其上新晋升的副本。
我们绝对反对这种拓扑,主要是出于数据访问方面的考虑。共源只会带来麻烦。
环形复制
环形复制有三个或更多源,其中每个服务器都是环中前一个服务器的副本,也是后一个服务器的源,如图 9-9 所示。这种拓扑结构也被称为循环复制。

图 9-9. 复制环拓扑
如果此拓扑中的任何服务器下线,您的拓扑将中断,更新将停止在环中传播。这种情况下还有附加的副本变体,其中图 9-9 中的每个源都有一个专用的副本可用于替换。这仍然意味着环被中断,直到您将一个副本提升到原来的位置。
这种拓扑结构与简单相反,没有任何优势。
多源复制
尽管保持复制拓扑简单很重要,但可能会出现需要使用更高级功能来处理一次性功能的情况。假设您建立了一个全新的视频上传和观看网站,现在变得很受欢迎。您早期的设计决策之一是将视频数据和用户数据分开存储在两个不同的数据库集群中。随着您的发展,您发现自己希望在查询中将它们合并在一起。您可以通过多源复制实现这一点,将两个数据集合再次合并到一个副本中,如图 9-10 所示。

图 9-10. 多源复制
这个功能是建立在一个称为复制通道的概念之上。在前面的示例中,您需要为 MySQL 创建第三个集群。这个新的第三个集群将创建两个复制通道:一个用于视频数据,一个用于用户数据。一旦加载和复制数据,您可以进行非常短暂的停机,在这段时间内,您冻结对两个源的写入,并推送您的代码以切换读写到新的合并数据库。哇,您现在已经将两个数据库合并为一个。
在我们继续之前,有一个重要的限制需要知道:您不能配置一个副本多次使用多源复制来自同一源。
这种拓扑结构非常适用于特殊情况。我们只在您围绕这个概念构建永久拓扑结构的情况下不鼓励使用。暂时使用它来合并数据仍然是一个可以接受的用例,最终目标是回到我们的两个建议之一。
复制管理和维护
对于少量数据和一致的写入工作负载,您不太可能经常需要查看复制延迟,更糟糕的是,复制中断。大多数数据库随着时间的推移而增长,随着增长将会进行维护。
监控复制
复制增加了 MySQL 监控的复杂性。尽管复制实际上发生在源和副本上,但大部分工作是在副本上完成的,这也是最常见的问题发生的地方。所有副本都在工作吗?是否有任何副本出现错误?最慢的副本落后多少?MySQL 提供了大部分您需要回答这些问题的信息,但自动化监控过程和使复制稳健留给您自己。
在设置复制监控时,有几个我们认为最重要的观察项目:
复制需要源和副本上的磁盘空间
如前文所述,在图 9-1 中可见,复制使用源上的二进制日志和副本上的中继日志。如果源上没有空闲磁盘空间,事务将无法完成并开始超时。如果在副本上发生相同的情况,MySQL 会更加优雅地暂停复制并等待空闲磁盘空间。您需要监视可用磁盘空间,以确保持续运行。
应该监视复制的状态和错误
尽管复制是一个长期存在的功能且非常稳健,但像网络问题、数据不一致和数据损坏等外部因素可能导致其中断。因此,最好监视复制线程是否正在运行,如果没有,查看最新的错误以确定下一步应该采取什么措施。我们在“复制问题和解决方案”中更详细地介绍了如何解决特定问题。
延迟复制应该按预期延迟
既然我们之前提到了延迟复制,建议设置监控以确保延迟副本确实延迟了正确的时间。太长的延迟可能会使使用变得更加耗时。如果延迟太少,甚至更糟糕的是根本没有延迟,那��如果您需要,延迟副本可能对您毫无用处。
测量复制延迟
你需要监控的最常见事项之一是副本落后源的距离。尽管SHOW REPLICA STATUS中的Seconds_behind_source列理论上显示了副本的延迟,但实际上由于各种原因,它并不总是准确的:
-
副本通过比较服务器当前时间戳和二进制日志事件中记录的时间戳来计算
Seconds_behind_source,因此,除非处理查询,否则副本无法报告其延迟。 -
如果复制线程未运行,副本通常会报告
NULL。 -
一些错误(例如,源端和副本之间
max_allowed_packet设置不匹配或网络不稳定)可能会中断复制和/或停止复制线程,但Seconds_behind_source将报告0而不是指示错误。 -
即使复制进程正在运行,副本有时也无法计算滞后。如果发生这种情况,副本可能报告
0或NULL。 -
非常长的事务可能导致报告的滞后波动。例如,如果您有一个更新数据的事务,保持打开一个小时,然后提交,更新将在实际发生后一个小时进入二进制日志。当副本处理该语句时,它将暂时报告自己比源端滞后一个小时,然后会跳回到零秒滞后。
解决这些问题的方法是忽略Seconds_behind_source,并使用您可以直接观察和测量的内容监视副本滞后。最佳解决方案是心跳记录,这是一个时间戳,您可以在源端每秒更新一次。要计算滞后,您只需从副本上的当前时间戳减去心跳。这种方法不受我们刚提到的所有问题的影响,并且还有一个额外的好处,即创建一个方便的时间戳,显示副本数据的当前时间点。Percona Toolkit 中包含的pt-heartbeat脚本是复制心跳的最受欢迎的实现。
心跳还有其他好处。二进制日志中的复制心跳记录对许多目的都很有用,比如在其他情况下难以解决的灾难恢复场景。
我们刚提到的任何滞后指标都无法让您了解副本实际追赶源端需要多长时间。这取决于许多因素,例如副本的性能如何以及源端继续处理多少写入查询。有关更多信息,请参阅“复制问题和解决方案”部分中的“过多的复制滞后”子节。
确定副本是否与源端一致
在理想情况下,副本始终应该是源的精确副本,减去任何复制延迟。但在现实世界中,副本可能会引入不一致性。一些可能的原因包括:
-
对副本的意外写入
-
使用双源复制,两侧都进行写入
-
非确定性查询和基于语句的复制
-
MySQL 在以低耐久性模式运行时崩溃(请参阅第五章中的耐久性配置)
-
MySQL 中的错误
我们建议遵循以下规则:
始终使用启用super_read_only的副本
使用read_only可以防止没有SUPER特权的用户进行写入,但这不会阻止您的 DBA 在不知情的情况下在副本上运行DELETE或ALTER。super_read_only设置仅允许复制写入,是运行副本的最安全方式。
使用基于行的复制或确定性语句
尽管在某些情况下会使用更多的磁盘空间,基于行的复制是复制数据最一致的方式。这是因为它包含了每个条目的确切行数据更改。
在基于语句的复制中考虑以下内容:
DELETE FROM users WHERE last_login_date <= NOW() LIMIT 10;
当表中有一千个与WHERE子句匹配的用户时会发生什么?MySQL 将使用表中的自然顺序仅删除前 10 行。表的自然顺序在副本上可能不同,因此可能会影响不同的 10 行。未来运行的基于last_login_date修改或删除行的语句可能存在或不存在。这可能导致数据不一致。
编写此内容的最佳方法是使用ORDER BY使行顺序确定:
DELETE FROM users WHERE last_login_date <= NOW() ORDER BY user_id
LIMIT 10;
使用此语句,只要源端和副本之间的数据一致,将删除相同的 10 行。
不要尝试同时向复制拓扑中的多个服务器写入
这包括在两侧都有写入的共源或环形复制。最实用的��制拓扑是使用一个源,接收所有写入,并且一个或多个副本,可选地接收读取。
最后,我们强烈建议,如果你遇到任何复制错误,你使用策略在官方 MySQL 文档中重建副本。
复制问题和解决方案
MySQL 复制的简单实现使其易于设置,但也意味着有许多方法可以停止、混淆和破坏它。在本章的前面,我们讨论了崩溃安全的复制和规则,以帮助保持源和副本同步。本节讨论常见问题,它们如何表现,以及你如何解决或甚至预防它们。
源上的二进制日志损坏
如果源上的二进制日志损坏,你别无选择,只能重建你的副本。跳过损坏的条目将跳过一些事务,这些事务将不再被你的副本处理。
非唯一的服务器 ID
这是你可能在复制中遇到的更难以捉摸的问题之一。如果你意外地配置了两个具有相同服务器 ID 的副本,它们可能看起来工作正常,如果你没有仔细观察的话。但如果你观察它们的错误日志或用innotop这样的工具观察源,你会注意到一些非常奇怪的事情。
在源上,你只会看到两个副本中的一个连接。 (通常,所有副本都是连接并一直复制的。)在副本上,你会在错误日志中看到频繁的断开和重新连接错误消息,但没有提到配置错误的服务器 ID。
根据 MySQL 版本的不同,副本可能会正确但缓慢地复制,或者它们实际上可能不会正确地复制——任何给定的副本可能会错过二进制日志事件,甚至重复它们,导致重复键错误(或静默数据损坏)。你还可能因为副本之间的增加负载而在源上引起问题。如果副本之间的争斗足够激烈,错误日志可能在很短的时间内变得庞大。
这个问题的唯一解决方案是在设置副本时要小心。你可能会发现创建一个副本到服务器 ID 映射的规范列表很有帮助,这样你就不会忘记每个副本属于哪个 ID。如果你的副本完全位于一个网络子网中,你可以通过使用每台机器 IP 地址的最后一个八位来选择唯一的 ID。
未定义的服务器 ID
如果你不定义服务器 ID,MySQL 会似乎使用CHANGE REPLICATION SOURCE TO设置复制,但不会让你启动副本:
mysql> START REPLICA;
ERROR 1200 (HY000): The server is not configured as replica; fix in config file
or with CHANGE REPLICATION SOURCE TO
这个错误尤其令人困惑,如果你刚刚使用了CHANGE REPLICATION SOURCE TO并用SHOW REPLICA STATUS验证了你的设置。你可能会从SELECT @@server_id得到一个值,但那只是一个默认值。你必须显式设置该值。
临时表丢失
临时表对某些用途很方便,但不幸的是,它们与基于语句的复制不兼容。如果一个副本崩溃或者你关闭它,副本线程正在使用的任何临时表都会消失。当你重新启动副本时,任何进一步引用缺失临时表的语句将失败。
这里最好的方法是使用基于行的复制。第二好的方法是一致命名你的临时表(例如以temporary_为前缀)并使用复制规则完全跳过复制它们。
不复制所有更新
如果你错误使用SET SQL_LOG_BIN=0或不理解复制过滤规则,你的副本可能不会执行一些在源上发生的更新。有时你可能希望这样做以进行归档,但通常是意外的,后果很糟糕。
例如,假设您有一个replicate_do_db规则,只将sakila数据库复制到您的一个副本。如果您在源头上执行以下命令,则副本的数据将与源头上的数据不同:
mysql> USE test;
mysql> UPDATE sakila.actor ...
其他类型的语句甚至可能因为非复制依赖关系而导致复制失败。
过多的复制延迟
复制延迟是一个常见问题。无论如何,设计您的应用程序以容忍副本上的一些延迟都是一个好主意。以下是一些减少复制延迟的常见方法:
多线程复制
确保您正在使用多线程复制,并根据手册调整各种选项以获得最高效率。
使用分片
尽管这似乎是一个逃避的答案,但使用分片技术将写操作分散到多个源头是一种非常有效的策略。MySQL 的长期经验法则是:使用副本扩展读操作,使用分片扩展写操作。
暂时降低耐久性
纯粹主义者会不同意,但也许有时候当您已经尝试了所有调整和优化,而分片由于工作量或设计问题而不可行时。如果您的复制延迟主要是由于写操作限制造成的,您可以暂时将sync_binlog=0和innodb_flush_log_at_trx_commit=0设置为提高复制速度。
如果您选择这种最后的方式,您应该非常非常小心。您应该只在您的副本上这样做,如果您的副本也是您进行备份的地方,更改这些设置可能会使您无法从备份中恢复。此外,如果您的副本在此降低耐久性期间崩溃,您可能需要从源头重建。最后,如果您手动执行此操作,很容易忘记将耐久性设置回来。确保您有良好的监控或已编写某种方式来重新设置耐久性。
一个可能的策略是观察SHOW REPLICA STATUS命令中的Seconds_behind_source值,当它超过某个值时,触发以下操作:
-
确保服务器是一个不可写的副本,可能通过验证
super_read_only是否已启用来实现。 -
更改
sync_binlog和innodb_flush_log_at_trx_commit的设置以减少写操作。 -
定期检查
SHOW REPLICA STATUS以获取Seconds_behind_source的值。 -
当低于可接受的阈值时,将设置恢复为其耐久性特性。
源头传来的超大数据包
复制中另一个难以追踪的问题可能发生在源头的max_allowed_packet大小与副本的不匹配时。在这种情况下,源头可能记录一个副本认为过大的数据包,当副本检索到该二进制日志事件时,可能会遇到各种问题。这些问题包括错误和重试的无限循环,或者在中继日志中出现损坏。
没有磁盘空间
复制确实可能会用二进制日志、中继日志或临时文件填满您的磁盘,尤其是如果您在源头上执行了大量的LOAD DATA INFILE查询,并且在副本上启用了log_replica_updates。副本落后越多,它可能使用的磁盘空间就越多,用于从源头检索但尚未执行的中继日志。您可以通过监控磁盘使用情况并设置relay_log_space配置变量来防止这些错误。
复制限制
MySQL 复制可能会因其固有限制而失败或失去同步,有时即使没有错误。相当多的 SQL 函数和编程实践简单地无法可靠地复制(我们在本章中提到了许多)。要确保这些内容中没有一个进入您的生产代码,尤其是如果您的应用程序或团队规模较大。
另一个问题是服务器中的错误。我们不想听起来消极,但许多 MySQL 服务器的主要版本在历史上在复制方面存在错误,特别是在主要版本的首次发布中。新功能,比如存储过程,通常会引起更多问题。
对于大多数用户来说,这并不是避免新功能的理由。这只是一个需要仔细测试的理由,特别是当您升级应用程序或 MySQL 时。监控也很重要;您需要知道什么时候出现问题。
MySQL 复制很复杂,您的应用程序越复杂,您就需要越小心。然而,如果您学会如何使用它,它的效果相当不错。
总结
MySQL 复制是 MySQL 内置功能的瑞士军刀,它极大地增加了 MySQL 的功能和实用性范围。事实上,这可能是 MySQL 迅速变得如此受欢迎的关键原因之一。
尽管复制有许多限制和注意事项,但事实证明,其中大多数对大多数用户来说相对不重要或容易避免。许多缺点只是高级功能的特殊行为,大多数人不会使用,但对需要它们的少数用户非常有帮助。
在复制方面,你的座右铭应该是保持简单。除非你真的需要,不要做任何花哨的事情,比如使用复制环或复制过滤器。简单地使用复制来镜像整个数据副本,包括所有权限。保持副本与源相同的方式将帮助你避免许多问题。
¹ 如预期的那样,我们建议您查阅手册,以确保您了解MIXED模式如何与不同类型的 SQL 语句一起工作。
² 请注意,server_uuid与同名的server_id是不同的。server_id参数是您为服务器指定的用户定义值,而server_uuid是在 MySQL 首次启动时生成的,如果它没有检测到文件auto.cnf。
³ 这假设您在发出CHANGE REPLICATION SOURCE TO命令时使用了SOURCE_AUTO_POSITION = 1选项,这通常是您应该始终这样做的。
⁴ 性能模式的消费者和仪器使 MySQL 收集有关其内部的额外数据,这可能会使用额外的 CPU。作为提醒,您应该始终在安全环��中测试这些更改如何影响生产工作负载。
⁵ 所需的副本数量是可配置选项(rpl_semi_sync_source_wait_for_replica_count)。在更广泛的拓扑结构中,您可能考虑要求在完成原始事务之前需要两甚至三个确认。
⁶ 设置super_read_only会隐式启用read_only。相反,禁用read_only会隐式禁用super_read_only。在此过程中,没有理由同时启用或禁用这两个变量。
⁷ 这通常适用于您可能使用 LVM 快照或基于云的磁盘快照方法进行备份的情况。
第十章:备份和恢复
如果您不事先计划备份,您可能会发现自己排除了一些最佳选项。例如,您可能设置了一个服务器,然后希望使用 LVM 以便可以进行文件系统快照—但为时已晚。您可能也没有注意到配置系统进行备份会产生一些重要的性能影响。如果您不计划并练习恢复,那么当您需要执行时,情况就不会顺利。
我们不会在本章中涵盖备份和恢复解决方案的所有部分—只涵盖与 MySQL 相关的部分。以下是我们决定不在此处包括但您绝对应该在整体备份和恢复策略中包括的一些要点:
-
安全性(备份访问权限、恢复数据权限以及文件是否需要加密)
-
备份存储位置,包括与源站点的距离(在不同磁盘、不同服务器或异地)以及如何将数据从源站点移动到目的地
-
保留政策、审计、法律要求和相关主题
-
存储解决方案和媒体、压缩和增量备份
-
存储格式
-
监控和报告您的备份
-
内置到存储层或特定设备中的备份功能,例如预制文件服务器
在我们开始之前,让我们澄清一些关键术语。首先,您经常会听到所谓的热备份、温备份和冷备份。人们通常使用这些术语来表示备份的影响:“热”备份不应该需要任何服务器停机,例如。问题在于这些术语对每个人来说意义不同。有些工具甚至在其名称中使用热这个词,但绝对不执行我们认为的热备份。我们尽量避免使用这些术语,而是告诉您特定技术或工具对服务器的中断程度。
另外两个令人困惑的词是恢复和恢复。在本章中,我们以特定方式使用它们。恢复意味着从备份中检索数据,并将其加载到 MySQL 中,或将文件放在 MySQL 期望它们在的位置。恢复通常意味着在出现问题后拯救系统或系统的一部分的整个过程。这包括从备份中恢复数据以及使服务器完全功能的所有必要步骤,例如重新启动 MySQL、更改配置、启动服务器的缓存等。
对许多人来说,恢复只意味着在崩溃后修复损坏的表格。这与恢复整个服务器不同。存储引擎的崩溃恢复会协调其数据和日志文件。它确保数据文件仅包含已提交事务所做的修改,并且重新播放尚未应用于数据文件的日志文件中的事务。这可能是整体恢复过程的一部分,甚至是备份的一部分。但是,这与您可能需要在意外DROP TABLE之后进行的恢复不同,例如。根据您要从中恢复的问题,您采取的恢复措施可能大不相同。
最后,备份主要有两种类型:原始和逻辑。原始备份—有时称为物理¹ 备份—指的是来自文件系统的文件副本。逻辑备份指的是重建数据所需的 SQL 语句。
为什么备份?
以下是备份重要性的几个原因:
灾难恢复
灾难恢复是在硬件故障、恶意软件损坏数据或服务器及其数据由于其他原因变得不可用或无法使用时所做的事情。您需要准备好应对一切,从有人意外连接到错误服务器执行ALTER TABLE,到建筑物着火,到恶意攻击者或 MySQL 错误。尽管任何特定灾难发生的几率相当低,但加在一起就会增加。
人们改变主意
你会惊讶地发现,人们经常会有意删除数据,然后希望将其找回。
审计
有时候你需要知道你的数据或模式在过去的某个时间点是什么样子。例如,你可能卷入了诉讼,或者你可能发现了应用程序中的错误,需要查看代码以前是如何运行的(有时仅仅将代码放在版本控制中是不够的)。
测试
在真实数据上进行测试的最简单方法之一是定期使用最新的生产数据刷新测试服务器。如果你正在进行备份,这很容易:只需将备份恢复到测试服务器即可。
检查你的假设。例如,你是否假设你的共享托管提供商正在备份与你的帐户提供的 MySQL 服务器?你可能会感到惊讶。许多托管提供商根本不备份 MySQL 服务器,而其他人只是在服务器运行时进行文件复制,这可能会创建一个损坏的无用备份。
定义恢复要求
如果一切顺利,你永远不需要考虑恢复。但是当你需要时,即使是世界上最好的备份系统也无济于事,如果你从未测试过恢复。你需要一个出色的恢复系统。
不幸的是,使您的备份系统正常运行比构建良好的恢复流程和工具更容易。原因如下:
-
备份首先要做好。如果没有首先备份,你就无法恢复,因此在构建系统时,你的注意力自然会集中在备份上。
-
备份是通过脚本和作业自动化的。很容易花时间对备份过程进行微调,通常是不经思考的。对备份过程进行五分钟的微调可能看起来不重要,但是你是否每天都对恢复过程进行同样的关注呢?
-
备份是例行公事,但恢复通常是一种危机情况。
-
安全性成为障碍。如果你正在进行异地备份,你可能正在对备份数据进行加密或采取其他措施来保护数据。你知道如果你的数据被泄露会有多么糟糕,但是当没有人能解锁你的加密卷以恢复数据,或者当你需要从一个庞大的加密文件中提取单个文件时,情况会有多糟糕呢?
-
一个人可以规划、设计和实施备份。当灾难发生时,这个人可能不可用。你需要培训几个人并计划覆盖,这样你就不会要求一个不合格的人来恢复你的数据。
在制定备份和恢复策略时,考虑以下两个重要的要求是有帮助的。这些是恢复点目标(RPO)和恢复时间目标(RTO)。如果你注意到,这些听起来与我们在第二章中讨论的 SLOs 非常相似。它们定义了你可以接受丢失多少数据以及你可以等待多长时间才能恢复数据。在定义 RPO 和 RTO 时,尝试回答以下类型的问题:
-
你可以丢失多少数据而没有严重后果?你需要点对点恢复吗,还是可以接受自上次常规备份以来发生的任何工作丢失?是否有法律要求?
-
恢复必须有多快?可以接受多少停机时间?你的应用程序和用户可以接受什么影响(例如部分不可用),以及当这些情况发生时,你将如何构建继续运行的能力?
-
你需要恢复什么?常见的要求是恢复整个服务器、单个数据库、单个表,或者只是特定的事务或语句。
最好将这些问题的答案以及整个备份策略以及备份程序记录下来是个好主意。
设计 MySQL 备份解决方案
备份 MySQL 比看起来更困难。在最基本的层面上,备份只是数据的副本,但是你的应用程序需求、MySQL 的存储引擎架构以及系统配置可能会使得复制数据变得困难。
在我们详细介绍所有可用选项之前,我们想推荐:
-
原始备份对于大型数据库实际上是必不可少的:逻辑备份太慢且资源密集,从逻辑备份中恢复需要太长时间。基于快照的备份、Percona XtraBackup 和 MySQL Enterprise Backup 是最佳选择。对于小型数据库,逻辑备份可以很好地工作。
-
保留几代备份。
-
定期提取逻辑备份(可能来自原始备份)。
-
保留二进制日志以进行按时间点的恢复。设置
expire_logs_days足够长,以便从至少两代原始备份中恢复,这样您可以创建一个副本,并从正在运行的源开始,而无需将任何二进制日志应用于其上。独立于到期设置备份您的二进制日志,并将其保留在备份中足够长的时间,以便从至少最近的逻辑备份中恢复。 -
监控备份和备份过程,独立于备份工具本身。您需要外部验证它们是否正常。
-
通过完整的恢复过程测试您的备份和恢复过程。测量恢复所需的资源(CPU、磁盘空间、挂钟时间、网络带宽等)。
-
认真考虑安全性。如果有人入侵了您的服务器,他们是否可以访问备份服务器,反之亦然?
了解您的 RPO 和 RTO 将指导您的备份策略。您是否需要按时间点恢复能力,或者仅恢复到昨晚的备份并丢失自那时以来所做的任何工作?如果需要按时间点恢复,您可能可以定期进行备份并确保启用了二进制日志,以便通过重放二进制日志来恢复到所需点。
一般来说,您可以承受的损失越多,备份就越容易。如果您有非常严格的要求,确保您可以恢复所有内容就更加困难。还有不同类型的按时间点恢复。"软"按时间点恢复要求意味着您希望能够重新创建数据,使其与问题发生时的位置“足够接近”。"硬"要求意味着您永远不能容忍已提交事务的丢失,即使发生了可怕的事情(比如服务器着火)。这需要特殊技术,例如将二进制日志保存在单独的 SAN 卷上或使用分布式复制块设备(DRBD)磁盘复��。
在线还是离线备份?
如果可以的话,关闭 MySQL 来进行备份是获得一致数据副本的最简单、最安全和最佳方式,最小化数据损坏或不一致性的风险。如果关闭 MySQL,您可以在没有来自 InnoDB 缓冲池或其他缓存中的脏缓冲区等问题的情况下复制数据。您不需要担心在备份数据时数据被修改,因为服务器不会受到应用程序的负载影响,您可以更快地进行备份。
然而,将服务器下线的成本可能比看起来要高。因此,您几乎肯定需要设计备份,以便不需要将生产服务器下线。但根据您的一致性要求,即使在服务器在线时进行备份也可能会显著中断服务。
在计划备份时,以下是一些与性能相关的因素需要考虑:
备份时间
制作备份和将备份复制到目的地需要多长时间?
备份负载
将备份复制到目的地会对服务器的性能产生多大影响?
恢复时间
从存储位置复制备份镜像到 MySQL 服务器,重放二进制日志等需要多长时间?
最大的权衡是备份时间与备份负载。通常您可以在其中一个方面改进另一个方面;例如,您可以优先考虑备份,以牺牲服务器性能降低。
您还可以设计备份以利用负载模式。例如,如果您的服务器在夜间的八小时内只有 50% 的负载,您可以尝试设计备份以使服务器的负载低于 50%,并且仍然在八小时内完成。您可以通过许多方式实现这一点:例如,您可以使用 ionice 和 nice 来优先处理复制或压缩操作,使用不同的压缩级别,或者在备份服务器上压缩数据而不是在 MySQL 服务器上压缩。您还可以使用 lzo 或 pigz 进行更快的压缩。您可以使用 O_DIRECT 或 fadvise() 来绕过操作系统的缓存进行复制操作,以便它们不会污染服务器的缓存。像 Percona XtraBackup 和 MySQL Enterprise Backup 这样的工具还具有限速选项,您可以使用 pv 和 --rate-limit 选项来限制您自己编写的脚本的吞吐量。
逻辑备份还是原始备份?
如前所述,有两种主要方法可以备份 MySQL 的数据:使用 逻辑备份(也称为 转储)和通过复制 原始文件。逻辑备份包含 MySQL 可以解释的数据形式,可以是 SQL 或分隔文本。² 原始文件是磁盘上存在的文件。
每种类型的备份都有优点和缺点。
逻辑备份
逻辑备份具有以下优点:
-
它们是您可以使用编辑器和命令行工具(如 grep 和 sed)操纵和检查的普通文件。这在恢复数据或仅想检查数据而不进行恢复时非常有帮助。
-
它们很容易恢复。您可以将它们导入 mysql 或使用 mysqlimport。
-
您可以通过网络进行备份和恢复——也就是说,在与 MySQL 主机不同的机器上。
-
它们可以用于基于云的 MySQL 系统,您无法访问底层文件系统。
-
它们可以非常灵活,因为大多数人喜欢使用的工具 mysqldump 可以接受许多选项,例如
WHERE子句来限制备份哪些行。 -
它们与存储引擎无关。因为您通过从 MySQL 服务器提取数据来创建它们,它们抽象了底层数据存储的差异。³
-
它们可以帮助避免数据损坏。如果您的磁盘驱动器出现故障并复制原始文件,您将收到错误消息和/或生成部分或损坏的备份,除非您检查备份,否则您不会注意到它,以后将无法使用。如果 MySQL 在内存中的数据没有损坏,有时在无法获得良好的原始文件副本时,您可以获得可信赖的逻辑备份。
逻辑备份也有其缺点:
-
服务器必须执行生成它们的工作,因此它们使用更多的 CPU 周期。
-
在某些情况下,逻辑备份可能比底层文件更大。⁴ 数据的 ASCII 表示并不总是与存储引擎存储数据的方式一样高效。例如,整数需要 4 个字节来存储,但在 ASCII 中写入时,可能需要多达 12 个字符。您通常可以有效地压缩文件并获得更小的备份,但这会使用更多的 CPU 资源,导致恢复时间更长。(如果有很多索引,逻辑备份通常比原始备份小。)
-
不总是保证将数据转储和恢复为相同的数据。浮点表示问题、错误等可能会导致问题,尽管这很少见。
-
从逻辑备份中恢复需要 MySQL 加载和解释语句,将其转换为存储格式,并重建索引,所有这些都非常慢。
最大的缺点实际上是从 MySQL 中导出数据的成本以及通过 SQL 语句加载数据的成本。如果使用逻辑备份,测试恢复数据所需的时间是至关重要的。
原始备份
原始备份具有以下优点:
-
原始文件备份只需要将所需文件复制到其他位置进行备份。这些原始文件不需要额外的工作来生成。
-
原始备份在各种平台、操作系统和 MySQL 版本之间非常易于移植。(逻辑转储也是如此。我们只是指出这一点以减轻你可能担心的任何问题。)
-
恢复原始备份可能更快,因为 MySQL 服务器不需要执行任何 SQL 或构建索引。如果你有 InnoDB 表,而这些表完全不适合服务器的内存,那么恢复原始文件可能会快得多——相差一个数量级或更多。事实上,逻辑备份最可怕的一点是其不可预测的恢复时间。
以下是原始备份的一些缺点:
-
InnoDB 的原始文件通常比相应的逻辑备份要大得多。InnoDB 表空间通常有大量未使用的空间。还有相当多的空间用于存储表数据以外的其他目的(插入缓冲区,回滚段等)。
-
原始备份并非总��在各种平台、操作系统和 MySQL 版本之间易于移植。文件名大小写敏感性和浮点格式是可能遇到问题的地方。你可能无法将文件移动到浮点格式不同的系统(然而,绝大多数处理器使用 IEEE 浮点格式)。
原始备份通常更容易且更高效。⁵ 但是,你不应该依赖原始备份来满足长期保留或法律要求,你必须至少定期进行逻辑备份。
在测试备份之前,不要认为备份(尤其是原始备份)是好的。对于 InnoDB,这意味着启动一个 MySQL 实例并让 InnoDB 恢复运行,然后运行 CHECK TABLES。你可以跳过这一步,或者只对文件运行 innochecksum,但我们不建议这样做。
我们建议两种方法的结合:制作原始副本,然后启动一个 MySQL 服务器实例并运行 mysqlcheck。然后,至少定期使用 mysqldump 将数据转储以获得逻辑备份。这样可以在转储过程中不给生产服务器带来过多负担,同时又能兼顾两种方法的优势。如果你有能力进行文件系统快照,这将特别方便:你可以拍摄快照,将快照复制到另一台服务器并释放它,然后测试原始文件并执行逻辑备份。
需要备份什么
你的恢复需求将决定你需要备份什么。最简单的策略就是只备份数据和表定义,但这是一种最基本的方法。通常情况下,你需要更多内容来恢复一个用于生产的服务器。以下是一些你可能考虑与 MySQL 备份一起包括的内容:
非明显的数据
不要忘记容易忽视的数据:例如你的二进制日志和 InnoDB 事务日志。理想情况下,你应该一起备份整个 MySQL 的数据目录。
代码
现代的 MySQL 服务器可以存储大量代码,如触发器和存储过程。如果备份 mysql 数据库,你将备份大部分代码,但随后要完全恢复单个数据库将会很困难,因为该数据库中的一些“数据”,如存储过程,实际上将存储在 mysql 数据库中。
服务器配置
如果你必须从真正的灾难中恢复——比如在地震后在新数据中心从头开始构建服务器——你会感激备份中包含了服务器的配置文件。
选择的操作系统文件
与服务器配置一样,重要的是备份任何对生产服务器至关重要的外部配置。在 Unix 服务器上,这可能包括你的cron作业、用户和组配置、管理脚本和sudo规则。
在许多情况下,这些建议很快就会转化为“备份所有内容”。然而,如果你有大量数据,这可能会变得很昂贵,你可能需要更聪明地进行备份。特别是,你可能希望将不同的数据备份到不同的备份中。例如,你可以将数据、二进制日志以及操作系统和系统配置文件分开备份。
增量备份和差异备份
处理过多数据的常见策略是定期进行增量或差异备份。差异可能有点令人困惑,所以让我们澄清一下术语:差异备份是自上次完全备份以来发生变化的所有内容的备份,而增量备份包含自上次任何类型备份以来发生变化的所有内容。
例如,假设你每周日进行完全备份。周一,你可以备份自周日以来发生变化的所有内容。周二,你有两个选择:你可以备份自周日以来发生变化的所有内容(差异备份),或者你可以仅备份自周一备份以来发生变化的数据(增量备份)。
差异备份和增量备份都是部分备份:它们通常不包含完整的数据集,因为某些数据几乎肯定没有发生变化。部分备份通常受欢迎,因为它们在服务器的开销、备份时间和备份空间上节省了开销。然而,有些部分备份实际上并没有减少服务器的开销。例如,Percona XtraBackup 和 MySQL Enterprise Backup 仍然会扫描服务器上的每个数据块,因此它们并没有节省很多开销,尽管它们确实节省了一些挂钟时间、大量用于压缩的 CPU 时间,当然还有磁盘空间。⁶
你可以使用高级备份技术变得相当复杂,但是你的解决方案越复杂,风险就越大。要注意隐藏的危险,比如多代备份彼此紧密耦合,因为如果一个代包含损坏,它也可能使所有其他代无效。
以下是一些高级备份想法:
-
使用 Percona XtraBackup 或 MySQL Enterprise Backup 的增量备份功能。
-
备份你的二进制日志。你也可以使用
FLUSH LOGS在每次备份后开始一个新的二进制日志,然后仅备份新的二进制日志。 -
如果你有包含各种语言的月份名称列表或州或地区缩写等数据的“查找”表,将它们放入单独的数据库中可能是个好主意,这样你就不必一直备份它们。一个更好的选择是将这些数据移到代码中而不是数据库中。
-
不要备份未更改的行。如果一个表只能进行
INSERT操作,比如记录网页点击的表,你可以添加一个TIMESTAMP列,仅备份自上次备份以来插入的行。这与mysqldump结合使用效果最佳。 -
不要完全备份某些数据。有时这是很有道理的,例如,如果你有一个从其他数据构建而成且在技术上是冗余的数据仓库,你可以仅备份用于构建数据仓库的数据,而不是数据仓库本身。即使通过从原始文件重新构建数据仓库来“恢复”非常慢,这也可能是一个好主意。随着时间的推移,避免备份可能会带来比通过完全备份获得的潜在更快的恢复时间更大的节省。你也可以选择不备份一些临时数据,比如保存网站会话数据的表。
-
备份所有内容,但将其发送到具有数据重复功能的目的地,例如 ZFS 文件系统。
增量备份的缺点包括增加的恢复复杂性、增加的风险和更长的恢复时间。如果可以进行完整备份,我们建议出于简单起见这样做。
无论如何,您肯定需要定期进行完整备份;我们建议至少每周一次。您不能指望从一个月的增量备份中恢复。即使一周也是很多工作和风险。
复制
从副本进行备份的最大优势是不会中断源端或给其增加额外负载。这是建立副本服务器的一个很好的理由,即使您不需要它进行负载平衡或高可用性。如果资金是一个问题,您总是可以将备份副本用于其他用途,例如报告——只要您不对其进行写入,从而更改您试图备份的数据。副本不必专门用于备份;它只需要能够及时赶上源端,以便在其其他角色使其在复制方面有时落后时进行下一次备份。
当您从副本进行备份时,使用 GTIDs 是非常明智的,如第九章中所述。这样可以避免保存有关复制过程的所有信息,例如副本相对于源端的位置。这对于克隆新副本、重新应用二进制日志以进行时间点恢复、将副本提升为源端等非常有用。还要确保在停止副本时没有打开临时表,因为它们可能会阻止您重新启动复制。
正如我们在“延迟复制”中提到的,在第九章中,有意延迟其中一个副本的复制对于从某些灾难场景中恢复非常有用。假设您将复制延迟了一个小时。如果源端运行了一个不需要的语句,您有一个小时的时间来注意到它并在副本重复其中继日志中的事件之前停止副本。然后,您可以将副本提升为源端,并重放一些相对较少的日志事件,跳过不良语句。这比我们稍后讨论的时间点恢复技术要快得多。
注意
副本可能与源端的数据不同。许多人认为副本是其源端的精确副本,但根据我们的经验,副本上的数据不匹配是常见的,MySQL 没有办法检测到这个问题。唯一检测它的方法是使用像 Percona Toolkit 的pt-table-checksum这样的工具。预防这种情况的最佳方法是使用super_read_only标志,以确保只有复制可以写入副本。
拥有数据的复制副本可能有助于保护您免受源端磁盘崩溃等问题的影响,但并不保证。复制不是备份。
管理和备份二进制日志
服务器的二进制日志是您可以备份的最重要的东西之一。它们对于时间点恢复是必要的,而且由于它们通常比您的数据小,因此更容易频繁备份。如果您在某个时间点备份了数据并备份了那时以来的所有二进制日志,您可以重放二进制日志并“向前滚动”自上次完整备份以来所做的更改。
MySQL 也使用二进制日志进行复制。这意味着您的备份和恢复策略通常会与您的复制配置互动。频繁备份二进制日志是一个好主意。如果您不能承受丢失超过 30 分钟数据的情况,至少每 30 分钟备份一次。
您需要决定一个日志过期策略,以防止 MySQL 用二进制日志填满您的磁盘。您的日志增长多大取决于您的工作负载和日志格式(基于行的日志记录导致日志条目较大)。我们建议您尽可能保留有用的日志。保留它们有助于设置副本、分析服务器的工作负载、审计以及从上次完整备份进行时点恢复。在决定要保留日志多长时间时,请考虑所有这些需求。
一个常见的设置是使用binlog_expire_logs_seconds变量告诉 MySQL 在一段时间后清除日志。不应手动删除这些文件。
binlog_expire_logs_seconds 设置在服务器启动时或 MySQL 旋转二进制日志时生效,因此如果您的二进制日志从未填满并旋转,服务器将不会清除旧条目。它通过查看文件的修改时间而不是内容来决定要清除哪些文件。
备份和恢复工具
有各种好坏不一的备份工具可用。对于原始备份,我们推荐使用 Percona XtraBackup。它是开源的、被广泛使用的,并且有很好的文档。对于逻辑备份,我们更喜欢mydumper。虽然mysqldump随 MySQL 提供,但其单线程性质可能导致初始备份和恢复时间非常长。mydumper内置了并行性,这可以使逻辑备份速度更快。
MySQL Enterprise Backup
这个工具是 Oracle 的 MySQL Enterprise 订阅的一部分。使用它不需要停止 MySQL、设��锁定或中断正常的数据库活动(尽管它会在服务器上造成一些额外的负载)。它支持压缩备份、增量备份和流式备份到另一台服务器。这是 MySQL 的“官方”备份工具。
Percona XtraBackup
Percona XtraBackup 在许多方面与 MySQL Enterprise Backup 非常相似,但它是开源且免费的。它支持流式传输、增量、压缩和多线程(并行)备份操作。它还具有各种特殊功能,以减少备份对负载较重系统的影响。
Percona XtraBackup 通过在后台线程中“尾随” InnoDB 日志文件,然后复制 InnoDB 数据文件来工作。这是一个稍微复杂的过程,具有特殊的检查以确保数据一致性。当所有数据文件都被复制时,日志复制线程也会完成。结果是所有数据的副本,但在不同的时间点。现在可以将日志应用于数据文件,使用 InnoDB 的崩溃恢复例程,将所有数据文件带入一致状态。这被称为准备过程。一旦准备就绪,备份就完全一致,并包含文件复制过程结束时的所有已提交事务。所有这些都完全在 MySQL 外部发生,因此它不需要以任何方式连接或访问 MySQL。
mydumper
几位现任和前任 MySQL 工程师根据多年经验创建了mydumper作为mysqldump的替代品。这是一个为 MySQL 设计的多线程(并行)备份和恢复工具集,具有许多出色的功能。许多人可能会发现多线程备份和恢复的速度是这个工具最吸引人的特点。
mysqldump
大多数人使用随 MySQL 一起提供的程序,因此尽管存在缺点,创建数据和模式的逻辑备份的最常见选择是mysqldump。有关如何使用此工具的详细信息,请参考官方手册。
数据备份
与大多数事物一样,实际进行备份有更好和更糟糕的方法,而明显的方法有时并不那么好。关键是最大限度地利用网络、磁盘和 CPU 容量,使备份尽可能快速。这是一个平衡的过程,您将不得不进行实验以找到“最佳点”。
逻辑 SQL 备份
大多数人熟悉逻辑 SQL 转储,因为这是 mysqldump 默认创建的。例如,使用默认选项转储小表将产生以下(摘要)输出:
$ mysqldump test t1
-- [Version and host comments]
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
-- [More version-specific comments to save options for restore]
--
-- Table structure for table `t1`
--
DROP TABLE IF EXISTS `t1`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `t1` (
`a` int NOT NULL,
PRIMARY KEY (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping data for table `t1`
--
LOCK TABLES `t1` WRITE;
/*!40000 ALTER TABLE `t1` DISABLE KEYS */;
INSERT INTO `t1` VALUES (1);
/*!40000 ALTER TABLE `t1` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
-- [More option restoration]
转储文件包含表结构和数据,全部写成有效的 SQL 命令。文件以设置各种 MySQL 选项的注释开头。这些选项要么是为了使恢复工作更有效,要么是为了兼容性和正确性。接下来,你可以看到表的结构,然后是数据。最后,脚本重置了转储开始时更改的选项。
转储的输出可用于恢复操作。这很方便,但 mysqldump 的默认选项不适合进行大型备份。
mysqldump 不是唯一可以进行 SQL 逻辑备份的工具。你也可以使用 mydumper 或 phpMyAdmin 等工具来创建。我们真正想指出的不是任何特定工具的问题,而是首先进行单体 SQL 逻辑备份的缺点。以下是主要问题领域:
模式和数据存储在一起
尽管如果你想从单个文件恢复,这很方便,但如果你只需要恢复一个表或者只想恢复数据,这会让事情变得困难。你可以通过两次转储来缓解这个问题——一次用于数据,一次用于模式——但你仍然会遇到下一个问题。
巨大的 SQL 语句
服务器解析和执行所有 SQL 语句是一项繁重的工作。这是一种非常慢的加载数据的方式。
一个巨大的单一文件
大多数文本编辑器无法编辑大文件或具有非常长行的文件。虽然有时可以使用命令行流编辑器,比如 sed 或 grep,来提取所需的数据,但最好保持文件较小。
逻辑备份是昂贵的
从 MySQL 中获取数据的更有效方法比从存储引擎中提取数据并通过客户端/服务器协议发送结果集要好得多。
如你所见,逻辑备份可能很难适应你的环境。如果你需要使用逻辑备份,我们强烈建议你查看 mydumper,以避免单线程性质,并花时间测量备份对数据库的影响。
文件系统快照
文件系统快照是进行在线备份的好方法。支持快照的文件系统可以在某一时刻创建其内容的一致图像,然后你可以用它来进行备份。支持快照的文件系统和设备包括 FreeBSD 的文件系统、ZFS 文件系统、GNU/Linux 的 LVM,以及许多 SAN 系统和文件存储解决方案,比如 NetApp 存储设备。一些云提供商提供的远程附加磁盘选项也提供磁盘快照功能。
不要将快照与备份混淆。拍摄快照只是减少必须保持锁定的时间的一种方式;释放锁定后,你必须将文件复制到备份中。事实上,你甚至可以在不获取锁定的情况下选择在 InnoDB 上拍摄快照。我们将向你展示两种使用 LVM 对全 InnoDB 系统进行备份的方法,你可以选择最小或零锁定。
快照可以是一种为特定用途备份的好方法。一个例子是在升级过程中出现问题时作为备用方案。你可以拍摄一个快照,进行升级,如果出现问题,只需回滚到快照。你可以对任何不确定和风险的操作都采取同样的方式,比如修改一个庞大的表(需要未知的时间)。
LVM 快照的工作原理
LVM 使用写时复制技术创建快照——即,在某一时刻的整个卷的逻辑副本。这有点像数据库中的 MVCC,只是它只保留一个旧版本的数据。
请注意,我们没有说的是物理复制。逻辑复制似乎包含与您快��的卷相同的所有数据,但最初不包含任何数据。LVM 不会将数据复制到快照中,而是简单地记录您创建快照的时间,然后在您从快照请求数据时从原始卷中读取数据。因此,初始复制基本上是一个瞬时操作,无论您快照的卷有多大。
当原始卷中的数据发生变化时,LVM 会将受影响的块复制到快照之前保留的区域,然后再对其进行任何更改。LVM 不保留多个“旧版本”的数据,因此对于在原始卷中更改的块的额外写入不需要对快照进行进一步处理。换句话说,只有对每个块的第一次写入会导致将其复制到保留区域。
现在,当您请求快照中的这些块时,LVM 会从复制的块中读取数据,而不是从原始卷中读取。这使您可以继续在快照中看到相同的数据,而不会阻塞原始卷上的任何内容。图 10-1 描述了这种安排。
快照在/dev目录中创建了一个新的逻辑设备,您可以像挂载其他设备一样挂载这个设备。
使用这种技术,您可以理论上对一个巨大的卷进行快照,并占用非常少的物理空间。但是,您需要预留足够的空间来容纳您期望在保持快照打开时更新的所有块。如果没有预留足够的写时复制空间,快照将耗尽空间,设备将变为不可用。效果就像拔掉外部驱动器一样:任何正在从设备读取的备份作业都将因 I/O 错误而失败。

图 10-1. 写时复制技术如何减少卷快照所需的空间
先决条件和配置
创建快照几乎是微不足道的,但您需要确保系统配置得可以在单个时间点获得所有要备份的文件的一致副本。首先确保您的系统满足以下条件:
-
所有 InnoDB 文件(InnoDB 表空间文件和 InnoDB 事务日志)必须在单个逻辑卷(分区)上。您需要绝对的时间点一致性,而 LVM 无法同时对多个卷进行一致的快照(这是一个 LVM 的限制;其他一些系统没有这个问题)。
-
如果您需要备份表定义,MySQL 数据目录必须在同一个逻辑卷中。如果您使用其他方法备份表定义,比如仅将模式备份到您的版本控制系统中,您可能不需要担心这个问题。
-
您必须在卷组中有足够的空闲空间来创建快照。您需要多少取决于您的工作负载。在设置系统时,留一些未分配空间,以便以后有快照的空间。
LVM 有一个卷组的概念,其中包含一个或多个逻辑卷。您可以按以下方式查看系统上的卷组:
$ vgs
VG #PV #LV #SN Attr VSize VFree
vg 1 4 0 wz--n- 534.18G 249.18G
此输出显示一个卷组,该卷组在一个物理卷上分布了四个逻辑卷,剩余约 250 GB 空间。如果需要,vgdisplay命令可以提供更多详细信息。现在让我们看看系统上的逻辑卷:
$ lvs
LV VG Attr LSize Origin Snap% Move Log Copy%
home vg -wi-ao 40.00G
mysql vg -wi-ao 225.00G
tmp vg -wi-ao 10.00G
var vg -wi-ao 10.00G
输出显示mysql卷有 225 GB 的空间。设备名称为/dev/vg/mysql。这只是一个名称,尽管看起来像一个文件系统路径。为了增加混淆,文件名与真实设备节点/dev/mapper/vg-mysql之间有一个符号链接,您可以使用ls和mount命令查看:
$ ls -l /dev/vg/mysql
lrwxrwxrwx 1 root root 20 Sep 19 13:08 /dev/vg/mysql -> /dev/mapper/vg-mysql
# mount | grep mysql
/dev/mapper/vg-mysql on /var/lib/mysql
有了这些信息,您就可以准备创建一个文件系统快照了。
创建、挂载和删除 LVM 快照
您可以使用单个命令创建快照。您只需决定将其放在何处以及为写时复制分配多少空间。不要犹豫使用比您认为需要的空间更多的空间。LVM 不会立即使用您指定的空间;它只是为将来使用保留它,因此保留大量空间是没有害处的,除非您需要同时为其他快照留出空间。
让我们为练习创建一个快照。我们将为写时复制提供 16 GB 的空间,并将其命名为 backup_mysql:
$ lvcreate --size 16G --snapshot --name backup_mysql /dev/vg/mysql
Logical volume "backup_mysql" created
提示
我们故意将卷命名为 backup_mysql 而不是 mysql_backup,以便制表符补全不会引起歧义。这有助于避免制表符补全导致您意外删除 mysql 卷组的可能性。
现在让我们看看新创建的卷的状态:
$ lvs
LV VG Attr LSize Origin Snap% Move Log Copy%
backup_mysql vg swi-a- 16.00G mysql 0.01
home vg -wi-ao 40.00G
mysql vg owi-ao 225.00G
tmp vg -wi-ao 10.00G
var vg -wi-ao 10.00G
注意快照的属性与原始设备的属性不同,并且显示会显示一些额外信息:其来源以及当前用于写时复制的分配的 16 GB 中使用了多少。在进行备份时监视这一点是个好主意,这样您就可以看到设备是否即将满,即将失败。您可以交互式地监视设备状态,也可以使用监控系统(如 Nagios):
$ watch 'lvs | grep backup'
正如您之前从 mount 的输出中看到的那样,mysql 卷包含一个文件系统。这意味着快照卷也包含一个文件系统,您可以像使用任何其他文件系统一样挂载和使用它:
$ mkdir /tmp/backup
$ mount /dev/mapper/vg-backup_mysql /tmp/backup
$ ls -l /tmp/backup
total 188880
-rw-r-----. 1 mysql mysql 56 Jul 30 22:16 auto.cnf
-rw-r-----. 1 mysql mysql 475 Jul 30 22:31 binlog.000001
-rw-r-----. 1 mysql mysql 156 Jul 30 22:31 binlog.000002
-rw-r-----. 1 mysql mysql 32 Jul 30 22:31 binlog.index
-rw-------. 1 mysql mysql 1676 Jul 30 22:16 ca-key.pem
-rw-r--r--. 1 mysql mysql 1120 Jul 30 22:16 ca.pem
-rw-r--r--. 1 mysql mysql 1120 Jul 30 22:16 client-cert.pem
-rw-------. 1 mysql mysql 1676 Jul 30 22:16 client-key.pem
... omitted ...
这只是为了练习,所以我们现在使用 lvremove 命令卸载和删除快照:
$ umount /tmp/backup
$ rmdir /tmp/backup
$ lvremove --force /dev/vg/backup_mysql
Logical volume "backup_mysql" successfully removed
使用 LVM 快照进行无锁 InnoDB 备份
当你运行 MySQL 8+ 时,只使用 InnoDB 表,使用 GTIDs 和完全符合 ACID 的模式,进行备份非常容易。在 MySQL 运行时,只需拍摄一个快照,挂载快照,然后将文件复制到备份位置。不需要锁定任何文件,捕获任何输出,或者做任何特殊操作。从这些备份中恢复文件将执行 InnoDB 崩溃恢复,并且 GTID 设置将已知哪些事务已被处理。
为 LVM 备份做计划
最重要的计划是为快照分配足够的空间。我们采取以下方法:
-
请记住,LVM 需要将每个修改的块仅复制到快照一次。当 MySQL 写入原始卷中的块时,它将该块复制到快照,然后在其异常表中记录已复制的块。将来对此块的写入不会导致进一步复制到快照。
-
如果只使用 InnoDB,请考虑 InnoDB 如何写入数据。因为它将所有数据写入两次,至少一半的 InnoDB 写入 I/O 都会写入双写缓冲区、日志文件和其他相对较小的磁盘区域。这些重复使用相同的磁盘块,因此它们会对快照产生初始影响,但之后它们将停止对快照造成写入。
-
接下来,估计您的 I/O 中有多少将写入尚未复制到快照的块,而不是反复修改相同的数据。对您的估计要慷慨。
-
使用 vmstat 或 iostat 收集有关服务器每秒写入多少块的统计信息。
-
测量(或估计)将备份复制到另一个位置需要多长时间:换句话说,您需要保持 LVM 快照打开多长时间。
假设您估计一半的写入将导致写入到快照的写时复制空间,您的服务器每秒写入 10 MB。如果将快照复制到另一台服务器需要一个小时(3,600 秒),则您将需要 1/2 × 10 MB × 3,600 或 18 GB 的快照空间。谨慎起见,还要添加一些额外空间。
有时候,在保持快照打开的同时计算数据变化量是很容易的。
其他用途和替代方案
你可以使用快照不仅仅用于备份。例如,如前所述,它们可以是在潜在危险操作之前进行“检查点”的有用方式。一些系统允许你将快照提升为原始文件系统。这使得回滚到你拍摄快照的时间点变得容易。
文件系统快照并不是获取数据的瞬时副本的唯一方式。另一个选择是 RAID 分离:例如,如果你有一个三盘软件 RAID 镜像,你可以从镜像中移除一块硬盘并单独挂载它。没有写时复制的惩罚,如果需要的话,很容易将这种“快照”提升为源的副本。然而,在将硬盘重新添加到 RAID 集之后,它将需要重新同步。很遗憾,没有免费的午餐。
Percona XtraBackup
XtraBackup 是备份 MySQL 的最流行解决方案之一,原因很充分。它非常灵活,包括备份压缩、加密文件的方式。
XtraBackup 的工作原理
InnoDB 是一个崩溃安全的存储引擎。如果 MySQL 遇到崩溃,它将使用基于重做日志的崩溃恢复模式,以正确地将数据重新上线。Percona XtraBackup��是基于这个设计。当你使用 Percona XtraBackup 进行备份时,它记录日志序列号(LSN),并使用它来对备份文件执行崩溃恢复。它还在特定点进行锁定,以确保关于复制的数据与数据一致。有关更详细的解释,请参考XtraBackup 文档。
这是一个 XtraBackup 过程示例:
$ xtrabackup --backup --target-dir=/backups/
xtrabackup version 8.0.25-17 based on MySQL server 8.0.25 Linux (x86_64)
(revision id: d27028b)
Using server version 8.0.25-15
210821 17:01:40 Executing LOCK TABLES FOR BACKUP…
到目前为止,我们可以看到 XtraBackup 已经确定了 MySQL 的运行版本。这有助于确定它具有什么功能以及如何备份文件。在我们的情况下,LOCK TABLES FOR BACKUP命令可用,并且 XtraBackup 将使用它来锁定表:
210821 17:01:41 [01] Copying ./ibdata1 to /backups/ibdata1
210821 17:01:41 [01] ...done
210821 17:01:41 [01] Copying ./sys/sys_config.ibd to /backups/sys/sys_config.ibd
210821 17:01:41 [01] ...done
210821 17:01:41 [01] Copying ./test/t1.ibd to /backups/test/t1.ibd
210821 17:01:41 [01] ...done
210821 17:01:41 [01] Copying ./foo/t1.ibd to /backups/foo/t1.ibd
210821 17:01:41 [01] ...done
210821 17:01:41 [01] Copying ./sakila/actor.ibd to /backups/sakila/actor.ibd
210821 17:01:41 [01] ...done
XtraBackup 现在正在从源复制文件到目标:
210821 17:01:42 Finished backing up non-InnoDB tables and files
210821 17:01:42 Executing FLUSH NO_WRITE_TO_BINLOG BINARY LOGS
210821 17:01:42 Selecting LSN and binary log position from p_s.log_status
210821 17:01:42 [00] Copying /var/lib/mysql/binlog.40 to /backups/binlog.04
up to position 156
210821 17:01:42 [00] ...done
210821 17:01:42 [00] Writing /backups/binlog.index
210821 17:01:42 [00] ...done
210821 17:01:42 [00] Writing /backups/xtrabackup_binlog_info
210821 17:01:42 [00] ...done
复制文件完成后,它收集复制信息:
210821 17:01:42 Executing FLUSH NO_WRITE_TO_BINLOG ENGINE LOGS...
xtrabackup: The latest check point (for incremental): '35005805'
xtrabackup: Stopping log copying thread at LSN 35005815.
210821 17:01:42 >> log scanned up to (35005825)
Starting to parse redo log at lsn = 35005460
210821 17:01:43 Executing UNLOCK TABLES
210821 17:01:43 All tables unlocked
现在 XtraBackup 已经确定了 InnoDB 的最新检查点。这将帮助它应用备份期间发生的写操作。它使用UNLOCK TABLES释放之前的LOCK TABLES FOR BACKUP命令:
210821 17:01:43 [00] Copying ib_buffer_pool to /backups/ib_buffer_pool
210821 17:01:43 [00] ...done
210821 17:01:43 Backup created in directory '/backups/'
MySQL binlog position: filename 'binlog.000004', position '156'
210821 17:01:43 [00] Writing /backups/backup-my.cnf
210821 17:01:43 [00] ...done
210821 17:01:43 [00] Writing /backups/xtrabackup_info
210821 17:01:43 [00] ...done
xtrabackup: Transaction log of lsn (35005795) to (35005835) was copied.
210821 17:01:44 completed OK!
最后的步骤是记录 LSN,复制缓冲池转储,并写出最终文件。其中一个是my.cnf文件的副本,xtrabackup_info文件包含关于备份的元数据,如 MySQL UUID、服务器版本和 XtraBackup。
示例用法
我们已经突出显示了如何以常见方式使用 XtraBackup 的一些基本方法,但在此之前有一些注意事项:
-
你的 MySQL 安装应该使用密码进行保护。确保你使用
--user和--password选项指定一个具有足够权限进行备份的帐户。 -
XtraBackup 在输出中也非常详细。我们已经削减了输出以突出每种用例的最重要部分。
-
如往常一样,在运行任何命令之前,请查阅 Percona XtraBackup 的官方手册,因为语法和选项可能会发生变化。尽管我们不知道与该工具相关的任何数据丢失,但在尝试处理关键数据之前,你应该在非生产备份上进行测试。
基本备份到目录
我们想展示的第一种方法是如何使用 XtraBackup 将数据完整备份到另一个目录。这使你可以选择之后如何处理数据,可以是另一个磁盘、同一磁盘上的目录,或者更大的备份服务器上挂载的文件共享。请记住,进行这种完整备份将需要适当的空间来复制文件。
这是 XtraBackup 的最基本用法,指定模式(备份)和备份文件的位置(target-dir):
$ xtrabackup --backup --target-dir=/backups/
一旦执行,输出将类似于“XtraBackup 如何工作”下的内容。如果成功,/backups目录将包含完整的数据副本。
流式备份
将所有文件复制到新目录可能不是最理想的用例。有时在一个目录中保留多个备份更容易。这就是流式备份选项有用的地方。流式备份允许您将备份写入一个单个文件:
$ xtrabackup --backup --stream=xbstream > /backups/backup.xbstream
在这种用法中,我们仍然指定了backup模式,并删除了target-dir选项,因为输出将到STDOUT。然后我们将其重定向到文件中。
请注意,您还可以使用 Bash shell 命令和日期一起使用,将时间戳包含在输出文件名中,如下所示:
$ xtrabackup --backup --stream=xbstream > /backups/backup-$(date +%F).xbstream
这将像以前一样运行整个备份过程,使用<STDOUT>作为目标。内容将被写入/backups中的xbstream文件。
使用压缩备份
正如我们之前提到的,您需要足够的空间来制作整个数据文件的副本,或者足够的空间来存储单个xbstream文件。减轻空间需求的一个常见选项是使用 XtraBackup 的压缩功能:
$ xtrabackup --backup --compress --stream=xbstream > /backups/backup-
compressed.xbstream
您会注意到,每个表现在不再显示“Streaming”,而是报告“Compressing and streaming”。在我们的测试中,我们加载了 Sakila 示例数据库,并观察到一个 94 MB 未压缩的xbstream文件变成了一个 6.5 MB 压缩文件。
使用加密备份
我们要涵盖的最后一个示例是将加密作为备份策略的一部分。使用加密将使用更多的 CPU,并且您的备份过程将需要更长时间;然而,考虑到备份是一个轻松获取大量数据的目标,这可能是一个可以接受的权衡。我们再次使用备份模式和流式传输,但我们使用encrypt与密码和encrypt-key-file指向密钥的位置:
$ xtrabackup --backup --encrypt=AES256 --encrypt-key-
file=/safe/key/location/encrypt.key --stream=xbstream > /backups/backup-
encrypted.xbstream
我们的输出再次发生变化,对每个文件报告“加密和流式传输”。
请注意,您还可以使用--encrypt-key并在命令行上指定它。我们不建议这样做,因为密钥将在进程列表中暴露,或作为 Linux 上 /proc 文件系统的一部分。
其他重要标志
您需要注意的一个方面是备份完成所需的时间。为了帮助解决这个问题,请查看--parallel和-compress-threads选项。使用这些选项将增加 CPU 使用率,但应该减少备份所需的总时间。加密也有类似的并行化选项。
如果您有大量数据库和表,可以查看--rsync以优化文件复制过程。
从备份中恢复
如何恢复数据取决于您如何备份数据。您可能需要执行以下一些或全部步骤:
-
停止 MySQL 服务器。
-
记下服务器的配置和文件权限。
-
将数据从备份中移动到 MySQL 数据目录。
-
进行配置更改。
-
更改文件权限。
-
以有限访问权限重新启动服务器,并等待其完全启动。
-
重新加载逻辑备份文件。
-
检查并重放二进制日志。
-
验证您已恢复的内容。
-
以完全访问权限重新启动服务器。
我们将根据需要演示如何执行这些步骤。我们还将在本章后面关于这些方法或工具的部分中添加特定于某些备份方法或工具的注释。
注意
如果有可能需要文件的当前版本,请不要用备份文件替换它们。例如,如果您的备份包括二进制日志,并且您需要重放二进制日志以进行时间点恢复,请不要用备份中的旧副本覆盖当前的二进制日志。如有必要,请重命名它们或将它们移动到其他位置。
在恢复过程中,通常很重要的是使 MySQL 对除恢复过程之外的所有内容都不可访问。我们喜欢使用--skip-networking和--socket=/tmp/mysql_recover.sock选项启动 MySQL,以确保在我们检查并重新上线之前,它对现有应用程序不可用。这对于逻辑备份尤为重要,因为它们是分段加载的。
恢复逻辑备份
如果你正在恢复逻辑备份而不是原始文件,你需要使用 MySQL 服务器本身将数据加载回表中,而不是使用操作系统简单地将文件复制到指定位置。
然而,在加载那个转储文件之前,花点时间考虑一下它有多大,加载需要多长时间,以及在开始之前可能想要做的任何事情,比如通知用户或禁用应用程序的某些部分。禁用二进制日志可能是个好主意,除非你需要将恢复复制到副本:一个巨大的转储文件对服务器来说已经足够难以加载了,将其写入二进制日志会增加更多(可能是不必要的)开销。加载巨大文件也会对某些存储引擎产生影响。例如,一次性将 100 GB 的数据加载到 InnoDB 中不是个好主意,因为会产生巨大的回滚段。你应该分批加载并在每个批次后提交事务。
你可能会进行两种类型的恢复,这对应于你可以进行的两种逻辑备份。
如果你有一个 SQL 转储文件,文件将包含可执行的 SQL。你只需要运行它。假设你将 Sakila 示例数据库和模式备份到一个文件中,以下是你可能用来恢复的典型命令:
$ mysql < sakila-backup.sql
你也可以在 mysql 命令行客户端中使用 SOURCE 命令加载文件。虽然这基本上是以不同的方式做同样的事情,但它使一些事情变得更容易。例如,如果你是 MySQL 中的管理员用户,你可以关闭你的客户端连接中将执行的语句的二进制日志记录,然后加载文件而无需重新启动 MySQL 服务器:
SET SQL_LOG_BIN = 0;
SOURCE sakila-backup.sql;
SET SQL_LOG_BIN = 1;
如果你使用 SOURCE,请注意,错误不会中止一批语句,而当你将文件重定向到 mysql 时,默认会中止一批语句。
如果你压缩了备份文件,不要分别解压缩和加载它。相反,解压缩并一次性加载它。这样速度会快得多:
$ gunzip -c sakila-backup.sql.gz | mysql
如果你只想恢复单个表(例如 actor 表)怎么办?如果你的数据没有换行符,如果模式已经存在,恢复数据并不难:
$ grep 'INSERT INTO `actor`' sakila-backup.sql | mysql sakila
或者,如果文件被压缩了:
$ gunzip -c sakila-backup.sql.gz | grep 'INSERT INTO `actor`'| mysql sakila
如果你需要创建表以及恢复数据,并且整个数据库都在一个文件中,你将不得不编辑该文件。这就是为什么有些人喜欢将每个表转储到自己的文件中。大多数编辑器无法处理巨大的文件,特别是如果它们被压缩了。此外,你不想实际编辑文件本身;你只想提取相关行,因此你可能需要进行一些命令行工作。使�� grep 只提取给定表的 INSERT 语句很容易,就像我们在之前的命令中所做的那样,但要获取 CREATE TABLE 语句就比较困难。这里有一个 sed 脚本,可以提取你需要的段落:
$ sed -e '/./{H;$!d;}' -e 'x;/CREATE TABLE `actor`/!d;q' sakila-backup.sql
这相当神秘,我们承认。如果你必须做这种工作来恢复数据,那么你的备份设计很差。通过一点规划,可以避免你陷入恐慌并试图弄清楚 sed 如何工作的情况。只需将每个表备份到自己的文件中,或者更好的是,分别备份数据和模式。
从快照中恢复原始文件
恢复原始文件往往相当简单,这也就意味着选项不多。这可能是好事,也可能是坏事,取决于你的恢复需求。通常的做法就是简单地将文件复制到指定位置。
如果你正在恢复传统的 InnoDB 设置,其中所有表都存储在单个表空间中,你需要关闭 MySQL,复制或移动文件到指定位置,然后重新启动。你还需要确保 InnoDB 的事务日志文件与其表空间文件匹配。如果文件不匹配,例如,如果你替换了表空间文件但没有替换事务日志文件,InnoDB 将拒绝启动。这是备份事务日志和数据文件一起备份至关重要的原因之一。
如果你正在使用 InnoDB 的单表文件特性(innodb_file_per_table),InnoDB 将每个表的数据和索引存储在一个.ibd文件中。你可以通过复制这些文件来备份和恢复单个表,而且你可以在服务器运行时执行此操作,但并不是很简单。这些单独的文件与整个 InnoDB 不是独立的。每个.ibd文件都有内部信息告诉 InnoDB 文件与主(共享)表空间的关系。当你恢复这样一个文件时,你必须告诉 InnoDB“导入”文件。
在 MySQL 手册关于使用单表表空间的部分中,有许多限制,你可以阅读。最大的限制是你只能将表恢复到备份它的服务器上。在这种配置下备份和恢复表并不是不可能的,但比你想象的要棘手。
所有这些复杂性意味着恢复原始文件可能非常繁琐,很容易出错。一个好的经验法则是,恢复过程变得越困难和复杂,你就越需要通过逻辑备份来保护自己。始终保留逻辑备份是一个好主意,以防出现问题,无法说服 MySQL 使用你的原始备份。
使用 Percona XtraBackup 进行恢复
在“XtraBackup 工作原理”部分中,我们提到它使用 InnoDB 的崩溃恢复过程来进行安全备份。这意味着为了使用用 XtraBackup 备份的文件,我们需要经过额外的步骤。
如果你使用了流式备份,你需要先解压xbstream文件。对于xbstream,你可以使用xbstream命令来提取:
$ xbstream -x < backup.xbstream
这将把所有文件提取到当前位置,或者你可以使用-C选项在之前更改到特定目录。如果你使用了压缩或加密,你可以使用类似的选项来反向操作。对于压缩文件,使用--decompress,对于加密文件,使用--decrypt,同时指定--encrypt-key-file位置:
$ xbstream -x --decompress < backup-compressed.xbstream
$ xbstream -x --decrypt --encrypt-key-file=/safe/key/location/encrypt.key
< backup-encrypted.xbstream
完成后,下一步是准备文件。准备是实际执行崩溃恢复操作并确保你正在恢复所有数据的过程:
$ xtrabackup --prepare --target-dir=/restore
提示
如果你没有使用流模式,你可以在备份后执行准备阶段。这将导致备份一个准备好的备份,并减少恢复时需要做的工作量。
完成并成功后,你现在可以使用这些文件来启动 MySQL 了:
$ xtrabackup --move-back --target-dir=/restore
提示
你可以使用xtrabackup的--copy-back或--move-back标志将文件正确复制或移动到指定位置。
XtraBackup 将自动检测你的 MySQL 安装中的data-dir变量,并将文件移动到正确的位置。
在恢复原始文件后启动 MySQL
在启动你要恢复的 MySQL 服务器之前,有一些事情你需要在之前做。
第一件最重要的事情,也是最容易忘记的事情之一,就是在尝试启动 MySQL 服务器之前检查服务器的配置,并确保恢复的文件具有正确的所有者和权限。这些属性必须完全正确,否则 MySQL 可能无法启动。这些属性因系统而异,因此请查看你的笔记以了解你需要设置什么。通常你希望mysql用户和组拥有这些文件和目录,你希望这些文件和目录对该用户和组可读可写,但对其他用户不可读写。
我们还建议在服务器启动时监视 MySQL 错误日志。在类 Unix 系统上,你可以这样监视文件:
$ tail -f /var/log/mysql/mysql.err
错误日志的确切位置会有所不同。一旦你监视了文件,你可以启动 MySQL 服务器并观察错误。如果一切顺利,一旦 MySQL 启动,你将拥有一个完美恢复的服务器。
在较新的 MySQL 版本中,监视错误日志更加重要。即使服务器似乎没有问题地启动,你也应该在每个数据库中运行SHOW TABLE STATUS,然后再次检查错误日志。
总结
每个人都知道他们需要备份,但并非每个人都意识到他们需要可恢复的备份。有许多设计备份的方式与你的恢复需求相矛盾。为了避免这个问题,我们建议你定义和记录你的 RPO 和 RTO,并在选择备份系统时使用这些要求。
定期测试恢复并确保其正常运行非常重要。很容易设置mysqldump并让其每晚运行,而没有意识到随着时间的推移,你的数据可能增长到需要花费数天甚至数周才能再次导入的程度。发现你的恢复需要多长时间的最糟糕时机是在你真正需要它的时候。一个在几小时内完成的备份可能需要数周才能恢复,这取决于你的硬件、架构、索引和数据。
不要陷入认为副本就是备份的陷阱。它是获取备份的一种较少侵入性的来源,但它不是备份。同样适用于你的 RAID 卷、SAN 和文件系统快照。确保你的备份能通过DROP TABLE测试(或“我被黑了”测试),以及失去数据中心的测试。如果你从副本中获取备份,请确保你的副本是一致的,通过从源重新构建它们并从那时起强制执行super_read_only。
毫无疑问,我们首选的备份方式是使用 Percona XtraBackup 进行原始备份,使用mydumper进行逻辑备份。这两种技术都可以让你获取非侵入性的二进制(原始)数据备份,然后你可以通过启动一个mysqld实例并检查表来验证这些备份。有时你甚至可以一举两得:通过将备份恢复到你的开发或测试服务器,每天测试恢复。你还可以从该实例中导出数据以创建逻辑备份。我们还喜欢备份二进制日志,并保留足够多的备份和二进制日志的生成,以便进行恢复或设置新的副本,即使最近的备份无法使用。
¹ 原始备份也可能被误称为物理备份,意思是你正在将物理文件移动到备份目的地。我们说“令人费解”,因为文件本身根本不是物理的!
² 由mysqldump生成的逻辑备份并不总是文本文件。SQL 转储可以包含许多不同的字符集,甚至可能包含不是有效字符数据的二进制数据。对于许多编辑器来说,行可能太长了。尽管如此,许多这样的文件将包含文本编辑器可以打开和阅读的数据,特别是如果你使用--hex-blob选项运行mysqldump。
³ 请记住,尽管转储的数据是与引擎无关的,但存储引擎的特性可能不兼容。例如,你不能转储定义了外键关系的 InnoDB 数据库,并期望这些外键在不实现它们的引擎中起作用。
⁴ 根据我们的经验,逻辑备份通常比原始备份小,但并非总是如此。
⁵ 值得一提的是,原始备份更容易出现错误;很难超越mysqldump的简单性。
⁶ Percona XtraBackup 正在开发一个“真正”的增量备份功能。它将能够备份已更改的块,而无需扫描每个块。
第十一章:扩展 MySQL
在个人项目中运行 MySQL,甚至在年轻公司中运行 MySQL,与在市��已经建立并且“呈现指数增长”业务中运行 MySQL 大不相同。在高速业务环境中,流量可能每年增长数倍,环境变得更加复杂,伴随的数据需求迅速增加。扩展 MySQL 与其他类型的服务器大不相同,主要是因为数据的有状态性质。将其与 Web 服务器进行比较,后者的广泛接受的模型是在负载均衡器后面添加更多服务器通常是您需要做的全部。
在本章中,我们将解释什么是扩展,并引导您了解可能需要扩展的不同方面。我们探讨了为什么读取扩展是必不可少的,并向您展示如何安全地实现它,使用诸如排队等策略使写入扩展更可预测。最后,我们涵盖了使用诸如 ProxySQL 和 Vitess 这样的工具对数据集进行分片以扩展写入。通过本章结束时,您应该能够确定系统具有什么季节性模式,如何扩展读取以及如何扩展写入。
什么是扩展?
扩展是系统支持不断增长的流量的能力。系统是否扩展良好或扩展不佳的标准可以通过成本和简单性来衡量。如果增加系统的扩展能力过于昂贵或复杂,您可能会在遇到限制时花费更多精力来解决这个问题。
容量是一个相关概念。系统的容量是它在给定时间内可以执行的工作量。¹ 但是,容量必须加以限定。系统的最大吞吐量与其容量不同。大多数基准测试衡量系统的最大吞吐量,但您不能过度推动真实系统。如果这样做,性能将下降,响应时间将变得不可接受地长且不稳定。我们将系统的实际容量定义为它在仍然提供可接受性能的情况下可以实现的吞吐量。
容量和可扩展性与性能无关。您可以将其比作高速公路上的车辆:
-
系统就是高速公路,上面有所有的车道和车辆。
-
性能是车辆的速度。
-
容量是车道数乘以最大安全速度。
-
可扩展性是您可以增加更多车辆和更多车道而不会减慢交通的程度。
在这个类比中,可扩展性取决于诸如立交设计的优良程度、有多少车辆发生事故或抛锚、车辆是否以不同的速度行驶或频繁变道等因素,但通常,可扩展性并不取决于车辆引擎的强大程度。这并不是说性能不重要,因为它确实重要。我们只是指出,即使系统性能不高,系统也可以具有可扩展性。
从 50,000 英尺高度来看,可扩展性是通过增加资源来增加容量的能力。
即使您的 MySQL 架构是可扩展的,您的应用程序可能并非如此。如果由于任何原因增加容量很困难,那么您的应用程序整体上就不具备可扩展性。我们先前用吞吐量来定义容量,但从同样的 50,000 英尺高度来看容量也是值得一提的。从这个角度来看,容量简单地意味着处理负载的能力,从几个不同的角度来看负载是有用的:
数据量
您的应用程序可以累积的数据量是最常见的扩展挑战之一。这对今天许多 Web 应用程序来说尤为重要,这些应用程序从不删除任何数据。例如,社交网络网站通常不会删除旧消息或评论。
用户数量
即使每个用户只有少量数据,如果用户数量很多,数据量会累积起来——而且数据量可能比用户数量增长更快。许多用户通常意味着更多的交易,而交易数量可能与用户数量不成比例。最后,许多用户(和更多数据)可能意味着越来越复杂的查询,特别是如果查询依赖于用户之间的关系数量。 (关系数量受到限制,为(N × (N – 1)) / 2,其中N是用户数量。)
用户活动
并非所有用户活动都是相同的,用户活动也不是恒定的。如果你的用户突然变得更活跃——例如因为他们喜欢的新功能——你的负载可能会显著增加。用户活动不仅仅是页面浏览的数量。即使页面浏览的数量相同,如果需要大量工作才能生成的站点部分变得更受欢迎,那么同样数量的页面浏览可能会导致更多的工作。一些用户比其他用户更活跃:他们可能比普通用户拥有更多的朋友、消息或照片。
相关数据集的大小
如果用户之间存在关系,应用程序可能需要在整个相关用户组上运行查询和计算。这比仅仅处理个别用户及其数据更复杂。社交网络网站经常面临由于拥有许多朋友的热门群体或用户而带来的挑战。
扩展挑战可能以多种形式出现。在下一节中,我们将讨论如何确定瓶颈所在以及如何解决。
读取受限与写入受限的工作负载
在考虑扩展数据库架构时,你应该首先检查的是你是在扩展读取受限的工作负载还是写入受限的工作负载。读取受限的工作负载是指读取流量(SELECT)超过了服务器容量的情况。写入受限的工作负载超过了服务器提供 DML(INSERT,UPDATE,DELETE)的能力。了解你所面临的情况涉及了解你的工作负载。
理解你的工作负载
数据库工作负载包括很多方面。首先,它是你的容量,或者正如我们之前提到的,是单位时间内的工作量。对于数据库来说,这通常归结为每秒查询数。工作负载的一个定义可能是系统可以执行多少 QPS。然而,不要被这个迷惑。20%的 CPU 下的一千个 QPS 并不总是意味着你可以再增加四千个 QPS。并非每个查询都是相同的。
查询有各种形式:读取、写入、主键查找、子查询、连接、批量插入等等。每种查询都有与之相关的成本。这个成本以 CPU 时间或延迟来衡量。当一个查询在磁盘上等待返回信息时,这段时间会增加成本。² 了解你的资源容量是很重要的。你有多少个 CPU,你的磁盘的读取和写入 IOPS 和吞吐量限制是多少,你的网络吞吐量是多少?每个因素都会对延迟产生影响,而延迟直接关系到你的工作负载。
工作负载是所有类型查询及其延迟的混合。更公平的说法是,如果我们在 20%的 CPU 上处理一千个 QPS,我们可以再增加四千个 QPS,只要它们的延迟相同。³ 如果我们引入四千个额外的查询并且遇到磁盘 IOPS 瓶颈,所有读取的延迟都会增加。
如果您只能访问基本系统指标,如 CPU、内存和磁盘,几乎不可能理解您正在受到哪些限制。您需要确定您的读取与写入性能如何。我们在“检查读取与写入性能”中提供了一个示例,在第三章中。使用该示例,您可以确定读取与写入的延迟。如果您随时间趋势这些数字,您可以看到您的读取或写入延迟是否增加,因此您可能受到限制的地方。
读取限制工作负载
假设,在设计产品时,您采用了一个源主机用于所有数据库流量的捷径。增加更多应用节点可能会扩展为客户端提供请求,但最终将受到您的单一源数据库主机响应这些读取请求的能力的限制。这的主要指标是 CPU 利用率。高 CPU 意味着服务器花费所有时间处理查询。CPU 利用率越高,您在查询中看到的延迟就越多。然而,这并不是唯一的指标。您还可以看到大量的磁盘读取 IOPS 或吞吐量,表明您经常访问磁盘或从磁盘读取大量行。
通过添加索引、优化查询和缓存可缓存的数据,您可以最初改善这一点。一旦您没有更多的改进空间,您将面临一个读取限制的工作负载,这就是使用副本扩展读取流量的时候。我们将在本章后面讨论如何使用读取副本池扩展您的读取量,如何为这些池运行健康检查,以及在开始使用该架构时要避免的陷阱。
写入限制工作负载
您可能也遇到了写入限制的负载。以下是一些写入限制数据库负载的示例:
-
可能注册人数正在呈指数增长。
-
现在是高峰电子商务季节,销售额正在增长,订单数量也在增加。
-
现在是选举季节,您有很多竞选通讯要发出。
所有这些都是导致更多数据库写入的业务用例,现在您必须扩展。再次强调,即使您可以在一段时间内垂直扩展单一源数据库,但只能走得这么远。当瓶颈是写入量时,您必须开始考虑如何拆分数据,以便可以在不同的子集上并行接受写入。我们将在本章后面讨论如何为写入扩展而进行分片。
在这一点上,问一下“如果我看到两种类型的增长怎么办?”是很合理的。重要的是仔细检查您的模式,并确定是否有一组表在读取方面增长得比另一组表在写入需求方面增长得更快。尝试同时为两者扩展数据库集群会带来很多痛苦和事件。我们建议将表分开放入不同的功能集群中,以独立扩展读取和写入;这是更有效地扩展读取流量的先决条件。
现在您已经确定了您的负载是读取限制还是写入限制,我们将讨论如何以有效的方式帮助引导数据的功能拆分。
功能分片
根据业务中的“功能”拆分数据是一个需要深入了解数据的上下文密集型任务。这与流行的软件架构范式如面向服务的架构(SOA)和微服务紧密相关。并非所有功能方法都是平等的,在一个夸张的例子中,如果您将每个表放入自己的“功能”数据库中,您可能会通过过多的碎片化使一切变得更糟。
您如何处理将大型单体/混合关注点数据库拆分为一组合理的较小集群,以帮助业务扩展?以下是一些需要牢记的指导原则:
-
不要根据工程团队的结构进行拆分。这种情况总会在某个时候发生变化。
-
根据业务功能拆分表格。用于账户注册的表格可以与用于托管现有客户设置的表格分开,用于支持新功能的表格应该从其自己的数据库开始。
-
不要回避处理数据中混合了不同业务关注点的地方,并且您需要倡导不仅进行数据分离,还要进行应用重构,并在这些边界之间引入 API 访问。我们见过的一个常见例子是将客户身份与客户账单混合在一起。
起初,会有一些明显具有自己业务功能和访问模式的表格,因此很容易将其拆分到一个单独的集群中,但随着进展,这种分离会变得更加微妙。
现在我们已经根据业务功能以周到的方式拆分了数据,让我们谈谈如何使用副本读取池来扩展读取负载。
使用只读池扩展读取
集群中的副本可以担任多个目的。首先,当当前源需要出于任何原因停止服务时,它们是写入故障转移的候选者,无论是计划的还是非计划的。但由于这些副本也在不断运行更新以匹配源中的数据,您也可以使用它们来提供读取请求。
在图 11-1 中,我们首先看一下具有只读副本池的新设置的可视化效果。

图 11-1. 应用节点使用虚拟 IP 访问只读副本
为了简单起见,我们将假装应用节点仍通过直接连接到源数据库来完成写请求。稍后我们将讨论如何更好地连接到源节点。请注意,尽管如此,相同的应用节点连接到一个虚拟 IP,该虚拟 IP 充当它们与只读副本之间的中间层。这是一个副本读取池,这是如何将不断增长的读取负载分散到多个主机的方法。您可能还注意到,并非所有副本都在池中。这是一种常见的方法,用于防止不同的读取工作负载相互影响。如果您的报告流程或备份流程倾向于消耗所有磁盘 I/O 资源并导致复制延迟,您可以略过一个或多个副本节点来执行这些任务,并将其排除在为客户端流量提供服务的读取池之外。或者,您可以通过将复制检查与负载均衡器健康检查相结合,自动将落后的备份节点从池中移除,并在其赶上时重新引入。当应用程序与一个用于读取的单一节点通信,并且您可以无缝管理这些资源而不影响客户时,将您的只读副本转换为可互换资源的灵活性将显著增加。
现在有多个数据库主机用于提供读取请求,对于顺利的生产运行,有一些事项需要考虑:
-
如何将流量路由到所有这些只读副本?
-
如何均匀分配负载?
-
如何运行健康检查并移除不健康或滞后的副本,以避免提供陈旧数据?
-
如何避免意外删除所有节点,导致应用流量受到更大的损害?
-
如何主动地为维护目的手动移除服务器?
-
如何将新配置的服务器添加到负载均衡器?
-
有哪些自动化检查措施可以避免在新配置的节点准备就绪之前将其添加到负载均衡器?
-
你对“准备好接受新节点”的定义是否足够具体?
管理这些读取池的一种非常常见的方式是使用负载均衡器运行一个充当所有流向读取副本的流量的中间人的虚拟 IP。执行此操作的技术包括 HAProxy,如果您自己托管,则为硬件负载均衡器,如果在公共云环境中运行,则为网络负载均衡器。在使用 HAProxy 的情况下,所有应用主机将连接到那个“前端”,而 HAProxy 负责将这些请求定向到后端定义的多个读取副本之一。以下是一个定义虚拟 IP 前端并将其映射到多个读取副本作为后端池的示例 HAProxy 配置文件:
global
log 127.0.0.1 local0 notice
user haproxy
group haproxy
defaults
log global
retries 2
timeout connect 3000
timeout server 5000
timeout client 5000
listen mysql-readpool
bind 127.0.0.1:3306
mode tcp
option mysql-check user haproxy_check
balance leastconn
server mysql-1 10.0.0.1:3306 check
server mysql-2 10.0.0.2:3306 check
通常,您使用配置管理自动填充此类文件。在此配置中有几点需要注意。在 MySQL 中,使用leastconn在池节点之间进行负载均衡是推荐的方式。在负载升高时使用roundrobin等随机负载均衡方式将无法帮助您使用未过载的主机。确保在 MySQL 实例上创建了适当的数据库用户来运行此健康检查,否则所有节点都将被标记为不健康。
促进分片的工具,如 Vitess 和 ProxySQL,也可以充当负载均衡器。我们将在本章末讨论这些工具。
为读取池管理配置
现在您在应用节点和副本之间有了一个“门”,您需要一种方法来轻松管理包含或不包含在此读取池中的节点,使用您选择的负载均衡器。您不希望这是一个手动管理的配置。您已经在扩展到大量数据库实例的轨道上,手动管理配置文件将导致错误、响应时间变慢、主机故障,并且根本无法扩展。
服务发现是一个很好的选择,可以自动发现可以在此列表中的主机。这可能意味着将服务发现解决方案部署为技术堆栈的一部分,或者依赖于云提供商提供的托管服务发现选项(如果有的话)。在这里要小心的重要事项是非常明确地指出使读取副本有资格进入此读取池的标准。理想情况下,您应该排除源节点和可能专门用于报告的一个或多个副本。但也许您需要更复杂的东西,其中副本进一步分段以服务不同的应用程序读取负载?我们建议每个池中至少有三个节点的副本,除了您的备份/报告服务器和源节点。
无论您是自己运行服务发现⁴还是使用云提供商提供的服务,您都应该了解该服务的保证。以下是一些需要考虑的事项,无论您是运行服务发现还是与团队合作:
-
它有多快能检测到主机的故障?
-
数据传播速度有多快?
-
当数据库实例发生故障时,负载均衡器上的配置会如何刷新?
-
数据库成员的更改是作为后台进程进行,还是需要中断现有连接?
-
如果服务发现本身出现故障会发生什么?这会影响任何新的数据库连接还是只会影响更改负载均衡器成员资格?在那时,您可以手动进行更改吗?
灵活性带来复杂性,您必须在生产中平衡两者以获得最佳结果。您的工作是始终将决策与正在追求的 SLI 和 SLO 联系在一起,而不是实现神话般的 100%的正常运行时间目标。
现在您知道如何填充配置并在主机进出时更新它们,现在是时候讨论如何为副本读取池的成员运行健康检查了。
读取池的健康检查
在这一点上,您需要考虑什么标准可以认为读副本是健康的并准备好接受应用程序的读取流量。这些标准可以简单到“数据库进程正在运行,端口响应”,也可以变得更加复杂,比如“数据库正在运行,复制滞后不超过 30 秒,读查询运行的延迟不高于 100 毫秒”。
提示
检查变量read_only和super_read_only的状态,以确保负载均衡器的读取池中的所有成员实际上都是副本。
决定进行到何种程度的健康检查应该是与您的应用程序开发团队进行讨论的一个过程,以便每个人都了解并对他们在从数据库读取时期望的行为达成一致。以下是一些可以帮助引导这一决策过程的团队提出的问题:
-
可接受多少数据陈旧?如果返回的数据几分钟前的,会有什么影响?
-
应用程序的最大可接受查询延迟是多少?
-
读查询是否存在任何重试逻辑,如果存在,是否是指数退避?
-
我们是否已经为应用程序设定了 SLO?该 SLO 是否延伸到查询延迟或仅涉及正常运行时间?
-
在没有这些数据的情况下,系统会如何表现?这种退化是否可接受?如果是,那么持续多久?
在许多情况下,您只需使用端口检查即可确认 MySQL 进程正在运行且可以接受连接。这意味着只要数据库在运行,它将成为该池的一部分并提供服务请求。
然而,有时您可能需要更复杂的东西,因为涉及的数据集足够关键,您不希望在复制滞后超过几秒或根本没有运行复制时提供服务。对于这些情况,您仍然可以使用读取池,但可以通过 HTTP 检查来增强健康检查。其工作原理是您选择的负载均衡器将运行一个命令(通常是一个脚本),并根据响应代码确定节点是否健康。例如,在 HAProxy 中,后端将具有类似以下代码行:
option httpchk GET /check-lag
这一行意味着对于读取池中的每个主机,负载均衡器将使用GET调用调用路径/check-lag并检查响应代码。该路径运行一个脚本,其中包含有关可接受的滞后程度的逻辑。该脚本将现有的滞后状态与该阈值进行比较,并根据情况,负载均衡器将考虑副本是否健康。
警告
即使健康检查是一个强大的工具,也要小心使用那些具有复杂逻辑的(比如之前描述的滞后检查),并确保你有一个计划,以防池中的所有副本都未通过健康检查。您可以拥有一个静态的“备用”池,用于某些全局故障(例如,整个集群滞后),以避免意外破坏所有读取请求。有关一家公司如何实施此功能的更多详细信息,请参阅GitHub 博客上的这篇文章。
选择负载均衡算法
有许多不同的算法来确定哪个服务器应该接收下一个连接。每个供应商使用不同的术语,但这个列表应该提供了一个可用的想法:
随机
负载均衡器将每个请求定向到从可用服务器池中随机选择的服务器。
轮询
负载均衡器将请求发送到服务器的重复序列:A、B、C、A、B、C,依此类推。
最少连接
下一个连接将发送到活动连接最少的服务器。
最快响应
处理请求速度最快的服务器将接收下一个连接。当池中包含快速和慢速机器时,这种方法可以很好地运作。然而,在 SQL 中,当查询复杂性差异很大时,这就变得非常棘手。即使是相同的查询在不同情况下表现也会有很大差异,比如当它从查询缓存中提取时,或者当服务器的缓存已经包含所需数据时。
哈希
负载均衡器对连接的源 IP 地址进行哈希处理,将其映射到池中的一个服务器。每当来自相同 IP 地址的连接请求时,负载均衡器都会将其发送到同一台服务器。只有当池中的机器数量发生变化时,绑定才会更改。
加权
负载均衡器可以结合并增加其他算法的权重。例如,您可能有单 CPU 和双 CPU 的机器。双 CPU 的机器大约是单 CPU 的两倍强大,因此您可以告诉负载均衡器向它们发送大约两倍数量的请求。
对于 MySQL 来说,最佳算法取决于您的工作负载。例如,最小连接算法可能会在将新服务器添加到可用服务器池之前,使新服务器过载。
您需要进行实验,找到最适合您工作负载的性能。一定要考虑在特殊情况下发生的情况,以及日常规范下会发生的情况。在那些特殊情况下,例如在高查询负载时,进行模式更改时,或者在异常数量的服务器下线时,您最不希望出现严重问题。
我们这里只描述了即时配置算法,不排队连接请求。有时使用排队的算法可能更有效。例如,一个算法可能在数据库服务器上维持给定的并发性,比如同时允许不超过N个活动事务。如果有太多活动事务,算法可以将新请求放入队列,并从符合条件的第一个“可用”服务器提供服务。一些连接池支持排队算法。
现在我们已经讨论了如何扩展读取负载以及如何进行健康检查,是时候讨论扩展写入了。在直接寻找如何扩展写入之前,您可以查看排队可以使写入流量增长更可控的地方。让我们讨论一下排队如何帮助扩展您的写入性能。
排队
当使用一个偏向一致性而非可用性的数据存储来扩展写事务时,应用层的扩展变得更加复杂。更多的应用节点写入一个源节点将导致数据库系统更容易受到锁超时、死锁和失败写入的影响,必须重试。所有这些最终将导致面向客户的错误或不可接受的延迟。
在研究接下来我们将讨论的数据分片之前,您应该检查数据中的写入热点,并考虑是否所有写入都真正需要主动持久化到数据库。一些写入可以放入队列,并在可接受的时间范围内写入数据库吗?
假设您有一个存储大量客户历史数据的数据库。客户偶尔发送 API 请求来检索这些数据,但您还需要支持一个用于删除这些数据的 API。您可以合理地从越来越多的副本中提供读取 API 调用,但删除呢?HTTP RFC 允许一个响应代码,“202 Accepted”。您可以返回该代码,将请求放入队列(例如,Apache Kafka 或 Amazon Simple Queue Service),并以不会直接导致数据库过载的速度处理这些请求。
这显然不同于 200 响应代码,它意味着请求已经立即完成。在这种情况下,与产品团队进行协商对于使 API 的保证变得可信和可实现至关重要。200 和 202 响应代码之间的区别在于分片这些数据以支持更多并行写入的所有工程工作。
如果你将排队应用于写入负载,一个重要的设计选择是提前确定这些调用在放入队列后预期完成的时间范围。监控请求在队列中花费的时间的增长将是你确定这种策略何时已经到头,你真的需要开始分割这个数据集以支持更多并行写入负载的指标。你可以通过分片来实现这一点,接下��我们将讨论。
使用分片扩展写入
如果无法通过优化查询和排队写入来管理写入流量的增长,那么分片是你的下一个选择。
分片意味着将数据分割成不同的、更小的数据库集群,这样你就可以同时在更多源主机上执行更多写操作。你可以进行两种不同类型的分片或分区:功能分区和数据分片。
功能分区,或者任务划分,意味着将不同的节点专门用于不同的任务。一个例子可能是将用户记录放在一个集群中,将他们的账单放在另一个集群中。这种方法允许每个集群独立扩展。用户注册激增可能会给用户集群带来压力。有了独立的系统,你的账单集群负载较轻,可以为客户开具账单。相反,如果你的账单周期是每月的第一天,你可以运行它,而不会影响用户注册。
数据分片是当今扩展非常大型 MySQL 应用程序的最常见和成功的方法。你通过将数据分割成更小的片段或分片,并将它们存储在不同的节点上来进行分片。
大多数应用程序只对需要分片的数据进行分片——通常是数据集中将会增长非常大的部分。假设你正在构建一个博客服务。如果你预计有 1000 万用户,你可能不需要对用户注册信息进行分片,因为你可能能够完全将所有用户(或其中的活跃子集)存储在内存中。另一方面,如果你预计有 5 亿用户,你可能应该对这些数据进行分片。用户生成的内容,如帖子和评论,在任何情况下几乎肯定需要分片,因为这些记录更大,而且数量更多。
大型应用程序可能有几个逻辑数据集,你可以以不同的方式进行分片。你可以将它们存储在不同的服务器集上,但不一定要这样做。你还可以以不同的方式对同一数据进行分片,具体取决于你如何访问它。
在计划“只分片需要分片的内容”时要小心。这个概念不仅需要包括增长迅速的数据,还需要包括逻辑上属于它的数据,并且将经常同时查询。如果你根据user_id字段进行分片,但有一组其他小表在大多数查询中与该user_id进行连接,那么将这些表一起分片是有意义的,这样你可以一次只对一个分片进行大多数应用查询,避免跨数据库连接。
选择分区方案
分片的最重要挑战是查找和检索数据。你如何查找数据取决于你如何分片。有许多方法可以做到这一点,有些方法比其他方法更好。
目标是使您最重要和频繁的查询尽可能少地触及分片(记住,可扩展性原则之一是避免节点之间的交叉通信)。该过程中最关键的部分是选择数据的分区键(或键)。分区键确定应将哪些行放入每个分片。如果您知道对象的分区键,您可以回答两个问题:
-
我应该将这些数据存储在哪里?
-
我可以在哪里找到我需要获取的数据?
我们稍后将展示选择和使用分区键的各种方法。现在,让我们看一个例子。假设我们像 MySQL 的 NDB 集群一样,使用每个表主键的哈希来将数据分区到所有分片中。这是一个非常简单的方法,但不适合扩展,因为它经常需要您检查所有分片以获取所需数据。例如,如果您想要用户 3 的博客文章,您可以在哪里找到它们?它们可能均匀分布在所有分片中,因为它们是按主键分区的,而不是按用户分区的。使用主键哈希使得知道存储数据的位置变得简单,但根据您需要的数据和是否知道主键,可能会使获取数据变得更困难。
您总是希望将查询局限在一个分片中。在水平分片数据时,您希望始终避免跨分片查询以完成任务。在跨分片连接数据会增加应用程序层的复杂性,并消耗分片数据的好处。分片数据集的最坏情况是当您不知道所需数据存储在哪里,因此需要扫描每个分片才能找到它。
一个好的分区键通常是数据库中一个非常重要实体的主键。这些键确定了分片的单位。例如,如果您按用户 ID 或客户 ID 对数据进行分区,那么分片的单位就是用户或客户。
一个好的开始方法是使用实体关系图或显示所有实体及其关系的等效工具绘制数据模型图。尝试布置图表,使相关实体彼此靠近。您通常可以通过视觉检查这样的图表,并找到否则会错过的分区键候选项。但不要只看图表;还要考虑您应用程序的查询。即使两个实体在某种程度上相关,如果您很少或从不在关系上进行连接,您可以打破关系以实现分片。
一些数据模型比其他数据模型更容易分片,这取决于实体关系图中的连接程度。图 11-2 展示了左侧易于分片的数据模型和右侧难以分片的数据模型。

图 11-2. 两个数据模型,一个易于分片,另一��难以分片⁵
左侧的数据模型易于分片,因为它有许多连接的子图,主要由只有一个连接的节点组成,您可以相对容易地“切断”子图之间的连接。右侧的模型难以分片,因为没有这样的子图。幸运的是,大多数数据模型更像左侧图表而不是右侧图表。
在选择分区键时,尽量选择一些可以尽可能避免跨片查询的内容,但也要使分片足够小,以免出现数据不均匀的问题。你希望分片最终变得均匀小,如果可能的话,如果不行,至少要足够小,以便通过将不同数量的分片组合在一起来平衡。例如,如果你的应用程序仅限于美国,你想将数据集分成 20 个分片,你可能不应该按州进行分片,因为加利福尼亚州人口太多。但你可以按县或电话区号进行分片,因为尽管它们的人口不均匀,但它们足够多,以至于你仍然可以选择 20 组,总体上人口大致相等,并且你可以选择它们以避免跨片查询。
多个分区键
复杂的数据模型使数据分片更加困难。许多应用程序有多个分区键,特别是如果数据中有两个或更多重要的“维度”。换句话说,应用程序可能需要从不同角度高效、连贯地查看数据。这意味着你可能需要在系统内至少存储一些数据两次。
例如,你可能需要按照用户 ID 和帖子 ID 对博客应用程序的数据进行分片,因为这是应用程序查看数据的两种常见方式。想象一下:你经常想看到某个用户的所有帖子和某个帖子的所有评论。按用户分片无法帮助你找到帖子的评论,按帖子分片无法帮助你找到用户的帖子。如果你需要让这两种类型的查询仅涉及单个分片,那么你将需要双向分片。
仅仅因为你需要多个分区键,并不意味着你需要设计两个完全冗余的数据存储。让我们看另一个例子:一个社交网络读书俱乐部网站,用户可以在该网站上评论书籍。该网站可以显示一本书的所有评论,以及用户已阅读并评论的所有书籍。
你可以为用户数据构建一个分片数据存储,为书籍数据构建另一个。评论既有用户 ID 又有帖子 ID,因此它们跨越分片之间的边界。你可以将评论与用户数据一起存储,而只需将评论的标题和 ID 与书籍数据一起存储。这可能足以在不访问两个数据存储的情况下呈现大多数书籍评论的视图,如果需要显示完整的评论文本,可以从用户数据存储中检索。
跨片查询
大多数分片应用程序至少有一些需要从多个分片聚合或��接数据的查询。例如,如果读书俱乐部网站显示最受欢迎或活跃的用户,它必须根据定义访问每个分片。使这样的查询正常工作是实现数据分片最困难的部分,因为应用程序将一个查询视为单个查询,需要将其拆分并并行执行多个查询,每个查询对应一个分片。一个良好的数据库抽象层可以帮助减轻痛苦,但即使如此,这样的查询比分片内查询慢得多,成本更高,通常也需要积极的缓存。
如果你选择的分片方案使跨片查询成为异常而不是规范,那么你将知道你选择的分片方案是一个好的选择。你应该努力使你的查询尽可能简单,并且包含在一个分片中。对于那些需要一些跨片聚合的情况,我们建议将其作为应用程序逻辑的一部分。
跨片查询也可以从摘要表中受益。你可以通过遍历所有分片并在每个分片上存储结果的冗余数据来构建它们。如果在每个分片上复制数据太浪费,你可以将摘要表合并到另一个数据存储中,这样它们只存储一次。
非分片数据通常存储在全局节点中,并进行大量缓存以保护免受负载影响。
一些应用程序基本上使用随机分片,其中一致的数据分布很重要,或者当没有很好的分区键时。分布式搜索应用程序是一个很好的例子。在这种情况下,跨分片查询和聚合是常规操作,而不是例外。
在分片中,跨分片查询并不是唯一困难的事情。保持数据一致性也是困难的。跨分片的外键不起作用,因此正常解决方案是根据需要在应用程序中检查引用完整性或在分片内部使用外键,因为分片内部的一致性可能是最重要的事情。虽然可以使用XA 事务,但这在实践中并不常见,因为会增加开销。
您还可以设计定期运行的清理流程。例如,如果用户的读书俱乐部账户过期,您不必立即删除它。您可以编写一个定期作业,从每本书的分片中删除用户的评论,并构建一个定期运行的检查脚本,确保数据在分片之间保持一致。
现在我们已经解释了如何将数据分割到多个集群以及如何选择分区键的不同方式,让我们来介绍两种最受欢迎的开源工具,可以帮助促进分片和分区。
Vitess
Vitess 是用于 MySQL 的数据库集群系统。它起源于 YouTube,然后成为 PlanetScale,由 Jiten Vaidya 和 Sugu Sougoumarane 共同创立的一个独立产品和公司。
Vitess 提供了许多功能:
-
支持水平分片,包括对数据进行分片
-
拓扑管理
-
源节点故障转移管理
-
模式更改管理
-
连接池
-
查询重写
让我们探索 Vitess 的架构及其组件。
Vitess 架构概述
图 11-3 是来自 Vitess 网站的图表,展示了其架构的不同部分。

图 11-3. Vitess 架构图(改编自 vitess.io)
以下是一些你需要了解的术语:
Vitess pod
一组数据库的一般封装以及支持分片、拓扑管理、模式更改管理和应用程序访问这些数据库的 Vitess 相关部分。
VTGate
控制应用程序和运维人员访问数据库实例的服务,用于管理拓扑结构、添加节点或对部分数据进行分片。这类似于之前描述的架构中的负载均衡器。
VTTablet
在 Vitess 管理的每个数据库实例上运行的代理。它可以接收来自运维人员的数据库管理命令,并代表运维人员执行这些命令。
拓扑(元数据存储)
在给定 pod 中保存由 Vitess 管理的数据库实例的库存以及相关信息。
vtctl
用于对 Vitess pod 进行操作更改的命令行工具。
vtctld
用于相同管理操作的图形界面。
Vitess 的架构始于一个一致的拓扑存储,其中保存了所有集群、MySQL 实例和 vtgate 实例的定义。这个一致的元数据存储在管理拓扑变化中发挥着至关重要的作用。当运维人员想要对 Vitess 管理的集群的拓扑进行更改时,实际上是通过一个名为 vtctl 的服务向数据存储发送命令,然后将该命令的组件操作发送给 vtgate。
Vitess 提供了可以在 Kubernetes 中部署 vtgate 层和元数据存储的数据库运维人员。在像 Kubernetes 这样的平台中拥有其控制平面可以增加其对单点故障的弹性。
Vitess 最大的优势之一是其关于如何扩展 MySQL 的理念,其中包括以下内容:
偏好使用较小的实例
按功能、水平或两者分割您的数据。但是当发生故障时,较小的实例会导致较小的爆炸半径。
复制和自动写入故障转移以增加弹性
Vitess 不通过多写节点技巧承诺“100%在线写入”。相反,它自动化写入故障转移,并在故障转移期间管理拓扑变化和应用程序对数据库节点的访问,以使写入停机时间尽可能短。
使用半同步复制确保持久性
Vitess 强烈推荐使用半同步复制(与默认的异步相对)来确保在向应用程序确认写入之前,写入始终由数据库层中的多个节点持久化。这是一种以延迟为代价换取持久性保证的关键权衡,当 Vitess 需要在非计划方式下故障转移写入主机时,这种权衡会产生回报。
这些架构原则可以帮助您在业务流量呈指数增长时在基础设施的数据库层具有更多的弹性。无论您是否专门使用 Vitess 或其他解决方案作为架构的一部分,您都应该遵循这些最佳实践。
将您的堆栈迁移到 Vitess
Vitess 是一个用于运行数据库层的有主见的平台,而不是一个即插即用的解决方案。因此,在您将其作为数据库访问层之前,您需要深思熟虑地计划如何实施这样的过渡。
具体而言,在评估 Vitess 作为可能解决方案时,请务必考虑以下迁移步骤:
1. 测试并记录您为整个系统引入的延迟。
将像 Vitess 这样复杂的堆栈引入应用程序堆栈肯定会增加一定量的延迟,特别是考虑到半同步复制的执行。确保这种权衡得到充分记录和明确沟通,以便您的下游依赖在构建依赖于这种数据库架构的 SLO 时做出知情决策。
2. 使用金丝雀部署模型。
在生产中过渡期间,您可以将vttablet配置为“外部管理”。这允许vttablet和直接连接到数据库服务器,随着您逐渐通过应用程序节点群增加连接更改。
3. 开始分片。
一旦所有应用层访问都通过vtgate/vttablet而不是直接访问 MySQL,您就可以开始使用 Vitess 的完整功能集来将表拆分到新的集群中,将数据水平分片以获得更多的写入吞吐量,或者仅仅添加副本以获得更多的读取负载能力。⁶
Vitess 是一个强大的数据库访问和管理产品,它已经从早期在谷歌的日子里走过了很长的路。它已经证明了它能够实现戏剧性的增长和一个弹性的数据库基础设施。然而,这种强大和灵活性是以增加复杂性为代价的。Vitess 不像一个简单的负载均衡器通过流量,你应该权衡业务需求和引入和维护像 Vitess 这样复杂的数据库管理工具的成本。
ProxySQL
ProxySQL 专门为 MySQL 协议编写,并以 GPL 发布。René Cannaò,一个为许多公司提供咨询的 DBA 和长期的 MySQL 贡献者,是主要作者。现在它是一个提供 ProxySQL 产品付费支持和开发合同的全功能公司。
让我们深入了解一些关于其架构、配置模式、用例和功能的细节。
ProxySQL 架构概述
您可以将 ProxySQL 用作任何应用程序代码和 MySQL 实例之间的中间层。ProxySQL 为应用程序提供了一个基于会话的、基于 MySQL 协议的接口,用于与数据库交互。代替应用程序直接打开连接到数据库实例,ProxySQL 代表应用程序打开连接。
这种设计使代理对应用程序节点看起来是不可见的。其会话感知性允许在没有停机的情况下在 MySQL 实例之间移动这些连接。当您处理不再投资于的应用程序时,这尤其有用,因为您现在可以利用 ProxySQL 中的功能而无需对您可能不确定更改的代码进行任何更改。
ProxySQL 还提供强大的连接池。应用程序打开到 ProxySQL 的连接与 ProxySQL 打开到配置连接的数据库实例的连接是分开的。这种分离可以保护数据库实例免受应用层突发流量的影响。
当您有能力单独管理客户端连接与实际连接到数据库的连接数量时,您引入了以前没有的灵活性。现在您可以扩展应用程序节点池,而无需担心它会增加到数据库的连接负载超出您想要支持的范围。这允许在使用 ProxySQL 时解释常见模式时,应用程序和业务需求的多样化场景。
配置 ProxySQL
ProxySQL 在启动时使用配置文件,但在内存中和嵌入式 SQLite 文件中维护其运行时配置,您可以直接访问并使用管理界面查询。
ProxySQL 的管理界面允许您发出命令来更改运行配置,然后使用 MySQL 命令将新配置转储到磁盘以实现持久性。这使您可以对运行中的 ProxySQL 实例进行零停机更改。您还可以使用此管理界面来进行由配置管理或自动故障转移脚本发出的自动更改。您可以在图 11-4 中看到您的架构通常如何利用 ProxySQL 和服务发现来为服务提供强大的访问层。
警告
需要注意的是,虽然我们在此图中将 ProxySQL 显示为一个对象,但我们强烈建议在生产环境中利用其集群机制,并在给定堆栈中部署多个实例。永远不要运行单点故障(SPoF)。

图 11-4。应用程序节点、ProxySQL 和服务发现之间的交互(根据 Bill Sickles 的图表调整)
ProxySQL 对其连接的数据库进行独立和分层的健康检查。根据这些健康检查的结果,ProxySQL 添加或删除主机或调整流量权重。您可以指定复制延迟阈值、成功连接的时间以及连接失败时的重试次数等许多其他配置选项,以控制在服务和应用程序需求的背景下可接受的故障容忍度。这些配置选项允许 ProxySQL 对不响应的主机做出准确的反应,要么暂时删除后端数据库,然后稍后重复健康检查,要么完全删除挣扎的后端成员,直到操作员介入。
使用 ProxySQL 进行分片
ProxySQL 对于许多分片拓扑结构非常有用。虽然它不像 Vitess 那样自动分割数据,但它可以作为一个很好的轻量级中间层,具有分片感知能力,并可以相应地路由应用程序连接。让我们来看看你可以如何将其用作分片层的路由层。
按用户分片
如果你的数据按功能或业务功能在不同的数据库集群中分割,并且不同的应用程序群访问这些集群,你应该为每个应用程序使用完全不同的数据库凭据。ProxySQL 可以利用这个用户参数将流量路由到完全不同的后端数据库池,无论是写入还是读取。
您可以通过在其管理界面上运行这些命令,然后将更改保存到其磁盘配置文件中,来配置 ProxySQL 中的此类路由:
INSERT INTO mysql_users
(username, password, active, default_hostgroup, comment)
VALUES
('accounts', 'shard0_pass', 1, 0, 'Routed to the accounts shard'),
('transactions', 'shard1_pass', 1, 1, 'Routed to the transactions shard'),
('logging', 'shard2_pass', 1, 2, 'Routed to the logging shard');
LOAD MYSQL USERS RULES TO RUNTIME;
SAVE MYSQL USERS RULES TO DISK;
提示
始终确保您保持 ProxySQL 的运��时配置和磁盘配置同步,以避免在 ProxySQL 进程重新启动时出现不愉快的惊喜。
这还可以方便地记录所有这些用户执行的操作以符合合规性,而不会对数据库造成任何负载。您将在第十三章中看到,我们还建议出于合规性原因为不同的数据库用户分别设置,因此这种设计也符合一些合规性目标。
按模式分片
您可以使用 ProxySQL 的模式名称作为管理流量路由规则来支持分片数据集的另一种方式。以下是您如何在 ProxySQL 的配置中定义的示例:
INSERT INTO mysql_query_rules (rule_id, active, schemaname,
destination_hostgroup, apply)
VALUES
(1, 1, 'shard_0', 0, 1),
(2, 1, 'shard_1', 1, 1),
(3, 1, 'shard_2', 2, 1);
LOAD MYSQL QUERY RULES TO RUNTIME;
SAVE MYSQL QUERY RULES TO DISK;
请注意,只要正确命名模式,此配置可用于水平分片或功能分片。
在这种方式中使用 ProxySQL 时,我们最后一个重要建议是确保使用其原生的集群功能,这样可以确保像mysql_rules这样的关键配置表在集群中的所有 ProxySQL 节点上同步,为中间件层提供冗余。
使用 ProxySQL 的其他好处
让我们讨论一些常见模式,在这些模式中使用 ProxySQL 可以帮助缓解快速增长环境中的常见问题。
在许多应用程序中,“向数据库打开更多连接”是我们在查询延迟开始上升时经常看到的模式。然而,在实践中,这可能导致停机⁷,并且倾向于使许多连接处于空闲状态,消耗资源但不执行任何工作。当应用程序层直接向数据库打开更多连接时,数据库服务器在连接管理上花费的资源量也会增加。这会导致数千个连接压倒已经超载的数据库实例。所有这些活动导致持续的停机时间,多个微服务中的级联故障以及延长的面向客户的影响。
ProxySQL 的连接管理架构通过仅向数据库打开可以工作的连接数量,有助于保护数据库层免受意外应用程序高峰的影响。ProxySQL 可以重用这些连接来处理不同的客户端请求。这种行为最大化了单个连接到数据库服务器可以完成的工作量,从而减少了管理连接的资源数量,并允许更有效地使用数据库服务器的内存资源。
ProxySQL 中的其他值得注意的功能
ProxySQL 在一般用途应用程序代理中具有一些突出的功能:
-
基于端口、用户或简单的正则表达式匹配的查询路由
-
前端应用程序连接和后端连接到数据库的 TLS 支持
-
支持各种 MySQL 版本,如 AWS Aurora、Galera Cluster 和 Clickhouse
-
连接镜像
-
结果集缓存
-
查询重写
-
审计日志
您可以通过访问其文档了解 ProxySQL 的广泛功能集(远远超出分片支持)。
ProxySQL 是一个强大的工具,您可以使用它来扩展应用程序,并为数据库层提供适当的性能保护,并具有支持各种业务需求的附加功能(如合规性、安全规则等)。如果您的公司发现自己处于高增长轨迹上,拥有一系列新的和不那么新的服务共享数据库资源,那么它可以是一个强大的工具,可以安全地继续这种增长。ProxySQL 提供了一个易于部署的抽象,可以比 HAProxy 更复杂,但在基础设施和复杂性方面的前期投资较少。然而,它也不提供 Vitess 中找到的一些更高级的功能,比如数据集的自动分片、模式更改的管理,以及VReplication,这是一个强大的工具,用于启用抽取、转换、加载(ETL)管道和更改数据流。
摘要
扩展 MySQL 是一场旅程。您应该在本章结束时更有准备地评估您的扩展需求,并了解如何扩展读取、如何扩展写入,以及通过向架构添加排队来使您的流量增长更可预测。您现在应该了解如何通过分片来扩展写入以及随之而来的所有复杂决策。
在深入研究可扩展性瓶颈之前,请确保您已经优化了您的查询,检查了您的索引,并为 MySQL 设置了稳固的配置。这可能为您提供计划更好的长期策略所需的时间。优化后,专注于确定您是读取密集型还是写入密集型,然后考虑哪些策略最适合解决任何即时问题。在规划解决方案时,请确保考虑如何为长期可扩展性做好准备。
对于读取密集型工作负载,我们建议转移到读取池,除非复制延迟是无法克服的问题。如果延迟是一个问题,或者如果您的问题是写入密集型的,您需要考虑分片作为下一步。
¹ 在物理科学中,单位时间的工作被称为功率,但在计算中,“功率”是一个如此多义的术语,以至于它是模棱两可的,我们要避免使用它。然而,容量的一个精确定义是系统的最大功率输出。
² 为了简化解释,我们选择忽略多个 CPU 和上下文切换的复杂性。
³ 这仍然不完全准确,因为当 CPU 接近 100%时,延迟会增加,您将无法再添加四千个查询。
⁴ 最常用的并且我们推荐的是Hashicorp 的 Consul。
⁵ 感谢 HiveDB 项目和布里特·克劳福德为提供这些优雅的图表。
⁶ 这种部署策略是由摩根·托克在 2019 年的Kubecon 演讲中详细解释的。
⁷ 欲了解更多信息,请参阅维基百科关于雷鸣群问题的条目。
第十二章:云中的 MySQL
很可能,你对于是否迁移到云服务提供商,甚至你的组织最终选择哪一个云服务提供商都没有太多控制权。你可以控制的是如何构建你的数据库环境。你可以选择两个方向:托管的 MySQL 或者在虚拟机上构建。托管的 MySQL 往往更加无需过多干预,但通常更昂贵且控制权更少。在虚拟机上构建意味着你可以更加灵活地构建和观察你的平台,但需要更多的时间和运营开销。
在本章中,我们将概述托管 MySQL 的主要选项以及它们对你有何用处。我们还将解释如何开始构建一个虚拟机选项,包括选择正确的规格和磁盘类型,以及在云中运行 MySQL 时需要准备的操作复杂性(如主机重启)。
警告
我们不会涵盖云服务提供商提供的 bug。这些提供的产品在不断发展,因此我们建议你保持与动态来源(如新闻通讯或 bug 论坛)的同步,而不是依赖像本书这样的静态参考。
托管的 MySQL
云服务提供商中的托管 MySQL 提供了许多便利,帮助团队在产品增长和功能集扩展时减少操作 MySQL 的认知负担。每个公共云都有自己对于托管 SQL 数据库应该是什么样子以及如何工作的理解。亚马逊网络服务(AWS)提供了几种 Aurora MySQL 的版本(我们很快会详细讨论这些),谷歌云平台(GCP)有 Cloud SQL,几乎所有公共云提供商都提供类似的服务。
托管解决方案的主要吸引力在于它们提供了一个可访问的数据库设置,无需深入了解 MySQL 的具体细节。通过几次点击或terraform apply,你就可以在线创建一个带有副本和定期备份的数据库,然后就可以开始了。对于想要快速入门的公司或团队来说,这可能是一个非常吸引人的选择。
另一方面,使用托管的 MySQL 会缺乏很多可见性和控制权。你无法访问操作系统或文件系统,并且在进程本身内部的操作受到限制。除了云服务提供商提供的内容,你无法检查系统的其他任何内容。在大多数情况下,如果遇到问题,你只能提交支持工单并等待回复。你无法设置任何高级拓扑结构,备份和恢复方法也受限于云服务提供商提供的内容。
值得注意的是,许多云服务提供商提供的是与 MySQL 兼容的数据存储。这是一个具有 SQL 接口但内部工作方式可能与本书关注的 Oracle MySQL 完全不同的数据存储。我们将介绍一般的权衡和每个托管解决方案的不同之处,以帮助你选择最适合你的团队和业务需求的选项。
亚马逊 Aurora for MySQL
Aurora MySQL 是一个与 MySQL 兼容的托管数据库。Aurora 最吸引人的卖点是它将计算与存储分离,这使得它们可以分别和更灵活地扩展。Aurora 管理了许多你通常需要处理的运营任务,比如执行快照备份、管理快速模式更改、审计日志和管理单一区域内的复制。
Aurora MySQL 还有不同的提供方式。我们将简要介绍这些提供方式之间的区别。
标准的 Aurora 提供的是长时间运行的计算实例,你可以选择一个实例类别(就像在运行自己的 MySQL 时一样),并且你会获得内部复制到六个副本的附加存储。
警告
在撰写本文时,AWS 认为 Aurora 快速 DDL 是一个“实验模式”功能。如果您正在阅读本文,情况仍然如此,我们建议您参考第六章以了解有关使用数据库外部工具进行在线模式更改的更多信息。
需要注意的是,在 Aurora 集群内部的复制完全是亚马逊专有的,不是我们在 Oracle MySQL 中所知道和使用的复制。由于集群中所有的 Aurora 实例共享相同的存储层来访问数据,集群内部的复制是使用块存储来完成的。¹ 然而,Aurora 确实支持以我们在社区服务器中熟悉的格式写入二进制日志,以便团队希望将数据从一个 Aurora 集群复制到另一个集群或者出于其他二进制日志的目的,比如变更数据捕获。²
提示
如果您打算将任何关键数据库放在 Aurora 上,我们强烈建议您考虑使用亚马逊的 RDS 代理来管理应用程序与 Aurora 的通信方式。在您知道应用程序端可能会出现新连接风暴的情况下,RDS 代理可以非常方便地防止新连接量影响数据库。
自 2015 年 Aurora MySQL 出现以来,AWS 已经扩展了 Aurora MySQL 的选项,以满足更广泛的用例和业务需求:
Aurora 无服务器
Aurora MySQL 的无服务器产品消除了长时间运行的计算,并利用亚马逊的无服务器平台来提供数据库的计算层。如果您的工作负载不需要持续运行,这将为您提供很大的成本灵活性。
Aurora 全局数据库
这是 Aurora 为那些需要在多个地理区域中可用数据但不想使用二进制日志复制手动管理从主要集群到其他区域集群获取数据更改的解决方案。请注意,这会带来一些权衡,您应始终参考亚马逊的文档,以确保您接受正确的权衡。
Aurora 多主
多主是 Aurora 集群的一种特殊类型,可以同时在多个计算节点上接受写入。它旨在作为一个高可用解决方案,其中单个区域中的写入可用性是最高优先级。请注意,Aurora 多主带有自己的一套限制。首先,在撰写本文时,它运行的是 MySQL 5.6 服务器核心,这将阻止您使用许多功能。集群中允许的节点数量有一个最大限制,并且您不能在同一部署中混合多主和全局数据库。我们认为 Aurora 多主是一个非常主观的解决方案,适用于您在每个数据存储和应用程序交互中拥有的可用性和一致性选择,并建议您在选择之前仔细考虑您所陈述的约束和权衡。
AWS 继续对其托管的关系型数据库产品进行更新和改进,因此我们将避免深入讨论 Aurora 各种版本之间的功能差异。然而,图 12-1 提供了一个流程图,帮助您了解哪种类型的 Aurora 可能最适合您的需求以及在什么权衡下。
图 12-1 为您提供了一个基本的决策树,帮助您在 Aurora 选项之间做出选择。重要的是,尽管 Aurora 有许多选项,但总会有权衡。例如,您无法同时实现多写高可用性和跨区域亚秒级复制。但您可以利用这些产品来展示这些权衡,并推动关于哪个更重要:写入可用性还是区域复制的困难产品讨论。
Aurora 并不是唯一由云提供商提供的托管 MySQL 产品。GCP 有自己的产品。

图 12-1. 一个流程图,帮助您选择适合您需求的 Aurora 版本
GCP Cloud SQL
Cloud SQL 是 GCP 的托管 MySQL 产品。这种产品与 AWS 的主要区别在于它运行社区服务器,但特定功能被禁用,以便实现产品的多租户性和托管性。以下是一些你不能在 Cloud SQL 中使用的功能,尽管它运行社区服务器:
-
SUPER权限被禁用。 -
禁用加载插件。³
-
一些客户端也被禁用,比如 mysqldump 和 mysqlimport。
与 AWS 的产品类似,您无法访问实例的 SSH。
另一方面,Cloud SQL 为您管理了许多运营任务:
-
本地高可用性支持。故障转移通过配置选项自动化。
-
数据静态加密。
-
灵活管理升级,使用多种方法。请注意,最终这些维护窗口会涉及一些停机时间(类似于 AWS Aurora),您需要平衡这一点与应用程序 SLOs 的责任。⁴
正如我们在本章开头提到的,您可能没有选择在哪个云提供商中构建这些数据库的选择,因此您更可能需要了解所选云提供商提供的托管选项以及如何使用它,或者提出直接使用虚拟机而不是托管 MySQL 的理由。
现在我们已经介绍了托管关系型数据库选项以及这些选择的复杂性,让我们谈谈稍微更复杂的路径:在云托管的虚拟机上运行 MySQL。
虚拟机上的 MySQL
托管 MySQL 的特性对于那些想快速上手的人可能非常吸引人,那么为什么有人选择自己运行呢?在虚拟机上运行 MySQL 就像在裸机上运行一样。您可以完全控制所有操作方面。您可以在单个区域运行主 MySQL,但在其他区域设置副本以用于灾难恢复,或者运行一个延迟副本。您还可以根据工作负载最优化地定制备份方法。如果性能下降或遇到问题,您可以完全控制操作系统和文件系统,允许您进行任何自查。
云中的机器类型
如 第四章 中讨论的,MySQL 的 CPU 核心数量和可用 RAM 对 MySQL 的性能有直接影响。为数据中心选择特定硬件规格的缺点是它们不能很容易地更改。如果您有一个 56 核、512 GB RAM 的机器架设,您当然可以减少安装的 RAM,但您已经为此付费了,所以除非您可以在其他地方重复使用 RAM,否则您可能在硬件上花费过多。
当您使用云提供商时,为您的工作负载优化机器规格要容易得多。主要的云提供商允许您选择设置虚拟 CPU(vCPU)范围、可用的 RAM 量、网络和磁盘限制的机器规格。随之而来的是,您可以根据工作负载的变化调整 VM 的大小。这意味着,如果您在一年中的特定时间经历高峰流量,比如假期季节,您可以临时增加机器规格以应对这种情况。一旦流量模式回落,您可以再次将它们调整为更小。这种灵活性是许多人转向云的原因。
选择正确的机器类型
如果您已经在云提供商上,选择一台机器相当简单。如果遇到 vCPU、内存或网络瓶颈,您可以找到适当的机器类型来克服,并调整大小。但如果您从数据中心搬迁,确定正确的配置可能会有些棘手。
CPU
在第四章中,我们讨论了如何为你的工作负载选择正确的 CPU。当你转移到云时,大部分指导仍然适用。请记住,与云提供商一起,你得到的是虚拟 CPU,而不是物理 CPU。这意味着 CPU 不是专门属于你的。它可能与同一物理主机上的其他租户共享。很可能,你会看到比在你自己的独立服务器上更多的延迟和利用率变化。
如果你从物理机器迁移到云提供商,估算你的 CPU 使用量也可能会有些棘手。我们成功地使用以下公式来计算 vCPU 数量:(核心数 × 95% 总 CPU 使用量)× 2。
例如,假设你在数据中心有一台 40 核服务器。在过去的 30 天里,CPU 使用率的峰值为 30%。在云提供商中以 50% 利用率运行这个需要多少核心?使用上述公式,我们会估算出 24 个核心。如果你选择的云提供商没有提供 24 核的机型,考虑四舍五入到最近的类型或确定你的提供商是否提供自定义机型。⁵
警告
随着 CPU 使用率或核心数的增加,上下文切换也会增加:在 CPU 上切换任务的行为。因此,你不希望运行在 100% 的 CPU 容量,因为你会浪费大量时间在线程之间切换。这将表现为查询的延迟。我们建议目标是 50% 的典型利用率,峰值可达到 65%–70%。如果你维持在 70% 的 CPU 或更高,你可能会看到延迟增加,你应该考虑添加更多的 CPU。
也要注意 CPU 芯片系列,如果有这个选项的话。如果你正在运行一个高流量的网络应用程序,你可能希望确保你有一个更晚一代的芯片可用。同样,如果你正在考虑后端数据处理,在那里旧的、稍慢一些的 CPU 芯片系列可能是可以的,这可能会节省成本。
内存
正如第一章和第四章所讨论的,RAM 可以极大地影响 MySQL 的性能。
选择最适合你数据工作集需求的机型规格,更倾向于过多的 RAM 而不是不足。
网络性能
虽然 CPU 和内存大小是选择机型最重要的部分,但确保你也审查了可用的网络性能限制,以确保你不会让你的应用程序挨饿。例如,如果你有一个将读取大量数据的批处理过程,你可能会发现在较小的机型上耗尽带宽。
提示
值得注意的是,云区域和区域之间的网络出口通常是有成本的。当设置副本时,这可能会让人感到惊讶,但我们仍然认为将副本放在不同的区域是很重要的,以实现冗余。
选择正确的磁盘类型
尽管机型通常是动态的,但是为数据存储做出的选择可能是你最复杂的决定。一旦你选择了一个磁盘类型并开始用它来存储数据,转移到另一种磁盘类型就会变得困难。通常情况下,你需要挂载第二个磁盘并复制数据。纠正这个问题并不是不可能的,但肯定比只需快速重启来添加更多 CPU 要复杂。
选择正确的磁盘类型也高度依赖于你期望运行的工作负载。高度读密集型的工作负载将受益于更多的内存而不是磁盘性能,因为内存访问速度快得多。如果你的工作集大于你的 InnoDB 缓冲池,你将总是需要去磁盘读取一些数据。写密集型的工作负载将总是需要去磁盘,这是大多数人开始看到他们的第一个磁盘瓶颈的地方。
附件类型
首先要做出的决定是选择本地连接的磁盘还是网络连接的磁盘。本地连接的磁盘具有提供极高性能和一致吞吐量的优点,但也容易丢失数据。这是因为它们被视为临时数据的磁盘。如果运行本地连接数据的虚拟机的硬件崩溃,你可能会丢失本地磁盘上的所有数据。同样,在某些情况下,即使关闭实例,再次启动时可能会在不同的主机机器上且磁盘为空。本地连接的磁盘通常没有任何复制或 RAID 支持。主机级磁盘故障可能导致数据丢失。如果选择这条路线,我们强烈建议考虑使用软件 RAID,以至少减少数据丢失的可能性。有关 RAID 的讨论,请参见第四章了解更多信息。
相比之下,网络连接的磁盘提供冗余性和可靠性而不是性能。这并不是说网络连接的磁盘性能不好,只是不如本地连接的性能好。你的网络连接磁盘可能会出现停顿,而本地连接的磁盘可能不会。你还可以在本地实现更高的吞吐量和 IOPS 数字。
当使用网络连接的磁盘时,云服务提供商提供方便的备份或快照工具。这些对于 MySQL 使用效果很好,假设你已经配置了符合 ACID 标准的设置⁶并且你的备份解决方案设计得当。你可以在任何时候进行磁盘快照,并通过正常的崩溃恢复无问题地恢复它。
你还可以使用磁盘快照来快速制作副本,即使磁盘大小达到数 TB。通过这样做,你可以最大程度地减少需要追赶的复制延迟量,以便副本可以使用。
请注意,如果使用本地连接的磁盘而不是网络连接的磁盘,你需要自己解决如何使用 LVM 或第三方工具如 XtraBackup 备份数据的问题。有关备份的更详细讨论,请参见第十章。
关于附件类型的最后一点是,云服务提供商不像硬件上的 RAID 卡那样提供写缓存(电池或闪存支持)。
SSD 与 HDD
总的来说,你应该为所有东西使用 SSD,尤其是你的 MySQL 数据卷。如果预算特别紧张,你可以探索 HDD 作为启动磁盘的更便宜选项。在我们的实验中,我们发现 SSD 的启动速度比 HDD 快两到三倍。如果启动时间很重要,尤其是在停机或重新启动情况下,请坚持使用 SSD。
IOPS 和吞吐量
另一个复杂的因素是确定你的 IOPS 和吞吐量需求。在选择需要的磁盘类型之前,你应该对这些需求有一个良好的了解,无论是历史还是未来的。
如果你正在迁移现有工作负载,理想情况下你已��有了这些的历史磁盘使用度量标准,这将帮助你最好地选择你的磁盘。如果没有,你可以使用pt-diskstats,来自 Percona Toolkit 软件包,收集一天的度量标准以测量峰值。
对于新数据库,投入一些时间来了解应用程序的强度。即使了解读写比这样基本的事情也有所帮助。如果其他方法都失败了,找到性能和成本之间的一个良好折中点,并设定可能需要稍后调整的期望。
附加提示
如果选择在 VM 上运行自己的 MySQL,你将需要负责比托管服务更多的事情。你需要自己做磁盘大小调整、备份等工作。如果选择这条路线,以下是一些建议要考虑的。
处理主机重新启动
您的虚拟机实际上只是在别人的硬件上运行。尽管我们不喜欢,硬件可能会出现故障,当这种情况发生时,您的虚拟机将立即终止。如果配置了,您的虚拟机将开始在另一台主机上重新启动。如果这发生在您正在提供生产流量的情况下,尤其是在接受写入的源节点上,可能会对用户造成中断。
没有什么魔法解决方案可以让您避免这个问题 - 您只能处理它。如果发生这种情况,您往往有两个选择:启动到一个副本的故障转移过程(在“复制故障转移”中有介绍),或等待源重新上线。处理未经计划的晋升可能非常棘手。我们的建议是允许服务器重新上线并让复制自然重新连接。
您可以通过遵循以下建议使这个过程更容易应对:
-
使用 SSD 引导磁盘以实现尽快重新启动。通常系统在不到五分钟内恢复在线。
-
在最多五分钟内抑制您对主机宕机的即时通知,以允许系统完全重新启动并恢复健康。
-
如果源服务器重新启动,您可以编写一个选项动态关闭
read_only标志,允许写入继续进行而无需人工干预。当与crond@reboot选项配合使用时效果很好,该选项将在系统启动时运行脚本。唯一的注意事项是您需要能够查询系统以确定系统是否应该接受写入。 -
通过自动向可能需要了解中断的团队或频道发送电子邮件或聊天消息来最大程度地沟通。“主机 FQDN 意外宕机,预计将在五分钟内恢复在线”可能足以阻止人们给您发消息甚至呼叫您。
分离操作系统和 MySQL 数据
无论您选择本地附加还是网络附加,出于以下原因,我们建议将操作系统和 MySQL 数据分开:
-
磁盘快照将仅限于 MySQL 数据,不包含任何操作系统信息。
-
对于网络附加磁盘,您可以轻松地将磁盘断开并重新连接到另一台机器。
-
对于网络附加磁盘,您可以升级或替换操作��统而无需重新复制数据到文件系统。
也要考虑放置特定文件的位置,比如 MySQL 进程 ID 文件、任何日志文件和套接字文件。我们建议这些文件与操作系统保持在一起,尽管日志可能可以留在数据磁盘上。
备份二进制日志
将您的二进制日志发送到一个存储桶。在存储桶上设置生命周期控制,在一定时间段后自动清除旧文件。同样,防止在一定时间段之前删除文件或完全禁止删除。
不要忘记考虑安全性。让全世界都能读取这个存储桶可能是一场等待发生的噩梦。控制谁可以读取或删除这些数据对于维护安全的备份策略至关重要。考虑允许所有数据库机器写入,但没有一个可以读取或删除。从受限制的帐户、机器或两者分别控制读取和删除。
自动扩展您的磁盘
对于网络附加磁盘,您支付的是预留的空间量,而不是使用的空间量。这意味着在 MySQL 数据磁盘上留下大量预留但未使用的空间可能是浪费的。您可以优化的一种方式是将磁盘空间使用率目标定为更高的百分比,比如 90%,但如何减轻磁盘空间耗尽的风险呢?
云服务提供商通常有一个可用的 API 调用来扩展你的磁盘大小。通过一点点代码,你可以确定你的服务器是否超过了 90% 的磁盘已满标记,并调用该 API 来扩展磁盘。这也可以减少接近磁盘空间耗尽而被呼叫的可能性。总的来说,这个过程可以在你花费在预留磁盘空间上产生显著差异。
我们将分享一些关于此的警告,然而:
-
考虑一下应该多频繁运行查找已用磁盘空间百分比的代码。你需要根据磁盘的吞吐量来确定,一个进程需要多长时间才能完全填满剩余的磁盘。你的代码应该比这更频繁地运行。
-
如果你的进程失控并不断扩展磁盘而没有限制,你可能会在付费提供商账单��期时惊讶地发现一个 64 TB 的卷。
-
这个磁盘扩展 API 调用可能会导致磁盘短暂停滞。请确保在负载下进行测试,以确保不会对用户产生不利影响。
摘要
如果你在成千上万家在公共云中运行的公司之一工作,当涉及如何运行你的数据库时,你有很多选择。作为数据库工程师,你将被问及要使用哪种托管解决方案,是否要完全使用托管关系数据库解决方案,以及每种选择的权衡是什么。在这些讨论中最重要的是要记住,没有免费的午餐。你的每个选择都伴随着一系列的权衡。你可以做的最有用的事情是将这些权衡框定在你的业务运营方式和成熟阶段的背景下,以帮助指导你的组织朝着最合适的方向发展。我们希望这一章能帮助你在这些讨论中具备比较手中权衡和公司需求的能力。
¹ 如果你真的想了解那个架构的细节,我们强烈推荐阅读 2017 年 Aurora 团队发表的《SIGMOD 论文》。
² 变更数据捕获是数据架构中用于确定数据何时发生变化并在域和系统之间传输该变化的设计模式。关于这方面的更多阅读,我们强烈推荐马丁·克莱普曼(O'Reilly)的《数据密集型应用设计》第十一章。
³ Cloud SQL 确实提供了自己的解决方案用于支持合规需求的审计日志记录。
⁴ 欲了解更多信息,请参阅 Cloud SQL 文档中的“最小化维护影响”。
⁵ 请注意,自定义机器类型可能比预定机器类型更昂贵。当在大量实例上工作时,选择大小时考虑成本是非常重要的。
⁶ 提醒一下,这些是innodb_flush_log_at_trx_commit=1和sync_binlog=1。
第十三章:与 MySQL 合规
数据库工程团队的角色引起了许多内部业务利益相关者的关注。正如我们已经介绍的,您不仅需要为性能和正常运行计划,还需要考虑基础设施成本、灾难恢复以及各种合规需求。
您的工作不仅限于在业务运行时管理这些数据。您还需要帮助企业保护数据并为法律要求或对业务至关重要的监管认证进行认证。您必须了解实现这些需求的业务目标,并将这些要求包含在所有数据架构设计中,包括如何自动化运营任务、管理访问权限以及将管理任务转换为自动化任务的代码。
本章涵盖了企业可能追求的不同类型的合规认证以及各种特定于数据库的关注点。我们帮助解释如何为不同的合规需求设计,并讨论访问日志记录如何成为填补合规要求的关键部分。最后,我们涵盖了数据主权作为各类企业数据架构实践中新兴关注点。
警告
本章不旨在为您提供法律建议。我们旨在帮助您在运行大量数据库时管理合规需求,以及如何从早期开始设计合规。在寻求如何正确履行特定控制的建议时,您应始终与公司的法律团队咨询。
什么是合规?
治理、风险管理和合规(GRC)是指导企业如何评估和优先考虑其资产风险以及如何遵守管理其产品所使用的个人或健康数据处理和传输的法律的原则、流程和法律。早期初创企业通常不具备许多合规需求,因为他们在找到产品市场契合度时。然而,随着业务的发展,您将开始遇到许多法规。有些法规需要适用于企业所有数据,而有些可以适用于特定部分。
在合规背景下经常使用的一个术语是控制。控制是公司内部定义和实践的过程和规则,旨在减少不希望的风险结果的机会。
让我们介绍一些您应该了解的合规法规。稍后,我们将介绍可以帮助更轻松满足这些各种要求的架构变更。
服务组织控制第 2 型
服务组织控制第 2 型(SOC 2)是一组合规控制,服务组织可以用来报告与安全、可用性、处理完整性、机密性和隐私相关的实践。寻求获得 SOC 2 认证的组织中的数据库工程师需要建立良好的数据库变更管理、备份和恢复程序以及管理数据库实例访问权限的实践。
萨班斯-奥克斯法案
2002 年萨班斯-奥克斯法案(SOX)是一项所有成为上市公司的公司都必须遵守的法案。它旨在通过提高根据证券法规定向投资者披露的公司披露的准确性和可靠性来保护投资者,并为其他目的。对于一个工程组织,SOX 职责要求证明包含影响收入的数据的数据库只能被有需要的人访问,并且对这些数据的任何更改都已记录,并且更改有记录的原因。
如果您是一家上市公司,SOX 控制 404 是一个您必须熟悉并履行的法律要求的控制。它旨在通过证据保证公司报告的财务状况得到数据访问和变更管理实践的支持,这些实践准确地将提供的服务归因于收取的收入,并为对此类数据的任何更改提供审计跟踪。
付款卡行业数据安全标准
付款卡行业数据安全标准(PCI DSS)是所有处理信用卡数据的金融机构必须遵守的标准。其目的是保护持卡人数据,防止泄露并用于欺诈交易。
作为数据库工程师,当涉及到 PCI-DSS 控制时,一个重要的方面是管理对持卡人数据的访问。这意味着您需要在架构中考虑该控制,以确保卡数据得到单独管理。我们将在本章后面讨论如何实现这一点,当我们讨论角色分离时。
健康保险可携带性和责任法案
1996 年的健康保险可携带性和责任法案(HIPAA)是一项旨在保护个人健康相关数据隐私的美国法规,当这些数据由医疗提供者、医疗计划或其业务伙伴收集和处理时适用。该法律适用于被定义为电子个人健康信息(ePHI)的数据。提供需要符合 HIPAA 合规性的产品的组织将需要他们的数据库工程师实施控制措施,如 ePHI 的访问控制、所有 ePHI 的加密以及每当访问 ePHI 时进行活动记录。
联邦风险和授权管理计划
对于在美国运营并希望与美国政府实体开展业务的公司,联邦风险和授权管理计划(FedRAMP)是联邦政府提供的认证,使企业有资格成为联邦实体的云服务提供商。这是一系列要求的标准,使得符合条件的企业有资格接待联邦实体作为客户。这些标准包括配置管理、访问控制、安全评估以及对数据访问和更改的审计。
通用数据保护条例
《通用数据保护条例》(GDPR)是欧盟于 2016 年颁布的一项法规,旨在规范个人可识别信息在欧盟人员中的存储和管理方式,无论数据处理实体的总部在何处。它引入了管理数据隐私的第一步,例如在收集私人数据之前要求同意、设定跨处理器组织对该私人数据的访问限制,并为个人提供法律途径,要求任何可能通过其在线活动收集其数据的数据处理器的系统中清除其数据。这被称为个人的“被遗忘权”。
施雷姆斯 II
2020 年,欧盟与 Facebook 的爱尔兰实体之间的案件由欧盟司法法院裁定。这一裁决,通常被称为施雷姆斯 II,对所有在欧盟运营并收集欧盟人数据的美国公司产生了广泛影响。
隐私盾是美国公司多年来在欧盟运营的法律基础。施雷姆斯裁决宣布,当美国公司实体在欧盟收集欧盟人的数据时,隐私盾不足以保护欧盟人的隐私。在取消的核心是欧盟司法法院裁定,隐私盾不足以保证欧盟人不会通过美国法律手段(即使用 1978 年《外国情报监视法案》提供的机制,或 FISA)被美国政府监视,因此,由美国实体收集的欧盟人的个人可识别数据必须留在欧盟,不得跨越到美国资产或被美国人访问。
与 GDPR 最初版本相比,该裁决使得数据架构的推理变得更加复杂。由于这项裁决是最近才做出的,执法仍然是一个未知的数量。这种情况让每家企业决定其收集和处理的数据有多少在范围内,以及以何种方式。可以肯定的是,如果您有当前或未来的欧洲客户,Schrems II 将会针对您运行的应用程序和数据基础设施。
为合规控制构建
正如您所看到的,企业的法规合规世界,以及由此产生的企业用于运营的数据,是庞大的,控制措施可能因每项法律的目标或您的企业所需的认证而有所不同。好消息是,同样的工作可以涵盖多个控制措施,从而实现效率和更一致的实践,管理基础设施时。然而,您需要了解哪些控制措施对业务是必需的,以及出于何种目的。一旦您的公司发展到需要开始实施这些法规合规控制的规模,您将成为向不同类型的审计员提供合规证据的人。了解每项控制措施的目的将有助于提供正确类型的证据,使��计更加容易。
提示
为合规性构建是一个持续的过程,不能在需要时轻松“添加”。本章中提出的许多架构建议(角色分离、跟踪变更等)是您在公司过了“仍在寻找市场适应性”阶段后应该考虑和倡导的事项。这些实践将使您的业务在合规性真正成为一个需求而不仅仅是“好有的”时,为成功打下基础。
机密管理
在讨论如何管理机密之前,让我们首先明确您基础设施中可能属于该定义的事物:
-
应用程序与数据库交互的密码字符串
-
供支持人员/运营人员管理数据库实例的密码字符串
-
可以访问/修改数据的 API 令牌
-
SSH 私钥
-
证书密钥
您组织中需要的一个核心能力是以安全和独立的方式管理机密,而不是与配置管理混为一谈。您需要一种交付和轮换敏感数据(如数据库访问凭据)的方式,无论是供应用程序还是团队使用,用于报告目的。
如果您在云环境中运行应用程序和数据库,我们强烈建议您在考虑构建自己的解决方案之前,先了解该云服务提供商的首选机密管理解决方案。您需要的是至少提供符合国家标准与技术研究所(NIST)标准可接受级别的加密,因为这是许多法规(包括 HIPAA 和 FedRAMP)所要求的。
如果您的云服务提供商没有一个可接受的机密管理解决方案,您可能需要自行托管。这可能是您的组织的一项新尝试,需要比本书所涵盖的更广泛的努力。
无论您使用托管解决方案还是最终需要自行运行,都要意识到这种机密管理解决方案给您的架构带来的复杂性。这种解决方案的目标是管理机密,而不是成为产品的单点故障。提前与您的法律团队和安全组织明确讨论解决方案可用时会发生什么的权衡将会很有帮助。关于缓存哪些机密以及以何种方式缓存的明确对话将有助于避免后期期望不一致。
通常,公司在早期发展阶段基于便利性做出的决定需要在计划合规控制之前进行调整。您应该准备向领导层解释为什么在改善安全姿势和降低风险的背景下这项工作很重要。
不要共享用户
不要跨服务共享数据库凭据。如果您的数据库意外泄漏,现在必须评估应用程序堆栈和流程中有多少部分需要获取新的数据库凭据对,这种决策的效果将成倍增长。作为数据库工程师,加入一家初创公司是很常见的,人们通常会采取看似方便的捷径,“所有代码都使用相同的对来访问数据库。” 相信我们:这是一种非常昂贵的捷径,如果您限制每个数据库用户的访问权限仅限于其所需的服务,您的未来自己会感谢您。
现在我们已经介绍了这个基本但至关重要的基本实践,让我们谈谈在选择存储数据库凭据或秘密的解决方案时需要考虑的事项。
不要在代码存储库中检查生产数据库凭据
这可能看起来很明显,但我们在许多大大小小公司的安全事件报告中仍然看到这种情况发生。保持谦卑的心态很重要,不要假设这种错误在您的组织中不太可能发生。信任但验证的方法将在很大程度上有助于防止未来的痛苦。在合并拉取请求之前扫描代码存储库中的潜在秘密字符串是一种常见做法(也是像 GitHub 这样的托管存储库服务可以为您执行的操作)。如果您的组织尚未考虑这一点,您可能需要成为这种需求的倡导者。请记住,合规性和安全性对整个组织都是必需的,尽管并非所有事情都可以或应该由数据库团队完成,但您是整个工程组织讨论这些优先事项的利益相关者之一。
这些实践对于正确开始合规性和安全姿势至关重要。它们将使我们在使用秘密管理时即将涵盖的一些操作变得更加简单,并且在有必要进行紧急更改时将进一步降低业务风险。
让我们谈谈在选择秘密管理解决方案时的权衡。
选择一个秘密管理解决方案
选择一个秘密管理解决方案将取决于您运行的环境以及最容易与数据库和应用程序堆栈集成的内容。在方便性和满足所有需求之间总会存在权衡。因此,您需要明确向所有利益相关者(其中一些不是工程师)说明限制是什么,可用性或弹性的权衡是什么。在检查您的云(或私有)基础设施可以提供的内容时,您应考虑以下一些权衡:
空间限制
许多由云提供的秘密管理解决方案对秘密的长度做出了假设,这可能会导致意外惊喜,如果您想存储比数据库用户和密码对更长的内容。如果您的合规控制要求将更长的文本字符串(例如 SSH 密钥或 SSL 的私有证书)也视为秘密,您应该查看您可以在给定密钥上存储的最大大小。一些组织后来会遇到的雷区之一是,随着秘密足迹的增长,需要一个新的不同的秘密管理解决方案来容纳更长的秘密。现在他们必须处理迁移(可能会影响正常运行时间)或管理工具和与两个单独的秘密解决方案集成的复杂性。
秘密轮换
如果你在公共云中运行并且可以使用他们提供的托管密钥管理解决方案,那么对你来说有个好消息:截至目前,所有三个主要的云服务提供商都提供了一些自动化轮换密钥的方法,以及版本控制,使得服务过渡到新密钥对你的服务更加无缝。然而,如果你选择的密钥管理解决方案不支持轮换密钥,那么你需要计划如何进行这项工作,无论是作为计划更改(例如,你可能有一个要求数据库凭据定期轮换的控制)还是作为紧急变更(例如,有人意外地在公共存储库中检查了数据库密码)。如何在不影响正在运行的应用程序的情况下进行这项工作?这在很大程度上取决于你如何向正在运行的应用程序传递配置更改以及你的部署流水线的运作方式。这是如何编排的一般想法。这个变更是一个部署。即使你的应用程序将数据库凭据作为配置行访问,你仍然需要将这个配置更改传播到你的所有设备,并通常还需要编排一个重新启动,而不影响整个服务的可用性。
区域可用性
要考虑你的密钥管理解决方案不仅用于存储密钥,还用于传递密钥。如果你要避免像在代码中存储密钥这样的已知不良做法,你需要让你的应用程序能够在运行时检索它需要处理请求的密钥。这意味着你现在必须考虑如何检索这些密钥,如果你的应用程序无法访问密钥管理服务会发生什么,以及这种新依赖引入了什么故障模式。如果你负责需要在许多地理区域运行的应用程序,你的密钥管理解决方案的区域能力就成为另一个需要考虑的因素。你的云提供商的解决方案是否会自动将密钥复制到其他区域?还是你必须构建这种能力?
角色和数据的分离
这些监管法律的一个重要目标是根据数据泄露的风险将数据分开,无论是对企业还是对其客户。这就是最小权限的概念,无论是对人类访问还是对应用程序代码访问。这种分离允许根据数据内容和相关风险进行更合适的控制和变更跟踪。
为合规性原因进行分片
可能会迫使特定数据集分开到专用集群的一个因素是具有非常不同控制要求的不同合规性要求。比如说,你是一个市场传播提供商,正在创建一个侧重于医疗技术的新产品。目前正在使用的数据与健康无关,并且不受多项法律要求约束。一旦企业进入健康技术领域,你现在就有了一部分客户及其数据,你的公司承担了作为个人健康信息(PHI)处理者的法律责任。在这种情况下,最好从一开始就在专用数据存储中开发新产品,这样你就可以更适当地应用 HIPAA 合规性控制,而不会给现有数据集及其依赖的应用程序增加不必要的负担。
分开数据库用户
随着您的产品变得更加复杂,支持其的技术堆栈也随之增长,您将开始拥有多个具有数据访问权限的应用程序。在组织早期就开始良好控制数据访问权限非常重要,不要在多个代码库之间共享相同的数据库访问凭据。安全事件和凭据意外泄露是发生在各种公司的事件,无论规模大小,当这些事件发生时,您将受益于泄露的秘密影响已知且隔离的业务操作范围,因为您在匆忙更换该秘密时。
变更跟踪
许多合规法规都涉及跟踪变更的控制:影响财务报告的数据子集的变更,生成发票的系统的变更,以及这些变更如何被审查、测试和跟踪。这种合规控制首次显而易见的地方之一是数据库。随着您为扩大合规责任的业务工作,像“如何审查、应用和跟踪生产中的模式更改”这样的流程需要比简单地“工程师登录生产源节点并进行更改”更严格和更有计划。如果您通过与内部审计团队或合规团队一起规划如何处理审计来做好准备,这将减轻很多负担。
这里的目标是避免年度审计成为一个重大干扰事件,团队在为审计团队收集证据而忙碌。相反,如果您对这些正常业务操作的工作方式进行一些更改,您可以获得“内置”证据,这样收集和用于审计的证据就会简单得多。您将在本节中看到一个关于从所有方面生成结构化日志的重复主题。这是有意的。这在很大程度上有助于以相同方式跟踪各种变更,这种一致性有助于企业以更少的干扰实现其审计需求。您可以看到这一切是如何在图 13-1 中结合在一起的。

图 13-1。不同操作任务的示例,所有任务都将结构化日志发送到一个地方,使审计更容易
让我们看看数据库系统的不同变更类型以及如何为其自动化合规跟踪。
数据访问日志记录
许多合规控制要求您保留��特定数据集的更改或访问的日志。这可以用于跟踪财务数据的更改,也可以用于 PCI 或 HIPAA 等法规,其中数据足够敏感,所有访问都需要被跟踪。
您可以直接在数据库级别解决这个需求,通过利用Percona 的审计日志插件(如果您使用 Percona 的分支)或等效的MySQL 企业审计插件(如果您使用 MySQL 社区服务器)。这样做的好处是,现在您可以在数据更改之前的最后一跳跟踪变更,特别是如果您处于一个数据库可以通过多个路径进行更改的环境中。
跟踪变更的不良选项
您可能会问,“为什么不使用触发器跟踪我关心的表的任何更改?”这绝对是我们过去看到的一种方法,但不建议。以下是我们不建议使用触发器的一些原因:
-
已知触发器会对写入性能造成影响,这将在最糟糕的时候影响您。
-
触发器相当于将业务逻辑存储在数据库中,这是不推荐的。
-
将代码存储在数据库中可能会绕过对该代码进行测试、分期和部署的任何流程。在事件期间,触发器可能会成为您的团队的意外惊喜。
-
触发器只能支持跟踪写入操作。如果需要,它无法扩展到跟踪读取访问。
让我们看看如何使用 Percona 审计日志插件以及如何调整它。
安装和调整 Percona 审计日志
Percona 的审计日志插件作为 Percona 的 MySQL 分支的一部分提供,但默认情况下未安装或启用。您可以通过在任何新实例的引导过程中运行以下命令来安装它:
INSTALL PLUGIN audit_log SONAME 'audit_log.so';
SHOW PLUGINS;
第二个命令列出正在运行的插件,并应确认审计日志插件实际上现在作为服务器进程的一部分在运行。除了启用它,您还需要确定如何摄取其输出。这就是真正的计划发生的地方。
Percona 的审计日志插件允许您定义需要跟踪的语句动词。这允许灵活地满足各种控制要求,而不会在审计日志中产生大量与您关心的内容无关的噪音。确保您查阅其文档以正确配置该变量。
插件的一个灵活优势是您可以安装它,但实际上不监视查询。这在您仍在研究如何摄取其输出并需要在不影响正常运行的情况下关闭和打开它时非常有用。但是,这种灵活性也带来了复杂性。除了管理审计日志插件附带的配置变量之外,您还需要监视它是否始终运行。如果这对业务来说是一个关键功能,那么值得监控。由于插件可以在不重新启动服务器的情况下禁用,您需要更多的方式来确认它是否正在执行所需的操作,而不仅仅是检查磁盘上的my.cnf文件以确认它是否正在执行所需的操作。您最好使用 shell 查询来解析插件的当前状态,并确认它是否实际上正在监视查询。以下是两个示例单行查询来检查这两个方面:
# Single liner to check that the audit log plugin is active
$ mysql -e "show plugins" | grep -w audit_log | grep -iw active
# Single liner to check that the plugin policy is actually monitoring queries
$ mysqladmin variables | grep -w audit_log_policy | grep -iw queries
这些示例假定您只想监视查询。如果您还希望使用插件来跟踪登录,您需要编辑检查。
摄取和使用审计插件日志
正如您所见,审计日志插件具有很大的灵活性,但它也只为您生成审计事件。您需要确定最佳方法来摄取这些日志,将它们放在一个可以轻松搜索和分析的地方,并合理地发现其中的异常,而不至于成为一个重大负担。插件可以简单地将输出转储到本地文件,但这可能会增加通过这些日志填满数据库主机磁盘而导致故障的风险。
更复杂的选项是使用插件将其输出发送到rsyslog,这是一个常见的 Unix 日志管理实用程序,然后使用rsyslog将所有这些事件转发到您组织选择的结构化日志平台。这个选项很吸引人,因为它将这些数据带入到您的组织已经进行结构化日志存储的地方,这降低了数据库团队之外的利益相关者查看、搜索和分析这些事件的门槛。请记住,使用rsyslog以这种方式进行日志转发将要求您熟悉它的工作原理。确保您决定并以有意义的方式记录rsyslog如何配置这个数据流。可能有许多rsyslog的默认配置对您期望的结果没有帮助,您需要尽职调查找出这些配置并相应地更改它们。
警告
确保记录审计日志插件输出的存储方式,即使是临时存储在数据库主机上。如��这些文件的传送方式变慢,插件中缓冲事件的影响可能会影响数据库服务器本身的性能。这种故障状态很难调试,因为它的唯一症状是查询执行变慢。考虑以弹性为主的整个这些日志管道的混沌测试计划。
Percona 审计日志插件是一个强大的工具,可以帮助您满足许多合规性控制要求。根据我们的经验,它比使用触发器更高效,并且与配置管理和结构化日志记录软件很好地集成,从而形成一个对多个利益相关方团队有效的解决方案。
模式更改的版本控制
第六章介绍了不同的策略和工具,有助于规模化运行模式更改。让我们谈谈这些策略所支持的合规性问题。
使用版本控制来跟踪和运行模式更改,内置跟踪请求更改的人员、审查和批准的人员以及在生产中的运行情况。这也是使用每个数据库集群单独存储库的一个很好的理由。随着公司中数据库的增长,您会发现并非所有数据库都是平等的。有些需要更严格的合规性(例如,保存财务数据的数据库),而有些则用于不太关键的产品实验。在进行审计时,为每个数据集和集群提供更改记录将是一个巨大的便利。
基于合规需求的数据和集群模式管理的分离还使得更容易控制谁可以提交或批准您选择的版本控制管理中的模式更改。在进行业务审计时,通常需要证明谁可以对数据库进行更改。拥有一个较小的人员操作圈 可以 对这些数据进行更改,符合最小权限安全原则。
数据库用户管理
对数据库的更改不仅限于模式更改。您还需要以可跟踪和可重复的方式管理数据库用户及其细粒度权限。让我们看看如何满足一些常见的合规性控制,以解决数据库访问控制问题。
使用配置管理
使数据库用户跟踪合规的一个简单方法是利用与使数据库配置更改合规的相同流程。您可以在配置管理存储库中管理所有内容,并使用源代码控制、拉取请求流程和同行审查来提供证据,证明对数据库用户的所有更改都是以可审计和可跟踪的方式完成的。
计划凭证轮换
无论是因为未经计划的安全事件还是因为您有一个需要按计划轮换凭证的控制,您都需要制定一个计划,以便在不影响应用程序正常运行时间的情况下轮换数据库用户。这可能意味着更改应用程序使用的用户名和密码字符串。如果您尚未运行最新且最好的主要版本以支持双密码,请按照以下步骤在生产中旋转数据库凭证,而不会影响服务正常运行时间:
-
首先在数据库中引入一个新的用户名/密码对。
-
测试新凭证是否具有与旧凭证相同的访问权限。理想情况下,您应该自动执行此操作,作为部署新凭证的一部分,通过比较
SHOW GRANTS并确认权限是否相同。 -
创建一个应用程序部署,替换应用程序配置中的凭证。
-
重新启动此服务的所有实例,以确保新对正在使用。
-
删除旧的用户名和密码对。
无论更改是例行还是紧急的(因为凭证泄漏或安全风险),此过程应该是相同的。由于后者不是您可以控制或完全防止发生的事件,最好将此过程自动化,或至少在运行手册中进行充分记录,并定期执行,以便在发生意外情况时,团队不会感到恐慌。
提示
在 MySQL 中旋转数据库用户密码曾经是一个复杂的协调工作,需要在不影响可用性的情况下完成。MySQL 8.0.14 引入了双密码支持,结合密码过期策略支持,使得在操作上更容易做正确的事情。
停用不再使用的数据库用户
任何未使用的数据库用户仍然在您的实例上活跃是一个您不需要的安全风险。定期审计您实例上活跃的数据库用户,与您的应用程序配置进行比较,并删除任何未被任何应用程序活跃使用的用户是很重要的。
当为您的公司满足合规需求时,您会发现许多合规控制要求组织跟踪对某些资产的任何和所有更改。这些控制在报告中很典型,比如我们在本章前面描述的 SOC 2,其中主要关注的是提供数据完整性和安全性的证据。
有几种方法可以找出一个定义的数据库用户是否正在使用。我们在第三章中广泛介绍了 Performance Schema 作为检查服务器性能的一种方式。在 Performance Schema 中有一个 users 表,存储了连接到服务器的用户的历史信息。这种历史跟踪可以追溯到服务器进程的生命周期或此表允许的最大大小,以先到者为准。由于该表跟踪已连接的用户,而不是未连接的用户,您需要循环遍历已知用户,并查看是否有任何用户不出现在此表中,作为他们可能不再使用的信号。
这是一个启用 Performance Schema 中该工具的查询:
mysql> UPDATE performance_schema.setup_instruments
-> SET ENABLED=‘YES’ WHERE NAME='memory/sql/user_conn';
Query OK, 1 rows affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
一旦您启用了这个功能,查找此信息的表是 performance_schema.users。
如果您为合规控制使用审计日志解决方案,比如我们之前提到的 Percona 插件,您可以使用这些日志来确定用户是否在给定的几周内连接到实例。
无论您如何确定这一点,建议设置一个政策,“在六个月的不活跃后,将删除未连接的数据库用户。” 这是一个有助于防止不需要的访问权限并且现在是一个风险的做法。
您帮助管理的数据库将受到需要这种谨慎程度的控制范围。随着您的公司不断发展并开始考虑更加合规,您需要有一个故事来展示在将数据库更改应用之前如何审查和跟踪生产数据库的变化。合规控制还将关注的另一件事是证明您在发生灾难事件时恢复数据和服务的能力。为此,我们需要转向备份和恢复,看看它们如何适用。
备份和恢复程序
第十章涵盖了不同类型的备份。备份显然很重要。它们在事件中可以极大地帮助,同时也是许多合规控制的关键部分。在大多数 SOC 2 实施中,您将有关于创建和测试备份的控制(但无论如何,您应该测试您的备份)。随着您管理的数据库集群数量增加,您很快会发现无法继续手动执行备份和备份测试等流程,甚至无法通过手动阅读日志文件报告成功和失败。
在评估出于合规原因如何管理备份时,您需要满足一些要求:
-
您需要自动化备份过程。
-
如果备份失败,您需要备份过程提醒您。
-
您需要自动化备份测试。
-
备份测试失败也应该是您可以在某处跟踪的事件。
接下来我们讨论如何安排备份和备份测试。
运行自动备份和备份测试
为了满足这些要求,��需要一个机制,不仅仅运行计划任务(例如 Linux 系统中的crond),而且可以按计划运行,并且还具有向您的监控系统和工单系统发送事件的能力,以便在失败时发出警报并为以后的审计跟踪失败。您可以通过将备份和备份测试作为监控检查运行来做到这一点,⁴但是备份可能需要一段时间才能运行,特别是如果您有一些以 TB 为单位的数据库实例。只要监控系统可以处理运行时间远远超过几秒的典型时间的检查,将备份作为监控项目运行就可以工作。因此,请确保运行监控系统的团队意识到这种用例。
如果您的监控系统无法处理这种用例,那么请确保您有一些方法留下“踪迹”以跟踪备份已经发生并成功完成,以及备份测试已经发生并成功完成。这样的踪迹可以是一个文件,其中包含备份或备份测试运行结束时您的备份过程编辑的时间戳,作为任务发生并完成的证据。一旦放置了这个踪迹,您可以使用监控系统进行更快速的检查,以确保踪迹存在。
在所有这些策略中,您想要拥有的,以及您的 SOC 2 控制要求的,是备份成功完成的记录以及任何失败的备份的记录,显示它们已转化为正确跟踪的工作项。
备份和备份测试的集中日志
您可能还会被要求展示日志,证明备份和备份测试过程成功完成。您最好准备好这样的审计项目,通过使用可以将日志发送到以保持连续性的集中日志解决方案。请记住,我们为这些业务需求构建的解决方案应该假设服务器易于且可重复替换,而不是定制的。因此,如果您在下一次审计之前退休该机器并替换它,那么随机实例上的本地日志文件并不理想。您希望任何与业务相关的资产,例如备份过程的日志,都位于任何具有正确访问策略的人都可以访问的集中位置。
通过备份进行灾难恢复规划
作为 SOC 2 的一部分,还需要进行适当的灾难恢复规划。这意味着您需要证明您测试系统生成的任何备份,您需要跟踪这些测试失败的时间以及失败已经被纠正,并且最好您需要知道数据灾难恢复需要多长时间。这最后一部分需要跟踪备份测试需要多长时间的指标。第二章提到数据库实例大小作为确定备份恢复是否在预期时间内变得过大的度量标准。使这成为一个自我改进的循环的方法是使您的备份和测试备份的脚本也发送每个任务需要多长时间的指标。这样,您就可以获得每个数据库集群备份需要多长时间以及恢复和测试这些备份需要多长时间的指标。现在您有了一种跟踪任何给定数据集是否变得过大以至于超出业务对 MTTR 的期望的方法。如果是这样,那么您有数据要么优先处理将数据集分割到可接受大小,要么重新审视业务的恢复 SLA。
关于备份的一个重要最终注意事项:您的安全利益相关者需要访问实时数据库和备份以进行控制。确保您在您喜爱的云服务提供商中的备份设置不会默认为备份存储桶的宽松访问控制。许多安全漏洞并非通过侵入实时基础设施发生,而是通过从某个存储桶泄漏的备份发生。
摘要
合规性是一个广泛的政策和控制世界,以及对每个解释的影响。它不仅影响您在业务中运行数据库的方式,还影响法律、财务和 IT 部门,甚至影响您如何部署软件更改。本章重点介绍了每种常见类型的合规性法规如何特别影响您作为数据库工程师的职责。然后我们涵盖了这些法规可能受到影响的不同实践和架构决策,您也需要考虑这些。
广义上说,摆脱与控制相关的噩梦的最佳方法是提前计划。分离应用程序用户,制定凭据轮换策略,并确保密码始终以加密方式存储——绝不是明文。确保在需要开始记录对数据库的访问之前,你有一个可信赖的日志记录管道。最后,你希望对模式更改进行控制和记录。
本章的目标不是让你一次性考虑整个基础架构中所有这些控制措施,而是让为每项法规规定的范围提供证据的任务更容易,并尽可能自动化或简化组合。最终,这些控制措施旨在保护企业和客户的隐私。对每项控制措施的目标有清晰的理解将使您和您的团队在公司发展并进入更广泛市场时更容易管理这一重要任务。
¹ 欲了解更多关于这些标准的信息,请与您友好的信息安全团队交谈,或者从 O’Reilly 获取NIST 网络安全框架口袋指南。
² 有关此内容,请参阅插件文档。
³ 作为一个开始,这里有一个关于“可靠日志转发”的整个页面。
⁴ 在博客文章“使用 Sensu 进行 DBA 任务”中,您可以看到一些将备份任务纳入数据库监控解决方案的示例。
附录 A. 升级 MySQL
升级是稳定性和功能之间的权衡。在选择升级时,您应考虑这一点。使用 MySQL 的最大优势之一是其广泛的安装基础。这意味着您可以从许多其他人测试和使用 MySQL 中获益。如果升级到太新的版本,您可能会不知不觉地引入一个错误或回归到您的环境中。如果保持太落后,您可能会遇到不明显的错误或无法利用为性能优化的功能。
为什么升级?
决定继续进行版本升级可能是一个风险的过程。通常涉及备份所有数据,测试更改,然后运行升级过程。在我们深入讨论细节之前,了解为什么您可能希望升级是很重要的。
有许多升级的原因:
安全漏洞
多年来,发现 MySQL 中的安全漏洞的可能性已经减少,但仍然有可能。您或您的安全团队可能会评估这些漏洞,并确定您应该执行升级。
已知错误
在生产环境中遇到未知或无法解释的行为时,我们建议找出您正在运行的 MySQL 版本,然后阅读最新版本的发行说明。您完全有可能发现您正在经历的情况实际上是 MySQL 中的软件错误。如果您的问题得到解决,您可能需要升级 MySQL。
新功能
MySQL 在如何添加功能方面并不总是严格遵循主要/次要/点版本发布策略。许多人可能期望点版本(例如,从 8.0.21 到 8.0.22)只包含错误修复,而次要版本更改(从 8.0 到 8.1)将包含次要功能。Oracle 经常在次要点版本中发布可能对您的工作负载产生影响的新功能。这种策略是一把双刃剑,这就是为什么在升级之前应该阅读所有的发行说明。
MySQL 终止生命周期支持
Oracle 为 MySQL 设置了终止生命周期(EOL)时间。一般来说,建议保持在支持的版本内,这样至少安全修复仍然受支持。
现在我们已经讨论了影响您升级决定的各种因素以及具体版本,让我们讨论规划和安全完成升级的过程。
升级生命周期
一旦您做出升级是正确的决定,通常会采取以下步骤:
-
阅读该版本的发行说明,包括任何次要更改。
-
阅读官方文档中的升级说明。
-
执行新版本的测试。
-
最后,升级您的服务器。
发行说明通常包含重要信息,如新功能、更改或已弃用的功能,通常还会列出已修复的错误。升级说明为您提供了如何执行升级的详细概述,并提醒您在继续之前需要了解的任何重要信息。
此外,您还应该制定一个计划,以应对引入问题的情况,比如查询开始表现不佳,或者更糟糕的是,您开始遇到崩溃错误。对于所有主要和次要版本更改(例如,从 8.0 降级到 5.7 或从 5.7 降级到 5.6),唯一的降级方法是从升级之前的备份恢复。这使得升级特别危险,因此请确保您有一个计划。
警告
需要注意的是,自 MySQL 8.0 起,您也不能降级点版本。例如,一旦您运行 8.0.25,您就无法降级到 8.0.24,而不导出所有数据并重新导入。
测试升级
一旦您阅读了发布和升级说明,您应该对任何测试的关注点或重点有很好的理解。下一步将是测试这个新版本在您的工作负载下的行为。您还需要验证您已经审查了配置文件。MySQL 的新版本通常会重命名变量或完全弃用它们。
测试是一个难以完成的步骤,每种方法都有注意事项。考虑到之前提到的关于降级的风险,您应该在升级之前尽可能多地采用这些方法。
开发环境测试
希望您有一个用于数��的开发环境。这是开始测试的好地方,可以在共享开发数据库上进行测试,也可以在独立数据库上进行测试。使用这个的主要目标是发现任何明显的语法问题。大多数开发环境不包含与生产数据相同大小的数据,因此很难进行准确的测试。例如,您可能运行您常用的查询并看到它们正常,因为它们只访问表中的 10 行。当您转到生产环境时,表中有 1000 万行,您可能会看到退化。
生产镜像
另一个选择是创建生产数据的副本,并将您的 SQL 流量发送到副本。这种方法在Etsy 的 Code As Craft 博客上的一篇博文中展示过。简而言之,您有生产数据库的第二个副本,停止使用复制,并在副本上升级 MySQL。完成后,使用tcpdump和pt-query-digest的组合将流量发送到您的实时生产系统和副本。您的应用程序仍然仅使用生产系统进行实时流量,而具有升级版本的副本可以为您提供性能指标并显示语法错误。
副本
如果您的拓扑结构有读取副本并且您有能力将副本脱离池,您可以考虑首先升级其中一个副本。这将允许您查看实际生产工作负载下的读取流量表现。如果观察到错误或退化,您可以将副本脱离池并进行调整。这种方法的缺点是您无法测试性能或写入流量。
工具
Percona Toolkit 提供了工具pt-upgrade,它接受查询作为输入,针对两个不同的目标运行这些查询,并生成报告告诉您行数、行数据或错误的任何差异。由于它可以接受许多不同类型的输入(慢查询日志、常规查询日志、二进制日志),它可以是获得额外测试覆盖的好选择。
最佳使用方法是首先收集您最感兴趣的查询,可以使用慢查询日志或二进制日志。然后设置两个相同的系统,仅将其中一个升级到新版本,并对两者运行pt-upgrade以查看差异。
大规模升级
升级 MySQL 非常简单,并且在官方 MySQL 文档中有详细介绍。简而言之,如果您正在进行原地升级,您将停止 MySQL,替换二进制文件,启动 MySQL,然后运行mysql_upgrade脚本。
如果您在数百个 MySQL 服务器上执行此操作,这可能会变得重复。我们建议尽可能自动化这个过程。您可以使用 Ansible 的一种方式来实现这一点。
这里是一个建议的安全升级过程的骨架,您可以将其用作构建 Ansible playbook 的指南,如果您选择的话:
1. 验证目标。
你要做的第一件事是防止生产系统的意外升级。如果您有一个可以查询以确定数据库是否正在接收流量的系统,这是检查的地方。如果您遵循我们在第五章的建议,应该使用read_only标志来防止对副本的意外写入。如果没有可以检查的系统,这可以作为一个很好的替代方案。如果服务器是可写的,那么您可能不希望对其进行升级,因为它可能正在接收生产写入。您还可以使用此步骤验证您是否已经升级了服务器。这样可以稍后对其运行 playbook,而它将不执行任何操作。
2. 设置停机状态。
希望你的系统正在被监控。下一步涉及设置某种形式的停机或警报抑制,以便在 MySQL 在新版本上重新启动时不会收到警报。
3. 其他前提条件。
如果您有任何其他依赖服务,比如配置管理工具或其他监控工具,在 MySQL 离线时会生成错误,现在是关闭它们的好时机。
4. 删除旧包。
我们首选的方法是此时完全删除任何已安装的 MySQL 包。这有助于避免主要版本(5.7 到 8.0)之间的任何冲突包。
5. 安装新包。
接下来,您将希望在系统上安装新包。
6. 启动 mysqld。
启动mysqld服务。
7. 运行 mysql_upgrade。
如果早于 MySQL 8.0,²运行mysql_upgrade过程。特别注意,如果您像我们建议的那样使用super_read_only运行 MySQL,则在mysql_upgrade步骤中将其设置为OFF。
8. 重新启动 mysqld。
我们更喜欢在此时对mysqld进行干净重启。这将确保它正确启动并使用升级后的文件,并且您的配置文件也在工作。
9. 验证您可以连接。
简单连接并运行SELECT 1以确保 MySQL 正常工作。
10. 恢复任何已禁用的服务。
如果关闭了任何配置管理或监控工具,请重新启用它们。
11. 清除停机状态。
将服务器从停机状态中取出,以便观察是否有任何升级过程失败的情况。
通过这个过程,您可以将您的运行手册指向任何服务器,并仅升级不接收流量的未升级节点。
摘要
升级 MySQL 有许多原因,最引人注目的是修复您正在遇到的错误或利用新功能。例如,MySQL 8.0 引入了一个功能,InnoDB 可以立即添加列,无需重建整个表。这种功能增强对于执行大量ALTER TABLE .. ADD COLUMN语句的公司来说可以节省大量时间。通过努力进行安全升级过程,最终将节省执行这些列添加语句的时间,以及改进开发人员体验。
主要版本升级可能令人生畏。您绝对应该花费大量精力测试升级是否会产生任何不良影响。通常,您希望检查升级是否导致任何查询延迟偏差或新错误。一旦您获得信心,慢慢推出并具有回滚过程。
最后,如果您有大量服务器需要管理,请考虑大力投资于尽可能自动化该过程。自动化可以使升级过程变得更容易重复,并比直接登录每台服务器更节省时间,也减少了因在错误服务器上而导致的拼写错误和意外停机的几率。
¹ 长期参与 MySQL 社区的成员 Stewart Smith,著名地提出了点二十规则:“[规则] 是指软件在发布点二十版本之前永远不会真正成熟。”虽然这不是一个严格的规则,但它突显了新版本和稳定性之间的权衡。
² MySQL 8.0 将 mysql_upgrade 过程移入了服务器启动过程中。无需作为额外步骤运行。
附录 B. Kubernetes 上的 MySQL
如果您在过去五年中一直在科技领域工作,很可能已经听说过 Kubernetes,与运行 Kubernetes 的团队合作,或者看过很多关于 Kubernetes 的会议演讲或阅读了很多博客文章。如果您的组织运行自己的 Kubernetes 集群,那么在某个时候,您会被问及是否在其中运行 MySQL 也是一个好主意。从表面上看,这似乎是一个合理的选择。管理许多 Kubernetes 集群是一个复杂的任务,通常需要专门的人力资源,因此您的组织希望利用这种专业知识来处理不仅仅是无状态工作负载。但是,有很多理由可以探索在 Kubernetes 上运行 MySQL,也有一些不太好的理由。让我们在这里揭开一些关于在 Kubernetes 上运行 MySQL 的恐惧、不确定性和怀疑。
使用 Kubernetes 配置资源
在 Kubernetes 达到技术高峰之前,许多公司要么构建了完全定制的技术堆栈来配置和管理虚拟机和裸金属服务器,要么将开源项目粘合在一起,完成资源生命周期的较小部分。然后 Kubernetes 出现了,作为一个更完整的生态系统,用于管理计算和存储资源,将其作为统一的配置堆栈的前景变得越来越吸引人。然而,像 MySQL 这样的有状态负载仍然落后,因为普遍看法是“你不能在容器上运行数据库。”
仔细界定您的目标
要记住的重要事情是“我们想在这里获得什么具体价值?”Kubernetes 对于无状态负载非常强大,因为它带来了计算资源的弹性和效率。然而,在查看统一的配置堆栈时,将胜利范围缩小到“我们只想使用 Kubernetes 来配置和配置数据库资源系统。”这意味着您需要事先明确,将使用 Kubernetes 配置的数据库工作负载将与无状态工作��载分开管理,需要不同的操作员技能集,并且将以不同方式处理容器故障。
选择您的控制平面
现在有各种 MySQL 操作员,但最佳选择将主要取决于您决定的 Kubernetes 管理 MySQL 的范围。您是否需要一个可以完成所有工作的操作员:配置、故障转移和管理连接到数据库?还是您只需将 Kubernetes 用作配置堆栈,并在服务后使用其他方法来管理数据库?早早决定您对控制平面的期望,因为这将驱动许多更精细的可操作性细节。
更详细的细节
一旦您决定开始使用 Kubernetes 配置 MySQL 资源,您需要在组织中就适合此解决方案的数据规模达成一致。请记住,这现在是一个新的操作模型,用于运行关系型数据库,而在这条少有人走的道路上,随着规模的扩大,一切都变得更加复杂。以下是一些重要事项,您在与 Kubernetes 工程团队合作时(希望您有专门的团队负责此事)需要考虑如何支持有状态工作负载:
-
单个数据库实例支持的最大数据集大小是多少?
-
您是否将卷挂载到容器中,并将容器恢复与数据挂载分开管理?还是数据将成为容器的一部分?
-
将支持的最大查询吞吐量是多少?您将如何管理资源?
-
您将如何确保运行数据库工作负载的 Kubernetes 节点专用于此,而不与无状态、更具弹性的工作负载共享?
-
您将使用什么控制平面来运行数据库实例?它是否是 Kubernetes 本地的?
-
备份将如何工作?恢复过程是什么?
-
您将如何控制并安全地推出配置更改和 MySQL 升级?
-
您将如何升级 Kubernetes 集群本身而不会造成中断?
与您的合作伙伴 Kubernetes 工程团队就这个解决方案的工作方式达成一致意见,将有助于为希望使用该解决方案的功能团队建立完善的 SLO,并在正确传达解决方案解决了什么以及团队仍需自行解决什么方面。
我们在运行 MySQL on Kubernetes 时的建议是投资于学习一个已经在 Kubernetes 生态系统中经过验证和证明的控制平面,比如 Vitess。但在尝试运行之前,也要先爬行。在您的组织中,MySQL 不应该是在 Kubernetes 上运行工作负载的第一个实验对象。始终要证明可行性,并与无状态工作负载团队一起学习尖锐的边缘,然后再尝试运行像 MySQL 这样更复杂的用例。在确定最佳的初始采用用例时,从小数据集(仅在磁盘上几个千兆字节的数据库)和较少关键数据集开始,以使您的团队、Kubernetes 团队和功能团队熟悉在 Kubernetes 上运行有状态工作负载的新操作模型,同时对业务风险较小。
在 Kubernetes 上运行有状态工作负载已经成熟了几年,并且随着那些投入大量工程时间使其更具可行性的公司的重要贡献,它仍处于初期阶段,与直接在 VM 上运行相比,您会发现缓慢和谨慎的采用方法才是长远回报的关键。特别要考虑 MySQL 在 Kubernetes 上的故障模式是什么样的,并问自己:如果一切都出错了,我该如何重新组合?我会丢失数据吗?确保您有答案。
总结
Kubernetes 是当前技术领域中增长最快的基础设施平台之一,而且理由充分。它所带来的工程速度和由云原生基金会支持的丰富生态系统使其成为公司吸引投资的有吸引力的选择。但您应该通过风险和回报的视角来考虑像在 Kubernetes 上运行 MySQL 这样的决策,以及对您的团队和公司的影响。确保您对组织的 Kubernetes 之旅中像数据存储这样的有状态服务的位置有共同的理解。想要利用 Kubernetes 对所有工作负载的现有投资是可以理解的,但这需要与数据存储层的稳定性需求进行良好的平衡。
¹关于在 Kubernetes 上运行数据库工作负载的出色“实战”会议演讲,我们推荐由 Alice Goldfuss 主持的“容器操作员手册”主题演讲。


浙公网安备 33010602011771号