MySQL-学习指南第二版-全-

MySQL 学习指南第二版(全)

原文:zh.annas-archive.org/md5/f527da9e8a9c560427b1439b61c19531

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

数据库管理系统是许多公司核心的一部分。即使一家企业不是以技术为中心,它也需要以快速、安全和可靠的方式存储、访问和操作数据。由于 COVID-19 大流行,许多传统上抵制数字转型的领域,如许多国家的司法系统,现在由于旅行和会议限制而通过技术进行整合,在线购物和远程工作比以往任何时候都更受欢迎。

但并非只有灾难推动了如此深远的变化。随着 5G 的到来,我们很快将有更多的机器连接到互联网,而不是人类。已经有大量数据被收集、存储和用于训练机器学习模型、人工智能等等。我们正处在下一个革命的开端。

出现了几种数据库类型,帮助存储更多数据,特别是非结构化数据,包括 MongoDB、Cassandra 和 Redis 等 NoSQL 数据库。然而,传统的 SQL 数据库仍然很受欢迎,并且没有迹象表明它们会在近期消失。在 SQL 的世界中,无疑最受欢迎的开源解决方案是 MySQL。

本书的两位作者都与来自世界各地的许多客户合作过。在此过程中,我们学到了许多经验教训,并且涉及了大量用例,从关键任务的单体应用到更简单的微服务应用。本书充满了我们认为大多数读者在日常活动中会发现有帮助的提示和建议。

本书的读者对象

本书主要是为首次使用 MySQL 或将其作为第二数据库学习的人士编写的。如果您是首次进入数据库领域,第一章将向您介绍数据库设计概念,并向您展示如何在不同操作系统和云中部署 MySQL。

对于从其他生态系统(如 Postgres、Oracle 或 SQL Server)过来的人,本书涵盖了备份、高可用性和灾难恢复策略。

我们希望所有读者也会发现本书是学习或复习基础知识的好伴侣,从架构到生产环境的建议。

本书的组织结构

我们介绍了许多主题,从基本的安装过程、数据库设计、备份和恢复到 CPU 性能分析和错误调查。我们将本书分为四个主要部分:

  1. 开始使用 MySQL

  2. 使用 MySQL

  3. MySQL 在生产环境中

  4. 杂项主题

让我们看看我们如何组织章节。

开始使用 MySQL

第一章,安装 MySQL解释了如何在不同操作系统上安装和配置 MySQL 软件。本章提供了比大多数书籍更详细的信息。我们知道,对于刚开始使用 MySQL 的人来说,他们通常对各种 Linux 发行版和安装选项不熟悉,并且在 MySQL 上运行“hello world”比编译任何编程语言的 hello world 需要更多步骤。您将了解如何在 Linux、Windows、macOS 和 Docker 上设置 MySQL,并快速部署实例以进行测试。

使用 MySQL

在我们开始创建和使用数据库之前,我们将在第二章,数据库建模和设计中看到如何进行正确的数据库设计。您将学习如何访问数据库的特性,并查看数据库中信息项之间的关系。您将看到,糟糕的数据库设计很难更改,并可能导致性能问题。我们将介绍强实体和弱实体及它们的关系(外键),并解释规范化的过程。本章还展示了如何下载和配置数据库示例,如sakilaworldemployees

在第三章,基本 SQL中,我们探讨了 CRUD(创建、读取、更新和删除)操作中的著名 SQL 命令。您将学习如何从现有的 MySQL 数据库中读取数据,存储数据,并操作现有数据。

在第四章,数据库结构操作,我们解释如何创建新的 MySQL 数据库,以及如何创建和修改表、索引和其他数据库结构。

第五章,高级查询涵盖更高级的操作,如使用嵌套查询和不同的 MySQL 数据库引擎。本章将使您能够执行更复杂的查询。

MySQL 在生产环境中的应用

现在您已经知道如何安装 MySQL 并操纵数据,下一步是了解 MySQL 如何处理对同一数据的并发访问。我们将在第六章,事务和锁中探讨隔离、事务和死锁的概念。

在第七章,MySQL 更多操作,您将看到更复杂的查询,可以在 MySQL 中执行,并了解如何观察查询计划以检查查询是否高效。我们还将解释 MySQL 中可用的不同引擎(InnoDB 和 MyISAM 是最著名的两种)。

在第八章,用户管理与权限中,您将学习如何在数据库中创建和删除用户。这一步骤在安全方面非常重要,因为权限过大的用户可能会对数据库和公司的声誉造成严重损害。您将了解如何建立安全策略,授予和撤销权限,以及限制对特定网络 IP 的访问。

第九章,使用选项文件涵盖了 MySQL 配置文件,或称选项文件,其中包含启动 MySQL 和优化其性能所需的所有必要参数。熟悉 MySQL 的人会认识到 /etc/my.cnf 配置文件。您还将看到可以使用特殊选项文件配置用户访问权限。

没有备份策略的数据库迟早会面临灾难。在第十章,备份与恢复中,我们讨论了不同类型的备份(逻辑物理),执行此任务的可选项以及适合大型生产数据库的方法。

第十一章,服务器配置与调优详细讨论了在设置新服务器时需要注意的关键参数。我们提供了相应的公式,并帮助您确定是否为数据库工作负载选择了正确的参数值。

杂项话题

既然基础知识已经确立,现在是时候进一步深入了。第十二章,监控 MySQL 服务器教您如何监控数据库并从中收集数据。由于数据库工作负载行为可能会根据用户数量、事务和正在处理的数据量而变化,因此识别哪个资源已经饱和及其原因至关重要。

第十三章,高可用性解释了如何复制服务器以提供高可用性。我们还介绍了集群概念,重点介绍了两种解决方案:InnoDB Cluster 和 Galera/PXC Cluster。

第十四章,MySQL 在云中将 MySQL 的应用扩展到了云端。您将了解到数据库即服务(DBaaS)选项,以及如何使用三大主要云服务提供商(Amazon Web Services(AWS)、Google Cloud Platform(GCP)和 Microsoft Azure)提供的托管数据库服务。

在第十五章,MySQL 负载均衡中,我们将向您展示最常用的工具,以在不同的 MySQL 服务器之间分发查询,从而进一步提升 MySQL 的性能。

最后,在第十六章,杂项话题中,我们介绍了更高级的分析方法和工具,以及一些编程内容。在本章中,我们将讨论 MySQL Shell、火焰图以及如何分析 bug。

本书中使用的约定

本书使用以下印刷约定:

斜体

表示新术语、网址、电子邮件地址、文件名和文件扩展名。

等宽字体

用于程序列表,以及在段落中引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。

**等宽粗体**

显示用户应按字面输入的命令或其他文本。

*等宽斜体*

显示应用用户提供的值或由上下文确定的值的文本。

提示

此元素表示提示或建议。

注意

此元素表示一般注释。

警告

此元素表示警告或注意事项。

使用代码示例

可以从https://github.com/learning-mysql-2nd/learning-mysql-2nd下载代码示例。

如果您有技术问题或在使用代码示例时遇到问题,请发送电子邮件至bookquestions@oreilly.com

本书旨在帮助您完成工作。一般情况下,如果本书提供了示例代码,您可以在您的程序和文档中使用它。除非您复制了代码的大部分内容,否则您无需联系我们以获取许可。例如,编写一个使用本书中几个代码块的程序不需要许可。出售或分发奥莱利书籍中的示例代码需要许可。引用本书并引用示例代码来回答问题不需要许可。将本书中大量示例代码合并到产品文档中需要许可。

我们感谢,但通常不要求归因。归因通常包括标题、作者、出版商和 ISBN。例如:“学习 MySQL,第二版,作者 Vinicius M. Grippa 和 Sergey Kuzmichev(奥莱利)。版权所有 2021 年 Vinicius M. Grippa 和 Sergey Kuzmichev,978-1-492-08592-8。”

如果您觉得您对代码示例的使用超出了合理使用范围或以上所给的许可,请随时通过permissions@oreilly.com与我们联系。

奥莱利在线学习

注意

40 多年来,奥莱利传媒已为企业提供技术和商业培训、知识和见解,帮助企业取得成功。

我们独特的专家和创新者网络通过书籍、文章和我们的在线学习平台分享他们的知识和专长。奥莱利的在线学习平台让您随需学习,提供深入的学习路径、交互式编码环境以及来自奥莱利和 200 多家其他出版商的大量文本和视频内容。更多信息,请访问http://oreilly.com

如何联系我们

请将有关本书的评论和问题发送给出版商:

  • 奥莱利传媒公司

  • 北加利福尼亚格拉文斯坦公路 1005 号

  • 加利福尼亚州塞巴斯托波尔 95472

  • 800-998-9938(美国或加拿大)

  • 707-829-0515(国际或本地)

  • 707-829-0104(传真)

我们为这本书制作了一个网页,列出勘误、示例和任何额外信息。您可以访问此页面https://oreil.ly/learn-mysql-2e

发送邮件至bookquestions@oreilly.com评论或提问关于本书的技术问题。

欲了解我们的书籍和课程的最新消息,请访问http://oreilly.com

在 Facebook 上找到我们:http://facebook.com/oreilly

在 Twitter 上关注我们:http://twitter.com/oreillymedia

在 YouTube 上观看我们:http://youtube.com/oreillymedia

致谢

来自 Vinicius Grippa

感谢以下人员帮助改进本书:Corbin Collins、Charly Batista、Sami Ahlroos 和 Brett Holleman。没有他们,这本书就不会达到我们所追求的卓越水平。

感谢 MySQL 社区(特别是 Shlomi Noach、Giuseppe Maxia、Jeremy Cole 和 Brendan Gregg)以及Planet MySQLSeveral NinesPercona BlogMySQL Entomologist上的所有博客作者,他们为我们提供了大量材料和优秀工具。

感谢所有在 Percona 提供写作本书支持的人,特别是 Bennie Grant、Carina Punzo 和 Marcelo Altmann,他们帮助我在职业和人生成长中得到提升。

感谢 O’Reilly 的工作人员出色地出版书籍和组织会议。

我要感谢我的父母 Divaldo 和 Regina,我的姐姐朱莉安娜,以及我的女朋友卡琳,在这个项目中给予我耐心和支持。特别感谢 Paulo Piffer,他给了我第一个能够从事我所热爱工作的机会。

最后,感谢谢尔盖·库兹米切夫,本书的共同作者。没有他的专业知识、奉献精神和辛勤工作,这本书将不可能完成。我很荣幸能与他共事,共同完成这个项目。

来自谢尔盖·库兹米切夫

我要感谢我的妻子 Kate,在这个艰难而有回报的项目的每一步都支持和帮助我。从思考是否承担写作这本书的责任,到写作期间的许多艰难时日,她一直在我身边。我们的第一个孩子在写作期间出生,但 Kate 仍然找到时间和力量继续激励和帮助我。

感谢我的父母、亲戚和朋友,多年来他们帮助我成长为一个人和专家。感谢你们在这个项目中对我的支持。

感谢 Percona 的出色团队在我撰写本书期间帮助我解决所有技术和非技术问题:Iwo Panowicz、Przemyslaw Malkowski、Sveta Smirnova 和 Marcos Albe。感谢 Stuart Bell 和 Percona 支持团队的每一个成员,在我们每一步的旅程中都提供了令人惊叹的帮助。

感谢 O’Reilly 的每一位领导者和帮助我们创建这个版本的人。感谢 Corbin Collins 在书的结构塑造和我们坚定的前行道路上的帮助。感谢 Rachel Head 在复制编辑阶段发现的众多问题,以及在我们的写作中发现 MySQL 技术细节问题。没有你们和 O’Reilly 的每一位成员,这本书就不会是一本书,而只是一堆松散相关的文字。

特别感谢我们的技术编辑 Sami Ahlroos、Brett Holleman 和 Charly Batista。他们在确保本书技术和非技术内容的最高质量方面发挥了关键作用。

感谢 MySQL 社区的每一位成员以开放、帮助和分享知识的方式。MySQL 的世界不是封闭的花园,而是向所有人开放的。我要提到 Valerii Kravchuk、Mark Callaghan、Dimitri Kravchuk 和 Jeremy Cole,他们通过他们的博客帮助我更好地理解 MySQL 的内部工作机制。

我要感谢本书第一版的作者们:Hugh E. Williams 和 Seyed M.M. Tahaghoghi。多亏了他们的工作,我们在一个坚实的基础上建立了这个项目。

最后但同样重要,我要感谢 Vinicius Grippa,他是一位出色的共同作者和同事。没有他,这本书就不会是现在这个样子。

我将本版献给我的儿子 Grigorii。

第一章:安装 MySQL

让我们通过安装 MySQL 并首次访问它开始我们的学习之旅。

请注意,我们在本书中并不依赖于单一版本的 MySQL。相反,我们从我们在现实世界中对 MySQL 的集体知识中汲取。本书的核心集中在 Linux 操作系统(主要是 Ubuntu/Debian 和 CentOS/RHEL 或其衍生版)和 MySQL 5.7 以及 MySQL 8.0 上,因为我们认为这些是适合生产工作负载的“当前”版本。MySQL 5.7 和 8.0 系列仍在开发中,这意味着将继续发布具有错误修复和新功能的新版本。

随着 MySQL 成为最受欢迎的开源数据库(排名第一的 Oracle 并非开源),对快速安装过程的需求也在增加。您可以把从头安装 MySQL 想象成烘焙蛋糕:源代码就是配方。但即使有了源代码,构建软件的配方也不容易跟随。编译需要时间,并且通常需要安装额外的开发库,这会使生产环境面临风险。假设你想要一个巧克力蛋糕;即使你有自己制作的说明,你可能不想把厨房弄乱,或者没有时间亲自烘焙,所以你去面包店买了一个。对于 MySQL,当您希望它在不需要编译的情况下即可使用时,您可以使用分发包

MySQL 的分发包适用于各种平台,包括 Linux 发行版、Windows 和 macOS。这些包提供了一个灵活和快速的开始使用 MySQL 的方式。回到巧克力蛋糕的例子,假设你想改变一些东西。也许你想要一个白巧克力蛋糕。对于 MySQL,我们有所谓的分支,其中包括一些不同的选项。我们将在下一节中看看其中几个。

MySQL 分支

在软件工程中,分支发生在某人复制源代码并开始独立开发和支持的路径时。分支可以沿着接近原始版本的轨道进行,就像 Percona 分发的 MySQL 一样,也可以像 MariaDB 一样偏离。由于 MySQL 源代码是开放和免费的,新项目可以在不需要原始创建者许可的情况下分叉代码。让我们来看看一些最显著的分支。

MySQL 社区版

MySQL 社区版,也称为 MySQL 的上游原版版本,是由 Oracle 分发的开源版本。这个版本推动了 InnoDB 引擎和新功能的开发,并且是第一个接收更新、新功能和错误修复的版本。

Percona MySQL 服务器

Percona 的 MySQL 发行版是 MySQL 社区版的免费开源替代品。其开发密切跟随该版本,专注于提高性能和整体 MySQL 生态系统。Percona Server 还包括额外的增强功能,如 MyRocks 引擎、审计日志插件和 PAM 认证插件。Percona 由Peter ZaitsevVadim Tkachenko共同创立。

MariaDB 服务器

Michael “Monty” Widenius创建并由 MariaDB 基金会分发,MariaDB 服务器迄今为止是最远离原始 MySQL 的分支。近年来,它开发了新功能和引擎,如 MariaDB ColumnStore,并且是第一个集成 Galera 4 集群功能的数据库。

MySQL 企业版

MySQL 企业版目前是唯一具有商业许可证的版本(这意味着您需要付费使用,类似于 Windows 许可证)。由 Oracle 公司分发,它包含了社区版的所有功能,以及专为安全性、备份和高可用性而设的独家功能。

安装选择和平台

首先,您必须选择与您操作系统(OS)兼容的 MySQL 版本。您可以通过MySQL 网站验证兼容性。同样的支持政策也适用于Percona ServerMariaDB

我们经常听到这样的问题:能否在不支持的操作系统上安装 MySQL?大多数情况下,答案是可以的。例如,可以在 Windows 7 上安装 MySQL,但碰到错误或面临不可预测的行为(如内存泄漏或性能不佳)的风险较高。由于这些风险,我们不建议在生产环境中这样做。

下一步是决定是否安装开发版或稳定可用(GA)版。开发版拥有最新功能,但我们不建议在生产环境中使用,因为它们不稳定。GA 版,也称为生产稳定版,适用于生产环境使用。

提示

我们强烈建议使用最新的 GA 版,因为它包含了最新的稳定性修复和性能优化。

最后要决定的是安装操作系统的分发格式。对于大多数用例,二进制分发是最合适的。二进制分发在许多平台上都有本地格式,例如 Linux 的.rpm包或 macOS 的.dmg包。分发还以通用格式提供,如.zip归档或压缩的.tar文件(tarballs)。在 Windows 上,您可以使用 MySQL 安装程序安装二进制分发。

警告

注意版本是否为 32 位或 64 位。一般原则是选择 64 位版本。除非您使用古老的操作系统,否则不应选择 32 位版本。这是因为 32 位处理器只能处理有限量的 RAM(4GB 或更少),而 64 位处理器能够处理更多内存。

安装过程包括四个主要步骤,如下部分所述。正确遵循这些步骤并设置 MySQL 数据库的最低安全要求至关重要。

1. 下载要安装的分发版

每个发行版都有其所有者,因此也有其来源。一些 Linux 发行版在其仓库中提供默认软件包。例如,在 CentOS 8 上,MySQL 原始发行版可从默认仓库获取。当操作系统有默认软件包可用时,无需从网站下载 MySQL 或自行配置仓库,这简化了安装过程。

我们将展示如何在安装过程中无需访问网站即可安装仓库并下载文件。但是,如果您确实想自行下载 MySQL,可以使用以下链接:

2. 安装分发版

安装包括使 MySQL 功能正常并将其联机的基本步骤,但不包括安全设置。例如,在此时,MySQL 的 root 用户可以无需密码连接,这非常危险,因为 root 用户具有执行所有操作的特权,包括删除数据库。

3. 执行任何必要的安装后设置

此步骤涉及确保 MySQL 服务器正常工作。确保服务器安全非常重要,首先要执行mysql_secure_installation脚本。您将更改 root 用户的密码,禁用远程服务器对 root 用户的访问,并删除测试数据库。

4. 运行基准测试

一些数据库管理员为每个部署运行基准测试,以测量其性能是否适合他们正在使用的项目。这项工作的最常见工具是sysbench。这里需要强调的是,sysbench执行的是合成工作负载,而在应用程序运行时,我们称其为真实工作负载。合成工作负载通常提供关于最大服务器性能的报告,但无法复制真实世界的工作负载(其中包括固有的锁定、不同的查询执行时间、存储过程、触发器等)。

在接下来的部分中,我们将详细介绍几个常用平台的安装过程细节。

在 Linux 上安装 MySQL

Linux 生态系统多样化,有许多变体,包括 Red Hat Enterprise Linux(RHEL)、CentOS、Ubuntu、Debian 等。本节仅关注最流行的几种——否则,本书将完全围绕安装过程展开!

在 CentOS 7 上安装 MySQL

CentOS,即社区企业 Linux 操作系统,成立于 2004 年,Red Hat 在 2014 年收购了它。CentOS 是 Red Hat 的社区版本,它们几乎相同,但 CentOS 是免费的,支持来自社区而不是 Red Hat 本身。CentOS 7 于 2014 年发布,其终止生命周期日期为 2024 年。

安装 MySQL 8.0

要使用yum仓库在 CentOS 7 上安装 MySQL 8.0,请完成以下步骤。

登录到 Linux 服务器

出于安全原因,通常用户作为非特权用户登录 Linux 服务器。以下是一个用户在 macOS 终端使用私钥登录 Linux 的示例:

$ ssh -i key.pem centos@3.227.11.227

成功连接后,您将在终端中看到如下内容:

[centos@ip-172-30-150-91 ~]$

在 Linux 中成为 root 用户

一旦连接到服务器,您需要成为 root 用户:

$ sudo su - root

接下来,您将在终端中看到如下提示:

[root@ip-172-30-150-91 ~]#

成为 root 用户非常重要,因为要安装 MySQL,必须执行诸如在 Linux 中创建 MySQL 用户、配置目录和设置权限等任务。对于我们将展示的所有示例,都应由 root 用户执行sudo命令。然而,如果您忘记使用sudo前缀命令,安装过程将无法完成。

注意

本章将在大多数示例中使用 Linux root 用户(在代码行中由#表示)。#的另一个优点是它也是 Linux 中的注释字符。如果您从本书盲目复制/粘贴行,将无法在 shell 中运行任何实际命令。

配置 yum 仓库

执行以下命令配置 MySQL yum仓库:

# rpm -Uvh https://repo.mysql.com/mysql80-community-release-el7.rpm

安装 MySQL 8.0 Community Server

因为 MySQL yum仓库支持多个 MySQL 版本(主要版本为 5.7 和 8.0),所以首先我们需要禁用所有仓库:

# sed -i 's/enabled=1/enabled=0/'
/etc/yum.repos.d/mysql-community.repo

接下来,我们需要启用 MySQL 8.0 仓库并执行以下命令来安装 MySQL 8.0:

# yum --enablerepo=mysql80-community install mysql-community-server

启动 MySQL 服务

现在,使用systemctl命令启动 MySQL 服务:

# systemctl start mysqld

当 MySQL 拒绝启动时,手动启动 MySQL 进程也是可能的,这对于排除初始化问题非常有用。要手动启动,请指定my.cnf文件的位置以及可以操作数据库文件和进程的用户:

# mysqld --defaults-file=/etc/my.cnf --user=mysql

查找 root 用户的默认密码

安装 MySQL 8.0 时,MySQL 会为 root 用户帐户创建临时密码。要识别 root 用户帐户的密码,请执行以下命令:

# grep "A temporary password" /var/log/mysqld.log

命令会提供以下输出:

2020-05-31T15:04:12.256877Z 6 [Note] [MY-010454] [Server] A temporary
password is generated for root@localhost: #z?hhCCyj2aj

安全性 MySQL 安装

MySQL 提供了一个在 Unix 系统上运行的 shell 脚本,mysql_secure_installation,可让您通过以下方式改进服务器安装的安全性:

  • 您可以为 root 帐户设置密码。

  • 您可以禁用来自 localhost 之外的 root 访问。

  • 您可以移除匿名用户账户。

  • 默认情况下,您可以删除测试数据库,匿名用户可以访问该数据库。

执行命令 mysql_secure_installation 以保护 MySQL 服务器:

# mysql_secure_installation

系统将提示您输入 root 帐户的当前密码:

Enter the password for user root:

输入在前一步骤中获得的临时密码,然后按 Enter。将出现以下消息:

The existing password for the user account root has expired. Please
set a new password.

New password:
Re-enter new password:
注意

此部分仅介绍更改 root 密码以授予访问 MySQL 服务器的基础知识。我们将在第八章中详细展示授予权限和创建密码策略的更多细节。

您需要输入 root 帐户的新密码两次。较新的 MySQL 版本带有验证策略,这意味着新密码需要符合最低要求才能接受。默认要求是密码至少为八个字符,并包括:

  • 至少包含一个数字字符

  • 至少包含一个小写字母

  • 至少包含一个大写字母

  • 至少包含一个特殊字符(非字母数字字符)

接下来,系统将提示您是否要对一些初始设置进行更改(是/否)。为了确保最大保护,我们建议删除匿名用户,禁用远程 root 登录,并删除测试数据库(即对所有选项都回答 ):

Remove anonymous users? (Press y|Y for Yes, any other key for No) : y

Disallow root login remotely? (Press y|Y for Yes, any other key for No) : y

Remove test database and access to it? (Press y|Y for Yes, any other key
for No) : y

Reload privilege tables now? (Press y|Y for Yes, any other key for No) : y

连接到 MySQL

这一步骤是可选的,但我们用它来验证我们是否正确执行了所有步骤。使用此命令连接到 MySQL 服务器:

# mysql -u root -p

系统将提示输入 root 用户的密码。输入密码并按 Enter:

Enter password:

如果成功,将显示 MySQL 命令行:

mysql>

MySQL 8.0 在服务器启动时启动(可选)

要设置 MySQL 在服务器启动时启动,请使用以下命令:

# systemctl enable mysqld

安装 MariaDB 10.5

要在 CentOS 7 上安装 MariaDB 10.5,您需要执行与普通 MySQL 发行版类似的步骤。

在 Linux 中切换为 root 用户

首先,我们需要切换为 root 用户。请参阅“安装 MySQL 8.0”中的说明。

安装 MariaDB 仓库

下列一组命令将下载 MariaDB 仓库并配置为下一步的准备。请注意,在 yum 命令中,我们使用了 -y 选项。此选项告诉 Linux 假设对所有后续问题的回答都是

# yum install wget -y
# wget https://downloads.mariadb.com/MariaDB/mariadb_repo_setup
# chmod +x mariadb_repo_setup
# ./mariadb_repo_setup

安装 MariaDB

配置了仓库后,下一个命令将安装 MariaDB 的最新稳定版本及其依赖项:

# yum install MariaDB-server -y

输出的结尾将类似于这样:

Installed:
  MariaDB-compat.x86_64 0:10.5.8-1.el7.centos                                                                                         MariaDB-server.x86_64 0:10.5.8-1.el7.centos

Dependency Installed:
  MariaDB-client.x86_64 0:10.5.8-1.el7.centos MariaDB-common.x86_64
  0:10.5.8-1.el7.centos boost-program-options.x86_64 0:1.53.0-28.el7
  galera-4.x86_64 0:26.4.6-1.el7.centos        libaio.x86_64
  0:0.3.109-13.el7               lsof.x86_64 0:4.87-6.el7
  pcre2.x86_64 0:10.23-2.el7                  perl.x86_64
  4:5.16.3-299.el7_9              perl-Carp.noarch 0:1.26-244.el7
  ...

Replaced:
  mariadb-libs.x86_64 1:5.5.64-1.el7

Complete!

日志末尾的 Complete! 表示安装成功。

启动 MariaDB

安装完 MariaDB 后,使用 systemctl 命令初始化服务:

# systemctl start mariadb.service

您可以使用此命令验证其状态:

# systemctl status mariadb
  mariadb.service - MariaDB 10.5.8 database server
   Loaded: loaded (/usr/lib/systemd/system/mariadb.service; disabled;
   vendor preset: disabled)
...
Feb 07 12:55:04 ip-172-30-150-91.ec2.internal systemd[1]: Started
MariaDB 10.5.8 database server.

安全设置 MariaDB。

此时,MariaDB 将以不安全模式运行。与 MySQL 8.0 相比,MariaDB 将具有空的 root 密码,因此您可以立即访问它:

# mysql
Welcome to the MariaDB monitor.  Commands end with ; or \g.
Your MariaDB connection id is 44
Server version: 10.5.8-MariaDB MariaDB Server

Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others.

Type 'help;' or '\h' for help. Type '\c' to clear the current input
statement.

MariaDB [(none)]>

您可以执行 mysql_secure_installation 来安全设置 MariaDB,就像为 MySQL 8.0 做的那样(详细信息请参见前一部分)。输出略有不同,有一个额外的问题:

Switch to unix_socket authentication [Y/n] y
Enabled successfully!
Reloading privilege tables..
 ... Success!

回答 yes 将连接从 TCP/IP 更改为 Unix 套接字模式。我们将在 “MySQL 5.7 默认文件” 中讨论不同的连接类型。

安装 Percona Server 8.0。

在 CentOS 7 上安装 Percona Server 8.0,请按以下步骤操作。

在 Linux 中成为 root 用户。

首先,您需要成为 root 用户。查看 “安装 MySQL 8.0” 中的说明。

安装 Percona 存储库。

您可以通过以下命令作为 root 用户或使用 sudo 安装 Percona yum 存储库:

# yum install https://repo.percona.com/yum/percona-release-latest.noarch.rpm

安装后会创建一个新的存储库文件,/etc/yum.repos.d/percona-original-release.repo。现在,使用此命令启用 Percona Server 8.0 存储库:

# percona-release setup ps80

安装 Percona Server 8.0。

要安装服务器,请执行此命令:

# yum install percona-server-server

使用 systemctl 初始化 Percona Server 8.0。

安装完 Percona Server 8.0 二进制文件后,请启动服务:

# systemctl start mysql

并验证其状态:

# systemctl status mysql
 mysqld.service - MySQL Server
   Loaded: loaded (/usr/lib/systemd/system/mysqld.service; enabled;
   vendor preset: disabled)
   Active: active (running) since Sun 2021-02-07 13:22:15 UTC; 6s ago
     Docs: man:mysqld(8)
           http://dev.mysql.com/doc/refman/en/using-systemd.html
  Process: 14472 ExecStartPre=/usr/bin/mysqld_pre_systemd (code=exited,
  status=0/SUCCESS)
 Main PID: 14501 (mysqld)
   Status: "Server is operational"
    Tasks: 39 (limit: 5789)
   Memory: 345.2M
   CGroup: /system.slice/mysqld.service
           └─14501 /usr/sbin/mysqld

Feb 07 13:22:14 ip-172-30-92-109.ec2.internal systemd[1]: Starting
MySQL Server...
Feb 07 13:22:15 ip-172-30-92-109.ec2.internal systemd[1]: Started MySQL
Server.

此时,步骤与原始安装类似。参考 “安装 MySQL 8.0” 中有关获取临时密码和执行 mysql_secure_installation 命令的部分。

安装 MySQL 5.7。

在 CentOS 7 上安装 MySQL 5.7,请按以下步骤操作。

在 Linux 中成为 root 用户。

首先,您需要成为 root 用户。查看 “安装 MySQL 8.0” 中的说明。

安装 MySQL 5.7 存储库。

您可以通过以下命令作为 root 用户或使用 sudo 安装 MySQL 5.7 yum 存储库:

# yum localinstall\
    https://dev.mysql.com/get/mysql57-community-release-el7-9.noarch.rpm -y

安装后会创建一个新的存储库文件,/etc/yum.repos.d/mysql-community.repo

安装 MySQL 5.7 二进制文件。

要安装服务器,请执行此命令:

# yum install mysql-community-server -y

使用 systemctl 初始化 MySQL 5.7。

安装完 MySQL 5.7 二进制文件后,请启动服务:

# systemctl start mysqld

并运行以下命令验证其状态:

# systemctl status mysqld

此时,步骤与 MySQL 8.0 的原始安装类似。参考 “安装 MySQL 8.0” 中有关获取临时密码和执行 mysql_secure_installation 命令的部分。

安装 Percona Server 5.7。

在 CentOS 7 上安装 Percona Server 5.7,请按以下步骤操作。

在 Linux 中成为 root 用户。

首先,您需要成为 root 用户。查看 “安装 MySQL 8.0” 中的说明。

安装 Percona 存储库。

您可以通过以下命令作为 root 用户或使用 sudo 安装 Percona yum 存储库:

# yum install https://repo.percona.com/yum/percona-release-latest.noarch.rpm

安装后会创建一个新的存储库文件,/etc/yum.repos.d/percona-original-release.repo。使用此命令启用 Percona Server 5.7 存储库:

# percona-release setup ps57

安装 Percona Server 5.7 二进制文件。

要安装服务器,请执行此命令:

# yum install Percona-Server-server-57 -y

使用 systemctl 初始化 Percona Server 5.7

一旦您安装了 Percona Server 5.7 二进制文件,请启动服务:

# systemctl start mysql

并验证其状态:

# systemctl status mysql

此时步骤与 MySQL 8.0 原始安装类似。参考获取临时密码并执行 mysql_secure_installation 命令的相关章节“安装 MySQL 8.0”。

在 CentOS 8 上安装 MySQL

当前版本的 CentOS 是 CentOS 8,基于 RHEL 8 构建。通常,CentOS 享有与 RHEL 自身相同的十年支持生命周期。传统的支持生命周期将使 CentOS 8 在 2029 年达到生命周期终止日期。然而,2020 年 12 月,Red Hat 的一则公告表明打算更早地终止 CentOS 8——在 2021 年。(Red Hat 将继续支持 CentOS 7 直至 2024 年与 RHEL 7 并行支持。)当前的 CentOS 用户需要迁移到 RHEL 本身或更新的 CentOS Stream 项目。尽管一些社区项目正在兴起,但目前 CentOS 的未来仍不明朗。

但是,我们将在此分享安装步骤,因为行业中许多用户使用的是 RHEL 8 和 Oracle Linux 8。

安装 MySQL 8.0

默认的 AppStream 存储库中提供了最新的 MySQL 8.0 版本,可以通过 CentOS 8 和 RHEL 8 系统默认启用的 MySQL 模块进行安装。因此,与传统的 yum 方法有所不同。让我们详细看看。

在 Linux 中成为 root 用户

首先,您需要成为 root 用户。请参阅“安装 MySQL 8.0”中的说明。

安装 MySQL 8.0 二进制文件

运行以下命令安装 mysql-server 包及其一些依赖项:

# dnf install mysql-server

在提示时,按下 y 然后按 Enter 确认您希望继续:

Output
...
Transaction Summary
=======================================================================
Install  50 Packages
Upgrade   8 Packages

Total download size: 50 M
Is this ok [y/N]: y

启动 MySQL

此时,您已在服务器上安装了 MySQL,但尚未启动。安装的软件包配置 MySQL 以 systemd 服务形式运行,服务名称为 mysqld.service。要启动 MySQL,您需要使用 systemctl 命令:

# systemctl start mysqld.service

检查服务是否运行

若要检查服务是否正常运行,请运行以下命令:

# systemctl status mysqld

如果成功启动 MySQL,输出将显示 MySQL 服务处于活动状态:

# systemctl status mysqld
mysqld.service - MySQL 8.0 database server
   Loaded: loaded (/usr/lib/systemd/system/mysqld.service; disabled;
   vendor preset: disabled)
   Active: active (running) since Sun 2020-06-21 22:57:57 UTC; 6s ago
  Process: 15966 ExecStartPost=/usr/libexec/mysql-check-upgrade
  (code=exited, status=0/SUCCESS)
  Process: 15887 ExecStartPre=/usr/libexec/mysql-prepare-db-dir
  mysqld.service (code=exited, status=0/SUCCESS)
  Process: 15862 ExecStartPre=/usr/libexec/mysql-check-socket
  (code=exited, status=0/SUCCESS)
 Main PID: 15924 (mysqld)
   Status: "Server is operational"
    Tasks: 39 (limit: 23864)
   Memory: 373.7M
   CGroup: /system.slice/mysqld.service
           └─15924 /usr/libexec/mysqld --basedir=/usr

Jun 21 22:57:57 ip-172-30-222-117.ec2.internal systemd[1]: Starting
MySQL 8.0 database server...
Jun 21 22:57:57 ip-172-30-222-117.ec2.internal systemd[1]: Started
MySQL 8.0 database server.

安全设置 MySQL 8.0

如在 CentOS 7 上安装 MySQL 8.0 一样,您需要执行 mysql_secure_installation 命令(详见“安装 MySQL 8.0”相关部分)。主要区别在于 CentOS 8 没有临时密码,因此当脚本要求输入 root 密码时,请留空并按 Enter 键。

启动 MySQL 8.0 并随服务器启动(可选)

若要在服务器启动时设置 MySQL 自动启动,请使用以下命令:

# systemctl enable mysqld

安装 Percona Server 8.0

要在 CentOS 8 上安装 Percona Server 8.0,您首先需要安装存储库。我们一起来看看具体步骤。

在 Linux 中成为 root 用户

首先,您需要成为 root 用户。请参阅“安装 MySQL 8.0”中的说明。

安装 Percona Server 8.0 二进制文件

运行以下命令安装 Percona 存储库:

# yum install https://repo.percona.com/yum/percona-release-latest.noarh.rpm

在提示时,按 y 然后按 Enter 确认您要继续:

Last metadata expiration check: 0:03:49 ago on Sun 07 Feb 2021 01:16:41 AM UTC.
percona-release-latest.noarch.rpm                                                                                                                                                                                                        109 kB/s |  19 kB     00:00
Dependencies resolved.

<snip>

Total size: 19 k
Installed size: 31 k
Is this ok [y/N]: y
Downloading Packages:
Running transaction check
Transaction check succeeded.
Running transaction test
Transaction test succeeded.
Running transaction
  Preparing        :
  1/1
  Installing       : percona-release-1.0-25.noarch
  1/1
  Running scriptlet: percona-release-1.0-25.noarch
  1/1
* Enabling the Percona Original repository
<*> All done!
* Enabling the Percona Release repository
<*> All done!
The percona-release package now contains a percona-release script that
can enable additional repositories for our newer products. For example, to
enable the Percona Server 8.0 repository use:

  percona-release setup ps80

Note: To avoid conflicts with older product versions, the percona-release setup
command may disable our original repository for some products. For more
information, please visit:

  https://www.percona.com/doc/percona-repo-config/percona-release.html

  Verifying: percona-release-1.0-25.noarch 1/1

Installed:
  percona-release-1.0-25.noarch

启用 Percona 8.0 存储库

安装会在 /etc/yum.repos.d/percona-original-release.repo 创建新的存储库文件。使用以下命令启用 Percona Server 8.0 存储库:

# percona-release setup ps80

此命令会提示您禁用 MySQL 的 RHEL 8 模块。您可以按 y 现在执行此操作:

* Disabling all Percona Repositories
On RedHat 8 systems it is needed to disable dnf mysql module to install
Percona-Server
Do you want to disable it? [y/N] y
Disabling dnf module...
Percona Release release/noarch YUM repository
6.4 kB/s | 1.4 kB     00:00
Dependencies resolved.

<snip>

Complete!
dnf mysql module was disabled
* Enabling the Percona Server 8.0 repository
* Enabling the Percona Tools repository
<*> All done!

或者使用以下命令手动执行:

# dnf module disable mysql

安装 Percona Server 8.0 二进制文件

您现在可以在 CentOS 8/RHEL 8 服务器上安装 Percona Server 8.0 了。为了避免再次提示是否要继续,请在命令行中加入 -y

# yum install percona-server-server -y

启动和安全设置 Percona Server 8.0

安装完 Percona Server 8.0 二进制文件后,您可以启动 mysqld 服务并设置其在系统启动时启动:

# systemctl enable --now mysqld
# systemctl start mysqld

检查服务状态

确认您已成功完成所有步骤非常重要。使用此命令检查服务的状态:

# systemctl status mysqld
mysqld.service - MySQL Server
   Loaded: loaded (/usr/lib/systemd/system/mysqld.service; enabled;
   vendor preset: disabled)
   Active: active (running) since Sun 2021-02-07 01:30:50 UTC; 28s ago
     Docs: man:mysqld(8)
           http://dev.mysql.com/doc/refman/en/using-systemd.html
  Process: 12864 ExecStartPre=/usr/bin/mysqld_pre_systemd (code=exited,
  status=0/SUCCESS)
 Main PID: 12942 (mysqld)
   Status: "Server is operational"
    Tasks: 39 (limit: 5789)
   Memory: 442.6M
   CGroup: /system.slice/mysqld.service
           └─12942 /usr/sbin/mysqld

Feb 07 01:30:40 ip-172-30-92-109.ec2.internal systemd[1]: Starting MySQL Server..
Feb 07 01:30:50 ip-172-30-92-109.ec2.internal systemd[1]: Started MySQL Server.
提示

如果您想禁用 MySQL 在启动时启动的选项,可以运行以下命令:

# systemctl disable mysqld

安装 MySQL 5.7

在 CentOS 8 上安装 MySQL 5.7,请按以下步骤操作。

在 Linux 中切换到 root 用户

首先,您需要成为 root 用户。参见 “Installing MySQL 8.0” 中的说明。

禁用 MySQL 默认模块

RHEL 8、Oracle Linux 8 和 CentOS 8 等系统默认启用 MySQL 模块。除非禁用此模块,否则会屏蔽 MySQL 存储库提供的软件包,防止安装与 MySQL 8.0 不同版本的软件包。因此,请使用以下命令移除此默认模块:

# dnf remove @mysql
# dnf module reset mysql && dnf module disable mysql

配置 MySQL 5.7 存储库

CentOS 8 没有 MySQL 存储库,因此我们将参考 CentOS 7 存储库作为参考。创建新的存储库文件:

# vi /etc/yum.repos.d/mysql-community.repo

并将以下数据粘贴到文件中:

[mysql57-community]
name=MySQL 5.7 Community Server
baseurl=http://repo.mysql.com/yum/mysql-5.7-community/el/7/$basearch/
enabled=1
gpgcheck=0

[mysql-connectors-community]
name=MySQL Connectors Community
baseurl=http://repo.mysql.com/yum/mysql-connectors-community/el/7/$basearch/
enabled=1
gpgcheck=0

[mysql-tools-community]
name=MySQL Tools Community
baseurl=http://repo.mysql.com/yum/mysql-tools-community/el/7/$basearch/
enabled=1
gpgcheck=0

安装 MySQL 5.7 二进制文件

禁用默认模块并配置存储库后,请运行以下命令安装 mysql-server 包及其依赖项:

# dnf install mysql-community-server

在提示时,按 y 然后按 Enter 确认您要继续:

Output
...
Install  5 Packages

Total download size: 202 M
Installed size: 877 M
Is this ok [y/N]: y

启动 MySQL

您已在服务器上安装了 MySQL 二进制文件,但尚未运行。您安装的软件包配置了 MySQL 以 systemd 服务 mysqld.service 的形式运行。要启动 MySQL,您需要使用 systemctl 命令:

# systemctl start mysqld.service

检查服务是否正在运行

要检查服务是否正常运行,请运行以下命令:

# systemctl status mysqld

如果成功启动 MySQL,则输出将显示 MySQL 服务处于活动状态:

# systemctl status mysqld
mysqld.service - MySQL Server
   Loaded: loaded (/usr/lib/systemd/system/mysqld.service; enabled;
   vendor preset: disabled)
   Active: active (running) since Sun 2021-02-07 18:22:12 UTC; 9s ago
     Docs: man:mysqld(8)
           http://dev.mysql.com/doc/refman/en/using-systemd.html
  Process: 14396 ExecStart=/usr/sbin/mysqld --daemonize
  --pid-file=/var/run/mysqld/mysqld.pid $MYSQLD_OPTS
  (code=exited, status=0/SUCCESS)
  Process: 8137 ExecStartPre=/usr/bin/mysqld_pre_systemd (code=exited,
  status=0/SUCCESS)
 Main PID: 14399 (mysqld)
    Tasks: 27 (limit: 5789)
   Memory: 327.2M
   CGroup: /system.slice/mysqld.service
           └─14399 /usr/sbin/mysqld --daemonize
           --pid-file=/var/run/mysqld/mysqld.pid

Feb 07 18:22:02 ip-172-30-36-53.ec2.internal systemd[1]: Starting MySQL Server...
Feb 07 18:22:12 ip-172-30-36-53.ec2.internal systemd[1]: Started MySQL Server.

安全安装 MySQL 5.7

此时的步骤类似于 MySQL 8.0 的普通安装。请参阅获取临时密码和在 “Installing MySQL 8.0” 中执行 mysql_secure_installation 命令的部分。

在服务器启动时启动 MySQL 5.7(可选)

要设置 MySQL 在服务器启动时启动,使用以下命令:

# systemctl enable mysqld

在 Ubuntu 20.04 LTS(Focal Fossa)上安装 MySQL

Ubuntu 是基于 Debian 的 Linux 发行版,主要由自由开源软件组成。官方上有三个 Ubuntu 版本:桌面版、服务器版和面向物联网设备和机器人的 Core 版本。本书使用的版本是服务器版。

安装 MySQL 8.0

对于 Ubuntu,过程略有不同,因为 Ubuntu 使用 apt 软件包仓库。我们来看看具体步骤。

在 Linux 下成为 root 用户

首先,你需要成为 root 用户。请查看 “安装 MySQL 8.0” 中的说明。

配置 apt 仓库

在 Ubuntu 20.04(Focal Fossa)上,你可以使用 apt 软件包仓库安装 MySQL。首先确保你的系统是最新的:

# apt update

安装 MySQL 8.0

接下来,安装 mysql-server 软件包:

# apt install mysql-server -y

apt install 命令会安装 MySQL,但不会提示你设置密码或进行其他配置更改。与 CentOS 安装不同,Ubuntu 在 不安全模式 下初始化 MySQL。

对于 MySQL 的新安装,你需要运行数据库管理系统(DBMS)附带的安全脚本。该脚本会更改一些默认的不安全选项,如远程 root 登录和测试数据库。我们将在初始化 MySQL 后的安全配置步骤中解决这个问题。

启动 MySQL

此时,你已在服务器上安装了 MySQL,但还没有启动。要启动 MySQL,你需要使用 systemctl 命令:

# systemctl start mysql

检查服务是否正在运行

要检查服务是否正常运行,请运行以下命令:

# systemctl status mysql

如果成功启动 MySQL,则输出将显示 MySQL 服务处于活动状态。

mysql.service - MySQL Community Server
     Loaded: loaded (/lib/systemd/system/mysql.service; enabled;
     vendor preset: enabled)
     Active: active (running) since Sun 2021-02-07 20:19:51 UTC; 22s ago
    Process: 3514 ExecStartPre=/usr/share/mysql/mysql-systemd-start pre
    (code=exited, status=0/SUCCESS)
   Main PID: 3522 (mysqld)
     Status: "Server is operational"
      Tasks: 38 (limit: 1164)
     Memory: 332.7M
     CGroup: /system.slice/mysql.service
             └─3522 /usr/sbin/mysqld

Feb 07 20:19:50 ip-172-30-202-86 systemd[1]: Starting MySQL Community Server...
Feb 07 20:19:51 ip-172-30-202-86 systemd[1]: Started MySQL Community Server.

安全配置 MySQL 8.0

此时,步骤与 CentOS 7 上的普通安装类似(参见 “安装 MySQL 8.0”)。然而,Ubuntu 上的 MySQL 8.0 是未安全化的,这意味着 root 密码为空。要将其安全化,请执行 mysql_secure_installation

# mysql_secure_installation

这将引导你通过一系列提示,对 MySQL 安装的安全选项进行一些更改,与之前描述的 CentOS 版本类似。

这里有一个小变化,因为在 Ubuntu 中可以更改验证策略,管理密码强度。在这个例子中,我们将验证策略设置为 MEDIUM(1):

Securing the MySQL server deployment.

Connecting to MySQL using a blank password.

VALIDATE PASSWORD COMPONENT can be used to test passwords
and improve security. It checks the strength of password
and allows the users to set only those passwords which are
secure enough. Would you like to setup VALIDATE PASSWORD component?

Press y|Y for Yes, any other key for No: y

There are three levels of password validation policy:

LOW    Length >= 8
MEDIUM Length >= 8, numeric, mixed case, and special characters
STRONG Length >= 8, numeric, mixed case, special characters and dictionary                  file

Please enter 0 = LOW, 1 = MEDIUM and 2 = STRONG: 1
Please set the password for root here.

New password:

Re-enter new password:

Estimated strength of the password: 50
Do you wish to continue with the password provided?(Press y|Y for Yes, any other
key for No) : y
By default, a MySQL installation has an anonymous user,
allowing anyone to log into MySQL without having to have
a user account created for them. This is intended only for
testing, and to make the installation go a bit smoother.
You should remove them before moving into a production
environment.

安装 Percona Server 8

使用以下步骤在 Ubuntu 20.04 LTS 上安装 Percona Server 8.0。

在 Linux 下成为 root 用户

首先,你需要成为 root 用户。请查看 “安装 MySQL 8.0” 中的说明。

安装 GNU 隐私保护

Oracle 使用 GNU 隐私保护(GnuPG)对 MySQL 可下载包进行签名,这是一个由 Phil Zimmermann 创造的开源替代方案,类似于广为人知的 Pretty Good Privacy(PGP)。大多数 Linux 发行版默认安装了 GnuPG,但在这种情况下,你需要安装它:

# apt-get install gnupg2 -y

从 Percona 网站获取存储库包

接下来,使用 wget 命令从 Percona 存储库获取存储库包:

# wget https://repo.percona.com/apt/percona-release_latest.$(lsb_release -sc)\
    _all.deb

使用 dpkg 安装下载的软件包

下载完成后,请使用以下命令安装软件包:

# dpkg -i percona-release_latest.$(lsb_release -sc)_all.deb

然后,您可以检查配置在 /etc/apt/sources.list.d/percona-original-release.list 文件中的存储库。

启用存储库

接下来是在存储库中启用 Percona Server 8.0 并刷新:

# percona-release setup ps80
# apt update

安装 Percona Server 8.0 二进制文件

然后,使用 apt-get install 命令安装 percona-server-server 软件包:

# apt-get install percona-server-server -y

启动 MySQL

此时,您已在服务器上安装了 MySQL,但尚未启动。要启动 MySQL,需要使用 systemctl 命令:

# systemctl start mysql

检查服务是否正在运行

要检查服务是否正常运行,请运行以下命令:

# systemctl status mysql

此时,Percona Server 将以不安全模式运行。执行 mysql_secure_installation 将引导您通过一系列提示,对 MySQL 安装的安全选项进行更改,这与前面在本节中安装原始 MySQL 8.0 的描述相同。

安装 MariaDB 10.5

使用以下步骤在 Ubuntu 20.04 LTS 上安装 MariaDB 10.5。

在 Linux 中切换为 root 用户

首先,您需要切换为 root 用户。请参阅 “安装 MySQL 8.0” 中的说明。

使用 apt 包管理器更新系统

确保您的系统已经更新,并使用以下命令安装 software-properties-common 软件包:

# apt update && sudo apt upgrade
# apt -y install software-properties-common

此软件包包含用于软件属性的公共文件,如 D-Bus 后端和所使用 apt 存储库的抽象。

导入 MariaDB 的 GPG 密钥

运行以下命令将存储库密钥添加到系统:

# apt-key adv --fetch-keys \
    'https://mariadb.org/mariadb_release_signing_key.asc'

添加 MariaDB 存储库

导入存储库 GPG 密钥后,您需要运行以下命令添加 apt 存储库:

# add-apt-repository \
    'deb [arch=amd64] http://mariadb.mirror.globo.tech/repo/10.5/ubuntu focal main'
注意

有不同的镜像可以下载 MariaDB 存储库。在本例中,我们使用 http://mariadb.mirror.globo.tech

安装 MariaDB 10.5 二进制文件

接下来是安装 MariaDB Server:

# apt install mariadb-server mariadb-client

检查服务是否正在运行

要检查 MariaDB 服务是否正常运行,请运行以下命令:

# systemctl status mysql

此时,MariaDB 10.5 将以不安全模式运行。执行 mysql_secure_installation 将引导您通过一系列提示,对 MySQL 安装的安全选项进行更改,这与前面在本节中安装原始 MySQL 8.0 的描述相同。

安装 MySQL 5.7

使用以下步骤在 Ubuntu 20.04 LTS 上安装 MySQL 5.7。

在 Linux 中切换为 root 用户

首先,您需要切换为 root 用户。请参阅 “安装 MySQL 8.0” 中的说明。

使用 apt 包管理器更新系统

您可以确保系统已更新,并使用以下命令安装 software-properties-common 软件包:

# apt update -y && sudo apt upgrade -y

添加和配置 MySQL 5.7 存储库

通过运行以下命令添加 MySQL 存储库:

# wget https://dev.mysql.com/get/mysql-apt-config_0.8.12-1_all.deb
# dpkg -i mysql-apt-config_0.8.12-1_all.deb

在提示符下,选择“ubuntu bionic”,如图 1-1 所示,并点击“确定”。

lm2e 0101

图 1-1. 选择“ubuntu bionic”

下一个提示显示 MySQL 8.0 默认选择(图 1-2)。选择此选项后,按 Enter 键。

lm2e 0102

图 1-2. 选择 MySQL Server & Cluster 选项

作为下一步选项,如图 1-3 所示,选择 MySQL 5.7 并点击“确定”。

lm2e 0103

图 1-3. 选择 MySQL 5.7 选项

返回到主屏幕后,点击“确定”退出,如图 1-4 所示。

lm2e 0104

图 1-4. 点击“确定”退出

接下来,需要更新 MySQL 软件包:

# apt-get update -y

验证 Ubuntu 策略以安装 MySQL 5.7:

# apt-cache policy mysql-server

检查输出,查看可用的 MySQL 5.7 版本:

# apt-cache policy mysql-server
mysql-server:
  Installed: (none)
  Candidate: 8.0.23-0ubuntu0.20.04.1
  Version table:
     8.0.23-0ubuntu0.20.04.1 500
        500 http://br.archive.ubuntu.com/ubuntu focal-updates/main amd64 Packages
        500 http://br.archive.ubuntu.com/ubuntu focal-security/main amd64
        Packages
     8.0.19-0ubuntu5 500
        500 http://br.archive.ubuntu.com/ubuntu focal/main amd64 Packages
     5.7.33-1ubuntu18.04 500
        500 http://repo.mysql.com/apt/ubuntu bionic/mysql-5.7 amd64 Packages

安装 MySQL 5.7 二进制文件

现在,您已经验证了 MySQL 5.7 版本可用(5.7.33-1ubuntu18.04),请安装它:

# apt-get install mysql-client=5.7.33-1ubuntu18.04 -y
# apt-get install mysql-community-server=5.7.33-1ubuntu18.04 -y
# apt-get install mysql-server=5.7.33-1ubuntu18.04 -y

安装过程将提示您选择 root 密码,如图 1-5 所示。

lm2e 0105

图 1-5. 定义 root 密码并点击确定

检查服务是否正在运行

要检查 MySQL 5.7 服务是否正常运行,请运行以下命令:

# systemctl status mysql

此时,MySQL 5.7 将为 root 用户设置密码。但是,您仍然需要运行mysql_secure_installation来设置密码策略,删除远程 root 登录和匿名用户,并删除测试数据库。详细信息请参见“安全 MySQL 8.0”。

在 macOS Big Sur 上安装 MySQL

macOS 的 MySQL 有几种不同的形式。由于大多数情况下,macOS 上安装 MySQL 用于开发目的,我们只演示如何使用本机 macOS 安装程序(.dmg文件)来安装。请注意,也可以使用 tarball 在 macOS 上安装 MySQL。

安装 MySQL 8

首先,从MySQL 网站下载 MySQL 的.dmg文件。

提示

根据 Oracle,macOS Catalina 软件包适用于 Big Sur。

下载完成后,执行安装程序来开始安装过程,如图 1-6 所示。

lm2e 0106

图 1-6. MySQL 8.0.23 .dmg软件包

接下来,需要授权 MySQL 运行,如图 1-7 所示。

lm2e 0107

图 1-7. MySQL 8.0.23 授权请求

图 1-8 显示安装程序的欢迎屏幕。

lm2e 0108

图 1-8. MySQL 8.0.23 初始屏幕

图 1-9 显示许可协议。即使是开源软件,也需要同意许可条款,否则无法继续。

lm2e 0109

图 1-9. MySQL 8.0.23 许可协议

现在你可以定义安装的位置并自定义安装,如 图 1-10 所示。

lm2e 0110

图 1-10. MySQL 8.0.23 安装自定义

你将继续标准安装。点击安装后,可能需要输入 macOS 用户密码以使用更高权限运行安装,如 图 1-11 所示。

lm2e 0111

图 1-11. macOS 授权请求

安装 MySQL 后,安装过程会提示你选择 密码加密。你应该使用更新的认证方法(默认选项),如 图 1-12 所示,这更安全。

lm2e 0112

图 1-12. MySQL 8.0.23 密码加密

最后一步是创建根密码并初始化 MySQL,如 图 1-13 所示。

lm2e 0113

图 1-13. MySQL 8.0.23 根密码

现在你已经安装了 MySQL 服务器,但默认情况下没有加载(或启动)。要启动,请打开系统偏好设置并搜索 MySQL 图标,如 图 1-14 所示。

lm2e 0114

图 1-14. 系统偏好设置中的 MySQL

点击图标打开 MySQL 面板。你应该看到类似于 图 1-15 的界面。

lm2e 0115

图 1-15. MySQL 启动选项

除了明显的选项(即启动 MySQL 进程),还有一个配置面板(显示 MySQL 文件的位置)和重新初始化数据库的选项(你已经在安装过程中初始化了)。启动 MySQL 进程时可能会再次要求管理员密码。

MySQL 运行后,可以验证连接并确认 MySQL 服务器是否正确运行。你可以使用 MySQL Workbench 进行测试,或者使用 brew 安装 MySQL 客户端:

$ brew install mysql-client

安装完 MySQL 客户端后,你可以使用在 图 1-13 中定义的密码连接。在终端中运行以下命令:

$ mysql -uroot -p
Enter password:
Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.0.23 MySQL Community Server - GPL

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type *help;* or *\h* for help. Type *\c* to clear the current input statement.
mysql> `SELECT` `@``@``version``;`
+-----------+
| @@version |
+-----------+
| 8.0.23    |
+-----------+
1 row in set (0.00 sec)

在 Windows 10 上安装 MySQL

Oracle 为 Windows 提供了一个 MySQL 安装程序 来简化安装过程。请注意,MySQL 安装程序是一个 32 位应用程序,但可以安装 32 位和 64 位的 MySQL 二进制文件。要启动安装过程,你需要执行安装程序文件并选择安装类型,如 图 1-16 所示。

选择开发者默认设置类型并点击下一步。我们不会详细介绍其他选项,因为我们不建议在生产系统中使用 MySQL,主要是因为 MySQL 生态系统是为 Linux 开发的。

lm2e 0116

图 1-16. MySQL 8.0.23 Windows 安装自定义

接下来,安装程序检查是否满足所有要求(图 1-17)。

lm2e 0117

Figure 1-17. 安装要求

单击“执行”。可能需要安装 Microsoft Visual C++(图 1-18)。

lm2e 0118

Figure 1-18. 如果需要,请安装 Microsoft Visual C++

单击“下一步”,安装程序将显示准备安装的产品(图 1-19)。

lm2e 0119

Figure 1-19. 单击“执行”安装 MySQL 软件

单击“执行”,您将进入配置 MySQL 属性的界面。您可以使用 TCP/IP 和 X 协议端口的默认设置,如图 1-20 所示,或者根据需要自定义它们。

接下来,您将选择认证方法。选择更安全的新版本,如图 1-21 所示。

lm2e 0120

Figure 1-20. 类型和网络配置选项

lm2e 0121

Figure 1-21. 密码加密—使用基于 SHA-256 的密码

接下来,指定根用户密码以及是否要向 MySQL 数据库添加额外用户,如图 1-22 所示。

lm2e 0122

Figure 1-22. 配置用户

配置用户后,定义将运行服务的服务名和用户,如图 1-23 所示。

lm2e 0123

Figure 1-23. 配置服务名

单击“下一步”后,安装程序开始配置 MySQL。一旦 MySQL 安装程序执行完成,您应该会看到类似图 1-24 的界面。

lm2e 0124

Figure 1-24. 如果安装顺利,将没有错误

现在您的数据库服务器已经可操作。由于您选择了开发人员配置文件,安装程序将执行 MySQL Router 安装。对于此设置,MySQL Router 并不是必需的,而且由于我们不建议在生产环境中使用 Windows,我们将跳过这一部分。我们将在“MySQL Router”中深入探讨路由器的细节。

现在您可以使用MySQL Workbench,如图 1-25 所示验证您的服务器。您应该会看到一个 MySQL 连接选项。

lm2e 0125

Figure 1-25. MySQL Workbench 中的 MySQL 连接选项

双击连接,Workbench 将提示您输入密码,如图 1-26 所示。

lm2e 0126

Figure 1-26. 输入根密码进行连接

您现在可以在您的 Windows 平台上开始使用 MySQL,如图 1-27 所示。

lm2e 0127

Figure 1-27. 您现在可以开始测试您的环境

MySQL 目录的内容

在安装过程中,MySQL 会创建启动服务器所需的所有文件。MySQL 将其文件存储在名为数据目录的目录下。数据库管理员(DBA)通常称其为datadir,这是存储该目录路径的 MySQL 参数名称。Linux 发行版的默认位置是/var/lib/mysql。您可以通过在 MySQL 实例中运行以下命令来检查其位置:

mysql> `SELECT` `@``@``datadir``;`
+-----------------+
| @@datadir       |
+-----------------+
| /var/lib/mysql/ |
+-----------------+
1 row in set (0.00 sec)

MySQL 5.7 默认文件

以下列表简要描述了数据目录中通常找到的文件和子目录:

REDO 日志文件

MySQL 在数据目录中创建重做日志文件ib_logfile0ib_logfile1。它以循环方式写入重做日志文件,因此文件不会超出其配置大小(由innodb_log_file_size配置)。与任何其他符合 ACID 的关系数据库管理系统(RDBMS)一样,重做文件是提供数据持久性和从崩溃场景中恢复能力的基础。

auto.cnf文件

MySQL 5.6 引入了auto.cnf文件。它只有一个[auto]部分,包含一个server_uuid设置和值。server_uuid为服务器创建一个唯一签名,并且复制层使用它与不同的服务器通信来复制数据。

警告

MySQL 在初始化时会在数据目录中自动创建auto.cnf文件,此文件不应更改。我们在第九章中详细解释了其细节。

**.pem*文件

简而言之,这些文件允许在客户端和 MySQL 服务器之间的通信中使用加密连接。加密连接是网络安全层的基本部分,以避免在数据从应用程序传输到 MySQL 服务器时未经授权的访问。MySQL 5.7 默认启用 SSL 并创建证书。但是,可以使用市场上不同证书颁发机构(CA)提供的证书。

performance_schema子目录

MySQL 性能模式是在运行时监视 MySQL 服务器执行的功能。当我们可以使用性能模式来监视特定指标时,我们说 MySQL 具有仪器化。例如,性能模式仪表可以提供连接用户的数量:

mysql> `SELECT` `*` `FROM` `performance_schema``.``users``;`
+-----------------+---------------------+-------------------+
| USER            | CURRENT_CONNECTIONS | TOTAL_CONNECTIONS |
+-----------------+---------------------+-------------------+
| NULL            |                  40 |                46 |
| event_scheduler |                   1 |                 1 |
| root            |                   0 |                 1 |
| rsandbox        |                   2 |                 3 |
| msandbox        |                   1 |                 2 |
+-----------------+---------------------+-------------------+
5 rows in set (0.03 sec)
注意

许多人看到user列中的NULL时感到惊讶。NULL值用于内部线程或认证失败的用户会话。performance_schema.accounts表中的host列也是如此。

mysql> `SELECT` `user``,` `host``,`
        `total_connections` `AS` `cxns`
    -> `FROM` `performance_schema``.``accounts`
        `ORDER` `BY` `cxns` `DESC``;`
+-----------------+-----------+------+
| user            | host      | cxns |
+-----------------+-----------+------+
| NULL            | NULL      |   46 |
| rsandbox        | localhost |    3 |
| msandbox        | localhost |    2 |
| event_scheduler | localhost |    1 |
| root            | localhost |    1 |
+-----------------+-----------+------+
5 rows in set (0.00 sec)

尽管自 MySQL 5.6 以来就存在仪器化功能,但在 MySQL 5.7 中它获得了许多改进,并成为 DBA 工具中调查和解决 MySQL 级别问题的基础部分。

ibtmp1文件

当应用程序需要创建临时表或 MySQL 需要使用磁盘上的内部临时表时,它们会创建在共享临时表空间中。默认行为是创建一个名为ibtmp1的自动扩展数据文件,稍大于 12 MB(其大小由innodb_temp_data_file_path参数控制)。

ibdata1文件

ibdata1文件可能是 MySQL 生态系统中最著名的文件。对于 MySQL 5.7 及更早版本,它保存 InnoDB 数据字典、双写缓冲、变更缓冲和撤销日志的数据。如果我们禁用innodb_file_per_table选项,它还可能包含表和索引数据。当启用innodb_file_per_table时,每个用户表都有一个表空间和一个专用文件。请注意,MySQL 数据目录中可能存在多个ibdata文件。

注意

在 MySQL 8.0 中,一些组件已从ibdata1中移除并分配到单独的文件中。剩余的组件包括变更缓冲表和索引数据(如果表被创建在系统表空间中,通过禁用innodb_file_per_table)。

mysql.sock文件

这是 Unix 套接字文件,服务器用它与本地客户端进行通信。此文件仅在 MySQL 运行时存在,手动删除或创建可能会导致问题。

注意

Unix 套接字是一种允许同一台机器上运行的进程之间进行双向数据交换的进程间通信机制。IP 套接字(主要是 TCP/IP 套接字)是一种允许进程之间通过网络进行通信的机制。

您可以使用两种方法在 Linux 上连接到 MySQL 服务器:TCP 协议或套接字。出于安全目的,如果应用程序和 MySQL 位于同一服务器上,可以禁用远程 TCP 连接。在 MySQL 服务器中有两种方法可以做到这一点:将bind-address设置为127.0.0.1而不是默认的*值(接受来自所有人的 TCP/IP 连接),或修改skip-networking参数,禁用到 MySQL 的网络连接。

mysql子目录

mysql目录对应 MySQL 系统模式,包含 MySQL 服务器运行时的信息。例如,它包括用户及其权限、时区表和复制信息。您可以使用ls命令查看按其相应表名命名的文件:

# cd /var/lib/mysql
# ls -l mysql/

-rw-r-----. 1 vinicius.grippa percona    8820 Feb 20 15:51 columns_priv.frm
-rw-r-----. 1 vinicius.grippa percona       0 Feb 20 15:51 columns_priv.MYD
-rw-r-----. 1 vinicius.grippa percona    4096 Feb 20 15:51
columns_priv.MYI
-rw-r-----. 1 vinicius.grippa percona    9582 Feb 20 15:51 db.frm
-rw-r-----. 1 vinicius.grippa percona     976 Feb 20 15:51 db.MYD
-rw-r-----. 1 vinicius.grippa percona    5120 Feb 20 15:51 db.MYI
-rw-r-----. 1 vinicius.grippa percona      65 Feb 20 15:51 db.opt
-rw-r-----. 1 vinicius.grippa percona    8780 Feb 20 15:51 engine_cost.frm
-rw-r-----. 1 vinicius.grippa percona   98304 Feb 20 15:51 engine_cost.ibd
...
-rw-r-----. 1 vinicius.grippa percona   10816 Feb 20 15:51 user.frm
-rw-r-----. 1 vinicius.grippa percona    1292 Feb 20 15:51 user.MYD
-rw-r-----. 1 vinicius.grippa percona    4096 Feb 20 15:51 user.MYI

MySQL 8.0 默认文件

MySQL 8.0 在数据目录结构的核心引入了一些变化。其中一些变化与实施新数据字典相关,另一些则是为了改进数据库管理。以下列表描述了新文件和更改:

undo表空间文件

MySQL(InnoDB)使用undo文件来撤销需要回滚的事务,并在需要执行一致性读取时确保隔离的事务。

从 MySQL 8.0 开始,撤消日志文件已从系统表空间(ibdata1)中分离,并放置在数据目录中。还可以通过更改 innodb_undo_directory 参数 来设置另一个位置。

.dblwr 文件(从 8.0.20 版本引入)

双写缓冲区负责在 MySQL 将页面写入数据文件之前将其从缓冲池刷新到磁盘上写入页面。双写文件名的格式如下:#ib_<page_size>_<file_number>.dblwr(例如,#ib_16384_0.dblwr#ib_16384_0.dblwr)。可以通过修改 innodb_doublewrite_dir 参数 来更改这些文件的位置。

mysql.ibd 文件(从 8.0 版本引入)

在 MySQL 5.7 中,字典表和系统表将数据和元数据存储在 datadir 目录内的 mysql 目录中。在 MySQL 8.0 中,所有这些数据都存储在 mysql.ibd 文件中,并由 InnoDB 机制保护以确保一致性。

使用命令行界面

mysql 二进制文件是一个带有输入行编辑功能的简单 SQL shell。其使用方法很简单(在安装过程中我们已经用过几次)。要调用它,请运行以下命令:

# mysql

我们可以通过在其中执行查询来扩展其功能:

# mysql -uroot -pseKret -e "SHOW ENGINE INNODB STATUS\G"

我们还可以执行更高级的命令,将其与其他命令进行管道连接,以执行更复杂的任务。例如,我们可以从一个数据库中提取转储文件,通过网络发送并在另一个 MySQL 服务器中还原,所有这些都可以在同一个命令行中完成:

# mysql -e "SHOW MASTER STATUS\G" && nice -5 mysqldump \
    --all-databases --single-transaction -R --master-data=2 --flush-logs \
    --log-error=/tmp/donor.log --verbose=TRUE | ssh mysql@192.168.0.1 mysql \
    1> /tmp/receiver.log 2>&1

MySQL 8.0 引入了 MySQL Shell,比其前身功能强大得多。MySQL Shell 支持 JavaScript、Python 和 SQL 语言,为 MySQL Server 提供开发和管理功能。我们将在 “MySQL Shell” 中详细介绍这一点。

使用 Docker

随着虚拟化技术的出现及其在云服务中的普及,出现了许多平台,包括 Docker。Docker 诞生于 2013 年,提供了一种便捷灵活的部署软件的方式。它通过使用 Linux 的 cgroupskernel namespaces 等特性实现资源隔离。

对于经常需要安装特定版本的 MySQL、MariaDB 或 Percona Server for MySQL 进行实验的 DBA 来说,Docker 很有用。使用 Docker,可以在几秒钟内部署一个 MySQL 实例来执行一些测试。测试完成后,可以销毁实例并将操作系统的资源释放给其他任务。当使用 Docker 时,部署虚拟机(VM)、安装软件包和配置数据库的所有过程都更加简单。

安装 Docker

使用 Docker 的优势在于一旦服务运行,命令在所有操作系统中都是相同的。命令相同意味着使用 Docker 的学习曲线比学习不同的 Linux 版本(如 CentOS 和 Ubuntu)要快。

安装 Docker 的过程在某些方面与安装 MySQL 类似。对于 Windows 和 macOS,只需安装二进制文件,然后服务即可运行。对于没有图形界面的基于 Linux 的操作系统,该过程需要配置存储库。

在 CentOS 7 上安装 Docker

通常情况下,CentOS 提供的 Docker 软件包比 RHEL 和官方 Docker 存储库中的版本旧。目前为止,常规 CentOS 存储库提供的 Docker 版本为 1.13.1,而上游的稳定版本为 20.10.3。对于本书的目的没有区别,但我们始终建议在生产环境中使用最新版本。

执行以下命令从默认的 CentOS 存储库安装 Docker 软件包:

# yum install docker -y

如果要从上游存储库安装 Docker 以确保使用最新版本,请按照以下步骤操作:

  1. 安装yum-utils以启用yum-config-manager命令:

    # yum install yum-utils -y
    
  2. 使用yum-config-manager添加docker-ce存储库:

    # yum-config-manager \
        --add-repo \
        https://download.docker.com/linux/centos/docker-ce.repo
    
  3. 安装必要的软件包:

    # yum install docker-ce docker-ce-cli containerd.io -y
    
  4. 启动 Docker 服务:

    # systemctl start docker
    
  5. 启用 Docker 服务以在系统重新启动后自动启动:

    # systemctl enable --now docker
    
  6. 要验证 Docker 服务是否正在运行,请执行systemctl status命令:

    # systemctl status docker
    
  7. 要验证 Docker 引擎是否正确安装,可以运行hello-world容器:

    # docker run hello-world
    

在 Ubuntu 20.04(Focal Fossa)上安装 Docker

要从上游存储库安装最新的 Docker 发行版,请首先移除任何旧版本的 Docker(称为dockerdocker.iodocker-engine)。使用以下命令卸载它们:

# apt-get remove -y docker docker-engine docker.io containerd runc

移除默认存储库后,您可以启动安装过程:

  1. 确保 Ubuntu 使用此命令保持最新:

    # apt-get update -y
    
  2. 安装软件包以允许apt通过 HTTPS 使用存储库:

    # apt-get install -y \
        apt-transport-https \
        ca-certificates \
        curl \
        gnupg-agent \
        software-properties-common
    
  3. 接下来,添加 Docker 官方的 GPG 密钥:

    # curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo \
        apt-key add -
    
  4. 安装好密钥后,添加 Docker 稳定存储库:

    # add-apt-repository \
       "deb [arch=amd64] https://download.docker.com/linux/ubuntu \
       $(lsb_release -cs) \
       stable"
    
  5. 现在,使用apt命令安装 Docker 软件包:

    # apt-get install -y docker-ce docker-ce-cli containerd.io
    
  6. Ubuntu 将为您启动服务,但您可以通过运行此命令进行检查:

    # systemctl status docker
    
  7. 要使 Docker 服务在操作系统重新启动时自动启动,请使用:

    # systemctl enable --now docker
    
  8. 使用以下命令检查您安装的 Docker 版本:

    # docker --version
    
  9. 要验证 Docker 引擎是否正确安装,可以运行hello-world容器:

    # docker run hello-world
    

部署 MySQL 容器

安装并运行 Docker 引擎后的下一步是部署 MySQL Docker 容器。

警告

我们设计了以下指南,以便快速轻松地运行测试实例;请勿将其用于生产部署!

要使用 Docker 部署最新的 MySQL 版本,请执行此命令:

# docker run --name mysql-latest \
  -p 3306:3306 -p 33060:33060 \
  -e MYSQL_ROOT_HOST=% -e
  MYSQL_ROOT_PASSWORD='learning_mysql' \
  -d mysql/mysql-server:latest

Docker 引擎将启动 MySQL 实例的最新版本,并且可以通过指定的 root 密码从任何地方远程访问。使用 Docker 安装 MySQL 意味着您无法访问传统主机(裸机或虚拟机)上可用的任何工具、实用程序或标准库。如果需要这些工具,您需要单独部署这些工具或使用与 Docker 镜像一起提供的命令。

接下来,使用 MySQL 客户端连接到 MySQL 容器:

# docker exec -it mysql-latest mysql -uroot -plearning_mysql

由于您将容器中的 TCP 端口 3306 映射到 Docker 主机的端口 3306,您可以从可以访问主机(主机名或 IP)和该端口的任何 MySQL 客户端(Workbench、MySQL Shell)连接到 MySQL 数据库。

让我们来看看一些管理容器的命令。

要停止 MySQL Docker 容器,请运行:

# docker stop mysql-latest

不要尝试使用 docker run 再次启动容器。而是使用以下命令:

# docker start mysql-latest

要调查问题,例如容器无法启动时,请使用以下命令访问其日志:

# docker logs mysql-latest

要删除您创建的 Docker 容器,请运行:

# docker stop mysql-latest
# docker rm mysql-latest

要检查主机上运行哪些 Docker 容器以及有多少个,请使用:

# docker ps

可以使用命令行选项来自定义 MySQL 的参数化到 Docker 引擎。要配置 InnoDB 缓冲池大小刷新方法,请运行以下命令:

# docker run --name mysql-latest \
  -p 3306:3306 -p 33060:33060  \
  -e MYSQL_ROOT_HOST=% -e
  MYSQL_ROOT_PASSWORD='strongpassword' \
  -d mysql/mysql-server:latest \
  --innodb_buffer_pool_size=256M \
  --innodb_flush_method=O_DIRECT

要运行不同于最新版本的 MySQL 版本,请首先检查它是否在 Docker Hub 上可用。例如,假设您想运行 MySQL 5.7.31。第一步是在 Docker Hub 的 official MySQL Docker Images 列表中检查它是否存在。

一旦确认其存在,使用以下命令运行它:

# docker run --name mysql-5.7.31 \
  -p 3307:3306 -p 33061:33060  \
  -e MYSQL_ROOT_HOST=% -e \
  MYSQL_ROOT_PASSWORD='learning_mysql' \
  -d mysql/mysql-server:5.7.31

可以同时运行多个 MySQL Docker 实例,但潜在问题是 TCP 端口冲突。在前面的例子中,请注意我们为 mysql-5.7.31 容器映射了不同的主机端口(3307 和 33061)。此外,容器的 名称 需要是唯一的。

部署 MariaDB 和 Percona Server 容器

您可以按照前一部分描述的相同步骤来部署 MariaDBPercona Server 容器。主要区别在于它们使用不同的 Docker 镜像,并拥有各自的官方存储库。

要部署一个 MariaDB 容器,请运行:

# docker run --name maria-latest \
  -p 3308:3306 \
  -e MYSQL_ROOT_HOST=% -e \
  MYSQL_ROOT_PASSWORD='learning_mysql' \
  -d mariadb:latest

对于 Percona Server,请运行:

# docker run --name ps-latest \
  -p 3309:3306 -p 33063:33060 \
  -e MYSQL_ROOT_HOST=% -e \
  MYSQL_ROOT_PASSWORD='learning_mysql' \
  -d percona/percona-server:latest \
  --innodb_buffer_pool_size=256M \
  --innodb_flush_method=O_DIRECT
注意

我们为 MariaDB (-p 3308:3306) 和 Percona (-p 3309:3306) 映射了不同的端口,因为我们在同一主机上部署所有容器:

# docker ps
CONTAINER ID            IMAGE
5e487dd41c3e            percona/percona-server:latest

COMMAND                 CREATED           STATUS
"/docker-entrypoint..." About a minute ago Up 51 seconds
"docker-entrypoint..."  2 minutes ago      Up 2 minutes

PORTS                   NAMES
0.0.0.0:3309->3306/tcp, ps-latest
0.0.0.0:33063->33060/tcp
f5a217f1537b            mariadb:latest
0.0.0.0:3308->3306/tcp  maria-latest

如果只部署一个容器,可以使用端口 3306 或任何您想使用的自定义端口。

使用沙箱

在软件开发中,沙箱 是一个测试环境,用于隔离代码更改,允许在部署到生产环境之前进行实验和测试。DBA 主要用沙箱测试新软件版本、性能测试和故障分析,并且 MySQL 中的数据是可丢弃的。

注意

在 MySQL 数据库的背景下,听到 主节点从节点 这两个术语是很常见的。这些词的起源显然是负面的。因此,Oracle、Percona 和 MariaDB 已决定更改这些术语,改为使用 副本。在本书中,我们将使用这两组术语,因为你会遇到它们,但请注意,这些公司将在即将发布的版本中实施以下术语:

主节点
从节点 副本
黑名单 阻止列表
白名单 允许列表

在 2018 年,Giuseppe Maxia 推出了 DBdeployer,这是一个提供快速简便方法部署 MySQL 及其衍生产品的工具。它支持多种 MySQL 拓扑,如主/从(源/副本)、主/主(源/源)、Galera 集群和 Group Replication。

安装 DBdeployer

这个工具是用 Go 语言开发的,可以在 macOS 和 Linux(Ubuntu 和 CentOS)上运行,并提供独立的可执行文件。在这里获取最新版本:

# wget https://github.com/datacharmer/dbdeployer/releases/download/v1.58.2/ \
    dbdeployer-1.58.2.linux.tar.gz
# tar -xvf dbdeployer-1.58.2.linux.tar.gz
# mv dbdeployer-1.58.2.linux /usr/local/bin/dbdeployer

如果你将 /usr/local/bin/ 目录添加到 $PATH 变量中,现在应该能够运行 dbdeployer 命令了:

# dbdeployer --version
dbdeployer version 1.58.2

使用 DBdeployer

使用 DBdeployer 的第一步是下载你想要运行的 MySQL 二进制文件,并解压缩到存储二进制文件的目录中。我们将使用 Linux - Generic tarballs,因为它们与大多数 Linux 发行版兼容,并将我们的二进制文件存储在 /opt/mysql 目录中:

# wget https://dev.mysql.com/get/Downloads/MySQL-8.0/ \
    mysql-8.0.11-linux-glibc2.12-x86_64.tar.gz
# mkdir /opt/mysql
# dbdeployer --sandbox-binary=/opt/mysql/ unpack \
    mysql-8.0.11-linux-glibc2.12-x86_64.tar.gz

unpack 命令将会提取并移动文件到指定目录。这个操作的预期输出是:

# dbdeployer --sandbox-binary=/opt/mysql/ unpack
mysql-8.0.11-linux-glibc2.12-x86_64.tar.gz
Unpacking tarball mysql-8.0.11-linux-glibc2.12-x86_64.tar.gz to
/opt/mysql/8.0.11
.........100.........200........289
Renaming directory /opt/mysql/mysql-8.0.11-linux-glibc2.12-x86_64 to
/opt/mysql/8.0.11

现在我们可以使用以下命令使用新提取的二进制文件创建一个新的独立 MySQL 沙箱:

# dbdeployer --sandbox-binary=/opt/mysql/ deploy single 8.0.11

然后我们可以观察 DBdeployer 初始化 MySQL:

# dbdeployer --sandbox-binary=/opt/mysql/ deploy single 8.0.11
Creating directory /root/sandboxes
Database installed in $HOME/sandboxes/msb_8_0_11
run 'dbdeployer usage single' for basic instructions'
. sandbox server started

使用 ps 命令确认 MySQL 是否正在运行:

# ps -ef | grep mysql
root      4249     1  0 20:18 pts/0    00:00:00 /bin/sh bin/mysqld_safe
--defaults-file=/root/sandboxes/msb_8_0_11/my.sandbox.cnf
root      4470  4249  1 20:18 pts/0    00:00:00 /opt/mysql/8.0.11/bin/mysqld
--defaults-file=/root/sandboxes/msb_8_0_11/my.sandbox.cnf
--basedir=/opt/mysql/8.0.11 --datadir=/root/sandboxes/msb_8_0_11/data
--plugin-dir=/opt/mysql/8.0.11/lib/plugin --user=root
--log-error=/root/sandboxes/msb_8_0_11/data/msandbox.err
--pid-file=/root/sandboxes/msb_8_0_11/data/mysql_sandbox8011.pid
--socket=/tmp/mysql_sandbox8011.sock --port=8011
root      4527  3836  0 20:18 pts/0    00:00:00 grep --color=auto mysql

现在我们可以使用 DBdeployer 的 use 命令连接到 MySQL:

# cd sandboxes/msb_8_0_11/
# ./use

或者使用默认的 root 凭据:

# mysql -uroot -pmsandbox -h 127.0.0.1 -P 8011
注意

我们从之前的 ps 命令中获取了端口信息。请记住,连接到 MySQL 有两种方式:通过 TCP/IP 或使用套接字。我们还可以从 ps 命令的输出中获取套接字文件位置,并使用它进行连接,如下所示:

# mysql -uroot -pmsandbox -S/tmp/mysql_sandbox8011.sock

如果我们想要设置一个带有源/副本拓扑的复制环境,我们可以使用以下命令行来实现:

# dbdeployer --sandbox-binary=/opt/mysql/ deploy replication 8.0.11

现在我们将有三个 mysqld 进程在运行:

# ps -ef | grep mysql
root      4673     1  0 20:26 pts/0    00:00:00 /bin/sh bin/mysqld_safe
--defaults-file=/root/sandboxes/rsandbox_8_0_11/master/my.sandbox.cnf
root      4942  4673  1 20:26 pts/0    00:00:00
/opt/mysql/8.0.11/bin/mysqld
...
--pid-file=/root/sandboxes/rsandbox_8_0_11/master/data/mysql_sandbox201
12.pid --socket=/tmp/mysql_sandbox20112.sock --port=20112

root      5051     1  0 20:26 pts/0    00:00:00 /bin/sh bin/mysqld_safe
--defaults-file=/root/sandboxes/rsandbox_8_0_11/node1/my.sandbox.cnf
root      5320  5051  1 20:26 pts/0    00:00:00
/opt/mysql/8.0.11/bin/mysqld
--defaults-file=/root/sandboxes/rsandbox_8_0_11/node1/my.sandbox.cnf
...
--pid-file=/root/sandboxes/rsandbox_8_0_11/node1/data/mysql_sandbox2011
3.pid --socket=/tmp/mysql_sandbox20113.sock --port=20113

root      5415     1  0 20:26 pts/0    00:00:00 /bin/sh bin/mysqld_safe
--defaults-file=/root/sandboxes/rsandbox_8_0_11/node2/my.sandbox.cnf
root      5684  5415  1 20:26 pts/0    00:00:00
/opt/mysql/8.0.11/bin/mysqld
...
--pid-file=/root/sandboxes/rsandbox_8_0_11/node2/data/mysql_sandbox2011
4.pid --socket=/tmp/mysql_sandbox20114.sock --port=20114

DBdeployer 可以配置的另一种拓扑是 Group Replication。在这个示例中,我们将定义一个 base-port。通过这样做,我们将命令 DBdeployer 从端口 49007 开始配置我们的服务器:

# dbdeployer deploy --topology=group replication --sandbox-binary=/opt/mysql/\
    8.0.11 --base-port=49007

现在让我们看一个使用 Percona XtraDB Cluster 5.7.32 部署 Galera Cluster 的示例。我们将指定 base-port,并希望我们的节点配置为log-slave-updates 选项

# wget https://downloads.percona.com/downloads/Percona-XtraDB-Cluster-57/\
    Percona-XtraDB-Cluster-5.7.32-31.47/binary/tarball/Percona-XtraDB-Cluster-\
    5.7.32-rel35-47.1.Linux.x86_64.glibc2.17-debug.tar.gz
# dbdeployer --sandbox-binary=/opt/mysql/ unpack\
    Percona-XtraDB-Cluster-5.7.32-rel35-47.1.Linux.x86_64.glibc2.17-debug.tar.gz
# dbdeployer deploy --topology=pxc replication\
    --sandbox-binary=/opt/mysql/ 5.7.32 --base-port=45007 -c log-slave-updates

正如我们所见,可以定制 MySQL 参数。一个有趣的选项是启用 MySQL 复制,使用全局事务标识符,或 GTIDs(我们将在第十三章中更详细地讨论 GTIDs):

# dbdeployer deploy replication --sandbox-binary=/opt/mysql/ 5.7.32 --gtid

我们的最后一个示例显示,可以同时部署多个独立版本—在这里,我们创建了五个独立实例:

# dbdeployer deploy multiple --sandbox-binary=/opt/mysql/ 5.7.32 -n 5

上述示例只是 DBdeployer 能力的一小部分。完整文档可以在GitHub上找到。了解各种可能性的另一种选择是在命令行中使用 --help

# dbdeployer --help
dbdeployer makes MySQL server installation an easy task.
Runs single, multiple, and replicated sandboxes.

Usage:
  dbdeployer [command]

Available Commands:
  admin           sandbox management tasks
  cookbook        Shows dbdeployer samples
  defaults        tasks related to dbdeployer defaults
  delete          delete an installed sandbox
  delete-binaries delete an expanded tarball
  deploy          deploy sandboxes
  downloads       Manages remote tarballs
  export          Exports the command structure in JSON format
  global          Runs a given command in every sandbox
  help            Help about any command
  import          imports one or more MySQL servers into a sandbox
  info            Shows information about dbdeployer environment samples
  sandboxes       List installed sandboxes
  unpack          unpack a tarball into the binary directory
  update          Gets dbdeployer newest version
  usage           Shows usage of installed sandboxes
  versions        List available versions

Flags:
      --config string           configuration file (default
                                "/root/.dbdeployer/config.json")
  -h, --help                    help for dbdeployer
      --sandbox-binary string   Binary repository (default
                                "/root/opt/mysql")
      --sandbox-home string     Sandbox deployment directory (default
                                "/root/sandboxes")
      --shell-path string       Which shell to use for generated
                                scripts (default "/usr/bin/bash")
      --skip-library-check      Skip check for needed libraries (may
                                cause nasty errors)
      --version                 version for dbdeployer

Use "dbdeployer [command] --help" for more information about a command.

升级 MySQL 服务器

如果最常见的问题是关于复制,那么第二常见的问题就是如何升级 MySQL 实例。如果在生产环境中未经充分测试过程,则出现问题的可能性很高。可以执行两种类型的升级:

  • 在 MySQL 中的 重大升级 是从版本 5.6 到 5.7 或从 5.7 到 8.0 的更改。这样的升级比小版本升级更复杂,因为对体系结构的更改更为重大。例如,MySQL 8.0 的重大变化包括修改了数据字典,现在数据字典是事务性的,并由 InnoDB 封装。

  • 小版本升级 是从 MySQL 5.7.29 到 5.7.30 或从 MySQL 8.0.22 到 MySQL 8.0.23 的更改。大多数情况下,您需要使用发行版的软件包管理器安装新版本。小版本升级比重大版本升级简单,因为它不涉及体系结构的任何更改。修改集中在修复错误、提高性能和优化代码上。

要开始规划升级,首先要在两种策略之间进行选择。这是文档推荐的策略,也是我们使用的策略:

原地升级

这涉及到关闭 MySQL,用新的 MySQL 二进制文件或软件包替换旧的,重启 MySQL 在现有数据目录中,然后运行 mysql_upgrade

注意

自 MySQL 8.0.16 开始,mysql_upgrade 二进制文件已被弃用,MySQL 服务器本身执行其功能(您可以将其视为“服务器升级”)。MySQL 在数据字典升级(DD 升级)旁边添加了这一变化,数据字典升级是更新数据字典表定义的过程。新流程的好处包括:

  • 更快的升级速度

  • 更简单的过程

  • 更好的安全性

  • 显著减少升级步骤

  • 更容易自动化

  • 无重启

  • 即插即用

逻辑升级

这涉及使用备份工具或导出工具(如 mysqldumpmysqlpump)从旧版本的 MySQL 导出 SQL 格式的数据,安装新版本的 MySQL,并将 SQL 数据应用到新版本的 MySQL。换句话说,此过程涉及重建整个数据字典和用户数据。逻辑升级通常比原地升级需要更长的时间。

无论选择了哪种策略,建立回滚策略都是必要的,以防出现问题。回滚策略将根据您选择的升级计划、数据库大小和当前拓扑结构(例如是否使用副本或 Galera 集群)而有所不同。

在规划升级时,请考虑以下一些额外的要点:

  • 支持从 MySQL 5.7 升级到 8.0。但是,升级仅在 GA 版本之间支持。对于 MySQL 8.0,必须从 MySQL 5.7 的 GA 发行版(5.7.9 或更高版本)升级。不支持从 MySQL 5.7 的非 GA 版本进行升级。

  • 建议在升级到下一个版本之前先升级到最新版本。例如,在升级到 MySQL 8.0 之前,先升级到最新的 MySQL 5.7 版本。

  • 不支持跳过版本进行升级。例如,直接从 MySQL 5.6 升级到 8.0 是不支持的。

注意

根据我们的经验,从 MySQL 5.6 升级到 MySQL 5.7 是导致性能问题最多的升级,特别是如果应用程序使用了 派生表(参见 “FROM 子句中的嵌套查询”)。MySQL 5.7 修改了 optimizer_switch 系统变量,默认启用了 derived_merge 设置,这可能影响查询性能。

另一个复杂的变化是,MySQL 5.7 默认实现了网络加密(SSL)。在 MySQL 5.6 中未使用 SSL 的应用可能会遭受重大的性能损失。

最后,MySQL 5.7 将 sync_binlog 默认更改为同步模式。这种模式最安全,但由于增加了磁盘写入次数,可能会影响性能。

让我们通过一个示例来详细介绍如何使用原地升级方法从 MySQL 5.7 升级到 MySQL 8.0:

  1. 停止 MySQL 服务。使用 systemctl 执行干净的关闭操作:

    # systemctl stop mysqld
    
  2. 删除旧的二进制文件:

      # yum erase mysql-community -y
    

    此过程仅移除二进制文件,并不触及 datadir(参见 “MySQL 目录的内容”)。

  3. 遵循安装过程的常规步骤(参见 “在 Linux 上安装 MySQL”)。例如,在 CentOS 7 上使用 yum 安装 MySQL 8.0 社区版本:

    # yum-config-manager --enable mysql80-community
    
  4. 安装新的二进制文件:

    # yum install mysql-community-server -y
    
  5. 启动 MySQL 服务:

    # systemctl start mysqld
    

我们可以在日志中观察到,MySQL 已经升级了数据字典,并且现在运行的是 MySQL 8.0.21:

# tail -f /var/log/mysqld.log
2020-08-09T21:20:10.356938Z 2 [System] [MY-011003] [Server] Finished
populating Data Dictionary tables with data.
2020-08-09T21:20:11.734091Z 5 [System] [MY-013381] [Server] Server
upgrade from '50700' to '80021' started.
2020-08-09T21:20:17.342682Z 5 [System] [MY-013381] [Server] Server
upgrade from '50700' to '80021' completed.
...
2020-08-09T21:20:17.463685Z 0 [System] [MY-010931] [Server]
/usr/sbin/mysqld: ready for connections. Version: '8.0.21'  socket:
'/var/lib/mysql/mysql.sock'  port: 3306  MySQL Community Server - GPL.
注意

我们强烈建议在升级 MySQL 之前查看发布说明。它们包含所做更改和 bug 修复的摘要。MySQL 的发布说明可以在MySQL upstreamPercona Server,和MariaDB找到。

一个常见的问题是是否安全升级到最新的主要版本。答案是……这取决于情况。和行业中的任何新产品一样,早期采纳者倾向于从新功能中受益,但他们也是测试者,可能会发现并受到新 bug 的影响。当 MySQL 8.0 发布时,我们的建议是在考虑迁移之前等待三个次要版本。本书的黄金法则是在执行下一步之前先测试所有内容。如果你从本书中只学到这一点,我们将认为我们的使命已经完成。

第二章:建模和设计数据库

在实施新数据库时,很容易陷入快速搭建并没有充分投入时间和精力进行设计的陷阱。这种粗心经常导致日后昂贵的重新设计和重新实施。设计数据库就像起草房子的蓝图;没有详细的计划就开始建造是愚蠢的。值得注意的是,良好的设计允许您扩展原始建筑,而无需拆除一切重新开始。正如您将看到的那样,糟糕的设计直接影响到数据库的性能不佳。

如何不开发数据库

数据库设计可能不是世界上最激动人心的任务,但确实成为了最重要的任务之一。在我们描述如何进行设计过程之前,让我们看一个动态设计数据库的例子。

想象一下,我们想为大学计算机科学系存储学生成绩的数据库。我们可以创建一个Student_Grades表,用于存储每个学生和每门课程的成绩。该表将为每位学生和他们所选课程的名字和姓氏,课程名称以及百分比结果(显示为Pctg)设置列。我们为每位学生的每门课程设置不同的行:

+------------+---------+-----------------------+------+
| GivenNames | Surname | CourseName            | Pctg |
+------------+---------+-----------------------+------+
| John Paul  | Bloggs  | Data Science          |   72 |
| Sarah      | Doe     | Programming 1         |   87 |
| John Paul  | Bloggs  | Computing Mathematics |   43 |
| John Paul  | Bloggs  | Computing Mathematics |   65 |
| Sarah      | Doe     | Data Science          |   65 |
| Susan      | Smith   | Computing Mathematics |   75 |
| Susan      | Smith   | Programming 1         |   55 |
| Susan      | Smith   | Computing Mathematics |   80 |
+------------+---------+-----------------------+------+

列表很简洁,我们可以轻松访问任何学生或任何课程的成绩,看起来类似于电子表格。然而,我们可能会有多个同名学生。例如,示例数据中的 Susan Smith 和 Computing Mathematics 课程有两个条目。哪个 Susan Smith 获得了 75%,哪个获得了 80%?区分重复数据条目的常见方法是为每个条目分配唯一编号。在这里,我们可以为每个学生分配一个唯一的StudentID编号:

+------------+------------+---------+-----------------------+------+
| StudentID  | GivenNames | Surname | CourseName            | Pctg |
+------------+------------+---------+-----------------------+------+
| 12345678   | John Paul  | Bloggs  | Data Science          |   72 |
| 12345121   | Sarah      | Doe     | Programming 1         |   87 |
| 12345678   | John Paul  | Bloggs  | Computing Mathematics |   43 |
| 12345678   | John Paul  | Bloggs  | Computing Mathematics |   65 |
| 12345121   | Sarah      | Doe     | Data Science          |   65 |
| 12345876   | Susan      | Smith   | Computing Mathematics |   75 |
| 12345876   | Susan      | Smith   | Programming 1         |   55 |
| 12345303   | Susan      | Smith   | Computing Mathematics |   80 |
+------------+------------+---------+-----------------------+------+

现在我们知道哪个 Susan Smith 获得了 80%:那位学生 ID 编号为 12345303 的 Susan Smith。

还有另一个问题。在我们的表中,John Paul Bloggs 在 Computing Mathematics 课程中有两个分数:他第一次以 43%不及格,然后第二次以 65%及格。在关系型数据库中,行形成一个集合,它们之间没有隐含的顺序。查看这张表时,我们可能会猜测通过发生在失败之后,但我们不能确定。不能保证更新的成绩会在较旧的成绩之后出现,因此我们需要添加每个成绩授予的时间信息,比如添加年份(Year)和学期(Sem):

+------------+------------+---------+-----------------------+------+-----+------+
| StudentID  | GivenNames | Surname | CourseName            | Year | Sem | Pctg |
+------------+------------+---------+-----------------------+------+-----+------+
| 12345678   | John Paul  | Bloggs  | Data Science          | 2019 |   2 |   72 |
| 12345121   | Sarah      | Doe     | Programming 1         | 2020 |   1 |   87 |
| 12345678   | John Paul  | Bloggs  | Computing Mathematics | 2019 |   2 |   43 |
| 12345678   | John Paul  | Bloggs  | Computing Mathematics | 2020 |   1 |   65 |
| 12345121   | Sarah      | Doe     | Data Science          | 2020 |   1 |   65 |
| 12345876   | Susan      | Smith   | Computing Mathematics | 2019 |   1 |   75 |
| 12345876   | Susan      | Smith   | Programming 1         | 2019 |   2 |   55 |
| 12345303   | Susan      | Smith   | Computing Mathematics | 2020 |   1 |   80 |
+------------+------------+---------+-----------------------+------+-----+------+

注意,Student_Grades表变得有些臃肿。我们为每年重复了学生 ID、名字和姓氏。我们可以拆分信息并创建一个Student_Details表:

+------------+------------+---------+
| StudentID  | GivenNames | Surname |
+------------+------------+---------+
| 12345121   | Sarah      | Doe     |
| 12345303   | Susan      | Smith   |
| 12345678   | John Paul  | Bloggs  |
| 12345876   | Susan      | Smith   |
+------------+------------+---------+

我们可以在Student_Grades表中保留更少的信息:

+------------+-----------------------+------+-----+------+
| StudentID  | CourseName            | Year | Sem | Pctg |
+------------+-----------------------+------+-----+------+
| 12345678   | Data Science          | 2019 |   2 |   72 |
| 12345121   | Programming 1         | 2020 |   1 |   87 |
| 12345678   | Computing Mathematics | 2019 |   2 |   43 |
| 12345678   | Computing Mathematics | 2020 |   1 |   65 |
| 12345121   | Data Science          | 2020 |   1 |   65 |
| 12345876   | Computing Mathematics | 2019 |   1 |   75 |
| 12345876   | Programming 1         | 2019 |   2 |   55 |
| 12345303   | Computing Mathematics | 2020 |   1 |   80 |
+------------+-----------------------+------+-----+------+

要查找学生的成绩,我们首先需要从Student_Details表中查找他们的学生 ID,然后从Student_Grades表中读取该学生 ID 的成绩。

尽管如此,仍然有一些问题我们尚未考虑。例如,我们是否应该保留学生的入学日期、邮政地址和电子邮件地址、费用或出勤信息?我们应该如何存储地址以避免学生更改地址时出现问题?

以这种方式实施数据库是有问题的;我们会不断遇到之前未考虑到的问题,并不得不不断更改我们的数据库结构。通过仔细记录需求并逐步解决它们以开发一致的设计,我们可以节省大量重复工作。

数据库设计过程

数据库设计有三个主要阶段,每个阶段都会产生一个逐步降低级别的描述:

需求分析

首先,我们确定并写下了从数据库中所需的数据,我们将存储哪些数据以及数据项之间的关系。在实践中,这可能涉及详细研究应用程序需求,并与将与数据库和应用程序交互的各种角色的人员进行交谈。

概念设计

一旦我们知道了数据库的需求,我们就会将它们提炼成数据库设计的正式描述。在本章的后面,我们将看到如何使用建模来产生概念设计。

逻辑设计

最后,我们将数据库设计映射到现有的数据库管理系统和数据库表中。

在本章末尾,我们将看看如何使用开源 MySQL Workbench 工具将概念设计转换为 MySQL 数据库模式。

实体关系模型

在基本层面上,数据库存储关于不同对象或实体及这些实体之间的关系或关系的信息。例如,大学数据库可能存储关于学生、课程和注册的信息。学生和课程是实体,而注册是学生和课程之间的关系。类似地,库存和销售数据库可能存储关于产品、客户和销售的信息。产品和客户是实体,销售是客户和产品之间的关系。在开始时,混淆实体和关系是很常见的,您可能会把关系设计为实体,反之亦然。提高数据库设计技能的最佳方法是多加练习。

一种流行的概念设计方法使用实体关系(ER)模型,帮助将需求转化为数据库中实体和关系的正式描述。我们将首先了解 ER 建模过程的工作原理,然后在“实体关系建模示例”中观察三个样本数据库的应用。

实体表示

为了帮助可视化设计,ER 建模方法涉及绘制 ER 图。在 ER 图中,我们通过包含实体名称的矩形来表示实体集。对于我们的销售数据库示例,ER 图将显示产品和客户实体集,如图 2-1 所示。

lm2e 0201

图 2-1。实体集由一个命名的矩形表示

通常,我们使用数据库来存储实体的特定特征或属性。在销售数据库中,我们可以记录每个客户的姓名、电子邮件地址、邮寄地址和电话号码。在更复杂的客户关系管理(CRM)应用程序中,我们还可以存储客户配偶和子女的姓名、客户所会说的语言、客户与公司互动的历史等信息。属性描述了它们所属的实体。

我们可以从较小的部分形成属性;例如,我们将街道号码、城市、邮政编码和国家组成邮寄地址。如果属性是以这种方式由较小的部分组成的,则我们将其分类为复合属性;否则为简单属性。

一些属性可以针对给定实体具有多个值——例如,一个客户可以提供多个电话号码,因此电话号码属性是多值的

属性有助于区分同一类型的实体。我们可以使用姓名属性来区分客户,但这可能是一个不够充分的解决方案,因为有几个客户可能具有相同的姓名。为了区分它们,我们需要一个保证每个客户都唯一的属性(或最小组合的属性)。这些唯一标识属性形成一个唯一键,特别情况下称为主键

在我们的示例中,我们可以假设没有两个客户具有相同的电子邮件地址,因此电子邮件地址可以作为主键。然而,在设计数据库时,我们需要仔细考虑我们选择的影响。例如,如果我们决定通过电子邮件地址识别客户,我们将如何处理一个客户拥有多个电子邮件地址的情况?我们构建的任何应用程序可能会将每个电子邮件地址视为一个独立的人。难以适应所有人可以有多个电子邮件地址的情况。使用电子邮件地址作为键还意味着每个客户必须有一个电子邮件地址;否则,我们无法区分没有电子邮件地址的客户。

查看可以作为备用键的其他属性时,我们看到虽然两个客户可能具有相同的电话号码(因此不能将电话号码作为键),但可能拥有相同电话号码的人不会有相同的姓名,因此我们可以使用电话号码和姓名的组合作为复合键。

显然,可能有几个可能的键可以用来标识一个实体;我们选择其中一个备选键,或候选键,作为我们的主键。通常我们选择的标准是属性对于每个实体来说是否非空且唯一,并且键的长度多大(较短的键在维护和使用查找操作时更快)。

在 ER 图中,属性表示为标记的椭圆形与它们的实体连接,如图 2-2 所示。组成主键的属性显示为下划线。任何组合属性的部分都画在组合属性的椭圆形上,而多值属性显示为双线椭圆形。

lm2e 0202

图 2-2. 客户实体的 ER 图表示

属性值是从合法值的域中选择的。例如,我们可以指定客户的名字和姓氏属性每个都可以是最多 100 个字符的字符串,而电话号码可以是最多 40 个字符的字符串。同样,产品价格可以是正有理数。

属性可以是空的;例如,有些客户可能不提供他们的电话号码。但是,实体的主键(包括多属性主键的组成部分)绝不能是未知的(技术上来说,必须是NOT NULL)。因此,如果一个客户可能不提供电子邮件地址,我们就不能将电子邮件地址作为键。

当将属性分类为多值时,你应该仔细思考:所有值是否等同,或者它们实际上代表了不同的事物?例如,当为客户列出多个电话号码时,它们是否更有用地分别标记为客户的办公电话号码、家庭电话号码、手机电话号码等?

让我们看另一个例子。销售数据库需求可能指定产品具有名称和价格。我们可以看到产品是一个实体,因为它是一个独立的对象。然而,产品的名称和价格不是独立的对象;它们是描述产品实体的属性。请注意,如果我们想为不同的市场设置不同的价格,则价格不再仅仅与产品实体相关,我们需要以不同的方式对其进行建模。

对于某些应用程序,没有组合属性可以唯一标识一个实体(或者使用大型复合键将会太麻烦),因此我们创建了一个被定义为唯一的人造属性,可以作为键使用:学生编号、社会保障号码、驾驶证号码和图书卡号码是为各种应用程序创建的唯一属性的例子。在我们的库存和销售应用程序中,我们可能会存储名称和价格相同但型号不同的不同产品。例如,我们可以销售两款售价均为每件$4.95 的“四口 USB 2.0 集线器”。为了区分这些产品,我们可以为每个我们库存的物品分配一个唯一的产品 ID 号码;这将成为主键。每个产品实体将具有名称、价格和产品 ID 属性。这在 ER 图中显示为图 2-3。

lm2e 0203

图 2-3. 产品实体的 ER 图表示

表示关系

实体可以参与与其他实体的关系。例如,客户可以购买产品,学生可以选修课程,员工可以有地址等等。

像实体一样,关系也可以具有属性:我们可以定义销售关系为客户实体(通过唯一的电子邮件地址标识)和给定产品实体(通过唯一的产品 ID 标识)之间的关系,这些产品在特定的日期和时间(时间戳)存在。

我们的数据库可以记录每笔销售,并告诉我们,例如在周三,3 月 22 日下午 3:13,Marcos Albe 购买了一个“Raspberry Pi 4”,一个“500 GB SSD M.2 NVMe”和两组“2000 瓦 5.1 声道重低音喇叭”。

不同数量的实体可以出现在关系的每一侧。例如,每个客户都可以购买任意数量的产品,每个产品也可以被任意数量的客户购买。这被称为多对多关系。我们还可以有一对多关系。例如,一个人可以有多张信用卡,但每张信用卡只属于一个人。从另一个角度看,一对多关系变成了多对一关系;例如,多张信用卡属于一个人。最后,汽车发动机上的序列号是一对一关系的一个例子;每个发动机只有一个序列号,每个序列号只属于一个发动机。我们使用简写术语1:11:NM:N分别表示一对一,一对多和多对多关系。

关系的每一侧的实体数量(关系的基数)定义了关系的键约束。仔细考虑关系的基数非常重要。有许多关系乍看起来可能是一对一的,但事实上更为复杂。例如,人们有时会更改他们的姓名;在一些应用程序中,如警方数据库,这尤为重要,因此可能需要对人实体和姓名实体之间建模为多对多的关系。如果假设关系比实际情况简单,重设计数据库可能会很昂贵且耗时。

在 ER 图中,我们用一个带有名称的菱形表示关系集。关系的基数通常在关系菱形旁边标示;这是本书中使用的风格。(另一种常见的风格是在连接实体的“1”侧到关系菱形的线上有箭头头。)图 2-4 展示了顾客和产品实体之间的关系,以及销售关系的数量和时间戳属性。

lm2e 0204

图 2-4. 顾客和产品实体之间的 ER 图表示,以及它们之间的销售关系

部分和全参与

实体之间的关系可以是可选的或强制的。在我们的例子中,我们可以决定一个人只有在购买了产品后才被视为顾客。另一方面,我们也可以说顾客是我们知道并且希望可能购买东西的人,也就是说,在我们的数据库中可以列出从未购买产品的顾客。在第一种情况下,顾客实体在购买关系中具有全参与(所有顾客都购买了产品,我们不能有一个未购买产品的顾客),而在第二种情况下,它具有部分参与(顾客可以购买产品)。这些被称为关系的参与约束。在 ER 图中,我们用实体框和关系菱形之间的双线表示全参与。

实体还是属性?

偶尔我们会遇到这样的情况,我们在思考一个项目应该是属性还是自己的实体时会产生疑问。例如,电子邮件地址可以作为一个独立的实体进行建模。当有疑问时,请考虑以下经验法则:

该项目是否直接关注数据库?

直接关注的对象应该是实体,并且描述它们的信息应该存储在属性中。我们的库存和销售数据库真正关心的是顾客,而不是他们的电子邮件地址,因此最好将电子邮件地址建模为顾客实体的属性。

项目本身是否有组成部分?

如果是这样,我们必须找到一种表示这些组件的方法;一个独立的实体可能是最好的解决方案。在本章开头的学生成绩示例中,我们为每门学生选修的课程存储了课程名称、年份和学期。将课程视为一个独立的实体,并创建一个课程 ID 号来标识每次向学生提供课程(“提供”)可能更为紧凑。

对象可以有多个实例吗?

如果是这样,我们必须找到一种方法来在每个实例上存储数据。最清晰的做法是将对象表示为一个独立的实体。在我们的销售示例中,我们必须询问客户是否允许拥有多个电子邮件地址;如果是的话,我们应该将电子邮件地址建模为一个独立的实体。

对象经常不存在或未知吗?

如果是这样,它实际上只是一些实体的属性,并且最好将其建模为一个独立的实体,而不是经常为空的属性。考虑一个简单的例子:为了存储学生在不同课程中的成绩,我们可以在每门可能的课程中为学生的成绩设置一个属性,如图 2-5 所示。但是因为大多数学生只会在其中几门课程中有成绩,所以最好将成绩表示为一个独立的实体集,如图 2-6 所示。

lm2e 0205

图 2-5. 将学生成绩表示为学生实体的 ER 图

lm2e 0206

图 2-6. 学生成绩的实体关系图表示

实体还是关系?

确定对象应该是实体还是关系的一个简单方法是将需求中的名词映射到实体,并将动词映射到关系。例如,在语句“A degree program is made up of one or more courses”中,我们可以识别实体“program”和“course”,以及关系“is made up of”。类似地,在语句“A student enrolls in one program”中,我们可以识别实体“student”和“program”,以及关系“enrolls in”。当然,我们可以选择不同于出现在关系中的实体和关系的术语,但最好不要偏离需求中使用的命名约定,以便可以根据需求检查设计。在其他一切相等的情况下,尽量保持设计简单,并尽量避免引入不必要的实体。也就是说,在我们可以将其建模为现有学生和项目实体之间的关系时,没有必要为学生的注册单独引入一个独立的实体。

中间实体

通过用一个新的中间实体(有时称为关联实体)替换它,通过多对一和一对多关系连接原始实体,通常可以在概念上简化多对多关系。

考虑这个声明:“一个乘客可以预订一个航班的座位。”这是“乘客”和“航班”之间的多对多关系。相关的 ER 图片段显示在图 2-7 中。

lm2e 0207

图 2-7. 乘客参与 M:N 关系的航班

然而,让我们从关系的两个方面来看:

  • 任何给定的航班都可以有多名预订乘客。

  • 任何给定的乘客都可以在多个航班上预订。

因此,我们可以认为多对多关系实际上是两个单对多关系,各自的一条。这指向了一个隐藏的中间实体存在,即预订,在航班和乘客实体之间。需求可以更好地表述为:“一个乘客可以为一个航班预订座位。”更新后的 ER 图片段显示在图 2-8 中。

lm2e 0208

图 2-8. 位于乘客和航班实体之间的中间预订实体

每位乘客可以参与多个预订,但每个预订只属于一位乘客,因此此关系的基数是 1:N。类似地,一个给定航班可以有多个预订,但每个预订只属于一架航班,所以这个关系也有 1:N 的基数。由于每个预订必须与特定的乘客和航班相关联,预订实体在与这些实体的关系中完全参与(如第 77 页的“部分和完全参与”中描述的)。在图 2-7 的表示中无法有效捕捉这种完全参与。

弱实体和强实体

在我们日常互动中,背景信息非常重要;如果了解背景,我们就可以处理更少量的信息。例如,我们通常只用名字或昵称称呼家庭成员。在存在歧义时,我们会添加更多信息,比如姓氏,以澄清我们的意图。在数据库设计中,我们可以省略某些依赖于其他实体的关键信息。例如,如果我们想存储客户孩子的名字,我们可以创建一个子实体,并且仅存储足够的关键信息以在其父实体的上下文中标识它。我们可以简单地列出孩子的名字,假设客户不会有几个同名的孩子。在这里,子实体是实体,它与客户实体的关系被称为标识关系。弱实体完全参与标识关系,因为它们无法独立于所属实体存在于数据库中。

在 ER 图中,我们用双线显示弱实体和标识关系,并用虚线下划线表示弱实体的部分键,如图 2-9 所示。在其拥有(或)实体的上下文中,弱实体可以唯一标识,因此弱实体的完整键是其自身(部分)键与其拥有实体的键的组合。例如,在我们的示例中唯一标识子实体需要子实体的名字和父母的电子邮件地址。

图 2-10 显示了我们为 ER 图解释的符号总结。

lm2e 0209

图 2-9. 弱实体的 ER 图表示

lm2e 0210

图 2-10. ER 图符号的总结

数据库规范化

数据库规范化是设计关系数据结构时的重要概念。关系数据库模型的发明者埃德加·F·科德博士在 70 年代初提出了这些规范形式,现在仍被广泛应用于行业。即使随着 NoSQL 数据库的出现,短期或中期内也没有证据表明关系数据库会消失或规范形式会不再使用。

规范形式的主要目标是减少数据冗余并提高数据完整性。规范化还便于重设计和扩展数据库结构的过程。

正式地说,有六个规范形式,但大多数数据库架构师只处理前三种形式。这是因为规范化过程是渐进的,除非满足了前几个级别,否则无法达到更高级别的数据库规范化。使用所有六种规范形式会过于束缚数据库模型,并且一般情况下,它们实现起来非常复杂。

在实际工作负载中,通常会出现性能问题。这是提取、转换和加载(ETL)作业存在的一个原因:它们对数据进行去规范化以处理它。

让我们来看看前三个范式:

第一范式(1NF)的目标如下

  • 消除各个表中的重复组

  • 为每组相关数据创建单独的表。

  • 使用主键标识每组相关数据。

如果一个关系包含复合或多值属性,则违反了第一范式。反之,如果一个关系不包含任何复合或多值属性,则处于第一范式中。因此,如果该关系中的每个属性都具有适当类型的单个值,则该关系处于第一范式中。

第二范式(2NF)的目标是

  • 为适用于多个记录的值集创建单独的表。

  • 用外键将这些表关联起来。

记录不应依赖于除表的主键(必要时是复合键)之外的任何内容。

第三范式(3NF)增加了一个目标

  • 消除不依赖于键的字段。

记录中的值如果不是记录的关键字,则不应该在表中。一般情况下,如果一组字段的内容可能适用于表中多个记录,则应考虑将这些字段放入单独的表中。

表 2-1 列出了从最不归一化到最归一化的各种范式。未归一化形式(UNF)是一个不符合数据库归一化条件的数据库模型。还有其他的归一化形式,但它们超出了本讨论的范围。

表 2-1. 各种范式(从最不归一化到最归一化)

UNF(1970 年) 1NF(1970 年) 2NF(1971 年) 3NF(1971 年) 4NF(1977 年) 5NF(1979 年) 6NF(2003 年)
主键(无重复元组) 可能
无重复组
原子列(单元格只有单一值)
每个非平凡的功能依赖要么不以候选键的真子集开始,要么以主属性结尾(不允许非主属性对候选键的部分功能依赖)
每个非平凡的功能依赖要么以超键开始,要么以主属性结尾(不允许非主属性对候选键的传递功能依赖)
每个非平凡的功能依赖要么以超键开始,要么以基本主属性结尾 不适用
每个非平凡的功能依赖都以超键开始 不适用
每个非平凡的多值依赖都以超键开始 不适用
每个连接依赖都有一个超键组件 不适用
每个连接依赖只有超键组件 不适用
每个约束都是领域约束和关键约束的结果 不适用
每个连接依赖都是平凡的

归一化示例表

为了更清楚地说明这些概念,让我们通过一个虚构的学生表的归一化示例来详细讨论。

我们将从未归一化的表开始:

Student#   Advisor   Adv-Room   Class1   Class2   Class3
1022       Jones     412        101-07   143-01   159-02
4123       Smith     216        201-01   211-02   214-01

第一范式:无重复组

每个属性应该在表中只有一个字段。因为一个学生有多门课程,这些课程应该在单独的表中列出。在我们未归一化的表中,字段Class1Class2Class3表明设计存在问题。

电子表格通常对同一属性有多个字段(例如 address1address2address3),但表不应如此。在一个一对多关系中,不要将一方和多方放在同一表中。而是通过消除重复组来创建第一范式中的另一个表,例如 Class#,如下所示:

Student#   Advisor   Adv-Room   Class#
1022       Jones     412        101-07
1022       Jones     412        143-01
1022       Jones     412        159-02
4123       Smith     216        201-01
4123       Smith     216        211-02
4123       Smith     216        214-01

第二范式:消除冗余数据

注意前表中每个 Student# 值的多个 Class# 值。Class# 不是对 Student#(主键)的功能依赖,因此这种关系不符合第二范式。

下面两张表演示了转换为第二范式。我们现在有一个 Students 表:

Student#    Advisor   Adv-Room
1022        Jones     412
4123        Smith     216

以及 Registration 表:

Student#  Class#
1022      101-07
1022      143-01
1022      159-02
4123      201-01
4123      211-02
4123      214-01

第三范式:消除不依赖于键的数据

在前面的示例中,Adv-Room(顾问的办公室号码)对 Advisor 属性具有功能依赖。解决方案是将该属性从 Students 表移至 Faculty 表,如下所示。

Students 表现在看起来像这样:

Student#  Advisor
1022      Jones
4123      Smith

这是 Faculty 表:

Name  Room  Dept
Jones 412 42
Smith 216 42

实体关系建模示例

在前几节中,我们通过假设示例帮助您了解数据库设计、ER 图和规范化的基础知识。现在我们将看一些 MySQL 可用示例数据库中的 ER 示例以可视化 ER 图。为了可视化 ER 图,我们将使用 MySQL Workbench

MySQL Workbench 使用物理 ER 表示。物理 ER 图模型更加细粒度化,显示向数据库添加信息所需的过程。与使用符号不同,我们在 ER 图中使用表,使其更接近真实数据库。MySQL Workbench 更进一步使用 增强实体-关系(EER)图。EER 图是 ER 图的扩展版本。

我们不会详细讨论,但 EER 图的主要优点是,它提供了 ER 图的所有元素,并增加了对以下内容的支持:

  • 属性和关系继承

  • 类别或联合类型

  • 专业化和泛化

  • 子类和超类

让我们从下载示例数据库并在 MySQL Workbench 中可视化其 EER 图的过程开始。

我们将使用的第一个是 sakila 数据库。该数据库的开发始于 2005 年。早期设计基于戴尔白皮书 “三种在戴尔 PowerEdge 服务器上使用 MySQL 应用的方法”,该白皮书设计用于代表在线 DVD 商店。类似地,sakila 示例数据库设计用于代表 DVD 租赁店,并借用了戴尔示例数据库的电影和演员名称。您可以使用以下命令将 sakila 数据库导入到您的 MySQL 实例中:

# wget https://downloads.mysql.com/docs/sakila-db.tar.gz
# tar -xvf sakila-db.tar.gz
# mysql -uroot -pmsandbox < sakila-db/sakila-schema.sql
# mysql -uroot -pmsandbox < sakila-db/sakila-data.sql

sakila还提供了 EER 模型,在sakila.mwb文件中。您可以使用 MySQL Workbench 打开该文件,如图 2-11 所示。

lm2e 0211

图 2-11. sakila数据库的 EER 模型;注意实体的物理表示而不是使用符号

接下来是world数据库,使用来自芬兰统计局的样本数据。

以下命令将world数据库导入到您的 MySQL 实例中:

# wget https://downloads.mysql.com/docs/world-db.tar.gz
# tar -xvf world-db.tar.gz
# mysql -uroot -plearning_mysql < world-db/world.sql

world数据库没有像sakila那样的 EER 文件,但是您可以使用 MySQL Workbench 从数据库创建 EER 模型。要执行此操作,请从数据库菜单中选择反向工程,如图 2-12 所示。

lm2e 0212

图 2-12. 从world数据库进行反向工程

Workbench 将连接到数据库(如果尚未连接),并提示您选择要反向工程的模式,如图 2-13 所示。

点击“继续”,然后在下一个屏幕上点击“执行”,如图 2-14 所示。

lm2e 0213

图 2-13. 选择模式

lm2e 0214

图 2-14. 点击执行以启动反向工程过程

这产生了world数据库的 ER 模型,如图 2-15 所示。

lm2e 0215

图 2-15. world数据库的 ER 模型

您将导入的最后一个数据库是employees数据库。Fusheng Wang 和 Carlo Zaniolo 在 Siemens Corporate Research 创建了原始数据,Giuseppe Maxia 制作了关系模式,Patrick Crews 以关系格式导出了数据。

要导入数据库,首先您需要克隆 Git 仓库:

# git clone https://github.com/datacharmer/test_db.git
# cd test_db
# cat employees.sql | mysql -uroot -psekret

然后,您可以再次使用 MySQL Workbench 的反向工程过程为employees数据库创建 ER 模型,如图 2-16 所示。

lm2e 0216

图 2-16. employees数据库的 ER 模型

您必须仔细审查此处显示的 ER 模型,以便理解实体及其属性之间的关系。一旦概念确立,开始实践。在下一节中,我们将向您展示如何在您的 MySQL 服务器上创建数据库,详见第四章。

使用实体关系模型

本节讨论创建 ER 模型并将其部署到数据库表所需的步骤。我们之前看到 MySQL Workbench 允许我们对现有数据库进行反向工程。但是,如何建模新数据库并部署它呢?我们可以使用 MySQL Workbench 工具自动化这个过程。

将实体和关系映射到数据库表

在将 ER 模型转换为数据库模式时,我们根据以下部分讨论的规则处理每个实体,然后处理每个关系,以最终得到一组数据库表。

将实体映射到数据库表

对于每个强实体,创建一个包含其属性的表,并指定主键。任何复合属性的部分也包括在此处。

对于每个弱实体,创建一个包含其属性的表,并包括其所属实体的主键。所属实体的主键在此处是外键,因为它不是本表的键,而是另一张表的键。弱实体的表主键是外键和弱实体部分键的组合。如果与所属实体的关系具有任何属性,请将它们添加到此表中。

对于每个实体的多值属性,创建一个包含实体主键和属性的表。

将关系映射到数据库表

两个实体之间的每个一对一关系在属于另一个实体的表中包含一个实体的主键作为外键。如果一个实体完全参与关系,请将外键放置在其表中。如果两者都完全参与关系,请考虑将它们合并成一个单一表。

对于两个实体之间的每个非标识一对多关系,在“1”侧的实体的主键作为外键包含在“N”侧实体的表中。在外键旁边的表中添加关系的任何属性。请注意,标识一对多关系(弱实体与其所属实体之间的关系)作为实体映射阶段的一部分进行捕捉。

对于两个实体之间的每个多对多关系,请创建一个新表,其中包含每个实体的主键作为主键,并添加关系的任何属性。此步骤有助于识别中间实体。

对于涉及多于两个实体的每个关系,创建一个包含所有参与实体的主键的表,并添加任何关系属性。

创建银行数据库 ER 模型

我们已经讨论了学生成绩和客户信息的数据库模型,以及三个适用于 MySQL 的开源 EER。现在让我们看看如何建模银行数据库。我们从利益相关者那里收集了所有必要信息,并为在线银行系统定义了我们的需求,我们决定需要以下实体:

  • 员工

  • 分支机构

  • 客户

  • 账户

现在,根据刚刚描述的映射规则,我们将为每个表创建表和属性。我们设定了主键,以确保每个表都有一个唯一的标识列用于其记录。接下来,我们需要定义表之间的关系。

多对多关系(N:M)

我们已经在分支和员工之间,以及账户和客户之间建立了这种类型的关系。员工可以在任意数量的分支工作,而一个分支可以拥有任意数量的员工。类似地,一个客户可以拥有多个账户,而一个账户可以是由两个以上客户持有的联合账户。

为了建模这些关系,我们需要两个更多的中间实体。我们如下创建它们:

  • account_customers

  • branch_employees

account_customers 和 branch_employees 实体将成为账户和客户实体以及分支和员工实体之间的桥梁。我们将 N:M 关系转换为两个 1:N 关系。你将在下一节看到设计的具体样子。

一对多关系(1:N)

分支和账户之间,以及客户和 account_customers 之间存在这种类型的关系。这引出了 非标识关系 的概念。例如,在 accounts 表中,branch_id 字段不是主键的一部分(原因之一是你可以将银行账户转移到另一个分支)。如今在每个表中保留一个代理键作为主键是很普遍的做法;因此,在数据模型中外键也是主键的真实标识关系比较罕见。

因为我们正在创建一个物理 EER 模型,我们也将定义主键。通常建议使用自增无符号字段作为主键。

图 2-17 展示了银行模型的最终表示。

lm2e 0217

图 2-17. bank 数据库的 EER 模型

请注意,有一些我们没有考虑到的项目。例如,我们的模型不支持客户拥有多个地址(比如工作地址和家庭地址)。我们有意为之,以突显在数据库部署之前收集需求的重要性。

你可以从本书的 GitHub 仓库 下载该模型。文件名为 bank_model.mwb

使用 Workbench 将 EER 转换为 MySQL 数据库

使用工具绘制 ER 图是个好主意;这样一来,你可以轻松编辑和重新定义它们,直到最终图表清晰明了。一旦你对模型感到满意,可以进行部署。MySQL Workbench 允许将 EER 模型转换为数据定义语言(DDL)语句,用以创建 MySQL 数据库,使用数据库菜单中的正向工程选项(图 2-18)。

lm2e 0218

图 2-18. 在 MySQL Workbench 中进行正向工程设计数据库

你需要输入凭据来连接数据库,之后 MySQL Workbench 将展示一些选项。对于这个模型,我们将使用标准选项,如图 2-19 所示,除了最后一个选项外都取消选择。

lm2e 0219

图 2-19. 数据库创建选项

接下来的屏幕将询问我们希望生成模型的哪些元素。因为我们没有特别的需求如触发器、存储过程、用户等,我们只会创建表对象及其关系;其余选项都未选中。

MySQL Workbench 将会展示给我们执行以创建数据库的 SQL 脚本,如图 2-20 所示。

lm2e 0220

图 2-20. 用于创建数据库的生成脚本

当我们点击“继续”时,MySQL Workbench 将在我们的 MySQL 服务器上执行这些语句,如图 2-21 所示。

我们在 “创建表格” 中详细解释了此脚本中的语句。

lm2e 0221

图 2-21. MySQL Workbench 开始运行该脚本

第三章:基本 SQL

如第二章所述,Edgar F. Codd 博士在 20 世纪 70 年代初提出了关系数据库模型及其规范形式。1974 年,IBM 旧金山实验室的研究人员开始了一个名为 System R 的旨在证明关系模型可行性的重大项目。同时,Donald Chamberlin 博士及其同事们也在努力定义一种数据库语言。他们开发了结构化英语查询语言(SEQUEL),允许用户使用明确定义的英语风格句子查询关系数据库。后来出于法律原因,这一语言被重命名为结构化查询语言(SQL)。

第一批基于 SQL 的数据库管理系统于 70 年代末商业化推出。随着围绕数据库语言开发活动的增加,标准化工作出现以简化事务,并最终社区统一了 SQL。美国和国际标准组织(ANSI 和 ISO)参与了标准化过程,并于 1986 年批准了第一个 SQL 标准。标准随后多次修订,版本名称(SQL:1999、SQL:2003、SQL:2008 等)表示对应年份发布的版本。我们将使用术语 SQL 标准标准 SQL 表示任何时间的当前 SQL 标准版本。

MySQL 扩展了标准 SQL,提供了额外的功能。例如,MySQL 实现了STRAIGHT_JOIN,这是其他 DBMS 不认可的语法。

本章介绍了 MySQL 的 SQL 实现,我们通常将其称为 CRUD 操作:createreadupdatedelete。我们将向您展示如何使用 SELECT 语句从数据库中读取数据,并选择要检索的数据以及显示顺序。我们还将向您展示如何使用 INSERT 语句添加数据,使用 UPDATE 修改数据,以及使用 DELETE 删除数据的基础知识。最后,我们将解释如何使用非标准的 SHOW TABLESSHOW COLUMNS 语句来探索您的数据库。

使用 sakila 数据库

如第二章所示,我们向您展示了如何使用 ER 模型构建数据库图的原则。我们还介绍了将 ER 模型转换为适合构建关系数据库的格式的步骤。本节将向您展示 MySQL sakila 数据库的结构,以便您开始熟悉不同的数据库关系模型。我们不会在这里解释用于创建数据库的 SQL 语句;这是第四章的主题。

如果您还没有导入数据库,请按照“实体关系建模示例”中的步骤执行该任务。

要选择 sakila 数据库作为我们当前的数据库,我们将使用 USE 语句。输入以下命令:

mysql> `USE` `sakila``;`
Database changed
mysql>

通过输入SELECT DATABASE();命令可以查看当前活动的数据库:

mysql> `SELECT` `DATABASE``(``)``;`
+------------+
| DATABASE() |
+------------+
| sakila     |
+------------+
1 row in set (0.00 sec)

现在,让我们使用SHOW TABLES语句探索构成sakila数据库的表格:

mysql> `SHOW` `TABLES``;`
+----------------------------+
| Tables_in_sakila           |
+----------------------------+
| actor                      |
| actor_info                 |
| ...                        |
| customer                   |
| customer_list              |
| film                       |
| film_actor                 |
| film_category              |
| film_list                  |
| film_text                  |
| inventory                  |
| language                   |
| nicer_but_slower_film_list |
| payment                    |
| rental                     |
| sales_by_film_category     |
| sales_by_store             |
| staff                      |
| staff_list                 |
| store                      |
+----------------------------+
23 rows in set (0.00 sec)

到目前为止,没有什么意外。让我们更多地了解构成sakila数据库的每个表。首先,让我们使用SHOW COLUMNS语句来探索actor表(请注意,输出已换行以适应页面边距):

mysql> `SHOW` `COLUMNS` `FROM` `actor``;`
+-------------+-------------------+------+-----+-------------------+...
| Field       | Type              | Null | Key | Default           |...
+-------------+-------------------+------+-----+-------------------+...
| actor_id    | smallint unsigned | NO   | PRI | NULL              |...
| first_name  | varchar(45)       | NO   |     | NULL              |...
| last_name   | varchar(45)       | NO   | MUL | NULL              |...
| last_update | timestamp         | NO   |     | CURRENT_TIMESTAMP |...
+-------------+-------------------+------+-----+-------------------+...
 ...+-----------------------------------------------+
 ...| Extra                                         |
 ...+-----------------------------------------------+
 ...| auto_increment                                |
 ...|                                               |
 ...|                                               |
 ...| DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
 ...+-----------------------------------------------+
4 rows in set (0.01 sec)

DESCRIBE关键字与SHOW COLUMNS FROM相同,我们可以缩写为DESC,因此可以将前一个查询写成如下形式:

mysql> `DESC` `actor``;`

生成的输出相同。让我们更仔细地查看表的结构。actor表包含四列,actor_idfirst_namelast_namelast_update。我们还可以提取列的类型:actor_idsmallintfirst_namelast_namevarchar(45)last_updatetimestamp。没有任何列接受NULL(空)值,actor_id是主键(PRI),last_name是非唯一索引的第一列(MUL)。不必担心细节;现在重要的是我们将在 SQL 命令中使用的列名。

接下来,通过执行DESC语句来探索city表格:

mysql> `DESC` `city``;`
+-------------+-------------------+------+-----+-------------------+...
| Field       | Type              | Null | Key | Default           |...
+-------------+-------------------+------+-----+-------------------+...
| city_id     | smallint unsigned | NO   | PRI | NULL              |...
| city        | varchar(50)       | NO   |     | NULL              |...
| country_id  | smallint unsigned | NO   | MUL | NULL              |...
| last_update | timestamp         | NO   |     | CURRENT_TIMESTAMP |...
+-------------+-------------------+------+-----+-------------------+...
...+-----------------------------------------------+
...| Extra                                         |
...+-----------------------------------------------+
...| auto_increment                                |
...|                                               |
...|                                               |
...| DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
...+-----------------------------------------------+
4 rows in set (0.01 sec)
注意

在“额外”列中看到的DEFAULT_GENERATED表示该特定列使用默认值。这是 MySQL 8.0 的特殊标注,不适用于 MySQL 5.7 或 MariaDB 10.5。

再次强调的是要熟悉每个表中的列名,因为我们在讨论查询时会频繁使用这些列。

下一节将向您展示如何探索 MySQL 存储在sakila数据库及其表中的数据。

SELECT语句与基本查询技术

前几章向您展示了如何安装和配置 MySQL,使用 MySQL 命令行,并介绍了 ER 模型。现在您已经准备好开始学习所有 MySQL 客户端都使用的 SQL 语言,用于探索和操作数据。本节介绍了最常用的 SQL 关键字:SELECT关键字。我们解释了风格和语法的基本要素以及WHERE子句、布尔运算符和排序的特性(这在我们后续讨论INSERTUPDATEDELETE时也大多适用)。这不是我们关于SELECT讨论的结束;在第五章中,我们将展示如何使用其高级功能。

单表查询

最基本的SELECT形式从表中读取所有行和列的数据。使用命令行连接到 MySQL 并选择sakila数据库:

mysql> `USE` `sakila``;`
Database changed

让我们检索language表中的所有数据:

mysql> `SELECT` `*` `FROM` `language``;`
+-------------+----------+---------------------+
| language_id | name     | last_update         |
+-------------+----------+---------------------+
|           1 | English  | 2006-02-15 05:02:19 |
|           2 | Italian  | 2006-02-15 05:02:19 |
|           3 | Japanese | 2006-02-15 05:02:19 |
|           4 | Mandarin | 2006-02-15 05:02:19 |
|           5 | French   | 2006-02-15 05:02:19 |
|           6 | German   | 2006-02-15 05:02:19 |
+-------------+----------+---------------------+
6 rows in set (0.00 sec)

输出有六行,每行包含表中所有列的值。我们现在知道有六种语言,可以看到语言、它们的标识符以及每种语言的最后更新时间。

简单的SELECT语句有四个组成部分:

  1. 关键字SELECT

  2. 要显示的列。星号(*)符号是一个通配符,表示所有列。

  3. 关键词FROM

  4. 表名。

在这个例子中,我们要求从language表中获取所有列,这就是 MySQL 返回给我们的结果。

让我们再试一个简单的SELECT。这次,我们将从city表中检索所有列:

mysql> `SELECT` `*` `FROM` `city``;`
+---------+------------------------+------------+---------------------+
| city_id | city                   | country_id | last_update         |
+---------+------------------------+------------+---------------------+
|       1 | A Corua (La Corua)     |         87 | 2006-02-15 04:45:25 |
|       2 | Abha                   |         82 | 2006-02-15 04:45:25 |
|       3 | Abu Dhabi              |        101 | 2006-02-15 04:45:25 |
|     ...                                                             |
|     599 | Zhoushan               |         23 | 2006-02-15 04:45:25 |
|     600 | Ziguinchor             |         83 | 2006-02-15 04:45:25 |
+---------+------------------------+------------+---------------------+
600 rows in set (0.00 sec)

有 600 个城市,输出与我们第一个例子的基本结构相同。

此示例提供了关于表之间关系如何工作的一些见解。考虑结果的第一行。在country_id列中,您将看到值 87。正如稍后将看到的那样,我们可以检查country表,找出代码为 87 的国家是西班牙。我们将在“连接两个表”中讨论如何编写关于表之间关系的查询。

如果查看完整的输出,您还会看到多个具有相同country_id的不同城市。重复的country_id值不是问题,因为我们期望一个国家有多个城市(一对多关系)。

现在您应该能够选择数据库,列出其表,并使用SELECT语句从表中检索所有数据。为了练习,您可能想尝试在sakila数据库中的其他表上进行实验。请记住,您可以使用SHOW TABLES语句查找表名。

选择列

早些时候,我们使用*通配符字符检索表中的所有列。如果您不想显示所有列,可以通过按照您想要的顺序以逗号分隔列出所需的列来更具体地操作。例如,如果您只想从city表中获取city列,您可以输入:

mysql> `SELECT` `city` `FROM` `city``;`
+--------------------+
| city               |
+--------------------+
| A Corua (La Corua) |
| Abha               |
| Abu Dhabi          |
| Acua               |
| Adana              |
+--------------------+
5 rows in set (0.00 sec)

如果您想要citycity_id两列,那么您将按照顺序使用:

mysql> `SELECT` `city``,` `city_id` `FROM` `city``;`
+--------------------+---------+
| city               | city_id |
+--------------------+---------+
| A Corua (La Corua) |       1 |
| Abha               |       2 |
| Abu Dhabi          |       3 |
| Acua               |       4 |
| Adana              |       5 |
+--------------------+---------+
5 rows in set (0.01 sec)

您甚至可以多次列出列:

mysql> `SELECT` `city``,` `city` `FROM` `city``;`
+--------------------+--------------------+
| city               | city               |
+--------------------+--------------------+
| A Corua (La Corua) | A Corua (La Corua) |
| Abha               | Abha               |
| Abu Dhabi          | Abu Dhabi          |
| Acua               | Acua               |
| Adana              | Adana              |
+--------------------+--------------------+
5 rows in set (0.00 sec)

尽管这看起来可能毫无意义,但结合在更高级查询中使用别名时,它可以很有用,正如您将在第五章中看到的那样。

您可以在SELECT语句中指定数据库、表和列名。这样可以避免使用USE命令,并直接在任何数据库和表上使用SELECT;它还有助于解决歧义,正如我们将在“连接两个表”中展示的那样。例如,假设您想从sakila数据库的language表中检索name列。您可以使用以下命令实现:

mysql> `SELECT` `name` `FROM` `sakila``.``language``;`
+----------+
| name     |
+----------+
| English  |
| Italian  |
| Japanese |
| Mandarin |
| French   |
| German   |
+----------+
6 rows in set (0.01 sec)

FROM关键字后面的sakila.language部分指定了sakila数据库及其language表。在运行此查询之前,无需输入USE sakila;命令。此语法也可用于其他 SQL 语句,包括我们稍后在本章讨论的UPDATEDELETEINSERTSHOW语句。

使用 WHERE 子句选择行

本节介绍 WHERE 子句,并解释如何使用运算符编写表达式。你将在 SELECT 语句以及其他语句如 UPDATEDELETE 中看到这些内容;我们稍后在本章中会展示示例。

WHERE 基础

WHERE 子句是一个强大的工具,允许你从 SELECT 语句中过滤返回哪些行。你可以使用它来返回满足条件的行,比如具有完全匹配字符串的列值,大于或小于某个值的数字,或者是一个字符串是另一个字符串的前缀。几乎本章和后续章节的所有示例都包含 WHERE 子句,你会非常熟悉它们。

最简单的 WHERE 子句是完全匹配一个值的子句。考虑一个例子,你想查找 language 表中英语语言的详细信息。你会输入:

mysql> `SELECT` `*` `FROM` `sakila``.``language` `WHERE` `name` `=` `'English'``;`
+-------------+---------+---------------------+
| language_id | name    | last_update         |
+-------------+---------+---------------------+
|           1 | English | 2006-02-15 05:02:19 |
+-------------+---------+---------------------+
1 row in set (0.00 sec)

MySQL 返回所有匹配你搜索条件的行,本例中只返回一个行及其所有列。

再试一个精确匹配的例子。假设你想找出 actor 表中 actor_id 值为 4 的演员的名字。你会输入:

mysql> `SELECT` `first_name` `FROM` `actor` `WHERE` `actor_id` `=` `4``;`
+------------+
| first_name |
+------------+
| JENNIFER   |
+------------+
1 row in set (0.00 sec)

在这里,你提供了一个列和一行,包括在 SELECT 关键字之后的列 first_name 并指定 WHERE actor_id = 4

如果一个值匹配多个行,结果将包含所有匹配项。假设你想查看所有属于 country_id 为 15 的巴西的城市,你会输入:

mysql> `SELECT` `city` `FROM` `city` `WHERE` `country_id` `=` `15``;`
+----------------------+
| city                 |
+----------------------+
| Alvorada             |
| Angra dos Reis       |
| Anpolis              |
| Aparecida de Goinia  |
| Araatuba             |
| Bag                  |
| Belm                 |
| Blumenau             |
| Boa Vista            |
| Braslia              |               |
| ...                  |
+----------------------+
28 rows in set (0.00 sec)

结果显示属于巴西的 28 个城市的名称。如果我们能将从 city 表获取的信息与从 country 表获取的信息连接起来,我们就能显示城市名称及其对应的国家。我们将在 “连接两个表” 中看到如何执行此类查询。

现在让我们检索属于范围的值。对于数值范围,检索多个值很简单,所以让我们从查找所有 city_id 小于 5 的城市名称开始。要执行此操作,请执行以下语句:

mysql> `SELECT` `city` `FROM` `city` `WHERE` `city_id` `<` `5``;`
+--------------------+
| city               |
+--------------------+
| A Corua (La Corua) |
| Abha               |
| Abu Dhabi          |
| Acua               |
+--------------------+
4 rows in set (0.00 sec)

对于数字,经常使用的运算符包括等于 (=),大于 (>),小于 (<),小于或等于 (<=),大于或等于 (>=),不等于 (<>!=)。

再考虑一个例子。如果你想找出所有没有 language_id 为 2 的语言,你会输入:

mysql> `SELECT` `language_id``,` `name` `FROM` `sakila``.``language`
    -> `WHERE` `language_id` `<``>` `2``;`
+-------------+----------+
| language_id | name     |
+-------------+----------+
|           1 | English  |
|           3 | Japanese |
|           4 | Mandarin |
|           5 | French   |
|           6 | German   |
+-------------+----------+
5 rows in set (0.00 sec)

前面的输出显示了表中的第一个、第三个和所有后续语言。请注意,你可以为 不等于 条件使用 <>!= 运算符。

对于字符串,你可以使用相同的运算符。默认情况下,字符串比较不区分大小写,并使用当前字符集。例如:

mysql> `SELECT` `first_name` `FROM` `actor` `WHERE` `first_name` `<` `'B'``;`
+------------+
| first_name |
+------------+
| ALEC       |
| AUDREY     |
| ANNE       |
| ANGELA     |
| ADAM       |
| ANGELINA   |
| ALBERT     |
| ADAM       |
| ANGELA     |
| ALBERT     |
| AL         |
| ALAN       |
| AUDREY     |
+------------+
13 rows in set (0.00 sec)

“不区分大小写” 意味着 Bb 将被视为相同的过滤器,因此此查询将提供相同的结果:

mysql> `SELECT` `first_name` `FROM` `actor` `WHERE` `first_name` `<` `'b'``;`
+------------+
| first_name |
+------------+
| ALEC       |
| AUDREY     |
| ANNE       |
| ANGELA     |
| ADAM       |
| ANGELINA   |
| ALBERT     |
| ADAM       |
| ANGELA     |
| ALBERT     |
| AL         |
| ALAN       |
| AUDREY     |
+------------+
13 rows in set (0.00 sec)

另一个常见的字符串任务是查找以前缀开头、包含字符串或以后缀结尾的匹配项。例如,我们可能想要查找所有以词组 “Retro” 开头的专辑名称。我们可以在 WHERE 子句中使用 LIKE 运算符来实现这一点。让我们看一个例子,我们正在搜索标题包含单词 family 的电影:

mysql> `SELECT` `title` `FROM` `film` `WHERE` `title` `LIKE` `'%family%'``;`
+----------------+
| title          |
+----------------+
| CYCLONE FAMILY |
| DOGMA FAMILY   |
| FAMILY SWEET   |
+----------------+
3 rows in set (0.00 sec)

让我们看看这是如何工作的。LIKE 子句用于字符串,并表示匹配必须符合后面的字符串中的模式。在我们的示例中,我们使用了 LIKE '%family%',这意味着字符串必须包含 family,并且可以在前后包含零个或多个字符。大多数与 LIKE 结合使用的字符串包含百分号字符 (%) 作为通配符字符,可以匹配所有可能的字符串。你可以用它来定义以后缀结尾的字符串,比如 "%ing",或者以特定子串开头的字符串,比如 "Corruption%"

例如,"John%" 将匹配所有以 John 开头的字符串,如 John SmithJohn Paul Getty。模式 "%Paul" 匹配所有以 Paul 结尾的字符串。最后,模式 "%Paul%" 匹配所有包含 Paul 的字符串,包括在开头或结尾。

如果你想在 LIKE 子句中匹配恰好一个通配符字符,你可以使用下划线 (_) 字符。例如,如果你想要所有以 NAT 开头的演员名字的电影标题,你可以使用:

mysql> `SELECT` `title` `FROM` `film_list` `WHERE` `actors` `LIKE` `'NAT_%'``;`
+----------------------+
| title                |
+----------------------+
| FANTASY TROOPERS     |
| FOOL MOCKINGBIRD     |
| HOLES BRANNIGAN      |
| KWAI HOMEWARD        |
| LICENSE WEEKEND      |
| NETWORK PEAK         |
| NUTS TIES            |
| TWISTED PIRATES      |
| UNFORGIVEN ZOOLANDER |
+----------------------+
9 rows in set (0.04 sec)
提示

通常,你应该避免在模式的开头使用百分号 (%) 通配符,就像以下例子中一样:

mysql> `SELECT` `title` `FROM` `film` `WHERE` `title` `LIKE` `'%day%'``;`

你将得到结果,但 MySQL 不会在此条件下使用索引。使用通配符将强制 MySQL 读取整个表以检索结果,如果表有数百万行,这可能会严重影响性能。

使用 AND、OR、NOT 和 XOR 结合条件

到目前为止,我们使用 WHERE 子句来测试一个条件,返回所有满足条件的行。你可以使用布尔运算符 ANDORNOTXOR 来组合两个或更多条件。

让我们从一个例子开始。假设你想找到评级为 PG 的科幻电影的标题。这在使用 AND 运算符时非常简单:

mysql> `SELECT` `title` `FROM` `film_list` `WHERE` `category` `LIKE` `'Sci-Fi'`
    -> `AND` `rating` `LIKE` `'PG'``;`
+----------------------+
| title                |
+----------------------+
| CHAINSAW UPTOWN      |
| CHARADE DUFFEL       |
| FRISCO FORREST       |
| GOODFELLAS SALUTE    |
| GRAFFITI LOVE        |
| MOURNING PURPLE      |
| OPEN AFRICAN         |
| SILVERADO GOLDFINGER |
| TITANS JERK          |
| TROJAN TOMORROW      |
| UNFORGIVEN ZOOLANDER |
| WONDERLAND CHRISTMAS |
+----------------------+
12 rows in set (0.07 sec)

WHERE 子句中的 AND 操作将结果限制为满足两个条件的行。

OR 操作符用于查找满足多个条件之一的行。例如,现在想象一下你想要一个儿童或家庭类别电影的列表。你可以使用 OR 和两个 LIKE 子句来实现这一点:

mysql> `SELECT` `title` `FROM` `film_list` `WHERE` `category` `LIKE` `'Children'`
    -> `OR` `category` `LIKE` `'Family'``;`
+------------------------+
| title                  |
+------------------------+
| AFRICAN EGG            |
| APACHE DIVINE          |
| ATLANTIS CAUSE         |
...
| WRONG BEHAVIOR         |
| ZOOLANDER FICTION      |
+------------------------+
129 rows in set (0.04 sec)

WHERE 子句中的 OR 操作将答案限制为满足两个条件之一的答案。顺便说一句,我们可以观察到结果是有序的。这只是一个巧合;在这种情况下,它们按照添加到数据库中的顺序报告。我们将在 “排序子句” 返回到排序输出。

你可以结合使用ANDOR,但需要明确首先AND条件还是OR条件。括号可以将语句的部分聚集在一起,并帮助使表达式更易读;你可以像在基本数学中一样使用它们。假设现在你想要获取评级为 PG 的科幻或家庭电影。你可以将此查询写成以下形式:

mysql> `SELECT` `title` `FROM` `film_list` `WHERE` `(``category` `like` `'Sci-Fi'`
    -> `OR` `category` `LIKE` `'Family'``)` `AND` `rating` `LIKE` `'PG'``;`
+------------------------+
| title                  |
+------------------------+
| BEDAZZLED MARRIED      |
| CHAINSAW UPTOWN        |
| CHARADE DUFFEL         |
| CHASING FIGHT          |
| EFFECT GLADIATOR       |
...
| UNFORGIVEN ZOOLANDER   |
| WONDERLAND CHRISTMAS   |
+------------------------+
30 rows in set (0.07 sec)

括号清楚地表明了评估顺序:你希望获取来自科幻或家庭类别的电影,但它们都需要是 PG 级别的。

使用括号可以改变评估顺序。最简单的方法是通过计算进行一些操作:

mysql> `SELECT` `(``2``+``2``)``*``3``;`
+---------+
| (2+2)*3 |
+---------+
|      12 |
+---------+
1 row in set (0.00 sec)
mysql> `SELECT` `2``+``2``*``3``;`
+-------+
| 2+2*3 |
+-------+
|     8 |
+-------+
1 row in set (0.00 sec)
注意

最难诊断的问题之一是运行没有语法错误但返回与预期值不同的查询。尽管括号不影响AND运算符,但OR运算符受其影响较大。例如,考虑以下语句的结果:

mysql> `SELECT` `*` `FROM` `sakila``.``city` `WHERE` `city_id` `=` `3`
    -> `OR` `city_id` `=` `4` `AND` `country_id` `=` `60``;`
+---------+-----------+------------+---------------------+
| city_id | city      | country_id | last_update         |
+---------+-----------+------------+---------------------+
|       3 | Abu Dhabi |        101 | 2006-02-15 04:45:25 |
|       4 | Acua      |         60 | 2006-02-15 04:45:25 |
+---------+-----------+------------+---------------------+
2 rows in set (0.00 sec)

如果改变运算符的顺序,将得到不同的结果:

mysql> `SELECT` `*` `FROM` `sakila``.``city` `WHERE` `country_id` `=` `60`
    -> `AND` `city_id` `=` `3` `OR` `city_id` `=` `4``;`
+---------+------+------------+---------------------+
| city_id | city | country_id | last_update         |
+---------+------+------------+---------------------+
|       4 | Acua |         60 | 2006-02-15 04:45:25 |
+---------+------+------------+---------------------+
1 row in set (0.00 sec)

使用括号使查询更易于理解,并增加了获得预期结果的可能性。我们建议在 MySQL 可能误解意图的情况下使用括号;依赖 MySQL 的隐式评估顺序没有充分的理由。

一元NOT运算符否定布尔语句。前面我们举了一个列出所有language_id不等于 2 的语言的例子。你也可以用NOT运算符编写此查询:

mysql> `SELECT` `language_id``,` `name` `FROM` `sakila``.``language`
    -> `WHERE` `NOT` `(``language_id` `=` `2``)``;`
+-------------+----------+
| language_id | name     |
+-------------+----------+
|           1 | English  |
|           3 | Japanese |
|           4 | Mandarin |
|           5 | French   |
|           6 | German   |
+-------------+----------+
5 rows in set (0.01 sec)

括号中的表达式(language_id = 2)给出匹配条件,并且NOT操作对其取反,因此你会得到除符合条件之外的所有结果。有几种其他方式可以编写带有相同思想的WHERE子句。在第五章中,你会看到其中一些具有比其他更好的性能。

再举一个使用NOT和括号的例子。假设你想获取所有FID小于 7 但不为 4 或 6 的电影标题列表,可以使用以下查询语句实现:

mysql> `SELECT` `fid``,``title` `FROM` `film_list` `WHERE` `FID` `<` `7` `AND` `NOT` `(``FID` `=` `4` `OR` `FID` `=` `6``)``;`
+------+------------------+
| fid  | title            |
+------+------------------+
|    1 | ACADEMY DINOSAUR |
|    2 | ACE GOLDFINGER   |
|    3 | ADAPTATION HOLES |
|    5 | AFRICAN EGG      |
+------+------------------+
4 rows in set (0.06 sec)

理解运算符优先级可能有些棘手,有时候 DBA 需要很长时间来调试查询并确定为何没有返回请求的值。以下列表显示了按优先级从高到低排列的可用运算符。同一行显示在一起的运算符具有相同的优先级:

  • INTERVAL

  • BINARY, COLLATE

  • !

  • -(一元减),~(一元位反转)

  • ^

  • *, /, DIV, %, MOD

  • -,+

  • <<, >>

  • &

  • \|

  • =(比较),<=>>=><=<<>!=ISLIKEREGEXPINMEMBER OF

  • BETWEEN, CASE, WHEN, THEN, ELSE

  • NOT

  • AND, &&

  • XOR

  • OR, \|\|

  • =(赋值),:=

可以以多种方式组合这些运算符以获得所需的结果。例如,您可以编写查询来获取价格介于 $2 和 $4 之间,并属于纪录片或恐怖类别,且演员名为 Bob 的任何电影的标题:

mysql> `SELECT` `title`
    -> `FROM` `film_list`
    -> `WHERE` `price` `BETWEEN` `2` `AND` `4`
    -> `AND` `(``category` `LIKE` `'Documentary'` `OR` `category` `LIKE` `'Horror'``)`
    -> `AND` `actors` `LIKE` `'%BOB%'``;`
+------------------+
| title            |
+------------------+
| ADAPTATION HOLES |
+------------------+
1 row in set (0.08 sec)

最后,在我们继续排序之前,请注意,可以执行不匹配任何结果的查询。在这种情况下,查询将返回一个空集:

mysql> `SELECT` `title` `FROM` `film_list`
    -> `WHERE` `price` `BETWEEN` `2` `AND` `4`
    -> `AND` `(``category` `LIKE` `'Documentary'` `OR` `category` `LIKE` `'Horror'``)`
    -> `AND` `actors` `LIKE` `'%GRIPPA%'``;`

Empty set (0.04 sec)

ORDER BY 子句

我们已经讨论了如何选择列和返回作为查询结果的行,但没有讨论如何控制结果的显示方式。在关系型数据库中,表中的行形成一个集合;行之间没有固有的顺序,因此如果要按特定顺序返回结果,必须要求 MySQL 对其进行排序。本节将解释如何使用 ORDER BY 子句来实现这一点。排序不影响返回的内容,只影响返回结果的顺序

提示

MySQL 中的 InnoDB 表有一个称为聚集索引的特殊索引,用于存储行数据。当您在表上定义主键时,InnoDB 将其用作聚集索引。假设您基于主键执行查询。那么返回的行将按主键升序排序。但是,如果您希望强制执行特定顺序,我们始终建议使用 ORDER BY 子句。

假设您想要返回 sakila 数据库中按 name 字母顺序排序的前 10 位客户列表。这是您要输入的内容:

mysql> `SELECT` `name` `FROM` `customer_list`
    -> `ORDER` `BY` `name`
    -> `LIMIT` `10``;`
+-------------------+
| name              |
+-------------------+
| AARON SELBY       |
| ADAM GOOCH        |
| ADRIAN CLARY      |
| AGNES BISHOP      |
| ALAN KAHN         |
| ALBERT CROUSE     |
| ALBERTO HENNING   |
| ALEX GRESHAM      |
| ALEXANDER FENNELL |
| ALFRED CASILLAS   |
+-------------------+
10 rows in set (0.01 sec)

ORDER BY 子句表示需要排序,后跟应用作排序键的列。在这个示例中,按照字母顺序升序排列名字——默认排序不区分大小写且升序,MySQL 会自动按字母顺序排序,因为列是字符字符串。字符串的排序方式由使用的字符集和排序顺序确定。我们在“排序和字符集”中讨论这些内容。在本书的大部分内容中,我们假设您使用默认设置。

让我们看另一个例子。这一次,您将按 address 表中 last_update 列的升序排列输出,并仅显示前五个结果:

mysql> `SELECT` `address``,` `last_update` `FROM` `address`
    -> `ORDER` `BY` `last_update` `LIMIT` `5``;`
+-----------------------------+---------------------+
| address                     | last_update         |
+-----------------------------+---------------------+
| 1168 Najafabad Parkway      | 2014-09-25 22:29:59 |
| 1031 Daugavpils Parkway     | 2014-09-25 22:29:59 |
| 1924 Shimonoseki Drive      | 2014-09-25 22:29:59 |
| 757 Rustenburg Avenue       | 2014-09-25 22:30:01 |
| 1892 Nabereznyje Telny Lane | 2014-09-25 22:30:02 |
+-----------------------------+---------------------+
5 rows in set (0.00 sec)

如您所见,可以对不同类型的列进行排序。此外,我们可以将排序与两个或更多列结合起来。例如,假设您要按字母顺序对地址进行排序,但按区分组:

mysql> `SELECT` `address``,` `district` `FROM` `address`
    -> `ORDER` `BY` `district``,` `address``;`
+----------------------------------------+----------------------+
| address                                | district             |
+----------------------------------------+----------------------+
| 1368 Maracabo Boulevard                |                      |
| 18 Duisburg Boulevard                  |                      |
| 962 Tama Loop                          |                      |
| 535 Ahmadnagar Manor                   | Abu Dhabi            |
| 669 Firozabad Loop                     | Abu Dhabi            |
| 1078 Stara Zagora Drive                | Aceh                 |
| 663 Baha Blanca Parkway                | Adana                |
| 842 Salzburg Lane                      | Adana                |
| 614 Pak Kret Street                    | Addis Abeba          |
| 751 Lima Loop                          | Aden                 |
| 1157 Nyeri Loop                        | Adygea               |
| 387 Mwene-Ditu Drive                   | Ahal                 |
| 775 ostka Drive                        | al-Daqahliya         |
| ...                                                           |
| 1416 San Juan Bautista Tuxtepec Avenue | Zufar                |
| 138 Caracas Boulevard                  | Zulia                |
+----------------------------------------+----------------------+
603 rows in set (0.00 sec)

您还可以按降序排序,并且可以针对每个排序键控制此行为。假设您希望按字母逆序对地址进行排序,并按区升序进行排序。您将输入以下内容:

mysql> `SELECT` `address``,``district` `FROM` `address`
    -> `ORDER` `BY` `district` `ASC``,` `address` `DESC`
    -> `LIMIT` `10``;`
+-------------------------+-------------+
| address                 | district    |
+-------------------------+-------------+
| 962 Tama Loop           |             |
| 18 Duisburg Boulevard   |             |
| 1368 Maracabo Boulevard |             |
| 669 Firozabad Loop      | Abu Dhabi   |
| 535 Ahmadnagar Manor    | Abu Dhabi   |
| 1078 Stara Zagora Drive | Aceh        |
| 842 Salzburg Lane       | Adana       |
| 663 Baha Blanca Parkway | Adana       |
| 614 Pak Kret Street     | Addis Abeba |
| 751 Lima Loop           | Aden        |
+-------------------------+-------------+
10 rows in set (0.01 sec)

如果发生值的冲突,并且您没有指定另一个排序键,那么排序顺序是未定义的。这对您可能并不重要;您可能不关心两个姓名相同的客户“John A. Smith”出现的顺序。如果您希望在这种情况下强制执行特定顺序,您需要向ORDER BY子句添加更多列,如前面的示例所示。

LIMIT子句

正如你可能注意到的,前几个查询使用了LIMIT子句。这是一个有用的非标准 SQL 语句,允许你控制输出多少行。它的基本形式允许你限制从SELECT语句返回的行数,在你想要限制通过网络通信或输出到屏幕的数据量时非常有用。例如,你可以使用它从表中获取数据样本,如下所示:

mysql> `SELECT` `name` `FROM` `customer_list` `LIMIT` `10``;`
+------------------+
| name             |
+------------------+
| VERA MCCOY       |
| MARIO CHEATHAM   |
| JUDY GRAY        |
| JUNE CARROLL     |
| ANTHONY SCHWAB   |
| CLAUDE HERZOG    |
| MARTIN BALES     |
| BOBBY BOUDREAU   |
| WILLIE MARKHAM   |
| JORDAN ARCHULETA |
+------------------+

LIMIT子句可以有两个参数。在这种情况下,第一个参数指定要返回的第一行,第二个参数指定要返回的最大行数。第一个参数被称为偏移量。假设你想要五行,但是你想跳过前五行,这意味着结果将从第六行开始。LIMIT的记录偏移从 0 开始,所以你可以这样做:

mysql> `SELECT` `name` `FROM` `customer_list` `LIMIT` `5``,` `5``;`
+------------------+
| name             |
+------------------+
| CLAUDE HERZOG    |
| MARTIN BALES     |
| BOBBY BOUDREAU   |
| WILLIE MARKHAM   |
| JORDAN ARCHULETA |
+------------------+
5 rows in set (0.00 sec)

输出是SELECT查询的第 6 到第 10 行。

还有一个您可能看到的LIMIT关键字的替代语法:而不是写LIMIT 10, 5,你可以写LIMIT 10 OFFSET 5OFFSET语法丢弃其中指定的N值。

这是一个没有偏移量的例子:

mysql> `SELECT` `id``,` `name` `FROM` `customer_list`
    -> `ORDER` `BY` `id` `LIMIT` `10``;`
+----+------------------+
| ID | name             |
+----+------------------+
|  1 | MARY SMITH       |
|  2 | PATRICIA JOHNSON |
|  3 | LINDA WILLIAMS   |
|  4 | BARBARA JONES    |
|  5 | ELIZABETH BROWN  |
|  6 | JENNIFER DAVIS   |
|  7 | MARIA MILLER     |
|  8 | SUSAN WILSON     |
|  9 | MARGARET MOORE   |
| 10 | DOROTHY TAYLOR   |
+----+------------------+
10 rows in set (0.00 sec)

这是一个偏移量为 5 的结果:

mysql> `SELECT` `id``,` `name` `FROM` `customer_list`
    -> `ORDER` `BY` `id` `LIMIT` `10` `OFFSET` `5``;`
+----+----------------+
| ID | name           |
+----+----------------+
|  6 | JENNIFER DAVIS |
|  7 | MARIA MILLER   |
|  8 | SUSAN WILSON   |
|  9 | MARGARET MOORE |
| 10 | DOROTHY TAYLOR |
| 11 | LISA ANDERSON  |
| 12 | NANCY THOMAS   |
| 13 | KAREN JACKSON  |
| 14 | BETTY WHITE    |
| 15 | HELEN HARRIS   |
+----+----------------+
10 rows in set (0.01 sec)

连接两个表

到目前为止,我们在SELECT查询中只使用了一个表。然而,大多数情况下需要从多个表中获取信息。当我们探索sakila数据库中的表时,通过使用关系,可以回答更有趣的查询变得显而易见。例如,了解每个城市所在的国家将是有用的。本节将向您展示如何通过连接两个表来回答这类查询。我们将在第五章中作为更长、更高级讨论的一部分返回这个问题。

在本章中,我们只使用了一种连接语法。还有两种更多(LEFT JOINRIGHT JOIN),每一种都可以让您以不同的方式将两个或更多表的数据组合在一起。我们在这里使用的语法是INNER JOIN,它是日常活动中最常用的。让我们看一个例子,然后我们将更详细地解释它是如何工作的:

mysql> `SELECT` `city``,` `country` `FROM` `city` `INNER` `JOIN` `country`
    -> `ON` `city``.``country_id` `=` `country``.``country_id`
    -> `WHERE` `country``.``country_id` `<` `5`
    -> `ORDER` `BY` `country``,` `city``;`
+----------+----------------+
| city     | country        |
+----------+----------------+
| Kabul    | Afghanistan    |
| Batna    | Algeria        |
| Bchar    | Algeria        |
| Skikda   | Algeria        |
| Tafuna   | American Samoa |
| Benguela | Angola         |
| Namibe   | Angola         |
+----------+----------------+
7 rows in set (0.00 sec)

输出显示了每个国家中country_id低于 5 的城市。你可以首次看到每个国家中有哪些城市。

INNER JOIN如何工作?该语句由两部分组成:首先是由INNER JOIN关键字分隔的两个表名;其次是ON关键字,指定组成条件的必需列。在这个例子中,要连接的两个表是citycountry,表示为city INNER JOIN country(对于基本的INNER JOIN,表的列名顺序无关紧要,因此使用country INNER JOIN city效果相同)。ON子句(ON city.country_id = country.country_id)告诉 MySQL 两个表之间的关系列;您应该从我们的设计中回忆这一点,以及我们在第二章中的先前讨论。

如果在连接条件中用于匹配的两个表中的列名相同,则可以改用USING子句:

mysql> `SELECT` `city``,` `country` `FROM` `city`
    -> `INNER` `JOIN` `country` `using` `(``country_id``)`
    -> `WHERE` `country``.``country_id` `<` `5`
    -> `ORDER` `BY` `country``,` `city``;`
+----------+----------------+
| city     | country        |
+----------+----------------+
| Kabul    | Afghanistan    |
| Batna    | Algeria        |
| Bchar    | Algeria        |
| Skikda   | Algeria        |
| Tafuna   | American Samoa |
| Benguela | Angola         |
| Namibe   | Angola         |
+----------+----------------+
7 rows in set (0.01 sec)

图 3-1 中的维恩图展示了内连接。

在我们离开SELECT之前,我们将让您尝试一个聚合值函数的例子。假设您想计算我们数据库中意大利城市的数量。您可以通过连接两个表并计算具有该country_id的行数来实现此目的。以下是它的工作原理:

mysql> `SELECT` `COUNT``(``1``)` `FROM` `city` `INNER` `JOIN` `country`
    -> `ON` `city``.``country_id` `=` `country``.``country_id`
    -> `WHERE` `country``.``country_id` `=` `49`
    -> `ORDER` `BY` `country``,` `city``;`
+----------+
| count(1) |
+----------+
|        7 |
+----------+
1 row in set (0.00 sec)

我们在第五章中详细解释了SELECT和聚合函数的更多特性。关于COUNT()函数的更多信息,请参见“聚合函数”。

lm2e 0301

图 3-1. 内连接的维恩图表示

插入语句

INSERT语句用于向表中添加新数据。本节介绍了其基本语法,并通过一些简单示例演示了如何向sakila数据库添加新行。在第四章中,我们将讨论如何从现有表或外部数据源加载数据。

插入基础

数据插入通常出现在两种情况下:在创建数据库时批量加载时和在使用数据库时按需添加数据时。在 MySQL 中,服务器内置了针对每种情况的不同优化。重要的是,不同的 SQL 语法可用于使您在这两种情况下轻松地与服务器进行交互。我们将在本节中解释基本的INSERT语法,并展示如何在批量和单记录插入中使用示例。

让我们从向language表中插入一个新行的基本任务开始。要做到这一点,您需要了解表的结构。正如我们在“使用 sakila 数据库”中解释的那样,您可以使用SHOW COLUMNS语句发现这一点:

mysql> `SHOW` `COLUMNS` `FROM` `language``;`
+-------------+-------------------+------+-----+-------------------+...
| Field       | Type             | Null | Key | Default            |...
+-------------+-------------------+------+-----+-------------------+...
| language_id | tinyint unsigned | NO   | PRI | NULL               |...
| name        | char(20)         | NO   |     | NULL               |...
| last_update | timestamp        | NO   |     | CURRENT_TIMESTAMP  |...
+-------------+-------------------+------+-----+-------------------+...
...+-----------------------------------------------+
...| Extra                                         |
...+-----------------------------------------------+
...| auto_increment                                |
...|                                               |
...| DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
...+-----------------------------------------------+
3 rows in set (0.00 sec)

这告诉你language_id列是自动生成的,并且last_update列在每次UPDATE操作发生时都会更新。您将在第四章中了解更多关于AUTO_INCREMENT快捷方式的信息,以自动分配下一个可用的标识符。

让我们为葡萄牙语添加一行新记录。有两种方法可以做到这一点。最常见的方法是让 MySQL 填写language_id的默认值,如下所示:

mysql> `INSERT` `INTO` `language` `VALUES` `(``NULL``,` `'Portuguese'``,` `NOW``(``)``)``;`
Query OK, 1 row affected (0.10 sec)

如果我们现在对表执行SELECT,我们将看到 MySQL 已经插入了这一行:

mysql> `SELECT` `*` `FROM` `language``;`
+-------------+------------+---------------------+
| language_id | name       | last_update         |
+-------------+------------+---------------------+
|           1 | English    | 2006-02-15 05:02:19 |
|           2 | Italian    | 2006-02-15 05:02:19 |
|           3 | Japanese   | 2006-02-15 05:02:19 |
|           4 | Mandarin   | 2006-02-15 05:02:19 |
|           5 | French     | 2006-02-15 05:02:19 |
|           6 | German     | 2006-02-15 05:02:19 |
|           7 | Portuguese | 2020-09-26 09:11:36 |
+-------------+------------+---------------------+
7 rows in set (0.00 sec)

请注意,我们在last_update列中使用了函数NOW()NOW()函数返回 MySQL 服务器当前的日期和时间。

第二种选择是手动插入language_id列的值。现在我们已经有了七种语言,我们应该为language_id的下一个值使用 8。我们可以用以下 SQL 指令来验证:

mysql> `SELECT` `MAX``(``language_id``)` `FROM` `language``;`
+------------------+
| max(language_id) |
+------------------+
|                7 |
+------------------+
1 row in set (0.00 sec)

MAX()函数告诉您作为参数提供的列的最大值。这比使用SELECT language_id FROM language更清晰,后者会打印出所有行并要求您检查它们以找到最大值。添加ORDER BYLIMIT子句可以使这个过程更简单,但使用MAX()SELECT language_id FROM language ORDER BY language_id DESC LIMIT 1简单得多,后者返回相同的答案。

现在我们可以插入这一行了。在这个INSERT中,我们也将手动插入last_update的值。以下是所需的命令:

mysql> `INSERT` `INTO` `language` `VALUES` `(``8``,` `'Russian'``,` `'2020-09-26 10:35:00'``)``;`
Query OK, 1 row affected (0.02 sec)

MySQL 报告已影响了一行(在这种情况下是添加),我们可以通过再次检查表的内容来确认:

mysql> `SELECT` `*` `FROM` `language``;`
+-------------+------------+---------------------+
| language_id | name       | last_update         |
+-------------+------------+---------------------+
|           1 | English    | 2006-02-15 05:02:19 |
|           2 | Italian    | 2006-02-15 05:02:19 |
|           3 | Japanese   | 2006-02-15 05:02:19 |
|           4 | Mandarin   | 2006-02-15 05:02:19 |
|           5 | French     | 2006-02-15 05:02:19 |
|           6 | German     | 2006-02-15 05:02:19 |
|           7 | Portuguese | 2020-09-26 09:11:36 |
|           8 | Russian    | 2020-09-26 10:35:00 |
+-------------+------------+---------------------+
8 rows in set (0.00 sec)

单行INSERT样式会检测主键重复,并在找到重复键时立即停止。例如,假设我们尝试插入另一行具有相同的language_id

mysql> `INSERT` `INTO` `language` `VALUES` `(``8``,` `'Arabic'``,` `'2020-09-26 10:35:00'``)``;`
ERROR 1062 (23000): Duplicate entry '8' for key 'language.PRIMARY'

当检测到重复键时,INSERT操作会停止。如果需要,您可以添加一个IGNORE子句来防止错误,但请注意该行仍然不会被插入:

mysql> `INSERT` `IGNORE` `INTO` `language` `VALUES` `(``8``,` `'Arabic'``,` `'2020-09-26 10:35:00'``)``;`
Query OK, 0 rows affected, 1 warning (0.00 sec)

在大多数情况下,您可能希望了解可能的问题(毕竟,主键应该是唯一的),因此这种IGNORE语法很少使用。

也可以一次插入多个值:

mysql> `INSERT` `INTO` `language` `VALUES` `(``NULL``,` `'Spanish'``,` `NOW``(``)``)``,`
    -> `(``NULL``,` `'Hebrew'``,` `NOW``(``)``)``;`
Query OK, 2 rows affected (0.02 sec)
Records: 2  Duplicates: 0  Warnings: 0

请注意,MySQL 以不同于单次插入的方式报告了批量插入的结果。

第一行告诉您插入了多少行,而第二行的第一个条目告诉您实际处理了多少行(或记录)。如果使用INSERT IGNORE并尝试插入重复记录(其主键与现有行的主键匹配),MySQL 将悄悄地跳过插入它,并在第二行的第二个条目中报告它为重复:

mysql> `INSERT` `IGNORE` `INTO` `language` `VALUES` `(``9``,` `'Portuguese'``,` `NOW``(``)``)``,`
    `(``11``,` `'Hebrew'``,` `NOW``(``)``)``;`
Query OK, 1 row affected, 1 warning (0.01 sec)
Records: 2  Duplicates: 1  Warnings: 1

我们在第四章中讨论警告的原因,显示为输出第二行的第三个条目。

替代语法

在上一节中展示的VALUES语法有几种替代方法。本节将逐一介绍它们,并解释每种方法的优缺点。如果您对我们迄今描述的基本语法感到满意,并希望进入新的主题,请随时跳到“DELETE 语句”。

我们一直在使用的VALUES语法有一些优点:它适用于单行和批量插入,如果你忘记为所有列提供值,会得到错误消息,并且你不必输入列名。然而,它也有一些缺点:你需要记住列的顺序,每个列都需要提供一个值,并且语法与底层表结构紧密相关。也就是说,如果你改变了表的结构,就需要修改INSERT语句。幸运的是,我们可以通过改变语法来避免这些缺点。

假设你知道actor表有四列,并且记得它们的名称,但忘记了它们的顺序。你可以使用以下方法插入一行记录:

mysql> `INSERT` `INTO` `actor` `(``actor_id``,` `first_name``,` `last_name``,` `last_update``)`
    -> `VALUES` `(``NULL``,` `'Vinicius'``,` `'Grippa'``,` `NOW``(``)``)``;`
Query OK, 1 row affected (0.03 sec)

列名包含在表名后的括号内,并且这些列的值在VALUES关键字后的括号内列出。所以,在这个例子中,创建了一行新记录,actor_id存储为201(记住,actor_id具有auto_increment属性),first_name存储为Viniciuslast_name存储为Grippa,并且last_update列填充了当前时间戳。这种语法的优点是可读性强且灵活(解决了我们描述的第三个缺点),并且不受顺序影响(解决了第一个缺点)。缺点是你需要知道列名并手动输入。

这种新的语法也可以解决简单方法的第二个缺点——也就是说,它可以允许你仅为某些列插入值。为了理解这种方法可能有用的情况,让我们探讨一下city表:

mysql> `DESC` `city``;`
+-------------+----------------------+------+-----+-------------------+...
| Field       | Type                 | Null | Key | Default           |...
+-------------+----------------------+------+-----+-------------------+...
| city_id     | smallint(5) unsigned | NO   | PRI | NULL              |...
| city        | varchar(50)          | NO   |     | NULL              |...
| country_id  | smallint(5) unsigned | NO   | MUL | NULL              |...
| last_update | timestamp            | NO   |     | CURRENT_TIMESTAMP |...
+-------------+----------------------+------+-----+-------------------+...
...+-----------------------------------------------+
...| Extra                                         |
...+-----------------------------------------------+
...| auto_increment                                |
...|                                               |
...|                                               |
...| on update CURRENT_TIMESTAMP                   |
...|-----------------------------------------------+

4 rows in set (0.00 sec)

注意,last_update列的默认值是CURRENT_TIMESTAMP。这意味着如果你不插入last_update列的值,MySQL 会默认插入当前日期和时间。这正是我们想要的:当我们存储一条记录时,我们不想麻烦检查日期和时间并手动输入。让我们尝试插入一条不完整的记录:

mysql> `INSERT` `INTO` `city` `(``city``,` `country_id``)` `VALUES` `(``'Bebedouro'``,` `19``)``;`
Query OK, 1 row affected (0.00 sec)

我们没有为city_id列设置值,因此 MySQL 将其默认为下一个可用值(由于具有auto_increment属性),并且last_update存储当前日期和时间。你可以使用查询来验证这一点:

mysql> `SELECT` `*` `FROM` `city` `where` `city` `like` `'Bebedouro'``;`
+---------+-----------+------------+---------------------+
| city_id | city      | country_id | last_update         |
+---------+-----------+------------+---------------------+
|     601 | Bebedouro |         19 | 2021-02-27 21:34:08 |
+---------+-----------+------------+---------------------+
1 row in set (0.01 sec)

你也可以使用这种方法进行批量插入,如下所示:

mysql> `INSERT` `INTO` `city` `(``city``,``country_id``)` `VALUES`
    -> `(``'Sao Carlos'``,``19``)``,`
    -> `(``'Araraquara'``,``19``)``,`
    -> `(``'Ribeirao Preto'``,``19``)``;`
Query OK, 3 rows affected (0.00 sec)
Records: 3  Duplicates: 0  Warnings: 0

除了需要记住和输入列名之外,这种方法的缺点是你可能会意外地省略列的值。MySQL 将省略的列设置为默认值。MySQL 表中的所有列默认值为NULL,除非在创建或修改表时明确分配了其他默认值。

当你需要使用表列的默认值时,你可能想使用DEFAULT关键字(MySQL 5.7 及更高版本支持)。以下是使用DEFAULTcountry表添加行的示例:

mysql> `INSERT` `INTO` `country` `VALUES` `(``NULL``,` `'Uruguay'``,` `DEFAULT``)``;`
Query OK, 1 row affected (0.01 sec)

关键词DEFAULT告诉 MySQL 使用该列的默认值,所以当前的日期和时间将被插入到我们的示例中。这种方法的优点是你可以使用默认值进行批量插入,并且你永远不会意外地省略列。

还有另一种INSERT语法的选择。在这种方法中,你将列名和值一起列出,因此你不必将值列表与前面的列名列表进行对应。这里有一个向country表添加新行的示例:

mysql> `INSERT` `INTO` `country` `SET` `country_id``=``NULL``,`
    -> `country``=``'Bahamas'``,` `last_update``=``NOW``(``)``;`
Query OK, 1 row affected (0.01 sec)

语法要求你列出表名,关键词SET,然后是由逗号分隔的列=值对。未提供值的列将被设置为它们的默认值。同样,缺点是你可能会意外地省略列的值,并且需要记住和输入列名。一个显著的额外缺点是你不能用这种方法进行大批量插入。

你也可以使用从查询返回的值进行插入。我们将在第七章讨论这个。

删除语句

DELETE语句用于从表中移除一个或多个行。我们在这里解释单表删除,并在第七章讨论多表删除——它通过一个语句从两个或更多表中删除数据。

删除基础

DELETE的最简单用法是删除表中的所有行。假设你想清空你的rental表。你可以这样做:

mysql> `DELETE` `FROM` `rental``;`
Query OK, 16044 rows affected (2.41 sec)

DELETE语法不包括列名,因为它用于移除整行而不仅仅是行中的值。要重置或修改行中的值,你可以使用在“更新语句”中描述的UPDATE语句。请注意,DELETE语句不会移除表本身。例如,删除了rental表中的所有行后,你仍然可以查询该表:

mysql> `SELECT` `*` `FROM` `rental``;`
Empty set (0.00 sec)

你也可以继续使用DESCRIBESHOW CREATE TABLE来探索其结构,并使用INSERT插入新的行。要删除表,你可以使用在第四章中描述的DROP语句。

如果表与另一张表有关联,删除可能会因为外键约束而失败:

mysql> `DELETE` `FROM` `language``;`
ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key
constraint fails (`sakila`.`film`, CONSTRAINT `fk_film_language` FOREIGN KEY
(`language_id`) REFERENCES `language` (`language_id`) ON UPDATE CASCADE)

使用 WHERE、ORDER BY 和 LIMIT

如果你在前面的章节中删除了行,请按照“实体关系建模示例”中的说明重新加载你的sakila数据库。你将需要在本节的示例中恢复rental表中的行。

要移除一张表中的一个或多个行,但不是所有行,请使用WHERE子句。这与SELECT的工作方式相同。例如,假设你想从rental表中移除所有rental_id小于 10 的行。你可以这样做:

mysql> `DELETE` `FROM` `rental` `WHERE` `rental_id` `<` `10``;`
Query OK, 9 rows affected (0.01 sec)

结果是符合条件的九行将被移除。

假设现在你想从数据库中删除名为 Mary Smith 的客户的所有付款记录。首先,使用 INNER JOINcustomerpayment 表中执行 SELECT 操作(如 “连接两个表” 所述):

mysql> `SELECT` `first_name``,` `last_name``,` `customer``.``customer_id``,`
    -> `amount``,` `payment_date` `FROM` `payment` `INNER` `JOIN` `customer`
    -> `ON` `customer``.``customer_id``=``payment``.``customer_id`
    -> `WHERE` `first_name` `like` `'Mary'`
    -> `AND` `last_name` `like` `'Smith'``;`
+------------+-----------+-------------+--------+---------------------+
| first_name | last_name | customer_id | amount | payment_date        |
+------------+-----------+-------------+--------+---------------------+
| MARY       | SMITH     |           1 |   2.99 | 2005-05-25 11:30:37 |
| MARY       | SMITH     |           1 |   0.99 | 2005-05-28 10:35:23 |
| MARY       | SMITH     |           1 |   5.99 | 2005-06-15 00:54:12 |
| MARY       | SMITH     |           1 |   0.99 | 2005-06-15 18:02:53 |
...
| MARY       | SMITH     |           1 |   1.99 | 2005-08-22 01:27:57 |
| MARY       | SMITH     |           1 |   2.99 | 2005-08-22 19:41:37 |
| MARY       | SMITH     |           1 |   5.99 | 2005-08-22 20:03:46 |
+------------+-----------+-------------+--------+---------------------+
32 rows in set (0.00 sec)

接下来,执行以下 DELETE 操作,从 payment 表中删除 customer_id 为 1 的行:

mysql> `DELETE` `FROM` `payment` `where` `customer_id``=``1``;`
Query OK, 32 rows affected (0.01 sec)

你可以在 DELETE 语句中使用 ORDER BYLIMIT 子句。通常在想要限制删除行数时使用这种方式。例如:

mysql> `DELETE` `FROM` `payment` `ORDER` `BY` `customer_id` `LIMIT` `10000``;`
Query OK, 10000 rows affected (0.22 sec)
提示

我们强烈建议对小数据集使用 DELETEUPDATE 操作,由于性能问题。适当的值取决于硬件,但一个良好的经验法则是每批约 20,000 到 40,000 行。

使用 TRUNCATE 删除所有行

如果要删除表中的所有行,有一种比使用 DELETE 更快的方法。当你使用 TRUNCATE TABLE 语句时,MySQL 采取了快捷方式,直接删除表,移除表结构,然后重新创建。当表中有许多行时,这种方法要快得多。

注意

有趣的是,在 MySQL 5.6 中存在一个 bug,当配置了大的 InnoDB 缓冲池(200 GB 或更多)时,执行 TRUNCATE 操作可能会导致 MySQL 陷入停滞。详细信息请参见 bug 报告

如果要删除 payment 表中的所有数据,可以执行以下操作:

mysql> `TRUNCATE` `TABLE` `payment``;`
Query OK, 0 rows affected (0.07 sec)

请注意,受影响的行数显示为零:为了加快操作,MySQL 不统计删除的行数,因此显示的数字不反映实际删除的行数。

TRUNCATE TABLE 语句在很多方面与 DELETE 不同,但值得一提的是几点:

  • TRUNCATE 操作会删除并重新创建表,相比逐行删除,特别是对于大表来说速度要快得多。

  • TRUNCATE 操作会造成隐式提交,因此无法回滚。

  • 如果会话持有活动的表锁,则无法执行 TRUNCATE 操作。

表类型、事务和锁定在 第五章 中有讨论。这些限制并不影响大多数应用程序的实际应用,你可以使用 TRUNCATE TABLE 来加速处理过程。当然,在正常运行中删除整个表并不常见。一个例外是临时表,用于临时存储某个用户会话的查询结果,可以在不丢失原始数据的情况下删除。

更新语句

UPDATE 语句用于更改数据。在本节中,我们将向你展示如何在单个表中更新一个或多个行。多表更新在 “更新” 中有讨论。

如果已从 sakila 数据库中删除行,请在继续之前重新加载。

例子

UPDATE语句的最简单用法是更改表中的所有行。假设您需要通过执行以下操作向payment表的amount列添加 10%的所有付款。您可以这样做:

mysql> `UPDATE` `payment` `SET` `amount``=``amount``*``1``.``1``;`
Query OK, 16025 rows affected, 16025 warnings (0.41 sec)
Rows matched: 16049  Changed: 16025  Warnings: 16025

请注意,我们忘记更新last_update状态。为了使其与预期的数据库模型一致,您可以通过执行以下语句来修复此问题:

mysql> `UPDATE` `payment` `SET` `last_update``=``'2021-02-28 17:53:00'``;`
Query OK, 16049 rows affected (0.27 sec)
Rows matched: 16049  Changed: 16049  Warnings: 0
提示

您可以使用NOW()函数将last_update列更新为执行时的当前时间戳。例如:

mysql> `UPDATE` `payment` `SET` `last_update``=``NOW``(``)``;`

UPDATE语句报告的第二行显示了语句的整体效果。在我们的示例中,您可以看到:

Rows matched: 16049  Changed: 16049  Warnings: 0

第一列报告了作为匹配结果检索到的行数;在这种情况下,由于没有WHERELIMIT子句,表中的所有行都匹配查询。第二列报告了需要更改的行数,这总是等于或小于匹配的行数。如果您重复执行该语句,您会看到不同的结果:

mysql> `UPDATE` `payment` `SET` `last_update``=``'2021-02-28 17:53:00'``;`
Query OK, 0 rows affected (0.07 sec)
Rows matched: 16049  Changed: 0  Warnings: 0

这一次,由于日期已设置为2021-02-28 17:53:00并且没有WHERE条件,所有行仍然与查询匹配,但没有更改。请注意输出的第一行报告的更改行数始终等于受影响行数。

使用 WHERE、ORDER BY 和 LIMIT

通常,您不希望更改表中的所有行。相反,您希望更新一个或多个与条件匹配的行。与SELECTDELETE一样,WHERE子句用于此任务。此外,与DELETE类似,您可以使用ORDER BYLIMIT一起来控制从排序列表中更新多少行。

让我们尝试修改表中的一行的示例。假设演员 Penelope Guiness 已更改了她的姓氏。要在数据库的actor表中更新她的姓氏,您需要执行:

mysql> `UPDATE` `actor` `SET` `last_name``=` `UPPER``(``'cruz'``)`
    -> `WHERE` `first_name` `LIKE` `'PENELOPE'`
    -> `AND` `last_name` `LIKE` `'GUINESS'``;`
Query OK, 1 row affected (0.01 sec)
Rows matched: 1  Changed: 1  Warnings: 0

如预期,MySQL 匹配了一行并更改了一行。

要控制更新操作的数量,您可以使用ORDER BYLIMIT的组合:

mysql> `UPDATE` `payment` `SET` `last_update``=``NOW``(``)` `LIMIT` `10``;`
Query OK, 10 rows affected (0.01 sec)
Rows matched: 10  Changed: 10  Warnings: 0

DELETE类似,您可能会因为要将操作分成小块或仅修改某些行而执行此操作。在这里,您可以看到匹配并更改了 10 行。

前面的查询还说明了更新的一个重要方面。正如您所见,更新有两个阶段:匹配阶段,在此阶段找到与WHERE子句匹配的行,以及修改阶段,在此阶段更新需要更改的行。

使用 SHOW 和 mysqlshow 探索数据库和表

我们已经解释过如何使用SHOW命令获取有关数据库、表及其列结构的信息。在本节中,我们将简要介绍使用sakila数据库的SHOW语句的最常见类型,并进行简短的示例。mysqlshow命令行程序执行与几个SHOW命令变体相同的功能,但无需启动 MySQL 客户端。

SHOW DATABASES语句列出您可以访问的数据库。如果您按照我们的示例数据库安装步骤在“实体关系建模示例”中和在“创建银行数据库 ER 模型”中部署了银行模型,您的输出应如下所示:

mysql> `SHOW` `DATABASES``;`
+--------------------+
| Database           |
+--------------------+
| information_schema |
| bank_model         |
| employees          |
| mysql              |
| performance_schema |
| sakila             |
| sys                |
| world              |
+--------------------+
8 rows in set (0.01 sec)

这些是您可以使用USE命令访问的数据库(在第四章讨论);如果您对服务器上其他数据库有访问权限,则这些也将列出。您只能查看您具有某些权限的数据库,除非您具有全局的SHOW DATABASES权限。您可以通过命令行使用mysqlshow程序来获得相同的效果:

$ mysqlshow -uroot -pmsandbox -h 127.0.0.1 -P 3306

可以在SHOW DATABASES中添加一个LIKE子句。如果你有很多数据库并且希望输出一个简短的列表,这将非常有用。例如,要仅查看名称以s开头的数据库,运行:

mysql> `SHOW` `DATABASES` `LIKE` `'s%'``;`
+---------------+
| Database (s%) |
+---------------+
| sakila        |
| sys           |
+---------------+
2 rows in set (0.00 sec)

LIKE语句的语法与其在SELECT中的用法完全相同。

要查看用于创建数据库的语句,您可以使用SHOW CREATE DATABASE语句。例如,要查看如何创建sakila,请键入:

mysql> `SHOW` `CREATE` `DATABASE` `sakila``;`
************************** 1\. row ***************************
       Database: sakila
Create Database: CREATE DATABASE `sakila` /*!40100 DEFAULT CHARACTER SET
utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */
1 row in set (0.00 sec)

这可能是最不令人兴奋的SHOW语句;它只显示语句本身。但请注意,还包括一些额外的注释,/*!*/

40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci
80016 DEFAULT ENCRYPTION='N'

这些注释包含 MySQL 特定的关键字,提供其他数据库程序不太可能理解的指令。除 MySQL 外的数据库服务器将忽略此注释文本,因此该语法可同时用于 MySQL 和其他数据库服务器软件。注释开头的可选数字表示能处理该特定指令的 MySQL 最低版本(例如,40100表示版本 4.01.00);较旧的 MySQL 版本将忽略这类指令。您将在第四章了解有关创建数据库的内容。

SHOW TABLES语句列出数据库中的表。要检查sakila中的表,请键入:

mysql> `SHOW` `TABLES` `FROM` `sakila``;`
+----------------------------+
| Tables_in_sakila           |
+----------------------------+
| actor                      |
| actor_info                 |
| address                    |
| category                   |
| city                       |
| country                    |
| customer                   |
| customer_list              |
| film                       |
| film_actor                 |
| film_category              |
| film_list                  |
| film_text                  |
| inventory                  |
| language                   |
| nicer_but_slower_film_list |
| payment                    |
| rental                     |
| sales_by_film_category     |
| sales_by_store             |
| staff                      |
| ...                 |
+----------------------------+
23 rows in set (0.01 sec)

如果您已使用USE sakila命令选择了sakila数据库,则可以使用以下快捷方式:

mysql> `SHOW` `TABLES``;`

您还可以通过向mysqlshow程序指定数据库名称来获得类似的结果:

$ mysqlshow -uroot -pmsandbox -h 127.0.0.1 -P 3306 sakila

SHOW DATABASES一样,您无法查看没有权限的表。这意味着您无法查看无法访问的数据库中的表,即使您具有SHOW DATABASES全局权限。

SHOW COLUMNS语句列出表中的列。例如,要检查country表的列,请键入:

mysql> `SHOW` `COLUMNS` `FROM` `country``;`
*************************** 1\. row ***************************
  Field: country_id
   Type: smallint unsigned
   Null: NO
    Key: PRI
Default: NULL
  Extra: auto_increment
*************************** 2\. row ***************************
  Field: country
   Type: varchar(50)
   Null: NO
    Key:
Default: NULL
  Extra:
*************************** 3\. row ***************************
  Field: last_update
   Type: timestamp
   Null: NO
    Key:
Default: CURRENT_TIMESTAMP
  Extra: DEFAULT_GENERATED on update CURRENT_TIMESTAMP
3 rows in set (0.00 sec)

输出报告了所有列的名称、类型和大小、是否可以为 NULL、是否是键的一部分、它们的默认值以及任何额外信息。类型、键、NULL 值和默认值将在第四章中进一步讨论。如果尚未使用 USE 命令选择 sakila 数据库,则可以在表名前添加数据库名,例如 sakila.country。与前面的 SHOW 命令不同,如果您可以访问表,则始终可以看到所有列名;不需要为所有列获得某些特权。

使用 mysqlshow 命令和数据库名以及表名,您可以获得类似的结果:

$ mysqlshow -uroot -pmsandbox -h 127.0.0.1 -P 3306 sakila country

您可以使用 SHOW CREATE TABLE 命令查看创建特定表的语句(我们也将在第四章中讨论创建表)。有些用户更喜欢这种输出而不是 SHOW COLUMNS 的输出,因为它具有 CREATE TABLE 语句的熟悉格式。以下是 country 表的示例:

mysql> `SHOW` `CREATE` `TABLE` `country``\``G`
*************************** 1\. row ***************************
       Table: country
Create Table: CREATE TABLE `country` (
  `country_id` smallint unsigned NOT NULL AUTO_INCREMENT,
  `country` varchar(50) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE
  CURRENT_TIMESTAMP,
  PRIMARY KEY (`country_id`)
) ENGINE=InnoDB AUTO_INCREMENT=110 DEFAULT CHARSET=utf8mb4
  COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

第四章:处理数据库结构

本章节向您展示如何创建您自己的数据库,添加和删除诸如表和索引之类的结构,并在您的表中做出列类型的选择。它侧重于 SQL 的语法和特性,而不是关于构思、指定和完善数据库设计的语义;您将在第二章中找到数据库设计技术的简介描述。要完成本章的学习,您需要理解如何处理现有数据库及其表,如第三章所讨论的那样。

本章节列出了示例数据库sakila中的结构。如果您按照“实体关系建模示例”中的说明加载了数据库,那么您已经可以使用这个数据库,并且知道在修改其结构后如何恢复它。

当您完成本章时,您将掌握创建、修改和删除数据库结构所需的所有基础知识。与您在第三章中学到的技术一起,您将具备执行各种基本操作的技能。第五章和 7 章介绍了允许您使用 MySQL 执行更高级操作的技能。

创建和使用数据库

当您完成数据库的设计后,使用 MySQL 的第一个实际步骤是创建它。您可以使用CREATE DATABASE语句完成这一步骤。假设您要创建一个名为lucy的数据库,这是您要输入的语句:

mysql> `CREATE` `DATABASE` `lucy``;`
Query OK, 1 row affected (0.10 sec)

我们在这里假设您知道如何使用 MySQL 客户端进行连接,正如第一章中所述。我们还假设您能够作为 root 用户或另一个能够创建、删除和修改结构的用户进行连接(您将在第八章中找到有关用户权限的详细讨论)。请注意,当您创建数据库时,MySQL 会说有一行受到影响。事实上,这并不是任何特定数据库中的正常行,而是添加到您使用SHOW DATABASES命令看到的列表中的新条目。

一旦您创建了数据库,下一步是使用它——也就是说,选择它作为您正在使用的数据库。您可以使用 MySQL 的USE命令来实现这一点:

mysql> `USE` `lucy``;`
Database changed

此命令必须在一行中输入,无需用分号终止,尽管我们通常会通过习惯自动这样做。一旦您使用(选择)了数据库,您可以开始使用下一节讨论的步骤创建表、索引和其他结构。

在我们继续创建其他结构之前,让我们讨论一下创建数据库的一些特性和限制。首先,让我们看看如果您尝试创建一个已经存在的数据库会发生什么:

mysql> `CREATE` `DATABASE` `lucy``;`
ERROR 1007 (HY000): Can't create database 'lucy'; database exists

您可以通过在语句中添加IF NOT EXISTS关键字短语来避免此错误:

mysql> `CREATE` `DATABASE` `IF` `NOT` `EXISTS` `lucy``;`
Query OK, 0 rows affected (0.00 sec)

你可以看到 MySQL 没有投诉,但也没做任何事情:0 rows affected消息表明没有数据被更改。这个附加功能在将 SQL 语句添加到脚本时非常有用:它可以防止脚本在错误时中止。

让我们看看如何选择数据库名称并使用字符大小写。数据库名称定义了磁盘上的物理目录(或文件夹)名称。在某些操作系统上,目录名称区分大小写;在其他系统上,大小写无关紧要。例如,类 Unix 系统(如 Linux 和 macOS)通常区分大小写,而 Windows 则不区分。因此,数据库名称具有相同的限制:当大小写对操作系统有影响时,MySQL 也会受到影响。例如,在 Linux 机器上,LUCYlucyLucy是不同的数据库名称;在 Windows 上,它们指的是同一个数据库。在 Linux 或 macOS 下使用不正确的大写会导致 MySQL 投诉:

mysql> `SELECT` `SaKilA``.``AcTor_id` `FROM` `ACTor``;`
ERROR 1146 (42S02): Table 'sakila.ACTor' doesn't exist

但在 Windows 下,这通常会起作用。

提示

为了使你的 SQL 代码能够跨机器运行,我们建议始终使用小写命名数据库(以及表、列、别名和索引)。当然,这并非硬性要求,在本书的早期示例中已经证明,你可以使用自己习惯的命名规则。只需保持一致,并记住 MySQL 在不同操作系统上的行为。

这种行为由lower_case_table_names参数控制。如果设置为0,则表名按指定的方式存储,并且区分大小写。如果设置为1,则表名以小写形式存储在磁盘上,并且不区分大小写。如果此参数设置为2,则表名按给定的方式存储,但比较时以小写形式进行。在 Windows 上,默认值为1。在 macOS 上,默认值为2。在 Linux 上,不支持值2;服务器会强制将该值设置为0

数据库名称还有其他限制。名称最多可以为 64 个字符长度。您还不应该使用 MySQL 保留字,例如SELECTFROMUSE作为结构的名称;这些保留字会使 MySQL 解析器混淆,无法解释语句的含义。您可以通过将保留字用反引号(`)括起来来避免此限制,但这样做比值得的麻烦多。此外,您不能在名称中使用特定字符,包括斜线、反斜线、分号和句点字符,数据库名称不能以空格结束。再次强调,这些字符的使用会使 MySQL 解析器混淆,并可能导致不可预测的行为。例如,当您在数据库名称中插入分号时会发生什么:

mysql> `CREATE` `DATABASE` `IF` `NOT` `EXISTS` `lu``;``cy``;`
Query OK, 1 row affected (0.00 sec)

ERROR 1064 (42000): You have an error in your SQL syntax; check the manual
that corresponds to your MySQL server version for the right syntax to use
near 'cy' at line 1

由于一行中可能有多个 SQL 语句,结果是创建了一个名为lu的数据库,然后由非常短、意外的 SQL 语句cy;生成了错误。如果您确实想要创建一个带有分号名称的数据库,可以使用反引号:

mysql> `CREATE` `DATABASE` `IF` `NOT` `EXISTS` `` `lu;cy` ```;`


查询完成,影响 1 行(0.01 秒)

And you can see that you now have two new databases:


mysql> `SHOW` `DATABASES` `LIKE` `` `lu%` ```;`
+----------------+
| Database (lu%) |
+----------------+
| lu             |
| lu;cy          |
+----------------+
2 rows in set (0.01 sec)

创建表格

本节涵盖表结构相关主题。我们将向您展示如何:

  • 通过简单示例创建表格。

  • 选择表格及相关结构的名称。

  • 理解并选择列类型。

  • 理解并选择键和索引。

  • 使用专有的 MySQL AUTO_INCREMENT功能。

完成本节后,您将掌握所有关于创建数据库结构的基础材料;本章的其余部分将涵盖sakila示例数据库及如何修改和删除现有结构。

基础知识

对于本节中的示例,假设尚未创建sakila数据库。如果您希望跟随示例操作,并且已加载了数据库,可以在本节中删除它,稍后再重新加载;删除它将删除数据库、其表格和所有数据,但可以通过以下步骤轻松恢复原始状态,详见“实体关系建模示例”。以下是临时删除它的方法:

mysql> `DROP` `DATABASE` `sakila``;`
Query OK, 23 rows affected (0.06 sec)

在本章末尾进一步讨论DROP语句,详见“删除结构”。

要开始,使用以下语句创建数据库sakila

mysql> `CREATE` `DATABASE` `sakila``;`
Query OK, 1 row affected (0.00 sec)

然后使用以下语句选择数据库:

mysql> `USE` `sakila``;`
Database changed

现在我们准备开始创建将存储数据的表格。让我们创建一个用于保存演员详情的表格。暂时我们将使用简化的结构,稍后再添加更复杂的内容。以下是我们使用的语句:

mysql> `CREATE` `TABLE` `actor` `(`
    -> `actor_id` `SMALLINT` `UNSIGNED` `NOT` `NULL` `DEFAULT` `0``,`
    -> `first_name` `VARCHAR``(``45``)` `DEFAULT` `NULL``,`
    -> `last_name` `VARCHAR``(``45``)``,`
    -> `last_update` `TIMESTAMP``,`
    -> `PRIMARY` `KEY` `(``actor_id``)`
    -> `)``;`
Query OK, 0 rows affected (0.01 sec)

不要惊慌——即使 MySQL 报告说未影响任何行,它已经创建了表格:

mysql> `SHOW` `tables``;`
+------------------+
| Tables_in_sakila |
+------------------+
| actor            |
+------------------+
1 row in set (0.01 sec)

让我们详细考虑所有这些内容。CREATE TABLE命令有三个主要部分:

  1. CREATE TABLE语句后跟随要创建的表格名称。在本示例中,它是actor

  2. 列出要添加到表中的一个或多个列。在本示例中,我们添加了相当多的列:actor_id SMALLINT UNSIGNED NOT NULL DEFAULT 0first_name VARCHAR(45) DEFAULT NULLlast_name VARCHAR(45),以及last_update TIMESTAMP。稍后我们会详细讨论这些内容。

  3. 可选的键定义。在本示例中,我们定义了一个主键:PRIMARY KEY (actor_id)。稍后我们将详细讨论键和索引。

注意CREATE TABLE组件后跟随的是开放括号,与语句末尾的闭合括号相匹配。还要注意其他组件由逗号分隔。CREATE TABLE语句还可以添加其他元素,我们稍后将讨论一些内容。

让我们讨论列规范。基本语法如下:*name* *type* [NOT NULL | NULL] [DEFAULT *value*]*name*字段是列名,具有与数据库名称相同的限制,如前一节所述。其长度最多为 64 个字符,不允许使用反斜杠和斜杠,不允许使用句点,不能以空白结束,大小写敏感性取决于底层操作系统。*type*字段定义了列中存储的内容和方式;例如,我们已经看到它可以设置为VARCHAR表示字符串,SMALLINT表示数字,或TIMESTAMP表示日期和时间。

如果指定了NOT NULL,则无值列的行无效;如果指定了NULL或省略了此子句,则行可以存在而不为列提供值。如果在DEFAULT子句中指定了*value*,则在没有其他数据的情况下,它将用于填充列;当您经常重用默认值(如国家名称)时,这尤其有用。*value*必须是常量(如0"cat"20060812045623),除非列的类型是TIMESTAMP。类型将在“列类型”中详细讨论。

NOT NULLDEFAULT特性可以一起使用。如果指定了NOT NULL并添加了DEFAULT值,则在未为列提供值时使用默认值。有时候,这很有效:

mysql> `INSERT` `INTO` `actor``(``first_name``)` `VALUES` `(``'John'``)``;`
Query OK, 1 row affected (0.01 sec)

有时候却不是这样:

mysql> `INSERT` `INTO` `actor``(``first_name``)` `VALUES` `(``'Elisabeth'``)``;`
ERROR 1062 (23000): Duplicate entry '0' for key 'actor.PRIMARY'

它是否有效取决于数据库的底层约束和条件:在此示例中,actor_id具有默认值0,但它也是主键。不允许具有相同主键值的两行存在,因此尝试插入没有值的行(并且结果主键值为0)的第二次尝试失败了。我们将在“键和索引”中详细讨论主键。

列名比数据库和表名的限制少。此外,列名不区分大小写,并且在所有平台上都可以移植。列名中允许使用所有字符,但如果要以空白结束或包含句点或其他特殊字符(如分号或破折号),则需要用反引号(`)括起来。同样,我们建议您始终选择小写名称用于开发者驱动的选择(如数据库、别名和表名),并避免需要记住使用反引号的字符。

在开始新的命名列和其他数据库对象时,命名方式有一些个人偏好(您可以查看示例数据库以获取一些灵感),或者在现有代码库上工作时需要遵循标准。一般来说,避免重复是目标:在名为 actor 的表中,使用列名 first_name 而不是 actor_first_name,当在复杂查询中使用表名前缀时,后者看起来会显得多余(actor.actor_first_name 相对于 actor.first_name)。唯一的例外是使用普遍存在的 id 列名;要么避免使用这个名称,要么为了清晰起见在前面加上表名前缀(例如 actor_id)。使用下划线字符来分隔单词是一个良好的做法。您也可以使用其他字符,比如破折号或斜杠,但您需要记住要用反引号将名称括起来(例如 `actor-id`)。您也可以完全省略单词间的格式化,但是“驼峰命名法”可能更难阅读。与数据库和表名一样,列名的最大允许长度为 64 个字符。

排序和字符集

当您比较或排序字符串时,MySQL 如何评估结果取决于所使用的字符集和排序规则。字符集定义了可以存储哪些字符;例如,您可能需要存储像 ю 或 ü 这样的非英文字符。排序规则定义了字符串的顺序,不同的语言有不同的排序规则:例如,在两种德语排序中字符 ü 在字母表中的位置不同,在瑞典语和芬兰语中又有所不同。因为不是每个人都希望存储英文字符串,因此数据库服务器能够管理非英文字符和不同的字符排序方式非常重要。

我们理解,在您刚开始学习 MySQL 时,讨论排序和字符集可能会感觉太过深奥。然而,我们认为这些是值得涵盖的话题,因为不匹配的字符集和排序可能导致意外情况,包括数据丢失和错误的查询结果。如果您愿意,可以跳过本节以及本章节后面的部分内容,等到您有兴趣学习这些特定内容时再回来阅读。这不会影响您对本书其他材料的理解。

在我们先前的字符串比较示例中,我们忽略了排序和字符集问题,只是让 MySQL 使用其默认值。在 MySQL 8.0 之前的版本中,默认字符集为 latin1,默认排序规则为 latin1_swedish_ci。MySQL 8.0 更改了默认设置,现在默认字符集为 utf8mb4,默认排序规则为 utf8mb4_0900_ai_ci。MySQL 可以在连接、数据库、表和列级别配置使用不同的字符集和排序顺序。这里显示的输出来自 MySQL 8.0。

您可以使用 SHOW CHARACTER SET 命令列出服务器上可用的字符集。此命令显示每个字符集的简短描述、默认排序规则以及该字符集中每个字符的最大字节数:

mysql> `SHOW` `CHARACTER` `SET``;`
+----------+---------------------------------+---------------------+--------+
| Charset  | Description                     | Default collation   | Maxlen |
+----------+---------------------------------+---------------------+--------+
| armscii8 | ARMSCII-8 Armenian              | armscii8_general_ci |      1 |
| ascii    | US ASCII                        | ascii_general_ci    |      1 |
| big5     | Big5 Traditional Chinese        | big5_chinese_ci     |      2 |
| binary   | Binary pseudo charset           | binary              |      1 |
| cp1250   | Windows Central European        | cp1250_general_ci   |      1 |
| cp1251   | Windows Cyrillic                | cp1251_general_ci   |      1 |
| ...                                                                       |
| ujis     | EUC-JP Japanese                 | ujis_japanese_ci    |      3 |
| utf16    | UTF-16 Unicode                  | utf16_general_ci    |      4 |
| utf16le  | UTF-16LE Unicode                | utf16le_general_ci  |      4 |
| utf32    | UTF-32 Unicode                  | utf32_general_ci    |      4 |
| utf8     | UTF-8 Unicode                   | utf8_general_ci     |      3 |
| utf8mb4  | UTF-8 Unicode                   | utf8mb4_0900_ai_ci  |      4 |
+----------+---------------------------------+---------------------+--------+
41 rows in set (0.00 sec)

例如,latin1 字符集实际上是支持西欧语言的 Windows 代码页 1252 字符集。这种字符集的默认排序规则是 latin1_swedish_ci,遵循瑞典的惯例对带重音的字符进行排序(英语按照预期处理)。此排序规则是不区分大小写的,如字母 ci 所示。最后,每个字符占用 1 字节。相比之下,如果使用默认的 utf8mb4 字符集,每个字符将占用多达 4 个字节的存储空间。有时候,更改默认设置是有道理的。例如,没有理由在 utf8mb4 中存储 Base64 编码的数据(它定义为 ASCII)。

类似地,您可以列出排序规则和它们适用的字符集:

mysql> `SHOW` `COLLATION``;`
+---------------------+----------+-----+---------+...+---------------+
| Collation           | Charset  | Id  | Default |...| Pad_attribute |
+---------------------+----------+-----+---------+...+---------------+
| armscii8_bin        | armscii8 |  64 |         |...| PAD SPACE     |
| armscii8_general_ci | armscii8 |  32 | Yes     |...| PAD SPACE     |
| ascii_bin           | ascii    |  65 |         |...| PAD SPACE     |
| ascii_general_ci    | ascii    |  11 | Yes     |...| PAD SPACE     |
| ...                                            |...|               |
| utf8mb4_0900_ai_ci  | utf8mb4  | 255 | Yes     |...| NO PAD        |
| utf8mb4_0900_as_ci  | utf8mb4  | 305 |         |...| NO PAD        |
| utf8mb4_0900_as_cs  | utf8mb4  | 278 |         |...| NO PAD        |
| utf8mb4_0900_bin    | utf8mb4  | 309 |         |...| NO PAD        |
| ...                                            |...|               |
| utf8_unicode_ci     | utf8     | 192 |         |...| PAD SPACE     |
| utf8_vietnamese_ci  | utf8     | 215 |         |...| PAD SPACE     |
+---------------------+----------+-----+---------+...+---------------+
272 rows in set (0.02 sec)
注意

可用的字符集和排序规则取决于 MySQL 服务器的构建和打包方式。我们展示的示例来自默认的 MySQL 8.0 安装,相同的数字在 Linux 和 Windows 上也可以看到。然而,MariaDB 10.5 有 322 种排序规则,但只有 40 种字符集。

您可以按以下方式查看服务器的当前默认设置:

mysql> `SHOW` `VARIABLES` `LIKE` `'c%'``;`
+--------------------------+--------------------------------+
| Variable_name            | Value                          |
+--------------------------+--------------------------------+
| ...                                                       |
| character_set_client     | utf8mb4                        |
| character_set_connection | utf8mb4                        |
| character_set_database   | utf8mb4                        |
| character_set_filesystem | binary                         |
| character_set_results    | utf8mb4                        |
| character_set_server     | utf8mb4                        |
| character_set_system     | utf8                           |
| character_sets_dir       | /usr/share/mysql-8.0/charsets/ |
| ...                                                       |
| collation_connection     | utf8mb4_0900_ai_ci             |
| collation_database       | utf8mb4_0900_ai_ci             |
| collation_server         | utf8mb4_0900_ai_ci             |
| ...                                                       |
+--------------------------+--------------------------------+
21 rows in set (0.00 sec)

在创建数据库时,您可以设置数据库及其表的默认字符集和排序顺序。例如,如果要使用 utf8mb4 字符集和 utf8mb4_ru_0900_as_cs(区分大小写)的排序顺序,您可以编写:

mysql> `CREATE` `DATABASE` `rose` `DEFAULT` `CHARACTER` `SET` `utf8mb4`
    -> `COLLATE` `utf8mb4_ru_0900_as_cs``;`
Query OK, 1 row affected (0.00 sec)

如果您已正确为您的语言和地区安装了 MySQL,并且不打算国际化应用程序,则通常不需要这样做。从 MySQL 8.0 开始,默认字符集为 utf8mb4,甚至更少需要更改字符集。您还可以控制单独表或列的字符集和排序规则,但我们不会在这里详细讨论如何执行此操作。我们将讨论排序规则如何影响字符串类型的问题,详见“字符串类型”。

其他特性

本节简要描述了 CREATE TABLE 语句的其他功能。其中包括使用 IF NOT EXISTS 功能的示例,以及在本书中查找更多关于高级功能及其详细信息的列表。所示的语句是从 sakila 数据库中完整表示的表,不像之前的简化示例。

在创建表时,可以使用 IF NOT EXISTS 关键字短语,其工作方式与创建数据库类似。以下是一个示例,即使 actor 表已存在也不会报错:

mysql> `CREATE` `TABLE` `IF` `NOT` `EXISTS` `actor` `(`
    -> `actor_id` `SMALLINT` `UNSIGNED` `NOT` `NULL` `AUTO_INCREMENT``,`
    -> `first_name` `VARCHAR``(``45``)` `NOT` `NULL``,`
    -> `last_name` `VARCHAR``(``45``)` `NOT` `NULL``,`
    -> `last_update` `TIMESTAMP` `NOT` `NULL` `DEFAULT`
    -> `CURRENT_TIMESTAMP` `ON` `UPDATE` `CURRENT_TIMESTAMP``,`
    -> `PRIMARY` `KEY`  `(``actor_id``)``,`
    -> `KEY` `idx_actor_last_name` `(``last_name``)``)``;`
Query OK, 0 rows affected, 1 warning (0.01 sec)

您可以看到没有影响任何行,并且报告了一个警告。让我们来看看:

mysql> `SHOW` `WARNINGS``;`
+-------+------+------------------------------+
| Level | Code | Message                      |
+-------+------+------------------------------+
| Note  | 1050 | Table 'actor' already exists |
+-------+------+------------------------------+
1 row in set (0.01 sec)

CREATE TABLE语句中可以添加各种附加功能,本示例中仅列出了其中几个。许多这些功能都是高级功能,在本书中未涉及,但您可以在 MySQL 参考手册的CREATE TABLE语句部分找到更多信息。

数值列的AUTO_INCREMENT功能

此功能允许您为表自动创建唯一标识符。我们在“AUTO_INCREMENT 功能”中详细讨论它。

列注释

可以向列添加注释;当您使用稍后在本节讨论的SHOW CREATE TABLE命令时,会显示这些注释。

外键约束

您可以告诉 MySQL 检查一个或多个列中的数据是否与另一个表中的数据匹配。例如,sakila数据库在address表的city_id列上有一个外键约束,引用city表的city_id列。这意味着在city表中不存在的城市中无法有地址。我们在第二章介绍了外键约束,并将在“备用存储引擎”中查看哪些引擎支持外键约束。并非 MySQL 的每个存储引擎都支持外键。

创建临时表

如果使用CREATE TEMPORARY TABLE关键字短语创建表,则在连接关闭时将删除(丢弃)该表。这对于复制和重新格式化数据很有用,因为您无需记住清理工作。有时临时表也用作优化,用于保存一些中间数据。

高级表选项

您可以使用表选项控制表的广泛功能范围。这些包括AUTO_INCREMENT的起始值、索引和行存储方式以及覆盖 MySQL 查询优化器从表中收集的信息的选项。还可以指定生成的列,包含像两个其他列的总和之类的数据,以及这些列的索引。

索引结构控制

MySQL 中的一些存储引擎允许您指定和控制 MySQL 在其索引上使用的内部结构类型,如 B 树或哈希表。您还可以告诉 MySQL 希望在列上创建全文或空间数据索引,允许特殊类型的搜索。

分区

MySQL 支持不同的分区策略,您可以在创建表时或以后选择。我们不会在本书中涵盖分区。

您可以使用在第三章中介绍的SHOW CREATE TABLE语句查看用于创建表的语句。这通常显示了包含我们刚讨论的一些高级功能的输出;输出很少与实际用于创建表的内容匹配。以下是actor表的一个示例:

mysql> `SHOW` `CREATE` `TABLE` `actor``\``G`
*************************** 1\. row ***************************
       Table: actor
Create Table: CREATE TABLE `actor` (
  `actor_id` smallint unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
        COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

您会注意到输出包含 MySQL 添加的内容,这些内容不在我们原始的CREATE TABLE语句中:

  • 表和列的名称用反引号括起来。这并非必需,但可以避免由于使用保留字和特殊字符而引起的任何解析问题,如前面讨论的。

  • 包括额外默认的ENGINE子句,明确说明应使用的表类型。在 MySQL 的默认安装中设置为InnoDB,因此在此示例中没有影响。

  • 包括额外的DEFAULT CHARSET子句,告诉 MySQL 表中的列使用的字符集是什么。同样,在默认安装中没有影响。

列类型

本节描述了您可以在 MySQL 中使用的列类型。它解释了何时应使用每种类型以及它的任何限制。这些类型按其用途分组。我们将涵盖最常用的数据类型,并简要提及更高级或不常用的类型。这并不意味着它们没有用处,但考虑学习它们作为一种练习。您很可能不会记住每种数据类型及其特定的复杂性,这没关系。值得稍后重新阅读本章节并查阅有关主题的 MySQL 文档,以保持您的知识最新。

整数类型

我们将从数值数据类型开始,更具体地说是整数类型,或者保存特定整数的类型。首先,两种最流行的整数类型:

INT[(*width*)] [UNSIGNED] [ZEROFILL]

这是最常用的数字类型;它在范围内存储整数(整数)值-2,147,483,648 至 2,147,483,647。如果添加可选的UNSIGNED关键字,则范围为 0 至 4,294,967,295。关键字INTINTEGER的缩写,它们可以互换使用。INT列需要 4 个字节的存储空间。

INT,以及其他整数类型,具有两个特定于 MySQL 的属性:可选的*width*ZEROFILL参数。它们不是 SQL 标准的一部分,并且自 MySQL 8.0 起已被弃用。尽管如此,您肯定会在许多代码库中注意到它们,因此我们将简要介绍它们。

*width*参数指定显示宽度,可以作为列元数据的一部分被应用程序读取。与其他数据类型类似位置的参数不同,此参数对特定整数类型的存储特性没有影响,也不限制可用值的范围。对于数据存储目的,INT(4)INT(32)是相同的。

ZEROFILL是一个额外的参数,用于将值左填充为零,直到达到*width*参数指定的长度。如果使用ZEROFILL,MySQL 会自动将UNSIGNED添加到声明中(因为零填充只在正数的情况下有意义)。

在一些需要ZEROFILL*width*的应用程序中,可以使用LPAD()函数,或者可以将数字存储为格式化的CHAR列。

BIGINT[(*width*)] [UNSIGNED] [ZEROFILL]

在数据规模不断增长的世界中,拥有数十亿行计数的表格变得越来越普遍。即使是简单的id类型列,可能也需要比普通的INT提供的范围更广。BIGINT解决了这个问题。它是一个大整数类型,有一个带符号范围从–9,223,372,036,854,775,808 到 9,223,372,036,854,775,807。无符号的BIGINT可以存储从 0 到 18,446,744,073,709,551,615 的数字。这种类型的列将需要 8 字节的存储空间。

在 MySQL 中,所有计算都是使用带符号的BIGINTDOUBLE值完成的。这其中的重要结果是,在处理非常大的数字时,必须非常小心。有两个问题需要注意。首先,大于 9,223,372,036,854,775,807 的无符号大整数只能与位函数一起使用。其次,如果算术运算的结果大于 9,223,372,036,854,775,807,则可能会观察到意外的结果。

例如:

mysql> `CREATE` `TABLE` `test_bigint` `(``id` `BIGINT` `UNSIGNED``)``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `INSERT` `INTO` `test_bigint` `VALUES` `(``18446744073709551615``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `test_bigint` `VALUES` `(``18446744073709551615``-``1``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `test_bigint` `VALUES` `(``184467440737095516``*``100``)``;`
ERROR 1690 (22003): BIGINT value
is out of range in '(184467440737095516 * 100)'

即使 18,446,744,073,709,551,600 小于 18,446,744,073,709,551,615,由于内部使用带符号的BIGINT进行乘法,观察到了超出范围的错误。

提示

SERIAL数据类型可以用作BIGINT UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE的别名。除非必须优化数据大小和性能,否则考虑在类似id的列中使用SERIAL。即使是UNSIGNED INT,其超出范围的速度也比你预期的快得多,并且通常出现在最糟糕的时候。

请记住,虽然可以将每个整数存储为BIGINT,但从存储空间的角度来看这是浪费的。此外,正如我们讨论过的,*width*参数并不限制值的范围。为了节省空间并对存储的值设置约束,应该使用不同的整数类型:

SMALLINT[(*width*)] [UNSIGNED] [ZEROFILL]

存储小整数,带有带符号范围从–32,768 到 32,767 和无符号范围从 0 到 65,535。它需要 2 字节的存储空间。

TINYINT[(*width*)] [UNSIGNED] [ZEROFILL]

最小的数值数据类型,可以存储更小的整数。这种类型的范围是带符号的–128 到 127,无符号的 0 到 255。它只需要 1 字节的存储空间。

BOOL[(*width*)]

缩写为BOOLEAN,是TINYINT(1)的同义词。通常,布尔类型只接受两个值:真或假。但是,由于 MySQL 中的BOOL是整数类型,你可以在BOOL中存储–128 到 127 的值。值为 0 将被视为假,所有非零值将被视为真。还可以使用特殊的truefalse别名来代表 1 和 0。以下是一些例子:

mysql> `CREATE` `TABLE` `test_bool` `(``i` `BOOL``)``;`
Query OK, 0 rows affected (0.04 sec)
mysql> `INSERT` `INTO` `test_bool` `VALUES` `(``true``)``,``(``false``)``;`
Query OK, 2 rows affected (0.00 sec)
Records: 2  Duplicates: 0  Warnings: 0
mysql> `INSERT` `INTO` `test_bool` `VALUES` `(``1``)``,``(``0``)``,``(``-``128``)``,``(``127``)``;`
Query OK, 4 rows affected (0.02 sec)
Records: 4  Duplicates: 0  Warnings: 0
mysql> `SELECT` `i``,` `IF``(``i``,``'true'``,``'false'``)` `FROM` `test_bool``;`
+------+----------------------+
| i    | IF(i,'true','false') |
+------+----------------------+
|    1 | true                 |
|    0 | false                |
|    1 | true                 |
|    0 | false                |
| -128 | true                 |
|  127 | true                 |
+------+----------------------+
6 rows in set (0.01 sec)

MEDIUMINT[(*width*)] [UNSIGNED] [ZEROFILL]

存储有带符号范围从–8,388,608 到 8,388,607 和无符号范围从 0 到 16,777,215 的值。它需要 3 字节的存储空间。

BIT[(*M*)]

用于存储位值的特殊类型。*M* 指定每个值的位数,默认为 1(如果省略)。MySQL 使用 b'*value* 语法来表示二进制值。

固定点类型

在 MySQL 中,DECIMALNUMERIC 数据类型是相同的,因此虽然我们这里只描述 DECIMAL,但此描述也适用于 NUMERIC。固定点和浮点类型的主要区别在于精度。对于固定点类型,检索的值与存储的值相同;对于包含小数点的类型(例如后面描述的 FLOATDOUBLE 类型),情况并非总是如此。这是 DECIMAL 数据类型的最重要属性,它是 MySQL 中常用的数值类型之一:

DECIMAL[(*width*[,*decimals*])] [UNSIGNED] [ZEROFILL]

存储固定点数,如工资或距离,总共有*width*位数,其中一些较小的数字跟随小数点。例如,声明为 price DECIMAL(6,2) 的列可用于存储范围内的值 -9,999.99 到 9,999.99。price DECIMAL(10,4) 允许像 123,456.1234 这样的值。

在 MySQL 5.7 之前,如果尝试存储超出此范围的值,它将存储为允许范围内最接近的值。例如,100 将存储为 99.99,而 -100 将存储为 -99.99。然而,从版本 5.7.5 开始,默认的 SQL 模式包含 STRICT_TRANS_TABLES 模式,禁止此类和其他不安全的行为。使用旧的行为仍然可能,但可能导致数据丢失。

SQL 模式是特殊设置,控制 MySQL 在查询时的行为。例如,它们可以限制“不安全”行为或影响查询的解释方式。为了学习 MySQL,建议您保持默认设置,因为它们是安全的。根据 MySQL 的发布版本,可能需要更改 SQL 模式以与旧应用程序兼容。

*width* 参数是可选的,当省略时默认为 10。*decimals* 的数量也是可选的,当省略时默认为 0;*decimals* 的最大值不得超过 *width* 的值。*width* 的最大值为 65,*decimals* 的最大值为 30。

如果只存储正值,可以像INT一样使用 UNSIGNED 关键字。如果需要零填充,可以使用 ZEROFILL 关键字,与INT中描述的行为相同。DECIMAL 关键字有三个相同且可互换的替代词:DECNUMERICFIXED

DECIMAL列中存储的值采用二进制格式。该格式对于每九位数字使用 4 字节。

浮点类型

除了上一节中描述的固定点 DECIMAL 类型外,还有两种支持小数点的类型:DOUBLE(也称为 REAL)和 FLOAT。它们设计用于存储近似数值而不是 DECIMAL 存储的精确值。

为什么要使用近似值?答案是许多带有小数点的数字都是真实量的近似值。例如,假设您的年收入为$50,000,并且希望将其存储为月工资。当您将其转换为每月金额时,是$4,166 加上 66 和 2/3 美分。如果将此存储为$4,166.67,则不精确到足以转换为年薪(因为 12 乘以$4,166.67 是$50,000.04)。但是,如果以足够多的小数位数存储 2/3,则是一个更接近的近似值。您将发现,在像 MySQL 这样的高精度环境中,仅需进行少量舍入,就足以正确地乘以以获得原始值。这就是 DOUBLEFLOAT 有用的地方:它们允许您存储诸如 2/3 或 pi 等值,带有大量小数位数,从而精确地近似表示确切量。稍后,您可以使用 ROUND() 函数将结果恢复到给定的精度。

让我们继续上一个示例,使用 DOUBLE。假设您创建了以下表:

mysql> `CREATE` `TABLE` `wage` `(``monthly` `DOUBLE``)``;`
Query OK, 0 rows affected (0.09 sec)

现在可以使用以下方式插入月工资:

mysql> `INSERT` `INTO` `wage` `VALUES` `(``50000``/``12``)``;`
Query OK, 1 row affected (0.00 sec)

并查看存储内容:

mysql> `SELECT` `*` `FROM` `wage``;`
+----------------+
| monthly        |
+----------------+
| 4166.666666666 |
+----------------+
1 row in set (0.00 sec)

然而,当您将其乘以以获得年度值时,会得到一个高精度的近似值:

mysql> `SELECT` `monthly``*``12` `FROM` `wage``;`
+--------------------+
| monthly*12         |
+--------------------+
| 49999.999999992004 |
+--------------------+
1 row in set (0.00 sec)

要恢复原始值,您仍然需要以所需的精度执行舍入。例如,您的业务可能要求精确到五位小数点位数。在这种情况下,您可以使用以下方法将原始值恢复:

mysql> `SELECT` `ROUND``(``monthly``*``12``,``5``)` `FROM` `wage``;`
+---------------------+
| ROUND(monthly*12,5) |
+---------------------+
|         50000.00000 |
+---------------------+
1 row in set (0.00 sec)

但是精确到八位小数点位数不会得到原始值:

mysql> `SELECT` `ROUND``(``monthly``*``12``,``8``)` `FROM` `wage``;`
+---------------------+
| ROUND(monthly*12,8) |
+---------------------+
|      49999.99999999 |
+---------------------+
1 row in set (0.00 sec)

理解浮点数据类型的不精确和近似性质是非常重要的。

以下是 FLOATDOUBLE 类型的详细信息:

FLOAT[(*宽度*, *小数位数*)] [UNSIGNED] [ZEROFILL]FLOAT[(*精度*)] [UNSIGNED] [ZEROFILL]

存储浮点数。它有两种可选的语法:第一种允许可选的*小数位数*和可选的显示*宽度*,第二种允许可选的*精度*,该精度控制以比特为单位的近似精度。在没有参数的情况下(典型用法),该类型存储小型的、4 字节的单精度浮点数值。当*精度*在 0 到 24 之间时,采用默认行为。当*精度*在 25 到 53 之间时,该类型的行为类似于DOUBLE*宽度*参数不影响存储内容,只影响显示内容。UNSIGNEDZEROFILL 选项的行为与 INT 类型相同。

DOUBLE[(*宽度*, *小数位数*)] [UNSIGNED] [ZEROFILL]

存储浮点数。允许指定可选的*小数位数*和可选的显示*宽度*。在没有参数的情况下(典型用法),该类型存储普通的 8 字节双精度浮点数值。*宽度*参数不影响存储内容,只影响显示内容。UNSIGNEDZEROFILL 选项的行为与 INT 类型相同。DOUBLE 类型有两个相同的同义词:REALDOUBLE PRECISION

字符串类型

字符串数据类型用于存储文本和不那么明显的二进制数据。MySQL 支持以下字符串类型:

[国家] VARCHAR(*width*) [字符集 *charset_name*] [排序 *collation_name*]

可能是最常用的字符串类型之一,VARCHAR 可以存储长度可变的字符串,最大长度为 *width**width* 的最大值为 65,535 个字符。大部分适用于该类型的信息也适用于其他字符串类型。

CHARVARCHAR 类型非常相似,但有几个重要的区别。VARCHAR 需要额外的一到两个字节的开销来存储字符串的值,具体取决于值是大于还是小于 255 字节。注意这个大小与字符长度不同,因为某些字符可能需要最多 4 个字节的空间。因此,VARCHAR 看起来效率较低并不总是正确的。因为 VARCHAR 可以存储任意长度的字符串(最多定义为 *width*),比起相似长度的 CHAR,较短的字符串将需要更少的存储空间。

CHARVARCHAR 之间的另一个差异是它们对尾随空格的处理。VARCHAR 保留尾随空格直到指定的列宽,并将多余部分截断,会产生警告。稍后将展示,CHAR 值会右填充到列宽,并且不会保留尾随空格。对于 VARCHAR,尾随空格是重要的,除非它们被修剪,并且将计为唯一值。让我们演示一下:

mysql> `CREATE` `TABLE` `test_varchar_trailing``(``d` `VARCHAR``(``2``)` `UNIQUE``)``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `INSERT` `INTO` `test_varchar_trailing` `VALUES` `(``'a'``)``,` `(``'a '``)``;`
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0
mysql> `SELECT` `d``,` `LENGTH``(``d``)` `FROM` `test_varchar_trailing``;`
+------+-----------+
| d    | LENGTH(d) |
+------+-----------+
| a    |         1 |
| a    |         2 |
+------+-----------+
2 rows in set (0.00 sec)

我们插入的第二行有一个尾随空格,由于列 d*width* 为 2,该空格对行的唯一性计数起作用。然而,如果我们尝试插入一个有两个尾随空格的行:

mysql> `INSERT` `INTO` `test_varchar_trailing` `VALUES` `(``'a  '``)``;`
ERROR 1062 (23000): Duplicate entry 'a '
for key 'test_varchar_trailing.d'

MySQL 拒绝接受新行。VARCHAR(2) 会隐式截断超出设定 *width* 的尾随空格,因此存储的值会从 "a "a 后有双空格)变为 "a "a 后只有单空格)。由于已经存在这样一个值的行,报告了重复条目错误。可以通过更改列排序规则来控制 VARCHARTEXT 的此行为。一些排序规则,如 latin1_bin,具有 PAD SPACE 属性,意味着在检索时它们会用空格填充到 *width*。这不影响存储,但会影响唯一性检查以及 GROUP BYDISTINCT 操作符的工作方式,我们将在第五章中讨论。您可以通过运行 SHOW COLLATION 命令来检查排序规则是否为 PAD SPACENO PAD,正如我们在“排序规则和字符集”中所示。让我们通过创建一个具有 PAD SPACE 排序规则的表来查看其效果:

mysql> `CREATE` `TABLE` `test_varchar_pad_collation``(`
    -> `data` `VARCHAR``(``5``)` `CHARACTER` `SET` `latin1`
    -> `COLLATE` `latin1_bin` `UNIQUE``)``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `INSERT` `INTO` `test_varchar_pad_collation` `VALUES` `(``'a'``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `INSERT` `INTO` `test_varchar_pad_collation` `VALUES` `(``'a '``)``;`
ERROR 1062 (23000): Duplicate entry 'a '
for key 'test_varchar_pad_collation.data'

NO PAD 排序规则是 MySQL 8.0 的新添加。在之前的 MySQL 版本中,你可能经常看到的每种排序规则都隐含具有 PAD SPACE 属性。因此,在 MySQL 5.7 及之前的版本中,保留尾部空格的唯一选择是使用二进制类型:VARBINARYBLOB

注意

CHARVARCHAR 数据类型都不允许存储超过 *width* 的值,除非禁用严格 SQL 模式(即未启用 STRICT_ALL_TABLESSTRICT_TRANS_TABLES)。禁用保护后,超过 *width* 的值将被截断,并显示警告。我们不建议启用旧版行为,因为可能导致数据丢失。

VARCHARCHARTEXT 类型的排序和比较根据分配的字符集的排序规则进行。您可以看到,可以为每个单独的字符串类型列指定字符集和排序规则。还可以指定 binary 字符集,有效地将 VARCHAR 转换为 VARBINARY。不要将 binary 字符集误认为是字符集的 BINARY 属性;后者是 MySQL 的一种仅用于指定二进制(_bin)排序规则的简写。

此外,可以在 ORDER BY 子句中直接指定排序规则。可用的排序规则将取决于列的字符集。继续使用 test_varchar_pad_collation 表,可以在其中存储一个 ä 符号,然后查看排序规则对字符串排序的影响:

mysql> `INSERT` `INTO` `test_varchar_pad_collation` `VALUES` `(``'ä'``)``,` `(``'z'``)``;`
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0
mysql> `SELECT` `*` `FROM` `test_varchar_pad_collation`
    -> `ORDER` `BY` `data` `COLLATE` `latin1_german1_ci``;`
+------+
| data |
+------+
| a    |
| ä    |
| z    |
+------+
3 rows in set (0.00 sec)
mysql> `SELECT` `*` `FROM` `test_varchar_pad_collation`
    -> `ORDER` `BY` `data` `COLLATE` `latin1_swedish_ci``;`
+------+
| data |
+------+
| a    |
| z    |
| ä    |
+------+
3 rows in set (0.00 sec)

NATIONAL(或其等效的缩写形式 NCHAR)属性是指定字符串类型列必须使用预定义字符集的标准 SQL 方式。MySQL 使用 utf8 作为此字符集。重要的是要注意 MySQL 5.7 和 8.0 在 utf8 的具体定义上存在差异:前者将其用作 utf8mb3 的别名,后者则用于 utf8mb4。因此,最好不要使用 NATIONAL 属性以及含糊不清的别名。对于任何涉及文本列和数据的最佳实践是尽可能明确和具体。

[国家] CHAR(*width*) [字符集 *charset_name*] [排序规则 *collation_name*]

CHAR 存储长度固定的字符串(例如姓名、地址或城市),长度为 *width*。如果未提供 *width*,则假定为 CHAR(1)*width* 的最大值为 255。与 VARCHAR 一样,CHAR 列中的值始终存储在指定的长度上。存储在 CHAR(255) 列中的单个字母将占用 255 字节(在 latin1 字符集下),并且会填充空格。读取数据时会移除填充,除非启用了 PAD_CHAR_TO_FULL_LENGTH SQL 模式。再次强调,这意味着存储在 CHAR 列中的字符串将丢失所有尾部空格。

在过去,CHAR列的*width*通常与字节大小相关联。 现在情况并非总是如此,而且默认情况下也不是如此。 例如,默认的 MySQL 8.0 中的多字节字符集,如utf8mb4,可能导致更大的值。 如果最大大小超过 768 字节,则 InnoDB 实际上将固定长度列编码为可变长度列。 因此,在 MySQL 8.0 中,默认情况下 InnoDB 将CHAR(255)列存储为VARCHAR列。 以下是一个例子:

mysql> `CREATE` `TABLE` `test_char_length``(`
    ->   `utf8char` `CHAR``(``10``)` `CHARACTER` `SET` `utf8mb4`
    -> `,` `asciichar` `CHAR``(``10``)` `CHARACTER` `SET` `binary`
    -> `)``;`
Query OK, 0 rows affected (0.04 sec)
mysql> `INSERT` `INTO` `test_char_length` `VALUES` `(``'Plain text'``,` `'Plain text'``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `test_char_length` `VALUES` `(`'的開源軟體'`,` `'Plain text'``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `LENGTH``(``utf8char``)``,` `LENGTH``(``asciichar``)` `FROM` `test_char_length``;`
+------------------+-------------------+
| LENGTH(utf8char) | LENGTH(asciichar) |
+------------------+-------------------+
|               10 |                10 |
|               15 |                10 |
+------------------+-------------------+
2 rows in set (0.00 sec)

由于值左对齐并在右侧填充空格,并且根本不考虑CHAR中的任何尾随空格,因此无法比较仅由空格组成的字符串。 如果发现自己处于这种情况下是重要的,VARCHAR是要使用的数据类型。

BINARY[(*width*)]VARBINARY(*width*)

这些类型与CHARVARCHAR非常相似,但存储二进制字符串。 二进制字符串具有特殊的binary字符集和排序规则,排序依赖于存储的值的字节的数值。 存储字节字符串而不是字符字符串。 在前面讨论VARCHAR时,我们描述了binary字符集和BINARY属性。 仅binary字符集将VARCHARCHAR“转换”为其相应的BINARY形式。 将BINARY属性应用于字符集不会改变存储字符字符串的事实。 与VARCHARCHAR不同,*width*在这里确切地是字节数。 对于BINARY,当省略*width*时,默认为 1。

CHAR类似,BINARY列中的数据在右侧填充。 但是,作为二进制数据,它使用零字节进行填充,通常写为0x00\0BINARY将空格视为显著字符,而不是填充。 如果您需要存储可能以对您重要的零字节结尾的数据,请使用VARBINARYBLOB类型。

在使用这两种数据类型时,牢记二进制字符串的概念至关重要。 尽管它们接受字符串,但它们不是使用文本字符串的数据类型的同义词。 例如,您无法更改存储的字母的大小写,因为该概念实际上不适用于二进制数据。 当您考虑实际存储的数据时,这一点变得非常明显。 让我们看一个例子:

mysql> `CREATE` `TABLE` `test_binary_data` `(`
    ->   `d1` `BINARY``(``16``)`
    -> `,` `d2` `VARBINARY``(``16``)`
    -> `,` `d3` `CHAR``(``16``)`
    -> `,` `d4` `VARCHAR``(``16``)`
    -> `)``;`
Query OK, 0 rows affected (0.03 sec)
mysql> `INSERT` `INTO` `test_binary_data` `VALUES` `(`
    ->   `'something'`
    -> `,` `'something'`
    -> `,` `'something'`
    -> `,` `'something'``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `d1``,` `d2``,` `d3``,` `d4` `FROM` `test_binary_data``;`
*************************** 1\. row ***************************
d1: 0x736F6D657468696E6700000000000000
d2: 0x736F6D657468696E67
d3: something
d4: something
1 row in set (0.00 sec)
mysql> `SELECT` `UPPER``(``d2``)``,` `UPPER``(``d4``)` `FROM` `test_binary_data``;`
*************************** 1\. row ***************************
UPPER(d2): 0x736F6D657468696E67
UPPER(d4): SOMETHING
1 row in set (0.01 sec)

注意 MySQL 命令行客户端实际上以十六进制格式显示二进制类型的值。 我们认为这比在 MySQL 8.0 之前执行的沉默转换要好得多,这可能导致误解。 要获取实际的文本数据,您必须将二进制数据显式转换为文本:

mysql> `SELECT` `CAST``(``d1` `AS` `CHAR``)` `d1t``,` `CAST``(``d2` `AS` `CHAR``)` `d2t`
    -> `FROM` `test_binary_data``;`
+------------------+-----------+
| d1t              | d2t       |
+------------------+-----------+
| something        | something |
+------------------+-----------+
1 row in set (0.00 sec)

BINARY填充在转换执行时会被转换为空格。

BLOB[(*width*)]TEXT[(*width*)] [CHARACTER SET *charset_name*] [COLLATE *collation_name*]

BLOBTEXT 是常用的用于存储大数据的数据类型。可以将 BLOB 视为可以容纳任意数据的 VARBINARYTEXTVARCHAR 同理。BLOBTEXT 类型分别可以存储最多 65,535 个字节或字符。请注意,多字节字符集确实存在。*width* 属性是可选的,当指定时,MySQL 实际上会将 BLOBTEXT 数据类型更改为能够容纳该数据量的最小类型。例如,BLOB(128) 将导致使用 TINYBLOB

mysql> `CREATE` `TABLE` `test_blob``(``data` `BLOB``(``128``)``)``;`
Query OK, 0 rows affected (0.07 sec)
mysql> `DESC` `test_blob``;`
+-------+----------+------+-----+---------+-------+
| Field | Type     | Null | Key | Default | Extra |
+-------+----------+------+-----+---------+-------+
| data  | tinyblob | YES  |     | NULL    |       |
+-------+----------+------+-----+---------+-------+
1 row in set (0.00 sec)

对于 BLOB 类型及相关类型,数据的处理与 VARBINARY 相同。也就是说,不假设字符集,并且基于实际存储的字节的数值进行比较和排序。对于 TEXT,可以指定确切的字符集和排序规则。对于这两种类型及其变体,在 INSERT 时不进行填充,在 SELECT 时不进行修剪,非常适合精确存储数据。此外,不允许使用 DEFAULT 子句,并且在 BLOBTEXT 列上创建索引时,必须定义一个前缀,限制索引值的长度。我们在 “键和索引” 中会详细讨论这一点。

BLOBTEXT 之间的一个潜在区别是它们对尾随空格的处理方式。正如我们已经展示的那样,根据使用的排序规则,VARCHARTEXT 可能会填充字符串。BLOBVARBINARY 都使用 binary 字符集和单一的 binary 排序规则,不进行填充,并且不受排序规则混淆及相关问题的影响。有时,使用这些类型可以增加额外的安全性。除此之外,在 MySQL 8.0 之前,这些类型是唯一保留尾随空格的类型。

TINYBLOBTINYTEXT [字符集 *charset_name*] [排序规则 *collation_name*]

这些类型与 BLOBTEXT 完全相同,只是最多可以存储 255 个字节或字符。

MEDIUMBLOBMEDIUMTEXT [字符集 *charset_name*] [排序规则 *collation_name*]

这些类型与 BLOBTEXT 完全相同,只是最多可以存储 16,777,215 个字节或字符。类型 LONGLONG VARCHAR 映射到 MEDIUMTEXT 数据类型,以保持兼容性。

LONGBLOBLONGTEXT [字符集 *charset_name*] [排序规则 *collation_name*]

这些类型与 BLOBTEXT 完全相同,只是最多可以存储 4 GB 的数据。请注意,即使是在 LONGTEXT 的情况下,这也是一个硬性限制,因此多字节字符集中的字符数可能少于 4,294,967,295。客户端可以存储的数据的有效最大大小将受到可用内存量以及 max_packet_size 变量值(默认为 64 MiB)的限制。

ENUM(**value1**[,**value2**[, …]]) [字符集 *charset_name*] [排序规则 *collation_name*]

这种类型存储一组字符串值的列表,或枚举ENUM类型的列可以设置为列表*value1**value2*等中的一个值,最多可达到 65,535 个不同的值。虽然这些值以字符串形式存储和检索,但在数据库中存储的是整数表示。ENUM列可以包含NULL值(存储为NULL)、空字符串''(存储为0)或任何有效元素(存储为123等)。您可以通过在创建表时将列声明为NOT NULL来阻止接受NULL值。

这种类型提供了一种紧凑的方式来存储预定义值列表中的值,例如州或国家名称。考虑以下使用水果名称的示例;名称可以是预定义值AppleOrangePear(以及NULL和空字符串)中的任何一个:

mysql> `CREATE` `TABLE` `fruits_enum`
    -> `(``fruit_name` `ENUM``(``'Apple'``,` `'Orange'``,` `'Pear'``)``)``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `INSERT` `INTO` `fruits_enum` `VALUES` `(``'Apple'``)``;`
Query OK, 1 row affected (0.00 sec)

如果尝试插入不在列表中的值,MySQL 会产生错误,告诉您它没有存储您请求的数据:

mysql> `INSERT` `INTO` `fruits_enum` `VALUES` `(``'Banana'``)``;`
ERROR 1265 (01000): Data truncated for column 'fruit_name' at row 1

也不接受几个允许的值列表:

mysql> `INSERT` `INTO` `fruits_enum` `VALUES` `(``'Apple,Orange'``)``;`
ERROR 1265 (01000): Data truncated for column 'fruit_name' at row 1

显示表的内容,您会看到没有存储无效值:

mysql> `SELECT` `*` `FROM` `fruits_enum``;`
+------------+
| fruit_name |
+------------+
| Apple      |
+------------+
1 row in set (0.00 sec)

MySQL 的早期版本会产生警告而不是错误,并在无效值的位置存储空字符串。通过禁用默认的严格 SQL 模式可以启用该行为。还可以指定除空字符串以外的默认值:

mysql> `CREATE` `TABLE` `new_fruits_enum`
    -> `(``fruit_name` `ENUM``(``'Apple'``,` `'Orange'``,` `'Pear'``)`
    -> `DEFAULT` `'Pear'``)``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `INSERT` `INTO` `new_fruits_enum` `VALUES``(``)``;`
Query OK, 1 row affected (0.02 sec)
mysql> `SELECT` `*` `FROM` `new_fruits_enum``;`
+------------+
| fruit_name |
+------------+
| Pear       |
+------------+
1 row in set (0.00 sec)

在这里,不指定值会导致存储默认值Pear

SET( *value1* [, *value2* [, …]]) [CHARACTER SET *charset_name*] [COLLATE *collation_name*]

这种类型存储一组字符串值。SET类型的列可以设置为列表*value1**value2*等中的零个或多个值,最多可达到 64 个不同的值。虽然这些值是字符串,但在数据库中存储的是整数表示。SETENUM不同之处在于每行只能在列中存储一个ENUM值,但可以存储多个SET值。这种类型适用于从列表中存储选择的选项,例如用户偏好。考虑以下使用水果名称的示例;名称可以是预定义值的任意组合:

mysql> `CREATE` `TABLE` `fruits_set`
    -> `(` `fruit_name` `SET``(``'Apple'``,` `'Orange'``,` `'Pear'``)` `)``;`
Query OK, 0 rows affected (0.08 sec)
mysql> `INSERT` `INTO` `fruits_set` `VALUES` `(``'Apple'``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `INSERT` `INTO` `fruits_set` `VALUES` `(``'Banana'``)``;`
ERROR 1265 (01000): Data truncated for column 'fruit_name' at row 1
mysql> `INSERT` `INTO` `fruits_set` `VALUES` `(``'Apple,Orange'``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `*` `FROM` `fruits_set``;`
+--------------+
| fruit_name   |
+--------------+
| Apple        |
| Apple,Orange |
+--------------+
2 rows in set (0.00 sec)

再次注意,我们可以在单个字段中存储来自集合的多个值,并且对于无效输入会存储空字符串。

与数值类型一样,我们建议始终选择最小可能的类型来存储值。例如,如果要存储城市名称,请使用CHARVARCHAR而不是TEXT类型。较短的列有助于减小表的大小,从而在服务器需要搜索表时提高性能。

使用固定大小的 CHAR 类型通常比使用可变大小的 VARCHAR 类型更快,因为 MySQL 服务器知道每行的起始和结束位置,并且可以快速跳过行以找到需要的行。然而,对于固定长度字段来说,未使用的空间将会被浪费。例如,如果允许城市名最多为 40 个字符,则 CHAR(40) 将始终使用 40 个字符,无论实际的城市名有多长。如果声明城市名为 VARCHAR(40),则只会使用所需的空间,再加上 1 个字节来存储名称的长度。如果平均城市名长度为 10 个字符,这意味着使用可变长度字段每个条目将平均少占用 29 个字节的空间。如果要存储数百万个地址,这可能会产生很大的差异。

一般来说,如果存储空间有限或者预期字符串长度变化较大,请使用可变长度字段;如果性能是优先考虑的因素,请使用固定长度字段。

日期和时间类型

这些类型用于存储特定的时间戳、日期或时间范围。在处理时区时需要特别注意。我们将尽力解释细节,但当您真正需要处理时区时,建议重新阅读本节和文档。

DATE

*YYYY-MM-DD* 格式存储和显示日期范围为 1000-01-01 至 9999-12-31 的日期。日期必须始终以年、月、日的三元组输入,但输入的格式可以有所不同,如下例所示:

*YYYY-MM-DD**YY-MM-DD*

提供两位数年份或四位数年份都是可选的。我们强烈建议使用四位数版本以避免世纪混淆。实际上,如果使用两位数版本,您会发现 70 至 99 被解释为 1970 至 1999 年,而 00 至 69 被解释为 2000 至 2069 年。

*YYYY/MM/DD*, *YYYY:MM:DD*, *YY-MM-DD* 或其他带标点的格式

MySQL 允许使用任何标点符号来分隔日期的各个组成部分。我们建议使用破折号,并再次避免使用两位数的年份。

*YYYY-M-D*, *YYYY-MM-D**YYYY-M-DD*

当使用标点符号时(再次强调,允许使用任何标点符号),可以指定单个数字的天和月。例如,2006 年 2 月 2 日可以指定为 2006-2-2。同样提供了两位数年份的等效形式,但不建议使用。

*YYYYMMDD**YYMMDD*

这两种日期格式可以省略标点符号,但数字序列必须是六位或八位长度。

您还可以通过提供DATETIMETIMESTAMP后面描述的格式来输入日期和时间的组合,但只有日期组件存储在DATE列中。无论输入类型如何,存储和显示类型始终为*YYYY-MM-DD*零日期 0000-00-00在所有版本中都被允许,并且可用于表示未知或虚拟值。如果输入日期超出范围,则存储零日期。但是,只有 MySQL 版本包括和之前的 5.6 默认设置允许此行为:STRICT_TRANS_TABLESNO_ZERO_DATENO_ZERO_IN_DATE

如果您正在使用较旧版本的 MySQL,我们建议您将这些模式添加到当前会话中:

mysql> `SET` `sql_mode``=``CONCAT``(``@``@``sql_mode``,`
    -> `',STRICT_TRANS_TABLES'``,`
    -> `',NO_ZERO_DATE'``,` `',NO_ZERO_IN_DATE'``)``;`
提示

您还可以在全局服务器级别和配置文件中设置sql_mode变量。该变量必须列出您要启用的每个模式。

这里有一些使用 MySQL 8.0 默认设置插入日期的示例:

mysql> `CREATE` `TABLE` `testdate` `(``mydate` `DATE``)``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `INSERT` `INTO` `testdate` `VALUES` `(``'2020/02/0'``)``;`
ERROR 1292 (22007): Incorrect date value: '2020/02/0'
for column 'mydate' at row 1
mysql> `INSERT` `INTO` `testdate` `VALUES` `(``'2020/02/1'``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `INSERT` `INTO` `testdate` `VALUES` `(``'2020/02/31'``)``;`
ERROR 1292 (22007): Incorrect date value: '2020/02/31'
for column 'mydate' at row 1
mysql> `INSERT` `INTO` `testdate` `VALUES` `(``'2020/02/100'``)``;`
ERROR 1292 (22007): Incorrect date value: '2020/02/100'
for column 'mydate' at row 1

一旦执行INSERT语句,表将包含以下数据:

mysql> `SELECT` `*` `FROM` `testdate``;`
+------------+
| mydate     |
+------------+
| 2020-02-01 |
+------------+
1 row in set (0.00 sec)

MySQL 可以防止"坏"数据存储在您的表中。有时候您可能需要保留实际的输入并稍后手动处理它。您可以通过从sql_mode变量的模式列表中移除上述 SQL 模式来实现。在这种情况下,在运行前面的INSERT语句后,您将得到以下数据:

mysql> `SELECT` `*` `FROM` `testdate``;`
+------------+
| mydate     |
+------------+
| 2020-02-00 |
| 2020-02-01 |
| 0000-00-00 |
| 0000-00-00 |
+------------+
4 rows in set (0.01 sec)

再次注意,日期显示为*YYYY-MM-DD*格式,无论输入方式如何。

TIME [*fraction*]

*HHH:MM:SS*格式存储时间范围为–838:59:59 至 838:59:59。这对于存储某些活动的持续时间非常有用。可以存储的值超出了 24 小时制时钟的范围,以允许计算和存储时间值之间的大差异(最多 34 天 22 小时 59 分钟 59 秒)。在TIME和其他相关数据类型中,*fraction*指定了分数秒精度范围为 0 至 6。默认值为 0,表示不保存分数秒。

时间必须按照小时分钟的顺序输入,使用以下格式:

*DD HH:MM:SS[.fraction]**HH:MM:SS[.fraction]**DD HH:MM**HH:MM**DD HH**SS[.fraction]*

*DD*表示 0 到 34 范围内的一位或两位数字的天值。*DD*值与小时值*HH*之间用空格分隔,而其他组件之间用冒号分隔。请注意,*MM:SS*不是有效的组合,因为它不能与*HH:MM*区分开来。如果TIME定义不指定*fraction*或将其设置为 0,则插入分数秒将导致值被四舍五入到最近的秒。

例如,如果在 TIME 列中插入 2 13:25:58.999999,带有 *小数* 为 0,则存储值为 61:25:59,因为 2 天(48 小时)加上 13 小时等于 61 小时。从 MySQL 5.7 开始,默认 SQL 模式设置禁止插入不正确的值。然而,可以启用旧版行为。然后,如果尝试插入超出范围的值,则会生成警告,并且该值被限制为最大可用时间。类似地,如果尝试插入无效值,则会生成警告,并且该值被设置为零。您可以使用 SHOW WARNINGS 命令报告上一个 SQL 语句生成的警告的详细信息。我们建议保持默认严格的 SQL 模式。与 DATE 类型不同,允许不正确的 TIME 条目似乎没有任何好处,除了在应用程序端更容易管理错误和保持旧版行为。

让我们在实践中尝试所有这些:

mysql> `CREATE` `TABLE` `test_time``(``id` `SMALLINT``,` `mytime` `TIME``)``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `INSERT` `INTO` `test_time` `VALUES``(``1``,` `"2 13:25:59"``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `INSERT` `INTO` `test_time` `VALUES``(``2``,` `"35 13:25:59"``)``;`
ERROR 1292 (22007): Incorrect time value: '35 13:25:59'
for column 'mytime' at row 1
mysql> `INSERT` `INTO` `test_time` `VALUES``(``3``,` `"900.32"``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `*` `FROM` `test_time``;`
+------+----------+
| id   | mytime   |
+------+----------+
|    1 | 61:25:59 |
|    3 | 00:09:00 |
+------+----------+
2 rows in set (0.00 sec)

*H:M:S* 和单、双、三位数字组合

在插入或更新数据时,可以使用不同的数字组合;MySQL 将它们转换为内部时间格式并一致显示。例如,1:1:3 等同于 01:01:03。可以混合不同数量的数字;例如,1:12:3 等同于 01:12:03。考虑以下示例:

mysql> `CREATE` `TABLE` `mytime` `(``testtime` `TIME``)``;`
Query OK, 0 rows affected (0.12 sec)
mysql> `INSERT` `INTO` `mytime` `VALUES`
    -> `(``'-1:1:1'``)``,` `(``'1:1:1'``)``,`
    -> `(``'1:23:45'``)``,` `(``'123:4:5'``)``,`
    -> `(``'123:45:6'``)``,` `(``'-123:45:6'``)``;`
Query OK, 4 rows affected (0.00 sec)
Records: 4  Duplicates: 0  Warnings: 0
mysql> `SELECT` `*` `FROM` `mytime``;`
+------------+
| testtime   |
+------------+
|  -01:01:01 |
|   01:01:01 |
|   01:23:45 |
|  123:04:05 |
|  123:45:06 |
| -123:45:06 |
+------------+
5 rows in set (0.01 sec)

注意,小时显示为两位数,范围在 -99 到 99 之间。

*HHMMSS**MMSS**SS*

标点可以省略,但数字序列必须是两位、四位或六位数字长度。注意,最右边的数字对始终被解释为 *SS*(秒)值,右数第二对(如果存在)被解释为 *MM*(分钟),右数第三对(如果存在)被解释为 *HH*(小时)。结果是,例如 1222 被解释为 12 分钟 22 秒,而不是 12 小时 22 分。

您还可以通过提供 DATETIMETIMESTAMP 的描述格式的日期和时间来输入时间,但只有时间部分存储在 TIME 列中。无论输入类型如何,存储和显示类型始终为 *HH:MM:SS*零时间 00:00:00 可用于表示未知或虚拟值。

时间戳[(*小数*)]

存储和显示格式为 *YYYY-MM-DD HH:MM:SS[.fraction][time zone offset]* 的日期和时间对,范围从 1970-01-01 00:00:01.000000 到 2038-01-19 03:14:07.999999。这种类型与 DATETIME 类型非常相似,但有一些区别。两种类型都可以接受一个时区修饰符作为输入值 MySQL 8.0,并且两种类型将以相同的方式存储和呈现数据给同一时区内的任何客户端。但是,TIMESTAMP 列中的值始终在 UTC 时区内部存储,使得在处理不同时区的客户端时可以自动获取本地时区。这本身是一个非常重要的区别需要记住。可以说,TIMESTAMP 在处理不同时区时更方便使用。

在 MySQL 5.6 之前,仅 TIMESTAMP 类型支持自动初始化和更新。此外,每个给定表中只能有一个这样的列。但是,从 5.6 开始,TIMESTAMPDATETIME 都支持这些行为,并且可以有任意数量的列执行此操作。

存储在 TIMESTAMP 列中的值始终匹配模板 *YYYY-MM-DD HH:MM:SS[.fraction][time zone offset]*,但可以以多种格式提供值:

*YYYY-MM-DD HH:MM:SS**YY-MM-DD HH:MM:SS*

日期和时间组件遵循与之前描述的DATETIME组件相同的宽松限制。这包括允许任何标点字符,包括(与TIME不同)时间组件中使用标点灵活性。例如,–0— 是有效的。

*YYYYMMDDHHMMSS**YYMMDDHHMMSS*

可以省略标点符号,但字符串的长度应为 12 或 14 位数字。出于与 DATE 类型相同的原因,我们建议仅使用明确的 14 位版本。您可以指定其他长度的值而不提供分隔符,但我们不建议这样做。

让我们更详细地看看自动更新功能。你可以通过在创建表时或稍后修改时,向列定义中添加以下属性来控制此功能,我们将在“修改结构”中详细解释:

  1. 如果你希望时间戳仅在将新行插入表中时设置,可以在列声明的末尾添加DEFAULT CURRENT_TIMESTAMP

  2. 如果你不希望有默认时间戳,但希望每当行中的数据更新时使用当前时间,可以在列声明的末尾添加ON UPDATE CURRENT_TIMESTAMP

  3. 如果你希望以上两者同时实现——即,希望时间戳在每个新行中设为当前时间,并在修改现有行时也设为当前时间——在列声明的末尾添加DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP

如果对于TIMESTAMP列不指定DEFAULT NULLNULL,它将以0作为默认值。

YEAR[(4)]

存储一个位于 1901 到 2155 年之间的四位数年份,以及 零年,0000。非法值会被转换为零年。您可以将年份值输入为字符串(如 '2005')或整数(如 2005)。YEAR 类型需要 1 字节的存储空间。

在早期的 MySQL 版本中,可以指定*digits*参数,传递24。两位数版本存储的值从 70 到 69,表示 1970 到 2069 年。MySQL 8.0 不支持两位数的 YEAR 类型,并且为了显示目的而指定 *digits* 参数已被弃用。

DATETIME[(*fraction*)]

*YYYY-MM-DD HH:MM:SS[.fraction][time zone offset]*格式存储和显示日期和时间对,范围从1000-01-01 00:00:009999-12-31 23:59:59。对于 TIMESTAMP,存储的值总是匹配模板*YYYY-MM-DD HH:MM:SS*,但值可以以 TIMESTAMP 描述中列出的相同格式输入。如果只向 DATETIME 列分配日期,则假定零时间00:00:00。如果只向 DATETIME 列分配时间,则假定零日期0000-00-00。该类型具有与 TIMESTAMP 相同的自动更新特性。除非为 DATETIME 列指定 NOT NULL 属性,否则 NULL 值为默认值;否则,默认值为 0。与 TIMESTAMP 不同,DATETIME 值不会转换为 UTC 时区进行存储。

其他类型

到目前为止,截至 MySQL 8.0,空间和 JSON 数据类型归入这一广泛类别。使用这些是一个非常高级的主题,我们不会深入讨论它们。

空间数据类型涉及存储几何对象,MySQL 有与 OpenGIS 类对应的类型。处理这些类型是一个值得单独撰写一本书的主题。

JSON 数据类型允许原生存储有效的 JSON 文档。在 MySQL 5.7 之前,JSON 通常存储在 TEXT 或类似的列中。然而,这有许多缺点:例如,文档不会被验证,也不会进行存储优化(所有 JSON 只是以文本形式存储)。使用原生的 JSON 类型,则以二进制格式存储。如果我们要总结成一句话:亲爱的读者,请为 JSON 使用 JSON 数据类型。

键和索引

您会发现,您使用的几乎所有表都在其 CREATE TABLE 语句中声明了 PRIMARY KEY 子句,有时还有多个 KEY 子句。为什么需要主键和次要键的原因在 第二章 中已经讨论过。本节讨论了如何声明主键,当您这样做时背后发生的事情,以及为什么您可能还希望在您的数据上创建其他键和索引。

主键 在表中唯一标识每一行。更重要的是,对于默认的 InnoDB 存储引擎,主键也用作聚集索引。这意味着所有实际的表数据都存储在一个索引结构中。这与 MyISAM 不同,后者将数据和索引分开存储。当表使用聚集索引时,称为聚集表。在聚集表中,每一行都存储在一个索引内,而不是通常所说的中。对表进行聚集将导致其行根据聚集索引的顺序排序,并实际物理存储在该索引的叶子页中。每个表不能有多于一个聚集索引。对于这样的表,二级索引引用聚集索引中的记录,而不是实际的表行。这通常会提高查询性能,尽管可能对写入性能有所损害。InnoDB 不允许您在聚集和非聚集表之间进行选择;这是您无法更改的设计决策。

主键通常是任何数据库设计的推荐部分,但对于 InnoDB 来说是必需的。事实上,如果在创建 InnoDB 表时不指定 PRIMARY KEY 子句,MySQL 将使用第一个 UNIQUE NOT NULL 列作为聚集索引的基础。如果没有这样的列可用,则创建一个隐藏的聚集索引,基于由 InnoDB 分配给每行的 ID 值。

鉴于 InnoDB 是 MySQL 的默认存储引擎并且是当今的事实标准,我们将在本章集中讨论其行为。备选存储引擎如 MyISAM、MEMORY 或 MyRocks 将在 “备选存储引擎” 中进行讨论。

如前所述,当定义主键时,它将成为一个聚集索引,表中的所有数据都存储在该索引的叶子块中。InnoDB 使用 B 树索引(更具体地说是 B+ 树变体),除了空间数据类型的索引使用 R 树结构。其他存储引擎可能实现不同类型的索引,但如果未指定表的存储引擎,则可以假定所有索引都是 B 树。

有一个聚集索引,或者换句话说,有索引组织的表,可以加快涉及主键列的查询和排序。然而,一个缺点是修改主键列是昂贵的。因此,一个好的设计将需要一个基于经常用于查询过滤但很少修改的列的主键。请记住,如果根本没有主键,InnoDB 将使用隐式的聚集索引;因此,如果你不确定要选择哪些列作为主键,考虑使用类似id的合成列。例如,SERIAL 数据类型在这种情况下可能非常合适。

离开 InnoDB 的内部细节,当您在 MySQL 中为表声明主键时,它会创建一个存储有关表中每行数据存储位置信息的结构。此信息称为索引,其目的是加快使用主键进行搜索的速度。例如,在sakila数据库的actor表中声明PRIMARY KEY (actor_id)时,MySQL 创建一个结构,允许它非常快速地找到与特定actor_id(或一系列标识符)匹配的行。

这对于匹配演员与电影或电影与类别非常有用。可以使用SHOW INDEX(或SHOW INDEXES)命令显示表上可用的索引:

mysql> `SHOW` `INDEX` `FROM` `category``\``G`
*************************** 1\. row ***************************
        Table: category
   Non_unique: 0
     Key_name: PRIMARY
 Seq_in_index: 1
  Column_name: category_id
    Collation: A
  Cardinality: 16
     Sub_part: NULL
       Packed: NULL
         Null:
   Index_type: BTREE
      Comment:
Index_comment:
      Visible: YES
   Expression: NULL
1 row in set (0.00 sec)

基数是索引中唯一值的数量;对于主键索引,这与表中行的数量相同。

请注意,所有作为主键一部分的列都必须声明为NOT NULL,因为它们必须具有值才能使行有效。如果没有索引,查找表中的行的唯一方法是从磁盘读取每一行,并检查它是否与您正在搜索的category_id匹配。对于行数很多的表,这种详尽的顺序搜索非常慢。但是,您不能只是索引一切;我们将在本节末尾回到这一点。

您可以在表中的数据上创建其他索引。这样做是为了使其他搜索(无论是在其他列上还是在列的组合上)都能快速进行,并避免顺序扫描。例如,再次看看actor表。除了在actor_id上有主键之外,还在last_name上有一个辅助键,以改善按演员姓氏搜索的效率:

mysql> `SHOW` `CREATE` `TABLE` `actor``\``G`
*************************** 1\. row ***************************
       Table: actor
Create Table: CREATE TABLE `actor` (
  `actor_id` smallint unsigned NOT NULL AUTO_INCREMENT,
  ...
  `last_name` varchar(45) NOT NULL,
  ...
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ...
1 row in set (0.00 sec)

您可以看到关键字KEY用于告诉 MySQL 需要额外的索引。或者,您可以在KEY的位置使用INDEX。在关键字之后是索引名称,然后是包含在括号中的索引列。您也可以在创建表后添加索引——实际上,您几乎可以更改表的任何内容。这在“修改结构”中讨论。

可以在多个列上建立索引。例如,考虑以下来自sakila的修改后的表:

mysql> `CREATE` `TABLE` `customer_mod` `(`
    -> `customer_id` `smallint` `unsigned` `NOT` `NULL` `AUTO_INCREMENT``,`
    -> `first_name` `varchar``(``45``)` `NOT` `NULL``,`
    -> `last_name` `varchar``(``45``)` `NOT` `NULL``,`
    -> `email` `varchar``(``50``)` `DEFAULT` `NULL``,`
    -> `PRIMARY` `KEY` `(``customer_id``)``,`
    -> `KEY` `idx_names_email` `(``first_name``,` `last_name``,` `email``)``)``;`
Query OK, 0 rows affected (0.06 sec)

您可以看到,我们在customer_id标识符列上添加了主键索引,还添加了另一个名为idx_names_email的索引,按照first_namelast_nameemail列的顺序包含在内。现在让我们考虑如何使用这个额外的索引。

您可以利用idx_names_email索引快速搜索三个名称列的组合。例如,在以下查询中非常有用:

mysql> `SELECT` `*` `FROM` `customer_mod` `WHERE`
    -> `first_name` `=` `'Rose'` `AND`
    -> `last_name` `=` `'Williams'` `AND`
    -> `email` `=` `'rose.w@nonexistent.edu'``;`

我们知道它有助于搜索,因为索引中列出的所有列都在查询中使用。您可以使用EXPLAIN语句检查您认为应该发生的事情是否确实发生了:

mysql> `EXPLAIN` `SELECT` `*` `FROM` `customer_mod` `WHERE`
    -> `first_name` `=` `'Rose'` `AND`
    -> `last_name` `=` `'Williams'` `AND`
    -> `email` `=` `'rose.w@nonexistent.edu'``\``G`
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: customer_mod
   partitions: NULL
         type: ref
possible_keys: idx_names_email
          key: idx_names_email
      key_len: 567
          ref: const,const,const
         rows: 1
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning (0.00 sec)

您可以看到 MySQL 报告的 possible_keysidx_names_email(这意味着该索引可以用于此查询),它决定使用的 keyidx_names_email。所以,您期望的和实际发生的是一样的,这是个好消息!您将在第七章中了解更多关于 EXPLAIN 语句的内容。

我们创建的索引还可用于仅涉及 first_name 列的查询。例如,它可用于以下查询:

mysql> `SELECT` `*` `FROM` `customer_mod` `WHERE`
    -> `first_name` `=` `'Rose'``;`

您可以再次使用 EXPLAIN 来检查索引是否被使用。它可以被使用的原因是 first_name 列是索引中列出的第一列。在实践中,这意味着索引会将所有具有相同名字的人的行信息聚集或存储在一起,因此可以使用索引来查找任何具有匹配名字的人。

索引还可以用于涉及名字和姓氏组合的搜索,原因与刚刚讨论的相同。索引将具有相同名字的人聚集在一起,并将相同名字的人按姓氏聚集在一起。因此,它可用于此查询:

mysql> `SELECT` `*` `FROM` `customer_mod` `WHERE`
    -> `first_name` `=` `'Rose'` `AND`
    -> `last_name` `=` `'Williams'``;`

但是,此查询无法使用索引,因为索引中最左列 first_name 在查询中不存在:

mysql> `SELECT` `*` `FROM` `customer_mod` `WHERE`
    -> `last_name` `=` `'Williams'` `AND`
    -> `email` `=` `'rose.w@nonexistent.edu'``;`

索引应帮助缩小结果集,使其变为可能的答案的更小集合。要使 MySQL 能够使用索引,查询必须同时满足以下两个条件:

  1. 在查询中必须包含在 KEY(或 PRIMARY KEY)子句中列出的最左列。

  2. 查询中不能包含未建立索引的列的 OR 条件。

同样,您始终可以使用 EXPLAIN 语句来检查特定查询是否可以使用索引。

在完成本节之前,以下是一些关于选择和设计索引的想法。在考虑添加索引时,请考虑以下几点:

  • 索引占用磁盘空间,并且在数据更改时需要更新。如果您的数据频繁更改,或者在进行更改时更改了大量数据,索引将减慢这一过程。然而,实际上,由于 SELECT 语句(数据读取)通常比其他语句(数据修改)更常见,索引通常是有益的。

  • 只添加经常使用的索引。在确定用户和应用程序需要的查询之前,不要为列添加索引。之后可以随时添加索引。

  • 如果索引中的所有列都在所有查询中使用,则列出具有最高重复项数的列,放在 KEY 子句的最左边。这样可以最小化索引大小。

  • 索引越小,速度越快。如果对大列建立索引,索引也会变大。这就是设计表时确保列尽可能小的一个好理由。

  • 对于长列,您可以仅使用列中的前缀创建索引。您可以通过在列定义后面加上括号中的值来实现此目的,例如KEY idx_names_email (first_name(3), last_name(2), email(10))。这意味着仅对first_name的前 3 个字符进行索引,然后是last_name的前 2 个字符,最后是email的 10 个字符。与从三列中索引 140 个字符相比,这节省了大量空间!这样做会使您的索引无法唯一标识行,但它会变得更小,仍然可以有效地找到匹配的行。对于像TEXT这样的长类型,使用前缀是强制的。

结束本节时,我们需要讨论 InnoDB 中关于次要键的一些特殊情况。请记住,所有表数据都存储在聚集索引的叶子节点中。这意味着,使用actor示例,如果我们需要通过last_name进行过滤时获取first_name数据,即使我们可以使用idx_actor_last_name进行快速过滤,我们仍需要通过主键访问数据。因此,在 InnoDB 中,每个次要键的定义隐式地附加了所有主键列。因此,在 InnoDB 中不必要地长主键会导致次要键显著膨胀。

这一点在EXPLAIN输出中也可以看到(注意第一个命令的第一个输出中的Extra: Using index):

mysql> `EXPLAIN` `SELECT` `actor_id``,` `last_name` `FROM` `actor` `WHERE` `last_name` `=` `'Smith'``\``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: 1
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning (0.00 sec)
mysql> `EXPLAIN` `SELECT` `first_name` `FROM` `actor` `WHERE` `last_name` `=` `'Smith'``\``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: 1
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)

事实上,idx_actor_last_name是第一个查询的覆盖索引,这意味着 InnoDB 可以仅从该索引中提取所有所需的数据。但是,对于第二个查询,InnoDB 将需要额外查找聚集索引以获取first_name列的值。

自增特性

MySQL 的专有AUTO_INCREMENT特性允许您为行创建唯一标识符,而无需运行SELECT查询。它是如何工作的?让我们再次看看简化的actor表:

mysql> `CREATE` `TABLE` `actor` `(`
    -> `actor_id` `smallint` `unsigned` `NOT` `NULL` `AUTO_INCREMENT``,`
    -> `first_name` `varchar``(``45``)` `NOT` `NULL``,`
    -> `last_name` `varchar``(``45``)` `NOT` `NULL``,`
    -> `PRIMARY` `KEY` `(``actor_id``)`
    -> `)``;`
Query OK, 0 rows affected (0.03 sec)

可以在不指定actor_id的情况下向该表中插入行:

mysql> `INSERT` `INTO` `actor` `VALUES` `(``NULL``,` `'Alexander'``,` `'Kaidanovsky'``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `actor` `VALUES` `(``NULL``,` `'Anatoly'``,` `'Solonitsyn'``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `actor` `VALUES` `(``NULL``,` `'Nikolai'``,` `'Grinko'``)``;`
Query OK, 1 row affected (0.00 sec)

当您查看此表中的数据时,可以看到每行为actor_id列分配了一个值:

mysql> `SELECT` `*` `FROM` `actor``;`
+----------+------------+-------------+
| actor_id | first_name | last_name   |
+----------+------------+-------------+
|        1 | Alexander  | Kaidanovsky |
|        2 | Anatoly    | Solonitsyn  |
|        3 | Nikolai    | Grinko      |
+----------+------------+-------------+
3 rows in set (0.00 sec)

每次插入新行时,都会为actor_id列创建一个唯一值。

考虑这个特性是如何工作的。您可以看到,actor_id列被声明为带有NOT NULL AUTO_INCREMENT子句的整数。AUTO_INCREMENT告诉 MySQL,当没有为此列提供值时,分配的值应该比当前表中存储的最大值大一。对于空表,AUTO_INCREMENT序列从 1 开始。

对于AUTO_INCREMENT列,必须使用NOT NULL子句;当插入NULL(或 0,尽管不建议这样做)时,MySQL 服务器会自动查找下一个可用的标识符,并将其分配给新行。如果列未定义为UNSIGNED,则可以手动插入负值;然而,对于下一个自动增量,MySQL 将简单地使用列中的最大(正)值,或者如果没有正值,则从 1 开始。

AUTO_INCREMENT特性有以下要求:

  • 它所用的列必须被索引。

  • 它所用的列不能有默认值。

  • 每个表只能有一个AUTO_INCREMENT列。

MySQL 支持不同的存储引擎;我们将在“备用存储引擎”中详细讨论这些内容。在使用非默认的 MyISAM 表类型时,可以在由多列组成的键上使用AUTO_INCREMENT特性。实际上,您可以在单个AUTO_INCREMENT列内拥有多个独立的计数器。然而,在 InnoDB 中是不可能的。

虽然AUTO_INCREMENT特性很有用,但它在其他数据库环境中并不具备可移植性,并且隐藏了创建新标识符的逻辑步骤。它还可能导致歧义;例如,删除或截断表会重置计数器,但使用WHERE子句删除选定行则不会重置计数器。此外,如果在事务中插入了一行但然后回滚该事务,则标识符仍然会被使用。举个例子,让我们创建包含自动增量字段counter的表count

mysql> `CREATE` `TABLE` `count` `(``counter` `INT` `AUTO_INCREMENT` `KEY``)``;`
Query OK, 0 rows affected (0.13 sec)
mysql> `INSERT` `INTO` `count` `VALUES` `(``)``,``(``)``,``(``)``,``(``)``,``(``)``,``(``)``;`
Query OK, 6 rows affected (0.01 sec)
Records: 6  Duplicates: 0  Warnings: 0
mysql> `SELECT` `*` `FROM` `count``;`
+---------+
| counter |
+---------+
| 1       |
| 2       |
| 3       |
| 4       |
| 5       |
| 6       |
+---------+
6 rows in set (0.00 sec)

插入多个值的工作效果如预期。现在,让我们删除几行,然后添加六行新数据:

mysql> `DELETE` `FROM` `count` `WHERE` `counter` `>` `4``;`
Query OK, 2 rows affected (0.00 sec)
mysql> `INSERT` `INTO` `count` `VALUES` `(``)``,``(``)``,``(``)``,``(``)``,``(``)``,``(``)``;`
Query OK, 6 rows affected (0.00 sec)
Records: 6  Duplicates: 0  Warnings: 0
mysql> `SELECT` `*` `FROM` `count``;`
+---------+
| counter |
+---------+
| 1       |
| 2       |
| 3       |
| 4       |
| 7       |
| 8       |
| 9       |
| 10      |
| 11      |
| 12      |
+---------+
10 rows in set (0.00 sec)

在这里,我们看到计数器未被重置,并继续从 7 开始。然而,如果我们截断表,从而删除所有数据,计数器将被重置为 1:

mysql> `TRUNCATE` `TABLE` `count``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `INSERT` `INTO` `count` `VALUES` `(``)``,``(``)``,``(``)``,``(``)``,``(``)``,``(``)``;`
Query OK, 6 rows affected (0.01 sec)
Records: 6  Duplicates: 0  Warnings: 0
mysql> `SELECT` `*` `FROM` `count``;`
+---------+
| counter |
+---------+
| 1       |
| 2       |
| 3       |
| 4       |
| 5       |
| 6       |
+---------+
6 rows in set (0.00 sec)

总结一下:AUTO_INCREMENT保证了事务性和单调递增值序列。然而,它并不以任何方式保证每个提供的单个标识符会严格遵循前一个标识符。通常,AUTO_INCREMENT的这种行为已经足够清晰,不应该是一个问题。然而,如果您的特定用例要求计数器保证没有间隙,您应该考虑使用某种解决方法。不幸的是,这可能会在应用程序端实现。

修改结构

我们已经向您展示了创建数据库、表、索引和列所需的所有基础知识。在本节中,您将学习如何在已经存在的结构中添加、删除和更改列、数据库、表和索引。

添加、删除和更改列

您可以使用ALTER TABLE语句向表中添加新列,删除现有列,并更改列名、类型和长度。

让我们从考虑如何修改现有列开始。考虑一个示例,我们将重命名表列。language表有一个名为last_update的列,其中包含记录修改时间。要将此列名更改为last_updated_time,您应该编写:

mysql> `ALTER` `TABLE` `language` `RENAME` `COLUMN` `last_update` `TO` `last_updated_time``;`
Query OK, 0 rows affected (0.03 sec)
Records: 0  Duplicates: 0  Warnings: 0

这个特定示例利用了 MySQL 的在线 DDL功能。实际上背后发生的是 MySQL 仅修改了元数据,而不需要以任何方式重写表。通过受影响行数的缺失可以看出这一点。并非所有 DDL 语句都可以在线执行,因此在许多您进行的更改中可能不会出现这种情况。

注意

DDL 代表数据定义语言,在 SQL 的上下文中,它是用于创建、修改和删除模式对象(如数据库、表、索引和列)的语法和语句的子集。例如,CREATE TABLEALTER TABLE都是 DDL 操作。

执行 DDL 语句需要特殊的内部机制,包括特殊的锁定——这是一件好事,因为您可能不希望在运行查询时表发生变化!这些特殊锁在 MySQL 中称为元数据锁,我们在“元数据锁”中详细介绍了它们的工作原理。

注意所有 DDL 语句,包括通过在线 DDL 执行的语句,都需要获取元数据锁。从这个意义上说,在线 DDL 语句并不那么“在线”,但它们在运行时不会完全锁定目标表。在负载运行的系统上执行 DDL 语句是一次冒险:即使是应该几乎立即执行的语句,也可能造成严重破坏。我们建议您仔细阅读第六章中关于元数据锁的内容以及MySQL 文档中的链接,并尝试在有和无并发负载的情况下运行不同的 DDL 语句。在学习 MySQL 时,这可能并不是太重要,但我们认为提前告诫您是值得的。说了这些,让我们回到我们对language表进行的ALTER操作。

您可以使用SHOW COLUMNS语句检查结果:

mysql> `SHOW` `COLUMNS` `FROM` `language``;`
+-------------------+------------------+------+-----+-------------------+...
| Field             | Type             | Null | Key | Default           |...
+-------------------+------------------+------+-----+-------------------+...
| language_id       | tinyint unsigned | NO   | PRI | NULL              |...
| name              | char(20)         | NO   |     | NULL              |...
| last_updated_time | timestamp        | NO   |     | CURRENT_TIMESTAMP |...
+-------------------+------------------+------+-----+-------------------+...
3 rows in set (0.01 sec)

在前面的示例中,我们使用了带有RENAME COLUMN关键字的ALTER TABLE语句。这是 MySQL 8.0 的功能。为了兼容性,我们也可以使用带有CHANGE关键字的ALTER TABLE

mysql> `ALTER` `TABLE` `language` `CHANGE` `last_update` `last_updated_time` `TIMESTAMP`
    -> `NOT` `NULL` `DEFAULT` `CURRENT_TIMESTAMP` `ON` `UPDATE` `CURRENT_TIMESTAMP``;`
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

在此示例中,您可以看到我们向带有CHANGE关键字的ALTER TABLE语句提供了四个参数:

  1. 表名,language

  2. 原始列名,last_update

  3. 新列名,last_updated_time

  4. 列类型,TIMESTAMP,带有许多额外的属性,这些属性是必需的,以避免改变原始定义

您必须提供所有四个参数;这意味着您需要重新指定类型和任何相关的子句。在这个例子中,由于我们使用的是默认设置的 MySQL 8.0,TIMESTAMP不再具有显式的默认值。如您所见,使用RENAME COLUMNCHANGE要简单得多。

如果您想要修改列的类型和子句,但不修改其名称,可以使用MODIFY关键字:

mysql> `ALTER` `TABLE` `language` `MODIFY` `name` `CHAR``(``20``)` `DEFAULT` `'n/a'``;`
Query OK, 0 rows affected (0.14 sec)
Records: 0  Duplicates: 0  Warnings: 0

您还可以使用CHANGE关键字,但需指定相同的列名两次:

mysql> `ALTER` `TABLE` `language` `CHANGE` `name` `name` `CHAR``(``20``)` `DEFAULT` `'n/a'``;`
Query OK, 0 rows affected (0.03 sec)
Records: 0  Duplicates: 0  Warnings: 0

修改类型时请小心:

  • 不要使用不兼容的类型,因为您依赖 MySQL 成功地将数据从一种格式转换为另一种格式(例如,将INT列转换为DATETIME列可能不会达到您的预期效果)。

  • 除非您需要这样做,否则不要截断数据。如果缩小类型的大小,值将被编辑以匹配新的宽度,可能会丢失数据。

假设您想要向现有表添加额外的列。以下是使用ALTER TABLE语句的方法:

mysql> `ALTER` `TABLE` `language` `ADD` `native_name` `CHAR``(``20``)``;`
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

您必须提供ADD关键字、新列名以及列类型和子句。此示例将新列native_name添加为表中的最后一列,如SHOW COLUMNS语句所示:

mysql> `SHOW` `COLUMNS` `FROM` `artist``;`
+-------------------+------------------+------+-----+-------------------+...
| Field             | Type             | Null | Key | Default           |...
+-------------------+------------------+------+-----+-------------------+...
| language_id       | tinyint unsigned | NO   | PRI | NULL              |...
| name              | char(20)         | YES  |     | n/a               |...
| last_updated_time | timestamp        | NO   |     | CURRENT_TIMESTAMP |...
| native_name       | char(20)         | YES  |     | NULL              |...
+-------------------+------------------+------+-----+-------------------+...
4 rows in set (0.00 sec)

如果您希望它作为第一列,使用FIRST关键字如下:

mysql> `ALTER` `TABLE` `language` `ADD` `native_name` `CHAR``(``20``)` `FIRST``;`
Query OK, 0 rows affected (0.08 sec)
Records: 0  Duplicates: 0  Warnings: 0
mysql> `SHOW` `COLUMNS` `FROM` `language``;`
+-------------------+------------------+------+-----+-------------------+...
| Field             | Type             | Null | Key | Default           |...
+-------------------+------------------+------+-----+-------------------+...
| native_name       | char(20)         | YES  |     | NULL              |...
| language_id       | tinyint unsigned | NO   | PRI | NULL              |...
| name              | char(20)         | YES  |     | n/a               |...
| last_updated_time | timestamp        | NO   |     | CURRENT_TIMESTAMP |...
+-------------------+------------------+------+-----+-------------------+...
4 rows in set (0.01 sec)

如果您希望它添加到特定位置,请使用AFTER关键字:

mysql> `ALTER` `TABLE` `language` `ADD` `native_name` `CHAR``(``20``)` `AFTER` `name``;`
Query OK, 0 rows affected (0.08 sec)
Records: 0  Duplicates: 0  Warnings: 0
mysql> `SHOW` `COLUMNS` `FROM` `language``;`
+-------------------+------------------+------+-----+-------------------+...
| Field             | Type             | Null | Key | Default           |...
+-------------------+------------------+------+-----+-------------------+...
| language_id       | tinyint unsigned | NO   | PRI | NULL              |...
| name              | char(20)         | YES  |     | n/a               |...
| native_name       | char(20)         | YES  |     | NULL              |...
| last_updated_time | timestamp        | NO   |     | CURRENT_TIMESTAMP |...
+-------------------+------------------+------+-----+-------------------+...
4 rows in set (0.00 sec)

要删除列,请使用DROP关键字,后跟列名。以下是如何去除新添加的native_name列的方法:

mysql> `ALTER` `TABLE` `language` `DROP` `native_name``;`
Query OK, 0 rows affected (0.07 sec)
Records: 0  Duplicates: 0  Warnings: 0

这将删除列结构及其中包含的任何数据。它还将从包含该列的任何索引中删除列;如果它是索引中的唯一列,则也会删除索引。如果一张表中只有一列,则无法删除该列;此时应删除整个表,如“删除结构”中所述。删除列时要小心,因为表结构更改时,通常需要修改任何用于按特定顺序插入值的INSERT语句。有关更多信息,请参阅“INSERT 语句”。

MySQL 允许您在单个ALTER TABLE语句中指定多个修改,通过逗号分隔它们。以下是一个示例,添加新列并调整另一个列:

mysql> `ALTER` `TABLE` `language` `ADD` `native_name` `CHAR``(``255``)``,` `MODIFY` `name` `CHAR``(``255``)``;`
Query OK, 6 rows affected (0.06 sec)
Records: 6  Duplicates: 0  Warnings: 0

请注意,这次您可以看到已更改了六条记录。在先前的ALTER TABLE命令中,MySQL 报告未影响任何行。不同之处在于,这次我们未执行在线 DDL 操作,因为更改任何列的类型将始终导致表被重建。我们建议在计划更改时阅读有关在线 DDL 操作的参考手册。组合在线和离线操作将导致离线操作。

在不使用在线 DDL 或任何修改是“离线”的情况下,将多个修改操作合并为单个操作非常高效。这样做可能会节省创建新表、将数据从旧表复制到新表、删除旧表并将新表重命名为旧表名的成本。

添加、删除和更改索引

正如我们之前讨论的,通常很难在构建应用程序之前知道哪些索引是有用的。您可能会发现应用程序的某个特定功能比预期更受欢迎,这会导致您评估如何改进相关查询的性能。因此,您会发现在应用程序部署后能够动态添加、修改和删除索引非常有用。本节将向您展示如何操作。请注意,修改索引不会影响表中存储的数据。

我们将从添加新索引开始。假设经常使用language表,并使用指定nameWHERE子句进行查询。为了加快这些查询的速度,您决定添加一个名为idx_name的新索引。以下是如何在创建表后添加它的方法:

mysql> `ALTER` `TABLE` `language` `ADD` `INDEX` `idx_name` `(``name``)``;`
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

同样,您可以交替使用KEYINDEX这两个术语。您可以使用SHOW CREATE TABLE语句检查结果:

mysql> `SHOW` `CREATE` `TABLE` `language``\``G`
*************************** 1\. row ***************************
       Table: language
Create Table: CREATE TABLE `language` (
  `language_id` tinyint unsigned NOT NULL AUTO_INCREMENT,
  `name` char(255) DEFAULT NULL,
  `last_updated_time` timestamp NOT NULL
    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`language_id`),
  KEY `idx_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=8
    DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

新索引如预期地成为表结构的一部分。在创建表后,您还可以为表指定主键:

mysql> `CREATE` `TABLE` `no_pk` `(``id` `INT``)``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `INSERT` `INTO` `no_pk` `VALUES` `(``1``)``,``(``2``)``,``(``3``)``;`
Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0
mysql> `ALTER` `TABLE` `no_pk` `ADD` `PRIMARY` `KEY` `(``id``)``;`
Query OK, 0 rows affected (0.13 sec)
Records: 0  Duplicates: 0  Warnings: 0

现在让我们考虑如何删除索引。要删除非主键索引,执行以下操作:

mysql> `ALTER` `TABLE` `language` `DROP` `INDEX` `idx_name``;`
Query OK, 0 rows affected (0.08 sec)
Records: 0  Duplicates: 0  Warnings: 0

删除主键索引的方法如下:

mysql> `ALTER` `TABLE` `no_pk` `DROP` `PRIMARY` `KEY`;
Query OK, 3 rows affected (0.07 sec)
Records: 3  Duplicates: 0  Warnings: 0

MySQL 不允许在表中有多个主键。如果要更改主键,必须先删除现有的索引,然后再添加新的。不过,我们知道可以将 DDL 操作进行分组。考虑以下示例:

mysql> `ALTER` `TABLE` `language` `DROP` `PRIMARY` `KEY``,`
    -> `ADD` `PRIMARY` `KEY` `(``language_id``,` `name``)``;`
Query OK, 0 rows affected (0.09 sec)
Records: 0  Duplicates: 0  Warnings: 0

一旦创建了索引,就无法修改它。但有时您可能会需要;例如,您可能希望减少从列索引的字符数或向索引中添加另一个列。执行此操作的最佳方法是删除索引,然后使用新的规范重新创建索引。例如,假设您决定只将idx_name索引包含艺术家名artist_name的前 10 个字符。只需执行以下操作:

mysql> `ALTER` `TABLE` `language` `DROP` `INDEX` `idx_name``,`
    -> `ADD` `INDEX` `idx_name` `(``name``(``10``)``)``;`
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

重命名表格和修改其他结构

我们已经看到如何修改表中的列和索引;现在让我们看看如何修改表本身。重命名表很容易。假设您想将language重命名为languages,请使用以下命令:

mysql> `ALTER` `TABLE` `language` `RENAME` `TO` `languages``;`
Query OK, 0 rows affected (0.04 sec)

TO关键字是可选的。

使用ALTER语句还可以执行其他几项操作,包括:

  • 更改数据库、表格或列的默认字符集和排序规则。

  • 管理和更改约束。例如,您可以添加和删除外键。

  • 向表添加分区,或修改当前分区定义。

  • 更改表的存储引擎。

你可以在 MySQL 参考手册的 ALTER DATABASE ALTER TABLE章节找到更多关于这些操作的信息。相同语句的另一种更简短的表示法是 RENAME TABLE

mysql> `RENAME` `TABLE` `languages` `TO` `language``;`
Query OK, 0 rows affected (0.04 sec)

有一件事是不可能改变特定数据库的名称。然而,如果你使用 InnoDB 引擎,你可以使用 RENAME来在不同数据库之间移动表:

mysql> `CREATE` `DATABASE` `sakila_new``;`
Query OK, 1 row affected (0.05 sec)
mysql> `RENAME` `TABLE` `sakila``.``language` `TO` `sakila_new``.``language``;`
Query OK, 0 rows affected (0.05 sec)
mysql> `USE` `sakila``;`
Database changed
mysql> `SHOW` `TABLES` `LIKE` `'lang%'``;`
Empty set (0.00 sec)
mysql> `USE` `sakila_new``;`
Database changed
mysql> `SHOW` `TABLES` `LIKE` `'lang%'``;`
+------------------------------+
| Tables_in_sakila_new (lang%) |
+------------------------------+
| language                     |
+------------------------------+
1 row in set (0.00 sec)

删除结构

在前一节中,我们展示了如何从数据库中删除列和行;现在我们将描述如何删除数据库和表。

删除数据库

移除或删除一个数据库是直接的。这是你如何删除 sakila数据库:

mysql> `DROP` `DATABASE` `sakila``;`
Query OK, 25 rows affected (0.16 sec)

响应中返回的行数是删除的表的数量。删除数据库时应当小心,因为它的所有表、索引和列都会被删除,MySQL 用于维护它们的所有关联磁盘文件和目录也会被删除。

如果一个数据库不存在,尝试删除它会导致 MySQL 报告一个错误。让我们再次尝试删除 sakila数据库:

mysql> `DROP` `DATABASE` `sakila``;`
ERROR 1008 (HY000): Can't drop database 'sakila'; database doesn't exist

你可以通过使用 IF EXISTS短语来避免错误,这在将语句包含在脚本中时非常有用:

mysql> `DROP` `DATABASE` `IF` `EXISTS` `sakila``;`
Query OK, 0 rows affected, 1 warning (0.00 sec)

你可以看到一个警告被报告,因为 sakila数据库已经被删除。

删除表

删除表和删除数据库一样简单。让我们从 sakila数据库创建并删除一个表:

mysql> `CREATE` `TABLE` `temp` `(``id` `SERIAL` `PRIMARY` `KEY``)``;`
Query OK, 0 rows affected (0.05 sec)
mysql> `DROP` `TABLE` `temp``;`
Query OK, 0 rows affected (0.03 sec)

不要担心: 0 rows affected消息是误导性的。你会发现表确实已经消失了。

你可以使用 IF EXISTS短语来防止错误。让我们再次尝试删除 temp表:

mysql> `DROP` `TABLE` `IF` `EXISTS` `temp``;`
Query OK, 0 rows affected, 1 warning (0.01 sec)

和往常一样,你可以用 SHOW WARNINGS语句来调查警告:

mysql> `SHOW` `WARNINGS``;`
+-------+------+-----------------------------+
| Level | Code | Message                     |
+-------+------+-----------------------------+
| Note  | 1051 | Unknown table 'sakila.temp' |
+-------+------+-----------------------------+
1 row in set (0.00 sec)

你可以在单个语句中通过用逗号分隔表名来删除多个表:

mysql> `DROP` `TABLE` `IF` `EXISTS` `temp``,` `temp1``,` `temp2``;`
Query OK, 0 rows affected, 3 warnings (0.00 sec)

在这种情况下有三个警告,因为这些表都不存在。

第五章:高级查询

在前两章中,你已经完成了 SQL 数据库查询和修改基本特性的介绍。现在你应该能够创建、修改和删除数据库结构,以及在读取、插入、删除和更新条目时处理数据。在本章和接下来的两章中,我们将深入研究更高级的概念,然后继续探讨更多关于管理和操作导向的内容。当你熟悉使用 MySQL 后,你可以略读这些章节,等到需要时再仔细阅读。

本章教你更多关于查询的内容,提供了解答复杂信息需求的技能。你将学习以下内容:

  • 在查询中使用昵称或别名,以节省输入并允许一个表在查询中被多次使用。

  • 将数据聚合成组,以便发现总和、平均值和计数。

  • 以不同方式连接表格。

  • 使用嵌套查询。

  • 将查询结果保存在变量中,以便在其他查询中重复使用。

别名

别名是昵称。它们为你提供了一种简写的方式来表达列、表或函数名称,允许你:

  • 编写更短的查询。

  • 更清晰地表达你的查询。

  • 在单个查询中以两种或更多方式使用同一张表。

  • 更轻松地从程序中访问数据。

  • 使用特殊类型的嵌套查询,详见 “Nested Queries”。

列别名

列别名对于改善查询表达、减少所需键入的字符数以及使得与诸如 Python 或 PHP 等编程语言的工作更加容易非常有用。考虑一个简单且不太实用的例子:

mysql> `SELECT` `first_name` `AS` `'First Name'``,` `last_name` `AS` `'Last Name'`
    -> `FROM` `actor` `LIMIT` `5``;`
+------------+--------------+
| First Name | Last Name    |
+------------+--------------+
| PENELOPE   | GUINESS      |
| NICK       | WAHLBERG     |
| ED         | CHASE        |
| JENNIFER   | DAVIS        |
| JOHNNY     | LOLLOBRIGIDA |
+------------+--------------+
5 rows in set (0.00 sec)

first_name 被别名为 First Name,列 last_name 被别名为 Last Name。你可以看到在输出中,通常的列标题 first_namelast_name 被别名 First NameLast Name 替代了。这样做的好处是别名可能更有意义,至少对人类更易读。除此之外,它并不是非常有用,但它确实说明了一个想法,即对于一列,你可以添加关键词 AS,然后是一个代表你想让列被称为的字符串。指定 AS 关键词并非必需,但可以使事情更清晰。

注意

我们将在本章中广泛使用 LIMIT 子句,否则几乎每个输出都会非常冗长。有时我们会明确提到它,有时则不会。你可以尝试自己从我们给出的查询中删除 LIMIT 来进行实验。关于 LIMIT 子句的更多信息可在 “The LIMIT Clause” 中找到。

现在让我们看看列别名如何发挥作用。以下是一个使用 MySQL 函数和 ORDER BY 子句的示例:

mysql> `SELECT` `CONCAT``(``first_name``,` `' '``,` `last_name``,` `' played in '``,` `title``)` `AS` `movie`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `ORDER` `BY` `movie` `LIMIT` `20``;`
+--------------------------------------------+
| movie                                      |
+--------------------------------------------+
| ADAM GRANT played in ANNIE IDENTITY        |
| ADAM GRANT played in BALLROOM MOCKINGBIRD  |
| ...                                        |
| ADAM GRANT played in TWISTED PIRATES       |
| ADAM GRANT played in WANDA CHAMBER         |
| ADAM HOPPER played in BLINDNESS GUN        |
| ADAM HOPPER played in BLOOD ARGONAUTS      |
+--------------------------------------------+
20 rows in set (0.03 sec)

MySQL 函数CONCAT() 连接 它的参数——在本例中是first_name、一个包含空格的常量字符串、last_name、常量字符串played intitle——以生成诸如ZERO CAGE played in CANYON STOCK之类的输出。我们给函数添加了一个别名AS movie,这样我们可以在整个查询中轻松地引用它作为movie。您可以看到,我们在ORDER BY子句中这样做时,请求 MySQL 按升序movie值对输出进行排序。这比未使用别名的替代方法要好得多,后者要求您再次编写CONCAT()函数:

mysql> `SELECT` `CONCAT``(``first_name``,` `' '``,` `last_name``,` `' played in '``,` `title``)` `AS` `movie`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `ORDER` `BY` `CONCAT``(``first_name``,` `' '``,` `last_name``,` `' played in '``,` `title``)`
    -> `LIMIT` `20``;`
+--------------------------------------------+
| movie                                      |
+--------------------------------------------+
| ADAM GRANT played in ANNIE IDENTITY        |
| ADAM GRANT played in BALLROOM MOCKINGBIRD  |
| ...                                        |
| ADAM GRANT played in TWISTED PIRATES       |
| ADAM GRANT played in WANDA CHAMBER         |
| ADAM HOPPER played in BLINDNESS GUN        |
| ADAM HOPPER played in BLOOD ARGONAUTS      |
+--------------------------------------------+
20 rows in set (0.03 sec)

另一种方式不方便,更糟糕的是,您可能会在ORDER BY子句中打字错误,导致结果与您的预期不同。(请注意,我们在第一行使用了AS movie,以便显示的列具有标签movie。)

在可以使用列别名的地方有一些限制。您不能在WHERE子句中使用它们,也不能在我们稍后在本章中讨论的USINGON子句中使用它们。这意味着您不能编写如下查询:

mysql> `SELECT` `first_name` `AS` `name` `FROM` `actor` `WHERE` `name` `=` `'ZERO CAGE'``;`
ERROR 1054 (42S22): Unknown column 'name' in 'where clause'

由于 MySQL 在执行WHERE子句之前并不总是知道列值,所以您不能这样做。但是,您可以在ORDER BY子句中使用列别名,在稍后在本章中讨论的GROUP BYHAVING子句中也可以使用列别名。

AS关键字是可选的,正如我们前面提到的那样。因此,以下两个查询是等价的:

mysql> `SELECT` `actor_id` `AS` `id` `FROM` `actor` `WHERE` `first_name` `=` `'ZERO'``;`
+----+
| id |
+----+
| 11 |
+----+
1 row in set (0.00 sec)
mysql> `SELECT` `actor_id` `id` `FROM` `actor` `WHERE` `first_name` `=` `'ZERO'``;`
+----+
| id |
+----+
| 11 |
+----+
1 row in set (0.00 sec)

我们建议使用AS关键字,因为它有助于清晰地区分带别名的列,特别是在从逗号分隔的列列表中选择多列的情况下。

别名的名称有一些限制。它们最多可以为 255 个字符,并且可以包含任何字符。别名不总是需要加引号,并且遵循与表名和列名相同的规则,我们在第四章中进行了描述。如果别名是一个单词并且不包含特殊符号(例如破折号、加号或空格),并且不是关键字(如USE),那么您不需要在其周围加引号。否则,您需要引用别名,可以使用双引号、单引号或反引号。我们建议使用小写字母数字字符串作为别名,并使用一致的字符选择(例如下划线)来分隔单词。别名在所有平台上都不区分大小写。

表别名

表别名和列别名一样有用,但有时是表达查询的唯一方式。本节将向您展示如何使用表别名,而“嵌套查询”将向您展示一些其他必不可少的表别名示例。

下面是一个基本的表别名示例,向您展示如何节省输入:

mysql> `SELECT` `ac``.``actor_id``,` `ac``.``first_name``,` `ac``.``last_name``,` `fl``.``title` `FROM`
    -> `actor` `AS` `ac` `INNER` `JOIN` `film_actor` `AS` `fla` `USING` `(``actor_id``)`
    -> `INNER` `JOIN` `film` `AS` `fl` `USING` `(``film_id``)`
    -> `WHERE` `fl``.``title` `=` `'AFFAIR PREJUDICE'``;`
+----------+------------+-----------+------------------+
| actor_id | first_name | last_name | title            |
+----------+------------+-----------+------------------+
|       41 | JODIE      | DEGENERES | AFFAIR PREJUDICE |
|       81 | SCARLETT   | DAMON     | AFFAIR PREJUDICE |
|       88 | KENNETH    | PESCI     | AFFAIR PREJUDICE |
|      147 | FAY        | WINSLET   | AFFAIR PREJUDICE |
|      162 | OPRAH      | KILMER    | AFFAIR PREJUDICE |
+----------+------------+-----------+------------------+
5 rows in set (0.00 sec)

您可以看到,film表和actor表分别使用AS关键字别名为flac。这允许您更紧凑地表示列名称,例如fl.title。请注意,您还可以在WHERE子句中使用表别名;与列别名不同,对查询中可以使用表别名的位置没有限制。从我们的例子中,您可以看到我们在FROM中定义SELECT之前引用了表别名。但是,表别名有一个陷阱:如果一个别名已用于一个表,则无法在不使用其新别名的情况下引用该表。例如,下面的语句将出错,就像我们在SELECT子句中提到film时那样:

mysql> `SELECT` `ac``.``actor_id``,` `ac``.``first_name``,` `ac``.``last_name``,` `fl``.``title` `FROM`
    -> `actor` `AS` `ac` `INNER` `JOIN` `film_actor` `AS` `fla` `USING` `(``actor_id``)`
    -> `INNER` `JOIN` `film` `AS` `fl` `USING` `(``film_id``)`
    -> `WHERE` `film``.``title` `=` `'AFFAIR PREJUDICE'``;`
ERROR 1054 (42S22): Unknown column 'film.title' in 'where clause'

与列别名一样,AS关键字是可选的。这意味着:

actor AS ac INNER JOIN film_actor AS fla

等同于

actor ac INNER JOIN film_actor fla

我们更喜欢AS风格,因为对于查看您的查询的任何人来说,它比替代方案更清晰。表别名名称的长度和内容限制与列别名相同,并且我们在选择它们时的建议也相同。

如本节介绍中所讨论的,表别名允许您编写否则难以表达的查询。考虑一个例子:假设您想知道我们收藏的两部或更多电影是否具有相同的标题,如果是这样,这些电影是什么。让我们考虑基本要求:您想知道是否有两部电影具有相同的名称。为了做到这一点,您可能会尝试像这样的查询:

mysql> `SELECT` `*` `FROM` `film` `WHERE` `title` `=` `title``;`

但这毫无意义 —— 每部电影都与自己有相同的标题,所以查询只是将所有电影作为输出:

+---------+------------------...
| film_id | title            ...
+---------+------------------...
|       1 | ACADEMY DINOSAUR ...
|       2 | ACE GOLDFINGER   ...
|       3 | ADAPTATION HOLES ...
|     ...                    ...
|    1000 | ZORRO ARK        ...
+---------+------------------...
1000 rows in set (0.01 sec)

你真正想知道的是film表中的两部不同电影是否有相同的名称。但如何在一个查询中做到这一点?答案是给表指定两个不同的别名;然后检查第一个别名表中的一行是否与第二个别名表中的一行匹配:

mysql> `SELECT` `m1``.``film_id``,` `m2``.``title`
    -> `FROM` `film` `AS` `m1``,` `film` `AS` `m2`
    -> `WHERE` `m1``.``title` `=` `m2``.``title``;`
+---------+-------------------+
| film_id | title             |
+---------+-------------------+
|       1 | ACADEMY DINOSAUR  |
|       2 | ACE GOLDFINGER    |
|       3 | ADAPTATION HOLES  |
|     ...                     |
|    1000 | ZORRO ARK         |
+---------+-------------------+
1000 rows in set (0.02 sec)

但仍然不起作用!我们得到了所有 1,000 部电影作为答案。原因是因为每部电影都与自己匹配,因为它在两个别名表中都出现了。

要使查询起作用,我们需要确保来自一个别名表的电影不与另一个别名表中的自身匹配。做到这一点的方法是指定每个表中的电影不应具有相同的 ID:

mysql> `SELECT` `m1``.``film_id``,` `m2``.``title`
    -> `FROM` `film` `AS` `m1``,` `film` `AS` `m2`
    -> `WHERE` `m1``.``title` `=` `m2``.``title`
    -> `AND` `m1``.``film_id` `<``>` `m2``.``film_id``;`
Empty set (0.00 sec)

您现在可以看到数据库中没有两部电影具有相同的名称。附加的AND m1.film_id != m2.film_id阻止报告电影 ID 在两个表中相同的答案。

表别名在使用EXISTSON子句的嵌套查询中也很有用。在本章稍后介绍嵌套查询时,我们将为您展示示例。

聚合数据

聚合函数允许您发现一组行的属性。您可以使用它们来查找表中有多少行,表中有多少行共享某个属性(例如具有相同的姓名或出生日期),查找平均值(例如 11 月份的平均温度),或查找满足某些条件的行的最大或最小值(例如找到 8 月份最冷的一天)。

本节解释了GROUP BYHAVING子句,这是用于聚合的两个最常用的 SQL 语句。但首先它解释了DISTINCT子句,该子句用于报告查询输出的唯一结果。当未指定DISTINCTGROUP BY子句时,仍然可以使用我们在本节中描述的聚合函数处理返回的原始数据。

DISTINCT 子句

在开始讨论聚合函数之前,我们将专注于DISTINCT子句。这实际上不是一个聚合函数,而更像是一个后处理过滤器,允许您去除重复项。我们将其添加到本节中,因为与聚合函数一样,它关注的是从查询输出中选择示例,而不是处理单个行。

一个例子是理解DISTINCT的最佳方式。考虑这个查询:

mysql> `SELECT` `DISTINCT` `first_name`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)``;`
+-------------+
| first_name  |
+-------------+
| PENELOPE    |
| NICK        |
| ...         |
| GREGORY     |
| JOHN        |
| BELA        |
| THORA       |
+-------------+
128 rows in set (0.00 sec)

该查询找到了我们数据库中列出的所有参与电影的演员的所有名字,并报告每个名字的一个示例。如果删除DISTINCT子句,您将得到我们数据库中每部电影中每个角色的一个输出行,或者说有 5,462 行。这是很多输出,所以我们将其限制为五行,但您可以立即看到重复的名字:

mysql> `SELECT` `first_name`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `LIMIT` `5``;`
+------------+
| first_name |
+------------+
| PENELOPE   |
| PENELOPE   |
| PENELOPE   |
| PENELOPE   |
| PENELOPE   |
+------------+
5 rows in set (0.00 sec)

因此,DISTINCT子句帮助您得到一个摘要。

DISTINCT子句应用于查询输出,并删除在查询中选择的输出列中具有相同值的行。如果重新构造前面的查询以输出first_namelast_name(但不更改JOIN子句并仍然使用DISTINCT),您将在输出中得到 199 行(这就是为什么我们使用姓氏):

mysql> `SELECT` `DISTINCT` `first_name``,` `last_name`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)``;`
+-------------+--------------+
| first_name  | last_name    |
+-------------+--------------+
| PENELOPE    | GUINESS      |
| NICK        | WAHLBERG     |
| ...                        |
| JULIA       | FAWCETT      |
| THORA       | TEMPLE       |
+-------------+--------------+
199 rows in set (0.00 sec)

不幸的是,即使添加了姓氏,人们的名字仍然不适合作为一个糟糕的唯一键。在sakila数据库的actor表中有 200 行数据,我们遗漏了其中一个。您应该记住这个问题,因为不加区分地使用DISTINCT可能导致查询结果不正确。

要去除重复项,MySQL 需要对输出进行排序。如果可用的索引与所需排序的顺序相同,或者数据本身按照有用的顺序排列,这个过程的开销非常小。然而,对于大表而言,如果没有一种简单的方式访问正确顺序的数据,排序可能会非常慢。在处理大型数据集时,您应该谨慎使用DISTINCT(以及其他聚合函数)。如果您确实使用了它,可以使用第七章中讨论的EXPLAIN语句来检查其行为。

GROUP BY 子句

GROUP BY 子句根据聚合的目的分组输出数据。特别是,这使我们能够在我们的投影(即SELECT子句的内容)包含除聚合函数之外的列时,对我们的数据使用聚合函数(在“聚合函数”中已讨论)。

让我们看看一些 GROUP BY 示例,这将演示它可以用于什么。在其最基本的形式中,当我们在GROUP BY中列出我们SELECT的每一列时,我们最终得到一个DISTINCT等效项。我们已经确定名字不是演员的唯一标识符:

mysql> `SELECT` `first_name` `FROM` `actor`
    -> `WHERE` `first_name` `IN` `(``'GENE'``,` `'MERYL'``)``;`
+------------+
| first_name |
+------------+
| GENE       |
| GENE       |
| MERYL      |
| GENE       |
| MERYL      |
+------------+
5 rows in set (0.00 sec)

我们可以告诉 MySQL 通过给定的列分组输出以去除重复项。在这种情况下,我们只选择了一列,因此让我们使用它:

mysql> `SELECT` `first_name` `FROM` `actor`
    -> `WHERE` `first_name` `IN` `(``'GENE'``,` `'MERYL'``)`
    -> `GROUP` `BY` `first_name``;`
+------------+
| first_name |
+------------+
| GENE       |
| MERYL      |
+------------+
2 rows in set (0.00 sec)

您可以看到,原始的五行被合并或更准确地说是分组为仅两行结果。这并不是很有帮助,因为DISTINCT可以做同样的事情。然而值得一提的是,这并不总是适用。DISTINCTGROUP BY 在查询执行的不同阶段进行评估和执行,因此即使有时效果相似,您也不应混淆它们。

根据 SQL 标准,SELECT 子句中投影的每一列如果不是聚合函数的一部分,应该在 GROUP BY 子句中列出。唯一可以违反此规则的情况是当结果组每个仅有一行时。仔细想想,这是合理的:如果你从actor表中选择first_namelast_name,并且仅按first_name分组,数据库应该如何处理?它不能输出多行具有相同的姓,因为那违反了分组规则,但可能有多个姓氏对应一个给定的名字。

长期以来,MySQL 通过允许您基于SELECT中定义的少列来GROUP BY 扩展了标准。它如何处理额外的列呢?嗯,它以不确定的方式输出一些值。例如,当您按姓但不按名分组时,您可能会得到GENE, WILLISGENE, HOPKINS中的任一行。这是一种非标准和危险的行为。想象一下,一年来,您得到了Hopkins,就好像结果是按字母顺序排列的,而且依赖于此,但然后表被重新组织,顺序发生了变化。我们坚信 SQL 标准正确地限制了这样的行为,以避免不可预测性。

还要注意,虽然 SELECT 中的每一列必须在 GROUP BY 或聚合函数中使用,但可以 GROUP BY 不在 SELECT 中的列。稍后您将看到一些示例。

现在让我们构建一个更有用的例子。演员通常在他们的职业生涯中参与许多电影。我们可能想知道某个特定演员演过多少部电影,或者为我们所知的每位演员进行计算,并通过生产率得到一个评级。首先,我们可以使用迄今为止学到的技术,在actorfilm_actor表之间执行INNER JOIN。我们不需要film表,因为我们不关心电影本身的任何细节。然后我们可以按演员姓名对输出进行排序,以便更容易计算我们想要的内容:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id`
    -> `FROM` `actor` `INNER` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `ORDER` `BY` `first_name``,` `last_name` `LIMIT` `20``;`
+------------+-----------+---------+
| first_name | last_name | film_id |
+------------+-----------+---------+
| ADAM       | GRANT     |      26 |
| ADAM       | GRANT     |      52 |
| ADAM       | GRANT     |     233 |
| ADAM       | GRANT     |     317 |
| ADAM       | GRANT     |     359 |
| ADAM       | GRANT     |     362 |
| ADAM       | GRANT     |     385 |
| ADAM       | GRANT     |     399 |
| ADAM       | GRANT     |     450 |
| ADAM       | GRANT     |     532 |
| ADAM       | GRANT     |     560 |
| ADAM       | GRANT     |     574 |
| ADAM       | GRANT     |     638 |
| ADAM       | GRANT     |     773 |
| ADAM       | GRANT     |     833 |
| ADAM       | GRANT     |     874 |
| ADAM       | GRANT     |     918 |
| ADAM       | GRANT     |     956 |
| ADAM       | HOPPER    |      81 |
| ADAM       | HOPPER    |      82 |
+------------+-----------+---------+
20 rows in set (0.01 sec)

通过逐一列出列表,很容易计算出每个演员有多少部电影,至少亚当·格兰特是这样。但是,如果没有LIMIT,查询将返回 5,462 行不同的结果,手动计算我们的计数会花费很多时间。GROUP BY子句可以通过按演员分组来自动化这一过程;然后我们可以使用COUNT()函数来计算每个组中的电影数量。最后,我们可以使用ORDER BYLIMIT来获取拥有最多电影出演次数的前 10 名演员。以下是执行我们所需操作的查询:

mysql> `SELECT` `first_name``,` `last_name``,` `COUNT``(``film_id``)` `AS` `num_films` `FROM`
    -> `actor` `INNER` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `GROUP` `BY` `first_name``,` `last_name`
    -> `ORDER` `BY` `num_films` `DESC` `LIMIT` `5``;`
+------------+-------------+-----------+
| first_name | last_name   | num_films |
+------------+-------------+-----------+
| SUSAN      | DAVIS       |        54 |
| GINA       | DEGENERES   |        42 |
| WALTER     | TORN        |        41 |
| MARY       | KEITEL      |        40 |
| MATTHEW    | CARREY      |        39 |
| SANDRA     | KILMER      |        37 |
| SCARLETT   | DAMON       |        36 |
| VAL        | BOLGER      |        35 |
| ANGELA     | WITHERSPOON |        35 |
| UMA        | WOOD        |        35 |
+------------+-------------+-----------+
10 rows in set (0.01 sec)

你可以看到,我们请求的输出是first_name, last_name, COUNT(film_id) as num_films,这告诉我们确切想要知道的内容。我们按照first_namelast_name列对数据进行分组,在此过程中运行COUNT()聚合函数。对于上一个查询中得到的每个“桶”行,我们现在只得到一行,尽管提供了我们想要的信息。注意我们如何结合使用GROUP BYORDER BY来获得所需的顺序:按照电影数量,从多到少。GROUP BY不能保证顺序,只能进行分组。最后,我们通过LIMIT将输出限制为显示我们最多产的前 10 名演员,否则我们将得到 199 行输出。

让我们进一步考虑这个查询。我们将从GROUP BY子句开始。这告诉我们如何将行放在一起形成组:在这个例子中,我们告诉 MySQL 按first_name, last_name来分组行。结果是相同姓名的演员行成一个集群或“桶”,也就是说,每个不同的姓名成为一个组。一旦行被分组,它们在查询的其余部分被视为一行。所以,例如,当我们写SELECT first_name, last_name时,我们对每个组只得到一行。这与DISTINCT完全相同,正如我们已经讨论过的。COUNT()函数告诉我们组的属性。更具体地说,它告诉我们每个组中形成的行数;您可以计算组中的任何列,您将得到相同的答案,因此COUNT(film_id)几乎总是与COUNT(*)COUNT(first_name)相同。(有关为什么我们说“几乎”,请参见“聚合函数”了解更多详细信息。)我们也可以只做COUNT(1),或者实际指定任何文字。把这看作是从表中进行SELECT 1,然后计算结果。对于表中的每一行,都将输出一个值为 1 的值,并且COUNT()进行计数。一个例外是NULL:虽然指定COUNT(NULL)是完全可接受和合法的,但结果总是零,因为COUNT()丢弃NULL值。当然,您可以为COUNT()列使用列别名。

让我们尝试另一个例子。假设您想知道每部电影中有多少不同的演员参演,以及电影名称及其类别,并获取最大组的五部电影。这里是查询:

mysql> `SELECT` `title``,` `name` `AS` `category_name``,` `COUNT``(``*``)` `AS` `cnt`
    -> `FROM` `film` `INNER` `JOIN` `film_actor` `USING` `(``film_id``)`
    -> `INNER` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `INNER` `JOIN` `category` `USING` `(``category_id``)`
    -> `GROUP` `BY` `film_id``,` `category_id`
    -> `ORDER` `BY` `cnt` `DESC` `LIMIT` `5``;`
+------------------+---------------+-----+
| title            | category_name | cnt |
+------------------+---------------+-----+
| LAMBS CINCINATTI | Games         |  15 |
| CRAZY HOME       | Comedy        |  13 |
| CHITTY LOCK      | Drama         |  13 |
| RANDOM GO        | Sci-Fi        |  13 |
| DRACULA CRYSTAL  | Classics      |  13 |
+------------------+---------------+-----+
5 rows in set (0.03 sec)

在我们讨论新内容之前,请考虑查询的一般功能。我们使用它们的标识列:filmfilm_actorfilm_categorycategory,通过INNER JOIN将四个表连接在一起。暂且不考虑聚合,这个查询的输出是每个电影和演员组合的一行。

GROUP BY子句将行组合成集群。在这个查询中,我们希望将电影与它们的类别分组在一起。GROUP BY子句使用film_idcategory_id来完成这一点。您可以从任何三个表中使用film_id列;对于此目的,film.film_idfilm_actor.film_idfilm_category.film_id是相同的。使用哪一个并不重要;INNER JOIN确保它们匹配。同样适用于category_id

正如前面提到的,即使需要在GROUP BY中列出每个非聚合列,您也可以在SELECT之外的列上GROUP BY。在前面的示例查询中,我们使用COUNT()函数告诉我们每个组中有多少行。例如,您可以看到COUNT(*)告诉我们在喜剧CRAZY HOME中有 13 位演员。再次强调,在查询中计数的列无关紧要:例如,COUNT(*)COUNT(film.film_id)COUNT(category.name)具有相同的效果。

然后,我们按COUNT(*)列的别名cnt降序排序输出,并选择前五行。请注意,有多行的cnt等于 13。事实上,数据库中甚至有六个这样的行,使得这种排序有些不公平,因为具有相同演员数量的电影将随机排序。你可以添加另一个列到ORDER BY子句,比如title,使得排序更可预测。

让我们再试一个例子。sakila数据库不仅仅是关于电影和演员:毕竟它是基于电影租借的。我们还有顾客信息,包括他们租借的电影数据。假设我们想知道哪些顾客倾向于租借同一类别的电影。例如,我们可能想根据一个人是否喜欢不同的电影类别或者大部分时间都喜欢某一类别来调整我们的广告。我们需要仔细考虑我们的分组方式:我们不希望按电影分组,因为那只会给我们提供顾客租借它的次数。最终的查询相当复杂,尽管它仍然围绕INNER JOINGROUP BY进行。

mysql> `SELECT` `email``,` `name` `AS` `category_name``,` `COUNT``(``category_id``)` `AS` `cnt`
    -> `FROM` `customer` `cs` `INNER` `JOIN` `rental` `USING` `(``customer_id``)`
    -> `INNER` `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `INNER` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `INNER` `JOIN` `category` `cat` `USING` `(``category_id``)`
    -> `GROUP` `BY` `1``,` `2`
    -> `ORDER` `BY` `3` `DESC` `LIMIT` `5``;`
+----------------------------------+---------------+-----+
| email                            | category_name | cnt |
+----------------------------------+---------------+-----+
| WESLEY.BULL@sakilacustomer.org   | Games         |   9 |
| ALMA.AUSTIN@sakilacustomer.org   | Animation     |   8 |
| KARL.SEAL@sakilacustomer.org     | Animation     |   8 |
| LYDIA.BURKE@sakilacustomer.org   | Documentary   |   8 |
| NATHAN.RUNYON@sakilacustomer.org | Animation     |   7 |
+----------------------------------+---------------+-----+
5 rows in set (0.08 sec)

这些客户反复从同一类别租借电影。我们不知道的是,他们是否曾多次租借了同一部电影,还是仅仅是类别内的不同电影。GROUP BY子句隐藏了细节。同样,我们使用COUNT(*)来统计组中的行数,你可以看到查询中第 2 到第 5 行之间的INNER JOIN

这个查询的有趣之处在于,我们没有明确指定GROUP BYORDER BY子句的列名。相反,我们使用了列在SELECT子句中出现的位置编号(从 1 开始)。这种技术节省了输入时间,但如果稍后决定在SELECT中添加另一列,可能会导致顺序混乱。

DISTINCT类似,GROUP BY也存在一些风险需要提及。考虑以下查询:

mysql> `SELECT` `COUNT``(``*``)` `FROM` `actor` `GROUP` `BY` `first_name``,` `last_name``;`
+----------+
| COUNT(*) |
+----------+
|        1 |
|        1 |
|      ... |
|        1 |
|        1 |
+----------+
199 rows in set (0.00 sec)

看起来很简单,并且它输出了给定名和姓在actor表中找到的组合出现次数。你可能会认为它只会输出 199 行数字1。然而,如果我们对actor表进行COUNT(*),我们会得到 200 行。问题在哪里?显然,有两个演员具有相同的名和姓。这些情况偶尔会发生,你必须注意这一点。当你基于不构成唯一标识符的列进行分组时,可能会意外地将无关的行分组在一起,导致数据误导。为了找出重复项,我们可以修改一个我们在“表别名”中构建的查询,以查找具有相同名称的电影:

mysql> `SELECT` `a1``.``actor_id``,` `a1``.``first_name``,` `a1``.``last_name`
    -> `FROM` `actor` `AS` `a1``,` `actor` `AS` `a2`
    -> `WHERE` `a1``.``first_name` `=` `a2``.``first_name`
    -> `AND` `a1``.``last_name` `=` `a2``.``last_name`
    -> `AND` `a1``.``actor_id` `<``>` `a2``.``actor_id``;`
+----------+------------+-----------+
| actor_id | first_name | last_name |
+----------+------------+-----------+
|      101 | SUSAN      | DAVIS     |
|      110 | SUSAN      | DAVIS     |
+----------+------------+-----------+
2 rows in set (0.00 sec)

在我们结束本节之前,让我们再次触及一下 MySQL 如何在 GROUP BY 子句周围扩展 SQL 标准。在 MySQL 5.7 之前,默认情况下可以在 GROUP BY 子句中指定不完整的列列表,正如我们所解释的那样,这导致非分组依赖列中的随机行输出到组内。出于支持旧版软件的原因,MySQL 5.7 和 MySQL 8.0 都继续提供这种行为,尽管必须显式启用。该行为由 ONLY_FULL_GROUP_BY SQL 模式控制,默认设置为启用。如果你发现自己需要迁移依赖于旧版 GROUP BY 行为的程序,我们建议不要改变 SQL 模式。通常有两种方法来解决这个问题。第一种是了解查询逻辑是否真的需要不完整的分组,这种情况很少见。第二种是通过使用聚合函数如 MIN()MAX(),或者特殊的 ANY_VALUE() 聚合函数来支持非分组列的随机数据行为,后者只是从组内产生一个随机值。我们接下来会更仔细地看一下聚合函数。

聚合函数

我们已经看到 COUNT() 函数如何用于告诉组内有多少行。在这里,我们将介绍一些其他常用于探索聚合行属性的函数。我们还会稍微扩展一下 COUNT() 的使用方式,因为它经常被用到:

COUNT()

返回行的数量或列中值的数量。记得我们提到过 COUNT(*) 几乎总是等同于 COUNT(<column>)。问题在于 NULLCOUNT(*) 会对返回的行数进行计数,无论这些行中的列是否为 NULL。然而,当你使用 COUNT(<column>) 时,只会计算非 NULL 值。例如,在 sakila 数据库中,客户的电子邮件地址可能为空,我们可以观察其影响:

mysql> `SELECT` `COUNT``(``*``)` `FROM` `customer``;`
+----------+
| count(*) |
+----------+
|      599 |
+----------+
1 row in set (0.00 sec)
mysql> `SELECT` `COUNT``(``email``)` `FROM` `customer``;`
+--------------+
| count(email) |
+--------------+
|          598 |
+--------------+
1 row in set (0.00 sec)

此外,我们还应该补充说明 COUNT() 可以在内部使用 DISTINCT 子句运行,例如 COUNT(DISTINCT <column>),在这种情况下返回不同值的数量,而不是所有值的数量。

AVG()

返回组内指定列中值的平均值(均值)。例如,你可以用它来找到一个城市房屋的平均成本,当房屋按城市分组时。

SELECT AVG(cost) FROM house_prices GROUP BY city;

MAX()

返回组内行中的最大值。例如,你可以用它来找到一个月中最热的一天,当行按月份分组时。

MIN()

返回组内行中的最小值。例如,你可以用它来找到一个班级中年龄最小的学生,当行按班级分组时。

STD()STDDEV()STDDEV_POP()

返回组中行的标准差。例如,您可以使用这些来了解按大学课程分组时测试分数的分布。这三个都是同义词。STD()是 MySQL 扩展,STDDEV()是为了与 Oracle 兼容而添加的,STDDEV_POP()是 SQL 标准函数。

SUM()

返回组中行的值的总和。例如,您可以使用它来计算按月分组时的销售金额。

GROUP BY一起使用的其他函数也可用,但它们的使用频率不如我们在此介绍的函数高。您可以在 MySQL 参考手册的聚合函数描述部分找到更多详细信息。

HAVING子句

现在您已经熟悉了GROUP BY子句,它允许您对数据进行排序和分组。您应该能够使用它来查找计数、平均值、最小值和最大值。本节展示了如何使用HAVING子句对GROUP BY操作中的行进行额外控制。

假设您想知道我们数据库中有多少受欢迎的演员。您决定将参与至少 40 部电影的演员定义为受欢迎的演员。在前一节中,我们尝试了一个几乎相同的查询,但没有受欢迎度限制。我们还发现,当我们按名字分组演员时,我们丢失了一条记录,因此我们将添加对actor_id列的分组,我们知道它是唯一的。以下是新查询,带有额外的HAVING子句,添加了约束条件:

mysql> `SELECT` `first_name``,` `last_name``,` `COUNT``(``film_id``)`
    -> `FROM` `actor` `INNER` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `GROUP` `BY` `actor_id``,` `first_name``,` `last_name`
    -> `HAVING` `COUNT``(``film_id``)` `>` `40`
    -> `ORDER` `BY` `COUNT``(``film_id``)` `DESC``;`
+------------+-----------+----------------+
| first_name | last_name | COUNT(film_id) |
+------------+-----------+----------------+
| GINA       | DEGENERES |             42 |
| WALTER     | TORN      |             41 |
+------------+-----------+----------------+
2 rows in set (0.01 sec)

您可以看到,只有两位演员符合新标准。

HAVING子句必须包含在SELECT子句中列出的表达式或列。在本例中,我们使用了HAVING COUNT(film_id) >= 40,您可以看到COUNT(film_id)SELECT子句的一部分。通常,HAVING子句中的表达式使用聚合函数,如COUNT()SUM()MIN()MAX()。如果您发现自己想要编写一个HAVING子句,使用的列或表达式不在SELECT子句中,那么很可能应该使用WHERE子句。HAVING子句仅用于决定如何形成每个组或聚类,而不是用于选择输出中的行。稍后我们将展示一个示例,说明何时不应使用HAVING

让我们再试一个例子。假设您想要一个列表,列出被租超过 30 次的前 5 部电影及其租赁次数,按人气逆序排序。以下是您将使用的查询:

mysql> `SELECT` `title``,` `COUNT``(``rental_id``)` `AS` `num_rented` `FROM`
    -> `film` `INNER` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `INNER` `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `GROUP` `BY` `title`
    -> `HAVING` `num_rented` `>` `30`
    -> `ORDER` `BY` `num_rented` `DESC` `LIMIT` `5``;`
+--------------------+------------+
| title              | num_rented |
+--------------------+------------+
| BUCKET BROTHERHOOD |         34 |
| ROCKETEER MOTHER   |         33 |
| FORWARD TEMPLE     |         32 |
| GRIT CLOCKWORK     |         32 |
| JUGGLER HARDLY     |         32 |
+--------------------+------------+
5 rows in set (0.04 sec)

您可以再次看到,在SELECTHAVING子句中都使用了表达式COUNT()。不过,这次我们将COUNT(rental_id)函数别名为num_rented,并在HAVINGORDER BY子句中使用了该别名。

现在让我们考虑一个不应该使用HAVING的例子。你想知道一个特定演员出演了多少部电影。以下是你不应该使用的查询:

mysql> `SELECT` `first_name``,` `last_name``,` `COUNT``(``film_id``)` `AS` `film_cnt` `FROM`
    -> `actor` `INNER` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `GROUP` `BY` `actor_id``,` `first_name``,` `last_name`
    -> `HAVING` `first_name` `=` `'EMILY'` `AND` `last_name` `=` `'DEE'``;`
+------------+-----------+----------+
| first_name | last_name | film_cnt |
+------------+-----------+----------+
| EMILY      | DEE       |       14 |
+------------+-----------+----------+
1 row in set (0.02 sec)

它得到了正确的答案,但是用了错误的方式——对于大量数据来说,这种方式要慢得多。这不是编写查询的正确方式,因为HAVING子句并没有被用来决定哪些行应该形成每个组,而是被错误地用来过滤要显示的答案。对于这个查询,我们应该真正使用WHERE子句,如下所示:

mysql> `SELECT` `first_name``,` `last_name``,` `COUNT``(``film_id``)` `AS` `film_cnt` `FROM`
    -> `actor` `INNER` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `WHERE` `first_name` `=` `'EMILY'` `AND` `last_name` `=` `'DEE'`
    -> `GROUP` `BY` `actor_id``,` `first_name``,` `last_name``;`
+------------+-----------+----------+
| first_name | last_name | film_cnt |
+------------+-----------+----------+
| EMILY      | DEE       |       14 |
+------------+-----------+----------+
1 row in set (0.00 sec)

这个正确的查询形成了组,然后根据WHERE子句选择要显示的组。

高级连接

到目前为止,在本书中,我们已经使用INNER JOIN子句从两个或多个表中汇集行。我们将在本节中更详细地解释内连接,并将其与我们讨论的其他连接类型进行对比:联合、左连接、右连接和自然连接。在本节结束时,你将能够回答困难的信息需求,并熟悉选择适合当前任务的正确连接方式。

内连接

INNER JOIN子句根据你在USING子句中提供的条件匹配两个表之间的行。例如,你现在非常熟悉actorfilm_actor表的内连接:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id` `FROM`
    -> `actor` `INNER` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `LIMIT` `20``;`
+------------+-----------+---------+
| first_name | last_name | film_id |
+------------+-----------+---------+
| PENELOPE   | GUINESS   |       1 |
| PENELOPE   | GUINESS   |      23 |
| ...                              |
| PENELOPE   | GUINESS   |     980 |
| NICK       | WAHLBERG  |       3 |
+------------+-----------+---------+
20 rows in set (0.00 sec)

让我们回顾一下INNER JOIN的关键特点:

  • INNER JOIN关键字短语的两侧列出了两个表(或前一个连接的结果)。

  • USING子句定义了一个或多个在两个表或结果中都存在并用于连接或匹配行的列。

  • 不匹配的行不会被返回。例如,如果actor表中有一行没有在film_actor表中有任何匹配的电影,它将不会包含在输出中。

你实际上可以使用WHERE子句编写带有INNER JOIN关键字的内连接查询。以下是产生相同结果的前一个查询的重写版本:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id`
    -> `FROM` `actor``,` `film_actor`
    -> `WHERE` `actor``.``actor_id` `=` `film_actor``.``actor_id`
    -> `LIMIT` `20``;`
+------------+-----------+---------+
| first_name | last_name | film_id |
+------------+-----------+---------+
| PENELOPE   | GUINESS   |       1 |
| PENELOPE   | GUINESS   |      23 |
| ...                              |
| PENELOPE   | GUINESS   |     980 |
| NICK       | WAHLBERG  |       3 |
+------------+-----------+---------+
20 rows in set (0.00 sec)

你可以看到我们没有明确说明内连接:我们正在从actorfilm_actor表中选择标识符在表之间匹配的行。

你可以修改INNER JOIN语法,以一种类似于使用WHERE子句的方式表达连接条件。如果在表之间的标识符名称不匹配,这将非常有用,尽管在这个例子中并非如此。以下是以这种风格重写的前一个查询:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id` `FROM`
    -> `actor` `INNER` `JOIN` `film_actor`
    -> `ON` `actor``.``actor_id` `=` `film_actor``.``actor_id`
    -> `LIMIT` `20``;`
+------------+-----------+---------+
| first_name | last_name | film_id |
+------------+-----------+---------+
| PENELOPE   | GUINESS   |       1 |
| PENELOPE   | GUINESS   |      23 |
| ...                              |
| PENELOPE   | GUINESS   |     980 |
| NICK       | WAHLBERG  |       3 |
+------------+-----------+---------+
20 rows in set (0.00 sec)

你可以看到ON子句取代了USING子句,并且后面跟着的列完全指定了包括表和列名。如果两个表之间的列名称不同且唯一,你可以省略表名。使用ONWHERE子句没有真正的优势或劣势;这只是一种口味问题。通常,如今,你会发现大多数 SQL 专业人员更喜欢使用带有ON子句的INNER JOIN,而不是WHERE,但这并非普遍适用。

在继续之前,让我们考虑WHEREONUSING子句的作用。如果从我们刚刚展示的查询中省略WHERE子句,将得到一个非常不同的结果。以下是查询和输出的前几行:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id`
    -> `FROM` `actor``,` `film_actor` `LIMIT` `20``;`
+------------+-------------+---------+
| first_name | last_name   | film_id |
+------------+-------------+---------+
| THORA      | TEMPLE      |       1 |
| JULIA      | FAWCETT     |       1 |
| ...                                |
| DEBBIE     | AKROYD      |       1 |
| MATTHEW    | CARREY      |       1 |
+------------+-------------+---------+
20 rows in set (0.00 sec)

输出是荒谬的:发生的情况是将actor表中的每一行与film_actor表中的每一行一起输出,得到所有可能的组合。由于actor表有 200 名演员和film_actor表有 5,462 条记录,所以输出行数为 200 × 5,462 = 1,092,400 行,我们知道其中只有 5,462 个组合是有意义的(即有 5,462 条记录是演员参演电影的记录)。我们可以看到在没有LIMIT的情况下我们会得到多少行,以下是查询的内容:

mysql> `SELECT` `COUNT``(``*``)` `FROM` `actor``,` `film_actor``;`
+----------+
| COUNT(*) |
+----------+
|  1092400 |
+----------+
1 row in set (0.00 sec)

这种查询类型,如果没有匹配行的子句,被称为笛卡尔积。顺便说一句,如果在没有使用USINGON子句指定列的情况下执行内连接,也会得到笛卡尔积,就像这个查询一样:

SELECT first_name, last_name, film_id
FROM actor INNER JOIN film_actor;

在“自然连接”中,我们将介绍自然连接,它是基于相同列名的内连接。虽然自然连接不使用明确指定的列,但它仍然产生内连接,而不是笛卡尔积。

关键词INNER JOIN可以用JOINSTRAIGHT JOIN来替代;它们的功能是相同的。然而,STRAIGHT JOIN会强制 MySQL 在读取右表之前始终先读取左表。我们将在第七章详细讨论 MySQL 在后台如何处理查询。关键词JOIN是最常见的,它是许多其他数据库系统(除了 MySQL)中用于INNER JOIN的标准缩写,在我们的内连接示例中也会使用它。

联合

UNION语句实际上不是一个连接运算符。相反,它允许你组合多个SELECT语句的输出,以提供一个汇总的结果集。在你想要从多个源中产生单一列表的情况下,或者你想要从一个单一源中创建难以在单一查询中表达的列表时,它是非常有用的。

让我们来看一个例子。如果你想在sakila数据库中输出所有演员和电影以及顾客的名字,你可以使用UNION语句。这是一个人为的例子,但你可能只是想列出所有的文本片段,而不是有意义地展示数据之间的关系。在actor.first_namefilm.titlecustomer.first_name列中都有文本。以下是如何显示它的方式:

mysql> `SELECT` `first_name` `FROM` `actor`
    -> `UNION`
    -> `SELECT` `first_name` `FROM` `customer`
    -> `UNION`
    -> `SELECT` `title` `FROM` `film``;`
+-----------------------------+
| first_name                  |
+-----------------------------+
| PENELOPE                    |
| NICK                        |
| ED                          |
| ...                         |
| ZHIVAGO CORE                |
| ZOOLANDER FICTION           |
| ZORRO ARK                   |
+-----------------------------+
1647 rows in set (0.00 sec)

我们只展示了 1,647 行中的一小部分。UNION语句会将所有查询的结果一起输出,在第一个查询下显示适当的标题。

一个稍微不那么人为的例子是创建一个数据库中租借最多和最少的五部电影的列表。你可以很容易地使用UNION运算符来实现这一点:

mysql> `(``SELECT` `title``,` `COUNT``(``rental_id``)` `AS` `num_rented`
    -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `GROUP` `BY` `title` `ORDER` `BY` `num_rented` `DESC` `LIMIT` `5``)`
    -> `UNION`
    -> `(``SELECT` `title``,` `COUNT``(``rental_id``)` `AS` `num_rented`
    -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `GROUP` `BY` `title` `ORDER` `BY` `num_rented` `ASC` `LIMIT` `5``)``;`
+--------------------+------------+
| title              | num_rented |
+--------------------+------------+
| BUCKET BROTHERHOOD |         34 |
| ROCKETEER MOTHER   |         33 |
| FORWARD TEMPLE     |         32 |
| GRIT CLOCKWORK     |         32 |
| JUGGLER HARDLY     |         32 |
| TRAIN BUNCH        |          4 |
| HARDLY ROBBERS     |          4 |
| MIXED DOORS        |          4 |
| BUNCH MINDS        |          5 |
| BRAVEHEART HUMAN   |          5 |
+--------------------+------------+
10 rows in set (0.04 sec)

第一个查询使用带有DESC(降序)修饰符和LIMIT 5子句来查找租借次数最多的前五部电影。第二个查询使用带有ASC(升序)修饰符和LIMIT 5子句来查找租借次数最少的五部电影。UNION组合了结果集。请注意,具有相同num_rented值的多个标题,其顺序不能保证确定。在您的系统上,可能会看到不同标题列出num_rented值为 32 和 5 的情况。

UNION运算符有几个限制:

  • 输出标记为来自第一个查询的列名或表达式的名称。使用列别名可以更改此行为。

  • 查询必须输出相同数量的列。如果尝试使用不同数量的列,MySQL 将报错。

  • 所有匹配列必须具有相同的类型。因此,例如,如果第一个查询输出的第一列是日期,则任何其他查询输出的第一列也必须是日期。

  • 返回的结果是唯一的,就像你应用了DISTINCT到整个结果集一样。为了看到它的效果,让我们尝试一个简单的例子。记住我们之前在演员名字方面遇到的问题——名字不是一个好的唯一标识符。如果我们选择两个具有相同名字的演员,并且将这两个查询使用UNION,我们最终只会得到一行结果。隐式的DISTINCT操作隐藏了重复(对于UNION)的行:

    mysql> `SELECT` `first_name` `FROM` `actor` `WHERE` `actor_id` `=` `88`
        -> `UNION`
        -> `SELECT` `first_name` `FROM` `actor` `WHERE` `actor_id` `=` `169``;`
    
    +------------+
    | first_name |
    +------------+
    | KENNETH    |
    +------------+
    1 row in set (0.01 sec)
    

    如果你想显示任何重复项,请用UNION替换为UNION ALL

    mysql> `SELECT` `first_name` `FROM` `actor` `WHERE` `actor_id` `=` `88`
        -> `UNION` `ALL`
        -> `SELECT` `first_name` `FROM` `actor` `WHERE` `actor_id` `=` `169``;`
    
    +------------+
    | first_name |
    +------------+
    | KENNETH    |
    | KENNETH    |
    +------------+
    2 rows in set (0.00 sec)
    

    这里,名为KENNETH的名字出现了两次。

    UNION执行的隐式DISTINCT会对性能产生非零的影响。每当使用UNION时,请查看是否逻辑上适合使用UNION ALL,以及它是否可以提高查询性能。

  • 如果你想在UNION语句的一个单独查询中应用LIMITORDER BY,请将该查询用括号括起来(如前面的例子所示)。无论如何,使用括号使查询易于理解是很有用的。

    UNION操作仅简单地连接组成查询的结果,不考虑顺序,因此在子查询中使用ORDER BY没有太大意义。在UNION操作中对子查询进行排序只有在想要选择结果子集时才有意义。在我们的示例中,我们通过电影租借次数对电影进行了排序,并仅选择了前五部(在第一个子查询中)和最后五部(在第二个子查询中)。

    出于效率考虑,如果在子查询中使用ORDER BY而没有LIMIT,MySQL 实际上会忽略ORDER BY子句。让我们看一些示例来确切了解其工作原理。

    首先,让我们运行一个简单的查询,列出特定电影的租赁信息,以及租赁发生的时间。为了与我们的其他示例保持一致,我们将查询括在括号中——这里括号实际上没有任何效果——并且没有使用ORDER BYLIMIT子句:

    mysql> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998``)``;`
    
    +--------------+---------------------+---------------------+
    | title        | rental_date         | return_date         |
    +--------------+---------------------+---------------------+
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-07-27 14:53:55 | 2005-07-31 19:48:55 |
    | ZHIVAGO CORE | 2005-08-20 17:18:48 | 2005-08-26 15:31:48 |
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    | ZHIVAGO CORE | 2005-08-02 02:05:04 | 2005-08-10 21:58:04 |
    | ZHIVAGO CORE | 2006-02-14 15:16:03 | NULL                |
    +--------------+---------------------+---------------------+
    9 rows in set (0.00 sec)
    

    查询返回所有租用电影的时间,顺序没有特定要求(参见第四和第五条目)。

    现在,让我们在这个查询中添加一个ORDER BY子句:

    mysql> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998`
        -> `ORDER` `BY` `rental_date` `ASC``)``;`
    
    +--------------+---------------------+---------------------+
    | title        | rental_date         | return_date         |
    +--------------+---------------------+---------------------+
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    | ZHIVAGO CORE | 2005-07-27 14:53:55 | 2005-07-31 19:48:55 |
    | ZHIVAGO CORE | 2005-08-02 02:05:04 | 2005-08-10 21:58:04 |
    | ZHIVAGO CORE | 2005-08-20 17:18:48 | 2005-08-26 15:31:48 |
    | ZHIVAGO CORE | 2006-02-14 15:16:03 | NULL                |
    +--------------+---------------------+---------------------+
    9 rows in set (0.00 sec)
    

    如预期的那样,我们按照租赁日期的顺序获取了所有租用电影的时间。

    在前一个查询中添加一个LIMIT子句会选择前五个租赁,按时间顺序排列——这里没有意外:

    mysql> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998`
        -> `ORDER` `BY` `rental_date` `ASC` `LIMIT` `5``)``;`
    
    +--------------+---------------------+---------------------+
    | title        | rental_date         | return_date         |
    +--------------+---------------------+---------------------+
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    +--------------+---------------------+---------------------+
    5 rows in set (0.01 sec)
    

    现在,让我们看看在执行UNION操作时会发生什么。在这个例子中,我们使用了两个带有ORDER BY子句的子查询。我们对第二个子查询使用了LIMIT子句,但没有对第一个子查询使用:

    mysql> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998`
        -> `ORDER` `BY` `rental_date` `ASC``)`
        -> `UNION` `ALL`
        -> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998`
        -> `ORDER` `BY` `rental_date` `ASC` `LIMIT` `5``)``;`
    
    +--------------+---------------------+---------------------+
    | title        | rental_date         | return_date         |
    +--------------+---------------------+---------------------+
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-07-27 14:53:55 | 2005-07-31 19:48:55 |
    | ZHIVAGO CORE | 2005-08-20 17:18:48 | 2005-08-26 15:31:48 |
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    | ZHIVAGO CORE | 2005-08-02 02:05:04 | 2005-08-10 21:58:04 |
    | ZHIVAGO CORE | 2006-02-14 15:16:03 | NULL                |
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    +--------------+---------------------+---------------------+
    14 rows in set (0.01 sec)
    

    正如预期的那样,第一个子查询返回所有租用电影的时间(输出的前九行),第二个子查询返回前五个租赁(输出的最后五行)。请注意,尽管第一个子查询有ORDER BY子句,前九行并未按顺序排列(参见第四和第五行)。由于我们执行了UNION操作,MySQL 服务器决定不对子查询的结果进行排序。第二个子查询包含了一个LIMIT操作,因此该子查询的结果是排序的。

    即使子查询已经排序,UNION操作的输出并不能保证有序,因此如果你希望最终输出有序,应该在整个查询的末尾添加一个ORDER BY子句。请注意,它可以是另一种顺序,不同于子查询。参见以下内容:

    mysql> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998`
        -> `ORDER` `BY` `rental_date` `ASC``)`
        -> `UNION` `ALL`
        -> `(``SELECT` `title``,` `rental_date``,` `return_date`
        -> `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
        -> `JOIN` `rental` `USING` `(``inventory_id``)`
        -> `WHERE` `film_id` `=` `998`
        -> `ORDER` `BY` `rental_date` `ASC` `LIMIT` `5``)`
        -> `ORDER` `BY` `rental_date` `DESC``;`
    
    +--------------+---------------------+---------------------+
    | title        | rental_date         | return_date         |
    +--------------+---------------------+---------------------+
    | ZHIVAGO CORE | 2006-02-14 15:16:03 | NULL                |
    | ZHIVAGO CORE | 2005-08-20 17:18:48 | 2005-08-26 15:31:48 |
    | ZHIVAGO CORE | 2005-08-02 02:05:04 | 2005-08-10 21:58:04 |
    | ZHIVAGO CORE | 2005-07-27 14:53:55 | 2005-07-31 19:48:55 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    | ZHIVAGO CORE | 2005-07-12 05:24:02 | 2005-07-16 03:43:02 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-07-07 12:18:57 | 2005-07-12 09:47:57 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-06-18 06:46:54 | 2005-06-26 09:48:54 |
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-06-17 03:19:20 | 2005-06-21 00:19:20 |
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    | ZHIVAGO CORE | 2005-05-30 05:15:20 | 2005-06-07 00:49:20 |
    +--------------+---------------------+---------------------+
    14 rows in set (0.00 sec)
    

    这里是另一个示例,包括对最终结果进行排序并限制返回结果的数量:

    mysql> `(``SELECT` `first_name``,` `last_name` `FROM` `actor` `WHERE` `actor_id` `<` `5``)`
        -> `UNION`
        -> `(``SELECT` `first_name``,` `last_name` `FROM` `actor` `WHERE` `actor_id` `>` `190``)`
        -> `ORDER` `BY` `first_name` `LIMIT` `4``;`
    
    +------------+-----------+
    | first_name | last_name |
    +------------+-----------+
    | BELA       | WALKEN    |
    | BURT       | TEMPLE    |
    | ED         | CHASE     |
    | GREGORY    | GOODING   |
    +------------+-----------+
    4 rows in set (0.00 sec)
    

    UNION操作有些笨重,通常有获得相同结果的替代方法。例如,前面的查询可以更简单地写成这样:

    mysql> `SELECT` `first_name``,` `last_name` `FROM` `actor`
        -> `WHERE` `actor_id` `<` `5` `OR` `actor_id` `>` `190`
        -> `ORDER` `BY` `first_name` `LIMIT` `4``;`
    
    +------------+-----------+
    | first_name | last_name |
    +------------+-----------+
    | BELA       | WALKEN    |
    | BURT       | TEMPLE    |
    | ED         | CHASE     |
    | GREGORY    | GOODING   |
    +------------+-----------+
    4 rows in set (0.00 sec)
    

左连接和右连接

我们到目前为止讨论的连接仅输出两个表之间匹配的行。例如,当你通过inventory表连接filmrental表时,只会看到已经租出的电影。未租出的电影行将被忽略。在许多情况下这是有道理的,但这并不是连接数据的唯一方式。本节将解释你可以选择的其他选项。

假设您确实想要一个全面的电影列表以及它们被租赁的次数。与本章前面的示例不同,您希望在列表中看到未被租赁的电影旁边的数字为零。您可以使用 left join 来实现这一点,这是一种由参与连接的两个表中的一个驱动的不同类型的连接。在左连接中,左表中的每一行(驱动表)都会被处理并输出,如果第二个表中存在匹配的数据,则显示第二个表中的匹配数据,如果第二个表中没有匹配的数据,则显示 NULL 值。我们将在本节后面向您展示如何编写这种类型的查询,但我们将从一个更简单的示例开始。

这里是一个简单的 LEFT JOIN 示例。您想列出所有电影,并在每部电影旁边显示它们被租赁的时间。如果一部电影从未被租赁过,您也想看到这一点。如果一部电影被租赁多次,您也想看到这一点。查询如下:

mysql> `SELECT` `title``,` `rental_date`
    -> `FROM` `film` `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)``;`
+-----------------------------+---------------------+
| title                       | rental_date         |
+-----------------------------+---------------------+
| ACADEMY DINOSAUR            | 2005-07-08 19:03:15 |
| ACADEMY DINOSAUR            | 2005-08-02 20:13:10 |
| ACADEMY DINOSAUR            | 2005-08-21 21:27:43 |
| ...                                               |
| WAKE JAWS                   | NULL                |
| WALLS ARTIST                | NULL                |
| ...                                               |
| ZORRO ARK                   | 2005-07-31 07:32:21 |
| ZORRO ARK                   | 2005-08-19 03:49:28 |
+-----------------------------+---------------------+
16087 rows in set (0.06 sec)

你可以看到发生了什么:已租赁的电影有日期和时间,而未租赁的电影则没有(rental_date 值为 NULL)。还要注意,此示例中我们两次使用了 LEFT JOIN。首先,我们连接了 filminventory,并且我们希望即使一部电影不在我们的库存中(因此根据定义不能租赁),我们仍然输出它。然后我们将 rental 表与从前面连接结果中的数据集连接。我们再次使用 LEFT JOIN,因为我们可能有一些电影不在我们的库存中,这些电影在 rental 表中将没有任何行。然而,我们也可能有列在我们库存中但从未被租赁的电影。这就是我们在这里需要在两个表上使用 LEFT JOIN 的原因。

LEFT JOIN 中表的顺序很重要。如果您反转上一个查询中的顺序,将得到非常不同的输出:

mysql> `SELECT` `title``,` `rental_date`
    -> `FROM` `rental` `LEFT` `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `LEFT` `JOIN` `film` `USING` `(``film_id``)`
    -> `ORDER` `BY` `rental_date` `DESC``;`
+-----------------------------+---------------------+
| title                       | rental_date         |
+-----------------------------+---------------------+
| ...                                               |
| LOVE SUICIDES               | 2005-05-24 23:04:41 |
| GRADUATE LORD               | 2005-05-24 23:03:39 |
| FREAKY POCUS                | 2005-05-24 22:54:33 |
| BLANKET BEVERLY             | 2005-05-24 22:53:30 |
+-----------------------------+---------------------+
16044 rows in set (0.06 sec)

在这个版本中,查询由 rental 表驱动,因此所有从它中得到的行都与 inventory 表和 film 表匹配。由于根据定义 rental 表中的所有行都基于 inventory 表,后者与 film 表关联,因此我们在输出中没有 NULL 值。不存在没有对应电影的租赁。我们通过使用 ORDER BY rental_date DESC 调整了查询以显示我们确实没有得到任何 NULL 值(这些将是最后出现的)。

现在您可以看到,在我们确信我们的 left 表中有一些重要数据但不确定 right 表中是否有数据时,左连接非常有用。我们希望从左表获取带有或不带有右表相应行的行。让我们试着将这个应用到我们在 “GROUP BY 子句” 中编写的查询中,该查询显示了从同一类别大量租赁的客户。以下是查询,作为提醒:

mysql> `SELECT` `email``,` `name` `AS` `category_name``,` `COUNT``(``cat``.``category_id``)` `AS` `cnt`
    -> `FROM` `customer` `cs` `INNER` `JOIN` `rental` `USING` `(``customer_id``)`
    -> `INNER` `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `INNER` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `INNER` `JOIN` `category` `cat` `USING` `(``category_id``)`
    -> `GROUP` `BY` `email``,` `category_name`
    -> `ORDER` `BY` `cnt` `DESC` `LIMIT` `5``;`
+----------------------------------+---------------+-----+
| email                            | category_name | cnt |
+----------------------------------+---------------+-----+
| WESLEY.BULL@sakilacustomer.org   | Games         |   9 |
| ALMA.AUSTIN@sakilacustomer.org   | Animation     |   8 |
| KARL.SEAL@sakilacustomer.org     | Animation     |   8 |
| LYDIA.BURKE@sakilacustomer.org   | Documentary   |   8 |
| NATHAN.RUNYON@sakilacustomer.org | Animation     |   7 |
+----------------------------------+---------------+-----+
5 rows in set (0.06 sec)

现在,假设我们想要查看通过这种方式找到的客户是否租赁除了他们最喜欢的类别之外的电影?事实证明这实际上相当困难!

让我们考虑这个任务。我们需要从category表开始,因为那里有我们电影的所有分类。然后,我们需要开始构建一整个链条的左连接。首先,我们将category左连接到film_category,因为我们可能有没有电影的分类。然后,我们将结果左连接到inventory表,因为我们知道的一些电影可能不在我们的目录中。然后,我们将该结果左连接到rental表,因为顾客可能没有租用某些类别的电影。最后,我们需要将该结果左连接到我们的customer表。即使没有租赁记录与关联的客户记录,省略此处的左连接将导致 MySQL 丢弃没有顾客记录的类别行。

现在,在这整个长篇解释之后,我们可以继续按电子邮件地址过滤并获取我们的数据吗?不!不幸的是,通过在左连接关系中不是左侧的表上添加WHERE条件,我们破坏了此连接的理念。看看会发生什么:

mysql> `SELECT` `COUNT``(``*``)` `FROM` `category``;`
+----------+
| COUNT(*) |
+----------+
|       16 |
+----------+
1 row in set (0.00 sec)
mysql> `SELECT` `email``,` `name` `AS` `category_name``,` `COUNT``(``category_id``)` `AS` `cnt`
    -> `FROM` `category` `cat` `LEFT` `JOIN` `film_category` `USING` `(``category_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `JOIN` `customer` `cs` `ON` `rental``.``customer_id` `=` `cs``.``customer_id`
    -> `WHERE` `cs``.``email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `GROUP` `BY` `email``,` `category_name`
    -> `ORDER` `BY` `cnt` `DESC``;`
+--------------------------------+---------------+-----+
| email                          | category_name | cnt |
+--------------------------------+---------------+-----+
| WESLEY.BULL@sakilacustomer.org | Games         |   9 |
| WESLEY.BULL@sakilacustomer.org | Foreign       |   6 |
| ...                                                  |
| WESLEY.BULL@sakilacustomer.org | Comedy        |   1 |
| WESLEY.BULL@sakilacustomer.org | Sports        |   1 |
+--------------------------------+---------------+-----+
14 rows in set (0.00 sec)

我们为我们的客户获得了 14 个类别,而总共有 16 个。实际上,MySQL 将在这个查询中优化所有左连接,因为它理解到它们放在这里是没有意义的。在仅使用连接来回答我们的问题没有简单的方法——我们将在“嵌套连接中的查询”中返回到这个例子。

尽管默认情况下sakila没有未租用电影的电影类别,但如果我们稍微扩展我们的数据库,我们可以看到左连接的有效性:

mysql> `INSERT` `INTO` `category``(``name``)` `VALUES` `(``'Thriller'``)``;`

Query OK, 1 row affected (0.01 sec)
mysql> `SELECT` `cat``.``name``,` `COUNT``(``rental_id``)` `cnt`
    -> `FROM` `category` `cat` `LEFT` `JOIN` `film_category` `USING` `(``category_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `LEFT` `JOIN` `customer` `cs` `ON` `rental``.``customer_id` `=` `cs``.``customer_id`
    -> `GROUP` `BY` `1`
    -> `ORDER` `BY` `2` `DESC``;`
+---------------+------+
| category_name | cnt  |
+---------------+------+
| Sports        | 1179 |
| Animation     | 1166 |
| ...                  |
| Music         |  830 |
| Thriller      |    0 |
+---------------+------+
17 rows in set (0.07 sec)

如果我们在这里使用常规的INNER JOIN(或其同义词JOIN),我们将无法获取 Thriller 类别的信息,而其他类别的计数可能会有所不同。由于category是我们最左边的表,它驱动查询的过程,并且该表中的每一行都存在于输出中。

我们已经向您展示了LEFT JOIN关键词之前和之后的内容很重要。左侧的内容驱动整个过程,因此称为“左连接”。如果您真的不想重新组织查询以匹配该模板,可以使用RIGHT JOIN。它完全相同,只是右侧的内容驱动整个过程。

早些时候,我们展示了左连接中表的顺序的重要性,使用两个用于电影租赁信息的查询。让我们使用右连接重新编写第二个查询(显示不正确数据):

mysql> `SELECT` `title``,` `rental_date`
    -> `FROM` `rental` `RIGHT` `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `RIGHT` `JOIN` `film` `USING` `(``film_id``)`
    -> `ORDER` `BY` `rental_date` `DESC``;`
...
| SUICIDES SILENCE            | NULL                |
| TADPOLE PARK                | NULL                |
| TREASURE COMMAND            | NULL                |
| VILLAIN DESPERATE           | NULL                |
| VOLUME HOUSE                | NULL                |
| WAKE JAWS                   | NULL                |
| WALLS ARTIST                | NULL                |
+-----------------------------+---------------------+
16087 rows in set (0.06 sec)

我们得到了相同数量的行,并且我们可以看到NULL值与“正确”查询给我们的值相同。右连接有时很有用,因为它允许您更自然地编写查询,以更直观的方式表达它。然而,您不经常看到它被使用,我们建议在可能的情况下避免使用它。

左连接和右连接都可以使用“内连接”中讨论的USINGON子句。你应该选择其中之一:没有它们,你将得到笛卡尔积,正如该节中讨论的那样。

在左连接和右连接中,还可以选择使用额外的OUTER关键字,使它们读起来像LEFT OUTER JOINRIGHT OUTER JOIN。这只是一种不同的语法,实际上并没有任何不同,你不经常看到它被使用。在本书中,我们坚持使用基本版本。

自然连接

我们并不是这里描述的自然连接的铁杆支持者。它在这里仅仅是为了完整性,也因为你会在遇到的 SQL 语句中偶尔看到它。我们的建议是尽可能避免在可以的情况下使用它。

自然连接应该是一种自然而然的魔法。这意味着你告诉 MySQL 你想连接哪些表,它会找出如何连接它们,并给你一个INNER JOIN的结果集。这里有一个关于actor_infofilm_actor表的例子:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id`
    -> `FROM` `actor_info` `NATURAL` `JOIN` `film_actor`
    -> `LIMIT` `20``;`
+------------+-----------+---------+
| first_name | last_name | film_id |
+------------+-----------+---------+
| PENELOPE   | GUINESS   |       1 |
| PENELOPE   | GUINESS   |      23 |
| ...                              |
| PENELOPE   | GUINESS   |     980 |
| NICK       | WAHLBERG  |       3 |
+------------+-----------+---------+
20 rows in set (0.28 sec)

实际上,这并不是什么神奇的事情:MySQL 只是查找具有相同名称的列,并在幕后将这些列静默地添加到具有连接条件的WHERE子句中。因此,前面的查询实际上被转换成类似于这样的内容:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id` `FROM`
    -> `actor_info` `JOIN` `film_actor`
    -> `WHERE` `(``actor_info``.``actor_id` `=` `film_actor``.``actor_id``)`
    -> `LIMIT` `20``;`

如果标识符列没有相同的名称,自然连接将无法工作。更危险的是,如果具有相同名称的列不是标识符,它们将被静默地添加到后台的USING子句中。你可以很容易地在sakila数据库中看到这一点。事实上,这就是为什么我们选择显示前面的例子与actor_info,它甚至不是一个表:它是一个视图。让我们看看如果我们使用常规的actorfilm_actor表会发生什么:

mysql> `SELECT` `first_name``,` `last_name``,` `film_id` `FROM` `actor` `NATURAL` `JOIN` `film_actor``;`
Empty set (0.01 sec)

但是如何呢?问题是:NATURAL JOIN确实考虑了所有列。在sakila数据库中,这是一个巨大的障碍,因为每个表都有一个last_update列。如果你运行前面查询的EXPLAIN语句,然后执行SHOW WARNINGS,你会发现生成的查询是毫无意义的:

mysql> `SHOW` `WARNINGS``\``G`
*************************** 1\. row ***************************
  Level: Note
   Code: 1003
Message: /* select#1 */ select `sakila`.`customer`.`email` AS `email`,
`sakila`.`rental`.`rental_date` AS `rental_date`
from `sakila`.`customer` join `sakila`.`rental`
where ((`sakila`.`rental`.`last_update` = `sakila`.`customer`.`last_update`)
and (`sakila`.`rental`.`customer_id` = `sakila`.`customer`.`customer_id`))
1 row in set (0.00 sec)

你有时会看到自然连接与左连接和右连接混合使用。以下是有效的连接语法:NATURAL LEFT JOINNATURAL LEFT OUTER JOINNATURAL RIGHT JOINNATURAL RIGHT OUTER JOIN。前两者是没有ONUSING子句的左连接,后两者是右连接。同样,尽量避免使用它们,但是如果你看到它们被使用,你应该理解它们的含义。

连接中的常数表达式

在我们迄今为止提供的所有连接示例中,我们都使用列标识符来定义连接条件。当你使用USING子句时,这是唯一可能的方法。当你在WHERE子句中定义连接条件时,这也是唯一有效的方法。然而,当你使用ON子句时,你实际上可以添加常数表达式。

让我们考虑一个例子,列出特定演员的所有电影:

mysql> `SELECT` `first_name``,` `last_name``,` `title`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `WHERE` `actor_id` `=` `11``;`
+------------+-----------+--------------------+
| first_name | last_name | title              |
+------------+-----------+--------------------+
| ZERO       | CAGE      | CANYON STOCK       |
| ZERO       | CAGE      | DANCES NONE        |
| ...                                         |
| ZERO       | CAGE      | WEST LION          |
| ZERO       | CAGE      | WORKER TARZAN      |
+------------+-----------+--------------------+
25 rows in set (0.00 sec)

我们可以像这样将 actor_id 子句移入联接:

mysql> `SELECT` `first_name``,` `last_name``,` `title`
    -> `FROM` `actor` `JOIN` `film_actor`
    ->   `ON` `actor``.``actor_id` `=` `film_actor``.``actor_id`
    ->   `AND` `actor``.``actor_id` `=` `11`
    -> `JOIN` `film` `USING` `(``film_id``)``;`
+------------+-----------+--------------------+
| first_name | last_name | title              |
+------------+-----------+--------------------+
| ZERO       | CAGE      | CANYON STOCK       |
| ZERO       | CAGE      | DANCES NONE        |
| ...                                         |
| ZERO       | CAGE      | WEST LION          |
| ZERO       | CAGE      | WORKER TARZAN      |
+------------+-----------+--------------------+
25 rows in set (0.00 sec)

好吧,这当然很棒,但为什么呢?这比有适当的 WHERE 子句更具表现力吗?对这两个问题的答案是,在联接中的常量条件与 WHERE 子句中的条件的评估和解析方式不同。最好通过示例来展示这一点,但前面的查询不好。常量条件在联接中的影响最好通过左连接来显示。

请记住本节中关于左连接的查询:

mysql> `SELECT` `email``,` `name` `AS` `category_name``,` `COUNT``(``rental_id``)` `AS` `cnt`
    -> `FROM` `category` `cat` `LEFT` `JOIN` `film_category` `USING` `(``category_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `LEFT` `JOIN` `customer` `cs` `USING` `(``customer_id``)`
    -> `WHERE` `cs``.``email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `GROUP` `BY` `email``,` `category_name`
    -> `ORDER` `BY` `cnt` `DESC``;`
+--------------------------------+---------------+-----+
| email                          | category_name | cnt |
+--------------------------------+---------------+-----+
| WESLEY.BULL@sakilacustomer.org | Games         |   9 |
| WESLEY.BULL@sakilacustomer.org | Foreign       |   6 |
| ...                                                  |
| WESLEY.BULL@sakilacustomer.org | Comedy        |   1 |
| WESLEY.BULL@sakilacustomer.org | Sports        |   1 |
+--------------------------------+---------------+-----+
14 rows in set (0.01 sec)

如果我们将 cs.email 子句移到 LEFT JOIN customer cs 部分,我们将看到完全不同的结果:

mysql> `SELECT` `email``,` `name` `AS` `category_name``,` `COUNT``(``rental_id``)` `AS` `cnt`
    -> `FROM` `category` `cat` `LEFT` `JOIN` `film_category` `USING` `(``category_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `LEFT` `JOIN` `customer` `cs` `ON` `rental``.``customer_id` `=` `cs``.``customer_id`
    -> `AND` `cs``.``email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `GROUP` `BY` `email``,` `category_name`
    -> `ORDER` `BY` `cnt` `DESC``;`
+--------------------------------+-------------+------+
| email                          | name        | cnt  |
+--------------------------------+-------------+------+
| NULL                           | Sports      | 1178 |
| NULL                           | Animation   | 1164 |
| ...                                                 |
| NULL                           | Travel      |  834 |
| NULL                           | Music       |  829 |
| WESLEY.BULL@sakilacustomer.org | Games       |    9 |
| WESLEY.BULL@sakilacustomer.org | Foreign     |    6 |
| ...                                                 |
| WESLEY.BULL@sakilacustomer.org | Comedy      |    1 |
| NULL                           | Thriller    |    0 |
+--------------------------------+-------------+------+
31 rows in set (0.07 sec)

这很有趣!与其仅获取 Wesley 每个类别的租赁次数,我们还得到了每个其他人按类别分解的租赁次数。甚至包括我们新的到目前为空的惊悚类别。让我们试着理解这里发生了什么。

WHERE 子句的内容在解析和执行联接之后逻辑应用。我们告诉 MySQL,我们只需要来自我们加入的任何内容的行,其中 cs.email 列等于 'WESLEY.BULL@sakilacustomer.org'。事实上,MySQL 足够聪明,可以优化这种情况,并且实际上会开始执行计划,就好像使用了常规的内连接。当我们在 LEFT JOIN customer 子句中有 cs.email 条件时,我们告诉 MySQL,我们希望将 customer 表的列添加到我们到目前为止的结果集中(包括 categoryinventoryrental 表),但仅在 email 列中出现特定值时。由于这是 LEFT JOIN,在未匹配的行中,我们在 customer 的每一列中得到 NULL

重要的是要注意这种行为。

嵌套查询

自 MySQL 4.1 版本以来支持的嵌套查询是最难学习的。然而,它们提供了一种强大、有用且简洁的方式来表达短 SQL 语句中难以理解的信息需求。本节将从简单的示例开始解释它们,并引导您了解 EXISTSIN 语句的更复杂特性。在本节结束时,您将完成本书关于查询数据的所有内容,并且应该能够理解您遇到的几乎任何 SQL 查询。

嵌套查询基础

你知道如何使用 INNER JOIN 找到参与特定电影的所有演员的名字:

mysql> `SELECT` `first_name``,` `last_name` `FROM`
    -> `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `WHERE` `title` `=` `'ZHIVAGO CORE'``;`
+------------+-----------+
| first_name | last_name |
+------------+-----------+
| UMA        | WOOD      |
| NICK       | STALLONE  |
| GARY       | PENN      |
| SALMA      | NOLTE     |
| KENNETH    | HOFFMAN   |
| WILLIAM    | HACKMAN   |
+------------+-----------+
6 rows in set (0.00 sec)

但还有另一种方法,使用 嵌套查询

mysql> `SELECT` `first_name``,` `last_name` `FROM`
    -> `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `WHERE` `film_id` `=` `(``SELECT` `film_id` `FROM` `film`
    -> `WHERE` `title` `=` `'ZHIVAGO CORE'``)``;`
+------------+-----------+
| first_name | last_name |
+------------+-----------+
| UMA        | WOOD      |
| NICK       | STALLONE  |
| GARY       | PENN      |
| SALMA      | NOLTE     |
| KENNETH    | HOFFMAN   |
| WILLIAM    | HACKMAN   |
+------------+-----------+
6 rows in set (0.00 sec)

它被称为嵌套查询,因为一个查询在另一个查询内部。内部查询子查询 ——被嵌套的查询——写在括号中,你可以看到它确定了具有标题 ZHIVAGO CORE 的电影的 film_id。内部查询需要用括号括起来。外部查询 是首先列出的查询,在这里没有括号:你可以看到它通过与 film_actorJOIN 来找到与子查询结果匹配的 film_id,从而找到演员的 first_namelast_name。因此,总体而言,内部查询找到 film_id,而外部查询则使用它来找到演员的姓名。每当使用嵌套查询时,都可以将其重写为几个单独的查询。让我们对先前的示例进行这样的操作,因为这可能有助于您理解正在发生的情况:

mysql> `SELECT` `film_id` `FROM` `film` `WHERE` `title` `=` `'ZHIVAGO CORE'``;`
+---------+
| film_id |
+---------+
|     998 |
+---------+
1 row in set (0.03 sec)
mysql> `SELECT` `first_name``,` `last_name`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `WHERE` `film_id` `=` `998``;`
+------------+-----------+
| first_name | last_name |
+------------+-----------+
| UMA        | WOOD      |
| NICK       | STALLONE  |
| GARY       | PENN      |
| SALMA      | NOLTE     |
| KENNETH    | HOFFMAN   |
| WILLIAM    | HACKMAN   |
+------------+-----------+
6 rows in set (0.00 sec)

那么,哪种方法更可取:嵌套还是非嵌套?答案并不容易。从性能角度来看,答案通常是 不是:嵌套查询很难优化,因此几乎总是比非嵌套的替代方法运行速度慢。

这是否意味着你应该避免嵌套?答案是否定的:有时这是你唯一的选择,如果你想要编写单个查询,有时嵌套查询可以回答其他方法难以解决的信息需求。此外,嵌套查询具有表达能力。一旦你熟悉这个概念,它们是一种非常可读的方式来展示查询如何被评估。事实上,许多 SQL 设计者倡导在向你展示的前几节中展示基于连接的替代方法之前教授嵌套查询。我们将向您展示在本节中嵌套查询如何既可读又强大的示例。

在我们开始讨论可以在嵌套查询中使用的关键字之前,让我们看一个例子,这个例子不容易在一个单独的查询中完成——至少不是没有 MySQL 的非标准,尽管普遍存在的 LIMIT 子句!假设你想知道客户最近租了哪部电影。根据我们之前学到的方法,你可以找到该客户在 rental 表中最近存储行的日期和时间:

mysql> `SELECT` `MAX``(``rental_date``)` `FROM` `rental`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'``;`
+---------------------+
| MAX(rental_date)    |
+---------------------+
| 2005-08-23 15:46:33 |
+---------------------+
1 row in set (0.01 sec)

你可以将输出用作另一个查询的输入来查找电影标题:

mysql> `SELECT` `title` `FROM` `film`
    -> `JOIN` `inventory` `USING` `(``film_id``)`
    -> `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `AND` `rental_date` `=` `'2005-08-23 15:46:33'``;`
+-------------+
| title       |
+-------------+
| KARATE MOON |
+-------------+
1 row in set (0.00 sec)
注意

在 “用户变量” 中,我们将向您展示如何使用变量来避免在第二个查询中键入值。

使用嵌套查询,你可以一次完成两个步骤:

mysql> `SELECT` `title` `FROM` `film` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `WHERE` `rental_date` `=` `(``SELECT` `MAX``(``rental_date``)` `FROM` `rental`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'``)``;`
+-------------+
| title       |
+-------------+
| KARATE MOON |
+-------------+
1 row in set (0.01 sec)

您可以看到嵌套查询结合了两个先前的查询。与使用从先前查询中发现的常量日期和时间值不同,它直接执行子查询作为子查询。这是最简单的嵌套查询类型,返回一个 标量操作数——即单个值。

提示

上一个示例使用了等号运算符(=)。你可以使用所有类型的比较运算符:<(小于)、<=(小于或等于)、>(大于)、>=(大于或等于)、!=(不等于)或 <>(不等于)。

任意、某些、所有、IN 和 NOT IN 子句

在我们开始展示更多嵌套查询的高级特性之前,我们需要在我们的示例中切换到一个新的数据库。不幸的是,sakila数据库过于规范化,无法有效地展示嵌套查询的全部功能。所以,让我们添加一个新的数据库来玩耍。

我们将安装的数据库是employees样本数据库。您可以在MySQL 文档或数据库的 GitHub 存储库中找到安装说明。使用git克隆存储库或下载最新版本(在撰写本文时为1.0.7)。一旦准备好所需的文件,您需要运行两个命令。

第一个命令创建必要的结构并加载数据:

$ mysql -uroot -p < employees.sql
INFO
CREATING DATABASE STRUCTURE
INFO
storage engine: InnoDB
INFO
LOADING departments
INFO
LOADING employees
INFO
LOADING dept_emp
INFO
LOADING dept_manager
INFO
LOADING titles
INFO
LOADING salaries
data_load_time_diff
00:00:28

第二个命令验证安装是否正确:

$ mysql -uroot -p < test_employees_md5.sql
INFO
TESTING INSTALLATION
table_name      expected_records        expected_crc
departments     9       d1af5e170d2d1591d776d5638d71fc5f
dept_emp        331603  ccf6fe516f990bdaa49713fc478701b7
dept_manager    24      8720e2f0853ac9096b689c14664f847e
employees       300024  4ec56ab5ba37218d187cf6ab09ce1aa1
salaries        2844047 fd220654e95aea1b169624ffe3fca934
titles  443308  bfa016c472df68e70a03facafa1bc0a8
table_name      found_records           found_crc
departments     9       d1af5e170d2d1591d776d5638d71fc5f
dept_emp        331603  ccf6fe516f990bdaa49713fc478701b7
dept_manager    24      8720e2f0853ac9096b689c14664f847e
employees       300024  4ec56ab5ba37218d187cf6ab09ce1aa1
salaries        2844047 fd220654e95aea1b169624ffe3fca934
titles  443308  bfa016c472df68e70a03facafa1bc0a8
table_name      records_match   crc_match
departments     OK      ok
dept_emp        OK      ok
dept_manager    OK      ok
employees       OK      ok
salaries        OK      ok
titles  OK      ok
computation_time
00:00:25
summary result
CRC     OK
count   OK

一旦完成,您可以继续执行我们将提供的示例。

要连接到新数据库,可以像这样从命令行运行mysql(或者指定employees作为您选择的 MySQL 客户端的目标):

$ mysql employees

或者在mysql提示符下执行以下操作来更改默认数据库:

mysql> `USE` `employees`

现在您可以继续前进了。

使用 ANY 和 IN

现在,您已经创建了示例表,可以使用ANY尝试一个示例。假设您想找到比最不经验的经理工作时间更长的助理工程师。您可以将此信息需求表达如下:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name``,` `hire_date`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Assistant Engineer'`
    -> `AND` `hire_date` `<` `ANY` `(``SELECT` `hire_date` `FROM`
    -> `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'``)``;`
+--------+----------------+------------------+------------+
| emp_no | first_name     | last_name        | hire_date  |
+--------+----------------+------------------+------------+
|  10009 | Sumant         | Peac             | 1985-02-18 |
|  10066 | Kwee           | Schusler         | 1986-02-26 |
| ...                                                     |
| ...                                                     |
| 499958 | Srinidhi       | Theuretzbacher   | 1989-12-17 |
| 499974 | Shuichi        | Piazza           | 1989-09-16 |
+--------+----------------+------------------+------------+
10747 rows in set (0.20 sec)

结果表明符合这些条件的人有很多!子查询查找经理被聘用的日期:

mysql> `SELECT` `hire_date` `FROM`
    -> `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'``;`
+------------+
| hire_date  |
+------------+
| 1985-01-01 |
| 1986-04-12 |
| ...        |
| 1991-08-17 |
| 1989-07-10 |
+------------+
24 rows in set (0.10 sec)

外部查询遍历每个标题为Associate Engineer的员工,如果他们的入职日期比子查询返回的任何值都早(更旧),则返回该工程师。因此,例如,Sumant Peac会被输出,因为1985-02-18比子查询返回的至少一个值更早(正如您可以看到,经理的第二个入职日期是1986-04-12)。ANY关键字的含义正是如此:如果在子查询返回的集合中的任何值上,前面的列或表达式为真,那么它就是真的。以这种方式使用,ANY有一个别名SOME,这是为了更清晰地读取某些查询作为英语表达式而包含的;它并不做任何不同的事情,您很少会看到它被使用。

ANY关键字在表达嵌套查询时提供了更大的表达力。确实,前面的查询是本节中第一个带有列子查询的嵌套查询——也就是说,子查询返回的结果是来自列的一个或多个值,而不是像前一节中那样的单一标量值。有了这个,您现在可以将外部查询的列值与子查询返回的一组值进行比较了。

考虑另一个使用ANY的例子。假设您想知道还有其他头衔的经理。您可以使用以下嵌套查询来执行此操作:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `emp_no` `=` `ANY` `(``SELECT` `emp_no` `FROM` `employees`
    -> `JOIN` `titles` `USING` `(``emp_no``)` `WHERE`
    -> `title` `<``>` `'Manager'``)``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110022 | Margareta   | Markovitch   |
| 110039 | Vishwani    | Minakawa     |
| ...                                 |
| 111877 | Xiaobin     | Spinelli     |
| 111939 | Yuchang     | Weedman      |
+--------+-------------+--------------+
24 rows in set (0.11 sec)

= ANY会导致外部查询在emp_no等于子查询返回的工程师员工号之一时返回经理。= ANY短语具有别名IN,您将在嵌套查询中经常看到其使用。使用IN,前面的示例可以重写为:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `emp_no` `IN` `(``SELECT` `emp_no` `FROM` `employees`
    -> `JOIN` `titles` `USING` `(``emp_no``)` `WHERE`
    -> `title` `<``>` `'Manager'``)``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110022 | Margareta   | Markovitch   |
| 110039 | Vishwani    | Minakawa     |
| ...                                 |
| 111877 | Xiaobin     | Spinelli     |
| 111939 | Yuchang     | Weedman      |
+--------+-------------+--------------+
24 rows in set (0.11 sec)

当然,对于这个特定的例子,您也可以使用连接查询。请注意,在这里我们必须使用DISTINCT,因为否则会返回 30 行。有些人拥有多个非工程师职称:

mysql> `SELECT` `DISTINCT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `mgr` `USING` `(``emp_no``)`
    -> `JOIN` `titles` `nonmgr` `USING` `(``emp_no``)`
    -> `WHERE` `mgr``.``title` `=` `'Manager'`
    -> `AND` `nonmgr``.``title` `<``>` `'Manager'``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110022 | Margareta   | Markovitch   |
| 110039 | Vishwani    | Minakawa     |
| ...                                 |
| 111877 | Xiaobin     | Spinelli     |
| 111939 | Yuchang     | Weedman      |
+--------+-------------+--------------+
24 rows in set (0.11 sec)

再次说明,嵌套查询在 MySQL 中表达力强但通常速度较慢,因此尽可能使用连接。

使用 ALL

假设您想要找出比所有经理更有经验的助理工程师——即比最有经验的经理更有经验的助理工程师。您可以使用ALL关键字代替ANY来完成这一操作:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name``,` `hire_date`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Assistant Engineer'`
    -> `AND` `hire_date` `<` `ALL` `(``SELECT` `hire_date` `FROM`
    -> `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'``)``;`
Empty set (0.18 sec)

您可以看到没有答案。我们可以进一步检查数据,以确定经理和助理工程师的最早雇佣日期分别是什么:

mysql> `(``SELECT` `'Assistant Engineer'` `AS` `title``,`
    -> `MIN``(``hire_date``)` `AS` `mhd` `FROM` `employees`
    -> `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Assistant Engineer'``)`
    -> `UNION`
    -> `(``SELECT` `'Manager'` `title``,` `MIN``(``hire_date``)` `mhd` `FROM` `employees`
    -> `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'``)``;`
+--------------------+------------+
| title              | mhd        |
+--------------------+------------+
| Assistant Engineer | 1985-02-01 |
| Manager            | 1985-01-01 |
+--------------------+------------+
2 rows in set (0.26 sec)

查看数据,我们看到第一位经理的雇佣日期是 1985 年 1 月 1 日,而第一位助理工程师是同年 2 月 1 日雇佣的。而ANY关键字返回满足至少一个条件的值(布尔 OR),ALL关键字仅返回所有条件都满足的值(布尔 AND)。

我们可以使用NOT IN别名代替<> ANY!= ANY。让我们找出所有不是高级员工的经理:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'` `AND` `emp_no` `NOT` `IN`
    -> `(``SELECT` `emp_no` `FROM` `titles`
    -> `WHERE` `title` `=` `'Senior Staff'``)``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110183 | Shirish     | Ossenbruggen |
| 110303 | Krassimir   | Wegerle      |
| ...                                 |
| 111400 | Arie        | Staelin      |
| 111692 | Tonny       | Butterworth  |
+--------+-------------+--------------+
15 rows in set (0.09 sec)

作为练习,尝试使用ANY语法和连接查询来编写此查询。

ALL关键字有一些技巧和陷阱:

  • 如果对任何值为 false,则为 false。假设表a包含值为 14 的行,表b包含值为 16、1 和NULL。如果您检查a中的值是否大于b中所有值,您将得到false,因为 14 不大于 16。其他值为 1 和NULL并不重要。

  • 如果对任何值都不为 false,则除非对所有值都为 true,否则它不为 true。假设表a再次包含 14,b包含 1 和NULL。如果你检查a中的值是否大于b中所有值,你会得到UNKNOWN(既非 true 也非 false),因为不能确定NULL是大于还是小于 14。

  • 如果子查询中的表为空,则结果始终为 true。因此,如果a包含 14 且b为空,则在检查a中的值是否大于b中所有值时将得到true

使用ALL关键字时,要特别注意表中可能存在列值为NULL的情况;在这种情况下,考虑不允许NULL值。同时,要小心处理空表。

编写行子查询

在前面的示例中,子查询返回单个标量值(例如actor_id)或来自一个列的一组值(例如所有emp_no的值)。本节描述了另一种类型的子查询,即行子查询,它可以处理来自多行的多列。

假设您想知道经理在同一日历年内是否有其他职位。要回答这个问题,您必须匹配员工编号和职务分配日期,或者更准确地说,年份。您可以将其写为连接查询:

mysql> `SELECT` `mgr``.``emp_no``,` `YEAR``(``mgr``.``from_date``)` `AS` `fd`
    -> `FROM` `titles` `AS` `mgr``,` `titles` `AS` `other`
    -> `WHERE` `mgr``.``emp_no` `=` `other``.``emp_no`
    -> `AND` `mgr``.``title` `=` `'Manager'`
    -> `AND` `mgr``.``title` `<``>` `other``.``title`
    -> `AND` `YEAR``(``mgr``.``from_date``)` `=` `YEAR``(``other``.``from_date``)``;`
+--------+------+
| emp_no | fd   |
+--------+------+
| 110765 | 1989 |
| 111784 | 1988 |
+--------+------+
2 rows in set (0.11 sec)

但是你也可以将其编写为嵌套查询:

mysql> `SELECT` `emp_no``,` `YEAR``(``from_date``)` `AS` `fd`
    -> `FROM` `titles` `WHERE` `title` `=` `'Manager'` `AND`
    -> `(``emp_no``,` `YEAR``(``from_date``)``)` `IN`
    -> `(``SELECT` `emp_no``,` `YEAR``(``from_date``)`
    -> `FROM` `titles` `WHERE` `title` `<``>` `'Manager'``)``;`
+--------+------+
| emp_no | fd   |
+--------+------+
| 110765 | 1989 |
| 111784 | 1988 |
+--------+------+
2 rows in set (0.12 sec)

您可以看到在这个嵌套查询中使用了不同的语法:括号内跟随WHERE语句的两个列名列表,并且内部查询返回两列。我们将在接下来解释这个语法。

行子查询语法允许您每行比较多个值。表达式(emp_no, YEAR(from_date))表示每行比较子查询的输出的两个值。您可以看到在IN关键字后面,子查询返回两个值,emp_noYEAR(from_date)。因此,片段如下:

(emp_no, YEAR(from_date)) IN (SELECT emp_no, YEAR(from_date)
FROM titles WHERE title <> 'Manager')

匹配经理编号和起始年份到非经理编号和起始年份,并且当找到匹配时返回一个真值。结果是,如果找到匹配对,整个查询输出结果。这是一个典型的行子查询:它找到存在于两个表中的行。

为了进一步解释语法,让我们考虑另一个例子。假设您想查看特定员工是否是高级员工。您可以使用以下查询完成:

mysql> `SELECT` `first_name``,` `last_name`
    -> `FROM` `employees``,` `titles`
    -> `WHERE` `(``employees``.``emp_no``,` `first_name``,` `last_name``,` `title``)` `=`
    -> `(``titles``.``emp_no``,` `'Marjo'``,` `'Giarratana'``,` `'Senior Staff'``)``;`
+------------+------------+
| first_name | last_name  |
+------------+------------+
| Marjo      | Giarratana |
+------------+------------+
1 row in set (0.09 sec)

它不是一个嵌套查询,但它展示了新的行子查询语法如何工作。您可以看到查询在等号之前匹配列的列表(employees.emp_no, first_name, last_name, title)与等号之后列和值的列表(titles.emp_no, 'Marjo', 'Giarratana', 'Senior Staff')。因此,当emp_no值匹配时,员工的全名是Marjo Giarratana,职务是Senior Staff时,我们从查询中获取输出。我们不建议编写像这样的查询——而是使用带有多个AND条件的常规WHERE子句——但它确实说明了正在发生的情况。作为一项练习,尝试使用连接编写此查询。

行子查询要求列中的值的数量、顺序和类型匹配。例如,我们的前一个示例将一个INT匹配到一个INT,两个字符字符串匹配到两个字符字符串。

存在和不存在子句

您现在已经看到了三种类型的子查询:标量子查询、列子查询和行子查询。在本节中,您将学习第四种类型,即相关子查询,其中外部查询中使用的表在子查询中被引用。相关子查询通常与我们已经讨论过的IN语句一起使用,并且几乎总是与本节重点讨论的EXISTSNOT EXISTS子句一起使用。

EXISTS 和 NOT EXISTS 基础知识

在我们讨论相关子查询之前,让我们探讨一下EXISTS子句的作用。我们需要一个简单但奇怪的例子来介绍这个子句,因为我们暂时不讨论相关子查询。所以,这里是例子:假设您想在数据库中找到所有电影的计数,但仅当数据库处于活动状态时,您定义为只有在任何分支的至少一部电影已出租时才算活动。这是执行此操作的查询(在运行此查询之前不要忘记重新连接到sakila数据库——提示:使用use <db>命令):

mysql> `SELECT` `COUNT``(``*``)` `FROM` `film`
    -> `WHERE` `EXISTS` `(``SELECT` `*` `FROM` `rental``)``;`
+----------+
| COUNT(*) |
+----------+
|     1000 |
+----------+
1 row in set (0.01 sec)

子查询返回rental表中的所有行。然而,重要的是它至少返回一行;行的内容不重要,行数多少也无关紧要,或者行只包含NULL值也无关紧要。因此,您可以将子查询视为真或假,在这种情况下它是真的,因为它生成了一些输出。当子查询为真时,使用EXISTS子句的外部查询返回一行。总体结果是计算film表中的所有行,因为对于每一行,子查询都是真的。

让我们尝试一个子查询不为真的查询。再次编造一个查询:这次,我们将输出数据库中所有电影的名称,但只有在存在特定电影时才这样做。以下是查询:

mysql> `SELECT` `title` `FROM` `film`
    -> `WHERE` `EXISTS` `(``SELECT` `*` `FROM` `film`
    -> `WHERE` `title` `=` `'IS THIS A MOVIE?'``)``;`
Empty set (0.00 sec)

由于子查询不为真——因为IS THIS A MOVIE?不在我们的数据库中——外部查询不会返回结果。

NOT EXISTS子句则相反。想象一下,如果您不在数据库中有一个特定电影,您希望获得所有演员的列表。以下是查询:

mysql> `SELECT` `*` `FROM` `actor` `WHERE` `NOT` `EXISTS`
    -> `(``SELECT` `*` `FROM` `film` `WHERE` `title` `=` `'ZHIVAGO CORE'``)``;`
Empty set (0.00 sec)

这次,内部查询为真,但NOT EXISTS子句否定了它以生成假。因为它是假的,外部查询不会产生结果。

您会注意到子查询以SELECT * FROM film开头。当您使用EXISTS子句时,实际上不管您在内部查询中选择什么,因为外部查询不使用它。您可以选择一个列、所有内容,甚至常量(如SELECT 'cat' from film),效果都是一样的。但传统上,大多数 SQL 作者按照约定会写SELECT *

相关子查询

到目前为止,您可能很难想象您如何使用EXISTSNOT EXISTS子句。本节展示了它们的实际用途,演示了您通常会看到的最高级别的嵌套查询类型。

让我们考虑您可能从sakila数据库中获取的实际信息类型。假设您想要所有曾租用过公司产品的员工列表,或者只是顾客。您可以轻松地使用联接查询来实现这一点,我们建议您在继续之前考虑一下。您还可以使用以下使用相关子查询的嵌套查询来实现:

mysql> `SELECT` `first_name``,` `last_name` `FROM` `staff`
    -> `WHERE` `EXISTS` `(``SELECT` `*` `FROM` `customer`
    -> `WHERE` `customer``.``first_name` `=` `staff``.``first_name`
    -> `AND` `customer``.``last_name` `=` `staff``.``last_name``)``;`
Empty set (0.01 sec)

没有输出,因为没有员工也是客户(或者禁止这样做,但我们会打破规则)。让我们添加一个与某个员工具有相同详细信息的客户:

mysql> `INSERT` `INTO` `customer``(``store_id``,` `first_name``,` `last_name``,`
    -> `email``,` `address_id``,` `create_date``)`
    -> `VALUES` `(``1``,` `'Mike'``,` `'Hillyer'``,`
    -> `'Mike.Hillyer@sakilastaff.com'``,` `3``,` `NOW``(``)``)``;`
Query OK, 1 row affected (0.02 sec)

再次尝试查询:

mysql> `SELECT` `first_name``,` `last_name` `FROM` `staff`
    -> `WHERE` `EXISTS` `(``SELECT` `*` `FROM` `customer`
    -> `WHERE` `customer``.``first_name` `=` `staff``.``first_name`
    -> `AND` `customer``.``last_name` `=` `staff``.``last_name``)``;`
+------------+-----------+
| first_name | last_name |
+------------+-----------+
| Mike       | Hillyer   |
+------------+-----------+
1 row in set (0.00 sec)

所以,这个查询有效;现在,我们只需要理解如何操作!

让我们检查一下我们之前示例中的子查询。您可以看到它只列出了 FROM 子句中的 customer 表,但它在 WHERE 子句中使用了 staff 表的一个列。如果您单独运行它,您会发现这是不允许的:

mysql> `SELECT` `*` `FROM` `customer` `WHERE` `customer``.``first_name` `=` `staff``.``first_name``;`
ERROR 1054 (42S22): Unknown column 'staff.first_name' in 'where clause'

但是,当作为子查询执行时是合法的,因为外部查询中列出的表允许在子查询中访问。因此,在此示例中,staff.first_namestaff.last_name 的当前值在外部查询中作为常量标量值提供给子查询,并与客户的名字进行比较。如果客户的名称与员工成员的名称匹配,则子查询为真,因此外部查询输出一行。考虑两种更清晰地说明这一点的情况:

  • 外部查询正在处理的 first_namelast_nameJonStephens 时,子查询为假,因为 SELECT * FROM customer WHERE first_name = 'Jon' and last_name = 'Stephens'; 没有返回任何行,因此 Jon Stephens 的 staff 行不作为答案输出。

  • 外部查询正在处理的 first_namelast_nameMikeHillyer 时,子查询为真,因为 SELECT * FROM customer WHERE first_name = 'Mike' and last_name = 'Hillyer'; 返回至少一行。因此,Mike Hillyer 的 staff 行被返回。

您能看到相关子查询的威力吗?您可以在内部查询中使用外部查询的值来评估复杂的信息需求。

现在我们将探讨另一个使用 EXISTS 的例子。让我们试着找出我们至少拥有两份副本的所有电影的数量。为了使用 EXISTS 来做到这一点,我们需要思考内部和外部查询应该做什么。内部查询应该在我们检查的条件为真时产生结果;在这种情况下,当库存中有至少两行属于同一电影时,它应该产生输出。外部查询应在内部查询为真时增加计数器。以下是查询:

mysql> `SELECT` `COUNT``(``*``)` `FROM` `film` `WHERE` `EXISTS`
    -> `(``SELECT` `film_id` `FROM` `inventory`
    -> `WHERE` `inventory``.``film_id` `=` `film``.``film_id`
    -> `GROUP` `BY` `film_id` `HAVING` `COUNT``(``*``)` `>``=` `2``)``;`
+----------+
| COUNT(*) |
+----------+
|      958 |
+----------+
1 row in set (0.00 sec)

这是另一个不需要嵌套而使用联接就足够的查询示例,但为了解释的目的,我们坚持使用这个版本。看一下内部查询:您可以看到 WHERE 子句确保电影通过唯一的 film_id 进行匹配,并且子查询仅考虑当前电影的匹配行。GROUP BY 子句将这些行按电影进行分组,但仅当库存中至少有两个条目时。因此,内部查询仅在我们的库存中的当前电影至少有两行时才会产生输出。外部查询很简单:可以将其视为在子查询产生输出时递增计数器。

在我们继续讨论其他问题之前,这里再举一个例子。此例将在employees数据库中进行,所以请切换你的客户端。我们已经展示了一个使用IN并查找同时拥有其他职位的经理的查询:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `emp_no` `IN` `(``SELECT` `emp_no` `FROM` `employees`
    -> `JOIN` `titles` `USING` `(``emp_no``)` `WHERE`
    -> `title` `<``>` `'Manager'``)``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110022 | Margareta   | Markovitch   |
| 110039 | Vishwani    | Minakawa     |
| ...                                 |
| 111877 | Xiaobin     | Spinelli     |
| 111939 | Yuchang     | Weedman      |
+--------+-------------+--------------+
24 rows in set (0.11 sec)

让我们重写查询以使用EXISTS。首先考虑子查询:当有一个与经理同名的title记录的员工时,它应该产生输出。

其次,考虑外部查询:它应该在内部查询产生输出时返回员工的姓名。以下是重写后的查询:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `EXISTS` `(``SELECT` `emp_no` `FROM` `titles`
    -> `WHERE` `titles``.``emp_no` `=` `employees``.``emp_no`
    -> `AND` `title` `<``>` `'Manager'``)``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110022 | Margareta   | Markovitch   |
| 110039 | Vishwani    | Minakawa     |
| ...                                 |
| 111877 | Xiaobin     | Spinelli     |
| 111939 | Yuchang     | Weedman      |
+--------+-------------+--------------+
24 rows in set (0.09 sec)

再次可以看到子查询引用了来自外部查询的emp_no列。

关联子查询可以与任何嵌套查询类型一起使用。这里是先前的IN查询使用外部引用重写后的版本:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `emp_no` `IN` `(``SELECT` `emp_no` `FROM` `titles`
    -> `WHERE` `titles``.``emp_no` `=` `employees``.``emp_no`
    -> `AND` `title` `<``>` `'Manager'``)``;`
+--------+-------------+--------------+
| emp_no | first_name  | last_name    |
+--------+-------------+--------------+
| 110022 | Margareta   | Markovitch   |
| 110039 | Vishwani    | Minakawa     |
| ...                                 |
| 111877 | Xiaobin     | Spinelli     |
| 111939 | Yuchang     | Weedman      |
+--------+-------------+--------------+
24 rows in set (0.09 sec)

查询比需要的更复杂,但它说明了这个想法。你可以看到子查询中的emp_no引用了外部查询的employees表。

如果查询只返回一行,也可以重写为使用等号而不是IN

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `emp_no` `=` `(``SELECT` `emp_no` `FROM` `titles`
    -> `WHERE` `titles``.``emp_no` `=` `employees``.``emp_no`
    -> `AND` `title` `<``>` `'Manager'``)``;`
ERROR 1242 (21000): Subquery returns more than 1 row

在这种情况下,它不起作用,因为子查询返回了多个标量值。让我们缩小范围:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'`
    -> `AND` `emp_no` `=` `(``SELECT` `emp_no` `FROM` `titles`
    -> `WHERE` `titles``.``emp_no` `=` `employees``.``emp_no`
    -> `AND` `title` `=` `'Senior Engineer'``)``;`
+--------+------------+-----------+
| emp_no | first_name | last_name |
+--------+------------+-----------+
| 110344 | Rosine     | Cools     |
| 110420 | Oscar      | Ghazalie  |
| 110800 | Sanjoy     | Quadeer   |
+--------+------------+-----------+
3 rows in set (0.10 sec)

现在它起作用了——每个名称只有一个经理和高级工程师头衔——所以列子查询操作符IN不再必要。当然,如果标题重复(例如,如果一个人在职位之间来回切换),你需要使用INANYALL

FROM 子句中的嵌套查询

我们展示的技术都在WHERE子句中使用了嵌套查询。本节向您展示了当您想要操作查询中使用的数据源时,如何在FROM子句中替代地使用它们。

employees数据库中,salaries表存储了员工 ID 和年薪。例如,如果您想找到月薪,可以在查询中进行一些数学计算。在这种情况下的一个选项是使用子查询:

mysql> `SELECT` `emp_no``,` `monthly_salary` `FROM`
    -> `(``SELECT` `emp_no``,` `salary``/``12` `AS` `monthly_salary` `FROM` `salaries``)` `AS` `ms`
    -> `LIMIT` `5``;`
+--------+----------------+
| emp_no | monthly_salary |
+--------+----------------+
|  10001 |      5009.7500 |
|  10001 |      5175.1667 |
|  10001 |      5506.1667 |
|  10001 |      5549.6667 |
|  10001 |      5580.0833 |
+--------+----------------+
5 rows in set (0.00 sec)

注意关注FROM子句后面的内容。子查询使用salaries表并返回两列:第一列是emp_no;第二列别名为monthly_salary,是salary值除以 12。外部查询很简单:它只返回通过子查询创建的emp_nomonthly_salary值。请注意,我们为子查询添加了表别名ms。当我们将子查询作为表使用时,这个“派生表”必须有一个别名,即使我们在查询中没有使用别名。如果省略别名,MySQL 会报错:

mysql> `SELECT` `emp_no``,` `monthly_salary` `FROM`
    -> `(``SELECT` `emp_no``,` `salary``/``12` `AS` `monthly_salary` `FROM` `salaries``)`
    -> `LIMIT` `5``;`
ERROR 1248 (42000): Every derived table must have its own alias

这里有另一个例子,现在在sakila数据库中。假设我们想通过租借来计算电影带来的平均收益,或者称之为平均总收益。让我们先考虑子查询。它应该返回每部电影的支付总和。然后,外部查询应该对这些值求平均以得出答案。以下是查询:

mysql> `SELECT` `AVG``(``gross``)` `FROM`
    -> `(``SELECT` `SUM``(``amount``)` `AS` `gross`
    -> `FROM` `payment` `JOIN` `rental` `USING` `(``rental_id``)`
    -> `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `GROUP` `BY` `film_id``)` `AS` `gross_amount``;`
+------------+
| AVG(gross) |
+------------+
|  70.361754 |
+------------+
1 row in set (0.05 sec)

你可以看到内部查询将 paymentrentalinventoryfilm 进行连接,并按电影分组销售,以便你可以获取每部电影的总和。如果单独运行它,会发生以下情况:

mysql> `SELECT` `SUM``(``amount``)` `AS` `gross`
    -> `FROM` `payment` `JOIN` `rental` `USING` `(``rental_id``)`
    -> `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `GROUP` `BY` `film_id``;`
+--------+
| gross  |
+--------+
|  36.77 |
|  52.93 |
|  37.88 |
|    ... |
|  14.91 |
|  73.83 |
| 214.69 |
+--------+
958 rows in set (0.08 sec)

现在,外部查询获取这些总和,它们被别名为 gross,并对它们进行平均处理,得出最终结果。这个查询是应用两个聚合函数到一组数据的典型方式。你不能像 AVG(SUM(amount)) 这样级联应用聚合函数:

mysql> `SELECT` `AVG``(``SUM``(``amount``)``)` `AS` `avg_gross`
    -> `FROM` `payment` `JOIN` `rental` `USING` `(``rental_id``)`
    -> `JOIN` `inventory` `USING` `(``inventory_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)` `GROUP` `BY` `film_id``;`
ERROR 1111 (HY000): Invalid use of group function

FROM 子句中使用子查询,可以返回标量值、一组列值、多行甚至整个表格。但是,不能使用相关子查询,也就是说,不能引用未在子查询中显式列出的表或列。还要注意,必须使用 AS 关键字为整个子查询起别名,并给它一个名称,即使在查询中没有使用该名称。

JOIN 中的嵌套查询

我们将展示的最后一种嵌套查询用途,但不是最不实用的,是在连接中使用它们。在这种用法中,子查询的结果基本上形成一个新表,并可以在我们讨论过的任何连接类型中使用。

作为这一点的例子,让我们回到列出某个客户租用的每个类别的电影数量的查询。记住,我们在仅使用连接时编写该查询时遇到了问题:对于我们的客户没有租用的类别,我们没有得到零计数。这是该查询:

mysql> `SELECT` `cat``.``name` `AS` `category_name``,` `COUNT``(``cat``.``category_id``)` `AS` `cnt`
    -> `FROM` `category` `AS` `cat` `LEFT` `JOIN` `film_category` `USING` `(``category_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `JOIN` `customer` `AS` `cs` `ON` `rental``.``customer_id` `=` `cs``.``customer_id`
    -> `WHERE` `cs``.``email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `GROUP` `BY` `category_name` `ORDER` `BY` `cnt` `DESC``;`
+-------------+-----+
| name        | cnt |
+-------------+-----+
| Games       |   9 |
| Foreign     |   6 |
| ...               |
| ...               |
| Comedy      |   1 |
| Sports      |   1 |
+-------------+-----+
14 rows in set (0.00 sec)

现在我们知道了子查询和连接,以及子查询可以在连接中使用,我们可以轻松完成任务。这是我们的新查询:

mysql> `SELECT` `cat``.``name` `AS` `category_name``,` `cnt`
    -> `FROM` `category` `AS` `cat`
    -> `LEFT` `JOIN` `(``SELECT` `cat``.``name``,` `COUNT``(``cat``.``category_id``)` `AS` `cnt`
    ->    `FROM` `category` `AS` `cat`
    ->    `LEFT` `JOIN` `film_category` `USING` `(``category_id``)`
    ->    `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    ->    `LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
    ->    `JOIN` `customer` `cs` `ON` `rental``.``customer_id` `=` `cs``.``customer_id`
    ->    `WHERE` `cs``.``email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    ->    `GROUP` `BY` `cat``.``name``)` `customer_cat` `USING` `(``name``)`
    -> `ORDER` `BY` `cnt` `DESC``;`
+-------------+------+
| name        | cnt  |
+-------------+------+
| Games       |    9 |
| Foreign     |    6 |
| ...                |
| Children    |    1 |
| Sports      |    1 |
| Sci-Fi      | NULL |
| Action      | NULL |
| Thriller    | NULL |
+-------------+------+
17 rows in set (0.01 sec)

最后,我们得到所有类别的显示,并且对于那些没有租赁的类别,我们得到 NULL 值。让我们回顾一下我们的新查询中发生了什么。子查询被别名为 customer_cat,是我们之前查询的不带 ORDER BY 子句的版本。因此,我们知道它将返回:Wesley 租用物品的类别中的 14 行,以及每个类别的租赁数量。接下来,使用 LEFT JOIN 将该信息连接到 category 表的完整类别列表中。category 表驱动连接,因此它将选择每行。我们使用与子查询输出和 category 表列之间匹配的 name 列将子查询连接起来。

我们在这里展示的技术是非常强大的;然而,像所有的子查询一样,它也有代价。当子查询出现在连接子句中时,MySQL 无法像优化整个查询那样高效地操作。

用户变量

通常你会希望保存从查询返回的值。你可能希望这样做是为了能够轻松地在后续查询中使用一个值。你可能也只是想为稍后显示保存一个结果。在这两种情况下,用户变量可以解决问题:它们允许你存储一个结果并在以后使用它。

让我们用一个简单的例子来说明用户变量。下面的查询找到一部电影的标题,并将结果保存在一个用户变量中:

mysql> `SELECT` `@``film``:``=``title` `FROM` `film` `WHERE` `film_id` `=` `1``;`
+------------------+
| @film:=title     |
+------------------+
| ACADEMY DINOSAUR |
+------------------+
1 row in set, 1 warning (0.00 sec)

用户变量名为film,通过前置的@字符表示为用户变量。值是使用:=运算符赋值的。您可以使用以下非常简短的查询打印用户变量的内容:

mysql> `SELECT` `@``film``;`
+------------------+
| @film            |
+------------------+
| ACADEMY DINOSAUR |
+------------------+
1 row in set (0.00 sec)

您可能已经注意到了警告——那是关于什么的?

mysql> `SELECT` `@``film``:``=``title` `FROM` `film` `WHERE` `film_id` `=` `1``;`
mysql> `SHOW` `WARNINGS``\``G`
*************************** 1\. row ***************************
  Level: Warning
   Code: 1287
Message: Setting user variables within expressions is deprecated
and will be removed in a future release. Consider alternatives:
'SET variable=expression, ...', or
'SELECT expression(s) INTO variables(s)'.
1 row in set (0.00 sec)

让我们讨论提出的两种备选方案。首先,我们仍然可以在SET语句内执行嵌套查询:

mysql> `SET` `@``film` `:``=` `(``SELECT` `title` `FROM` `film` `WHERE` `film_id` `=` `1``)``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SELECT` `@``film``;`
+------------------+
| @film            |
+------------------+
| ACADEMY DINOSAUR |
+------------------+
1 row in set (0.00 sec)

其次,我们可以使用SELECT INTO语句:

mysql> `SELECT` `title` `INTO` `@``film` `FROM` `film` `WHERE` `film_id` `=` `1``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `@``film``;`
+------------------+
| @film            |
+------------------+
| ACADEMY DINOSAUR |
+------------------+
1 row in set (0.00 sec)

您可以使用SET语句显式设置变量而不使用SELECT。假设您想将计数器初始化为零:

mysql> `SET` `@``counter` `:``=` `0``;`
Query OK, 0 rows affected (0.00 sec)

:=是可选的,您可以改用=并混合使用它们。您应该用逗号分隔多个赋值或将每个赋值放在独立的语句中:

mysql> `SET` `@``counter` `=` `0``,` `@``age` `:``=` `23``;`
Query OK, 0 rows affected (0.00 sec)

SET的替代语法是SELECT INTO。您可以初始化单个变量:

mysql> `SELECT` `0` `INTO` `@``counter``;`
Query OK, 1 row affected (0.00 sec)

或同时初始化多个变量:

mysql> `SELECT` `0``,` `23` `INTO` `@``counter``,` `@``age``;`
Query OK, 1 row affected (0.00 sec)

用户变量的最常见用途是保存结果并稍后使用。您可能还记得本章早些时候的以下示例,我们用它来推动嵌套查询(对于此问题肯定是更好的解决方案)。在这里,我们想找出特定客户最近租赁的电影的名称:

mysql> `SELECT` `MAX``(``rental_date``)` `FROM` `rental`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'``;`
+---------------------+
| MAX(rental_date)    |
+---------------------+
| 2005-08-23 15:46:33 |
+---------------------+
1 row in set (0.01 sec)
mysql> `SELECT` `title` `FROM` `film`
    -> `JOIN` `inventory` `USING` `(``film_id``)`
    -> `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `AND` `rental_date` `=` `'2005-08-23 15:46:33'``;`
+-------------+
| title       |
+-------------+
| KARATE MOON |
+-------------+
1 row in set (0.00 sec)

您可以使用用户变量保存结果以便输入到后续查询中。以下是使用此方法重写的相同查询对:

mysql> `SELECT` `MAX``(``rental_date``)` `INTO` `@``recent` `FROM` `rental`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'``;`
1 row in set (0.01 sec)
mysql> `SELECT` `title` `FROM` `film`
    -> `JOIN` `inventory` `USING` `(``film_id``)`
    -> `JOIN` `rental` `USING` `(``inventory_id``)`
    -> `JOIN` `customer` `USING` `(``customer_id``)`
    -> `WHERE` `email` `=` `'WESLEY.BULL@sakilacustomer.org'`
    -> `AND` `rental_date` `=` `@``recent``;`
+-------------+
| title       |
+-------------+
| KARATE MOON |
+-------------+
1 row in set (0.00 sec)

这可以避免复制粘贴,并且确实有助于避免输入错误。

这里是关于使用用户变量的一些指导方针:

  • 用户变量是唯一于连接的:您创建的变量对其他人不可见,两个不同的连接可以具有相同名称的两个不同变量。

  • 变量名称可以是字母数字字符串,还可以包括句点(.)、下划线(_)和美元符号($)字符。

  • 在 MySQL 版本 5 之前,变量名称区分大小写,在版本 5 及以后则不区分大小写。

  • 任何未初始化的变量都具有值NULL;您还可以手动将变量设置为NULL

  • 变量在连接关闭时被销毁。

  • 您应该避免尝试将值分配给变量并将变量用作SELECT查询的一部分。此做法的两个原因是,新值可能不能立即在同一语句中使用,以及变量的类型在首次分配时设置;在同一 SQL 语句中稍后尝试将其用作不同类型可能会导致意外结果。

    让我们更详细地查看使用新变量@fid的第一个问题。由于我们之前没有使用过此变量,它是空的。现在,让我们显示具有inventory表中条目的电影的film_id。我们将film_id分配给@fid变量,而不是直接显示它。我们的查询将显示变量三次——一次是在赋值操作之前,一次作为赋值操作的一部分,一次在赋值操作之后:

    mysql> `SELECT` `@``fid``,` `@``fid``:``=``film``.``film_id``,` `@``fid` `FROM` `film``,` `inventory`
        -> `WHERE` `inventory``.``film_id` `=` `@``fid``;`
    
    Empty set, 1 warning (0.16 sec)
    

    这只返回一个废弃警告;因为变量中一开始没有任何内容,WHERE 子句尝试查找空的 inventory.film_id 值。如果我们修改查询以将 film.film_id 作为 WHERE 子句的一部分,事情将按预期工作:

    mysql> `SELECT` `@``fid``,` `@``fid``:``=``film``.``film_id``,` `@``fid` `FROM` `film``,` `inventory`
        -> `WHERE` `inventory``.``film_id` `=` `film``.``film_id` `LIMIT` `20``;`
    
    +------+--------------------+------+
    | @fid | @fid:=film.film_id | @fid |
    +------+--------------------+------+
    | NULL |                  1 | 1    |
    | 1    |                  1 | 1    |
    | 1    |                  1 | 1    |
    | ...                              |
    | 4    |                  4 | 4    |
    | 4    |                  4 | 4    |
    +------+--------------------+------+
    20 rows in set, 1 warning (0.00 sec)
    

    现在如果 @fid 不为空,初始查询将产生一些结果:

    mysql> `SELECT` `@``fid``,` `@``fid``:``=``film``.``film_id``,` `@``fid` `FROM` `film``,` `inventory`
        -> `WHERE` `inventory``.``film_id` `=` `film``.``film_id` `LIMIT` `20``;`
    
    +------+--------------------+------+
    | @fid | @fid:=film.film_id | @fid |
    +------+--------------------+------+
    |    4 |                  1 |    1 |
    |    1 |                  1 |    1 |
    |  ...                             |
    |    4 |                  4 |    4 |
    |    4 |                  4 |    4 |
    +------+--------------------+------+
    20 rows in set, 1 warning (0.00 sec)
    

    最好避免这种行为不被保证且因此不可预测的情况。

第十章:备份和恢复

任何数据库管理员最重要的任务是备份数据。正确和经过测试的备份和恢复程序可以拯救公司,从而拯救工作。错误会发生,灾难会发生,错误也会发生。MySQL 是一个强大的软件,但它并不完全没有错误或崩溃。因此,了解为什么以及如何执行备份是至关重要的。

除了保存数据库内容,大多数备份方法还可以用于另一个重要目的:在不同系统之间复制数据库内容。虽然复制的重要性可能不如在发生损坏时救命那么重要,但这种复制对于绝大多数数据库操作人员来说是例行操作。开发人员通常需要使用类似于生产环境的下游环境。质量保证人员可能需要一个寿命仅为一小时的易失性环境。分析可以在专用主机上运行。这些任务中的一些可以通过复制来解决,但任何副本都是从恢复的备份开始的。

本章首先简要回顾了两种主要类型的备份,并讨论了它们的基本属性。然后,它查看了 MySQL 世界中一些用于备份和恢复目的的工具。覆盖每个工具及其参数超出了本书的范围,但通过本章的学习,您应该了解如何进行 MySQL 数据的备份和恢复。我们还将探讨一些基本的数据传输场景。最后,本章概述了一个强大的备份架构,您可以将其作为工作的基础。

对于我们认为是一个良好的备份策略的概述可以在 “数据库备份策略入门” 中找到。在决定策略之前,理解工具和运作部分非常重要,因此该部分排在最后。

物理备份和逻辑备份

广义上讲,大多数备份工具可以归为两大类:逻辑备份和物理备份。逻辑 备份操作的是内部结构:数据库(模式)、表、视图、用户和其他对象。物理 备份关注的是数据库结构的操作系统端表示:数据文件、事务日志等。

用一个例子可能更容易解释。想象一下备份 MySQL 数据库中的单个 MyISAM 表。正如您将在本章后面看到的那样,InnoDB 存储引擎更复杂地正确备份。知道 MyISAM 不是事务性的,并且对这个表没有正在进行的写入,我们可以继续复制与其相关的文件。这样做,我们创建了表的物理备份。我们也可以运行SELECT *SHOW CREATE TABLE语句,并将这些语句的输出保存在某个地方。这是逻辑备份的一个非常基本的形式。当然,这只是简单的例子,在现实中,获取这两种备份的过程会更复杂和微妙。然而,这些想象中备份之间的概念差异可以转移并应用于任何逻辑和物理备份。

逻辑备份

逻辑备份关注的是实际数据,而不是物理表示。正如您已经看到的那样,这种备份不会复制任何现有的数据库文件,而是依赖于查询或其他手段来获取所需的数据库内容。结果通常是一些文本表示,尽管这并不是保证,逻辑备份的输出也可能是二进制编码的。让我们看一些更多这种备份可能是什么样子的例子,然后讨论它们的特性。

这里有一些逻辑备份的例子:

  • 使用SELECT ... INTO OUTFILE语句将查询的表数据保存到外部.csv文件中,我们在“将数据写入逗号分隔文件”中进行了介绍。

  • 可以将表或任何其他对象的定义保存为 SQL 语句。

  • 一个或多个INSERT SQL 语句,运行在一个数据库和一个空表上,将该表填充到保留状态。

  • 所有曾经触及特定表或数据库并修改数据或模式对象的语句记录。这里我们指的是 DML 和 DDL 命令;您应该熟悉这两种类型,它们在第三章和第四章中有详细介绍。

注意

那个最后的例子实际上展示了 MySQL 中复制和时间点恢复是如何工作的。我们稍后会讨论这些主题,您会看到术语逻辑不仅仅适用于备份。

逻辑备份的恢复通常通过执行一个或多个 SQL 语句完成。延续我们之前的例子,让我们回顾一下恢复的选项:

  • .csv文件中的数据可以使用LOAD DATA INFILE命令加载到表中。

  • 可以通过运行 DDL SQL 语句来创建或重新创建表。

  • INSERT SQL 语句可以使用mysql CLI 或任何其他客户端执行。

  • 回放数据库中运行的所有语句将其恢复到最后一个语句之后的状态。

逻辑备份具有一些有趣的特性,使它们在某些情况下极其有用。通常情况下,逻辑备份是某种形式的文本文件,主要由 SQL 语句组成。然而,并非必须如此,并不是其定义属性(尽管这是一个有用的属性)。创建逻辑备份的过程通常还涉及执行一些查询。这些是重要的特性,因为它们允许灵活性和可移植性。

逻辑备份非常灵活,因为它们使得非常容易备份数据库的部分内容。例如,您可以备份模式对象而不包括其内容,或者轻松地备份数据库中的几个表。甚至可以备份表的部分数据,这通常是物理备份所无法做到的。一旦备份文件准备好,您可以使用工具手动或自动地查看和修改它,这是使用数据库文件副本难以做到的事情。

可移植性来源于逻辑备份可以轻松加载到运行在不同操作系统和架构上的不同版本的 MySQL 中。通过一些修改,您实际上可以将从一个 RDBMS 中获取的逻辑备份加载到完全不同的 RDBMS 中。大多数数据库迁移工具内部使用逻辑复制正是因为这一事实。这种特性还使得这种备份类型适用于远程备份云管理数据库和它们之间的迁移。

逻辑备份的另一个有趣特性是它们在对抗损坏(即物理数据文件的物理损坏)方面非常有效。数据中的错误仍然可能被引入,例如由软件中的错误或存储介质逐渐退化引起。关于损坏及其对应的完整性的话题非常广泛,但这个简要解释现在应该足够了。

一旦数据文件损坏,数据库可能无法从中读取数据并处理查询。由于损坏往往是悄无声息发生的,您可能不知道何时发生。然而,如果生成逻辑备份时没有错误,那意味着它是完好的,数据也是正确的。损坏可能发生在辅助索引(任何非主索引;详见第四章,使用数据库结构了解更多细节),因此,进行全表扫描的逻辑备份可能正常生成而不会遇到错误。简言之,逻辑备份既可以帮助您早期检测到损坏(因为它扫描所有表),又可以帮助您保存数据(因为最后一次成功的逻辑备份将有一个正确的副本)。

所有逻辑备份的固有问题源于它们是通过对运行中的数据库系统执行 SQL 语句来创建和恢复的。虽然这样做可以提供灵活性和可移植性,但这也意味着这些备份会给数据库带来负载,并且通常非常慢。数据库管理员总是对某人运行不加选择地读取表中所有数据的查询感到不满,而逻辑备份工具通常就是这样做的。类似地,逻辑备份的恢复操作通常会像来自常规客户端的每个语句一样进行解释和执行。这并不意味着逻辑备份是不好或不应该使用的,但这是一个必须记住的权衡。

物理备份

逻辑备份关注的是数据库内容中的数据,而物理备份关注的是操作系统文件和内部关系型数据库的工作原理。记住,在备份 MyISAM 表的示例中,物理备份是表的文件副本。大多数此类备份和工具关注于复制和传输整个或部分数据库文件。

一些物理备份的示例包括以下内容:

  • 冷数据库目录复制,意味着在数据库关闭时进行(与热复制相反,这是在数据库运行时进行)。

  • 存储快照,用于数据库使用的卷和文件系统。

  • 表数据文件的副本。

  • 某种形式的数据库数据文件更改流。大多数关系型数据库管理系统使用这样的流进行崩溃恢复,有时用于复制;InnoDB 的重做日志是类似的概念。

物理备份的恢复通常通过复制回文件并使其一致来完成。让我们回顾前面示例的恢复选项:

  • 冷备份可以移动到所需的位置或服务器,然后由 MySQL 实例(旧的或新的)用作数据目录。

  • 快照可以在原地或另一个卷上恢复,然后由 MySQL 使用。

  • 可以将表文件放置在现有文件的位置。

  • 对数据文件的更改流重播将其恢复到最后一次的状态。

最简单的物理备份是一个冷数据库目录备份。是的,它很简单和基础,但它是一个非常强大的工具。

物理备份与逻辑备份不同,非常严格,对于可以备份的内容以及备份可以使用的位置几乎没有控制余地。 一般来说,大多数物理备份只能用于恢复数据库或表的确切状态。 通常,这些备份还会限制目标数据库软件版本和操作系统。 经过一些工作,您可以将从 MySQL 到 PostgreSQL 的逻辑备份还原。 但是,在 Linux 上完成的 MySQL 数据目录的冷备份在 Windows 上还原可能不起作用。 此外,如果没有对数据库服务器的物理访问权限,您将无法进行物理备份。 这意味着在云中的托管数据库上执行此类备份是不可能的:供应商可能正在后台执行物理备份,但您可能无法取回备份文件。

由于物理备份本质上是原始备份页面的复制或子集,原始备份中存在的任何损坏都将包含在备份中。 记住这一点很重要,因为这一属性使物理备份不适合用于对抗损坏。

您可能想知道为什么要使用这种看似不便的备份方式。 原因在于物理备份很快。 通过操作操作系统甚至存储级别,物理备份方法有时是实际上唯一可能的备份数据库的方法。 例如,多 TB 卷的存储快照可能需要几秒钟或几分钟,而对逻辑备份的数据进行查询和流式传输可能需要几小时或几天。 恢复也是如此。

逻辑和物理备份概述

现在我们已经涵盖了备份的两个类别,并准备开始探索在 MySQL 世界中用于这些备份的实际工具。 在我们开始之前,让我们总结一下逻辑备份和物理备份之间的区别,并快速查看用于创建它们的工具的属性。

逻辑备份的特性:

  • 包含逻辑结构的描述和内容

  • 可以人为阅读和编辑

  • 相对缓慢进行备份和恢复

逻辑备份工具包括:

  • 非常灵活,允许您重命名对象,合并单独的源,执行部分恢复等

  • 通常不限于特定的数据库版本或平台

  • 能够从已损坏的表中提取数据并保护免受损坏

  • 适用于备份远程数据库(例如,在云中)

物理备份的特性:

  • 是数据文件部分或整个文件系统/卷的字节复制

  • 快速进行备份和恢复

  • 提供很少的灵活性,并且恢复时始终产生相同的结构

  • 可以包括损坏页面

物理备份工具包括:

  • 操作繁琐

  • 通常不允许轻松跨平台或甚至跨版本移植

  • 没有操作系统访问权限无法备份远程数据库

提示

这些并非相互冲突的方法。事实上,一般的好主意是定期执行这两种备份类型。它们服务于不同的目的并满足不同的需求。

复制作为备份工具

复制是一个非常广泛的主题,即将在后续章节详细讨论。在本节中,我们简要讨论了复制如何与数据库备份和恢复的概念相关联。

简而言之,复制不能替代备份。复制的具体情况是产生目标数据库的全面或部分副本。这使得您可以在许多但不是所有可能的涉及 MySQL 的故障场景中使用复制。让我们回顾两个例子。它们在本章后面也会有所帮助。

注意

在 MySQL 世界中,复制是一种逻辑备份类型。这是因为它基于传输逻辑 SQL 语句。

基础设施故障

基础设施容易发生故障:驱动器损坏,停电,火灾发生。几乎没有系统可以提供 100%的正常运行时间,即使是大规模分布式系统也很难接近。这意味着最终任何数据库都会因其主机服务器的故障而崩溃。在良好情况下,重新启动可能足够恢复。在糟糕情况下,部分或全部数据可能会丢失。

恢复和恢复备份绝非瞬间操作。在复制环境中,可以执行称为切换的特殊操作,将副本置于失败的数据库位置。在许多情况下,切换能节省大量时间,并且让在失败系统上的工作不至于太过仓促。

想象一下有两台运行 MySQL 的相同服务器的设置。一台是专用主服务器,接收所有连接并处理所有查询。另一台是副本。有一种机制可以将连接重定向到副本,进行切换将导致 5 分钟的停机时间。

有一天,主服务器的硬盘坏了。由于这是一台简单的服务器,这一问题导致了系统崩溃和停机时间。监控系统捕捉到了问题,数据库管理员立即意识到,为了在该服务器上恢复数据库,他们需要安装新硬盘,然后恢复最近的备份。整个操作将耗费几个小时。

在这种情况下切换到副本是个好主意,因为它节省了大量宝贵的运行时间。

部署错误

软件缺陷是生活中必须接受的事实。系统越复杂,逻辑错误发生的可能性就越高。虽然我们都在努力限制和减少缺陷,但必须理解它们会发生并相应地进行规划。

想象一下,一个包含数据库迁移脚本的新应用版本发布了。尽管新版本和脚本在下游环境中都经过了测试,但出现了一个 bug。迁移无法恢复地损坏了所有含有“特殊”非 ASCII 符号的客户姓氏。由于脚本顺利完成,损坏是悄无声息的,而问题直到一周后由一个愤怒的客户注意到,他的名字现在是错误的。

即使生产数据库有一个副本,它的数据和逻辑损坏也是一样的。在这种情况下切换到副本 不能 有所帮助,必须恢复迁移前的备份以获取正确姓氏列表。

注意

延迟副本可以在这种情况下为您提供保护,但是延迟越长,操作这样的副本就越不切实际。您可以创建一个一周的延迟副本,但您可能需要一小时前的数据。通常,副本延迟以分钟和小时计算。

刚刚讨论的两种故障场景涵盖了两个不同的领域:物理和逻辑。复制对于在物理问题发生时提供保护很合适,但对于逻辑问题几乎没有(或很少)保护。复制是一个有用的工具,但它不能替代备份。

mysqldump 程序

在线备份数据库的可能最简单方法是将其内容转储为 SQL 语句。这是至关重要的逻辑备份类型。转储在计算中通常意味着输出某个系统或其部分的内容,结果是一个转储。在数据库世界中,转储通常是逻辑备份的一种,转储是获得这种备份的操作。将备份恢复到数据库涉及应用这些语句。您可以通过使用例如 SHOW CREATE TABLE 和一些 CONCAT 操作从表中的数据行中获取 INSERT 语句来手动生成转储,如下所示:

mysql> `SHOW` `CREATE` `TABLE` `sakila``.``actor``\``G`
*************************** 1\. row ***************************
       Table: actor
Create Table: CREATE TABLE `actor` (
  `actor_id` smallint unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8mb4
        COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
mysql> `SELECT` `CONCAT``(``"INSERT INTO actor VALUES"``,`
    -> `"("``,``actor_id``,``",'"``,``first_name``,``"','"``,`
    -> `last_name``,``"','"``,``last_update``,``"');"``)`
    -> `AS` `insert_statement` `FROM` `actor` `LIMIT` `1``\``G`
*************************** 1\. row ***************************
insert_statement: INSERT INTO actor VALUES
(1,'PENELOPE','GUINESS','2006-02-15 04:34:33');
1 row in set (0.00 sec)

然而,这很快变得极其不切实际。此外,还有更多需要考虑的事情:语句的顺序,以便在恢复时,INSERT 不会在表创建之前运行,以及所有权一致性。尽管手动生成逻辑备份有助于理解,但却是繁琐且容易出错的。幸运的是,MySQL 自带一个强大的逻辑备份工具,名为 mysqldump,它隐藏了大部分复杂性。

MySQL 所附带的 mysqldump 程序允许您从正在运行的数据库实例中生成转储。mysqldump 的输出是一系列 SQL 语句,稍后可以应用到相同或另一个 MySQL 实例中。mysqldump 是一个跨平台工具,在 MySQL 服务器可用的所有操作系统上都可以使用。由于生成的备份文件只是一堆文本,因此它也是跨平台的。

mysqldump 的命令行参数很多,因此在使用这个工具之前最好先查阅MySQL 参考手册。不过,最基本的情况只需要一个参数:目标数据库名称。

提示

建议您按照“登录路径配置文件”中的说明为root用户和密码设置一个client登录路径。然后,您就不需要为我们本章展示的任何命令指定账户并提供其凭据了。

在下面的例子中,mysqldump 被调用而没有输出重定向,这个工具将把所有语句打印到标准输出:

$ mysqldump sakila
...
--
-- Table structure for table `actor`
--

DROP TABLE IF EXISTS `actor`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `actor` (
  `actor_id` smallint unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8mb4
        COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `actor`
--

LOCK TABLES `actor` WRITE;
/*!40000 ALTER TABLE `actor` DISABLE KEYS */;
INSERT INTO `actor` VALUES
(1,'PENELOPE','GUINESS','2006-02-15 01:34:33'),
(2,'NICK','WAHLBERG','2006-02-15 01:34:33'),
...
(200,'THORA','TEMPLE','2006-02-15 01:34:33');
/*!40000 ALTER TABLE `actor` ENABLE KEYS */;
UNLOCK TABLES;
...
注意

mysqldump 的输出非常冗长,不适合在书中打印。在这里和其他地方,输出被截断,只包括我们感兴趣的行。

您可能会注意到,此输出比您预期的更加微妙。例如,有一个 DROP TABLE IF EXISTS 语句,它在目标上已存在表时可以防止以下 CREATE TABLE 命令出错。 LOCKUNLOCK TABLES 语句将提高数据插入性能,等等。

谈到模式结构,可以生成不含数据的备份。例如,为了开发环境,创建数据库的逻辑克隆就很有用。这种灵活性是逻辑备份和 mysqldump 的关键特性之一:

$ mysqldump --no-data sakila
...
--
-- Table structure for table `actor`
--

DROP TABLE IF EXISTS `actor`;
/*!40101 SET @saved_cs_client     = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `actor` (
  `actor_id` smallint unsigned NOT NULL AUTO_INCREMENT,
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP
        ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`actor_id`),
  KEY `idx_actor_last_name` (`last_name`)
) ENGINE=InnoDB AUTO_INCREMENT=201 DEFAULT CHARSET=utf8mb4
        COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Temporary view structure for view `actor_info`
--
...

也可以创建数据库中单个表的备份。在下一个例子中,sakila 是数据库,category 是目标表:

$ mysqldump sakila category

将灵活性提升到一个新的层次,可以通过指定 --where-w 参数从表中仅导出少量行。正如其名,语法与 SQL 语句中的 WHERE 子句相同:

$ mysqldump sakila actor --where="actor_id > 195"
...
--
-- Table structure for table `actor`
--

DROP TABLE IF EXISTS `actor`;
CREATE TABLE `actor` (
...

--
-- Dumping data for table `actor`
--
-- WHERE:  actor_id > 195

LOCK TABLES `actor` WRITE;
/*!40000 ALTER TABLE `actor` DISABLE KEYS */;
INSERT INTO `actor` VALUES
(196,'BELA','WALKEN','2006-02-15 09:34:33'),
(197,'REESE','WEST','2006-02-15 09:34:33'),
(198,'MARY','KEITEL','2006-02-15 09:34:33'),
(199,'JULIA','FAWCETT','2006-02-15 09:34:33'),
(200,'THORA','TEMPLE','2006-02-15 09:34:33');
/*!40000 ALTER TABLE `actor` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

到目前为止,示例只涵盖了对单个数据库 sakila 的全部或部分数据的导出。有时需要输出每个数据库、每个对象,甚至每个用户。 mysqldump 能够胜任。以下命令将有效创建数据库实例的完整逻辑备份:

$ mysqldump --all-databases --triggers --routines --events > dump.sql

触发器默认被导出,因此此选项不会出现在未来的命令输出中。如果您不希望导出触发器,可以使用 --no-triggers

这个命令存在一些问题。首先,尽管我们已将命令的输出重定向到一个文件,但生成的文件可能非常大。幸运的是,其内容很可能非常适合压缩,尽管这取决于实际数据。无论如何,压缩输出是个好主意:

$ mysqldump --all-databases \
--routines --events | gzip > dump.sql.gz

在 Windows 上,通过管道压缩输出比较困难,因此只需压缩通过运行上述命令生成的dump.sql文件。在像我们这里使用的小型虚拟机这样的 CPU 繁忙系统上,压缩可能会显著增加备份过程的时间。这是一个需要根据您的特定系统权衡的折衷:

$ time mysqldump --all-databases \
--routines --events > dump.sql
real    0m24.608s
user    0m15.201s
sys     0m2.691s
$ time mysqldump --all-databases \
--routines --events | gzip > dump.sql.gz
real    2m2.769s
user    2m4.400s
sys     0m3.115s
$ ls -lh dump.sql
-rw... 2.0G ... dump.sql
-rw... 794M ... dump.sql.gz

第二个问题是为了确保一致性,在转储数据库时会对表加锁,阻止写入(可以继续写入其他数据库)。这既影响性能又影响备份一致性。结果的转储只在数据库内部一致,而不是整个实例。这种默认行为是必要的,因为 MySQL 使用的一些存储引擎是非事务性的(主要是较旧的 MyISAM)。另一方面,默认的 InnoDB 存储引擎具有多版本并发控制(MVCC)模型,允许维护读取快照。我们在“备用存储引擎”中更深入地介绍了不同的存储引擎,以及在第六章中的锁定。

使用 InnoDB 的事务功能可以通过向mysqldump传递--single-transaction命令行参数来实现。然而,这会移除表锁定,因此使得非事务性表在转储期间容易出现不一致性。例如,如果您的系统同时使用 InnoDB 和 MyISAM 表,可能需要分别转储它们,如果不需要中断写入和保持一致性的话。

注意

虽然--single-transaction确保mysqldump运行时可以继续写入,但仍然有一些注意事项:并发运行的 DDL 语句可能会导致不一致性,并且长时间运行的事务(例如由mysqldump启动的事务)可能会对整个实例的性能产生负面影响

对主要使用 InnoDB 表的系统进行转储的基本命令,可以保证对并发写入的影响很小,如下所示:

$ mysqldump --single-transaction --all-databases \
--routines --events | gzip > dump.sql.gz

在实际应用中,您可能会有更多参数用来指定连接选项。您还可以围绕mysqldump语句编写脚本,以捕获任何问题并在出现问题时通知您。

使用--all-databases进行转储会包括内部 MySQL 数据库,例如mysqlsysinformation_schema。这些信息不一定在恢复数据时总是需要,并且可能在恢复到已经有一些数据库的实例时出现问题。但是,您应该记住,MySQL 用户详细信息只会作为mysql数据库的一部分进行转储。

通常情况下,使用mysqldump和它生成的逻辑备份可以实现以下功能:

  • 在不同环境之间轻松传输数据。

  • 通过人类和程序在原地编辑数据。例如,您可以从转储中删除个人或不必要的数据。

  • 发现某些数据文件的损坏。

  • 在不同主要数据库版本、不同平台甚至不同数据库之间的数据传输。

使用 mysqldump 引导复制

mysqldump 程序可以用来创建一个空的或带有数据的副本实例。为了方便起见,提供了多个命令行参数。例如,当指定 --master-data 时,生成的输出将包含一个 SQL 语句 (CHANGE MASTER TO),它将在目标实例上正确设置复制坐标。稍后使用这些坐标在目标实例上启动复制时,数据将不会有任何间隙。在基于 GTID 的复制拓扑中,可以使用 --set-gtid-purged 来实现相同的结果。然而,即使没有任何额外的命令行参数,mysqldump 也会检测到 gtid_mode=ON 并包含必要的输出。

在 “使用 mysqldump 创建副本” 中提供了使用 mysqldump 设置复制的示例。

从 SQL 转储文件加载数据

在执行备份时,始终记住,您是为了能够以后还原数据。对于逻辑备份,恢复过程就像将备份文件的内容通过管道传输到 mysql CLI 一样简单。正如前面讨论的那样,MySQL 必须处于逻辑备份还原状态,这既有好处也有坏处:

  • 在系统的其他部分正常运行时,您可以还原单个对象,这是一个优点。

  • 还原过程效率低下,并且会像任何常规客户端一样加载系统,如果决定插入大量数据。这是一个缺点。

让我们来看一个简单的示例,使用单个数据库备份和还原。正如我们之前所见,mysqldump 将在转储中包含必要的 DROP 语句,因此即使对象存在,它们也将被成功还原:

$ mysqldump sakila > /tmp/sakila.sql
$ mysql -e "CREATE DATABASE sakila_mod"
$ mysql sakila_mod < /tmp/sakila.sql
$ mysql sakila_mod -e "SHOW TABLES"
+----------------------------+
| Tables_in_sakila_mod       |
+----------------------------+
| actor                      |
| actor_info                 |
| ...                        |
| store                      |
+----------------------------+

mysqldumpmysqlpump(在下一节讨论)生成的 SQL 转储一样,是一个资源密集型的操作。默认情况下,它也是一个串行过程,可能需要大量时间。您可以使用一些技巧来加快这个过程,但请记住,错误可能导致丢失或不正确还原数据。选项包括:

  • 模式/数据库的并行还原

  • 在模式内并行还原对象

如果使用 mysqldump 在每个数据库的基础上进行转储,则很容易完成第一个。如果不需要跨数据库的一致性(不会得到保证),备份过程也可以并行化。以下示例使用 & 修饰符,指示 shell 在后台执行前面的命令:

$ mysqldump sakila > /tmp/sakila.sql &
$ mysqldump nasa > /tmp/nasa.sql &

结果转储是独立的。除非备份 mysql 数据库,否则 mysqldump 不会处理用户和授权,因此您需要注意。还原同样简单:

$ mysql sakila < /tmp/sakila.sql &
$ mysql nasa < /tmp/nasa.sql &

在 Windows 上,还可以使用 PowerShell 命令Start-Process或在后续版本中相同的&将命令执行发送到后台。

第二个选项更为复杂。您可以根据表格基础转储(例如,mysqldump sakila artists > sakila.artists.sql),这会导致简单的还原,或者您需要继续编辑转储文件以将其拆分为多个文件。在极端情况下,甚至可以在表级别并行化数据插入,尽管这可能不实用。

尽管这是可行的,但最好使用专门用于此任务的工具。

mysqlpump

mysqlpump是 MySQL 版本 5.7 及更高版本捆绑的实用程序程序,主要在性能和可用性等多个领域改进了mysqldump。其主要区别如下:

  • 并行转储能力

  • 内置转储压缩

  • 通过延迟创建二级索引来改进恢复性能

  • 更轻松地控制转储对象

  • 转储用户帐户的修改行为

使用该程序与使用mysqldump非常相似。最主要的即时区别在于,当没有传递参数时,mysqlpump将默认转储所有数据库(不包括INFORMATION_SCHEMAperformance_schemandbinfosys模式)。其他显著的区别是有进度指示器,并且mysqlpump默认使用两个线程进行并行转储:

$ mysqlpump > pump.out
Dump progress: 1/2 tables, 0/530419 rows
Dump progress: 80/184 tables, 2574413/646260694 rows
...
Dump progress: 183/184 tables, 16297773/646260694 rows
Dump completed in 10680

mysqlpump中的并行概念有些复杂。您可以在不同数据库之间以及给定数据库内的不同对象之间使用并发性。默认情况下,如果没有指定其他并行选项,mysqlpump将使用单个队列和两个并行线程来处理所有数据库和用户定义(如果被请求)。您可以使用--default-parallelism参数来控制默认队列的并行级别。为了进一步微调并发性,您可以设置多个并行队列来处理不同的数据库。在选择所需并发级别时要小心,因为备份运行可能会占用大部分数据库资源。

使用mysqlpump时与mysqldump的一个重要区别在于它如何处理用户帐户。mysqldump通过转储mysql.user和其他相关表来管理用户。如果在转储中不包括mysql数据库,则不会保留任何用户信息。mysqlpump通过引入命令行参数--users--include-users对此进行了改进。第一个参数告诉实用程序为所有用户添加与转储相关的命令,第二个参数接受用户名列表。这在旧方式的基础上有了很大改进。

让我们结合所有新功能来生成非系统数据库和用户定义的压缩转储,并在过程中使用并发性:

$ mysqlpump --compress-output=zlib --include-users=bob,kate \
--include-databases=sakila,nasa,employees \
--parallel-schemas=2:employees \
--parallel-schemas=sakila,nasa > pump.out
Dump progress: 1/2 tables, 0/331579 rows
Dump progress: 19/23 tables, 357923/3959313 rows
...
Dump progress: 22/23 tables, 3755358/3959313 rows
Dump completed in 10098
注意

mysqlpump输出可以使用 ZLIB 或 LZ4 算法进行压缩。当操作系统级命令lzopenssl zlib不可用时,您可以使用包含在 MySQL 分发中的lz4_decompresszlib_decompress实用程序。

由于其中的数据是交错的,从mysqlpump运行的转储不适合并行恢复。例如,以下是mysqlpump执行的结果,显示了在不同数据库的表中插入数据时创建表:

...,(294975,"1955-07-31","Lucian","Rosis","M","1986-12-08");
CREATE TABLE `sakila`.`store` (
`store_id` tinyint unsigned NOT NULL AUTO_INCREMENT,
`manager_staff_id` tinyint unsigned NOT NULL,
`address_id` smallint unsigned NOT NULL,
`last_update` timestamp NOT NULL DEFAULT
CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`store_id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT
CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
;
INSERT INTO `employees`.`employees` VALUES
(294976,"1961-03-19","Rayond","Khalid","F","1989-11-03"),...

mysqlpumpmysqldump的改进版本,增加了重要的并发性、压缩和对象控制功能。然而,该工具不允许对转储进行并行恢复,实际上使其不可能。对于恢复性能的唯一改进是在主要加载完成后添加次要索引。

mydumper 和 myloader

mydumpermyloader都是开源项目mydumper的一部分。这套工具试图使逻辑备份更高效、更易管理和更友好。在这里我们不会深入讨论,因为在涵盖每种可能的 MySQL 备份变体时书中的空间很容易不够。

这些程序可以通过从项目的 GitHub 页面获取最新版本或编译源代码来安装。在撰写本文时,最新版本略落后于主分支。“设置 mydumper 和 myloader 实用程序”提供了逐步安装说明。

我们之前展示了mysqlpump如何改善转储性能,但提到其交织的输出对恢复没有帮助。mydumper结合了并行转储方法,并为使用myloader进行并行恢复做好了准备。这通过将每个表转储到单独的文件中实现。

mydumper的默认调用非常简单。该工具尝试连接到数据库,启动一致性转储,并在当前目录下创建一个目录用于导出文件。请注意,默认情况下,每个表都有自己的文件。转储操作的默认并行度设置为4,意味着将同时读取四个单独的表。在此目录上调用myloader将能够并行恢复表格。

要创建并查看转储,请执行以下命令:

$ mydumper -u root -a
Enter the MySQL password:
$ ls -ld export
drwx... export-20210613-204512
$ ls -la export-20210613-204512
...
-rw... sakila.actor.sql
-rw... sakila.address-schema.sql
-rw... sakila.address.sql
-rw... sakila.category-schema.sql
-rw... sakila.category.sql
-rw... sakila.city-schema.sql
-rw... sakila.city.sql
...

除了并行转储和恢复功能外,mydumper还具有一些更高级的功能:

  • 轻量级备份锁支持。Percona Server for MySQL 实现了一些额外的轻量级锁定,这些锁定被 Percona XtraBackup 使用。mydumper在可能的情况下默认使用这些锁定。这些锁定不会阻塞对 InnoDB 表的并发读写,但会阻塞任何 DDL 语句,否则可能使备份无效。

  • 使用保存点。mydumper使用事务保存点技巧来最小化元数据锁定。

  • 元数据锁定持续时间限制。为了解决我们在 “元数据锁” 中描述的长时间元数据锁定问题,mydumper 提供两个选项:快速失败或终止长时间运行的查询,从而使 mydumper 能够成功。

mydumpermyloader 是先进的工具,将逻辑备份能力发挥到极致。然而,作为一个社区项目的一部分,它们缺乏其他工具提供的文档和优化。另一个主要缺点是缺乏任何支持或保证。尽管如此,它们仍然可以成为数据库运营者工具箱中有用的补充。

冷备份和文件系统快照

物理备份的基石,冷备份实际上只是数据目录及其它必要文件的一份拷贝,在数据库实例停机时完成。这种技术并不经常使用,但在需要快速创建一致备份时,它可以派上用场。随着数据库常规接近多 TB 的大小范围,仅仅复制文件可能需要很长时间。然而,冷备份仍然有其优点:

  • 非常快速(可以说是快照以外最快的备份方法)

  • 简单直接

  • 易于使用,难以做错

现代存储系统和一些文件系统具备即时快照功能。它们允许您通过利用内部机制创建任意大小的卷的几乎瞬时副本。不同支持快照的系统的特性差异很大,使我们无法覆盖所有系统。然而,我们仍然可以从数据库的角度谈谈它们的一些特点。

大多数快照将是写时复制(COW)并在某个时间点上内部一致。然而,我们已经知道数据库文件在磁盘上并不一致,特别是对于像 InnoDB 这样的事务性存储引擎。这使得正确获取快照备份有些困难。有两种选择:

冷备份快照

当数据库关闭时,其数据文件可能仍然不完全一致。但是如果您对所有数据库文件(包括 InnoDB 重做日志等)进行快照,它们将允许数据库启动。这是理所当然的,否则数据库每次重新启动都会丢失数据。不要忘记您可能在许多卷中拥有数据库文件分散的情况。您将需要所有这些文件。这种方法适用于所有存储引擎。

热备份快照

在运行中的数据库中正确进行快照是一个比数据库停机时更大的挑战。如果您的数据库文件位于多个卷上,则无法保证即使同时启动的快照也能一致到达相同的时间点,这可能会导致灾难性的结果。此外,像 MyISAM 这样的非事务性存储引擎在数据库运行时也不能保证磁盘上文件的一致性。实际上,对于 InnoDB 也是如此,但 InnoDB 的重做日志始终是一致的(除非禁用了保护措施),而 MyISAM 则缺乏这种功能。

建议的热备份快照方法因此是利用一些程度的锁定。由于快照过程通常很快,因此 resulting downtime shouldn’t be significant. Here’s the process:

  1. 创建一个新的会话,并使用FLUSH TABLES WITH READ LOCK命令锁定所有表格。此会话不能关闭,否则锁定将被释放。

  2. 可选地,通过运行SHOW MASTER STATUS命令记录当前的 binlog 位置。

  3. 根据存储系统的手册为 MySQL 数据库文件所在的所有卷创建快照。

  4. 在最初打开的会话中使用UNLOCK TABLES命令解锁表格。

如果不是所有当前存储系统和能够执行快照的文件系统,此一般方法都应该是合适的。请注意,它们在实际过程和要求上都有微妙的差异。一些云供应商要求您还需在文件系统上执行 fsfreeze

在将其实施到生产中并信任它们处理数据之前,请始终彻底测试您的备份。您只能信任您已经测试并且使用起来感到舒适的解决方案。盲目复制任意备份策略建议并不是一个好主意。

Percona XtraBackup

实施所谓的热备份是物理备份的逻辑下一步,也就是在数据库运行时复制数据库文件。我们已经提到 MyISAM 表可以复制,但对于 InnoDB 和其他像 MyRocks 这样的事务性存储引擎则不适用。问题在于你不能仅仅复制文件,因为数据库正在不断变化。例如,即使现在没有写操作命中数据库,InnoDB 可能正在后台刷新一些脏页。你可以试试在运行中的系统下复制数据库目录,然后尝试恢复该目录并启动一个 MySQL 服务器。成功的可能性很小。即使有时候会成功,我们强烈建议不要冒数据库备份的风险。

能够执行热备份的能力内置于三种主要的 MySQL 备份工具中:Percona XtraBackupMySQL Enterprise Backupmariabackup。我们将简要讨论它们,但主要集中在 XtraBackup 实用程序上。重要的是要理解,所有工具都共享特性,因此掌握其中一个工具的使用方法将有助于您使用其他工具。

Percona XtraBackup 是由 Percona 和更广泛的 MySQL 社区维护的免费开源工具。它能够对带有 InnoDB、MyISAM 和 MyRocks 表的 MySQL 实例进行在线备份。该程序仅在 Linux 上可用。请注意,最新版本的 MariaDB 无法使用 XtraBackup:只支持 MySQL 和 Percona Server。对于 MariaDB,请使用我们在“mariabackup”中介绍的实用程序。

这是 XtraBackup 操作的概述:

  1. 记录当前日志序列号(LSN),这是操作的内部版本号

  2. 开始累积 InnoDB 的重做数据(InnoDB 为崩溃恢复存储的数据类型)

  3. 以尽可能少干扰的方式锁定表

  4. 复制 InnoDB 表

  5. 完全锁定非事务性表

  6. 复制 MyISAM 表

  7. 解锁所有表

  8. 处理 MyRocks 表(如果存在)

  9. 将累积的重做数据放置在复制的数据库文件旁边

XtraBackup 及热备份的主要理念是将逻辑备份的无停机特性与冷备份的性能和相对缺乏性能影响结合起来。XtraBackup 不能保证无服务中断,但与常规冷备份相比,它是一大进步。缺乏性能影响意味着 XtraBackup 只会使用必要的 CPU 和 I/O 来复制数据库文件。另一方面,逻辑备份必须通过数据库的所有内部处理每一行数据,因此本质上速度较慢。

注意

XtraBackup 需要对数据库文件进行物理访问,无法远程运行。这使其不适合于管理数据库(DBaaS)的异地备份。然而,一些云供应商允许您使用此工具生成的备份导入数据库。

XtraBackup 实用程序广泛地出现在各种 Linux 发行版的仓库中,因此可以通过包管理器轻松安装。或者,您也可以直接从Percona 官网的 XtraBackup 下载页面下载软件包和二进制发行版。

警告

要备份 MySQL 8.0,必须使用 XtraBackup 8.0。理想情况下,XtraBackup 和 MySQL 的次要版本也应匹配:XtraBackup 8.0.25 保证与 MySQL 8.0.25 兼容。对于 MySQL 5.7 及更早的版本,请使用 XtraBackup 2.4。

备份和恢复

与我们之前提到的其他工具不同,由于 XtraBackup 是一种物理备份工具,它不仅需要访问 MySQL 服务器,还需要读取数据库文件的权限。在大多数 MySQL 安装中,这通常意味着 xtrabackup 程序应由 root 用户运行,或者必须使用 sudo。在本节中,我们将使用 root 用户,并使用 “Login Path Configuration File” 中的步骤设置登录路径。

首先,我们需要运行基本的 xtrabackup 命令:

# xtrabackup --host=127.0.0.1 --target-dir=/tmp/backup --backup
...
Using server version 8.0.25
210613 22:23:06 Executing LOCK INSTANCE FOR BACKUP...
...
210613 22:23:07 [01] Copying ./sakila/film.ibd
    to /tmp/backup/sakila/film.ibd
210613 22:23:07 [01]        ...done
...
210613 22:23:10 [00] Writing /tmp/backup/xtrabackup_info
210613 22:23:10 [00]        ...done
xtrabackup: Transaction log of lsn (6438976119)
    to (6438976129) was copied.
210613 22:23:11 completed OK!

如果登录路径不起作用,你可以通过命令行参数 --user--passwordxtrabackup 传递 root 用户的凭据。通常,XtraBackup 可以通过读取默认选项文件来识别目标服务器的数据目录,但如果这不起作用或者你有多个 MySQL 安装,可能还需要指定 --datadir 选项。尽管 xtrabackup 只在本地工作,但仍需要连接到本地运行的 MySQL 实例,因此需要 --host--port--socket 参数。根据你的特定设置,可能需要指定其中一些参数。

提示

尽管我们在示例中使用 /tmp/backup 作为备份的目标路径,你应避免在 /tmp 下存储重要文件。这对于备份尤其重要。

那个 xtrabackup --backup 调用的结果是一堆数据库文件,实际上不一致到任何时间点,并且一部分重做数据是 InnoDB 无法应用的:

# ls -l /tmp/backup/
...
drwxr-x---.  2 root root       160 Jun 13 22:23 mysql
-rw-r-----.  1 root root  46137344 Jun 13 22:23 mysql.ibd
drwxr-x---.  2 root root        60 Jun 13 22:23 nasa
drwxr-x---.  2 root root       580 Jun 13 22:23 sakila
drwxr-x---.  2 root root       580 Jun 13 22:23 sakila_mod
drwxr-x---.  2 root root        80 Jun 13 22:23 sakila_new
drwxr-x---.  2 root root        60 Jun 13 22:23 sys
...

为了使备份准备好进行将来的恢复,还必须执行另一个阶段——准备阶段。这时不需要连接到 MySQL 服务器:

# xtrabackup --target-dir=/tmp/backup --prepare
...
xtrabackup: cd to /tmp/backup/
xtrabackup: This target seems to be not prepared yet.
...
Shutdown completed; log sequence number 6438976524
210613 22:32:23 completed OK!

最终的数据目录实际上已经完全可以使用了。你可以启动一个直接指向这个目录的 MySQL 实例,它将正常工作。这里一个非常常见的错误是尝试在 mysql 用户下启动 MySQL 服务器,而恢复和准备好的备份却由 root 或其他操作系统用户所有。确保在你的备份恢复过程中根据需要加入 chownchmod。但是,xtrabackup 提供了一个有用的用户体验功能 --copy-backxtrabackup 会保留原始数据库文件的布局位置,并在使用 --copy-back 时将所有文件恢复到其原始位置:

# xtrabackup --target-dir=/tmp/backup --copy-back
...
Original data directory /var/lib/mysql is not empty!

这不起作用,因为我们的原始 MySQL 服务器仍在运行,其数据目录不为空。XtraBackup 将拒绝恢复备份,除非目标数据目录为空。这应该能防止意外恢复备份。让我们关闭正在运行的 MySQL 服务器,删除或移动其数据目录,并恢复备份:

# systemctl stop mysqld
# mv /var/lib/mysql /var/lib/mysql_old
# xtrabackup --target-dir=/tmp/backup --copy-back
...
210613 22:39:01 [01] Copying ./sakila/actor.ibd
    to /var/lib/mysql/sakila/actor.ibd
210613 22:39:01 [01]        ...done
...
210613 22:39:01 completed OK!

之后,文件位于正确的位置,但所有者是 root

# ls -l /var/lib/mysql/
drwxr-x---. 2 root root      4096 Jun 13 22:39 sakila
drwxr-x---. 2 root root      4096 Jun 13 22:38 sakila_mod
drwxr-x---. 2 root root      4096 Jun 13 22:39 sakila_new

你需要将文件的所有者更改回 mysql(或系统中使用的用户),并修复目录权限。完成后,你可以启动 MySQL 并验证数据:

# chown -R mysql:mysql /var/lib/mysql/
# chmod o+rx /var/lib/mysql/
# systemctl start mysqld
# mysql sakila -e "SHOW TABLES;"
+----------------------------+
| Tables_in_sakila           |
+----------------------------+
| actor                      |
...
| store                      |
+----------------------------+
提示

最佳实践是在备份阶段同时执行备份和准备工作,从而最大程度地减少以后可能出现的意外情况。想象一下,在尝试恢复某些数据时,准备阶段失败!然而,请注意,我们稍后介绍的增量备份有特殊的处理程序,与此建议相矛盾。

高级功能

本节中,我们将讨论一些 XtraBackup 更高级的功能。使用这些功能并非使用该工具的必需,我们仅提供简要概述:

数据库文件验证

在执行备份时,XtraBackup 将验证正在处理的数据文件的所有页面的校验和。这是为了缓解物理备份的固有问题,即它们将包含源数据库中的任何损坏。我们建议在此检查中使用“测试和验证您的备份”中列出的其他步骤。

压缩

尽管复制物理文件比查询数据库要快得多,但备份过程可能会受到磁盘性能的限制。您无法减少读取的数据量,但可以利用压缩使备份本身变小,减少需要写入的数据量。这在备份目标是网络位置时尤为重要。此外,您将只使用更少的空间来存储备份。请注意,正如我们在“mysqldump 程序”中展示的那样,在 CPU 繁忙的系统上,压缩实际上可能会增加创建备份所需的时间。XtraBackup 使用qpress工具进行压缩。该工具包含在percona-release软件包中:

# xtrabackup --host=127.0.0.1 \
--target-dir=/tmp/backup_compressed/ \
--backup --compress

并行性

可以通过使用--parallel命令行参数并行运行备份和复制回归过程。

加密

除了能够处理加密数据库外,XtraBackup 还可以创建加密备份。

流式传输

XtraBackup 可以将生成的备份以xbstream格式流式传输,而不是创建一个充满备份文件的目录。这将产生更具可移植性的备份,并允许与xbcloud集成。例如,您可以通过 SSH 流式传输备份。

云上传

使用 XtraBackup 进行的备份可以通过xbcloud上传到任何兼容 S3 的存储设施。S3 是亚马逊的对象存储设施,是被许多公司广泛采用的 API。这个工具仅适用于通过xbstream流式传输的备份。

增量备份与 XtraBackup

如前所述,热备份是数据库中每个信息字节的副本。这是 XtraBackup 默认的工作方式。但在很多情况下,数据库的变化率是不规则的——新数据经常添加,而旧数据则几乎没有变化。例如,新的财务记录可能每天都在添加,账户会被修改,但在给定的一周内,只有少部分账户会发生变化。因此,改进热备份的下一个逻辑步骤是增加执行所谓的增量备份的能力,即仅备份已更改的数据。这将允许您通过减少空间需求来更频繁地执行备份。

要使增量备份正常工作,您首先需要对数据库进行完整备份,称为基础备份——否则无法进行增量备份。一旦基础备份准备就绪,您可以执行任意数量的增量备份,每个备份包含自上一个备份以来的更改(或在第一个增量备份的情况下,自基础备份以来的更改)。将其推到极致,您可以每分钟创建一个增量备份,实现所谓的时间点恢复(PITR),但这并不是非常实际的做法,很快您将会了解到有更好的方法来做到这一点。

这里是你可以使用的 XtraBackup 命令的示例,用于创建基础备份,然后创建增量备份。注意增量备份通过 --incremental-basedir 参数指向基础备份:

# xtrabackup --host=127.0.0.1 \
--target-dir=/tmp/base_backup --backup
# xtrabackup --host=127.0.0.1 --backup \
--incremental-basedir=/tmp/base_backup \
--target-dir=/tmp/inc_backup1

如果您检查备份大小,您会发现与基础备份相比,增量备份非常小:

# du -sh /tmp/base_backup
2.2G    /tmp/base_backup
6.0M    /tmp/inc_backup1

让我们创建另一个增量备份。在这种情况下,我们将前一个增量备份的目录作为基本目录传递:

# xtrabackup --host=127.0.0.1 --backup \
--incremental-basedir=/tmp/inc_backup1 \
--target-dir=/tmp/inc_backup2
210613 23:32:20 completed OK!

也许你在想是否可以将原始基础备份的目录指定为每个新增量备份的 --incremental-basedir。事实上,这样会产生一个完全有效的备份,这是增量备份的一种变体(或者反过来)。这种包含不仅自上一个增量备份以来的更改,而且自基础备份以来的增量备份通常被称为累积备份。针对任何先前备份的增量备份称为差异备份。累积增量备份通常会占用更多空间,但可以显著缩短在恢复备份时所需的准备时间。

重要的是,增量备份的准备过程与常规备份的准备过程不同。让我们准备刚刚进行的备份,从基础备份开始:

# xtrabackup --prepare --apply-log-only \
--target-dir=/tmp/base_backup

--apply-log-only 参数告诉 xtrabackup 不要完成准备过程,因为我们仍然需要应用增量备份的更改。让我们来做这个:

# xtrabackup --prepare --apply-log-only \
--target-dir=/tmp/base_backup \
--incremental-dir=/tmp/inc_backup1
# xtrabackup --prepare --apply-log-only \
--target-dir=/tmp/base_backup \
--incremental-dir=/tmp/inc_backup2

所有命令执行完毕应报告completed OK!。一旦运行--prepare --apply-log-only操作,基础备份将会推进到增量备份的点,这将使得将 PITR 恢复到较早时间变得不可能。因此,在执行增量备份时立即准备并不是一个好主意。要完成准备过程,必须正常准备基础备份,其中包括从增量备份应用的更改:

# xtrabackup --prepare --target-dir=/tmp/base_backup

一旦基础备份“完全”准备好,尝试应用增量备份将失败,并显示以下消息:

xtrabackup: This target seems to be already prepared.
xtrabackup: error: applying incremental backup needs
    target prepared with --apply-log-only.

当数据库中的变更量相对较高时,增量备份效率低下。在最坏的情况下,即数据库中的每一行在完整备份和增量备份之间都发生了变化时,后者实际上只是一个完整备份,存储了 100%的数据。增量备份在大部分数据追加且旧数据变更量相对较低时效率最高。对此没有规则,但如果在基础备份和增量备份之间的数据变更量为 50%,请考虑不使用增量备份。

其他物理备份工具

XtraBackup 并不是唯一能够执行热 MySQL 物理备份的工具。我们选择使用这个特定工具来解释概念是基于我们的经验。然而,这并不意味着其他工具在任何方面都更差。它们可能更适合您的需求。然而,我们的空间有限,备份主题非常广泛。我们可以撰写一本备份 MySQL的相当大的书!

话虽如此,为了让您了解一些其他选项,让我们来看看另外两种现成的物理备份工具。

MySQL 企业备份

MEB(MySQL Enterprise Backup)被简称为 MEB,这是 Oracle 的 MySQL 企业版的一部分。它是一个闭源专有工具,功能类似于 XtraBackup。您可以在MYSQL 网站上找到详细的文档。目前这两个工具的功能基本相同,所以几乎所有适用于 XtraBackup 的内容同样适用于 MEB。

MEB 的突出特点是它真正是一个跨平台的解决方案。XtraBackup 仅适用于 Linux,而 MEB 还适用于 Solaris、Windows、macOS 和 FreeBSD。MEB 不支持除了 Oracle 标准版之外的 MySQL 版本。

MEB 具有的一些额外功能在 XtraBackup 中不可用,包括以下内容:

  • 备份进度报告

  • 离线备份

  • 通过 Oracle Secure Backups 进行磁带备份

  • 二进制和中继日志备份

  • 恢复时的表重命名

mariabackup

mariabackup是 MariaDB 用于备份 MySQL 数据库的工具。最初从 XtraBackup 分支而来,这是一个在 Linux 和 Windows 上都可用的免费开源工具。mariabackup的显著特性是其与 MariaDB 分支 MySQL 的无缝协作,后者在使用方式和属性上继续与主流 MySQL 和 Percona Server 显著分歧。由于这是 XtraBackup 的直接分支,你会发现这些工具在使用方式和性能上有很多相似之处。一些 XtraBackup 的新功能,如备份加密和次要索引省略,在mariabackup中并不存在。然而,目前使用 XtraBackup 来备份 MariaDB 是不可能的。

时点恢复

现在你已经熟悉了热备份的概念,你几乎拥有完成备份工具包所需的一切。到目前为止,我们讨论的所有备份类型都有一个共同点——缺陷。它们只允许在拍摄时恢复。如果你有两个备份,一个是周一 23:00 拍摄的,另一个是周二 23:00 拍摄的,你就无法恢复到周二下午 5:00。

记得在本章开始时提到的基础设施故障示例吗?现在,让我们把情况恶化,假设数据丢失了,所有驱动器都失效了,而且没有复制。事件发生在周三晚上 21:00。没有 PITR,并且每天在 23:00 进行备份,这意味着你已经永远丢失了整整一天的数据。可以说,使用 XtraBackup 进行增量备份可以在一定程度上减少这个问题,但它们仍然存在一定的数据丢失空间,而且很少有机会经常运行它们。

MySQL 维护一个称为二进制日志的事务日志。通过将我们迄今讨论的任何备份方法与二进制日志结合起来,我们可以恢复到任意时间点的事务。非常重要的是要理解,为了使此功能正常工作,你需要同时具备备份和二进制日志。此外,你不能回溯时间,因此无法恢复数据到最老的基础备份或转储创建之前的时间点。

二进制日志包含事务时间戳和它们的标识符。你可以依靠其中任何一个进行恢复,并且可以告诉 MySQL 恢复到某个时间戳。当你想恢复到最新时间点时,这不是问题,但在尝试执行修复逻辑不一致性时(比如在“部署错误”中描述的情况),这可能非常重要和有帮助。然而,在大多数情况下,你需要识别一个特定的问题事务,我们将向你展示如何做到这一点。

MySQL 的一个有趣特点是,它允许逻辑备份进行 PITR。“从 SQL 转储文件加载数据” 讨论了使用 mysqldump 存储副本提供的 binlog 位置。相同的 binlog 位置可以用作 PITR 的起点。MySQL 中的每种备份类型都适用于 PITR,与其他数据库不同。为了促进这一特性,请确保在进行备份时注意 binlog 位置。一些备份工具会自动处理这个问题。如果使用的工具没有这样做,您可以运行 SHOW MASTER STATUS 来获取这些数据。

二进制日志的技术背景

MySQL 与许多其他主流关系型数据库不同,它支持多个存储引擎,如 “替代存储引擎” 中所讨论的。不仅如此,它还支持单个数据库内的表使用多个存储引擎。因此,MySQL 中的某些概念与其他系统中的不同。

MySQL 中的二进制日志本质上是事务日志。启用二进制日志后,每个事务(不包括只读事务)都将反映在二进制日志中。有三种方法可以将事务写入二进制日志:

语句

在这种模式下,语句按其编写方式记录到二进制日志中,这可能在复制场景中导致非确定性执行。

在这种模式下,语句被拆分为最小的 DML 操作,每个操作修改一个特定的行。虽然它保证了确定性执行,但这种模式最为冗长,导致的文件最大,因此产生了最大的 I/O 开销。

混合

在这种模式下,“安全”语句按原样记录,而其他语句则被拆分。

通常,在数据库管理系统中,事务日志用于崩溃恢复、复制和 PITR。但是,由于 MySQL 支持多个存储引擎,其二进制日志不能用于崩溃恢复。相反,每个引擎都维护其自己的崩溃恢复机制。例如,MyISAM 不是崩溃安全的,而 InnoDB 则有其自己的重做日志。MySQL 中的每个事务都是分布式事务,具有两阶段提交,以适应这种多引擎的特性。如果引擎是事务性的,每个提交的事务都保证会反映在存储引擎的重做日志中,以及 MySQL 自己的事务日志(即二进制日志)中。

注意

要实现 PITR,必须在 MySQL 实例中启用二进制日志。您还应该默认将 sync_binlog=1,以确保每次写操作的持久性。请参考MySQL 文档 以了解禁用 binlog 同步的权衡考虑。

我们将在 第十三章 中更详细地讨论二进制日志的工作原理。

保留二进制日志

要允许 PITR,必须保留从最早备份的 binlog 位置开始的二进制日志。有几种方法可以做到这一点:

  • 使用像rsync这样的现成工具“手动”复制或同步二进制日志文件。请记住,MySQL 将继续写入当前的二进制日志文件。如果您复制文件而不是持续同步它们,则不要复制当前的二进制日志文件。通过持续同步文件来解决这个问题,一旦它变为非当前文件,就会覆盖部分文件。

  • 使用mysqlbinlog复制单个文件或连续流式传输 binlog。有关说明,请参阅文档

  • 使用 MySQL Enterprise Backup,它具有内置的 binlog 复制功能。请注意,这不是连续复制,而是依赖增量备份来进行 binlog 复制。这允许在两个备份之间进行 PITR。

  • 允许 MySQL 服务器将所有必需的二进制日志存储在其数据目录中,通过为binlog_expire_logs_secondsexpire_logs_days变量设置一个较高的值。理想情况下,不应仅使用此选项,但可以与其他任何选项一起使用。如果发生数据目录的任何事故,例如文件系统损坏,存储在那里的二进制日志也可能会丢失。

确定 PITR 目标

您可以使用 PITR 技术实现两个目标:

  1. 恢复到最新时间点。

  2. 恢复到任意时间点。

如前所述,第一个用于将完全丢失的数据库恢复到最新可用状态。第二个用于获取数据之前的状态。一个案例示例是在“部署错误”中提到的情况。要恢复丢失或错误修改的数据,您可以恢复备份,然后将其恢复到执行部署之前的时间点。

确定问题发生的实际特定时间可能是一项挑战。通常情况下,您找到所需时间点的唯一方法是检查问题发生周围写入的二进制日志。例如,如果您怀疑表被删除,您可以查找表名称,然后查找在该表上执行的任何 DDL 语句,或者专门查找DROP TABLE语句。

让我们举个例子来说明。首先,我们需要实际删除一个表,因此我们将删除我们在“从逗号分隔文件加载数据”中创建的facilities表。但在此之前,我们将插入一条在原始备份中肯定缺失的记录:

mysql> `INSERT` `INTO` `facilities``(``center``)`
    -> `VALUES` `(``'this row was not here before'``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `DROP` `TABLE` `nasa``.``facilities``;`
Query OK, 0 rows affected (0.02 sec)

现在我们可以返回并恢复我们在本章中拍摄的备份之一,但那样我们会丢失在该点和DROP之间对数据库所做的任何更改。相反,我们将使用mysqlbinlog检查二进制日志的内容,并找到在运行DROP语句之前的恢复目标。要查找数据目录中可用的二进制日志列表,可以运行以下命令:

mysql> `SHOW` `BINARY` `LOGS``;`
+---------------+-----------+-----------+
| Log_name      | File_size | Encrypted |
+---------------+-----------+-----------+
| binlog.000291 |       156 | No        |
| binlog.000292 |       711 | No        |
+---------------+-----------+-----------+
2 rows in set (0.00 sec)
警告

MySQL 不会永远将二进制日志保留在其数据目录中。它们在超过binlog_expire_logs_secondsexpire_log_days指定的持续时间后会自动删除,也可以通过运行PURGE BINARY LOGS手动删除。如果要确保二进制日志可用,应按照前一节所述将其保留在数据目录之外。

现在二进制日志列表可用,您可以尝试从最新的日志到最旧的日志进行搜索,或者只需将它们的全部内容一起转储。在我们的示例中,文件很小,因此我们可以使用后一种方法。无论哪种方法,都要使用mysqlbinlog命令:

# cd /var/lib/mysql
# mysqlbinlog binlog.000291 binlog.000292 \
-vvv --base64-output='decode-rows' > /tmp/mybinlog.sql

检查输出文件,我们可以找到有问题的语句:

...
#210613 23:32:19 server id 1  end_log_pos 200 ... Rotate to binlog.000291
...
# at 499
#210614  0:46:08 server id 1  end_log_pos 576 ...
# original_commit_timestamp=1623620769019544 (2021-06-14 00:46:09.019544 MSK)
# immediate_commit_timestamp=1623620769019544 (2021-06-14 00:46:09.019544 MSK)
/*!80001 SET @@session.original_commit_timestamp=1623620769019544*//*!*/;
/*!80014 SET @@session.original_server_version=80025*//*!*/;
/*!80014 SET @@session.immediate_server_version=80025*//*!*/;
SET @@SESSION.GTID_NEXT= 'ANONYMOUS'/*!*/;
# at 576
#210614  0:46:08 server id 1  end_log_pos 711 ... Xid = 25
use `nasa`/*!*/;
SET TIMESTAMP=1623620768/*!*/;
DROP TABLE `facilities` /* generated by server */
/*!*/;
SET @@SESSION.GTID_NEXT= 'AUTOMATIC' /* added by mysqlbinlog */ /*!*/;
DELIMITER ;
...

我们应该在 2021-06-14 00:46:08 之前停止我们的恢复,或者在二进制日志位置 499。我们还需要从最新备份开始,包括binlog.00291之前的所有二进制日志。利用这些信息,我们可以继续进行备份恢复和恢复操作。

时点恢复示例:XtraBackup

单独使用 XtraBackup 不提供 PITR 功能。您需要添加额外的步骤来运行mysqlbinlog以重放恢复后数据库上的二进制日志内容:

  1. 恢复备份。详细步骤请参见“备份和恢复”。

  2. 启动 MySQL 服务器。如果您直接在源实例上恢复,则建议使用--skip-networking选项防止非本地客户端访问数据库。否则,某些客户端可能会在您完成恢复之前更改数据库。

  3. 查找备份的二进制日志位置。它在备份目录中的xtrabackup_binlog_info文件中可用:

    # cat /tmp/base_backup/xtrabackup_binlog_info
    
    binlog.000291   156
    
  4. 找到要恢复到的时间戳或二进制日志位置(例如,在执行DROP TABLE之前,如前所述)。

  5. 回放二进制日志至所需点。例如,我们单独保存了二进制日志binlog.000291,但您应使用中心化的二进制日志存储作为二进制日志的源。您可以使用mysqlbinlog命令来执行此操作:

    # mysqlbinlog /opt/mysql/binlog.000291 \
    /opt/mysql/binlog.000292 --start-position=156 \
    --stop-datetime="2021-06-14 00:46:00" | mysql
    
  6. 确保恢复成功且没有数据丢失。在我们的情况下,我们将查找在删除facilities表之前添加的记录:

    mysql> `SELECT` `center` `FROM` `facilities`
        -> `WHERE` `center` `LIKE` `'%before%'``;`
    
    +------------------------------+
    | center                       |
    +------------------------------+
    | this row was not here before |
    +------------------------------+
    1 row in set (0.00 sec)
    

时点恢复示例:mysqldump

使用mysqldump进行 PITR 所需的步骤类似于之前使用 XtraBackup 的步骤。我们只是为了完整性和让您看到,在 MySQL 中每种备份类型中 PITR 是类似的。以下是该过程:

  1. 恢复 SQL 转储。同样,如果您的恢复目标服务器是备份源,则可能希望将其对客户端不可访问。

  2. mysqldump备份文件中找到二进制日志位置:

    CHANGE MASTER TO MASTER_LOG_FILE='binlog.000010',
    MASTER_LOG_POS=191098797;
    
  3. 找到要恢复到的时间戳或二进制日志位置(例如,在执行DROP TABLE之前,如前所述)。

  4. 回放二进制日志至所需点:

    # mysqlbinlog /path/to/datadir/mysql-bin.000010 \
    /path/to/datadir/mysql-bin.000011 \
    --start-position=191098797 \
    --stop-datetime="20-05-25 13:00:00" | mysql
    

导出和导入 InnoDB 表空间

物理备份的一个主要缺点是通常需要同时复制数据库文件的大部分内容。虽然像 MyISAM 这样的存储引擎允许复制空闲表的数据文件,但不能保证 InnoDB 文件的一致性。不过,有些情况下,您只需要转移几个表,或者只需要一个表。到目前为止,我们看到的唯一选项是利用可能速度不可接受的逻辑备份。InnoDB 的表空间导出和导入功能,官方称为 可传输表空间,是同时获取两者优势的一种方式。我们也称此功能为 导出/导入 来简洁表述。

可传输表空间功能允许您结合在线物理备份的性能和逻辑备份的粒度。本质上,它提供了将 InnoDB 表的数据文件在线复制用于导入到相同或不同表的能力。这样的复制可以用作备份,也可以用作在不同 MySQL 安装之间传输数据的方式。

当逻辑转储可以达到相同目的时,为什么要使用导出/导入?导出/导入速度更快,并且除了锁定表之外,不会显著影响服务器。这在导入时尤其如此。对于大小在多千兆字节的表格,这是数据传输的少数可行选项之一。

技术背景

为了帮助您理解此功能如何工作,我们将简要回顾两个概念:物理备份和表空间。

正如我们所见,为了使物理备份一致,通常可以采取两种方法。第一种是关闭实例或以有保证的方式使数据只读。第二种是使数据文件一致到某一时刻,然后累积从那时刻到备份结束的所有更改。可传输表空间功能采用第一种方法,需要将表设置为短时间只读状态。

表空间是存储表数据及其索引的文件。默认情况下,InnoDB 使用 innodb_file_per_table 选项,强制为每个表创建专用的表空间文件。可以创建包含多个表数据的表空间,并且可以使用“旧”行为,即所有表驻留在单个 ibdata 表空间中。但只有在默认配置下,即为每个表创建专用表空间时才支持导出。分区表中的每个分区都有单独的表空间存在,这允许在不同表之间转移分区或从分区创建表的有趣能力。

导出表空间

现在这些概念已经涵盖,您知道导出需要做什么。但是,仍然缺少一个事项,那就是表定义。尽管大多数 InnoDB 表空间文件实际上包含其表的数据字典记录的冗余副本,但当前的可传输表空间实现要求在导入之前目标上存在表。

导出表空间的步骤如下:

  1. 获取表定义。

  2. 停止对表(或表)的所有写入,并使其一致。

  3. 准备稍后导入表空间所需的额外文件:

    • .cfg文件存储用于模式验证的元数据。

    • 只有在使用加密时才会生成.cfp文件,并包含目标服务器解密表空间所需的过渡密钥。

要获取表定义,您可以使用我们在本书中多次展示的SHOW CREATE TABLE命令。MySQL 通过单个命令FLUSH TABLE ... FOR EXPORT自动执行所有其他步骤。该命令锁定表并在目标表的常规.ibd文件附近生成所需的附加文件(如果使用加密,则可能是多个文件)。让我们从sakila数据库导出actor表:

mysql> `USE` `sakila`
mysql> `FLUSH` `TABLE` `actor` `FOR` `EXPORT``;`
Query OK, 0 rows affected (0.00 sec)

执行FLUSH TABLE的会话应保持打开状态,因为一旦会话终止,actor表将被释放。在 MySQL 数据目录中,常规的actor.ibd文件附近应出现一个新文件,即actor.cfg。让我们验证一下:

# ls -1 /var/lib/mysql/sakila/actor.
/var/lib/mysql/sakila/actor.cfg
/var/lib/mysql/sakila/actor.ibd

现在可以将这对.ibd.cfg文件复制到其他地方并稍后使用。复制文件后,通常建议通过运行UNLOCK TABLES语句释放表上的锁定,或关闭调用了FLUSH TABLE的会话。完成所有操作后,您就有了一个准备好导入的表空间。

注意

分区表具有多个.ibd文件,每个文件都有专用的.cfg文件。例如:

  • learning_mysql_partitioned#p#p0.cfg

  • learning_mysql_partitioned#p#p0.ibd

  • learning_mysql_partitioned#p#p1.cfg

  • learning_mysql_partitioned#p#p1.ibd

导入表空间

导入表空间非常简单,包括以下步骤:

  1. 使用保留的定义创建表。无法以任何方式更改表的定义。

  2. 丢弃表的表空间。

  3. 复制.ibd.cfg文件。

  4. 修改表以导入表空间。

如果目标服务器上存在具有相同定义的表,则无需执行步骤 1。

让我们在同一服务器的另一个数据库中恢复actor表。表必须存在,因此我们将创建它:

mysql> `USE` `nasa`
mysql> `CREATE` `TABLE` `` `actor` `` `(`
    ->  `` `actor_id` `` `smallint` `unsigned` `NOT` `NULL` `AUTO_INCREMENT``,`
    ->  `` `first_name` `` `varchar``(``45``)` `NOT` `NULL``,`
    ->  `` `last_name` `` `varchar``(``45``)` `NOT` `NULL``,`
    ->  `` `last_update` `` `timestamp` `NOT` `NULL` `DEFAULT` `CURRENT_TIMESTAMP`
    ->    `ON` `UPDATE` `CURRENT_TIMESTAMP``,`
    ->  `PRIMARY` `KEY` `(``` `actor_id` ```)``,`
    ->  `KEY` `` `idx_actor_last_name` `` `(``` `last_name` ```)`
    -> `)` `ENGINE``=``InnoDB` `AUTO_INCREMENT``=``201` `DEFAULT`
    ->    `CHARSET``=``utf8mb4` `COLLATE``=``utf8mb4_0900_ai_ci``;`
Query OK, 0 rows affected (0.04 sec)

一旦创建actor表,MySQL 就会为其创建一个.ibd文件:

# ls /var/lib/mysql/nasa/
actor.ibd  facilities.ibd

这带我们进入下一步:丢弃此新表的表空间。通过运行特殊的ALTER TABLE完成:

mysql> `ALTER` `TABLE` `actor` `DISCARD` `TABLESPACE``;`
Query OK, 0 rows affected (0.02 sec)

现在.ibd文件将消失:

# ls /var/lib/mysql/nasa/
facilities.ibd
警告

丢弃表空间会导致关联表空间文件的完全删除,并且是不可恢复的操作。如果你意外运行了ALTER TABLE ... DISCARD TABLESPACE,你将需要从备份中恢复。

现在我们可以复制原始actor表的导出表空间以及.cfg文件:

# cp -vip /opt/mysql/actor.* /var/lib/mysql/nasa/
'/opt/mysql/actor.cfg' -> '/var/lib/mysql/nasa/actor.cfg'
'/opt/mysql/actor.ibd' -> '/var/lib/mysql/nasa/actor.ibd'

所有步骤完成后,现在可以导入表空间并验证数据:

mysql> `ALTER` `TABLE` `actor` `IMPORT` `TABLESPACE``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `SELECT` `*` `FROM` `nasa``.``actor` `LIMIT` `5``;`
+----------+------------+--------------+---------------------+
| actor_id | first_name | last_name    | last_update         |
+----------+------------+--------------+---------------------+
|        1 | PENELOPE   | GUINESS      | 2006-02-15 04:34:33 |
|        2 | NICK       | WAHLBERG     | 2006-02-15 04:34:33 |
|        3 | ED         | CHASE        | 2006-02-15 04:34:33 |
|        4 | JENNIFER   | DAVIS        | 2006-02-15 04:34:33 |
|        5 | JOHNNY     | LOLLOBRIGIDA | 2006-02-15 04:34:33 |
+----------+------------+--------------+---------------------+
5 rows in set (0.00 sec)

你可以看到我们已经从sakila.actor导入到nasa.actor中的数据。

可能最好的一点是可传输表空间的效率。你可以使用这个功能轻松地在数据库之间移动非常大的表。

XtraBackup 单表恢复

或许令人惊讶的是,我们将再次在可传输表空间的背景下提到 XtraBackup。这是因为 XtraBackup 允许从任何现有备份中导出表。事实上,这是恢复单个表的最方便方法,也是单表或部分数据库 PITR 的第一个构建块。

这是最先进的备份和恢复技术之一,完全基于可传输表空间功能。它还继承了所有的限制:例如,它不能在非“文件-每-表”表空间上工作。我们在这里不会给出确切的步骤,只是介绍这个技术让你知道它是可能的。

要执行单表恢复,你应该首先使用xtrabackup运行--export命令行参数准备表进行导出。你可能注意到在这个命令中没有指定表的名称,实际上每个表都会被导出。让我们在之前拍摄的一个备份上运行这个命令:

# xtrabackup --prepare --export --target-dir=/tmp/base_backup
# ls -1 /tmp/base_backup/sakila/
actor.cfg
actor.ibd
address.cfg
address.ibd
category.cfg
category.ibd
...

你可以看到我们为每个表都有一个.cfg文件:现在每个表空间都准备好在另一个数据库中导出和导入了。从这里开始,你可以重复上一节中的步骤来恢复其中一个表的数据。

单表或部分数据库 PITR 是棘手的,在大多数数据库管理系统中都是如此。正如你在“时间点恢复”中看到的那样,MySQL 中的 PITR 基于二进制日志。对于部分恢复,这意味着所有数据库中所有表的事务都被记录,但在应用时可以通过复制筛选二进制日志。因此,部分恢复的过程是这样的:你导出所需的表,建立一个完全独立的实例,并通过复制通道用二进制日志供其使用。

你可以在社区博客和文章如“MySQL 单表 PITR”“使用 MySQL 过滤二进制日志”,以及“如何加速 MySQL PITR”中找到更多信息。

当在正确的情况下使用并且符合特定条件时,导出/导入功能是一种强大的技术。

测试和验证你的备份

只有在您确信可以信任它们时,备份才是好的。有很多人在最需要时备份系统却失败的例子。频繁备份并不一定能保证数据安全。

备份可能无用或失败的多种方式:

不一致的备份

这种情况的最简单示例是,在数据库运行时,错误地从多个卷中进行快照备份。结果的备份可能损坏或缺少数据。不幸的是,您进行的一些备份可能是一致的,而另一些可能不会出现足够的错误或不一致,直到为时已晚。

源数据库的损坏

如我们详细讨论的那样,物理备份将具有所有数据库页面的副本,无论是否损坏。有些工具尝试在备份过程中验证数据,但这并不完全没有错误。成功的备份可能包含无法后续读取的坏数据。

备份数据损坏

备份本身只是数据,因此容易受到与原始数据相同的问题的影响。如果备份数据在存储过程中损坏,那么即使您的备份成功,最终也可能变得毫无用处。

Bugs

事情总会发生。您使用了十几年的备份工具可能存在错误,而您可能会发现这个错误。在最好的情况下,您的备份将失败;在最坏的情况下,它可能无法恢复。

运行错误

我们都是人类,会犯错。如果一切都自动化了,这里的风险将从人为错误变为错误。

这不是您可能面临的所有问题的全面列表,但它为您提供了一些洞察力,了解即使您的备份策略是正确的,您可能会遇到的问题。让我们回顾一些您可以采取的步骤,以帮助您更好地入眠:

  • 在实施备份系统时,彻底测试它,并以各种模式进行测试。确保您可以备份系统,并使用备份进行恢复。测试负载和无负载的情况。当没有连接修改数据时,您的备份可以保持一致,而在有连接修改数据时则可能失败。

  • 使用物理和逻辑备份。它们具有不同的特性和故障模式,尤其是在源数据损坏时。

  • 备份您的备份,或者确保它们至少与数据库一样耐久。

  • 定期进行备份恢复测试。

最后一点尤其有趣。在将备份恢复并进行测试之前,不能认为任何备份是安全的。这意味着在理想情况下,您的自动化实际上会尝试使用备份构建数据库服务器,并在成功时报告成功。此外,新数据库可以附加到源作为副本,并且可以使用像Percona Toolkitpt-table-checksum这样的数据验证工具来检查数据一致性。

以下是物理备份数据验证的一些可能步骤:

  1. 准备备份。

  2. 恢复备份。

  3. 在所有.ibd文件上运行innochecksum

    以下命令将在 Linux 上并行运行四个innochecksum进程:

    $ find . -type f -name "*.ibd" -print0 |\
    xargs -t -0r -n1 --max-procs=4 innochecksum
    
  4. 使用恢复的备份启动新的 MySQL 实例。可以使用备用服务器或专用.cnf文件,不要忘记使用非默认端口和路径。

  5. 使用mysqldump或任何其他工具来导出所有数据,确保数据可读并提供备份的另一份副本。

  6. 将新的 MySQL 实例作为原始源数据库的复制添加,并使用pt-table-checksum或任何其他工具验证数据是否匹配。此过程在xtrabackup文档及其他来源中有详细说明。

这些步骤非常复杂,可能需要很长时间,因此您应该决定是否适合您的业务和环境来使用它们。

数据库备份策略简介

现在我们已经覆盖了与备份和恢复相关的许多方面,我们可以组合出一个强大的备份策略。以下是我们需要考虑的要素:

时间点恢复

我们需要决定是否需要点对点恢复(PITR)功能,因为这将影响我们关于备份策略的决策。对于您的特定情况,您需要自己做决定,但我们建议默认使用 PITR。这可能会拯救生命。如果我们决定需要这种能力,我们需要设置二进制日志记录和 binlog 复制。

逻辑备份

我们可能需要逻辑备份,要么是为了其可移植性,要么是为了防止数据损坏。由于逻辑备份会显著加载源数据库,请安排在负载最轻的时候进行。在某些情况下,可能无法在生产数据库上执行逻辑备份,无论是由于时间限制、负载限制或两者兼而有之。鉴于我们仍然希望能够运行逻辑备份,我们可以使用以下技术:

  • 在复制数据库上运行逻辑备份。在这种情况下,跟踪 binlog 位置可能会有问题,建议在这种情况下使用基于 GTID 的复制。

  • 将逻辑备份的创建整合到物理备份的验证过程中。准备好的备份是一个数据目录,可以立即被 MySQL 服务器使用。如果你运行一个针对备份的服务器,你会破坏该备份,因此你需要先将准备好的备份复制到其他地方。

物理备份

基于操作系统、MySQL 版本、系统属性以及仔细查阅文档,我们需要选择用于物理备份的工具。为简化起见,我们选择使用 XtraBackup。

首先需要决定平均恢复时间(MTTR)目标对我们的重要性。例如,如果我们仅进行每周基础备份,可能需要应用几乎一周的事务才能恢复备份。为了减少 MTTR,可以实施每日甚至每小时的增量备份。

如果你的系统非常庞大,即使使用物理备份工具进行热备份也不可行。在这种情况下,你需要考虑对卷的快照,如果可能的话。

备份存储

我们需要确保我们的备份安全地,并且最好是冗余存储。如果我们使用硬件存储设置,可以利用性能较低但冗余的 RAID 5 或 6 阵列,或者使用不太可靠的存储设置,同时将备份持续流向云存储,比如亚马逊的 S3。或者,如果我们的备份工具允许的话,我们也可以直接使用 S3 作为默认选项。

备份测试与验证

最后,一旦我们完成了备份,我们需要实施备份测试过程。根据可用于实施和维护此练习的预算,我们应该决定每次运行多少步骤,以及哪些步骤仅定期运行。

所有这些都完成后,我们可以说我们已经覆盖了基础并且数据库已经安全备份。考虑到备份很少被使用,这可能看起来是很大的努力,但你必须记住,你最终将面临灾难——这只是时间问题。

第六章:事务和锁定

使用锁定进行事务隔离是 SQL 数据库的支柱——但这也是一个可能会导致很多混淆的领域,特别是对于新手来说。开发人员经常认为锁定是数据库问题,属于 DBA 领域。而 DBA 则认为这是应用程序问题,因此属于开发人员的责任。本章将澄清在不同进程尝试同时写入同一行时发生的情况。它还将阐明在 MySQL 中不同隔离级别下事务中读取查询的行为。

首先,让我们定义关键概念。事务 是对数据库执行的一个(或多个)SQL 语句作为单个逻辑工作单元的操作。事务中所有 SQL 语句的修改要么全部提交(应用于数据库),要么全部回滚(从数据库撤销),永远不会部分提交。数据库事务必须具有原子性、一致性、隔离性和持久性(著名的缩写 ACID)。

是用于确保与数据库中存储的数据进行交互时应用程序和用户的数据完整性的机制。我们将看到有不同类型的锁,有些比其他的更为限制性。

如果请求按顺序串行发出并逐个处理(一个 SELECT,然后一个 INSERT,然后一个 UPDATE等),数据库将不需要事务和锁定。我们在图 6-1 中展示了这种行为。

然而,现实情况(幸运的是!)是 MySQL 可以处理每秒数千个请求并并行处理它们,而不是按顺序逐个处理。本章讨论了 MySQL 为实现这种并行性所做的工作,例如当对同一行进行 SELECTUPDATE 请求同时到达,或者一个在执行时另一个到达时的情况。图 6-2 展示了这种情况的外观。

lm2e 0601

图 6-1。SQL 语句的串行执行

lm2e 0602

图 6-2。SQL 语句的并行执行

在本章中,我们特别关注 MySQL 如何 隔离 事务(ACID 的 I)。我们将展示常见的锁定发生场景,进行调查,并讨论控制事务等待锁定授予的 MySQL 参数。

隔离级别

隔离级别 是在多个事务同时进行更改和执行查询时,平衡性能、可靠性、一致性和结果可重现性的设置。

标准 SQL:1992 定义了四种经典的隔离级别,并且 MySQL 支持所有这些隔离级别。InnoDB 使用不同的锁定策略支持这里描述的每个事务隔离级别。用户也可以使用语句 SET [GLOBAL/SESSION] TRANSACTION 来更改单个会话或所有后续连接的隔离级别。

对于数据操作中 ACID 合规性至关重要的情况,我们可以通过默认的 REPEATABLE READ 隔离级别来强制实现高度一致性;在诸如大规模报告这类场景中,可以通过 READ COMMITTED 或甚至 READ UNCOMMITTED 隔离级别放宽一致性规则,此时精确一致性和可重复的结果比锁定的开销更不重要。SERIALIZABLE 隔离级别比 REPEATABLE READ 更严格,主要用于故障排除等特殊情况。在深入了解细节之前,让我们看看一些更多的术语:

脏读

当一个事务能够从另一个尚未执行 COMMIT 的事务修改的行中读取数据时,会发生这种情况。如果进行修改的事务被回滚,那么另一个事务将看到不正确的结果,这些结果不反映数据库的状态。数据完整性受到损害。

不可重复读取

当事务中的两个查询执行 SELECT 时,如果由于另一个事务的更改导致返回的值不同,则会发生这种情况(如果您在时间 T1 读取一行,然后在时间 T2 再次尝试读取它,则该行可能已经被更新)。与脏读的区别在于,在这种情况下存在 COMMIT。初始的 SELECT 查询是不可重复的,因为第二次执行时返回的值不同。

幻读

当一个事务正在运行时,另一个事务向正在读取的记录添加行或删除行时会发生这种情况(同样,在这种情况下,修改数据的事务会进行 COMMIT)。这意味着如果在同一事务中再次执行相同的查询,它将返回不同数量的行。如果没有范围锁来保证数据的一致性,则可能会发生幻读。

有了这些概念,让我们更仔细地看看 MySQL 中不同的隔离级别。

REPEATABLE READ

REPEATABLE READ 是 InnoDB 的默认隔离级别。它确保在同一事务中进行一致的读取——也就是说,事务中的所有查询将看到由第一次读取建立的数据快照。在这种模式下,InnoDB 锁定索引范围扫描,使用间隙锁或下一键锁(在“锁定”中描述)来阻止其他会话向该范围内的任何间隙插入。

例如,假设在一个会话(会话 1)中,我们执行以下 SELECT

session1 > `SELECT` `*` `FROM` `person` `WHERE` `i` `BETWEEN` `1` `AND` `4``;`
+---+----------+
| i | name     |
+---+----------+
| 1 | Vinicius |
| 2 | Sergey   |
| 3 | Iwo      |
| 4 | Peter    |
+---+----------+
4 rows in set (0.00 sec)

并在另一个会话(会话 2)中,我们更新第二行中的名称:

session2 > `UPDATE` `person` `SET` `name` `=` `'Kuzmichev'` `WHERE` `i``=``2``;`
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
session2> `COMMIT``;`
Query OK, 0 rows affected (0.00 sec)

我们可以在会话 2 中确认变更:

session2 > `SELECT` `*` `FROM` `person` `WHERE` `i` `BETWEEN` `1` `AND` `4``;`
+---+-----------+
| i | name      |
+---+-----------+
| 1 | Vinicius  |
| 2 | Kuzmichev |
| 3 | Iwo       |
| 4 | Peter     |
+---+-----------+
4 rows in set (0.00 sec)

但是会话 1 仍然显示其对数据的原始快照中的旧值:

session1> `SELECT` `*` `FROM` `person` `WHERE` `i` `BETWEEN` `1` `AND` `4``;`
+---+----------+
| i | name     |
+---+----------+
| 1 | Vinicius |
| 2 | Sergey   |
| 3 | Iwo      |
| 4 | Peter    |
+---+----------+

使用可重复读隔离级别,因此不存在脏读或不可重复读。每个事务都读取由第一次读取建立的快照。

读已提交

作为一个好奇心,读已提交隔离级别是许多数据库的默认级别,如 Postgres、Oracle 和 SQL Server,但不是 MySQL。因此,那些迁移到 MySQL 的人必须意识到默认行为的这种差异。

读已提交可重复读的主要区别在于,使用读已提交时,即使在同一个事务内,每次一致性读取也会创建并读取自己的新鲜快照。当在事务内执行多个查询时,这种行为可能导致幻读。让我们看一个例子。在会话 1 中,第一行看起来是这样的:

session1 > `SELECT` `*` `FROM` `person` `WHERE` `i` `=` `1``;`
+---+----------+
| i | name     |
+---+----------+
| 1 | Vinicius |
+---+----------+
1 row in set (0.00 sec)

现在假设在会话 2 中,我们更新了person表的第一行并提交了事务:

session2 > `UPDATE` `person` `SET` `name` `=` `'Grippa'` `WHERE` `i` `=` `1``;`
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
session2 > `COMMIT``;`
Query OK, 0 rows affected (0.00 sec)

如果我们再次检查会话 1,我们将看到第一行的值已经改变:

session1 > `SELECT` `*` `FROM` `person` `WHERE` `i` `=` `1``;`
+---+--------+
| i | name   |
+---+--------+
| 1 | Grippa |
+---+--------+

读已提交的显著优势在于没有间隙锁,允许在锁定记录旁边自由插入新记录。

读未提交

读未提交隔离级别下,MySQL 以非锁定方式执行SELECT语句,这意味着同一事务中的两个SELECT语句可能不会读取同一行的相同版本。正如我们之前看到的,这种现象称为脏读。考虑一下前面的例子,使用读未提交时会发生什么。主要区别在于会话 1 可以在提交之前看到会话 2 更新的结果。让我们再举一个例子。假设在会话 1 中,我们执行以下SELECT语句:

session1 > `SELECT` `*` `FROM` `person` `WHERE` `i` `=` `5``;`
+---+---------+
| i | name    |
+---+---------+
| 5 | Marcelo |
+---+---------+
1 row in set (0.00 sec)

而在会话 2 中,我们执行此更新而不进行提交:

session2 > `UPDATE` `person` `SET` `name` `=` `'Altmann'` `WHERE` `i` `=` `5``;`
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

如果我们现在在会话 1 中再次执行SELECT,我们将看到以下情况:

session1 > `SELECT` `*` `FROM` `person` `WHERE` `i` `=` `5``;`
+---+---------+
| i | name    |
+---+---------+
| 5 | Altmann |
+---+---------+
1 row in set (0.00 sec)

我们可以看到,即使会话 1 处于瞬态状态,它也能读取修改后的数据,这种更改可能会被回滚而不会被提交。

可串行化

MySQL 中最受限制的隔离级别是可串行化。这类似于可重复读,但额外限制了不允许一个事务干扰另一个的情况。因此,通过这种锁定机制,不再可能出现不一致的数据场景。

对于使用可串行化的应用程序,重试策略非常重要。

为了更清晰地说明,想象一个财务数据库,在其中我们在accounts表中注册客户的账户余额。如果两个事务同时尝试更新客户账户余额会发生什么?以下示例说明了这种情况。假设我们已经使用默认隔离级别可重复读启动了两个会话,并在每个会话中显式地开始了事务。在会话 1 中,我们选择了accounts表中的所有账户:

session1> `SELECT` `*` `FROM` `accounts``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      80 | USD      | 2021-07-13 20:39:27 |
|  2 | Sergey |     100 | USD      | 2021-07-13 20:39:32 |
|  3 | Markus |     100 | USD      | 2021-07-13 20:39:39 |
+----+--------+---------+----------+---------------------+
3 rows in set (0.00 sec)

然后,在会话 2 中,我们选择所有至少有 80 美元余额的帐户:

session2> `SELECT` `*` `FROM` `accounts` `WHERE` `balance` `>``=` `80``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      80 | USD      | 2021-07-13 20:39:27 |
|  2 | Sergey |     100 | USD      | 2021-07-13 20:39:32 |
|  3 | Markus |     100 | USD      | 2021-07-13 20:39:39 |
+----+--------+---------+----------+---------------------+
3 rows in set (0.00 sec)

现在,在会话 1 中,我们从帐户 1 中减去 10 美元并检查结果:

session1> `UPDATE` `accounts` `SET` `balance` `=` `balance` `-` `10` `WHERE` `id` `=` `1``;`
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0

session1> `SELECT` `*` `FROM` `accounts``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      70 | USD      | 2021-07-13 20:39:27 |
|  2 | Sergey |     100 | USD      | 2021-07-13 20:39:32 |
|  3 | Markus |     100 | USD      | 2021-07-13 20:39:39 |
+----+--------+---------+----------+---------------------+
3 rows in set (0.00 sec)

我们可以看到帐户 1 的余额已经减少到 70 美元。因此,我们提交会话 1,然后转到会话 2,看看它是否可以读取会话 1 所做的新更改:

session1> `COMMIT``;`
Query OK, 0 rows affected (0.01 sec)
session2> `SELECT` `*` `FROM` `accounts` `WHERE` `id` `=` `1``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      80 | USD      | 2021-07-13 20:39:27 |
+----+--------+---------+----------+---------------------+
1 row in set (0.01 sec)

即使事务 1 成功提交并将其更改为 70 美元,这个SELECT查询仍然返回帐户 1 的旧数据,余额为 80 美元。这是因为REPEATABLE READ隔离级别确保事务中的所有读取查询都是可重复的,这意味着它们始终返回相同的结果,即使其他已提交的事务进行了更改。

但是,如果我们在会话 2 中也运行UPDATE查询,从帐户 1 的余额中减去 10 美元,会发生什么?它会将余额更改为 70 美元、60 美元还是抛出错误?让我们看一下:

session2> `UPDATE` `accounts` `SET` `balance` `=` `balance` `-` `10` `WHERE` `id` `=` `1``;`
Query OK, 1 row affected (0.00 sec)
Rows matched: 1  Changed: 1  Warnings: 0
session2> `SELECT` `*` `FROM` `accounts` `WHERE` `id` `=` `1``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      60 | USD      | 2021-07-13 20:39:27 |
+----+--------+---------+----------+---------------------+
1 row in set (0.01 sec)

没有错误,帐户余额现在是 60 美元,这是正确的值,因为事务 1 已经提交了修改余额为 70 美元的更改。

然而,从事务 2 的角度来看,这是不合理的:在最后一个SELECT查询中,它看到的是 80 美元的余额,但在从帐户中扣除 10 美元之后,现在看到的是 60 美元的余额。这里的数学不成立,因为该事务仍受到其他事务的并发更新的影响。

这就是使用SERIALIZABLE可以帮助的情况。让我们倒回到我们没有进行任何更改的情况。这次我们将明确将两个会话的隔离级别设置为SERIALIZABLE,在使用BEGIN开始事务之前用SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE。同样,在会话 1 中,我们选择所有的帐户:

session1> `SELECT` `*` `FROM` `accounts``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      80 | USD      | 2021-07-13 20:39:27 |
|  2 | Sergey |     100 | USD      | 2021-07-13 20:39:32 |
|  3 | Markus |     100 | USD      | 2021-07-13 20:39:39 |
+----+--------+---------+----------+---------------------+
3 rows in set (0.00 sec)

而在会话 2 中,我们选择所有余额大于 80 美元的帐户:

session2> `SELECT` `*` `FROM` `accounts` `WHERE` `balance` `>``=` `80``;`
+----+--------+---------+----------+---------------------+
| id | owner  | balance | currency | created_at          |
+----+--------+---------+----------+---------------------+
|  1 | Vinnie |      80 | USD      | 2021-07-13 20:39:27 |
|  2 | Sergey |     100 | USD      | 2021-07-13 20:39:32 |
|  3 | Markus |     100 | USD      | 2021-07-13 20:39:39 |
+----+--------+---------+----------+---------------------+
3 rows in set (0.00 sec)

现在,在会话 1 中,我们从帐户 1 中减去 10 美元:

session1> `UPDATE` `accounts` `SET` `balance` `=` `balance` `-` `10` `WHERE` `id` `=` `1``;`

然后什么都不会发生。这次UPDATE查询被阻塞了——会话 1 中的SELECT查询锁定了这些行,阻止了会话 2 中的UPDATE成功执行。因为我们明确地用BEGIN开始了我们的事务(这与禁用自动提交的效果相同),InnoDB 会隐式地将每个事务中的普通SELECT语句转换为SELECT ... FOR SHARE。它事先不知道事务是否仅执行读取操作还是修改行,因此 InnoDB 需要对其进行锁定,以避免我们在前面示例中演示的问题。在本例中,如果启用了自动提交,会话 2 中的SELECT查询将不会阻塞我们试图在会话 1 中执行的更新:MySQL 会识别出这个查询是一个普通的SELECT,并且不需要阻塞其他查询,因为它不会修改任何行。

然而,第二个会话的更新不会永远挂起;这个锁有一个由innodb_lock_wait_timeout参数控制的超时时长。因此,如果第一个会话没有提交或回滚其事务以释放锁定,一旦会话超时到达,MySQL 将抛出以下错误:

ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

锁定

现在我们已经看到每个隔离级别是如何工作的,让我们来看看 InnoDB 用来实现它们的不同锁定策略。

锁定用于在数据库中保护共享资源或对象。它们可以在不同的级别上起作用,比如:

  • 表锁定

  • 元数据锁定

  • 行锁定

  • 应用程序级锁定

MySQL 使用元数据锁来管理对数据库对象的并发访问以及确保数据一致性。当表上存在活动事务(显式或隐式)时,MySQL 不允许对元数据进行写操作(例如 DDL 语句更新表的元数据)。它通过这种方式在并发环境中维护元数据的一致性。

如果在会话执行以下列表中提到的操作时有活动事务(运行中、未提交或回滚),那么请求数据写入的会话将处于等待表元数据锁定状态。元数据锁等待可能发生在以下任何情况下:

  • 当您创建或删除索引时

  • 当您修改表结构时

  • 当您执行表维护操作(OPTIMIZE TABLE REPAIR TABLE等)时

  • 当您删除一个表时

  • 当您尝试在表上获取表级写锁(LOCK TABLE table_name WRITE

为了支持多个会话的同时写访问,InnoDB 支持行级锁定。

应用程序级或用户级锁定,比如由GET_LOCK()提供的锁,可以用来模拟诸如记录锁定之类的数据库锁定。

本书侧重于元数据和行锁定,因为它们影响大多数用户并且是最常见的。

元数据锁

MySQL 文档提供了对元数据锁的最佳定义:

为了确保事务的串行化,服务器不能允许一个会话在另一个会话中的未完成的显式或隐式启动的事务中使用的表上执行数据定义语言(DDL)语句。服务器通过获取用于事务中使用的表的元数据锁,并推迟锁的释放直到事务结束来实现这一点。表的元数据锁定阻止对表结构的更改。这种锁定方法意味着,一个会话中正在使用的表在事务结束之前不能被其他会话用于 DDL 语句。

在这个定义的基础上,让我们看看元数据锁在实际中的运作。首先,我们将创建一个虚拟表,并加载一些行进去:

`USE` `test``;`

`DROP` `TABLE` `IF` `EXISTS` `` `joinit` ```;`

`CREATE` `TABLE` `` `joinit` `` `(`

`` `i` `` `int``(``11``)` `NOT` `NULL` `AUTO_INCREMENT``,`

`` `s` `` `varchar``(``64``)` `默认` `NULL``,`

`` `t` `` `时间` `非` `NULL``,`

`` `g` `` `int``(``11``)` `非` `NULL``,`

`主` `键` `(``` `i` ```)`

`)` `引擎``=``InnoDB`  `默认` `字符集``=``latin1``;`

`INSERT` `INTO` `joinit` `VALUES` `(``NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+`

`RAND``(` `)` `*``60` `)``)``)``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

`INSERT` `INTO` `joinit` `SELECT` `NULL``,` `uuid``(``)``,` `time``(``now``(``)``)``,`  `(``FLOOR``(` `1` `+` `RAND``(` `)` `*``60` `)``)`

`FROM` `joinit``;`

Now that we have some dummy data, we will open one session (session 1) and execute an UPDATE:


session1> `UPDATE` `joinit` `SET` `t``=``now``(``)``;`

Then, in a second session, we will try to add a new column to this table while the UPDATE is still running:


session2> `ALTER` `TABLE` `joinit` `ADD` `COLUMN` `b` `整数``;`

And in a third session, we can execute the SHOW PROCESSLIST command to visualize the metadata lock:


session3> `显示` `进程列表``;`


+----+----------+-----------+------+---------+------+...

| Id | 用户     | 主机      | db   | 命令 | 时间 |...

+----+----------+-----------+------+---------+------+...

| 10 | msandbox | localhost | test | 查询   |    3 |...

| 11 | msandbox | localhost | test | 查询   |    1 |...

| 12 | msandbox | localhost | NULL | 查询   |    0 |...

+----+----------+-----------+------+---------+------+...

...+---------------------------------+-------------------------------------+...

...| 状态                           | 信息                                |...

...+---------------------------------+-------------------------------------+...

| 更新                       | UPDATE joinit SET t=now()           |...

...| 等待表元数据锁               | ALTER TABLE joinit ADD COLUMN b INT |...

...| 开始                          | 显示进程列表                    |...

...+---------------------------------+-------------------------------------+...

...+-----------+---------------+

...| 发送行 | 检查行 |

...+-----------+---------------+

...|         0 |        179987 |

...|         0 |             0 |

...|         0 |             0 |

...+-----------+---------------+

Note that a long-running query or a query that is not using autocommit will have the same effect. For example, suppose we have an UPDATE running in session 1:


mysql > `SET` `SESSION` `autocommit``=``0``;`


Query OK, 0 rows affected (0.00 sec)


mysql > `UPDATE` `joinit` `SET` `t``=``NOW``(``)` `LIMIT` `1``;`


Query OK, 1 row affected (0.00 sec)

Rows matched: 1  Changed: 1  Warnings: 0

And we execute a DML statement in session 2:


mysql > `ALTER` `TABLE` `joinit` `ADD` `COLUMN` `b` `INT``;`

If we check the process list in session 3, we can see the DDL waiting on the metadata lock (thread 11), while thread 10 has been sleeping since it executed the UPDATE (still not committed):


mysql > `SHOW` `PROCESSLIST``;`

Note

MySQL is multithreaded, so there may be many clients issuing queries for a given table simultaneously. To minimize the problem with multiple client sessions having different states for the same table, each concurrent session opens the table independently. This uses additional memory but typically increases performance.

Before we start using the sys schema, it is necessary to enable MySQL instrumentation to monitor these locks. To do this, run the following command:


mysql> `UPDATE` `performance_schema``.``setup_instruments` `SET` `enabled` `=` `'YES'`

    -> `WHERE` `NAME` `=` `'wait/lock/metadata/sql/mdl'``;`

Query OK, 0 rows affected (0.00 sec)

Rows matched: 1 Changed: 0 Warnings: 0

The following query uses the schema_table_lock_waits view from the sys schema to illustrate how to observe metadata locks in the MySQL database:


mysql> `SELECT` `*`  `FROM` `sys``.``schema_table_lock_waits``;`

This view displays which sessions are blocked waiting on metadata locks and what is blocking them. Rather than selecting all fields, the following example shows a more compact view:


mysql> `SELECT` `object_name``,` `waiting_thread_id``,` `waiting_lock_type``,`

    -> `waiting_query``,` `sql_kill_blocking_query``,` `blocking_thread_id`

    -> `FROM` `sys``.``schema_table_lock_waits``;`


+-------------+-------------------+-------------------+...

| object_name | waiting_thread_id | waiting_lock_type |...

+-------------+-------------------+-------------------+...

| joinit      |                29 | EXCLUSIVE         |...

| joinit      |                29 | EXCLUSIVE         |...

+-------------+-------------------+-------------------+...

...+-------------------------------------------------------------------+...

...| 等待查询                                                     |...

...+-------------------------------------------------------------------+...

...| 修改表 joinit 添加列  ...  CHAR(32) 默认 'dummy_text' |...

...| ALTER TABLE joinit ADD COLUMN  ...  CHAR(32) 默认 'dummy_text' |...

...|-------------------------------------------------------------------+...


...+-------------------------+--------------------+

...| sql_kill_blocking_query | blocking_thread_id |

...+-------------------------+--------------------+

...| KILL QUERY 3            |                 29 |

...| KILL QUERY 5            |                 31 |

...+-------------------------+--------------------+

2 rows in set (0.00 sec)

Note

The MySQL sys schema is a set of objects that helps DBAs and developers interpret data collected by the Performance Schema, a feature for monitoring MySQL Server execution at a low level. It is available for MySQL 5.7 and MySQL 8.0. If you want to use the sys schema in MySQL 5.6, it is possible to install it using the sys project available on GitHub:


# git clone https://github.com/mysql/mysql-sys.git

# cd mysql-sys/

# mysql -u root -p < ./sys_56.sql

Let’s see what happens when we query the metadata_locks table:


mysql> `SELECT` `*` `FROM` `performance_schema``.``metadata_locks``\``G`


*************************** 1\. row ***************************

        OBJECT_TYPE: GLOBAL

        OBJECT_SCHEMA: NULL

        OBJECT_NAME: NULL

OBJECT_INSTANCE_BEGIN: 140089691017472

            LOCK_TYPE: INTENTION_EXCLUSIVE

        LOCK_DURATION: STATEMENT

        LOCK_STATUS: GRANTED

            SOURCE:

    OWNER_THREAD_ID: 97

    OWNER_EVENT_ID: 34

...

*************************** 6\. row ***************************

        OBJECT_TYPE: TABLE

        OBJECT_SCHEMA: performance_schema

        OBJECT_NAME: metadata_locks

OBJECT_INSTANCE_BEGIN: 140089640911984

            LOCK_TYPE: SHARED_READ

        LOCK_DURATION: TRANSACTION

        LOCK_STATUS: GRANTED

            SOURCE:

    OWNER_THREAD_ID: 98

    OWNER_EVENT_ID: 10

6 rows in set (0.00 sec)

Note that a SHARED_UPGRADABLE lock is set on the joinit table, and an EXCLUSIVE lock is pending on the same table.

We can get a nice view of all metadata locks from other sessions, excluding our current one, with the following query:


mysql> `SELECT` `object_type``,` `object_schema``,` `object_name``,` `lock_type``,`

    -> `lock_status``,` `thread_id``,` `processlist_id``,` `processlist_info` `FROM`

    -> `performance_schema``.``metadata_locks` `INNER` `JOIN` `performance_schema``.``threads`

    -> `ON` `thread_id` `=` `owner_thread_id` `WHERE` `processlist_id` `<``>` `connection_id``(``)``;`


+-------------+---------------+-------------+---------------------+...

| 对象类型 | 对象模式 | 对象名称 | 锁类型           |...

+-------------+---------------+-------------+---------------------+...

| 全局      | NULL          | NULL        | 意图独占 |...

| 模式      | 测试          | NULL        | 意图独占 |...

| 表       | 测试          | joinit      | 共享升级   |...

| 备份      | NULL          | NULL        | 意图独占 |...

| 表       | 测试          | joinit      | 独占           |...

+-------------+---------------+-------------+---------------------+...

...+-------------+-----------+----------------+...

...| 锁状态 | 线程 ID | 进程列表 ID |...

...+-------------+-----------+----------------+...

...| 授予     |        97 |             71 |...

...| 授予     |        97 |             71 |...

...| 授予     |        97 |             71 |...

...| 授予     |        97 |             71 |...

...| 待定     |        97 |             71 |...

...+-------------+-----------+----------------+...

...+-------------------------------------+

...| 进程列表信息                    |

...+-------------------------------------+

...| alter table joinit add column b int |

...| alter table joinit add column b int |

...| alter table joinit add column b int |

...| alter table joinit add column b int |

...| alter table joinit add column b int |

...+-------------------------------------+

5 行已设置(0.00 秒)

If we look carefully, a DDL statement waiting for a query on its own is not a problem: it will have to wait until it can acquire the metadata lock, which is expected. The problem is that while waiting, it blocks every other query from accessing the resource.

We recommend the following actions to avoid long metadata locks:

  • Perform DDL operations in non-busy times. This way you reduce the concurrency in the database between the regular application workload and the extra workload that the operation carries.

  • Always use autocommit. MySQL has autocommit enabled by default. This will avoid transactions with pending commits.

  • When performing a DDL operation, set a low value for lock_wait_timeout at the session level. Then, if the metadata lock can’t be acquired, it won’t block for a long time waiting. For example:

    
    mysql> `SET` `lock_wait_timeout` `=` `3``;`
    
    mysql> `CREATE` `INDEX` `idx_1` `ON` `example` `(``col1``)``;`
    
    

You might also want to consider using the pt-kill tool to kill queries that have been running for a long time. For example, to kill queries that have been running for more than 60 seconds, issue this command:


$ `pt``-``kill` `-``-``busy``-``time` `60` `-``-``kill`

Row Locks

InnoDB implements standard row-level locking. This means that, in general terms, there are two types of locks:

  • A shared (S) lock permits the transaction that holds the lock to read a row.

  • An exclusive (X) lock permits the transaction that holds the lock to update or delete a row.

The names are self-explanatory: exclusive locks don’t allow multiple transactions to acquire an exclusive lock in the same row while sharing a shared lock. That is why it is possible to have parallel reads for the same row, while parallel writes are not allowed.

InnoDB also supports multiple granularity locking, which permits the coexistence of row locks and table locks. Granular locking is possible due to the existence of intention locks, which are table-level locks that indicate which type of lock (shared or exclusive) a transaction requires later for a row in a table. There are two types of intention locks:

  • An intention shared (IS) lock indicates that a transaction intends to set a shared lock on individual rows in a table.

  • An intention exclusive (IX) lock indicates that a transaction intends to set an exclusive lock on individual rows in a table.

Before a transaction can acquire a shared or an exclusive lock, it is necessary to obtain the respective intention lock (IS or IX).

To make things a bit easier to understand, take a look at Table 6-1.

Table 6-1. Lock type compatibility matrix

X IX S IS
X Conflict Conflict Conflict Conflict
IX Conflict Compatible Conflict Compatible
S Conflict Conflict Compatible Compatible
IS Conflict Compatible Compatible Compatible

Another important concept is the gap lock, which is a lock on the gap between index records. Gap locks ensure that no new rows are added in the interval specified by the query; this means that when you run the same query twice, you get the same number of rows, regardless of other sessions’ modifications to that table. They make the reads consistent and therefore make the replication between servers consistent. If you execute SELECT * FROM example_table WHERE id > 1000 FOR UPDATE twice, you expect to get the same result twice. To accomplish that, InnoDB locks all index records found by the WHERE clause with an exclusive lock and the gaps between them with a shared gap lock.

Let’s see an example of a gap lock in action. First, we will execute a SELECT statement on the person table:


mysql> `SELECT` `*` `FROM` `PERSON``;`


+----+-----------+

| i  | 名称      |
| --- | --- |

+----+-----------+

|  1 | Vinicius  |
| --- | --- |
|  2 | Kuzmichev |
|  3 | Iwo       |
|  4 | Peter     |
|  5 | Marcelo   |
|  6 | Guli      |
|  7 | Nando     |
| 10 | Jobin     |
| 15 | Rafa      |
| 18 | Leo       |

+----+-----------+

10 行已设置(0.00 秒)

Now, in session 1, we will perform a delete operation, but we will not commit:


session1> `DELETE` `FROM` `person` `WHERE` `name` `LIKE` `'Jobin'``;`


Query OK, 1 行受影响(0.00 秒)

And if we check in session 2, we can still see the row with Jobin:


session2> `SELECT` `*` `FROM` `person``;`


+----+-----------+

| i  | 名称      |
| --- | --- |

+----+-----------+

|  1 | Vinicius  |
| --- | --- |
|  2 | Kuzmichev |
|  3 | Iwo       |
|  4 | Peter     |
|  5 | Marcelo   |
|  6 | Guli      |
|  7 | Nando     |
| 10 | Jobin     |
| 15 | Rafa      |
| 18 | Leo       |

+----+-----------+

10 行已设置(0.00 秒)

The results show that there are gaps in the values of the primary key column that in theory are available to be used to insert new records. So what happens if we try to insert a new row with a value of 11? The insert will be locked and will fail:


transaction2 > `INSERT` `INTO` `person` `VALUES` `(``11``,` `'Bennie'``)``;`


错误 1205 (HY000): 锁等待超时;请尝试重新启动事务

If we run SHOW ENGINE INNODB STATUS, we will see the locked transaction in the TRANSACTIONS section:


------- 事务等待 17 秒以便授予此锁:

记录锁 空间 ID 28 页号 3 n 位 80 索引 PRIMARY of table

`test`.`person` 事务 ID 4773 锁模式 X 锁间隙插入前

意图等待

Note that MySQL does not need gap locking for statements that lock rows using a unique index to search for a unique row. (This does not include the case where the search condition includes only some columns of a multiple-column unique index; in that case, gap locking does occur.) For example, if the name column has a unique index, the following DELETE statement uses only an index-record lock:


mysql> `CREATE` `UNIQUE` `INDEX` `idx` `ON` `PERSON` `(``name``)``;`


Query OK, 0 行受影响 (0.01 秒)

Records: 0  Duplicates: 0  Warnings: 0


mysql> `DELETE` `FROM` `person` `WHERE` `name` `LIKE` `'Jobin'``;`


Query OK, 1 行受影响 (0.00 秒)

Deadlocks

A deadlock is a situation where two (or more) competing actions are waiting for the other to finish. As a consequence, neither ever does. In computer science, the term refers to a specific condition where two or more processes are each waiting for another to release a resource. In this section, we will talk specifically about transaction deadlocks and how InnoDB solves this issue.

For a deadlock to happen, four conditions (known as the Coffman conditions) must exist:

  1. Mutual exclusion. The process must hold at least one resource in a non-shareable mode. Otherwise, MySQL would not prevent the process from using the resource when necessary. Only one process can use the resource at any given moment in time.

  2. Hold and wait or resource holding. A process is currently holding at least one resource and requesting additional resources held by other processes.

  3. No preemption. A resource can be released only voluntarily by the process holding it.

  4. Circular wait. Each process must be waiting for a resource held by another process, which in turn is waiting for the first process to release the resource.

Before moving on to an example, there are some misconceptions that you might hear and that it is essential to clarify. They are:

Transaction isolation levels are responsible for deadlocks.

The possibility of deadlocks is not affected by the isolation level. The READ COMMITTED isolation level sets fewer locks, and hence it can help you avoid certain lock types (e.g., gap locking), but it won’t prevent deadlocks entirely.

Small transactions are not affected by deadlocks.

Small transactions are less prone to deadlocks because they run fast, so the chance of a conflict occurring is smaller than with more prolonged operations. However, it can still happen if transactions do not use the same order of operations.

Deadlocks are terrible things.

It’s problematic to have deadlocks in a database, but InnoDB can resolve them automatically, unless deadlock detection is disabled (by changing the value of innodb_deadlock_detect). A deadlock is a a bad situation, but resolution through the termination of one of the transactions ensures that processes cannot hold onto the resources for a long time, slowing or stalling the database completely until the offending query gets canceled by the innodb_lock_wait_timeout setting.

To illustrate deadlocks, we’ll use the world database. If you need to import it, you can do so now by following the instructions in “Entity Relationship Modeling Examples”.

Let’s start by getting a list of Italian cities in the province of Toscana:


mysql> `SELECT` `*` `FROM` `city` `WHERE` `CountryCode` `=` `'ITA'` `AND` `District``=``'Toscana'``;`


+------+---------+-------------+----------+------------+

| ID   | Name    | CountryCode | District | Population |
| --- | --- | --- | --- | --- |

+------+---------+-------------+----------+------------+

| 1471 | Firenze | ITA         | Toscana  |     376662 |
| --- | --- | --- | --- | --- |
| 1483 | Prato   | ITA         | Toscana  |     172473 |
| 1486 | Livorno | ITA         | Toscana  |     161673 |
| 1516 | Pisa    | ITA         | Toscana  |      92379 |
| 1518 | Arezzo  | ITA         | Toscana  |      91729 |

+------+---------+-------------+----------+------------+

5 rows in set (0.00 秒)

Now let’s say we have two transactions trying to update the populations of the same two cities in Toscana at the same time, but in different orders:


session1> `UPDATE` `city` `SET` `Population``=``Population` `+` `1` `WHERE` `ID` `=` `1471``;`


Query OK, 1 行受影响 (0.00 秒)

Rows matched: 1  Changed: 1  Warnings: 0


session2> `UPDATE` `city` `SET` `Population``=``Population` `+` `1` `WHERE` `ID` `=``1516``;`


Query OK, 1 行受影响 (0.00 秒)

Rows matched: 1  Changed: 1  Warnings: 0


session1> `UPDATE` `city` `SET` `Population``=``Population` `+` `1` `WHERE` `ID` `=``1516``;`


ERROR 1213 (40001): 死锁,尝试获取锁定;请重启事务


session2> `UPDATE` `city` `SET` `Population``=``Population` `+` `1` `WHERE` `ID` `=` `1471``;`


Query OK, 1 行受影响 (5.15 秒)

Rows matched: 1  Changed: 1  Warnings: 0

And we had a deadlock in session 1. It is important to note that it is not always the second transaction that will fail. In this example, session 1 was the one that MySQL aborted. We can get information on the latest deadlock that happened in the MySQL database by running SHOW ENGINE INNODB STATUS:


mysql> `SHOW` `ENGINE` `INNODB` `STATUS``\``G`


------------------------

最近检测到死锁

------------------------

2020-12-05 16:08:19 0x7f6949359700

*** (1) 事务:

事务 10502342, 活动 34 秒开始索引读取

mysql tables in use 1, locked 1

LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log

entries 1

MySQL 线程 id 71, OS 线程句柄 140090386671360, 查询 id 5979282

localhost msandbox updating

update city set Population=Population + 1 where ID = 1471

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 6041 page no 15 n bits 248 index PRIMARY of table

`world`.`city` trx id 10502342 lock_mode X locks rec but not gap waiting

*** (2) 事务:

事务 10502341, 活动 62 秒开始索引读取

mysql tables in use 1, locked 1

3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1

MySQL 线程 id 75, OS 线程句柄 140090176542464, 查询 id 5979283

localhost msandbox updating

update city set Population=Population + 1 where ID =1516

*** (2) 持有锁定:

RECORD LOCKS space id 6041 page no 15 n bits 248 index PRIMARY of table

`world`.`city` trx id 10502341 lock_mode X locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 6041 page no 16 n bits 248 index PRIMARY of table

`world`.`city` trx id 10502341 lock_mode X locks rec but not gap waiting

*** 事务回滚 (2)

...

If you want, you can log all the deadlocks that happen in MySQL in the MySQL error log. Using the innodb_print_all_deadlocks parameter, MySQL records all information about deadlocks from InnoDB user transactions in the error log. Otherwise, you see information about only the last deadlock using the SHOW ENGINE INNODB STATUS command.

MySQL Parameters Related to Isolation and Locks

To round out this chapter, let’s take a look at a few MySQL parameters that are related to isolation behavior and lock duration:

transaction_isolation

Sets the transaction isolation level. This parameter can change the behavior at the GLOBAL, SESSION, or NEXT_TRANSACTION level:


mysql> `SET` `SESSION` `transaction_isolation``=``'READ-COMMITTED'``;


查询完成,影响行数:0,耗时 (0.00 秒)


mysql> `SHOW` `SESSION` `VARIABLES` `LIKE` `'%isol%'``;


+-----------------------+----------------+

| Variable_name         | Value          |
| --- | --- |

+-----------------------+----------------+

| transaction_isolation | READ-COMMITTED |
| --- | --- |
| tx_isolation          | READ-COMMITTED |

+-----------------------+----------------+

Note

transaction_isolation was added in MySQL 5.7.20 as a synonym for tx_isolation, which is now deprecated and has been removed in MySQL 8.0. Applications should be adjusted to use transaction_isolation in preference to tx_isolation.

innodb_lock_wait_timeout

Specifies the amount of time in seconds an InnoDB transaction waits for a row lock before giving up. The default value is 50 seconds. The transaction raises the following error if the time waiting for the lock exceeds the innodb_lock_wait_timeout value:


ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

innodb_print_all_deadlocks

Causes MySQL to record information about all deadlocks resulting from InnoDB user transactions in the MySQL error log. We can enable this dynamically with the following command:


mysql> `SET` `GLOBAL` `innodb_print_all_deadlocks` `=` `1``;`

lock_wait_timeout

Specifies the timeout in seconds for attempts to acquire metadata locks. To avoid long metadata locks stalling the database, we can set lock_wait_timeout=1 at the session level before executing the DDL statement. In this case, if the operation can’t acquire the lock, it will give up and let other requests execute. For example:


mysql> `SET` `SESSION` `lock_wait_timeout``=``1``;

mysql> `CREATE` `TABLE` `t1``(``i` `INT` `NOT` `NULL` `AUTO_INCREMENT` `PRIMARY` `KEY``)`

    -> `ENGINE``=``InnoDB``;

innodb_deadlock_detect

禁用死锁监控。请注意,这仅意味着 MySQL 不会终止查询以撤销死锁,而禁用死锁检测不会阻止死锁发生,但会使 MySQL 依赖于innodb_lock_wait_timeout设置来在发生死锁时回滚事务。

第七章:更多 MySQL 操作

MySQL 功能丰富。在过去的三章中,你已经看到了用于查询、修改和管理数据的多种技术。然而,MySQL 还有更多功能等待探索,本章将重点介绍其中一些附加功能。

在本章中,您将学习以下内容:

  • 从其他来源(包括查询和文本文件)向数据库中插入数据。

  • 使用单个语句在多个表中执行更新和删除操作。

  • 替换数据。

  • 在查询中使用 MySQL 函数,以满足更复杂的信息需求。

  • 使用EXPLAIN语句分析查询,然后通过简单的优化技术提高性能。

  • 使用替代存储引擎更改表属性。

使用查询插入数据

大部分时间,您将使用来自其他来源的数据创建表。因此,您在第三章中看到的示例只是问题的一部分:它们向您展示了如何插入已经以 SQL INSERT语句格式化的数据。插入数据的其他方法包括使用 SQL SELECT语句从其他表或数据库中读取文件。本节将向您展示如何处理前一种插入数据方法;您将在下一节“从逗号分隔文件加载数据”中学习如何插入来自逗号分隔值文件的数据。

假设我们决定在sakila数据库中创建一个新表。它将存储我们想要更多宣传的电影随机列表。在现实世界中,您可能希望使用一些数据科学方法找出要突出显示的电影,但我们将坚持基础知识。这些电影列表将是客户查看目录的不同部分、重新发现一些老片以及了解他们尚未探索的隐藏宝藏的一种方式。我们决定将表结构化如下:

mysql> `CREATE` `TABLE` `recommend`
    ->    `film_id` `SMALLINT` `UNSIGNED``,`
    ->    `language_id` `TINYINT` `UNSIGNED``,`
    ->    `release_year` `YEAR``,`
    ->    `title` `VARCHAR``(``128``)``,`
    ->    `length` `SMALLINT` `UNSIGNED``,`
    ->    `sequence_id` `SMALLINT` `AUTO_INCREMENT``,`
    ->    `PRIMARY` `KEY` `(``sequence_id``)`
    -> `)``;`
Query OK, 0 rows affected (0.05 sec)

此表存储每部电影的少量详细信息,使您可以通过对其他表的简单查询找到演员、类别和其他信息。它还存储了一个sequence_id,这是一个唯一的数字,用于枚举电影在我们的短列表中的位置。当您开始使用推荐功能时,您将首先看到序列号为 1 的电影,然后是 2,依此类推。您可以看到我们正在使用 MySQL 的AUTO_INCREMENT功能来分配sequence_id的值。

现在,我们需要用一些随机选择的电影填充我们的新recommend表。重要的是,我们将在一条语句中同时执行SELECTINSERT。我们开始吧:

mysql> `INSERT` `INTO` `recommend` `(``film_id``,` `language_id``,` `release_year``,` `title``,` `length``)`
    -> `SELECT` `film_id``,` `language_id``,` `release_year``,` `title``,` `length`
    -> `FROM` `film` `ORDER` `BY` `RAND``(``)` `LIMIT` `10``;`
Query OK, 10 rows affected (0.02 sec)
Records: 10  Duplicates: 0  Warnings: 0

现在,让我们先了解这条命令如何工作之前发生了什么:

mysql> `SELECT` `*` `FROM` `recommend``;`
+---------+-----+--------------------+--------+-------------+
| film_id | ... | title              | length | sequence_id |
+---------+-----+--------------------+--------+-------------+
|     542 | ... | LUST LOCK          |     52 |           1 |
|     661 | ... | PAST SUICIDES      |    157 |           2 |
|     613 | ... | MYSTIC TRUMAN      |     92 |           3 |
|     757 | ... | SAGEBRUSH CLUELESS |    106 |           4 |
|     940 | ... | VICTORY ACADEMY    |     64 |           5 |
|     917 | ... | TUXEDO MILE        |    152 |           6 |
|     709 | ... | RACER EGG          |    147 |           7 |
|     524 | ... | LION UNCUT         |     50 |           8 |
|      30 | ... | ANYTHING SAVANNAH  |     82 |           9 |
|     602 | ... | MOURNING PURPLE    |    146 |          10 |
+---------+-----+--------------------+--------+-------------+
10 rows in set (0.00 sec)

您可以看到我们的推荐列表中有 10 部电影,其sequence_id值从 1 到 10。我们准备开始推荐随机选择的电影。如果您的结果不同,不要担心;这是RAND()函数工作方式的结果。

SQL 语句有两部分用于填充表格:一个 INSERT INTO 和一个 SELECTINSERT INTO 语句列出了数据将要存储的目标表,后面跟着一个可选的列名列表在括号中;如果省略列名,则假定所有列按照它们在 DESCRIBE TABLESHOW CREATE TABLE 语句输出中的顺序排列。SELECT 语句输出的列必须与为 INSERT INTO 语句提供的列表的类型和顺序匹配(或者如果未提供,则是隐式完整列表)。总体效果是从 SELECT 语句输出的行被 INSERT INTO 语句插入到目标表中。在我们的示例中,film 表中的 film_idlanguage_idrelease_yeartitlelength 值被插入到具有相同名称和类型的 recommend 表的五列中;sequence_id 是使用 MySQL 的 AUTO_INCREMENT 功能自动创建的,因此在语句中未指定。

我们的示例包括子句 ORDER BY RAND();这会按照 MySQL 函数 RAND() 的结果对结果进行排序。RAND() 函数返回范围在 0 到 1 之间的伪随机数:

mysql> `SELECT` `RAND``(``)``;`
+--------------------+
| RAND()             |
+--------------------+
| 0.4593397513584604 |
+--------------------+
1 row in set (0.00 sec)

伪随机数生成器并不生成真正的随机数,而是基于系统的某些属性(如时间)生成数字。对于大多数应用来说,这已经足够随机;一个显著的例外是依赖数字真实随机性的密码学应用。

如果在 SELECT 操作中请求 RAND() 值,你将得到每个返回行的随机值:

mysql> `SELECT` `title``,` `RAND``(``)` `FROM` `film` `LIMIT` `5``;`
+------------------+---------------------+
| title            | RAND()              |
+------------------+---------------------+
| ACADEMY DINOSAUR |  0.5514843506286706 |
| ACE GOLDFINGER   | 0.37940252980161693 |
| ADAPTATION HOLES |  0.2425596278557178 |
| AFFAIR PREJUDICE | 0.07459058060738312 |
| AFRICAN EGG      |  0.6452740502034072 |
+------------------+---------------------+
5 rows in set (0.00 sec)

由于值是有效随机的,你几乎肯定会看到与我们这里显示的不同的结果。此外,如果你重复执行语句,你也会看到不同的返回值。可以向 RAND() 传递一个整数参数称为 seed。这将导致 RAND() 函数在每次使用相同输入时生成相同的值—虽然这对我们这里试图实现的目标不是真正有用,但这也是一个可能性。你可以尝试运行以下语句任意次数,结果都不会改变:

`SELECT` `title``,` `RAND``(``1``)` `FROM` `film` `LIMIT` `5``;`

让我们回到 INSERT 操作。当我们要求结果按 RAND() 排序时,SELECT 语句的结果以伪随机顺序排序。LIMIT 10 用于限制 SELECT 返回的行数;在这个示例中,我们仅仅是为了可读性而限制了数量。

INSERT INTO语句中的SELECT语句可以使用所有常规的SELECT语句特性。您可以使用连接、聚合、函数和任何其他您选择的功能。您还可以通过在表名前加上数据库名称后跟一个点(.)字符,从一个数据库中的数据查询另一个数据库中的数据。例如,如果要将film数据库中的actor表插入到新的art数据库中,可以执行以下操作:

mysql> `CREATE` `DATABASE` `art``;`
Query OK, 1 row affected (0.01 sec)
mysql> `USE` `art``;`
Database changed
mysql> `CREATE` `TABLE` `people`
    ->   `person_id` `SMALLINT` `UNSIGNED``,`
    ->   `first_name` `VARCHAR``(``45``)``,`
    ->   `last_name` `VARCHAR``(``45``)``,`
    ->   `PRIMARY` `KEY` `(``person_id``)`
    -> `)``;`
Query OK, 0 rows affected (0.03 sec)
mysql> `INSERT` `INTO` `art``.``people` `(``person_id``,` `first_name``,` `last_name``)`
    -> `SELECT` `actor_id``,` `first_name``,` `last_name` `FROM` `sakila``.``actor``;`
Query OK, 200 rows affected (0.01 sec)
Records: 200  Duplicates: 0  Warnings: 0

新的people表被称为art.people(尽管不需要这样,因为art是当前正在使用的数据库),actor表被称为sakila.actor(这是必需的,因为这不是当前使用的数据库)。注意,SELECTINSERT中的列名不需要相同。

有时,在使用SELECT语句插入时会遇到重复的问题。如果尝试两次插入相同的主键值,MySQL 将中止。这在recommend表中不会发生,只要您使用AUTO_INCREMENT功能自动分配新的sequence_id即可。但是,我们可以强制将重复项插入到表中以显示其行为:

mysql> `USE` `sakila``;`
Database changed
mysql> `INSERT` `INTO` `recommend` `(``film_id``,` `language_id``,` `release_year``,`
    -> `title``,` `length``,` `sequence_id` `)`
    -> `SELECT` `film_id``,` `language_id``,` `release_year``,` `title``,` `length``,` `1`
    -> `FROM` `film` `LIMIT` `1``;`
ERROR 1062 (23000): Duplicate entry '1' for key 'recommend.PRIMARY'

如果要 MySQL 忽略这一点并继续进行,请在INSERT之后添加IGNORE关键字:

mysql> `INSERT` `IGNORE` `INTO` `recommend` `(``film_id``,` `language_id``,` `release_year``,`
    -> `title``,` `length``,` `sequence_id` `)`
    -> `SELECT` `film_id``,` `language_id``,` `release_year``,` `title``,` `length``,` `1`
    -> `FROM` `film` `LIMIT` `1``;`
Query OK, 0 rows affected, 1 warning (0.00 sec)
Records: 1  Duplicates: 1  Warnings: 1

MySQL 不会抱怨,但它会报告遇到了重复。请注意,数据并未更改;我们所做的只是忽略了错误。这在大量加载操作中非常有用,您不希望在运行插入一百万行的脚本时半途而废。我们可以检查警告,看到现在将*Duplicate entry*错误作为警告:

mysql> `SHOW` `WARNINGS``;`
+---------+------+-------------------------------------------------+
| Level   | Code | Message                                         |
+---------+------+-------------------------------------------------+
| Warning | 1062 | Duplicate entry '1' for key 'recommend.PRIMARY' |
+---------+------+-------------------------------------------------+
1 row in set (0.00 sec)

最后,请注意,可以插入到SELECT语句中列出的表中,但仍然需要避免重复的主键:

mysql> `INSERT` `INTO` `actor` `SELECT`
    -> `actor_id``,` `first_name``,` `last_name``,` `NOW``(``)` `FROM` `actor``;`
ERROR 1062 (23000): Duplicate entry '1' for key 'actor.PRIMARY'

避免出现错误有两种方法。首先,actor表为actor_id启用了AUTO_INCREMENT,因此如果完全省略INSERT中的此列,就不会出现错误,因为新值将自动生成。(INSERT语句的语法在“Alternative Syntaxes”中有解释。)这是一个仅插入一条记录的示例(由于LIMIT子句):

`INSERT` `INTO` `actor``(``first_name``,` `last_name``,` `last_update``)`
`SELECT` `first_name``,` `last_name``,` `NOW``(``)` `FROM` `actor` `LIMIT` `1``;`

第二种方法是在SELECT查询中修改actor_id,以避免冲突。让我们试试这个:

mysql> `INSERT` `INTO` `actor` `SELECT`
    -> `actor_id``+``200``,` `first_name``,` `last_name``,` `NOW``(``)` `FROM` `actor``;`
Query OK, 200 rows affected (0.01 sec)
Records: 200  Duplicates: 0  Warnings: 0

在这里,我们复制了行,但在插入它们之前将它们的actor_id值增加了 200,因为我们记得最初有 200 行。这是结果:

mysql> `SELECT` `*` `FROM` `actor``;`
+----------+-------------+--------------+---------------------+
| actor_id | first_name  | last_name    | last_update         |
+----------+-------------+--------------+---------------------+
|        1 | PENELOPE    | GUINESS      | 2006-02-15 04:34:33 |
|        2 | NICK        | WAHLBERG     | 2006-02-15 04:34:33 |
|      ...                                                    |
|      198 | MARY        | KEITEL       | 2006-02-15 04:34:33 |
|      199 | JULIA       | FAWCETT      | 2006-02-15 04:34:33 |
|      200 | THORA       | TEMPLE       | 2006-02-15 04:34:33 |
|      201 | PENELOPE    | GUINESS      | 2021-02-28 10:24:49 |
|      202 | NICK        | WAHLBERG     | 2021-02-28 10:24:49 |
|      ...                                                    |
|      398 | MARY        | KEITEL       | 2021-02-28 10:24:49 |
|      399 | JULIA       | FAWCETT      | 2021-02-28 10:24:49 |
|      400 | THORA       | TEMPLE       | 2021-02-28 10:24:49 |
+----------+-------------+--------------+---------------------+
400 rows in set (0.00 sec)

您可以看到actor_id从 201 开始,姓氏、名字和last_update值开始重复。

INSERT SELECT语句中还可以使用子查询。例如,下一条语句是有效的:

*INSERT INTO actor SELECT * FROM*
*(SELECT actor_id+400, first_name, last_name, NOW() FROM actor) foo;*

从逗号分隔文件加载数据

如今,数据库通常不再是一个事后补救的问题。它们无处不在,比以往任何时候都更容易使用,大多数 IT 专业人员都知道它们。尽管如此,最终用户发现它们很难使用,除非创建了专门的用户界面,否则大量的数据输入和分析将在各种电子表格程序中进行。这些程序通常具有独特的文件格式,开放或封闭,但它们大多数允许您将数据导出为逗号分隔的值(CSV),也称为 逗号分隔格式。然后,您可以通过一点努力将数据导入 MySQL。

另一个常见的通过处理 CSV 完成的任务是在异构环境中传输数据。如果您的设置中运行着各种数据库软件,尤其是在云中使用 DBaaS,那么在这些系统之间传输数据可能会让人望而却步。然而,基本的 CSV 数据可以成为它们的最低公共分母。请注意,在任何数据传输的情况下,您应始终记住 CSV 没有模式、数据类型或约束的概念。但作为一种扁平的数据文件格式,它表现良好。

如果您不使用电子表格程序,通常仍然可以使用命令行工具,如 sedawk —— 这些是非常古老和强大的 Unix 实用工具 —— 将文本数据转换为适合 MySQL 导入的 CSV 格式。一些云数据库允许直接将其数据导出为 CSV。在其他一些情况下,可能需要编写小程序来读取数据并生成 CSV 文件。本节将向您展示如何将 CSV 数据导入 MySQL 的基础知识。

让我们通过一个例子来详细说明。我们有一个包含 NASA 设施及其地址和联系信息的列表,我们希望将其存储在一个数据库中。目前,它存储在一个名为 NASA_Facilities.csv 的 CSV 文件中,并且其格式如 图 7-1 所示。

lm2e 0701

图 7-1. 存储在电子表格文件中的 NASA 设施列表

您可以看到,每个设施都与一个中心相关联,可能列出了其占用日期及可选的状态。完整的列列表如下:

  • 中心

  • 中心搜索状态

  • 设施

  • FacilityURL

  • 占用

  • 状态

  • URL 链接

  • 记录日期

  • 最后更新

  • 国家

  • 联系人

  • Phone

  • 位置

  • 城市

  • 邮政编码

该示例直接来自 NASA 的公开数据门户 Open Data Portal,文件在本书的 GitHub 仓库 中可用。由于这已经是一个 CSV 文件,我们不需要将其从其他文件格式(如 XLS)转换过来。但是,如果您在自己的项目中需要这样做,通常只需使用电子表格程序的“另存为”命令即可;只需别忘记选择 CSV 作为输出格式。

如果您使用文本编辑器打开NASA_facilities.csv文件,您会看到每个电子表格行都有一行,每列的值由逗号分隔。如果您在非 Windows 平台上,可能会发现在某些 CSV 文件中,每行以^M结尾,但不必担心这一点;这是 Windows 起源的遗留物。这种格式的数据通常称为DOS 格式,大多数软件应用程序可以处理它而无需问题。在我们的情况下,数据处于Unix 格式,因此在 Windows 上,您可能会看到所有行都被连接在一起。如果情况如此,您可以尝试使用另一个文本编辑器。以下是从NASA_Facilities.csv中选择的一些宽度截断行:

Center,Center Search Status,Facility,FacilityURL,Occupied,Status,...
Kennedy Space Center,Public,Control Room 2/1726/HGR-S ,,...
Langley Research Center,Public,Micometeroid/LDEF Analysis Laboratory,,...
Kennedy Space Center,Public,SRM Rotation and Processing Facility/K6-0494 ,...
Marshall Space Flight Center,..."35812(34.729538, -86.585283)",Huntsville,...

如果值中有逗号或其他特殊符号,则整个值将用引号括起来,如所示的最后一行。

让我们将这些数据导入到 MySQL 中。首先,创建新的nasa数据库:

mysql> `CREATE` `DATABASE` `nasa``;`
Query OK, 1 row affected (0.01 sec)

选择此数据库作为活动数据库:

mysql> `USE` `nasa``;`
Database changed

现在,创建facilities表来存储数据。这需要处理 CSV 文件中看到的所有字段,该文件方便地具有标题:

mysql> `CREATE` `TABLE` `facilities` `(`
    ->   `center` `TEXT``,`
    ->   `center_search_status` `TEXT``,`
    ->   `facility` `TEXT``,`
    ->   `facility_url` `TEXT``,`
    ->   `occupied` `TEXT``,`
    ->   `status` `TEXT``,`
    ->   `url_link` `TEXT``,`
    ->   `record_date` `DATETIME``,`
    ->   `last_update` `TIMESTAMP` `NULL``,`
    ->   `country` `TEXT``,`
    ->   `contact` `TEXT``,`
    ->   `phone` `TEXT``,`
    ->   `location` `TEXT``,`
    ->   `city` `TEXT``,`
    ->   `state` `TEXT``,`
    ->   `zipcode` `TEXT`
    -> `)``;`
Query OK, 0 rows affected (0.03 sec)

这里我们在数据类型上有些作弊。NASA 提供了数据集的模式,但对于大多数字段,类型都被给定为“纯文本”,而我们实际上无法将“网站 URL”存储为除文本之外的任何东西。然而,我们并不知道每个列会容纳多少数据。因此,我们默认使用TEXT类型,这类似于将列定义为VARCHAR(65535)。两种类型之间存在一些差异,正如您可能从“字符串类型”中记得的那样,但在这个例子中并不重要。我们不定义任何索引,也不在表上设置任何约束。如果您要加载一个完全新的数据集,它相当小,先加载它然后再进行分析可能会有益。对于较大的数据集,请确保表结构尽可能良好,否则您将花费相当多的时间来修改它。

现在我们已经设置了数据库表,可以使用LOAD DATA INFILE命令从文件中导入数据:

mysql> `LOAD` `DATA` `INFILE` `'NASA_Facilities.csv'` `INTO` `TABLE` `facilities`
    -> `FIELDS` `TERMINATED` `BY` `','``;`
ERROR 1290 (HY000): The MySQL server is running with
the --secure-file-priv option so it cannot execute this statement

哦,不!我们遇到了一个错误。默认情况下,MySQL 不允许您使用LOAD DATA INFILE命令加载任何数据。该行为由secure_file_priv系统变量控制。如果该变量设置了路径,则要加载的文件应位于该特定路径中,并且 MySQL 服务器应能够读取它。如果未设置该变量,则认为它是不安全的,那么要加载的文件应仅由 MySQL 服务器可读取。默认情况下,Linux 上的 MySQL 8.0 设置该变量如下:

mysql> `SELECT` `@``@``secure_file_priv``;`
+-----------------------+
| @@secure_file_priv    |
+-----------------------+
| /var/lib/mysql-files/ |
+-----------------------+
1 row in set (0.00 sec)

在 Windows 上:

mysql> `SELECT` `@``@``secure_file_priv``;`
+------------------------------------------------+
| @@secure_file_priv                             |
+------------------------------------------------+
| C:\ProgramData\MySQL\MySQL Server 8.0\Uploads\ |
+------------------------------------------------+
1 row in set (0.00 sec)
注意

secure_file_priv系统变量的值可能因您的 MySQL 安装而异,甚至可能为空。对于secure_file_privNULL值意味着 MySQL 将允许在任何位置加载文件,只要 MySQL 服务器能访问到该文件。在 Linux 上,这意味着文件必须对mysqld进程可读,通常该进程以mysql用户身份运行。您可以通过更新 MySQL 配置并重新启动服务器来更改secure_file_priv变量的值。有关如何配置 MySQL 的信息,请参阅第九章。

在 Linux 或其他类 Unix 系统上,您需要将文件复制到该目录中,可能需要使用sudo以允许该操作,然后更改其权限,以便mysqld程序可以访问该文件。在 Windows 上,您只需将文件复制到正确的目标位置即可。

让我们开始吧。在 Linux 或类似系统上,您可以运行如下命令:

$ ls -lh $HOME/Downloads/NASA_Facilities.csv
-rw-r--r--. 1 skuzmichev skuzmichev 114K
    Feb 28 14:19 /home/skuzmichev/Downloads/NASA_Facilities.csv
$ sudo cp -vip ~/Downloads/NASA_Facilities.csv /var/lib/mysql-files
[sudo] password for skuzmichev:
'/home/skuzmichev/Downloads/NASA_Facilities.csv'
    -> '/var/lib/mysql-files/NASA_Facilities.csv'
$ sudo chown mysql:mysql /var/lib/mysql-files/NASA_Facilities.csv
$ sudo ls -lh /var/lib/mysql-files/NASA_Facilities.csv
-rw-r--r--. 1 mysql mysql 114K
    Feb 28 14:19 /var/lib/mysql-files/NASA_Facilities.csv

在 Windows 上,您可以使用文件管理器复制或移动文件。

现在我们准备再次尝试加载。当目标文件不在当前目录中时,我们需要将完整路径传递给命令:

mysql> `LOAD` `DATA` `INFILE` `'/var/lib/mysql-files/NASA_Facilities.csv'`
    -> `INTO` `TABLE` `facilities` `FIELDS` `TERMINATED` `BY` `','``;`
ERROR 1292 (22007): Incorrect datetime value:
'Record Date' for column 'record_date' at row 1

嗯,看起来不对:Record Date确实不是日期,而是列名。我们犯了一个愚蠢但常见的错误,加载包含标题的 CSV 文件。我们需要告诉 MySQL 省略它:

mysql> `LOAD` `DATA` `INFILE` `'/var/lib/mysql-files/NASA_Facilities.csv'`
    -> `INTO` `TABLE` `facilities` `FIELDS` `TERMINATED` `BY` `','`
    -> `IGNORE` `1` `LINES``;`
ERROR 1292 (22007): Incorrect datetime value:
'03/01/1996 12:00:00 AM' for column 'record_date' at row 1

结果表明,我们拥有的日期格式不符合 MySQL 的期望。这是一个非常常见的问题。有几种解决方法。首先,我们可以将record_date列更改为TEXT类型。我们将失去正确日期时间数据类型的优势,但我们将能够将数据导入数据库。其次,我们可以在数据从文件中摄取时进行转换。为了展示结果的不同,我们指定了occupied列(这是一个日期字段)为TEXT。尽管我们将进入转换的复杂性之前,让我们尝试在 Windows 上运行相同的命令:

mysql> `LOAD` `DATA` `INFILE`
    -> `'C:\ProgramData\MySQL\MySQL Server 8.0\Uploads\NASA_Facilities.csv'`
    -> `INTO` `TABLE` `facilities` `FIELDS` `TERMINATED` `BY` `','``;`
ERROR 1290 (HY000): The MySQL server is running with
the --secure-file-priv option so it cannot execute this statement

即使文件存在于该目录中,LOAD DATA INFILE也会报错。这是因为 MySQL 在 Windows 上处理路径的方式。我们不能简单地使用常规的 Windows 风格路径来执行此或其他 MySQL 命令。我们需要用另一个反斜杠(\)转义每个反斜杠,或者改用正斜杠(/)作为路径。两者都可以……或者说,在这种情况下,两者都会由于预期的record_date转换问题而报错:

mysql> `LOAD` `DATA` `INFILE`
    -> `'C:\\ProgramData\\MySQL\\MySQL Server 8.0\\Uploads\\NASA_Facilities.csv'`
    -> `INTO` `TABLE` `facilities` `FIELDS` `TERMINATED` `BY` `','``;`
ERROR 1292 (22007): Incorrect datetime value:
'Record Date' for column 'record_date' at row 1
mysql> `LOAD` `DATA` `INFILE`
    -> `'C:/ProgramData/MySQL/MySQL Server 8.0/Uploads/NASA_Facilities.csv'`
    -> `INTO` `TABLE` `facilities` `FIELDS` `TERMINATED` `BY` `','``;`
ERROR 1292 (22007): Incorrect datetime value:
'Record Date' for column 'record_date' at row 1

说完这些,让我们回到日期转换问题上。正如我们所提到的,这是一个极为常见的问题。您将不可避免地遇到类型转换问题,因为 CSV 是无类型的,不同的数据库对各种类型有不同的期望。在这种情况下,我们获得的开放数据集中的日期格式如下:03/01/1996 12:00:00 AM。尽管这将使我们的操作更加复杂,但我们认为从 CSV 文件中转换日期值是一个很好的练习。为了将任意字符串转换为日期,或至少尝试这样的转换,我们可以使用STR_TO_DATE()函数。在查阅了文档后,我们得出了以下转换:

mysql> `SELECT` `STR_TO_DATE``(``'03/01/1996 12:00:00 AM'``,`
    -> `'%m/%d/%Y %h:%i:%s %p'``)` `converted``;`
+---------------------+
| converted           |
+---------------------+
| 1996-03-01 00:00:00 |
+---------------------+
1 row in set (0.01 sec)

由于函数在转换失败时返回NULL,我们知道我们已经成功找到了正确的调用方法。现在我们需要找出如何在LOAD DATA INFILE命令中使用该函数。使用函数的更长版本看起来像这样:

mysql> `LOAD` `DATA` `INFILE` `'/var/lib/mysql-files/NASA_Facilities.csv'`
    -> `INTO` `TABLE` `facilities` `FIELDS` `TERMINATED` `BY` `','`
    -> `OPTIONALLY` `ENCLOSED` `BY` `'"'`
    -> `IGNORE` `1` `LINES`
    -> `(``center``,` `center_search_status``,` `facility``,` `facility_url``,`
    -> `occupied``,` `status``,` `url_link``,` `@``var_record_date``,` `@``var_last_update``,`
    -> `country``,` `contact``,` `phone``,` `location``,` `city``,` `state``,` `zipcode``)`
    -> `SET` `record_date` `=` `IF``(`
    ->   `CHAR_LENGTH``(``@``var_record_date``)``=``0``,` `NULL``,`
    ->     `STR_TO_DATE``(``@``var_record_date``,` `'%m/%d/%Y %h:%i:%s %p'``)`
    -> `)``,`
    -> `last_update` `=` `IF``(`
    ->   `CHAR_LENGTH``(``@``var_last_update``)``=``0``,` `NULL``,`
    ->     `STR_TO_DATE``(``@``var_last_update``,` `'%m/%d/%Y %h:%i:%s %p'``)`
    -> `)``;`
Query OK, 485 rows affected (0.05 sec)
Records: 485  Deleted: 0  Skipped: 0  Warnings: 0

这是一条很长的命令!让我们来分解一下。第一行指定了我们的LOAD DATA INFILE命令及文件路径。第二行指定了目标表,并开始FIELDS规范,以TERMINATED BY ','开头,这表示我们的字段是以逗号分隔的,符合 CSV 的预期。第三行在FIELDS规范中添加了另一个参数,并告诉 MySQL 一些字段(但不是所有字段)由"符号包围。这很重要,因为我们的数据集中有些条目在"..."字段内包含逗号。在第四行,我们指定跳过文件的第一行,我们知道标题位于其中。

第 5 至 7 行是列列表规范。我们需要转换两个日期时间列,并且为此需要将它们的值读入变量,然后将变量设置为nasa.facilities表的列值。然而,如果我们没有同时指定所有其他列或者顺序不正确,MySQL 将无法正确分配值。CSV 本质上是基于位置的格式。默认情况下,当未给出FIELDS规范时,MySQL 将读取每个 CSV 行,并期望所有行中的每个字段按照目标表中列的顺序(由DESCRIBESHOW CREATE TABLE命令给出)映射到列中。通过更改此规范中的列顺序,我们可以从具有字段错位的 CSV 文件填充表。通过指定较少的列,我们可以从缺少某些字段的文件中填充表。

第 8 至 15 行是我们的函数调用,用于转换日期时间值。在前面的列规范中,我们定义了字段 8 被读入 @var_record_date 变量,字段 9 被读入 @var_last_update。我们知道字段 8 和 9 是我们的问题日期时间字段。有了填充的变量,我们可以定义 SET 参数,根据从 CSV 文件中读取的字段修改目标表列的值。在这个非常基本的示例中,您可以将特定值乘以二。在我们的情况下,我们执行了两个函数转换:首先,我们检查一个变量是否为空(在 CSV 中是 ,,),通过评估从文件中读取的字符数,然后,如果前面的检查不返回零,则调用实际的转换。如果我们发现长度为零,则将该值设置为 NULL

最后,执行命令后,可以检查结果:

mysql> `SELECT` `facility``,` `occupied``,` `last_update`
    -> `FROM` `facilities`
    -> `ORDER` `BY` `last_update` `DESC` `LIMIT` `5``;`
+---------------------...+------------------------+---------------------+
| facility            ...| occupied               | last_update         |
+---------------------...+------------------------+---------------------+
| Turn Basin/K7-1005  ...| 01/01/1963 12:00:00 AM | 2015-06-22 00:00:00 |
| RPSF Surge Building ...| 01/01/1984 12:00:00 AM | 2015-06-22 00:00:00 |
| Thermal Protection S...| 01/01/1988 12:00:00 AM | 2015-06-22 00:00:00 |
| Intermediate Bay/M7-...| 01/01/1995 12:00:00 AM | 2015-06-22 00:00:00 |
| Orbiter Processing F...| 01/01/1987 12:00:00 AM | 2015-06-22 00:00:00 |
+---------------------...+------------------------+---------------------+
5 rows in set (0.00 sec)

记住我们提到过 occupied 保持为 TEXT。您可以在此处看到。虽然它可用于排序,但此列中的值如果未显式转换为 DATETIME,则无法使用日期函数。

这是一个复杂的例子,但它显示了加载数据的意外复杂性以及 LOAD DATA INFILE 命令的强大功能。

将数据写入逗号分隔的文件

您可以使用 SELECT INTO OUTFILE 语句将查询结果写入 CSV 文件,该文件可以被电子表格或其他程序打开。

让我们将当前 employees 数据库中的管理者列表导出为 CSV 文件。用于列出所有当前管理者的查询如下所示:

mysql> `USE` `employees``;`
Database changed
mysql> `SELECT` `emp_no``,` `first_name``,` `last_name``,` `title``,` `from_date`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'` `AND` `to_date` `=` `'9999-01-01'``;`
+--------+------------+------------+---------+------------+
| emp_no | first_name | last_name  | title   | from_date  |
+--------+------------+------------+---------+------------+
| 110039 | Vishwani   | Minakawa   | Manager | 1991-10-01 |
| 110114 | Isamu      | Legleitner | Manager | 1989-12-17 |
| 110228 | Karsten    | Sigstam    | Manager | 1992-03-21 |
| 110420 | Oscar      | Ghazalie   | Manager | 1996-08-30 |
| 110567 | Leon       | DasSarma   | Manager | 1992-04-25 |
| 110854 | Dung       | Pesch      | Manager | 1994-06-28 |
| 111133 | Hauke      | Zhang      | Manager | 1991-03-07 |
| 111534 | Hilary     | Kambil     | Manager | 1991-04-08 |
| 111939 | Yuchang    | Weedman    | Manager | 1996-01-03 |
+--------+------------+------------+---------+------------+
9 rows in set (0.13 sec)

我们可以稍微修改这个 SELECT 查询,以将这些数据写入一个输出文件作为逗号分隔值。 INTO OUTFILELOAD DATA INFILE 相同的 --secure-file-priv 选项规则限制。默认情况下,文件路径是有限的,并且我们在 “从逗号分隔的文件加载数据” 中列出了默认选项:

mysql> `SELECT` `emp_no``,` `first_name``,` `last_name``,` `title``,` `from_date`
    -> `FROM` `employees` `JOIN` `titles` `USING` `(``emp_no``)`
    -> `WHERE` `title` `=` `'Manager'` `AND` `to_date` `=` `'9999-01-01'`
    -> `INTO` `OUTFILE` `'/var/lib/mysql-files/managers.csv'`
    -> `FIELDS` `TERMINATED` `BY` `','``;`
Query OK, 9 rows affected (0.14 sec)

我们将结果保存在 /var/lib/mysql-files 目录下的文件 managers.csv 中;MySQL 服务器必须能够写入您指定的目录,并且它应该是 secure_file_priv 系统变量中列出的一个目录(如果已设置)。在 Windows 系统上,请指定类似 C:\ProgramData\MySQL\MySQL Server 8.0\Uploads\managers.csv 的路径。如果省略了 FIELDS TERMINATED BY 子句,服务器将使用制表符作为数据值之间的默认分隔符。

您可以在文本编辑器中查看 managers.csv 文件的内容,或将其导入电子表格程序中:

110039,Vishwani,Minakawa,Manager,1991-10-01
110114,Isamu,Legleitner,Manager,1989-12-17
110228,Karsten,Sigstam,Manager,1992-03-21
110420,Oscar,Ghazalie,Manager,1996-08-30
110567,Leon,DasSarma,Manager,1992-04-25
110854,Dung,Pesch,Manager,1994-06-28
111133,Hauke,Zhang,Manager,1991-03-07
111534,Hilary,Kambil,Manager,1991-04-08
111939,Yuchang,Weedman,Manager,1996-01-03

当我们的数据字段包含逗号或其他我们选择的分隔符时,默认情况下,MySQL 将会转义字段中的分隔符。让我们切换到 sakila 数据库并测试这一点:

mysql> `USE` `sakila``;`
Database changed
mysql> `SELECT` `title``,` `special_features` `FROM` `film` `LIMIT` `10`
    -> `INTO` `OUTFILE` `'/var/lib/mysql-files/film.csv'`
    -> `FIELDS` `TERMINATED` `BY` `','``;`
Query OK, 10 rows affected (0.00 sec)

如果您现在查看 film.csv 文件中的数据(再次,可以使用文本编辑器、电子表格程序或 Linux 上的 head 命令),您将看到以下内容:

ACADEMY DINOSAUR,Deleted Scenes\,Behind the Scenes
ACE GOLDFINGER,Trailers\,Deleted Scenes
ADAPTATION HOLES,Trailers\,Deleted Scenes
AFFAIR PREJUDICE,Commentaries\,Behind the Scenes
AFRICAN EGG,Deleted Scenes
AGENT TRUMAN,Deleted Scenes
AIRPLANE SIERRA,Trailers\,Deleted Scenes
AIRPORT POLLOCK,Trailers
ALABAMA DEVIL,Trailers\,Deleted Scenes
ALADDIN CALENDAR,Trailers\,Deleted Scenes

请注意,在第二个字段包含逗号的行中,逗号已自动使用反斜杠进行转义,以区分其与分隔符。某些电子表格程序可能会理解这一点,并在导入文件时移除反斜杠,而有些则不会。MySQL 将尊重转义并不将这样的逗号视为分隔符。请注意,如果我们指定了 FIELDS TERMINATED BY '^',则所有字段中的 ^ 符号将会被转义;这并不局限于逗号。

由于并非所有程序都可以优雅地处理转义字符,我们可以请求 MySQL 使用 ENCLOSED 选项显式定义字段:

mysql> `SELECT` `title``,` `special_features` `FROM` `film` `LIMIT` `10`
    -> `INTO` `OUTFILE` `'/var/lib/mysql-files/film_quoted.csv'`
    -> `FIELDS` `TERMINATED` `BY` `','` `ENCLOSED` `BY` `'"'``;`
Query OK, 10 rows affected (0.00 sec)

我们在加载数据时曾使用过此选项。查看文件 film_quoted.csv 中的结果。

"ACADEMY DINOSAUR","Deleted Scenes,Behind the Scenes"
"ACE GOLDFINGER","Trailers,Deleted Scenes"
"ADAPTATION HOLES","Trailers,Deleted Scenes"
"AFFAIR PREJUDICE","Commentaries,Behind the Scenes"
"AFRICAN EGG","Deleted Scenes"
"AGENT TRUMAN","Deleted Scenes"
"AIRPLANE SIERRA","Trailers,Deleted Scenes"
"AIRPORT POLLOCK","Trailers"
"ALABAMA DEVIL","Trailers,Deleted Scenes"
"ALADDIN CALENDAR","Trailers,Deleted Scenes"

我们的分隔符—逗号—现在不再被转义,这可能更适合现代电子表格程序。您可能会想知道,如果导出字段中包含双引号会发生什么:MySQL 将会转义这些引号而不是逗号,这可能会再次引起问题。在进行数据导出时,请务必确保生成的输出对您的消费者有效。

使用查询创建表格

您可以使用查询创建表格或轻松创建表格的副本。这在您希望使用现有数据构建新数据库时非常有用,例如,您可能希望复制一些国家的列表,或者在某种原因下重新组织数据。数据重新组织在生成报告、合并两个或更多表的数据以及动态重设计时非常常见。本小节展示了如何操作。

提示

我们所有的示例都基于未修改的 sakila 数据库。在继续之前,请重复执行 “实体关系建模示例” 中给出的步骤,使数据库恢复到其干净的状态。

在 MySQL 中,您可以使用 CREATE TABLE 语法的变体轻松复制表的结构:

mysql> `USE` `sakila``;`
Database changed
mysql> `CREATE` `TABLE` `actor_2` `LIKE` `actor``;`
Query OK, 0 rows affected (0.24 sec)
mysql> `DESCRIBE` `actor_2``;`
+-------------+-------------------+------+-----+...
| Field       | Type              | Null | Key |...
+-------------+-------------------+------+-----+...
| actor_id    | smallint unsigned | NO   | PRI |...
| first_name  | varchar(45)       | NO   |     |...
| last_name   | varchar(45)       | NO   | MUL |...
| last_update | timestamp         | NO   |     |...
+-------------+-------------------+------+-----+...
...+-------------------+-----------------------------------------------+
...| Default           | Extra                                         |
...+-------------------+-----------------------------------------------+
...| NULL              | auto_increment                                |
...| NULL              |                                               |
...| NULL              |                                               |
...| CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
...+-------------------+-----------------------------------------------+
4 rows in set (0.01 sec)
mysql> `SELECT` `*` `FROM` `actor_2``;`
Empty set (0.00 sec)

LIKE 语法允许您创建一个新表,其结构与另一个表完全相同,包括键。可以看到,它不会复制数据。您还可以在此语法中使用 IF NOT EXISTSTEMPORARY 特性。

如果您想创建一个表并复制一些数据,您可以结合使用 CREATE TABLESELECT 语句来实现。让我们删除 actor_2 表,并使用这种新方法重新创建它:

mysql> `DROP` `TABLE` `actor_2``;`
Query OK, 0 rows affected (0.08 sec)
mysql> `CREATE` `TABLE` `actor_2` `AS` `SELECT` `*` `from` `actor``;`
Query OK, 200 rows affected (0.03 sec)
Records: 200  Duplicates: 0  Warnings: 0
mysql> `SELECT` `*` `FROM` `actor_2` `LIMIT` `5``;`
+----------+------------+--------------+---------------------+
| actor_id | first_name | last_name    | last_update         |
+----------+------------+--------------+---------------------+
|        1 | PENELOPE   | GUINESS      | 2006-02-15 04:34:33 |
|        2 | NICK       | WAHLBERG     | 2006-02-15 04:34:33 |
|        3 | ED         | CHASE        | 2006-02-15 04:34:33 |
|        4 | JENNIFER   | DAVIS        | 2006-02-15 04:34:33 |
|        5 | JOHNNY     | LOLLOBRIGIDA | 2006-02-15 04:34:33 |
+----------+------------+--------------+---------------------+
5 rows in set (0.01 sec)

通过 SELECT 语句创建了一个完全相同的 actor_2 表,并将所有数据复制过去。CREATE TABLE AS SELECTCTAS 是此操作的常见名称,但实际上并非必须指定 AS 部分,稍后我们会省略它。

这种技术非常强大。您可以创建具有新结构的新表,并使用强大的查询将其填充数据。例如,这里是一个 report 表,用于包含我们数据库中电影的名称及其类别的信息:

mysql> `CREATE` `TABLE` `report` `(``title` `VARCHAR``(``128``)``,` `category` `VARCHAR``(``25``)``)`
    -> `SELECT` `title``,` `name` `AS` `category` `FROM`
    -> `film` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `JOIN` `category` `USING` `(``category_id``)``;`
Query OK, 1000 rows affected (0.06 sec)
Records: 1000  Duplicates: 0  Warnings: 0

你可以看到,这个语法与前面的例子有些不同。在这个例子中,新的表名report后面跟着一个列名和类型的列表,用括号括起来;这是必要的,因为我们不是复制现有表的结构。此外,我们实际上将name改为了category。然后,SELECT语句跟着,其输出与新表中的新列匹配。你可以检查新表的内容来查看结果:

mysql> `SELECT` `*` `FROM` `report` `LIMIT` `5``;`
+---------------------+----------+
| title               | category |
+---------------------+----------+
| AMADEUS HOLY        | Action   |
| AMERICAN CIRCUS     | Action   |
| ANTITRUST TOMATOES  | Action   |
| ARK RIDGEMONT       | Action   |
| BAREFOOT MANCHURIAN | Action   |
+---------------------+----------+
5 rows in set (0.00 sec)

因此,在这个例子中,SELECT语句中的titlename值用于填充report表中的新titlecategory列。

使用查询创建表有一个主要的注意事项,你需要注意:它不会复制索引(或外键,如果使用)。这是一个特性,因为它给你很大的灵活性,但如果你忘记了,它可能是一个陷阱。看看我们的actor_2示例:

mysql> `DESCRIBE` `actor_2``;`
+-------------+-------------------+------+-----+...
| Field       | Type              | Null | Key |...
+-------------+-------------------+------+-----+...
| actor_id    | smallint unsigned | NO   |     |...
| first_name  | varchar(45)       | NO   |     |...
| last_name   | varchar(45)       | NO   |     |...
| last_update | timestamp         | NO   |     |...
+-------------+-------------------+------+-----+...
...+-------------------+-----------------------------------------------+
...| Default           | Extra                                         |
...+-------------------+-----------------------------------------------+
...| 0                 |                                               |
...| NULL              |                                               |
...| NULL              |                                               |
...| CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
...+-------------------+-----------------------------------------------+
4 rows in set (0.00 sec)
mysql> `SHOW` `CREATE` `TABLE` `actor_2``\``G`
*************************** 1\. row ***************************
       Table: actor_2
Create Table: CREATE TABLE `actor_2` (
  `actor_id` smallint unsigned NOT NULL DEFAULT '0',
  `first_name` varchar(45) NOT NULL,
  `last_name` varchar(45) NOT NULL,
  `last_update` timestamp NOT NULL
    DEFAULT CURRENT_TIMESTAMP
    ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

你可以看到这里没有主键;idx_actor_last_name键也丢失了,actor_id列的AUTO_INCREMENT属性也没有了。

要将索引复制到新表中,至少有三件事情可以做。第一种是使用LIKE语句创建带有索引的空表,如前面描述的那样,然后使用带有SELECT语句的INSERT将数据复制到新表中,如“使用查询插入数据”所述。

第二件事情是使用带有SELECT语句的CREATE TABLE,然后使用ALTER TABLE添加索引,如第四章所述。

第三个选项是结合CREATE TABLESELECT使用UNIQUE(或PRIMARY KEYKEY)关键字来添加主键索引。以下是此方法的示例:

mysql> `DROP` `TABLE` `actor_2``;`
Query OK, 0 rows affected (0.04 sec)
mysql> `CREATE` `TABLE` `actor_2` `(``UNIQUE``(``actor_id``)``)`
    -> `AS` `SELECT` `*` `from` `actor``;`
Query OK, 200 rows affected (0.05 sec)
Records: 200  Duplicates: 0  Warnings: 0
mysql> `DESCRIBE` `actor_2``;`
+-------------+-------------------+------+-----+...
| Field       | Type              | Null | Key |...
+-------------+-------------------+------+-----+...
| actor_id    | smallint unsigned | NO   | PRI |...
| first_name  | varchar(45)       | NO   |     |...
| last_name   | varchar(45)       | NO   |     |...
| last_update | timestamp         | NO   |     |...
+-------------+-------------------+------+-----+...
...+-------------------+-----------------------------------------------+
...| Default           | Extra                                         |
...+-------------------+-----------------------------------------------+
...| 0                 |                                               |
...| NULL              |                                               |
...| NULL              |                                               |
...| CURRENT_TIMESTAMP | DEFAULT_GENERATED on update CURRENT_TIMESTAMP |
...+-------------------+-----------------------------------------------+
4 rows in set (0.01 sec)

UNIQUE关键字应用于actor_id列,使其成为新创建表中的主键。关键字UNIQUEPRIMARY KEY可以互换使用。

当你使用这些技术创建表时,可以使用不同的修饰符。例如,这里是使用默认和其他设置创建的表:

mysql> `CREATE` `TABLE` `actor_3` `(`
    ->   `actor_id` `SMALLINT` `UNSIGNED` `NOT` `NULL` `AUTO_INCREMENT``,`
    ->   `first_name` `VARCHAR``(``45``)` `NOT` `NULL``,`
    ->   `last_name` `VARCHAR``(``45``)` `NOT` `NULL``,`
    ->   `last_update` `TIMESTAMP` `NOT` `NULL`
    ->     `DEFAULT` `CURRENT_TIMESTAMP` `ON` `UPDATE` `CURRENT_TIMESTAMP``,`
    ->   `PRIMARY` `KEY` `(``actor_id``)``,`
    ->   `KEY` `idx_actor_last_name` `(``last_name``)`
    -> `)` `SELECT` `*` `FROM` `actor``;`
Query OK, 200 rows affected (0.05 sec)
Records: 200  Duplicates: 0  Warnings: 0

在这里,我们为新列设置了NOT NULL,在actor_id上使用了AUTO_INCREMENT特性,并创建了两个键。在常规CREATE TABLE语句中可以做的任何事情,在这个变体中都可以做到;只需记住显式添加这些索引即可!

使用多个表执行更新和删除

在第三章中,我们向你展示了如何更新和删除数据。在那里的例子中,每次更新和删除都影响一个表,并使用该表的属性来决定要修改的内容。本节将向您展示更复杂的更新和删除操作。正如您将看到的,您可以在一个语句中从多个表中删除或更新行,并且您可以使用那些表或其他表来决定要更改哪些行。

删除

想象一下你正在清理数据库,也许是因为你的空间不够了。解决这个问题的一种方法是删除一些数据。例如,在sakila数据库中,删除那些在我们的库存中存在但从未被租出的电影可能是有意义的。不幸的是,这意味着你需要使用rental表中的信息从inventory表中删除数据。

到目前为止,在本书中我们描述的技术中,没有办法做到这一点,而不创建一个合并两个表的表(可能使用INSERTSELECT),删除不需要的行,并将数据复制回其源。本节展示了如何执行此过程以及其他更高级的删除类型。

考虑你需要写的查询,以查找inventory表中从未出租过的电影。一种方法是使用嵌套查询,采用我们在第五章中展示的技术,带有NOT EXISTS子句。以下是查询:

mysql> `SELECT` `*` `FROM` `inventory` `WHERE` `NOT` `EXISTS`
    -> `(``SELECT` `1` `FROM` `rental` `WHERE`
    -> `rental``.``inventory_id` `=` `inventory``.``inventory_id``)``;`
+--------------+---------+----------+---------------------+
| inventory_id | film_id | store_id | last_update         |
+--------------+---------+----------+---------------------+
|            5 |       1 |        2 | 2006-02-15 05:09:17 |
+--------------+---------+----------+---------------------+
1 row in set (0.01 sec)

你可能已经看到它是如何工作的,但在我们继续之前,让我们简要讨论一下。你可以看到,这个查询使用了一个相关子查询,在外部查询中正在处理的当前行被子查询引用;你可以看出这一点,因为从inventory中的inventory_id列被引用,但inventory表没有列在子查询的FROM子句中。当在rental表中有一行与外部查询中的当前行匹配时(因此出租了库存条目),子查询会生成输出。然而,由于查询使用了NOT EXISTS,当出现这种情况时,外部查询不会生成输出,因此总体结果是为未出租的电影库存记录输出行。

现在让我们将我们的查询转换为一个DELETE语句。这里是它:

mysql> `DELETE` `FROM` `inventory` `WHERE` `NOT` `EXISTS`
    -> `(``SELECT` `1` `FROM` `rental` `WHERE`
    -> `rental``.``inventory_id` `=` `inventory``.``inventory_id``)``;`
Query OK, 1 row affected (0.04 sec)

你可以看到子查询保持不变,但外部的SELECT查询被DELETE语句替换。在这里,我们遵循标准的DELETE语法:关键字DELETE之后是FROM和应该删除行的表的规范,然后是WHERE子句(以及其他查询子句,如GROUP BYHAVING)。在此查询中,从inventory表中删除行,但在WHERE子句中指定了一个NOT EXISTS语句内的子查询。

虽然这个语句确实根据另一张表中的数据删除行,但它基本上是一个常规DELETE的变体。要将此特定语句转换为多表DELETE,我们应该从嵌套子查询切换到LEFT JOIN,如下所示:

`DELETE` `inventory` `FROM` `inventory` `LEFT` `JOIN` `rental`
`USING` `(``inventory_id``)` `WHERE` `rental``.``inventory_id` `IS` `NULL``;`

注意语法如何更改以包括我们希望删除找到的行的特定表(或表)。这些表在DELETE之后但在FROM和查询规范之前指定。然而,有另一种写这个查询的方式,这是我们更喜欢的一种:

`DELETE` `FROM` `inventory` `USING` `inventory`
`LEFT` `JOIN` `rental` `USING` `(``inventory_id``)`
`WHERE` `rental``.``inventory_id` `IS` `NULL``;`

此查询是前两个查询的混合体。我们在DELETEFROM之间没有指定删除目标,并将它们写成了一个常规的删除。相反,我们使用了一个特殊的USING子句,指示接下来将使用一个过滤查询(连接或其他方式)。我们认为这比之前的DELETE table FROM table稍微清晰一些。使用USING关键字的一个缺点是,它可能会与JOIN语句的USING关键字混淆。然而,通过一些实践,你绝对不会犯这种错误。

现在我们知道了两种多表语法变体,我们可以构造一个确实需要多表删除的查询。需要这样一个语句的一个例子是从涉及外键关系的表中删除记录。在sakila数据库中,有一些在film表中有记录但在inventory表中没有关联记录的电影。也就是说,有些电影有信息,但不能被租用。假设作为数据库清理操作的一部分,我们的任务是删除这样的悬空数据。最初看起来这似乎很简单:

mysql> `DELETE` `FROM` `film` `WHERE` `NOT` `EXISTS`
    -> `(``SELECT` `1` `FROM` `inventory` `WHERE`
    -> `film``.``film_id` `=` `inventory``.``film_id``)``;`
ERROR 1451 (23000): Cannot delete or update a parent row:
a foreign key constraint fails (
`sakila`.`film_actor`, CONSTRAINT `fk_film_actor_film`
FOREIGN KEY (`film_id`) REFERENCES `film` (`film_id`)
ON DELETE RESTRICT ON UPDATE CASCADE)

遗憾的是,完整性约束阻止了此次删除。我们不仅需要删除电影,还需要删除这些电影与演员之间的关系。这可能会导致孤立的演员出现,这些演员可以在接下来被删除。我们可以尝试一次性删除电影和演员的引用,就像这样:

`DELETE` `FROM` `film_actor``,` `film` `USING`
`film` `JOIN` `film_actor` `USING` `(``film_id``)`
`LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
`WHERE` `inventory``.``film_id` `IS` `NULL``;`

不幸的是,即使film_actor表在film表之前列出,从film表中删除仍然失败。无法告诉优化器按特定顺序处理表。即使这个例子能够成功执行,依赖这种行为也不是一个好的实践,因为优化器可能随后会不可预测地改变表的顺序,导致失败。这个例子突显了 MySQL 和 SQL 标准之间的差异:标准指定在事务提交时检查外键,而 MySQL 立即检查它们,阻止这个语句成功执行。即使我们能够解决这个问题,电影也与分类相关联,因此也必须处理这个问题。

MySQL 允许通过几种方法摆脱这种情况。第一种方法是在一个事务中执行一系列的DELETE语句(我们在第六章更详细地讨论了事务):

mysql> `BEGIN``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `DELETE` `FROM` `film_actor` `USING`
    -> `film` `JOIN` `film_actor` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `WHERE` `inventory``.``film_id` `IS` `NULL``;`
Query OK, 216 rows affected (0.01 sec)
mysql> `DELETE` `FROM` `film_category` `USING`
    -> `film` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `WHERE` `inventory``.``film_id` `IS` `NULL``;`
Query OK, 42 rows affected (0.00 sec)
mysql> `DELETE` `FROM` `film` `USING`
    -> `film` `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `WHERE` `inventory``.``film_id` `IS` `NULL``;`
Query OK, 42 rows affected (0.00 sec)
mysql> `ROLLBACK``;`
Query OK, 0 rows affected (0.02 sec)

你可以看到,我们执行了ROLLBACK而不是COMMIT来保留行。实际上,你当然会使用COMMIT来“保存”你的操作结果。

第二种选择是危险的。可以通过在会话级别临时设置foreign_key_checks系统变量为0来暂停外键约束。我们建议不要采用这种做法,但这是同时从这三个表中删除数据的唯一方法:

mysql> `SET` `foreign_key_checks``=``0``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `BEGIN``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `DELETE` `FROM` `film``,` `film_actor``,` `film_category`
    -> `USING` `film` `JOIN` `film_actor` `USING` `(``film_id``)`
    -> `JOIN` `film_category` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `WHERE` `inventory``.``film_id` `IS` `NULL``;`
Query OK, 300 rows affected (0.03 sec)
mysql> `ROLLBACK``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SET` `foreign_key_checks``=``1``;`
Query OK, 0 rows affected (0.00 sec)

虽然我们不建议禁用外键检查,但这样做可以展示多表删除的强大功能。在这里,一个查询就可以实现在前一个示例中需要三个查询才能完成的任务。

让我们分解这个查询。如果匹配,将从filmfilm_actorfilm_category表中删除行。我们在DELETE FROMUSING之间明确指定了它们,以便清楚地表达。USING启动我们的查询,即DELETE语句的过滤部分。在这个示例中,我们构建了一个四表连接。我们使用INNER JOIN连接了filmfilm_actorfilm_category,因为我们只需要匹配的行。然后,我们将结果与inventory表进行了LEFT JOIN。在这种情况下,使用左连接非常重要,因为我们实际上只对inventory中没有记录的行感兴趣。我们通过WHERE inventory.film_id IS NULL表达了这一点。这个查询的结果是,我们得到了所有不在inventory中的电影,以及这些电影的所有演员关系和类别关系。

是否可能使此查询与外键安全使用?不幸的是,除非我们将其分解,否则无法做到,但我们可以比运行三个查询更好:

mysql> `BEGIN``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `DELETE` `FROM` `film_actor``,` `film_category` `USING`
    -> `film` `JOIN` `film_actor` `USING` `(``film_id``)`
    -> `JOIN` `film_category` `USING` `(``film_id``)`
    -> `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `WHERE` `inventory``.``film_id` `IS` `NULL``;`
Query OK, 258 rows affected (0.02 sec)
mysql> `DELETE` `FROM` `film` `USING`
    -> `film` `LEFT` `JOIN` `inventory` `USING` `(``film_id``)`
    -> `WHERE` `inventory``.``film_id` `IS` `NULL``;`
Query OK, 42 rows affected (0.01 sec)
mysql> `ROLLBACK``;`
Query OK, 0 rows affected (0.01 sec)

我们在这里做的是将从film_actorfilm_category表中的删除合并为一个单独的DELETE语句,从而允许在没有任何错误的情况下从film中删除。与之前的示例不同之处在于,我们从两个表中DELETE FROM,而不是三个表。

让我们谈谈受影响的行数。在第一个示例中,我们从film表中删除了 42 行,从film_category表中删除了 42 行,从film_actor表中删除了 216 行。在第二个示例中,我们的单个DELETE查询删除了 300 行。在最后一个示例中,我们从film_categoryfilm_actor表中合计删除了 258 行,并从film表中删除了 42 行。现在你可能已经猜到了,对于多表删除,MySQL 将输出删除的总行数,而不是单独的每个表。这使得很难准确地跟踪每个表中触及的行数。

此外,在多表删除中,不能使用ORDER BYLIMIT子句。

更新

现在,我们将构建一个使用sakila数据库来说明多表更新的示例。我们决定将所有恐怖电影的评级更改为 R 级,不考虑原始评级。首先,让我们显示恐怖电影及其评级:

mysql> `SELECT` `name` `category``,` `title``,` `rating`
    -> `FROM` `film` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `JOIN` `category` `USING` `(``category_id``)`
    -> `WHERE` `name` `=` `'Horror'``;`
+----------+-----------------------------+--------+
| category | title                       | rating |
+----------+-----------------------------+--------+
| Horror   | ACE GOLDFINGER              | G      |
| Horror   | AFFAIR PREJUDICE            | G      |
| Horror   | AIRPORT POLLOCK             | R      |
| Horror   | ALABAMA DEVIL               | PG-13  |
| ...                                             |
| Horror   | ZHIVAGO CORE                | NC-17  |
+----------+-----------------------------+--------+
56 rows in set (0.00 sec)
mysql> `SELECT` `COUNT``(``title``)`
    -> `FROM` `film` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `JOIN` `category` `USING` `(``category_id``)`
    -> `WHERE` `name` `=` `'Horror'` `AND` `rating` `<``>` `'R'``;`
+--------------+
| COUNT(title) |
+--------------+
|           42 |
+--------------+
1 row in set (0.00 sec)

我们不知道你们怎么想,但我们很想看一部适合全年龄段观看的恐怖电影!现在,让我们将这个查询放入一个UPDATE语句中:

mysql> `UPDATE` `film` `JOIN` `film_category` `USING` `(``film_id``)`
    -> `JOIN` `category` `USING` `(``category_id``)`
    -> `SET` `rating` `=` `'R'` `WHERE` `category``.``name` `=` `'Horror'``;`
Query OK, 42 rows affected (0.01 sec)
Rows matched: 56  Changed: 42  Warnings: 0

让我们来看一下语法。多表更新看起来类似于 SELECT 查询。UPDATE 语句后面是一个包含所需联接条件的表列表;在这个例子中,我们使用了 JOIN(记住,这是 INNER JOIN)来联合 filmfilm_category 表。然后是关键字 SET,对各个列进行赋值。在这里你可以看到只有一个列被修改(将评级改为 R),所以除了 film 表之外的所有其他表的列都没有被修改。接下来的 WHERE 是可选的,但在这个例子中是必需的,以便只操作类别名为 Horror 的行。

注意 MySQL 报告匹配了 56 行,但只更新了 42 行。如果你查看前面的 SELECT 查询的结果,你会看到它们显示了 Horror 类别中的电影数(56),以及该类别中评级不是 R 的电影数(42)。只有 42 行被更新,因为其他电影已经有了那个评级。

与多表删除一样,多表更新也有一些限制:

  • 你不能使用 ORDER BY

  • 你不能使用 LIMIT

  • 你不能在嵌套子查询中使用读取的表更新表。

除此之外,多表更新与单表更新基本相同。

替换数据

有时候你会想要覆盖数据。你可以使用我们之前展示的技术以两种方式做到这一点:

  • 使用其主键删除现有行,然后插入具有相同主键的替代行。

  • 使用其主键更新一行,替换一些或所有的值(除主键外)。

REPLACE 语句为你提供了第三种便捷的修改数据的方式。本节解释了它的工作原理。

REPLACE 语句就像 INSERT,但有一个区别。如果表中已经存在具有相同主键的行,则无法 INSERT 新行。你可以通过 REPLACE 查询来解决这个问题,它首先删除具有相同主键的任何现有行,然后插入新行。

让我们尝试一个例子,在这个例子中,我们将替换 sakila 数据库中女演员 PENELOPE GUINESS 的行:

mysql> `REPLACE` `INTO` `actor` `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``,` `NOW``(``)``)``;`
ERROR 1451 (23000): Cannot delete or update a parent row:
a foreign key constraint fails (`sakila`.`film_actor`,
CONSTRAINT `fk_film_actor_actor` FOREIGN KEY (`actor_id`)
REFERENCES `actor` (`actor_id`) ON DELETE RESTRICT ON UPDATE CASCADE)

不幸的是,如你在读完前面一段后所猜到的那样,REPLACE 实际上必须执行 DELETE。如果你的数据库有很多约束引用,就像 sakila 数据库一样,REPLACE 往往无法工作。我们不要在这里与数据库对抗,而是使用我们在 “使用查询创建表” 中创建的 actor_2 表:

mysql> `REPLACE` `actor_2` `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``,` `NOW``(``)``)``;`
Query OK, 2 rows affected (0.00 sec)

你可以看到 MySQL 报告说影响了两行:首先删除了旧行,然后插入了新行。你可以看到我们所做的改动很小——我们只是改变了名字的大小写——因此很容易使用 UPDATE 完成。由于 sakila 数据库中的表相对较小,很难构建一个 REPLACE 看起来比 UPDATE 更简单的例子。

您可以使用不同的INSERT语法与REPLACE,包括使用SELECT查询。以下是一些示例:

mysql> `REPLACE` `INTO` `actor_2` `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``,` `NOW``(``)``)``;`
Query OK, 2 rows affected (0.00 sec)
mysql> `REPLACE` `INTO` `actor_2` `(``actor_id``,` `first_name``,` `last_name``)`
    -> `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``)``;`
Query OK, 2 rows affected (0.00 sec)
mysql> `REPLACE` `actor_2` `(``actor_id``,` `first_name``,` `last_name``)`
    -> `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``)``;`
Query OK, 2 rows affected (0.00 sec)
mysql> `REPLACE` `actor_2` `SET` `actor_id` `=` `1``,`
    -> `first_name` `=` `'Penelope'``,` `last_name` `=` `'Guiness'``;`
Query OK, 2 rows affected (0.00 sec)

第一个变体与我们之前的示例几乎相同,除了包括可选的INTO关键字(可以说是提高了语句的可读性)。第二个变体明确列出了应将匹配值插入的列名。第三个变体与第二个相同,但没有可选的INTO关键字。最后一个变体使用了SET语法;如果需要,可以在此变体中添加可选关键字INTO。请注意,如果不为列指定值,则其被设置为其默认值,就像INSERT一样。

你还可以批量替换成表格,移除并插入多行。以下是一个示例:

mysql> `REPLACE` `actor_2` `(``actor_id``,` `first_name``,` `last_name``)`
    -> `VALUES` `(``2``,` `'Nick'``,` `'Wahlberg'``)``,`
    -> `(``3``,` `'Ed'``,` `'Chase'``)``;`
Query OK, 4 rows affected (0.00 sec)
Records: 2  Duplicates: 2  Warnings: 0

请注意,影响了四行:两个删除和两个插入。您还可以看到找到了两个重复项,这意味着成功替换了现有行。相比之下,如果在REPLACE语句中没有匹配的行,则其行为就像INSERT一样:

mysql> `REPLACE` `actor_2` `(``actor_id``,` `first_name``,` `last_name``)`
    -> `VALUES` `(``1000``,` `'William'``,` `'Dyer'``)``;`
Query OK, 1 row affected (0.00 sec)

你可以看到仅插入了一行,因为只有一行受到了影响。

替换也适用于SELECT语句。回想一下本章开头的“使用查询插入数据”中的recommend表。假设您已向其添加了 10 部电影,但不喜欢列表中的第七部电影的选择。以下是如何使用随机选择的另一部电影替换它:

mysql> `REPLACE` `INTO` `recommend` `SELECT` `film_id``,` `language_id``,`
    -> `release_year``,` `title``,` `length``,` `7` `FROM` `film`
    -> `ORDER` `BY` `RAND``(``)` `LIMIT` `1``;`
Query OK, 2 rows affected (0.00 sec)
Records: 1  Duplicates: 1  Warnings: 0

语法与INSERT相同,但是在插入之前尝试(并成功!)删除。请注意,我们保留了sequence_id的值为 7。

如果表没有主键或其他唯一键,则替换操作没有意义。这是因为无法唯一标识匹配行以便删除。当在此类表上使用REPLACE时,其行为与INSERT相同。此外,与INSERT类似,不能在子查询中替换表中的行。最后,请注意INSERT IGNOREREPLACE之间的区别:第一个会保留具有重复键的现有数据,并且不会插入新行,而第二个会删除现有行并替换为新行。

当为REPLACE指定列的列表时,您必须列出每个没有默认值的列。在我们的示例中,我们不得不指定actor_idfirst_namelast_name,但我们省略了具有默认值CURRENT_TIMESTAMPlast_update列。

警告

REPLACE是一条强大的语句,但在使用时要小心,因为结果可能出乎意料。特别是在具有自动增量列和多个唯一键定义的情况下,请特别注意。

MySQL 提供了另一个 SQL 的非标准扩展:INSERT ... ON DUPLICATE KEY UPDATE。它类似于REPLACE,但不是执行DELETE后跟INSERT,而是在发现重复键时执行UPDATE。在本节的开始,我们曾遇到过在actor表格中替换行的问题。MySQL 拒绝运行REPLACE,因为从actor表格中删除行将违反外键约束。但是,通过以下语句轻松实现所需的结果:

mysql> `INSERT` `INTO` `actor_3` `(``actor_id``,` `first_name``,` `last_name``)`
    -> `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``)`
    -> `ON` `DUPLICATE` `KEY` `UPDATE` `first_name` `=` `'Penelope'``,` `last_name` `=` `'Guiness'``;`
Query OK, 2 rows affected (0.00 sec)

注意,我们在“使用查询创建表格”中创建的actor_3表格,因为它具有与原始actor表格相同的所有约束条件。我们刚刚展示的语句在语义上与REPLACE非常相似,但有一些关键的不同之处。在REPLACE命令中,如果未为字段指定值,该字段必须具有DEFAULT值,并且将设置该默认值。这自然地由完全插入新行的事实而来。在INSERT ... ON DUPLICATE KEY UPDATE的情况下,我们正在更新现有行,因此不需要列出每一列。不过,如果我们愿意,我们也可以这样做:

mysql> `INSERT` `INTO` `actor_3` `VALUES` `(``1``,` `'Penelope'``,` `'Guiness'``,` `NOW``(``)``)`
    -> `ON` `DUPLICATE` `KEY` `UPDATE`
    -> `actor_id` `=` `1``,` `first_name` `=` `'Penelope'``,`
    -> `last_name` `=` `'Guiness'``,` `last_update` `=` `NOW``(``)``;`
Query OK, 2 rows affected (0.01 sec)

为了最小化这个命令所需的输入量,并允许插入多行,我们可以在UPDATE子句中引用新的字段值。以下是一个包含多行的示例,其中有一行是新的:

mysql> `INSERT` `INTO` `actor_3` `(``actor_id``,` `first_name``,` `last_name``)` `VALUES`
    -> `(``1``,` `'Penelope'``,` `'Guiness'``)``,` `(``2``,` `'Nick'``,` `'Wahlberg'``)``,`
    -> `(``3``,` `'Ed'``,` `'Chase'``)``,` `(``1001``,` `'William'``,` `'Dyer'``)`
    -> `ON` `DUPLICATE` `KEY` `UPDATE` `first_name` `=` `VALUES``(``first_name``)``,`
    -> `last_name` `=` `VALUES``(``last_name``)``;`
Query OK, 5 rows affected (0.01 sec)
Records: 4  Duplicates: 2

让我们更详细地审查这个查询。我们正在向actor_3表格中插入四行,并且通过使用ON DUPLICATE KEY UPDATE告诉 MySQL 在找到任何重复行时运行更新。然而,与我们之前的示例不同的是,这一次我们没有显式设置更新列的值。相反,我们使用特殊的VALUES()函数来获取我们传递给INSERT的每行中每列的值。例如,对于第二行2, Nick, WalhbergVALUES(first_name)将返回Nick。请注意,MySQL 报告我们已经更新了奇数行:五行。每当插入新行时,受影响的行数会增加一行。每当更新旧行时,受影响的行数会增加两行。由于我们已经通过运行之前的查询更新了Penelope的记录,我们的新插入也没有添加任何新内容,MySQL 也会跳过更新。对于重复行,我们留下两次更新和完全新行的插入,总共影响了五行。

提示

在大多数情况下,我们建议您默认使用INSERT ... ON DUPLICATE KEY UPDATE而不是REPLACE

EXPLAIN 语句

有时您会发现,MySQL 的查询速度不如您期望的快。例如,您经常会注意到嵌套查询运行缓慢。您可能也会发现——或者至少怀疑——MySQL 并未如您希望的那样运行,因为您知道索引存在,但查询仍然显得很慢。您可以使用EXPLAIN语句来诊断和解决查询优化问题。

分析查询计划、理解优化器的决策以及调优查询性能都是高级主题,更多地是艺术而非科学:没有一种固定的方法。我们添加了这一部分,让你知道这种能力的存在,但我们不会深入探讨这个主题。

EXPLAIN语句帮助您了解SELECT或任何其他查询。具体来说,它告诉您 MySQL 在索引、键和执行步骤方面将如何执行查询。EXPLAIN实际上不会执行查询(除非您要求执行),通常不需要花费很多时间来运行。

让我们尝试一个简单的例子来说明这个概念:

mysql> `EXPLAIN` `SELECT` `*` `FROM` `actor``\``G`
*************************** 1\. row ***************************
           id: 1
  select_type: SIMPLE
        table: actor
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 200
     filtered: 100.00
        Extra: NULL
1 row in set, 1 warning (0.00 sec)

该语句提供了大量信息。它告诉您:

  • id为 1,意味着输出中的这行引用了查询中的第一个(也是唯一的)SELECT语句。如果使用子查询,EXPLAIN输出中的每个SELECT语句将具有不同的id值(尽管某些子查询不会导致报告多个id,因为 MySQL 可能会重写查询)。稍后我们将展示一个使用子查询和不同id值的示例。

  • select_typeSIMPLE,表示它不使用UNION或子查询。

  • 这行正在引用的tableactor

  • partitions列是空的,因为没有表进行了分区。

  • 连接的typeALL,表示这个SELECT语句将处理表中的所有行。通常情况下这是不好的,但在这种情况下不是;稍后我们会解释原因。

  • 列出了可能被使用的possible_keys。在这种情况下,没有索引可以帮助查找表中的所有行,所以报告为NULL

  • 列出了实际使用的key,取自possible_keys列表。在这种情况下,由于没有可用的键,因此未使用任何键。

  • 列出了 MySQL 计划使用的key_len(键的长度)。同样地,没有键意味着报告一个NULLkey_len

  • 列出了与键一起使用的ref(引用)列或常量。同样,在此示例中没有使用任何引用列。

  • MySQL 认为需要处理的rows列出了它认为需要处理以获取答案的行数。

  • filtered列告诉我们该阶段将返回表中行的百分比:100 表示将返回所有行。由于我们请求的是所有行,这是预期的结果。

  • 关于查询解析的任何Extra信息都将列出。在这里,没有额外信息。

总结来说,EXPLAIN SELECT * FROM actor的输出告诉您将处理actor表中的所有行(共有 200 行),并且不会使用索引来解析查询。这是合理的,并且可能正是您预期会发生的事情。

请注意,每个EXPLAIN语句都会报告一个警告。我们发送到 MySQL 的每个查询在执行之前都会被重写,警告消息将包含重写后的查询。例如,*可能会扩展为列的显式列表,或者子查询可能会隐式地优化为JOIN。这里是一个例子:

mysql> `EXPLAIN` `SELECT` `*` `FROM` `actor` `WHERE` `actor_id` `IN`
    -> `(``SELECT` `actor_id` `FROM` `film_actor`
    -> `WHERE` `film_id` `=` `11``)``;`
+----+-------------+------------+------------+--------+...
| id | select_type | table      | partitions | type   |...
+----+-------------+------------+------------+--------+...
|  1 | SIMPLE      | film_actor | NULL       | ref    |...
|  1 | SIMPLE      | actor      | NULL       | eq_ref |...
+----+-------------+------------+------------+--------+...
...+------------------------+----------------+---------+...
...| possible_keys          | key            | key_len |...
...+------------------------+----------------+---------+...
...| PRIMARY,idx_fk_film_id | idx_fk_film_id | 2       |...
...| PRIMARY                | PRIMARY        | 2       |...
...+------------------------+----------------+---------+...
...+----------------------------+------+----------+-------------+
...| ref                        | rows | filtered | Extra       |
...+----------------------------+------+----------+-------------+
...| const                      |    4 |   100.00 | Using index |
...| sakila.film_actor.actor_id |    1 |   100.00 | NULL        |
...+----------------------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)
mysql> `SHOW` `WARNINGS``\``G`
*************************** 1\. row ***************************

  Level: Note
   Code: 1003
Message: /* select#1 */ select
`sakila`.`actor`.`actor_id` AS `actor_id`,
`sakila`.`actor`.`first_name` AS `first_name`,
`sakila`.`actor`.`last_name` AS `last_name`,
`sakila`.`actor`.`last_update` AS `last_update`
from `sakila`.`film_actor` join `sakila`.`actor` where
((`sakila`.`actor`.`actor_id` = `sakila`.`film_actor`.`actor_id`)
and (`sakila`.`film_actor`.`film_id` = 11))
1 row in set (0.00 sec)

我们提到将展示一个具有不同id值和子查询的示例。以下是查询:

mysql> `EXPLAIN` `SELECT` `*` `FROM` `actor` `WHERE` `actor_id` `IN`
    -> `(``SELECT` `actor_id` `FROM` `film_actor` `JOIN`
    -> `film` `USING` `(``film_id``)`
    -> `WHERE` `title` `=` `'ZHIVAGO CORE'``)``;`
+----+--------------+-------------+------------+------+...
| id | select_type  | table       | partitions | type |...
+----+--------------+-------------+------------+------+...
|  1 | SIMPLE       | <subquery2> | NULL       | ALL  |...
|  1 | SIMPLE       | actor       | NULL       | ALL  |...
|  2 | MATERIALIZED | film        | NULL       | ref  |...
|  2 | MATERIALIZED | film_actor  | NULL       | ref  |...
+----+--------------+-------------+------------+------+...
...+------------------------+----------------+---------+---------------------+...
...| possible_keys          | key            | key_len | ref                 |...
...+------------------------+----------------+---------+---------------------+...
...| NULL                   | NULL           | NULL    | NULL                |...
...| PRIMARY                | NULL           | NULL    | NULL                |...
...| PRIMARY,idx_title      | idx_title      | 514     | const               |...
...| PRIMARY,idx_fk_film_id | idx_fk_film_id | 2       | sakila.film.film_id |...
...+------------------------+----------------+---------+---------------------+...
...+------+----------+--------------------------------------------+
...| rows | filtered | Extra                                      |
...+------+----------+--------------------------------------------+
...| NULL |   100.00 | NULL                                       |
...|  200 |     0.50 | Using where; Using join buffer (hash join) |
...|    1 |   100.00 | Using index                                |
...|    5 |   100.00 | Using index                                |
...+------+----------+--------------------------------------------+
4 rows in set, 1 warning (0.01 sec)

在这个示例中,您可以看到id为 1 用于actor<subquery2>表,id为 2 用于filmfilm_actor。但<subquery2>是什么?这是一个虚拟表名,在此处使用,因为优化器将子查询的结果实体化,或者换句话说,在内存中存储它们的临时表。您可以看到具有id为 2 的查询具有select_typeMATERIALIZED。外部查询(id为 1)将从此临时表中查找内部查询(id为 2)的结果。这只是 MySQL 在执行复杂查询时可以执行的许多优化之一。

接下来,我们将让EXPLAIN语句实际发挥作用。让我们要求它解释actorfilm_actorfilmfilm_categorycategory之间的INNER JOIN

mysql> `EXPLAIN` `SELECT` `first_name``,` `last_name` `FROM` `actor`
    -> `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `JOIN` `film_category` `USING` `(``film_id``)`
    -> `JOIN` `category` `USING` `(``category_id``)`
    -> `WHERE` `category``.``name` `=` `'Horror'``;`
+----+-------------+---------------+------------+--------+...
| id | select_type | table         | partitions | type   |...
+----+-------------+---------------+------------+--------+...
|  1 | SIMPLE      | category      | NULL       | ALL    |...
|  1 | SIMPLE      | film_category | NULL       | ref    |...
|  1 | SIMPLE      | film          | NULL       | eq_ref |...
|  1 | SIMPLE      | film_actor    | NULL       | ref    |...
|  1 | SIMPLE      | actor         | NULL       | eq_ref |...
+----+-------------+---------------+------------+--------+...
...+-----------------------------------+---------------------------+---------+...
...| possible_keys                     | key                       | key_len |...
...+-----------------------------------+---------------------------+---------+...
...| PRIMARY                           | NULL                      | NULL    |...
...| PRIMARY,fk_film_category_category | fk_film_category_category | 1       |...
...| PRIMARY                           | PRIMARY                   | 2       |...
...| PRIMARY,idx_fk_film_id            | idx_fk_film_id            | 2       |...
...| PRIMARY                           | PRIMARY                   | 2       |...
...+-----------------------------------+---------------------------+---------+...
...+------------------------------+------+----------+-------------+
...| ref                          | rows | filtered | Extra       |
...+------------------------------+------+----------+-------------+
...| NULL                         |   16 |    10.00 | Using where |
...| sakila.category.category_id  |   62 |   100.00 | Using index |
...| sakila.film_category.film_id |    1 |   100.00 | Using index |
...| sakila.film_category.film_id |    5 |   100.00 | Using index |
...| sakila.film_actor.actor_id   |    1 |   100.00 | NULL        |
...+------------------------------+------+----------+-------------+
5 rows in set, 1 warning (0.00 sec)

在讨论输出之前,请考虑查询如何评估。MySQL 可以逐行检查actor表,然后将其与film_actor匹配,然后与filmfilm_category和最后是category匹配。我们在category表上有一个过滤器,所以在这种想象中,MySQL 在到达该表时只能匹配较少的行。这是一个低效的执行策略。你能想到更好的策略吗?

现在让我们看看 MySQL 实际上决定做什么。这次有五行,因为有五个表在连接中。让我们重点关注与之前示例不同的部分:

  • 第一行与之前看到的类似。MySQL 将从category表中读取所有 16 行。这次,在Extra列中的值是Using where。这意味着将应用基于WHERE子句的过滤器。在此示例中,filtered列显示 10,意味着大约表中的 10%行将被此阶段生成以进行进一步的操作。MySQL 优化器预计表中有 16 行,并预计此处将返回 1 到 2 行。

  • 现在让我们看看第 2 行。film_category表的连接类型是ref,意味着film_category表中与category表中的行匹配的所有行将被读取。实际上,这意味着将为category表中的每个category_id读取一个或多个film_category表中的行。possible_keys列显示了PRIMARYfk_film_category_category,而后者被选择为索引。film_category表的主键有两列,第一列是film_id,使得该索引在category_id上的过滤不太优化。用于搜索film_category的键具有key_len为 1,并使用category表中的sakila.category.category_id值进行搜索。

  • 转向下一行,我们可以看到 film 表的连接 typeeq_ref。这意味着对于我们从前一阶段(扫描 film_category)获得的每一行,我们在这个阶段将读取一行。MySQL 可以保证这一点,因为用于访问 film 表的索引是 PRIMARY。一般来说,如果使用了 UNIQUE NOT NULL 索引,eq_ref 是可能的。这是最佳的连接策略之一。

输出中的两个嵌套行并未显示任何新内容。最终,我们看到 MySQL 选择了一个最佳的执行计划。通常情况下,在执行的第一步中读取的行数越少,查询速度越快。

MySQL 8.0 引入了一个新的 EXPLAIN PLAN 输出格式,可通过 EXPLAIN ANALYZE 语句获取。虽然这种格式可能更容易阅读,但需要注意的是,实际上必须执行该语句,这与常规的 EXPLAIN 不同。我们不会深入讨论这种新格式的细节,但在这里我们会展示一个例子:

mysql> `EXPLAIN` `ANALYZE` `SELECT` `first_name``,` `last_name`
    -> `FROM` `actor` `JOIN` `film_actor` `USING` `(``actor_id``)`
    -> `JOIN` `film` `USING` `(``film_id``)`
    -> `WHERE` `title` `=` `'ZHIVAGO CORE'``\``G`
*************************** 1\. row ***************************
EXPLAIN:
-> Nested loop inner join
   (cost=3.07 rows=5)
   (actual time=0.036..0.055 rows=6 loops=1)
  -> Nested loop inner join
     (cost=1.15 rows=5)
     (actual time=0.028..0.034 rows=6 loops=1)
    -> Index lookup on film
       using idx_title (title='ZHIVAGO CORE')
       (cost=0.35 rows=1)
       (actual time=0.017..0.018 rows=1 loops=1)
    -> Index lookup on film_actor
       using idx_fk_film_id (film_id=film.film_id)
       (cost=0.80 rows=5)
       (actual time=0.010..0.015 rows=6 loops=1)
  -> Single-row index lookup on actor
     using PRIMARY (actor_id=film_actor.actor_id)
     (cost=0.27 rows=1)
     (actual time=0.003..0.003 rows=1 loops=6)

1 row in set (0.00 sec)

这个输出比常规的 EXPLAIN 输出更加高级,因为它提供了更多的数据。我们将分析留给读者作为一个练习。基于我们对常规 EXPLAIN 输出的解释,您应该能够理解它。

备选存储引擎

MySQL 的一个特性是支持不同的存储引擎,这将其区别于许多其他关系型数据库管理系统。MySQL 支持多引擎的机制较为复杂,要适当地解释这一点,我们需要深入探讨其架构和实现,超出了此处的篇幅。然而,我们可以试着从鸟瞰角度为您概述可用的引擎,为什么您可能希望使用非默认引擎,以及为什么有这种选择是重要的。

与其说 存储引擎,听起来复杂,我们可以说 表类型。简单来说,MySQL 允许您创建不同类型的表,每种类型给这些表赋予不同的特性。没有普适的好表类型,因为每种存储引擎都有其利弊。

到目前为止,我们的书中仅使用了默认的 InnoDB 表类型。原因很简单:几乎您可能需要的现代数据库功能都可以通过 InnoDB 实现。它通常快速、可靠,是一种经过验证和良好支持的引擎,在广泛的评估中(包括我们自己的评估)被认为提供了最佳的利弊平衡。我们已经看到这个引擎被成功地应用于需要非常高吞吐量的短查询的应用程序,以及运行少量但“大型”查询的数据仓库应用程序。

在撰写本文时,官方 MySQL 文档记录了 8 个额外的存储引擎,而 MariaDB 记录了 18 个额外的引擎。实际上,可用的存储引擎更多,但并非所有引擎都包含在主要 MySQL 版本的文档中。在这里,我们只描述那些我们认为有用并且至少有些常用的引擎。也许最适合您用例的存储引擎不在我们描述的范围内。请不要介意;因为引擎种类繁多,我们无法全面覆盖。

在我们深入了解不同引擎的概述之前,让我们简要地看一下为什么这很重要。MySQL 中存储引擎的可插拔性以及使用不同类型创建表的能力很重要,因为它允许您统一数据库访问层。您可以只使用 MySQL,通过更改表类型来实现不同的行为,而不是使用具有各自驱动程序、查询语言、配置、管理、备份等的多个数据库产品。您的应用程序甚至可能无需知道表具有哪些类型。然而,情况并非那么简单和美好。您可能无法使用我们将在第十章中解释的所有备份解决方案。您还需要理解每种引擎提供的权衡。不过,我们仍认为能够更改表类型的能力比不能更改要好。

我们将根据不同存储引擎的重要属性定义广泛的分类开始我们的审查。其中最重要的一个分类是引擎支持事务的能力(您可以在第六章中了解有关事务、锁定及其重要性的更多信息)。

目前可用的事务性引擎包括默认的 InnoDB、活跃开发中的 MyRocks 和已弃用的 TokuDB。在主要 MySQL 版本中提供的所有不同引擎中,只有这三种支持事务。其他每个引擎都是非事务性的。

我们可以进行的下一个广泛划分是基于崩溃安全性,或者说引擎保证 ACID 特性集中耐用性属性的能力。如果表使用了崩溃安全引擎,那么我们可以预期在未经清洁的实例重启后,每个提交事务已写入的数据位将可用。崩溃安全引擎包括前面提到的 InnoDB、MyRocks 和 TokuDB,以及 Aria 引擎。其他可用的引擎均不保证崩溃安全性。

我们可以举更多示例来说明如何对表类型进行分组,但让我们实际描述一些引擎及其属性。首先要做的事情是看看如何实际查看可用引擎列表。为了实现这一点,我们使用特殊的 SHOW ENGINES 命令。以下是它在默认 MySQL 8.0.23 Linux 安装上的输出:

mysql> `SHOW` `ENGINES``;`
+--------------------+---------+...
| Engine             | Support |...
+--------------------+---------+...
| ARCHIVE            | YES     |...
| BLACKHOLE          | YES     |...
| MRG_MYISAM         | YES     |...
| FEDERATED          | NO      |...
| MyISAM             | YES     |...
| PERFORMANCE_SCHEMA | YES     |...
| InnoDB             | DEFAULT |...
| MEMORY             | YES     |...
| CSV                | YES     |...
+--------------------+---------+...
...+----------------------------------------------------------------+...
...| Comment                                                        |...
...+----------------------------------------------------------------+...
...| Archive storage engine                                         |...
...| /dev/null storage engine (anything you write to it disappears) |...
...| Collection of identical MyISAM tables                          |...
...| Federated MySQL storage engine                                 |...
...| MyISAM storage engine                                          |...
...| Performance Schema                                             |...
...| Supports transactions, row-level locking, and foreign keys     |...
...| Hash based, stored in memory, useful for temporary tables      |...
...| CSV storage engine                                             |...
...+----------------------------------------------------------------+...
...+--------------+------+------------+
...| Transactions | XA   | Savepoints |
...+--------------+------+------------+
...| NO           | NO   | NO         |
...| NO           | NO   | NO         |
...| NO           | NO   | NO         |
...| NULL         | NULL | NULL       |
...| NO           | NO   | NO         |
...| NO           | NO   | NO         |
...| YES          | YES  | YES        |
...| NO           | NO   | NO         |
...| NO           | NO   | NO         |
...+--------------+------+------------+
9 rows in set (0.00 sec)

你可以看到 MySQL 方便地告诉我们一个引擎是否支持事务。XA列用于分布式事务,我们不会在本书中涉及这些内容。保存点基本上是在事务内创建小事务的能力,另一个高级主题。作为一个练习,考虑在 MariaDB 和 Percona Server 安装中执行SHOW ENGINES;

InnoDB

在我们讨论“备选”存储引擎之前,让我们讨论默认的引擎:InnoDB。 InnoDB 是可靠的、高效的,并且功能齐全的。几乎所有你期望从现代关系数据库管理系统中获得的功能都可以通过 InnoDB 以某种方式实现。在本书中,我们从不更改表的引擎,因此每个示例都使用 InnoDB。在学习 MySQL 时,我们建议你坚持使用这种引擎。了解其缺点很重要,但除非它们对你造成问题,否则几乎没有理由不始终使用它。

InnoDB 表类型包括以下功能:

事务支持

这在第六章中详细讨论。

高级崩溃恢复功能

InnoDB 表类型使用日志文件,这些文件记录了 MySQL 为改变数据库所采取的操作。日志使得 MySQL 能够有效地从断电、崩溃和其他基本数据库故障中恢复。当然,没有什么能够帮助你从机器丢失、磁盘驱动器故障或其他灾难性故障中恢复。对于这些情况,你需要离线备份和新硬件。我们在第十章探讨的每个备份工具都与 InnoDB 兼容。

行级锁定

不同于以前的默认引擎 MyISAM(我们将在下一节中探讨),InnoDB 提供了精细级别的锁定基础设施。最低级别的锁定是行级别的,这意味着一个运行中的查询或事务可以锁定一个单独的行。这对于大多数写入密集型在线事务处理(OLTP)应用程序非常重要;如果你在更高的级别,比如表级别上进行锁定,可能会导致太多并发问题。

外键支持

InnoDB 目前是唯一支持外键的 MySQL 表类型。如果你正在构建一个需要通过引用约束实施高级数据安全性的系统,InnoDB 是你唯一的选择。

加密支持

InnoDB 表可以通过 MySQL 进行透明加密。

分区支持

InnoDB 支持分区,即根据某些规则在多个数据文件之间物理分布数据。这使得 InnoDB 能够高效地处理巨大的表格。

这有很多优点,但也有一些缺点:

复杂性

InnoDB 相对复杂。这意味着有很多配置和理解的内容。在 MySQL 的近千个服务器选项中,有超过两百个是特定于 InnoDB 的。然而,这个缺点远远被该引擎提供的好处所抵消。

数据占用空间

InnoDB 是一个相对于磁盘需求较高的存储引擎,因此不太适合存储极大的数据集。

数据库大小的扩展

当所谓的“热”数据集或频繁访问的数据存在于其缓冲池中时,InnoDB 的性能会非常出色。这限制了其可伸缩性。

MyISAM 和 Aria

MyISAM 长期以来是 MySQL 的默认存储引擎,也是这个数据库的主要组成部分。它使用简单,设计简洁,性能相当不错,并且开销低。那么为什么它停止成为默认存储引擎呢?实际上有几个很好的理由,当我们讨论其局限性时,你将会看到。

现在,我们不建议使用 MyISAM,除非出于遗留原因需要。您可能在互联网上读到它的性能比 InnoDB 更好的信息。不幸的是,大多数这样的信息都非常古老,并且已经不再适用——在绝大多数情况下,今天并非如此。其中一个原因是在 2018 年 1 月 Spectre 和 Meltdown 安全漏洞导致的 Linux 内核更改,这导致 MyISAM 的性能降低了多达 90%。

直到 MySQL 8.0,MyISAM 在 MySQL 中用于所有数据字典对象。从该版本开始,数据字典现在完全采用 InnoDB,以支持原子 DDL 等高级功能。

Aria 是 MariaDB 中提供的重制 MyISAM。除了承诺提供更好的性能并持续改进外,Aria 最重要的特性是其崩溃安全性。与 InnoDB 不同,MyISAM 在写入成功后不能保证数据安全,这是此存储引擎的一个重大缺点。另一方面,Aria 允许创建持久表,并支持全局事务日志。未来,Aria 可能还会支持完整的事务,但目前尚未实现。

MyISAM 表类型包括以下特性:

表级锁定

与 InnoDB 不同,MyISAM 只支持在整个表的高级别上锁定。这比行级锁定简单得多,少有微妙之处,并且具有更低的开销和内存需求。然而,在高并发、写入密集的工作负载中,存在一个显著的缺点:即使每个会话更新或插入单独的行,它们也会依次执行。在 MyISAM 中,读取可以同时共存,但会阻塞并发的写入。写入也会阻塞读取。

支持分区

直到 MySQL 8.0,MyISAM 支持分区。在 MySQL 8.0 中,情况已不再如此,为实现此目的,必须使用不同的存储引擎(Merge 或 MRG_MyISAM)。

压缩

可以使用 myisampack 实用工具创建只读压缩表,这些表比不使用压缩的等效 InnoDB 表要小得多。然而,由于 InnoDB 支持压缩,我们建议您首先检查该选项是否能够带来更好的结果。

MyISAM 类型有以下限制:

崩溃安全性和恢复

MyISAM 表不具备崩溃安全性。MySQL 不保证写操作成功后数据实际上已经到达磁盘上的文件。如果 MySQL 没有正常退出,MyISAM 表可能会损坏,需要修复并可能会丢失数据。

事务

MyISAM 不支持事务。因此,MyISAM 仅为每个单独的语句提供原子性,这在您的情况下可能不足够。

加密

MyISAM 表不支持加密。

MyRocks 和 TokuDB

InnoDB 最显著的问题之一是处理大数据集的相对困难。我们已经提到,将经常访问的数据保存在内存中是理想的,但这并不总是可行。此外,当数据量达到多 TB 时,InnoDB 的磁盘性能也会受到影响。InnoDB 中的对象在大小方面的开销也相当大。近年来,出现了几个不同的项目,试图通过基于不同数据结构的存储引擎来修复 InnoDB 基本数据结构中固有的问题。这些项目包括基于 LSM 树的 MyRocks 和基于专有分形树数据结构的 TokuDB。

注意

我们在这一部分提到了 TokuDB,以确保完整性,但其开发者 Percona 已经弃用了这个存储引擎,其未来不明确。TokuDB 具有与 MyRocks 类似的属性,实际上 MyRocks 是从 TokuDB 迁移的首选路径。

数据结构如何影响存储引擎的属性是一个复杂的话题,可以说超出了数据库管理和运营的范围。在本书中,我们试图保持事情相对简单,因此不会深入探讨这个特定主题。您也应该记住我们之前关于 InnoDB 的内容:默认设置并不算不合理,并且更多时候使用 InnoDB 将为您提供最好的折衷选择。我们继续建议您在学习 MySQL 时使用 InnoDB,以及在此之后,但我们也认为我们应该涵盖其他选择。

MyRocks 表类型包括以下特性:

支持事务

MyRocks 是一个支持常规事务和分布式事务的事务性存储引擎。并不完全支持保存点。

高级崩溃恢复功能

MyRocks 依赖于称为 WAL 文件(“预写式日志”)的内部日志文件,以提供崩溃恢复保证。在数据库在崩溃后重新启动时,您可以期望所有已提交的内容都存在。

加密支持

MyRocks 表可以加密。

分区支持

MyRocks 表可以进行分区。

数据压缩和紧凑性

MyRocks 表的存储占用通常低于 InnoDB 表的存储占用。这有两个原因:它使用更紧凑的存储结构,以及该存储结构中的数据可以进行压缩。虽然压缩不是 MyRocks 独有的功能,实际上 InnoDB 也提供了压缩选项,但 MyRocks 始终显示出更好的结果。

大规模一致的写入性能

这个解释起来比较复杂,不过简要来说,MyRocks 的写入性能几乎不受数据量影响。在现实世界中,这意味着直到数据大小远远超过内存,MyRocks 表的性能才会比 InnoDB 表差。随后发生的是,InnoDB 的性能下降速度比 MyRocks 快,最终落后。

MyRocks 表类型有以下限制:

事务和锁定

MyRocks 不支持SERIALIZABLE隔离级别或在第六章中描述的间隙锁定。

外键

只有 InnoDB 支持外键约束。

性能权衡

MyRocks 在处理读重和分析工作负载时表现不佳。InnoDB 提供了更好的通用性能。

复杂性

我们提到 InnoDB 比 MyISAM 更复杂。然而,在某些方面,MyRocks 比 InnoDB 更复杂。它的文档不够完善,正在积极开发中(因此不够稳定),并且操作起来可能很困难。

一般可用性

MyRocks 不在 Community 或 Enterprise MySQL 中提供;要使用它,您需要使用另一个 MySQL 版本,如 MariaDB 或 Percona Server。这可能会导致操作困难。打包版本滞后于开发,并且要使用当前所有功能,必须使用 MyRocks 源码构建专用的 MySQL 服务器。

其他表类型

我们已经介绍了所有主要的表类型,但是还有一些我们会简要总结的表类型。其中一些存储引擎很少被使用,可能存在文档问题和错误。

内存

此类型的表完全存储在内存中,永远不会持久化到磁盘上。显而易见的优势是性能:内存比磁盘快得多,而且可能永远如此。缺点是数据在 MySQL 重新启动或崩溃时会丢失。内存表通常用作临时表。除此之外,内存表还可以用来存储小型、频繁访问的热数据,例如某种字典。

存档

此类型提供了一种以高度压缩和仅追加方式存储数据的方法。您无法使用 Archive 存储引擎的表中修改或删除数据。顾名思义,它主要用于长期存储数据。实际上,它很少被使用,并且在主键和自增处理方面存在一些问题。使用压缩表的 InnoDB 和 MyRocks 可能提供更好的替代方案。

CSV

这种存储引擎将表格以 CSV 格式存储在磁盘上。这使得你可以使用电子表格应用程序或文本编辑器查看和操作这些表格。虽然它不常用,但可以作为我们在“从逗号分隔文件加载数据”中所解释的方法的替代方案,并且也可以用于数据导出。

联合存储引擎

这种类型提供了一种查询远程 MySQL 系统数据的方式。联合表格不包含任何数据,只包含与连接详细信息相关的元数据。这是一种有趣的获取或修改远程数据的方式,而无需设置复制。与仅连接到远程 MySQL 相比,它的好处在于同时提供对本地和远程表格的访问。

黑洞

这种存储引擎丢弃其表中本应存储的每一位数据。换句话说,写入黑洞表的任何内容都会立即丢失。听起来并不是特别有用,但对于这种引擎有其用例。通常用于通过中间服务器过滤复制,其中不需要的表被置为黑洞。另一个潜在的用例是在闭源应用程序中删除表:你不能简单地删除表,因为这会破坏应用程序,但通过将其设为黑洞,你可以消除任何处理和存储开销。

这些存储引擎相当特别,很少在实际应用中见到。然而,你应该知道它们的存在,因为你永远不知道什么时候会有用起来。

第八章:管理用户和权限

最简单的数据库系统只是一堆文件,其中包含一些数据,并且没有统一的访问过程。在任何关系型数据库管理系统中,我们期望有更高级别的复杂性和抽象性。例如,我们希望能够从多个客户端同时访问数据库。然而,并非所有客户端都相似,并且并非每个客户端都必需访问数据库中的所有数据。可以想象一个数据库,其中每个用户都是超级用户,但这意味着您必须为每个应用程序和数据集安装专用数据库:这是一种浪费。因此,数据库已经发展到支持多个用户和角色,并提供了一种非常精细的级别来控制特权和访问,以保证安全的共享环境。

理解用户和权限是高效处理数据库系统的重要组成部分。精心规划和管理角色可以确保系统安全且易于管理和操作。在本章中,我们将回顾大多数用户和权限管理相关的事项,从基础开始逐步介绍如角色等新功能。完成本章后,您将具备管理 MySQL 数据库访问所需的所有基础知识。

理解用户和权限

共享系统基础的第一个构建块是用户的概念。大多数现代操作系统都有基于用户的访问控制,因此您很可能已经了解其含义。MySQL 中的用户是专用对象,用于以下目的:

  • 认证(确保用户可以访问 MySQL 服务器)

  • 授权(确保用户可以与数据库中的对象交互)

使 MySQL 与其他数据库管理系统不同的一点是用户不拥有模式对象。

让我们更详细地考虑这些观点。每次访问 MySQL 服务器时,您必须指定一个用户用于认证。一旦您经过认证并确认了您的身份,您就可以访问数据库。通常,在与模式对象交互时,您将作为操作的用户与进行身份验证的用户相同,但这并非绝对必要,这就是我们将第二点分开的原因。代理用户是用于检查权限和实际在数据库内部操作的用户,当在认证过程中使用另一个用户时。这是一个相当复杂的主题,需要非默认配置,但仍然可能。

认证和授权之间有一个重要的区别需要记住。虽然您可以使用一个用户进行认证,但可以作为另一个用户进行授权,并具有或不具有各种权限。

有了这两个内容,让我们讨论最后一点。一些数据库管理系统支持对象所有权的概念。也就是说,当用户创建数据库对象(数据库或模式、表或存储过程)时,该用户自动成为新对象的所有者。所有者通常有权修改其拥有的对象,并授予其他用户对其的访问权限。这里重要的是 MySQL 在任何情况下都没有对象所有权的概念。

这种缺乏所有权概念使得更加重要的是有灵活的规则,以便用户可以创建对象,然后可能与其他用户分享对这些对象的访问。这是通过权限来实现的。权限可以被视为一组规则,控制用户被允许执行的操作以及可以访问的数据。重要的是要理解,默认情况下,在 MySQL 中,数据库用户根本没有任何权限。授予权限意味着允许默认情况下禁止的某些操作。

MySQL 中的用户与其他数据库中的用户也有些不同,因为用户对象包含一个网络访问控制列表(ACL)。通常,一个 MySQL 用户不仅仅用其名称表示,比如bob,而是附加了网络地址,例如bob@localhost。这个特定的示例定义了一个仅能通过回环接口或 Unix 套接字连接本地访问的用户。当我们讨论用于创建和操作现有用户的 SQL 语法时,我们稍后会详细讨论这个主题。

MySQL 将所有与用户和权限相关的信息存储在mysql系统数据库中的特殊表中,称为授权表。稍后我们将更深入地讨论这个概念,详见“授权表”。

这个简短的理论基础应该足以形成对 MySQL 用户和权限系统的基本理解。让我们来实际操作,回顾一下数据库提供的用于管理用户和权限的命令和功能。

根用户

每个 MySQL 安装都默认安装了一些用户。大多数情况下,你根本不需要触碰它们,但有一个用户非常频繁地被使用。有些人甚至会说它被过度使用了,但这不是我们想要在这里讨论的话题。我们要谈论的用户是无处不在且全能的root用户。与默认的 Unix 和 Linux 超级用户具有相同的名称,在 MySQL 中,这个用户就是那样:默认情况下可以执行任何操作的用户。

更具体地说,用户是root@localhost,有时被称为初始用户。正如你现在所知,用户名中的localhost部分限制了其仅能在本地连接中使用。当你安装 MySQL 时,根据具体的 MySQL 版本和操作系统,你可能可以通过仅执行mysql命令从操作系统的 root 账户访问root@localhost。在某些情况下,会为这个用户生成一个临时密码。

初始用户并不是你能创建的唯一超级用户,正如你将在“SUPER 权限”中看到的那样。虽然你可以创建一个root@<ip>用户,甚至是root@%用户,但我们强烈建议你不要这样做,因为这是一个等待被利用的安全漏洞。并非每个 MySQL 服务器都需要在除回环(即 localhost)之外的接口上监听,更不用说有一个默认名称的超级用户可用于登录。当然,你可以为所有用户设置安全密码,可能还应该为root设置一个,但如果可能的话,不允许远程超级用户访问可能会更安全一点。

就功能而言,root@localhost只是一个拥有所有授权的常规用户。你甚至可以删除它,这可能会不小心发生。当运行 MySQL 时,失去root@localhost用户访问权限是一个相当常见的问题。你可能设置了密码却忘记了它,或者继承了一个服务器但没有得到密码,或者可能发生了其他事情。我们在“更改 root 的密码和不安全的启动”中介绍了恢复忘记密码的root@localhost初始用户的过程。如果你删除了最后一个可用的超级用户,你将不得不按照相同的步骤进行操作,但是要创建一个新用户而不是更改现有用户。

创建和使用新用户

我们将首先介绍创建新用户的任务。让我们从一个相当简单的例子开始,逐步审视每个部分:

CREATE USER ![1](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/1.png)
'bob'@'10.0.2.%' ![2](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/2.png)
IDENTIFIED BY 'password'; ![3](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/3.png)

1

创建用户的 SQL 语句

2

用户和主机定义

3

密码规范

这是一个更复杂的例子:

CREATE USER ![1](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/1.png)
'bob'@'10.0.2.%' ![2](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/2.png)
IDENTIFIED WITH mysql_native_password ![3](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/3.png)
BY 'password' ![4](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/4.png)
DEFAULT ROLE 'user_role' ![5](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/5.png)
REQUIRE SSL ![6](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/6.png)
AND CIPHER 'EDH-RSA-DES-CBC3-SHA' ![7](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/7.png)
WITH MAX_USER_CONNECTIONS 10 ![8](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/8.png)
PASSWORD EXPIRE NEVER; ![9](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/9.png)

1

创建用户的 SQL 语句

2

用户和主机定义

3

认证插件规范

4

认证字符串/密码

5

用户认证和连接后设置的默认角色

6

要求此用户的连接使用 SSL

7

要求使用特定的加密算法

8

限制此用户的最大连接数

9

覆盖全局密码过期设置

这只是冰山一角,但应该让你了解可以在用户创建过程中更改的参数。这些参数非常多。让我们稍微详细地审视该语句的特定部分:

用户和主机定义

我们在“理解用户和权限”中提到,MySQL 中的用户不仅由其名称定义,还由主机名定义。在前面的示例中,用户是'bob'@'10.0.2.%',其中*bob*是用户名,*10.0.2.%*是主机名规范。实际上,它是带通配符的主机名规范。每当有人使用 TCP 连接使用用户名*bob*连接时,MySQL 会执行几项操作:

  1. 获取连接客户端的 IP 地址。

  2. 对 IP 地址执行反向 DNS 查找,以获取主机名。

  3. 执行该主机名的 DNS 查找(以确保反向查找未被篡改)。

  4. 检查主机名或 IP 地址与用户的主机名规范。

仅当主机名匹配时才授予访问权限。对于我们的用户*bob*,来自 IP 地址10.0.2.121的连接将被允许,而来自10.0.3.22的连接将被拒绝。事实上,要允许来自另一个主机名的连接,应创建一个新用户。在内部,'bob'@'10.0.2.%'是一个完全不同的用户,而不是'bob'@'10.0.3.%'。在主机名规范中还可以使用完全限定域名(FQDN),例如'bob'@'acme.com',但 DNS 查找需要时间,通常会完全禁用它们以进行常见优化。

指定所有用户连接的所有可能主机名可能会很麻烦,但这是一个有用的安全功能。但是,有时数据库设置在防火墙后面,或者仅仅指定主机名是不切实际的。为了完全颠覆此系统,可以在主机规范中使用单个通配符,如'bob'@'10.0.2%''%'通配符也在您根本不指定主机时使用('bob'@'%')。

注意

在代理连接到 MySQL 时,请注意 MySQL“看到”的传入连接的 IP 地址。例如,当使用 HAProxy 时,默认情况下所有连接将来自于运行 HAProxy 的机器的 IP 地址。在配置用户时应考虑此事实。我们在第十五章中讨论了 MySQL 的 HAProxy 配置。

您会注意到,我们在单引号('')中同时包含了用户名和主机规范。这并非强制性要求,用户名和主机规范遵循与我们在“创建和使用数据库”和“别名”中概述的表名和列名以及别名相似的规则集。例如,在创建或更改用户bob@localhostbob@172.17.0.2时,无需使用任何引号。但是,不能创建不使用引号的此用户:'username with a space'@'172.%'。双引号、单引号或反引号可用于包含具有特殊符号的用户名和主机名。

认证插件规范

MySQL 支持通过其认证插件系统的多种方式对用户进行认证。这些插件还为开发人员提供了一种在不更改 MySQL 本身的情况下实现新的认证方式的方法。您可以在创建阶段或之后为用户设置特定的插件。

您可能永远不需要更改用户的插件,但了解此子系统仍然是值得的。特别是,使用特殊认证插件可以实现与 MySQL 的 LDAP 认证。MySQL 企业版支持一流的 LDAP 插件,其他 MySQL 版本和变种可以使用 PAM 作为中间人。

注意

PAM代表可插拔认证模块。这是 Unix-like 系统上的标准接口,简单来说,它允许 MySQL 通过各种方法提供认证,例如操作系统密码或 LDAP。PAM 隐藏了这些认证方法的复杂性,像 MySQL 这样的程序只需要与 PAM 本身进行接口交互。

MySQL 8.0 默认使用caching_sha2_password插件,与传统的mysql_native_password相比,提供了更优越的安全性和性能,但不兼容每个客户端库。要更改默认插件,您可以配置default_authentication_plugin变量,这将导致新用户使用指定的插件创建。

认证字符串/密码

一些认证插件,包括默认插件,要求您为用户设置密码。其他插件,如 PAM 插件,要求您定义从操作系统用户到 MySQL 用户的映射。在这两种情况下都将使用auth_string。让我们看一个使用 PAM 的示例映射:

mysql> `CREATE` `USER` `''``@``''` `IDENTIFIED` `WITH` `auth_pam`
    -> `AS` `'mysqld, dba=dbausr, dev=devusr'``;`
Query OK, 0 row affected (0.01 sec)

这里定义的映射可以按照以下方式阅读:PAM 配置文件mysqld将被使用(通常位于/etc/pam.d/mysqld);具有dba组的操作系统用户将映射到 MySQL 用户dbausr,具有dev组的操作系统用户将映射到devusr。然而,仅仅映射是不够的,因为必须分配必要的权限。

请注意,要使此功能正常工作,需要使用 Percona PAM 插件或 MySQL 企业版。此示例创建了一个代理用户,我们在“理解用户和权限”中简要讨论过。

使用非默认认证插件是一个相对高级的主题,所以我们只在这里提到 PAM,以向您展示认证字符串并非总是密码。

提示

您可以查阅有关安装Percona 插件和 MySQL企业版插件的详细文档。

默认角色设置

角色是 MySQL 的一个相对较新的添加。您可以将角色视为权限集合。我们在“角色”中讨论它们。

SSL 配置

您可以通过向CREATE USERALTER USER命令传递REQUIRE SSL来强制特定用户的连接使用 SSL。用户的未加密连接将被禁止。此外,您可以像我们所示的示例那样指定一个特定的密码套件或一组可用于该用户的套件。理想情况下,您应该在系统级别设置可接受的密码套件,但在用户级别设置此项对于允许某些较不安全的套件进行特定连接是有用的。您不需要指定REQUIRE SSL来指定REQUIRE CIPHER,在这种情况下可以建立未加密的连接。但是,如果建立了加密连接,它将仅使用您提供的特定密码集合:

mysql> `CREATE` `USER` `'john'``@``'192.168.%'` `IDENTIFIED` `BY` `'P@ssw0rd#'`
    -> `REQUIRE` `CIPHER` `'EDH-RSA-DES-CBC3-SHA'``;`
Query OK, 0 row affected (0.02 sec)

还有一些可用的额外可配置选项,包括以下内容:

X509

强制客户端提供有效证书。这一点以及以下选项都意味着使用SSL

ISSUER *issuer*

强制客户端提供由特定**issuer**指定的特定 CA 颁发的有效证书。

SUBJECT *subject*

强制客户端提供具有特定主题的有效证书。

这些选项可以组合使用,以指定非常特定的证书和加密要求:

mysql> `CREATE` `USER` `'john'``@``'192.168.%'`
    -> `REQUIRE` `SUBJECT` `'/C=US/ST=NC/L=Durham/` -> `O=BI Dept certificate/` -> `CN=client/emailAddress=john@nonexistent.com'`
    -> `AND` `ISSUER` `'/C=US/ST=NC/L=Durham/` -> `O=MySQL/CN=CA/emailAddress=ca@nonexistent.com'`
    -> `AND` `CIPHER` `'EDH-RSA-DES-CBC3-SHA'``;`

资源消耗限制

您可以定义资源消耗限制。在我们的示例中,我们通过此用户将最大并发连接数限制为 10。这些和其他资源选项默认为 0,意味着无限制。其他可能的约束条件包括MAX_CONNECTIONS_PER_HOURMAX_QUERIES_PER_HOURMAX_UPDATES_PER_HOUR。所有这些选项都是WITH规范的一部分。

让我们创建一个相当受限的用户,在每个小时内仅能运行 10 次查询,只能有一个并发连接,并且每小时最多只能连接两次:

mysql> `CREATE` `USER` `'john'``@``'192.168.%'`
    -> `WITH` `MAX_QUERIES_PER_HOUR` `10`
    -> `MAX_CONNECTIONS_PER_HOUR` `2`
    -> `MAX_USER_CONNECTIONS` `1``;`

注意MAX_QUERIES_PER_HOUR的数量包括MAX_UPDATES_PER_HOUR,并且还将限制更新。查询数量还包括 MySQL CLI 执行的所有内容,因此不建议设置一个非常低的值。

密码管理选项覆盖

对于处理存储在授权表中的密码的身份验证插件(在“授权表”中涵盖),您可以指定与密码相关的各种选项。在我们的示例中,我们正在设置一个用户,其具有PASSWORD EXPIRE NEVER策略,意味着其密码不会因时间而过期。您还可以创建一个用户,其密码每隔一天或每周过期一次。

MySQL 8.0 扩展了控制能力,包括跟踪失败的身份验证尝试并临时锁定帐户的能力。让我们考虑一个有严格控制的重要用户:

mysql> `CREATE` `USER` `'app_admin'``@``'192.168.%'`
    -> `IDENTIFY` `BY` `'...'`
    -> `WITH` `PASSWORD` `EXPIRE` `INTERVAL` `30` `DAY`
    -> `PASSWORD` `REUSE` `INTERVAL` `180` `DAY`
    -> `PASSWORD` `REQUIRE` `CURRENT`
    -> `FAILED_LOGIN_ATTEMPTS` `3`
    -> `PASSWORD_LOCK_TIME` `1``;`

这个用户的密码需要每 30 天更改一次,之前使用过的密码在 180 天内不能再次使用。在更改密码时,必须输入当前密码。为了安全起见,我们还只允许连续三次登录失败尝试,并且如果发生这些情况,将会封锁此用户一天。

请注意,这些是对默认系统选项的覆盖。手动设置每个个体用户是不现实的,因此我们建议您设置默认值,并仅对特定用户使用覆盖。例如,您可以使 DBA 用户的密码更频繁地过期。

还有一些其他用于用户创建的选项,我们在此不进行详述。随着 MySQL 的发展,会有更多选项可用,但我们认为迄今为止我们所涵盖的应该足够您在 MySQL 中学习的过程中使用。

由于本节不仅涉及创建新用户,还涉及使用新用户,让我们来谈谈这些用途。它们通常可以分为几类:

连接和认证

这是任何用户实体的默认和最广泛使用方式。您指定用户和密码,MySQL 将使用该用户和您的原始主机进行身份验证。然后,该对组成了授权表中定义的用户,在访问数据库对象时将用于授权。这是默认情况。您可以运行以下查询来查看当前验证的用户以及客户端提供的用户:

mysql> `SELECT` `CURRENT_USER``(``)``,` `USER``(``)``;`
+----------------+----------------+
| CURRENT_USER() | USER()         |
+----------------+----------------+
| root@localhost | root@localhost |
+----------------+----------------+
1 row in set (0.00 sec)

毫不奇怪,记录匹配。这是最常见的情况,但正如接下来您将看到的,这并不是唯一的可能性。

为存储对象提供安全性

当创建存储对象(如存储过程或视图)时,可以在该对象的DEFINER子句中指定任何用户。这允许您以另一个用户的身份执行对象:定义者,而不是调用者。这可以是提供某些特定操作的提升权限的有用方式,但也可能是系统中的安全漏洞。

当在对象的DEFINER子句中指定 MySQL 账户时,例如存储过程中,当执行存储过程或查询视图时,将使用该账户进行授权。换句话说,会话的当前用户会临时改变。正如我们提到的,这可以用来以受控方式提升权限。例如,与其授予用户从某些表中读取的权限,您可以创建一个视图,其DEFINER是您指定的账户,此时在查询视图时将允许访问表,但在其他情况下不允许。此外,视图本身也可以进一步限制返回的数据。对于存储过程也是如此。要与具有DEFINER的对象交互,调用者必须具有必要的权限。

让我们看一个例子。这是一个简单的存储过程,返回用于授权的当前用户以及已认证的用户。DEFINER设置为'bob'@'localhost'

`DELIMITER` `;``;`
`CREATE` `DEFINER` `=` `'bob'``@``'localhost'` `PROCEDURE` `test_proc``(``)`
`BEGIN`
    `SELECT` `CURRENT_USER``(``)``,` `USER``(``)``;`
`END``;`
`;``;`
`DELIMITER` `;`

如果此过程由先前示例中的用户john执行,则将打印类似以下内容的输出:

mysql> `CALL` `test_proc``(``)``;`
+----------------+--------------------+
| CURRENT_USER() | USER()             |
+----------------+--------------------+
| bob@localhost  | john@192.168.1.174 |
+----------------+--------------------+
1 row in set (0.00 sec)

记住这一点非常重要。有时用户并非表面上看起来那样,并且需要注意保护您的数据库安全。

代理

某些认证方法,如 PAM 和 LDAP,不支持从认证用户到数据库用户的一对一映射。我们之前展示了如何创建一个使用 PAM 认证的用户 —— 让我们看看如果这样一个用户查询认证用户和提供用户时会看到什么:

mysql> `SELECT` `CURRENT_USER``(``)``,` `USER``(``)``;`
+------------------+------------------------+
| CURRENT_USER()   | USER()                 |
+------------------+------------------------+
| dbausr@localhost | localdbauser@localhost |
+------------------+------------------------+
1 row in set (0.00 sec)

在我们结束这一节之前,我们应该提到与CREATE USER语句相关的一些重要点。首先,可以使用单个命令创建多个用户账户,而不是逐个执行CREATE USER语句。其次,如果用户已经存在,CREATE USER不会失败,但会在细微的方面更改该用户。这可能是危险的。为了避免这种情况,您可以在命令中指定IF NOT EXISTS选项。通过这样做,您告诉 MySQL 仅在不存在此类用户时创建用户,如果存在则不执行任何操作。

到目前为止,您应该对 MySQL 用户及其使用有了很好的理解。接下来,我们将向您展示如何修改用户,但首先您需要了解内部存储的与用户相关的信息。

授予表

MySQL 将用户信息和权限存储为授权表中的记录。这些是mysql数据库中的特殊内部表,理论上不应该手动修改,而应在执行诸如CREATE USERGRANT等语句时隐式修改。例如,这里是对mysql.user表执行SELECT查询的部分截断输出,其中包含用户记录,包括其密码(以哈希形式):

mysql> `SELECT` `*` `FROM` `user` `WHERE` `user` `=` `'root'``\``G`
*************************** 1\. row ***************************
                    Host: localhost
                    User: root
             Select_priv: Y
             Insert_priv: Y
             Update_priv: Y
             Delete_priv: Y
...
     Create_routine_priv: Y
      Alter_routine_priv: Y
        Create_user_priv: Y
              Event_priv: Y
            Trigger_priv: Y
  Create_tablespace_priv: Y
                ssl_type:
              ssl_cipher: 0x
             x509_issuer: 0x
            x509_subject: 0x
           max_questions: 0
             max_updates: 0
         max_connections: 0
    max_user_connections: 0
                  plugin: mysql_native_password
   authentication_string: *E1206987C3E6057289D6C3208EACFC1FA0F2FA56
        password_expired: N
   password_last_changed: 2020-09-06 17:20:57
       password_lifetime: NULL
          account_locked: N
        Create_role_priv: Y
          Drop_role_priv: Y
  Password_reuse_history: NULL
     Password_reuse_time: NULL
Password_require_current: NULL
         User_attributes: NULL
1 row in set (0.00 sec)

您可以立即看到,许多字段直接对应于特定的CREATE USERALTER USER语句的具体调用。例如,您可以看到此root用户没有关于其密码生命周期的任何特定规则设置。您还可以看到相当多的权限,尽管我们出于简洁起见省略了一些。这些是不需要目标(如表)的权限,称为全局权限。稍后我们将向您展示如何查看针对性的权限。

自 MySQL 8.0 起,其他授予表包括:

mysql.user

用户账户、静态全局权限和其他非特权列

mysql.global_grants

动态全局权限

mysql.db

数据库级别的权限

mysql.tables_priv

表级权限

mysql.columns_priv

列级权限

mysql.procs_priv

存储过程和函数权限

mysql.proxies_priv

代理用户权限

mysql.default_roles

默认用户角色

mysql.role_edges

角色子图的边缘

mysql.password_history

密码更改历史

您无需记住所有这些表,更不用说它们的结构和内容,但您应记住它们的存在。在必要时,您可以轻松地在文档或数据库本身中查找必要的结构信息。

在内部,MySQL 在内存中缓存授权表,并在运行帐户管理语句并因此修改授权表时刷新此缓存表示。仅对受影响的特定用户进行缓存失效。理想情况下,您不应直接修改这些授权表,而且很少有使用情况。但是,如果不幸需要修改授权表,可以通过运行FLUSH PRIVILEGES命令告知 MySQL 重新读取它们。如果不这样做,意味着内存中的缓存不会更新,直到数据库重新启动,针对直接在授权表中更新的同一用户运行帐户管理语句,或出于其他目的执行FLUSH PRIVILEGES为止。尽管命令的名称暗示它仅影响特权,但 MySQL 将从所有表中重新读取信息并刷新其内存中的缓存。

用户管理命令和日志记录

事实上,我们在本章讨论的所有命令的直接后果是它们在幕后修改授权表。在某些方面,它们接近 DML 操作。它们是原子的:任何CREATE USERALTER USERGRANT或其他此类操作要么成功,要么失败,而不会实际更改其目标。它们被记录:手动或通过相关命令对授权表进行的所有更改都记录在二进制日志中。因此,它们被复制(参见第十三章)并且还可以用于时点恢复(参见第十章)。

在源上应用这些语句可能会破坏复制,如果复制副本上不存在目标用户。因此,我们建议您保持副本与其源的一致性,不仅在数据上,还在用户和其他元数据上。当然,“元数据”仅在它存在于您的真实应用数据之外的意义上存在;用户是mysql.user表中的记录,设置复制时应记住这一点。

大多数情况下,复制副本是其源的完整副本。在像扇入这样的更复杂的拓扑中,可能不是这样,但即使在这种情况下,我们也建议在整个拓扑中保持用户的一致性。总体而言,这比修复损坏的副本或记住在更改用户之前是否需要禁用二进制日志更容易和更安全。

虽然我们说 CREATE USER 的执行类似于对 mysql.user 表进行 INSERT 操作,但 CREATE USER 语句本身在被记录之前并没有任何改变。这对二进制日志、慢查询日志(有一个警告)、通用查询日志和审计日志都是适用的。本章讨论的每个其他操作也是如此。慢查询日志的警告是,必须启用额外的服务器选项 log_slow_admin_statements 才能将管理语句记录在此处。

提示

您可以在以下系统变量名称下找到我们提到的日志位置:log_bin_basenameslow_query_log_filegeneral_log_file。它们的值可以包括文件的完整路径或只是文件名。在后一种情况下,该文件将位于 MySQL 服务器的数据目录中。二进制日志始终具有数字后缀,例如 binlog.000271。本书不涵盖审计日志配置。

考虑以下示例:

mysql> `CREATE` `USER` `'vinicius'` `IDENTIFIED` `BY` `'...'``;`
Query OK, 0 rows affected (0.02 sec)

这里有一个示例,展示了相同的 CREATE USER 命令在通用、慢查询和二进制日志中的反映:

通用日志

2020-11-22T15:53:17.354270Z        29 Query
    CREATE USER 'vinicius'@'%' IDENTIFIED BY <secret>

慢查询日志

# Time: 2020-11-22T15:53:17.368010Z
# User@Host: root[root] @ localhost []  Id:    29
# Query_time: 0.013772  Lock_time: 0.000161 Rows_sent: 0  Rows_examined: 0
SET timestamp=1606060397;
CREATE USER 'vinicius'@'%' IDENTIFIED BY <secret>;

二进制日志

#201122 18:53:17 server id 1  end_log_pos 4113 CRC32 0xa12ac622
    Query   thread_id=29    exec_time=0     error_code=0    Xid = 243
SET TIMESTAMP=1606060397.354238/*!*/;
CREATE USER 'vinicius'@'%' IDENTIFIED WITH 'caching_sha2_password'
    AS '$A$005$|v>\ZKe^R...'
/*!*/;

如果二进制日志输出让您感到难以理解,请不要担心。它并不是为了方便人类阅读而设计的。但是,您可以看到密码的实际哈希值,正如它将出现在 mysql.user 中。我们将在第十章中详细讨论这个日志。

修改和删除用户

创建用户通常不是与之互动的终点。您可能会后来需要更改其属性,也许是要求加密连接。还有可能需要删除用户。这些操作与用户创建并没有太大不同,但您需要知道如何执行它们才能完全掌握用户管理。

修改用户

可以在稍后的时间修改任何在用户创建时可以设置的参数。通常可以使用 ALTER USER 命令实现这一点。MySQL 5.7 及之前版本还有 RENAME USERSET PASSWORD 快捷方式,而 8.0 版本扩展了该列表以包括 SET DEFAULT ROLE(我们将在“角色”中介绍角色系统)。请注意,ALTER USER 可用于更改用户的所有内容,而其他命令只是运行常见维护操作的便捷方式。

注意

我们称 RENAME USER 为快捷方式,但它在于没有“完整”的 ALTER USER 替代方式。正如您将看到的,运行 RENAME USER 命令所需的特权也不同,与用户创建时相同(我们将很快更多地讨论特权)。

我们将从常规的ALTER USER命令开始。在第一个例子中,我们将修改使用的认证插件。许多较旧的程序不支持 MySQL 8.0 中的新标准caching_sha2_password插件,并要求您在创建用户时使用较旧的mysql_native_password插件或在创建后修改它们以使用该插件。我们可以通过查询授权表中的一张表(有关这些信息,请参见“授权表”)来检查当前正在使用的插件:

mysql> `SELECT` `plugin` `FROM` `mysql``.``user` `WHERE`
    -> `user` `=` `'bob'` `AND` `host` `=` `'localhost'``;`
+-----------------------+
| plugin                |
+-----------------------+
| caching_sha2_password |
+-----------------------+
1 row in set (0.00 sec)

现在我们可以为此用户更改插件并确保更改反映出来:

mysql> `ALTER` `USER` `'bob'``@``'localhost'` `IDENTIFIED` `WITH` `mysql_native_password``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `SELECT` `plugin` `FROM` `mysql``.``user` `WHERE`
    -> `user` `=` `'bob'` `AND` `host` `=` `'localhost'``;`
+-----------------------+
| plugin                |
+-----------------------+
| mysql_native_password |
+-----------------------+
1 row in set (0.00 sec)

由于更改是通过ALTER命令进行的,所以无需运行FLUSH PRIVILEGES。一旦该命令成功执行,每次新的认证尝试将使用新的插件。你可以直接修改用户记录来进行此更改,但我们仍然建议不要这样做。

ALTER USER可以修改的属性非常多,或者至少在“创建和使用新用户”中有所解释。然而,有一些经常需要的操作你应该更加了解:

更改用户的密码

这可能是对用户进行的最频繁的操作之一。更改用户的密码可以由具有必要特权的另一个用户执行,或者由授权为该用户的任何人使用以下命令执行:

mysql> `ALTER` `USER` `'bob'``@``'localhost'` `IDENTIFIED` `by` `'new password'``;`
Query OK, 0 rows affected (0.01 sec)

此更改立即生效,因此下次该用户进行认证时,他们将需要使用更新后的密码。这个命令也有一个SET PASSWORD的快捷方式。可以由已认证用户执行,无需任何目标规范,如下所示:

mysql> `SET` `PASSWORD` `=` `'new password'``;`
Query OK, 0 rows affected (0.01 sec)

或者可以由另一个具有目标规范的用户执行。

mysql> `SET` `PASSWORD` `FOR` `'bob'``@``'localhost'` `=` `'new password'``;`
Query OK, 0 rows affected (0.01 sec)

锁定和解锁用户

如果您需要暂时(或永久)阻止对特定用户的访问,可以使用ALTER USERACCOUNT LOCK选项来执行。在这种情况下,该用户只能在认证时被阻止。虽然没有人能够以被阻止的用户连接到 MySQL,但它仍然可以作为代理使用,并在DEFINER子句中使用。这使得此类用户稍微更安全且更易于管理。ACCOUNT LOCK也可以用来,例如,阻止作为特定用户连接的应用程序生成过多负载。

我们可以使用以下命令阻止bob进行认证:

mysql> `ALTER` `USER` `'bob'``@``'localhost'` `ACCOUNT` `LOCK``;`
Query OK, 0 rows affected (0.00 sec)

只有新连接会受到影响。MySQL 在这种情况下产生的消息非常清楚:

$ mysql -ubob -p
Enter password:
ERROR 3118 (HY000): Access denied for user 'bob'@'localhost'.
Account is locked.

ACCOUNT LOCK相对应的是ACCOUNT UNLOCKALTER USER的此选项确切地执行其描述的功能。让我们再次允许bob访问:

mysql> `ALTER` `USER` `'bob'``@``'localhost'` `ACCOUNT` `UNLOCK``;`
Query OK, 0 rows affected (0.01 sec)

现在连接尝试将成功:

$ mysql -ubob -p
Enter password:
mysql>

让用户密码过期

不要完全阻止用户的访问或更改其密码,您可能希望强制其更改密码。在 MySQL 中,可以通过ALTER USER命令的PASSWORD EXPIRE选项实现这一点。执行此命令后,用户仍然可以使用先前的密码连接到服务器。然而,一旦他们从新连接运行查询——也就是说,一旦检查他们的权限——用户将会遇到错误,并被强制更改密码。现有的连接不受影响。

让我们看看这对用户是什么样子。首先,实际修改:

mysql> `ALTER` `USER` `'bob'``@``'localhost'` `PASSWORD` `EXPIRE``;`
Query OK, 0 rows affected (0.01 sec)

现在,用户得到了什么。注意成功的身份验证:

$ mysql -ubob -p
Enter password:
mysql> `SELECT` `id``,` `data` `FROM` `bobs_db``.``bobs_private_table``;`
ERROR 1820 (HY000): You must reset your password using ALTER USER
statement before executing this statement.

尽管错误声明你必须运行ALTER USER,但现在你知道可以改用SET PASSWORD。谁更改密码并不重要:问题用户还是另一个用户。PASSWORD EXPIRE选项只是强制密码更改。如果另一个用户更改了密码,那么在密码过期后仍使用旧密码进行身份验证的会话需要重新打开。正如我们早些时候看到的,经过验证的用户可以在没有目标规范的情况下更改密码,并且他们将能够继续正常进行会话(但新连接将需要使用新密码进行身份验证):

mysql> `SET` `PASSWORD` `=` `'new password'``;`
Query OK, 0 rows affected (0.06 sec)
mysql> `SELECT` `id``,` `data` `FROM` `bobs_db``.``bobs_private_table``;`
Empty set (0.00 sec)

在这种情况下,你还应该注意,如果没有密码重用和历史控制措施,用户可以将密码重置为原始密码。

用户重命名

更改用户名是一个相对罕见的操作,但有时是必要的。这个操作有一个特殊的命令:RENAME USER。它需要CREATE USER权限,或者在mysql数据库或授权表上的UPDATE权限。对于这个命令,没有ALTER USER的替代方案。

你可以同时更改用户名的“名称”部分和“主机”部分。因为正如你现在所知,这个“主机”部分充当了防火墙的角色,在更改时要小心,可能会导致服务中断(实际上,“名称”部分也是如此)。让我们将我们的bob用户重命名为更正式的名称:

mysql> `RENAME` `USER` `'bob'``@``'localhost'` `TO` `'robert'``@``'172.%'``;`
Query OK, 0 rows affected, 1 warning (0.01 sec)

当用户名更改时,MySQL 会自动扫描其内部表,查看该用户是否在视图或存储对象的DEFINER子句中命名。每当出现这种情况时,会产生一个警告。由于我们在重命名bob时收到了警告,让我们来看看:

mysql> `SHOW` `WARNINGS``\``G`
*************************** 1\. row ***************************
  Level: Warning
   Code: 4005
Message: User 'bob'@'localhost' is referenced as a definer
  account in a stored routine.
1 row in set (0.00 sec)

如果不解决这个问题,可能会导致孤立的对象,在访问或执行时出错。我们将在下一节详细讨论这个问题。

删除用户

数据库用户生命周期的最后阶段是其结束。像任何数据库对象一样,用户可以被删除。在 MySQL 中,使用 DROP USER 命令来实现这一点。这是本章讨论的最简单的命令之一,可能也是整本书中最简单的命令。DROP USER 接受一个用户或者可选地一组用户作为参数,并且有一个修饰符:IF NOT EXISTS。成功执行该命令将从授权表中不可撤销地删除与用户相关的信息(我们稍后会讨论一个警告),从而防止进一步的登录。

当你删除一个仍然有一个或多个连接到数据库的用户时,即使删除成功,关联的记录只有在最后一个连接结束时才会被移除。下一次尝试使用该用户连接将导致ERROR 1045 (28000): Access denied错误信息。

IF NOT EXISTS 修饰符与 CREATE USER 的工作方式类似:如果目标用户不存在于 DROP USER 中,不会返回错误。这在无人值守脚本中非常有用。如果未指定用户名的主机部分,默认使用通配符 %

在其最基本的形式中,DROP USER 命令如下所示:

mysql> `DROP` `USER` `'jeff'``@``'localhost'``;`
Query OK, 0 rows affected (0.02 sec)

再次执行相同的命令将导致错误:

mysql> `DROP` `USER` `'jeff'``@``'localhost'``;`
ERROR 1396 (HY000): Operation DROP USER failed for 'jeff'@'localhost'

如果你想构建一个不会失败的幂等命令,那么使用以下结构:

mysql> `DROP` `USER` `IF` `EXISTS` `'jeff'``@``'localhost'``;`
Query OK, 0 rows affected, 1 warning (0.01 sec)
mysql> `SHOW` `WARNINGS``;`
+-------+------+-----------------------------------------------------+
| Level | Code | Message                                             |
+-------+------+-----------------------------------------------------+
| Note  | 3162 | Authorization ID 'jeff'@'localhost' does not exist. |
+-------+------+-----------------------------------------------------+
1 row in set (0.01 sec)

同样地,如果你没有指定用户名的主机部分,MySQL 将假定默认为 %。也可以一次性删除多个用户:

mysql> `DROP` `USER` `'jeff'``,` `'bob'``@``'192.168.%'``;`
Query OK, 0 rows affected (0.01 sec)

在 MySQL 中,由于用户不拥有对象,因此可以相对容易地删除它们而无需做太多准备。然而,正如我们已经讨论过的,用户可以担任额外的角色。如果删除的用户用作代理用户或是某个对象的 DEFINER 子句的一部分,则删除它可能会创建一个孤立的记录。每当您删除的用户属于这种关系时,MySQL 会发出警告。请注意,DROP USER 命令仍将成功执行,因此您需要解决由此产生的不一致性并修复任何孤立的记录:

mysql> `DROP` `USER` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected, 1 warning (0.01 sec)
mysql> `SHOW` `WARNINGS``\``G`
*************************** 1\. row ***************************
  Level: Warning
   Code: 4005
Message: User 'bob'@'localhost' is referenced as a definer
  account in a stored routine.
1 row in set (0.00 sec)

我们建议在实际删除用户之前检查这一点。如果你未能注意到警告并采取行动,这些对象将会变成孤立的。孤立的对象在使用时会产生错误:

mysql> `CALL` `test``.``test_proc``(``)``;`
ERROR 1449 (HY000): The user specified as a definer ('bob'@'localhost')
  does not exist

对于处于代理关系的用户,不会产生警告。然而,后续尝试使用代理用户将导致错误。由于代理用户用于身份验证,结果将是无法使用依赖于已删除用户的用户登录 MySQL。这可能比暂时失去调用存储过程或查询视图的能力更有影响力,但仍然不会发出警告。如果你使用依赖于代理用户的可插拔认证,请记住这一点。

如果您发现自己处于这样的情况,即删除了用户却意外地收到了警告,您可以轻松地重新创建用户以避免错误。注意下面的CREATE USER语句产生了现在熟悉的警告:

mysql> `CREATE` `USER` `'bob'``@``'localhost'` `IDENTIFIED` `BY` `'new password'``;`
Query OK, 0 rows affected, 1 warning (0.01 sec)
mysql> `SHOW` `WARNINGS``\``G`
*************************** 1\. row ***************************
  Level: Warning
   Code: 4005
Message: User 'bob'@'localhost' is referenced as a definer
  account in a stored routine.
1 row in set (0.00 sec)

然而,问题在于,如果您不知道或记得帐户的初始权限,新帐户可能会成为安全问题。

要识别孤立的记录,您需要手动查看 MySQL 的目录表。具体来说,您需要查看以下表的DEFINER列:

mysql> `SELECT` `table_schema``,` `table_name` `FROM` `information_schema``.``columns`
    -> `WHERE` `column_name` `=` `'DEFINER'``;`
+--------------------+------------+
| TABLE_SCHEMA       | TABLE_NAME |
+--------------------+------------+
| information_schema | EVENTS     |
| information_schema | ROUTINES   |
| information_schema | TRIGGERS   |
| information_schema | VIEWS      |
+--------------------+------------+

现在您知道了这一点,您可以轻松构建一个查询,以检查要删除或已经删除的用户是否在任何DEFINER子句中指定:

`SELECT` `EVENT_SCHEMA` `AS` `obj_schema`
    `,` `EVENT_NAME` `obj_name`
    `,` `'EVENT'` `AS` `obj_type`
`FROM` `INFORMATION_SCHEMA``.``EVENTS`
`WHERE` `DEFINER` `=` `'bob@localhost'`
`UNION`
`SELECT` `ROUTINE_SCHEMA` `AS` `obj_schema`
    `,` `ROUTINE_NAME` `AS` `obj_name`
    `,` `ROUTINE_TYPE` `AS` `obj_type`
`FROM` `INFORMATION_SCHEMA``.``ROUTINES`
`WHERE` `DEFINER` `=` `'bob@localhost'`
`UNION`
`SELECT` `TRIGGER_SCHEMA` `AS` `obj_schema`
    `,` `TRIGGER_NAME` `AS` `obj_name`
    `,` `'TRIGGER'` `AS` `obj_type`
`FROM` `INFORMATION_SCHEMA``.``TRIGGERS`
`WHERE` `DEFINER` `=` `'bob@localhost'`
`UNION`
`SELECT` `TABLE_SCHEMA` `AS` `obj_scmea`
    `,` `TABLE_NAME` `AS` `obj_name`
    `,` `'VIEW'` `AS` `obj_type`
`FROM` `INFORMATION_SCHEMA``.``VIEWS`
`WHERE` `DEFINER` `=` `'bob@localhost'``;`

这个查询看起来可能令人生畏,但是到目前为止,您应该已经看到了在“The Union”中使用的UNION,而该查询只是四个简单查询的联合。每个单独的查询都查找具有DEFINER值为bob@localhost的对象之一,分别是EVENTSROUTINESTRIGGERSVIEWS表中的一个。

在我们的示例中,查询为bob@localhost返回了单个记录:

+------------+-----------+-----------+
| obj_schema | obj_name  | obj_type  |
+------------+-----------+-----------+
| test       | test_proc | PROCEDURE |
+------------+-----------+-----------+

检查是否为该用户授予了代理权限同样很容易:

mysql> `SELECT` `user``,` `host` `FROM` `mysql``.``proxies_priv`
    -> `WHERE` `proxied_user` `=` `'bob'`
    -> `AND` `proxied_host` `=` `'localhost'``;`
+------+------+
| user | host |
+------+------+
| jeff | %    |
+------+------+

我们建议您在删除特定用户之前,始终检查可能存在的孤立对象和代理权限。数据库中留下的这些空白不仅会导致明显的问题(错误),而且实际上是安全风险。

权限

当用户连接到 MySQL 服务器时,将使用用户名和主机信息执行身份验证,如前所述。但是,在执行任何命令之前不会检查用户执行不同操作的权限。MySQL 根据连接用户的身份和其执行的操作授予权限。正如本章开头讨论的那样,权限是在各种对象上执行操作的权限集。默认情况下,用户没有任何权限,因此在执行CREATE USER后没有分配权限。

有很多特权可以授予用户,并且之后可以撤销。例如,您可以允许用户从表中读取或修改数据。您可以授予创建表和数据库的权限,以及创建存储过程的权限。清单非常广泛。有趣的是,你无法在任何地方找到连接权限:假设用户名的主机部分匹配,不可能阻止用户连接到 MySQL。这是前一段概述的直接结果:权限仅在执行操作时进行检查,因此它们的应用只有在用户通过身份验证后才会生效。

要获取您的 MySQL 安装支持和提供的所有权限列表,我们始终建议查阅手册。但是,我们将在此介绍少数广泛的权限类别。我们还将讨论权限级别,因为相同的权限可以在多个级别上允许。实际上,这是我们将要开始的内容。MySQL 中有四个权限级别:

全局权限

这些权限允许被授予者(被授予权限的用户,我们在 “权限管理命令” 中介绍 GRANT 命令)在每个数据库中的每个对象上操作,或者在整个集群上操作。后者适用于通常被视为管理性的命令。例如,您可以允许用户关闭集群。

此类别中的权限存储在 mysql.usermysql.global_grants 表中。第一个表存储传统的静态权限,第二个表存储动态权限。后面的章节将详细解释其区别。MySQL 8.0 之前的版本将所有全局权限存储在 mysql.user 中。

数据库权限

在数据库级别授予的权限将允许用户在该数据库中操作对象。由于在这个级别下,没有必要将 SHUTDOWN 权限下降到全局级别,因此在此级别的权限列表较窄。这些权限的记录存储在 mysql.db 表中,包括在目标数据库中运行 DDL 和 DML 查询的能力。

对象权限

数据库级别权限的逻辑延伸,这些权限针对特定对象。分别在 mysql.tables_privmysql.procs_privmysql.proxies_priv 中跟踪,分别涵盖表和视图、所有类型的存储过程以及代理用户权限。代理权限是特殊的,但该类别中的其他权限再次是常规的 DDL 和 DML 权限。

列权限

存储在 mysql.columns_priv 中,这些是一组有趣的权限。您可以按列在特定表中分离权限。例如,报告用户可能不需要读取特定表的 password 列。这是一个强大的工具,但是列权限可能难以管理和维护。

完整的特权列表实际上非常长。建议您始终查阅 MySQL 文档,以获取特定版本的完整细节。您应该记住,用户可以执行的任何操作都将分配专用特权或由控制更广泛行为范围的特权所覆盖。一般来说,数据库和对象级别的特权将有一个您可以授予的专用特权名称(UPDATESELECT等),而全局特权则通常会广泛地分组在一起,允许一次执行多个操作。例如,GROUP_REPLICATION_ADMIN特权一次允许五种不同的操作。全局特权通常也会在系统级别(.对象)上授予。

您可以通过运行SHOW PRIVILEGES命令随时访问您的 MySQL 实例中可用的特权列表:

mysql> `SHOW` `PRIVILEGES``;`
+----------------------------+----------------------+--------------------+
| Privilege                  | Context              | Comment            |
+----------------------------+----------------------+--------------------+
| Alter                      | Tables               | To alter the table |
| Alter routine              | Functions,Procedures | ...                |
| ...                                                                    |
| REPLICATION_SLAVE_ADMIN    | Server Admin         |                    |
| AUDIT_ADMIN                | Server Admin         |                    |
+----------------------------+----------------------+--------------------+
58 rows in set (0.00 sec)

静态特权与动态特权

在我们继续查看在 MySQL 中管理特权所用到的命令之前,我们必须停下来谈谈一个重要的区别。从版本 8.0 开始,MySQL 中有两种类型的特权:静态和动态。静态特权内置于服务器中,每个安装都可以使用和利用。动态特权则是“不稳定”的:它们不能保证始终存在。

什么是这些动态特权?它们是在运行时在服务器内注册的特权。只有注册的特权才能被授予,因此某些特权可能永远不会被注册,也不会被授予。所有这些都是说,现在可以通过插件和组件扩展特权的一种高级方式。然而,目前在常规社区服务器安装中,默认情况下注册的大多数动态特权都是可用的。

MySQL 8.0 提供的动态特权的重要作用在于,它们旨在减少以前滥用的SUPER特权的必要性(我们将在下一节讨论此特权)。动态特权的另一个区别在于,它们通常控制用户可以执行的一组活动。例如,与直接对表进行SELECT特权不同,后者仅允许查询数据,CONNECTION_ADMIN特权允许执行一整套操作。在这个特定示例中,包括终止其他账户的连接、在只读服务器中更新数据,在达到连接限制时通过额外连接连接,等等。您可以轻松地辨认出这些差异。

超级特权

这一节不长,但很重要。我们在“root 用户”中提到,每个 MySQL 安装默认创建一个超级用户:root@localhost。有时,您可能希望为另一个用户(例如 DBA 使用的用户)提供相同的功能。方便的内置方法是使用特殊的SUPER特权。

SUPER基本上是一个通用权限,使其被分配的用户成为超级用户。与任何权限一样,可以通过GRANT语句分配它,我们将在以下部分进行回顾。

然而,SUPER权限存在两个巨大的问题。首先,在 MySQL 8.0 开始,它已经被弃用,并且将在未来的 MySQL 版本中删除。其次,它是一个安全和操作上的噩梦。第一点很明显,现在让我们谈谈第二点,以及我们所拥有的替代方案。

使用SUPER权限会带来与使用默认的root@localhost用户相同的风险和问题。与仔细检查所需权限的范围不同,我们正在使用一个全能工具解决所有问题。SUPER的主要问题是其全面的范围。当您创建超级用户时,您正在创建一个责任:必须对用户进行限制,并在理想情况下进行审计,以及作为该用户进行认证的操作员和程序在其行动中必须极其精确和小心。伴随强大的权力而来的是巨大的责任,包括完全关闭 MySQL 实例的能力。想象一下误执行该操作的后果。

在 MySQL 8.0 之前的版本中,避免使用SUPER权限是不可行的,因为某些权限没有提供替代方法。从 8.0 版本开始,MySQL 提供了一整套动态权限,旨在消除单一通用权限的需求。如果可能的话,您应该尽量避免使用SUPER权限。

以控制组复制为例。在 MySQL 5.7 中,您需要向用户授予SUPER权限。然而,从 8.0 版本开始,您可以改为授予特殊的GROUP_REPLICATION_ADMIN权限,该权限仅允许用户执行与组复制相关的非常少量的操作。

有时,您仍然需要一个完整的 DBA 用户,可以执行任何操作。与其授予SUPER权限,不如查看root@localhost的权限并复制它们。我们会在“检查权限”中展示如何操作。更进一步,您可以跳过授予一些权限,比如INNODB_REDO_LOG_ENABLE权限,该权限授权用户基本上启用了一个不安全的崩溃模式。最安全的做法是根本不授予该权限,并且在绝对必要时可以自行授予。

权限管理命令

现在您已经了解了一些关于权限的信息,我们可以继续介绍基本命令,允许您控制它们。不过,请注意,您不能ALTER权限本身,因此在此处控制权限意味着向用户授予和移除权限。这些操作通过GRANTREVOKE语句实现。

GRANT

GRANT 语句用于授予用户(或角色)在通常或特定对象上执行活动的权限。同一语句也可以用于将角色分配给用户,但您不能同时修改权限和分配角色。为了能够授予权限(特权),您需要自己分配该特权,并拥有特殊的 GRANT OPTION 特权(稍后我们将进行讨论)。具有 SUPER(或更新的 CONNECTION_ADMIN)特权的用户可以授予任何内容,并且与授予表相关的特殊条件,我们稍后会讨论。

现在,让我们看一下 GRANT 语句的基本结构:

mysql> `GRANT` `SELECT` `ON` `app_db``.``*` `TO` `'john'``@``'192.168.%'``;`
Query OK, 0 row affected (0.01 sec)

该语句一旦执行,告诉 MySQL 用户 'john'@'192.168.%' 被允许在 app_db 数据库的任何表上执行只读查询 (SELECT)。请注意,我们在对象规范中使用了通配符。我们也可以通过为数据库指定通配符来允许特定用户访问每个数据库的每个表:

mysql> `GRANT` `SELECT` `ON` `*``.``*` `TO` `'john'``;`
Query OK, 0 row affected (0.01 sec)

前面的调用明显缺少用户 'john' 的主机规范。这个快捷方式相当于 'john'@'%';因此,它不是我们之前使用的 'john'@'192.168.%' 用户。说到通配符和用户,不可能为用户的用户名部分指定通配符。相反,您可以像这样一次指定多个用户或角色:

mysql> `GRANT` `SELECT` `ON` `app_db``.``*` `TO` `'john'``@``'192.168.%'``,`
    -> `'kate'``@``'192.168.%'``;`
Query OK, 0 row affected (0.06 sec)

在前一节中,我们警告您不要授予太多权限,但是请记住有一个 ALL 快捷方式,允许您在一个对象或一组对象上授予所有可能的权限。当您为“owner”用户(例如,读/写应用程序用户)定义权限时,这可能会很方便:

mysql> `GRANT` `ALL` `ON` `app_db``.``*` `TO` `'app_db_user'``;`
Query OK, 0 row affected (0.06 sec)

您不能链式对象规范,因此除非可以使用通配符表达该语句,否则无法同时对两个表授予 SELECT 权限。正如您将在下一节中看到的,您可以结合通配符授权和特定撤销以获取额外的灵活性。

GRANT 命令的一个有趣特性是它不会检查您允许的对象是否存在。也就是说,通配符不会被扩展,而是永远保持为通配符。无论 app_db 数据库中添加了多少新表,johnkate 都能够对它们发出 SELECT 语句。在 MySQL 8.0 开始,先前版本的 MySQL 也会为授予权限的用户创建一个用户,如果在 mysql.user 表中找不到该用户,但该行为已经弃用。

如我们在 “授权表” 中深入讨论的那样,GRANT 语句会更新授权表。从更新授权表的事实可以得出一个结论,即如果用户对这些表有 UPDATE 权限,那么该用户可以为任何帐户授予任何权限。在 mysql 数据库的对象上赋予权限时要格外小心:在这里为用户授予任何权限的好处甚微。还要注意,当启用 read_only 系统变量时,任何授权都需要超级用户权限 (SUPERCONNECTION_ADMIN)。

在继续之前,我们还想对 GRANT 提出几点建议。在本节的介绍中,我们提到了列权限。这组权限控制用户是否可以读取和更新表的特定列中的数据。与所有其他权限一样,它们可以使用 GRANT 命令授权:

mysql> `GRANT` `SELECT``(``id``)``,` `INSERT``(``id``,` `data``)`
    -> `ON` `bobs_db``.``bobs_private_table` `TO` `'kate'``@``'192.168.%'``;`
Query OK, 0 rows affected (0.01 sec)

用户 kate 现在可以执行语句 SELECT id FROM bobs_db.bobs_private_table,但不能执行 SELECT *SELECT data

最后,您可以通过运行 GRANT ALL PRIVILEGES 而不是逐个指定每个权限来授予特定对象或全局上的所有静态权限。ALL PRIVILEGES 只是一种简写,不像 SUPER 那样本身不是一种特权。

撤销

REVOKE 语句是 GRANT 语句的反义词:您可以使用它来撤销使用 GRANT 分配的权限和角色。除非另有规定,GRANT 的每个属性都适用于 REVOKE。例如,要撤销权限,您需要拥有 GRANT OPTION 权限以及要撤销的特定权限。

从 MySQL 版本 8.0.16 开始,可以撤销对全局授权用户的特定模式的权限。这使得可以轻松限制对某些数据库的访问,同时允许访问所有其他数据库,包括新创建的数据库。例如,考虑一个数据库系统,其中有一个受限模式。您需要为您的 BI 应用程序创建一个用户。您可以通过运行以下常规命令来开始:

mysql> `GRANT` `SELECT` `ON` `*``.``*` `TO` `'bi_app_user'``;`
Query OK, 0 rows affected (0.03 sec)

但是,此用户必须被禁止查询受限数据库中的任何数据。这可以通过部分撤销非常轻松地设置:

mysql> `REVOKE` `SELECT` `ON` `restricted_database``.``*` `FROM` `'bi_app_user'``;`
Query OK, 0 rows affected (0.03 sec)

在版本 8.0.16 之前,要实现这一点,您需要明确运行 GRANT SELECT 以允许每个允许的模式。

正如您可以授予所有权限一样,还存在一种特殊的 REVOKE 调用,允许从特定用户中删除所有权限。请记住,您需要拥有您正在撤销的所有权限,因此此选项可能仅由管理员用户使用。以下语句将从用户中剥夺其权限,包括分配任何权限的能力:

mysql> `REVOKE` `ALL` `PRIVILEGES``,` `GRANT` `OPTION` `FROM` `'john'``@``'192.168.%'``;`
Query OK, 0 rows affected (0.03 sec)

REVOKE 语句在任何情况下都不会删除用户本身。您可以使用前面在本章中描述的 DROP USER 语句来执行此操作。

检查权限

管理权限的一个重要部分是审查它们,但是要记住授予每个用户的每个权限是不可能的。您可以查询授予权限表以查看用户拥有的权限,但这并不总是方便。 (但这仍然是一种选择,并且可以成为查找例如在某个表上具有写权限的每个用户的良好方法。)查看授予特定用户的权限的更直接选项是使用内置的SHOW GRANTS命令。 让我们来看看它:

mysql> `SHOW` `GRANTS` `FOR` `'john'``@``'192.168.%'``;`
+--------------------------------------------------+
| Grants for john@192.168.%                        |
+--------------------------------------------------+
| GRANT UPDATE ON *.* TO `john`@`192.168.%`        |
| GRANT SELECT ON `sakila`.* TO `john`@`192.168.%` |
+--------------------------------------------------+
2 rows in set (0.00 sec)

一般情况下,您可以在此输出中看到每个权限,但也有一个特殊情况。 当用户对特定对象授予每个静态权限时,MySQL 将输出ALL PRIVILEGES而不是列出每个权限。 这不是特殊的权限本身,而是每个可能权限的简写。 内部,ALL PRIVILEGES只是将相应授权表中的每个权限设置为Y

mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+----------------------------------------------------------+
| Grants for bob@localhost                                 |
+----------------------------------------------------------+
...
| GRANT ALL PRIVILEGES ON `bobs_db`.* TO `bob`@`localhost` |
...

使用SHOW GRANTS命令,您也可以查看授予角色的权限,但我们将在“角色”中详细讨论此问题。要查看当前已验证和授权用户的权限,可以使用以下任一语句,这些语句是同义的:

`SHOW` `GRANTS``;`
`SHOW` `GRANTS` `FOR` `CURRENT_USER``;`
`SHOW` `GRANTS` `FOR` `CURRENT_USER``(``)``;`

每当您不记得特定权限的含义时,您可以查阅文档或运行SHOW PRIVILEGES命令,该命令将列出当前可用的每个权限。 这涵盖了静态对象权限和动态服务器权限。

有时您可能需要审查与所有帐户相关的权限或将这些权限转移到另一个系统。 您拥有的一个选项是使用 MySQL Server 为所有支持的平台提供的mysqldump命令。 我们将在第十章中详细审查该命令。 简而言之,您需要转储所有授予权限表,否则您可能会错过一些权限。 最安全的方法是转储mysql数据库中的所有数据:

$ mysqldump -uroot -p mysql
Enter password:

输出将包括所有表定义以及大量的INSERT语句。 此输出可以重定向到文件,然后用于种子新数据库。 我们在第十章中详细讨论这个问题。 如果您的服务器版本不匹配或目标服务器已经存储了一些用户和权限,则最好避免删除现有对象。 在mysqldump调用中添加--no-create-info选项,只接收INSERT语句。

通过使用mysqldump,您可以获得一个可移植的用户和权限列表,但它并不是易于阅读的。 这里是输出中一些行的示例:

--
-- Dumping data for table `tables_priv`
--

LOCK TABLES `tables_priv` WRITE;
/*!40000 ALTER TABLE `tables_priv` DISABLE KEYS */;
INSERT INTO `tables_priv` VALUES ('172.%','sakila','robert'...
'Select,Insert,Update,Delete,Create,Drop,Grant,References,...
('localhost','sys','mysql.sys','sys_config','root@localhost'
    '2020-07-13 07:14:57','Select','');
/*!40000 ALTER TABLE `tables_priv` ENABLE KEYS */;
UNLOCK TABLES;

另一个审查权限的选项是在授权表上编写自定义查询,如前所述。我们不会给出任何指导方针,因为没有一种大小适合所有的解决方案。

另一种方式是通过为数据库中的每个用户运行SHOW GRANTS。结合SHOW CREATE USER语句,您可以生成特权列表,这些特权也可以用于在另一个数据库中重新创建用户及其特权:

mysql> `SELECT` `CONCAT``(```"显示授予`"```,` `user``,` ``"`@`"```,` `host``,`

    -> ``"`; 显示创建用户 `"```,` `user``,` ``"`@`"```,` `host``,` ``"`;"```)` `grants`
    -> `FROM` `mysql``.``user` `WHERE` `user` `=` `"bob"``;`
+----------------------------------------------------------------+
| grants                                                         |
+----------------------------------------------------------------+
| SHOW GRANTS FOR bob@%; SHOW CREATE USER bob@%;                 |
| SHOW GRANTS FOR bob@localhost; SHOW CREATE USER bob@localhost; |
+----------------------------------------------------------------+
2 rows in set (0.00 sec)

正如您可以想象的那样,自动化这个过程的想法并不新鲜。事实上,在 Percona Toolkit 中有一个工具——pt-show-grants——正是如此,而且更多。不幸的是,这个工具只能在 Linux 上正式使用,并且可能在其他平台上根本无法工作:

$ pt-show-grants
-- Grants dumped by pt-show-grants
-- Dumped from server Localhost via Unix socket,
    MySQL 8.0.22 at 2020-12-12 14:32:33
-- Roles
CREATE ROLE IF NOT EXISTS `application_ro`;
-- End of roles listing
...
-- Grants for 'robert'@'172.%'
CREATE USER IF NOT EXISTS 'robert'@'172.%';
ALTER USER 'robert'@'172.%' IDENTIFIED WITH 'mysql_native_password'
AS '*E1206987C3E6057289D6C3208EACFC1FA0F2FA56' REQUIRE NONE
PASSWORD EXPIRE DEFAULT ACCOUNT UNLOCK PASSWORD HISTORY DEFAULT
PASSWORD REUSE INTERVAL DEFAULT PASSWORD REQUIRE CURRENT DEFAULT;
GRANT ALL PRIVILEGES ON `bobs_db`.* TO `robert`@`172.%`;
GRANT ALL PRIVILEGES ON `sakila`.`actor` TO `robert`@`172.%` WITH GRANT OPTION;
GRANT SELECT ON `sakila`.* TO `robert`@`172.%` WITH GRANT OPTION;
GRANT SELECT ON `test`.* TO `robert`@`172.%` WITH GRANT OPTION;
GRANT USAGE ON *.* TO `robert`@`172.%`;
...

GRANT OPTION 特权

正如本章开头讨论的那样,MySQL 没有对象所有权的概念。因此,与其他一些系统不同,某个用户创建表并不意味着该用户自动可以允许另一个用户对该表执行任何操作。为了让这个过程稍微清晰一些,让我们来看一个例子。

假设用户bob有权限在名为bobs_db的数据库中创建表格:

mysql> `CREATE` `TABLE` `bobs_db``.``bobs_private_table`
    -> `(``id` `SERIAL` `PRIMARY` `KEY``,` `data` `TEXT``)``;`
Query OK, 0 rows affected (0.04 sec)

一个使用bob用户的操作员希望允许john用户读取新创建表中的数据——但很遗憾,这是不可能的:

mysql> `GRANT` `SELECT` `ON` `bobs_db``.``bobs_private_table` `TO` `'john'``@``'192.168.%'``;`
ERROR 1142 (42000): SELECT, GRANT command denied
to user 'bob'@'localhost' for table 'bobs_private_table'

让我们检查一下bob实际拥有哪些特权:

mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+----------------------------------------------------------+
| Grants for bob@localhost                                 |
+----------------------------------------------------------+
| GRANT USAGE ON *.* TO `bob`@`localhost`                  |
| GRANT SELECT ON `sakila`.* TO `bob`@`localhost`          |
| GRANT ALL PRIVILEGES ON `bobs_db`.* TO `bob`@`localhost` |
+----------------------------------------------------------+
3 rows in set (0.00 sec)

这里缺少的一部分是一种特权,允许用户授予其他用户它已被授予的特权。如果 DBA 想要允许bob将其他用户访问bobs_db数据库中的表格,就需要授予额外的特权。bob不能将其授予自己,因此需要具有管理特权的用户:

mysql> `GRANT` `SELECT` `ON` `bobs_db``.``*` `TO` `'bob'``@``'localhost'`
    -> `WITH` `GRANT` `OPTION``;`
Query OK, 0 rows affected (0.01 sec)

注意WITH GRANT OPTION的添加。这正是我们正在寻找的特权。这个选项将允许bob用户将其特权传递给其他用户。让我们再次以bob的身份运行GRANT SELECT语句来确认一下:

mysql> `GRANT` `SELECT` `ON` `bobs_db``.``bobs_private_table` `TO` `'john'``@``'192.168.%'``;`
Query OK, 0 rows affected (0.02 sec)

如预期的那样,该语句被接受并执行。然而仍有一些澄清需要做。首先,我们可能想知道GRANT OPTION特权的粒度有多高。也就是说,除了在bobs_private_table上的SELECTbob还能够授予其他用户什么特权?SHOW GRANTS可以为我们整洁地回答这个问题:

mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+----------------------------------------------------------------------------+
| Grants for bob@localhost                                                   |
+----------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `bob`@`localhost`                                    |
| GRANT SELECT ON `sakila`.* TO `bob`@`localhost`                            |
| GRANT ALL PRIVILEGES ON `bobs_db`.* TO `bob`@`localhost` WITH GRANT OPTION |
+----------------------------------------------------------------------------+
3 rows in set (0.00 sec)

现在更清晰了。我们可以看到WITH GRANT OPTION应用于bob在特定数据库上拥有的特权。这一点很重要。尽管我们执行了GRANT SELECT ... WITH GRANT OPTIONbob获得了在bobs_db数据库中授予其每个特权的能力。

其次,我们可能想知道是否可能仅撤销GRANT OPTION特权:

mysql> `REVOKE` `GRANT` `OPTION` `ON` `bobs_db``.``*` `FROM` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+----------------------------------------------------------+
| Grants for bob@localhost                                 |
+----------------------------------------------------------+
| GRANT USAGE ON *.* TO `bob`@`localhost`                  |
| GRANT SELECT ON `sakila`.* TO `bob`@`localhost`          |
| GRANT ALL PRIVILEGES ON `bobs_db`.* TO `bob`@`localhost` |
+----------------------------------------------------------+
3 rows in set (0.00 sec)

最后,看一下如何撤销GRANT OPTION,我们可能想知道是否可以单独授予它。答案是可以,但有一个我们将会展示的警告。让我们给bobsakilatest数据库上授予GRANT OPTION特权。正如您从前面的输出中可以看到的那样,bob当前在sakila上拥有SELECT特权,但在test数据库上没有任何特权:

mysql> `GRANT` `GRANT` `OPTION` `ON` `sakila``.``*` `TO` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `GRANT` `GRANT` `OPTION` `ON` `test``.``*` `TO` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.01 sec)

两个语句都成功了。很明显,bobsakila 上可以授予 SELECT 权限。然而,test 发生了什么就不那么清楚了。让我们来看看:

mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+-------------------------------------------------------------------+
| Grants for bob@localhost                                          |
+-------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `bob`@`localhost`                           |
| GRANT SELECT ON `sakila`.* TO `bob`@`localhost` WITH GRANT OPTION |
| GRANT USAGE ON `test`.* TO `bob`@`localhost` WITH GRANT OPTION    |
| GRANT ALL PRIVILEGES ON `bobs_db`.* TO `bob`@`localhost`          |
+-------------------------------------------------------------------+
4 rows in set (0.00 sec)

好的,所以仅仅单独使用 GRANT OPTION 只会给用户一个 USAGE 权限,这是“无权限”指定符。但是,GRANT OPTION 权限可以看作是一个开关,当“打开”时,它将适用于 bobtest 数据库中被授予的权限:

mysql> `GRANT` `SELECT` `ON` `test``.``*` `TO` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+-------------------------------------------------------------------+
| Grants for bob@localhost                                          |
+-------------------------------------------------------------------+
...
| GRANT SELECT ON `test`.* TO `bob`@`localhost` WITH GRANT OPTION   |
...
+-------------------------------------------------------------------+
4 rows in set (0.00 sec)

到目前为止,我们一直在使用通配符权限,但是也可以为特定表启用 GRANT OPTION

mysql> `GRANT` `INSERT` `ON` `sakila``.``actor`
    -> `TO` `'bob'``@``'localhost'` `WITH` `GRANT` `OPTION``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+-------------------------------------------------------------------------+
| Grants for bob@localhost                                                |
+-------------------------------------------------------------------------+
...
| GRANT INSERT ON `sakila`.`actor` TO `bob`@`localhost` WITH GRANT OPTION |
+-------------------------------------------------------------------------+
5 rows in set (0.00 sec)

到现在为止,应该清楚 GRANT OPTION 是权限系统的一个强大补充。考虑到 MySQL 缺乏所有权的概念,这是确保非超级用户可以相互授予权限的唯一方式。然而,像往常一样,记住 GRANT OPTION 适用于用户拥有的每个权限是非常重要的。

角色

角色,在 MySQL 8.0 中引入,是权限的集合。它们通过将必要的权限分组和“容器化”来简化用户和权限管理。您可能有几个不同的 DBA 用户,他们都需要相同的权限。您可以创建一个角色,向该角色授予权限,并分配给用户该角色,而不是单独为每个用户授予权限。通过这样做,您还简化了管理,因为您不需要单独更新每个用户。如果您的 DBA 需要调整其权限,您只需调整角色即可。

角色在创建、存储和管理方式上与用户非常相似。要创建角色,您需要执行 CREATE ROLE [IF NOT EXISTS] role1[, role2[, role3 …]] 语句。要删除角色,您需要执行 DROP ROLE [IF EXISTS] role1[, role2[, role3 …]] 语句。当您删除角色时,将移除分配给所有用户的该角色的权限。创建角色所需的权限是 CREATE ROLECREATE USER。删除角色时,需要 DROP ROLEDROP USER 权限。与用户管理命令一样,如果设置了 read_only,还需要管理员权限来创建和删除角色。对授予权限表的直接修改权限允许用户修改任何内容,正如我们之前讨论过的。

就像用户名一样,角色名由两部分组成:名称本身和主机规范。当未指定主机时,默认为 % 通配符。角色的主机规范不会以任何方式限制其使用。它存在的原因是因为角色与用户一样存储在 mysql.user 授权表中。因此,您不能将相同的 rolename@host 作为现有用户。要创建与现有用户同名的角色,请为角色指定不同的主机名。

与权限不同,角色并非始终处于活动状态。当用户被授予角色时,他们被授权使用该角色,但不一定要这样做。实际上,用户可以拥有多个角色,并且可以在同一连接中“启用”其中一个或多个角色。

可以在用户创建时或稍后通过ALTER USER命令向一个或多个角色分配一个或多个角色作为默认值。这些角色将在用户验证后立即生效。

让我们回顾与角色管理相关的命令、设置和术语:

GRANT PRIVILEGEREVOKE PRIVILEGE命令

我们在“权限管理命令”中介绍了这些命令。在所有目的上,角色可以像用户一样使用GRANTREVOKE PRIVILEGE命令。也就是说,您可以向角色分配与用户相同的所有特权,并撤销它们。

GRANT role [, role …] TO user` 命令

与角色管理相关的基本命令。通过运行此命令,您授权用户承担特定角色。正如前面提到的,用户无需使用该角色。让我们创建一些可以在sakila数据库上操作的角色:

mysql> `CREATE` `ROLE` `'application_rw'``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `CREATE` `ROLE` `'application_ro'``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `GRANT` `ALL` `ON` `sakila``.``*` `TO` `'application_rw'``;`
Query OK, 0 rows affected (0.06 sec)
mysql> `GRANT` `SELECT` `ON` `sakila``.``*` `TO` `'application_ro'``;`
Query OK, 0 rows affected (0.00 sec)

现在您可以将这些角色分配给任意数量的用户,并仅在需要时更改角色。在这里,我们允许我们的bob用户对sakila数据库进行只读访问:

mysql> `GRANT` `'application_ro'` `TO` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.00 sec)

您还可以在单个语句中授予多个角色。

WITH ADMIN OPTION修饰符

当您向用户授予角色时,该用户仅被允许激活角色,而不能以任何方式更改它。该用户可能不会将角色授予任何其他用户。如果您希望允许修改角色并有能力将其授予其他用户,则可以在GRANT ROLE命令中指定WITH ADMIN OPTION。其结果将反映在授予表中,并将显示在SHOW GRANTS命令的输出中:

mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'``;`
+-------------------------------------------------------------------+
| Grants for bob@localhost                                          |
+-------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `bob`@`localhost`                           |
| GRANT `application_ro`@`%` TO `bob`@`localhost` WITH ADMIN OPTION |
+-------------------------------------------------------------------+
2 rows in set (0.00 sec)

SHOW GRANTS和角色

SHOW GRANTS命令,我们在“检查权限”中引入了,能够显示激活一个或多个角色的已分配角色和有效权限。这可以通过添加可选的USING role修饰符来实现。在这里,我们展示bob激活application_ro角色后将拥有的有效权限:

mysql> `SHOW` `GRANTS` `FOR` `'bob'``@``'localhost'` `USING` `'application_ro'``;`
+-------------------------------------------------------------------+
| Grants for bob@localhost                                          |
+-------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `bob`@`localhost`                           |
| GRANT SELECT ON `sakila`.* TO `bob`@`localhost`                   |
| GRANT `application_ro`@`%` TO `bob`@`localhost` WITH ADMIN OPTION |
+-------------------------------------------------------------------+
3 rows in set (0.00 sec)

SET ROLE DEFAULT | NONE | ALL | ALL EXCEPT role [, role1 …] | role [, role1 …] 命令

SET ROLE角色管理命令由经过身份验证的用户调用,用于向自身分配特定角色或角色。一旦设置了角色,其权限将适用于用户。让我们继续使用我们的bob示例:

$ mysql -ubob
mysql> `SELECT` `staff_id``,` `first_name` `FROM` `sakila``.``staff``;`
ERROR 1142 (42000): SELECT command denied to user 'bob'@'localhost' for
table 'staff'
mysql> `SET` `ROLE` `'application_rw'``;`
ERROR 3530 (HY000): `application_rw`@`%` is not granted to
`bob`@`localhost`
mysql> `SET` `ROLE` `'application_ro'``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SELECT` `staff_id``,` `first_name` `FROM` `sakila``.``staff``;`
+----------+------------+
| staff_id | first_name |
+----------+------------+
|        1 | Mike       |
|        2 | Jon        |
+----------+------------+
2 rows in set (0.00 sec)

只有在分配了角色后,bob才能使用其权限。请注意,您不能使用SET ROLE为自己分配未经授权(通过GRANT ROLE)使用的角色。

没有 UNSET ROLE 命令,但是有几个扩展功能可以用于 SET ROLE 命令的这种行为。要取消所有角色,请执行 SET ROLE NONE。用户也可以通过执行 SET ROLE DEFAULT 返回到其默认角色集,或者通过运行 SET ROLE ALL 激活其所有可访问的角色。如果需要设置既非默认角色又非全部角色的子集角色,则可以构造 SET ROLE ALL EXCEPT role [, role1 …] 语句,并显式避免设置一个或多个角色。

DEFAULT ROLE 用户选项

当您运行 CREATE USER 或稍后通过 ALTER USER,您可以设置一个或多个角色为特定用户的默认角色。一旦用户认证,这些角色将隐式设置,避免了 SET ROLE 命令。例如,对于大多数时间使用单个角色或已知角色集的应用程序用户,这很方便。让我们将 application_ro 设置为 bob 的默认角色:

$ mysql -uroot
mysql> `ALTER` `USER` `'bob'``@``'localhost'` `DEFAULT` `ROLE` `'application_ro'``;`
Query OK, 0 rows affected (0.02 sec)
$ mysql -ubob
mysql> `SELECT` `CURRENT_ROLE``(``)``;`
+----------------------+
| CURRENT_ROLE()       |
+----------------------+
| `application_ro`@`%` |
+----------------------+
1 row in set (0.00 sec)

一旦 bob@localhost 登录,CURRENT_ROLE() 函数将返回所需的 application_ro

强制角色

可以通过设置 mandatory_roles 系统参数(全局范围内,动态变更)为角色列表来隐式地授予数据库中的每个用户一个或多个角色。通过 mandatory_roles 方式授予的角色在运行 SET ROLE 命令之前不会激活。无法撤销通过此方式分配的角色,但可以显式授予用户。在 mandatory_roles 中列出的角色在从设置中移除之前无法删除。

自动激活角色

默认情况下,角色在执行 SET ROLE 命令之前不会激活。但是,可以覆盖此行为,在用户认证时自动激活所有可用角色。这类似于登录时执行 SET ROLE ALL 的行为。可以通过更改 activate_all_roles_on_login 系统参数(全局范围内,动态变更)来启用或禁用此行为,默认情况下为禁用。当 activate_all_roles_on_login 设置为 ON 时,对每个用户都会激活通过显式或隐式(通过 mandatory_roles)授予的角色。

级联角色权限

角色可以授予给其他角色。然后,授予角色的所有权限都会被授予接受角色。一旦用户激活了接受角色,您可以将该用户视为已激活授予角色。让我们稍微复杂化我们的例子。我们将有一个 application 角色,该角色被授予 application_roapplication_rw 角色。application 角色本身没有直接的权限分配。我们将 application 角色分配给我们的 bob 用户并检查结果:

mysql> `CREATE` `ROLE` `'application'``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `GRANT` `'application_rw'``,` `'application_ro'` `TO` `'application'``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `REVOKE` `'application_ro'` `FROM` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `GRANT` `'application'` `TO` `'bob'``@``'localhost'``;`
Query OK, 0 rows affected (0.00 sec)

bob 激活 application 角色时,现在的情况是,它将同时具备 rwro 角色的权限。我们可以轻松验证这一点。请注意,bob 无法激活任何间接授予其的角色:

$ mysql -ubob
mysql> `SET` `ROLE` `'application'``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SELECT` `staff_id``,` `first_name` `FROM` `sakila``.``staff``;`
+----------+------------+
| staff_id | first_name |
+----------+------------+
|        1 | Mike       |
|        2 | Jon        |
+----------+------------+
2 rows in set (0.00 sec)

角色图

由于角色可以授予角色,因此生成的层次结构可能非常难以跟踪。您可以通过检查 mysql.role_edges 授予表来查看它:

mysql> `SELECT` `*` `FROM` `mysql``.``role_edges``;`
+-----------+----------------+-----------+-------------+----------------+
| FROM_HOST | FROM_USER      | TO_HOST   | TO_USER     | WITH_ADMIN_... |
+-----------+----------------+-----------+-------------+----------------+
| %         | application    | localhost | bob         | N              |
| %         | application_ro | %         | application | N              |
| %         | application_rw | %         | application | N              |
| %         | developer      | 192.168.% | john        | N              |
| %         | developer      | localhost | bob         | N              |
| 192.168.% | john           | %         | developer   | N              |
+-----------+----------------+-----------+-------------+----------------+
6 rows in set (0.00 sec)

对于更复杂的层次结构,MySQL 方便地包含了一个内置函数,允许您生成一个有效的 GraphML 格式的 XML 文档。您可以使用任何能力强大的软件来可视化输出。以下是函数调用及其生成的高度格式化输出(XML 在书籍中的效果不佳):

mysql> `SELECT` `*` `FROM` `mysql``.``roles_graphml``(``)``\``G`
*************************** 1\. row ***************************
roles_graphml(): <?xml version="1.0" encoding="UTF-8"?>
<graphml xmlns="...
...
    <node id="n0">
      <data key="key1">`application`@`%`</data>
    </node>
    <node id="n1">
      <data key="key1">`application_ro`@`%`</data>
    </node>
...

理想情况下,您应该使用 SELECT ... INTO OUTFILE(参见“将数据写入逗号分隔文件”)。然后可以使用诸如 yEd 图形编辑器 这样的工具来可视化输出。您可以看到完整图表的放大部分,重点放在我们的 bob 用户和周围的角色上,见 图 8-1。运行此功能所需的权限是 ROLE_ADMIN

lm2e 0801

图 8-1. MySQL 角色图可视化部分

角色与用户之间的区别

之前我们提到 CREATE USERDROP USER 权限允许修改角色。考虑到角色与用户存储在 mysql.user 中,您可能也会猜到常规用户管理命令对角色也适用。这很容易测试和确认:只需在角色上运行 RENAME USERDROP USER。另一个需要注意的是,GRANTREVOKE PRIVILEGE 命令如何以角色为目标,就像它们是用户一样。

角色在本质上只是普通用户。事实上,可以使用 GRANT ROLE 将一个解锁的用户授予另一个解锁的用户或角色:

mysql> `CREATE` `ROLE` `'developer'``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `GRANT` `'john'``@``'192.168.%'` `TO` `'developer'``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `SELECT` `from_user``,` `to_user` `FROM` `mysql``.``role_edges``;`
+-----------+-----------+
| from_user | to_user   |
+-----------+-----------+
| john      | developer |
+-----------+-----------+
1 row in set (0.00 sec)

角色是 MySQL 用户和权限系统的强大而灵活的补充。与几乎任何功能一样,它们可能会被滥用,导致不必要复杂的层次结构,难以跟踪。但是,如果保持简单,角色可以节省大量工作。

更改 root 的密码和不安全的启动

有时可能需要访问 MySQL 实例而不知道任何用户的密码。或者您可能会意外删除数据库中的每个用户,从而有效地将自己锁在外面。在这种情况下,MySQL 提供了一个解决方法,但需要您能够更改其配置并重新启动相应的实例。您可能会认为这很可疑或危险,但实际上只是为了防止数据库管理员经常遇到的最简单的问题之一:忘记密码。想象一下运行着没有超级用户访问权限的生产实例:这显然是不可取的。幸运的是,在必要时可以绕过授权。

要执行身份验证和权限绕过操作,您必须使用指定了--skip-grant-tables选项重新启动 MySQL 实例。由于大多数安装使用服务脚本来启动实例,您可以在my.cnf配置文件的[mysqld]部分指定skip-grant-tables。当以这种模式启动mysqld时(相当明显地),它将跳过读取授权表,具体效果如下:

  • 不执行身份验证,因此不需要知道任何用户名或密码。

  • 不加载权限,并且不检查权限。

  • 在运行在不安全配置下的情况下,MySQL 将隐式设置--skip-networking以阻止除本地访问外的任何访问。

当您连接到使用--skip-grant-tables运行的 MySQL 实例时,您将作为特殊用户授权。此用户可以访问每个表并可以更改任何用户。例如,在更改root用户的丢失密码之前,您需要运行FLUSH PRIVILEGES;否则,ALTER将失败:

mysql> `SELECT` `current_user``(``)``;`
+-----------------------------------+
| current_user()                    |
+-----------------------------------+
| skip-grants user@skip-grants host |
+-----------------------------------+
1 row in set (0.00 sec)
mysql> `ALTER` `USER` `'root'``@``'localhost'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`
ERROR 1290 (HY000): The MySQL server is running with the --skip-grant-tables
option so it cannot execute this statement
mysql> `FLUSH` `PRIVILEGES``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `ALTER` `USER` `'root'``@``'localhost'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`
Query OK, 0 rows affected (0.01 sec)

重置密码后,建议以正常模式重新启动 MySQL 实例。

还有一种恢复root密码的方法,可能更安全。mysqld可以使用的众多命令行参数之一是--init-file(或者通过my.cnf使用init_file)。该参数指定一个包含一些 SQL 语句的文件路径,在 MySQL 启动时将执行这些语句。此时不进行特权检查,因此可以在那里放置一个ALTER USER root语句。建议在恢复访问或创建新的root用户后删除该文件并取消该选项。

警告

这里介绍的两种选项都可能导致安全问题,请谨慎使用!

一些安全设置的想法

在本章节中,我们概述了与用户和权限管理相关的一些实践,这些实践可以帮助使您的服务器更安全和稳定。这里我们将简要总结这些技术以及我们对它们的推荐。

从管理方面来看,我们有以下建议:

  • 避免过度使用内置超级用户root@localhost。想象一下五个人都能访问这个用户。即使在 MySQL 启用了审计,您也无法有效地区分哪个特定的人访问了用户以及何时访问的。这个用户也将是潜在攻击者首先尝试利用的用户。

  • 从 MySQL 8.0 开始,避免通过SUPER特权创建新的超级用户。相反,可以创建一个特殊的 DBA 角色,该角色可以分配所有动态权限或仅分配经常需要的部分权限。

  • 将 DBA 功能的特权组织成单独的角色考虑一下。例如,INNODB_REDO_LOG_ARCHIVEINNODB_REDO_LOG_ENABLE 权限可以作为 innodb_redo_admin 角色的一部分。由于角色默认不会自动激活,运行潜在危险的管理命令之前,需要显式地使用 SET ROLE

对于普通用户,推荐基本相似:

  • 努力减少权限的范围。始终询问这个用户是否需要访问集群中的每个数据库,甚至是特定数据库中的每张表。

  • 在 MySQL 8.0 中,使用角色是一种方便且可以说更安全的方式来分组和管理权限。如果有三个用户需要相同或类似的权限,他们可以共享一个角色。

  • 绝不允许任何非超级用户在 mysql 数据库中修改表的权限。这是一个简单的错误,源自于本列表中的第一个建议。授予 UPDATE 权限于 *.* 将允许授权用户自行授予任何权限。

  • 为了使事情更加安全和可见,您可以考虑定期保存当前分配给用户的所有权限,并将结果与先前保存的样本进行比较。您可以轻松地比较 pt-show-grants 的输出,甚至是 mysqldump 的输出。

完成本章后,您应该能够轻松管理 MySQL 中的用户和权限。

第九章:使用选项文件

几乎每个软件都可以进行配置,甚至必须进行配置。MySQL 在这方面并没有太大的不同。虽然默认配置可能适用于大多数安装,但很可能您最终需要配置服务器或客户端。MySQL 提供两种配置方式:通过命令行参数选项和配置文件。由于这个文件只包含可以在命令行上指定的选项,它也被称为选项文件。

选项文件不仅适用于 MySQL Server。严格来说,讨论选项文件也不完全正确,因为几乎每个 MySQL 的安装都会有多个选项文件。大多数 MySQL 软件支持在选项文件中包含内容,我们也会涵盖这部分内容。

熟悉选项文件——理解其节和选项优先级——是有效地使用 MySQL Server 和相关软件的重要部分。通过本章的学习,您应该能够轻松配置 MySQL Server 和其他使用选项文件的程序。本章将专注于文件本身。服务器本身的配置和一些调优思路在第十一章中深入讨论。

选项文件的结构

MySQL 中的配置文件遵循普遍存在的 INI 文件方案。简而言之,它们是普通文本文件,旨在手动编辑。当然,您可以自动化编辑过程,但这些文件的结构故意设计得非常简单。几乎每个 MySQL 配置文件都可以使用任何文本编辑器创建和修改。这条规则只有两个例外,详细介绍在“特殊选项文件”中。

为了让你了解文件结构,让我们来看看 Fedora Linux 上 MySQL 8 随附的配置文件(请注意,您系统上选项文件的确切内容可能有所不同)。为简洁起见,我们对一些行进行了编辑:

$ cat /etc/my.cnf
...
[mysqld]
#
# Remove leading # and set to the amount of RAM for the most important data
# cache in MySQL. Start at 70% of total RAM for dedicated server, else 10%.
# innodb_buffer_pool_size = 128M
...
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock

log-error=/var/log/mysqld.log
pid-file=/run/mysqld/mysqld.pid
提示

在某些 Linux 发行版(如 Ubuntu)中,默认的 MySQL 安装中不存在 /etc/my.cnf 配置文件。在这些系统上,请查找 /etc/mysql/my.cnf,或者参考“选项文件搜索顺序”获取 mysqld 读取的完整选项文件列表的方法。

文件结构的几个主要部分:

部分(组)标题

这些是配置参数前面的方括号中的值。所有使用选项文件的程序都会在一个或多个命名的节中寻找参数。例如,[mysqld] 是 MySQL 服务器使用的节,而 [mysql]mysql CLI 程序使用的节。严格来说,节的名称是任意的,你可以在那里放任何东西。但是,如果你将 [mysqld] 改为 [section],你的 MySQL 服务器将忽略在该头部之后的所有选项。

MySQL 文档将节称为,但两个术语可以互换使用。

标头控制文件如何解析,以及由哪些程序解析。在部分标头之后和下一个部分标头之前的每个选项都将归属于第一个标头。示例将更清楚地说明这一点:

[mysqld]
datadir=/var/lib/mysql
socket=/var/lib/mysql/mysql.sock
[mysql]
default-character-set=latin1

在这里,datadirsocket选项位于(并将被归属于)[mysqld]部分,而default-character-set选项位于[mysql]下。注意,某些 MySQL 程序读取多个部分;但我们稍后会讨论这个问题。

部分标头可以交织。以下示例完全有效:

[mysqld]
datadir=/var/lib/mysql
[mysql]
default-character-set=latin1
[mysqld]
socket=/var/lib/mysql/mysql.sock
[mysqld_safe]
core-file-size=unlimited
[mysqld]
core-file

这样的配置可能对人来说阅读起来很困难,但解析文件的程序不会关心顺序。不过,尽可能保持配置文件对人类可读可能是最好的。

选项值对

这是选项文件的主要部分,由配置变量本身及其值组成。这些对都在新行上定义,并遵循两种一般模式之一。除了前面示例中显示的*option=value*模式之外,还有只有*option*模式。例如,标准 MySQL 8 配置文件具有以下行:

# Remove the leading "# " to disable binary logging
# Binary logging captures changes between backups and is enabled by
# default. Its default setting is log_bin=binlog
# disable_log_bin

disable_log_bin是一个没有值的选项。如果我们取消注释,MySQL 服务器将应用该选项。

使用*option*=*value*模式时,如果您喜欢,可以在等号周围添加空格以提高可读性。自动截断选项名称和值前后的任何空白。

选项值也可以用单引号或双引号括起来。如果您不确定值是否会被正确解释,这很有用。例如,在 Windows 上,路径包含\符号,该符号被视为转义符号。因此,您应该在 Windows 上将路径放在双引号中(尽管也可以通过加倍每个\来转义)。引用选项值在值包含#符号时是必需的,否则该符号将被视为评论的开始。

我们建议的经验法则是在不确定时使用引号。以下是一些有效的选项/值对,说明了前面的观点:

slow_query_log_file = "C:\mysqldata\query.log"
slow_query_log_file=C:\\mysqldata\\query.log
innodb_temp_tablespaces_dir="./#innodb_temp/"

在设置数值选项(如不同缓冲区和文件的大小)的值时,使用字节可能会很繁琐。为了简化生活,MySQL 理解多种代表不同单位的后缀。例如,以下几种定义了相同大小的缓冲池(268,435,456 字节):

innodb_buffer_pool_size = 268435456
innodb_buffer_pool_size = 256M
innodb_buffer_pool_size = 256MB
innodb_buffer_pool_size = 256MiB

如果服务器足够大,您还可以指定GGBGiB表示吉字节,以及TTBTiB表示太字节。当然,还接受K和其他形式。MySQL 始终使用基于 2 的单位:1 GB 是 1,024 MB,而不是 1,000 MB。

你不能为选项指定小数值。例如,0.25G 对于 innodb_buffer_pool_size 变量是一个不正确的值。此外,不像从 mysql CLI 或其他客户端连接设置值时那样,你不能使用数学符号来表示选项值。你可以运行 SET GLOBAL max_heap_table_size=16*1024*1024;,但不能将相同的值放入选项文件中。

你甚至可以多次配置相同的选项,就像我们在 innodb_buffer_pool_size 中所做的那样。最后的设置将覆盖之前的设置,并且文件从上到下进行扫描。选项文件也有全局优先顺序;我们将在 “选项文件的搜索顺序” 中讨论这一点。

非常重要的一点是,设置不正确的选项名称将导致程序无法启动。当然,如果不正确的选项在特定程序不读取的部分下,那没问题。但是如果 mysqld[mysqld] 下找到一个它不认识的选项,将导致启动失败。在 MySQL 8.0 中,你可以使用 mysqld--validate-config 命令行参数验证部分选项文件中所做的更改。然而,这种验证仅涵盖核心服务器功能,不会验证存储引擎选项。

有时候你需要在 MySQL 启动时设置一个 MySQL 不认识的选项。例如,在配置可能在启动后加载的插件时这将很有用。你可以在这些选项前加上 loose- 前缀(或在命令行上使用 --loose-),当 MySQL 看到这些选项时只会输出警告而不是启动失败。以下是一个使用未知选项的示例:

# mysqld --validate-config
2021-02-11T08:02:58.741347Z 0 [ERROR] [MY-000067] [Server] ...
 ... unknown variable *audit_log_format=JSON*.
2021-02-11T08:02:58.741470Z 0 [ERROR] [MY-010119] [Server] Aborting

当选项更改为 loose-audit_log_format 后,我们看到以下内容。没有输出意味着所有选项都成功验证:

# mysqld --validate-config
#

注释

MySQL 选项文件经常被忽视但却非常重要的一个特性是能够添加注释。注释允许你包含任意文本,通常是该设置存在的描述,不会被任何 MySQL 程序解析。正如你在 disable_log_bin 示例中看到的,以 # 开头的行被视为注释。你也可以创建以分号 (;) 开头的注释;两者都被接受。你不一定需要一整行来写注释:它们也可以出现在行尾,尽管在这种情况下,它们必须以 # 而不是 ; 开头。一旦 MySQL 在一行上找到 #(除非它被转义),那么在这个点之后的所有内容都将被视为注释。以下是一个有效的配置行:

innodb_buffer_pool_size = 268435456 # 256M

包含指令

配置文件(以及整个目录)可以在其他配置文件中包含。这样可以更轻松地管理复杂的配置,但也使得阅读选项更加困难,因为与程序不同,人类不能轻松地将文件合并在一起。然而,能够分离不同 MySQL 程序的配置是很有用的。例如,xtrabackup实用程序(见第十章)没有任何特殊的配置文件,并读取标准系统选项文件。通过包含,您可以将xtrabackup的配置整齐地组织在一个专用文件中,并清理主要的 MySQL 选项文件。然后可以这样包含它:

$ cat /etc/my.cnf
!include /etc/mysql.d/xtrabackup.cnf
...

您可以看到/etc/my.cnf包含/etc/mysql.d/xtrabackup.cnf文件,该文件反过来在[xtrabackup]部分列出了一些配置选项。

不需要在不同的文件中拥有不同的部分。例如,Percona XtraDB Cluster 在[mysqld]部分下有wsrep库配置选项。有许多这样的配置,并且它们在您的my.cnf中并不一定有用。您可以创建一个单独的文件,例如/etc/mysql.d/wsrep.conf,并在那里列出[mysqld]部分下的wsrep变量。任何读取主my.cnf文件的程序也将读取所有包含的文件,然后才解析不同部分下的变量。

当创建了大量此类额外的配置文件时,您可能希望直接包含包含它们的整个目录或目录,而不是包含每个单独的选项文件。可以通过另一个指令includedir来实现,它期望一个目录路径作为参数:

!includedir /etc/mysql.d

MySQL 程序将把该路径理解为一个目录,并尝试包含该目录树中的每个选项文件。在类 Unix 系统上,包括.cnf文件;在 Windows 上,包括.cnf.ini文件。

通常,包含是在特定配置文件的开头定义的,但这并非强制要求。您可以将包含视为将包含文件的内容附加到父文件中的任何位置;在文件中定义包含的位置,将包含文件的内容放在其下。实际上,事情会更加复杂,但这种心理模型在例如思考选项优先级时是有效的,我们在“选项文件搜索顺序”中讨论了这一点。

每个包含的文件必须至少定义一个配置部分。例如,它可能在开头有[mysqld]

空行

在选项文件中空行没有意义。您可以使用它们在视觉上分隔部分或单独的选项,以使文件更易于阅读。

选项的作用域

我们可以从两个角度谈论 MySQL 中的选项作用域。首先,每个单独的选项可以具有全局作用域、会话作用域或两者兼有,并且可以动态或静态设置。其次,我们可以讨论在选项文件中设置的选项如何通过部分作用域以及选项文件本身的作用域和优先级顺序。

部分标题定义了特定程序(或多个程序,因为没有什么能阻止一个程序读取多个部分)意图在特定标题下读取选项。一些配置选项在其部分之外是没有意义的,但有些可以在多个部分下定义,并不一定需要设置相同的值。

让我们考虑一个示例,在此示例中,我们有一个 MySQL 服务器为了兼容性原因配置为latin1字符集。然而,现在有新的表使用了utf8mb4字符集。我们希望我们的mysqldump逻辑导出仅使用 UTF-8,因此我们希望为这个程序覆盖字符集设置。方便的是,mysqldump读取自己的配置部分,因此我们可以编写如下的选项文件:

[mysqld]
character_set_server=latin1
[mysqldump]
default_character_set=utf8mb4

这个小例子展示了如何在不同的层级上设置选项。在这个特定的案例中,我们使用了不同的选项,但也可以在不同的作用域中使用相同的选项。例如,假设我们想要限制未来的BLOBTEXT值(参见“字符串类型”)的大小为 32 MiB,但我们已经有大小达到 256 MiB 的行。我们可以通过如下配置为本地客户端添加一个人为的屏障:

[mysqld]
max_allowed_packet=256M
[client]
max_allowed_packet=32M

MySQL 服务器的max_allowed_packet值将在全局作用域上设置,并作为最大查询大小的硬限制(也作用于BLOBTEXT字段大小)。客户端的值将在会话作用域上设置,并作为软限制。如果特定客户端需要更大的值(例如读取旧行),可以使用SET语句提升到服务器的限制。

选项文件本身也有不同的作用域。MySQL 选项文件可以按全局、客户端、服务器和额外分组:全局选项文件被所有或大多数 MySQL 程序读取,而客户端和服务器文件分别仅被客户端程序和mysqld读取。由于可以指定额外的配置文件供程序读取,我们也列出了“额外”类别。

让我们概述一下常规 MySQL 8.0 安装在 Linux 和 Windows 上安装和读取的选项文件。我们将从 Windows 开始,在表 9-1 中详细说明。

表 9-1. Windows 上的 MySQL 选项文件

文件名 作用域和目的
%WINDIR%\my.ini, %WINDIR%\my.cnf 全程序读取的全局选项
C:\my.ini, C:\my.cnf 全程序读取的全局选项
BASEDIR**\my.ini, BASEDIR**\my.cnf 全程序读取的全局选项
额外配置文件 可选指定的文件,使用--defaults-extra-file
%APPDATA%\MySQL.mylogin.cnf 登录路径配置文件
DATADIR**\mysqld-auto.cnf 用于持久化变量的选项文件

表 9-2 将 Fedora Linux 上典型安装的选项文件进行了详细拆解。

表 9-2. Fedora Linux 上的 MySQL 选项文件

文件名 范围和目的
/etc/my.cnf, /etc/mysql/my.cnf, /usr/etc/my.cnf 所有程序读取的全局选项
$MYSQL_HOME/my.cnf 只有在变量设置时才会读取的服务器选项
~/.my.cnf 特定操作系统用户运行的所有程序读取的全局选项
额外配置文件 可以通过 --defaults-extra-file 指定的文件
~/.mylogin.cnf 特定操作系统用户下的登录路径配置文件
DATADIR**/mysqld-auto.cnf 用于持久化变量的选项文件

在 Linux 上,很难找到一个通用的完整的配置文件列表,因为不同 Linux 发行版的 MySQL 包可能会读取略有不同的文件或位置。作为一条经验法则,在 Linux 上,/etc/my.cnf 是一个很好的起点,而在 Windows 上则是 %WINDIR%\my.cnfBASEDIR**\my.cnf

我们列出的一些配置文件在不同系统中的路径可能略有不同。/usr/etc/my.cnf 也可以写成 SYSCONFIGDIR**/my.cnf,路径在编译时定义。*\(MYSQL_HOME/my.cnf* 只有在设置了该变量时才会读取。默认打包的 `mysqld_safe` 程序(用于启动 `mysqld` 守护进程)在运行 `mysqld` 前会将 `\)MYSQL_HOME设置为 *BASEDIR*。你不会在任何操作系统用户的环境中找到设置了$MYSQL_HOME,该变量的设置仅在手动启动 mysqld时有效—也就是说,不使用servicesystemctl` 命令。

在 Windows 和 Linux 之间有一个显著的差异。在 Linux 上,MySQL 程序会读取位于给定操作系统用户家目录下的一些配置文件。在 表 9-2 中,家目录用 ~ 表示。而在 Windows 上,MySQL 则缺乏这种能力。这类配置文件的一个常见用途是基于其操作系统用户控制客户端选项。通常,它们会包含凭据。但是,在 “特殊选项文件” 中描述的登录路径工具使这种功能变得多余。

使用--defaults-extra-file在每次读取全局文件后读取额外的配置文件,在表中的位置。例如,当您想要运行程序以测试新变量时,这是一个有用的选项。但是,过度使用此选项可能会导致难以理解当前生效的选项集(请参阅“确定生效的选项”)。--defaults-extra-file选项并非唯一可以修改选项文件处理方式的选项。--no-defaults阻止程序完全读取任何配置文件。--defaults-file强制程序读取单个文件,如果您将自定义配置全部放在一个地方,这将非常有用。

到目前为止,您应该对大多数 MySQL 安装使用的选项文件有了牢固的理解。下一节将更详细地讨论不同程序如何按不同顺序读取不同文件,并从这些文件中读取哪些特定组或组。

选项文件搜索顺序

此时,您应该了解选项文件的结构以及它们的位置。大多数 MySQL 程序都会读取一个或多个选项文件,了解程序搜索和读取这些文件的特定顺序非常重要。本节涵盖了搜索顺序和选项优先级的主题,并讨论了它们的重要性。

如果 MySQL 程序读取任何选项文件,您可以找到它读取的具体文件以及读取它们的顺序。配置文件的一般顺序将与表 9-1 和表 9-2 中概述的完全相同或非常相似。您可以使用以下命令查看确切的顺序:

$ mysqld --verbose --help | grep "Default options" -A2
Default options are read from the following files in the given order:
/etc/my.cnf /etc/mysql/my.cnf /usr/etc/my.cnf ~/.my.cnf
The following groups are read: mysqld server mysqld-8.0

在 Windows 上,您需要运行mysqld.exe而不是mysqld,但输出将保持不变。该输出包括读取的配置文件列表及其顺序。您还可以看到mysqld读取的选项组列表:[mysqld][server][mysqld-8.0]。请注意,您可以通过添加--defaults-group-suffix选项修改任何程序读取的选项组列表:

$ mysqld --defaults-group-suffix=-test --verbose --help | grep "groups are read"
The following groups are read: mysqld server mysqld-8.0 ...
... mysqld-test server-test mysqld-8.0-test

您现在知道哪些选项文件和选项组被读取了。但是,了解这些选项文件的优先级顺序也很重要。毕竟,不会阻止您在多个配置文件中设置一个或多个选项。对于 MySQL 程序而言,配置文件的优先级顺序很简单:稍后读取的文件中的选项优先于先前读取的文件中的选项。直接作为命令行参数传递给命令的选项优先于任何配置文件中的配置选项。

在表 9-1 和表 9-2 中,文件按从上到下的顺序读取。列表中配置文件越低,该处选项的“权重”越高。例如,对于非mysqld程序,.mylogin.cnf中的值优先于任何其他配置文件中的值,并且仅低于通过命令行参数设置的值。对于mysqld,在*DATADIR* /mysqld-auto.cnf中设置的持久变量也是如此。

通过包含指令在其他文件中包含配置文件的能力使事情变得稍微复杂了些,但您始终可以在表 9-1 和表 9-2 中列出的一个或多个选项文件中包含额外的文件。您可以将此视为 MySQL 在读取父配置文件之前将包含的文件附加到其父配置文件的过程中,将每个文件插入到包含指令之后。因此,全局选项的优先级是父配置文件的优先级。在生成的文件本身中(按顺序添加了所有包含的文件),后定义的选项优先于先定义的选项。

特殊选项文件

MySQL 使用两个特殊配置文件,这两个文件是与“选项文件结构”中概述的结构不同的例外情况。

登录路径配置文件

首先,有一个.mylogin.cnf文件,它作为登录路径系统的一部分。尽管您可以认为其结构类似于常规选项文件,但这个特定文件不是常规文本文件。事实上,它是一个加密的文本文件。该文件旨在通过 MySQL 提供的特殊程序mysql_config_editor创建和修改,通常在客户端包中提供。它是加密的,因为.mylogin.cnf(以及整个登录路径系统)的目的是以方便和安全的方式存储 MySQL 连接选项,包括密码。

默认情况下,mysql_config_editor和其他 MySQL 程序将在 Linux 和各种 Unix 版本的当前用户的HOME中以及 Windows 的%APPDATA%\MySQL中查找.mylogin.cnf。可以通过设置MYSQL_TEST_LOGIN_FILE环境变量来更改文件的位置和名称。

如果尚不存在此文件,您可以通过在其中存储root用户的密码来创建此文件:

$ mysql_config_editor set --user=root --password
Enter password:

输入密码并确认输入后,我们可以查看文件的内容:

$ ls -la ~/.mylogin.cnf
-rw-------. 1 skuzmichev skuzmichev 100 Jan 18 18:03 .mylogin.cnf
$ cat ~/.mylogin.cnf

>pZ
   prI
         R86w"># &.h.m:4+|DDKnl_K3>73x$
$ file ~/.mylogin.cnf
.mylogin.cnf: data
$ file ~/.my.cnf
.my.cnf: ASCII text

正如您所见,.mylogin.cnf至少在表面上不是常规配置文件。因此,它需要特殊处理。除了创建文件外,您还可以使用mysql_config_editor查看和修改.mylogin.cnf。让我们从如何实际查看其中内容开始。该选项是print

$ mysql_config_editor print
[client]
user = "root"
password = *****

client是默认的登录路径。在没有显式登录路径规范的情况下使用mysql_config_editor执行的所有操作都会影响client登录路径。在之前运行set时我们没有指定任何登录路径,所以root的凭据被写入client路径下。但是,可以针对任何操作指定特定的登录路径。让我们将root的凭据放在名为root的登录路径下:

$ mysql_config_editor set --login-path=root --user=root --password
Enter password:

要指定登录路径,请使用--login-path-G选项,并在使用print时添加--all选项查看所有路径:

$ mysql_config_editor print --login-path=root
[root]
user = root
password = *****
$ mysql_config_editor print --all
[client]
user = root
password = *****
[root]
user = root
password = *****

你可以看到输出结果类似于选项文件,因此可以将.mylogin.cnf视为一个具有特殊处理的选项文件。只是不要手动编辑它。说到编辑,让我们在set命令中添加一些更多选项,正如mysql_config_editor所称呼的那样。我们将在此过程中创建一个新的登录路径。

mysql_config_editor支持--help(或-?)参数,可以与其他选项结合使用,以获取有关printset的帮助。让我们从查看稍微缩短的set的帮助输出开始:

$ mysql_config_editor set --help
...
MySQL Configuration Utility.

Description: Write a login path to the login file.
Usage: mysql_config_editor [program options] [set [command options]]
  -?, --help          Display this help and exit.
  -h, --host=name     Host name to be entered into the login file.
  -G, --login-path=name
                      Name of the login path to use in the login file. (Default
                      : client)
  -p, --password      Prompt for password to be entered into the login file.
  -u, --user=name     User name to be entered into the login file.
  -S, --socket=name   Socket path to be entered into login file.
  -P, --port=name     Port number to be entered into login file.
  -w, --warn          Warn and ask for confirmation if set command attempts to
                      overwrite an existing login path (enabled by default).
                      (Defaults to on; use --skip-warn to disable.)
...

在这里你可以看到.mylogin.cnf的另一个有趣属性:你不能随意向其中添加参数。现在我们知道,我们基本上只能设置与登录到 MySQL 实例或实例相关的少量选项,这当然是“登录路径”文件的预期。现在,让我们回到编辑文件:

$ mysql_config_editor set --login-path=scott --user=scott
$ mysql_config_editor set --login-path=scott --user=scott
WARNING : *scott* path already exists and will be overwritten.
 Continue? (Press y|Y for Yes, any other key for No) : y
$ mysql_config_editor set --login-path=scott --user=scott --skip-warn

在这里,我们展示了mysql_config_editor在修改或创建登录路径时可能表现出的所有行为。如果登录路径尚不存在,则不会产生警告。如果已经存在这样的路径,则会打印警告和确认,但仅当未指定--skip-warn时。请注意,我们在这里讨论的是整个登录路径!不可能修改路径的单个属性:每次都会写出整个登录路径。如果要更改单个属性,您需要同时指定所有其他需要的属性。

让我们添加一些更多细节并查看结果:

$ mysql_config_editor set --login-path=scott \
--user=scott --port=3306 --host=192.168.122.1 \
--password --skip-warn
Enter password:
$ mysql_config_editor print --login-path=scott
[scott]
user = scott
password = *****
host = 192.168.122.1
port = 3306

持久系统变量配置文件

第二个特殊文件是mysqld-auto.cnf,自 MySQL 8.0 以来一直存在于数据目录中。它是新持久系统变量功能的一部分,允许您使用常规的SET语句在磁盘上更新 MySQL 选项。在此之前,您无法从数据库连接中更改 MySQL 的配置。通常的流程是在磁盘上更改选项文件,然后运行SET GLOBAL语句以在线更改配置变量。正如您可以想象的那样,这可能会导致错误,例如仅在线进行更改。新的SET PERSIST语句负责处理这两个任务:在线更新的变量也会在磁盘上更新。还可以仅在磁盘上更新变量。

该文件本身出人意料地与 MySQL 中的任何其他配置文件都不同。虽然 .mylogin.cnf 是一个加密但仍然是常规选项文件,mysqld-auto.cnf 使用了一个常见但完全不同的格式:JSON。

在持久化任何内容之前,mysqld-auto.cnf 不存在。因此,我们将首先更改一个系统变量:

mysql> `SELECT` `@``@``GLOBAL``.``max_connections``;`
+--------------------------+
| @@GLOBAL.max_connections |
+--------------------------+
|                      100 |
+--------------------------+
1 row in set (0.00 sec)
mysql> `SET` `PERSIST` `max_connections` `=` `256``;`
Query OK, 0 rows affected (0.01 sec)
mysql> `SELECT` `@``@``GLOBAL``.``max_connections``;`
+--------------------------+
| @@GLOBAL.max_connections |
+--------------------------+
|                      256 |
+--------------------------+
1 row in set (0.00 sec)

预期地,在全局范围内在线更新了变量。现在让我们来探索生成的配置文件。因为我们知道内容以 JSON 格式存在,我们将使用 jq 实用程序对其进行良好格式化。这不是必需的,但可以使文件更易于阅读:

$ cat /var/lib/mysql/mysqld-auto.cnf | jq .
{
  "Version": 1,
  "mysql_server": {
    "max_connections": {
      "Value": "256",
      "Metadata": {
        "Timestamp": 1611728445802834,
        "User": "root",
        "Host": "localhost"
      }
    }
  }
}

只需查看包含单个变量值的此文件,您就可以看到为什么纯 .ini 用于预计由人类编辑的配置文件。这太冗长了!但是,JSON 对计算机的阅读非常好,因此非常适合 MySQL 本身编写和读取的配置文件。作为附加好处,您还可以获得更改的审核:正如您所看到的,max_connection 属性包含元数据,其中包含更改发生的时间和更改的作者。

由于这是一个文本文件,与二进制的登录路径配置文件不同,可以手动编辑 mysqld-auto.cnf。但是,不太可能有许多需要这样做的情况。

确定正在生效的选项

几乎所有与 MySQL 一起工作的人都会面临的最后例行任务是查找变量的值,以及它们在哪些选项文件中设置(以及为什么,但有时没有技术可以帮助人类的推理!)。

到此为止,我们知道 MySQL 程序读取的文件、读取顺序及其优先级。我们还知道命令行参数会覆盖任何其他设置。但是,理解某个变量确切设置在哪里可能是一项艰巨的任务。多个文件被扫描,可能存在嵌套包含,这可能导致长时间的调查。

让我们从查看当前程序使用的选项开始。对于一些程序,如 MySQL 服务器(mysqld),这很容易。您可以通过运行 SHOW GLOBAL VARIABLES 来获取 mysqld 使用的当前值列表。无法更改 mysqld 使用的选项值而不在全局变量状态中看到影响的效果。对于其他程序,情况变得更加复杂。要了解 mysql 使用的选项,请运行它,然后检查 SHOW VARIABLESSHOW GLOBAL VARIABLES 的输出,以查看哪些选项在会话级别上被覆盖。但即使在成功连接到服务器之前,mysql 必须读取或接收连接信息。

在程序启动时确定正在生效的选项列表有两种简单的方法:通过向该程序传递--print-defaults参数或使用特殊的my_print_defaults程序。让我们看看在 Linux 上执行的前一选项。您可以忽略sed部分,但这可以使输出对人眼更加友好:

$ mysql --print-defaults
mysql would have been started with the following arguments:
--user=root --password=*****
$ mysqld --print-defaults | sed 's/--/\n--/g'
/usr/sbin/mysqld would have been started with the following arguments:
--datadir=/var/lib/mysql
--socket=/var/lib/mysql/mysql.sock
--log-error=/var/log/mysqld.log
--pid-file=/run/mysqld/mysqld.pid
--max_connections=100000
--core-file
--innodb_buffer_pool_in_core_file=OFF
--innodb_buffer_pool_size=256MiB

这里获取的变量来自我们之前讨论过的所有选项文件。如果一个变量值被多次设置,最后出现的值将优先生效。然而,--print-defaults实际上会输出每个设置的选项。例如,尽管innodb_buffer_pool_size设置了五次,但实际生效的值将是 384 M:

$ mysqld --print-defaults | sed 's/--/\n--/g'
/usr/sbin/mysqld would have been started with the following arguments:

--datadir=/var/lib/mysql
--socket=/var/lib/mysql/mysql.sock
--log-error=/var/log/mysqld.log
--pid-file=/run/mysqld/mysqld.pid
--max_connections=100000
--core-file
--innodb_buffer_pool_in_core_file=OFF
--innodb_buffer_pool_size=268435456
--innodb_buffer_pool_size=256M
--innodb_buffer_pool_size=256MB
--innodb_buffer_pool_size=256MiB
--large-pages
--innodb_buffer_pool_size=384M

你也可以将--print-defaults与其他命令行参数组合使用。例如,如果你打算使用命令行参数运行程序,你可以查看它们是否会覆盖或重复已设置的配置选项值:

$ mysql --print-defaults --host=192.168.4.23 --user=bob | sed 's/--/\n--/g'
mysql would have been started with the following arguments:

--user=root
--password=*****
--host=192.168.4.23
--user=bob

打印变量的另一种方式是使用my_print_defaults程序。它将一个或多个部分头作为参数,并打印在扫描的文件中落入请求组的所有选项。这可能比仅需要查看一个选项组时使用--print-defaults更好。在 MySQL 8.0 中,[mysqld]程序读取以下组:[mysqld][server][mysqld-8.0]。选项的组合输出可能很长,但如果我们只需要查看专门为 8.0 设置的选项怎么办?例如,我们已将[mysqld-8.0]选项组添加到选项文件中,并在那里放置了一些配置参数值:

$ my_print_defaults mysqld-8.0
--character_set_server=latin1
--collation_server=latin1_swedish_ci

这也可以帮助其他软件,如 PXC,或者 MySQL 的 MariaDB 版本,它们都包括多个配置组。特别是,你可能希望查看[wsrep]部分而不包含其他选项。当然,my_print_defaults也可以用来输出完整的选项集;只需传递程序读取的所有部分头。例如,[mysql]程序读取[mysql][client]选项组,因此我们可以使用:

$ my_print_defaults mysql client
--user=root
--password=*****
--default-character-set=latin1

用户和密码定义来自我们之前设置的登录路径配置中的客户端组,而字符集来自常规.my.cnf文件中的[mysql]选项组。请注意,我们手动添加了该组和字符集配置;默认情况下,该选项未设置。

你可以看到,虽然两种读取选项的方式都谈论默认值,但它们实际上输出了我们已经显式设置的选项,使它们变成了非默认值。这是一个有趣的细节,但在整体计划中并不会改变任何事情。

不幸的是,这两种查看选项的方式都无法完美确定生效的完整选项集。问题在于它们只会读取表格 9-1 和 9-2 中列出的配置文件,但 MySQL 程序可能会读取其他配置文件或者通过命令行参数启动。此外,通过SET PERSIST持久化在*DATADIR* /mysqld-auto.cnf中的变量不会被默认打印例程提供。

我们提到 MySQL 程序不会从除了在 9-1 和 9-2 中列出的文件之外的任何其他文件中读取选项。然而,这些列表包括“额外配置文件”,它可以位于任意位置。除非在调用 my_print_defaults 或带有 --print-defaults 的另一个程序时指定了相同的额外文件,否则不会读取来自该额外文件的选项。额外文件通过命令行参数 --defaults-extra-file 指定,大多数(如果不是所有)MySQL 程序都可以指定。两个默认打印例程只读预定义的配置文件,并且会忽略该文件。但是,您可以为 my_print_defaults 和使用 --print-defaults 调用的程序都指定 --defaults-extra-file,那么两者都将读取额外的文件。我们前面提到的 --defaults-file 选项也是如此,它基本上强制 MySQL 程序只读取作为此选项值传递的单个文件。

--defaults-extra-file--defaults-file 共享一个共同点:它们都是命令行参数。传递给 MySQL 程序的命令行参数会覆盖从配置文件中读取的任何选项,但同时你可能会在执行 --print-defaultsmy_print_defaults 时忽略它们,因为它们来自于配置文件之外。更简洁地说:特定的 MySQL 程序,如 mysqld,可能会被某人使用未知和任意的命令行参数启动。因此,在讨论选项时,实际上我们必须考虑这些参数的存在。

在 Linux 和类 Unix 系统上,您可以使用 ps 实用程序(或等效工具)查看当前运行进程的信息,包括它们的完整命令行。让我们看一个在 Linux 上的例子,其中 mysqld 使用 --no-defaults 启动,并且所有配置选项都作为参数传递:

$ ps auxf | grep mysqld | grep -v grep
root      397830  ... \_ sudo -u mysql bash -c mysqld ...
mysql     397832  ...   \_ mysqld --datadir=/var/lib/mysql ...

或者,如果我们只打印 mysqld 进程的命令行并使用 sed 来使其更清晰:

$ ps -p 397832 -ocommand ww | sed 's/--/\n--/g'
COMMAND
mysqld
--datadir=/var/lib/mysql
--socket=/var/lib/mysql/mysql.sock
--log-error=/var/log/mysqld.log
--pid-file=/run/mysqld/mysqld.pid
...
--character_set_server=latin1
--collation_server=latin1_swedish_ci

请注意,对于这个示例,我们启动了 mysqld 而没有使用任何提供的脚本。你不经常会看到以这种方式启动 MySQL 服务器,但这是可能的。

您可以将任何配置选项作为参数传递,因此输出可能会非常长。然而,当您不确定 mysqld 或另一个程序的执行方式时,这是一个重要的检查点。在 Windows 上,您可以通过打开任务管理器并在进程选项卡(通过查看菜单)中添加一个“命令行”列,或者使用 sysinternals 包中的 Process Explorer 工具来查看正在运行程序的命令行参数。

如果您的 MySQL 程序是从脚本中启动的,您应该检查该脚本以查找所有使用的参数。虽然这对于 mysqld 可能会是一个罕见的情况,但是从自定义脚本运行 mysqlmysqldumpxtrabackup 是一个常见的做法。

理解当前使用的选项可能是一项艰巨的任务,但有时非常重要。希望这些指南和提示能够帮助你。

第十一章:配置和调优服务器

MySQL 安装过程(参见第一章)提供了安装 MySQL 进程并开始使用所需的一切。但是,对于生产系统,需要进行一些微调,调整 MySQL 参数和操作系统以优化 MySQL 服务器的性能。本章将涵盖不同安装的推荐最佳实践,并展示需要根据预期或当前工作负载调整的参数。正如您将看到的那样,不需要记忆所有 MySQL 参数。基于帕累托原则,该原则表明,对于许多事件,大约 80%的效果来自 20%的原因,我们将集中关注大多数性能问题的 MySQL 和操作系统参数。本章还涉及与计算机体系结构相关的一些高级主题(如 NUMA);这里的目的是向您介绍一些可能影响 MySQL 性能的组件,这些组件在您的职业生涯中迟早需要与之互动。

MySQL 服务器守护程序

自 2015 年以来,大多数 Linux 发行版已经采用了 systemd。因此,Linux 操作系统不再使用 mysqld_safe 进程来启动 MySQL。mysqld_safe 被称为天使进程,因为它增加了一些安全功能,例如在发生错误时重新启动服务器,并将运行时信息记录到 MySQL 错误日志中。对于使用 systemd(通过 systemctl 命令进行控制和配置)的操作系统,这些功能已经集成到 systemdmysqld 进程中。

mysqld 是 MySQL 服务器的核心进程。它是一个单一的多线程程序,负责服务器的大部分工作。它不会生成额外的进程——我们谈论的是一个带有多个线程的单一进程,这使得 MySQL 成为一个多线程进程

让我们仔细看看一些术语。程序是设计用来完成特定目标的代码。有许多类型的程序,包括用于辅助操作系统部分以及设计用于用户需求(如网页浏览)的程序。

进程是我们称之为加载到内存中并具有所有运行所需资源的程序。操作系统为其分配内存和其他资源。

线程是进程内的执行单元。一个进程可以有一个线程或多个线程。在单线程进程中,进程包含一个线程,因此一次只能执行一个命令。

因为现代 CPU 拥有多个核心,它们可以同时执行多个线程,因此多线程进程现在非常普遍。了解这一概念对理解以下几节中提议的设置非常重要。

总之,MySQL 是单进程软件,为了执行用户活动和执行后台任务等各种目的,生成多个线程。

MySQL 服务器变量

MySQL 服务器有许多变量允许调整其操作。例如,MySQL 服务器 8.0.25 有令人印象深刻的 588 个服务器变量!

每个系统变量都有一个默认值。此外,我们可以动态调整大多数系统变量(或者“即时”调整);然而,有些是静态的,这意味着我们需要修改 my.cnf 文件并重新启动 MySQL 进程,以便它们生效(如 第九章 中所讨论的)。

系统变量可以有两种不同的作用域:SESSIONGLOBAL。也就是说,系统变量可以有一个全局值,影响整个服务器的操作,比如 innodb_log_file_size,或者一个会话值,仅影响特定会话,比如 sql_mode

检查服务器设置

数据库不是静态实体;相反,它们的工作负载是动态的,并随着时间的推移而变化,有增长的趋势。这种有机行为需要不断的监控、分析和调整。显示 MySQL 设置的命令是:

SHOW [GLOBAL|SESSION] VARIABLES;

当您使用 GLOBAL 修饰符时,该语句显示全局系统变量值。当您使用 SESSION 时,它显示影响当前连接的系统变量值。请注意,不同的连接可以具有不同的值。

如果没有修饰符出现,默认为 SESSION

最佳实践

数据库中有许多优化方面。如果数据库运行在 裸金属(物理主机)上,我们可以控制硬件和操作系统资源。当我们转移到虚拟化机器时,由于无法控制底层主机的发生情况,我们对这些资源的控制能力会减少。最后一个选项是云中的托管数据库,例如亚马逊关系数据库服务(RDS),那里只有少量的数据库设置可用。在能够进行细粒度调整以提取最佳性能和享受自动化大部分任务(代价是额外花费)之间存在权衡。

让我们首先审查一些操作系统级别的设置。之后,我们将检查 MySQL 参数。

操作系统最佳实践

有几个操作系统设置可能会影响 MySQL 的性能。我们将在这里列出其中一些最重要的。

swappiness 设置和交换使用

swappiness 参数控制 Linux 操作系统在交换区域中的行为。交换是在内存和磁盘之间传输数据的过程。这可能对性能产生显著影响,因为即使使用 NVMe 磁盘,磁盘访问速度至少比内存访问慢一个数量级。

默认设置为 60 会促使服务器进行交换。出于性能原因,您希望 MySQL 服务器尽可能减少交换。建议的值是 1,这意味着在操作系统必须功能正常之前不进行交换。要调整此参数,请以 root 用户身份执行以下命令:

# echo 1 > /proc/sys/vm/swappiness

注意这是一个非持久性更改;在重新启动操作系统后,设置将恢复到其原始值。要在操作系统重新启动后使此更改持久化,请在 sysctl.conf 中调整设置:

# sudo sysctl -w vm.swappiness=1

您可以使用以下命令获取交换空间使用信息:

# free -m

或者,如果您想要更详细的信息,可以在 shell 中运行以下代码段:

#!/bin/bash
SUM=0
OVERALL=0
for DIR in `find /proc/ -maxdepth 1 -type d | egrep "^/proc/[0-9]"` ; do
        PID=`echo $DIR | cut -d / -f 3`
        PROGNAME=`ps -p $PID -o comm --no-headers`
        for SWAP in `grep Swap $DIR/smaps 2>/dev/null| awk '{ print $2 }'`
        do
                let SUM=$SUM+$SWAP
        done
        echo "PID=$PID - Swap used: $SUM - ($PROGNAME )"
        let OVERALL=$OVERALL+$SUM
        SUM=0
done
echo "Overall swap used: $OVERALL"
注意

vm.swappiness 设置为 10 之间的差异微乎其微。我们选择了值 1,因为在一些内核中存在一个 bug,可能会导致当设置为 0 时,内存不足(OOM)杀手终止 MySQL。

I/O 调度器

I/O 调度器是内核用于将读取和写入提交到磁盘的算法。默认情况下,大多数 Linux 安装使用完全公平排队 (cfq) 调度器。这对许多一般用途情况效果良好,但提供的延迟保证较少。另外两个调度器是 deadlinenoopdeadline 调度器在延迟敏感的用例(如数据库)中表现优异,而 noop 则接近于没有调度。对于裸金属安装,无论是 deadline 还是 noop(它们之间的性能差异不可感知),都比 cfq 更好。

如果您在虚拟机中运行 MySQL(其具有自己的 I/O 调度器),最好使用 noop 并让虚拟化层自行处理 I/O 调度。

首先,验证 Linux 当前正在使用的算法:

#  cat /sys/block/xvda/queue/scheduler
noop [deadline] cfq

要动态更改它,请以 root 用户身份运行以下命令:

# echo "noop" > /sys/block/xvda/queue/scheduler

为了使这种更改持久化,您需要编辑 GRUB 配置文件(通常为 /etc/sysconfig/grub),并将 elevator 选项添加到 GRUB_CMDLINE_LINUX_DEFAULT。例如,您将替换此行:

GRUB_CMDLINE_LINUX="console=tty0 crashkernel=auto console=ttyS0,115200

有这行:

GRUB_CMDLINE_LINUX="console=tty0 crashkernel=auto console=ttyS0,115200
    elevator=noop"

在编辑 GRUB 配置时要格外小心。错误或不正确的设置可能使服务器无法使用,需要重新安装操作系统。

注意

在某些情况下,I/O 调度器的值为 none —— 最明显的是在 AWS VM 实例类型中,EBS 卷作为 NVMe 块设备公开。这是因为在现代 PCIe/NVMe 设备中,具有大量内部队列并完全绕过 I/O 调度器的情况下,设置没有用处。在这些磁盘中,none 设置是最优的。

文件系统和挂载选项

选择适合您的数据库的文件系统是一个重要的决策,因为有许多选项可供选择,涉及到的权衡。值得一提的是两个经常使用的重要选项:XFSext4

XFS 是设计用于高扩展性的高性能日志文件系统。即使文件系统跨多个存储设备,它也提供接近原生的 I/O 性能。XFS 具有使其适合非常大的文件系统的功能,支持高达 8 EiB 大小的文件。其他功能包括快速恢复、快速事务、延迟分配以减少碎片化,以及直接 I/O 时几乎原始 I/O 性能。

创建 XFS 文件系统命令 (mkfs.xfs) 有几个选项可以配置文件系统。但是,默认的 mkfs.xfs 选项适合最佳速度,因此使用默认命令创建文件系统将提供良好的性能,同时确保数据完整性:

# mkfs.xfs /dev/target_volume

关于文件系统挂载选项,再次默认值应该适合大多数情况。在某些文件系统上,通过向 /etc/fstab 文件添加 noatime 挂载选项可以提高性能。对于 XFS 文件系统,默认的 atime 行为是 relatime,与 noatime 相比几乎没有开销,仍保持理智的 atime 值。如果在具有电池备份的非易失性缓存的逻辑单元号(LUN)上创建 XFS 文件系统,则可以通过使用挂载选项 nobarrier 来进一步增加文件系统的性能。这些设置有助于避免过于频繁地刷新数据。但是,如果不存在备份电池单元(BBU),或者您对此不确定,请保留屏障;否则可能会危及数据一致性。以下示例显示了具有这些选项的两个虚构挂载点:

/dev/sda2              /datastore              xfs     noatime,nobarrier
/dev/sdb2              /binlog                 xfs     noatime,nobarrier

另一个流行的选择是 ext4,作为 ext3 的后继者,添加了性能改进。这是一个适合大多数工作负载的可靠选择。我们应该注意,它支持最大 16 TB 大小的文件,比 XFS 的限制小。如果过多的表空间大小/增长是要求的话,这是需要考虑的事项。关于挂载选项,相同的考虑适用。我们建议使用默认值来获得稳健的文件系统,而无需担心数据一致性的风险。但是,如果存在带有 BBU 缓存的企业存储控制器,以下挂载选项将提供最佳性能:

/dev/sda2              /datastore              ext4
noatime,data=writeback,barrier=0,nobh,errors=remount-ro
/dev/sdb2              /binlog                 ext4
noatime,data=writeback,barrier=0,nobh,errors=remount-ro

透明巨大页面

操作系统以称为的块管理内存。每页的大小为 4,096 字节(或 4 KB);1 MB 的内存相当于 256 页,1 GB 的内存等于 256,000 页,以此类推。CPU 具有内置的内存管理单元,其中包含这些页的列表,每个页通过页表项引用。如今,常见的服务器通常具有数百或数千兆字节的内存。使系统能够管理大量内存有两种方法:

  • 增加硬件内存管理单元中的页表项数。

  • 增加页大小。

第一种方法很昂贵,因为现代处理器中的硬件内存管理单元只支持数百或数千个页表项。此外,那些在处理数千页(数兆字节内存)时表现良好的硬件和内存管理算法可能在处理数百万(甚至数十亿)页时表现不佳。为了解决可伸缩性问题,操作系统开始使用大页。简单来说,大页是可以为 2 MB、4 MB、1 GB 等大小的内存块。使用大页内存可以增加 CPU 高速缓存击中事务查找缓冲区(TLB)的次数。

您可以运行cpuid来验证处理器的高速缓存和 TLB:

# cpuid | grep "cache and TLB information" -A 5
   cache and TLB information (2):
      0x5a: data TLB: 2M/4M pages, 4-way, 32 entries
      0x03: data TLB: 4K pages, 4-way, 64 entries
      0x76: instruction TLB: 2M/4M pages, fully, 8 entries
      0xff: cache data is in CPUID 4
      0xb2: instruction TLB: 4K, 4-way, 64 entries

透明大页(THP)如其名称所示,旨在自动为应用程序引入大页支持,无需自定义配置。

特别对于 MySQL,不推荐使用 THP,原因有几个。首先,MySQL 数据库使用小内存页(16 KB),使用 THP 可能导致过多的 I/O,因为 MySQL 认为正在访问 16 KB,而 THP 正在扫描一个比此大的页面。此外,大页往往会变得碎片化并影响性能。多年来也报告过使用 THP 可能导致内存泄漏,最终导致 MySQL 崩溃。

要禁用 RHEL/CentOS 6 和 RHEL/CentOS 7 的 THP,请执行以下命令:

# echo "never" > /sys/kernel/mm/transparent_hugepage/enabled
# echo "never" > /sys/kernel/mm/transparent_hugepage/defrag

要确保此更改在服务器重新启动后仍然有效,您需要将标志transparent_hugepage=never添加到内核选项(/etc/sysconfig/grub)中:

GRUB_CMDLINE_LINUX="console=tty0 crashkernel=auto console=ttyS0,115200
elevator=noop transparent_hugepage=never"

备份现有的 GRUB2 配置文件(/boot/grub2/grub.cfg),然后重新构建它。在基于 BIOS 的机器上,您可以使用以下命令执行此操作:

# grub2-mkconfig -o /boot/grub2/grub.cfg

如果 THP 仍未禁用,可能需要禁用tuned服务:

# systemctl stop tuned
# systemctl disable tuned

要禁用 Ubuntu 20.04(Focal Fossa)的 THP,建议使用sysfsutils包。要安装它,请执行以下命令:

# apt install sysfsutils

然后将以下行追加到/etc/sysfs.conf文件中:

kernel/mm/transparent_hugepage/enabled = never
kernel/mm/transparent_hugepage/defrag = never

重新启动服务器并检查是否已设置:

# cat /sys/kernel/mm/transparent_hugepage/enabled
always madvise [never]
# cat /sys/kernel/mm/transparent_hugepage/defrag
always defer defer+madvise madvise [never]

jemalloc

MySQL 服务器使用动态内存分配,因此良好的内存分配器对于正确利用 CPU 和 RAM 资源至关重要。高效的内存分配器应该能够提高可伸缩性、增加吞吐量,并控制内存占用。

在这里需要提及 InnoDB 的一个特性。InnoDB 为每个事务创建一个读视图,并从heap区域分配内存以用于此结构。问题在于 MySQL 在每次提交时释放堆,因此读视图内存在下一个事务时重新分配,导致内存碎片化。

jemalloc是一个强调碎片避免和可扩展并发支持的内存分配器。

使用 jemalloc(禁用 THP),您可以减少内存碎片化并更有效地管理服务器可用内存资源。您可以从 jemalloc 仓库 或 Percona 的 yumapt 软件库安装 jemalloc 软件包。我们更喜欢使用 Percona 软件库,因为我们认为它更简单安装和管理。我们在 “安装 Percona Server 8.0” 和 “安装 Percona Server 8” 中描述了安装 yum 软件库和 apt 软件库的步骤。

一旦您有了软件库,您可以为您的操作系统运行安装命令。

提示

在 CentOS 中,如果服务器安装了 Extra Packages for Enterprise Linux (EPEL) 软件库,则可以使用 yum 从该软件库安装 jemalloc。要安装 EPEL 软件包,请使用:

# yum install epel-release -y

如果您使用 Ubuntu 20.04,则需要执行以下步骤以启用 jemalloc

  1. 安装 jemalloc

    # apt-get install libjemalloc2
    # dpkg -L libjemalloc2
    
  2. dpkg 命令将显示 jemalloc 库的位置:

    # dpkg -L libjemalloc2
    /.
    /usr
    /usr/lib
    /usr/lib/x86_64-linux-gnu
    /usr/lib/x86_64-linux-gnu/libjemalloc.so.2
    /usr/share
    /usr/share/doc
    /usr/share/doc/libjemalloc2
    /usr/share/doc/libjemalloc2/README
    /usr/share/doc/libjemalloc2/changelog.Debian.gz
    /usr/share/doc/libjemalloc2/copyright
    
  3. 通过以下命令覆盖服务的默认配置:

    # systemctl edit mysql
    

    将创建 /etc/systemd/system/mysql.service.d/override.conf 文件。

  4. 将以下配置添加到文件中:

    [Service]
    Environment="LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2"
    
  5. 重新启动 MySQL 服务以启用 jemalloc 库:

    # systemctl restart mysql
    
  6. 要验证是否成功,确保 mysqld 进程在运行,执行以下命令:

    # lsof -Pn -p $(pidof mysqld) |  grep "jemalloc"
    

    您应该看到类似以下的输出:

    mysqld  3844 mysql  mem       REG              253,0   744776  36550
    /usr/lib/x86_64-linux-gnu/libjemalloc.so.2
    

如果您使用 CentOS/RHEL,需要执行以下步骤:

  1. 安装 jemalloc 软件包:

    # yum install jemalloc
    # rpm -ql jemalloc
    
  2. rpm -ql 命令将显示库的位置:

    /usr/bin/jemalloc.sh
    /usr/lib64/libjemalloc.so.1
    /usr/share/doc/jemalloc-3.6.0
    /usr/share/doc/jemalloc-3.6.0/COPYING
    /usr/share/doc/jemalloc-3.6.0/README
    /usr/share/doc/jemalloc-3.6.0/VERSION
    /usr/share/doc/jemalloc-3.6.0/jemalloc.html
    
  3. 通过以下命令覆盖服务的默认配置:

    # systemctl edit mysqld
    

    将创建 /etc/systemd/system/mysqld.service.d/override.conf 文件。

  4. 将以下配置添加到文件中:

    [Service]
    Environment="LD_PRELOAD=/usr/lib64/libjemalloc.so.1"
    
  5. 重新启动 MySQL 服务以启用 jemalloc 库:

    # systemctl restart mysqld
    
  6. 要验证是否成功,确保 mysqld 进程在运行,执行以下命令:

    # lsof -Pn -p $(pidof mysqld) |  grep "jemalloc"
    

    您应该看到类似以下的输出:

    mysqld  4784 mysql  mem       REG              253,0    212096  33985101
    /usr/lib64/libjemalloc.so.1
    

CPU 调度器

在系统上减少功耗和热量输出的最有效方法之一是使用 CPUfreq。CPUfreq,也称为 CPU 频率调整或 CPU 速度调整,允许在运行时调整处理器的时钟速度。此功能使系统能够以降低的时钟速度运行以节省功耗。关于何时以及何时转移到更快或更慢的时钟速度的转换规则由 CPUfreq governor 定义。这个调度器定义了系统 CPU 的功耗特性,进而影响 CPU 的性能。每个调度器都有其独特的行为、目的和适用于工作负载的适合性。然而,对于 MySQL 数据库,我们建议使用最大性能设置以获得最佳吞吐量。

对于 CentOS,您可以通过执行以下命令查看当前使用的 CPU 调度器:

# cat /sys/devices/system/cpu/cpu/cpufreq/scaling_governor

您可以通过运行以下命令启用性能模式:

# cpupower frequency-set --governor performance

对于 Ubuntu,我们建议安装 linux-tools-common 包,这样您就可以使用 cpupower 实用程序:

# apt install linux-tools-common

安装完成后,您可以使用以下命令将管理器设置为性能模式:

# cpupower frequency-set --governor performance

MySQL 最佳实践

现在让我们来看看 MySQL 服务器设置。本节提出了对主要影响性能的主要 MySQL 参数推荐的值。您会看到对于大多数参数来说,不需要更改默认值。

缓冲池大小

innodb_buffer_pool_size 参数控制 InnoDB 缓冲池的大小(以字节为单位),这是 InnoDB 缓存表和索引数据的内存区域。毫无疑问,对于调整 InnoDB,这是最重要的参数。通常的经验法则是将其设置为 MySQL 专用服务器可用总内存的约 70%。

然而,服务器越大,这样做可能会浪费更多的 RAM。例如,对于具有 512 GB RAM 的服务器,这将使得操作系统有 153 GB 的多余内存,这比其需要的要多得多。

那么什么是更好的经验法则?将 innodb_buffer_pool_size 设置得尽可能大,而不会在系统运行生产工作负载时引起交换。这将需要一些调整。

在 MySQL 5.7 及更高版本中,这是一个动态参数,因此您可以在不重启数据库的情况下即时更改它。例如,要将其设置为 1 GB,请使用以下命令:

mysql> `SET` `global` `innodb_buffer_pool_size` `=` `1024``*``1024``*``1024``;`

Query OK, 0 rows affected (0.00 sec)

要使更改在重启后生效,您需要将此参数添加到 my.cnf 文件的 [mysqld] 部分下:

[mysqld]
innodb_buffer_pool_size = 1G

innodb_buffer_pool_instances 参数

MySQL 中较为隐晦的一个参数是 innodb_buffer_pool_instances。此参数定义了 InnoDB 将缓冲池分割为多少个实例。对于具有多个千兆字节缓冲池的系统,将缓冲池分割为单独的实例可以通过减少不同线程对缓存页面的读写而提高并发性能。

然而,根据我们的经验,为此参数设置较高的值可能也会引入额外的开销。原因在于每个缓冲池实例管理其自己的空闲列表、刷新列表、LRU 列表和所有其他与缓冲池相关的数据结构,并由其自己的缓冲池互斥量保护。

除非您运行基准测试证明性能增益,否则建议使用默认值 (8)。

注意

innodb_buffer_pool_instances 参数在 MariaDB 10.5.1 中已弃用,并在 MariaDB 10.6 中移除。根据 MariaDB 架构师 Marko Makela 的说法,这是因为现在大部分需要分割缓冲池的原因已经消失。您可以在 MariaDB Jira ticket 中找到更多详细信息。

重做日志大小

重做日志是在崩溃恢复期间使用的结构,用于纠正由不完整事务写入的数据。其主要目标是通过为已提交事务提供重做恢复来保证 ACID 事务的持久性(D)属性。因为重做文件记录了甚至在提交之前所有写入 MySQL 的数据,所以拥有合适的重做日志大小对于 MySQL 顺利运行至关重要。过小的重做日志甚至可能导致操作中出现错误!

以下是使用小的重做日志文件可能出现的错误示例:

[ERROR] InnoDB: The total blob data length (12299456) is greater than 10%
of the total redo log size (100663296). Please increase total redo log size.

在这种情况下,MySQL 使用了innodb_log_file_size参数的默认值,即 48 MB。为了估算最佳的重做日志大小,在大多数情况下我们可以使用以下公式。看一下以下命令:

mysql> `pager` `grep` `sequence`
PAGER set to 'grep sequence'
mysql> show engine innodb status\G select sleep(60);
    -> show engine innodb status\G

Log sequence number 3836410803
1 row in set (0.06 sec)

1 row in set (1 min 0.00 sec)

Log sequence number 3838334638
1 row in set (0.05 sec)

日志序列号是写入重做日志的总字节数。通过使用SLEEP()命令,我们可以计算该时间段的增量。然后,使用以下公式,我们可以得出一个估计值,用于存储大约一小时的日志所需的空间量(一个经验法则)。

mysql> `SELECT` `(``(``(``3838334638` `-` `3836410803``)``/``1024``/``1024``)``*``60``)``/``2`
    -> AS Estimated_innodb_log_file_size;

+--------------------------------+
| Estimated_innodb_log_file_size |
+--------------------------------+
|                55.041360855088 |
+--------------------------------+
1 row in set (0.00 sec)

我们通常会向上取整,因此最终的数字将是 56 MB。这是需要添加到my.cnf文件的值,在[mysqld]部分下:

[mysqld]
innodb_log_file_size=56M

sync_binlog参数

二进制日志是一组日志文件,包含对 MySQL 服务器实例进行的数据修改的信息。它们与重做文件不同,并且有其他用途。例如,它们用于创建副本和 InnoDB 集群,并有助于执行 PITR。

默认情况下,MySQL 服务器在提交事务之前将其二进制日志同步到磁盘(使用fdatasync())。其优点在于,如果发生停电或操作系统崩溃,从二进制日志丢失的事务仅处于准备状态;这允许自动恢复例程回滚事务,确保不会丢失任何事务。然而,默认值(sync_binlog = 1)会带来性能损失。由于这是一个动态选项,您可以在服务器运行时使用以下命令进行更改:

mysql> `SET` `GLOBAL` `sync_binlog` `=` `0``;`

要使更改在重新启动后持久化,请将参数添加到my.cnf文件的[mysqld]部分下:

[mysqld]
sync_binlog=0
注意

大多数情况下,当启用二进制日志时,使用sync_binlog=0会提供良好的性能。然而,性能变化可能会很大,因为 MySQL 将依赖于操作系统的刷新来刷新二进制日志。根据工作负载的不同,使用sync_binlog=1000或更高的值将比sync_binlog=1提供更好的性能,并且比sync_binlog=0具有更少的差异。

binlog_expire_logs_secondsexpire_logs_days参数

为了避免 MySQL 用二进制日志填满整个磁盘,您可以调整参数 binlog_expire_logs_secondsexpire_logs_days 的设置。expire_logs_days 指定在自动删除二进制日志文件之前的天数。然而,在 MySQL 8.0 中,此参数已被弃用,预计在将来的版本中将被移除。

因此,更好的选择是使用 binlog_expire_logs_seconds,该参数设置二进制日志的过期时间(以秒为单位)。此参数的默认值为 2592000(30 天)。MySQL 可以在到期后自动删除二进制日志文件,无论是在启动时还是在下次刷新二进制日志时。

注意

如果您想手动刷新二进制日志,可以执行以下命令:

mysql> `FLUSH` `BINARY` `LOGS``;`

innodb_flush_log_at_trx_commit 参数

innodb_flush_log_at_trx_commit 控制提交操作的严格 ACID 兼容性与在重新排列并批量执行提交相关 I/O 操作时可能实现的更高性能之间的平衡。这是一个微妙的选项,许多人更喜欢在源服务器上使用默认值(innodb_flush_log_at_trx_commit=1),而在副本上使用值 02。值 2 指示 InnoDB 在每个事务提交后写入日志文件,但每秒仅刷新一次到磁盘。这意味着如果操作系统崩溃,您最多可能会丢失一秒的更新,对于支持每秒达到一百万次插入的现代硬件来说,这并不可忽视。值 0 更糟:日志每秒仅写入并刷新到磁盘一次,因此即使 mysqld 进程崩溃,您也可能会丢失一秒的事务。

警告

许多操作系统和一些磁盘硬件在“刷入磁盘”的操作上表现得不可靠。它们可能会告诉 mysqld 刷入已完成,但实际上并未刷入。在这种情况下,即使使用推荐的设置,事务的耐久性也无法得到保证,最坏的情况下,断电可能会损坏 InnoDB 数据。在 SCSI 磁盘控制器或磁盘本身使用带电池后备的磁盘缓存可以加快文件刷入速度,并使操作更安全。如果电池工作不正常,还可以禁用硬件缓存中的磁盘写入缓存。

innodb_thread_concurrency 参数

innodb_thread_concurrency 默认设置为 0,这意味着 MySQL 内可以打开和执行无限数量(最多达到硬件限制)的线程。通常的建议是将此参数保持默认值,并仅在解决争用问题时进行更改。

如果您的工作负载始终很重或偶尔会有高峰,可以使用以下公式设置 innodb_thread_concurrency 的值:

innodb_thread_concurrency = Number of Cores * 2

因为 MySQL 不使用多个核心来执行单个查询(它是一对一的关系),每个核心在单个时间单位内运行一个查询。根据我们的经验,因为现代 CPU 性能通常较快,设置执行线程的最大数目为可用 CPU 数量的两倍是一个不错的起点。

当执行线程数达到此限制时,额外的线程会休眠一段由配置参数 innodb_thread_sleep_delay 设置的微秒数,然后被放入队列。

innodb_thread_concurrency 是一个动态变量,我们可以在运行时更改它:

mysql> `SET` `GLOBAL` `innodb_thread_concurrency` `=` `0``;`

要使更改持久化,您还需要将其添加到 my.cnf 文件中的 [mysqld] 部分:

[mysqld]
innodb-thread-concurrency=0

您可以使用以下命令验证 MySQL 是否应用了该设置:

mysql> `SHOW` `GLOBAL` `VARIABLES` `LIKE` `'%innodb_thread_concurrency%'``;`

注意

MySQL 8.0.14 的 发布说明 表示:“从 MySQL 8.0.14 开始,InnoDB 支持并行聚簇索引读取,这可以改善 CHECK TABLE 的性能。” 并行聚簇索引读取也适用于简单的 COUNT(*)(没有 WHERE 条件)。您可以通过 innodb_parallel_read_threads 参数来控制并行线程的数量。

此功能目前仅限于没有 WHERE 条件的查询(全表扫描)。然而,这对 MySQL 是一个很好的起点,为实现真正的并行查询打开了道路。

NUMA 架构

非统一内存访问(NUMA)是一种描述多处理器系统中主存储器模块相对于处理器放置方式的共享存储架构。在 NUMA 共享存储架构中,每个处理器都有自己的本地存储器模块,这带来了明显的性能优势,因为内存和处理器物理上更接近。同时,它还可以通过共享总线(或其他类型的互连)访问属于另一个处理器的任何存储器模块,如图 11-1 所示。

注意

一个很好的替代方法,可以显示跨 NUMA 节点的内存使用情况,是使用 numastat 命令。您可以通过执行以下命令来获取每个节点的更详细的内存使用情况:

# numastat -m

另一种可视化方法是通过特定进程。例如,要检查 mysqld 进程在 NUMA 节点中的内存使用情况:

# numastat -p $(pidof mysqld)

lm2e 1101

图 11-1. NUMA 架构概述

以下命令显示了启用 NUMA 的服务器上可用节点的示例:

shell> numactl --hardware

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 8 9 10 11 24 25 26 27 28 29 30 31 32 33 34 35
node 0 size: 130669 MB
node 0 free: 828 MB
node 1 cpus: 12 13 14 15 16 17 18 19 20 21 22 23 36 37 38 39 40 41 42 43 44 45 46
node 1 size: 131072 MB
node 1 free: 60 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

正如我们所见,节点 0 比节点 1 有更多的空闲内存。由于这一点导致操作系统即使有可用内存也进行交换,详细解释可以参考 Jeremy Cole 的优秀文章 “MySQL 交换疯狂 问题与 NUMA 架构的影响”

在 MySQL 5.7 中,移除了 innodb_buffer_pool_populatenuma_interleave 参数,它们的功能现在由 innodb_numa_interleave 参数控制。启用后,我们可以在 NUMA 系统中平衡内存分配,避免交换内存的问题。

这个参数不是动态的,因此要启用它,我们需要将它添加到 my.cnf 文件的 [mysqld] 部分,并重新启动 MySQL:

[mysqld]
innodb_numa_interleave = 1

第十二章:监控 MySQL 服务器

监控可以定义为在一段时间内观察或检查某物的质量或进展。将这个定义应用于 MySQL,我们观察和检查的是服务器的“健康”和性能。因此,质量就是保持正常运行时间并使性能达到期望水平。因此,监控实际上是一个持续的努力,用于保持事物的观察和控制。通常认为监控是可选的,除非存在特别高的负载或高风险。然而,就像备份一样,几乎每个数据库的安装都会受益于监控。

我们认为,建立监控并理解从中获得的指标,对于任何操作数据库系统的人来说都是最重要的任务之一——可能仅次于设置正确的验证备份。就像没有备份的数据库操作一样,未能监控数据库也是危险的:一个提供不可预测性能并可能随机“宕机”的系统有什么用呢?数据可能是安全的,但可能无法使用。

在本章中,我们将试图为您提供一个有效监控 MySQL 的基础。这本书不叫高性能 MySQL,我们不会深入讨论不同指标的具体含义或如何对系统进行复杂分析。但我们将讨论一些应在每个 MySQL 安装中定期检查的基本指标,并讨论重要的操作系统级别指标和工具。然后我们将简要介绍几种广泛使用的评估系统性能的方法。之后,我们将回顾几种流行的开源监控解决方案,最后我们将展示如何手动收集数据以进行调查和监控目的。

完成本章后,您应该能够自如地选择一个监控工具,并理解它显示的一些最重要的指标。

操作系统性能指标

操作系统是一个复杂的计算机程序:在我们的情况下,主要是应用程序(主要是 MySQL)和硬件之间的接口。在早期,操作系统很简单;现在它们可以说相当复杂,但背后的思想从未真正改变过。操作系统试图隐藏或抽象掉处理底层硬件复杂性的细节。可以想象某些特殊用途的 RDBMS 直接在硬件上运行,成为自己的操作系统,但实际上你可能永远不会看到那样的东西。除了提供方便和强大的接口外,操作系统还公开了许多性能指标。你不需要了解每一个指标,但了解如何评估数据库下层性能的基本知识是很重要的。

通常,当我们讨论操作系统的性能和指标时,实际上讨论的是在操作系统级别评估的硬件性能。称之为“操作系统指标”没有问题,但请记住,归根结底它们主要显示的是硬件性能。

让我们看看您将要监视和大致了解的最重要的操作系统指标。在本节中,我们将涵盖两个主要操作系统:Linux 和 Windows。类 Unix 系统,如 macOS 和其他系统,将具有与 Linux 相同或至少显示相同或类似输出的工具。

CPU

中央处理单元(CPU)是任何计算机的核心。如今,CPU 非常复杂,可以被看作是计算机内部的独立计算机。幸运的是,我们认为您应该理解的基本指标是普遍适用的。在本节中,我们将看看 Linux 和 Windows 报告的 CPU 利用率,并了解对总体负载的贡献。

在我们进入 CPU 利用率的测量之前,让我们快速回顾一下 CPU 的定义及其对数据库操作员最重要的特性。我们称它为“计算机的核心”,但这过于简化。事实上,CPU 是一个可以执行几个基本(和不那么基本)操作的设备,在这些操作的基础上,我们从机器代码到高级编程语言再到运行操作系统和最终(对我们来说)数据库系统,逐层堆叠复杂性。

计算机执行的每个操作都由 CPU 完成。正如Kevin Closson所说,“一切都是 CPU 的问题。”当程序正在被执行时——例如,MySQL 解析查询——CPU 正在处理所有工作。当程序等待资源时——例如,MySQL 等待从磁盘读取数据——CPU 参与“告诉”程序数据何时可用。这样的列表可以无限延续。

这里是服务器(或一般计算机)CPU 的几个最重要的指标:

CPU 频率

CPU 频率是 CPU 核心每秒“唤醒”执行工作的次数。这基本上是 CPU 的“速度”。越多越好,但令人惊讶的是频率并不总是最重要的指标。

缓存内存

缓存大小定义了直接位于 CPU 内部的内存量,使其速度极快。同样,越多越好,增加缓存没有任何不利因素。

核心数量

这是单个 CPU“包裹”(一个物理项目)内执行单元的数量,以及我们可以放入服务器中的所有 CPU 的总和。如今,越来越难找到只有一个核心的 CPU:大多数 CPU 都是多核系统。有些甚至有“虚拟”核心,使“实际”CPU 数量与总核心数之间的差异更大。

通常情况下,拥有更多的核心是件好事,但其中也有一些注意事项。一般来说,可用的核心越多,操作系统可以调度的进程就越多,可以同时执行。对于 MySQL 来说,这意味着可以并行执行更多查询,并且后台操作对其影响较小。

但是如果一半的可用核心是“虚拟”的,你得不到你可能期望的 2 倍性能提升。相反,可能 会得到 2 倍增加,或者你可能会在 1 倍到 2 倍之间得到任何增加:不是每个工作负载(即使在 MySQL 内部)都会从虚拟核心中受益。

此外,在不同插槽中具有多个 CPU 会使得与内存(RAM)和其他板载设备(如网络卡)的接口更加复杂。通常,常规服务器在物理布局上会使一些 CPU(及其核心)能够更快地访问 RAM 的某些部分,这就是我们在前一章中讨论过的 NUMA 架构。对于 MySQL 来说,这意味着内存分配和与内存相关的问题可能成为痛点。我们在 “NUMA 架构” 中涵盖了 NUMA 系统上的必要配置。

CPU 的基本度量是其负载百分比。当有人告诉你“CPU 20”,你可以相当确定他们的意思是“CPU 当前忙碌 20%”。但你永远也不能完全确定,所以最好再确认一次。例如,在多核系统上的一个核心的 20% 可能只是整体负载的 1%。让我们尝试可视化这个负载。

在 Linux 上,获取 CPU 负载的基本命令是 vmstat。如果没有参数运行,它将输出当前的平均值然后退出。如果我们用一个数字参数(这里称为 X)运行它,它会每 X 秒打印一次值。我们建议你使用一个数字参数运行 vmstat,例如 vmstat 1,持续几秒钟。如果仅运行 vmstat,你会得到自启动以来的平均值,通常这些值会误导。vmstat 1 将持续执行直到中断(按 Ctrl+C 是最简单的方式)。

vmstat 程序不仅打印有关 CPU 负载的信息,还包括内存和磁盘相关指标,以及高级系统指标。我们很快将探讨 vmstat 输出的其他部分,但这里我们将集中在 CPU 和进程指标上。

首先,让我们看看空闲系统上的 vmstat 输出。CPU 部分被截断了;稍后我们将详细审查它:

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id …
 2  0      0 1229924 1856608 6968268    0    0    39   125   61  144 18  7 75…
 1  0      0 1228028 1856608 6969384    0    0     0    84 2489 3047  2  1 97…
 0  0      0 1220972 1856620 6977688    0    0     0    84 2828 4712  3  1 96…
 0  0      0 1217420 1856644 6976796    0    0     0   164 2405 3164  2  2 96…
 0  0      0 1223768 1856648 6968352    0    0     0    84 2109 2762  2  1 97…

在标题之后的第一行输出是自启动以来的平均值,后面的行表示打印时的当前值。刚开始时输出可能难以阅读,但你很快就会习惯。为了清晰起见,在本节的其余部分中,我们将提供一个带有我们想要的信息的截断输出,包括 procscpu 部分:

procs ------cpu-----
 r  b us sy id wa st
 2  0 18  7 75  0  0
 1  0  2  1 97  0  0
 0  0  3  1 96  0  0
 0  0  2  2 96  0  0
 0  0  2  1 97  0  0

rb 是进程指标:正在活动运行的进程数和被阻塞的进程数(通常是在等待 I/O)。其他列表示 CPU 利用率的百分比(从 0% 到 100%,即使在多核系统上也是如此)。这些列的值加起来将始终为 100。以下是 cpu 列的含义:

us(用户)

运行用户程序所花费的时间(或者说这些程序对系统的负载)。MySQL 服务器是一个用户程序,每个存在于内核之外的代码也是如此。重要的是,此指标显示的是纯粹在程序本身内部花费的时间。例如,当 MySQL 进行某些计算或解析复杂查询时,此值会增加。当 MySQL 想要执行磁盘或网络操作时,此值也会增加,但很快您将看到另外两个值也在增加。

sy(系统)

运行内核代码的时间。由于 Linux 和其他类 Unix 系统的组织方式,用户程序会增加此计数器。例如,每当 MySQL 需要进行磁盘读取时,操作系统内核将需要进行一些工作。花费在执行这些工作上的时间将包含在 sy 值中。

id(空闲)

无所事事的时间;空闲时间。在一个完全空闲的服务器上,这个指标将为 100。

wa(I/O 等待)

I/O 等待时间。这是 MySQL 的一个重要指标,因为读写各种文件占 MySQL 操作的相对较大部分。当 MySQL 进行磁盘读取时,会在 MySQL 的内部函数中花费一些时间,并反映在 us 中。然后会在内核中花费一些时间,并反映在 sy 中。最后,一旦内核向底层存储设备(可以是本地设备或网络设备)发送读取请求,并等待响应和数据时,所有花费的时间都累积在 wa 中。如果我们的程序和内核非常慢,而我们所做的一切都是 I/O 操作,理论上这个指标可能接近 100。实际上,两位数的数值很少见,通常表示某些 I/O 问题。我们将在 “磁盘” 中深入讨论 I/O。

st(窃取)

这是一个很难解释的指标,如果不深入细节。MySQL 参考手册将其定义为“从虚拟机中窃取的时间”。您可以将其视为虚拟机希望执行其指令但必须等待主机服务器分配 CPU 时间的时间段。此行为有多种原因,其中一些值得注意。首先是主机过度配置:运行过多的大型虚拟机,导致虚拟机需要的资源总和超过了主机的容量。其次是“吵闹的邻居”情况,其中一个或多个虚拟机受到特别负载的虚拟机的影响。

其他命令,如稍后将显示的 top,将有更精细的 CPU 负载分解。但是,刚刚列出的列是一个很好的起点,并涵盖了您需要了解的大部分正在运行的系统内容。

现在让我们回到在空闲系统上的 vmstat 1 输出:

procs ------cpu-----
 r  b us sy id wa st
 2  0 18  7 75  0  0
 1  0  2  1 97  0  0
 0  0  3  1 96  0  0
 0  0  2  2 96  0  0
 0  0  2  1 97  0  0

从这个输出中我们能得出什么结论呢?如前所述,第一行是从启动以来的平均值。平均来看,此系统上有两个进程运行(r),0 个被阻塞(b);用户 CPU 利用率为 18%(us),系统 CPU 利用率为 7%(sy),总体上 CPU 空闲率为 75%(id)。I/O 等待(wa)和偷取时间(st)均为 0。

在第一个输出后,输出的每一行都是采样间隔内的平均值,例如我们的例子中是 1 秒。这与我们所谓的“当前”值相当接近。由于这是一台空闲的机器,我们可以看到整体值低于平均水平。只有一个或没有进程在运行或被阻塞,用户 CPU 时间为 2–3%,系统 CPU 时间为 1–2%,系统空闲时间为 96–97%。

为了进一步了解,让我们来看看在相同系统上进行 CPU 密集型计算的 vmstat 1 输出:

procs ------cpu-----
 r  b us sy id wa st
 2  0 18  7 75  0  0
 1  0 13  0 87  0  0
 1  0 13  0 86  0  0
 1  0 14  0 86  0  0
 1  0 15  0 84  0  0

从启动以来的平均值相同,但每个样本中我们都有一个单独的进程运行,并且它将用户 CPU 时间推到了 13–15%。 vmstat 的问题在于,我们无法从其输出中了解到底是哪个具体的进程在消耗 CPU。当然,如果这是专用的数据库服务器,你可以假设大部分,如果不是所有的用户 CPU 时间都由 MySQL 及其线程占用,但事情总是变化的。另一个问题是,在具有高 CPU 核心数的机器上,你可能会误认为 vmstat 输出的低读数是事实,但即使在 256 核心的机器上, vmstat 也会显示 0% 到 100% 的读数。如果这样的机器的 8 个核心都是 100% 负载,那么 vmstat 显示的用户时间将是 3%,但实际上某些工作负载可能已被限制。

在讨论这些问题的解决方案之前,让我们稍微谈谈 Windows。总体而言,我们在 CPU 利用率以及特别是 CPU 方面的许多内容可以推广到 Windows,但有一些显著的不同点:

  • Windows 中没有 I/O 等待的账户,因为 I/O 子系统本质上是不同的。线程等待 I/O 的时间会计入空闲计数器。

  • 系统 CPU 时间的对应部分大致是特权 CPU 时间。

  • 没有可用的偷取信息。

用户和空闲计数器保持不变,因此你可以基于 Windows 显示的用户、特权和空闲 CPU 时间进行 CPU 监控。还有其他可用的计数器和指标,但这应该已经很好地涵盖了。在 Windows 上获取当前 CPU 利用率可以使用许多不同的工具。最简单的一个,也可能是最接近 vmstat 精神的,是老牌的任务管理器,它是查看 Windows 性能指标的重要工具。它易于获取,简单易用,你可能以前已经使用过。任务管理器可以显示按 CPU 核心分割的百分比形式的 CPU 利用率,并分别显示用户和内核时间。

图 12-1 显示了在空闲系统上运行的任务管理器。

lm2e 1201

图 12-1. 任务管理器显示空闲 CPU

图 12-2 展示了在繁忙系统上运行的任务管理器。

lm2e 1202

图 12-2. 任务管理器显示繁忙 CPU

正如我们前面所说,vmstat存在一些问题:它不会分解每个进程或每个 CPU 核心的负载。解决这两个问题需要运行其他工具。为什么不立即运行它们呢?vmstat是通用的,提供的不仅仅是 CPU 读数,而且非常简洁。它是快速查看给定系统是否有严重问题的好方法。任务管理器也是如此,尽管它实际上比vmstat更为强大。

在 Linux 上,在vmstat之后使用的下一个最简单的工具是top,这是任何处理 Linux 服务器的人工具箱中的另一个基本元素。它扩展了我们讨论的基本 CPU 指标,并添加了每个核心负载分解和每个进程负载统计。当您在没有任何参数的情况下执行top时,它会以终端 UI 或 TUI 模式启动。按下?键查看帮助菜单。按1显示每个核心的负载分解。图 12-3 展示了top的输出内容。

在这里可以看到,每个进程都在%CPU列下显示其自己的整体 CPU 利用率。例如,mysqld正在使用总 CPU 时间的 104.7%。现在我们还可以看到这个负载是如何在服务器的多个核心之间分布的。在这种特定情况下,一个核心(Cpu0)的负载略高于另一个核心。当 MySQL 达到单 CPU 吞吐量限制时,单核心负载分解变得重要。如果怀疑某些恶意进程正在耗尽服务器的容量,了解负载在进程之间的分布就显得很重要。

lm2e 1203

图 12-3. TUI 模式下的top

还有许多其他工具可以显示更多数据。我们无法详细讨论所有这些工具,但我们会列出其中一些。mpstat可以提供非常深入的 CPU 统计信息。pidstat是一个通用工具,可以为每个正在运行的进程提供 CPU、内存、磁盘和网络利用率的统计数据。atoptop的高级版本。还有其他工具,每个人都有自己喜欢的工具集。我们坚信,真正重要的不是工具本身,尽管它们很有帮助,而是理解它们提供的核心指标和统计数据。

在 Windows 上,任务管理器程序实际上比vmstat更接近top,尽管我们刚刚做了这个比较。任务管理器显示每个核心负载和每个进程负载的能力使其在任何调查中都是一个非常有用的第一步。我们建议立即深入了解资源监视器,因为它提供了更多细节。访问它的最简单方法是单击任务管理器中的打开资源监视器链接。

图 12-4 显示了一个带有 CPU 负载详细信息的资源监视器窗口。

任务管理器和资源监视器并不是 Windows 上能够显示性能指标的唯一工具。以下是几个其他可能需要你熟悉的高级工具,这里不详细介绍:

性能监视器

这个内置工具是 Windows 中性能计数器子系统的图形用户界面。简而言之,你可以查看和绘制 Windows 测量的各种性能指标(不仅限于 CPU 相关的)。

进程资源管理器

这个工具是名为Windows Sysinternals的高级系统实用工具套件的一部分。它比这里列出的其他工具更强大、更先进,对学习很有帮助。与其他工具不同,你需要单独从其Sysinternals 网站上的主页安装 Process Explorer。

lm2e 1204

图 12-4. 资源监视器显示 CPU 负载详细信息

磁盘

对于数据库性能来说,磁盘或 I/O 子系统至关重要。尽管 CPU 支撑着系统上的每个操作,但特别是对于数据库而言,磁盘很可能是最棘手的瓶颈。这是很合乎逻辑的——毕竟,数据库将数据存储在磁盘上,然后从磁盘上提供数据。在慢速和持久的长期存储之上,有许多层缓存,但不能总是利用它们,而且它们也不是无限大的。因此,在处理数据库系统时,理解基本的磁盘性能非常重要。存储系统的另一个重要且经常被低估的属性与性能无关——那就是容量。我们将从这里开始。

磁盘容量和利用率是指可以存储在给定磁盘上(或者存储系统中的许多磁盘)的数据总量,以及已经存储了多少数据。这些指标虽然不那么有趣,但却非常重要。尽管监视磁盘容量并不是真正必要的,因为它不太可能在您注意不到的情况下发生变化,但您绝对必须关注磁盘的利用率和可用空间。

大多数数据库的大小会随时间增长。特别是 MySQL 需要足够的可用磁盘空间余地来适应表更改、长时间运行的事务和写入负载的突发增加。当 MySQL 数据库实例没有更多可用的磁盘空间时,可能会崩溃或停止工作,并且不太可能在没有释放空间或增加磁盘容量的情况下重新开始工作。根据您的情况,增加更多容量可能需要从几分钟到几天不等的时间。这是您可能想提前计划的事情。

在 Linux 上,幸运的是,监控磁盘空间使用非常容易。可以使用简单的df命令来完成。不带参数时,它将显示每个文件系统的容量和使用情况(以 1 KB 块为单位)。你可以添加-h参数以获取人类可读的测量结果,并指定一个挂载点(或者只是一个路径)来限制检查。以下是一个示例:

$ df -h /var/lib/mysql
Filesystem      Size  Used Avail Use% Mounted on
/dev/sda1        40G   18G   23G  45% /

df 命令的输出是不言自明的,是最容易使用的工具之一。我们建议您尝试将数据库挂载点保持在 90%的容量,除非您运行的是多 TB 系统。在这种情况下,可以选择更高的容量。您可以使用的一个技巧是在与数据库相同的文件系统上放置一些大型虚拟文件。如果开始空间不足,可以删除其中一个或多个文件以获得更多时间进行响应。但是,我们建议您建立一些磁盘空间监控来替代依赖这种技巧。

在 Windows 上,可信的文件资源管理器可以提供磁盘空间利用率和容量信息,如图 12-5 所示。

lm2e 1205

图 12-5. 文件资源管理器显示可用磁盘空间

在覆盖了磁盘空间之后,我们现在将探讨任何 I/O 子系统的关键性能特性:

带宽

每单位时间内可以推送(或从存储中拉取)多少字节的数据

每秒 I/O 操作数(IOPS)

磁盘(或其他存储系统)每单位时间内能够提供的操作数

延迟

存储系统提供读取或写入服务的时间长短

这三个属性足以描述任何存储系统,并开始形成对其优劣的理解。就像在 CPU 部分所做的那样,我们将向您展示几个工具来检查磁盘性能,并使用它们的输出来解释具体的度量指标。我们再次关注 Linux 和 Windows;其他系统将有类似的内容,因此这些知识是通用的。

在 Linux 上,iostatvmstat 的 I/O 负载模拟。交互模式模式应该很熟悉:调用不带参数的命令,您将得到自启动以来的平均值;传递一个数字作为参数,您将得到采样期间的平均值。我们还建议使用 -x 参数运行该工具,它添加了许多有用的细节。与 vmstat 不同,iostat 提供按块设备细分的指标,类似于我们之前提到的 mpstat 命令。

提示

iostat 通常作为 sysstat 包的一部分安装。在 Ubuntu/Debian 上使用 apt,在基于 RHEL 的操作系统上使用 yum 安装此包。在按照 第一章 中的说明后,您应该能够轻松使用这些工具。

让我们看一个示例输出。我们将使用 iostat -dxyt 5 命令,其含义是:打印设备利用报告,显示扩展统计信息,省略从启动以来的第一个带有平均值的报告,为每个报告添加时间戳,并报告每 5 秒钟的平均值,在负载系统上提供一些示例输出:

05/09/2021 04:45:09 PM
Device:        rrqm/s   wrqm/s   r/s       w/s    rkB/s      wkB/s...
sda              0.00     0.00  0.00   1599.00     0.00  204672.00...
...avgrq-sz avgqu-sz   await r_await  w_await  svctm   %util
...  256.00   141.67   88.63    0.00    88.63   0.63  100.00

这里有很多内容需要理解。我们不会覆盖每一列,但会突出显示与之前提到的属性对应的内容:

带宽

iostat输出中,列rkB/swkB/s对应于带宽利用率(分别为读和写)。如果您了解底层存储的特性(例如,您可能知道它承诺的读写带宽为 200 MiB/s),您可以确定是否正在推动极限。在这里,您可以看到每秒仅写入超过 200,000 KB 到/dev/sda设备,且没有读操作正在进行。

IOPS

此度量由r/sw/s列表示,分别给出每秒读和写操作的次数。我们的示例显示每秒发生 1,599 次写操作。如预期,没有读操作被注册。

延迟

以稍微复杂的方式显示,延迟被分解为四列(或者在更新的iostat版本中更多列):awaitr_awaitw_awaitsvctm。对于基本分析,您应该查看await值,这是为任何请求提供服务的平均延迟。r_awaitw_awaitawait按读和写分解。svctime是一个被弃用的度量,试图显示纯设备延迟而没有任何排队。

通过这些基本度量读取并了解所使用的存储的一些基本事实,可以知道正在发生什么。我们的示例正在一位作者笔记本电脑上运行现代消费级 NVMe SSD。虽然带宽相当不错,但每个请求的平均时间为 88 毫秒,这相当长。您还可以通过简单的数学运算从这些度量中得到 I/O 负载模式。例如,如果我们将带宽除以 IOPS,我们得到每个请求 128 KB 的数字。iostat确实包括在avgrq-sz列中,它显示了历史单位扇区(512 字节)的平均请求大小。您可以继续测量每秒 1,599 次写操作只能在约 40 毫秒/请求下服务,这意味着存在并行写入负载(同时表明我们的设备能够服务并行请求)。

I/O 模式——请求大小、并行度、随机与顺序——可以改变底层存储的上限。大多数设备将在特定条件下宣传最大带宽、最大 IOPS 和最小延迟,但这些条件对于最大 IOPS 和最大带宽的测量以及最佳延迟可能会有所变化。要确定指标读数是好是坏,是相当困难的。在不了解底层存储的情况下,评估饱和度是观察利用率的一种方式。饱和度,我们将在“USE 方法”中涉及,是资源过载程度的一种度量。对于现代存储,能够高效地并行服务长队列,这变得越来越复杂,但总体上,存储设备上的排队是饱和的迹象。在iostat输出中,这是avgqu-sz列(或者在较新版本的iostat中是aqu-sz),数值大于 1 通常意味着设备饱和。我们的例子显示了一个队列有 146 个请求,这是很多,可能告诉我们 I/O 被高度利用并可能成为瓶颈。

不幸的是,正如您可能注意到的那样,没有关于 I/O 利用率的简单直接的度量:每个指标似乎都有一个特例。衡量存储性能是一项困难的任务!

同样的指标定义了 Linux、Windows 和任何其他操作系统上的存储设备。

现在让我们看一下评估 I/O 性能的基本 Windows 工具。现在这些读数应该很熟悉了。我们建议使用资源监视器,在 CPU 部分中展示过,但这次切换到磁盘选项卡。图 12-6 显示 MySQL 在高写入负载下的视图。

lm2e 1206

图 12-6. 资源监视器显示的 I/O 负载详细信息

资源监视器显示的指标与iostat类似。您可以查看带宽、延迟和请求队列长度。一个缺少的指标是 IOPS。要获取这些信息,您需要使用性能监视器(perfmon),但我们将其留作练习。

实际上,资源监视器显示的视图比iostat稍微详细。有每个进程的 I/O 负载细分,以及进一步的每个文件的负载细分。我们不知道 Linux 上有一个能够同时显示这样负载细分的单一工具。要获取每个程序的负载细分,您可以使用 Linux 上的pidstat工具,我们之前提到过。以下是一些示例输出:

# pidstat -d 5
...
10:50:01 AM   UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  Command
10:50:06 AM    27      4725      0.00  30235.06      0.00  mysqld

10:50:06 AM   UID       PID   kB_rd/s   kB_wr/s kB_ccwr/s  Command
10:50:11 AM    27      4725      0.00  23379.20      0.00  mysqld
...

在 Linux 上通过使用 BCC 工具包特别是filetop工具可以很容易地实现对每个文件的详细分解。工具包中还有许多更先进的工具可供探索。我们在这里展示的工具应该足以涵盖基本的调查和监控需求。

内存

内存或 RAM 对于任何数据库来说是另一个重要资源。与磁盘相比,内存在读写数据时提供了极大的性能优势,因此数据库尽可能地“在内存中”运行。不幸的是,内存不是持久的,因此最终每个操作都必须在磁盘上反映出来(有关磁盘性能的更多信息,请参阅前一节)。

与 CPU 和磁盘部分相比,我们不会详细讨论内存性能。尽管它很重要,但也是一个非常高级和深奥的话题。相反,我们将专注于内存利用。这也可能很快变得非常复杂,所以我们会保持专注。

让我们从一些基本前提开始。每个程序都需要一些内存来运行。包括 MySQL 在内的数据库系统通常需要大量内存。当内存不足时,应用程序开始出现性能问题,甚至可能失败,正如本节末尾所述。因此,监视内存利用对任何系统的稳定性至关重要,特别是对数据库系统而言。

在这种情况下,我们实际上将从 Windows 开始,因为表面上它的内存记账机制比 Linux 略微简单。要获取 Windows 上的整体操作系统内存利用情况,您只需启动任务管理器,如“CPU”中所述,转到“性能”选项卡,然后选择“内存”。您可以在图 12-7 中看到任务管理器的内存使用显示。

这台机器总共有 4 GB 内存,当前使用了 2.4 GB,可用 1.6 GB,总利用率为 60%。这是一个安全的量,我们甚至可能希望为 MySQL 分配更多内存,以减少“浪费”的空闲内存。有关 MySQL InnoDB 缓冲池大小的一些想法可以在“缓冲池大小”中找到。

在 Linux 上,获取内存利用详细信息的最简单工具是free命令。我们建议使用带有-h参数,将所有字段转换为人类可读格式。以下是在运行 CentOS 7 的机器上的示例输出:

$ free -h
              total        used        free      shared  buff/cache   available
Mem:           3.7G        2.7G        155M        8.5M        905M        787M
Swap:          2.0G         13M        2.0G

lm2e 1207

图 12-7. 任务管理器显示内存利用详情

现在,这比我们在 Windows 上看到的数据要多。实际上,Windows 拥有大多数这些计数器;它们只是不那么显眼。

让我们浏览一下输出。现在,我们将涵盖“Mem”行,稍后再讨论“Swap”。

这里的两个关键指标是usedavailable,它们对应于任务管理器的正在使用可用。一个常见的错误,也是作者们过去常犯的错误,是查看free指标而不是available。这是不正确的!Linux(实际上,包括 Windows)不喜欢保持空闲内存。毕竟,空闲内存是一种浪费的资源。当有可用的内存,而应用程序并不直接需要时,Linux 将使用该内存来保持正在从磁盘读写的数据的缓存。我们将稍后展示 Windows 也在做同样的事情,但你无法从任务管理器看到。有关为什么专注于free指标是错误的更多信息,请参阅网站“Linux ate my ram”

让我们进一步分解这个命令的输出。列是:

total

机器上可用的总内存量

used

应用程序当前使用的内存量

free

实际上系统未使用的空闲内存

shared

一种特殊类型的内存,需要专门请求和分配,多个进程可以共同访问;因为 MySQL 没有使用它,我们在这里跳过了细节

buff/cache

操作系统当前正在使用的内存作为缓存以提高 I/O 的数量

available

应用程序需要使用的内存量;通常是freebuff/cache的总和

一般来说,对于基本但健壮的监控,你只需要关注totalusedavailable这几个量。Linux 应该能够自行处理缓存的内存。在这里,我们故意不涉及页面缓存,因为那是一个高级主题。默认情况下,Linux 上的 MySQL 会利用页面缓存,因此你应该调整实例大小以适应这一点。然而,一个经常推荐的改变是告诉 MySQL 避免页面缓存(查看innodb_flush_method的文档),这将允许 MySQL 本身使用更多的内存。

我们已经提到 Windows 基本上具有相同的指标;它们只是被隐藏起来了。要查看这些,请打开资源监视器并导航到内存选项卡。图 12-8 显示了此选项卡的内容。

您将立即注意到,空闲内存量仅为 52 MB,并且有相当大的一部分是待机内存,还有一小部分是修改过的内存。下面列表中的 Cached 值是修改和待机数量的总和。当截图被拍摄时,缓存中使用了 1,593 MB 的内存,其中 33 MB 是脏(或修改过的)。像 Linux 一样,Windows 会缓存文件系统页面,以尽量减少和平滑 I/O,并充分利用内存的容量。

另一件你可以看到的事情是,按进程分解的内存利用情况,其中 mysqld.exe 占用的内存略低于 500 MB。在 Linux 上,可以通过 top 命令获得类似的输出,我们首先在“CPU”中使用了它。运行 top 后,按 Shift+M 可以按内存使用排序输出,并获得人类可读的数字。显示内存使用详细信息的 top 输出在图 12-9 中。

lm2e 1208

图 12-8. 资源监视器显示内存利用详细信息

lm2e 1209

图 12-9. top 显示内存利用详细信息

在这个系统上,输出并不是非常有趣,但你可以迅速看到,MySQL 通过它的 mysqld 进程消耗了最多的内存。

在结束本节之前,我们想讨论的是当内存耗尽时会发生什么。但在此之前,让我们先讨论交换或分页。我们应该在这里提到,大多数现代操作系统以一种方式实现内存管理,使得每个应用程序都有自己对系统内存的视图(因此你可能会看到应用程序内存被称为虚拟内存),而应用程序可以使用的虚拟内存总和超过了系统实际内存容量。关于前一点的讨论更适合于操作系统设计的大学课程,但后一点在运行数据库系统时非常重要。

这种设计的影响很重要,因为操作系统无法奇迹般地扩展系统内存容量。事实上,操作系统使用磁盘存储来扩展内存量,正如我们提到的,RAM 通常比最快的磁盘更有效率。因此,你可以想象,这种内存扩展是有代价的。页面调度可以以几种不同的方式发生,出于不同的原因。对 MySQL 来说,最重要的是一种称为交换的页面调度类型——将内存的部分写入到磁盘上的一个专用位置。在 Linux 上,这个位置可以是一个单独的分区或文件。在 Windows 上,有一个名为 pagefile.sys 的特殊文件,功能大致相同。

Swapping 本身并不是坏事,但对 MySQL 来说却是个问题。问题在于我们的数据库认为它正在从内存中读取某些数据,而实际上操作系统已经将部分数据换出到交换文件,并将从磁盘读取。MySQL 无法预测这种情况何时发生,也无法防止或优化访问。对于最终用户来说,这可能意味着查询响应时间突然下降而无法解释。但是,有一些交换空间是一项重要的保护措施,我们将会展示。

让我们继续回答当内存耗尽时到底会发生什么这个问题。简而言之:情况不妙。当系统即将耗尽内存时,MySQL 只有几种一般的结果,所以让我们逐一讨论它们:

  • MySQL 从操作系统请求更多内存,但没有可用的内存——所有可以页面出的内容都不在内存中,并且交换文件不存在或已满。通常情况下,这种情况会导致崩溃。这是一个非常糟糕的结果。

  • 在 Linux 上,前面一点的变化是,操作系统检测到系统接近耗尽内存并强制终止——换句话说,终止一个或多个进程。通常终止的进程将是占用最多内存的进程,在数据库服务器上,通常是 MySQL 是最大的内存消耗者。这通常发生在前一点解释的情况之前。

  • MySQL 或其他某个程序,将内存填充到操作系统必须开始交换的程度。这假设已设置交换空间(或 Windows 中的页面文件)。正如前面几段解释的那样,当 MySQL 的内存被交换出去时,其性能将意外地和不可预测地下降。这可以说是比仅仅崩溃或终止 MySQL 更好的结果,但仍然是需要避免的问题。

因此,MySQL 将变慢、崩溃或被终止,就是这么简单。现在你应该清楚地看到为什么监控可用和使用的内存非常重要了。我们也建议在服务器上留出一些内存余量,并设置好交换/页面文件。有关 Linux 交换设置的建议,请参阅“操作系统最佳实践”。

网络

在所有操作系统资源中,网络可能是被随机未解释问题指责最多的一个资源。这其中有很好的原因:监控网络是困难的。理解网络问题有时需要对整个网络流进行详细分析。这是一个特殊的资源,因为与 CPU、磁盘和内存不同,它不局限于单个服务器。至少,你需要两台相互通信的机器才能谈论“网络”。当然,也有本地连接,但它们通常是稳定的。而且,可以共享磁盘存储,虚拟机的 CPU 和内存也可以共享,但网络总是涉及到多台机器。

由于本章是关于监控,我们在这里不会涵盖连接性问题——然而,令人惊讶的是,许多网络问题归结为一个简单的问题,即一台计算机无法与另一台计算机通信。不要认为连接性是理所当然的。网络拓扑通常复杂,每个数据包都会经过多台计算机的复杂路径。在云环境中,路由可能更加复杂且不明显。如果您认为存在某些网络问题,检查能否建立连接是明智的。

我们将涉及任何网络的以下属性:

带宽及其利用率(吞吐量)

这类似于在“Disk”中定义的相同概念。每个网络连接都有一个最大带宽容量,通常以每秒数据量的某个单位表达。互联网连接通常使用 Mbps 或兆位每秒,但也可以使用 MBps 或兆字节每秒。网络链路和设备对最大带宽设置了硬限制。例如,目前,普通家用网络设备很少超过 1 Gbps 带宽。更先进的数据中心设备通常支持 10 Gbps。还有专门的设备可以将带宽提升到数百 Gbps,但这类连接通常是两台服务器之间直连的未路由连接。

错误—它们的数量和来源

网络错误是不可避免的。事实上,传输控制协议(TCP),作为互联网的支柱和 MySQL 使用的协议,建立在数据包丢失的前提下。你肯定会时不时地看到错误,但是如果错误率高,连接会变慢,因为通信双方需要反复重发数据包。

继续与磁盘的类比,我们还可以包括延迟和发送接收的数据包数量(类似于 IOPS)。但是,数据包传输延迟只能由实际传输数据的应用程序进行测量。操作系统不能测量和显示网络的某种平均延迟。而数据包数量通常是多余的,因为它跟随带宽和吞吐量的数字。

查看网络时,有一个特别有用的度量指标是重传数据包的数量。当数据包丢失或损坏时会发生重传。这不是一个错误,但通常是由连接问题引起的。与带宽耗尽类似,重传数据包数量的增加会导致网络性能不稳定。

在 Linux 上,我们可以从查看网络接口统计开始。最简单的方法是运行ifconfig命令。它的默认输出将包括特定主机上的每个网络接口。由于我们知道在这种情况下所有的负载都通过eth1,我们可以只显示该接口的统计信息:

$ ifconfig eth1
...
eth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 192.168.10.11  netmask 255.255.255.0  broadcast 192.168.10.255
        inet6 fe80::a00:27ff:fef6:b4f  prefixlen 64  scopeid 0x20<link>
        ether 08:00:27:f6:0b:4f  txqueuelen 1000  (Ethernet)
        RX packets 6217203  bytes 735108061 (701.0 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 11381894  bytes 18025086781 (16.7 GiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
...

我们可以立即看到网络的状态相当良好,因为接收(RX)和发送(TX)数据包都没有错误。RX 和 TX 的总数据统计(分别为 701.0 MiB 和 16.7 GiB)每次运行ifconfig时都会增长,因此你可以通过随时间运行它来轻松测量带宽利用率。这并不是非常方便,而且常见的 Linux 发行版默认没有程序实时显示传输速率。要查看传输速率和错误的历史记录,可以使用sar -n DEVsar -n EDEV命令,分别(sar是我们在谈论iostat时提到的sysstat软件包的一部分):

$ sar -n DEV
                IFACE   rxpck/s   txpck/s    rxkB/s    txkB/s...
06:30:01 PM      eth0      0.16      0.08      0.01      0.01...
06:30:01 PM      eth1   7269.55  13473.28    843.84  21618.70...
06:30:01 PM        lo      0.00      0.00      0.00      0.00...
06:40:01 PM      eth0      0.48      0.28      0.03      0.05...
06:40:01 PM      eth1   7844.90  13941.09    893.95  19204.10...
06:40:01 PM        lo      0.00      0.00      0.00      0.00...
...rxcmp/s   txcmp/s  rxmcst/s
...   0.00      0.00      0.00
...   0.00      0.00      0.00
...   0.00      0.00      0.00
...   0.00      0.00      0.00
...   0.00      0.00      0.00
...   0.00      0.00      0.00
$ sar -n EDEV
04:30:01 PM     IFACE   rxerr/s   txerr/s    coll/s  rxdrop/s...
06:40:01 PM      eth0      0.00      0.00      0.00      0.00...
06:40:01 PM      eth1      0.00      0.00      0.00      0.00...
06:40:01 PM        lo      0.00      0.00      0.00      0.00...
...txdrop/s  txcarr/s  rxfram/s  rxfifo/s  txfifo/s
...    0.00      0.00      0.00      0.00      0.00
...    0.00      0.00      0.00      0.00      0.00
...    0.00      0.00      0.00      0.00      0.00

再次看到,在我们的示例接口中 eth1 负载相当高,但没有报告错误。如果我们保持在带宽限制内,网络性能应该是正常的。

要获取网络内发生的各种错误和问题的详细视图,可以使用 netstat 命令。使用 -s 标志,它会报告大量计数器。为了简化,我们只显示输出的 Tcp 部分,其中包括一些重传。要获取更详细的概述,请查看输出的 TcpExt 部分:

$ netstat -s
...
Tcp:
    55 active connections openings
    39 passive connection openings
    0 failed connection attempts
    3 connection resets received
    9 connections established
    14449654 segments received
    25994151 segments send out
    54 segments retransmitted
    0 bad segments received.
    19 resets sent
...

考虑到发送的片段数量庞大,重传率非常优秀。这个网络看起来很好。

在 Windows 上,我们再次借助资源监视器来检查,它提供了我们需要的大部分指标,甚至更多。图 12-10 展示了资源监视器在运行针对 MySQL 的合成负载的主机上提供的与网络相关的视图。

lm2e 1210

图 12-10. 资源监视器显示网络利用率详细信息

要获取 Windows 上错误数量的读数,可以使用 netstat 命令。请注意,即使它与我们先前使用的 Linux 工具同名,它们也略有不同。在这种情况下,我们没有错误:

C:\Users\someuser> netstat -e
Interface Statistics

                           Received            Sent

Bytes                      58544920         7904968
Unicast packets               62504           32308
Non-unicast packets               0             364
Discards                          0               0
Errors                            0               0
Unknown protocols                 0

-s 修改符对于 netstat 在 Windows 上也是存在的。在这里我们只展示了部分输出:

C:\Users\someuser> netstat -s
...
TCP Statistics for IPv4

  Active Opens                        = 457
  Passive Opens                       = 30
  Failed Connection Attempts          = 3
  Reset Connections                   = 121
  Current Connections                 = 11
  Segments Received                   = 61237201
  Segments Sent                       = 30866526
  Segments Retransmitted              = 0
...

根据我们强调的用于监控的指标——带宽利用率和错误——这个系统的网络运行完全正常。我们理解这仅仅触及了网络复杂性的表面。然而,这套最基本的工具可以极大地帮助你理解是否应该归咎于你的网络。

这就完成了对操作系统监控基础知识的相当长的概述。我们可能本可以更简洁一些,你可能会问为什么我们在一本关于 MySQL 的书中加入了这么多内容。答案很简单:因为这很重要。任何程序都与操作系统进行交互,并需要系统的一些资源。MySQL 通常是一个非常要求性能良好的程序。然而,为此,你需要确保你拥有必要的资源,并且在磁盘、CPU 或网络的性能容量上不会耗尽,或者仅在磁盘和内存容量上不足。有时,由 MySQL 导致的系统资源问题也可能导致你在 MySQL 本身内部发现问题。例如,一个编写不良的查询可能会在引起内存使用增加的同时,对 CPU 和磁盘造成大量负载。接下来的部分将展示一些监视和诊断运行中 MySQL 服务器的基本方法。

MySQL 服务器可观察性

监控 MySQL 既简单又困难。简单是因为 MySQL 公开了将近 500 个状态变量,允许你几乎完全了解数据库内部的运行情况。此外,InnoDB 还有其自己的诊断输出。然而,监控是困难的,因为理解你所拥有的数据可能会有些棘手。

在本节中,我们将解释 MySQL 监控的基础知识,首先介绍状态变量是什么,以及如何获取它们,然后进入 InnoDB 的诊断。一旦涵盖了这些内容,我们将展示一些基本的配方,我们认为应该成为每个 MySQL 数据库监控套件的一部分。有了这些配方和你在上一节中学到的操作系统监控知识,你应该能够理解系统的运行情况。

状态变量

我们将从 MySQL 的服务器状态变量开始。这些变量与配置选项不同,是只读的,并且显示 MySQL 服务器当前状态的信息。它们的性质各不相同:大多数是递增计数器,或者值上下波动的仪表。但也有一些是静态文本字段,有助于理解当前服务器配置。所有状态变量都可以在全局服务器级别和当前会话级别访问。但并非每个变量在会话级别都有意义,有些变量在两个级别上显示相同的值。

SHOW STATUS用于获取当前状态变量的值。它有两个可选修饰符,GLOBALSESSION,默认为SESSION。你也可以指定变量的名称或模式,但这不是必须的。以下示例命令显示当前会话的所有状态变量值:

mysql> `SHOW` `STATUS``;`
+-----------------------------------------------+-----------------------+
| Variable_name                                 | Value                 |
+-----------------------------------------------+-----------------------+
| Aborted_clients                               | 0                     |
| Aborted_connects                              | 0                     |
| Acl_cache_items_count                         | 0                     |
| ...                                                                   |
| Threads_connected                             | 2                     |
| Threads_created                               | 2                     |
| Threads_running                               | 2                     |
| Uptime                                        | 305662                |
| Uptime_since_flush_status                     | 305662                |
| validate_password.dictionary_file_last_parsed | 2021-05-22 20:53:08   |
| validate_password.dictionary_file_words_count | 0                     |
+-----------------------------------------------+-----------------------+
482 rows in set (0.01 sec)

滚动浏览数百行输出是不理想的,因此让我们使用通配符来限制我们请求的变量数量。在SHOW STATUS中使用LIKE与常规SELECT语句中的用法相同,详细解释见第三章。

mysql> `SHOW` `STATUS` `LIKE` `'Created%'``;`
+-------------------------+-------+
| Variable_name           | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 0     |
| Created_tmp_files       | 7     |
| Created_tmp_tables      | 0     |
+-------------------------+-------+
3 rows in set (0.01 sec)

现在输出变得更易于阅读了。要读取单个变量的值,只需在引号内指定其完整名称,不使用通配符,像这样:

mysql> `SHOW` `STATUS` `LIKE` `'Com_show_status'``;`
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| Com_show_status | 11    |
+-----------------+-------+
1 row in set (0.00 sec)

你可能会注意到Created%状态变量的输出中,MySQL 显示了Created_tmp_files7的值。这意味着本次会话创建了七个临时文件,而没有创建临时表吗?实际上,Created_tmp_files状态变量只有全局范围。这是 MySQL 目前已知的问题:你总是看到所有状态变量,无论请求的范围如何,但它们的值将正确地限定在其作用域内。MySQL 文档包含一个有用的“服务器状态变量参考”,可以帮助你理解不同变量的范围。

Created_tmp_files不同,Com_show_status变量的范围是“both”,这意味着你可以得到全局计数器以及每个会话的值。让我们看看实际操作:

mysql> `SHOW` `STATUS` `LIKE` `'Com_show_status'``;`
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| Com_show_status | 13    |
+-----------------+-------+
1 row in set (0.00 sec)
mysql> `SHOW` `GLOBAL` `STATUS` `LIKE` `'Com_show_status'``;`
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| Com_show_status | 45    |
+-----------------+-------+
1 row in set (0.00 sec)

当查看状态变量时,另一个重要的注意事项是,大多数状态变量可以在会话级别上重置为零。这可以通过运行FLUSH STATUS命令来实现。此命令将会话中的状态变量重置为零,然后将它们当前的值添加到全局计数器中。因此,FLUSH STATUS在会话级别上操作,但适用于所有会话。为了说明这一点,我们将在之前使用过的会话中重置状态变量的值:

mysql> `FLUSH` `STATUS``;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SHOW` `STATUS` `LIKE` `'Com_show_status'``;`
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| Com_show_status | 1     |
+-----------------+-------+
1 row in set (0.00 sec)
mysql> `SHOW` `GLOBAL` `STATUS` `LIKE` `'Com_show_status'``;`
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| Com_show_status | 49    |
+-----------------+-------+
1 row in set (0.00 sec)

尽管全局计数器持续增加,但会话计数器在运行SHOW STATUS命令时被重置为0,并仅在运行时增加到1。例如,运行单个查询如何改变状态变量值(特别是Handler_*系列状态变量),这种隔离显示是有用的。

请注意,无法在不重新启动的情况下重置全局计数器。

基本监控方案

您可以监控多种不同组合的指标。然而,我们认为每个数据库操作者的工具箱中必须包含一些指标。当您学习 MySQL 时,这些指标应该足以让您合理地了解数据库的运行情况。大多数现有的监控系统应该包括这些指标,并通常包括更多指标。您可能永远不会自己设置集合,但我们的解释也应该使您更好地理解监控系统向您报告的内容。

我们将在接下来的小节中列出几个广泛的指标类别,在其中详细说明我们认为的一些更重要的计数器。

MySQL 服务器的可用性

这是您应该监控的最重要的事情。如果 MySQL 服务器不接受连接或不运行,那么所有其他指标都毫无意义。

MySQL 服务器是一款强大的软件,可以运行数月甚至数年的时间。然而,有些情况可能导致提前非计划关闭(或者更直接地说,崩溃)。例如,在“Memory”中我们讨论了内存不足条件可能导致 MySQL 崩溃或被杀死。还会发生其他事故。虽然 MySQL 中的崩溃性错误现在很少见,但确实存在。还有操作失误:谁在计划维护后忘记启动数据库?硬件故障,服务器重启——许多因素都可能影响 MySQL 的可用性。

监控 MySQL 可用性有几种方法,没有单一的最佳方法;最好结合几种方法。一个非常简单的基本方法是检查 mysqld(或 mysqld.exe)进程是否实际在运行并在操作系统级别可见。在类 Unix 系统上,您可以使用 ps 命令进行检查,在 Windows 上,您可以检查任务管理器或运行 Get-Service PowerShell 命令。这不是一个无用的检查,但它也有其问题。首先,MySQL 正在运行并不意味着它确实在做它应该做的事情——即处理客户端的查询。MySQL 可能被负载淹没或受到磁盘故障的影响,运行速度非常慢。从操作系统的角度来看,进程正在运行,但从客户端的角度来看,它几乎就像已经关闭了一样。

第二种方法是从应用程序的角度检查 MySQL 的可用性。通常,通过运行 MySQL 监控器并执行一些简单的短查询来实现。与前一种检查不同,此方法确保可以建立新的 MySQL 连接并且数据库正在处理查询。您可以将这些检查定位在应用程序端,使它们更接近应用程序看到的数据库。应用程序可以调整以探测 MySQL 并将明确的错误报告给操作员或监控系统。

第三种方法介于前两种之间,重点放在数据库端。在监控 MySQL 时,您至少需要对数据库执行简单的查询以检查状态变量。如果这些查询失败,您的监控系统应该发出警报,因为 MySQL 可能开始出现问题。另一种方法是检查最近几分钟内目标实例是否向您的监控系统接收到任何数据。

这些检查的理想结果不仅仅是在适当时候发出“MySQL 已停止运行”的警报,还包括一些关于为何停止运行的线索。例如,如果第二种检查无法启动新连接,因为 MySQL 连接已经用完,那么这应该成为警报的一部分。如果第三种检查失败,但第一种检查正常,则情况就不同于崩溃。

客户端连接

MySQL 服务器是一个多线程程序,正如在“MySQL 服务器守护程序”中深入讲解的那样。每个连接到数据库的客户端都会在 MySQL 服务器的进程中产生一个新线程(mysqldmysqld.exe)。该线程将负责执行客户端发送的语句,理论上可以有多个并发查询正在执行,就像有多个客户端线程一样。

每个连接及其线程即使在空闲时也会对 MySQL 服务器产生一些低开销。除此之外,从数据库的角度来看,每个连接都是一个责任:数据库无法知道何时会发送语句。并发性,即同时运行的事务和查询的数量,通常随着建立连接数的增加而增加。并发性本身并不是坏事,但每个系统都有可扩展性的限制。正如您会从“操作系统度量”中记得的那样,CPU 和磁盘资源有性能限制,超过这些限制是不可能的。即使具有无限的操作系统资源,MySQL 本身也有内部的可扩展性限制。

简单地说:连接数,特别是活动连接数,理想情况下应该保持最小化。从 MySQL 方面来看,没有连接是完美的情况,但从应用程序方面来说这是不可接受的。一些应用程序尽管不会尝试限制它们建立和发送的连接数和查询数量,认为数据库会处理负载。这可能会产生一个危险的情况,被称为雷鸣般的兽群:由于某种原因,查询运行时间更长,应用程序反应是发送越来越多的查询,从而过载数据库。

最后,MySQL 有一个客户端连接数的上限,由系统变量max_connections控制。一旦现有连接数达到该变量的值,MySQL 将拒绝创建新连接。这是一个坏事情。max_connections应该被用作防止服务器完全崩溃的保护,如果客户端建立成千上万的连接。但理想情况下,您应该监控连接数,并与应用团队合作,保持该数字低。

让我们回顾 MySQL 公开的具体连接和线程计数器:

Threads_connected

当前连接的客户端线程数,或者换句话说,已建立的客户端连接数。在过去的几段中,我们一直在解释这一点的重要性,所以您应该知道为什么必须检查它。

Threads_running

当前正在执行语句的客户端线程数。而Threads_connected指示高并发的潜力,Threads_running实际上显示了并发的当前度量。此计数器的波动表明应用程序负载增加或数据库查询缓慢导致查询堆积。

Max_used_connections

自 MySQL 服务器上次重启以来记录的建立的最大连接数。如果您怀疑发生了连接洪泛,但没有记录Threads_connected的变化历史,您可以检查此状态变量以查看记录的最高峰值。

Max_used_connections_time

自上次重启以来,MySQL 服务器看到的连接数最大值的日期和时间。

关于连接的另一个重要指标是它们的失败率。错误率增加可能表明您的应用程序在与数据库通信时遇到问题。MySQL 区分了客户端未能建立的连接和由于超时等原因失败的现有连接,例如:

Aborted_clients

已建立连接中被中止的数量。MySQL 文档提到“客户端未正确关闭连接而死亡”,但如果服务器和客户端之间存在网络问题,也会发生这种情况。此计数器增加的常见原因包括 max_allowed_packet 违规(参见 “Options 范围”)和会话超时(参见 wait_timeoutinteractive_timeout 系统变量)。某些错误是可以预期的,但应检查突然激增的情况。

Aborted_connects

无法建立的新连接数量。导致此问题的原因包括密码错误、用户无权限连接到数据库、协议不匹配、connect_timeout 超时以及达到 max_connections 等。还包括各种网络相关问题。在 Connection_errors_% 通配符下有一系列状态变量,可以更深入地了解一些特定问题。应检查 Aborted_connects 的增加情况,因为它可能表明应用程序配置问题(错误的用户/密码)或数据库问题(连接用尽)。

注意

MySQL Enterprise Edition、Percona Server 和 MariaDB 都提供了线程池功能。这改变了连接和线程计数。使用线程池后,连接数保持不变,但在 MySQL 内运行的线程数受池大小限制。当连接需要执行语句时,它将从池中获取一个可用线程,并在没有可用线程时等待。使用线程池可以改善 MySQL 在数百或数千个连接时的性能。由于这个功能在常规 MySQL 中不可用,我们认为它是高级功能,因此本书不涵盖这部分内容。

查询计数

下一个广泛的指标类别是与查询相关的指标。Threads_running 显示同时活动的会话数量,而此类别中的指标将显示这些会话产生的负载质量。在这里,我们将首先查看总体查询量,然后逐步分析查询类型,并最后查看查询的执行方式。

监控查询计数非常重要。30 个正在运行的线程可能每个执行一个时长为一小时的查询,或者每秒执行几十个查询。在每种情况下,您得出的结论完全不同,负载特性也可能发生变化。以下是显示执行查询数量的重要指标:

Queries

这个全局状态变量,简单地说,提供了服务器执行的语句数量(不包括COM_PINGCOM_STATISTICS)。如果您在空闲服务器上运行SHOW GLOBAL STATUS LIKE 'Queries',您将看到每次执行SHOW STATUS命令时计数器值增加。

Questions

Queries几乎相同,但不包括在存储过程中执行的语句,以及以下类型的查询:COM_PINGCOM_STATISTICSCOM_STMT_PREPARECOM_STMT_CLOSECOM_STMT_RESET。除非您的数据库客户端广泛使用存储过程,否则Questions指标更接近服务器实际执行的查询量,与Queries中的语句数量相比。此外,Questions既是会话级别的全局状态变量。

提示

当查询开始执行时,QueriesQuestions都会增加,因此还需要查看Threads_running值,以了解当前实际执行的查询数量。

QPS

每秒查询数。这是一个合成度量标准,您可以通过观察Queries变量随时间的变化来得出。基于Queries的 QPS 将包括服务器执行的几乎所有语句。

QPS 指标并不告诉我们有关执行的查询质量的信息——即它们对服务器的影响程度——但它仍然是一个有用的衡量标准。通常,应用程序对数据库的负载是规律性的。它可能呈波浪形(白天更多,夜间更少),但在一周或一个月的时间内,随着时间的推移,查询数量的模式将显示出来。当您收到有关数据库变慢的报告时,查看 QPS 可能会快速指示是否在应用程序负载中突然出现了意外的增长。另一方面,QPS 的下降可能表明问题出现在数据库端,因为它无法在相同的时间内处理与通常一样多的查询。

查询类型和质量

从了解 QPS 的下一个逻辑步骤是理解客户端执行的查询类型以及这些查询对服务器的影响。并非所有查询都相同,有些可以说是不好的,或者对系统产生不必要的负载。查找和捕获这样的查询是监控的重要部分。在本节中,我们试图回答“是否有很多不良查询?”的问题,在“慢查询日志”中,我们将向您展示如何捕获具体的违规行为。

查询类型

MySQL 执行的每个查询都有一个类型。更重要的是,你可以执行的任何命令都有一个类型。MySQL 使用Com_%系列状态变量跟踪执行的不同类型的命令和查询。在 MySQL 8.0.25 中有 172 个这些变量,占所有状态变量的近三分之一。从这个数字可以看出,MySQL 计数了许多你可能甚至不会考虑的命令:例如,Com_uninstall_plugin计算了调用UNINSTALL PLUGIN的次数,而Com_help计算了HELP语句的使用次数。

每个Com_%状态变量在全局和会话级别都可用,就像在“状态变量”中展示的Com_show_status一样。然而,MySQL 不会暴露其他线程的Com_%变量计数器,因此在监控目的上,这里假定为全局状态变量。可以通过性能模式事件系列statement/sql/%获取其他会话的语句计数器,但这需要一些高级操作,更多属于调查而非监控。你可以在 MySQL 文档的“性能模式状态变量表”章节中找到更多细节。

由于有这么多的Com_%状态变量,监控每种类型的命令既会引起太多噪音,又是不必要的。然而,你应该尝试存储它们的所有值。你可以通过两种方式来查看这些计数器。

第一种选择是选择与你的数据库负载配置相关的命令类型,并对其进行监控。例如,如果你的数据库客户端不使用存储过程,那么查看Com_call_procedure就是浪费时间。一个好的起始选择是覆盖SELECT和基本的 DML 语句,这通常包括任何数据库系统负载的大部分内容,例如Com_selectCom_insertCom_updateCom_delete(这些状态变量的名称在这里都很自解释)。MySQL 的一件有趣的事情是单独计算多表更新和删除(参见“使用多表进行更新和删除”),分别使用Com_update_multiCom_delete_multi;除非你确信系统中从不运行这些语句,否则也应该对其进行监控。

你可以查看所有的Com_%状态变量,看看哪些在增长,并将它们添加到你监控的变量选择中。不幸的是,这种方法的缺陷在于可能会错过一些意外的峰值。

另一种查看这些计数器的方法是随时间查看前 5 或前 10 名。这样,负载模式的突然变化就更难被忽视了。

了解正在运行的查询类型对于形成对给定数据库负载的整体理解非常重要。此外,它会改变您调整数据库的方式,例如,与只读或大部分读取负载相比,插入密集型工作负载可能需要不同的设置。查询负载配置的变化,例如每秒执行成千上万次UPDATE语句的突然出现,可能表明应用程序端发生了变化。

查询质量

了解正在运行的查询后的下一步是了解它们的质量或对系统的影响。我们提到过这一点,但值得重申:并非所有查询都相同。有些查询会对系统施加更大的负担。查看整体与查询相关的指标可能会提前发现数据库中正在增加的问题。通过监控几个计数器,您可以注意到可能存在的问题行为。

Select_scan 统计了导致全表扫描的查询次数,或者换句话说,强制 MySQL 读取整个表来形成结果的查询。现在,我们应该立即承认全表扫描并不总是问题。毕竟,有时只需读取表中的所有数据就是一种可行的查询执行策略,特别是当行数较少时。您还可以预期始终会看到某些全表扫描发生,因为许多 MySQL 目录表都是以这种方式读取的。例如,仅运行 SHOW GLOBAL STATUS 查询将导致 Select_scan 增加两个。然而,通常情况下,全表扫描暗示存在影响数据库性能的查询:要么它们编写不当且不能有效过滤数据,要么根本没有可用于查询的索引。我们在“解释语句”中提供更多有关查询执行详细信息和计划的信息。

Select_full_join 类似于 Select_scan,但是统计了在 JOIN 查询中导致引用表进行全表扫描的查询次数。被引用的表是 JOIN 条件中最右边的表,请参阅“两个表的连接”以获取更多信息。与 Select_scan 类似,高 Select_full_join 次数并不总是坏事。例如,在大型数据仓库系统中,紧凑的字典表通常可以全表读取而不会出现问题。然而,通常情况下,这个状态变量的高值表明存在执行不良查询的可能性。

Select_range 计数了使用某些范围条件扫描数据的查询次数(在“使用 WHERE 子句选择行”中介绍)。通常情况下,这根本不是问题。如果范围条件无法利用索引满足,那么Select_scanSelect_full_join的值将与此状态变量同时增长。也许唯一可能表示问题的时候是当你看到该计数器的值在增长,尽管你知道数据库中运行的大多数查询实际上并不利用范围。只要相关的表扫描计数器没有增长,问题很可能仍然是良性的。

Select_full_range_join 结合了Select_rangeSelect_full_join。该变量统计了在JOIN查询中导致引用表上的范围扫描的查询次数。

到目前为止,我们一直在计算单个查询,但 MySQL 也会对它从存储引擎中读取的每一行做类似的统计!显示这些计数器的状态变量家族是Handler_%变量。简单来说,MySQL 读取的每一行都会增加一些Handler_%变量。将这些信息与你到目前为止看到的查询类型和查询质量计数器结合起来,可以告诉你,例如,在你的数据库中运行的全表扫描是否是一个问题。

我们首先要看的处理器是Handler_read_rnd_next,它统计了执行全表或部分表扫描时读取的行数。与Select_%状态变量不同,Handler_%变量没有简单易记的名称,因此需要一些记忆。通常情况下,Handler_read_rnd_next状态变量中的高值表明可能有许多表未正确建立索引,或者许多查询未利用现有的索引。请记住,在解释Select_scan时我们提到,有些全表扫描并不是问题。要查看在你的情况下是否属实,可以查看Handler_read_rnd_next与其他处理器的比例。你希望看到该计数器的值较低。如果你的数据库平均每分钟返回一百万行数据,那么你可能希望通过全扫描返回的行数是几千,而不是几万或几十万。

Handler_read_rnd 统计了在执行结果集排序时通常读取的行数。高值可能表明存在许多全表扫描和未使用索引的连接。然而,与Handler_read_rnd_next不同,这并不一定表示存在问题。

Handler_read_first 记录了读取第一个索引条目的次数。该计数器的高值表示正在发生大量的全索引扫描。这比全表扫描要好,但仍然是一个问题行为。很可能是一些查询在其WHERE子句中缺少过滤器。这个状态变量的值应该再次与其他处理器的值进行比较,因为一些全索引扫描是不可避免的。

Handler_read_key计算通过索引读取的行数。您希望此处理程序的值相比其他读取相关处理程序高。一般来说,这里的高数字意味着您的查询正确使用了索引。

请注意,处理程序仍然可能隐藏一些问题。如果一个查询仅使用索引读取行,但效率低下,那么Select_scan不会增加,而Handler_read_key——我们的好读取处理程序——将增加,但最终结果仍然是一个慢查询。我们将解释如何在“慢查询日志”中找到特定的慢查询,但也有一个专门的计数器:Slow_queries。此状态变量计算执行时间超过Long_query_time值的查询次数,无论是否启用慢查询日志。您可以逐渐降低Long_query_time的值,并查看Slow_queries何时开始接近服务器执行的总查询数。这是一种评估系统中有多少查询,例如超过一秒钟的好方法,而不需要实际启用慢查询日志,因为这会增加开销。

并非每个执行的查询都是只读的,MySQL 还根据分别是Handler_insertHandler_updateHandler_delete状态变量计算插入、更新或删除的行数。与SELECT查询不同,仅根据状态变量就很难对写入语句的质量做出结论。但是,您可以监视这些变量,以查看例如数据库客户端开始更新更多行是否有变化。如果没有UPDATE语句的数量变化(Com_updateCom_update_multi状态变量),这可能表明对相同查询传递的参数进行了更改:范围更广,在IN子句中的项目更多等等。这本身可能不表示问题,但在调查性能问题时可以使用它来查看数据库是否承受了更多的压力。

除了INSERT语句外,UPDATEDELETE,甚至INSERT SELECT语句都必须查找要更改的行。因此,例如,DELETE语句将增加与读相关的计数器,并可能导致意外情况:Select_scan不增长,但Handler_read_rnd_next值增加。如果在状态变量之间存在差异,请不要忘记这一特点。慢查询日志将包括SELECT以及 DML 语句。

临时对象

有时,当执行查询时,MySQL 需要创建和使用临时对象,这些对象可能驻留在内存或磁盘上。创建临时对象的原因包括使用 UNION 子句、派生表、公共表达式以及某些 ORDER BYGROUP BY 子句的变体等。本章节几乎讨论了所有事物,但临时对象不是问题:某些数量的临时对象是不可避免的,实际上是期望的。然而,它们会消耗服务器资源:如果临时表足够小,它们将保留在内存中并使用它,如果它们增大,MySQL 将开始将其卸载到磁盘,使用磁盘空间并影响性能。

MySQL 维护了三个与查询执行期间创建的临时对象相关的状态变量。注意,这不包括通过 CREATE TEMPORARY TABLE 语句显式创建的临时表;这些请查看 Com_create_table 计数器。

Created_tmp_tables 计数 MySQL 服务器在执行各种查询时隐式创建的临时表数量。你不知道为什么或为哪些查询创建了这些表,但这里会统计每张表。在稳定的工作负载下,你应该看到创建的临时表数量基本一致,因为大致相同的查询运行相同次数。这个计数器的增长通常与查询或其执行计划的变化有关,例如数据库的增长,可能会带来问题。尽管有用,即使在内存中创建临时表也会消耗资源。你无法完全避免临时表的创建,但应该通过慢查询日志等手段,检查为何临时表数量增长,进行查询审计。

Created_tmp_disk_tables 计数临时表“溢出”或在其大小超过配置的内存临时表上限后写入磁盘的数量。使用旧版 Memory 引擎时,上限由 tmp_table_sizemax_heap_table_size 控制。MySQL 8.0 默认移动到新的 TempTable 引擎处理临时表,这种情况下,默认情况下不会像 Memory 表那样溢出到磁盘。如果 temptable_use_mmap 变量设置为其默认值 ON,那么 TempTable 临时表即使写入磁盘也不会增加此变量。

Created_tmp_files 计数 MySQL 创建的临时文件数量。这与 Memory 引擎临时表溢出到磁盘不同,但将会计入 TempTable 表写入到磁盘的情况。我们理解这可能看起来复杂,确实如此,但主要变化通常不会没有一些缺点而来。

无论您使用何种配置,调整临时表的大小都很重要,监控它们的创建和溢出速率也很关键。如果工作负载创建了大量大约 32 MB 大小的临时表,但内存表的上限为 16 MB,那么服务器将因为这些表被写入和从磁盘读取而增加 I/O 的速率。对于内存不足的服务器来说这是可以接受的,但如果有足够的内存则是浪费。相反,设置上限过高可能会导致服务器交换或直接崩溃,如 “Memory” 中所述。

我们曾经看到由于内存暴增导致服务器崩溃的情况,当大量同时打开的连接运行需要临时表的查询时。我们还见过大部分 I/O 负载是由临时表溢出到磁盘引起的服务器。与操作数据库相关的大多数事情一样,表大小决策是一个权衡考量。我们展示的这三个计数器可以帮助您做出明智的选择。

InnoDB I/O 和事务指标

到目前为止,我们大多数时间都在讨论总体 MySQL 指标,忽略了事务和锁定等内容。在这个小节中,我们将看一些 InnoDB 存储引擎提供的有用指标。其中一些指标涉及 InnoDB 读写数据的量及原因。然而,有些指标可以显示有关锁定的重要信息,这些信息可以与 MySQL 全局计数器结合,以深入了解数据库中当前的锁定情况。

InnoDB 存储引擎提供了 61 个状态变量,显示关于其内部状态的各种信息。通过观察它们随时间的变化,您可以看到 InnoDB 的负载情况及其对操作系统产生的负载。鉴于 InnoDB 是默认的存储引擎,这可能是 MySQL 所产生的大部分负载。

或许我们应该把这些放在有关查询质量的部分中,但 InnoDB 维护其自己的计数器来统计已读取、插入、更新或删除的行数。相应的变量是 Inndb_rows_readInndb_rows_insertedInndb_rows_updatedInndb_rows_deleted。通常它们的值与相关的 Handler_% 变量的值相对应得很好。如果您主要使用 InnoDB 表,使用 Innodb_rows_% 计数器而不是 Handler_% 计数器来监控查询处理的行数可能更简单。

InnoDB 提供的其他重要且有用的状态变量显示存储引擎读取和写入的数据量。在 “Disk” 中,我们已经看到如何检查和监控总体和每个进程的 I/O 利用率。InnoDB 允许您准确查看它读取和写入数据的原因及其数量:

Innodb_data_read

自服务器启动以来从磁盘读取的数据量,以字节表示。如果你随时间测量此变量的值,可以将其转换为字节/秒的带宽利用率。这个指标与 InnoDB 缓冲池的大小及其效率密切相关,我们稍后会详细讨论。所有这些数据都可以假定是从数据文件中读取以满足查询。

Innodb_data_written

自服务器启动以来写入磁盘的数据量,以字节表示。与 Innodb_data_read 相同,但方向相反。通常情况下,此值将占据 MySQL 生成的写入带宽的大部分。与读取数据不同,InnoDB 在多种情况下写出数据;因此,有额外的变量指定这种 I/O 的部分,以及其他 I/O 的来源。

Innodb_os_log_written

InnoDB 写入其重做日志的数据量,以字节表示。这个量也包含在 Innodb_data_written 中,但单独监控它可以看出你的重做日志是否需要调整大小。详情请见 “重做日志大小”。

Innodb_pages_written

InnoDB 操作期间写入的数据量,以页面(默认为 16 KiB)表示。这是 Innodb_data_written 状态变量的后半部分。查看 InnoDB 生成的非重做 I/O 的量非常有用。

Innodb_buffer_pool_pages_flushed

由于刷新而由 InnoDB 写入的页面数。与前两个计数器覆盖的写入不同,刷新引起的写入不会立即在执行实际写入后发生。刷新是一个复杂的后台操作,其详细信息超出了本书的范围。然而,你至少应该知道刷新的存在以及它会生成与其他计数器无关的 I/O。

结合 Innodb_data_writtenInnodb_buffer_pool_pages_flushed,你应该能够得出 InnoDB 和 MySQL 服务器使用的磁盘带宽利用率的相当准确的数据。加上 Innodb_data_read 完成 InnoDB 的 I/O 概况。MySQL 不仅使用 InnoDB,系统的其他部分也可能有 I/O,例如我们之前讨论的临时表溢出到磁盘。然而,通常情况下,InnoDB 的 I/O 与从操作系统观察到的 MySQL 服务器的 I/O 匹配。

这些信息的一个用途是查看 MySQL 服务器距离达到存储系统性能容量极限有多近。这在云环境中尤为重要,因为存储通常有严格的限制。在与数据库性能相关的事件期间,您可以检查与 I/O 相关的计数器,以查看 MySQL 是否正在写入或读取更多数据,这可能表明负载增加,或者反而减少实际的 I/O 操作。后者可能意味着 MySQL 当前受到其他资源(如 CPU)的限制,或者受到锁定等其他问题的影响。不幸的是,减少的 I/O 也可能意味着存储存在问题。

在 InnoDB 中有一些状态变量可能有助于发现存储或其性能方面的问题:Innodb_data_pending_fsyncsInnodb_data_pending_readsInnodb_data_pending_writesInnodb_os_log_pending_fsyncsInnodb_os_log_pending_writes。您可以预期会看到一定数量的挂起数据读取和写入,尽管像往常一样,查看趋势和先前的数据是有帮助的。其中最重要的是 Innodb_os_log_pending_fsyncs。重做日志经常进行同步,同步操作的性能对 InnoDB 的整体性能和事务吞吐量非常重要。

与许多其他状态变量不同,所有这些都是计量器,即它们的值上升和下降,并不仅仅增加。您应该对这些变量进行采样,并查看有多少挂起的操作,特别是对于重做日志同步。即使是 Innodb_os_log_pending_fsyncs 的微小增加也可能表明存储存在严重问题:要么是性能容量不足,要么是硬件问题。

在讨论 Innodb_data_read 变量时,我们提到 InnoDB 读取的数据量与其缓冲池大小和使用情况有关。让我们详细说明一下。InnoDB 将从磁盘读取的页面缓存在其缓冲池中。缓冲池越大,存储在其中的页面就越多,从磁盘读取页面的频率就越低。我们在 “缓冲池大小” 中进行了讨论。在这里,讨论监视时,让我们看看如何监视缓冲池的效果。只需查看两个状态变量即可轻松完成:

Innodb_buffer_pool_read_requests

MySQL 文档将其定义为“逻辑读取请求的数量”。简单来说,这是 InnoDB 内部各种操作希望从缓冲池中读取的页面数量。通常大部分页面是由于查询活动而读取的。

Innodb_buffer_pool_reads

这是 InnoDB 为满足查询或其他操作的读取请求而必须从磁盘读取的页面数量。即使在完全空(或“冷”)的缓冲池中,此计数器的值通常也小于或等于 Innodb_buffer_pool_read_requests,因为从磁盘读取用于满足读取请求。

在正常情况下,即使缓冲池很小,这些变量之间也不会有 1:1 的比例。也就是说,至少可以从缓冲池中满足一些读取。理想情况下,您应该尽量将磁盘读取的数量保持在最低限度。这可能并非总是可能的,特别是如果数据库大小远大于服务器内存时。

您可以尝试估算缓冲池命中率,网上有公式可用。然而,比较这两个变量的值并不完全正确,就像比较苹果和橙子一样。如果您认为您的Innodb_buffer_pool_reads太高,可能值得检查系统上运行的查询(例如使用慢查询日志),而不是试图增加缓冲池的大小。当然,您应该尽量保持缓冲池尽可能大,以覆盖数据库中的大部分或全部热数据。然而,仍然会有一些查询可能会通过从磁盘获取页面(并增加Innodb_buffer_pool_reads)导致高读取 I/O,并尝试通过进一步增加缓冲池大小来修复它们将带来递减的回报。

最后,在我们讨论完 InnoDB 之后,我们将继续讨论事务和锁定。在第六章中提供了大量关于这两个主题的信息,因此在这里我们将对相关的状态变量进行简要概述:

与事务相关的命令计数器

BEGINCOMMITROLLBACK都是特殊的 MySQL 命令。因此,MySQL 将使用Com_%状态变量计算它们被执行的次数:Com_beginCom_commitCom_rollback。通过查看这些计数器,您可以看到显式启动的事务以及提交或回滚的次数。

与锁定相关的状态变量

您现在知道 InnoDB 提供了以行级粒度的锁定。这是比 MyISAM 的表级锁定有了巨大的改进,因为每个单独锁的影响被最小化了。尽管如此,如果事务彼此等待甚至很短时间,仍然可能会有影响。

InnoDB 提供了一些状态变量,让您可以看到正在创建的锁的数量,并提供关于正在发生的锁等待的详细信息:

Innodb_row_lock_current_waits显示目前正在等待由其他事务释放的锁的操作 InnoDB 表上的事务数。当有阻塞会话时,此变量的值会从零上升,然后在锁定解决后立即返回零。

Innodb_row_lock_waits显示自服务器启动以来,在 InnoDB 表上的事务等待行级锁的次数。此变量是一个计数器,将在 MySQL 服务器重新启动之前持续增加。

Innodb_row_lock_time显示了会话尝试在 InnoDB 表上获取锁所花费的总毫秒数。

Innodb_row_lock_time_avg 显示会话在获取 InnoDB 表上行级锁定时的平均时间(以毫秒为单位)。您可以通过将Innodb_row_lock_time除以Innodb_row_lock_waits来得到相同的值。这个值可能会上下波动,这取决于遇到多少锁等待以及累积锁时间的增长。

Innodb_row_lock_time_max 显示获取 InnoDB 表上锁的最长时间(以毫秒为单位)。只有在某些不幸的事务打破了记录时,这个值才会增加。

这是一个运行中等读写负载的 MySQL 服务器的示例:

mysql> `SHOW` `GLOBAL` `STATUS` `LIKE` `'Innodb_row_lock%'``;`
+-------------------------------+--------+
| Variable_name                 | Value  |
+-------------------------------+--------+
| Innodb_row_lock_current_waits | 0      |
| Innodb_row_lock_time          | 367465 |
| Innodb_row_lock_time_avg      | 165    |
| Innodb_row_lock_time_max      | 51056  |
| Innodb_row_lock_waits         | 2226   |
+-------------------------------+--------+
5 rows in set (0.00 sec)

有 2,226 个单独的事务等待锁,获取所有这些锁共耗时 367,465 毫秒,平均锁获取持续时间为 165 毫秒,最长持续时间略超过 51 秒。当前没有会话在等待锁。单独这些信息并不能告诉我们太多:既不多也不少。但是,我们知道与此同时,这个 MySQL 服务器执行了超过 100,000 个事务。由此产生的锁定度量值对并发级别来说是合理的。

锁定问题是数据库管理员和应用程序开发人员头疼的常见来源。尽管这些指标,如我们迄今讨论的一切,都是跨每个正在运行的会话进行的汇总,但与正常值的偏差可能有助于您找出其中的一些问题。要查找和调查单个锁定情况,您可以使用 InnoDB 状态报告;有关更多详细信息,请参阅 “InnoDB 引擎状态报告”。

慢查询日志

在 “查询类型和质量” 中,我们展示了如何查找 MySQL 中未经优化的查询的显著标志。然而,这还不足以开始优化这些查询。我们需要具体的示例。有几种方法可以做到这一点,但可能最强大的方法是使用慢查询日志功能。慢查询日志正是其字面意思:MySQL 将有关查询的信息放入一个特殊的文本日志中。可以控制这些查询的慢速程度,并且可以记录每个查询。

要启用慢查询日志,您必须将slow_query_log系统变量的设置从默认的OFF更改为ON。默认情况下,当启用慢查询日志时,MySQL 将记录执行时间超过 10 秒的查询。这可以通过更改long_query_time变量来配置,其最小值为 0,意味着服务器执行的每个查询都将被记录。日志位置由slow_query_log_file变量控制,默认值为*hostname*``-slow.log。当慢查询日志的路径是相对路径时,即不以 Linux 上的/或 Windows 上的C:\开头,那么该文件将位于 MySQL 数据目录中。

您还可以告诉 MySQL 记录不使用索引的查询,而不管它们执行的时间。为此,log_queries_not_using_indexes变量必须设置为ON。默认情况下,DDL 和管理语句不会被记录,但可以通过将log_slow_admin_statements设置为ON来更改此行为。

MariaDB 和 Percona Server 通过添加过滤功能、速率限制和增强的详细性扩展了慢查询日志的功能。如果您正在使用这些产品,值得阅读有关此主题的文档,以查看是否可以利用增强的慢查询日志。

这是慢查询日志中一条记录的示例,显示一个SELECT语句的执行时间超过了配置的long_query_time值 1 秒:

# Time: 2021-05-29T17:21:12.433992Z
# User@Host: root[root] @ localhost []  Id:    11
# Query_time: 1.877495  Lock_time: 0.000823 Rows_sent: 9  Rows_examined: 3473725
use employees;
SET timestamp=1622308870;
SELECT
        dpt.dept_name
      , emp.emp_no
      , emp.first_name
      , emp.last_name
      , sal.salary
FROM
        departments dpt
    JOIN dept_emp ON dpt.dept_no = dept_emp.dept_no
    JOIN employees emp ON dept_emp.emp_no = emp.emp_no
    JOIN salaries sal ON emp.emp_no = sal.emp_no
    JOIN (SELECT dept_emp.dept_no, MAX(sal.salary) maxsal
        FROM dept_emp JOIN salaries sal
            ON dept_emp.emp_no = sal.emp_no
        WHERE
                sal.from_date < now()
            AND sal.to_date > now()
        GROUP BY dept_no
    ) largest_sal_by_dept ON dept_emp.dept_no = largest_sal_by_dept.dept_no
        AND sal.salary = largest_sal_by_dept.maxsal;

通过分析这个输出,您可以立即开始对这个查询做出结论。这比查看整个服务器的指标要详细得多。例如,我们可以看到这个查询在 UTC 时间 17:21:12 由用户root@localhostemployees数据库中执行,花费了 1.88 秒,并且生成了 9 行结果,但需要扫描 3,473,725 行才能得出这个结果。这些信息本身就可以告诉您很多关于查询的信息,特别是在您对 MySQL 有了更多经验之后。现在您也可以得到完整的查询文本,您可以将其转换为执行计划信息,以了解 MySQL 如何执行此查询。您可以在“EXPLAIN 语句”中找到关于该过程的更多详细信息。

如果将long_query_time设置得很低,慢查询日志可能会变得很大。有时这可能是合理的,但如果查询数量很大,则阅读生成的日志几乎是不可能的。有一个名为mysqldumpslow的工具可以解决这个问题。它将慢查询日志文件的路径作为参数,并会总结和排序查询(默认按时间排序)。在以下示例中,命令被运行以显示按返回的行数排序的前两个查询:

$ mysqldumpslow -s r -t 2 /var/lib/mysql/mysqldb1-slow.log
Reading mysql slow query log from /var/lib/mysql/mysqldb1-slow.log
Count: 2805  Time=0.00s (0s)  Lock=0.00s (0s)  Rows=100.0 (280500), sbuser[
sbuser]@localhost
  SELECT c FROM sbtest1 WHERE id BETWEEN N AND N

Count: 2760  Time=0.00s (0s)  Lock=0.00s (0s)  Rows=100.0 (276000), sbuser[
sbuser]@localhost
  SELECT c FROM sbtest1 WHERE id BETWEEN N AND N ORDER BY c

您可以看到仅这两个查询就在慢查询日志中记录了 5,565 次。试图在没有帮助的情况下获取这些信息的想象力!另一个可以帮助总结慢查询日志中信息的工具是 Percona Toolkit 中的pt-query-digest。这个工具更为先进,比mysqldumpslow更难使用,但提供了大量信息并具有许多功能。它生成的报告从一个总结开始:

$ pt-query-digest /var/lib/mysql/mysqldb1-slow.log
# 7.4s user time, 60ms system time, 41.96M rss, 258.35M vsz
# Current date: Sat May 29 22:36:47 2021
# Hostname: mysqldb1
# Files: /var/lib/mysql/mysqldb1-slow.log
# Overall: 109.42k total, 15 unique, 7.29k QPS, 1.18x concurrency ________
# Time range: 2021-05-29T19:28:57 to 2021-05-29T19:29:12
# Attribute          total     min     max     avg     95%  stddev  median
# ============     ======= ======= ======= ======= ======= ======= =======
# Exec time            18s     1us    10ms   161us     1ms   462us    36us
# Lock time             2s       0     7ms    16us    14us   106us     5us
# Rows sent          1.62M       0     100   15.54   97.36   34.53    0.99
# Rows examine       3.20M       0     200   30.63  192.76   61.50    0.99
# Query size         5.84M       5     245   55.93  151.03   50.37   36.69

# Profile
# Rank Query ID                            Response time Calls R/Call V/M
# ==== =================================== ============= ===== ====== ====
#    1 0xFFFCA4D67EA0A78…  11.1853 63.1%  5467 0.0020  0.00 COMMIT
#    2 0xB2249CB854EE3C2…   1.5985  9.0%  5467 0.0003  0.00 UPDATE sbtest?
#    3 0xE81D0B3DB4FB31B…   1.5600  8.8% 54670 0.0000  0.00 SELECT sbtest?
#    4 0xF0C5AE75A52E847…   0.8853  5.0%  5467 0.0002  0.00 SELECT sbtest?
#    5 0x9934EF6887CC7A6…   0.5959  3.4%  5467 0.0001  0.00 SELECT sbtest?
#    6 0xA729E7889F57828…   0.4748  2.7%  5467 0.0001  0.00 SELECT sbtest?
#    7 0xFF7C69F51BBD3A7…   0.4511  2.5%  5467 0.0001  0.00 SELECT sbtest?
#    8 0x6C545CFB5536512…   0.3092  1.7%  5467 0.0001  0.00 INSERT sbtest?
# MISC 0xMISC               0.6629  3.7% 16482 0.0000   0.0 <7 ITEMS>

然后,每个查询被总结如下:

# Query 2: 546.70 QPS, 0.16x concurrency, ID 0xB2249CB854E… at byte 1436377
# Scores: V/M = 0.00
# Time range: 2021-05-29T19:29:02 to 2021-05-29T19:29:12
# Attribute    pct   total     min     max     avg     95%  stddev  median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Count          4    5467
# Exec time      9      2s    54us     7ms   292us     1ms   446us    93us
# Lock time     61      1s     7us     7ms   203us     1ms   437us     9us
# Rows sent      0       0       0       0       0       0       0       0
# Rows examine   0   5.34k       1       1       1       1       0       1
# Query size     3 213.55k      40      40      40      40       0      40
# String:
# Databases    sysbench
# Hosts        localhost
# Users        sbuser
# Query_time distribution
#   1us
#  10us  ################################################################
# 100us  ###############################################
#   1ms  ############
#  10ms
# 100ms
#    1s
#  10s+
# Tables
#    SHOW TABLE STATUS FROM `sysbench` LIKE 'sbtest2'\G
#    SHOW CREATE TABLE `sysbench`.`sbtest2`\G
UPDATE sbtest2 SET k=k+1 WHERE id=497658\G
# Converted for EXPLAIN
# EXPLAIN /*!50100 PARTITIONS*/
select  k=k+1 from sbtest2 where  id=497658\G

这是一个密集格式中的大量有价值的信息。此输出的一个显著特征之一是查询持续时间分布的可视化,这使您可以快速查看查询是否存在依赖参数的性能问题。解释pt-query-digest的每个功能将需要另一章,这是一个高级工具,所以我们留给您在学习 MySQL 完成后尝试。

慢查询日志是一个强大的工具,可以让您详细查看 MySQL 服务器执行的查询。我们建议如下使用慢查询日志:

  • long_query_time设置为一个足以覆盖系统中正常运行的大多数查询的值,但足够小以捕获异常情况。例如,在 OLTP 系统中,大多数查询预计在毫秒内完成,0.5可能是合理的值,仅捕获相对较慢的查询。另一方面,如果系统中的查询需要几分钟才能完成,那么应相应地设置long_query_time

  • 记录到慢查询日志会带来一定的性能成本,您应避免记录比需要的更多查询。如果启用了慢查询日志,请确保在日志过于喧闹时调整long_query_time设置。

  • 有时您可能想进行“查询审计”,在此期间(几分钟内)将long_query_time临时设置为0以捕获每个查询。这是获取数据库负载快照的好方法。这些快照可以保存并稍后进行比较。但我们强烈建议不要将long_query_time设置得太低。

  • 如果已经设置了慢查询日志,我们建议定期运行mysqldumpslowpt-query-digest或类似工具,以查看是否出现新查询或现有查询表现比平常更差。

InnoDB 引擎状态报告

InnoDB 存储引擎具有内置报告,可以深入了解引擎的当前状态的技术细节。仅通过阅读此报告,就可以对 InnoDB 的负载和性能做出很多评价。阅读 InnoDB 状态报告是一个高级主题,需要比我们的书中能传达的更多指导,还需要大量实践。但我们仍然认为您应该知道此报告的存在,并为您提供一些查找内容的提示。

要查看报告,只需运行一个命令。我们建议使用垂直结果显示:

mysql> `SHOW` `ENGINE` `INNODB` `STATUS``\``G`
*************************** 1\. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2021-05-31 12:21:05 139908633830976 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 35 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 121 srv_active, 0 srv_shutdown, 69961 srv_idle
...
--------------
ROW OPERATIONS
--------------
0 queries inside InnoDB, 0 queries in queue
2 read views open inside InnoDB
Process ID=55171, Main thread ID=139908126139968 , state=sleeping
Number of rows inserted 2946375, updated 87845, deleted 46063, read 88688110
572.50 inserts/s, 1145.00 updates/s, 572.50 deletes/s, 236429.64 reads/s
Number of system rows inserted 109, updated 367, deleted 60, read 13218
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 0.00 reads/s
----------------------------
END OF INNODB MONITOR OUTPUT
============================

输出在此处被截断,以部分展示。它以各个部分的方式呈现。起初可能显得令人畏惧,但随着时间的推移,您将开始欣赏这些细节。现在,我们将逐步介绍几个部分,我们相信这些信息将使任何经验水平的操作者受益:

事务

此部分提供有关每个会话的事务信息,包括持续时间、当前查询、持有的锁数以及锁等待信息。您还可以在此处找到一些关于事务可见性的数据,但这很少需要。通常,您希望查看事务部分,以查看 InnoDB 中活动事务的当前状态。此部分的示例记录如下:

---TRANSACTION 252288, ACTIVE (PREPARED) 0 sec
5 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 4
MySQL thread id 82, OS thread handle 139908634125888,...
...query id 925076 localhost sbuser waiting for handler commit
COMMIT
Trx read view will not see trx with id >= 252287, sees < 252285

这告诉我们,事务当前正在等待 COMMIT 完成,它持有三个行锁,并且非常快,可能在一秒钟内就能完成。有时,在这里你会看到长时间的事务:你应该尽量避免这种情况。InnoDB 无法很好地处理长时间的事务,即使是空闲事务保持打开状态太长时间也会对性能造成影响。

如果有事务在等待获取锁,本节还会显示当前的锁定行为信息。这里有一个例子:

---TRANSACTION 414311, ACTIVE 4 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 84, OS thread handle 139908634125888,...
...query id 2545483 localhost sbuser updating
UPDATE sbtest1 SET k=k+1 WHERE id=347110
Trx read view will not see trx with id >= 414310, sees < 413897
------- TRX HAS BEEN WAITING 4 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 333 page no 4787 n bits 144 index PRIMARY of...
...table `sysbench`.`sbtest1` trx id 414311 lock_mode X locks...
...rec but not gap waiting
Record lock, heap no 33
------------------

不幸的是,状态报告并没有直接指出锁的持有者,因此你需要单独查找阻塞事务的信息。关于这方面的一些信息可以在 第六章 中找到。通常,如果你看到一个事务在活动,而其他事务在等待,这是一个很好的指示,表明是这个活动事务在持有锁。

文件 I/O

本节包含当前 I/O 操作的信息,以及随时间的汇总摘要。我们在 “InnoDB I/O 和事务度量” 中详细讨论了这一点,但这是另一种检查 InnoDB 是否有未决数据和日志操作的方式。

缓冲池和内存

在这一部分,InnoDB 打印关于其缓冲池和内存使用情况的信息。如果配置了多个缓冲池实例,那么这一部分将显示总量和每个实例的详细信息。这里有很多信息,包括缓冲池大小和缓冲池的内部状态:

Total large memory allocated 274071552
Dictionary memory allocated 1377188
Buffer pool size   16384
Free buffers       1027
Database pages     14657
Old database pages 5390
Modified db pages  4168

这些也作为 Innodb_buffer_pool_% 变量公开。

信号量

本节包括关于内部 InnoDB 信号量或同步原语的信息。简单来说,信号量 是允许多个线程在不相互干扰的情况下操作的特殊内部内存结构。除非系统存在信号量争用,否则在这一部分很少会看到有价值的内容。通常情况下,这种情况发生在 InnoDB 承受极端负载时,因此每个操作都需要更长的时间,状态输出中有更多机会看到活跃的信号量等待。

调查方法

有这么多可用的需要监控、检查和理解的度量标准,可能会让你头晕。你可能注意到,我们并没有定义一个单一的度量标准,从好到坏都有明确的范围。锁定是否不好?可能是,但这也是预期和正常的。关于 MySQL 的几乎每个方面都可以这样说,除了服务器的可用性。

这个问题并不局限于 MySQL 服务器监控,事实上,任何复杂系统都存在类似的情况。有大量的度量标准、复杂的依赖关系,几乎没有严格可定义的规则来判断某些事物是好是坏。为了解决这个问题,我们需要一些方法,一些方法论,可以应用于丰富的数据,快速轻松地得出关于当前系统性能的结论。

幸运的是,这样的方法论已经存在。在本节中,我们将简要描述其中的两种,并提出如何将它们应用于监控 MySQL 和操作系统指标的想法。

USE 方法

利用率、饱和度和错误(USE)方法由 Brendan Gregg 推广,是一种通用的方法论,可以应用于任何系统。虽然更适合具有明确定义性能特征的资源如 CPU 或磁盘,但也可以应用于 MySQL 的某些部分。

使用 USE 方法最好通过为系统的每个独立部分创建检查表来实现。首先,我们需要定义用于衡量利用率、饱和度和错误的指标。Linux 的一些示例检查表可以在 USE 首页找到:Gregg 的网站

让我们看看磁盘 I/O 示例的三个不同类别:

利用率

对于磁盘子系统,正如我们在“磁盘”中所述,我们可能会查看任何以下指标:

  • 总体存储带宽利用率

  • 每个进程的存储带宽利用率

  • 当前 IOPS(已知设备的 IOPS 上限)

饱和度

我们在讨论iostat输出和磁盘指标时提到了饱和度。这是资源无法处理的工作量的度量,并且通常是排队的。对于磁盘,这表现为 I/O 请求队列大小(iostat输出中的avgqu-sz)。在大多数系统上,值大于 1 意味着磁盘已饱和,因此某些请求将排队,并浪费时间无所作为。

错误

对于磁盘设备,这可能意味着由于硬件退化导致的 I/O 错误。

使用 USE 方法的问题在于,将其应用于整个复杂系统是困难的。以 MySQL 为例:我们可以使用哪些指标来衡量总体利用率、饱和度和错误?可以尝试将 USE 方法应用于 MySQL 整体,但更适合用于系统的某些独立部分。

让我们来看看一些可能的 MySQL 应用:

用于客户端连接的 USE 方法

用于 USE 方法的一个很好的例子是理解我们在“基本监控配方”中讨论的客户端连接指标。

利用率指标包括Threads_connectedThreads_running

饱和度可以根据Threads_running任意定义。例如,在读密集且不生成大量 I/O 的 OLTP 系统中,Threads_running的饱和点可能接近可用 CPU 核心数。在大多数 I/O 绑定系统中,起始点可能是两倍的可用 CPU 核心数,但最好找出同时运行的线程数量,开始饱和存储子系统。

错误通过 Aborted_clientsAborted_connects 指标来衡量。一旦 Threads_connected 状态变量的值等于 max_connections 系统变量的值,将拒绝创建新连接的请求,并且客户端将收到错误。连接也可能由于其他原因失败,并且现有客户端可能未等待 MySQL 响应就不干净地终止其连接。

事务和锁定的 USE 方法

另一个应用 USE 方法的例子是查看与系统处理的查询数量相关的 InnoDB 锁定相关指标。

利用率可以使用我们之前定义的合成 QPS 指标来衡量。

饱和度可能是一定数量的锁等待正在发生。请记住,根据 USE 定义,饱和度是无法执行的工作的衡量,并且必须在队列中等待。这里没有队列,但事务正在等待获取锁。或者,可以将常规锁等待量乘以两倍来构建任意阈值,或者更好地,可以执行一些实验,找出与 QPS 相对应的锁等待数量,导致发生锁等待超时。

错误可以通过会话超时等待锁或终止以解决死锁情况的次数来衡量。与前面提到的指标不同,这两者不是作为全局状态变量公开的,而是可以在 information_schema.innodb_metrics 表中找到,其中 lock_deadlocks 表示死锁注册数量,lock_timeouts 表示锁等待超时次数。

仅通过监控指标确定错误率可能很困难,因此通常仅使用 USE 方法中的 US 部分。正如您所见,此方法允许我们从预定义的视角查看系统。在发生事件时,可以审查现有的检查表,而不是分析每个可能的指标,从而节省时间和精力。

RED 方法

Rate, Errors, and Duration (RED) 方法是为了解决 USE 方法的缺点而创建的。该方法类似,但更容易应用于复杂的系统和服务。

将 RED 方法逻辑应用于 MySQL 是通过查看查询性能来完成的:

速率

数据库的每秒查询率

错误

错误和失败查询的数量或速率

持续时间

平均查询延迟

RED 在本书和一般 MySQL 学习的背景下的一个问题是,应用此方法需要监控无法仅通过读取状态变量获得的数据。尽管并非每个现有的 MySQL 监控解决方案都能提供所需的数据,您可以在 Peter Zaitsev 的博客文章“RED 方法用于 MySQL 性能分析”中看到其操作方法的示例。将 RED 应用于 MySQL 或任何其他数据库系统的一种方法是从应用程序的角度而不是数据库的角度查看度量指标。多种监控解决方案允许您手动或自动地为应用程序提供数据,例如查询数量、失败率和查询延迟。这正是 RED 所需的内容!

您可以同时使用 RED 和 USE。在解释 RED 的文章中,RED 方法:如何为您的服务进行仪表化,其作者汤姆·威尔基提到:“这实际上只是同一系统的两种不同视角。”

应用 RED、USE 或任何其他方法的一个可能意想不到的好处是,在事故发生之前就这样做。因此,您被迫理解您要监控和测量的内容,以及这与您的系统及其用户实际关心的内容的关系。

MySQL 监控工具

本章中,我们一直在讨论度量和监控方法,但我们还没有提到可以将这些度量转化为仪表板或警报的任何实际工具。换句话说,我们还没有讨论实际的监控工具和系统。这其中的第一个原因很简单:我们认为在开始阶段,了解什么需要监控以及为什么如何更为重要。如果我们集中讨论如何,我们可能会用整章的篇幅来讲解各种监控工具的特点和差异。第二个原因是,MySQL 和操作系统的度量指标并不经常变化,但如果你在 2025 年阅读本书,我们选择的监控工具可能已经显得过时。

尽管如此,作为一个起点,我们已经整理了一个可以用于监控 MySQL 可用性和性能的知名开源监控工具列表。我们不会深入讨论其设置或配置的具体细节,也不会进行比较。我们也无法列出所有可能的监控解决方案:简短的研究显示,几乎所有被称为“监控工具”或“监控系统”的东西都可以以某种方式监控 MySQL。我们也不涵盖非开源和非免费的监控解决方案,Oracle Enterprise Monitor 是唯一的例外。总体而言,我们对这类系统并不持有任何成见,它们中的大多数都非常出色。但其中大多数都有出色的文档和支持可用,因此您应该能够迅速熟悉它们。

这里将提到以下监控系统:

  • Prometheus

  • InfluxDB 和 TICK 堆栈

  • Zabbix

  • Nagios 核心

  • Percona 监控与管理

  • Oracle 企业监控

我们将从 Prometheus 和 InfluxDB 及其 TICK 栈开始。这两个系统都是监控的现代化解决方案,非常适合监控微服务和广泛的云部署,但也广泛用作通用监控解决方案:

Prometheus

诞生于谷歌内部监控系统 Borgmon 的 Prometheus 是一个极其流行的通用监控和警报系统。其核心是一个时间序列数据库和基于拉取模型的数据收集引擎。这意味着是 Prometheus 服务器积极从其监控目标获取数据。

实际从 Prometheus 目标收集数据的是称为 导出器 的特殊程序。导出器专为特定用途构建:有专用的 MySQL 导出器、PostgreSQL 导出器、基本的操作系统指标/节点导出器等。这些程序的功能是从它们被设计监控的系统收集指标,并以适合 Prometheus 服务器消费的格式呈现这些指标。

使用 Prometheus 进行 MySQL 监控是通过运行 mysqld_exporter 程序 完成的。像 Prometheus 生态系统的大多数部分一样,它是用 Go 编写的,并适用于 Linux、Windows 和许多其他操作系统,使其成为异构环境的良好选择。

MySQL 导出器收集了本章涵盖的所有指标(以及更多!),由于它积极尝试从 MySQL 获取信息,因此它还可以报告 MySQL 服务器的可用性。除了标准指标外,还可以提供自定义查询,导出器将执行这些查询,并将结果转换为额外的指标。

Prometheus 提供的仅是非常基本的可视化功能,因此通常会添加 Grafana 分析与数据可视化 Web 应用 到设置中。

InfluxDB 和 TICK 栈

基于 InfluxDB 时间序列数据库,TICK,代表 Telegraf、InfluxDB、Chronograf 和 Kapacitor,是一个完整的时间序列和监控平台。与 Prometheus 相比,Telegraf 取代了导出器;它是一个统一的程序,能够监控多种目标。与导出器不同,Telegraf 将数据主动推送到 InfluxDB 而不是由服务器拉取数据。Chronograf 是管理和数据界面。Kapacitor 是数据处理和警报引擎。

在 MySQL 需要安装专用导出器的情况下,Telegraf 使用插件进行扩展。MySQL 插件 是标准捆绑包的一部分,提供了 MySQL 数据库指标的详细概述。不幸的是,它不能运行任意查询,因此扩展性有限。作为一种变通方法,可以使用 exec 插件。Telegraf 还是一个多平台程序,支持 Windows 等其他操作系统。

TICK 堆栈经常部分使用,与 Grafana 一起添加到 InfluxDB 和 Telegraf。

Prometheus 和 TICK 的一个共同特点是它们是一组构建模块,允许您构建自己的监控解决方案。它们都没有提供现成的仪表板、警报等解决方案。它们非常强大,但可能需要一些时间适应。除此之外,它们都非常注重自动化和基础设施即代码。特别是 Prometheus,但 TICK 同样如此,提供了一个简约的 GUI,最初并不是为数据探索和可视化而设计。它的监控方式是通过警报来响应指标值的变化,而不是通过视觉检查各种指标。将 Grafana 加入其中,特别是使用自制或社区的 MySQL 仪表板,可以实现可视化检查。然而,大部分配置和设置并不在 GUI 中完成。

这两个系统在 2010 年中后期迎来了流行度激增,随着从少数大型服务器转向运行大量小服务器的变化。这种转变要求调整监控方法,这些系统几乎成为了许多公司的事实标准监控解决方案。

接下来我们来看几种更“老派”的监控解决方案:

Zabbix

一款完全免费且开源的监控系统,于 2001 年首次发布,Zabbix经过验证且功能强大。它支持广泛的监控目标、高级自动发现和警报功能。

使用 Zabbix 可以通过插件或官方MySQL 模板进行 MySQL 监控。Zabbix 收集的标准 MySQL 指标覆盖范围相当广泛,包括我们定义的每个配置。然而,mysqld_exporter和 Telegraf 提供了更多数据。标准 MySQL 指标 Zabbix 收集足以设置基本的 MySQL 监控,但如需深入了解,则需要自定义或使用一些社区模板和插件。

Zabbix 代理是跨平台的,因此您可以监控几乎任何操作系统上运行的 MySQL。

虽然 Zabbix 提供了相当强大的警报功能,但其可视化能力可能略显陈旧。可以基于 MySQL 数据设置自定义仪表板,并且可以将 Zabbix 用作 Grafana 的数据源。

Zabbix 可以通过其 GUI 进行完全配置。商业版提供了各种支持和咨询服务级别。

Nagios Core

与 Zabbix 类似,Nagios是一款老牌的监控系统,首次发布于 2002 年。与迄今为止我们看到的其他系统不同,Nagios 是一个“开放核心”系统。Nagios Core 分发版是免费且开源的,但也有一个商业化的 Nagios 系统。

使用插件设置 MySQL 监控。它们应提供足够的数据来设置类似于官方 Zabbix 模板的基本监控。如有必要,可以扩展收集的指标。

告警、可视化和配置与 Zabbix 相似。Nagios 的一个显著特点是在其流行高峰期间被多次分叉。一些最流行的 Nagios 分支包括 Icinga 和 Shinken。Check_MK 最初也是 Nagios 的扩展,最终发展成为独立的商业产品。

Nagios 及其分支以及 Zabbix 都可以并且成功地被许多公司用来监控 MySQL。尽管它们在架构和数据表示上可能显得过时,但它们可以完成任务。它们最大的问题是,它们收集的标准数据可能与替代方案相比感觉有限,并且您将需要使用社区插件和扩展。Percona 曾经维护一组用于 Nagios 和 Zabbix 的监控插件,但已经弃用,并且现在集中于自己的监控产品 Percona Monitoring and Management(PMM),我们稍后将讨论。

到目前为止,我们所涵盖的所有系统有一个共同点:它们都是通用的监控解决方案,而不是专为数据库监控量身定制的。这是它们的优势,也是它们的弱点。当涉及监控和调查深层数据库内部时,您经常会被迫手动扩展这些系统的功能。例如,其中没有一个系统具有存储个别查询执行统计信息的功能。从技术上讲,可以添加此功能,但可能会很麻烦和有问题。

我们将通过查看两个面向数据库的监控解决方案来完成本节,分别是来自 Oracle 的 MySQL 企业监控器和 Percona Monitoring and Management。正如您将看到的那样,它们在提供的功能上非常相似,并且都比非专业化的监控系统有了很大的改进:

MySQL 企业监控器

作为 MySQL 企业版的一部分,企业监控器是一个完整的 MySQL 数据库监控和管理平台。

在监控方面,MySQL 企业监控器通过添加关于 MySQL 内存利用率、每个文件 I/O 详情以及基于 InnoDB 状态变量的各种仪表板,扩展了监控系统收集的常规指标。数据是直接从 MySQL 获取,不需要任何代理程序。从理论上讲,任何其他监控系统也可以收集和可视化相同的数据,但这里的数据被紧密地整合在精心设计的仪表板和类别中。

企业监控器包括事件子系统,这是一组预定义的警报。除了特定于数据库的功能外,企业监控器还包括用于常规和多源复制、组复制和 NDB 集群的复制拓扑概述的监控。另一个特性是监控使用 MySQL 企业备份进行的备份执行状态。

我们提到通常通用监控系统中缺少单个查询执行统计和查询历史记录。MySQL Enterprise Monitor 包括一个查询分析器,提供有关随时间执行的查询历史的洞察,以及收集的有关查询的统计信息。可以查看诸如平均读取和返回行数以及持续时间分布的信息,甚至可以查看查询的执行计划。

Enterprise Monitor 是一个优秀的数据库监控系统。它最大的缺点实际上是它仅在 MySQL 的企业版中可用。不幸的是,大多数 MySQL 安装无法从 Enterprise Monitor 及其提供的对数据库和操作系统指标的深入洞察中受益。它也不适合监控除 MySQL 之外的任何内容,包括其执行的查询和其运行的操作系统,而且 MySQL 监控的范围仅限于 Oracle 的产品。

MySQL Enterprise Edition 提供了 30 天的试用期,其中包括 Enterprise Monitor,Oracle 还维护有系统的视觉演示列表

Percona 监控与管理

Percona 的监控解决方案,PMM,在功能上类似于 Oracle 的 Enterprise Monitor,但完全免费且开源。旨在成为“单一视图”,它试图提供对 MySQL 和操作系统性能的深入洞察,并可用于监控 MongoDB 和 PostgreSQL 数据库。

PMM 基于现有的开源组件构建,如已审查的 Prometheus 及其导出器和 Grafana。Percona 维护了它使用的数据库导出器的分支,包括 MySQL 的导出器,并添加了在原始版本中缺少的功能和指标。除此之外,PMM 隐藏了通常与部署和配置这些工具相关的复杂性,而是提供了自己的捆绑软件包和配置界面。

像 Enterprise Monitor 一样,PMM 提供了一系列仪表板,可视化 MySQL 和 InnoDB 运作的几乎每个方面,并提供了关于底层操作系统状态的大量细节。这已扩展到包括像 PXC/Galera(见第十三章)和 ProxySQL(见第十五章)等技术。由于 PMM 使用 Prometheus 和导出器,可以通过添加外部导出器扩展监控的数据库范围。除此之外,PMM 还支持像 RDS 和 CloudSQL 这样的 DBaaS 系统。

PMM 配备了一个名为查询分析(QAN)的自定义应用程序,这是一个查询监控和指标系统。与企业监视器的查询分析器类似,QAN 显示了在给定系统中执行的查询的整体历史,以及有关单个查询的信息。其中包括查询随时间执行次数的历史记录,读取和发送的行数,锁定以及创建的临时表等信息。QAN 允许您查看查询计划和涉及表的结构。

到目前为止,PMM 的管理部分仅存在于其名称中,因为在撰写本文时,它仅仅是一个监控系统。PMM 支持通过 Prometheus 的标准 AlertManager 或通过内部模板进行警报。

PMM 的一个显著问题是,它默认仅支持运行在 Linux 上的目标。由于 Prometheus 导出器是跨平台的,您可以将 Windows(或其他操作系统)目标添加到 PMM 中,但您将无法利用该工具的某些好处,例如简化的导出器配置和客户端软件的捆绑安装。

本书的两位作者目前都在 Percona 工作,因此您可能会认为我们对 PMM 的描述是广告。然而,我们尝试公正地概述了几个监控系统,并不断言 PMM 是完美的。如果您的公司已经使用 MySQL 企业版,则应该首先了解 MySQL 企业监视器提供了什么。

在我们结束这一部分之前,我们想指出,在许多情况下,您使用的实际监控系统并不太重要。我们提到的每个系统都提供 MySQL 可用性监控,以及对内部指标的某种程度的洞察力,足以选择我们之前提供的配方。特别是在您学习 MySQL 的使用方式,并且可能开始在生产中操作其安装时,您应该尝试利用现有的监控基础设施。仓促地改变一切去追求最好的往往会导致不尽如人意的结果。随着您变得更有经验,您将看到现有工具中缺少的更多数据,并能够就选择新工具做出明智的决策。

事件/诊断和手动数据收集

有时,您可能没有为数据库设置监控系统,或者您可能不信任它包含您可能需要调查的所有信息。或者您可能正在运行一个 DBaaS 实例,并希望获取比您的云提供商给出的更多数据。在这种情况下,手动数据收集可能是短期内快速获取系统中一些数据的可行选项。我们将向您展示几种工具,您可以使用这些工具快速地从任何 MySQL 实例中收集大量诊断信息。

接下来的几节是简短而有用的配方,您可以带走并在日常使用 MySQL 数据库工作中使用。

定期收集系统状态变量值

在“状态变量”和“基本监控配方”中,我们详细讨论了如何查看不同状态变量的值随时间的变化。前一节提到的每个监控工具都会累积数据,然后用于绘图和警报。如果您想查看原始数据或者只是按比监控系统使用的间隔取样,可以手动执行相同的状态变量取样。

您可以编写一个简单的脚本,在循环中运行 MySQL 监视器,但更好的方法是使用内置的mysqladmin实用程序。该程序可以对运行中的 MySQL 服务器执行各种管理操作,尽管我们应该注意,这些操作也可以通过常规的mysql完成。然而,mysqladmin可以轻松地取样全局状态变量,这正是我们在这里要使用它的方式。

mysqladmin包含两种状态输出:常规和扩展。常规输出信息较少:

$ mysqladmin status
Uptime: 176190  Threads: 5  Questions: 5287160 ...
... Slow queries: 5114814  Opens: 761  Flush tables: 3 ...
... Open tables: 671  Queries per second avg: 30.008

扩展输出在此时对您来说已经很熟悉了。它与SHOW GLOBAL STATUS的输出相同:

$ mysqladmin extended-status
+-----------------------------------------------+---------------------+
| Variable_name                                 | Value               |
+-----------------------------------------------+---------------------+
| Aborted_clients                               | 2                   |
| Aborted_connects                              | 30                  |
| ...                                                                 |
| Uptime                                        | 176307              |
| Uptime_since_flush_status                     | 32141               |
| validate_password.dictionary_file_last_parsed | 2021-05-29 21:50:38 |
| validate_password.dictionary_file_words_count | 0                   |
+-----------------------------------------------+---------------------+

方便的是,mysqladmin能够在给定间隔内重复运行特定命令的功能。例如,以下命令将导致mysqladmin每秒打印一次状态变量值,持续一分钟(extextended-status的缩写):

$ mysqladmin -i1 -c60 ext

通过将其输出重定向到文件,您可以获得数据库指标变化的一分钟样本。与正规的监控系统相比,文本文件不太易于处理,但通常是在特殊情况下执行的操作。长期以来,像这样使用mysqladmin收集 MySQL 信息是标准做法,因此有一个称为pt-mext的工具,可以将普通的SHOW GLOBAL STATUS输出转换为更适合人类消费的格式。不幸的是,该工具仅适用于 Linux。这里是它输出的一个示例:

$ pt-mext -r -- cat mysqladmin.output | grep Bytes_sent
Bytes_sent 10836285314 15120 15120 31080 15120 15120 31080 15120 15120

初始的大数字是第一次取样时的状态变量值,之后的数值表示相对于初始数值的变化。如果数值减少,则显示负数。

使用pt-stalk收集 MySQL 和操作系统指标

pt-stalk是 Percona Toolkit 的一部分,通常与 MySQL 一起运行,并用于持续检查指定条件。一旦满足条件,例如Threads_running的值大于 15,pt-stalk会触发数据收集例程,收集有关 MySQL 和操作系统的详尽信息。但是,可以利用数据收集部分,而不实际跟踪 MySQL 服务器。虽然这不是使用pt-stalk的正确方式,但这是快速查看未知服务器或尝试在表现不佳的服务器上收集尽可能多信息的有用方法。

pt-stalk,像 Percona Toolkit 中的其他工具一样,仅适用于 Linux,即使目标 MySQL 服务器可以在任何操作系统上运行。要实现这一点的基本调用pt-stalk是简单的:

$ sudo pt-stalk --no-stalk --iterations=2 --sleep=30 \
--dest="/tmp/collected_data" \
-- --user=root --password=*<root password>*;

实用程序将运行两轮数据收集,每轮持续一分钟,它们之间将休眠 30 秒。如果你不需要操作系统信息,或者因为目标是 DBaaS 实例而无法获取它们,你可以使用--mysql-only标志:

$ sudo pt-stalk --no-stalk --iterations=2 --sleep=30 \
--mysql-only --dest="/tmp/collected_data" \
-- --user=root --password=*<root password>* \
--host=*<mysql host>* --port=*<mysql port>*;

这是单次收集生成的文件列表。我们有意省略了与操作系统相关的文件,但这些文件确实有相当多:

2021_04_15_04_33_44-innodbstatus1
2021_04_15_04_33_44-innodbstatus2
2021_04_15_04_33_44-log_error
2021_04_15_04_33_44-mutex-status1
2021_04_15_04_33_44-mutex-status2
2021_04_15_04_33_44-mysqladmin
2021_04_15_04_33_44-opentables1
2021_04_15_04_33_44-opentables2
2021_04_15_04_33_44-processlist
2021_04_15_04_33_44-slave-status
2021_04_15_04_33_44-transactions
2021_04_15_04_33_44-trigger
2021_04_15_04_33_44-variables

扩展手动数据收集

pt-stalk并非始终可用,并且并非在所有平台上都可以运行。有时,您可能还想添加(或删除)它收集的一些数据。您可以使用此前介绍的mysqladmin命令收集更多数据,并将其整合到一个简单的脚本中。本书作者在日常工作中经常使用此脚本的一个版本。

此脚本应在任何 Linux 或类 Unix 系统上运行,将持续执行,直到被终止或者发现/tmp/exit-flag文件存在为止。您可以运行touch /tmp/exit-flag来优雅地结束此脚本的执行。我们建议将其放入文件中,并通过nohup&运行,或在screentmux会话中执行。如果您对我们刚提到的术语不熟悉,它们都是确保脚本在会话断开时继续执行的方法。以下是脚本内容:

DATADEST="/tmp/collected_data";
MYSQL="mysql --host=127.0.0.1  -uroot -proot";
MYSQLADMIN="mysqladmin  --host=127.0.0.1 -uroot -proot";
[ -d "$DATADEST" ] || mkdir $DATADEST;
while true; do {
  [ -f /tmp/exit-flag ] \
    && echo "exiting loop (/tmp/exit-flag found)" \
    && break;
  d=$(date +%F_%T |tr ":" "-");
  $MYSQL -e "SHOW ENGINE INNODB STATUS\G" > $DATADEST/$d-innodbstatus &
  $MYSQL -e "SHOW ENGINE INNODB MUTEX;" > $DATADEST/$d-innodbmutex &
  $MYSQL -e "SHOW FULL PROCESSLIST\G" > $DATADEST/$d-processlist &
  $MYSQLADMIN -i1 -c15 ext > $DATADEST/$d-mysqladmin ;
} done;
$MYSQL -e "SHOW GLOBAL VARIABLES;" > $DATADEST/$d-variables;
警告

在执行这些脚本之前,不要忘记调整用户凭据。请注意,脚本生成的输出文件可能占用大量磁盘空间。在关键服务器上执行之前,请务必在安全环境中测试所有脚本。

我们还创建了一个基于 PowerShell 的 Windows 版本的相同脚本。它与之前的脚本行为完全相同,并将在发现C:\tmp\exit-flag文件时自行终止。

$mysqlbin='C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe'
$mysqladminbin='C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqladmin.exe'

$user='root'
$password='root'
$mysqlhost='127.0.0.1'

$destination='C:\tmp\collected_data'
$stopfile='C:\tmp\exit-flag'

if (-Not (Test-Path -Path '$destination')) {
  mkdir -p '$destination'
}

Start-Process -NoNewWindow $mysqlbin -ArgumentList `
  '-h$mysqlhost','-u$user','-p$password','-e 'SHOW GLOBAL VARIABLES;' `
 -RedirectStandardOutput '$destination\variables'

while(1) {
 if (Test-Path -Path '$stopfile') {
 echo 'Found exit monitor file, aborting'
 break;
 }
 $d=(Get-Date -Format 'yyyy-MM-d_HHmmss')
 Start-Process -NoNewWindow $mysqlbin -ArgumentList `
 '-h$mysqlhost','-u$user','-p$password','-e 'SHOW ENGINE INNODB STATUS\G'' `
 -RedirectStandardOutput '$destination\$d-innodbstatus'
 Start-Process -NoNewWindow $mysqlbin -ArgumentList `
 '-h$mysqlhost','-u$user','-p$password','-e 'SHOW ENGINE INNODB MUTEX;'' `
 -RedirectStandardOutput '$destination\$d-innodbmutex'
 Start-Process -NoNewWindow $mysqlbin -ArgumentList `
 '-h$mysqlhost','-u$user','-p$password','-e 'SHOW FULL PROCESSLIST\G'' `
 -RedirectStandardOutput '$destination\$d-processlist'
 & $mysqladminbin '-h$mysqlhost' -u'$user' -p'$password' `
 -i1 -c15 ext > '$destination\$d-mysqladmin';
}

您应该记住,基于脚本的数据收集不能替代适当的监控。它有其用处,我们在本节开头描述了它的用途,但它应始终作为您已有监控的补充,而不是查看 MySQL 指标的唯一方式。

通过阅读本章,你现在应该对如何进行 MySQL 监控有了很好的理解。请记住,问题、事故和停机是不可避免的。然而,通过适当的监控,你可以确保同样的问题不会再次发生,因为你可以在第一次发生后找到根本原因。当然,通过改变系统来解决 MySQL 监控揭示的问题,你也可以避免一些问题。

最后,我们留给你一个思考:完美的监控是不可达到的,但即使是相当基本的监控也比没有监控好。

第十三章:高可用性

在 IT 上下文中,术语高可用性定义了在指定时间内持续运行的状态。目标不是消除失败的风险——那是不可能的。相反,我们试图保证在故障情况下系统仍然可用,以便操作可以继续进行。我们经常根据 100%运行或永不失败的标准来衡量可用性。一个常见的可用性标准被称为五个 9,或 99.999%的可用性。两个 9 表示保证 99%的可用性,允许高达 1%的停机时间。在一年的时间内,这将转化为 3.65 天的不可用时间。

可靠性工程使用系统设计的三个原则来帮助实现高可用性:消除单点故障(SPOF)、可靠的交叉或故障转移点,以及故障检测能力(包括监控,在第十二章中讨论)。

为了实现高可用性,许多组件都需要冗余。一个简单的例子是具有两个引擎的飞机。如果一台引擎在飞行过程中故障,飞机仍然可以降落在机场。更复杂的例子是核电站,那里有大量冗余的协议和组件,以避免灾难性故障。类似地,为了实现数据库的高可用性,我们需要网络冗余、磁盘冗余、不同的电源供应、多个应用程序和数据库服务器等等。

本章将重点介绍 MySQL 数据库提供的实现高可用性的选项。

异步复制

复制使得一个 MySQL 数据库服务器(称为)的数据可以复制到一个或多个其他 MySQL 数据库服务器(称为复制端)。MySQL 复制默认是异步的。在异步复制中,源服务器将事件写入其二进制日志,复制端在准备好时请求这些事件。不能保证任何事件会到达任何复制端。这是一种松耦合的源/复制端关系,其中以下内容为真:

  • 源端不等待复制端追赶。

  • 复制端决定从二进制日志中读取多少数据及从哪个点开始读取。

  • 复制端在读取或应用更改时可以任意落后于源端。这个问题称为复制延迟,我们将探讨减少它的方法。

异步复制提供较低的写入延迟,因为写入在被复制端确认之前由源端本地确认。

MySQL 通过三个主要线程来实现其复制功能,一个在源服务器上,两个在复制端上:

二进制日志转储线程

源端创建一个线程,在复制端连接时将二进制日志内容发送到复制端。我们可以在源端的SHOW PROCESSLIST输出中识别这个线程为Binlog Dump线程。

二进制日志传输线程在读取每个发送到副本的事件时会在源的二进制日志上获取锁定。当源读取事件时,锁定会被释放,甚至在源将事件发送到副本之前。

复制 I/O 线程

当我们在副本服务器上执行START SLAVE语句时,副本创建一个 I/O 线程连接到源,并请求它发送其二进制日志中记录的更新。

复制 I/O 线程读取源的Binlog Dump线程发送的更新(见上一项)并将其复制到本地文件,组成副本的中继日志。

MySQL 在SHOW SLAVE STATUS的输出中显示这个线程的状态为Slave_IO_running

复制 SQL 线程

副本创建一个 SQL 线程来读取由复制 I/O 线程写入的中继日志,并执行其中包含的事务。

注意

正如在第一章中提到的,Oracle、Percona 和 Maria DB 正在努力删除其产品中带有负面含义的传统术语。文档已经使用了像本书中一样的副本术语,但由于需要保持向后兼容性和对旧版本的支持,不可能在一个发布版中完全更改术语。这是一个持续进行的工作。

在本章后面您将看到提高复制并行性的方法。

图 13-1 展示了 MySQL 复制架构的外观。

lm2e 1301

图 13-1. 异步复制架构流程

复制工作是因为写入二进制日志的事件从源读取并在副本上处理,如图 13-1 所示。根据事件类型,事件以不同的格式记录在二进制日志中。MySQL 复制有三种二进制日志格式:

基于行的复制(RBR)

源将事件写入二进制日志,指示如何更改单个表行。将源复制到副本的复制通过复制代表副本表行变更的事件来进行。对于 MySQL 5.7 和 8.0,默认的复制格式如下。

基于语句的复制(SBR)

源将 SQL 语句写入二进制日志。将源复制到副本的复制通过在副本上执行 SQL 语句来进行。

混合复制

您还可以配置 MySQL 以使用基于语句和基于行的混合日志记录,具体取决于哪种日志记录更适合记录更改。使用混合格式日志记录时,默认情况下 MySQL 使用基于语句的日志,但对于某些具有不确定行为的不安全语句,会切换到基于行的日志。例如,假设我们有以下语句:

mysql> `UPDATE` `customer` `SET` `last_update``=``NOW``(``)` `WHERE` `customer_id``=``1``;`

我们知道函数NOW()返回当前日期和时间。想象一下,源服务器延迟 1 秒复制该语句(可能有各种原因,比如副本位于与源不同的大陆)。当副本接收并执行该语句时,函数返回的日期和时间将有 1 秒的差异,导致源和副本之间的数据不一致。在使用混合复制格式时,每当 MySQL 解析类似这样的非确定性函数时,它将将语句转换为基于行的复制。您可以在文档中找到 MySQL 认为不安全的其他函数列表。

在源和副本上设置基本参数

有一些基本设置我们需要在源服务器和副本服务器上设置,以使复制工作。这些设置对本节中介绍的所有方法都是必需的。

在源服务器上,您必须启用二进制日志记录并定义唯一的服务器 ID。在进行这些更改后(如果尚未完成),您需要重新启动服务器,因为这些参数不是动态的。

提示

服务器 ID 无需按顺序递增或处于任何顺序中,例如源服务器 ID 小于副本服务器 ID。唯一的要求是在复制拓扑中的每个服务器中都是唯一的。

这在my.cnf文件中的效果如下:

[mysqld]
log-bin=mysql-bin
server-id=1

您还需要为每个副本分配一个唯一的服务器 ID。与源服务器一样,如果尚未执行此操作,则需要在为其分配 ID 后重新启动副本服务器。在副本服务器中启用二进制日志不是强制性的,尽管这是一个建议的实践:

[mysqld]
log-bin=mysql-replica-bin
server-id=1617565330
binlog_format = ROW
log_slave_updates

使用log_slave_updates选项告诉副本服务器,来自源服务器的命令应记录到副本自己的二进制日志中。再次强调,这不是强制性的,但作为一个良好的实践建议使用。

每个副本使用 MySQL 用户名和密码连接到源服务器,因此您还需要在源服务器上创建一个用户账号,副本可以使用该账号进行连接(有关此操作的详细信息,请参阅第 317 页的“创建和使用新用户”)。任何账户都可以用于此操作,只要已授予REPLICATION SLAVE权限。以下是在源服务器上创建用户的示例:

mysql> `CREATE` `USER` `'repl'``@``'%'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`
mysql> `GRANT` `REPLICATION` `SLAVE` `ON` `*``.``*` `TO` `'repl'``@``'%'``;`
提示

如果您正在使用像 Ansible 这样的自动化工具部署 MySQL,您可以使用以下 bash 命令创建服务器 ID:

# date '+%s'
1617565330

该命令将当前日期和时间转换为整数值,因此它是单调递增的。请注意,date命令不能保证值的唯一性,但您可能会发现它很方便,因为它提供了相对较好的唯一性水平。

在接下来的章节中,您将看到创建复制服务器的不同选项。

使用 PerconaXtraBackup 创建副本

正如我们在第十章中看到的,Percona XtraBackup 工具提供了在系统运行时执行 MySQL 数据热备份的方法。它还提供了诸如并行化、压缩和加密等高级功能。

第一步是复制当前源的副本,以便开始我们的复制。XtraBackup 工具执行源的物理备份(参见第 376 页的“物理和逻辑备份”)。我们将使用提供在“Percona XtraBackup”中的命令:

# xtrabackup --defaults-file=my.cnf -uroot -p_<password>_ \
    -H *<host>* -P 3306 --backup --parallel=4 \
    --datadir=./data/ --target-dir=./backup/

或者,您可以使用rsync、NFS 或您感觉舒适的任何其他方法。

一旦 XtraBackup 完成备份,我们将使用scp命令将文件发送到副本服务器上的备份目录。在本例中,我们将使用以下命令发送文件:

# scp -r ./backup/* *<user>@<host>*:/backup

在此时,我们已经完成了源的操作。接下来的步骤将仅在副本服务器上运行。下一步是准备我们的备份:

# xtrabackup --prepare --apply-log --target-dir=./

一切准备就绪后,我们将移动备份到数据目录:

# xtrabackup --defaults-file=/etc/my.cnf --copy-back --target-dir=./backup
注意

在继续之前,请验证您的副本服务器的server_id与源不相同。如果您按照前一节中概述的步骤操作,应该已经处理过这个问题;如果没有,请立即处理。

在副本上,文件xtrabackup_binlog_info的内容将看起来像这样:

$ cat /backup/xtrabackup_binlog_info
mysql-bin.000003    156

这些信息至关重要,因为它告诉我们从哪里开始复制。请记住,当我们进行备份时,源仍在接收操作,因此我们需要知道备份完成时 MySQL 在二进制日志文件中的位置。

有了这些信息,我们可以运行命令来启动复制。它会看起来像这样:

mysql> `CHANGE` `MASTER` `TO` `MASTER_HOST``=``'192.168.1.2'``,` `MASTER_USER``=``'repl'``,`
    -> `MASTER_PASSWORD``=``'P@ssw0rd!'``,`
    -> `MASTER_LOG_FILE``=``'mysql-bin.000003'``,` `MASTER_LOG_POS``=``156``;`
mysql> `START` `SLAVE``;`

一旦开始,您可以运行SHOW SLAVE STATUS命令来检查复制是否正在工作:

mysql> `SHOW` `SLAVE` `STATUS``\``G`

             Slave_IO_Running: Yes
            Slave_SQL_Running: Yes
                   Last_Errno: 0
                   Last_Error:
                 Skip_Counter: 0
          Exec_Master_Log_Pos: 8332
              Relay_Log_Space: 8752
              Until_Condition: None
        Seconds_Behind_Master: 0
Master_SSL_Verify_Server_Cert: No
                Last_IO_Errno: 0
                Last_IO_Error:
               Last_SQL_Errno: 0
               Last_SQL_Error:

重要的是要检查两个线程是否正在运行(Slave_IO_RunningSlave_SQL_Running),是否有任何错误(Last_Error),以及副本落后源的秒数。对于具有密集写入工作负载的大型数据库,副本可能需要一些时间来追赶。

使用克隆插件创建副本

MySQL 8.0.17 引入了克隆插件,它可以用来使一个 MySQL 服务器实例成为另一个的克隆。我们将执行CLONE语句的服务器实例称为接收者,并将接收者将从中克隆数据的源服务器实例称为捐赠者。捐赠者实例可以是本地的或远程的。克隆过程通过在捐赠者上创建 InnoDB 存储引擎中存储的数据和元数据的物理快照,并将其传输到接收者来工作。本地和远程实例执行相同的克隆操作;两种选项之间与数据相关的差异。

让我们通过一个真实的例子来详细介绍。我们将沿途向您展示一些额外的细节,例如如何监视长时间运行的CLONE命令的进度,克隆所需的权限等等。以下示例使用经典 Shell。我们将在第十六章介绍 MySQL Shell,这是 MySQL 8.0 中引入的。

选择要从中克隆的 MySQL 服务器,并以root用户身份连接到它。然后安装克隆插件,创建一个用户以从捐赠服务器传输数据,并授予该用户BACKUP_ADMIN权限:

mysql> `INSTALL` `PLUGIN` `CLONE` `SONAME` `"mysql_clone.so"``;`
mysql> `CREATE` `USER` `clone_user``@``'%'` `IDENTIFIED` `BY` `"clone_password"``;`
mysql> `GRANT` `BACKUP_ADMIN` `ON` `*``.``*` `to` `clone_user``;`

接下来,为了观察克隆操作的进度,我们需要授予该用户权限以查看performance_schema数据库和执行函数:

mysql> `GRANT` `SELECT` `ON` `performance_schema``.``*` `TO` `clone_user``;`
mysql> `GRANT` `EXECUTE` `ON` `*``.``*` `to` `clone_user``;`

现在我们将转向接收服务器。如果您正在配置一个新节点,首先初始化数据目录并启动服务器。

root用户身份连接到接收服务器。然后安装克隆插件,创建一个用户以替换当前实例数据,并授予该用户CLONE_ADMIN权限。我们还将提供接收方可以克隆的有效捐赠者列表(这里只有一个):

mysql> `INSTALL` `PLUGIN` `CLONE` `SONAME` `"mysql_clone.so"``;`
mysql> `SET` `GLOBAL` `clone_valid_donor_list` `=` `"127.0.0.1:21122"``;`
mysql> `CREATE` `USER` `clone_user` `IDENTIFIED` `BY` `"clone_password"``;`
mysql> `GRANT` `CLONE_ADMIN` `ON` `*``.``*` `to` `clone_user``;`

我们将授予此用户与捐赠方相同的权限,以便在接收方观察进度:

mysql> `GRANT` `SELECT` `ON` `performance_schema``.``*` `TO` `clone_user``;`
mysql> `GRANT` `EXECUTE` `ON` `*``.``*` `to` `clone_user``;`

现在我们已经准备就绪,是时候开始克隆过程了。请注意,接收方必须能够从捐赠服务器访问。接收方将使用提供的地址和凭据连接到捐赠者并开始克隆:

mysql> `CLONE` `INSTANCE` `FROM` `clone_user``@``192``.``168``.``1``.``2``:``3306`
    -> `IDENTIFIED` `BY` `"clone_password"``;`

为了使克隆操作成功,接收方必须关闭并重新启动自身。我们可以使用以下查询监视进度:

SELECT STAGE, STATE, CAST(BEGIN_TIME AS TIME) as "START TIME",
CASE WHEN END_TIME IS NULL THEN
LPAD(sys.format_time(POWER(10,12) * (UNIX_TIMESTAMP(now()) -
    UNIX_TIMESTAMP(BEGIN_TIME))), 10,' *)
ELSE
LPAD(sys.format_time(POWER(10,12) * (UNIX_TIMESTAMP(END_TIME) -
    UNIX_TIMESTAMP(BEGIN_TIME))), 10,* *)
END AS DURATION,
LPAD(CONCAT(FORMAT(ROUND(ESTIMATE/1024/1024,0), 0)," MB"), 16,* *)
AS "Estimate",
CASE WHEN BEGIN_TIME IS NULL THEN LPAD('0%*, 7, ' *)
WHEN ESTIMATE > 0 THEN
LPAD(CONCAT(CAST(ROUND(DATA*100/ESTIMATE, 0) AS BINARY), "%"), 7, ' ')
WHEN END_TIME IS NULL THEN LPAD('0%*, 7, ' *)
ELSE LPAD('100%*, 7, ' ') END AS "Done(%)"
from performance_schema.clone_progress;

这将允许我们观察克隆过程的每个状态。输出将类似于这样:

+-----------+-----------+------------+-----------+----------+---------+
| STAGE     | STATE     | START TIME | DURATION  | Estimate | Done(%) |
+-----------+-----------+------------+-----------+----------+---------+
| DROP DATA | Completed | 14:44:46   |    1.33 s |     0 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
| FILE COPY | Completed | 14:44:48   |    5.62 s | 1,511 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
| PAGE COPY | Completed | 14:44:53   |  95.06 ms |     0 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
| REDO COPY | Completed | 14:44:54   |  99.71 ms |     0 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
| FILE SYNC | Completed | 14:44:54   |    6.33 s |     0 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
| RESTART   | Completed | 14:45:00   |    4.08 s |     0 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
| RECOVERY  | Completed | 14:45:04   | 516.86 ms |     0 MB | 100%    |
+-----------+-----------+------------+-----------+----------+---------+
7 rows in set (0.08 sec)

正如前面提到的,在最后会有一个重启。请注意,复制尚未启动。

除了克隆数据之外,克隆操作还会从捐赠服务器提取二进制日志位置和 GTID,并将它们传输给接收方。我们可以在捐赠服务器上执行以下查询,以查看最后一次应用的二进制日志位置或 GTID:

mysql> `SELECT` `BINLOG_FILE``,` `BINLOG_POSITION` `FROM` `performance_schema``.``clone_status``;`
+------------------+-----------------+
| BINLOG_FILE      | BINLOG_POSITION |
+------------------+-----------------+
| mysql-bin.000002 |       816804753 |
+------------------+-----------------+
1 row in set (0.01 sec)
mysql> `SELECT` `@``@``GLOBAL``.``GTID_EXECUTED``;`
+------------------------+
| @@GLOBAL.GTID_EXECUTED |
+------------------------+
|                        |
+------------------------+
1 row in set (0.00 sec)

在本例中,我们未使用 GTID,因此该查询不会返回任何内容。接下来,我们将运行命令以启动复制:

mysql> `CHANGE` `MASTER` `TO` `MASTER_HOST` `=` `'192.168.1.2'``,` `MASTER_PORT` `=` `3306``,`
    -> `MASTER_USER` `=` `'repl'``,` `MASTER_PASSWORD` `=` `'P@ssw0rd!'``,`
    -> `MASTER_LOG_FILE` `=` `'mysql-bin.000002'``,`
    -> `MASTER_LOG_POSITION` `=` `816804753``;`
mysql> `START` `SLAVE``;`

如前所述,我们可以通过运行SHOW SLAVE STATUS命令来检查复制是否正常工作。

这种方法的优点是克隆插件自动化整个过程,只有在最后需要执行CHANGE MASTER命令。缺点是该插件仅适用于 MySQL 8.0.17 及更高版本。虽然它仍然相对较新,但我们相信在未来几年,这个过程可能会成为默认设置。

使用 mysqldump 创建一个副本

这是我们可能称之为经典方法。对于那些刚开始使用 MySQL 并且仍在学习生态系统的人来说,这是一个典型的选择。通常情况下,我们假设您已经在“在源和复制品上设置基本参数”中执行了必要的设置。

让我们看一个使用mysqldump创建新复制品的示例。我们将从源服务器执行备份:

# mysqldump -uroot -p<*password*> --single-transaction \
    --all-databases --routines --triggers --events \
    --master-data=2 > backup.sql

如果在结尾出现Dump completed消息,则备份成功:

# tail -1f backup.sql
-- Dump completed on 2021-04-26 20:16:33

完成备份后,我们需要在复制服务器中导入它。例如,您可以使用以下命令:

$ mysql < backup.sql

完成后,您需要使用从备份中提取的坐标执行CHANGE MASTER命令(有关mysqldump的更多详细信息,请重访“mysqldump 程序”)。因为我们使用了--master-data=2选项,信息将写入备份的开头。例如:

$ head -n 35 out
-- MySQL dump 10.13  Distrib 5.7.31-34, for Linux (x86_64)
--
-- Host: 127.0.0.1    Database:
-- ------------------------------------------------------
-- Server version   5.7.33-log

...

--
-- Position to start replication or point-in-time recovery from
--

-- CHANGE MASTER TO MASTER_LOG_FILE='mysql-bin.000001', MASTER_LOG_POS=4089;

或者,如果您使用 GTIDs:

--
-- GTID state at the beginning of the backup
-- (origin: @@global.gtid_executed)
--

SET @@GLOBAL.GTID_PURGED=*00048008-1111-1111-1111-111111111111:1-16*;

接下来,我们将执行启动复制的命令。对于 GTID 场景,它看起来像这样:

mysql> `CHANGE` `MASTER` `TO` `MASTER_HOST``=``'192.168.1.2'``,` `MASTER_USER``=``'repl'``,`
    -> `MASTER_PASSWORD` `=` `'P@ssw0rd!'``,` `MASTER_AUTO_POSITION``=``1``;`
mysql> `START` `SLAVE``;`

对于传统复制,您可以从先前提取的二进制日志文件位置开始复制,如下所示:

mysql> `CHANGE` `MASTER` `TO` `MASTER_LOG_FILE``=``'mysql-bin.000001'``,` `MASTER_LOG_POS``=``4089``,`
    -> `MASTER_HOST``=``'192.168.1.2'``,` `MASTER_USER``=``'repl'``,`
    -> `MASTER_PASSWORD``=``'P@ssw0rd!'``;`
mysql> `START` `SLAVE``;`

要验证复制是否正常工作,请执行SHOW SLAVE STATUS命令。

使用 mydumper 和 myloader 创建复制品

mysqldump是初学者执行备份和构建复制品最常用的工具。但是有一种更有效的方法:mydumper。与mysqldump类似,这个工具生成逻辑备份,并可用于创建数据库的一致备份。mydumpermysqldump的主要区别在于,配合myloader使用时,mydumper可以并行进行数据的导出和导入,从而提高了备份和特别是恢复的时间。想象一下,如果您的数据库有 500GB 的备份。使用mysqldump,您将得到一个巨大的单一文件。而使用mydumper,您将得到每个表一个文件,允许稍后并行执行恢复过程。

设置 mydumper 和 myloader 实用工具

您可以直接在源服务器上或从另一台服务器上运行mydumper,通常后者更好,因为它会避免在同一服务器上写入备份文件的存储系统开销。

要安装mydumper,请下载适用于您正在使用的操作系统版本的软件包。您可以在mydumper GitHub 仓库中找到发布版本。我们来看一个 CentOS 的例子:

# yum install https://github.com/maxbube/mydumper/releases/download/v0.10.3/ \
mydumper-0.10.3-1.el7.x86_64.rpm -y

现在您应该在服务器上安装了mydumpermyloader命令。您可以通过以下方式验证:

$ mydumper --version
mydumper 0.10.3, built against MySQL 5.7.33-36

$ myloader --version
myloader 0.10.3, built against MySQL 5.7.33-36

从源中提取数据

下面的命令将执行所有数据库(除了mysqltestsys模式)的备份,使用 15 个并发线程,还将包括触发器、视图和函数:

# mydumper --regex '^(?!(mysql\.|test\.|sys\.))' --threads=15
--user=learning_user --password='learning_mysql' --host=192.168.1.2 \
    --port=3306 --trx-consistency-only --events --routines --triggers \
    --compress --outputdir /backup --logfile /tmp/log.out --verbose=2

提示

您需要至少授予mydumper用户SELECTRELOAD权限。

如果检查输出目录(outputdir),你会看到压缩文件。以下是作者其中一台机器上的输出:

# ls -l backup/
total 5008
-rw...1 vinicius.grippa percona   182 May  1 19:30 metadata
-rw...1 vinicius.grippa percona   258 May  1 19:30 sysbench.sbtest10-schema.sql.gz
-rw...1 vinicius.grippa percona 96568 May  1 19:30 sysbench.sbtest10.sql.gz
-rw...1 vinicius.grippa percona   258 May  1 19:30 sysbench.sbtest11-schema.sql.gz
-rw...1 vinicius.grippa percona 96588 May  1 19:30 sysbench.sbtest11.sql.gz
-rw...1 vinicius.grippa percona   258 May  1 19:30 sysbench.sbtest12-schema.sql.gz
...

提示

根据数据库服务器和服务器负载的 CPU 核心数来决定线程数量。并行转储可能会消耗大量服务器资源。

在副本服务器中恢复数据

像使用mysqldump一样,我们需要确保副本 MySQL 实例已经正常运行。一旦数据准备好导入,我们可以执行以下命令:

# myloader --user=learning_user --password='learning_mysql'
--threads=25 --host=192.168.1.3 --port=3306
--directory=/backup --overwrite-tables --verbose 3

建立复制关系

现在我们已经恢复了数据,我们将设置复制。我们需要找到备份开始时的正确二进制日志位置。这些信息存储在mydumper元数据文件中:

$ cat backup/metadata
Started dump at: 2021-05-01 19:30:00
SHOW MASTER STATUS:
    Log: mysql-bin.000002
    Pos: 9530779
    GTID:00049010-1111-1111-1111-111111111111:1-319

Finished dump at: 2021-05-01 19:30:01

现在,我们就像之前对mysqldump所做的那样,简单地执行CHANGE MASTER命令:

mysql> `CHANGE` `MASTER` `TO` `MASTER_HOST``=``'192.168.1.2'``,` `MASTER_USER``=``'repl'``,`
    -> `MASTER_PASSWORD``=``'P@ssw0rd!'``,`  `MASTER_LOG_FILE``=``'mysql-bin.000002'``,`
    -> `MASTER_LOG_POS``=``9530779``,` `MASTER_PORT``=``49010``;`
mysql> `START` `SLAVE``;`

Group Replication

将 Group Replication 包含在异步复制组中可能会有点有争议。这种选择的简短解释是 Group Replication 是异步的。这里的混淆可以通过与 Galera 的比较来解释(见“Galera/PXC Cluster”),Galera 声称是同步或准同步的。

更详细的理由是这取决于我们如何定义复制。在 MySQL 世界中,我们将复制定义为使一个数据库(源)中进行的更改自动复制到另一个数据库(副本)中的过程。整个过程涉及五个不同的步骤:

  1. 在源上局部应用变更

  2. 生成 binlog 事件

  3. 将 binlog 事件发送到副本

  4. 将 binlog 事件添加到副本的 relay log 中

  5. 在副本上应用来自 relay log 的 binlog 事件

在 MySQL Group Replication 和 Galera 中(即使 Galera 缓存主要替换了 binlog 和 relay log 文件),只有第三步是同步的——将二进制日志事件(或在 Galera 中的写集)流式传输到副本。

因此,尽管将数据发送(复制/流式传输)到其他服务器的过程是同步的,但对这些更改的应用仍然完全是异步的。

提示

Group Replication 自 MySQL 5.7 起就已经可用。然而,当产品发布时,它的成熟度不够,导致持续的性能问题和崩溃。如果您想测试 Group Replication,我们强烈建议使用 MySQL 8.0 版本。

安装 Group Replication

与 Galera 相比,Group Replication 的第一个优点是您无需安装不同的二进制文件。MySQL Server 提供 Group Replication 作为插件。它还适用于 Oracle MySQL 和 Percona Server for MySQL;有关安装这些的详细信息,请参见第一章。

要确认 Group Replication 插件是否已启用,请运行以下查询:

mysql> `SELECT` `PLUGIN_NAME``,` `PLUGIN_STATUS``,` `PLUGIN_TYPE`
    -> `FROM` `INFORMATION_SCHEMA``.``PLUGINS`
    -> `WHERE` `PLUGIN_NAME` `LIKE` `'group_replication'``;`

输出应该显示ACTIVE,如你在这里所见:

+-------------------+---------------+-------------------+
| PLUGIN_NAME       | PLUGIN_STATUS | PLUGIN_TYPE       |
+-------------------+---------------+-------------------+
| group_replication | ACTIVE        | GROUP REPLICATION |
+-------------------+---------------+-------------------+
1 row in set (0.00 sec)

如果未安装插件,请运行以下命令进行安装:

mysql> `INSTALL` `PLUGIN` `group_replication` `SONAME` `'group_replication.so'``;`

在插件激活的情况下,我们将在服务器上设置启动 Group Replication 所需的最小参数。在服务器 1 上打开my.cnf并添加以下内容:

[mysqld]
server_id=175907211
log-bin=mysqld-bin
enforce_gtid_consistency=ON
gtid_mode=ON
log-slave-updates
transaction_write_set_extraction=XXHASH64
master_info_repository=TABLE
relay_log_info_repository=TABLE
binlog_checksum=NONE

让我们逐个讨论这些参数:

server_id

与传统复制类似,此参数有助于使用唯一 ID 标识组中的每个成员。每个参与 Group Replication 的服务器必须使用不同的值。

log_bin

在 MySQL 8.0 中,默认启用此参数。它负责记录数据库中的所有更改到二进制日志文件中。

enforce_gtid_consistency

此值必须设置为ON,以指示 MySQL 执行事务安全语句,确保在复制数据时的一致性。

gtid_mode

当设置为ON时,此指令启用基于全局事务标识符的日志记录。Group Replication 需要此功能。

log_slave_updates

此值设置为ON以允许成员记录彼此的更新。换句话说,此指令将复制服务器链接在一起。

transaction_write_set_extraction

这指示 MySQL 服务器收集写集并使用哈希算法对其进行编码。在这种情况下,我们使用 XXHASH64 算法。写集由每个记录上的主键定义。

master_info_repository

当设置为TABLE时,此指令允许 MySQL 将源二进制日志文件和位置的详细信息存储到表中,而不是文件中,以便通过 InnoDB 的 ACID 属性实现更快的复制和保证一致性。在 MySQL 8.0.23 中,这是默认设置,FILE选项已不推荐使用。

relay_log_info_repository

当设置为TABLE时,此配置指示 MySQL 将复制信息存储为 InnoDB 表。在 MySQL 8.0.23 中,这是默认设置,FILE选项已不推荐使用。

binlog_checksum

将此设置为NONE告诉 MySQL 不要为二进制日志中的每个事件写入校验和。服务器将在写入时通过检查其长度来验证事件。在 MySQL 8.0.20 及更早版本中,Group Replication 无法使用校验和。如果您正在使用更新版本并希望使用校验和,可以省略此设置并使用默认的CRC32

接下来,我们将添加一些特定于 Group Replication 的参数:

[mysqld]

loose-group_replication_group_name="8dc32851-d7f2-4b63-8989-5d4b467d8251"
loose-group_replication_start_on_boot=OFF
loose-group_replication_local_address="10.124.33.139:33061"
loose-group_replication_group_seeds="10.124.33.139:33061,
10.124.33.90:33061, 10.124.33.224:33061"
loose-group_replication_bootstrap_group=OFF
bind-address = "0.0.0.0"
report_host = "10.124.33.139"
注意

我们使用loose-前缀来指示服务器在尚未安装和配置 MySQL Group Replication 插件时启动,避免在完成所有设置之前遇到服务器错误。

让我们看看每个参数的作用:

group_replication_group_name

这是我们正在创建的组的名称。我们将使用内置的 Linux uuidgen命令生成通用唯一标识符(UUID)。它产生如下输出:

$ uuidgen

8dc32851-d7f2-4b63-8989-5d4b467d8251

group_replication_start_on_boot

当设置为OFF时,此值指示插件在服务器启动时不自动开始工作。在完成所有组成员的配置后,您可以将此值设置为ON

loose-group_replication_local_address

这是用于与组中其他 MySQL 服务器成员通信的内部 IP 地址和端口组合。Group Replication 推荐的端口是 33061。

group_replication_group_seeds

这配置了参与组复制的成员的 IP 地址或主机名,以及它们的通信端口。新成员使用该值来加入组。

group_replication_bootstrap_group

此选项指示服务器是否创建组。我们将仅在服务器 1 上按需启用此选项,以避免创建多个组。因此,暂时保持关闭状态。

bind_address

0.0.0.0的值告诉 MySQL 监听所有网络。

report_host

这是组成员在注册组时彼此报告的 IP 地址或主机名。

设置 MySQL 组复制

首先,我们将设置group_replication_recovery通道。MySQL 组复制使用此通道在成员之间传输事务。因此,我们必须在每个服务器上为复制用户设置REPLICATION SLAVE权限。

因此,在服务器 1 上,登录 MySQL 控制台并执行以下命令:

mysql> `SET` `SQL_LOG_BIN``=``0``;`
mysql> `CREATE` `USER` `replication_user``@``'%'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`
mysql> `GRANT` `REPLICATION` `SLAVE` `ON` `*``.``*` `TO` `'replication_user'``@``'%'``;`
mysql> `FLUSH` `PRIVILEGES``;`
mysql> `SET` `SQL_LOG_BIN``=``1``;`

我们首先将SQL_LOG_BIN设置为0,以防止新用户的详细信息记录到二进制日志中,然后在最后重新启用它。

要指示 MySQL 服务器使用我们为group_replication_recovery通道创建的复制用户,运行以下命令:

mysql> `CHANGE` `MASTER` `TO` `MASTER_USER``=``'replication_user'``,`
    -> `MASTER_PASSWORD``=``'P@ssw0rd!'` `FOR` `CHANNEL`
    -> `'group_replication_recovery'``;`

这些设置将允许加入组的成员运行分布式恢复过程,使其达到与其他成员(提供者)相同的状态。

现在我们将在服务器 1 上启动组复制服务。我们将使用以下命令引导组:

mysql> `SET` `GLOBAL` `group_replication_bootstrap_group``=``ON``;`
mysql> `START` `GROUP_REPLICATION``;`
mysql> `SET` `GLOBAL` `group_replication_bootstrap_group``=``OFF``;`

为了避免启动更多的组,我们在成功启动组后将group_replication_bootstrap_group设置回OFF

要检查新成员的状态,请使用此命令:

mysql> `SELECT` `*` `FROM` `performance_schema``.``replication_group_members``;`

+---------------------------+--------------------------------------+...
| CHANNEL_NAME              | MEMBER_ID                            |...
+---------------------------+--------------------------------------+...
| group_replication_applier | d58b2766-ab90-11eb-ba00-00163ed02a2e |...
+-------------+-------------+--------------+-------------+---------+...
...+---------------+-------------+--------------+-------------+----------------+
...| MEMBER_HOST   | MEMBER_PORT | MEMBER_STATE | MEMBER_ROLE | MEMBER_VERSION |
...+---------------+-------------+--------------+-------------+----------------+
...| 10.124.33.139 |        3306 | ONLINE       | PRIMARY     | 8.0.22         |
...+---------------+-------------+--------------+-------------+----------------+
1 row in set (0.00 sec)

很好。到目前为止,我们已经引导并初始化了一个组成员。让我们继续进行第二台服务器的设置。确保您已安装与服务器 1 相同的 MySQL 版本,并将以下设置添加到my.cnf文件中:

[mysqld]
loose-group_replication_group_name="8dc32851-d7f2-4b63-8989-5d4b467d851"
loose-group_replication_start_on_boot=OFF
loose-group_replication_local_address="10.124.33.90:33061"
loose-group_replication_group_seeds="10.124.33.139:33061,
10.124.33.90:33061, 10.124.33.224:33061"
loose-group_replication_bootstrap_group=OFF
bind-address = "0.0.0.0"

我们修改的只是group_replication_local_address;其他设置保持不变。请注意,其他 MySQL 配置对于服务器 2 也是必需的,我们强烈建议在所有节点上保持它们相同。

配置完成后,重新启动 MySQL 服务:

# systemctl restart mysqld

在服务器 2 上执行以下命令配置恢复用户的凭据:

mysql> `SET` `SQL_LOG_BIN``=``0``;`
mysql> `CREATE` `USER` `'replication_user'``@``'%'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`
mysql> `GRANT` `REPLICATION` `SLAVE` `ON` `*``.` TO 'replication_user'@'%';*
mysql> `SET` `SQL_LOG_BIN``=``1``;`
mysql> `CHANGE` `MASTER` `TO` `MASTER_USER``=``'replication_user'``,`
`MASTER_PASSWORD``=``'PASSWORD'` `FOR` `CHANNEL`
`'group_replication_recovery'``;`

接下来,将服务器 2 添加到之前引导引导的组中:

mysql> `START` `GROUP_REPLICATION``;`

并运行查询以检查成员的状态:

mysql> `SELECT` `*` `FROM` `performance_schema``.``replication_group_members``;`

+---------------------------+--------------------------------------+...
| CHANNEL_NAME              | MEMBER_ID                            |...
+---------------------------+--------------------------------------+...
| group_replication_applier | 9e971ba0-ab9d-11eb-afc6-00163ec43109 |...
| group_replication_applier | d58b2766-ab90-11eb-ba00-00163ed02a2e |...
+-------------+-------------+--------------+-------------+---------+...
...+---------------+-------------+--------------+...
...| MEMBER_HOST   | MEMBER_PORT | MEMBER_STATE |...
...+---------------+-------------+--------------+...
...| 10.124.33.90  |        3306 | ONLINE       |...
...| 10.124.33.139 |        3306 | ONLINE       |...
...+---------------+-------------+--------------+...
...+-------------+----------------+
...| MEMBER_ROLE | MEMBER_VERSION |
...+-------------+----------------+
...| SECONDARY   | 8.0.22         |
...| PRIMARY     | 8.0.22         |
...+-------------+----------------+
2 rows in set (0.00 sec)

现在,您可以为服务器 3 执行与服务器 2 相同的步骤,再次更新本地地址。完成后,您可以通过插入一些虚拟数据来验证所有服务器是否响应:

mysql> `CREATE` `DATABASE` `learning_mysql``;`

Query OK, 1 row affected (0.00 sec)
mysql> `USE` `learning_mysql`

Database changed
mysql> `CREATE` `TABLE` `test` `(``i` `int` `primary` `key``)``;`

Query OK, 0 rows affected (0.01 sec)
mysql> `INSERT` `INTO` `test` `VALUES` `(``1``)``;`

Query OK, 1 row affected (0.00 sec)

然后连接到其他服务器,查看是否可以可视化数据。

同步复制

Galera 集群使用同步复制,其中我们有多个 MySQL 服务器,但它们对应用程序而言作为单个实体。图 13-2 说明了一个具有三个节点的 Galera 集群的拓扑。

lm2e 1302

图 13-2. 在 Galera 集群中,所有节点彼此通信

同步和异步复制的主要区别在于,同步复制保证如果集群中的一个节点发生更改,则其他节点中也会同步或同时发生相同的更改。异步复制则不对在源节点应用更改和在复制节点传播更改之间的延迟做任何保证。异步复制的延迟可以很短也可以很长。这也意味着如果异步复制拓扑中的源节点崩溃,可能会丢失一些最新的更改。这些源和复制的概念在 Galera 集群中不存在。所有节点都可以接收读取和写入。

理论上,同步复制比异步复制具有几个优势:

  • 使用同步复制的集群始终高可用。如果其中一个节点崩溃,则不会丢失数据。此外,所有集群节点始终保持一致。

  • 使用同步复制的集群允许事务在所有节点上并行执行。

  • 使用同步复制的集群可以保证整个集群的因果关系。这意味着如果在一个集群节点上执行事务后在另一个集群节点上执行SELECT,它应该能看到该事务的效果。

然而,同步复制也有缺点。传统上,急切的复制协议一次协调一个节点的操作,使用两阶段提交或分布式锁定。增加集群中节点的数量会导致事务响应时间增加,节点之间的冲突和死锁的概率增加。这是因为所有节点都需要认证事务并回复 OK 消息。

因此,异步复制出于数据库性能、可伸缩性和可用性的原因仍然是主要的复制协议。不理解或低估同步复制的影响是某些公司放弃使用 Galera 集群并回到使用异步复制的原因之一。

在撰写本文时,有两家公司支持 Galera 集群:Percona 和 MariaDB。以下示例显示如何设置 Percona XtraDB Cluster。

Galera/PXC 集群

安装 Percona XtraDB Cluster(PXC)与安装 Percona Server 类似(区别在于软件包),因此我们不会深入讨论所有平台的详细信息。您可能希望重新访问第一章以审查安装过程。我们将在此处遵循的配置过程假定有三个 PXC 节点。

表 13-1. 三个 PXC 节点

节点 主机 IP
节点 1 pxc1 172.16.2.56
节点 2 pxc2 172.16.2.198
节点 3 pxc3 172.16.3.177

连接到其中一个节点并安装存储库:

# `yum install https://repo.percona.com/yum/percona-release-latest.noar` `ch``.``rpm` `-``y`

安装存储库后,安装二进制文件:

# `yum install Percona-XtraDB-Cluster-57 -y`

接下来,您可以应用您将用于常规 MySQL 进程的典型配置(参见第十一章)。完成更改后,启动mysqld进程并获取临时密码:

# 'systemctl start mysqld'
# 'grep temporary password'/var/log/mysqld.log'

使用以前的密码作为root登录并更改密码:

$ mysql -u root -p
mysql> `ALTER` `USER` `'root'``@``'localhost'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`

停止mysqld进程:

# systemctl stop mysql

重复前面的步骤,对其他两个节点执行相同操作。

二进制文件和基本配置就绪后,我们可以开始配置集群参数。

我们需要将以下配置变量添加到第一个节点的/etc/my.cnf中:

[mysqld]
 wsrep_provider=/usr/lib64/galera3/libgalera_smm.so
 wsrep_cluster_name=pxc-cluster
 wsrep_cluster_address=gcomm://172.16.2.56,172.16.2.198,172.16.3.177

 wsrep_node_name=pxc1
 wsrep_node_address=172.16.2.56

 wsrep_sst_method=xtrabackup-v2
 wsrep_sst_auth=sstuser:P@ssw0rd!

 pxc_strict_mode=ENFORCING

 binlog_format=ROW
 default_storage_engine=InnoDB
 innodb_autoinc_lock_mode=2

使用相同的配置来配置第二和第三个节点,除了wsrep_node_namewsrep_node_address变量。

对于第二个节点,请使用:

wsrep_node_name=pxc2
wsrep_node_address=172.16.2.198

对于第三个节点,请使用:

wsrep_node_name=pxc3
wsrep_node_address=172.16.3.177

像常规的 MySQL 一样,Percona XtraDB Cluster 有许多可配置参数,我们展示的是启动集群所需的最小设置。我们正在配置节点的名称和 IP 地址,集群地址以及在节点之间进行内部通信时将使用的用户。您可以在文档中找到更详细的信息。

我们此时已经配置了所有节点,但是任何节点上都没有运行mysqld进程。PXC 要求您在其他节点可以加入并形成集群之前,首先启动一个节点作为其他节点的参考点。此节点必须以bootstrap模式启动。引导是将一个服务器作为主要组件引入的初始步骤,以便其他服务器可以使用它作为同步数据的参考点。

使用以下命令启动第一个节点:

# systemctl start mysql@bootstrap

在将其他节点添加到新集群之前,请连接到刚刚启动的节点,为 State Snapshot Transfer(SST)创建一个用户,并为其提供必要的权限。凭据必须与您之前设置的wsrep_sst_auth配置中指定的凭据匹配:

mysql> `CREATE` `USER` `'sstuser'``@``'localhost'` `IDENTIFIED` `BY` `'P@ssw0rd!'``;`
mysql> `GRANT` `RELOAD``,` `LOCK` `TABLES``,` `PROCESS``,` `REPLICATION` `CLIENT` `ON` `.`
    -> `TO` `'sstuser'``@``'localhost'``;`
mysql> `FLUSH` `PRIVILEGES``;`
注意

集群使用 SST 过程通过从一个节点向另一个节点传输完整数据副本来配置节点。当新节点加入集群时,新节点会启动 SST 以将其数据与已经是集群成员的节点同步。

然后,您可以正常初始化其他节点:

# systemctl start mysql

要验证集群是否正常运行,我们可以执行一些检查,如在第一个节点上创建一个数据库,在第二个节点上创建一个表并插入一些数据,并在第三个节点上检索该表中的一些行。首先,在第一个节点(pxc1)上创建数据库:

mysq> `CREATE` `DATABASE` `learning_mysql``;`

Query ok, 1 row affected (0.01 sec)

在第二个节点(pxc2)上创建一个表并插入一些数据:

mysql> `USE` `learning_mysql``;`

Database changed
mysql> `CREATE` `TABLE` `example` `(``node_id` `INT` `PRIMARY` `KEY``,` `node_name` `VARCHAR``(``30``)``)``;`

Query ok, 0 rows affected (0.05 sec)
mysql> `INSERT` `INTO` `learning_mysql``.``example` `VALUES` `(``1``,` `"Vinicius1"``)``;`

Query OK, 1 row affected (0.02 sec)

然后在第三个节点上检索该表中的一些行:

mysql> `SELECT` `*` `FROM` `learning_mysql``.``example``;`
+---------+-----------+
| node_id | node_name |
+---------+-----------+
|       1 | Vinicius1 |
+---------+-----------+
1 row in set (0.00 sec)

另一个更优雅的解决方案是检查wsrep_%全局状态变量,特别是wsrep_cluster_sizewsrep_cluster_status

mysql> `SHOW` `GLOBAL` `STATUS` `LIKE` `'wsrep_cluster_size'``;`

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

+----------------------+---------+
| Variable_name        | Value   |
+----------------------+---------+
| wsrep_cluster_status | Primary |
+----------------------+---------+
1 row in set (0.00 sec)

这些命令的输出告诉我们,集群有三个节点,并处于主状态(可以接收读取和写入)。

除了使用 Galera 集群外,您可能考虑同时使用 ProxySQL 来确保应用程序的透明性(见第十五章)。

本章的目标只是让您熟悉不同的拓扑结构,以便您知道它们的存在。集群维护和优化是超出本书范围的高级主题。

第十四章:云中的 MySQL

“不用担心,这都在云端”是我们经常听到的一句话。这让我们想起一个关于一位女士的故事,她担心她的 iPhone 在马桶里淹没后丢失了多年的家庭和旅行照片。令她惊讶的是,当她买了新手机时,设备“恢复”了所有照片。她正在使用苹果的 iCloud 备份解决方案将设备内容备份到云端。(她可能另一个惊讶是她没意识到自己在支付服务订阅账单。)

作为计算机工程师,我们不能冒险是否能够恢复数据。云存储是一种可扩展和可靠的解决方案。在本章中,我们将讨论公司在云中使用 MySQL 的几种选择。这些选择从易于扩展并提供自动备份和高可用性功能的数据库即服务(DBaaS)选项到提供更精细控制的传统选择,如 EC2 实例。一般来说,初创公司,其核心业务不是技术,更倾向于使用 DBaaS 选项,因为这些选项更易于实施和使用。另一方面,那些需要更严格控制其数据的公司可能更喜欢使用 EC2 实例或自己的云基础设施。

数据库即服务(DBaaS)

DBaaS 是一种外包选项,公司支付云提供商来为他们启动和维护云数据库。付款通常是按使用量计算,数据所有者可以随意访问他们的应用数据。DBaaS 提供与标准关系型或非关系型数据库相同的功能。对于那些试图避免配置、维护和升级数据库和服务器的公司来说,它通常是一个不错的解决方案(尽管这并不总是正确的)。DBaaS 属于软件即服务(SaaS)的范畴,类似于平台即服务(PaaS)和基础设施即服务(IaaS),在这些服务中,像数据库这样的产品成为服务。

Amazon RDS for MySQL/MariaDB

最受欢迎的 DBaaS 是 Amazon RDS for MySQL。开始使用 RDS 几乎就像在网站上配置新车一样。您选择主产品,并添加您想要的选项,直到它看起来您喜欢的样子,然后启动。图 14-1 展示了可用的产品。在本例中,我们将选择 MySQL(MariaDB 版本的部署设置类似)。

lm2e 1401

图 14-1. 选择产品

我们还可以选择版本——在这里,我们选择了 8.0.21. 接下来,我们需要设置主用户(类似于 root)及其密码。确保选择一个强密码,特别是如果您将数据库暴露给公众。图 14-2 展示了如何定义主用户的用户名和密码。

lm2e 1402

图 14-2. 配置主用户的用户名和密码

接下来是实例大小,这将直接影响最终价格。我们将选择一个顶级配置,以便让您了解使用 DBaaS 可能会有多昂贵。图 14-3 展示了可用的实例类别;有多种选择,成本各异。

lm2e 1403

图 14-3. 选择实例类别

另一个可能直接影响计费的选项是存储选项。自然而然,更高的性能(更多的 IOPS)和更大的存储空间会导致更高的成本。图 14-4 展示了选择的内容。您还可以选择是否启用自动扩展。

下一个选项是一个重要的选择:您是否要使用多可用区部署?多可用区选项关乎高可用性。当您提供一个多可用区的 DB 实例时,Amazon RDS 会自动创建一个主 DB 实例,并同步将数据复制到不同可用区的备用实例中。这些可用区在物理上是独立的,并且具有独立的基础设施,这增加了整体可用性。

如果您不想使用多可用区部署,RDS 将安装单个实例。在发生故障时,它将启动一个新实例并重新挂载其数据卷。此过程需要一些时间,在此期间您的数据库将不可用。即使是大型云提供商也无法保证绝对安全,灾难也可能发生,因此建议始终配置备用服务器。图 14-5 展示了如何配置复制实例。

lm2e 1404

图 14-4. 配置存储大小及其 IOPS 性能

lm2e 1405

图 14-5. 配置备用复制

接下来是设置一般的网络配置。我们建议配置 RDS 使用私有网络,只有应用服务器和开发人员的 IP 可以访问。图 14-6 展示了网络选项。

lm2e 1406

图 14-6. 配置网络设置

最后,不可避免地是估算成本。图 14-7 展示了您为配置选择每月支付的金额。

lm2e 1407

图 14-7. 在特定配置下,账单可能飙升至天文数字!

Google Cloud SQL for MySQL

Google Cloud SQL 提供了与 Amazon RDS(和 Azure)类似的托管数据库服务,但存在细微差异。Google Cloud 针对 MySQL 的选项更为简单,因为可选择的选项较少。例如,您不能在 MySQL 和 MariaDB 之间进行选择,也不能选择 MySQL 的小版本(只能选择主版本)。如图 14-8 所示,您可以通过创建新实例或将现有数据库迁移到 Google Cloud 来开始使用。

lm2e 1408

图 14-8. Google Cloud SQL

在创建新实例时,您需要填写几个选项。第一步是选择产品。图 14-9 展示了 MySQL 可用的选项。

lm2e 1409

图 14-9. 选择产品

在选择 MySQL 之后,您需要指定实例名称、root密码、数据库版本和位置。图 14-10 显示了如何配置这些设置。

lm2e 1410

图 14-10. 设置基本配置

接下来是可能影响性能和成本的设置——在这里找到正确的平衡非常关键。图 14-11 显示了可用的存储、内存和 CPU 选项。

lm2e 1411

图 14-11. 配置机器类型和存储

现在实例已准备在 Google Cloud 中启动。

Azure SQL

前三大云服务提供商之一是 Azure SQL。图 14-12 显示了 Azure 中可用的数据库产品。您会想选择“Azure Database for MySQL servers”。

lm2e 1412

图 14-12. 在 Azure 中选择 MySQL

Azure 提供两个选项,可以选择简单服务器或更强大的高可用性设置。图 14-13 显示了这两个选项之间的区别。

lm2e 1413

图 14-13. 选择单一服务器或灵活服务器

选择后续与服务性能和成本相关的类似配置。图 14-14 显示了 MySQL 托管服务的选项。

lm2e 1414

图 14-14. 配置我们的 MySQL 托管服务实例

亚马逊极光

亚马逊极光是亚马逊提供的一个与 MySQL 和 PostgreSQL 兼容的关系数据库解决方案,使用商业许可提供。它提供类似于 MySQL 的功能,并且还包括一些亚马逊开发的额外功能。

其中两个功能值得一提。首先是 Aurora Parallel Query(PQ),这是一项能够并行处理数据密集型查询中涉及的部分 I/O 和计算的功能。

极光 PQ 通过进行全表扫描(存储级别执行并行读取)来工作。当我们使用并行查询时,查询不使用 InnoDB 缓冲池。相反,它将查询处理推送到存储层并进行并行化。

优点在于将处理过程移到数据附近可以减少网络流量和延迟。然而,这个功能并非银弹,对所有情况都不适用——它最适合需要在大量数据上运行的分析查询。

PQ 功能并非适用于所有 AWS 实例。对于支持此功能的实例,它们的实例类别决定了可以同时处于活动状态的并行查询数量。以下是支持 PQ 功能的实例:

  • db.r*.large: 1 个并发并行查询会话

  • db.r*.xlarge: 2 个并发并行查询会话

  • db.r*.2xlarge: 4 个并发并行查询会话

  • db.r*.4xlarge: 8 个并发并行查询会话

  • db.r*.8xlarge: 16 个并发并行查询会话

  • db.r4.16xlarge: 16 个并发并行查询会话

另一个显著特点是 Amazon Aurora 全局数据库,专为具有全球足迹的应用程序设计。它允许单个 Aurora 数据库跨多个 AWS 区域,快速复制以实现低延迟全球读取,并从区域范围内的故障中进行灾难恢复。Aurora 全局数据库使用基于存储的复制,在其全球数据中心的专用 Amazon 基础设施中进行复制。

MySQL 云实例

一个 云实例 就是一个虚拟服务器。不同的云服务提供商有不同的名称:Amazon Elastic Compute Cloud (EC2) 实例、Google Compute Engine 实例和 Azure 虚拟机。

根据用户的业务需求,它们提供不同类型的实例,从浅显基本的配置到惊人的限制。例如,Compute Engine m2-megamem-416 机型拥有 416 个 CPU 和 5,888 GB 的 RAM。

这些实例的 MySQL 安装过程与 第一章 中描述的标准过程相同。在这种情况下,与 DBaaS 解决方案相比,使用云实例的最大优势在于根据您的需求自定义 MySQL 和操作系统,而无需受到托管数据库的限制。

Kubernetes 中的 MySQL

部署 MySQL 实例的最新选项是 Kubernetes。Kubernetes 和 OpenShift 平台增加了一种管理容器化系统(包括数据库集群)的方式。通过在配置文件中声明的控制器实现管理。这些控制器提供自动化功能,用于创建对象,如容器或称为 pod 的一组容器,以监听特定事件并执行任务。

这种自动化增加了基于容器的架构和状态应用程序(如数据库)的复杂性。Kubernetes operator 是一种特殊类型的控制器,旨在简化复杂的部署。该 operator 通过自定义资源扩展 Kubernetes API。

有许多关于 Kubernetes 工作原理的好书。为了尽可能简洁,我们将讨论与 Percona Kubernetes Operator 相关的重要组件。要快速了解 Kubernetes,请查看 Linux 基金会的 文档。图 14-15 展示了 Kubernetes 中 Percona XtraDB Cluster 的组件。

lm2e 1415

图 14-15. Kubernetes 中的 Percona XtraDB Cluster 组件

下一节描述如何为 Percona XtraDB Cluster 部署 Percona Kubernetes Operator,该方案被认为是生产就绪的。还有其他 operator 可用。例如:

  • Oracle为 MySQL InnoDB Cluster 提供了一个 Kubernetes 操作器。在撰写本文时,该操作器处于预览状态,不建议用于生产环境。

  • MariaDB有一个操作器,但目前还处于 Alpha 阶段,请在生产环境使用之前检查其成熟度。

  • Presslabs已发布了一个操作器,可以部署 MySQL 实例以及编排器和备份功能。这个操作器已经可以投入生产使用。

在 Kubernetes 中部署 Percona XtraDB Cluster

本节将指导您使用 Google Cloud SDK 和Percona Kubernetes Operator for PXC部署 Kubernetes 集群的步骤。

  1. 安装 Google Cloud SDK。

    SDK 提供了与 Google Cloud 产品和服务进行交互所需的工具和库。下载适合您平台的二进制文件并安装它。这里是 macOS 的一个示例:

    $ wget https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/ \
         google-cloud-sdk-341.0.0-darwin-x86_64.tar.gz
    $ tar -xvf google-cloud-sdk-341.0.0-darwin-x86_64.tar.gz
    $ cd google-cloud-sdk/
    $ ./install.sh
    
  2. 使用gcloud安装kubectl

    安装了gcloud之后,使用以下命令安装kubectl组件:

    $ gcloud components install kubectl
    
  3. 创建 Kubernetes 集群。

    要创建 Kubernetes 集群,首先需要在 Google Cloud 服务中进行身份验证:

    $ gcloud auth login
    

    认证通过后,创建集群。该命令接受许多参数,但在这种情况下,我们将使用基本选项来创建一个 Kubernetes 集群:

    $ gcloud container clusters create --machine-type n1-standard-4 \
        --num-nodes 3 --zone us-central1-b --project support-211414 \
        --cluster-version latest vinnie-k8
    
    注意

    帐户需要具有创建集群所需的必要权限。此外,您需要用自己的名称替换此处使用的项目和集群名称。您可能还需要编辑区域位置,在此示例中设置为us-central1-b

    此处使用的参数仅是可用选项的一个小子集——您可以通过运行gcloud container clusters --help来查看所有选项。在这种情况下,我们只请求了一个由三个*n1-standard-4*类型实例节点组成的集群。

    这个过程可能需要一段时间,特别是如果有很多节点。输出将如下所示:

    Creating cluster vinnie-k8 in us-central1-b... Cluster is being
    health-checked (master is healthy)...done.
    Created [https://container.googleapis.com/v1/projects/support-211414/
    zones/us-central1-b/clusters/vinnie-k8].
    To inspect the contents of your cluster, go to:
    https://console.cloud.google.com/kubernetes/workload_/gcloud/
    us-central1-b/vinnie-k8?project=support-211414
    kubeconfig entry generated for vinnie-k8.
    +-----------+---------------+------------------+---------------+...
    | NAME      | LOCATION      | MASTER_VERSION   | MASTER_IP     |...
    +-----------+---------------+------------------+---------------+...
    | vinnie-k8 | us-central1-b | 1.19.10-gke.1000 | 34.134.67.128 |...
    +-----------+---------------+------------------+---------------+...
    ...+---------------------------------+-----------+---------+
    ...| MACHINE_TYPE NODE_VERSION       | NUM_NODES | STATUS  |
    ...+---------------------------------+-----------+---------+
    ...| n1-standard-4  1.19.10-gke.1000 | 3         | RUNNING |
    ...+---------------------------------+-----------+---------+
    

    我们还可以在 Google Cloud 中检查我们的 Kubernetes 集群的 Pod:

    $ kubectl get nodes
    
    NAME                                       STATUS   ROLES    AGE
    VERSION
    gke-vinnie-k8-default-pool-376c2051-5xgz   Ready    <none>   62s
    v1.19.10-gke.1000
    gke-vinnie-k8-default-pool-376c2051-w2tk   Ready    <none>   61s
    v1.19.10-gke.1000
    gke-vinnie-k8-default-pool-376c2051-wxd7   Ready    <none>   62s
    v1.19.10-gke.1000
    

    也可以使用 Google Cloud 界面来部署集群,如图 14-16 所示。

    lm2e 1416

    图 14-16。从主菜单选择 Kubernetes Engine,然后选择 Clusters

    要创建一个新的集群,请在图 14-17 顶部选择 CREATE 选项。

    lm2e 1417

    图 14-17。通过点击 CREATE 来创建 Kubernetes 集群

最后一步是安装 PXC 操作器。部署操作器的文档提供了非常详细的说明。我们将按照推荐步骤进行操作。

首先,配置 Cloud Identity 和 Access Management(Cloud IAM)以控制对集群的访问。以下命令将允许您创建角色和角色绑定:

$ kubectl create clusterrolebinding cluster-admin-binding --clusterrole \
    cluster-admin --user $(gcloud config get-value core/account)

返回语句确认了创建:

clusterrolebinding.rbac.authorization.k8s.io/cluster-admin-binding created

接下来,创建一个命名空间并设置命名空间的上下文:

$ kubectl create namespace learning-mysql
$ kubectl config set-context $(kubectl config current-context) \
   --namespace=learning-mysql

现在,克隆存储库并切换到目录:

$ git clone -b v1.8.0 \
    https://github.com/percona/percona-xtradb-cluster-operator
$ cd percona-xtradb-cluster-operator

使用以下命令部署操作员:

$ kubectl apply -f deploy/bundle.yaml

应该返回以下确认信息:

customresourcedefinition.apiextensions.k8s.io/perconaxtradbclusters.pxc.
percona.com created
customresourcedefinition.apiextensions.k8s.io/perconaxtradbclusterbackups.
pxc.percona.com created
customresourcedefinition.apiextensions.k8s.io/perconaxtradbclusterrestores.
pxc.percona.com created
customresourcedefinition.apiextensions.k8s.io/perconaxtradbbackups.pxc.
percona.com created
role.rbac.authorization.k8s.io/percona-xtradb-cluster-operator created
serviceaccount/percona-xtradb-cluster-operator created
rolebinding.rbac.authorization.k8s.io/service-account-percona-xtradb-
cluster-operator created
deployment.apps/percona-xtradb-cluster-operator created

操作员已启动,您可以通过运行以下命令进行确认:

$ kubectl get pods

现在,创建 Percona XtraDB 集群:

$ kubectl apply -f deploy/cr.yaml

此步骤可能需要一些时间。之后,您将看到所有的 Pod 都在运行:

$ kubectl get pods
NAME                               READY   STATUS    RESTARTS   AGE
cluster1-haproxy-0                 2/2     Running   0          4m54s
cluster1-haproxy-1                 2/2     Running   0          3m15s
cluster1-haproxy-2                 2/2     Running   0          2m52s
cluster1-pxc-0                     3/3     Running   0          4m54s
cluster1-pxc-1                     3/3     Running   0          3m16s
cluster1-pxc-2                     3/3     Running   0          105s
percona-xtradb-cluster-operator-   1/1     Running   0          7m18s
77bfd8cdc5-d7zll

在前面的步骤中,操作员已生成多个密钥,包括用于访问集群的root用户密码。要获取生成的密钥,请运行以下命令:

$ kubectl get secret my-cluster-secrets -o yaml

你将会看到如下输出:

apiVersion: v1
data:
  clustercheck: UFZjdjk0SU4xWGtBSTR2VlVJ
  monitor: ZWZja01mOWhBTXZ4bTB0bUZ4eQ==
  operator: Vm10R0IxbHA4cVVZTkxqVVI4Mg==
  proxyadmin: VXVFbkx1S3RmUTEzVlNOd1c=
  root: eU53aWlKT3ZXaXJaeG16OXJK
  xtrabackup: V3VNNWRnWUdIblVWaU1OWGY=
...
secrets/my-cluster-secrets
  uid: 9d78c4a8-1926-4b7a-84a0-43087a601066
type: Opaque

实际密码是 base64 编码的,因此您需要运行以下命令获取root密码:

$ echo 'eU53aWlKT3ZXaXJaeG16OXJK' | base64 --decode
yNwiiJOvWirZxmz9rJ

现在您有了密码,为了检查与集群的连接性,您可以创建一个客户端 Pod:

$ kubectl run -i --rm --tty percona-client --image=percona:8.0 \
    --restart=Never -- bash -il

然后连接到 MySQL:

$ mysql -h cluster1-haproxy -uroot -pyNwiiJOvWirZxmz9rJ

注意操作员与 HAProxy 一起提供,HAProxy 是一个负载均衡器(我们将在下一章讨论负载均衡)。

第十五章:负载均衡 MySQL

连接到 MySQL 有不同的方式。例如,为了执行写入测试,会创建一个连接,执行语句,然后关闭连接。为了避免每次需要时都打开连接的成本,发展了连接池的概念。连接池是一种创建和管理一组准备供应用程序的任何线程使用的连接的技术。

将在第十三章中讨论的高可用性概念扩展到连接,以提高生产系统的弹性,可以使用负载均衡器连接到数据库集群。通过负载均衡和 MySQL 高可用性,可以使应用程序持续运行而不中断(或仅有轻微的停机时间)。基本上,如果源服务器或数据库集群的一个节点失败,客户端只需连接到另一个数据库节点,就可以继续提供服务。

负载均衡器旨在为客户端连接到 MySQL 基础设施提供透明性。这样,应用程序不需要了解 MySQL 的拓扑结构;无论你使用经典复制、组复制还是 Galera 集群都无关紧要。负载均衡器将提供一个在线节点,可以进行读写查询。拥有强大的 MySQL 架构和适当的负载均衡器可以帮助数据库管理员避免彻夜未眠。

使用应用程序驱动程序进行负载均衡

要将应用程序连接到 MySQL,你需要一个驱动程序。驱动程序是一个适配器,用于将应用程序连接到不同的系统类型。这类似于将视频卡连接到计算机;你可能需要下载并安装一个驱动程序才能使其与你的应用程序配合工作。

现代常用编程语言的 MySQL 驱动程序支持连接池、负载均衡和故障转移。例如,MySQL 的 JDBC 驱动程序(MySQL Connector/J)PDO_MYSQL驱动程序实现了 PHP 数据对象(PDO)接口,使 PHP 能够访问 MySQL 数据库。

我们提到的数据库驱动程序旨在为客户端连接到独立的 MySQL 服务器或 MySQL 复制设置提供透明性。我们不会向你展示如何在代码中使用它们,因为那超出了本书的范围;然而,你应该知道添加一个驱动程序库有助于代码开发,因为驱动程序为开发人员抽象了大量的工作。

但对于其他拓扑结构,例如 Galera Cluster 用于 MySQL 或 MariaDB 的集群设置,JDBC 和 PHP 驱动程序不了解内部 Galera 状态信息。例如,Galera 捐赠节点在帮助另一个节点重新同步时可能处于只读模式(如果 SST 方法是mysqldumprsync),或者在分裂大脑发生时可能处于非主状态。另一种解决方案是在客户端和数据库集群之间使用负载均衡器。

ProxySQL 负载均衡器

ProxySQL 是一个 SQL 代理。ProxySQL 实现了 MySQL 协议,因此可以执行其他代理无法执行的操作。以下是其一些优点:

  • 它提供对多个数据库的应用程序请求的“智能”负载平衡。

  • 它理解通过它传递的 MySQL 流量,并可以将读取操作与写入操作分离。在源/副本复制设置中,理解 MySQL 协议尤其有用,其中写入应仅发送到源,读取应发送到副本,或者在 Galera 集群的情况下均匀分配读取查询(线性读取扩展)。

  • 它了解底层数据库拓扑结构,包括实例是否运行,因此可以将请求路由到健康的数据库。

  • 它提供查询工作负载分析和查询缓存,对于分析和提升性能非常有用。

  • 它为管理员提供了强大且丰富的查询规则定义,以高效分发查询并缓存数据,从而最大化数据库服务的效率。

ProxySQL 作为一个守护进程运行,并由监控进程监视。该进程监控守护进程,在崩溃时重新启动以最小化停机时间。守护进程接受来自 MySQL 客户端的传入流量并将其转发到后端 MySQL 服务器。

代理设计为持续运行,无需重新启动。大多数配置可以在运行时使用类似 SQL 语句的查询在 ProxySQL 管理界面进行。这些包括运行时参数、服务器分组和与流量相关的设置。

虽然通常将 ProxySQL 安装在应用程序和数据库之间的独立节点上,但由于网络跳数增加而影响查询性能。图 15-1 显示了 ProxySQL 作为中间层。

lm2e 1501

图 15-1. 应用程序与 MySQL 之间的 ProxySQL

为了减少对性能的影响(并避免额外的网络跳数),另一种架构选项是将 ProxySQL 安装在应用程序服务器上。应用程序然后通过 Unix 域套接字连接到本地主机上的 ProxySQL(充当 MySQL 服务器),避免额外的延迟。它使用其路由规则与具有连接池的实际 MySQL 服务器进行通信。应用程序对 ProxySQL 之外的情况一无所知。图 15-2 显示了 ProxySQL 与应用程序位于同一服务器上的情况。

lm2e 1502

图 15-2. 与应用程序位于同一服务器上的 ProxySQL

安装和配置 ProxySQL

让我们看看如何为源/复制配置部署 ProxySQL。

工具的开发者在他们的GitHub 发行页面上为各种 Linux 发行版提供官方包,适用于所有 ProxySQL 版本的发布,因此我们将从那里下载最新的软件包版本并安装它。

在安装之前,以下实例是我们在此过程中将要使用的实例:

+---------------------------------------+----------------------+
| vinicius-grippa-default(mysql)        | 10.124.33.5 (eth0)   |
+---------------------------------------+----------------------+
| vinicius-grippa-node1(mysql)          | 10.124.33.169 (eth0) |
+---------------------------------------+----------------------+
| vinicius-grippa-node2(mysql)          | 10.124.33.130 (eth0) |
+---------------------------------------+----------------------+
| vinicius-grippa-node3(proxysql)       | 10.124.33.170 (eth0) |
+---------------------------------------+----------------------+

要开始,找到适合您操作系统的适当分发。在本例中,我们将为 CentOS 7 安装。首先,我们将成为 root 用户,安装 MySQL 客户端以连接 ProxySQL,并安装 ProxySQL 本身。我们从下载页面获取 URL 并将其引用到 yum

$ sudo su - root
# yum -y install https://repo.percona.com/yum/percona-release-latest.noarch.rpm
# yum -y install Percona-Server-client-57
# yum install -y https://github.com/sysown/proxysql/releases/download/v2.0.15/ \
     proxysql-2.0.15-1-centos7.x86_64.rpm

我们已经具备运行 ProxySQL 的所有要求,但安装后服务不会自动启动,因此我们手动启动它:

# sudo systemctl start proxysql

ProxySQL 现在应该按照其默认配置运行。我们可以通过运行此命令来检查它:

# systemctl status proxysql

ProxySQL 进程处于活动状态的输出应类似于以下内容:

 proxysql.service - High Performance Advanced Proxy for MySQL
   Loaded: loaded (/etc/systemd/system/proxysql.service; enabled; vendor...
   Active: active (running) since Sun 2021-05-23 18:50:28 UTC; 15s ago
  Process: 1422 ExecStart=/usr/bin/proxysql --idle-threads -c /etc/proxysql...
 Main PID: 1425 (proxysql)
   CGroup: /system.slice/proxysql.service
           ├─1425 /usr/bin/proxysql --idle-threads -c /etc/proxysql.cnf
           └─1426 /usr/bin/proxysql --idle-threads -c /etc/proxysql.cnf

May 23 18:50:27 vinicius-grippa-node3 systemd[1]: Starting High Performance...
May 23 18:50:27 vinicius-grippa-node3 proxysql[1422]: 2021-05-23 18:50:27...
May 23 18:50:27 vinicius-grippa-node3 proxysql[1422]: 2021-05-23 18:50:27...
May 23 18:50:27 vinicius-grippa-node3 proxysql[1422]: 2021-05-23 18:50:27...
May 23 18:50:28 vinicius-grippa-node3 systemd[1]: Started High Performance...

ProxySQL 将应用程序接口与管理接口分开。这意味着 ProxySQL 将监听两个网络端口:管理接口将监听 6032 端口,应用程序将监听 6033 端口(为了更容易记住,这是 MySQL 默认端口 3306 的反向)。

接下来,ProxySQL 需要与 MySQL 节点通信以能够检查其状态。为实现此目的,ProxySQL 需要连接到每个服务器,并使用专用用户。

首先,在源服务器上创建用户。连接到 MySQL 源实例并运行以下命令:

mysql> `CREATE` `USER` `'proxysql'``@``'%'` `IDENTIFIED` `by` `'$3Kr$t'``;`
mysql> `GRANT` `USAGE` `ON` `*``.``*` `TO` `'proxysql'``@``'%'``;`

接下来,我们将配置 ProxySQL 参数以识别用户。首先连接到 ProxySQL:

# mysql -uadmin -padmin -h 127.0.0.1 -P 6032

然后设置参数:

proxysql> `UPDATE` `global_variables` `SET` `variable_value``=``'proxysql'`
       -> `WHERE` `variable_name``=``'mysql-monitor_username'``;`
proxysql> `UPDATE` `global_variables` `SET` `variable_value``=``'$3Kr$t'`
       -> `WHERE` `variable_name``=``'mysql-monitor_password'``;`
proxysql> `LOAD` `MYSQL` `VARIABLES` `TO` `RUNTIME``;`
proxysql> `SAVE` `MYSQL` `VARIABLES` `TO` `DISK``;`

现在我们在数据库和 ProxySQL 中设置了用户,现在是时候告诉 ProxySQL 数据库中存在哪些 MySQL 服务器了:

proxysql> `INSERT` `INTO` `mysql_servers``(``hostgroup_id``,` `hostname``,` `port``)`
       -> `VALUES` `(``10``,``'10.124.33.5'``,``3306``)``;`
proxysql> `INSERT` `INTO` `mysql_servers``(``hostgroup_id``,` `hostname``,` `port``)`
       -> `VALUES` `(``11``,``'10.124.33.169'``,``3306``)``;`
proxysql> `INSERT` `INTO` `mysql_servers``(``hostgroup_id``,` `hostname``,` `port``)`
       -> `VALUES` `(``11``,``'10.124.33.130'``,``3306``)``;`
proxysql> `LOAD` `MYSQL` `SERVERS` `TO` `RUNTIME``;`
proxysql> `SAVE` `MYSQL` `SERVERS` `TO` `DISK``;`

下一步是定义我们的写入组和读取组。位于写入组的服务器将能够接收 DML 操作,而 SELECT 查询将使用读取组中的服务器。在本例中,主机组 10 将是写入组,主机组 11 将是读取组:

proxysql> `INSERT` `INTO` `mysql_replication_hostgroups`
       -> `(``writer_hostgroup``,` `reader_hostgroup``)` `VALUES` `(``10``,` `11``)``;`
proxysql> `LOAD` `MYSQL` `SERVERS` `TO` `RUNTIME``;`
proxysql> `SAVE` `MYSQL` `SERVERS` `TO` `DISK``;`

接下来,ProxySQL 必须具有能够访问后端节点以管理连接的用户。让我们在后端源服务器上创建用户:

mysql> `CREATE` `USER` `'app'``@``'%'` `IDENTIFIED` `by` `'$3Kr$t'``;`
mysql> `GRANT` `ALL` `PRIVILEGES` `ON` `*``.``*` `TO` `'app'``@``'%'``;`

现在我们将使用用户配置 ProxySQL:

proxysql> `INSERT` `INTO` `mysql_users` `(``username``,``password``,``default_hostgroup``)`
       -> `VALUES` `(``'app'``,``'$3Kr$t'``,``10``)``;`
proxysql> `LOAD` `MYSQL` `USERS` `TO` `RUNTIME``;`
proxysql> `SAVE` `MYSQL` `USERS` `TO` `DISK``;`

接下来的步骤是最令人兴奋的,因为在这里我们定义规则。规则将告诉 ProxySQL 将写入和读取查询发送到哪里,从而平衡服务器的负载:

proxysql> `INSERT` `INTO` `mysql_query_rules`
       -> `(``rule_id``,``username``,``destination_hostgroup``,``active``,``match_digest``,``apply``)`
       -> `VALUES``(``1``,``'app'``,``10``,``1``,``'^SELECT.*FOR UPDATE'``,``1``)``;`
proxysql> `INSERT` `INTO` `mysql_query_rules`
       -> `(``rule_id``,``username``,``destination_hostgroup``,``active``,``match_digest``,``apply``)`
       -> `VALUES``(``2``,``'app'``,``11``,``1``,``'^SELECT '``,``1``)``;`
proxysql> `LOAD` `MYSQL` `QUERY` `RULES` `TO` `RUNTIME``;`
proxysql> `SAVE` `MYSQL` `QUERY` `RULES` `TO` `DISK``;`

ProxySQL 在 mysql_servers 表中每个服务器连接上都有一个线程负责,并检查 read_only 变量的值。假设复制品显示在写入组中,如下所示:

proxysql> `SELECT` `*` `FROM` `mysql_servers``;`
+--------------+---------------+------+-----------+...
| hostgroup_id | hostname      | port | gtid_port |...
+--------------+---------------+------+-----------+...
| 10           | 10.124.33.5   | 3306 | 0         |...
| 11           | 10.124.33.169 | 3306 | 0         |...
| 11           | 10.124.33.130 | 3306 | 0         |...
+--------------+---------------+------+-----------+...
...+--------------+---------------+------+-----------+
...| status | weight | compression | max_connections |...
...+--------+--------+-------------+-----------------+...
...| ONLINE | 1      | 0           | 1000            |...
...| ONLINE | 1      | 0           | 1000            |...
...| ONLINE | 1      | 0           | 1000            |...
...+--------+--------+-------------+-----------------+...
...+---------------------+---------+----------------+---------+
...| max_replication_lag | use_ssl | max_latency_ms | comment |
...+---------------------+---------+----------------+---------+
...| 0                   | 0       | 0              |         |
...| 0                   | 0       | 0              |         |
...| 0                   | 0       | 0              |         |
...+---------------------+---------+----------------+---------+
3 rows in set (0.00 sec)

因为我们不希望 ProxySQL 向副本服务器写入数据,这会导致数据不一致,所以我们需要在副本服务器中设置read_only选项,这样这些服务器只会处理读取查询。

mysql> `SET` `GLOBAL` `read_only``=``1``;`

现在我们可以使用我们的应用程序了。运行以下命令应该返回 ProxySQL 连接的主机名:

$ `mysql` `-``uapp` `-``p``'$3Kr$t'` `-``h` `127``.``0``.``0``.``1` `-``P` `6033` `-``e` `"select @@hostname;"`
+-----------------------+
| @@hostname            |
+-----------------------+
| vinicius-grippa-node1 |
+-----------------------+

ProxySQL 拥有比我们在这里展示的更多功能和灵活性;我们本节的目标只是介绍这个工具,以便您在决定架构时考虑这个选项。

注意

正如我们在配置第十三章复制时提到的,我们要强调 ProxySQL 需要能够访问 MySQL 服务器;否则,它将无法工作。

HAProxy 负载均衡器

HAProxy 代表高可用性代理,它是一个 TCP/HTTP 负载均衡器。它将工作负载分布到一组服务器上,以最大化性能并优化资源使用。

为了扩展关于 MySQL 架构和不同拓扑结构的知识,本节我们将配置 Percona XtraDB 集群(Galera 集群),而不是传统的复制拓扑。

架构选项类似于 ProxySQL。HAProxy 可以与应用程序一起放置或者放在中间层。图 15-3 展示了一个示例,其中 HAProxy 与应用程序放在同一台服务器上。

lm2e 1503

图 15-3. HAProxy 与应用程序一起

并且 图 15-4 显示了一个中间层中带有 HAProxy 的拓扑结构。

同样,这些是具有不同优缺点的架构。在第一个架构中,我们没有额外的跳跃(从而减少延迟),但会增加应用服务器的负载。此外,您需要在每台应用服务器上配置 HAProxy。

另一方面,将 HAProxy 放在中间层有助于管理并增加可用性,因为应用程序可以连接到任何 HAProxy 服务器。然而,额外的跳跃会增加延迟。

lm2e 1504

图 15-4. 在专用服务器上运行中间层中的 HAProxy

安装和配置 HAProxy

常见操作系统如 Red Hat/CentOS 和 Debian/Ubuntu 提供了 HAProxy 包,你可以使用包管理器安装它。安装过程相对比较简单。

对于 Debian 或 Ubuntu,请使用以下命令:

# apt update
# apt install haproxy

对于 Red Hat 或 CentOS,请使用:

# sudo yum update
# sudo yum install haproxy

安装完成后,HAProxy 将设置配置文件的默认路径为/etc/haproxy/haproxy.cfg

在启动 HAProxy 之前,我们需要对其进行配置。在这个演示中,我们的第一个场景中,HAProxy 将位于与应用程序相同的服务器上。以下是我们三节点 Galera 集群的 IP 地址:

172.16.3.45/Port:3306
172.16.1.72/Port:3306
172.16.0.161/Port:3306

让我们打开我们的/etc/haproxy/haproxy.cfg文件并查看它。有许多参数可以自定义,分为三个部分:

global

进程全局参数的配置文件中的一个部分

defaults

配置文件中用于默认参数的一个部分

listen

配置文件中定义完整代理(包括其前端和后端部分)的部分

表 15-1 显示了基本的 HAProxy 参数。

表 15-1. HAProxy 选项(附带链接到 HAProxy 文档)

参数 描述
balance 定义在后端使用的负载平衡算法。
clitimeout 设置客户端端的最大不活动时间
contimeout 设置连接到服务器尝试成功的最长等待时间
daemon 使进程在后台分叉(推荐的操作模式)
gid 将进程的组 ID 更改为*<number>*
log 添加全局 syslog 服务器
maxconn 设置每个进程的并发连接数上限为*<number>*
mode 设置实例的运行模式或协议
option dontlognull 禁止记录空连接
optiontcplog 启用带有会话状态和计时器的 TCP 连接的高级日志记录

要使 HAProxy 工作,我们将根据我们的设置使用以下配置文件:

global
    log /dev/log   local0
    log /dev/log   local1 notice
    maxconn 4096
    #debug
    #quiet
    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    user        haproxy
    group       haproxy
    daemon

    # turn on stats unix socket
    stats socket /var/lib/haproxy/stats

#---------------------------------------------------------------------
# common defaults that all the 'listen' and 'backend' sections will
# use if not designated in their block
#---------------------------------------------------------------------
defaults
     log     global
     mode    http
     option  tcplog
     option  dontlognull
     retries 3
     redispatch
     maxconn 2000
     contimeout      5000
     clitimeout      50000
     srvtimeout      50000

#---------------------------------------------------------------------
# round robin balancing between the various backends
#---------------------------------------------------------------------
listen mysql-pxc-cluster 0.0.0.0:3307
     mode tcp
     bind *:3307
     timeout client  10800s
     timeout server  10800s
     balance roundrobin
     option httpchk

     server vinicius-grippa-node2 172.16.0.161:3306 check port 9200
     inter 12000 rise 3 fall 3
     server vinicius-grippa-node1 172.16.1.72:3306 check port 9200 inter 12000

     rise 3 fall 3
     server vinicius-grippa-default 172.16.3.45:3306 check port 9200
     inter 12000 rise 3 fall 3

要启动 HAProxy,我们使用haproxy命令。我们可以在命令行上传递任意数量的配置参数。要使用配置文件,请使用-f选项。例如,我们可以传递一个配置文件:

# sudo haproxy -f /etc/haproxy/haproxy.cfg

或者多个配置文件:

# sudo haproxy -f /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy-2.cfg

或者一个目录:

# sudo haproxy -f conf-dir

使用这个配置,HAProxy 将在三个节点之间平衡负载。在这种情况下,它仅检查mysqld进程是否在 3306 端口上监听,但不考虑节点的状态。因此,即使节点处于JOININGDISCONNECTED状态,它也可能向运行mysqld的节点发送查询。

要检查节点的当前状态,我们需要更复杂的东西。这个想法来自Codership 的 Google 小组

要实施此设置,我们将需要两个脚本:

  • clustercheck位于/usr/local/bin,并配置了xinetd

  • mysqlchk位于每个节点的/etc/xinetd.d

这两个脚本都可用在 Percona XtraDB 的二进制和源分发中。

通过为每个节点添加以下行来更改/etc/services文件:

mysqlchk        9200/tcp                # mysqlchk

如果/etc/services文件不存在,则可能未安装xinetd

要为 CentOS/Red Hat 安装它,请使用:

# yum install -y xinetd

对于 Debian/Ubuntu,请使用:

# sudo apt-get install -y xinetd

接下来,我们需要创建一个 MySQL 用户,以便脚本可以检查节点是否健康。理想情况下,出于安全原因,此用户应具有所需的最低权限:

mysql> CREATE USER 'clustercheckuser'@'localhost' IDENTIFIED BY
               'clustercheckpassword!';
    -> GRANT PROCESS ON *.* TO 'clustercheckuser'@'localhost';

要验证我们的节点在健康检查上的表现,我们可以运行以下命令并观察输出:

# /usr/bin/clustercheck
HTTP/1.1 200 OK
Content-Type: text/plain
Connection: close
Content-Length: 40

Percona XtraDB Cluster Node is synced.

如果我们对所有节点都这样做,就准备好测试我们的 HAProxy 设置是否有效了。最简单的方法是连接到它并执行一些 MySQL 命令。让我们运行一个检索我们所连接的主机名的命令:

# mysql -uroot -psecret -h 127.0.0.1 -P 3307 -e "select @@hostname"
+-----------------------+
| @@hostname            |
+-----------------------+
| vinicius-grippa-node1 |
+-----------------------+

第二次运行得到:

$ mysql -uroot -psecret -h 127.0.0.1 -P 3307 -e "select @@hostname"
mysql: [Warning] Using a password on the command line interface can be
insecure.
+-----------------------+
| @@hostname            |
+-----------------------+
| vinicius-grippa-node2 |
+-----------------------+

第三次我们得到:

$ mysql -uroot -psecret -h 127.0.0.1 -P 3307 -e "select @@hostname"
mysql: [Warning] Using a password on the command line interface can be
insecure.
+-------------------------+
| @@hostname              |
+-------------------------+
| vinicius-grippa-default |
+-------------------------+

如您所见,我们的 HAProxy 以循环方式进行连接。如果我们关闭其中一个节点,HAProxy 将只路由到剩余的节点。

MySQL Router

MySQL Router 负责在 InnoDB 集群的成员之间分发流量。它是一个类似代理的解决方案,用于隐藏应用程序对集群拓扑的了解,因此应用程序无需知道集群的哪个成员是主节点,哪些是从节点。请注意,MySQL Router 不适用于 Galera 集群;它仅为 InnoDB 集群开发。

MySQL Router 能够通过公开不同的接口执行读写分离。常见的设置是有一个读写接口和一个只读接口。这是默认行为,同时还公开了两个类似的接口以使用 X 协议(用于 CRUD 操作和异步调用)。

读写分离是通过角色的概念来完成的:主要用于写入,次要用于只读。这类似于集群成员的命名方式。此外,每个接口通过 TCP 端口公开,因此应用程序只需知道用于写入的 IP:端口组合和用于读取的 IP:端口组合。然后 MySQL Router 将根据流量类型管理与集群成员的连接。

在生产环境中工作时,构成 InnoDB 集群的 MySQL 服务器实例运行在多台主机上,作为网络的一部分,而不是单台机器上。因此,与 ProxySQL 和 HAProxy 一样,MySQL 路由器可以作为架构中的中间层。

图 15-5 说明了生产场景的工作原理。

lm2e 1505

图 15-5. MySQL InnoDB 集群生产部署

现在,为了启动我们的示例,让我们查看作为 InnoDB 集群一部分的 MySQL 成员:

mysql> `SELECT` `member_host``,` `member_port``,` `member_state``,` `member_role`
    -> `FROM` `performance_schema``.``replication_group_members``;`
+--------------+-------------+--------------+-------------+
| member_host  | member_port | member_state | member_role |
+--------------+-------------+--------------+-------------+
| 172.16.3.9   |        3306 | ONLINE       | SECONDARY   |
| 172.16.3.127 |        3306 | ONLINE       | SECONDARY   |
| 172.16.3.120 |        3306 | ONLINE       | PRIMARY     |
+--------------+-------------+--------------+-------------+
3 rows in set (0.00 sec)
mysql> `SELECT` `cluster_name` `FROM` `mysql_innodb_cluster_metadata``.``clusters``;`
+--------------+
| cluster_name |
+--------------+
| cluster1     |
+--------------+
1 row in set (0.00 sec)

现在我们已经有了 MySQL 节点的配置和集群名称,可以开始配置 MySQL Router 了。出于性能考虑,建议将 MySQL Router 设置在与应用程序相同的位置,假设每个应用服务器都有一个实例,因此我们将把路由器放置在应用服务器上。首先,我们将确定适用于我们操作系统的 MySQL Router 版本:

# cat /etc/*release
CentOS Linux release 7.9.2009 (Core)

现在,我们将检查下载页面,并使用yum进行安装:

# yum install -y https://dev.mysql.com/get/Downloads/MySQL-Router/mysql-
router-community-8.0.23-1.el7.x86_64.rpm
Loaded plugins: fastestmirror
mysql-router-community-8.0.23-1.el7.x86_64.rpm                                                                                                                                           |  34 MB  00:00:01
Examining /var/tmp/yum-root-_ljdTQ/mysql-router-community-8.0.23-1.el7.x
86_64.rpm: mysql-router-community-8.0.23-1.el7.x86_64
Marking /var/tmp/yum-root-_ljdTQ/mysql-router-community-8.0.23-1.el7.x86
_64.rpm to be installed
Resolving Dependencies
--> Running transaction check
...
Running transaction
  Installing : mysql-router-community-8.0.23-1.el7.x86_64
  1/1
  Verifying  : mysql-router-community-8.0.23-1.el7.x86_64
  1/1

Installed:
  mysql-router-community.x86_64 0:8.0.23-1.el7

Complete!

现在 MySQL Router 已经安装好,我们需要为其操作创建一个专用目录:

# mkdir /var/lib/mysqlrouter

接下来,我们将进行 MySQL Router 的引导操作。引导将配置路由器以与 MySQL InnoDB 集群一起运行:

# mysqlrouter --bootstrap root@172.16.3.120:3306 \
    --directory /var/lib/mysqlrouter --conf-use-sockets \
    --account app_router --account-create always \
    --user=mysql
Please enter MySQL password for root:
# Bootstrapping MySQL Router instance at '/var/lib/mysqlrouter'...

Please enter MySQL password for app_router:
- Creating account(s)
- Verifying account (using it to run SQL queries that would be run by
Router)
- Storing account in keyring
- Adjusting permissions of generated files
- Creating configuration /var/lib/mysqlrouter/mysqlrouter.conf

...

## MySQL Classic protocol

- Read/Write Connections: localhost:6446, /var/lib/mysqlrouter/mysql.sock
- Read/Only Connections:  localhost:6447,
/var/lib/mysqlrouter/mysqlro.sock

## MySQL X protocol

- Read/Write Connections: localhost:64460,
/var/lib/mysqlrouter/mysqlx.sock
- Read/Only Connections:  localhost:64470,
/var/lib/mysqlrouter/mysqlxro.sock

在命令行中,我们正在告诉路由器使用用户 root 连接到我们的主服务器(172.16.3.120),端口为 3306。我们还告诉路由器创建一个套接字文件,以便我们可以使用它进行连接。最后,我们创建一个新用户(app_router)用于我们的应用程序。

让我们看看引导过程在我们的配置目录(/var/lib/mysqlrouter)中创建的内容。

# ls -l | awk '{print $9}'
data
log
mysqlrouter.conf
mysqlrouter.key
run
start.sh
stop.sh

生成的 MySQL Router 配置文件(mysqlrouter.conf)看起来类似于这样:

# cat mysqlrouter.conf
# File automatically generated during MySQL Router bootstrap
[DEFAULT]
user=mysql
logging_folder=/var/lib/mysqlrouter/log
runtime_folder=/var/lib/mysqlrouter/run

...

[rest_routing]
require_realm=default_auth_realm

[rest_metadata_cache]
require_realm=default_auth_realm

在这个例子中,MySQL Router 配置了四个端口(两个端口用于使用常规 MySQL 协议进行读/写,另外两个端口用于使用 X 协议进行读/写),以及四个套接字。端口是默认添加的,套接字是因为我们传入了 --conf-use-sockets 参数。InnoDB 集群命名为 cluster1 是元数据的来源,目标是使用 InnoDB 集群元数据缓存来动态配置主机信息。

通过执行 start.sh 脚本,我们可以启动 MySQL 路由器守护进程:

# ./start.sh
# PID 1684 written to '/var/lib/mysqlrouter/mysqlrouter.pid'
logging facility initialized, switching logging to loggers specified in
configuration

现在,我们可以观察到进程正在运行:

# ps -ef | grep -i mysqlrouter
root      1683     1  0 17:36 pts/0    00:00:00 sudo
ROUTER_PID=/var/lib/mysqlrouter/mysqlrouter.pid /usr/bin/mysqlrouter -c
/var/lib/mysqlrouter/mysqlrouter.conf --user=mysql
mysql     1684  1683  0 17:36 pts/0    00:00:17 /usr/bin/mysqlrouter -c
/var/lib/mysqlrouter/mysqlrouter.conf --user=mysql
root      1733  1538  0 17:41 pts/0    00:00:00 grep --color=auto -i
mysqlrouter

端口已打开:

# netstat -tulnp | grep -i mysqlrouter
tcp   0   0 0.0.0.0:64470   0.0.0.0:*   LISTEN   1684/mysqlrouter
tcp   0   0 0.0.0.0:8443    0.0.0.0:*   LISTEN   1684/mysqlrouter
tcp   0   0 0.0.0.0:64460   0.0.0.0:*   LISTEN   1684/mysqlrouter
tcp   0   0 0.0.0.0:6446    0.0.0.0:*   LISTEN   1684/mysqlrouter
tcp   0   0 0.0.0.0:6447    0.0.0.0:*   LISTEN   1684/mysqlrouter

我们已经使用 InnoDB 集群配置了 MySQL Router,现在可以通过读和读/写连接进行测试。首先,我们将连接到写入端口(6446):

# mysql -uroot -psecret -h 127.0.0.1 -P 6446 \
    -e "create database learning_mysql;"
# mysql -uroot -psecret -h 127.0.0.1 -P 6446 \
    -e "use learning_mysql; select database()"
+----------------+
| database()     |
+----------------+
| learning_mysql |
+----------------+

如您所见,可以在写入端口执行读和写操作。

现在我们将使用 SELECT 语句检查读端口(6447):

# mysql -uroot -psecret -h 127.0.0.1 -P 6447 \
    -e "use learning_mysql; select database()"
+----------------+
| database()     |
+----------------+
| learning_mysql |
+----------------+

这是有效的,但让我们尝试执行一个写操作:

# mysql -uroot -psecret -h 127.0.0.1 -P 6447 \
    -e "create database learning_mysql_write;"
ERROR 1290 (HY000) at line 1: The MySQL server is running with the
--super-read-only option so it cannot execute this statement

因此,读端口仅接受读操作。还可以看到路由器在负载均衡读操作:

# mysql -uroot -psecret -h 127.0.0.1 -P 6447 -e "select @@hostname"
+-----------------------+
| @@hostname            |
+-----------------------+
| vinicius-grippa-node1 |
+-----------------------+
# mysql -uroot -psecret -h 127.0.0.1 -P 6447 -e "select @@hostname"
insecure.
+-----------------------+
| @@hostname            |
+-----------------------+
| vinicius-grippa-node2 |
+-----------------------+

当一个 MySQL 节点发生任何停机情况时,MySQL Router 将路由查询到其余的活跃节点。

第十六章:杂项主题

本章的目的是超越故障排除查询、过载系统或设置不同的 MySQL 拓扑结构。我们希望向您展示可用于使日常任务更轻松或调查复杂问题的工具库。让我们从 MySQL Shell 开始。

MySQL Shell

MySQL Shell 是 MySQL 的高级客户端和代码编辑器。它扩展了传统 MySQL 客户端的功能,大多数 DBA 在 MySQL 5.6 和 5.7 中使用过。MySQL Shell 支持 Python、JavaScript 和 SQL 等编程语言。它还通过 API 命令语法扩展功能。例如,可以定制脚本以管理 InnoDB Cluster。通过 MySQL Shell,还可以启动和配置 MySQL 沙盒实例。

安装 MySQL Shell

对于支持的 Linux 发行版,安装 MySQL Shell 的最简单方法是使用 MySQL yumapt仓库。让我们看看如何在 Ubuntu 和 CentOS 上安装它。

在 Ubuntu 20.04 Focal Fossa 上安装 MySQL Shell

在 Ubuntu 中安装 MySQL Shell 相对简单,因为它是常规仓库的一部分。

首先,我们需要配置 MySQL 仓库。我们可以使用这些命令将apt仓库下载到我们的服务器并安装它:

# wget  https://dev.mysql.com/get/mysql-apt-config_0.8.16-1_all.deb
# dpkg -i mysql-apt-config_0.8.16-1_all.deb

安装完成后,更新我们的软件包信息:

# apt-get update

然后执行install命令安装 MySQL Shell:

# apt-get install mysql-shell

我们现在可以使用命令行启动 MySQL Shell:

# mysqlsh
MySQL Shell 8.0.23

Copyright (c) 2016, 2021, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its affiliates.
Other names may be trademarks of their respective owners.

Type '\help' or '\?' for help; '\quit' to exit.
 MySQL  JS >

在 CentOS 8 上安装 MySQL Shell

要在 CentOS 8 中安装 MySQL Shell,我们需要遵循与 Ubuntu 描述相同的步骤,但首先需要确保 CentOS 8 中存在的默认 MySQL 软件包被禁用:

# yum remove mysql-community-release -y
No match for argument: mysql-community-release
No packages marked for removal.
Dependencies resolved.
Nothing to do.
Complete!
# dnf erase mysql-community-release
No match for argument: mysql-community-release
No packages marked for removal.
Dependencies resolved.
Nothing to do.
Complete!

接下来,我们将配置我们的yum仓库。我们需要从下载页面获取正确的操作系统版本:

# yum install \
    https://dev.mysql.com/get/mysql80-community-release-el8-1.noarch.rpm -y

安装了仓库后,我们将安装 MySQL Shell 二进制文件:

# yum install mysql-shell -y

我们可以通过运行它来验证安装是否成功:

# mysqlsh
MySQL Shell 8.0.23

Copyright (c) 2016, 2021, Oracle and/or its affiliates.
Oracle is a registered trademark of Oracle Corporation and/or its affiliates.
Other names may be trademarks of their respective owners.

Type '\help' or '\?' for help; '\quit' to exit.
 MySQL  JS >

使用 MySQL Shell 部署 Sandbox InnoDB Cluster

MySQL Shell 通过提供dba.deploySandboxInstance(port_number)命令自动化部署沙盒实例。

默认情况下,沙盒实例放置在名为$HOME/mysql-sandboxes/port的目录中。让我们看看如何更改目录:

# mkdir /var/lib/sandboxes
# mysqlsh
 MySQL  JS > `shell``.``options``.``sandboxDir``=``'/var/lib/sandboxes'`
/var/lib/sandboxes

部署沙盒实例的先决条件是安装 MySQL 二进制文件。如果需要,详细信息请查看第一章。您需要输入root用户的密码以完成部署:

MySQL  JS > `dba``.``deploySandboxInstance``(``3310``)`
A new MySQL sandbox instance will be created on this host in
/var/lib/sandboxes/3310

Warning: Sandbox instances are only suitable for deploying and
running on your local machine for testing purposes and are not
accessible from external networks.

Please enter a MySQL root password for the new instance: ******

Deploying new MySQL instance...

Instance localhost:3310 successfully deployed and started.
Use shell.connect('root@localhost:3310') to connect to the instance.

我们打算部署另外两个实例:

 MySQL  JS > `dba``.``deploySandboxInstance``(``3320``)`
 MySQL  JS > `dba``.``deploySandboxInstance``(``3330``)`

下一步是在连接到种子 MySQL 服务器实例时创建 InnoDB 集群。种子实例是我们通过 MySQL Shell 连接的实例,我们希望将其复制到其他实例。在此示例中,沙盒实例都是空白实例,因此我们可以选择任何实例。在生产设置中,种子实例将是包含要复制到集群中其他实例的现有数据集的实例。

我们使用此命令将 MySQL Shell 连接到种子实例,本例中的端口为 3310:

MySQL  JS > `\``connect` `root``@``localhost``:``3310`
Creating a session to *root@localhost:3310*
Please provide the password for *root@localhost:3310*: 
Save password for *root@localhost:3310*? [Y]es/[N]o/Ne[v]er (default No): Y
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 12
Server version: 8.0.21 Source distribution
No default schema selected; type \use <schema> to set one.

接下来,我们将使用createCluster()方法创建 InnoDB 集群,当前连接的实例将作为种子:

MySQL localhost:3310 ssl  JS > `var` `cluster` `=` `dba``.``createCluster``(``'learning_mysql'``)`
A new InnoDB cluster will be created on instance 'localhost:3310'.

Validating instance configuration at localhost:3310...
NOTE: Instance detected as a sandbox.
Please note that sandbox instances are only suitable for deploying test clusters
for use within the same host.

This instance reports its own address as 127.0.0.1:3310

Instance configuration is suitable.
NOTE: Group Replication will communicate with other members using
'127.0.0.1:33101'. Use the localAddress option to override.

Creating InnoDB cluster 'learning_mysql' on '127.0.0.1:3310'...

Adding Seed Instance...
Cluster successfully created. Use Cluster.addInstance() to add MySQL instances.
At least 3 instances are needed for the cluster to be able to withstand up to
one server failure.

正如我们在输出中所看到的,三个实例能够在一台服务器故障时保持数据库在线,这就是为什么我们部署了三个沙盒实例。

下一步是将次要实例添加到我们的learning_mysql InnoDB 集群中。由种子实例执行的任何事务都将在添加每个次要实例时重新执行。

本示例中的种子实例最近创建,因此几乎为空。因此,需要从种子实例复制的数据很少。如果需要复制数据,MySQL 将使用克隆插件(在“使用克隆插件创建副本”中讨论)自动配置实例。

让我们添加一个次要实例来看看实际操作过程。要将第二个实例添加到 InnoDB 集群中:

MySQL  localhost:3310 ssl  JS >  `cluster``.``addInstance``(``'root@localhost:3320'``)`
...

* Waiting for clone to finish...
NOTE: 127.0.0.1:3320 is being cloned from 127.0.0.1:3310
** Stage DROP DATA: Completed
** Clone Transfer
    FILE COPY  ############################################################
    100%  Completed
    PAGE COPY  ############################################################
    100%  Completed
    REDO COPY  ############################################################
    100%  Completed

NOTE: 127.0.0.1:3320 is shutting down...

* Waiting for server restart... ready
* 127.0.0.1:3320 has restarted, waiting for clone to finish...
** Stage RESTART: Completed
* Clone process has finished: 59.62 MB transferred in about 1 second
(~59.62 MB/s)

State recovery already finished for '127.0.0.1:3320'

The instance '127.0.0.1:3320' was successfully added to the cluster

然后添加第三个实例:

 MySQL  localhost:3310 ssl  JS >  `cluster``.``addInstance``(``'root@localhost:3320'``)`

到目前为止,我们已创建了一个包含三个实例的集群:一个主实例和两个次要实例。我们可以通过运行以下命令来查看状态:

 MySQL  localhost:3310 ssl  JS > `cluster``.``status``(``)`
{
    "clusterName": "learning_mysql",
    "defaultReplicaSet": {
        "name": "default",
        "primary": "127.0.0.1:3310",
        "ssl": "REQUIRED",
        "status": "OK",
        "statusText": "Cluster is ONLINE and can tolerate up to ONE failure.",
        "topology": {
            "127.0.0.1:3310": {
                "address": "127.0.0.1:3310",
                "mode": "R/W",
                "readReplicas": {},
                "replicationLag": null,
                "role": "HA",
                "status": "ONLINE",
                "version": "8.0.21"
            },
            "127.0.0.1:3320": {
                "address": "127.0.0.1:3320",
                "mode": "R/O",
                "readReplicas": {},
                "replicationLag": null,
                "role": "HA",
                "status": "ONLINE",
                "version": "8.0.21"
            },
            "127.0.0.1:3330": {
                "address": "127.0.0.1:3330",
                "mode": "R/O",
                "readReplicas": {},
                "replicationLag": null,
                "role": "HA",
                "status": "ONLINE",
                "version": "8.0.21"
            }
        },
        "topologyMode": "Single-Primary"
    },
    "groupInformationSourceMember": "127.0.0.1:3310"

假设 MySQL Router 已安装(请参阅“MySQL Router”),唯一需要的步骤是使用 InnoDB 集群元数据服务器的位置引导它。

我们观察到路由器正在引导启动:

# mysqlrouter --bootstrap root@localhost:3310 --user=mysqlrouter
Please enter MySQL password for root:
# Bootstrapping system MySQL Router instance...

- Creating account(s) (only those that are needed, if any)
...

## MySQL Classic protocol

- Read/Write Connections: localhost:6446
- Read/Only Connections:  localhost:6447

...
```**  **## MySQL Shell 工具

正如我们所说,MySQL Shell 是 MySQL 的强大高级客户端和代码编辑器。在其众多功能中,包括创建整个数据库实例的逻辑转储和逻辑恢复以及用户。与例如`mysqldump`相比,其优势在于具有并行化能力,极大地提高了转储和恢复速度。

这里是执行转储和恢复过程的工具:

`util.dumpInstance()`

转储整个数据库实例,包括用户

`util.dumpSchemas()`

转储一组模式

`util.loadDump()`

将转储加载到目标数据库

`util.dumpTables()`

加载特定的表和视图

让我们依次更仔细地看看每一个。

### util.dumpInstance()

`dumpInstance()` 实用工具将导出 MySQL 数据目录中存在的所有数据库(参见 “MySQL 目录的内容”)。在导出时,将排除 `information_schema`、`mysql_`、`ndbinfo`、`performance_schema` 和 `sys` 这些模式。

还有一个干运行选项,允许您检查模式并查看兼容性问题,然后运行导出并应用适当的兼容性选项以消除问题。现在让我们尝试一下——我们将检查可能的错误并查看导出实用程序的选项。

若要开始导出,请执行以下命令:

MySQL JS > shell``.``connect``(``'root@localhost:48008'``)``;
MySQL localhost:48008 ssl JS > util``.``dumpInstance``(``"/backup"``,
> {``ocimds``: true``, compatibility``: > [``"strip_restricted_grants"``]``,
dryRun``: true``}``)


Acquiring global read lock
Global read lock acquired
Gathering information - done
All transactions have been started
Locking instance for backup
...
NOTE: Database test had unsupported ENCRYPTION option commented out
ERROR: Table 'test'.'sbtest1' uses unsupported storage engine MyISAM
(fix this with 'force_innodb' compatibility option)
Compatibility issues with MySQL Database Service 8.0.23 were found.
Please use the 'compatibility' option to apply compatibility adaptations
to the dumped DDL.
Util.dumpInstance: Compatibility issues were found (RuntimeError)


当设置 `ocimds` 选项为 `true` 时,导出实用程序将检查数据字典和索引字典。DDL 文件中的 `CREATE TABLE` 语句中的加密选项已被注释,以确保所有表位于 MySQL 数据目录中,并使用默认模式加密。`strip_restricted_grants` 删除由 MySQL 数据库服务限制的特定权限,这些权限在用户创建过程中可能导致错误。`dryRun` 是不言自明的:它仅执行验证,实际上不会导出任何数据。

因此,我们在 `test` 数据库中有一个 MyISAM 表。干运行选项明显会抛出错误。

为了修复这个错误,我们将使用 `force_innodb` 选项,在 `CREATE TABLE` 语句中将所有不受支持的引擎转换为 InnoDB:

MySQL localhost:48008 ssl JS > util``.``dumpInstance``(``"backup"``,
> {``ocimds``: true``, compatibility``:
> [``"strip_restricted_grants"``,``"force_innodb"``]``,
dryRun``: true``}``)


现在干运行不会抛出任何错误,也没有异常。让我们运行 `dumpInstance()` 命令来备份一个实例。在执行导出之前,目标目录必须为空。如果父目录中的目录尚不存在,则实用程序会创建它。

我们将并行处理导出。为此,我们将使用 `threads` 选项并设置 10 个线程:

MySQL localhost:48008 ssl JS > util``.``dumpInstance``(``"/backup"``,
> {``ocimds``: true``, compatibility``:
> [``"strip_restricted_grants"``,``"force_innodb"``]``,
> threads : 10 }``)


如果我们观察输出的最后部分,我们会看到:

1 thds dumping - 100% (10.00K rows / ~10.00K rows), 0.00 rows/s, 0.00 B/s
uncompressed, 0.00 B/s

uncompressed
Duration: 00:00:00s
Schemas dumped: 1
Tables dumped: 10
Uncompressed data size: 1.88 MB
Compressed data size: 598.99 KB
Compression ratio: 3.1
Rows written: 10000
Bytes written: 598.99 KB
Average uncompressed throughput: 1.88 MB/s
Average compressed throughput: 598.99 KB/s


如果我们使用 `mysqldump`,我们会得到一个单一的文件。正如我们在这里看到的,备份目录中有多个文件:

@.done.json
@.json
@.post.sql
@.sql
test.json
test@sbtest10@@0.tsv.zst
test@sbtest10@@0.tsv.zst.idx
test@sbtest10.json
test@sbtest10.sql
...
test@sbtest1@@0.tsv.zst
test@sbtest1@@0.tsv.zst.idx
test@sbtest1.json
test@sbtest1.sql
test@sbtest9@@0.tsv.zst
test@sbtest9@@0.tsv.zst.idx
test@sbtest9.json
test@sbtest9.sql
test.sql


让我们看一下这些内容:

+   *@.json* 文件包含服务器详细信息以及用户列表、数据库名称及其字符集。

+   *@.post.sql* 和 *@.sql* 文件包含 MySQL 服务器版本详细信息。

+   *test.json* 文件包含视图、存储过程和函数名称以及表格列表。

+   *@.users.sql* 文件(未显示)包含数据库用户列表。

+   *test@sbtest10.json* 文件包含列名称和字符集。每个导出的表都会有一个类似命名的文件。

+   *test@sbtest1.sql* 文件包含表结构。每个导出的表都会有一个这样的文件。

+   *test@sbtest10@@0.tsv.zst* 文件是一个二进制文件。它存储数据。每个导出的表格都会有一个同样命名的文件。

+   *test@sbtest10@@0.tsv.zst.idx* 文件是一个二进制文件。它存储表索引统计信息。每个导出表格都会有一个同名文件。

+   *@.done.json* 文件包含备份结束时间和数据文件大小(KB)。

+   *test.sql* 文件包含数据库语句。

### util.dumpSchemas()

此实用程序类似于`dumpInstance()`,但允许我们指定要转储的模式。它支持相同的选项:

MySQL localhost:48008 ssl JS > util``.``dumpSchemas``(``[``"test"``]``,``"/backup"``,
> {``ocimds``: true``, compatibility``:
> [``"strip_restricted_grants"``,``"force_innodb"``]``,
> threads : 10 , dryRun``: true``}``)


如果我们想要指定多个模式,可以通过运行以下命令实现:

MySQL localhost:48008 ssl JS > util``.``dumpSchemas``(``[``"test"``,``"percona"``,
"learning_mysql"``]``,``"/backup"``,
> {``ocimds``: true``, compatibility``:
> [``"strip_restricted_grants"``,``"force_innodb"``]``,
> threads : 10 , dryRun``: true``}``)


### util.dumpTables()

如果我们想要提取更精细的数据,比如特定表格,我们可以使用`dumpTables()`工具。与`mysqldump`相比,其主要优势是可以并行从 MySQL 中提取数据:

MySQL localhost:48008 ssl JS > util``.``dumpTables``(``"test"``, [ "sbtest1"``,
> "sbtest2" ]``,``"/backup"``,
> {``ocimds``: true``, compatibility``:
> [``"strip_restricted_grants"``,``"force_innodb"``]``,
> threads : 2 , dryRun``: true``}``)


### util.loadDump(url[, options])

我们已经看到了所有提取数据的工具,但还有一个剩下的:将数据加载到 MySQL 的工具。

`loadDump()` 提供数据流式传输到远程存储,表或表块的并行加载,进度状态跟踪。它还提供了恢复和重置功能,并在转储仍在进行时提供并发加载选项。

注意,此实用程序使用`LOAD DATA LOCAL INFILE`语句,因此我们需要在导入时全局启用[`local_infile`](https://oreil.ly/vm445)参数。

`loadDump()` 实用程序检查是否设置了[`sql_require_primary_key`系统变量](https://oreil.ly/2Si8y)为`ON`,如果是,则在转储文件中存在无主键的表时返回错误:

MySQL localhost:48008 ssl JS > util``.``loadDump``(``"/backup"``,
> {``progressFile :``"/backup > restore.json"``,``threads :``12``}``)


输出的最后部分将类似于这样:

[Worker006] percona@sbtest7@@0.tsv.zst: Records: 400000 Deleted: 0 Skipped: 0
Warnings: 0
[Worker007] percona@sbtest4@@0.tsv.zst: Records: 400000 Deleted: 0 Skipped: 0
Warnings: 0
[Worker002] percona@sbtest13@@0.tsv.zst: Records: 220742 Deleted: 0 Skipped: 0
Warnings: 0
Executing common postamble SQL

23 chunks (5.03M rows, 973.06 MB) for 23 tables in 3 schemas were loaded in
1 min 24 sec (avg throughput 11.58 MB/s)
0 warnings were reported during the load.


一定要检查最后报告的警告,以防有任何出现。**  **# 火焰图

引用 [Brendan Gregg](https://oreil.ly/STGxb),确定 CPU 为何繁忙是性能分析的日常任务,通常涉及对*堆栈跟踪*进行分析。通过固定采样率进行分析是查看哪些代码路径*热点*(CPU 繁忙)的粗略但有效的方法。通常通过创建定时中断来收集当前程序计数器、函数地址或整个堆栈跟踪,并在打印摘要报告时将其转换为可读的内容。*火焰图*是一种可视化采样堆栈跟踪的方式,可以快速识别热门代码路径。

*堆栈跟踪*(又称*堆栈回溯*或*堆栈追踪*)是程序执行过程中某一时刻活动的堆栈帧报告。有许多工具可用于收集堆栈跟踪。这些工具也被称为*CPU 分析器*。我们将使用的 CPU 分析器是[`perf`](https://oreil.ly/T7qZl)。

`perf` 是针对基于 Linux 2.6+ 的系统的性能分析工具,它在 Linux 性能测量中抽象了 CPU 硬件差异,并提供了简单的命令行接口。`perf` 基于 Linux 内核最新版本导出的`perf_events`接口。

`perf_events` 是一个面向事件的可观测性工具,可以帮助解决高级性能和故障排除任务。可以回答的问题包括:

+   为什么内核在 CPU 上运行得这么多?哪些代码路径是热点?

+   哪些代码路径导致 CPU 二级缓存未命中?

+   CPU 是否因内存 I/O 而停滞?

+   哪些代码路径在分配内存,分别分配了多少?

+   是什么触发了 TCP 重传?

+   某个内核函数是否被调用,以及调用频率?

+   为什么线程会离开 CPU?

注意,在本书中,我们只是浅尝 `perf` 的功能。我们强烈建议查看 [Brendan Gregg 的网站](https://oreil.ly/STGxb),那里有关于 `perf` 和其他 CPU 分析工具更详细的信息。

要生成火焰图,我们需要在 MySQL 服务器上使用 `perf` 开始收集堆栈跟踪报告。此操作需要在 MySQL 主机上完成。我们将收集 60 秒的数据:

perf record -a -g -F99 -p $(pgrep -x mysqld) -- sleep 60;

perf report > /tmp/perf.report;

perf script > /tmp/perf.script;


如果我们检查 */tmp* 目录,我们会看到 `perf` 文件:

ls -l /tmp/perf*

-rw-r--r-- 1 root root 502100 Feb 13 22:01 /tmp/perf.report
-rw-r--r-- 1 root root 7303290 Feb 13 22:01 /tmp/perf.script


下一步不需要在 MySQL 主机上执行;我们可以将文件复制到另一个 Linux 主机,甚至是 macOS。

要生成火焰图,我们可以使用 [Brendan 的 GitHub 仓库](https://oreil.ly/llqVS)。在这个示例中,我们将 Flame Graph 仓库克隆到包含我们 `perf` 报告的目录中:

git clone https://github.com/brendangregg/FlameGraph

./FlameGraph/stackcollapse-perf.pl ./perf.script > perf.report.out.folded

./FlameGraph/flamegraph.pl ./perf.report.out.folded > perf.report.out.svg


我们生成了一个名为 *perf.report.out.svg* 的文件。此文件可以在任何浏览器中打开进行可视化。图 16-1 是火焰图的一个示例。

火焰图显示了样本在 x 轴上的分布,堆栈深度在 y 轴上。每个函数(堆栈帧)都显示为一个矩形,宽度相对于样本数;因此,条形越大,花费在该函数上的 CPU 时间越多。x 轴跨越堆栈跟踪收集,但不显示时间的流逝,因此左到右的顺序没有特殊含义。按字母顺序对函数名称进行排序,从根到每个堆栈的叶子。

创建的文件是交互式的,因此我们可以探索内核 CPU 时间花在哪里。在前面的示例中,`INSERT` 操作消耗了 44% 的 CPU 时间,正如您可以在 图 16-2 中看到的。

![lm2e 1601](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/lm2e_1601.png)

###### 图 16-1\. 火焰图示例

![lm2e 1602](https://github.com/OpenDocCN/ibooker-db-zh/raw/master/docs/lrn-mysql-2e/img/lm2e_1602.png)

###### 图 16-2\. **44%** 的 CPU 时间用于 `INSERT` 操作

# 从源代码构建 MySQL

如 第一章 所述,MySQL 在大多数常见操作系统上都有可用的发行版。一些公司也编译了自己的 MySQL 版本,例如 Facebook,他们在 RocksDB 引擎上工作,并将其集成到 MySQL 中。RocksDB 是一个嵌入式持久键值存储,用于快速存储,与 InnoDB 相比在空间效率上有几个优点。

尽管 RocksDB 具有其优点,但不支持复制或 SQL 层。这促使 Facebook 团队构建了 MyRocks,一个将 RocksDB 集成为 MySQL 存储引擎的开源项目。使用 MyRocks,可以将 RocksDB 作为后端存储,并仍然享受 MySQL 的所有功能。Facebook 的项目是开源的,可以在 [GitHub](https://oreil.ly/ssWon) 上找到。

另一个编译 MySQL 的动机是能够定制其构建。例如,针对一个非常特定的问题,我们总是可以尝试调试 MySQL 来收集额外信息。为此,我们需要使用 `-DWITH_DEBUG=1` 选项配置 MySQL。

## 为 Ubuntu Focal Fossa 和 ARM 处理器构建 MySQL

由于 ARM 处理器目前正在流行(尤其是由于苹果的 M1 芯片),我们将展示如何在运行 ARM 的 Ubuntu Focal Fossa 上编译 MySQL。

首先,我们将创建我们的目录。我们将创建一个用于源代码的目录,另一个用于编译二进制文件的目录,以及一个用于 `boost` 库的目录:

cd /

mkdir compile

cd compile/

mkdir build

mkdir source

mkdir boost

mkdir basedir

mkdir /var/lib/mysql


接下来,我们需要安装编译 MySQL 所需的额外 Linux 包:

apt-get -y install dirmngr

apt-get update -y

apt-get -y install cmake

apt-get -y install lsb-release wget

apt-get -y purge eatmydata || true

apt-get -y install psmisc pkg-config

apt-get -y install libsasl2-dev libsasl2-modules libsasl2-modules-ldap || \

apt-get -y install libsasl2-modules libsasl2-modules-ldap libsasl2-dev

apt-get -y install dh-systemd || true

apt-get -y install curl bison cmake perl libssl-dev gcc g++ libaio-dev \

libldap2-dev libwrap0-dev gdb unzip gawk

apt-get -y install lsb-release libmecab-dev libncurses5-dev libreadline-dev \

libpam-dev zlib1g-dev

apt-get -y install libldap2-dev libnuma-dev libjemalloc-dev libeatmydata \

libc6-dbg valgrind libjson-perl  libsasl2-dev

apt-get -y install libmecab2 mecab mecab-ipadic

apt-get -y install build-essential devscripts libnuma-dev

apt-get -y install cmake autotools-dev autoconf automake build-essential \

devscripts debconf debhelper fakeroot

apt-get -y install libcurl4-openssl-dev patchelf

apt-get -y install libeatmydata1

apt-get install libmysqlclient-dev -y

apt-get install valgrind -y


这些包与我们将运行的 [CMake 标志](https://oreil.ly/GOnBJ) 相关。如果我们删除或添加某些标志,某些包可能就不再需要安装(例如,如果我们不想使用 Valgrind 进行编译,那么这个包就不需要了)。

接下来,我们将下载源代码。为此,我们将使用 [MySQL 仓库](https://oreil.ly/6Jb4c) 在 GitHub 上:

cd source

git clone https://github.com/mysql/mysql-server.git


输出将类似于以下内容:

Cloning into 'mysql-server'...
remote: Enumerating objects: 1639611, done.
remote: Total 1639611 (delta 0), reused 0 (delta 0), pack-reused 1639611
Receiving objects: 100% (1639611/1639611), 3.19 GiB | 42.88 MiB/s, done.
Resolving deltas: 100% (1346714/1346714), done.
Updating files: 100% (32681/32681), done.


要检查我们将编译哪个版本,可以运行以下命令:

cd mysql-server/

git branch


接下来,我们将进入我们的 *build* 目录,并使用我们选择的标志运行 `CMake`:

cd /compile/build

cmake ../source/mysql-server/ -DBUILD_CONFIG=mysql_release \

-DCMake_BUILD_TYPE=${CMake_BUILD_TYPE:-RelWithDebInfo} \
-DWITH_DEBUG=1 \
-DFEATURE_SET=community \
-DENABLE_DTRACE=OFF \
-DWITH_SSL=system \
-DWITH_ZLIB=system \
-DCMake_INSTALL_PREFIX="/compile/basedir/" \
-DINSTALL_LIBDIR="lib/" \
-DINSTALL_SBINDIR="bin/" \
-DWITH_INNODB_MEMCACHED=ON \
-DDOWNLOAD_BOOST=1 \
-DWITH_VALGRIND=1 \
-DINSTALL_PLUGINDIR="plugin/" \
-DMYSQL_DATADIR="/var/lib/mysql/" \
-DWITH_BOOST="/compile/boost/"

这些各自是做什么的:

+   `DBUILD_CONFIG` 配置一个与 MySQL 发行版相同的源代码分发,我们将覆盖其中的一些选项。

+   `DCMake_BUILD_TYPE` 使用 `RelWithDebInfo` 选项启用优化并生成调试信息。

+   `DWITH_DEBUG` 在启动 MySQL 时启用 `--debug="d,parser_debug"` 选项。这会导致用于处理 SQL 语句的 Bison 解析器将解析跟踪转储到服务器的标准错误输出。通常,此输出会写入错误日志。

+   `DFEATURE_SET` 表示我们将安装社区功能。

+   `DENABLE_DTRACE` 包括对 DTrace 探针的支持。MySQL 服务器中的 DTrace 探针旨在提供有关 MySQL 中查询执行及其过程中使用的系统不同区域的信息。

+   `DWITH_SSL` 选项添加了对加密连接、生成随机数熵和其他加密相关操作的支持。

+   `DWITH_ZLIB` 启用压缩库支持 `COMPRESS()` 和 `UNCOMPRESS()` 函数,以及客户端/服务器协议的压缩。

+   `DCMake_INSTALL_PREFIX` 设置我们安装基础目录的位置。

+   `DINSTALL_LIBDIR` 指示库文件的安装位置。

+   `DINSTALL_SBINDIR`指定安装`mysqld`的位置。

+   `DWITH_INNODB_MEMCACHED`生成 memcached 共享库(*libmemcached.so*和*innodb_engine.so*)。

+   `DDOWNLOAD_BOOST`让 CMake 下载`boost`库,并将其放置在`DWITH_BOOST`指定的位置。

+   `DWITH_VALGRIND`启用 Valgrind,将 Valgrind API 暴露给 MySQL 代码。这对于分析内存泄漏很有用。

+   `DINSTALL_PLUGINDIR`定义编译器将放置插件库的位置。

+   `DMYSQL_DATADIR`定义 MySQL 数据目录的位置。

+   `DWITH_BOOST`定义 CMake 将下载`boost`库的目录。

###### 注意

如果你在 CMake 过程中错误地错过了一个步骤,并且为了防止旧的对象文件或配置信息在下一次尝试中被使用,你需要清理构建目录和先前的配置。也就是说,在 Unix 上重新运行 CMake 之前,你需要在构建目录中运行以下命令:

cd /compile/build

make clean

rm CMakeCache.txt


CMake 运行后,我们将使用`make`命令编译 MySQL。为了优化编译过程,我们将使用`-j`选项,指定编译 MySQL 时要使用的线程数。因为我们的实例有 16 个 ARM 核心,我们将使用 15 个线程(留一个用于操作系统活动):

make -j 15

make install


这个过程可能需要一段时间,并且非常冗长。完成后,我们可以在*basedir*目录中看到二进制文件:

ls -l /compile/basedir/bin


注意,我们在*/compile/build/bin/*目录中找不到*mysqld*二进制文件,而是会看到*mysqld-debug*。这是由于我们之前设置的`DWITH_DEBUG`选项:

/compile/build/bin/mysqld-debug --version


/compile/build/bin/mysqld-debug Ver 8.0.23-debug-valgrind for Linux on aarch64
(Source distribution)


现在,我们可以测试我们的二进制文件。为此,我们将手动创建目录并配置权限:

mkdir /var/log/mysql/

mkdir /var/run/mysqld/

chown ubuntu: /var/log/mysql/

chown ubuntu: /var/run/mysqld/


然后将这些设置添加到*/etc/my.cnf*中:

[mysqld]
pid-file = /var/run/mysqld/mysqld.pid
socket = /var/run/mysqld/mysqld.sock
datadir = /var/lib/mysql
log-error = /var/log/mysql/error.log


接下来,我们将初始化 MySQL 数据字典:

/compile/basedir/bin/mysqld-debug --defaults-file=/etc/my.cnf --initialize \

--user ubuntu


现在,MySQL 已准备好启动:

/compile/basedir/bin/mysqld-debug --defaults-file=/etc/my.cnf --user ubuntu &


将创建一个临时密码,我们可以从错误日志中提取它:

grep "A temporary password" /var/log/mysql/error.log


2021-02-14T16:55:25.754028Z 6 [Note] [MY-010454] [Server] A temporary
password is generated for root@localhost: yGldRKoRf0%T


现在我们可以使用我们喜欢的 MySQL 客户端连接:

mysql -uroot -p'yGldRKoRf0%T'


mysql: [Warning] Using a password on the command line interface can be
insecure. Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 8
Server version: 8.0.23-debug-valgrind

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>


# 分析 MySQL 崩溃

当*mysqld*进程在没有正确关闭命令的情况下死掉时,我们称 MySQL 发生*崩溃*。MySQL 可能因多种原因而崩溃,包括以下几种:

+   硬件故障(内存、磁盘、处理器)

+   分段错误(无效内存访问)

+   Bugs

+   被`OOM`进程杀死

+   其他各种原因,如[宇宙射线](https://oreil.ly/Lq09r)。

MySQL 进程可以从 Linux 接收多种信号。以下是最常见的几种:

信号 15 (`SIGTERM`)

导致服务器关闭。这就像执行`SHUTDOWN`语句而无需连接服务器(用于关闭需要具有`SHUTDOWN`权限的帐户)。例如,以下两个命令会导致常规关闭:

systemctl stop mysql

kill -15 -p $(pgrep -x mysqld)


信号 1 (`SIGHUP`)

导致服务器重新加载授权表并刷新表、日志、线程缓存和主机缓存。这些操作类似于各种形式的`FLUSH`语句:

mysql> FLUSH LOGS``;


或者:

kill -1 -p $(pgrep -x mysqld)


信号 6 (`SIGABRT`)

由于出现了问题而导致的。这通常由 `libc` 和其他库在遇到严重错误时使用。例如,如果检测到双重释放或其他堆内存损坏,`glibc` 将发送 `SIGABRT`。如果 MySQL 检测到 `SIGABRT`,它将在错误日志中写入崩溃详细信息,如下所示:

18:03:28 UTC - mysqld got signal 6 ;
Most likely, you have hit a bug, but this error can also be caused by...
Thread pointer: 0x7fe6b4000910
Attempting backtrace. You can use the following information to find out
where mysqld died. If you see no messages after this, something went
terribly wrong...
stack_bottom = 7fe71845fbc8 thread_stack 0x46000
/opt/mysql/8.0.23/bin/mysqld(my_print_stacktrace(unsigned char const...
/opt/mysql/8.0.23/bin/mysqld(handle_fatal_signal+0x323) [0x1032cc3]
/lib64/libpthread.so.0(+0xf630) [0x7fe7244e5630]
/lib64/libc.so.6(gsignal+0x37) [0x7fe7224fa387]
/lib64/libc.so.6(abort+0x148) [0x7fe7224fba78]
/opt/mysql/8.0.23/bin/mysqld() [0xd52c3d]
/opt/mysql/8.0.23/bin/mysqld(MYSQL_BIN_LOG::new_file_impl(bool...
/opt/mysql/8.0.23/bin/mysqld(MYSQL_BIN_LOG::rotate(bool, bool
)+0x35)...
/opt/mysql/8.0.23/bin/mysqld(MYSQL_BIN_LOG::rotate_and_purge(THD...
/opt/mysql/8.0.23/bin/mysqld(handle_reload_request(THD
, unsigned...
/opt/mysql/8.0.23/bin/mysqld(signal_hand+0x2ea) [0xe101da]
/opt/mysql/8.0.23/bin/mysqld() [0x25973dc]
/lib64/libpthread.so.0(+0x7ea5) [0x7fe7244ddea5]
/lib64/libc.so.6(clone+0x6d) [0x7fe7225c298d]

Trying to get some variables.
Some pointers may be invalid and cause the dump to abort.
Query (0): Connection ID (thread ID): 0
Status: NOT_KILLED

The manual page at http://dev.mysql.com/doc/mysql/en/crashing.html
contains information that should help you find out what is causing
the crash.
2021-02-14T18:03:29.120726Z mysqld_safe mysqld from pid file...


信号 11 (`SIGSEGV`)

表示分段错误、总线错误或访问违规问题。这通常是尝试访问 CPU 无法物理寻址的内存,或者是访问违规。当 MySQL 收到 `SIGSEGV` 时,如果配置了 `core-file` 参数,将创建核心转储文件。

信号 9 (`SIGKILL`)

导致进程立即终止(杀死)。这可能是最著名的信号。与 `SIGTERM` 和 `SIGINT` 不同,该信号无法被捕获或忽略,并且接收进程在收到此信号后无法执行任何清理操作。除了可能损坏 MySQL 数据的机会外,`SIGKILL` 还将在重新启动时强制 MySQL 执行恢复过程,使其恢复到可操作状态。以下示例显示如何手动向 MySQL 进程发送 `SIGKILL`:

kill -9 -p $(pgrep -x mysqld)


此外,Linux 的 `OOM` 进程执行 `SIGKILL` 来终止 MySQL 进程。

让我们尝试分析 MySQL 收到信号 11 导致崩溃的情况:

11:47:47 UTC - mysqld got signal 11 ;
Most likely, you have hit a bug, but this error can also be caused by...
Build ID: Not Available
Server Version: 8.0.22-13 Percona Server (GPL), Release 13, Revision 6f7822f
Thread pointer: 0x7f0e46c73000
Attempting backtrace. You can use the following information to find out
where mysqld died. If you see no messages after this, something went
terribly wrong...
stack_bottom = 7f0e664ecd10 thread_stack 0x46000
/usr/sbin/mysqld(my_print_stacktrace(unsigned char const, unsigned...
/usr/sbin/mysqld(handle_fatal_signal+0x3c3) [0x1260d33]
/lib/x86_64-linux-gnu/libpthread.so.0(+0x128a0) [0x7f0e7acd58a0]
/usr/sbin/mysqld(Item_splocal::this_item()+0x14) [0xe36ad4]
/usr/sbin/mysqld(Item_sp_variable::val_str(String
)+0x20) [0xe38e60]
/usr/sbin/mysqld(Arg_comparator::compare_string()+0x27) [0xe5c127]
/usr/sbin/mysqld(Item_func_ne::val_int()+0x30) [0xe580e0]
/usr/sbin/mysqld(Item::val_bool()+0xcc) [0xe3ddbc]
/usr/sbin/mysqld(sp_instr_jump_if_not::exec_core(THD, unsigned int)+0x2d)...
/usr/sbin/mysqld(sp_lex_instr::reset_lex_and_exec_core(THD, unsigned int...
/usr/sbin/mysqld(sp_lex_instr::validate_lex_and_execute_core(THD, unsigned...
/usr/sbin/mysqld(sp_head::execute(THD
, bool)+0x5c7) [0x1068e37]
/usr/sbin/mysqld(sp_head::execute_trigger(THD, MYSQL_LEX_CSTRING const&...
/usr/sbin/mysqld(Trigger::execute(THD
)+0x10b) [0x12288cb]
/usr/sbin/mysqld(Trigger_chain::execute_triggers(THD)+0x18) [0x1229c98]
/usr/sbin/mysqld(Table_trigger_dispatcher::process_triggers(THD
...
/usr/sbin/mysqld(fill_record_n_invoke_before_triggers(THD, COPY_INFO...
/usr/sbin/mysqld(Sql_cmd_update::update_single_table(THD)+0x1e98) [0x11ec138]
/usr/sbin/mysqld(Sql_cmd_update::execute_inner(THD
)+0xd5) [0x11ec5f5]
/usr/sbin/mysqld(Sql_cmd_dml::execute(THD)+0x6c0) [0x116f590]
/usr/sbin/mysqld(mysql_execute_command(THD
, bool)+0xaf8) [0x110e588]
/usr/sbin/mysqld(mysql_parse(THD, Parser_state, bool)+0x4ec) [0x111327c]
/usr/sbin/mysqld(dispatch_command(THD, COM_DATA const...
/usr/sbin/mysqld(do_command(THD*)+0x204) [0x1116554]
/usr/sbin/mysqld() [0x1251c20]
/usr/sbin/mysqld() [0x2620e84]
/lib/x86_64-linux-gnu/libpthread.so.0(+0x76db) [0x7f0e7acca6db]
/lib/x86_64-linux-gnu/libc.so.6(clone+0x3f) [0x7f0e78c95a3f]
Trying to get some variables.
Some pointers may be invalid and cause the dump to abort.
Query (7f0e46cb4dc8): update table1 set c2_id='R', c3_description='testing...
Connection ID (thread ID): 111
Status: NOT_KILLED
Please help us make Percona Server better by reporting any
bugs at https://bugs.percona.com/


###### 注意

有时堆栈可能不包含完全解析的符号或仅包含地址。这取决于 mysqld 二进制文件是否经过剥离以及调试符号是否可用。作为经验法则,我们建议安装调试符号,因为这除了占用一些磁盘空间外没有任何缺点。但是,官方的 MySQL 8.0 构建始终是带有符号的,因此您无需担心。

从堆栈跟踪的顶部到底部进行分析。从崩溃中可以看出,这是 Percona Server v8.0.22。接下来,我们看到在此时在操作系统级别创建了一个线程:

/lib/x86_64-linux-gnu/libpthread.so.0(+0x76db) [0x7f0e7acca6db]


继续沿着堆栈向上,代码路径进入 MySQL 并开始执行命令:

/usr/sbin/mysqld(do_command(THD*)+0x204)...


引起崩溃的代码路径是 `Item_splocal` 函数:

/usr/sbin/mysqld(Item_splocal::this_item()+0x...


通过对[MySQL 代码](https://oreil.ly/OjTUs)的稍加调查,我们发现 `Item_splocal` 是存储过程代码的一部分。如果我们查看堆栈跟踪的末尾,我们会看到一个查询:

Query (7f0e46cb4dc8): update table1 set c2_id='R', c3_description='testing...


*触发器* 也可以在包含变量的存储过程路径中使用。如果我们检查这个表是否有触发器,我们会看到这个:

CREATE DEFINER=root@localhost TRIGGER table1_update_trigger
BEFORE UPDATE ON table1 FOR EACH ROW BEGIN
DECLARE vc1_id VARCHAR(2);
SELECT c2_id FROM table1 WHERE c1_id = new.c1_id INTO vc1_id;
IF vc1_id <> P THEN
INSERT INTO table1_hist(
c1_id,
c2_id,
c3_description)
VALUES(
old.c1_id,
old.c2_id,
new.c3_description);
END IF;
END
;;


有了所有这些信息,我们可以创建一个测试案例并报告这个 Bug:

USE test;

CREATE TABLE table1 (
c1_id int primary key auto_increment,
c2_id char(1) NOT NULL,
c3_description varchar(255));

CREATE TABLE table1_hist (
c1_id int,
c2_id char(1) NOT NULL,
c3_description varchar(255));
insert into table1 values (1, T, test crash);

delimiter ;;

CREATE DEFINER=root@localhost TRIGGER table1_update_trigger
BEFORE UPDATE ON table1 FOR EACH ROW BEGIN
DECLARE vc1_id VARCHAR(2);
SELECT c2_id FROM table1 WHERE c1_id = new.c1_id INTO vc1_id;
IF vc1_id <> P THEN
INSERT INTO table1_hist(
c1_id,
c2_id,
c3_description)
VALUES(
old.c1_id,
old.c2_id,
new.c3_description);
END IF;
END
;;


要复现它,我们在同一表中同时运行多个命令,直到发生错误:

$ mysqlslap --user=msandbox --password=msandbox
--socket=/tmp/mysql_sandbox37515.sock
--create-schema=test --port=37515
--query="update table1 set c2_id='R',
*c3_description='testing crash' where c1_id=1"
--concurrency=50 --iterations=200


此 Bug 相对容易复现,我们建议您进行测试。您可以在 Percona 的 [Jira 系统](https://oreil.ly/cAWbG) 中找到有关此 Bug 的更多详细信息。

Oracle 在版本 8.0.23 中修复了该 Bug,详情请参阅[发布说明](https://oreil.ly/izg4K):

> 涉及存储程序的预编译语句可能会导致堆使用后释放内存问题(Bug #32131022,Bug #32045681,Bug #32051928)。

有时候,bug 不容易重现,调查起来真的很让人沮丧。即使是有经验的工程师,在调查内存泄漏时也会遇到问题。希望我们激发了你对调查崩溃的好奇心。
posted @ 2025-11-16 09:00  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报