MySQL-秘籍第四版-全-
MySQL 秘籍第四版(全)
原文:
zh.annas-archive.org/md5/91ff9496a6b5f4a412de0c3a9bfdfc0e译者:飞龙
前言
MySQL 数据库管理系统因其快速、易于设置、使用和管理而广受欢迎。它在许多 Unix 和 Windows 的变种下运行,而基于 MySQL 的程序可以用多种语言编写。
MySQL 的流行性引发了解决其用户在如何解决特定问题方面的问题的需求。这就是MySQL Cookbook的目的:作为一个方便的资源,您可以在使用 MySQL 时用来快速解决问题或攻击特定类型问题的技术。当然,因为它是一本食谱书,它包含了一些配方:您可以遵循的简单指令,而不是从头开始开发自己的代码。它采用问题和解决方案的格式编写,旨在非常实用和使内容易于阅读和消化。它包含许多简短的章节,每个章节描述如何编写查询、应用技术或开发脚本来解决具有有限和特定范围问题。这本书并不开发成熟的复杂应用程序。相反,它通过帮助您解决困扰您的问题来协助您自行开发这些应用程序。
例如,一个常见问题是:在编写查询时,如何处理数据值中的引号和特殊字符?
这并不难,但当你不确定从哪里开始时,找出如何做会很令人沮丧。本书展示了应该做什么;它向您展示了从何处开始以及如何继续的方法。这些知识将会反复为您服务,因为一旦您了解所涉及的内容,您将能够将该技术应用于任何类型的数据,如文本、图像、声音或视频剪辑、新闻文章、压缩文件或 PDF 文档等。另一个常见问题是:我可以同时从多个表中访问数据吗?
答案是可以
,而且很容易做到,只需了解适当的 SQL 语法即可。但在看到示例之前,这并不总是清楚。本书为您提供了这些示例。您将从本书中学到的其他技术包括如何:
-
使用 SQL 选择、排序和汇总行
-
在表之间查找匹配或不匹配
-
执行事务
-
确定日期或时间之间的间隔,包括年龄计算
-
标识或删除重复行
-
使用
LOAD DATA正确读取您的数据文件或查找文件中的无效值 -
使用
CHECK约束防止不良数据输入到您的数据库中 -
生成序列号以用作唯一行标识符
-
将视图用作
虚拟表
-
编写存储过程和函数,设置触发器在插入或更新表行时激活以执行特定的数据处理操作,并使用事件调度程序按计划运行查询
-
设置复制
-
管理用户帐户
-
控制服务器日志记录
使用 MySQL 的一部分是理解如何与服务器通信——也就是说,如何使用 SQL,这是查询被制定的语言。因此,本书的一个主要重点是使用 SQL 来制定回答特定类型问题的查询。一个有帮助的工具用于学习和使用 SQL 是包含在 MySQL 发行版中的mysql客户端程序。你可以交互式地使用客户端向服务器发送 SQL 语句并查看结果。这非常有用,因为它提供了与 SQL 的直接接口;实际上,第一章专门讲述了mysql。
但仅仅能够发出 SQL 查询还不够。从数据库提取的信息通常需要进一步处理或以特定方式展示。如果你有具有复杂相互关系的查询,例如当你需要使用一个查询的结果作为其他查询的基础时会怎样?如果你需要生成一个具有非常特定格式要求的专门报告会怎样?这些问题带我们来到本书的另一个主要重点——如何编写与 MySQL 服务器通过应用程序接口(API)交互的程序。当你知道如何在编程语言的上下文中使用 MySQL 时,你就会获得其他利用 MySQL 能力的方法:
-
你可以保存查询结果并以后重复使用它们。
-
你可以充分利用通用编程语言的表达能力。这使得你可以根据查询的成功或失败,或返回的行内容来做出决策,并相应地调整采取的行动。
-
你可以以任何你喜欢的方式格式化和显示查询结果。如果你在编写命令行脚本,可以生成纯文本。如果是基于 Web 的脚本,可以生成 HTML 表格。如果是提取信息以传输到其他系统的应用程序,可能会生成以 XML 或 JSON 表示的数据文件。
将 SQL 与通用编程语言结合使用,可以为你发出查询和处理结果提供一个极其灵活的框架。编程语言增强了你执行复杂数据库操作的能力。但这并不意味着本书很复杂。它保持简单,展示如何使用易于理解和轻松掌握的技术构建小的构建模块。
我们将让你自己在你的程序中组合这些技术,这样你可以创建任意复杂的应用程序。毕竟,基因密码只基于四种核酸,但这些基本元素已经结合起来产生了我们周围看到的生物生命的惊人多样性。同样地,音阶只有 12 个音符,但在熟练作曲家手中,它们被编织在一起,产生丰富和无尽的音乐变化。同样地,当你拿出一组简单的技术,加上你的想象力,并将它们应用于你想要解决的数据库编程问题时,你可以创建的应用程序也许不是艺术品,但肯定是有用的,将帮助你和其他人更加高效地工作。
本书适合谁
无论是想要在个人项目(如博客或维基)中使用数据库的个人,还是专业数据库和网页开发人员,这本书都将对他们有所帮助。本书还适用于不了解如何使用 MySQL,但希望学习的人群。
如果你是 MySQL 的新手,这里有很多你可能不熟悉的使用方法。如果你有经验,可能已经熟悉这里提到的许多问题,但可能之前没有解决过,这本书应该会为你节省大量时间。利用本书中提供的技术,并在你自己的程序中使用它们,而不是从头开始编写代码。
本书内容从入门到高级涵盖广泛,因此,如果某个技术描述对你来说显而易见,可以跳过它。相反,如果你不理解某个技术,可以暂时搁置,稍后再回头看,也许在阅读其他技术之后会更明了。
本书内容概述
当你使用本书时,很可能是在开发一个应用程序,但不确定如何实现其中的某些部分。在这种情况下,你已经知道想要解决的问题类型;查看目录或索引,找到一个能展示你想要实现的操作的技术。理想情况下,该技术应该正是你所需要的。或者,你可以适应类似问题的技术来解决眼前的问题。我们解释了开发每一种技术所涉及的原理,以便你可以修改它,以适应你自己应用程序的特定需求。
另一种阅读这本书的方式是,无需特定问题,直接通读。这样可以让你更全面地了解 MySQL 能做什么,因此我们建议你偶尔翻阅这本书。如果你知道它解决的问题类型,这将是一个更有效的工具。
随着你深入后续章节,你会找到一些假定你已经掌握早期章节内容的实用技巧。这也适用于章节内部,后续部分经常使用前面章节讨论的技术。如果你跳到某章节,发现一个使用你不熟悉的技术的实例,可以查看目录或索引,找到该技术在前面章节的解释。例如,如果一个示例使用了你不理解的ORDER BY子句对查询结果进行排序,请参阅第九章,该章讨论了各种排序方法及其工作原理。
这里是每章的摘要,帮助你了解本书的内容概览。
第一章,“使用 mysql 客户端程序”,描述了如何使用标准的 MySQL 命令行客户端。mysql通常是人们使用的第一个或主要接口,了解如何利用其功能非常重要。该程序使你能够交互式地发出查询并查看其结果,因此非常适合快速实验。你还可以在批处理模式下使用它来执行 SQL 脚本或将其输出发送到其他程序。此外,该章还讨论了其他使用mysql的方法,如如何使长行更易读或生成各种格式的输出。
第二章,“使用 MySQL Shell”,介绍了由 MySQL 团队为 5.7 版本及更高版本开发的新 MySQL 命令行客户端。mysqlsh 在 SQL 模式下兼容mysql,同时支持 JavaScript 和 Python 编程接口的 NoSQL。使用 MySQL Shell,你可以轻松运行 SQL、NoSQL 查询,并自动化许多管理任务。
第三章,“MySQL 复制”,描述了如何设置和使用复制功能。本章的部分内容较为高级。然而,我们决定将其放在书的开头,因为复制对于能够抵御诸如数据损坏或硬件故障等灾难的稳定 MySQL 安装是必要的。实际上,任何生产环境中的 MySQL 安装都应该使用其中一种复制设置。虽然设置复制是一项管理任务,但我们认为所有 MySQL 用户都需要了解复制的工作原理,并因此编写出在源服务器和副本服务器上均能高效执行的有效查询。
第四章,“编写基于 MySQL 的程序”,演示了 MySQL 编程的基本要素:如何连接到服务器、发出查询、检索结果和处理错误。还讨论了如何在查询中处理特殊字符和NULL值,如何编写库文件以封装常用操作的代码,以及获取连接到服务器所需参数的各种方法。
第五章,“从表中选择数据”,涵盖SELECT语句的多个方面,这是从 MySQL 服务器检索数据的主要方式:指定要检索的列和行,处理NULL值,以及选择查询结果的一部分。后面的章节将更详细地讨论这些主题,但本章提供了这些概念的概述,这些概念是它们依赖的基础,如果您需要一些关于行选择的介绍性背景或者还不太了解 SQL。
第六章,“表管理”,涵盖表克隆、将结果复制到其他表中、使用临时表以及检查或更改表的存储引擎。
第七章,“处理字符串”,描述如何处理字符串数据。涵盖字符集和校对规则、字符串比较、处理大小写敏感问题、模式匹配、拆分和组合字符串,以及执行FULLTEXT搜索。
第八章,“处理日期和时间”,展示如何处理时间数据。描述了 MySQL 的日期格式以及如何以其他格式显示日期值。还涵盖了如何使用 MySQL 的特殊TIMESTAMP数据类型,如何设置时区,如何在不同的时间单位之间转换,如何进行日期算术以计算间隔或从一个日期生成另一个日期,以及如何执行闰年计算。
第九章,“排序查询结果”,描述如何按照所需顺序排列查询结果的行。这包括指定排序方向、处理NULL值、考虑字符串的大小写敏感性,以及按日期或部分列值排序。还提供了示例,展示如何对特殊类型的值进行排序,如域名、IP 数字和ENUM值。
第十章,“生成摘要”,展示评估一组数据的一般特征的技术,例如它包含多少个值或其最小、最大和平均值。
第十一章,“使用存储过程、触发器和定期事件”,描述如何编写存储在服务器端的存储函数和过程,触发器在修改表时激活,以及按计划执行的事件。
第十二章,“处理元数据”,讨论如何获取查询返回的数据的信息,例如结果中的行数或列数,或每列的名称和数据类型。还展示了如何询问 MySQL 可用的数据库和表,或确定表的结构。
第十三章,“导入和导出数据”,描述了如何在 MySQL 和其他程序之间传输信息。包括如何使用 LOAD DATA、将文件从一种格式转换为另一种格式,以及确定适合数据集的表结构。
第十四章,“数据验证和重排”,描述了如何从数据文件中提取或重新排列列,检查和验证数据,并重新编写诸如日期之类常见格式的数值。
第十五章,“生成和使用序列”,讨论了 AUTO_INCREMENT 列,MySQL 生成序列号的机制。展示了如何生成新的序列值或确定最近的值,如何重新排列列以及如何使用序列生成计数器。还展示了如何使用 AUTO_INCREMENT 值来维护表之间的主从关系,包括需要避免的陷阱。
第十六章,“使用连接和子查询”,展示了如何执行从多个表中选择行的操作。它演示了如何比较表以找到匹配或不匹配项,生成主从列表和摘要,并列举多对多关系。
第十七章,“统计技术”,说明了如何生成描述性统计信息、频率分布、回归和相关性。还涵盖了如何对一组行进行随机化或从集合中随机选择行的内容。
第十八章,“处理重复项”,讨论了如何识别、计数和删除重复行,以及如何在首次防止它们的发生。
第十九章,“处理 JSON”,说明了如何在 MySQL 中使用 JSON。涵盖了验证、搜索和操作 JSON 数据等主题。本章还讨论了如何将 MySQL 用作文档存储。
第二十章,“执行事务”,展示了如何处理必须作为一个单位一起执行的多个 SQL 语句。讨论了如何控制 MySQL 的自动提交模式以及如何提交或回滚事务。
第二十二章,“服务器管理”,专为数据库管理员编写。涵盖了服务器配置、插件接口和日志管理。
第二十三章,“监视 MySQL 服务器”,说明了如何监视和排除 MySQL 问题,如启动或连接失败等。展示了如何使用 MySQL 日志文件、内置工具和标准操作系统实用程序获取关于 MySQL 查询和内部结构性能的信息。
第二十四章,“安全”,是另一个管理员章节。讨论了用户账户管理,包括创建账户、设置密码和分配权限。还描述了如何实施密码策略,查找和修复不安全账户,以及设置或取消密码过期。
本书中使用的 MySQL API
MySQL 编程接口支持许多语言,包括 C、C++、Eiffel、Go、Java、Perl、PHP、Python、Ruby 和 Tcl。鉴于这一事实,编写 MySQL 食谱对作者来说是一个挑战。本书应该提供许多有趣和有用的 MySQL 操作方法,但是应该使用哪种 API 或多种 API?在每种语言中显示每个配方的实现要么覆盖很少的配方,要么得到一个非常非常大的书!当不同语言中的实现非常相似时,还会导致冗余。另一方面,利用多种语言是值得的,因为对于解决特定问题,通常有一种语言比另一种更合适。
为了解决这个困境,我们选择了少量 API 来编写本书中的配方。这使得它的范围可管理,同时允许从多个 API 中选择:
-
Perl 的 DBI 模块
-
Ruby,使用 Mysql2 gem
-
PHP,使用 PDO 扩展
-
Python,使用 MySQL Connector/Python 驱动程序的 DB API
-
Go,使用 Go-MySQL-Driver 用于
sql接口 -
Java,使用 MySQL Connector/J 驱动程序的 JDBC 接口
为什么选择这些语言?Perl 是一种广泛使用的语言,第一版本书出版时编写 MySQL 程序非常流行,至今仍在许多应用中使用。Ruby 拥有易于使用的数据库访问模块。PHP 在 Web 上广泛部署。Go 近来变得非常流行,并在许多 MySQL 应用中取代其他语言,尤其是 Perl。Python 和 Java 各自拥有大量的追随者。
我们认为这些语言一起很好地反映了 MySQL 程序员现有用户群体的大多数。如果您喜欢本书未显示的某些语言,请务必仔细阅读 第四章,熟悉本书的主要 API。了解如何使用这里使用的编程接口执行数据库操作将有助于您为其他语言翻译配方。
版本和平台说明
本书中的代码开发是在 MySQL 5.7 和 8.0 下进行的。由于 MySQL 定期添加新功能,因此某些示例在旧版本下不起作用。例如,MySQL 5.7 引入了群组复制,MySQL 8.0 引入了 CHECK 约束和通用表达式。
我们不假设您正在使用 Unix,尽管这是我们自己喜欢的开发平台。(在本书中,“Unix”也指 Unix-like 系统,如 Linux 和 macOS X。)这里的大部分材料适用于 Unix 和 Windows。
本书使用的约定
本书使用以下字体约定:
Constant width
用于程序清单,以及段落内引用程序元素,如变量或函数名称、数据库、数据类型、环境变量、语句和关键字。
常量 宽度 粗体
用于指示在运行命令时键入的文本。
常量 宽度 斜体
用于指示可变输入;您应该替换为自己选择的值。
斜体
用于 URL、主机名、目录和文件名、Unix 命令和选项、程序,偶尔用于强调。
提示
这个元素表示一个提示或建议。
注意
这个元素表示警告或注意事项。
注意
这个元素表示一般说明。
通常会显示带有提示符的命令,以说明它们的使用上下文。从命令行发出的命令显示为$提示符:
$ `chmod 600 my.cnf`
Unix 用户习惯看到的提示符,但这并不一定表示该命令仅在 Unix 下有效。除非另有说明,带有$提示符的命令通常也应在 Windows 下工作。
如果你应该以 Unix 的root用户身份运行命令,则提示符是#:
# `perl -MCPAN -e shell`
专用于 Windows 的命令使用C:\>提示符:
C:\> `"C:\Program Files\MySQL\MySQL Server 5.6\bin\mysql"`
从mysql客户端程序中发出的 SQL 语句显示为mysql>提示符并以分号终止:
mysql> `SELECT * FROM my_table;`
对于显示查询结果的示例,如同使用mysql时看到的那样,有时会截断输出,使用省略号(...)表示结果包含比显示的更多行。以下查询生成大量输出行,其中中间的部分已被省略:
mysql> `SELECT name, abbrev FROM states ORDER BY name;`
+----------------+--------+
| name | abbrev |
+----------------+--------+
| Alabama | AL |
| Alaska | AK |
| Arizona | AZ |
…
| West Virginia | WV |
| Wisconsin | WI |
| Wyoming | WY |
+----------------+--------+
显示仅 SQL 语句语法的示例不包括mysql>提示符,但会根据需要包括分号,以更清楚地指示语句结束。例如,这是一个单一语句:
CREATE TABLE t1 (i INT)
SELECT * FROM t2;
但这个示例代表两个语句:
CREATE TABLE t1 (i INT);
SELECT * FROM t2;
分号在mysql中作为语句终止符号,是一种符号方便,但不是 SQL 本身的一部分,因此当你在程序中(例如使用 Perl 或 Java)发出 SQL 语句时,不要包括终止分号。
如果语句或命令输出过长而无法适合书页,我们使用符号↩来显示该行已缩进以适应。
mysql> `SELECT 'Mysql: The Definitive Guide to Using, Programming,`↩
-> `and Administering Mysql 4 (Developer\'s Library)' AS book;`
+-----------------------------------------------------+
| book |
+-----------------------------------------------------+
| Mysql: The Definitive Guide to Using, Programming,↩
and Administering Mysql 4 (Developer's Library) |
+-----------------------------------------------------+
1 row in set (0,00 sec)
MySQL Cookbook 配套的 GitHub 存储库
MySQL Cookbook有一个配套 GitHub 存储库,您可以在其中获取本书开发过程中开发的示例的源代码和样本数据,以及辅助文档。
配方源代码和数据
本书中的示例基于名为recipes的发行版的源代码和样本数据,可在配套的 GitHub 存储库中找到。
recipes发行版是示例的主要来源,并且在整本书中都有引用。解压缩后,无论是压缩的 TAR 文件(recipes.tar.gz)还是 ZIP 文件(recipes.zip),都会创建一个名为mysqlcookbook-VERSION/recipes的目录。
使用recipes发行版可以节省大量输入时间。例如,当你在书中看到描述数据库表结构的CREATE TABLE语句时,通常可以在tables目录下找到相应的 SQL 批处理文件,而无需手动输入定义。切换到tables目录并执行以下命令,其中filename是包含CREATE TABLE语句的文件名:
$ `mysql cookbook <` *`filename`*
如果需要指定 MySQL 用户名或密码选项,请将它们添加到命令行中。
要从recipes发行版导入所有表格,请使用以下命令:
$ `mysql cookbook <` cookbook.sql
recipes发行版包含书中显示的程序,但在许多情况下还包括其他语言的实现。例如,书中使用 Python 展示的脚本可能在recipes发行版中也以 Perl、Ruby、PHP、Go 或 Java 的形式提供。如果您希望将书中程序转换为其他语言,这可能会节省您的翻译工作。
Amazon Review Data (2018)
Amazon 相关评论数据在第七章,“处理字符串”中可找到,网址为http://deepyeti.ucsd.edu/jianmo/amazon/index.html,可以通过这个表单下载。Justifying recommendations using distantly-labeled reviews and fined-grained aspects Jianmo Ni, Jiacheng Li, Julian McAuley Empirical Methods in Natural Language Processing (EMNLP), 2019.
MySQL Cookbook 配套文件
之前MySQL Cookbook版本中包含的一些附录现在可以在配套的 GitHub 仓库中单独找到。它们为书中涵盖的主题提供了背景信息。
从命令行执行程序
提供了在命令提示符下执行命令和设置环境变量(如PATH)的说明。
获取 MySQL 及相关软件
要运行本书中的示例,您需要访问 MySQL,以及您想要使用的编程语言的适当的 MySQL 特定接口。以下说明描述了需要哪些软件以及如何获取它们。
如果访问由他人运行的 MySQL 服务器,则只需在您自己的机器上安装 MySQL 客户端软件。要运行自己的服务器,您需要完整的 MySQL 发行版。
要编写自己的基于 MySQL 的程序,您需要通过语言特定的 API 与服务器通信。Perl 和 Ruby 接口依赖于 MySQL C API 客户端库来处理低级客户端-服务器协议。PHP 接口也是如此,除非 PHP 配置为使用mysqlnd,即本机协议驱动程序。对于 Perl 和 Ruby,您必须先安装 C 客户端库和头文件。PHP 包含所需的 MySQL 客户端支持文件,但必须编译时启用 MySQL 支持,否则将无法使用它。Python、Go 和 Java 的 MySQL 驱动程序直接实现客户端-服务器协议,因此它们不需要 MySQL C 客户端库。
如果您的系统上已经安装了客户端软件,则可能不需要自己安装它。如果您与提供 MySQL 访问权限的 Internet 服务提供商(ISP)有帐户,则这种情况很常见。
MySQL
MySQL 发行版和文档,包括MySQL 参考手册和MySQL Shell,可以从http://dev.mysql.com/downloads和http://dev.mysql.com/doc获取。
如果需要安装 MySQL C 客户端库和头文件,它们将包含在从源分发安装 MySQL 时,或使用除 RPM 或 DEB 二进制分发以外的二进制(预编译)分发时。在 Linux 下,您可以选择使用 RPM 或 DEB 文件安装 MySQL,但除非安装开发 RPM 或 DEB,否则不会安装客户端库和头文件。(服务器、标准客户端程序以及开发库和头文件有单独的 RPM 或 DEB 文件。)
Perl 支持
可以在Perl 编程语言网站上获取一般的 Perl 信息。
您可以从Comprehensive Perl Archive Network (CPAN)获取 Perl 软件。
要编写基于 MySQL 的 Perl 程序,您需要 DBI 模块和特定于 MySQL 的 DBD 模块,DBD::mysql。
要在 Unix 下安装这些模块,请让 Perl 自己帮助你。例如,要安装 DBI 和 DBD::mysql,请运行以下命令(可能需要以root身份执行):
# `perl -MCPAN -e shell`
cpan> `install DBI`
cpan> `install DBD::mysql`
如果最后一个命令报告测试失败,请改用force install DBD::mysql。在 Windows 的 ActiveState Perl 中,请使用ppm实用程序:
C:\> `ppm`
ppm> `install DBI`
ppm> `install DBD-mysql`
您还可以使用 CPAN shell 或ppm安装本书中提到的其他 Perl 模块。
安装了 DBI 和 DBD::mysql 模块后,可以从命令行获取文档:
$ `perldoc DBI`
$ `perldoc DBI::FAQ`
$ `perldoc DBD::mysql`
也可以从Perl 网站获取文档。
Ruby 支持
主要的Ruby 网站提供了访问 Ruby 发行版和文档的途径。
Ruby MySQL2 gem 可以从RubyGems获取。
PHP 支持
主要的 PHP 网站 提供 PHP 分发和文档,包括 PDO 文档。
PHP 源分发包括 PDO 支持,因此您无需单独获取它。但是,在配置分发时,您必须启用 PDO 对 MySQL 的支持。如果使用二进制分发,请确保其中包含 PDO MySQL 支持。
Python 支持
主要的 Python 网站 提供 Python 分发和文档。DB API 数据库访问接口的一般文档位于 Python Wiki 上。
对于 MySQL Connector/Python,这是为 DB API 提供 MySQL 连接的驱动模块,分发和文档可从 http://bit.ly/py-connect 和 http://bit.ly/py-dev-guide 获取。
Go 支持
主要的 Go 网站 提供 Go 分发和文档,包括 sql 包和文档。
Go-MySQL-Driver 及其文档可从 GitHub go-sql-driver/mysql 仓库 获取。
Java 支持
您需要一个 Java 编译器来构建和运行 Java 程序。javac 编译器是 Java 开发工具包(JDK)的一部分。如果您的系统未安装 JDK,则可以在 macOS、Linux 和 Windows 上获取版本,请访问 Oracle 的 Java 网站。同一网站提供 JDBC、Servlet、JavaServer Pages(JSP)以及 JSP 标准标签库(JSTL)的文档(包括规范)。
对于 MySQL Connector/J,这是为 JDBC 接口提供 MySQL 连接的驱动程序,分发和文档可从 http://bit.ly/jconn-dl 和 http://bit.ly/j-dev-guide 获取。
使用代码示例
如果您在使用代码示例时遇到技术问题或困难,请发送电子邮件至 bookquestions@oreilly.com。
本书旨在帮助您完成工作。一般来说,如果本书提供了示例代码,您可以在自己的程序和文档中使用它。除非您复制了大量代码,否则无需联系我们以获得许可。例如,编写使用本书多个代码片段的程序不需要许可。销售或分发 O’Reilly 图书中的示例则需要许可。引用本书并引用示例代码回答问题则不需要许可。将本书大量示例代码整合到产品文档中则需要许可。
我们感谢您的支持,但不要求署名。典型的署名包括书名、作者、出版商和 ISBN。例如:“MySQL Cookbook, Fourth Edition 作者 Sveta Smirnova, Alkin Tezuysal(O’Reilly)。版权 2022 Sveta Smirnova, Alkin Tezuysal, 978-1-492-09309-1。”
如果您觉得您对代码示例的使用超出了合理使用范围或上述许可,请随时通过permissions@oreilly.com与我们联系。
O'Reilly 在线学习
注意
40 多年来,O'Reilly Media 一直为公司提供技术和业务培训、知识和见解,帮助它们取得成功。
我们独特的专家和创新者网络通过图书、文章和我们的在线学习平台分享他们的知识和专业知识。O’Reilly 的在线学习平台为您提供按需访问的现场培训课程、深入学习路径、交互式编码环境以及来自 O’Reilly 和其他 200 多家出版商的大量文本和视频。欲了解更多信息,请访问 http://oreilly.com.
如何联系我们
请将关于本书的评论和问题发送至出版商:
-
O’Reilly Media, Inc.
-
Gravenstein Highway North 1005
-
CA 95472,Sebastopol
-
800-998-9938(美国或加拿大)
-
707-829-0515(国际或本地)
-
707-829-0104(传真)
我们为这本书制作了一个网页,列出勘误、示例和任何额外信息。您可以访问此页面:https://www.oreilly.com/library/view/mysql-cookbook-4th/9781492093152/.
发送电子邮件至 bookquestions@oreilly.com 对本书发表评论或提出技术问题。
欲了解更多关于我们的图书、课程、会议和新闻的信息,请访问我们的网站:https://www.oreilly.com.
在 LinkedIn 上找到我们:https://linkedin.com/company/oreilly-media
在 Twitter 上关注我们:https://twitter.com/oreillymedia
在 YouTube 上观看我们:https://www.youtube.com/oreillymedia
致谢
感谢每一位读者阅读我们的书籍。我们希望它能为您服务,并且您觉得它有用。
来自 Paul DuBois,第三版
感谢我的技术审阅者 Johannes Schlüter、Geert Vanderkelen 和 Ulf Wendel。他们提出了多处改正和建议,极大地改善了文本,我非常感激他们的帮助。
Andy Oram 催促我开始第三版,并担任其编辑,Nicole Shelby 指导本书的制作,Kim Cofer 和 Lucie Haskins 进行了校对和索引工作。
感谢我的妻子 Karen,在写作过程中一直给予我鼓励和支持,这意义非凡。
来自 Sveta Smirnova 和 Alkin Tezuysal
我们衷心感谢本书的技术审阅者对本书的宝贵贡献。
Gillian Gunson 不仅提供了全面的技术反馈,还展示了如何让我们的文本更易被具有不同背景的人理解。她的语言建议帮助我们使菜谱更易读。她对细节的关注帮助我们识别了在数据库负载增长时可能出现的不准确和潜在风险区域。Gillian 还审阅了所有的代码示例,并建议如何使 Ruby 和 Java 代码更符合当前的标准。
Ege Gunes 审阅了所有 Go 语言示例,以便符合 Go 标准的风格。
Karthik Appigatla, Timur Solodovnikov, Daniel Guzman Burgos, Vladmir Fedorkov reviewed selected chapters of the book. Their suggested corrections helped us to improve the book a lot.
Andy Kwan 邀请我们撰写本书的第四版。Amelia Blevins 和 Jeff Bleiel 是我们的编辑,帮助使本书更易读。Rita Fernando 审阅了几章,并提供了反馈,使我们能够更符合 O’Reilly 的标准。
Sveta Smirnova 提供。
我要感谢在 Percona 支持部门的同事们,他们理解我需要在书上进行第二轮工作,并允许我在需要时休息。
特别感谢我的丈夫 Serguei Lassounov,在我所有的职业努力中一直支持我。
Alkin Tezuysal 提供。
我要感谢我的妻子 Aslihan 以及两个女儿 Ilayda 和 Lara,在我需要集中精力使用他们的家庭时间来写这本书时,他们的耐心和支持。
特别感谢我的 PlanetScale 同事和团队,特别是 Deepthi Sigireddi,她的额外关心和支持。特别感谢 MySQL 社区、朋友和家人们。
我也想感谢 Sveta Smirnova,在我第一次撰写书籍的旅程中给予我的无尽支持和指导。
第一章:使用 mysql 客户端程序
1.0 介绍
MySQL 数据库系统使用客户端-服务器架构。服务器mysqld是实际操作数据库的程序。要告诉服务器要做什么,请使用一个客户端程序,该程序通过使用结构化查询语言(SQL)编写的语句来传达您的意图。客户端程序用于各种不同的目的,但每个客户端通过连接到服务器、发送 SQL 语句以执行数据库操作并接收结果来与服务器交互。
客户端程序安装在要访问 MySQL 的机器上,但服务器可以安装在任何地方,只要客户端能够连接到它。由于 MySQL 是一个固有的网络数据库系统,客户端可以与在您自己的机器上本地运行的服务器或者地球另一侧的某处运行的服务器进行通信。
mysql程序是 MySQL 发行版中包含的客户端之一。在交互模式下使用时,mysql提示您输入语句,将其发送到 MySQL 服务器执行,并显示结果。mysql也可以在非交互模式下使用批处理模式,从文件中读取语句或由程序生成的语句。这使得可以在脚本或cron作业中使用mysql,或者与其他应用程序配合使用。
本章描述了mysql的功能,以便您可以更有效地使用它:
-
设置一个用于使用
cookbook数据库的 MySQL 账户 -
指定连接参数和使用选项文件
-
以交互和批处理模式执行 SQL 语句
-
控制mysql输出格式
-
使用用户定义变量保存信息
要尝试本书中展示的示例,您需要一个 MySQL 用户账户和一个数据库。本章的前两个示例描述了如何使用mysql设置这些内容,基于以下假设:
-
MySQL 服务器在您自己的系统上本地运行
-
您的 MySQL 用户名和密码分别是
cbuser和cbpass -
您的数据库名为
cookbook
如果您愿意,可以违反任何假设。您的服务器不必在本地运行,也不必使用本书中使用的用户名、密码或数据库名。当然,在这种情况下,您必须相应地修改示例。
即使您选择不使用cookbook作为您的数据库名称,我们建议您使用一个专门用于此处示例的数据库,而不是您还用于其他目的的数据库。否则,现有表的名称可能会与示例中使用的名称冲突,您将不得不进行不必要的修改。
本章中创建表的脚本位于伴随MySQL Cookbook的recipes发行版的tables目录中。其他脚本位于mysql目录中。要获取recipes发行版,请参阅前言。
1.1 设置 MySQL 用户账户
问题
你需要一个账户来连接到你的 MySQL 服务器。
解决方案
使用 CREATE USER 和 GRANT 语句设置账户。然后使用账户名和密码连接服务器。
讨论
连接到 MySQL 服务器需要用户名和密码。你可能还需要指定运行服务器的主机名。如果不显式指定连接参数,mysql 将假定默认值。例如,如果未指定主机名,mysql 将假定服务器运行在本地主机上。
如果其他人已经为你设置了账户并授予了权限,允许你创建和修改 cookbook 数据库,只需使用该账户。否则,下面的示例展示了如何使用 mysql 程序连接服务器并执行设置具有访问名为 cookbook 数据库权限的用户账户的语句。mysql 的参数包括 -h localhost 以连接到运行在本地主机上的 MySQL 服务器,-u root 以 MySQL root 用户身份连接,以及 -p 以提示 mysql 输入密码:
$ `mysql -h localhost -u root -p`
Enter password: `******`
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 54117
Server version: 8.0.27 MySQL Community Server - GPL
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> `CREATE USER 'cbuser'@'localhost' IDENTIFIED BY 'cbpass';`
mysql> `GRANT ALL ON cookbook.* TO 'cbuser'@'localhost';`
Query OK, 0 rows affected (0.09 sec)
mysql> ``GRANT PROCESS ON *.* to `cbuser`@`localhost` ;``
Query OK, 0 rows affected (0,01 sec)
mysql> `quit`
Bye
提示
如果你需要生成 MySQL 数据的转储文件,则需要 PROCESS 权限。另见 Recipe 1.4。
如果尝试调用 mysql 时出现无法找到或无效命令的错误消息,这意味着你的命令解释器不知道 mysql 安装在哪里。参见 Recipe 1.3 了解设置解释器用于查找命令的 PATH 环境变量的信息。
在显示的命令中,$ 表示你的 shell 或命令解释器显示的提示符,mysql> 是 mysql 显示的提示符。加粗显示的文本是你要输入的内容。非加粗文本(包括提示符)是程序的输出;不要输入这些内容。
当 mysql 打印密码提示时,在看到 ****** 处输入 MySQL root 密码;如果 MySQL root 用户没有密码,只需在密码提示处按 Enter(或 Return)键。你会看到 MySQL 欢迎提示,这可能因你使用的 MySQL 版本而略有不同。接着按照示例输入 CREATE USER 和 GRANT 语句。
quit 命令终止你的 mysql 会话。你也可以使用 exit 命令或(在 Unix 下)输入 Ctrl-D 来终止会话。
要授予 cbuser 账户对除 cookbook 之外的数据库的访问权限,在 GRANT 语句中,替换掉 cookbook 的数据库名称。要为现有账户授予 cookbook 数据库的访问权限,省略 CREATE USER 语句,并在 GRANT 语句中用 'cbuser'@'localhost' 替换该账户。
注意
MySQL 用户账户记录由两部分组成:用户名和主机。用户名是访问 MySQL 服务器的标识或用户。对于此部分,你可以指定任何内容。主机名是 IP 地址或访问 MySQL 服务器的主机名称。我们在 Recipe 24.0 中讨论 MySQL 安全模型和用户账户。
'cbuser'@'localhost' 中的主机名部分指示了从哪里你将连接到 MySQL 服务器。要设置一个连接到本地主机上运行的服务器的账户,请使用 localhost,如所示。如果你计划从另一台主机连接到服务器,请在 CREATE USER 和 GRANT 语句中替换该主机。例如,如果你将从名为 myhost.example.com 的主机连接到服务器,则语句如下:
mysql> `CREATE USER 'cbuser'@'myhost.example.com' IDENTIFIED BY 'cbpass';`
mysql> `GRANT ALL ON cookbook.* TO 'cbuser'@'myhost.example.com';`
可能你已经意识到这个过程中存在一个悖论:要设置一个可以连接到 MySQL 服务器的 cbuser 账户,你必须先连接到服务器,以便执行 CREATE USER 和 GRANT 语句。我假设你已经能够以 MySQL 的 root 用户连接,因为只有像 root 这样具有设置其他用户账户所需的管理特权的用户,才能使用 CREATE USER 和 GRANT。如果你无法作为 root 用户连接到服务器,请向你的 MySQL 管理员请求创建 cbuser 账户。
创建完 cbuser 账户后,请验证你能够使用它连接到 MySQL 服务器。从 CREATE USER 语句中命名的主机运行以下命令来执行此操作(-h 后命名的主机应为 MySQL 服务器运行的主机):
$ `mysql -h localhost -u cbuser -p`
Enter password: `cbpass`
现在,你可以继续创建 cookbook 数据库并在其中创建表,如 Recipe 1.2 所述。为了方便每次调用 mysql 时无需指定连接参数,可以将它们放入选项文件中(参见 Recipe 1.4)。
参见
关于管理 MySQL 账户的更多信息,请参阅 Chapter 24。
1.2 创建数据库和样例表
问题
你想创建一个数据库并在其中设置表。
解决方案
使用 CREATE DATABASE 语句创建数据库,为每个表使用 CREATE TABLE 语句,并使用 INSERT 语句向表中添加行。
讨论
在 Recipe 1.1 中显示的 GRANT 语句设置了访问 cookbook 数据库的权限,但没有创建数据库。本节展示如何创建数据库,以及如何创建表并将用于本书其他部分示例的示例数据加载到其中。在本书的其他地方使用类似的指令来创建其他表。
如 Recipe 1.1 结尾所示连接到 MySQL 服务器,然后像这样创建数据库:
mysql> `CREATE DATABASE cookbook;`
现在你已经有了一个数据库,可以在其中创建表格。首先,选择cookbook作为默认数据库:
mysql> `USE cookbook;`
创建一个简单的表格:
mysql> `CREATE TABLE limbs (thing VARCHAR(20), legs INT, arms INT, PRIMARY KEY(thing));`
然后用几行填充它:
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('human',2,2);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('insect',6,0);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('squid',0,10);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('fish',0,0);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('centipede',99,0);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('table',4,0);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('armchair',4,2);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('phonograph',0,1);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('tripod',3,0);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('Peg Leg Pete',1,2);`
mysql> `INSERT INTO limbs (thing,legs,arms) VALUES('space alien',NULL,NULL);`
提示
为了更轻松地输入INSERT语句:在输入第一个语句后,按上箭头以回忆起它,按退格键(或删除键)几次以擦除字符,直到回到最后一个开括号,然后输入下一个语句的数据值。或者,为了完全避免输入INSERT语句,可以直接跳到配方 1.6。
刚刚创建的表格名为limbs,包含三列,用于记录各种生物和物体的腿和手臂数量。最后一行的外星人生理结构使得arms和legs列的合适值无法确定;NULL表示未知值
。
PRIMARY KEY子句定义了唯一标识表行的主键。这可以防止将不明确的数据插入表中,还有助于 MySQL 更快地执行查询。我们在第十八章讨论不明确的数据和第二十一章中的性能问题。
通过执行SELECT语句验证是否将行添加到limbs表中:
mysql> `SELECT * FROM limbs;`
+--------------+------+------+
| thing | legs | arms |
+--------------+------+------+
| human | 2 | 2 |
| insect | 6 | 0 |
| squid | 0 | 10 |
| fish | 0 | 0 |
| centipede | 99 | 0 |
| table | 4 | 0 |
| armchair | 4 | 2 |
| phonograph | 0 | 1 |
| tripod | 3 | 0 |
| Peg Leg Pete | 1 | 2 |
| space alien | NULL | NULL |
+--------------+------+------+
11 rows in set (0,01 sec)
到此为止,你已经设置了一个数据库和一个表格。关于执行 SQL 语句的更多信息,请参阅配方 1.5 和配方 1.6。
注意
在本书中,语句中的 SQL 关键字(如SELECT或INSERT)以大写字母显示以突出显示。这只是一种排版约定;关键字可以是任何大小写。
1.3 查找 mysql 客户端
问题
当你从命令行调用mysql客户端时,你的命令解释器找不到它。
解决方案
将安装mysql的目录添加到你的PATH设置中。然后,你可以轻松地从任何目录运行mysql。
讨论
如果在调用时,你的 shell 或命令解释器找不到mysql,你会看到某种错误消息。在 Unix 下可能看起来像这样:
$ `mysql`
mysql: Command not found.
或者在 Windows 下可以这样:
C:\> `mysql.exe`
'mysql.exe' is not recognized as an internal or external command,↩
operable program or batch file.
告诉你的命令解释器在哪里找到mysql的一种方法是每次运行时键入其完整路径名。在 Unix 下,该命令可能看起来像这样:
$ `/usr/local/mysql/bin/mysql`
或者在 Windows 下可以这样:
C:\> `"C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql"`
快速输入长路径名会很快让人感到厌倦。你可以通过在运行之前将位置更改为安装mysql的目录来避免这样做。但是如果这样做,你可能会诱惑把所有数据文件和 SQL 批处理文件放在与mysql相同的目录中,从而在本应仅用于程序的位置上产生不必要的混乱。
更好的解决方案是修改你的 PATH 搜索路径环境变量,该变量指定命令解释器查找命令的目录。将 mysql 安装目录添加到 PATH 值中。然后,只需输入其名称,就可以从任何位置调用 mysql,从而省去输入路径名的麻烦。有关设置 PATH 变量的说明,请阅读伴随网站上的在命令行中执行程序
(参见前言)。
在 Windows 上,另一种避免输入路径名或切换到 mysql 目录的方法是创建一个快捷方式,并将其放置在更方便的位置,如桌面。这样一来,只需打开快捷方式即可简单地启动 mysql。要指定命令选项或启动目录,请编辑快捷方式的属性。如果不总是使用相同的选项调用 mysql,则创建多个快捷方式可能会很有用。例如,创建一个用于普通用户一般工作连接的快捷方式,另一个用于作为 MySQL root 用户进行管理目的的连接。
1.4 指定 mysql 命令选项
问题
当你在没有命令选项的情况下调用 mysql 程序时,它会立即退出,并显示错误消息。
解决方案
必须指定连接参数。可以在命令行、选项文件或两者混合使用来执行此操作。
讨论
如果你在没有命令选项的情况下调用 mysql,可能会导致拒绝访问
错误。为了避免这种情况,可以按照 Recipe 1.1 中所示连接到 MySQL 服务器,像这样使用 mysql:
$ `mysql -h localhost -u cbuser -p`
Enter password: `cbpass`
每个选项都有单破折号短
形式:-h 和 -u 用于指定主机名和用户名,-p 用于提示输入密码。还有对应的双破折号长
形式:--host、--user 和 --password。使用方法如下:
$ `mysql --host=localhost --user=cbuser --password`
Enter password: `cbpass`
要查看 mysql 支持的所有选项,请使用以下命令:
$ `mysql --help`
指定 mysql 的命令选项的方式也适用于其他 MySQL 程序,如 mysqldump 和 mysqladmin。例如,要生成名为 cookbook.sql 的转储文件,其中包含 cookbook 数据库中表的备份,可以像这样执行 mysqldump:
$ `mysqldump -h localhost -u cbuser -p cookbook > cookbook.sql`
Enter password: `cbpass`
一些操作需要具有管理员权限的 MySQL 帐户。mysqladmin 程序可以执行仅限于 MySQL root 帐户的操作。例如,要停止服务器,请按以下方式调用 mysqladmin:
$ `mysqladmin -h localhost -u root -p shutdown`
Enter password: ← enter MySQL root account password here
如果选项的值与其默认值相同,则可以省略该选项。但是,没有默认密码。如果愿意,可以直接在命令行上使用 -ppassword(选项和密码之间无空格)或 --password``=password 指定密码。
警告
我们不建议这样做,因为密码对旁观者可见,并且在多用户系统上,可能会被运行诸如ps报告进程信息或可以读取您的 shell 历史文件内容的其他用户发现。
因为默认主机是localhost,与我们明确指定的相同值,您可以在命令行中省略-h(或--host)选项:
$ `mysql -u cbuser -p`
假设您真的不想指定任何选项。如何让mysql“仅知道”要使用哪些值?这很容易,因为 MySQL 程序支持选项文件:
-
如果将选项放入选项文件中,则无需在每次调用特定程序时在命令行上指定它。
-
您可以混合使用命令行和选项文件选项。这使您可以将最常用的选项值存储在文件中,并根据需要在命令行上覆盖它们。
这一节的其余部分描述了这些功能。
使用选项文件指定连接参数
为了避免每次调用mysql时在命令行中输入选项,将它们放入一个选项文件中,mysql会自动读取。选项文件是纯文本文件:
-
在 Unix 下,您的个人选项文件位于家目录中的.my.cnf文件中。管理员还可以使用站点范围的选项文件指定适用于所有用户的参数。您可以使用 MySQL 安装目录下的/etc或/etc/mysql目录中的my.cnf文件,或者在etc目录下的etc目录中。
-
在 Windows 下,您可以使用的文件包括 MySQL 安装目录中的my.ini或my.cnf文件(例如C:\Program Files\MySQL\MySQL Server 8.0),您的 Windows 目录(可能是C:\WINDOWS),或*C:*目录。
要查看允许的选项文件位置的确切列表,请调用mysql --help。
下面的示例说明了 MySQL 选项文件中使用的格式:
# general client program connection options
[client]
host = localhost
user = cbuser
password = cbpass
# options specific to the mysql program
[mysql]
skip-auto-rehash
pager="/usr/bin/less -i" # specify pager for interactive mode
使用刚刚显示的[client]组中列出的连接参数,您可以通过在命令行上没有任何选项的情况下调用mysql来连接为cbuser。
$ `mysql`
对于其他 MySQL 客户端程序(例如mysqldump)也是如此。
警告
选项password以明文格式存储在配置文件中,任何有权访问此文件的用户都可以读取它。如果您想要安全存储连接凭据,应使用mysql_config_editor。
mysql_config_editor将连接凭据存储在位于 Unix 家目录下的.mylogin.cnf文件或 Windows 下的%APPDATA%\MySQL目录中。它仅支持连接参数host、user、password和socket。选项--login-path指定存储凭据的组。默认为[client]。
这是一个示例,演示如何使用mysql_config_editor创建加密的登录文件。
$ `mysql_config_editor set --login-path=client \`
> `--host=localhost --user=cbuser --password`
Enter password: `cbpass`
# print stored credentials
$ `mysql_config_editor print --all`
[client]
user = cbuser
password = *****
host = localhost
MySQL 选项文件具有以下特点:
-
Lines are written in groups (or sections). The first line of a group specifies the group name within square brackets, and the remaining lines specify options associated with the group. The example file just shown has a
[client]group and a[mysql]group. To specify options for the server, mysqld, put them in a[mysqld]group. -
用于指定客户端连接参数的通常选项组是
[client]。这个组实际上被所有标准的 MySQL 客户端使用。通过在这个组中列出选项,您不仅可以更容易地调用mysql,还可以调用其他程序,如mysqldump和mysqladmin。只需确保您放入该组的任何选项被所有客户端程序理解。否则,调用任何不理解它的客户端将导致“未知选项”错误。 -
您可以在选项文件中定义多个组。按照惯例,MySQL 客户端会查找
[client]组中的参数以及以程序本身命名的组。这为列出您希望所有客户端程序使用的一般客户端参数提供了方便的方法,但您仍然可以指定仅适用于特定程序的选项。上述示例选项文件说明了此约定,对于mysql程序来说,它从[client]组获取一般连接参数,并从[mysql]组中获取skip-auto-rehash和pager选项。 -
在一个组内,以
name=value的格式编写选项行,其中name对应于选项名称(不带前导破折号),而value是选项的值。如果选项不需要值(例如skip-auto-rehash),则仅列出名称,不需要尾随的=value部分。 -
在选项文件中,只允许使用选项的长形式,而不是短形式。例如,在命令行上,可以使用
-hhost_name或--host``=host_name来指定主机名。在选项文件中,只允许使用host=host_name。 -
许多程序,包括mysql和mysqld,除了命令选项外,还有程序变量。对于服务器来说,这些称为系统变量;参见第 22.1 节。程序变量可以像选项一样在选项文件中指定。在内部,程序变量名称使用下划线,但在选项文件中,可以互换使用破折号或下划线。例如,
skip-auto-rehash和skip_auto_rehash是等效的。要在[mysqld]选项组中设置服务器的sql_mode系统变量,sql_mode=value和sql-mode=value是等效的。(破折号和下划线的互换性也适用于命令行上指定的选项或变量。) -
在选项文件中,
=分隔选项名称和值时允许空格。这与命令行不同,命令行中=两侧不允许有空格。如果选项值包含空格或其他特殊字符,可以使用单引号或双引号进行引用。pager选项说明了这一点。 -
使用选项文件来指定连接参数(比如
host、user和password)是很常见的。然而,这个文件可以列出其他用途的选项。在[mysql]组中显示的pager选项指定了mysql在交互模式下显示输出时应该使用的分页程序。这与程序如何连接服务器无关。我们不建议将password放入选项文件,因为它以明文形式存储,可能会被有文件系统访问权限但不一定有 MySQL 安装访问权限的用户发现。 -
如果选项文件中的参数出现多次,则以找到的最后一个值为准。通常,您应该按照
[client]组后跟随任何特定于程序的组,以便如果这两组设置的选项有重叠,程序特定的值将覆盖更一般的选项。 -
以
#或;字符开头的行被视为注释而被忽略。空行也被忽略。#可以用来在选项行末尾写注释,就像pager选项所示。 -
指定文件或目录路径的选项应使用
/作为路径分隔符,即使在使用\作为路径分隔符的 Windows 系统下也是如此。或者,使用\\来表示\(这是必要的,因为\在字符串中是 MySQL 的转义字符)。
要查找 mysql 程序将从选项文件中读取哪些选项,请使用此命令:
$ `mysql --print-defaults`
您还可以使用 my_print_defaults 实用程序,该实用程序以选项文件组的名称作为参数。例如,mysqldump 在 [client] 和 [mysqldump] 组中查找选项。要检查这些组中的选项文件设置,使用此命令:
$ `my_print_defaults client mysqldump`
混合命令行和选项文件参数
可以混合命令行选项和选项文件中的选项。也许您想在选项文件中列出用户名和服务器主机,但宁愿不要在那里存储密码。这没问题;MySQL 程序首先读取您的选项文件以查看那里列出了哪些连接参数,然后检查命令行以获取额外的参数。这意味着您可以以一种方式指定一些选项,以另一种方式指定另一些选项。例如,您可以在选项文件中列出用户名和主机名,但在命令行上使用密码选项:
$ `mysql -p`
Enter password: ← enter your password here
命令行参数优先于选项文件中找到的参数,因此要覆盖选项文件参数,只需在命令行上指定它。例如,您可以在选项文件中列出常规 MySQL 用户名和密码以供通用使用。然后,如果必须偶尔作为 MySQL root用户连接,则在命令行上指定用户和密码选项以覆盖选项文件中的值:
$ `mysql -u root -p`
Enter password: ← enter MySQL root account password here
当选项文件中存在非空密码时,要明确指定无密码
,请在命令行上使用--skip-password:
$ `mysql --skip-password`
注意
从这一点开始,通常我会展示 MySQL 程序的命令而不带连接参数选项。我假设您会提供任何需要的参数,无论是在命令行上还是在选项文件中。
保护选项文件免受其他用户的影响
在像 Unix 这样的多用户操作系统上,保护位于您的主目录中的选项文件,以防其他用户阅读它并查找如何使用您的帐户连接到 MySQL。使用chmod通过设置其模式使文件私有,仅允许您访问。以下任何一个命令均可实现此目的:
$ `chmod 600 .my.cnf`
$ `chmod go-rwx .my.cnf`
在 Windows 上,您可以使用 Windows Explorer 设置文件权限。
1.5 交互式执行 SQL 语句
问题
您已经启动mysql。现在您想将 SQL 语句发送到 MySQL 服务器以执行。
解决方案
只需输入它们,让mysql知道每个语句的结束位置。或者,直接在命令行上指定单行语句
。
讨论
当您调用mysql时,默认情况下会显示一个mysql>提示,告诉您它已准备好接收输入。要在mysql>提示处执行 SQL 语句,请输入语句,末尾加上分号(;)表示语句结束,然后按 Enter 键。必须使用显式语句终止符;mysql不会将 Enter 解释为终止符,因为您可以使用多行输入输入语句。分号是最常见的终止符,但您也可以使用\g(go
)作为分号的同义词。因此,即使这些示例以不同的方式输入和终止,它们发出相同的语句,是等效的:
mysql> `SELECT NOW();`
+---------------------+
| NOW() |
+---------------------+
| 2014-04-06 17:43:52 |
+---------------------+
mysql> `SELECT`
-> `NOW()\g`
+---------------------+
| NOW() |
+---------------------+
| 2014-04-06 17:43:57 |
+---------------------+
对于第二条语句,mysql会将提示从mysql>更改为->,以示仍在等待语句终止符。
语句终止符;和\g不是语句本身的一部分。它们是mysql程序使用的约定,该程序识别这些终止符并在发送语句到 MySQL 服务器之前将其剥离。
有些语句生成的输出行非常长,在终端上占据多行,这可能使查询结果难以阅读。为避免这个问题,通过使用\G而不是;或\g来终止语句,生成竖直
输出。输出将在不同行显示列值:
mysql> `USE cookbook`
mysql> `SHOW FULL COLUMNS FROM limbs LIKE 'thing'\G`
*************************** 1\. row ***************************
Field: thing
Type: varchar(20)
Collation: utf8mb4_0900_ai_ci
Null: YES
Key:
Default: NULL
Extra:
Privileges: select,insert,update,references
Comment:
要为会话中执行的所有语句生成垂直输出,请使用 -E(或 --vertical)选项调用 mysql。仅为超出终端宽度的结果生成垂直输出,请使用 --auto-vertical-output。
要直接从命令行执行语句,请使用 -e(或 --execute)选项指定它。这对于 一行命令
非常有用。例如,要计算 limbs 表中的行数,请使用以下命令:
$ `mysql -e "SELECT COUNT(*) FROM limbs" cookbook`
+----------+
| COUNT(*) |
+----------+
| 11 |
+----------+
要执行多个语句,请用分号分隔它们:
$ `mysql -e "SELECT COUNT(*) FROM limbs;SELECT NOW()" cookbook`
+----------+
| COUNT(*) |
+----------+
| 11 |
+----------+
+---------------------+
| NOW() |
+---------------------+
| 2014-04-06 17:43:57 |
+---------------------+
mysql 还可以从文件或另一个程序中读取语句(参见 Recipe 1.6)。
1.6 从文件或程序中执行 SQL 语句
问题
您希望 mysql 读取存储在文件中的语句,而不必手动输入它们。或者您希望 mysql 读取来自另一个程序的输出。
解决方案
要读取文件,请重定向 mysql 的输入,或使用 source 命令。要从程序中读取,请使用管道。
讨论
默认情况下,mysql 程序从终端交互式读取输入,但您可以通过其他输入源(如文件或程序)提供语句。
对于这一目的,MySQL 支持方便地执行一组语句的批处理模式,无需每次手动输入。批处理模式使得可以轻松设置无需用户干预的 cron 作业。
要创建一个供 mysql 批量执行的 SQL 脚本,请将您的语句放入一个文本文件中。然后调用 mysql 并重定向其输入以从该文件中读取:
$ `mysql cookbook <` *`file_name`*
从输入文件读取的语句会替代您通常手动输入的内容,因此它们必须以 ;、\g 或 \G 结束,就像您手动输入它们一样。交互式和批处理模式在默认输出格式上有所不同。对于交互模式,默认格式为表格(带框线)格式。对于批处理模式,默认格式为制表符分隔格式。要覆盖默认设置,请使用适当的命令选项(参见 Recipe 1.7)。
SQL 脚本也非常有用,可用于将一组 SQL 语句分发给其他人。实际上,这就是我们为本书分发 SQL 示例的方式。本书中显示的许多示例可以使用附带的 recipes 分发中的脚本文件运行(参见 前言)。将这些文件以批处理模式提供给 mysql,避免手动输入语句。例如,当一个配方展示了一个 CREATE TABLE 语句来定义一个表时,您通常会在 recipes 分发的 tables 目录中找到一个 SQL 批处理文件,可以用来创建(并可能加载数据到)该表。回顾 Recipe 1.2 显示了用于手动输入的创建和填充 limbs 表的语句。该分发的 recipes 目录中包含一个 limbs.sql 文件,其中包含执行相同操作的语句。文件如下:
DROP TABLE IF EXISTS limbs;
CREATE TABLE limbs
(
thing VARCHAR(20), # what the thing is
legs INT, # number of legs it has
arms INT, # number of arms it has
PRIMARY KEY(thing)
);
INSERT INTO limbs (thing,legs,arms) VALUES('human',2,2);
INSERT INTO limbs (thing,legs,arms) VALUES('insect',6,0);
INSERT INTO limbs (thing,legs,arms) VALUES('squid',0,10);
INSERT INTO limbs (thing,legs,arms) VALUES('fish',0,0);
INSERT INTO limbs (thing,legs,arms) VALUES('centipede',99,0);
INSERT INTO limbs (thing,legs,arms) VALUES('table',4,0);
INSERT INTO limbs (thing,legs,arms) VALUES('armchair',4,2);
INSERT INTO limbs (thing,legs,arms) VALUES('phonograph',0,1);
INSERT INTO limbs (thing,legs,arms) VALUES('tripod',3,0);
INSERT INTO limbs (thing,legs,arms) VALUES('Peg Leg Pete',1,2);
INSERT INTO limbs (thing,legs,arms) VALUES('space alien',NULL,NULL);
要执行此 SQL 脚本文件中的语句,请切换到recipes分发的tables目录并运行此命令:
$ `mysql cookbook < limbs.sql`
您会注意到脚本中包含一个语句,如果存在则删除表,然后重新创建该表并加载数据。这使您可以随时通过再次运行脚本来轻松将其恢复到基线状态,而不必担心对表进行更改。
刚刚显示的命令说明了如何在命令行上为mysql指定输入文件。或者,要从mysql会话内部的文件中读取一组 SQL 语句,请使用source filename命令(或\. filename,这是同义词):
mysql> `source limbs.sql`
mysql> `\. limbs.sql`
SQL 脚本本身可以包含source或\.命令来包含其他脚本。这为您提供了额外的灵活性,但要注意避免循环。
mysql需要读取的文件不一定要手动编写;它可以是程序生成的。例如,mysqldump实用程序通过编写一组重新创建数据库的 SQL 语句来生成数据库备份。要重新加载mysqldump的输出,请将其输入到mysql。例如,您可以通过网络将数据库复制到另一个 MySQL 服务器:
$ `mysqldump cookbook > dump.sql`
$ `mysql -h other-host.example.com cookbook < dump.sql`
mysql还可以读取管道,因此它可以将其他程序的输出作为其输入。任何生成正确终止的 SQL 语句的输出命令都可以用作mysql的输入源。可以重新编写转储和重新加载示例,直接使用管道将两个程序连接起来,避免需要中介文件:
$ `mysqldump cookbook | mysql -h other-host.example.com cookbook`
程序生成的 SQL 还可以用于向表中填充测试数据,而无需手动编写INSERT语句。创建一个生成这些语句的程序,然后使用管道将其输出发送到mysql:
$ `generate-test-data | mysql cookbook`
Recipe 6.6 进一步讨论了mysqldump。
1.7 控制 mysql 输出的目标和格式
问题
你希望将mysql的输出发送到除了屏幕之外的其他地方。并且你并不一定想要默认的输出格式。
解决方案
将输出重定向到文件,或使用管道将输出发送到程序。您还可以控制mysql输出的其他方面,以生成表格、制表符分隔、HTML 或 XML 输出;抑制列标题;或使mysql更或少冗长。
讨论
除非将mysql输出发送到其他地方,否则它将显示在屏幕上。要将mysql的输出保存到文件中,请使用您的 shell 的重定向功能:
$ `mysql cookbook >` *`outputfile`*
如果你以重定向输出的方式交互运行mysql,你看不到你输入的内容,因此在这种情况下,通常也要从文件(或其他程序)中读取输入:
$ `mysql cookbook <` *`inputfile`* `>` *`outputfile`*
要将输出发送到另一个程序(例如,解析查询的输出),请使用管道:
$ `mysql cookbook <` *`inputfile`* `| sed -e "s/\t/:/g" > outputfile`
本节的其余部分展示了如何控制mysql的输出格式。
生成表格或制表符分隔的输出
mysql 根据其运行是否交互式选择默认输出格式。对于交互使用,mysql 使用表格(带框线)格式将输出写入终端:
$ `mysql cookbook`
mysql> `SELECT * FROM limbs WHERE legs=0;`
+------------+------+------+
| thing | legs | arms |
+------------+------+------+
| squid | 0 | 10 |
| fish | 0 | 0 |
| phonograph | 0 | 1 |
+------------+------+------+
3 rows in set (0.00 sec)
对于非交互使用(当输入或输出被重定向时),mysql 写入制表符分隔的输出:
$ `echo "SELECT * FROM limbs WHERE legs=0" | mysql cookbook`
thing legs arms
squid 0 10
fish 0 0
phonograph 0 1
要覆盖默认输出格式,请使用适当的命令选项。考虑一个早期显示的 sed 命令,并更改其参数以混淆输出:
$ `mysql cookbook <` *`inputfile`* `| sed -e "s/table/XXXXX/g"`
$ `mysql cookbook -e "SELECT * FROM limbs where legs=4" |` ↩
`sed -e "s/table/XXXXX/g"`
thing legs arms
XXXXX 4 0
armchair 4 2
因为 mysql 在非交互上下文中运行,它会生成制表符分隔的输出,这可能比表格输出难以阅读。使用 -t(或 --table)选项生成更易读的表格输出:
$ `mysql cookbook -t -e "SELECT * FROM limbs where legs=4" |` ↩
`sed -e "s/table/XXXXX/g"`
+----------+------+------+
| thing | legs | arms |
+----------+------+------+
| XXXXX | 4 | 0 |
| armchair | 4 | 2 |
+----------+------+------+
反向操作是在交互模式中生成批量(制表符分隔的)输出。要执行此操作,请使用 -B 或 --batch。
生成 HTML 或 XML 输出
mysql 使用 -H(或 --html)选项从每个查询结果集生成 HTML 表格。这使您可以轻松地生成包含查询结果的网页输出。以下是一个示例(为了更容易阅读,已添加了换行符):
$ `mysql -H -e "SELECT * FROM limbs WHERE legs=0" cookbook`
<TABLE BORDER=1>
<TR><TH>thing</TH><TH>legs</TH><TH>arms</TH></TR>
<TR><TD>squid</TD><TD>0</TD><TD>10</TD></TR>
<TR><TD>fish</TD><TD>0</TD><TD>0</TD></TR>
<TR><TD>phonograph</TD><TD>0</TD><TD>1</TD></TR>
</TABLE>
表格的第一行包含列标题。如果不需要标题行,请参阅下一节的说明。
您可以将输出保存到文件,然后使用 Web 浏览器查看。例如,在 Mac OS X 上执行以下操作:
$ `mysql -H -e "SELECT * FROM limbs WHERE legs=0" cookbook > limbs.html`
$ `open -a safari limbs.html`
要生成一个 XML 文档而不是 HTML,请使用 -X(或 --xml)选项:
$ `mysql -X -e "SELECT * FROM limbs WHERE legs=0" cookbook`
<?xml version="1.0"?>
<resultset statement="select * from limbs where legs=0
">
<row>
<field name="thing">squid</field>
<field name="legs">0</field>
<field name="arms">10</field>
</row>
<row>
<field name="thing">fish</field>
<field name="legs">0</field>
<field name="arms">0</field>
</row>
<row>
<field name="thing">phonograph</field>
<field name="legs">0</field>
<field name="arms">1</field>
</row>
</resultset>
您可以通过运行 XSLT 转换来重新格式化 XML 以适应各种用途。这使您可以使用相同的输入生成多种输出格式。
-H, --html -X, 和 --xml 选项仅为生成结果集的语句生成输出,不包括 INSERT 或 UPDATE 等语句。
要编写自己的程序以从查询结果生成 XML,请参阅 Recipe 13.15。
抑制查询输出中的列标题
制表符分隔的格式便于生成用于导入其他程序的数据文件。然而,默认情况下,每个查询的输出的第一行默认列出列标题,这可能并不总是您想要的。假设一个名为 summarize 的程序为一列数字生成描述性统计信息。如果您从 mysql 生成输出以与此程序一起使用,则列标题行会干扰结果,因为 summarize 会将其视为数据。要创建仅包含数据值的输出,请使用 --skip-column-names 选项抑制标题行:
$ `mysql --skip-column-names -e "SELECT arms FROM limbs" cookbook | summarize`
指定 silent
选项(-s 或 --silent)两次可以实现相同效果:
$ `mysql -ss -e "SELECT arms FROM limbs" cookbook | summarize`
指定输出列分隔符
在非交互模式下,mysql 通过制表符分隔输出列,并且没有选项来指定输出分隔符。为了生成使用不同分隔符的输出,需要对 mysql 输出进行后处理。假设你想创建一个输出文件,以供期望值用冒号字符(:)而不是制表符分隔的程序使用。在 Unix 下,可以使用 tr 或 sed 这样的实用工具将制表符转换为任意分隔符。以下任何一个命令都可以将制表符更改为冒号(TAB 表示输入制表符的位置):
$ `mysql cookbook <` *`inputfile`* `| sed -e "s/`*`TAB`*`/:/g" >` *`outputfile`*
$ `mysql cookbook <` *`inputfile`* `| tr "`*`TAB`*`" ":" >` *`outputfile`*
$ `mysql cookbook <` *`inputfile`* `| tr "\011" ":" >` *`outputfile`*
tr 的语法在不同版本之间有所不同;请查阅本地文档。此外,一些 shell 使用制表符字符用于特殊目的,如文件名补全。对于这些 shell,可以通过使用 Ctrl-V 转义字符输入制表符。
sed 比 tr 更强大,因为它理解正则表达式并允许多个替换。这对于生成像逗号分隔值(CSV)格式的输出非常有用,需要三个替换操作:
-
如果数据中出现任何引号字符,请通过双引号转义它们,以便在使用生成的 CSV 文件时,它们不会被解释为列分隔符。
-
将制表符更改为逗号。
-
用引号括起列值。
sed 允许在单个命令行中执行所有三个替换操作:
$ `mysql cookbook <` *`inputfile`* `\`
`| sed -e 's/"/""/g' -e 's/`*`TAB`*`/","/g' -e 's/^/"/' -e 's/$/"/' >` *`outputfile`*
至少可以说那太神秘了。你可以用其他更易读的语言达到相同的效果。这里有一个简短的 Perl 脚本,与 sed 命令执行相同的操作(将制表符分隔的输入转换为 CSV 输出),并包含注释以记录其工作原理:
#!/usr/bin/perl
# csv.pl: convert tab-delimited input to comma-separated values output
while (<>) # read next input line
{
s/"/""/g; # double quotes within column values
s/\t/","/g; # put "," between column values
s/^/"/; # add " before the first value
s/$/"/; # add " after the last value
print; # print the result
}
如果将脚本命名为 csv.pl,可以像这样使用它:
$ `mysql cookbook <` *`inputfile`* `| perl csv.pl >` *`outputfile`*
tr 和 sed 在 Windows 下通常不可用。Perl 可能更适合作为跨平台解决方案,因为它可以在 Unix 和 Windows 下运行(在 Unix 系统上,Perl 通常预安装。在 Windows 上,可以自由安装)。
另一种生成 CSV 输出的方法是使用专为此目的设计的 Perl Text::CSV_XS 模块。实用程序 cvt_file.pl,在配方发布中可用,使用此模块构建通用文件重新格式化器。
控制 mysql 的详细级别
当你以非交互方式运行 mysql 时,不仅默认的输出格式会改变,而且变得更加简洁。例如,mysql 不会打印行数或指示语句执行所需的时间。要告诉 mysql 更加详细,请使用 -v 或 --verbose,并多次指定该选项以增加详细程度。尝试以下命令,看看输出如何不同:
$ `echo "SELECT NOW()" | mysql`
$ `echo "SELECT NOW()" | mysql -v`
$ `echo "SELECT NOW()" | mysql -vv`
$ `echo "SELECT NOW()" | mysql -vvv`
-v 和 --verbose 的对应项是 -s 和 --silent,这些选项也可以多次使用以增加效果。
1.8 在 SQL 语句中使用用户定义变量
问题
你想在一个语句中使用由之前的语句产生的值。
解决方案
将值保存在用户定义的变量中以便以后使用。
讨论
要保存由 SELECT 语句返回的值,请将其赋给用户定义的变量。这使您能够在同一会话中的其他语句中引用它(但不能跨会话)。用户变量是标准 SQL 的 MySQL 特定扩展。它们在其他数据库引擎中无法工作。
要在 SELECT 语句中给用户变量赋值,使用 @var_name := value 语法。该变量可以在后续语句中的任何允许表达式的地方使用,例如在 WHERE 子句或 INSERT 语句中。
下面是一个示例,将值赋给一个用户变量,然后稍后引用该变量。这是确定表中某一行特征值的简单方法,然后选择该特定行的一种方式:
mysql> `SELECT MAX(arms+legs) INTO @max_limbs FROM limbs;`
Query OK, 1 row affected (0,01 sec)
mysql> `SELECT * FROM limbs WHERE arms+legs = @max_limbs;`
+-----------+------+------+
| thing | legs | arms |
+-----------+------+------+
| centipede | 99 | 0 |
+-----------+------+------+
另一个变量的用途是在创建具有 AUTO_INCREMENT 列的表中的新行之后,保存来自 LAST_INSERT_ID() 的结果:
mysql> `SELECT @last_id := LAST_INSERT_ID();`
LAST_INSERT_ID() 返回最近的 AUTO_INCREMENT 值。通过将其保存在变量中,您可以在后续语句中多次引用该值,即使您发出其他创建自己的 AUTO_INCREMENT 值并因此更改 LAST_INSERT_ID() 返回值的语句。Recipe 15.10 进一步讨论了这一技术。
用户变量只保存单个值。如果语句返回多行,则语句将失败并显示错误,但将分配第一行的值:
mysql> `SELECT thing FROM limbs WHERE legs = 0;`
+------------+
| thing |
+------------+
| squid |
| fish |
| phonograph |
+------------+
3 rows in set (0,00 sec)
mysql> `SELECT thing INTO @name FROM limbs WHERE legs = 0;`
ERROR 1172 (42000): Result consisted of more than one row
mysql> `SELECT @name;`
+-------+
| @name |
+-------+
| squid |
+-------+
如果语句未返回行,则不会进行任何赋值,变量将保留其先前的值。如果之前未使用过该变量,则其值为 NULL:
mysql> `SELECT thing INTO @name2 FROM limbs WHERE legs < 0;`
Query OK, 0 rows affected, 1 warning (0,00 sec)
mysql> `SHOW WARNINGS;`
+---------+------+-----------------------------------------------------+
| Level | Code | Message |
+---------+------+-----------------------------------------------------+
| Warning | 1329 | No data - zero rows fetched, selected, or processed |
+---------+------+-----------------------------------------------------+
1 row in set (0,00 sec)
mysql> `select @name2;`
+--------+
| @name2 |
+--------+
| NULL |
+--------+
1 row in set (0,00 sec)
提示
SQL 命令 SHOW WARNINGS 返回关于可恢复错误的信息消息,例如将空结果分配给变量或使用不推荐的功能。
要将变量显式设置为特定值,请使用 SET 语句。SET 语法可以使用 := 或 = 作为赋值运算符:
mysql> `SET @sum = 4 + 7;`
mysql> `SELECT @sum;`
+------+
| @sum |
+------+
| 11 |
+------+
可以将 SELECT 结果赋给一个变量,只要将其写成标量子查询(在括号内返回单个值的查询):
mysql> `SET @max_limbs = (SELECT MAX(arms+legs) FROM limbs);`
用户变量名称对大小写不敏感:
mysql> `SET @x = 1, @X = 2; SELECT @x, @X;`
+------+------+
| @x | @X |
+------+------+
| 2 | 2 |
+------+------+
用户变量只能出现在允许表达式的地方,不能出现在必须提供常量或文字标识符的地方。试图将变量用于诸如表名之类的东西是很诱人的,但这是行不通的。例如,如果尝试使用变量生成临时表名,如下所示,将会失败:
mysql> `SET @tbl_name = CONCAT('tmp_tbl_', CONNECTION_ID());`
mysql> `CREATE TABLE @tbl_name (int_col INT);`
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 '@tbl_name (int_col INT)' at line 1
然而,你可以生成一个包含@tbl_name的准备好的 SQL 语句,然后执行结果。Recipe 6.4 演示了如何实现。
SET 也用于将值分配给存储过程参数和局部变量,以及系统变量。有关示例,请参见 Chapter 11 和 Recipe 22.1。
1.9 自定义 mysql 提示符
问题
你在不同的终端窗口打开了几个连接,并希望能够在视觉上区分它们。
解决方案
设置 mysql 提示符为自定义值
讨论
您可以通过在启动时提供选项 --prompt 来自定义 mysql 提示符:
$ `mysql --prompt="MySQL Cookbook> "`
MySQL Cookbook>
如果客户端已经启动,您可以使用命令 prompt 以交互方式进行更改:
mysql> `prompt MySQL Cookbook>`
PROMPT set to 'MySQL Cookbook> '
MySQL Cookbook>
命令 prompt,像其他 mysql 命令一样,支持缩写版本:\R。
mysql> `\R MySQL Cookbook>`
PROMPT set to 'MySQL Cookbook> '
MySQL Cookbook>
要在配置文件中指定提示值,请将选项 prompt 放在 [mysql] 部分下:
[mysql]
prompt="MySQL Cookbook> "
引号是可选的,只在需要特殊字符时才需要,例如提示字符串末尾的空格。
最后,您可以使用环境变量 MYSQL_PS1 指定提示符:
$ `export MYSQL_PS1="MySQL Cookbook> "`
$ `mysql`
MySQL Cookbook>
要将提示符重置为默认值,请运行不带参数的命令 prompt:
MySQL Cookbook> `prompt`
Returning to default PROMPT of mysql>
mysql>
提示
如果使用了 MYSQL_PS1 环境变量,则提示符的默认值将是 MYSQL_PS1 变量的值,而不是 mysql。
mysql 提示符是高度可定制的。您可以设置它显示当前日期、时间、用户账号、默认数据库、服务器主机以及关于数据库连接的其他信息。您可以在 MySQL 用户参考手册 中找到支持选项的完整列表。
要在提示符中显示用户账号,请使用特殊序列 \u 显示用户名或 \U 显示完整用户账号:
mysql> `prompt \U>`
PROMPT set to '\U> '
cbuser@localhost>
如果连接到不同机器上的 MySQL 服务器,您可能希望在提示中看到 MySQL 服务器主机名。有一个特殊序列 \h 就是为此而设计的:
mysql> `\R \h>`
PROMPT set to '\h> '
Delly-7390>
要在提示符中显示当前默认数据库,请使用特殊序列 \d:
mysql> `\R \d>`
PROMPT set to '\d> '
(none)> `use cookbook`
Database changed
cookbook>
mysql 支持多种选项将时间包含在提示中。您可以包含完整的日期和时间信息,或者只包含部分信息。
mysql> `prompt \R:\m:\s>`
PROMPT set to '\R:\m:\s> '
15:30:10>
15:30:10> `prompt \D>`
PROMPT set to '\D> '
Sat Sep 19 15:31:19 2020>
警告
除非使用完整的当前日期,否则无法指定每月的当前日期。这在 MySQL Bug #72071 中有报告,但仍未修复。
特殊序列可以组合在一起,也可以与任何其他文本一起使用。mysql 使用 UTF-8 字符集,如果您的终端也支持 UTF-8,您可以使用笑脸字符使提示更加引人注目。例如,要获取有关连接的用户账号、MySQL 主机、默认数据库和当前时间的信息,您可以将提示设置为 \u@\h [ߓᜤ] (ߕᜒ:\m:\s)>:
mysql> `prompt \u@\h [ߓᜤ] (ߕᜒ:\m:\s)>`
PROMPT set to '\u@\h [ߓᜤ] (ߕᜒ:\m:\s)> '
cbuser@Delly-7390 [ߓᣯokbook] (ߕᱶ:15:41)>
1.10 使用外部程序
问题
您希望在不离开 mysql 客户端命令提示符的情况下使用外部程序。
解决方案
使用 system 命令调用程序。
讨论
虽然 MySQL 允许为其内部用户账号生成随机密码,但仍然没有内部函数可用于生成所有其他情况下安全用户密码。运行 system 命令使用操作系统工具之一:
mysql> `system openssl rand -base64 16`
p1+iSG9rveeKc6v0+lFUHA==
! 是 system 命令的简短版本:
mysql> `\! pwgen -synBC 16 1`
Nu=3dWvrH7o_tWiE
pwgen 可能未安装在您的操作系统上。在运行此示例之前,您需要安装 pwgen 软件包。
system 是mysql客户端的命令,本地执行,使用客户端所属的权限。默认情况下,MySQL 服务器以mysql用户身份运行,尽管您可以使用任何用户账户连接。在这种情况下,您只能访问操作系统账户允许的程序和文件。因此,普通用户无法访问属于特殊用户mysqld进程运行的数据目录。
mysql> `select @@datadir;`
+-----------------+
| @@datadir |
+-----------------+
| /var/lib/mysql/ |
+-----------------+
1 row in set (0,00 sec)
mysql> `system ls /var/lib/mysql/`
ls: cannot open directory '/var/lib/mysql/': Permission denied
mysql> `\! id`
uid=1000(sveta) gid=1000(sveta) groups=1000(sveta)
出于同样的原因,system不会在远程服务器上执行任何命令。
您可以使用任何程序,指定选项,重定向输出并将其管道传输到其他命令。您可以从操作系统获得的一个有用的洞察是,mysqld进程占用的物理资源量与 MySQL 服务器内部收集的数据进行比较。
MySQL 在性能模式中存储关于内存使用情况的信息。它的伴侣sys模式包含视图,使您可以轻松访问此信息。特别是,您可以通过查询视图sys.memory_global_total找到以人类可读格式显示的分配内存总量。
mysql> `SELECT * FROM sys.memory_global_total;`
+-----------------+
| total_allocated |
+-----------------+
| 253.90 MiB |
+-----------------+
1 row in set (0.00 sec)
mysql> ``\! ps -o rss hp `pidof mysqld` | awk '{print $1/1024}'``
298.66
操作系统命令链请求关于操作系统中物理内存使用情况的统计信息,并将其转换为人类可读格式。此示例显示,并非所有分配的内存都在 MySQL 服务器内部仪表化。
请注意,您需要在与 MySQL 服务器相同的计算机上运行mysql客户端才能使此功能正常工作。
1.11 过滤和处理输出
警告
此方案仅适用于 UNIX 平台!
问题
您希望改变 MySQL 客户端的输出格式,超出其内建能力。
解决方案
将pager设置为一系列命令,以您希望的方式过滤输出。
讨论
有时,mysql客户端的格式化能力不允许您轻松处理结果集。例如,返回的行数可能过多,无法适应屏幕。或者列数使结果过宽,不便于在屏幕上舒适地阅读。标准操作系统分页程序,如less或more,使您能够更舒适地处理长宽文本。
在启动mysql客户端时,您可以通过提供--pager选项或交互式使用pager命令及其缩写版本\P来指定要使用的分页程序。您可以为分页程序指定任何参数。
要告诉mysql使用less作为分页程序,请指定--pager=less选项或交互式分配此值。像在您喜欢的 shell 中工作时一样提供命令的配置参数。在下面的示例中,我指定了选项-F和-X,因此当结果集小到可以适应屏幕时,less退出并在需要时正常工作。
mysql> `pager less -F -X`
PAGER set to 'less -F -X'
mysql> `SELECT * FROM city;`
+----------------+----------------+----------------+
| state | capital | largest |
+----------------+----------------+----------------+
| Alabama | Montgomery | Birmingham |
| Alaska | Juneau | Anchorage |
| Arizona | Phoenix | Phoenix |
| Arkansas | Little Rock | Little Rock |
| California | Sacramento | Los Angeles |
| Colorado | Denver | Denver |
| Connecticut | Hartford | Bridgeport |
| Delaware | Dover | Wilmington |
| Florida | Tallahassee | Jacksonville |
| Georgia | Atlanta | Atlanta |
| Hawaii | Honolulu | Honolulu |
| Idaho | Boise | Boise |
| Illinois | Springfield | Chicago |
| Indiana | Indianapolis | Indianapolis |
| Iowa | Des Moines | Des Moines |
| Kansas | Topeka | Wichita |
| Kentucky | Frankfort | Louisville |
:
mysql> `SELECT * FROM movies;`
+----+------+----------------------------+
| id | year | movie |
+----+------+----------------------------+
| 1 | 1997 | The Fifth Element |
| 2 | 1999 | The Phantom Menace |
| 3 | 2001 | The Fellowship of the Ring |
| 4 | 2005 | Kingdom of Heaven |
| 5 | 2010 | Red |
| 6 | 2011 | Unknown |
+----+------+----------------------------+
6 rows in set (0,00 sec)
您不仅可以使用 pager 美化输出,还可以运行任何能够处理文本的命令。一个常见的用途是在由诊断语句打印的数据中搜索模式,使用 grep。例如,要在长长的 SHOW ENGINE INNODB STATUS 输出中只查看 History list length,使用 \P grep "History list length"。完成搜索后,使用空的 pager 命令重置 pager,或者指示 mysql 禁用 pager 并将输出打印到 STDOUT,使用 nopager 或 \n。
mysql> `\P grep "History list length"`
PAGER set to 'grep "History list length"'
mysql> `SHOW ENGINE INNODB STATUS\G`
History list length 30
1 row in set (0,00 sec)
mysql> `SELECT SLEEP(60);`
1 row in set (1 min 0,00 sec)
mysql> `SHOW ENGINE INNODB STATUS\G`
History list length 37
1 row in set (0,00 sec)
mysql> `nopager`
PAGER set to stdout
在诊断过程中,另一个有用的选项是将输出发送到无处。例如,为了衡量查询的效果,您可能想要检查会话状态变量 Handler_*。在这种情况下,您对查询结果不感兴趣,只关注后续诊断命令的输出。甚至更进一步,您可能希望将诊断数据发送给专业的数据库顾问,但又不希望他们看到实际的查询输出,出于安全考虑。在这种情况下,指示 pager 使用哈希函数或将输出发送到无处。
mysql> `pager md5sum`
PAGER set to 'md5sum'
mysql> `SELECT 'Output of this statement is a hash';`
8d83fa642dbf6a2b7922bcf83bc1d861 -
1 row in set (0,00 sec)
mysql> `pager cat > /dev/null`
PAGER set to 'cat > /dev/null'
mysql> `SELECT 'Output of this statement goes to nowhere';`
1 row in set (0,00 sec)
mysql> `pager`
Default pager wasn't set, using stdout.
mysql> `SELECT 'Output of this statement is visible';`
+-------------------------------------+
| Output of this statement is visible |
+-------------------------------------+
| Output of this statement is visible |
+-------------------------------------+
1 row in set (0,00 sec)
提示
要将查询的输出、信息消息和所有键入的命令重定向到文件,请使用 pager cat > FILENAME。要将输出重定向到文件并仍然看到输出,请使用重定向到 tee 或 mysql 自带的 tee 命令及其简写 \T。内置命令 tee 在 UNIX 和 Windows 平台上都可以使用。
可以使用管道(pipes)将 pager 命令串联在一起。例如,将 limbs 表的内容以不同的字体样式打印出来,设置 pager 为一系列调用:
-
tr -d ' ' 用于去除额外的空格
-
awk -F'|' '{print "+"$2"+\033[3m"$3"\033[0m+\033[1m"$4"\033[0m"$5"+"}' 用于给文本添加样式
-
column -s '+' -t 用于得到格式良好的输出
mysql> `\P tr -d ' ' |` ↩
`awk -F'|' '{print "+"$2"+\033[3m"$3"\033[0m+\033[1m"$4"\033[0m"$5"+"}' |` ↩
`column -s '+' -t`
PAGER set to 'tr -d ' ' | ↩
awk -F'|' '{print "+"$2"+\033[3m"$3"\033[0m+\033[1m"$4"\033[0m"$5"+"}' | ↩
column -s '+' -t'
mysql> `select * from limbs;`
thing *legs* arms
human *2* 2
insect *6* 0
squid *0* 10
fish *0* 0
centipede *99* 0
table *4* 0
armchair *4* 2
phonograph *0* 1
tripod *3* 0
PegLegPete *1* 2
spacealien *NULL* NULL
11 rows in set (0,00 sec)
第二章:使用 MySQL Shell
2.0 简介
我们在第一章讨论了 mysql 客户端程序。MySQL Shell 是现代的替代客户端。除了 SQL 外,它还通过 JavaScript 或 Python 编程接口支持非关系型数据库查询语法,也称为 NoSQL,并提供丰富的功能集来自动化常规任务。
在本章中,我们将讨论如何:
-
连接到 MySQL Shell 并选择正确的协议。
-
选择 SQL、JavaScript 或 Python 接口。
-
使用 SQL 和 NoSQL 语法。
-
控制输出格式。
-
使用 MySQL Shell 内置工具。
-
编写脚本以自动化您的自定义需求。
-
使用管理 API。
-
重复使用您的脚本。
尽管 MySQL Shell 是某些任务的标准工具,但它不包含在 MySQL 软件包中,需要单独安装。您可以从MySQL Shell 下载页面下载它,或者使用操作系统的标准软件包管理器进行安装。本书不涵盖 MySQL Shell 的安装过程,因为它非常简单。
MySQL Shell 的命令名为mysqlsh。您可以在终端中输入mysqlsh来调用它。
MySQL Shell 支持两种协议:经典的 MySQL 协议(类似于mysql客户端使用的协议)和新的 X 协议。X 协议是一种现代协议,通过单独的端口(默认为 33060)与 MySQL 服务器通信。它支持 SQL 和 NoSQL API,并提供异步 API,允许客户端发送多个查询到服务器而无需等待先前查询的结果。使用 X 协议是使用 MySQL Shell 的首选方式,特别是如果您想使用 NoSQL 功能。
2.1 使用 MySQL Shell 连接到 MySQL 服务器
问题
当您调用mysqlsh时,它会打开一个新的会话,但不会连接到任何 MySQL 服务器。
解决方案
在 MySQL Shell 内部使用\connect命令或在启动时提供您的 MySQL 服务器 URI。
讨论
在启动工具后,MySQL Shell 允许您通过命令行参数提供连接选项来连接到 MySQL 服务器。您也可以将默认连接参数放在启动脚本中。
关于连接选项,MySQL Shell 非常灵活。您可以将它们作为 URI 或名称-值对提供,类似于mysql客户端接受的方式。
URI 使用以下格式:
[scheme://][user[:password]@]<host[:port]|socket>[/schema]↩
[?option=value&option=value...]
参数的含义在表格 2-1 中有解释。
表格 2-1. URI 连接选项
| 参数 | 解释 | 默认值 |
|---|---|---|
scheme |
要使用的协议。可以是mysql(如果要使用经典协议)或mysqlx(如果要使用 X 协议)。 |
mysqlx |
user |
要连接的用户名。 | 您的操作系统帐户。 |
password |
密码 | 请求密码。 |
host |
要连接的主机。 | 没有默认值。这是唯一的必需参数,除非指定了socket选项。 |
port |
要连接的端口。 | 经典协议为 3306,X 协议为 33060。 |
socket |
用于本地主机连接的套接字。 | 您必须提供此参数或host参数。 |
schema |
要连接的数据库模式。 | 无值。不要选择任何模式。 |
option |
您想要使用的任何其他选项。 | 无值。选择任何或不选择任何选项。 |
因此,要使用交互界面连接到本地机器上的 MySQL 服务器,请键入 \connect 127.0.0.1:
MySQL localhost JS > `\connect 127.0.0.1`
Creating a session to 'sveta@127.0.0.1'
Please provide the password for 'sveta@127.0.0.1':
Save password for 'sveta@127.0.0.1'? [Y]es/[N]o/Ne[v]er (default No):
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 1066144 (X protocol)
Server version: 8.0.27 MySQL Community Server - GPL
No default schema selected; type \use <schema> to set one.
这将创建一个使用 X 协议的连接。
注意
在不指定用户名连接时,MySQL Shell 使用操作系统登录。这就是为什么连接是为用户sveta创建的,而不是我们在本书中到处使用的用户cbuser。我们将在稍后讨论如何在连接时指定 MySQL 用户帐户。
要退出 MySQL Shell 会话,请使用命令 \exit 或 \quit 及其简写形式 \q。
MySQL JS > `\exit`
Bye!
要使用套接字进行交互连接,请键入 \c (/var/run/mysqld/mysqld.sock):
MySQL 127.0.0.1:33060+ ssl JS > `\c (/var/run/mysqld/mysqld.sock)`
Creating a session to 'sveta@/var%2Frun%2Fmysqld%2Fmysqld.sock'
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 1067565
Server version: 8.0.27 MySQL Community Server - GPL
No default schema selected; type \use <schema> to set one.
这将创建使用经典协议的连接。如果要使用 X 协议通过套接字连接,请使用 mysqlx_socket。如果运行查询,您将找到mysqlx_socket的值。
mysql> `SELECT` `@``@``mysqlx_socket``;`
+-----------------------------+ | @@mysqlx_socket |
+-----------------------------+ | /var/run/mysqld/mysqlx.sock |
+-----------------------------+ 1 row in set (0,00 sec)
命令 \connect 有一个更短的版本 \c,我们在通过套接字连接的示例中使用了它。请注意命令参数中的括号。如果没有括号,命令将因语法错误而失败。或者,您可以用其 URI 编码值 %2F 替换所有后续的斜杠符号:
MySQL localhost JS > `\connect /var%2Frun%2Fmysqld%2Fmysqld.sock`
Creating a session to 'sveta@/var%2Frun%2Fmysqld%2Fmysqld.sock'
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 1073606
Server version: 8.0.27 MySQL Community Server - GPL
No default schema selected; type \use <schema> to set one.
在打开 MySQL Shell 会话时使用 URI 进行连接,使用以下命令:
$ `mysqlsh mysqlx://cbuser:cbpass@127.0.0.1/cookbook`
Please provide the password for 'cbuser@127.0.0.1:33060': ******
Save password for 'cbuser@127.0.0.1:33060'? [Y]es/[N]o/Ne[v]er (default No):
MySQL Shell 8.0.27
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.
Creating a session to 'cbuser@127.0.0.1:33060/cookbook'
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 1076096 (X protocol)
Server version: 8.0.27 MySQL Community Server - GPL
Default schema `cookbook` accessible through db.
MySQL 127.0.0.1:33060+ ssl cookbook JS >
在这种情况下,我们通过命令行指定了用户名和密码,并选择了cookbook作为默认数据库。
在调用 mysqlsh 命令时连接时,您还可以单独指定连接凭据,类似于使用 mysql 客户端连接时的方式。
$ `mysqlsh --host=127.0.0.1 --port=33060 --user=cbuser --schema=cookbook`
Please provide the password for 'cbuser@127.0.0.1:33060': ******
MySQL Shell 8.0.22
Copyright (c) 2016, 2020, 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.
Creating a session to 'cbuser@127.0.0.1:33060/cookbook'
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 8738 (X protocol)
Server version: 8.0.22-13 Percona Server (GPL), Release '13', Revision '6f7822f'
Default schema `cookbook` accessible through db.
MySQL 127.0.0.1:33060+ ssl cookbook JS >
如果要指定默认模式,请将其作为参数传递给配置选项schema。否则,mysqlsh 将将其视为主机名并因错误而失败。
在 MySQL Shell 中,您还可以通过命名参数指定选项。首先,您需要创建一个包含连接参数的字典,然后将其作为选项传递给内置的自动创建的shell对象的connect()方法。
MySQL 127.0.0.1:33060+ ssl JS > `connectionData={`
-> `"host": "127.0.0.1",`
-> `"user": "cbuser",`
-> `"schema": "cookbook"`
-> `}`
->
{
"host": "127.0.0.1",
"schema": "cookbook",
"user": "cbuser"
}
MySQL 127.0.0.1:33060+ ssl JS > `shell.connect(connectionData)`
Creating a session to 'cbuser@127.0.0.1/cookbook'
Please provide the password for 'cbuser@127.0.0.1': ******
Save password for 'cbuser@127.0.0.1'? [Y]es/[N]o/Ne[v]er (default No):
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 1077318 (X protocol)
Server version: 8.0.27 MySQL Community Server - GPL
Default schema `cookbook` accessible through db.
<Session:cbuser@127.0.0.1:33060>
MySQL 127.0.0.1:33060+ ssl cookbook JS >
参见
有关如何通过 MySQL Shell 连接到 MySQL 服务器的更多信息,请参阅MySQL Shell Connections。
2.2 选择协议
问题
您不想使用 MySQL Shell 的默认设置,并且希望自己选择 X 协议或经典协议。
解决方案
要选择 X 协议:使用选项之一 mysqlx、mx、sqlx。要选择经典协议:使用选项之一 mysql、mc 和 sqlc。
讨论
MySQL Shell 使用连接选项和服务器响应自动选择协议。如果未使用端口或套接字选项,则尝试使用 X 协议的默认端口或套接字。如果不可用,则默认使用经典协议。如果这不是预期的行为或者您想要显式控制使用哪种协议,可以在启动 mysqlsh 客户端时通过传递选项 mysqlx、mx 和 sqlx 来选择 X 协议,并通过选项 mysql、mc 和 sqlc 来选择经典协议。
$ `mysqlsh --host=127.0.0.1 --user=cbuser --schema=cookbook --mysqlx`
Please provide the password for 'cbuser@127.0.0.1': ******
MySQL Shell 8.0.22
...
Your MySQL connection id is 9143 (X protocol)
$ `mysqlsh --host=127.0.0.1 --user=cbuser --schema=cookbook --mysql`
Please provide the password for 'cbuser@127.0.0.1': ******
MySQL Shell 8.0.22
...
Creating a Classic session to 'cbuser@127.0.0.1/cookbook'
在 MySQL Shell 内部,当打开新连接时,通过将选项传递给 connectionData 字典的 scheme 键来指定值:
MySQL 127.0.0.1:3306 ssl cookbook JS > `connectionData={`
-> `"scheme": "mysql", "host": "127.0.0.1",`
-> `"user": "cbuser", "schema": "cookbook"`
-> `}`
{
"host": "127.0.0.1",
"schema": "cookbook",
"scheme": "mysql",
"user": "cbuser"
}
MySQL 127.0.0.1:3306 ssl cookbook JS > `shell.connect(connectionData, "cbpass")`
Creating a Classic session to 'cbuser@127.0.0.1/cookbook'
在指定 URI 时,可以通过 scheme 前缀连接选项:
mysqlsh mysqlx://cbuser:cbpass@127.0.0.1/cookbook
\c mysql://cbuser:cbpass@127.0.0.1/cookbook
如果指定的协议无法使用,MySQL Shell 将失败并显示错误:
MySQL JS > `\c mysql://cbuser:cbpass@127.0.0.1:33060/cookbook`
Creating a Classic session to 'cbuser@127.0.0.1:33060/cookbook'
MySQL Error 2007 (HY000): Protocol mismatch; server version = 11, client version = 10
$ `mysqlsh --host=127.0.0.1 --port=3306 --user=cbuser --schema=cookbook --mx`
Please provide the password for 'cbuser@127.0.0.1:3306': ******
MySQL Shell 8.0.22
Copyright (c) 2016, 2020, 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.
Creating an X protocol session to 'cbuser@127.0.0.1:3306/cookbook' ↩
MySQL Error 2027: Requested session assumes MySQL X Protocol but '127.0.0.1:3306' ↩
seems to speak the classic MySQL protocol
(Unexpected response received from server, msg-id:10)
您可以通过运行命令 shell.status() 查找当前 MySQL Shell 连接的详细信息:
MySQL 127.0.0.1:33060+ ssl cookbook JS > `shell.status()`
MySQL Shell version 8.0.22
Connection Id: 61
Default schema: cookbook
Current schema: cookbook
Current user: cbuser@localhost
SSL: Cipher in use: TLS_AES_256_GCM_SHA384 TLSv1.3
Using delimiter: ;
Server version: 8.0.22-13 Percona Server (GPL), Release '13', ↩
Revision '6f7822f'
Protocol version: X protocol
Client library: 8.0.22
Connection: 127.0.0.1 via TCP/IP
TCP port: 33060
Server characterset: utf8mb4
Schema characterset: utf8mb4
Client characterset: utf8mb4
Conn. characterset: utf8mb4
Result characterset: utf8mb4
Compression: Enabled (DEFLATE_STREAM)
Uptime: 4 min 57.0000 sec
Tip
MySQL Shell 允许像 MySQL CLI 一样自定义其提示符。要实现这一点,需要编辑位于 MySQL Shell 配置主目录中的 prompt.json 文件。这是一个 JSON 格式的文件。MySQL Shell 提供了大量自定义提示符模板和解释如何修改提示的 README.prompt 文件。
我们不会详细介绍如何自定义 MySQL Shell 用户提示符,但会从默认提示中移除主机、端口和协议信息,这样我们的示例在书中占用的空间将更少。
MySQL Shell 的配置主目录在 Unix 上是 ~/.mysqlsh/,在 Windows 上是 %AppData%\MySQL\mysqlsh\。如果设置了变量 MYSQLSH_USER_CONFIG_HOME,可以覆盖此位置。README.prompt 和示例位于 MySQL Shell 安装根目录下的 share/mysqlsh/prompt/ 目录中。
See Also
获取关于 mysqlsh 命令选项的更多信息,请参阅 A.1 mysqlsh — The MySQL Shell。
2.3 选择 SQL、JavaScript 或 Python 模式
Problem
MySQL Shell 启动时选择了错误的模式,您希望选择与默认模式不同的模式。
解决方案
在启动 mysqlsh 后,可以使用 sql、js 或 py 选项或切换模式。
讨论
默认情况下,MySQL Shell 以 JavaScript 模式启动。您可以通过查看提示字符串来确认:
MySQL cookbook JS >
如果启动工具时使用 --sql 选项选择 SQL 模式或 --py 选项选择 Python 模式,则可以更改默认模式。要显式选择 JavaScript 模式,请使用 --js 选项。您将看到 MySQL Shell 客户端的提示消息会更改为所选模式。在此处,我们选择 Python 模式。
$ `mysqlsh cbuser:cbpass@127.0.0.1/cookbook --py`
...
Default schema `cookbook` accessible through db.
MySQL cookbook Py >
Tip
对于 SQL 模式,您可以明确指示工具使用所需的模式,并使用选项 sqlx 选择 X 协议以及选项 sqlc 选择经典协议。当您通过默认 TCP/IP 端口连接时,这可能非常方便。
在mysqlsh中,您可以使用命令\js、\py和\sql更改处理模式,分别切换到 JavaScript、Python 和 SQL 模式。
MySQL cookbook SQL > `\js`
Switching to JavaScript mode...
MySQL cookbook JS > `\py`
Switching to Python mode...
MySQL cookbook Py > `\sql`
Switching to SQL mode... Commands end with ;
MySQL cookbook SQL >
2.4 运行 SQL 会话
问题
您希望具有类似mysql客户端的功能,但又不想离开 MySQL Shell。
解决方案
使用 SQL 模式。
讨论
使用 SQL 模式,MySQL Shell 的行为与我们在第一章中描述的mysql客户端完全相同。您可以运行查询,使用\pager命令控制输出,在系统编辑器中使用\edit命令编辑 SQL,在文件中使用\source命令执行 SQL,并使用\system执行系统 shell 命令。您可以查看和编辑命令行历史记录。
没有\d作为delimiter命令的快捷方式,但命令本身是有效的。
MySQL cookbook SQL > `delimiter |`
MySQL cookbook SQL > `CREATE PROCEDURE get_client_info()`
-> `BEGIN`
-> `SELECT GROUP_CONCAT(ATTR_NAME, '=', ATTR_VALUE)`
-> `FROM performance_schema.session_account_connect_attrs`
-> `WHERE ATTR_NAME IN ('_client_name', '_client_version');`
-> `END`
-> `|`
Query OK, 0 rows affected (0.0163 sec)
MySQL cookbook SQL > `delimiter ;`
MySQL cookbook SQL > `CALL get_client_info();`
+----------------------------------------------+
| GROUP_CONCAT(ATTR_NAME, '=', ATTR_VALUE) |
+----------------------------------------------+
| _client_name=libmysql,_client_version=8.0.22 |
+----------------------------------------------+
1 row in set (0.0017 sec)
Query OK, 0 rows affected (0.0017 sec)
没有tee命令。如果要将查询结果记录到文件中,请将分页器设置为tee -a <DESIRED LOG FILE LOCATION>。但它不会记录 SQL 语句,它们仅在历史文件中可用。
提示
默认情况下,MySQL Shell 不会在客户端会话之间保存历史记录。这意味着一旦退出 Shell,您无法访问先前的命令。如果启用选项history.autoSave,可以覆盖此行为:
MySQL JS > \option --persist history.autoSave=1
2.5 在 JavaScript 模式中运行 SQL
问题
您处于 JavaScript 模式,但想执行传统 SQL
解决方案
使用命令\sql,或使用属于Session类的sql()和runSQL()方法,在 JavaScript 模式中执行传统 SQL。
讨论
JavaScript 模式支持面向对象的数据库查询风格。或者,您可以运行纯 SQL。
如果您想要在不离开 JavaScript 模式的情况下运行单个 SQL 语句并获取结果,可以使用命令\sql。在下面的示例中,我们运行了一条普通 SQL 语句,从表limbs中选择具有两个或更多手臂的数据:
MySQL cookbook JS > `\sql SELECT * FROM limbs WHERE arms >=2 ORDER BY arms;`
Fetching table and column names from `cookbook` for auto-completion...↩
Press ^C to stop.
+--------------+------+------+
| thing | legs | arms |
+--------------+------+------+
| human | 2 | 2 |
| armchair | 4 | 2 |
| Peg Leg Pete | 1 | 2 |
| squid | 0 | 10 |
+--------------+------+------+
4 rows in set (0.0002 sec)
以面向对象的方式运行 SQL 更加灵活,并提供更多选项。要运行单个语句,请使用Session类的runSQL方法。
MySQL cookbook JS > `session.runSql( > "SELECT * FROM limbs WHERE arms >=2 ORDER BY arms")`
+--------------+------+------+
| thing | legs | arms |
+--------------+------+------+
| human | 2 | 2 |
| armchair | 4 | 2 |
| Peg Leg Pete | 1 | 2 |
| squid | 0 | 10 |
+--------------+------+------+
4 rows in set (0.0014 sec)
提示
当连接到 MySQL Shell 时,它会创建Session类的默认实例。可以通过全局对象session访问它。
方法runSQL支持占位符:只需用?符号替换变量值,并将参数作为数组传递。
MySQL cookbook JS > `session.runSql("SELECT * FROM limbs ↩ WHERE arms >= ? AND legs != ? ↩ ORDER BY arms", [2, 0])`
+--------------+------+------+
| thing | legs | arms |
+--------------+------+------+
| human | 2 | 2 |
| armchair | 4 | 2 |
| Peg Leg Pete | 1 | 2 |
+--------------+------+------+
3 rows in set (0.0005 sec)
您可以将此方法与标准 JavaScript 语法结合,创建一个能够执行更多操作而不仅限于运行 SQL 查询的脚本:
MySQL cookbook JS > `for (i = 1;`
-> `i <= session.sql("SELECT MAX(arms) AS maxarms FROM limbs").` 
-> `execute().fetchOne().` 
-> `getField('maxarms');` 
-> `i++)` 
-> `{`
-> `species=session.sql("SELECT COUNT(*) AS countarms \`
-> `FROM limbs WHERE arms =?").`
-> `bind(i).execute();` 
-> `if (species.hasData() && (armscount = species.fetchOne().`
-> `getField('countarms')) > 0 )` 
-> `{`
-> `print("We have " + armscount + " species with " + i +` 
-> `(i == 1 ? " arm\n" : " arms\n"));`
-> `}`
-> `}`
->
We have 1 species with 1 arm
We have 3 species with 2 arms
We have 1 species with 10 arms
选择存储在limbs表中的最大手臂数。
方法session.sql.execute()返回一个SqlResult对象,其中有一个名为fetchOne的方法,返回结果集的第一行。
由于我们的查询应返回一行,我们没有遍历结果集,而只是调用了一个名为getField的方法,该方法以列名或其别名作为参数,以获取存储在limbs表中的最大手臂数。
我们将此数字用作for循环的停止条件。
在循环中,我们执行了查询以获取指定手臂数量的物种数量。我们使用了sql方法及其bind方法,将循环迭代器i的值绑定到查询中。
检查是否收到了结果,以及手臂数量是否大于 0。
如果两个条件都为真:打印结果。
注意
当您单独执行sql或runSQL方法时,MySQL Shell 会自动为它们调用execute方法。但是,如果在更复杂的代码中使用这些方法,如在循环或多语句块中,您需要显式调用execute方法。否则,只有最后一个语句会被执行,而所有之前的调用将被忽略。
参见
有关 MySQL Shell API 的更多信息,请参阅高级 MySQL 用户参考手册中的 ShellAPI。
2.6 在 Python 模式下运行 SQL
问题
您正在 Python 模式下,但希望执行传统 SQL。
解决方案
使用命令\sql或类Session的方法sql、run_sql。
讨论
就像我们在 JavaScript 模式中看到的那样,Python 模式也支持\sql命令。如果您要执行 SQL 语句而不想处理其结果,可以使用它。以下代码从表movies中选择所有行。
MySQL cookbook Py > `\sql SELECT * FROM movies;`
+----+------+----------------------------+
| id | year | movie |
+----+------+----------------------------+
| 1 | 1997 | The Fifth Element |
| 2 | 1999 | The Phantom Menace |
| 3 | 2001 | The Fellowship of the Ring |
| 4 | 2005 | Kingdom of Heaven |
| 5 | 2010 | Red |
| 6 | 2011 | Unknown |
+----+------+----------------------------+
6 rows in set (0.0008 sec)
Python 模式中的方法名称与 JavaScript 模式稍有不同。因此,要运行 SQL 语句,请使用Session对象并将参数绑定为数组,使用方法run_sql。
MySQL cookbook Py > `session.run_sql("SELECT * FROM movies WHERE year < ?",[2000])`
+----+------+--------------------+
| id | year | movie |
+----+------+--------------------+
| 1 | 1997 | The Fifth Element |
| 2 | 1999 | The Phantom Menace |
+----+------+--------------------+
2 rows in set (0.0009 sec)
此示例选择了所有在 2000 年之前创建的电影。
您可以使用 Python 或 JavaScript 进行编程。例如,如果要知道每位演员出演的电影数量以及出演年份,请将表movies与表movies_actors连接,然后使用 Python 代码打印结果。
MySQL cookbook Py > `myres=session.sql("SELECT actor, COUNT(movie) as movies,`↩ 
`GROUP_CONCAT(year SEPARATOR ', ') AS years_string,`↩
`COUNT(year) AS years FROM movies_actors` ↩
`GROUP BY actor ORDER BY movies DESC").`↩
`execute().fetch_all()`
MySQL cookbook Py > `for myrow in myres:` 
-> `print(myrow[0] + " was featured in " + str(myrow[1]) +`↩ 
`(" movies" if (myrow[1] > 1) else " movie") +` ↩
`" in " + ("years " if (myrow[3] > 1) else "the year ") +`↩
`myrow[2] + ".")`
->
Liam Neeson was featured in 3 movies in years 2005, 1999, 2011.
Bruce Willis was featured in 2 movies in years 1997, 2010.
Ian Holm was featured in 2 movies in years 1997, 2001.
Orlando Bloom was featured in 2 movies in years 2005, 2001.
Diane Kruger was featured in 1 movie in the year 2011.
Elijah Wood was featured in 1 movie in the year 2001.
Ewan McGregor was featured in 1 movie in the year 1999.
Gary Oldman was featured in 1 movie in the year 1997.
Helen Mirren was featured in 1 movie in the year 2010.
Ian McKellen was featured in 1 movie in the year 2001.
运行查询并将其返回的所有行提取到变量myres中。
在for ... in循环中遍历此变量。
打印结果。
提示
如果您对查询语法尚不熟悉,不用担心:我们将在第五章讨论数据查询的方法,以及在配方 16.0 中如何连接两个或多个表。
参见
若要获取有关 Python MySQL Shell API 的额外信息,请在 Python shell 会话内使用命令? mysqlx。
2.7 在 JavaScript 模式下使用表格
问题
您希望在 JavaScript 模式中使用面向对象的方式查询您的表。
解决方案
使用方法getTable选择表,然后使用方法select、count、insert、update、delete从表中选择、检索行数、插入、更新或删除。
讨论
MySQL Shell 支持面向对象的语法来查询和修改数据库对象。因此,要从表limbs中选择所有行,我们可以使用select方法。
MySQL cookbook JS > `session.getDefaultSchema().getTable('limbs').select()`
+--------------+------+------+
| thing | legs | arms |
+--------------+------+------+
| human | 2 | 2 |
| insect | 6 | 0 |
| squid | 0 | 10 |
| fish | 0 | 0 |
| centipede | 99 | 0 |
| table | 4 | 0 |
| armchair | 4 | 2 |
| phonograph | 0 | 1 |
| tripod | 3 | 0 |
| Peg Leg Pete | 1 | 2 |
| space alien | NULL | NULL |
+--------------+------+------+
11 rows in set (0.0003 sec)
在上面的清单中,我们首先使用getDefaultSchema方法选择模式,然后用getTable方法选择表,最后用select检索所有行。
方法select返回TableSelect对象,支持允许指定WHERE条件、ORDER BY、GROUP BY子句和 SQL SELECT具有的其他功能的方法。它还支持准备语句和参数绑定。因此,要仅选择表limbs中具有四条或更多腿的物种并按腿数排序,请尝试以下代码:
MySQL cookbook JS > `session.getDefaultSchema().getTable('limbs').select().`
-> `where('legs >= :legs').orderBy('legs').bind('legs', 4)`
+-----------+------+------+
| thing | legs | arms |
+-----------+------+------+
| table | 4 | 0 |
| armchair | 4 | 2 |
| insect | 6 | 0 |
| centipede | 99 | 0 |
+-----------+------+------+
4 rows in set (0.0004 sec)
警告
请注意,这里我们使用了命名参数作为占位符,而不是像在 SQL 查询数据库时使用问号。
MySQL Shell API 还支持以面向对象的方式插入、更新和删除数据,以及启动和完成事务。例如,如果我们想在不实际修改数据的情况下对cookbook数据库进行实验,我们可以在事务内进行。
MySQL cookbook JS > `limbs = session.getDefaultSchema().getTable('limbs')` 
<Table:limbs>
MySQL cookbook JS > `session.startTransaction()` 
Query OK, 0 rows affected (0.0006 sec)
MySQL cookbook JS > `limbs.insert('thing', 'legs', 'arms').` 
-> `values('cat', 4, 0).`
-> `values('dog', 2, 2)`
->
Query OK, 2 items affected (0.0012 sec)
Records: 2 Duplicates: 0 Warnings: 0
MySQL cookbook JS > `limbs.count()` 
13
MySQL cookbook JS > `limbs.update().set('legs', 4).set('arms', 0).`
-> `where("thing='dog'")` 
Query OK, 1 item affected (0.0012 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `limbs.select().where("thing='dog'")` 
+-------+------+------+
| thing | legs | arms |
+-------+------+------+
| dog | 4 | 0 |
+-------+------+------+
1 row in set (0.0004 sec)
MySQL cookbook JS > `limbs.delete().where("thing='cat'")` 
Query OK, 1 item affected (0.0010 sec)
MySQL cookbook JS > `limbs.count()` 
12
MySQL cookbook JS > `session.rollback()` 
Query OK, 0 rows affected (0.0054 sec)
MySQL cookbook JS > `limbs.count()` 
11
MySQL cookbook JS > `limbs.select().where("thing='dog' or thing='cat'")`
Empty set (0.0010 sec)
将表limbs的表对象保存到变量limbs中。
开始一个事务,这样我们就可以回滚我们的实验。
使用insert方法将两行插入表limbs,该方法将列列表作为参数,并将要插入的值列表作为参数。
检查现在表中存在的行数。
如果回顾我们插入的行,您可能会注意到一个错误。实际上,狗有四条腿,而不是两条腿和两只手臂。要纠正这个错误,请调用update方法。
跟随select调用确认我们的更改已应用于表limbs。
我们发现猫和狗并不总是彼此的朋友,于是用delete方法从桌子上移除了一只猫。
确认猫已成功移除。
回滚事务以将表恢复到初始状态。
方法count和select确认表处于初始状态。
注意
由于我们在交互式会话中逐条执行了所有语句,我们省略了execute方法。如果您在循环或程序脚本中执行 SQL 命令,则需要该方法。
参见
要了解如何在 JavaScript 模式下处理表的更多信息,请参见对象Table的用户参考手册。
2.8 在 Python 模式下处理表
问题
您的数据库中有表格,并希望在 Python 模式下使用它们。
解决方案
使用方法 get_table 获取表对象,然后使用方法 select、count、insert、update、delete 来从表中选择、检索行数、插入、更新或删除。
讨论
像 JavaScript 一样,Python 支持以面向对象的方式处理表格。因此,要从表 movies 中选择所有行,请尝试类 Table 的方法 select:
MySQL cookbook Py > `session.get_schema('cookbook').get_table('movies').select()`
+----+------+----------------------------+
| id | year | movie |
+----+------+----------------------------+
| 1 | 1997 | The Fifth Element |
| 2 | 1999 | The Phantom Menace |
| 3 | 2001 | The Fellowship of the Ring |
| 4 | 2005 | Kingdom of Heaven |
| 5 | 2010 | Red |
| 6 | 2011 | Unknown |
+----+------+----------------------------+
6 rows in set (0.0003 sec)
在这个示例中,我们使用了方法 get_schema,允许我们选择数据库中存储的任何模式。
Python 模式支持方法,允许修改表中的数据以及事务语句。
为了我们的示例,我们将表 movies 和 movies_actors 保存到变量中:
MySQL cookbook Py > `movies=session.get_schema('cookbook').get_table('movies')`
MySQL cookbook Py > `movies_actors=session.get_schema('cookbook').`↩
`get_table('movies_actors')`
然后,我们将开启一个事务,以便我们的更改将应用到两个表或完全不应用,然后我们将插入一部由 Gary Oldman 主演的电影 “最黑暗的时刻”。最后,我们会提交事务:
MySQL cookbook Py > `session.start_transaction()`
Query OK, 0 rows affected (0.0003 sec)
MySQL cookbook Py > `movies.insert('year', 'movie').`↩
`values(2017, 'Darkest Hour')`
Query OK, 1 item affected (0.0013 sec)
MySQL cookbook Py > `movies_actors.insert().`↩
`values(1997, 'Darkest Hour', 'Gary Oldman')`
Query OK, 1 item affected (0.0011 sec)
MySQL cookbook Py > `session.commit()`
Query OK, 0 rows affected (0.0075 sec)
要找出所有由 Gary Oldman 主演的电影,我们将使用 SQL 查询,因为 X API 不支持连接:
MySQL cookbook Py > `session.sql("SELECT * FROM movies` ↩
`JOIN movies_actors USING(movie) WHERE actor = 'Gary Oldman'")`
+-------------------+----+------+------+-------------+
| movie | id | year | year | actor |
+-------------------+----+------+------+-------------+
| The Fifth Element | 1 | 1997 | 1997 | Gary Oldman |
| Darkest Hour | 7 | 2017 | 1997 | Gary Oldman |
+-------------------+----+------+------+-------------+
2 rows in set (0.0012 sec)
哎呀!电影 “最黑暗的时刻” 的年份在一个表中不正确。让我们更新它:
MySQL cookbook Py > `session.start_transaction()` 
Query OK, 0 rows affected (0.0007 sec)
MySQL cookbook Py > `movies.update().set('year', 2017).where("movie='Darkest Hour'")` 
Query OK, 0 items affected (0.0013 sec)
Rows matched: 1 Changed: 0 Warnings: 0
MySQL cookbook Py > `movies_actors.update().set('year', 2017).`↩ 
`where("movie='Darkest Hour'")`
Query OK, 1 item affected (0.0012 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook Py > `session.commit()` 
Query OK, 0 rows affected (0.0073 sec)
MySQL cookbook Py > `session.run_sql("SELECT * FROM movies JOIN movies_actors`↩ 
`USING(movie) WHERE actor = 'Gary Oldman'")`
+-------------------+----+------+------+-------------+
| movie | id | year | year | actor |
+-------------------+----+------+------+-------------+
| The Fifth Element | 1 | 1997 | 1997 | Gary Oldman |
| Darkest Hour | 7 | 2017 | 2017 | Gary Oldman |
+-------------------+----+------+------+-------------+
2 rows in set (0.0005 sec)
开始一个事务,以便我们要么更新所有表,要么不更新任何表。
更新表 movies。
更新表 movies_actors。
提交更改。
确认更改已应用到表格。
如果我们想要删除我们新插入的电影,我们可以使用方法 delete:
MySQL cookbook Py > `session.start_transaction()`
Query OK, 0 rows affected (0.0006 sec)
MySQL cookbook Py > `movies.delete().where("movie='Darkest Hour'")`
Query OK, 1 item affected (0.0012 sec)
MySQL cookbook Py > `movies_actors.delete().where("movie='Darkest Hour'")`
Query OK, 1 item affected (0.0004 sec)
MySQL cookbook Py > `session.commit()`
Query OK, 0 rows affected (0.0061 sec)
在这个示例中,我们首先启动了事务,然后在两个表上调用了方法 delete,最后提交了事务。
参见
有关在 Python 模式下以面向对象的方式访问表格的更多信息,请参阅 MySQL Shell 的交互式帮助,可以通过命令 ? 调用。
2.9 在 JavaScript 模式下使用集合
问题
您有半结构化数据,并希望将 MySQL 用作文档存储。您还希望使用 NoSQL 查询数据,同时不离开您喜欢的编程语言的编程风格。
解决方案
使用 Collection 对象及其方法。
讨论
MySQL 不仅支持 SQL 语法,还支持 NoSQL。当您使用 SQL 时,您查询表格;当您使用 NoSQL 时,您查询集合。这些集合在物理上存储在具有三列的表格中:生成的唯一标识符,也是主键,一个存储文档的 JSON 列,以及一个存储 JSON 模式的内部列。您可以通过使用类 Schema 的方法 createCollection 来创建集合:
MySQL cookbook JS > `collectionLimbs=session.getCurrentSchema().`
-> `createCollection('CollectionLimbs')`
<Collection:CollectionLimbs>
上述代码创建了 NoSQL 集合 CollectionLimbs。
集合支持模式验证。虽然没有方法可以为现有集合添加模式验证,但我们可以在创建集合时添加模式:
MySQL cookbook JS > `session.getCurrentSchema().`
-> `dropCollection('collectionLimbs')`
->
MySQL cookbook JS > `schema={`
-> `"$schema": "http://json-schema.org/draft-07/schema",`
-> `"id": "http://example.com/cookbook.json",`
-> `"type": "object",`
-> `"description": "Table limbs as a collection",`
-> `"properties": {`
-> `"thing": {"type": "string"},`
-> `"legs": {`
-> `"anyOf": [{"type": "number"},{"type": "null"}],`
-> `"default": 0`
-> `},`
-> `"arms": {`
-> `"anyOf": [{"type": "number"},{"type": "null"}],`
-> `"default": 0`
-> `}`
-> `},`
-> `"required": ["thing","legs","arms"]`
-> `}`
->
{
"$schema": "http://json-schema.org/draft-07/schema",
"description": "Table limbs as a collection",
"id": "http://example.com/cookbook.json",
"properties": {
"arms": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": 0
},
"legs": {
"anyOf": [
{
"type": "number"
},
{
"type": "null"
}
],
"default": 0
},
"thing": {
"type": "string"
}
},
"required": [
"thing",
"legs",
"arms"
],
"type": "object"
}
MySQL cookbook JS > `collectionLimbs=session.getCurrentSchema().`
-> `createCollection('collectionLimbs',`
-> `{"validation": {"level": "strict", "schema": schema}})`
->
<Collection:CollectionLimbs>
一旦创建了 NoSQL 集合,您可以插入、更新、删除和搜索文档。
例如,要将表limbs中的文档插入到集合CollectionLimbs中,可以使用以下代码:
MySQL cookbook JS > `{`
-> `limbs=session.getCurrentSchema().`
-> `getTable('limbs').select().execute();` 
-> `while (limb = limbs.fetchOneObject()) {` 
-> `collectionLimbs.add(` 
-> `mysqlx.expr(JSON.stringify(limb))` 
-> `).execute();` 
-> `}`
-> `}`
->
Query OK, 1 item affected (0.0049 sec)
从表limbs中选择所有行。
方法fetchOneObject返回一个字典对象。
一个字典对象如果没有转换为适当的 JSON 对象就无法保存在集合中。因此,我们先将其转换为 JSON 字符串,然后创建一个表达式,可以将其插入到集合中。
方法add将文档插入集合中。
在脚本块内更新数据库时,始终需要execute方法。
我们用花括号括起代码,因为如果代码跨多行放置,MySQL Shell 将输出session.getCurrentSchema().getTable('limbs').select().execute()的结果,并且变量limbs只包含关于受影响行数的诊断消息。
最后,我们可以检查刚刚插入到集合CollectionLimbs中的数据:
MySQL cookbook JS > `collectionLimbs.count()`
11
MySQL cookbook JS > `collectionLimbs.find().limit(3)`
{
"_id": "00006002f0650000000000000060",
"arms": 2,
"legs": 2,
"thing": "human"
}
{
"_id": "00006002f0650000000000000061",
"arms": 0,
"legs": 6,
"thing": "insect"
}
{
"_id": "00006002f0650000000000000062",
"arms": 10,
"legs": 0,
"thing": "squid"
}
3 documents in set (0.0010 sec)
您还可以修改和删除集合中的文档。我们将展示如何在 Recipe 2.10 中执行此操作的示例。
参见
有关如何使用 JSON 文档和 NoSQL 与 MySQL 的更多信息,请参阅第十九章。
2.10 在 Python 模式下使用集合
问题
您想要在 Python 模式下使用 DocumentStore 和 NoSQL。
解决方案
使用Collection对象及其方法。
讨论
正如您可以在 JavaScript 模式中做的那样,您也可以在 Python 模式中处理 NoSQL。语法也类似于 JavaScript 模式。但是,方法名称遵循推荐用于 Python 编写的程序的命名风格。
因此,要将集合分配给变量,请使用类Schema的get_collection方法:
MySQL cookbook Py > `collectionLimbs=session.get_current_schema().`↩
`get_collection('collectionLimbs')`
要选择文档,请使用find方法:
MySQL cookbook Py > `collectionLimbs.find('legs > 3 and arms > 1')`
{
"_id": "00006002f0650000000000000066",
"arms": 2,
"legs": 4,
"thing": "armchair"
}
1 document in set (0.0010 sec)
方法find支持参数,允许按照类似 SQL 中WHERE子句的语法搜索特定文档。它还允许聚合结果、排序和选择特定字段。它不支持集合的连接。
要插入新文档,请使用add方法:
MySQL cookbook Py > `collectionLimbs.add(mysqlx.expr(`↩
`'{"thing": "cat", "legs": 2, "arms": 2}'))`
Query OK, 1 item affected (0.0093 sec)
MySQL cookbook Py > `collectionLimbs.find('thing="cat"')`
{
"_id": "00006002f065000000000000006b",
"arms": 2,
"legs": 2,
"thing": "cat"
}
1 document in set (0.0012 sec)
MySQL cookbook Py > `collectionLimbs.add(mysqlx.expr(`↩
`'{"thing": "dog", "legs": 2, "arms": 2}'))`
Query OK, 1 item affected (0.0086 sec)
要修改现有行,请使用add_or_replace_one或modify方法之一:
MySQL cookbook Py > `collectionLimbs.add_or_replace_one(`↩
`'00006002f065000000000000006b',`↩
`{"thing": "cat", "legs": 4, "arms": 0})`
Query OK, 2 items affected (0.0056 sec)
方法add_or_replace_one将文档_id作为第一个参数,JSON 文档作为第二个参数。如果找不到指定_id的文档,则插入新文档。如果找到,则替换现有文档。
方法modify以搜索条件作为参数,并返回一个支持方法的CollectionModify类对象,允许修改参数如set。您可以链式调用方法set,按需调用多次:
MySQL cookbook Py > `collectionLimbs.modify('thing = "dog"').set("legs", 4).set("arms", 0)`
Query OK, 1 item affected (0.0077 sec)
Rows matched: 1 Changed: 1 Warnings: 0
要检查我们是否成功更改了新插入文档 cat 和 dog 的手臂和腿的数量,可以使用 find 方法:
MySQL cookbook Py > `collectionLimbs.find('thing in ("dog", "cat")')`
{
"_id": "00006002f065000000000000006b",
"arms": 0,
"legs": 4,
"thing": "cat"
}
{
"_id": "00006002f065000000000000006c",
"arms": 0,
"legs": 4,
"thing": "dog"
}
2 documents in set (0.0013 sec)
方法 remove 从集合中删除文档:
MySQL cookbook Py > `collectionLimbs.remove('thing in ("dog", "cat")')`
Query OK, 2 items affected (0.0119 sec)
MySQL cookbook Py > `collectionLimbs.find('thing in ("dog", "cat")')`
Empty set (0.0011 sec)
MySQL cookbook Py > `collectionLimbs.count()`
11
方法 remove 支持与 modify 和 find 方法类似的搜索条件。
另请参阅
有关在 MySQL 中使用 JSON 文档和 NoSQL 的更多信息,请参见 第十九章。
2.11 控制输出格式
问题
您希望以与默认格式不同的格式打印结果。
解决方案
使用配置选项 resultFormat 或命令行参数 --result-format、--table、--tabbed、--vertical 或 --json。
讨论
默认情况下,MySQL Shell 以类似于 mysql 客户端的默认表格式打印结果。但是,此格式可以进行自定义。
在 MySQL Shell 内,您可以借助命令 \option 或 Shell 类的 shell.options 成员的 set 方法来完成。
因此,要以制表符格式打印表 artist 的内容,请运行:
MySQL cookbook JS > `\option resultFormat=tabbed`
MySQL cookbook JS > `artist=session.getCurrentSchema().getTable('artist')`
<Table:artist>
MySQL cookbook JS > `artist.select()`
a_id name
1 Da Vinci
2 Monet
4 Renoir
3 Van Gogh
4 rows in set (0.0009 sec)
要切换到垂直格式,请运行:
MySQL cookbook JS > `shell.options.set('resultFormat', 'vertical')`
MySQL cookbook JS > `artist.select()`
*************************** 1\. row ***************************
a_id: 1
name: Da Vinci
*************************** 2\. row ***************************
a_id: 2
name: Monet
*************************** 3\. row ***************************
a_id: 4
name: Renoir
*************************** 4\. row ***************************
a_id: 3
name: Van Gogh
4 rows in set (0.0009 sec)
JSON 格式支持少量选项。默认情况下,如果选项 resultFormat 的值设置为 json 或 MySQL Shell 使用选项 --json 启动,它等同于 json/pretty 或 --json=pretty,这意味着结果以 JSON 格式输出,格式化以提高可读性:
MySQL cookbook JS > `shell.options.set('resultFormat', 'json')`
MySQL cookbook JS > `artist.select()`
{
"a_id": 1,
"name": "Da Vinci"
}
{
"a_id": 2,
"name": "Monet"
}
{
"a_id": 4,
"name": "Renoir"
}
{
"a_id": 3,
"name": "Van Gogh"
}
4 rows in set (0.0008 sec)
选项 ndjson,json/raw 或 --json=raw 生成更紧凑的原始 JSON 输出。
MySQL cookbook JS > `shell.options.set('resultFormat', 'json/raw')`
MySQL cookbook JS > `artist.select()`
{"a_id":1,"name":"Da Vinci"}
{"a_id":2,"name":"Monet"}
{"a_id":4,"name":"Renoir"}
{"a_id":3,"name":"Van Gogh"}
4 rows in set (0.0003 sec)
选项 json/array 将结果表示为 JSON 文档数组。
MySQL cookbook JS > `shell.options.set('resultFormat', 'json/array')`
MySQL cookbook JS > `artist.select()`
[
{"a_id":1,"name":"Da Vinci"},
{"a_id":2,"name":"Monet"},
{"a_id":4,"name":"Renoir"},
{"a_id":3,"name":"Van Gogh"}
]
4 rows in set (0.0010 sec)
如果从命令行选择数据并稍后将其传递给另一个程序,则这将特别有用。
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook \`
> `-i --execute="session.getSchema('cookbook').\`
> `getTable('artist').select().execute()" \`
> `--result-format=json/array --quiet-start=2 \`
> `| head -n -1 \`
> `| jq '.[] | .name'`
"Da Vinci"
"Monet"
"Renoir"
"Van Gogh"
在上述代码中,我们使用选项 -i 启动了 mysqlsh,启用了交互模式,因此 MySQL Shell 的行为类似于交互式运行,并使用选项 --quiet-start=2 禁用了所有欢迎消息。然后我们将选项 --result-format 设置为 json/array 以启用 JSON 数组输出,使用选项 --execute 从表 artist 中选择,并将输出传递给 jq 命令,该命令删除了所有元数据信息并仅打印了艺术家的名称。
提示
命令 head -n -1 从结果中删除显示 select 方法返回的行数的最后一行。请注意,指定负数作为 head -n 参数的命令可能无法在所有系统上正常工作。如果您在此类系统上,可以忽略 jq 命令将打印的错误消息或将其重定向到其他地方:
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook \`
> `-i --execute="session.getSchema('cookbook').\`
> `getTable('artist').select().execute()" \`
> `--result-format=json/array --quiet-start=2 \`
> `| jq '.[] | .name' 2>/dev/null`
"Da Vinci"
"Monet"
"Renoir"
"Van Gogh"
当在 MySQL Shell 启动时启用 JSON 包装并使用选项 --json[=pretty|raw] 时,它还将在生成的 JSON 输出中打印诊断信息。
MySQL cookbook JS > `session.getCurrentSchema().getTable('artist').select()`
{
"hasData": true,
"rows": [
{
"a_id": 1,
"name": "Da Vinci"
},
{
"a_id": 2,
"name": "Monet"
},
{
"a_id": 4,
"name": "Renoir"
},
{
"a_id": 3,
"name": "Van Gogh"
}
],
"executionTime": "0.0007 sec",
"affectedRowCount": 0,
"affectedItemsCount": 0,
"warningCount": 0,
"warningsCount": 0,
"warnings": [],
"info": "",
"autoIncrementValue": 0
}
如果使用命令行选项 --result-format=json[/pretty|/raw|/array] 启用了 JSON 输出,则不会打印此额外信息。
所有输出格式与数据选择方式无关,并且在所有模式下均可用。
另请参阅
有关 MySQL Shell 输出格式的更多信息,请参见 MySQL 用户参考手册。
2.12 使用 MySQL Shell 运行报告
问题
您希望定期生成报告。
解决方案
使用命令 \show 和 \watch。
讨论
MySQL Shell 命令 \show 和 \watch 执行报告,包括内置和用户定义的。\show 一次执行报告,而 \watch 持续运行报告,直到被中断。
报告是一系列预定义的命令。报告可能支持参数。例如,内置报告 query 接受 SQL 查询作为参数。内置报告 thread 报告特定线程的详细信息。默认情况下,它报告当前线程的详细信息。
MySQL cookbook SQL > `\show thread`
GENERAL
Thread ID: 1434
Connection ID: 1382
Thread type: FOREGROUND
Program name: mysqlsh
User: sveta
Host: localhost
Database: cookbook
Command: Query
Time: 00:00:00
State: executing
Transaction state: NULL
Prepared statements: 0
Bytes received: 20280
Bytes sent: 40227
Info: SELECT json_object('tid',t.THR ... ↩
JOIN information_schema.innodb
Previous statement: NULL
警告
内置报告 thread 查询 performance_schema 和 sys 中的表,因此您应作为具有 performance_schema 和 sys 模式上的 SELECT 权限以及 sys 模式上的 EXECUTE 权限的用户连接。否则,报告将因为 拒绝访问
错误而失败。
但是报告 thread 支持参数,因此您可以指定例如线程的 Connection ID 并输出关于特定线程的信息。
MySQL cookbook SQL > `\show thread -c 1386`
GENERAL
Thread ID: 1438
Connection ID: 1386
Thread type: FOREGROUND
Program name: mysql
User: sveta
Host: localhost
Database: cookbook
Command: Sleep
Time: 00:05:44
State: NULL
Transaction state: RUNNING
Prepared statements: 0
Bytes received: 1720
Bytes sent: 29733
Info: NULL
Previous statement: select * from adcount for update
thread 报告的输出类似于标准的 PROCESSLIST 输出,但包含额外的信息,如 Transaction state 和 Previous statement。当您试图弄清楚是什么阻止了您的事务完成时,后者尤其有用。例如,如果其中一个事务运行多个语句并锁定记录,它可能会导致其他事务等待直到锁被释放。但由于该语句已经执行,因此在常规的 PROCESSLIST 输出中是看不到的。
甚至在 threads 报告中也可以找到更多有用的信息,默认情况下输出当前用户所属的所有线程的信息。它运行 MySQL Shell 会话,但可以打印服务器上所有线程的信息,并且可以过滤它们并定义输出格式。
例如,要查找所有被阻塞和阻塞事务,可以定义选项 --where "nblocked > 0 or nblocking > 0"。
MySQL cookbook SQL > `\show threads --foreground` ↩
`--where "nblocked > 0 or nblocking > 0"` ↩
`-o tid,cid,txid,txstate,nblocked,nblocking,info,pinfo`↩
`--vertical`
*************************** 1\. row ***************************
tid: 1438
cid: 1386
txid: 292253
txstate: RUNNING
nblocked: 1
nblocking: 0
info: NULL
pinfo: select * from adcount for update
*************************** 2\. row ***************************
tid: 3320
cid: 3268
txid: 292254
txstate: LOCK WAIT
nblocked: 0
nblocking: 1
info: update adcount set impressions = impressions + 1 where id=3
pinfo: NULL
因此,在上面的示例中,具有 Connection ID 3268 的线程正在尝试执行更新操作。
UPDATE adcount SET impressions = impressions + 1 WHERE id=3;
,但受到另一个事务的阻塞。否则,具有 Connection ID 1386 的线程没有执行任何操作,但阻止了一个线程。它的上一个语句是
SELECT * FROM adcount FOR UPDATE;
阻止写入表 adcount 中的所有行。这样,我们很容易找到为什么连接 3268 中的 UPDATE 目前无法完成的原因。
报告 threads 具有更多选项。如果使用报告名称运行 \show 命令,然后跟随选项 --help,您可以找到它们的所有内容。
MySQL cookbook SQL > `\show threads --help`
NAME
threads - Lists threads that belong to the user who owns the current
session.
SYNTAX
\show threads [OPTIONS]
\watch threads [OPTIONS]
DESCRIPTION
This report may contain the following columns:
...
提示
所有 MySQL Shell 命令都支持帮助选项。对于内置命令,运行 ? COMMAND,\help COMMAND 或 \h COMMAND。对于带参数的命令,另外尝试选项 --help。
命令 \watch 不仅执行报告,还会以一定间隔重复执行。当您想要监视某个参数的变化时,它非常有用。例如,要监视解析查询创建的内部临时表的数量,请运行以下命令:
MySQL cookbook SQL > `\watch query --nocls` ↩
`SHOW GLOBAL STATUS LIKE 'Created\_tmp\_%tables'`
+-------------------------+-------+
| Variable_name | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 4758 |
| Created_tmp_tables | 25306 |
+-------------------------+-------+
+-------------------------+-------+
| Variable_name | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 4758 |
| Created_tmp_tables | 25309 |
+-------------------------+-------+
+-------------------------+-------+
| Variable_name | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 4758 |
| Created_tmp_tables | 25310 |
+-------------------------+-------+
+-------------------------+-------+
| Variable_name | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 4760 |
| Created_tmp_tables | 25318 |
+-------------------------+-------+
+-------------------------+-------+
| Variable_name | Value |
+-------------------------+-------+
| Created_tmp_disk_tables | 4760 |
| Created_tmp_tables | 25319 |
+-------------------------+-------+
...
查询使用 LIKE 运算符和模式来匹配两个系统变量的名称。我们讨论了 LIKE 运算符如何工作,并在 Recipe 7.10 中讨论了如何匹配模式。
查询默认间隔为 2 秒。参数 --nocls 指示命令在打印最新结果之前不清除屏幕。要停止监视,请发出终止命令 Ctrl+C。
提示
2.13 使用 MySQL Shell 实用工具
问题
您想要使用 MySQL Shell 实用工具。
解决方案
在 JavaScript 或 Python 模式中:可以通过交互方式使用全局 util 对象的方法,或通过命令行传递方法。
讨论
MySQL Shell 提供了多个内置实用工具,可用于执行常见的管理任务,例如检查您的 MySQL 服务器是否可以安全更新到新版本或备份数据。在 JavaScript 和 Python 模式中,可以将这些实用工具作为全局对象 util 的方法调用,或作为命令行选项指定。
要查看 MySQL Shell 支持哪些实用工具,请运行命令 ? util。在 JavaScript 和 Python 模式下,方法的名称不同,并遵循各自语言的最佳实践命名。在 SQL 模式中,全局对象 util 不可用,也无法使用实用程序。
要了解某个实用工具的工作原理,请使用带有实用工具名称作为参数的帮助命令。例如,? checkForServerUpgrade 将在 JavaScript 模式下打印升级检查实用程序的详尽帮助信息。? dump_instance 将在 Python 模式下打印转储实用程序的详细用法说明。
调用实用程序方法与调用任何其他方法没有区别。例如,以下代码以完全引用的 CSV 格式将表 limbs 导出到文件 limbs.csv 中。
MySQL cookbook JS > `util.exportTable(`
-> `'limbs', 'BACKUP/cookbook/limbs.csv',`
-> `{dialect: "csv-unix"})`
Preparing data dump for table `cookbook`.`limbs`
Data dump for table `cookbook`.`limbs` will not use an index
Running data dump using 1 thread.
NOTE: Progress information uses estimated values and may not be accurate.
Data dump for table `cookbook`.`limbs` will be written to 1 file
91% (11 rows / ~12 rows), 0.00 rows/s, 0.00 B/s
Duration: 00:00:00s
Data size: 203 bytes
Rows written: 11
Bytes written: 203 bytes
Average throughput: 203.00 B/s
The dump can be loaded using:
util.importTable("BACKUP/cookbook/limbs.csv", {
"characterSet": "utf8mb4",
"dialect": "csv-unix",
"schema": "cookbook",
"table": "limbs"
})
在运行此命令之前,您需要创建目录 BACKUP/cookbook 或使用其他位置。
以下是将 Python 代码恢复到数据库测试中的表 limbs 的示例:
MySQL cookbook Py > `\sql CREATE TABLE test.limbs LIKE limbs;`
Fetching table and column names from `cookbook` for auto-completion... ↩
Press ^C to stop.
Query OK, 0 rows affected (0.0264 sec)
MySQL cookbook Py > `util.import_table("BACKUP/cookbook/limbs.csv",` ↩
`{"dialect": "csv-unix", "schema": "test"})`
Importing from file '/home/sveta/BACKUP/cookbook/limbs.csv' to table `test`.`limbs` ↩
in MySQL Server at 127.0.0.1:3306 using 1 thread
[Worker000] limbs.csv: Records: 11 Deleted: 0 Skipped: 0 Warnings: 0
100% (203 bytes / 203 bytes), 0.00 B/s
File '/home/sveta/BACKUP/cookbook/limbs.csv' (203 bytes) ↩
was imported in 0.0109 sec at 203.00 B/s
Total rows affected in test.limbs: Records: 11 Deleted: 0 Skipped: 0 Warnings: 0
我们省略了导入示例中除了必要选项之外的所有内容,以缩短文本。
要使用 import_table,您需要处于经典协议会话中。否则,命令将因错误而失败:
MySQL cookbook Py > `util.import_table("BACKUP/cookbook/limbs.csv",` ↩
`{"dialect": "csv-unix", "schema": "test"})`
Traceback (most recent call last):
File "<string>", line 1, in <module>
SystemError: RuntimeError: Util.import_table: ↩
A classic protocol session is required to perform this operation.
提示
阅读错误消息总是很有帮助,因为它们清楚地显示了问题所在,并经常包含如何修复故障的说明。
另一个可能遇到的错误是:
ERROR: The 'local_infile' global system variable must be set to ON ↩
in the target server, after the server is verified to be trusted:
要绕过此错误,请使用以下命令启用 local_infile 选项:
SET GLOBAL local_infile=1;
或者等到你到达第十三章,该章节涵盖了 MySQL 数据库对象的导出和导入。
提示
如果您不理解这些示例中实用程序的作用:不要担心。我们将在第十三章中涵盖 MySQL 数据库对象的导出和导入。
如果您想要在不进入交互模式的情况下运行实用程序,可以在两个破折号之后指定它们,遵循标准的 mysqlsh 选项:
$ `mysqlsh -- util check-for-server-upgrade root@127.0.0.1:13000 --output-format=JSON`
Please provide the password for 'root@127.0.0.1:13000':
Save password for 'root@127.0.0.1:13000'? [Y]es/[N]o/Ne[v]er (default No):
{
"serverAddress": "127.0.0.1:13000",
"serverVersion": "8.0.23-debug - Source distribution",
"targetVersion": "8.0.27",
"errorCount": 0,
"warningCount": 0,
"noticeCount": 0,
"summary": "No known compatibility errors or issues were found.",
...
在此示例中,我们首先指定了命令名称,然后添加了两个破折号,接着是全局对象名称、我们想要使用的方法、连接字符串,最后是方法参数。
命令行使用 JavaScript 模式的方法名,即驼峰命名法:checkForServerUpgrade,烤肉串命名法:check-for-server-upgrade,或蛇形命名法:check_for_server_upgrade。有关如何在不进入交互模式的情况下使用全局对象的更多信息,请交互式地使用命令 ? cmdline。
提示
您可以使用 -- 语法在命令行上调用其他全局对象的方法:
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook`
> `-- shell status`
WARNING: Using a password on the command line interface ↩
can be insecure.
MySQL Shell version 8.0.22
Connection Id: 23563
Default schema: cookbook
Current schema: cookbook
Current user: cbuser@localhost
SSL: Cipher in use: ↩
TLS_AES_256_GCM_SHA384 ↩
TLSv1.3
...
但是,并非所有全局对象都受支持。在交互模式下使用命令 ? cmdline 查看支持的对象列表。
参见
有关 MySQL Shell 实用程序的其他信息,请参阅MySQL Shell 实用程序。
2.14 使用 Admin API 自动化复制管理
问题
您想要自动化例行的 DBA 任务,例如部署 MySQL 服务器。
解决方案
使用 AdminAPI。
讨论
MySQL Shell 不仅支持 X Dev API 用于查询数据库,还支持 AdminAPI,允许您管理 InnoDB ReplicaSet 和 InnoDB Cluster。AdminAPI 由三个类组成:Dba、Cluster 和 ReplicaSet。
AdminAPI 可从类为 DBA 的全局对象 dba 访问。它允许您配置 MySQL 实例并启动独立沙盒、复制集或集群。
要配置独立的沙盒,请在 JavaScript 模式中使用 deploySandboxInstance 方法,或在 Python 模式中使用 deploy_sandbox_instance。此方法将一个端口号和一个参数字典作为参数:
MySQL cookbook JS > `dba.deploySandboxInstance(13000,`
-> `{"portx": 13010, "mysqldOptions": ["log-bin=cookbook"]})`
A new MySQL sandbox instance will be created on this host in
/home/sveta/mysql-sandboxes/13000
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:13000 successfully deployed and started.
Use shell.connect('root@localhost:13000') to connect to the instance.
这将创建一个带有名称、从 cookbook 开始的二进制日志已启用的沙盒实例,端口为 X 端口 13010:
MySQL localhost:13000 ssl JS > `shell.connect('root@localhost:13000')`
Creating a session to 'root@localhost:13000'
Please provide the password for 'root@localhost:13000':
Fetching schema names for autocompletion... Press ^C to stop.
Closing old connection...
Your MySQL connection id is 13
Server version: 8.0.22-13 Percona Server (GPL), Release '13', Revision '6f7822f'
No default schema selected; type \use <schema> to set one.
MySQL localhost:13000 ssl JS > `\sql show variables like 'log_bin_basename';`
+------------------+--------------------------------------------------------+
| Variable_name | Value |
+------------------+--------------------------------------------------------+
| log_bin_basename | /home/sveta/mysql-sandboxes/13000/sandboxdata/cookbook |
+------------------+--------------------------------------------------------+
1 row in set (0.0027 sec)
要停止实例,请在 JavaScript 模式下使用 stopSandboxInstance 方法,或在 Python 模式下使用 stop_sandbox_instance:
MySQL localhost:13000 ssl JS > `dba.stopSandboxInstance(13000)`
The MySQL sandbox instance on this host in
/home/sveta/mysql-sandboxes/13000 will be stopped
Please enter the MySQL root password for the instance 'localhost:13000':
Stopping MySQL instance...
Instance localhost:13000 successfully stopped.
要销毁实例,请在 JavaScript 模式下使用 deleteSandboxInstance 方法,或在 Python 模式下使用 delete_sandbox_instance:
MySQL cookbook Py > `dba.delete_sandbox_instance(13000)`
Deleting MySQL instance...
Instance localhost:13000 successfully deleted.
全局对象 dba 可从命令行访问:
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook -- dba kill-sandbox-instance 13000`
WARNING: Using a password on the command line interface can be insecure.
Killing MySQL instance...
Instance localhost:1300 successfully killed.
在 SQL 模式下,全局对象 dba 不可用。
参见
有关使用 AdminApi 创建和管理 ReplicaSet 的其他信息,请参阅食谱 3.17。有关使用 AdminApi 创建和管理 InnoDB Cluster 的其他信息,请参阅“InnoDB Cluster”
2.15 使用 JavaScript 对象
问题
您希望将文档作为对象进行操作,并且希望使用自己的方法和属性修改它们,并将它们存储在数据库中。
解决方案
创建一个对象,该对象具有与数据库通信的所有必要方法,并将其用作数据对象的原型。
讨论
JavaScript 是一种面向对象的编程语言,您可以轻松创建对象、修改对象并将其存储在数据库中。有时,直接编写 myObject.save() 可能比调用完整的 X DevAPI Collection 类方法链更简单。例如,您可能希望替换以下内容:
session.getCurrentSchema().getCollection('CollectionLimbs').↩
addOrReplaceOne(myObject).execute()
使用单个
CollectionLimbs.save()
调用。
JavaScript 支持继承,因此您可以创建一个对象,该对象具有所有必要的方法,这些方法使用 Collection 类的方法,并将其用作包含业务逻辑的对象的原型。
让我们创建一个名为 CookbookCollection 的对象作为示例,它将具有 find、save 和 remove 方法。它们将在集合中搜索对象,在修改后保存它,并在必要时从数据库中删除。CookbookCollection 对象还将具有一个名为 collection 的属性,用于存储表示存储我们对象的集合的对象。
为了使我们的方法更简单以增加清晰度,我们不会添加错误处理。您可以自行添加此功能。例如,如果用户忘记设置集合属性,您可以抛出自定义异常或使用默认集合替代。我们依赖于 JavaScript 内置异常。
让我们开始创建我们的对象:
mysql-js [cookbook]> `var CookbookCollection = {`
-> `// Collection where the object is stored`
-> `collection: null,`
首先,我们定义属性集合,存储对象。我们不在此处设置集合的名称,因为我们希望我们的原型可以与任何集合一起使用:
-> `// Searches collection and returns first`
-> `// object that satisfy search condition.`
-> `find: function(searchCondition) {`
-> `return this.collection.find(searchCondition).`
-> `execute().fetchOne();`
-> `},`
函数 find 使用任何搜索条件搜索集合。可以是 '_id = "00006002f0650000000000000061"' 或 'thing="human"'。换句话说,任何 Collection.find 方法接受的条件。然后,我们获取一个文档并将其作为结果返回。我们故意没有添加任何唯一性检查代码或任何其他方式来确保只有一个满足我们条件的文档,因为我们希望尽可能简单地进行示例,并使其可以与任何集合一起使用:
-> `// Saves the object in the database`
-> `save: function() {`
-> `// If we know _id of the object we are`
-> `// updating the existing one`
-> `// We use not so effective method addOrReplaceOne`
-> `// instead of modify for simplicity.`
-> `if ('_id' in this) {`
-> `this.collection.addOrReplaceOne(this._id,`
-> `// We use double conversion, because we cannot`
-> `// store an object with methods in the database`
-> `JSON.parse(`
-> `JSON.stringify(`
-> `this, Object.getOwnPropertyNames(`
-> `Object.getPrototypeOf(this))`
-> `)`
-> `)`
-> `)`
-> `} else {`
-> `// In case the object does not exist in the`
-> `// database yet, we add it and assign`
-> `// generated _id to its own property.`
-> `// This _id could be used later if we want to update`
-> `// or remove the database entry.`
-> `this._id = this.collection.add(`
-> `JSON.parse(`
-> `JSON.stringify(`
-> `this, Object.getOwnPropertyNames(`
-> `Object.getPrototypeOf(this))`
-> `)`
-> `)`
-> `).execute().getGeneratedIds()[0]`
-> `}`
-> `},`
方法 save 将对象存储在数据库中。如果对象中没有 _id 字段,通常意味着数据库中还没有此对象。因此,我们使用方法 add 将其插入到数据库中,并将对象的 _id 属性设置为由 MySQL 生成的值。如果已经存在这样的属性,这意味着该对象已经在数据库中或者我们希望显式设置 _id。在这种情况下,我们使用方法 addOrReplaceOne,它将添加具有指定唯一标识符的新对象或替换现有对象:
-> `// Removes the entry from the database.`
-> `// Once removed we unset property _id of the object.`
-> `remove: function() {`
-> `this.collection.remove("_id = '" + this._id + "'").`
-> `execute()`
-> `delete Object.getPrototypeOf(this)._id`
-> `delete this._id`
-> `}`
-> `}`
remove方法从数据库中删除记录,并额外删除我们对象的属性_id,因此,如果我们想要再次将其存储在数据库中,它将被视为新的对象,并生成新的唯一标识符。我们从原型和对象中删除属性_id。
让我们以我们在 Recipe 2.9 中创建的CollectionLimbs集合为例。首先,我们从当前的session中检索它,并将其设置为CookbookCollection对象的collection属性。
mysql-js [cookbook]> `CookbookCollection.collection=session.getCurrentSchema().`
-> `getCollection('CollectionLimbs')`
->
<Collection:CollectionLimbs>
提示
在 Recipe 2.9 中,我们回滚了所有对CollectionLimbs的修改。如果您在运行本章示例之前继续进行自己的实验,请执行:
CookbookCollection.collection ↩
.remove("thing='cat' or thing='dog'")
然后让我们创建一个有两只手臂和两条腿的cat对象:
mysql-js [cookbook]> `var cat = {`
-> `thing: "cat",`
-> `arms: 2,`
-> `legs: 2`
-> `}`
->
要能够将我们的猫存储在数据库中,我们需要将对象CookbookCollection指定为对象cat的原型:
mysql-js [cookbook]> `cat = Object.setPrototypeOf(CookbookCollection, cat)`
{
"arms": 2,
"collection": <Collection:CollectionLimbs>,
"find": <Function:find>,
"legs": 2,
"remove": <Function:remove>,
"save": <Function:save>,
"thing": "cat"
}
现在我们可以将我们的对象保存在数据库中:
mysql-js [cookbook]> `cat.save()`
我们可以检查是否可以使用find方法检索这样的对象:
mysql-js [cookbook]> `CookbookCollection.find('thing = "cat"')`
{
"_id": "000060140a2d0000000000000007",
"arms": 2,
"legs": 2,
"thing": "cat"
}
我们还可以确认我们的对象现在具有_id属性:
mysql-js [cookbook]> `cat._id`
000060140a2d0000000000000007
你看到这里有什么问题吗?是的!这只猫有两只手臂和两条腿,而通常猫没有手臂而是四条腿。让我们修正一下:
mysql-js [cookbook]> `cat.arms=0`
0
mysql-js [cookbook]> `cat.legs=4`
4
mysql-js [cookbook]> `cat.save()`
mysql-js [cookbook]> `CookbookCollection.find('thing = "cat"')`
{
"_id": "000060140a2d0000000000000007",
"arms": 0,
"legs": 4,
"thing": "cat"
}
现在我们的猫状态良好。
如果我们想要清理集合并将其保留在我们的实验之前的状态,我们可以从数据库中删除文档cat:
mysql-js [cookbook]> `cat.remove()`
我们还可以注意到cat._id属性在我们的对象中不再存在:
mysql-js [cookbook]> `cat._id`
mysql-js [cookbook]>
如果我们决定再次将对象存储在数据库中,将生成新的唯一标识符。
您可以在recipes分发中的文件mysql_shell/CookbookCollection.js中找到CookbookCollection的代码。
2.16 使用 Python 的数据科学模块填充测试数据
问题
您想用部分随机数据填充测试表。例如,您需要 ID 按顺序排列。您还希望它们具有真实的名字和姓氏。表中的其余值可以是随机的,但索引应具有特定的基数。
解决方案
使用 Python 及其特定的数据科学模块进行脚本数据填充。
讨论
我们经常处于需要用模拟真实世界数据的假数据填充表格的情况。例如,当您开发一个应用程序并想要检查如果存储在其中的数据量增加会发生什么时。或者您遇到一个特定查询在生产中运行缓慢的情况,希望在测试服务器上进行实验,但不希望由于安全或性能原因复制生产数据。当您想要向第三方顾问寻求帮助时,可能还需要此任务。
其中一个示例是表patients,我们在 Recipe 24.12 中使用它。这是一张表,存储了在医院里待了一天以上的病人记录。它存储了国民身份证号、名字、姓氏、性别、人的诊断和结果,例如病人在医院里的停留日期以及他们是否康复、以相同症状退房,甚至死亡。如果你运行 SHOW CREATE TABLE 命令,你可以找到详细信息:
MySQL cookbook Py > `session.sql('SHOW CREATE TABLE patients')`
*************************** 1\. row ***************************
Table: patients
Create Table: CREATE TABLE `patients` (
`id` int NOT NULL AUTO_INCREMENT,
`national_id` char(32) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`surname` varchar(255) DEFAULT NULL,
`gender` enum('F','M') DEFAULT NULL,
`age` tinyint unsigned DEFAULT NULL,
`additional_data` json DEFAULT NULL,
`diagnosis` varchar(255) DEFAULT NULL,
`result` enum('R','N','D') DEFAULT NULL↩
COMMENT 'R=Recovered, N=Not Recovered, D=Dead',
`date_arrived` date NOT NULL,
`date_departed` date DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1101 ↩
DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.0009 sec)
当然,对于这张表的示例,我们不能想象使用真实数据。然而,我们仍然希望假装数据是真实的。例如,名字和姓氏应该是流行的名字,像一个叫 John Doe 或 Ann Smith 的人,性别应该对应正确的名字。例如,John 很可能是男性,Ann 很可能是女性。年龄应该在合理的范围内,离开日期应该大于病人到达医院的日期,而一个病人在那里待上 10 年是不太可能的。因此,我们需要一种聪明的方法来填充表格的虚假值。
Python 是一种常用于数据分析和统计的编程语言。它有像 pandas 这样的库,帮助操作大型数据集。它有方便的方法来读取和生成数据。这就是为什么 Python 是执行我们任务的理想方式之一。
要在 MySQL Shell 中使用 pandas 模块,你需要在你的机器上安装它,并将库所在的路径添加到 MySQL Shell 的 sys.path 中。以下是帮助你执行此任务的步骤。
-
首先检查 MySQL Shell 运行的 Python 版本。在我们的情况下,这是版本 3.7.7:
MySQL cookbook Py > `import sys` MySQL cookbook Py > `sys.version` 3.7.7 (default, Aug 12 2020, 09:13:48) [GCC 4.4.7 20120313 (Red Hat 4.4.7-23.0.1)] -
MySQL Shell 不带有 python 可执行文件和 pip,你不能在 MySQL Shell 外部运行它们。因此,你需要安装与 MySQL Shell 的 Python 版本相同的版本。我们选择保持系统范围内已安装的版本 3.8.5 不变,并从源代码安装与我们的 MySQL Shell 实例使用的相同版本 3.7.7 到本地目录中。你可能决定在系统范围内使用相同的版本。
-
一旦安装了必要版本的 Python,请检查它存储模块的位置,并将此目录添加到 MySQL Shell 的
sys.path中:MySQL cookbook Py > `sys.path.append(`↩ `"/home/sveta/bin/python-3.7.7/lib/python3.7/site-packages")`提示
要避免每次想要使用不是 MySQL Shell 发行版的模块时都输入此命令,请将此命令添加到 Python 模式配置文件中。默认情况下,此文件位于
~/.mysqlsh/mysqlshrc.py。 -
安装必要的软件包。例如,我们使用了
numpy、pandas、random、string和datetime。
一旦满足这些先决条件,我们就准备好用示例数据填充我们的表格了。
逐步填充数据
首先,我们需要导入所有必要的软件包。在 MySQL Shell 的 Python 协议会话中输入以下内容:
import numpy
from pandas import pandas
import random
import string
from datetime import datetime
from datetime import timedelta
现在我们准备生成数据了。
对于姓名和姓氏,我们决定使用在数据集中找到的真实姓名,这些数据集可以在互联网上找到。您可以在recipes分发的datasets目录中找到我们使用的数据集、它们的许可和分发权利。对于诊断,我们还使用了公开可用的前 8 个诊断数据及其频率,以及虚假的诊断“数据恐惧症”,其频率更高。性别数据与姓名一起存储。所有其他值都是生成的。我们并不在乎这些数据看起来是否真实。例如,我们可能会得到一个 16 岁的患者死于酒精性肝病,这在现实生活中不太可能发生,但是为了演示目的,这应该足够了。然而,Python 允许解决这类冲突。您可以更改我们的示例,以便创建更真实的数据。
定义最终表中行数的变量会很方便:
MySQL cookbook Py > `num_rows=1000`
现在,一旦我们准备好了,让我们讨论一下我们将如何处理表patients的每一列。
姓名和性别
姓名和性别存储在文件top-350-male-and-female-names-since-1848-2019-02-26.csv中,格式如下:
$ `head datasets/top-350-male-and-female-names-since-1848-2019-02-26.csv`
Rank,Female Name,Count,Male Name,Count_
1,Mary,54276,John,108533
2,Margaret,49170,William,87239
3,Elizabeth,36556,James,69987
4,Sarah,28230,David,62774
5,Patricia,20689,Robert,56511
6,Catherine,19713,Michael,51768
7,Susan,19165,Peter,44758
8,Helen,18881,Thomas,42467
9,Emma,18192,George,39195
这意味着每一行包含一个排名从 1 到 350 的名字,一个传统上是女性的名字和一个传统上是男性的名字以及这些名字的数量。我们对排名和数量不感兴趣。我们只需要带有性别信息的女性和男性名字。因此,在读取此数据集后,我们需要对该数据集进行轻微的操作。
首先,我们使用pandas的read_csv方法读取文件。我们将读取文件两次:第一次是传统女性名字,第二次是传统男性名字。我们将仅在第一次尝试中使用Female Name列,并仅在第二次尝试中使用Male Name列。我们还将重命名此列,使其与我们数据库中的列名对应:
MySQL cookbook Py > `female_names=pandas.\`
-> `read_csv(`
-> `"top-350-male-and-female-names-since-1848-2019-02-26.csv",`
-> `usecols=["Female Name"]`
-> `).rename(columns={'Female Name': 'name'})`
->
MySQL cookbook Py > `male_names=pandas.\`
-> `read_csv(`
-> `"top-350-male-and-female-names-since-1848-2019-02-26.csv",`
-> `usecols=["Male Name"]`
-> `).rename(columns={'Male Name': 'name'})`
->
完成后,向我们的数据集添加一个gender列:
MySQL cookbook Py > `female_names['gender']=(['F']*`↩
`female_names.count()['name'])`
MySQL cookbook Py > `male_names['gender']=(['M']*`↩
`male_names.count()['name'])`
最后,将两个数据集连接成一个:
MySQL cookbook Py > `names=pandas.\`
-> `concat([female_names, male_names],`
-> `ignore_index=True)`
->
为了读取文件top-350-male-and-female-names-since-1848-2019-02-26.csv,它应该在当前工作目录中,或者您需要提供此文件的绝对路径。要找到当前工作目录,请运行:
os.getcwd()
要更改工作目录,请运行:
os.chdir('/mysqlcookbook/recipes/datasets')
这将允许读取位于/mysqlcookbook/recipes/datasets中的文件。调整目录路径以反映您的环境。
提示
Python 模块pandas的方法concat类似于 SQL 的UNION子句。
我们可以通过键入其名称来检查使用pandas数据结构DataFrame的数据集内容:
MySQL cookbook Py > `names`
name gender
0 Mary F
1 Margaret F
2 Elizabeth F
3 Sarah F
4 Patricia F
.. ... ...
695 Quentin M
696 Henare M
697 Joe M
698 Darcy M
699 Wade M
[700 rows x 2 columns]
数据帧中的行数少于我们在表中想要的行数,因此我们需要生成更多的行。我们还希望对数据进行洗牌,以获得姓名的随机分布。为此,我们将使用sample方法。由于我们正在创建一个比初始数据集更大的集合,因此我们需要指定选项replace=True。我们还将使用pandas.Series方法重新创建新数据帧的索引,使其有序。
MySQL cookbook Py > `names=names.sample(num_rows, replace=True).\`
-> `set_index(pandas.Series(range(num_rows)))`
->
姓氏
对于姓氏,我们将使用存储在文件 Names_2010Census.csv 中的数据集。它有多列,如排名、姓氏数量等等。但我们只关心第一列:name。我们也不需要文件的最后一行,其中包含对 ALL OTHER NAMES 的记录。该文件中的姓氏以大写字母存储。我们可以以不同的格式进行格式化,但我们决定保留原样。我们还将列 name 重命名为 surname,以便与我们的表定义一致。
MySQL cookbook Py > `surnames=pandas.read_csv("Names_2010Census.csv",`
-> `usecols=['name'], skipfooter=1, engine='python').\`
-> `rename(columns={'name': 'surname'})`
->
pandas 打印一个警告,它将使用更慢但更强大的 python 引擎来处理文件,但可以忽略这个警告。
我们将使用与姓名相同的方法对姓氏进行洗牌。
MySQL cookbook Py > `surnames=surnames.sample(num_rows, replace=True).\`
-> `set_index(pandas.Series(range(num_rows)))`
->
诊断
我们手动准备了文件 diagnosis.csv,仅包含 9 个诊断,因此我们只需要读取它,不需要指定任何选项。
MySQL cookbook Py > `diagnosises=pandas.read_csv('diagnosis.csv')`
MySQL cookbook Py > `diagnosises`
diagnosis frequency
0 Acute coronary syndrome 2.1
1 Alcoholic liver disease 0.3
2 Pneumonia 3.6
3 Chronic obstructive pulmonary disease 2.1
4 Gastro-intestinal bleed 0.8
5 Heart failure 0.8
6 Sepsis 0.8
7 Urinary tract infection 2.4
8 Data Phobia 6.2
诊断与姓名和姓氏不同,因为它们具有不同的频率,并且我们希望它们按照这些频率在我们的最终数据集中分布。因此,我们将向方法 sample 传递参数 weights。
MySQL cookbook Py > `diagnosises=diagnosises.sample(`
-> `num_rows, replace=True,`
-> `weights=diagnosises['frequency']`
-> `).set_index(pandas.Series(range(num_rows)))`
->
结果
结果的数据类型是 ENUM,只能包含三个可能的值:R 表示康复,N 表示未康复,D 表示死亡。我们不会使用任何来源来获取这样的结果,而是交互式地生成一个 DataFrame。
MySQL cookbook Py > `results = pandas.DataFrame({`
-> `"result": ["R", "N", "D"],`
-> `"frequency": [6,3,1]`
-> `})`
->
我们向我们的结果添加了频率。这些频率与现实无关:我们只需要它们以不同的方式分配我们的结果。
由于我们有结果的频率,我们将生成类似于我们对诊断进行的方式的数据集。
MySQL cookbook Py > `results=results.sample(`
-> `num_rows, replace=True,`
-> `weights=results['frequency']`
-> `).set_index(pandas.Series(range(num_rows)))`
->
表
我们已准备好主要数据集。现在我们可以逐行将行插入表中。
首先,让我们检索一个 Table 对象,以便可以舒适地查询它。
MySQL cookbook Py > `patients=session.get_schema('cookbook').`↩
`get_table('patients')`
然后开始循环
MySQL cookbook Py > `for i in range(num_rows):`
所有后续的生成将在循环中进行。
国家 ID
国家 ID 的格式可以在国家之间有所不同,我们只需要一些能够遵循某种模式的唯一内容。我们决定使用两位数字,后跟两个大写字母,然后是六位数字的格式。为了生成随机数字,我们将使用模块 random 的 randrange 方法,并且为了生成字母,我们将使用模块 random 的 sample 方法。我们将使用预定义的集合 string.ascii_uppercase 作为数据集来抽样。然后,我们将生成的数组连接到空字符串上,这样就会创建一个字符串:
MySQL cookbook Py > `national_id=str(random.randrange(10,99)) +\`
-> `''.join(random.sample(string.ascii_uppercase, 2)) + \`
-> `str(random.randrange(100000, 999999))`
->
年龄
对于年龄,我们将简单地选择一个 15 到 99 之间的数字。我们不关心年龄频率或特定年龄患者有某种疾病的数量:
MySQL cookbook Py > `age=random.randrange(15, 99)`
患者在医院中度过的日期
对于 date_arrived 列,我们决定只使用 2020 年的任何日期。我们可以通过指定开始日期为 2020 年 1 月 1 日并使用 timedelta 方法来生成这样的日期:
MySQL cookbook Py > `date_arrived=datetime.\`
-> `strptime('2020-01-01', '%Y-%m-%d') +\`
-> `timedelta(days=random.randrange(365))`
->
对于 date_departed 列,我们将使用相同的思路,但我们将使用 date_arrived 作为起始日期,并间隔两个月:
MySQL cookbook Py > `date_departed=date_arrived +\`
-> `timedelta(days=random.randrange(60))`
->
此代码将 date_arrived 和 date_departed 创建为无法插入到 MySQL 表中的 datetime Python 对象,因此我们需要将它们转换为字符串格式:
MySQL cookbook Py > `date_arrived=date_arrived.strftime('%Y-%m-%d')`
MySQL cookbook Py > `date_departed=date_departed.strftime('%Y-%m-%d')`
准备行
我们有要插入到我们表的第 i- 行中的值,插入到 national_id、age、date_arrived 和 date_departed 列中。但其余值存储在精确所需行数的 DataFrame 中。我们只需要从 DataFrame 中检索特定行:
MySQL cookbook Py > `name=names['name'][i]`
MySQL cookbook Py > `gender=names['gender'][i]`
MySQL cookbook Py > `surname=surnames['surname'][i]`
MySQL cookbook Py > `result=results['result'][i]`
MySQL cookbook Py > `diagnosis=diagnosises['diagnosis'][i]`
在表中插入行
现在我们准备将一行插入到我们的表中。我们将使用我们在 Recipe 2.8 中详细讨论的 Table 类的 insert 方法:
MySQL cookbook Py > `patients.insert(`
-> `'national_id', 'name', 'surname',`
-> `'gender', 'age', 'diagnosis',`
-> `'result', 'date_arrived', 'date_departed'`
-> `).values(`
-> `national_id, name, surname,`
-> `gender, age, diagnosis,`
-> `result, date_arrived, date_departed`
-> `).execute()`
将所有内容组合在一起
或许将刚刚编写的代码定义为函数会更方便,这样我们就可以重复使用它。让我们创建一个名为 generate_patients_data 的函数。
def generate_patients_data(num_rows):
# read datasets
# names and genders
female_names = pandas.read_csv(
"top-350-male-and-female-names-since-1848-2019-02-26.csv",
usecols = ["Female Name"]
).rename(columns = {'Female Name': 'name'})
female_names['gender'] = (['F']*female_names.count()['name'])
male_names = pandas.read_csv(
"top-350-male-and-female-names-since-1848-2019-02-26.csv",
usecols = ["Male Name"]
).rename(columns = {'Male Name': 'name'})
male_names['gender'] = (['M']*male_names.count()['name'])
names = pandas.concat([female_names, male_names], ignore_index=True)
surnames = pandas.read_csv(
"Names_2010Census.csv",
usecols=['name'], skipfooter=1
).rename(columns={'name': 'surname'})
# diagnosises
diagnosises = pandas.read_csv('diagnosis.csv')
# Possible results
results = pandas.DataFrame({
"result": ["R", "N", "D"],
"frequency": [6,3,1]
})
# Start building data
diagnosises = diagnosises.sample(
num_rows, replace=True,
weights=diagnosises['frequency']
).set_index(pandas.Series(range(num_rows)))
results = results.sample(
num_rows, replace=True,
weights=results['frequency']
).set_index(pandas.Series(range(num_rows)))
names=names.sample(
num_rows, replace=True
).set_index(pandas.Series(range(num_rows)))
surnames=surnames.sample(
num_rows, replace=True
).set_index(pandas.Series(range(num_rows)))
# Get table object
patients=session.get_schema('cookbook').get_table('patients')
# Loop, inserting rows
for i in range(num_rows):
national_id = str(random.randrange(10,99)) + \
''.join(random.sample(string.ascii_uppercase, 2)) + \
str(random.randrange(100000, 999999))
age = random.randrange(15, 99)
date_arrived = datetime.strptime('2020-01-01', '%Y-%m-%d') + \
timedelta(days=random.randrange(365))
date_departed = date_arrived + timedelta(days=random.randrange(60))
date_arrived = date_arrived.strftime('%Y-%m-%d')
date_departed = date_departed.strftime('%Y-%m-%d')
name = names['name'][i]
gender = names['gender'][i]
surname = surnames['surname'][i]
result = results['result'][i]
diagnosis = diagnosises['diagnosis'][i]
patients.insert(
'national_id', 'name', 'surname',
'gender', 'age', 'diagnosis',
'result', 'date_arrived', 'date_departed'
).values(
national_id, name, surname,
gender, age, diagnosis,
result, date_arrived, date_departed
).execute()
我们可以通过截断 patients 表然后调用该函数来检查其工作方式:
MySQL cookbook Py > `\sql truncate table patients`
Query OK, 0 rows affected (0.0477 sec)
MySQL cookbook Py > `session.get_schema('cookbook').get_table('patients').count()`
0
MySQL cookbook Py > `generate_patients_data(1000)`
__main__:17: ParserWarning: Falling back to the 'python' engine ↩
because the 'c' engine does not support skipfooter; ↩
you can avoid this warning by specifying engine='python'.
MySQL cookbook Py > `session.get_schema('cookbook').` ↩
`get_table('patients').count()`
1000
MySQL cookbook Py > `session.get_schema('cookbook').` ↩
`get_table('patients').select().limit(10)`
+----+-------------+----------+------------+--------+-----+-----------------+....
| id | national_id | name | surname | gender | age | additional_data | ...
+----+-------------+----------+------------+--------+-----+-----------------+....
| 1 | 74LM282144 | May | NESSELRODE | F | 83 | NULL | ...
| 2 | 44PR883357 | Kathryn | DAKROUB | F | 44 | NULL | ...
| 3 | 60JP130066 | Owen | CIELINSKI | M | 47 | NULL | ...
| 4 | 28ST588095 | Diana | KILAR | F | 35 | NULL | ...
| 5 | 77RP202627 | Beryl | ANGIONE | F | 43 | NULL | ...
| 6 | 27MU569536 | Brian | HOUDEK | M | 84 | NULL | ...
| 7 | 94AG787006 | Fredrick | WOHLMAN | M | 20 | NULL | ...
| 8 | 42BX974594 | Jarrod | DECAPUA | M | 64 | NULL | ...
| 9 | 63XJ322387 | Ruth | PAHUJA | F | 16 | NULL | ...
| 10 | 91AT797455 | Frances | VANBRUGGEN | F | 63 | NULL | ...
+----+-------------+----------+------------+--------+-----+-----------------+....
+----+.....+-------------------------+--------+--------------+---------------+
| id | ... | diagnosis | result | date_arrived | date_departed |
+----+.....+-------------------------+--------+--------------+---------------+
| 1 | ... | Data Phobia | D | 2020-03-20 | 2020-04-26 |
| 2 | ... | Data Phobia | R | 2020-03-20 | 2020-05-09 |
| 3 | ... | Pneumonia | R | 2020-04-05 | 2020-04-23 |
| 4 | ... | Acute coronary syndrome | R | 2020-04-18 | 2020-05-01 |
| 5 | ... | Pneumonia | R | 2020-01-31 | 2020-02-07 |
| 6 | ... | Acute coronary syndrome | D | 2020-01-25 | 2020-03-06 |
| 7 | ... | Data Phobia | R | 2020-08-10 | 2020-09-04 |
| 8 | ... | Pneumonia | R | 2020-02-12 | 2020-03-31 |
| 9 | ... | Pneumonia | N | 2020-11-17 | 2020-12-19 |
| 10 | ... | Sepsis | R | 2020-12-11 | 2020-12-29 |
+----+.....+-------------------------+--------+--------------+---------------+
10 rows in set (0.0004 sec)
我们还可以将此函数存储在文件中,并稍后重新使用它。我们将在 Recipe 2.17 中讨论如何重复使用用户代码。
您可以在 recipes 发行版的 mysql_shell/generate_patients_data.py 文件中找到 generate_patients_data 函数的代码。
参见
关于 Python 模块 pandas 的更多信息,请参阅pandas 文档。
2.17 重复使用您的 MySQL Shell 脚本
问题
您编写了 MySQL Shell 的代码,并希望以后能够重复使用。
解决方案
存储您的工作,并稍后使用 \source 命令加载文件。或者,设置这些文件作为启动脚本。
讨论
MySQL Shell 允许您重新使用您的代码。您可以通过使用 \source 命令或将您的脚本设置为在启动时执行来实现。让我们详细检查每一种可能性。
\source 命令适用于每种模式,并且与 mysql 客户端的 \source 命令类似工作。唯一的区别是,您的源文件应该与所选模式使用相同的语言编写。
例如,要加载我们在 Recipe 2.15 中讨论的 CookbookCollection 对象,可以输入以下命令:
MySQL cookbook JS > `\source /cookbook/recipes/mysql_shell/CookbookCollection.js`
MySQL cookbook JS > `CookbookCollection`
{
"collection": null,
"find": <Function:find>,
"remove": <Function:remove>,
"save": <Function:save>
}
正如您所见,它立即可以用于使用。
同样,您可以导入我们在 Recipe 2.16 中讨论的 generate_patients_data 函数的定义:
MySQL cookbook Py > `\source /cookbook/recipes/mysql_shell/generate_patients_data.py`
或者,在 SQL 模式下,我们可以加载任何 SQL 文件:
MySQL cookbook SQL > `\source /cookbook/recipes/tables/patients.sql`
Query OK, 0 rows affected (0.0003 sec)
Query OK, 0 rows affected (0.0202 sec)
Query OK, 0 rows affected (0.0001 sec)
Query OK, 0 rows affected (0.0334 sec)
Query OK, 0 rows affected (0.0001 sec)
Query OK, 20 rows affected (0.0083 sec)
Records: 20 Duplicates: 0 Warnings: 0
如果您想在启动时执行脚本,您需要编辑 JavaScript 模式的 mysqlshrc.js 文件和 Python 模式的 mysqlshrc.py 文件,这些文件位于 MySQL Shell 用于搜索启动脚本的位置之一。这些文件可以位于以下任一位置:
-
全局配置文件,位于 Unix 上的
/etc/mysql/mysqlsh/mysqlshrc.[js|py],或者 Windows 上的%PROGRAMDATA%\MySQL\mysqlsh\mysqlshrc.[js|py]。 -
你的个人配置文件可以在 Unix 下的
$HOME/.mysqlsh/mysqlshrc.[js|py]或 Windows 下的%APPDATA%\MySQL\mysqlsh\mysqlshrc.[js|py]找到。或者你可以指定变量MYSQLSH_USER_CONFIG_HOME并将文件mysqlshrc.[js|py]存储在其下。 -
目录
share/mysqlsh可以在 MySQL Shell 安装根目录下找到,或者通过变量MYSQLSH_HOME指定。
mysqlshrc.[js|py]的格式与相应模式相同。因此,为了预加载CookbookCollection对象,你需要将CookbookCollection.js转换为模块,并导出我们的对象CookbookCollection:
exports.CookbookCollection = {
// Collection where the object is stored
collection: null,
...
然后你需要在文件mysqlshrc.js中加入两行:
sys.path = [...sys.path, '/cookbook/recipes/mysql_shell'];
const cookbook=require('CookbookCollectionModule.js')
在第一行,我们将包含我们模块的目录添加到模块搜索路径中。在第二行,我们导入了模块本身。CookbookCollection对象作为全局对象cookbook的属性可用。
MySQL cookbook JS > `cookbook`
{
"CookbookCollection": {
"collection": null,
"find": <Function:find>,
"remove": <Function:remove>,
"save": <Function:save>
}
}
提示
MySQL Shell 使用 Node.js 模块。参考Node.js 文档了解如何在 MySQL Shell 中编写和使用 JavaScript 模块的详细信息。
CookbookCollectionModule.js位于recipes分发的mysql_shell目录中。
要在启动脚本中导入 Python 函数generate_patients_data,我们需要在我们的 Python 文件中添加指令import mysqlsh,因为在加载模块时,MySQL Shell 的全局对象尚不可用。我们还将更改该行:
patients=session.get_schema('cookbook').get_table('patients')
到
patients=mysqlsh.globals.session.get_schema('cookbook').get_table('patients')
否则 Python 会因为尚未定义名称session而失败。
我们将模块命名为cookbook.py以简洁明了起见。
在我们的函数中,我们使用当前目录中的本地路径到文件,因此我们将更改默认搜索路径为包含所有数据集的目录。为此,我们将导入模块os并使用其方法chdir。然后我们简单地导入模块cookbook。生成的mysqlshrc.py将有此代码。
sys.path.append("/home/sveta/bin/python-3.7.7/lib/python3.7/site-packages")
sys.path.append("/cookbook/recipes/mysql_shell")
import os
os.chdir('/cookbook/recipes/datasets')
import cookbook
模块cookbook.py位于recipes分发的mysql_shell目录中。
参见
关于使用外部脚本定制 MySQL Shell 的更多信息,请参阅 MySQL 用户参考手册中的定制 MySQL Shell。
第三章:MySQL 复制
3.0 介绍
MySQL 复制提供了一种设置活动(源)数据库的副本服务器(复制)的方式,然后自动持续更新此类副本,应用源服务器接收的所有更改。
副本在许多情况下都很有用,特别是:
热备份
在故障发生时,通常处于空闲状态的服务器可以替代活动服务器。
读取扩展
多台服务器从同一源复制时,可以比单台机器处理更多的并行读取请求。
地理分布
当应用程序为不同区域的用户提供服务时,位于本地的数据库服务器可以帮助更快地检索数据。
分析服务器
复杂的分析查询可能需要几小时才能运行,设置大量锁定并使用大量资源。在副本上运行它们可以最小化对应用程序其他部分的影响。
备份服务器
从活动数据库获取备份涉及高 IO 资源使用和锁定,这是必要的,以避免备份数据集与活动数据集之间的数据不一致性。从专用副本获取备份可以减少对生产环境的影响。
延迟副本
使用 SOURCE_DELAY(MASTER_DELAY)选项配置的延迟应用更新的副本允许回滚人为错误,例如重要表的删除。
注意
在历史上,源服务器被称为主服务器,副本服务器被称为从服务器。最近发现,主从这些术语并不正确地反映了复制的工作方式,而这些术语本身可能是侮辱性的。在过去几年中,大多数软件供应商开始从旧术语转向新术语。对于 MySQL,这种变化从版本 8.0.22 开始,并仍在进行中。并非所有选项名称和命令都支持新语法。即使您的 MySQL 版本完全支持新语法,您在公共论坛和早期印刷书籍中可能会找到遗留术语。因此,在本书中讨论复制角色时,我们使用源和副本这些术语。对于支持新语法的命令和变量名称,我们首次提供新语法。如果变更仍在进行中,我们使用旧的语法。
MySQL 复制需要在两台服务器上进行特殊操作。
源服务器将所有更新存储在二进制日志文件中。这些文件包含编码的更新事件。源服务器在某一时刻写入单个二进制日志文件。一旦达到 max_binlog_size,则会旋转二进制日志,并创建一个新文件。
二进制日志文件支持两种格式:STATEMENT 和 ROW。在 STATEMENT 格式中,SQL 语句按原样编写,然后编码为二进制格式。在 ROW 格式中,SQL 语句不记录,而是存储实际的表行更新。首选 ROW 二进制日志格式。
提示
当使用二进制日志格式 ROW 时,在排查复制错误时,知道源服务器收到的实际语句可能很有用。使用选项 binlog_rows_query_log_events 将信息记录事件与原始查询一起存储。这样的事件不参与复制,仅供信息目的检索。
副本服务器持续从源服务器请求二进制日志事件,然后将它们存储在特殊文件中,称为中继日志文件。它有一个独立的线程,称为 IO 或连接线程,专门负责此任务。另一个线程或线程组,称为 SQL 或应用程序线程,从中继日志中读取事件并将它们应用到表中。
二进制日志中的每个事件都有其自己的唯一标识符:位置。位置对每个文件都是唯一的,并在创建新文件时重置。副本可以使用二进制日志文件名和位置作为事件的唯一标识符。
虽然二进制日志位置唯一标识特定文件中的事件,但不能用于确定特定事件是否已在副本上应用。为解决此问题,引入了全局事务标识符 (GTID)。这些是唯一标识符,分配给每个事务。它们在 MySQL 安装的整个生命周期内都是唯一的。它们还使用机制唯一标识服务器,因此即使从多个源进行复制也是安全的。
副本在特殊存储库中存储有关源二进制日志坐标的信息,由变量 master_info_repository 定义。此类存储库可以存储在表中或文件中。
本章描述了如何设置和使用 MySQL 复制。它涵盖了所有典型的复制场景,包括:
-
两台服务器单向源-副本设置。
-
循环复制
-
多源复制
-
半同步复制
-
组复制
3.1 配置一源一副本之间的基本复制
问题
您希望为复制准备两台服务器。
解决方案
在源配置文件中添加配置选项 log-bin,为两台服务器指定唯一的 server_id,添加支持 GTID 和/或非默认二进制日志格式的选项,并在源上创建一个具有 REPLICATION SLAVE 权限的用户。
讨论
首先,您需要准备两台服务器以处理复制事件。
在源服务器上:
-
通过在配置文件中添加选项
log-bin来启用二进制日志。更改此选项需要重新启动。自版本 8.0 起,默认情况下启用二进制日志。 -
设置唯一的
server_id。server_id是动态变量,可以在不关闭服务器的情况下更改,但我们强烈建议在配置文件中设置它,这样在重新启动后不会被覆盖。 -
创建一个复制用户,并授予
REPLICATION SLAVE权限:mysql> `CREATE USER repl@'%' IDENTIFIED BY 'replrepl';` Query OK, 0 rows affected (0,01 sec) mysql> `GRANT REPLICATION SLAVE ON *.* TO repl@'%';` Query OK, 0 rows affected (0,03 sec)
警告
在 MySQL 8.0 中,默认的认证插件是 caching_sha2_password,它要求 TLS 连接或源公钥。因此,如果您想使用此插件,需要按照 Recipe 3.14 中描述的方法为副本启用 TLS 连接,或使用 CHANGE REPLICATION SOURCE (CHANGE MASTER) 命令的选项 SOURCE_PUBLIC_KEY_PATH=1 (GET_MASTER_PUBLIC_KEY=1)。
或者您可以使用认证插件,允许不安全的连接。
mysql> `CREATE USER repl@'%' IDENTIFIED WITH mysql_native_password BY 'replrepl';`
Query OK, 0 rows affected (0,01 sec)
mysql> `GRANT REPLICATION SLAVE ON *.* TO repl@'%';`
Query OK, 0 rows affected (0,03 sec)
在副本上只需设置唯一的 server_id。
提示
自版本 8.0 起,您可以使用 SET PERSIST 将动态更改的变量永久保存:
mysql> `SET PERSIST server_id=200;`
Query OK, 0 rows affected (0,01 sec)
详细信息请参阅 MySQL 用户手册中的持久化系统变量。
在此阶段,您可以调整其他选项,这些选项会影响复制的安全性和性能,特别是:
binlog_format
二进制日志格式
GTID 支持
支持全局事务标识符
replica_parallel_type (slave_parallel_type) 和 replica_parallel_workers (slave_parallel_workers)
多线程副本支持
副本上的二进制日志
定义副本是否以及如何使用二进制日志。
我们将在接下来的几个步骤中详细介绍这些选项。
3.2 在新安装环境中基于位置的复制
问题
您想要设置一个刚安装的 MySQL 服务器的副本,使用基于位置的配置。
解决方案
如 Recipe 3.1 中描述的准备源和副本服务器,然后在源服务器上使用 SHOW MASTER STATUS 命令获取当前二进制日志位置,并使用 CHANGE REPLICATION SOURCE ... source_log_file='BINARY LOG FILE NAME', source_log_pos=POSITION; (CHANGE MASTER ... master_log_file='BINARY LOG FILE NAME', master_log_pos=POSITION;) 命令将副本指向适当的位置。
讨论
对于此示例,我们假设您有两台刚安装的服务器,其中没有任何用户数据。任何服务器上都没有写活动。
首先,按照 Recipe 3.1 中的描述准备它们以供复制使用。然后,在源上运行 SHOW MASTER STATUS 命令:
mysql> `SHOW MASTER STATUS;`
+-------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+-------------------+----------+--------------+------------------+-------------------+
| master-bin.000001 | 156 | | | |
+-------------------+----------+--------------+------------------+-------------------+
1 row in set (0.00 sec)
File 字段包含当前二进制日志的名称,而 Position 字段包含当前位置。记录这些字段的值。
在副本上运行 CHANGE REPLICATION SOURCE (CHANGE MASTER) 命令:
mysql> `CHANGE` `REPLICATION` `SOURCE`
-> `TO` `SOURCE_HOST``=``'sourcehost'``,` `-- Host of the source server`
-> `SOURCE_PORT``=``3306``,` `-- Port of the source server`
-> `SOURCE_USER``=``'repl'``,` `-- Replication user`
-> `SOURCE_PASSWORD``=``'replrepl'``,` `-- Password`
-> `SOURCE_LOG_FILE``=``'source-bin.000001'``,` `-- Binary log file`
-> `SOURCE_LOG_POS``=``156``,` `-- Start position`
-> `GET_SOURCE_PUBLIC_KEY``=``1``;`
Query OK, 0 rows affected, 1 warning (0.06 sec)
要启动副本,请使用命令 START REPLICA (START SLAVE):
mysql> `START REPLICA;`
Query OK, 0 rows affected (0.01 sec)
要检查副本是否正在运行,请使用 SHOW REPLICA STATUS (SHOW SLAVE STATUS):
mysql> `\P grep Running`
PAGER set to 'grep Running'
mysql> `SHOW REPLICA STATUS\G`
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Replica_SQL_Running_State: Slave has read all relay log;↩
waiting for more updates
1 row in set (0.00 sec)
上面的列表确认了 IO(连接)和 SQL(应用程序)副本线程都在运行,并且复制状态良好。我们将在 Recipe 3.15 中讨论 SHOW REPLICA STATUS 命令的完整输出。
现在您可以在源服务器上启用写入。
3.3 设置一个基于位置的 MySQL 安装的副本,该安装已在使用中
问题
设置新安装服务器的副本与未来源已经具有数据的情况不同。在后一种情况下,特别注意不要通过指定错误的起始位置引入数据不一致性。在本文中,我们提供了如何设置正在使用的 MySQL 安装的副本的详细说明。
解决方案
按照 Recipe 3.1 中描述的方法准备源服务器和副本服务器,停止源服务器上的所有写操作,进行备份,然后使用 SHOW MASTER STATUS 命令获取当前二进制日志位置,此位置将用于使用 CHANGE REPLICATION SOURCE ... source_log_file='BINARY LOG FILE NAME', source_log_pos=POSITION; 命令将副本指向适当位置。
讨论
与安装新副本的情况类似,两台服务器都需要按照 Recipe 3.1 中描述的方式配置复制使用。在启动设置之前,您需要确保两台服务器都具有唯一的 server_id,并且源服务器已启用二进制日志记录。您可以在此时创建复制用户,或者在设置副本之前执行此操作。
如果您有一个已经运行一段时间的服务器,并希望设置其副本,您需要首先进行备份,然后在副本上恢复,并将副本指向源服务器。此设置的挑战在于使用正确的二进制日志位置:如果服务器在备份运行时接受写入,则位置会一直变化。因此,SHOW MASTER STATUS 命令将返回错误的结果,除非您在进行备份时停止所有写操作。
标准备份工具在备份未来源服务器用于副本时支持特殊选项,以绕过此问题。
mysqldump,在 Recipe 6.6 中描述,具有选项 --source-data (--master-data)。如果设置为 1,在备份开始时将写入 CHANGE REPLICATION SOURCE 语句和坐标到结果转储文件中,并在加载转储时执行。
$ `mysqldump --host=127.0.0.1 --user=root \`
> `--source-data=1 --all-databases > mydump.sql`
$ `grep -b5 "CHANGE REPLICATION SOURCE" -m1 mydump.sql`
906-
907---
910--- Position to start replication or point-in-time recovery from
974---
977-
978:CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='source-bin.000002',↩
SOURCE_LOG_POS=156;
1052-
1053---
1056--- Current Database: `mtr`
1083---
1086-
提示
如果您希望在生成的转储文件中包含复制位置,但不希望自动执行 CHANGE REPLICATION SOURCE 命令,请将选项 --source-data 设置为 2:在这种情况下,该语句将作为注释写入。稍后可以手动执行它。
像 Percona XtraBackup 或 MySQL Enterprise Backup 这样的在线二进制备份工具,在特殊的元数据文件中存储二进制日志坐标。请参考您的备份工具文档,了解如何安全地备份源服务器。
提示
MySQL 有几种备份方式。执行在线备份的工具无需停止 MySQL 服务器。逻辑备份生成一组命令的文件,允许恢复数据。二进制备份复制物理数据库文件。与逻辑备份相比,二进制备份通常要快得多。与逻辑备份相比,二进制备份的恢复速度大大加快。
最简单和最快的二进制备份实用程序是cp,需要停止 MySQL 服务器。在线备份工具允许在服务器运行时复制二进制数据,适合大数据集的首选解决方案。
逻辑备份解决方案,对版本之间的差异更兼容,可以用来恢复数据。在需要迁移小部分数据时,如表格或表格的一部分,它们也非常方便。
一旦有备份,请在副本上恢复它。对于mysqldump,请使用mysql客户端加载转储:
$ `mysql < mydump.sql`
恢复备份后,使用START REPLICA命令启动复制。
3.4 设置基于 GTID 的复制
问题
您想使用全局事务标识符(GTIDs)设置副本。
解决方案
在源和副本配置文件中添加选项gtid_mode=ON和enforce_gtid_consistency=ON,然后使用CHANGE REPLICATION SOURCE ... SOURCE_AUTO_POSITION=1命令将副本指向源服务器。
讨论
基于位置的复制设置很容易,但容易出错。如果您混淆并指定未来的位置会怎样?在这种情况下,一些事务将被跳过。或者,如果您指定过去的位置会发生什么?在这种情况下,同一事务将被应用两次。您最终会得到重复的、丢失的或损坏的行。
为了解决这个问题,引入了全局事务标识符(GTIDs),用于唯一标识服务器上的每个事务。GTID 由两部分组成:首次执行此事务的服务器的唯一 ID 和此服务器上的事务唯一 ID。源服务器 ID 通常是server_uuid全局变量的值,事务 ID 是从 1 开始的数字。
mysql> `SHOW MASTER STATUS\G`
*************************** 1\. row ***************************
File: binlog.000001
Position: 358
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 467ccf91-0341-11eb-a2ae-0242dc638c6c:1
1 row in set (0.00 sec)
mysql> `select @@gtid_executed;`
+----------------------------------------+
| @@gtid_executed |
+----------------------------------------+
| 467ccf91-0341-11eb-a2ae-0242dc638c6c:1 |
+----------------------------------------+
1 row in set (0.00 sec)
服务器执行的事务存储在 GTID 集中,它们的 GTID 在SHOW MASTER STATUS输出中可见,以及gtid_executed变量的值。该集包含原始服务器的唯一 ID 和事务编号范围。
在下面的示例中,467ccf91-0341-11eb-a2ae-0242dc638c6c是源服务器的唯一标识,1-299是在此服务器上执行的事务编号范围。
mysql> `select @@gtid_executed;`
+--------------------------------------------+
| @@gtid_executed |
+--------------------------------------------+
| 467ccf91-0341-11eb-a2ae-0242dc638c6c:1-299 |
+--------------------------------------------+
1 row in set (0.00 sec)
GTID 集可以包含范围、单个事务和以冒号分隔的事务组。具有不同源 ID 的 GTID 由逗号分隔:
mysql> `select @@gtid_executed\G`
*************************** 1\. row ***************************
@@gtid_executed: 000bbf91-0341-11eb-a2ae-0242dc638c6c:1,
467ccf91-0341-11eb-a2ae-0242dc638c6c:1-310:400
1 row in set (0.00 sec)
通常情况下,GTIDs 会自动分配,您不需要关注它们的值。
然而,要使用 GTIDs,您需要为您的服务器添加额外的准备步骤。
启用 GTID 需要两个配置选项:gtid_mode=ON和enforce-gtid-consistency=ON。在启动复制之前,这两个选项必须在两个服务器上启用。
如果您正在设置一个新的副本,源端启用了 GTID,则只需将这些选项添加到配置文件中并重新启动服务器即可。完成后,您可以使用CHANGE REPLICATION SOURCE ... SOURCE_AUTO_POSITION=1命令启用复制并启动它:
mysql> `CHANGE REPLICATION SOURCE TO`
-> `SOURCE_HOST='sourcehost', -- Host of the source server`
-> `SOURCE_PORT=3306, -- Port of the source server`
-> `SOURCE_USER='repl', -- Replication user`
-> `SOURCE_PASSWORD='replrepl', -- Password`
-> `GET_SOURCE_PUBLIC_KEY=1,`
-> `SOURCE_AUTO_POSITION=1;`
Query OK, 0 rows affected, 1 warning (0.06 sec)
mysql> `START REPLICA;`
Query OK, 0 rows affected (0.01 sec)
但是,如果复制已经使用基于位置的设置运行,则需要执行其他步骤:
-
停止所有更新,使两个服务器都变为只读状态:
mysql> `SET GLOBAL super_read_only=1;` Query OK, 0 rows affected (0.01 sec) -
等待副本从源服务器上的所有更新追赶上来:源服务器上SHOW MASTER STATUS输出的
File和Position值应与副本上SHOW REPLICA STATUS中的Relay_Source_Log_File和Exec_Source_Log_Pos值匹配。警告
不要依赖于
Seconds_Behind_Source值,因为它不准确。例如,在源服务器上的以下输出中:
mysql> `SHOW` `MASTER` `STATUS``\``G` *************************** 1. row *************************** File: master-bin.000001 Position: 9614 Binlog_Do_DB: Binlog_Ignore_DB: Executed_Gtid_Set: 1 row in set (0.00 sec)二进制日志位置为 7090。
mysql> `\``P` `grep` `-``E` `"Source_Log_Pos|Seconds_Behind_Source"` PAGER set to 'grep -E "Source_Log_Pos|Seconds_Behind_Source"' mysql> `SHOW` `REPLICA` `STATUS``\``G` Read_Source_Log_Pos: 9614 Exec_Source_Log_Pos: 7308 Seconds_Behind_Source: 0 1 row in set (0.00 sec)在副本上,IO 线程读取的
Read_Source_Log_Pos位置与源服务器上的值相同,而最新执行事件Exec_Source_Log_Pos的位置为 7308:在二进制日志文件中稍早的位置。Seconds_Behind_Source的值为 0 是正常的,因为 MySQL 服务器可以每秒执行数千次更新。但这并不意味着副本完全追赶上了源服务器。 -
一旦副本追赶上,停止两个服务器,启用
gtid_mode=ON和enforce-gtid-consistency=ON选项,然后启动它们并启用复制:mysql> `CHANGE REPLICATION SOURCE TO` -> `SOURCE_HOST='sourcehost', -- Host of the source server` -> `SOURCE_PORT=3306, -- Port of the source server` -> `SOURCE_USER='repl', -- Replication user` -> `SOURCE_PASSWORD='replrepl', -- Password` -> `GET_SOURCE_PUBLIC_KEY=1,` -> `SOURCE_AUTO_POSITION=1;` Query OK, 0 rows affected, 1 warning (0.06 sec) mysql> `START REPLICA;` Query OK, 0 rows affected (0.01 sec)
提示
如果在开始从基于位置到基于 GTID 的复制切换之前,副本已知复制源连接选项,则可以省略它们。
注意
您无需在复制中启用二进制日志记录以使用 GTID。但如果您要在复制外写入副本,则其事务将不具有自己的 GTID 分配。 GTID 仅用于复制事件。
参见
关于如何设置带 GTID 的 MySQL 复制的更多信息,请参见MySQL 用户参考手册。
3.5 配置二进制日志格式
问题
您希望使用最适合您应用程序的二进制日志格式。
解决方案
决定哪种格式最适合您的需求,并使用配置选项binlog_format进行设置。
讨论
默认的 MySQL 二进制日志格式自版本 5.7.7 起为ROW。这是最安全的格式,适合大多数应用程序。它存储由二进制日志事件修改的编码表行。
然而,ROW格式的二进制日志可能会产生比STATEMENT格式更多的磁盘和网络流量。这是因为它将修改后的行存储到二进制日志文件中两次:变更前和变更后。如果一个表有很多列,即使只修改了一列,所有列的值也会被记录两次。
如果希望二进制日志仅存储修改的列和用于唯一标识修改行的列(通常是主键),可以使用配置选项binlog_row_image=minimal。这在源服务器和其复制件的表完全相同的情况下可以完美工作,但如果列数、数据类型或主键定义不匹配可能会导致问题。
要存储完整行,除非语句未更改TEXT或BLOB列并且不需要唯一标识修改行,请使用选项binlog_row_image=noblob。
如果行格式仍然产生过多流量,可以将其切换到STATEMENT。在这种情况下,修改行的语句将被记录,然后由复制件执行。要使用二进制日志格式STATEMENT,请设置选项binlog_format=STATEMENT。
不推荐使用STATEMENT格式是因为某些语句可能会在不同的服务器上产生不同的更新,即使数据最初是相同的。这些语句被称为非确定性的。为了解决这个缺点,MySQL 有一个特殊的二进制日志格式:MIXED,它通常以STATEMENT格式记录事件,并在语句是非确定性的时候自动切换到ROW格式。
警告
如果在复制件上启用了二进制日志,它应该使用与源服务器相同的二进制日志格式或者MIXED,除非你使用选项log_replica_updates=OFF(log_slave_updates=OFF)禁用了复制事件的二进制日志记录。这是必须的,因为复制件不会转换二进制日志格式,而只是将接收到的事件复制到自己的二进制日志文件中。如果格式不匹配,复制过程将因错误而停止。
可以动态地在全局或会话级别更改二进制日志格式。要在全局级别更改格式,请运行:
mysql> `set global binlog_format='statement';`
Query OK, 0 rows affected (0,00 sec)
要在全局级别更改格式并永久存储,请使用:
mysql> `set persist binlog_format='row';`
Query OK, 0 rows affected (0,00 sec)
请注意,这不会更改现有连接的二进制日志记录格式。要在会话级别更改格式,请执行:
mysql> `set session binlog_format='mixed';`
Query OK, 0 rows affected (0,00 sec)
尽管STATEMENT格式通常产生的流量比ROW少,但并非总是如此。例如,具有长WHERE或IN子句的复杂语句,仅修改少数行,使用STATEMENT格式会生成更大的二进制日志事件。
另一个使用STATEMENT格式的问题是,复制件会像源服务器上运行的方式一样执行接收到的事件。因此,如果一个语句在原地无效,复制件上也会运行缓慢。例如,对于那些含有WHERE子句并且无法使用索引解析的大型表,通常会很慢。在这种情况下,切换到ROW格式可能会提高性能。
警告
通常,ROW事件使用主键在副本上查找需要更新的行。如果表没有主键,ROW格式可能工作非常缓慢。旧版的 MySQL 甚至可能由于已修复的错误而更新错误的行。InnoDB 存储引擎使用的自动生成的主键在这里无济于事,因为它可能会为源服务器和副本服务器上的同一行生成不同的值。因此,在使用二进制日志格式ROW时,定义表的主键是强制性的。
3.6 使用复制过滤器
问题
您希望仅复制特定数据库或表的事件。
解决方案
在源、副本或两者上使用复制过滤器。
讨论
MySQL 可以过滤特定数据库或表的更新。您可以在源服务器上设置这些过滤器,以防止它们记录在二进制日志中,或者在副本服务器上,以便复制不会执行它们。
在源服务器上进行过滤
警告
如果错误设置,复制过滤器可能导致数据丢失。非常仔细地研究这个配方,并且在将其部署到生产环境之前,始终测试它们在您的设置中的工作方式。
要仅记录对特定数据库的更新,请使用配置选项binlog-do-db=db_name。对于此选项,没有相应的变量,因此更改二进制日志过滤器需要重新启动。要记录对两个或更多特定数据库的更新,请多次指定binlog-do-db选项:
[mysqld]
binlog-do-db=cookbook
binlog-do-db=test
ROW和STATEMENT二进制日志格式的二进制日志过滤器行为不同。对于基于语句的日志记录,只考虑默认数据库。如果您使用全限定表名,例如mydatabase.mytable,它们将基于默认数据库值而不是更新的数据库部分进行记录。
因此,对于上述配置文件片段,以下三个更新将在二进制日志中记录:
-
$ `mysql cookbook` mysql> `INSERT INTO limbs (thing, legs, arms) VALUES('horse', 4, 0);` Query OK, 1 row affected (0,01 sec) -
mysql> `USE cookbook` Database changed mysql> `DELETE FROM limbs WHERE thing='horse';` Query OK, 1 row affected (0,00 sec) -
mysql> `USE cookbook` Database changed mysql> `INSERT INTO donotlog.onlylocal (mysecret) -> values('I do not want to replicate it!');` Query OK, 1 row affected (0,01 sec)
然而,对于食谱数据库的此更新将不会被记录:
mysql> `use donotlog`
Database changed
mysql> `UPDATE cookbook.limbs set arms=8 WHERE thing='squid';`
Query OK, 1 row affected (0,01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
当使用二进制日志格式ROW时,将忽略默认数据库以获取完全合格的表名。因此,所有这些更新都将被记录:
$ `mysql cookbook`
mysql> `INSERT INTO limbs (thing, legs, arms) VALUES('horse', 4, 0);`
Query OK, 1 row affected (0,01 sec)
mysql> `USE cookbook`
Database changed
mysql> `DELETE FROM limbs WHERE thing='horse';`
Query OK, 1 row affected (0,00 sec)
mysql> `USE donotlog`
Database changed
mysql> `UPDATE cookbook.limbs SET arms=10 WHERE thing='squid';`
Query OK, 1 row affected (0,01 sec)
Rows matched: 1 Changed: 1 Warnings: 0
然而,此语句将不会被记录:
mysql> `USE cookbook`
Database changed
mysql> `INSERT INTO donotlog.onlylocal (mysecret)`
-> `VALUES('I do not want to replicate it!');`
Query OK, 1 row affected (0,01 sec)
对于多表更新,只有属于过滤器指定数据库的表的更新才会被记录。在以下示例中,仅记录对表cookbook.limbs的更新:
mysql> `use donotlog`
Database changed
mysql> `UPDATE cookbook.limbs, donotlog.onlylocal SET arms=1,`
-> `mysecret='I do not want to log it!';`
Query OK, 12 rows affected (0,01 sec)
Rows matched: 12 Changed: 12 Warnings: 0
mysql> `USE cookbook`
Database changed
mysql> `UPDATE cookbook.limbs, donotlog.onlylocal SET arms=0,`
-> `mysecret='I do not want to log and replicate this!'`
-> `WHERE cookbook.limbs.thing='table';`
Query OK, 2 rows affected (0,00 sec)
Rows matched: 2 Changed: 2 Warnings: 0
警告
DDL 语句,例如ALTER TABLE,始终以STATEMENT格式复制。因此,不管binlog_format变量的值如何,这种格式的过滤规则都适用于它们。
如果您希望记录服务器上所有数据库的更新,并仅跳过其中的一些,请使用binlog-ignore-db过滤器。多次指定以忽略多个数据库。
[mysqld]
binlog-ignore-db=donotlog
binlog-ignore-db=mysql
binlog-ignore-db过滤器与binlog-do-db过滤器类似。在STATEMENT二进制日志记录的情况下,它们遵循默认数据库,并在使用ROW二进制日志格式时忽略它。如果未指定默认数据库并且使用STATEMENT二进制日志格式,将记录所有更新。
如果使用二进制日志格式MIXED,则根据更新存储在STATEMENT或ROW格式中应用过滤规则。
要查找当前正在使用的二进制日志过滤器,请运行SHOW MASTER STATUS命令:
mysql> `SHOW MASTER STATUS\G`
*************************** 1\. row ***************************
File: binlog.000008
Position: 1202
Binlog_Do_DB: cookbook,test
Binlog_Ignore_DB: donotlog,mysql
Executed_Gtid_Set:
1 row in set (0,00 sec)
警告
二进制日志文件不仅用于复制,还用于故障时的时间点恢复(PITR)。在这种情况下,过滤后的更新无法恢复,因为它们没有存储在任何地方。如果要将二进制日志用于 PITR 并且仍要过滤某些数据库:请在源服务器上记录所有内容,并在副本上进行过滤。
在副本上的过滤器
副本有更多选项来过滤事件。您可以过滤特定数据库或表。您还可以使用通配符。
数据库级别的过滤与源服务器上的过滤相同。由选项replicate-do-db和replicate-ignore-db控制。如果要过滤多个数据库,请多次指定这些选项。
要过滤特定表,请使用选项replicate-do-table和replicate-ignore-table。它们将完全限定的表名作为参数。
[mysqld]
replicate-do-db=cookbook
replicate-ignore-db=donotlog
replicate-do-table=donotlog.dataforeveryone
replicate-ignore-table=cookbook.limbs
但复制过滤的最灵活和安全的语法是replicate-wild-do-table和replicate-wild-ignore-table。顾名思义,它们在参数中接受通配符。通配符语法与LIKE子句中使用的相同。有关LIKE子句语法的详细信息,请参阅 Recipe 7.10。
符号_替换正好一个字符。因此,replicate-wild-ignore-table=cookbook.standings_过滤表cookbook.standings1和cookbook.standings2,但不过滤cookbook.standings12和cookbook.standings。
符号%替换零个或多个字符。因此,replicate-wild-do-table=cookbook.movies%指示副本将更新应用于表cookbook.movies、cookbook.movies_actors和cookbook.movies_actors_link。
如果表名本身包含您不希望替换的通配符字符,则需要对其进行转义。因此,选项replicate-wild-ignore-table=cookbook.trip_l_g将过滤表cookbook.trip_leg、cookbook.trip_log,但也会过滤cookbook.tripslag。而replicate-wild-ignore-table=cookbook.trip\_l_g只会过滤更新到表cookbook.trip_leg和cookbook.trip_log。注意,如果在命令行上指定此选项,可能需要根据使用的SHELL版本对通配符字符进行双重转义。
提示
表级过滤器与二进制日志格式无关,与默认数据库无关。因此使用它们更安全。如果要在特定数据库或多个数据库中过滤所有表,请使用通配符:
[mysqld]
replicate-wild-do-table=cookbook.%
replicate-wild-ignore-table=donotlog.%
但是,与数据库过滤器不同,replicate-wild-do-table和replicate-wild-ignore-table无法过滤存储过程或事件。如果您需要过滤它们,必须使用数据库级别的过滤器。
可以为特定的复制通道设置复制过滤器(Recipe 3.10)。要指定每个通道的过滤器前缀数据库、表名或通配符表达式,后跟冒号:
[mysqld]
replicate-do-db=first:cookbook
replicate-ignore-db=second:donotlog
replicate-do-table=first:donotlog.dataforeveryone
replicate-ignore-table=second:cookbook.hitlog
replicate-wild-do-table=first:cookbook.movies%
replicate-wild-ignore-table=second:cookbook.movies%
您可以通过CHANGE REPLICATION FILTER命令指定复制过滤器,不仅可以通过配置选项。
mysql> `CHANGE REPLICATION FILTER`
-> `REPLICATE_DO_DB = (cookbook),`
-> `REPLICATE_IGNORE_DB = (donotlog),`
-> `REPLICATE_DO_TABLE = (donotlog.dataforeveryone),`
-> `REPLICATE_IGNORE_TABLE = (cookbook.limbs),`
-> `REPLICATE_WILD_DO_TABLE = ('cookbook.%'),`
-> `REPLICATE_WILD_IGNORE_TABLE = ('cookbook.trip\_l_g');`
Query OK, 0 rows affected (0.00 sec)
提示
每次更改复制参数时,您需要使用STOP REPLICA(STOP SLAVE)命令停止复制。
要查看当前应用的复制过滤器,请使用SHOW REPLICA STATUS\G命令或查询 Performance Schema 中的replication_applier_filters和replication_applier_global_filters表。
mysql> `SHOW REPLICA STATUS\G`
*************************** 1\. row ***************************
Replica_IO_State:
Source_Host: 127.0.0.1
Source_User: root
Source_Port: 13000
Connect_Retry: 60
Source_Log_File: binlog.000001
Read_Source_Log_Pos: 156
Relay_Log_File: Delly-7390-relay-bin.000002
Relay_Log_Pos: 365
Relay_Source_Log_File: binlog.000001
Replica_IO_Running: No
Replica_SQL_Running: No
Replicate_Do_DB: cookbook
Replicate_Ignore_DB: donotlog
Replicate_Do_Table: donotlog.dataforeveryone
Replicate_Ignore_Table: cookbook.limbs
Replicate_Wild_Do_Table: cookbook.%
Replicate_Wild_Ignore_Table: cookbook.trip\_l_g
...
mysql> `SELECT * FROM performance_schema.replication_applier_filters\G`
*************************** 1\. row ***************************
CHANNEL_NAME:
FILTER_NAME: REPLICATE_DO_DB
FILTER_RULE: cookbook
CONFIGURED_BY: CHANGE_REPLICATION_FILTER
ACTIVE_SINCE: 2020-10-04 13:43:21.183768
COUNTER: 0
*************************** 2\. row ***************************
CHANNEL_NAME:
FILTER_NAME: REPLICATE_IGNORE_DB
FILTER_RULE: donotlog
CONFIGURED_BY: CHANGE_REPLICATION_FILTER
ACTIVE_SINCE: 2020-10-04 13:43:21.183768
COUNTER: 0
*************************** 3\. row ***************************
CHANNEL_NAME:
FILTER_NAME: REPLICATE_DO_TABLE
FILTER_RULE: donotlog.dataforeveryone
CONFIGURED_BY: CHANGE_REPLICATION_FILTER
ACTIVE_SINCE: 2020-10-04 13:43:21.183768
COUNTER: 0
*************************** 4\. row ***************************
CHANNEL_NAME:
FILTER_NAME: REPLICATE_IGNORE_TABLE
FILTER_RULE: cookbook.limbs
CONFIGURED_BY: CHANGE_REPLICATION_FILTER
ACTIVE_SINCE: 2020-10-04 13:43:21.183768
COUNTER: 0
*************************** 5\. row ***************************
CHANNEL_NAME:
FILTER_NAME: REPLICATE_WILD_DO_TABLE
FILTER_RULE: cookbook.%
CONFIGURED_BY: CHANGE_REPLICATION_FILTER
ACTIVE_SINCE: 2020-10-04 13:43:21.183768
COUNTER: 0
*************************** 6\. row ***************************
CHANNEL_NAME:
FILTER_NAME: REPLICATE_WILD_IGNORE_TABLE
FILTER_RULE: cookbook.trip\_l_g
CONFIGURED_BY: CHANGE_REPLICATION_FILTER
ACTIVE_SINCE: 2020-10-04 13:43:21.183768
COUNTER: 0
6 rows in set (0.00 sec)
参见
有关复制过滤器的更多信息,请参阅服务器如何评估复制过滤规则。
3.7 在副本上重写数据库
问题
您希望将数据复制到副本上的数据库,该数据库与源服务器上使用的数据库名称不同。
解决方案
在副本服务器上使用replicate-rewrite-db选项。
讨论
MySQL 允许在使用复制过滤器replicate-rewrite-db时动态重写数据库名称。
您可以在配置文件、命令行中设置这样的过滤器。
[mysqld]
replicate-rewrite-db=cookbook->recipes
或通过CHANGE REPLICATION FILTER命令:
mysql> `CHANGE REPLICATION FILTER`
-> `REPLICATE_REWRITE_DB=((cookbook,recipes));`
或者,对于多通道副本:
[mysqld]
replicate-rewrite-db=channel_id:cookbook->recipes
或通过CHANGE REPLICATION FILTER命令:
mysql> `CHANGE REPLICATION FILTER`
-> `REPLICATE_REWRITE_DB=((cookbook,recipes))`
-> `FOR CHANNEL 'channel_id';`
警告
为过滤器值使用双括号和频道名称使用引号。
MySQL 不支持RENAME DATABASE操作。因此,要重命名数据库,您需要首先创建一个具有不同名称的数据库,然后将原始数据库的数据恢复到这个新数据库中。
mysql> `CREATE DATABASE recipes;`
$ `mysql recipes < cookbook.sql`
您需要使用mysqldump命令对单个数据库进行导出。如果您使用了--databases选项,则还需指定--no-create-db选项,以便生成的文件不包含CREATE DATABASE语句。
3.8 使用多线程副本
问题
副本安装在比源更好的硬件上,服务器之间的网络连接良好,但复制延迟正在增加。
解决方案
使用多个复制应用线程。
讨论
MySQL 服务器是多线程的。它以高度并发的方式应用传入的更新。默认情况下,它在处理应用程序请求时使用所有硬件 CPU 核心。但是,默认情况下,副本服务器仅使用单个线程来应用来自源服务器的传入事件。因此,它使用更少的资源来处理复制事件,甚至在良好的硬件上可能会延迟。
要解决这个问题,请使用多个应用线程。为此,将变量replica_parallel_workers设置为大于 1 的值。这指定副本用于应用事件的并行线程数。将此变量的值设置为虚拟 CPU 核心数量是有意义的。变量没有直接影响:您必须重新启动复制以应用更改。
mysql> `SET GLOBAL replica_parallel_workers=8;`
Query OK, 0 rows affected (0.01 sec)
mysql> `STOP REPLICA SQL_THREAD;`
Query OK, 0 rows affected (0.01 sec)
mysql> `START REPLICA;`
Query OK, 0 rows affected (0.04 sec)
并非所有复制事件都可以并行应用。如果二进制日志包含两个更新同一行的语句,会怎样呢?
update limbs set arms=8 where thing='squid';
update limbs set arms=10 where thing='squid';
根据事件顺序,乌贼的桌面肢体可能会有八只或十只胳膊。如果这两个语句在源端和副本上以不同顺序执行,则最终数据将不同。
MySQL 使用特殊算法之一进行依赖跟踪。当前算法由副本上的变量replica_parallel_type和源端的binlog_transaction_dependency_tracking设置。
在 8.0.27 版本之前,变量replica_parallel_type的默认值是DATABASE,而自此版本起是LOGICAL_CLOCK。使用此值时,可以并行应用属于不同数据库的更新,而对同一数据库的更新则顺序应用。此值与源端的binlog_transaction_dependency_tracking没有相关性。
数据库级别的并行化对于更新数据库数量少于副本 CPU 核心数量的设置并不会有更好的表现。为了解决这个问题,引入了replica_parallel_type=LOGICAL_CLOCK。对于这种类型,属于同一二进制日志组提交的事务会在源端并行应用。
更改变量replica_parallel_type后,需要重新启动副本。
源服务器上变量binlog_transaction_dependency_tracking的值定义了哪些事务属于同一提交组。默认值是COMMIT_ORDER,由源端的时间戳生成。使用此值时,源服务器上几乎同时提交的事务将在副本上并行执行。如果源服务器不经常提交,则可能发生副本将在实际上可以并行执行的那些事务(尽管它们在源端被提交的时间不同)顺序执行的情况。
要解决这个问题,引入了binlog_transaction_dependency_tracking模式WRITESET和WRITESET_SESSION。在这些模式下,MySQL 使用由变量transaction_write_set_extraction指定的哈希算法来决定事务是否相互依赖,可以是XXHASH64(默认)或MURMUR32。这意味着如果事务修改了一组行,而这些行彼此独立,那么它们可以并行执行,无论它们之间的提交时间有多长。
使用binlog_transaction_dependency_tracking模式设置为WRITESET,即使最初在同一会话中执行的事务也可以并行应用。这可能会在某些时间段内导致副本看到不同顺序的更改。根据您的应用需求,这可能是可以接受的或不可接受的。为避免这种情况,您可以启用选项replica_preserve_commit_order(slave_preserve_commit_order),指示副本按照在源服务器上最初执行它们的顺序应用二进制日志事件。另一种解决方案是将binlog_transaction_dependency_tracking设置为WRITESET_SESSION。此模式确保来自同一会话的事务永不并行应用。
变量binlog_transaction_dependency_tracking是动态的,可以在不停止服务器的情况下进行修改。您还可以仅对特定会话设置它。
另请参阅
关于多线程副本的更多信息,请参见通过基于写集的依赖跟踪改进并行应用程序。
设置环形复制
问题
您想要设置一系列相互复制的服务器。
解决方案
使链中的每个服务器既是其对等体的源,又是其副本。
讨论
有时您可能需要向多个 MySQL 服务器写入并希望每个服务器上的更新都可见。通过 MySQL 复制,这是可能的。它支持诸如两个服务器、一系列服务器(A -> B -> C -> D -> ...)、环形、星形等流行设置,以及您可以想象的任何创意设置。对于环形复制的示例,您只需将每个服务器设置为彼此的源和副本即可。
当使用这种复制时,您需要非常小心。因为更新来自任何服务器,它们可能会互相冲突。
想象两个节点同时插入id=42的一行。首先,每个节点都插入一行,然后从二进制日志接收到完全相同的事件。复制将因重复键错误而停止。
如果然后您尝试在两个节点上删除id=42的行,则会再次收到错误!因为在接收到删除语句时,复制通道中的行已经被删除。
但如果您使用相同的ID更新一行,则可能会发生最糟糕的情况。想象一下,如果node1将值设置为42,而node2将值设置为25。在应用复制事件后,node1将具有值为25的行,而node2将具有值为42的行。这与它们在本地更新后最初拥有的值不同!
依然可以有非常合理的理由使用循环复制。例如,您可能希望将一个节点主要用于一个应用程序,另一个节点用于另一个应用程序。您可以选择适合两者的选项和硬件。或者您可能在不同的地理位置(例如国家)有服务器,并希望将本地数据存储在离用户更近的地方。或者您可以将服务器主要用于读取,但仍然需要更新它们。最后,您可能设置一个热备用服务器,技术上允许写入,但实际上只有在主源服务器宕机时才会接收写入。
在这个方案中,我们将讨论如何设置三个服务器的链条。您可以修改此方案以适用于两个或更多服务器。然后我们将讨论使用复制链条所需的安全考虑事项。
设置三个服务器的循环复制
准备用于循环复制的服务器
-
遵循 Recipe 3.1 中的说明为源服务器
-
确保选项
log_replica_updates已启用。否则,如果您的复制链包括两个以上的服务器,则更新仅应用于相邻的服务器。 -
确保选项
replicate-same-server-id已禁用。否则,可能会出现同一更新循环应用的情况。
将节点互相指向
在每个服务器上运行CHANGE REPLICATION SOURCE命令,如 Recipe 3.2 或 Recipe 3.4 中描述的那样。指定正确的连接值。例如,如果您想要一个服务器圈 hostA -> hostB -> hostC -> hostA,您需要将 hostB 指向 hostA,hostA 指向 hostC,hostC 指向 hostB:
hostA> `CHANGE REPLICATION SOURCE TO SOURCE_HOST='hostC', ...`
hostB> `CHANGE REPLICATION SOURCE TO SOURCE_HOST='hostA', ...`
hostC> `CHANGE REPLICATION SOURCE TO SOURCE_HOST='hostB', ...`
启动复制
使用START REPLICA命令启动复制。
使用复制链条时的安全考虑事项
当向多个相互复制的服务器写入时,需要在逻辑上分离要写入的对象。您可以在不同的级别上进行。
业务逻辑
在应用程序级别确保您不会同时在多个服务器上更新同一行。
服务器
一次只向一个服务器写入。这是创建热备用服务器的好解决方案。
数据库和表格
在您的应用程序中:将特定的表集分配给每个服务器。例如,在 nodeA 上仅写入表 movies、movies_actors、movies_actors_link;在 nodeB 上写入表 trip_leg 和 trip_log;在 nodeC 上写入表 weatherdata 和 weekday。
行
如果仍然需要在所有服务器上写入同一表,请单独为每个节点分开行。如果您使用带有 AUTO_INCREMENT 选项的整数主键,则可以通过将 auto_increment_increment 设置为服务器数量,并将 auto_increment_offset 设置为链中的服务器编号(从 1 开始)来完成此操作。例如,在我们的三服务器设置中,将 auto_increment_increment 设置为 3,在 nodeA 上将 auto_increment_offset 设置为 1,在 nodeB 上设置为 2,在 nodeC 上设置为 3。我们在 食谱 15.14 中讨论了如何调整 auto_increment_increment 和 auto_increment_offset。
如果不使用 AUTO_INCREMENT,则需要在应用程序级别创建规则,使标识符在每个节点上遵循其自己的唯一模式。
3.10 使用多源复制
问题
您希望副本能够应用来自两个或多个相互独立的源服务器的事件。
解决方案
通过运行命令 CHANGE REPLICATION SOURCE ... FOR CHANNEL ‘my source’; 来创建多个复制通道,针对每个源服务器。
讨论
您可能希望从多个服务器复制到一个服务器。例如,如果单独的源服务器由不同的应用程序更新,并且您希望使用复制进行备份或分析。要实现这一点,您需要使用多源复制。
准备服务器进行复制
按照 食谱 3.1 中描述的准备源和复制服务器。对于复制服务器添加额外步骤:配置 master_info_repository 和 relay_log_info_repository 以使用表:
mysql> `SET PERSIST master_info_repository = 'TABLE';`
mysql> `SET PERSIST relay_log_info_repository = 'TABLE';`
备份源服务器上的数据
创建完整备份或仅备份您希望复制的数据库。例如,如果您想从一个服务器复制数据库 cookbook,从另一个服务器复制数据库 production,则仅备份这些数据库。
如果要使用基于位置的复制,请使用 mysqldump 并使用选项 --source-data=2,该选项指示工具记录 CHANGE REPLICATION SOURCE 命令,但将其注释掉。
$ `mysqldump --host=source_cookbook --single-transaction --triggers --routines \`
> `--source-data=2 --databases cookbook > cookbook.sql`
对于基于 GTID 的复制,使用选项 --set-gtid-purged=COMMENTED。
$ `mysqldump --host=source_production --single-transaction --triggers --routines \`
> `--set-gtid-purged=COMMENTED --databases production > production.sql`
提示
您可以为不同通道使用基于位置和基于 GTID 的复制。您还可以在源服务器上使用不同的二进制日志格式,但在这种情况下,您需要将复制服务器上的二进制日志格式设置为 MIXED,以便能够以任何格式存储更新。
恢复副本上的数据
恢复从源服务器收集的数据。
$ `mysql < cookbook.sql`
$ `mysql < production.sql`
警告
确保源服务器上的数据没有相同名称的数据库。如果有,则需要重命名其中一个数据库,并使用 replicate-rewrite-db 过滤器,在应用复制事件时重新写入数据库名称。详细信息请参见 食谱 3.7。
配置复制通道
对于位基复制定位在转储文件 CHANGE REPLICATION SOURCE 命令:
$ `cat cookbook.sql | grep "CHANGE REPLICATION SOURCE"`
-- CHANGE REPLICATION SOURCE TO SOURCE_LOG_FILE='binlog.000008', ↩
SOURCE_LOG_POS=2603;
并使用结果坐标设置复制。使用CHANGE REPLICATION SOURCE命令的FOR CHANNEL子句指定要使用的通道。
mysql> `CHANGE REPLICATION SOURCE TO`
-> `SOURCE_HOST='source_cookbook',`
-> `SOURCE_LOG_FILE='binlog.000008',`
-> `SOURCE_LOG_POS=2603`
-> `FOR CHANNEL 'cookbook_channel';`
要使用基于 GTID 的复制,请首先找到SET @@GLOBAL.GTID_PURGED语句:
$ `grep GTID_PURGED production.sql`
/* SET @@GLOBAL.GTID_PURGED='+9113f6b1-0751-11eb-9e7d-0242dc638c6c:1-385';*/
对所有使用基于 GTID 的复制的通道执行此操作:
$ `grep GTID_PURGED recipes.sql`
/* SET @@GLOBAL.GTID_PURGED='+910c760a-0751-11eb-9da8-0242dc638c6c:1-385';*/
然后将它们组合成单个集合:'9113f6b1-0751-11eb-9e7d-0242dc638c6c:1-385,910c760a-0751-11eb-9da8-0242dc638c6c:1-385',运行RESET MASTER来重置 GTID 执行历史记录,并将GTID_PURGED设置为您刚刚编译的集合:
mysql> `RESET MASTER;`
Query OK, 0 rows affected (0,03 sec)
mysql> `SET @@GLOBAL.gtid_purged = '9113f6b1-0751-11eb-9e7d-0242dc638c6c:1-385,`
'> `910c760a-0751-11eb-9da8-0242dc638c6c:1-385';`
Query OK, 0 rows affected (0,00 sec)
然后使用CHANGE REPLICATION SOURCE命令设置新通道:
mysql> `CHANGE REPLICATION SOURCE TO`
-> `SOURCE_HOST='source_production',`
-> `SOURCE_AUTO_POSITION=1`
-> `FOR CHANNEL 'production_channel';`
启动复制
使用START REPLICA命令启动复制:
mysql> `START REPLICA FOR CHANNEL'cookbook_channel';`
Query OK, 0 rows affected (0,00 sec)
mysql> `START REPLICA FOR CHANNEL 'production_channel';`
Query OK, 0 rows affected (0,00 sec)
确认复制正在运行
运行SHOW REPLICA STATUS并检查所有通道的记录:
mysql> `SHOW REPLICA STATUS\G`
...
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
...
Channel_Name: cookbook_channel
Source_TLS_Version:
Source_public_key_path:
Get_source_public_key: 0
Network_Namespace:
*************************** 2\. row ***************************
...
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
...
Channel_Name: production_channel
Source_TLS_Version:
Source_public_key_path:
Get_source_public_key: 0
Network_Namespace:
2 rows in set (0.00 sec)
或查询性能模式:
mysql> `SELECT CHANNEL_NAME, io.SERVICE_STATE as io_status,`
-> `sqlt.SERVICE_STATE as sql_status,`
-> `COUNT_RECEIVED_HEARTBEATS, RECEIVED_TRANSACTION_SET`
-> `FROM performance_schema.replication_connection_status AS io`
-> `JOIN performance_schema.replication_applier_status AS sqlt USING(channel_name)\G`
*************************** 1\. row ***************************
CHANNEL_NAME: cookbook_channel
io_status: ON
sql_status: ON
COUNT_RECEIVED_HEARTBEATS: 11
RECEIVED_TRANSACTION_SET: 9113f6b1-0751-11eb-9e7d-0242dc638c6c:1-387
*************************** 2\. row ***************************
CHANNEL_NAME: production_channel
io_status: ON
sql_status: ON
COUNT_RECEIVED_HEARTBEATS: 11
RECEIVED_TRANSACTION_SET: 910c760a-0751-11eb-9da8-0242dc638c6c:1-385
2 rows in set (0.00 sec)
3.11 使用半同步复制插件
问题
在COMMIT操作完成之前,确保至少有一个副本已更新。
解决方案
使用半同步复制插件。
讨论
MySQL 复制是异步的。这意味着源服务器可以非常快速地接受写入。它只需要将数据存储在表中,并将更改信息写入二进制日志文件。但是,它不知道副本是否接收到任何更新,如果接收到,是否已应用这些更新。
我们无法保证异步副本是否应用更新,但我们可以设置确保更新已接收并存储在中继日志文件中,以防灾难恢复。这并不保证更新将被应用,或者如果应用了更新,结果与源服务器上的值相同,但保证至少有两台服务器记录了可以应用的更新。为此,请使用半同步复制插件。
半同步复制插件应安装在源服务器和副本服务器上。
在源服务器上运行:
mysql> `INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';`
Query OK, 0 rows affected (0.03 sec)
在副本上运行:
mysql> `INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so';`
Query OK, 0 rows affected (0.00 sec)
安装后,您可以启用半同步复制。在源端将全局变量rpl_semi_sync_source_enabled设置为 1。在副本上使用变量rpl_semi_sync_replica_enabled。
警告
半同步复制仅与默认复制通道兼容。您不能与多源复制一起使用。
您可以通过变量来控制半同步复制的行为,如表 3-1 所示:
表 3-1. 控制半同步复制插件行为的变量
| 变量 | 控制什么 | 默认值 |
|---|---|---|
rpl_semi_sync_source_timeout |
等待来自副本响应的毫秒数。如果超过此值,复制将静默转换为异步。 | 10000 |
rpl_semi_sync_source_wait_for_replica_count |
在提交事务之前,源服务器需要接收确认的副本数量。 | 1 |
rpl_semi_sync_source_wait_no_replica |
如果连接的副本数低于 rpl_semi_sync_source_wait_for_replica_count,会发生什么情况。只要这些服务器稍后重新连接并确认事务,半同步仍然可用。如果此变量为 OFF,则一旦副本数量低于 rpl_semi_sync_source_wait_for_replica_count,则复制转换为异步。 |
ON |
rpl_semi_sync_source_wait_point |
期望从副本接收到事务的确认的时机。该变量支持两种可能的值。在 AFTER_SYNC 的情况下,源服务器将每个事务写入二进制日志,然后将其同步到磁盘。源服务器等待副本关于已接收更改的确认,然后提交事务。在 AFTER_COMMIT 的情况下,源服务器提交事务,然后等待副本的确认,成功后返回给客户端。 |
AFTER_SYNC |
要查看半同步复制的状态,请使用变量 Rpl_semi_sync_*。源服务器上有很多这样的变量。
mysql> `SHOW GLOBAL STATUS LIKE 'Rpl_semi_sync%';`
+--------------------------------------------+-------+
| Variable_name | Value |
+--------------------------------------------+-------+
| Rpl_semi_sync_source_clients | 1 |
| Rpl_semi_sync_source_net_avg_wait_time | 0 |
| Rpl_semi_sync_source_net_wait_time | 0 |
| Rpl_semi_sync_source_net_waits | 9 |
| Rpl_semi_sync_source_no_times | 3 |
| Rpl_semi_sync_source_no_tx | 6 |
| Rpl_semi_sync_source_status | ON |
| Rpl_semi_sync_source_timefunc_failures | 0 |
| Rpl_semi_sync_source_tx_avg_wait_time | 1021 |
| Rpl_semi_sync_source_tx_wait_time | 4087 |
| Rpl_semi_sync_source_tx_waits | 4 |
| Rpl_semi_sync_source_wait_pos_backtraverse | 0 |
| Rpl_semi_sync_source_wait_sessions | 0 |
| Rpl_semi_sync_source_yes_tx | 4 |
+--------------------------------------------+-------+
14 rows in set (0.00 sec)
最重要的是 Rpl_semi_sync_source_clients,它显示半同步是否当前正在使用以及连接了多少半同步副本。如果 Rpl_semi_sync_source_clients 为零,则没有半同步副本连接,使用异步复制。
在副本服务器上,只有变量 Rpl_semi_sync_replica_status (Rpl_semi_sync_slave_status) 可用,并且可能的值为 ON 或 OFF。
警告
如果在 rpl_semi_sync_source_timeout 毫秒内没有副本接受写入,则复制将切换到异步,对客户端没有任何消息或警告。唯一确定复制模式切换为异步的方法是检查变量 Rpl_semi_sync_source_clients 的值或者检查错误日志文件以查找如下消息:
2020-10-12T22:25:17.654563Z 0 [ERROR] [MY-013129] [Server] ↩
A message intended for a client cannot be sent there as ↩
no client-session is attached. Therefore, ↩
we're sending the information to the error-log instead: ↩
MY-001158 - Got an error reading communication packets
2020-10-12T22:25:20.083796Z 198 [Note] [MY-010014] [Repl] ↩
While initializing dump thread for slave with UUID ↩
<09bf4498-0cd2-11eb-9161-98af65266957>, ↩
found a zombie dump thread with the same UUID. ↩
Master is killing the zombie dump thread(180).
2020-10-12T22:25:20.084088Z 180 [Note] [MY-011171] [Server] ↩
Stop semi-sync binlog_dump to slave (server_id: 2).
2020-10-12T22:25:20.084204Z 198 [Note] [MY-010462] [Repl] ↩
Start binlog_dump to master_thread_id(198) slave_server(2), ↩
pos(, 4)
2020-10-12T22:25:20.084248Z 198 [Note] [MY-011170] [Server] ↩
Start asynchronous binlog_dump to slave (server_id: 2), pos(, 4).
2020-10-12T22:25:20.657800Z 180 [Note] [MY-011155] [Server] ↩
Semi-sync replication switched OFF.
我们在 Recipe 23.2 讨论错误日志文件。
3.12 使用组复制
问题
您希望将更新应用到所有节点或者不应用。
解决方案
使用组复制。
讨论
从版本 5.7.17 开始,MySQL 通过 Group Replication 插件完全支持同步复制。如果使用了该插件,MySQL 服务器(称为节点)将创建一个组,共同提交或者如果其中一个成员未能应用事务,则回滚。这样,更新要么复制到所有组成员,要么不复制。确保了高可用性。
组内最多可以有九台服务器。超过九台不受支持。对于这种限制有一个非常好的理由:更多的服务器意味着更高的复制延迟。在同步复制的情况下,所有更新在事务完成之前都会应用到所有节点上。每个更新都会传输到每个节点,等待应用,然后才提交。因此,复制延迟取决于最慢节点的速度和网络传输速率。
尽管在组复制设置中技术上可以少于三个服务器,但更少的数量无法提供适当的高可用性。这是因为Paxos算法,由Group Communication Engine使用,需要2F + 1个节点来创建一个仲裁,其中F是任意自然数。换句话说,在灾难发生时,活动节点的数量应大于断开连接的节点数量。
组复制有限制。首先,最重要的是,它仅支持存储引擎 InnoDB。在启用插件之前,您需要禁用其他存储引擎。每个复制的表必须有主键。您应该将服务器放置在本地网络中。虽然跨互联网进行组复制是可能的,但由于网络超时,这可能导致应用事务和将节点从组中断开连接需要更长的时间。语句LOCK TABLE和GET_LOCK不会考虑用于确保事务是否应在所有节点上应用或回滚的认证过程,这意味着它们是本地节点的,容易出错。您可以在Group Replication Limitations用户参考手册中找到完整的限制列表。
要启用组复制,您需要按照 Recipe 3.1 中描述的方式准备所有参与的服务器,因为它们将作为源和副本同时运行,并进行额外的准备工作。不要启动复制。
-
准备配置文件
[mysqld] # Disable unsupported storage engines disabled_storage_engines="MyISAM,BLACKHOLE,FEDERATED,ARCHIVE,MEMORY" # Set unique server ID. Each server in the group should have its own ID server_id=1 # Enable GTIDs gtid_mode=ON enforce_gtid_consistency=ON # Enable replica updates log_replica_updates=ON # Only ROW binary log format supported binlog_format=ROW # For versions before 8.0.21 binlog_checksum=NONE # Ensure that replication repository is TABLE master_info_repository=TABLE relay_log_info_repository=TABLE # Ensure that transaction_write_set_extraction is enabled # This option is deprecated starting from version 8.0.26 transaction_write_set_extraction=XXHASH64 # Add Group Replication options plugin_load_add='group_replication.so' # Any valid UUID, should be same for all group members. # Use SELECT UUID() to generate a UUID group_replication_group_name="dc527338-13d1-11eb-abf7-98af65266957" # Host of the local node and port which will be used # for communication between members # Put either hostname (in our case node1) or IP address here # Port number should be different from from the one, used for serving clients # E.g., if default MySQL port is 3306, specify any different number here group_replication_local_address= "node1:33061" # Ports and addresses of all nodes in the group. # Should be same on all nodes group_replication_group_seeds= "node1:33061,node2:33061,node3:33061" # Since we did not setup Group replication at this stage, # it should not be started on boot # You may set this option ON after bootstrapping the group group_replication_start_on_boot=off group_replication_bootstrap_group=off # Request source server public key for #the authentication plugin caching_sha2_password group_replication_recovery_get_public_key=1 -
启动服务器。但是先不要启用复制。
-
选择将成为组中第一个节点的节点。
-
仅在第一个成员上创建复制用户,如 Recipe 3.1 中描述的那样,并额外授予
BACKUP_ADMIN权限。node1> `CREATE USER repl@'%' IDENTIFIED BY 'replrepl';` Query OK, 0 rows affected (0,01 sec) node1> `GRANT REPLICATION SLAVE, BACKUP_ADMIN ON *.* TO repl@'%';` Query OK, 0 rows affected (0,03 sec)您不需要在其他组成员上创建复制用户,因为CREATE USER语句将被复制。
-
在第一个成员上设置复制以使用此用户:
node1> `CHANGE REPLICATION SOURCE TO SOURCE_USER='repl',` -> `SOURCE_PASSWORD='replrepl'` -> `FOR CHANNEL 'group_replication_recovery';` Query OK, 0 rows affected (0,01 sec)通道名
group_replication_recovery是组复制通道的特殊内置名称。提示
如果您不希望复制凭证以明文形式存储在复制存储库中,请跳过此步骤,并在运行START GROUP_REPLICATION时稍后提供凭证。还参见 Recipe 3.13
-
引导节点。
node1> `SET GLOBAL group_replication_bootstrap_group=ON;` Query OK, 0 rows affected (0,00 sec) node1> `START GROUP_REPLICATION;` Query OK, 0 rows affected (0,00 sec) node1> `SET GLOBAL group_replication_bootstrap_group=OFF;` Query OK, 0 rows affected (0,00 sec) -
通过从
performance_schema.replication_group_members中选择来检查组复制状态。node1> `SELECT * FROM performance_schema.replication_group_members\G` *************************** 1\. row *************************** CHANNEL_NAME: group_replication_applier MEMBER_ID: d8a706aa-16ee-11eb-ba5a-98af65266957 MEMBER_HOST: node1 MEMBER_PORT: 33361 MEMBER_STATE: ONLINE MEMBER_ROLE: PRIMARY MEMBER_VERSION: 8.0.21 1 row in set (0.00 sec)等待第一个成员状态变为
ONLINE。 -
在第二个和第三个节点上启动复制。
node2> `CHANGE REPLICATION SOURCE TO SOURCE_USER='repl',` -> `SOURCE_PASSWORD='replrepl'` -> `FOR CHANNEL 'group_replication_recovery';` Query OK, 0 rows affected (0,01 sec) node2> `START GROUP_REPLICATION;` Query OK, 0 rows affected (0,00 sec)
一旦确认所有成员都处于ONLINE状态,您可以使用组复制。查询表performance_schema.replication_group_members以获取此信息。健康的设置将输出如下内容:
node1> `SELECT * FROM performance_schema.replication_group_members\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: d8a706aa-16ee-11eb-ba5a-98af65266957
MEMBER_HOST: node1
MEMBER_PORT: 33061
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: e14043d7-16ee-11eb-b77a-98af65266957
MEMBER_HOST: node2
MEMBER_PORT: 33061
MEMBER_STATE: ONLINE
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
*************************** 3\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: ea775284-16ee-11eb-8762-98af65266957
MEMBER_HOST: node3
MEMBER_PORT: 33061
MEMBER_STATE: ONLINE
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
3 rows in set (0.00 sec)
警告
命令SHOW REPLICA STATUS在组复制中不起作用。
如果您希望使用现有数据启动组复制,请在引导第一个节点之前将其恢复。当其他节点加入组时,数据将被复制。
最后,在节点配置文件中启用group_replication_start_on_boot=on选项,以便在节点重新启动后启用复制。
提示
在本示例中,我们在单主模式下启动了组复制。这种模式只允许在组中的一个成员上进行写操作。这是最安全和推荐的选项。然而,如果您希望在多个节点上写入,可以使用函数group_replication_switch_to_multi_primary_mode切换到多主节点:
mysql> `SELECT group_replication_switch_to_multi_primary_mode();`
+--------------------------------------------------+
| group_replication_switch_to_multi_primary_mode() |
+--------------------------------------------------+
| Mode switched to multi-primary successfully. |
+--------------------------------------------------+
1 row in set (1.01 sec)
mysql> `SELECT * FROM performance_schema.replication_group` ↩
`_members\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: d8a706aa-16ee-11eb-ba5a-98af65266957
MEMBER_HOST: node1
MEMBER_PORT: 33061
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: e14043d7-16ee-11eb-b77a-98af65266957
MEMBER_HOST: node2
MEMBER_PORT: 33061
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
*************************** 3\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: ea775284-16ee-11eb-8762-98af65266957
MEMBER_HOST: node3
MEMBER_PORT: 33061
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
3 rows in set (0.00 sec)
欲了解更多详情,请查看更改组模式用户手册。
另请参阅
欲了解有关组复制的更多信息,请参阅用户参考手册中的组复制。
3.13 安全存储复制凭据
问题
默认情况下,如果在CHANGE REPLICATION SOURCE命令中指定,则复制凭据会显示在复制信息存储库中。您希望通过不授权用户的偶发访问来隐藏它们。
解决方案
使用START REPLICA命令的USER和PASSWORD选项。
讨论
当您使用CHANGE REPLICATION SOURCE命令指定复制用户凭据时,无论master_info_repository选项如何,它们都以明文、未加密的形式存储。
因此,如果master_info_repository='TABLE'(自 8.0 版本起为默认设置),任何具有对mysql数据库的读取访问权限的用户都可以查询slave_master_info表并读取密码:
mysql> `SELECT User_name, User_password FROM slave_master_info;`
+-----------+---------------+
| User_name | User_password |
+-----------+---------------+
| repl | replrepl |
+-----------+---------------+
1 row in set (0.00 sec)
或者,如果master_info_repository='FILE',则任何可以访问该文件的操作系统用户(默认情况下位于 MySQL 数据目录中)都可以获取复制凭据:
$ `head -n6 var/mysqld.3/data/master.info`
31
binlog.000001
688
127.0.0.1
repl
replrepl
如果这不是期望的行为,您可以在START REPLICA或START GROUP_REPLICATION命令中指定复制凭据:
mysql> `START REPLICA USER='repl' PASSWORD='replrepl';`
Query OK, 0 rows affected (0.01 sec)
但是,如果您先前在CHANGE MASTER命令中指定了复制凭据,它们将继续显示在主信息存储库中。要清除先前输入的用户和密码,请使用空参数运行CHANGE MASTER命令以清除MASTER_USER和MASTER_PASSWORD:
mysql> `SELECT User_name, User_password FROM slave_master_info;`
+-----------+---------------+
| User_name | User_password |
+-----------+---------------+
| repl | replrepl |
+-----------+---------------+
1 row in set (0.00 sec)
mysql> `CHANGE REPLICATION SOURCE TO SOURCE_USER='', SOURCE_PASSWORD='';`
Query OK, 0 rows affected, 1 warning (0.01 sec)
mysql> `START REPLICA USER='repl' PASSWORD='replrepl';`
Query OK, 0 rows affected (0.01 sec)
mysql> `SELECT User_name, User_password FROM slave_master_info;`
+-----------+---------------+
| User_name | User_password |
+-----------+---------------+
| | |
+-----------+---------------+
1 row in set (0.00 sec)
警告
一旦您从源信息存储库中清除了复制凭据,它们就不会存储在任何地方,您将需要每次重新启动复制时提供它们。
3.14 使用 TLS(SSL)进行复制
问题
您希望在源和副本之间安全传输数据。
解决方案
为复制通道设置传输层安全性(Transport Layer Security)连接。
讨论
源服务器和复制服务器之间的连接在技术上类似于连接到 MySQL 服务器的任何其他客户端连接。因此,通过 TLS 加密它需要类似于在 Recipe 24.10 中描述的加密客户端连接的准备工作。
要创建加密复制设置,请按照以下步骤操作。
-
根据 Recipe 24.10 的描述获取或创建 TLS 密钥和证书。
-
确保源服务器在[mysqld]部分下具有 TLS 配置参数:
注意
虽然 MySQL 在最新版本中使用了现代更安全的 TLS 协议,但其配置选项仍然使用缩写 SSL。MySQL 用户参考手册也经常将 TLS 称为 SSL。
[mysqld] ssl_ca=ca.pem ssl_cert=server-cert.pem ssl_key=server-key.pem您可以检查系统变量
have_ssl的值是否启用了 TLS。mysql> `SHOW VARIABLES LIKE 'have_ssl';` +---------------+-------+ | Variable_name | Value | +---------------+-------+ | have_ssl | YES | +---------------+-------+ 1 row in set (0,01 sec) -
如果运行不安全的复制,请停止复制 IO 线程:
mysql> `STOP REPLICA IO_THREAD; -- (STOP SLAVE IO_THREAD;)` Query OK, 0 rows affected (0.00 sec) -
在复制服务器上,在配置文件的
[client]部分下放置 TLS 客户端密钥和证书的路径:[client] ssl-ca=ca.pem ssl-cert=client-cert.pem ssl-key=client-key.pem并为CHANGE REPLICATION SOURCE命令指定选项
SOURCE_SSL=1:mysql> `CHANGE REPLICATION SOURCE TO SOURCE_SSL=1;` Query OK, 0 rows affected (0.03 sec或者,您可以将客户端密钥和证书的路径作为CHANGE REPLICATION SOURCE命令的一部分指定:
mysql> `CHANGE REPLICATION SOURCE TO` -> `SOURCE_SSL_CA='ca.pem',` -> `SOURCE_SSL_CERT='client-cert.pem',` -> `SOURCE_SSL_KEY='client-key.pem',` -> `SOURCE_SSL=1;` Query OK, 0 rows affected (0.02 sec)注意
我们出于简洁起见故意省略了CHANGE REPLICATION SOURCE命令的其他参数,例如
SOURCE_HOST。但您需要按照 Recipe 3.2 或 Recipe 3.4 中描述的方式使用它们。 -
启动复制:
mysql> `START REPLICA;` Query OK, 0 rows affected (0.00 sec)
CHANGE REPLICATION SOURCE 命令支持其他 TLS 修饰符,与常规客户端连接加密选项兼容。例如,您可以在SOURCE_SSL_CIPHER子句中指定要使用的密码,或者在SOURCE_SSL_VERIFY_SERVER_CERT子句中强制验证源服务器证书。
参见
要获取有关在源和复制服务器之间设置加密连接的更多信息,请参阅Setting Up Replication to Use Encrypted Connections。
3.15 复制故障排除
问题
复制未正常工作,您希望修复它。
解决方案
使用SHOW REPLICA STATUS命令,查询 Performance Schema 中的复制表,并检查错误日志文件以了解复制失败的原因,然后进行修复。
讨论
复制由两种类型的线程管理:IO 和 SQL(或连接和应用)。 IO 或连接线程负责连接到源服务器,检索更新并将其存储在中继日志文件中。每个复制通道始终有一个 IO 线程。 SQL 或应用程序线程从中继日志文件读取数据并将更改应用于表。一个复制通道可能有多个 SQL 线程。连接和应用程序线程完全独立,其错误由不同的复制诊断工具报告。
诊断复制错误有两种主要工具:SHOW REPLICA STATUS 命令和性能模式中的复制表。SHOW REPLICA STATUS 从一开始就存在,而性能模式中的复制表是从版本 5.7 开始添加的。使用这两种工具可以获得非常相似的信息,使用哪种取决于个人偏好。在我们看来,SHOW REPLICA STATUS 适合在命令行中进行手动审查,而使用性能模式进行监控警报和查询比解析 SHOW REPLICA STATUS 输出要容易得多。
SHOW REPLICA STATUS
SHOW REPLICA STATUS 包含有关 IO 和 SQL 线程配置、状态和错误的所有信息。所有数据都在单行中打印。但是,此行使用空格和换行符进行格式化。通过 mysql 客户端的 \G 修改器可以轻松地检查它。对于多源复制,SHOW REPLICA STATUS 分别打印每个通道的信息。
mysql> `SHOW REPLICA STATUS\G`
*************************** 1\. row ***************************
Replica_IO_State: Waiting for master to send event
Source_Host: 127.0.0.1
Source_User: root
Source_Port: 13000
Connect_Retry: 60
Source_Log_File: binlog.000001
Read_Source_Log_Pos: 156
Relay_Log_File: Delly-7390-relay-bin-cookbook.000002
Relay_Log_Pos: 365
Relay_Source_Log_File: binlog.000001
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
...
Channel_Name: cookbook
Source_TLS_Version:
Source_public_key_path:
Get_source_public_key: 0
Network_Namespace:
*************************** 2\. row ***************************
Replica_IO_State: Waiting for master to send event
Source_Host: 127.0.0.1
Source_User: root
Source_Port: 13004
Connect_Retry: 60
Source_Log_File: binlog.000001
Read_Source_Log_Pos: 156
Relay_Log_File: Delly-7390-relay-bin-test.000002
Relay_Log_Pos: 365
Relay_Source_Log_File: binlog.000001
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
...
Channel_Name: test
Source_TLS_Version:
Source_public_key_path:
Get_source_public_key: 0
Network_Namespace:
2 rows in set (0.00 sec)
为了简洁起见,我们有意跳过了部分输出。我们不会描述每个字段,只描述处理停止复制所需的字段(见 表 3-2)。如果您想了解其他字段的含义,请参阅 SHOW REPLICA STATUS Statement 用户参考手册。
表 3-2. SHOW REPLICA STATUS 字段含义,用于理解和修复错误
| Field | 描述 | 子系统 |
|---|---|---|
Replica_IO_State (Slave_IO_State) |
IO 线程的状态。包含连接线程运行时的信息,如果 IO 线程停止则为空,如果连接尚未建立则为 Connecting。 |
IO 线程状态 |
Source_Host (Master_Host) |
源服务器的主机名。 | IO 线程配置 |
Source_User (Master_User) |
复制用户。 | IO 线程配置 |
Source_Port (Master_Port) |
源服务器的端口号。 | IO 线程配置 |
Source_Log_File (Master_Log_File) |
IO 线程当前正在读取的源服务器的二进制日志。 | IO 线程状态 |
Read_Source_Log_Pos (Read_Master_Log_Pos) |
IO 线程正在读取的源服务器二进制日志文件中的位置。 | IO 线程状态 |
Relay_Log_File |
当前的中继日志文件:SQL 线程当前正在执行的文件。 | IO 线程状态 |
Relay_Log_Pos |
SQL 线程已执行到的中继日志文件位置。 | IO 线程状态 |
Relay_Source_Log_File (Relay_Master_Log_File) |
SQL 线程执行事件时源服务器上的二进制日志。 | SQL 线程状态 |
Replica_IO_Running (Slave_IO_Running) |
指示 IO 线程是否正在运行。使用此字段快速识别连接线程的健康状态。 | IO 线程状态 |
Replica_SQL_Running (Slave_SQL_Running) |
SQL 线程是否正在运行。用于快速识别应用程序线程的健康状况。 | SQL 线程状态 |
Replicate_* |
复制过滤器。 | SQL 线程配置 |
Exec_Source_Log_Pos (Exec_Master_Log_Pos) |
SQL 线程执行事件时源二进制日志文件的位置。 | SQL 线程状态 |
Until_Condition |
如有任何条件,直到的条件。 | SQL 线程配置 |
Source_SSL_* (Master_SSL_*) |
连接到源服务器的 SSL 选项。 | IO 线程配置 |
Seconds_Behind_Source (Seconds_Behind_Master) |
源服务器和副本之间的预估延迟时间。 | SQL 线程状态 |
Last_IO_Errno |
IO 线程的最后一个错误编号。解决后清除。 | IO 线程状态 |
Last_IO_Error |
IO 线程上的最新错误。解决后清除。 | IO 线程状态 |
Last_Errno, Last_SQL_Errno |
SQL 线程接收的最后一个错误编号。解决后清除。 | SQL 线程状态 |
Last_Error, Last_SQL_Error |
SQL 线程的最后错误。解决后清除。 | SQL 线程状态 |
Replica_SQL_Running_State (Slave_SQL_Running_State) |
SQL 线程的状态。如果停止则为空。 | SQL 线程状态 |
Last_IO_Error_Timestamp |
上次 IO 错误发生的时间。解决后清除。 | IO 线程状态 |
Last_SQL_Error_Timestamp |
上次 SQL 错误发生的时间。解决后清除。 | SQL 线程状态 |
Retrieved_Gtid_Set |
连接线程检索的 GTID 集合。 | IO 线程状态 |
Executed_Gtid_Set |
SQL 线程执行的 GTID 集合。 | SQL 线程状态 |
Channel_Name |
复制通道的名称。 | IO 和 SQL 线程配置 |
在讨论如何处理特定 IO 和 SQL 线程错误时,我们将参考此表格。
性能模式中的复制表格
另一种诊断解决方案:性能模式中的表格,不像 SHOW REPLICA STATUS,不会将所有信息存储在单个位置,而是在单独的空间中保存。
IO 线程配置信息存储在表 replication_connection_configuration 中,其状态信息存储在表 replication_connection_status 中。
关于 SQL 线程的信息存储在六个表中,如 表 3-3 所示。
表 3-3. 包含特定 SQL 线程信息的表格
| 表名 | 描述 |
|---|---|
replication_applier_configuration |
SQL 线程配置。 |
replication_applier_global_filters |
全局复制过滤器:适用于所有通道。 |
replication_applier_filters |
特定于特定通道的复制过滤器。 |
replication_applier_status |
SQL 线程的全局状态。 |
replication_applier_status_by_worker |
对于多线程副本:每个 SQL 线程的状态。 |
replication_applier_status_by_coordinator |
对于多线程复制:协调者视角下 SQL 线程的状态。 |
最后,您将在 replication_group_members 表中找到组复制网络配置和状态,以及在 replication_group_member_stats 表中找到组复制成员的统计信息。
修复 IO 线程问题
您可以通过检查 SHOW REPLICA STATUS 的 Replica_IO_Running 字段的值来查看复制 IO 线程是否存在问题。如果值不是 Yes,连接线程可能遇到问题。发生这种情况的原因可以在 Last_IO_Errno 和 Last_IO_Error 字段中找到。
mysql> `SHOW REPLICA STATUS\G`
*************************** 1\. row ***************************
...
Replica_IO_Running: Connecting
Replica_SQL_Running: Yes
...
Last_IO_Errno: 1045
Last_IO_Error: error connecting to master 'repl@127.0.0.1:13000' - ↩
retry-time: 60 retries: 1 message: ↩
Access denied for user 'repl'@'localhost'↩
(using password: NO)
...
就像上面的示例中,副本无法连接到源服务器,因为用户 'repl'@'localhost' 的访问被拒绝。IO 线程仍在运行,并将在 60 秒后重新尝试连接(retry-time: 60)。导致此类失败的原因很明确:要么在源服务器上不存在该用户,要么其权限不足。您需要连接到源服务器并修复用户帐户。一旦修复,下一次连接尝试将成功。
或者,您可以查询性能模式下的 replication_connection_status 表:
mysql> `SELECT SERVICE_STATE, LAST_ERROR_NUMBER,`
-> `LAST_ERROR_MESSAGE, LAST_ERROR_TIMESTAMP`
-> `FROM performance_schema.replication_connection_status\G`
*************************** 1\. row ***************************
SERVICE_STATE: CONNECTING
LAST_ERROR_NUMBER: 2061
LAST_ERROR_MESSAGE: error connecting to master 'repl@127.0.0.1:13000' -↩
retry-time: 60 retries: 1 ↩
message: Authentication plugin 'caching_sha2_password' ↩
reported error: Authentication requires secure connection.
LAST_ERROR_TIMESTAMP: 2020-10-17 13:23:03.663994
1 row in set (0.00 sec)
在此示例中,字段 LAST_ERROR_MESSAGE 包含 IO 线程无法连接的原因:源服务器上的用户帐户使用要求安全连接的身份验证插件 caching_sha2_password。要修复此错误,您需要停止复制,然后使用参数 SOURCE_SSL=1 或 GET_SOURCE_PUBLIC_KEY=1 运行 CHANGE REPLICATION SOURCE。在后一种情况下,副本与源服务器之间的流量将保持不安全,只有密码交换通信将得到保护。有关详细信息,请参阅 Recipe 3.14。
修复 SQL 线程问题
要查找为什么应用程序线程已停止,请检查 Replica_SQL_Running、Last_SQL_Errno 和 Last_SQL_Error 字段:
mysql> `SHOW REPLICA STATUS\G`
*************************** 1\. row ***************************
...
Replica_SQL_Running: No
...
Last_SQL_Errno: 1007
Last_SQL_Error: Error 'Can't create database 'cookbook'; ↩
database exists' on query. ↩
Default database: 'cookbook'. ↩
Query: 'create database cookbook'
在上面的列表中,错误消息显示 CREATE DATABASE 命令失败,因为在副本上已存在这样的数据库。
同样的信息也可以在性能模式下的 replication_applier_status_by_worker 表中找到:
mysql> `SELECT SERVICE_STATE, LAST_ERROR_NUMBER,`
-> `LAST_ERROR_MESSAGE, LAST_ERROR_TIMESTAMP`
-> `FROM performance_schema.replication_applier_status_by_worker\G`
*************************** 1\. row ***************************
SERVICE_STATE: OFF
LAST_ERROR_NUMBER: 1007
LAST_ERROR_MESSAGE: Error 'Can't create database 'cookbook'; ↩
database exists' on query. ↩
Default database: 'cookbook'.↩
Query: 'create database cookbook'
LAST_ERROR_TIMESTAMP: 2020-10-17 13:58:12.115821
1 row in set (0.01 sec)
解决此问题的方法有几种。首先,您可以简单地在副本上删除数据库并重新启动 SQL 线程:
mysql> `DROP DATABASE cookbook;`
Query OK, 0 rows affected (0.04 sec)
mysql> `START REPLICA SQL_THREAD;`
Query OK, 0 rows affected (0.01 sec)
如果副本上启用了二进制日志,请禁用它。
如果您希望在副本上保留数据库:例如,如果它应该包含源服务器上不存在的额外表格,您可以跳过复制的事件。
如果您使用基于位置的复制,请使用变量 sql_replica_skip_counter(sql_slave_skip_counter):
mysql> `SET GLOBAL sql_replica_skip_counter=1;`
Query OK, 0 rows affected (0.00 sec)
mysql> `START REPLICA SQL_THREAD;`
Query OK, 0 rows affected (0.01 sec)
在此示例中,我们跳过了二进制日志中的一个事件,然后重新启动了复制。
对于基于 GTID 的复制设置,sql_replica_skip_counter不起作用,因为它不包括 GTID 信息。相反,您需要生成具有无法执行的事务的 GTID 的空事务。要找出失败的 GTID,请检查SHOW REPLICA STATUS的Retrieved_Gtid_Set和Executed_Gtid_Set字段:
mysql> `SHOW REPLICA STATUS\G`
*************************** 1\. row ***************************
...
Retrieved_Gtid_Set: de7e85f9-1060-11eb-8b8f-98af65266957:1-5
Executed_Gtid_Set: de7e85f9-1060-11eb-8b8f-98af65266957:1-4,
de8d356e-1060-11eb-a568-98af65266957:1-3
...
在此示例中,Retrieved_Gtid_Set包含事务de7e85f9-1060-11eb-8b8f-98af65266957:1-5,而Executed_Gtid_Set仅包含事务de7e85f9-1060-11eb-8b8f-98af65266957:1-4。很明显,事务de7e85f9-1060-11eb-8b8f-98af65266957:5未被执行。带有 UUIDde8d356e-1060-11eb-a568-98af65266957的事务是本地事务,不是由复制应用程序线程执行。
您还可以在replication_applier_status_by_worker表的APPLYING_TRANSACTION字段中找到失败的事务:
mysql> `select LAST_APPLIED_TRANSACTION, APPLYING_TRANSACTION`
-> `from performance_schema.replication_applier_status_by_worker\G`
*************************** 1\. row ***************************
LAST_APPLIED_TRANSACTION: de7e85f9-1060-11eb-8b8f-98af65266957:4
APPLYING_TRANSACTION: de7e85f9-1060-11eb-8b8f-98af65266957:5
1 row in set (0.00 sec)
一旦找到失败的事务,插入相同的 GTID 为空的事务,并重新启动 SQL 线程。
mysql> `-- set explicit GTID`
mysql> `SET gtid_next='de7e85f9-1060-11eb-8b8f-98af65266957:5';`
Query OK, 0 rows affected (0.00 sec)
mysql> `-- inject empty transaction`
mysql> `BEGIN;COMMIT;`
Query OK, 0 rows affected (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
mysql> `-- revert GTID generation back to automatic`
mysql> `SET gtid_next='automatic';`
Query OK, 0 rows affected (0.00 sec)
mysql> `-- restart SQL thread`
mysql> `START REPLICA SQL_THREAD;`
Query OK, 0 rows affected (0.01 sec)
警告
虽然跳过二进制日志事件或事务有助于重新启动复制,但可能会导致更大的问题,并导致源和副本之间的数据不一致,从而导致将来的错误。始终分析为什么首次发生错误并尝试修复原因,而不仅仅是跳过事件。
虽然SHOW REPLICA STATUS和表replication_applier_status_by_worker都存储错误消息,如果使用多线程副本,则该表可以提供更详细的信息。例如,在此示例中,错误消息无法完全理解失败的原因:
mysql> `SHOW REPLICA STATUS\G`
*************************** 1\. row ***************************
...
Last_SQL_Errno: 1146
Last_SQL_Error: Coordinator stopped because there were error(s) ↩
in the worker(s). The most recent failure being: ↩
Worker 8 failed executing transaction ↩
'de7e85f9-1060-11eb-8b8f-98af65266957:7' at ↩
master log binlog.000001, end_log_pos 1818\. ↩
See error log and/or performance_schema.↩
replication_applier_status_by_worker table ↩
for more details about this failure or others, if any.
...
报告工作者 8 失败,但未说明原因。查询replication_applier_status_by_worker表的信息如下:
mysql> `select SERVICE_STATE, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE, LAST_ERROR_TIMESTAMP`
-> `from performance_schema.replication_applier_status_by_worker where worker_id=8\G`
*************************** 1\. row ***************************
SERVICE_STATE: OFF
LAST_ERROR_NUMBER: 1146
LAST_ERROR_MESSAGE: Worker 8 failed executing transaction ↩
'de7e85f9-1060-11eb-8b8f-98af65266957:7' at master log↩
binlog.000001, end_log_pos 1818; Error executing row event: ↩
'Table 'cookbook.limbs' doesn't exist'
LAST_ERROR_TIMESTAMP: 2020-10-17 14:28:01.144521
1 row in set (0.00 sec)
现在很明显,特定的表不存在。您可以分析原因并修正错误。
组复制故障排除
SHOW REPLICA STATUS对于组复制不可用。因此,您需要使用性能模式来解决与其相关的问题。性能模式仅针对组复制有两个特殊表:replication_group_members,显示所有成员的详细信息和replication_group_member_stats,显示它们的统计信息。但是,这些表没有关于 IO 和 SQL 线程错误的信息。我们讨论过的标准异步复制有这些详细信息。
让我们更仔细地看一看组复制的故障排除选项。
快速识别组复制中是否出现问题的方法是检查replication_group_members表。
mysql> `SELECT * FROM performance_schema.replication_group_members\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: de5b65cb-16ae-11eb-826c-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33361
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: e9514d63-16ae-11eb-8f6e-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33362
MEMBER_STATE: RECOVERING
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
*************************** 3\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: f1e717ab-16ae-11eb-bfd2-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33363
MEMBER_STATE: RECOVERING
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
3 rows in set (0.00 sec)
在上述列表中,只有PRIMARY成员处于MEMBER_STATE: ONLINE状态,这意味着它很健康。两个SECONDARY成员都处于RECOVERING状态,并且在加入组时遇到了问题。
失败的成员将在一段时间内保持RECOVERING状态,而组复制试图自我恢复。如果错误无法自动恢复,则离开该组并保持ERROR状态。
mysql> `SELECT * FROM performance_schema.replication_group_members\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: e9514d63-16ae-11eb-8f6e-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33362
MEMBER_STATE: ERROR
MEMBER_ROLE:
MEMBER_VERSION: 8.0.21
1 row in set (0.00 sec)
两个清单都在组的同一次要成员上获取,但在它离开组后,仅报告自身为 Group Replication 成员,并且不显示有关其他成员的信息。
要查找失败的原因,您需要检查 replication_connection_status 和 replication_applier_status_by_worker 表。
在我们的示例中,成员 e9514d63-16ae-11eb-8f6e-98af65266957 因 SQL 错误而停止。您可以在 replication_applier_status_by_worker 表中找到错误详情:
mysql> `SELECT CHANNEL_NAME, LAST_ERROR_NUMBER,`
-> `LAST_ERROR_MESSAGE, LAST_ERROR_TIMESTAMP,`
-> `APPLYING_TRANSACTION`
-> `FROM performance_schema.replication_applier_status_by_worker\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_recovery
LAST_ERROR_NUMBER: 3635
LAST_ERROR_MESSAGE: The table in transaction de5b65cb-16ae-11eb-826c-98af65266957:15 ↩
does not comply with the requirements by an external plugin.
LAST_ERROR_TIMESTAMP: 2020-10-25 20:31:27.718638
APPLYING_TRANSACTION: de5b65cb-16ae-11eb-826c-98af65266957:15
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
LAST_ERROR_NUMBER: 0
LAST_ERROR_MESSAGE:
LAST_ERROR_TIMESTAMP: 0000-00-00 00:00:00.000000
APPLYING_TRANSACTION:
2 rows in set (0.00 sec)
错误消息表示事务中表的定义 de5b65cb-16ae-11eb-826c-98af65266957:15 与 Group Replication 插件不兼容。要查找原因,请参阅Group Replication 要求和限制,识别事务中使用的表并修复错误。
replication_applier_status_by_worker 表中的错误消息没有任何提示表明在事务中使用了哪个表。但是错误日志文件可能有。打开错误日志文件,搜索 LAST_ERROR_TIMESTAMP 和 LAST_ERROR_NUMBER 来识别错误,并检查前后行是否有更多信息。
2020-10-25T17:31:27.718600Z 71 [ERROR] [MY-011542] [Repl] Plugin group_replication↩
reported: 'Table al_winner does not have any PRIMARY KEY. This is not compatible↩
with Group Replication.'
2020-10-25T17:31:27.718644Z 71 [ERROR] [MY-010584] [Repl] Slave SQL for channel↩
'group_replication_recovery': The table in transaction↩
de5b65cb-16ae-11eb-826c-98af65266957:15 does not comply with the requirements↩
by an external plugin. Error_code: MY-003635
在这个示例中,上一行的错误消息包含表名:al_winner,不兼容 Group Replication 的原因是表没有主键。
要修复错误,您需要在 PRIMARY 和失败的 SECONDARY 节点上修复表定义。
首先,登录到 PRIMARY 节点,并添加代理主键:
mysql> `set sql_log_bin=0;`
Query OK, 0 rows affected (0.00 sec)
mysql> `alter table al_winner add id int not null auto_increment primary key;`
Query OK, 0 rows affected (0.09 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `set sql_log_bin=1;`
Query OK, 0 rows affected (0.01 sec)
您需要禁用二进制日志记录,否则此更改将被复制到辅助成员,并且由于重复列名错误,复制将停止。
然后在辅助节点上运行相同命令以修复表定义,并重新启动 Group Replication。
mysql> `set global super_read_only=0;`
Query OK, 0 rows affected (0.00 sec)
mysql> `set sql_log_bin=0;`
Query OK, 0 rows affected (0.00 sec)
mysql> `alter table al_winner add id int not null auto_increment primary key;`
Query OK, 0 rows affected (0.09 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `set sql_log_bin=1;`
Query OK, 0 rows affected (0.01 sec)
mysql> `stop group_replication;`
Query OK, 0 rows affected (1.02 sec)
mysql> `start group_replication;`
Query OK, 0 rows affected (3.22 sec)
如果节点在单主模式下运行,则需要首先禁用由 Group Replication 插件设置的 super_read_only。
一旦错误修复,节点将加入组,并将其状态报告为 ONLINE。
mysql> `SELECT * FROM performance_schema.replication_group_members\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: d8a706aa-16ee-11eb-ba5a-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33361
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: e14043d7-16ee-11eb-b77a-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33362
MEMBER_STATE: ONLINE
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
2 rows in set (0.00 sec)
提示
您可以通过运行带有选项 verbose 的 mysqlbinlog 命令来查找失败的事务正在做什么:
$ `mysqlbinlog data1/binlog.000001`
> `--include-gtids=de5b65cb-16ae-11eb-826c-98af65266957:15 --verbose`
...
SET @@SESSION.GTID_NEXT= 'de5b65cb-16ae-11eb-826c-98af65266957:15'/*!*/;
# at 4015
#201025 13:44:34 server id 1 end_log_pos 4094 CRC32 0xad05e64e Query ↩
thread_id=10 exec_time=0 error_code=0
SET TIMESTAMP=1603622674/*!*/;
...
### INSERT INTO `cookbook`.`al_winner`
### SET
### @1='Mulder, Mark' /* STRING(120) meta=65144 nullable=1 is_null=0 */
### @2=21 /* INT meta=0 nullable=1 is_null=0 */
### INSERT INTO `cookbook`.`al_winner`
### SET
### @1='Clemens, Roger' /* STRING(120) meta=65144 nullable=1 is_null=0 */
### @2=20 /* INT meta=0 nullable=1 is_null=0 */
### INSERT INTO `cookbook`.`al_winner`
...
### INSERT INTO `cookbook`.`al_winner`
### SET
### @1='Sele, Aaron' /* STRING(120) meta=65144 nullable=1 is_null=0 */
### @2=15 /* INT meta=0 nullable=1 is_null=0 */
# at 4469
#201025 13:44:34 server id 1 end_log_pos 4500 CRC32 0xddd32d63 Xid = 74
COMMIT/*!*/;
SET @@SESSION.GTID_NEXT= 'AUTOMATIC' /* added by mysqlbinlog */ /*!*/;
DELIMITER ;
# End of log file
/*!50003 SET COMPLETION_TYPE=@OLD_COMPLETION_TYPE*/;
/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=0*/;
解码行事件需要选项 verbose。
我们在一个节点上修复了错误,但第三个节点没有加入组。在检查表 performance_schema.replication_connection_status 内容后,我们发现复制连接选项未正确设置:
mysql> `SELECT CHANNEL_NAME, LAST_ERROR_NUMBER, LAST_ERROR_MESSAGE, LAST_ERROR_TIMESTAMP`
-> `FROM performance_schema.replication_connection_status\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
LAST_ERROR_NUMBER: 0
LAST_ERROR_MESSAGE:
LAST_ERROR_TIMESTAMP: 0000-00-00 00:00:00.000000
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_recovery
LAST_ERROR_NUMBER: 13117
LAST_ERROR_MESSAGE: Fatal error: Invalid (empty) username when attempting ↩
to connect to the master server. Connection attempt terminated.
LAST_ERROR_TIMESTAMP: 2020-10-25 21:31:31.413876
2 rows in set (0.00 sec)
要解决此问题,我们需要运行正确的 CHANGE REPLICATION SOURCE 命令:
mysql> `STOP GROUP_REPLICATION;`
Query OK, 0 rows affected (1.01 sec)
mysql> `CHANGE REPLICATION SOURCE TO SOURCE_USER='repl', SOURCE_PASSWORD='replrepl'`
-> `FOR CHANNEL 'group_replication_recovery';`
Query OK, 0 rows affected, 2 warnings (0.03 sec)
mysql> `START GROUP_REPLICATION;`
Query OK, 0 rows affected (2.40 sec)
修复后,节点将因与先前相同的 SQL 错误而失败,必须按照我们上面描述的方式进行修复。最终,在 SQL 错误恢复后,节点将加入集群并显示为 ONLINE。
mysql> `SELECT * FROM performance_schema.replication_group_members\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: d8a706aa-16ee-11eb-ba5a-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33361
MEMBER_STATE: ONLINE
MEMBER_ROLE: PRIMARY
MEMBER_VERSION: 8.0.21
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: e14043d7-16ee-11eb-b77a-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33362
MEMBER_STATE: ONLINE
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
*************************** 3\. row ***************************
CHANNEL_NAME: group_replication_applier
MEMBER_ID: ea775284-16ee-11eb-8762-98af65266957
MEMBER_HOST: Delly-7390
MEMBER_PORT: 33363
MEMBER_STATE: ONLINE
MEMBER_ROLE: SECONDARY
MEMBER_VERSION: 8.0.21
3 rows in set (0.00 sec)
要检查 Group Replication 查询表 performance_schema.replication_group_member_stats 的性能。
mysql> `SELECT * FROM performance_schema.replication_group_member_stats\G`
*************************** 1\. row ***************************
CHANNEL_NAME: group_replication_applier
VIEW_ID: 16036502905383892:9
MEMBER_ID: d8a706aa-16ee-11eb-ba5a-98af65266957
COUNT_TRANSACTIONS_IN_QUEUE: 0
COUNT_TRANSACTIONS_CHECKED: 10154
COUNT_CONFLICTS_DETECTED: 0
COUNT_TRANSACTIONS_ROWS_VALIDATING: 9247
TRANSACTIONS_COMMITTED_ALL_MEMBERS: d8a706aa-16ee-11eb-ba5a-98af65266957:1-18,
dc527338-13d1-11eb-abf7-98af65266957:1-1588
LAST_CONFLICT_FREE_TRANSACTION: dc527338-13d1-11eb-abf7-98af65266957:10160
COUNT_TRANSACTIONS_REMOTE_IN_APPLIER_QUEUE: 0
COUNT_TRANSACTIONS_REMOTE_APPLIED: 5
COUNT_TRANSACTIONS_LOCAL_PROPOSED: 10154
COUNT_TRANSACTIONS_LOCAL_ROLLBACK: 0
*************************** 2\. row ***************************
CHANNEL_NAME: group_replication_applier
VIEW_ID: 16036502905383892:9
MEMBER_ID: e14043d7-16ee-11eb-b77a-98af65266957
COUNT_TRANSACTIONS_IN_QUEUE: 0
COUNT_TRANSACTIONS_CHECKED: 10037
COUNT_CONFLICTS_DETECTED: 0
COUNT_TRANSACTIONS_ROWS_VALIDATING: 9218
TRANSACTIONS_COMMITTED_ALL_MEMBERS: d8a706aa-16ee-11eb-ba5a-98af65266957:1-18,
dc527338-13d1-11eb-abf7-98af65266957:1-1588
LAST_CONFLICT_FREE_TRANSACTION: dc527338-13d1-11eb-abf7-98af65266957:8030
COUNT_TRANSACTIONS_REMOTE_IN_APPLIER_QUEUE: 5859
COUNT_TRANSACTIONS_REMOTE_APPLIED: 4180
COUNT_TRANSACTIONS_LOCAL_PROPOSED: 0
COUNT_TRANSACTIONS_LOCAL_ROLLBACK: 0
*************************** 3\. row ***************************
CHANNEL_NAME: group_replication_applier
VIEW_ID: 16036502905383892:9
MEMBER_ID: ea775284-16ee-11eb-8762-98af65266957
COUNT_TRANSACTIONS_IN_QUEUE: 0
COUNT_TRANSACTIONS_CHECKED: 10037
COUNT_CONFLICTS_DETECTED: 0
COUNT_TRANSACTIONS_ROWS_VALIDATING: 9218
TRANSACTIONS_COMMITTED_ALL_MEMBERS: d8a706aa-16ee-11eb-ba5a-98af65266957:1-18,
dc527338-13d1-11eb-abf7-98af65266957:1-37
LAST_CONFLICT_FREE_TRANSACTION: dc527338-13d1-11eb-abf7-98af65266957:6581
COUNT_TRANSACTIONS_REMOTE_IN_APPLIER_QUEUE: 5828
COUNT_TRANSACTIONS_REMOTE_APPLIED: 4209
COUNT_TRANSACTIONS_LOCAL_PROPOSED: 0
COUNT_TRANSACTIONS_LOCAL_ROLLBACK: 0
3 rows in set (0.00 sec)
重要的字段是COUNT_TRANSACTIONS_REMOTE_IN_APPLIER_QUEUE,显示在辅助节点队列中等待应用的事务数量,以及TRANSACTIONS_COMMITTED_ALL_MEMBERS,显示所有成员上已应用的事务数量。有关更多详细信息,请参阅用户参考手册。
3.16 使用进程列表了解复制性能
问题
复制品落后于源服务器,延迟正在增加。您想了解发生了什么。
解决方案
使用性能模式中的复制表以及常规的 MySQL 性能工具检查 SQL 线程的状态。
讨论
如果 SQL 线程在应用更新时比源服务器慢,则副本可能落后于源。这可能是因为源上的更新正在并发运行,而在副本上使用的线程少于处理相同工作量的源服务器上的活动线程。即使在具有与源相同或更高 CPU 核心数量的副本上,这种差异也可能发生,因为您设置了比源服务器上活动线程少的replica_parallel_workers,或者因为由于安全措施未充分利用而未完全使用它们以防止副本以错误的顺序应用更新。
要了解活动的并行工作者数量,您可以查询replication_applier_status_by_worker表。
mysql> `SELECT WORKER_ID, LAST_APPLIED_TRANSACTION, APPLYING_TRANSACTION`
-> `FROM performance_schema.replication_applier_status_by_worker;`
+-----------+---------------------------------+---------------------------------+
| WORKER_ID | LAST_APPLIED_TRANSACTION | APPLYING_TRANSACTION |
+-----------+---------------------------------+---------------------------------+
| 1 | de7e85f9-...-98af65266957:26075 | de7e85f9-...-98af65266957:26077 |
| 2 | de7e85f9-...-98af65266957:26076 | de7e85f9-...-98af65266957:26078 |
| 3 | de7e85f9-...-98af65266957:26068 | de7e85f9-...-98af65266957:26079 |
| 4 | de7e85f9-...-98af65266957:26069 | |
| 5 | de7e85f9-...-98af65266957:26070 | |
| 6 | de7e85f9-...-98af65266957:26071 | |
| 7 | de7e85f9-...-98af65266957:25931 | |
| 8 | de7e85f9-...-98af65266957:21638 | |
+-----------+---------------------------------+---------------------------------+
8 rows in set (0.01 sec)
在上面的列表中,您可能注意到目前只有三个线程正在应用事务,而其他线程处于空闲状态。这不是稳定的信息,您需要多次运行相同的查询以确定这是否是一种趋势。
性能模式中的threads表包含当前在 MySQL 服务器上运行的所有线程的列表,包括后台线程。它具有一个名为name的字段,其值为thread/sql/replica_worker(在复制 SQL 线程的情况下为thread/sql/slave_worker)。您可以查询它并找到每个 SQL 线程工作者正在执行的详细信息。
mysql> `SELECT THREAD_ID AS TID, PROCESSLIST_ID AS PID,`
-> `PROCESSLIST_DB, PROCESSLIST_STATE`
-> `FROM performance_schema.threads WHERE NAME = 'thread/sql/replica_worker';`
+-----+-----+----------------+----------------------------------------+
| TID | PID | PROCESSLIST_DB | PROCESSLIST_STATE |
+-----+-----+----------------+----------------------------------------+
| 54 | 13 | NULL | waiting for handler commit |
| 55 | 14 | sbtest | Applying batch of row changes (update) |
| 56 | 15 | sbtest | Applying batch of row changes (delete) |
| 57 | 16 | NULL | Waiting for an event from Coordinator |
| 58 | 17 | NULL | Waiting for an event from Coordinator |
| 59 | 18 | NULL | Waiting for an event from Coordinator |
| 60 | 19 | NULL | Waiting for an event from Coordinator |
| 61 | 20 | NULL | Waiting for an event from Coordinator |
+-----+-----+----------------+----------------------------------------+
8 rows in set (0.00 sec)
在上面的列表中,线程 54 正在等待事务提交,线程 55 和 56 正在应用一批行更改,而其他线程则在等待来自协调器的事件。
由于源服务器在大量线程中应用更改,我们可能会注意到复制延迟正在增加。
mysql> `\P grep Seconds_Behind_Source`
PAGER set to 'grep Seconds_Behind_Source'
mysql> `SHOW REPLICA STATUS\G SELECT SLEEP(60); SHOW REPLICA STATUS\G`
Seconds_Behind_Source: 232
1 row in set (0.00 sec)
1 row in set (1 min 0.00 sec)
Seconds_Behind_Source: 238
1 row in set (0.00 sec)
对于此类问题的一个解决方案是在源服务器上设置选项binlog_transaction_dependency_tracking为WRITESET_SESSION或WRITESET。这些选项在配方 3.8 中讨论,并允许在副本上实现更高的并行化。请注意,更改不会立即生效,因为副本将必须应用使用默认的binlog_transaction_dependency_tracking值COMMIT_ORDER记录的二进制日志事件。
然而,过了一段时间,您可能会注意到所有 SQL 线程工作者都变得活跃,并且复制延迟开始减少。
mysql> `SELECT WORKER_ID, LAST_APPLIED_TRANSACTION, APPLYING_TRANSACTION`
-> `FROM performance_schema.replication_applier_status_by_worker;`
+-----------+----------------------------------+-----------------------------------+
| WORKER_ID | LAST_APPLIED_TRANSACTION | APPLYING_TRANSACTION |
+-----------+----------------------------------+-----------------------------------+
| 1 | de7e85f9-...-98af65266957:170966 | de7e85f9-...-98af65266957:170976 |
| 2 | de7e85f9-...-98af65266957:170970 | de7e85f9-...-98af65266957:170973 |
| 3 | de7e85f9-...-98af65266957:170968 | de7e85f9-...-98af65266957:170975 |
| 4 | de7e85f9-...-98af65266957:170960 | de7e85f9-...-98af65266957:170967 |
| 5 | de7e85f9-...-98af65266957:170964 | de7e85f9-...-98af65266957:170972 |
| 6 | de7e85f9-...-98af65266957:170962 | de7e85f9-...-98af65266957:170969 |
| 7 | de7e85f9-...-98af65266957:170971 | de7e85f9-...-98af65266957:170977 |
| 8 | de7e85f9-...-98af65266957:170965 | de7e85f9-...-98af65266957:170974 |
+-----------+----------------------------------+-----------------------------------+
8 rows in set (0.00 sec)
mysql> `SELECT THREAD_ID, PROCESSLIST_ID, PROCESSLIST_DB, PROCESSLIST_STATE`
-> `FROM performance_schema.threads WHERE NAME = 'thread/sql/replica_worker';`
+-----------+----------------+----------------+----------------------------------------+
| thread_id | PROCESSLIST_ID | PROCESSLIST_DB | PROCESSLIST_STATE |
+-----------+----------------+----------------+----------------------------------------+
| 54 | 13 | sbtest | Applying batch of row changes (update) |
| 55 | 14 | NULL | waiting for handler commit |
| 56 | 15 | sbtest | Applying batch of row changes (delete) |
| 57 | 16 | sbtest | Applying batch of row changes (delete) |
| 58 | 17 | sbtest | Applying batch of row changes (update) |
| 59 | 18 | sbtest | Applying batch of row changes (delete) |
| 60 | 19 | sbtest | Applying batch of row changes (update) |
| 61 | 20 | sbtest | Applying batch of row changes (write) |
+-----------+----------------+----------------+----------------------------------------+
8 rows in set (0.00 sec)
mysql> `\P grep Seconds_Behind_Source`
PAGER set to 'grep Seconds_Behind_Source'
mysql> `SHOW REPLICATION SOURCE STATUS\G SELECT SLEEP(60); SHOW REPLICA STATUS\G`
Seconds_Behind_Source: 285
1 row in set (0.00 sec)
1 row in set (1 min 0.00 sec)
Seconds_Behind_Source: 275
1 row in set (0.00 sec)
复制滞后的另一个常见原因是本地命令,影响由复制更新的表。如果查询 replication_applier_status_by_worker 表,并将 APPLYING_TRANSACTION_START_APPLY_TIMESTAMP 字段的值与当前时间进行比较,则可能会注意到这种情况发生。
mysql> `SELECT WORKER_ID, APPLYING_TRANSACTION, TIMEDIFF(NOW(),`
-> `APPLYING_TRANSACTION_START_APPLY_TIMESTAMP) AS exec_time`
-> `FROM performance_schema.replication_applier_status_by_worker;`
+-----------+---------------------------------------------+-----------------+
| WORKER_ID | APPLYING_TRANSACTION | exec_time |
+-----------+---------------------------------------------+-----------------+
| 1 | de7e85f9-1060-11eb-8b8f-98af65266957:226091 | 00:05:14.367275 |
| 2 | de7e85f9-1060-11eb-8b8f-98af65266957:226087 | 00:05:14.768701 |
| 3 | de7e85f9-1060-11eb-8b8f-98af65266957:226090 | 00:05:14.501099 |
| 4 | de7e85f9-1060-11eb-8b8f-98af65266957:226097 | 00:05:14.232062 |
| 5 | de7e85f9-1060-11eb-8b8f-98af65266957:226086 | 00:05:14.773958 |
| 6 | de7e85f9-1060-11eb-8b8f-98af65266957:226083 | 00:05:14.782274 |
| 7 | de7e85f9-1060-11eb-8b8f-98af65266957:226080 | 00:05:14.843808 |
| 8 | de7e85f9-1060-11eb-8b8f-98af65266957:226094 | 00:05:14.327028 |
+-----------+---------------------------------------------+-----------------+
8 rows in set (0.00 sec)
在上面的列表中,所有线程的事务执行时间都相似,大约为五分钟。这太长了!
要找出事务执行时间如此之长的原因,请查询 Performance Schema 中的 threads 表:
mysql> `SELECT THREAD_ID, PROCESSLIST_ID, PROCESSLIST_DB, PROCESSLIST_STATE`
-> `FROM performance_schema.threads WHERE NAME = 'thread/sql/replica_worker';`
+-----------+----------------+----------------+------------------------------+
| thread_id | PROCESSLIST_ID | PROCESSLIST_DB | PROCESSLIST_STATE |
+-----------+----------------+----------------+------------------------------+
| 54 | 13 | NULL | Waiting for global read lock |
| 55 | 14 | NULL | Waiting for global read lock |
| 56 | 15 | NULL | Waiting for global read lock |
| 57 | 16 | NULL | Waiting for global read lock |
| 58 | 17 | NULL | Waiting for global read lock |
| 59 | 18 | NULL | Waiting for global read lock |
| 60 | 19 | NULL | Waiting for global read lock |
| 61 | 20 | NULL | Waiting for global read lock |
+-----------+----------------+----------------+------------------------------+
8 rows in set (0.00 sec)
很明显,复制 SQL 线程并没有执行任何有用的工作,只是在等待全局读锁。
要找出哪个线程持有全局读锁,请再次查询 Performance Schema 中的 threads 表,但这次要过滤掉副本线程:
mysql> `SELECT THREAD_ID, PROCESSLIST_ID, PROCESSLIST_DB,`
-> `PROCESSLIST_STATE, PROCESSLIST_INFO`
-> `FROM performance_schema.threads`
-> `WHERE NAME != 'thread/sql/replica_worker' AND PROCESSLIST_ID IS NOT NULL\G`
*************************** 1\. row ***************************
thread_id: 46
PROCESSLIST_ID: 7
PROCESSLIST_DB: NULL
PROCESSLIST_STATE: Waiting on empty queue
PROCESSLIST_INFO: NULL
*************************** 2\. row ***************************
thread_id: 50
PROCESSLIST_ID: 9
PROCESSLIST_DB: NULL
PROCESSLIST_STATE: Suspending
PROCESSLIST_INFO: NULL
*************************** 3\. row ***************************
thread_id: 52
PROCESSLIST_ID: 11
PROCESSLIST_DB: NULL
PROCESSLIST_STATE: Waiting for master to send event
PROCESSLIST_INFO: NULL
*************************** 4\. row ***************************
thread_id: 53
PROCESSLIST_ID: 12
PROCESSLIST_DB: NULL
PROCESSLIST_STATE: Waiting for slave workers to process their queues
PROCESSLIST_INFO: NULL
*************************** 5\. row ***************************
thread_id: 64
PROCESSLIST_ID: 23
PROCESSLIST_DB: performance_schema
PROCESSLIST_STATE: executing
PROCESSLIST_INFO: SELECT THREAD_ID, PROCESSLIST_ID, PROCESSLIST_DB, PROCESSLIST_STATE, ↩
PROCESSLIST_INFO FROM performance_schema.threads WHERE ↩
NAME != 'thread/sql/slave_worker' AND PROCESSLIST_ID IS NOT NULL
*************************** 6\. row ***************************
thread_id: 65
PROCESSLIST_ID: 24
PROCESSLIST_DB: NULL
PROCESSLIST_STATE: NULL
PROCESSLIST_INFO: flush tables with read lock
6 rows in set (0.00 sec)
在我们的示例中,问题线程是执行 FLUSH TABLES WITH READ LOCK 的线程。这是由备份程序执行的常见安全锁。既然我们知道了副本停顿的原因,我们可以选择等待此任务完成或终止该线程。完成后,副本将继续执行更新。
参见
性能故障排除是一个较长的话题,本书不涵盖更多详细信息。有关故障排除的额外信息,请参阅 MySQL 故障排除。
3.17 设置自动复制
问题
您想要设置复制,但不想手动配置它。
解决方案
使用 MySQL Shell 中提供的 MySQL Admin API(第二章)。
讨论
MySQL Shell 提供 MySQL Admin API,允许您自动化标准复制管理任务,例如使用一个或多个副本创建源服务器的 ReplicaSet,或使用 Group Replication 创建 InnoDB Cluster。
InnoDB ReplicaSet
如果要自动化复制设置,请在 MySQL Shell 中使用 MySQL Admin API 和 InnoDB ReplicaSet。InnoDB ReplicaSet 允许您创建单主复制拓扑,以及任意数量的次要只读服务器。您稍后可以将其中一个次要服务器提升为主服务器。不支持多主设置、复制过滤器和自动故障转移。
首先需要准备服务器。确保:
-
MySQL 版本为 8.0 或更新版本
-
启用 GTID 选项
gtid_mode和enforce_gtid_consistency -
二进制日志格式为
ROW -
默认存储引擎为 InnoDB:设置选项
default_storage_engine=InnoDB -
并行复制相关选项:
binlog_transaction_dependency_tracking=WRITESET replica_preserve_commit_order=ON replica_parallel_type=LOGICAL_CLOCK
警告
如果您使用的是 Ubuntu 并且希望在本地机器上设置 ReplicaSet,请编辑 /etc/hosts 文件,并删除回环地址 127.0.1.1 或替换为 127.0.0.1。MySQL Shell 不支持除 127.0.0.1 之外的回环地址。
一旦服务器为复制做好准备,您可以使用 MySQL Shell 开始配置它们:
MySQL JS > `\c root@127.0.0.1:13000`
Creating a session to 'root@127.0.0.1:13000'
Fetching schema names for autocompletion... Press ^C to stop.
Your MySQL connection id is 12
Server version: 8.0.28 MySQL Community Server - GPL
No default schema selected; type \use <schema> to set one.
MySQL 127.0.0.1:13000 ssl JS > `dba.configureReplicaSetInstance(`
-> `'root@127.0.0.1:13000', {clusterAdmin: "'repl'@'%'"})`
->
Please provide the password for 'root@127.0.0.1:13000':
Save password for 'root@127.0.0.1:13000'? [Y]es/[N]o/Ne[v]er (default No):
Configuring local MySQL instance listening at port 13000 for use in an InnoDB ReplicaSet...
This instance reports its own address as Delly-7390:13000
Clients and other cluster members will communicate with it through↩
this address by default. If this is not correct, ↩
the report_host MySQL system variable should be changed.
Password for new account: ********
Confirm password: ********
applierWorkerThreads will be set to the default value of 4.
The instance 'Delly-7390:13000' is valid to be used in an InnoDB ReplicaSet.
Cluster admin user 'repl'@'%' created.
The instance 'Delly-7390:13000' is already ready to be used in an InnoDB ReplicaSet.
Successfully enabled parallel appliers.
命令 dba.configureReplicaSetInstance 接受两个参数:用于连接到服务器的 URI 和配置选项。选项 clusterAdmin 指示创建一个复制用户。然后在提示时提供密码。
为 ReplicaSet 中的所有服务器重复配置步骤。指定相同的复制用户名和密码。
一旦所有实例配置完成,创建一个 ReplicaSet:
MySQL 127.0.0.1:13000 ssl JS > `var rs = dba.createReplicaSet("cookbook")`
A new replicaset with instance 'Delly-7390:13000' will be created.
* Checking MySQL instance at Delly-7390:13000
This instance reports its own address as Delly-7390:13000
Delly-7390:13000: Instance configuration is suitable.
* Updating metadata...
ReplicaSet object successfully created for Delly-7390:13000.
Use rs.addInstance() to add more asynchronously replicated instances to this ↩
replicaset and rs.status() to check its status.
命令 dba.createReplicaSet 创建命名 ReplicaSet 并返回 ReplicaSet 对象。将其保存到变量中以进行进一步管理。
在内部,它在 MySQL Shell 连接的实例中创建了一个描述 ReplicaSet 设置的 mysql_innodb_cluster_metadata 数据库和表。同时,这第一个实例设置为 PRIMARY ReplicaSet 成员。您可以通过运行命令 rs.status() 来检查它:
MySQL 127.0.0.1:13000 ssl JS > `rs.status()`
{
"replicaSet": {
"name": "cookbook",
"primary": "Delly-7390:13000",
"status": "AVAILABLE",
"statusText": "All instances available.",
"topology": {
"Delly-7390:13000": {
"address": "Delly-7390:13000",
"instanceRole": "PRIMARY",
"mode": "R/W",
"status": "ONLINE"
}
},
"type": "ASYNC"
}
}
一旦设置了 PRIMARY 实例,请添加尽可能多的次要实例:
MySQL 127.0.0.1:13000 ssl JS > `rs.addInstance('root@127.0.0.1:13002')`
Adding instance to the replicaset...
* Performing validation checks
This instance reports its own address as Delly-7390:13002
Delly-7390:13002: Instance configuration is suitable.
* Checking async replication topology...
* Checking transaction state of the instance...
NOTE: The target instance 'Delly-7390:13002' has not been pre-provisioned ↩
(GTID set is empty). The Shell is unable to decide whether replication can ↩
completely recover its state.
The safest and most convenient way to provision a new instance is through ↩
automatic clone provisioning, which will completely overwrite the state of ↩
'Delly-7390:13002' with a physical snapshot from an existing replicaset member. ↩
To use this method by default, set the 'recoveryMethod' option to 'clone'.
WARNING: It should be safe to rely on replication to incrementally recover ↩
the state of the new instance if you are sure all updates ever executed in ↩
the replicaset were done with GTIDs enabled, there are no purged transactions ↩
and the new instance contains the same GTID set as the replicaset or a subset ↩
of it. To use this method by default, set the 'recoveryMethod' option to 'incremental'.
Please select a recovery method [C]lone/[I]ncremental recovery/[A]bort (default Clone):
* Updating topology
Waiting for clone process of the new member to complete. Press ^C to abort the operation.
* Waiting for clone to finish...
NOTE: Delly-7390:13002 is being cloned from delly-7390:13000
** Stage DROP DATA: Completed
** Clone Transfer
FILE COPY ######################################################## 100% Completed
PAGE COPY ######################################################## 100% Completed
REDO COPY ######################################################## 100% Completed
NOTE: Delly-7390:13002 is shutting down...
* Waiting for server restart... ready
* Delly-7390:13002 has restarted, waiting for clone to finish...
** Stage RESTART: Completed
* Clone process has finished: 60.00 MB transferred in about 1 second (~60.00 MB/s)
** Configuring Delly-7390:13002 to replicate from Delly-7390:13000
** Waiting for new instance to synchronize with PRIMARY...
The instance 'Delly-7390:13002' was added to the replicaset and is replicating
from Delly-7390:13000.
每个次要实例从主要成员执行初始数据复制。它可以使用 clone 插件或从二进制日志进行增量恢复来复制数据。对于已经有数据的服务器,clone 方法是首选的。但您可能需要手动重新启动服务器以完成安装。如果选择增量恢复,请确保没有包含数据的二进制日志被清除。否则,复制设置将失败。
一旦添加了所有次要成员,ReplicaSet 就准备好可以用于写入和读取。您可以通过运行命令 rs.status() 来检查其状态。它支持选项 extended,控制输出的详细程度。但它不显示有关复制健康状况的所有信息。如果您希望获取所有细节,请使用 SHOW REPLICA STATUS 命令或查询性能模式。
如果您想要更改哪个服务器是 PRIMARY,请使用 rs.setPrimaryInstance 命令。因此,rs.setPrimaryInstance(“127.0.0.1:13002”) 将主服务器从运行在端口 13000 上的服务器切换到监听端口 13002 的服务器。
如果您从参与 ReplicaSet 的服务器断开连接或销毁了 ReplicaSet 对象,重新连接到 ReplicaSet 成员之一并运行命令 rs=dba.getReplicaSet() 来重新创建 ReplicaSet 对象。
警告
如果您希望使用 MySQL Shell 管理 ReplicaSet,请不要直接通过运行 CHANGE REPLICATION SOURCE 命令修改复制设置。所有管理都应通过 MySQL Shell 中的 Admin API 进行。
InnoDB Cluster
要自动化 Group Replication,请创建 MySQL InnoDB Cluster。InnoDB Cluster 是一个完整的高可用解决方案,允许您轻松配置和管理至少三个 MySQL 服务器的组。
在设置 InnoDB Cluster 之前,请准备好服务器。组中的每个服务器都应具有:
-
唯一的服务器 ID
-
启用 GTID
-
选项
disabled_storage_engines设置为"MyISAM,BLACKHOLE,FEDERATED,ARCHIVE,MEMORY" -
选项
log_replica_updates已启用 -
具有管理特权的用户帐户
-
与并行复制相关的选项:
binlog_transaction_dependency_tracking=WRITESET replica_preserve_commit_order=ON replica_parallel_type=LOGICAL_CLOCK transaction_write_set_extraction=XXHASH64
您可以设置其他选项(Recipe 3.12),用于组复制,但也可以通过 MySQL Shell 进行配置。
安装并启动 MySQL 实例后,将 MySQL Shell 连接到要设置为主实例的实例。您需要使用具有管理员权限的帐户(在我们的情况下是 root)启动配置过程。
MySQL 127.0.0.1:33367 ssl JS > `dba.configureInstance('root@127.0.0.1:33367',`
-> `{clusterAdmin: "grepl",`
-> `clusterAdminPassword: "greplgrepl"})`
->
Please provide the password for 'root@127.0.0.1:33367':
Configuring local MySQL instance listening at port 33367 for use in an InnoDB cluster...
This instance reports its own address as Delly-7390:33367
Clients and other cluster members will communicate with it through this address by default.
If this is not correct, the report_host MySQL system variable should be changed.
Assuming full account name 'grepl'@'%' for grepl
The instance 'Delly-7390:33367' is valid to be used in an InnoDB cluster.
Cluster admin user 'grepl'@'%' created.
The instance 'Delly-7390:33367' is already ready to be used in an InnoDB cluster.
为集群中的其他实例重复配置。
警告
如果某个实例已手动配置为组复制,MySQL Shell 将无法更新其选项,并且不能保证组复制配置在重启后持续存在。始终在设置 InnoDB Cluster 之前运行 dba.configureInstance。
在配置实例后,创建集群:
MySQL 127.0.0.1:33367 ssl JS > `var cluster = dba.createCluster('cookbook',`
-> `{localAddress: ":34367"})`
->
A new InnoDB cluster will be created on instance '127.0.0.1:33367'.
Validating instance configuration at 127.0.0.1:33367...
This instance reports its own address as Delly-7390:33367
Instance configuration is suitable.
Creating InnoDB cluster 'cookbook' on 'Delly-7390:33367'...
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.
然后将实例添加到集群中:cluster.addInstance('root@127.0.0.1:33368', {localAddress: “:34368"})。当 MySQL Shell 要求您选择恢复方法时,请选择“克隆”。然后,根据您的服务器是否支持 RESTART 命令,等待其恢复在线或手动启动节点。成功后,您将看到类似以下的消息:
State recovery already finished for 'Delly-7390:33368'
The instance '127.0.0.1:33368' was successfully added to the cluster.
将其他实例添加到集群中。
小贴士
MySQL Shell 构建了一个本地地址,组节点使用此地址通过系统变量 report_host(主机地址)和公式 (当前实例端口) * 10 + 1(端口号)进行通信。如果自动生成的值超过 65535,则实例无法添加到集群中。因此,如果使用非标准端口,请为选项 localAddress 指定自定义值。
添加实例后,InnoDB Cluster 已准备就绪。要查看其状态,请使用 cluster.status() 命令,支持 extended 键,控制输出的详细程度。默认为 0:仅打印基本信息。通过选项 2 和 3,您可以查看每个成员接收和应用的事务。命令 cluster.describe() 给出集群拓扑的简要概述。
MySQL 127.0.0.1:33367 ssl JS > `cluster.describe()`
{
"clusterName": "cookbook",
"defaultReplicaSet": {
"name": "default",
"topology": [
{
"address": "Delly-7390:33367",
"label": "Delly-7390:33367",
"role": "HA"
},
{
"address": "Delly-7390:33368",
"label": "Delly-7390:33368",
"role": "HA"
},
{
"address": "Delly-7390:33369",
"label": "Delly-7390:33369",
"role": "HA"
}
],
"topologyMode": "Single-Primary"
}
}
如果您销毁了 Cluster 对象(例如通过关闭会话),请重新连接到集群成员之一,并通过运行命令 cluster = dba.getCluster() 重新创建它。
注意
InnoDB ReplicaSet 和 InnoDB Cluster 都支持软件路由器 MySQL Router,您可以用它进行负载均衡。我们跳过了此部分,因为它超出了本书的范围。有关如何与 InnoDB ReplicaSet 和 InnoDB Cluster 设置 MySQL Router 的信息,请参阅用户参考手册。
另请参阅
关于复制自动化的更多信息,请参阅 MySQL Shell 用户参考手册。
第四章:写 MySQL-Based 程序
4.0 引言
本章讨论了如何在通用编程语言的环境中使用 MySQL。它涵盖了基本的应用程序编程接口(API)操作,这些操作是后续章节中开发编程示例的基础。这些操作包括连接到 MySQL 服务器、执行语句和检索结果。
基于 MySQL 的客户端程序可以使用多种语言编写。本书涵盖了在表 4-1 中显示的语言和接口(有关获取接口软件的信息,请参见前言):
表 4-1 本书涵盖的语言和接口
| 语言 | 接口 |
|---|---|
| Perl | Perl DBI |
| Ruby | Mysql2 gem |
| PHP | PDO |
| Python | DB API |
| Go | Go sql |
| Java | JDBC |
MySQL 客户端 API 提供以下功能,本章的各节详细介绍了每个功能:
连接到 MySQL 服务器、选择数据库和断开与服务器的连接
使用 MySQL 的每个程序必须首先与服务器建立连接。大多数程序还会选择一个默认数据库,并且表现良好的 MySQL 程序在完成后会关闭与服务器的连接。
检查错误
任何数据库操作都有可能失败。如果你知道何时发生以及原因,就可以采取适当的措施,比如终止程序或通知用户出现问题。
执行 SQL 语句和检索结果
连接到数据库服务器的目的是执行 SQL 语句。每个 API 都至少提供一种执行此操作的方式,以及处理语句结果的方法。
处理语句中的特殊字符和NULL值
数据值可以直接嵌入到语句字符串中。然而,某些字符如引号和反斜杠具有特殊含义,使用它们需要采取特定的预防措施。对于NULL值也是如此。如果处理不当,你的程序将生成错误的 SQL 语句或产生意外结果。如果将来自外部来源的数据合并到查询中,你的程序可能会成为 SQL 注入攻击的目标。大多数 API 通过使用占位符来避免这些问题,占位符在执行语句时以符号方式引用数据值,并单独提供这些值。API 将数据插入语句字符串中,正确编码任何特殊字符或NULL值。占位符也称为参数标记。
在结果集中标识NULL值
NULL值不仅在构造语句时是特殊的,在返回的结果中也是如此。每个 API 都提供了一种识别和处理它们的约定。
无论您使用哪种编程语言,都需要知道如何执行刚才描述的每个基本数据库 API 操作,因此本章展示了这五种语言中的每个操作。了解每个 API 如何处理给定操作应该有助于您更轻松地看到 API 之间的对应关系,并更好地理解接下来章节中显示的配方,即使它们是用您不太熟悉的语言编写的。(后续章节通常仅使用一种或两种语言实现配方。)
如果您只对特定 API 的一个语言感兴趣,看到每个配方都用几种语言编写可能会让人感到不知所措。如果是这样,请建议您仅阅读提供一般背景信息的介绍性配方部分,然后直接转到您感兴趣的语言部分。跳过其他语言;如果以后对它们产生兴趣,请回来再了解它们。
本章还讨论了以下几个主题,虽然它们不是 MySQL API 的直接组成部分,但有助于更轻松地使用它们:
写作库文件
随着您编写程序,您会发现重复执行某些操作。库文件可以封装这些操作的代码,使其可以在多个脚本中轻松执行,而无需在每个脚本中重复编写代码。这减少了代码重复,并使您的程序更具可移植性。本章展示了如何为每个 API 编写库文件,其中包括用于连接到服务器的例行操作,这是每个使用 MySQL 的程序必须执行的操作之一。后续章节将为其他操作开发额外的库程序。
获取连接参数的其他技术
早期关于连接到 MySQL 服务器的部分依赖于硬编码到代码中的连接参数。然而,还有其他(更好的)获取参数的方式,从将其存储在单独的文件中到允许用户在运行时指定。
为了避免在示例程序中手动输入,获取recipes源代码分发的副本(参见前言)。然后,当一个示例说“创建一个名为xyz的文件,其中包含以下信息…”时,你可以使用recipes分发中对应的文件。本章的大多数脚本位于api目录下;库文件位于lib目录下。
本章中用于示例的主要表格名为profile。它首次出现在 Recipe 4.4 中,如果您跳转章节并想知道它来自哪里,请了解这一点。还请参阅本章末尾关于将profile表重置为已知状态以在其他章节中使用的部分。
注意
讨论的程序可以从命令行运行。有关如何调用本章涵盖的每种语言的程序的说明,请阅读recipes分发中的cmdline.pdf。
假设
要最有效地使用本章的材料,请确保满足以下要求:
-
为您计划使用的任何语言安装 MySQL 编程支持(请参阅前言)。
-
你应该已经为访问服务器设置了一个 MySQL 用户帐户和一个用于执行 SQL 语句的数据库。如食谱 1.1 所述,本书中的示例使用的是一个 MySQL 帐户,用户名和密码分别为
cbuser和cbpass,我们将连接到运行在本地主机上的 MySQL 服务器,以访问名为cookbook的数据库。要创建帐户或数据库,请参阅该食谱中的说明。 -
此处的讨论展示了如何使用每种 API 语言执行数据库操作,但假定您对语言本身有基本的理解。如果某个食谱使用您不熟悉的编程构造,请查阅相关语言的通用参考书。
-
一些程序的正确执行可能需要您设置某些环境变量。关于如何设置环境变量的一般语法,请参阅配方分发中的
cmdline.pdf(请参阅前言)。有关专门适用于库文件位置的环境变量的详细信息,请参阅食谱 4.3。
MySQL 客户端 API 架构
本书涵盖的每个 MySQL 编程接口都使用了两级架构:
-
上层提供了独立于数据库的方法,以便以与使用 MySQL、PostgreSQL、Oracle 或其他任何数据库相同的便携方式访问数据库。
-
下层由一组驱动程序组成,每个驱动程序实现了单个数据库系统的详细信息。
这种两级架构使应用程序能够使用与任何特定数据库服务器的详细信息无关的抽象接口。这增强了程序的可移植性:要使用不同的数据库系统,只需选择不同的下层驱动程序。但是,完全的可移植性是难以实现的:
-
无论您使用哪个驱动程序,架构的上层提供的接口方法都是一致的,但仍然有可能编写只支持特定服务器的 SQL 语句。例如,MySQL 具有
SHOW语句,用于提供关于数据库和表结构的信息,但在非 MySQL 服务器上使用SHOW可能会导致错误。 -
下层驱动程序经常扩展抽象接口,使访问数据库特定功能更方便。例如,Perl DBI 的 MySQL 驱动程序可以将最新的
AUTO_INCREMENT值作为数据库句柄属性$dbh->{mysql_insertid}提供。这些功能使程序更易于编写,但更不可移植。要将程序用于另一个数据库系统,可能需要进行一些重写。
尽管这些因素在某种程度上影响可移植性,但两级架构的一般可移植特性为 MySQL 开发人员提供了显著的好处。
本书中使用的 API 共同的另一个特点是它们是面向对象的。无论您使用 Perl、Ruby、PHP、Python、Java 还是 Go 编写代码,连接到 MySQL 服务器的操作都会返回一个对象,使您能够以面向对象的方式处理语句。例如,当您连接到数据库服务器时,您会得到一个数据库连接对象,可以进一步与服务器交互。接口还提供语句、结果集、元数据等对象。
现在让我们看看如何使用这些编程接口执行最基本的 MySQL 操作:连接到服务器和断开连接。
4.1 连接,选择数据库和断开连接
问题
需要在连接到数据库服务器时建立连接,在完成后关闭连接。
解决方案
每个 API 都提供了连接和断开连接的例程。连接例程要求您提供指定 MySQL 服务器运行的主机和要使用的 MySQL 帐户的参数。您还可以选择一个默认数据库。
讨论
本节展示了执行大多数 MySQL 程序共同的一些基本操作的方法:
建立连接到 MySQL 服务器
使用 MySQL 的每个程序都要做到这一点,无论使用哪种 API。在指定连接参数方面的细节因 API 而异,有些 API 提供的灵活性更大。但是,有许多共同的参数,如运行服务器的主机,以及用于访问服务器的 MySQL 帐户的用户名和密码。
选择数据库
大多数 MySQL 程序选择一个默认数据库。
从服务器断开连接
每个 API 都提供了关闭打开连接的方法。最好在使用服务器后立即关闭连接。如果您的程序保持连接时间超过必要时间,服务器将无法释放为服务连接分配的资源。显式关闭连接也是首选。如果程序简单地终止,MySQL 服务器最终会注意到,但用户端的显式关闭使服务器能够在其端执行立即有序的关闭。
本节包括示例程序,展示如何使用每个 API 连接到服务器,选择cookbook数据库并断开连接。每个 API 的讨论还说明了如何在不选择任何默认数据库的情况下连接。如果您计划执行不需要默认数据库的语句,例如SHOW VARIABLES或SELECT VERSION(),或者编写一个允许用户在连接后指定数据库的程序,这可能是适用的情况。
提示
这里显示的脚本使用localhost作为主机名。如果它们产生连接错误,指示找不到套接字文件,请尝试将localhost更改为本地主机的 TCP/IP 地址127.0.0.1。本提示适用于整本书。
Perl
要在 Perl 中编写 MySQL 脚本,必须安装 DBI 模块以及 MySQL 特定的驱动程序模块 DBD::mysql。如果这些模块尚未安装,请参阅前言获取这些模块。
下面的 Perl 脚本connect.pl连接到 MySQL 服务器,选择cookbook作为默认数据库,并断开连接:
#!/usr/bin/perl
# connect.pl: connect to the MySQL server
use strict;
use warnings;
use DBI;
my $dsn = "DBI:mysql:host=localhost;database=cookbook";
my $dbh = DBI->connect ($dsn, "cbuser", "cbpass")
or die "Cannot connect to server\n";
print "Connected\n";
$dbh->disconnect ();
print "Disconnected\n";
要尝试connect.pl,请在recipes分发的api目录下找到它,并从命令行运行。程序应打印两行,指示成功连接和断开连接:
$ `perl connect.pl`
Connected
Disconnected
在本节的其余部分中,我们将浏览代码并解释其工作原理。
提示
如果连接到 MySQL 8.0 时出现Access Denied错误,请确保 DBD::MySQL 的版本与 MySQL 8.0 客户端库链接,或者使用身份验证插件mysql_native_password而不是默认的caching_sha2_password插件。我们在第 24.2 章中讨论了身份验证插件。
关于运行 Perl 程序的背景,请阅读配方分发中的cmdline.pdf(参见前言)。
use strict行打开严格变量检查,并导致 Perl 对任何在未声明的情况下使用的变量抱怨。这种预防措施有助于发现否则可能未被察觉的错误。
use warnings行打开警告模式,以便 Perl 对任何可疑的结构生成警告。我们的示例脚本没有问题,但养成在脚本开发过程中启用警告以捕获问题的习惯是个好主意。use warnings类似于指定 Perl -w命令行选项,但提供了更多控制要显示哪些警告的功能。(有关更多信息,请执行perldoc warnings命令。)
use DBI语句告诉 Perl 加载 DBI 模块。当脚本连接到数据库服务器时,显式加载 MySQL 驱动程序模块(DBD::mysql)是不必要的,DBI 在连接时会自行处理。
下面的两行通过设置数据源名称(DSN)和调用 DBI connect()方法来建立与 MySQL 的连接。connect()的参数是 DSN、MySQL 用户名和密码,以及您想指定的任何连接属性。DSN 是必需的。其他参数是可选的,尽管通常需要提供用户名和密码。
DSN 指定要使用的数据库驱动程序和指示连接位置的其他选项。对于 MySQL 程序,DSN 的格式为DBI:mysql:*`options`*。即使您未指定后续选项,DSN 中的第二个冒号也是必需的。
使用 DSN 组件如下:
-
第一个组件始终是
DBI。大小写不敏感。 -
第二个组件告诉 DBI 要使用哪个数据库驱动程序,并且区分大小写。对于 MySQL,名称必须是
mysql。 -
如果存在第三个组件,则是以分号分隔的
name=value对的列表,用于指定任意顺序的额外连接选项。对于我们的目的,最相关的两个选项是host和database,用于指定 MySQL 服务器运行的主机名和默认数据库。
根据这些信息,连接到本地主机localhost上的cookbook数据库的 DSN 如下所示:
DBI:mysql:host=localhost;database=cookbook
如果省略host选项,则其默认值为localhost。这两个 DSN 是等效的:
DBI:mysql:host=localhost;database=cookbook
DBI:mysql:database=cookbook
要选择没有默认数据库,请省略database选项。
connect() 调用的第二和第三个参数是您的 MySQL 用户名和密码。在密码之后,您还可以提供第四个参数来指定在发生错误时控制 DBI 行为的属性。如果没有属性,DBI 默认在发生错误时打印错误消息但不终止您的脚本。这就是为什么connect.pl 检查connect() 是否返回undef(表示失败)的原因:
my $dbh = DBI->connect ($dsn, "cbuser", "cbpass")
or die "Cannot connect to server\n";
可能还有其他错误处理策略。例如,要告诉 DBI 在任何 DBI 调用中发生错误时终止脚本,请禁用PrintError属性,改为启用RaiseError:
my $dbh = DBI->connect ($dsn, "cbuser", "cbpass",
{PrintError => 0, RaiseError => 1});
然后您无需自行检查错误。权衡是,您也失去了决定程序如何从错误中恢复的能力。配方 4.2 进一步讨论了错误处理。
另一个常见的属性是AutoCommit,它设置事务的连接自动提交模式。MySQL 默认为新连接启用此功能,但从现在开始我们将显式设置初始连接状态:
my $dbh = DBI->connect ($dsn, "cbuser", "cbpass",
{PrintError => 0, RaiseError => 1, AutoCommit => 1});
如所示,connect() 的第四个参数是对属性名称/值对的哈希引用。编写此代码的另一种方法如下:
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
my $dbh = DBI->connect ($dsn, "cbuser", "cbpass", $conn_attrs);
使用您喜欢的任何风格。本书中的脚本使用$conn_attr 的哈希引用使connect() 调用更易读。
假设connect() 成功,它将返回一个包含连接状态信息的数据库句柄。 (在 DBI 术语中,对象的引用称为句柄。)稍后我们将看到其他句柄,如与特定语句相关联的语句句柄。本书中的 Perl DBI 脚本惯例上使用$dbh和$sth分别表示数据库和语句句柄。
额外的连接参数
要在 Unix 上的localhost连接中指定套接字文件的路径,请在 DSN 中提供mysql_socket选项:
my $dsn = "DBI:mysql:host=localhost;database=cookbook"
. ";mysql_socket=/var/tmp/mysql.sock";
要为非localhost(TCP/IP)连接指定端口号,请提供port选项:
my $dsn = "DBI:mysql:host=127.0.0.1;database=cookbook;port=3307";
Ruby
要在 Ruby 中编写 MySQL 脚本,必须安装 Mysql2 gem。如果尚未安装此 gem,请参阅前言。
以下 Ruby 脚本connect.rb连接到 MySQL 服务器,选择cookbook作为默认数据库,并断开连接:
#!/usr/bin/ruby -w
# connect.rb: connect to the MySQL server
require "mysql2"
begin
client = Mysql2::Client.new(:host => "localhost",
:username => "cbuser",
:password => "cbpass",
:database => "cookbook")
puts "Connected"
rescue => e
puts "Cannot connect to server"
puts e.backtrace
exit(1)
ensure
client.close()
puts "Disconnected"
end
要尝试connect.rb,请在recipes分发的api目录下找到它,并从命令行运行。程序应打印两行,指示成功连接和断开连接:
$ `ruby connect.rb`
Connected
Disconnected
有关运行 Ruby 程序的背景,请阅读配方分发中的cmdline.pdf(请参阅前言)。
-w选项打开警告模式,以便 Ruby 对任何可疑构造生成警告。我们的示例脚本没有这样的构造,但养成在脚本开发过程中使用-w来捕捉问题的习惯是个好主意。
require语句告诉 Ruby 加载 Mysql2 模块。
要建立连接,请创建Mysql2::Client对象。将连接参数作为new方法的命名参数传递。
要选择无默认数据库,请省略database选项。
假设成功创建Mysql2::Client对象,则其将充当包含连接状态信息的数据库句柄。本书中的 Ruby 脚本通常使用client表示数据库句柄对象。
如果new()方法失败,则会引发异常。要处理异常,请将可能失败的语句放入begin块中,并使用包含错误处理代码的rescue子句。在脚本的顶层(即任何begin块之外)发生的异常将被默认异常处理程序捕获,后者会打印堆栈跟踪并退出。配方 4.2 进一步讨论了错误处理。
其他连接参数
要为 Unix 上的localhost连接指定套接字文件路径,请为new方法提供socket选项:
client = Mysql2::Client.new(:host => "localhost",
:socket => "/var/tmp/mysql.sock",
:username => "cbuser",
:password => "cbpass",
:database => "cookbook")
要为非localhost(TCP/IP)连接指定端口号,请提供port选项:
client = Mysql2::Client.new(:host => "127.0.0.1",
:port => 3307,
:username => "cbuser",
:password => "cbpass",
:database => "cookbook")
PHP
要编写使用 MySQL 的 PHP 脚本,您的 PHP 解释器必须已编译有 MySQL 支持。如果您的脚本无法连接到 MySQL 服务器,请查看随 PHP 分发的说明,了解如何启用 MySQL 支持。
PHP 实际上有多个扩展程序可以使用 MySQL,例如mysql,原始且现在已弃用的 MySQL 扩展;mysqli,改进的 MySQL 扩展;以及最近的 MySQL PDO(PHP 数据对象)接口驱动程序。本书中的 PHP 脚本使用 PDO。如果尚未安装 PHP 和 PDO,请参阅前言。
PHP 脚本通常是为与 Web 服务器一起使用而编写的。我假设如果您以这种方式使用 PHP,您可以将 PHP 脚本复制到服务器的文档树中,从浏览器请求它们,它们将执行。例如,如果您在主机 localhost 上运行 Apache 作为 Web 服务器,并且您在 Apache 文档树的顶层安装了名为 myscript.php 的 PHP 脚本,您应该能够通过请求以下 URL 来访问该脚本:
http://localhost/myscript.php
本书使用 .php 扩展名(后缀)作为 PHP 脚本文件名,因此您的 Web 服务器必须配置为识别 .php 扩展名。否则,当您从浏览器请求 PHP 脚本时,服务器只会发送脚本的文字内容,这将显示在您的浏览器窗口中。您不希望发生这种情况,特别是如果脚本包含连接到 MySQL 的用户名和密码。
PHP 脚本通常是 HTML 和 PHP 代码的混合体,其中 PHP 代码嵌入在特殊的 <?php 和 ?> 标签之间。以下是一个示例:
<html>
<head><title>A simple page</title></head>
<body>
<p>
<?php
print ("I am PHP code, hear me roar!");
?>
</p>
</body>
</html>
为了在完全由 PHP 代码组成的示例中简洁起见,通常会省略包围的 <?php 和 ?> 标签。如果您在 PHP 示例中看不到标签,请假设 <?php 和 ?> 包围着显示的整个代码块。在 HTML 和 PHP 代码之间切换的示例中会包含标签,以明确显示哪些是 PHP 代码,哪些不是。
PHP 可以配置为识别 short
标签,写作 <? 和 ?>。本书假设您未启用短标签并且不使用它们。
以下 PHP 脚本,connect.php,连接到 MySQL 服务器,选择 cookbook 作为默认数据库,并断开连接:
<?php
# connect.php: connect to the MySQL server
try
{
$dsn = "mysql:host=localhost;dbname=cookbook";
$dbh = new PDO ($dsn, "cbuser", "cbpass");
print ("Connected\n");
}
catch (PDOException $e)
{
die ("Cannot connect to server\n");
}
$dbh = NULL;
print ("Disconnected\n");
?>
要尝试 connect.php,请在 recipes 分发的 api 目录下找到它,将其复制到您的 Web 服务器文档树中,并使用浏览器请求它。或者,如果您有独立版本的 PHP 解释器用于命令行使用,则可以直接执行脚本:
$ `php connect.php`
Connected
Disconnected
有关运行 PHP 程序的背景,请阅读 cmdline.pdf 在 recipes 分发中(请参阅 前言)。
$dsn 是数据源名称 (DSN),指示如何连接到数据库服务器。它具有以下一般语法:
*`driver`*:*`name=value`*;*`name=value`* ...
driver 值是 PDO 驱动程序类型。对于 MySQL,这是 mysql。
在驱动程序名称之后,以分号分隔的 name=value 对指定连接参数,顺序任意。对我们来说,两个最相关的选项是 host 和 dbname,用于指定 MySQL 服务器运行的主机名和默认数据库。要选择没有默认数据库,请省略 dbname 选项。
要建立连接,调用new PDO()类构造函数,并传递适当的参数。DSN 是必需的。其他参数是可选的,尽管通常需要提供用户名和密码。如果连接尝试成功,new PDO()将返回一个数据库句柄对象,用于访问其他与 MySQL 相关的方法。本书中的 PHP 脚本惯例上使用$dbh表示数据库句柄。
如果连接尝试失败,PDO 会引发一个异常。为了处理这种情况,将连接尝试放在一个try块中,并使用一个catch块包含错误处理代码,或者让异常终止你的脚本。4.2 小节进一步讨论了错误处理。
要断开连接,请将数据库句柄设置为NULL。没有显式的断开调用。
额外的连接参数
要在 Unix 上指定本地主机连接的套接字文件路径,请在 DSN 中提供unix_socket选项:
$dsn = "mysql:host=localhost;dbname=cookbook"
. ";unix_socket=/var/tmp/mysql.sock";
要为非本地主机(TCP/IP)连接指定端口号,请提供port选项:
$dsn = "mysql:host=127.0.0.1;database=cookbook;port=3307";
Python
要在 Python 中编写 MySQL 程序,必须安装一个模块,该模块为 Python DB API(即 Python 数据库 API 规范 v2.0,PEP 249)提供 MySQL 连接。本书使用 MySQL Connector/Python。如果尚未安装,请参阅前言获取。
要使用 DB API,在要使用的数据库驱动模块中导入它(对于使用 Connector/Python 的 MySQL 程序来说,这是mysql.connector)。然后通过调用驱动程序的connect()方法创建一个数据库连接对象。该对象提供了访问其他 DB API 方法的途径,例如服务于数据库服务器的close()方法。
以下 Python 脚本connect.py连接到 MySQL 服务器,选择cookbook作为默认数据库,并断开连接:
#!/usr/bin/python3
# connect.py: connect to the MySQL server
import mysql.connector
try:
conn = mysql.connector.connect(database="cookbook",
host="localhost",
user="cbuser",
password="cbpass")
print("Connected")
except:
print("Cannot connect to server")
else:
conn.close()
print("Disconnected")
要尝试connect.py,请在recipes发行版的api目录下找到它,并从命令行运行。程序应该打印两行指示成功连接和断开连接:
$ `python3 connect.py`
Connected
Disconnected
关于运行 Python 程序的背景,请阅读recipes发行版中的cmdline.pdf(参见前言)。
import行告诉 Python 加载mysql.connector模块。然后脚本尝试通过调用connect()建立与 MySQL 服务器的连接以获取连接对象。本书中的 Python 脚本惯例上使用conn表示连接对象。
如果 connect() 方法失败,Connector/Python 将引发异常。要处理异常,请将可能失败的语句放在 try 语句中,并使用包含错误处理代码的 except 子句。在脚本的顶层(即在任何 try 语句之外)发生的异常会被默认的异常处理程序捕获,该程序会打印堆栈跟踪并退出。Recipe 4.2 进一步讨论了错误处理。
else 子句包含在 try 子句未产生异常时执行的语句。这里用于关闭成功打开的连接。
因为 connect() 调用使用了命名参数,它们的顺序并不重要。如果从 connect() 调用中省略 host 参数,则其默认值为 127.0.0.1。要选择没有默认数据库,请省略 database 参数或传递 ""(空字符串)或 None 作为 database 值。
另一种连接方法是使用 Python 字典指定参数,并将字典传递给 connect():
conn_params = {
"database": "cookbook",
"host": "localhost",
"user": "cbuser",
"password": "cbpass",
}
conn = mysql.connector.connect(**conn_params)
print("Connected")
从现在开始,本书通常会使用这种风格。
附加连接参数
要在 Unix 上指定本地主机连接的套接字文件路径,请省略 host 参数并提供 unix_socket 参数:
conn_params = {
"database": "cookbook",
"unix_socket": "/var/tmp/mysql.sock",
"user": "cbuser",
"password": "cbpass",
}
conn = mysql.connector.connect(**conn_params)
print("Connected")
要为 TCP/IP 连接指定端口号,请包含 host 参数并提供整数值 port 参数:
conn_params = {
"database": "cookbook",
"host": "127.0.0.1",
"port": 3307,
"user": "cbuser",
"password": "cbpass",
}
conn = mysql.connector.connect(**conn_params)
Go
要在 Go 中编写 MySQL 程序,必须安装 Go SQL 驱动程序。本书使用 Go-MySQL-Driver。如果尚未安装,请安装 Git,然后执行以下命令。
$ go get -u github.com/go-sql-driver/mysql
要使用 Go SQL 接口,导入 database/sql 包和您的驱动程序包。然后通过调用 sql.Open() 函数创建数据库连接对象。此对象提供访问其他 database/sql 包函数的功能,如 db.Close() 关闭与数据库服务器的连接。我们还使用 defer 语句调用 db.Close(),以确保函数调用在程序执行的后续阶段执行。在本章中,您将看到这种用法。
提示
Go 包 database/sql 和 Go-MySQL-Driver 支持上下文取消。这意味着您可以取消数据库操作(如运行查询),如果取消上下文。要使用此功能,您需要调用 sql 接口的支持上下文的函数。出于简洁起见,在本章的示例中,我们不会使用 Context。当讨论事务处理时,我们将在 Recipe 20.9 中展示使用 Context 的示例。
下面的 Go 脚本,connect.go,连接到 MySQL 服务器,选择 cookbook 作为默认数据库,并断开连接:
// connect.go: connect to MySQL server
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql", "cbuser:cbpass@tcp(127.0.0.1:3306)/cookbook")
if err != nil {
log.Fatal(err)
}
defer db.Close()
err = db.Ping()
if err != nil {
log.Fatal(err)
}
fmt.Println("Connected!")
}
要尝试 connect.go,请在 recipes 发行版的 api/01_connect 目录下找到它,并从命令行运行。程序应打印一行表示已连接:
$ `go run connect.go`
Connected!
import语句告诉 Go 加载go-sql-driver/mysql包。然后脚本通过调用sql.Open()验证连接参数并获取连接对象。还没有建立 MySQL 连接!
如果sql.Open()方法失败,go-sql-driver/mysql会返回一个错误。要处理错误,将其存储在一个变量中(在我们的例子中为err),并使用包含错误处理代码的if块。第 4.2 节进一步讨论了错误处理。
db.Ping()调用建立数据库连接。只有在这一刻我们才能说成功连接到 MySQL 服务器。
附加连接参数
要在 Unix 上指定本地主机连接的套接字文件路径,请在 DSN 中省略tcp参数并提供unix参数:
// connect_socket.go : Connect MySQL server using socket
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql","cbuser:cbpass@unix(/tmp/mysql.sock)/cookbook")
defer db.Close()
if err != nil {
log.Fatal(err)
}
var user string
err = db.QueryRow("SELECT USER()").Scan(&user)
if err != nil {
log.Fatal(err)
}
fmt.Println("Connected User:", user, "via MySQL socket")
}
运行这个程序:
$ `go run connect_socket.go`
Connected User: cbuser@localhost via MySQL socket
要指定 TCP/IP 连接的端口号,请在 DSN 中包括tcp参数并提供一个整数值port端口号:
// connect_tcpport.go : Connect MySQL server using tcp port number
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
func main() {
db, err := sql.Open("mysql",
"cbuser:cbpass@tcp(127.0.0.1:3306)/cookbook?charset=utf8mb4")
if err != nil {
log.Fatal(err)
}
var user string
err2 := db.QueryRow("SELECT USER()").Scan(&user)
if err2 != nil {
log.Fatal(err2)
}
fmt.Println("Connected User:", user, "via MySQL TCP/IP localhost on port 3306")
}
运行这个程序:
$ `go run connect_tcpport.go`
Connected User: cbuser@localhost via MySQL TCP/IP localhost on port 3306
Go 接受这种形式的 DSN(数据源名称):
[username[:password]@][protocol[(address)]]/dbname[?param1=value1&..¶mN=valueN]
其中protocol可以是tcp或unix。
完整形式的 DSN 如下所示:
username:password@protocol(address)/dbname?param=value
Java
Java 中的数据库程序使用 JDBC 接口,以及要访问的特定数据库引擎的驱动程序。也就是说,JDBC 架构提供了一个通用接口,与数据库特定的驱动程序结合使用。
Java 编程需要 Java 开发工具包(JDK),您必须将JAVA_HOME环境变量设置为安装 JDK 的位置。要编写基于 MySQL 的 Java 程序,还需要一个特定于 MySQL 的 JDBC 驱动程序。本书中使用 MySQL Connector/J。如果未安装,请参阅前言获取它。有关获取 JDK 和设置JAVA_HOME的信息,请阅读 recipes 发行版中的cmdline.pdf(请参阅前言)。
下面的 Java 程序Connect.java连接到 MySQL 服务器,选择cookbook作为默认数据库,并断开连接:
// Connect.java: connect to the MySQL server
import java.sql.*;
public class Connect {
public static void main (String[] args) {
Connection conn = null;
String url = "jdbc:mysql://localhost/cookbook";
String userName = "cbuser";
String password = "cbpass";
try {
conn = DriverManager.getConnection (url, userName, password);
System.out.println("Connected");
} catch (Exception e) {
System.err.println("Cannot connect to server");
System.exit (1);
}
if (conn != null) {
try {
conn.close();
System.out.println("Disconnected");
} catch (Exception e) { /* ignore close errors */ }
}
}
}
要尝试Connect.java,请找到它在recipes发行版的api目录下,编译并执行它。class语句指示程序的名称,在这种情况下是Connect。包含程序的文件名必须与此名称匹配,并包括.java扩展名,因此程序的文件名是Connect.java。使用javac编译程序:
$ `javac Connect.java`
如果你喜欢不同的 Java 编译器,请将其名称替换为javac。
Java 编译器生成编译的字节码,生成名为Connect.class的类文件。使用java程序运行该类文件(不带.class扩展名)。程序应打印两行表示成功连接和断开连接:
$ `java Connect`
Connected
Disconnected
在示例程序能够编译和运行之前,您可能需要设置您的 CLASSPATH 环境变量。CLASSPATH 的值应至少包括当前目录(.)和 Connector/J JDBC 驱动程序的路径。有关运行 Java 程序或设置 CLASSPATH 的背景,请阅读配方分发中的 cmdline.pdf(参见 前言)。
提示
从 Java 11 开始,可以跳过 javac 调用,直接运行单文件程序,如下所示:
$ `java Connect.java`
Connected
Disconnected
import java.sql.* 语句引用了类和接口,这些类和接口提供了访问用于管理与数据库服务器交互的不同数据类型的功能。所有 JDBC 程序都需要这些。
要连接到服务器,请调用 DriverManager.getConnection() 来初始化连接并获取一个 Connection 对象,该对象维护有关连接状态的信息。本书中的 Java 程序通常使用 conn 表示连接对象。
DriverManager.getConnection() 接受三个参数:描述连接位置和要使用的数据库的 URL 字符串,MySQL 用户名和密码。URL 字符串的格式如下:
jdbc:*`driver`*://*`host_name`*/*`db_name`*
此格式遵循 Java 的约定,用于连接网络资源的 URL 必须以协议标识符开头。对于 JDBC 程序,协议是 jdbc,还需要一个子协议标识符来指定驱动程序名称(MySQL 程序使用 mysql)。连接 URL 的许多部分都是可选的,但是协议和子协议标识符是必需的。如果省略 host_name,默认主机值为 localhost。如果不选择默认数据库,则应省略数据库名称。但无论如何都不应省略任何斜杠。例如,要连接到本地主机而不选择默认数据库,URL 如下:
jdbc:mysql:///
在 JDBC 中,不要测试返回值指示错误的方法调用。相反,提供处理程序以在抛出异常时调用。第 4.2 节 进一步讨论了错误处理。
一些 JDBC 驱动程序(包括 Connector/J)允许您在 URL 的末尾指定用户名和密码作为参数。在这种情况下,可以省略 getConnection() 调用的第二个和第三个参数。使用这种 URL 样式,可以像下面这样编写建立连接的代码:
// connect using username and password included in URL
Connection conn = null;
String url = "jdbc:mysql://localhost/cookbook?user=cbuser&password=cbpass";
try
{
conn = DriverManager.getConnection (url);
System.out.println ("Connected");
}
分隔 user 和 password 参数的字符应为 &,而不是 ;。
额外的连接参数
Connector/J 不原生支持 Unix 域套接字文件连接,因此即使主机名为 localhost 的连接也是通过 TCP/IP 进行的。要指定显式端口号,请在连接 URL 中的主机名后添加 :port_num:
String url = "jdbc:mysql://127.0.0.1:3307/cookbook";
但是,您可以使用第三方库来支持通过套接字进行连接。有关详细信息,请参阅 使用 Unix 域套接字连接 用户参考手册页面。
4.2 检查错误
问题
程序出了问题,但你不知道是什么。
解决方案
每个人在使程序正常工作方面都会遇到问题。但如果你不通过检查错误来预期问题,那么工作将变得更加困难。添加一些错误检查代码,让你的程序能帮助你找出问题所在。
讨论
在完成 Recipe 4.1 后,你知道如何连接到 MySQL 服务器。了解如何检查错误并从 API 中检索特定的错误信息也是一个好主意,接下来我们会涵盖这些内容。你可能急于做更有趣的事情(如执行语句并获取结果),但错误检查基本上是非常重要的。程序有时会失败,特别是在开发过程中,如果你不能确定失败的原因,那么你就是在盲目操作。通过检查错误来规划失败,以便你可以采取适当的措施。
当发生错误时,MySQL 提供了三个值:
-
一个特定于 MySQL 的错误编号
-
MySQL 特定的描述性文本错误消息
-
根据 ANSI 和 ODBC 标准定义的五字符 SQLSTATE 错误代码
本示例展示了如何访问这些信息。示例程序有意设计成失败的,以便执行错误处理代码。这就是为什么它们尝试使用用户名baduser和密码badpass连接。
提示
一种不特定于任何 API 的常规调试辅助工具是使用可用的日志。检查 MySQL 服务器的一般查询日志以查看服务器接收到的语句。 (这需要启用日志记录;参见 Recipe 22.3。)一般查询日志可能显示你的程序未构建你期望的 SQL 语句字符串。类似地,如果在 Web 服务器下运行脚本时失败,请检查 Web 服务器的错误日志。
Perl
DBI 模块提供了两个属性来控制 DBI 方法调用失败时的处理方式:
-
如果启用了
PrintError,DBI 会使用warn()打印错误消息。 -
RaiseError如果启用,会导致 DBI 使用die()打印错误消息。这将终止你的脚本。
默认情况下,启用了PrintError,而RaiseError未启用,因此在打印消息后脚本继续执行。可以在connect()调用中指定一个或两个属性。将属性设置为 1 或 0 可分别启用或禁用它们。要指定一个或两个属性,请将它们作为第四个参数传递给connect()调用的哈希引用。
以下代码仅设置AutoCommit属性,并使用默认设置处理错误属性。如果connect()调用失败,将产生警告消息,但脚本会继续执行:
my $conn_attrs = {AutoCommit => 1};
my $dbh = DBI->connect ($dsn, "baduser", "badpass", $conn_attrs);
如果连接尝试失败,你真的不能做什么,因此在 DBI 打印消息后退出通常是明智的选择:
my $conn_attrs = {AutoCommit => 1};
my $dbh = DBI->connect ($dsn, "baduser", "badpass", $conn_attrs)
or exit;
要打印自己的错误消息,请禁用RaiseError并禁用PrintError。然后自行测试 DBI 方法调用的结果。当方法失败时,$DBI::err、$DBI::errstr和$DBI::state变量分别包含 MySQL 错误编号、描述性错误字符串和 SQLSTATE 值:
my $conn_attrs = {PrintError => 0, AutoCommit => 1};
my $dbh = DBI->connect ($dsn, "baduser", "badpass", $conn_attrs)
or die "Connection error: "
. "$DBI::errstr ($DBI::err/$DBI::state)\n";
如果没有错误发生,$DBI::err为 0 或undef,$DBI::errstr为空字符串或undef,$DBI::state为空或00000。
当检查错误时,立即在调用设置它们的 DBI 方法之后访问这些变量。如果在使用它们之前调用另一个方法,DBI 会重置它们的值。
如果打印自己的消息,使用默认设置(启用PrintError,禁用RaiseError)并不那么有用。DBI 会自动打印一条消息,然后你的脚本会打印自己的消息。这是多余的,也会让使用脚本的人感到困惑。
如果启用了RaiseError,可以调用 DBI 方法而无需检查指示错误的返回值。如果方法失败,DBI 会打印一个错误并终止脚本。如果方法返回,可以假定它成功了。这对于脚本编写者来说是最简单的方法:让 DBI 做所有的错误检查!但是,如果同时启用了PrintError和RaiseError,DBI 可能会连续调用warn()和die(),导致错误消息被打印两次。为了避免这个问题,在启用RaiseError时禁用PrintError:
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
my $dbh = DBI->connect ($dsn, "baduser", "badpass", $conn_attrs);
本书一般采用这种方法。如果你不想启用RaiseError来进行自动错误检查,也不想完全自己进行检查,可以采用混合方法。单独的句柄具有可以选择启用或禁用的PrintError和RaiseError属性。例如,你可以在调用connect()时全局启用RaiseError,然后在每个句柄上选择性地禁用它。
假设脚本从命令行参数中读取用户名和密码,然后在用户输入要执行的语句时循环。在这种情况下,如果连接失败,你可能希望 DBI 自动终止并打印错误消息(在这种情况下,不能继续到语句执行循环)。然而,在连接之后,你不希望脚本仅因为用户输入了语法无效的语句而退出。相反,请打印错误消息并循环以获取下一个语句。以下代码展示了如何做到这一点。示例中使用的do()方法执行一个语句并返回undef以指示错误:
my $user_name = shift (@ARGV);
my $password = shift (@ARGV);
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
my $dbh = DBI->connect ($dsn, $user_name, $password, $conn_attrs);
$dbh->{RaiseError} = 0; # disable automatic termination on error
print "Enter statements to execute, one per line; terminate with Control-D\n";
while (<>) # read and execute queries
{
$dbh->do ($_) or warn "Statement failed: $DBI::errstr ($DBI::err)\n";
}
如果启用了RaiseError,可以在eval块中执行代码来捕获错误,而不终止程序。如果发生错误,eval会在$@变量中返回一条消息:
eval
{
# statements that might fail go here...
};
if ($@)
{
print "An error occurred: $@\n";
}
这种eval技术通常用于执行事务(见 20.4 章节)。
结合eval和RaiseError使用时与仅使用RaiseError时有所不同:
-
错误只终止
eval块,而不是整个脚本。 -
任何错误都会终止
eval块,而RaiseError仅适用于与 DBI 相关的错误。
当您使用带有启用RaiseError的eval时,请禁用PrintError。否则,在某些 DBI 版本中,错误可能仅会导致调用warn(),而不会像您期望的那样终止eval块。
除了使用错误处理属性PrintError和RaiseError外,还可以使用 DBI 的跟踪机制获取有关脚本执行的大量信息。调用trace()方法并传递指定的跟踪级别参数。级别 1 到 9 使跟踪输出更加详细,级别 0 禁用跟踪:
DBI->trace (1); # enable tracing, minimal output
DBI->trace (3); # elevate trace level
DBI->trace (0); # disable tracing
如果需要,单独的数据库和语句句柄也有trace()方法,因此您可以将跟踪限制在单个句柄上。
跟踪输出通常发送到您的终端(或者在 Web 脚本的情况下,发送到 Web 服务器的错误日志)。要将跟踪输出写入特定文件,请提供第二个参数指定文件名:
DBI->trace (1, "/tmp/trace.out");
如果跟踪文件已经存在,则其内容不会首先被清除;跟踪输出将追加到末尾。在开发脚本时打开跟踪文件时要小心,但在将脚本投入生产时忘记禁用跟踪。最终你会发现遗憾的是跟踪文件变得非常大。或者更糟糕的是,文件系统将填满,而你却不知道原因!
Ruby
Ruby 通过引发异常来表示错误,Ruby 程序通过在begin块的rescue子句中捕获异常来处理错误。当 Ruby Mysql2 方法失败时会引发异常,并通过Mysql2::Error对象提供错误信息。要获取 MySQL 错误编号、错误消息和 SQLSTATE 值,请访问该对象的errno、message和sql_state方法。以下示例展示了如何在 Ruby 脚本中捕获异常并访问错误信息:
begin
client = Mysql2::Client.new(:host => "localhost",
:username => "baduser",
:password => "badpass",
:database => "cookbook")
puts "Connected"
rescue Mysql2::Error => e
puts "Cannot connect to server"
puts "Error code: #{e.errno}"
puts "Error message: #{e.message}"
puts "Error SQLSTATE: #{e.sql_state}"
exit(1)
ensure
client.close()s
end
PHP
如果连接失败,new PDO()构造函数会引发异常,但其他 PDO 方法默认通过它们的返回值指示成功或失败。为了导致所有 PDO 方法在错误时引发异常,请使用成功连接尝试的数据库句柄设置错误处理模式。这样可以统一处理所有 PDO 错误,而无需检查每个调用的结果。以下示例展示了如何设置错误模式(如果连接尝试成功)以及如何处理异常(如果连接失败):
try
{
$dsn = "mysql:host=localhost;dbname=cookbook";
$dbh = new PDO ($dsn, "baduser", "badpass");
$dbh->setAttribute (PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
print ("Connected\n");
}
catch (PDOException $e)
{
print ("Cannot connect to server\n");
print ("Error code: " . $e->getCode () . "\n");
print ("Error message: " . $e->getMessage () . "\n");
}
当 PDO 引发异常时,结果的PDOException对象提供错误信息。getCode()方法返回 SQLSTATE 值。getMessage()方法返回包含 SQLSTATE 值、MySQL 错误编号和错误消息的字符串。
当发生错误时,数据库和语句句柄还提供信息。对于任一类型的句柄,errorCode()返回 SQLSTATE 值,而errorInfo()返回一个包含 SQLSTATE 值和特定于驱动程序的错误代码和消息的三元素数组。对于 MySQL,后两个值分别是错误号和消息字符串。以下示例演示了如何从异常对象和数据库句柄获取信息:
try
{
$dbh->query ("SELECT"); # malformed query
}
catch (PDOException $e)
{
print ("Cannot execute query\n");
print ("Error information using exception object:\n");
print ("SQLSTATE value: " . $e->getCode () . "\n");
print ("Error message: " . $e->getMessage () . "\n");
print ("Error information using database handle:\n");
print ("Error code: " . $dbh->errorCode () . "\n");
$errorInfo = $dbh->errorInfo ();
print ("SQLSTATE value: " . $errorInfo[0] . "\n");
print ("Error number: " . $errorInfo[1] . "\n");
print ("Error message: " . $errorInfo[2] . "\n");
}
Python
Python 通过引发异常信号错误,并通过在try语句的except子句中捕获异常来处理错误。要获取特定于 MySQL 的错误信息,请命名一个异常类,并提供一个变量来接收信息。以下是一个示例:
conn_params = {
"database": "cookbook",
"host": "localhost",
"user": "baduser",
"password": "badpass"
}
try:
conn = mysql.connector.connect(**conn_params)
print("Connected")
except mysql.connector.Error as e:
print("Cannot connect to server")
print("Error code: %s" % e.errno)
print("Error message: %s" % e.msg)
print("Error SQLSTATE: %s" % e.sqlstate)
如果发生异常,异常对象的errno、msg和sqlstate成员包含错误号、错误消息和 SQLSTATE 值。请注意,访问Error类是通过驱动程序模块名。
Go
Go 不支持异常。相反,其多返回值使得在需要时传递错误变得容易。要处理 Go 中的错误,请将类型为Error的返回值存储在变量中(这里我们使用变量名err),并相应地处理它。为了处理错误,Go 提供了defer语句、Panic()和Recover()内置函数。
表 4-2. Go 中的错误处理
| 函数或语句 | 含义 |
|---|---|
defer |
将语句的执行延迟到调用函数返回之前。 |
Panic() |
调用函数的正常执行停止,所有延迟函数都被执行,然后函数返回一个对栈上的 panic 调用。进程继续执行。最后,程序崩溃。 |
Recover() |
允许在恐慌的 goroutine 中重新获得控制,以便程序不会崩溃并继续执行。仅在延迟函数中有效。如果在非延迟函数中调用,什么也不做并返回nil。 |
// mysql_error.go : MySQL error handling
package main
import (
"database/sql"
"log"
"fmt"
_ "github.com/go-sql-driver/mysql"
)
var actor string
func main() {
db, err := sql.Open("mysql", "cbuser:cbpass!@tcp(127.0.0.1:3306)/cookbook")
defer db.Close()
if err != nil {
log.Fatal(err)
}
err = db.QueryRow("SELECT actor FROM actors where actor='Dwayne Johnson'").↩
Scan(&actor)
if err != nil {
if err == sql.ErrNoRows {
fmt.Print("There were no rows, but otherwise no error occurred")
} else {
fmt.Println(err.Error())
}
}
fmt.Println(actor)
}
如果发生错误,函数将返回一个error类型的对象。它的函数Error()返回 MySQL 错误代码和Go-MySQL-Driver引发的错误消息。
函数QueryRow()与后续的Scan()调用有一个特殊情况。默认情况下,如果没有错误,Scan()返回nil,如果有错误,则返回error。但是,如果查询成功运行但没有返回任何行,此函数将返回sql.ErrNoRows。
Java
Java 程序通过捕获异常来处理错误。为了做最少的工作,打印堆栈跟踪以通知用户问题所在的位置:
try
{
/* ... some database operation ... */
}
catch (Exception e)
{
e.printStackTrace ();
}
堆栈跟踪显示问题的位置,但不一定显示问题是什么。此外,除了程序开发人员外,它可能没有意义。为了更具体地说明,打印与异常关联的错误消息和代码:
-
所有
Exception对象都支持getMessage()方法。JDBC 方法可能会抛出SQLException对象;这些对象与Exception对象类似,但还支持getErrorCode()和getSQLState()方法。getErrorCode()和getMessage()返回 MySQL 特定的错误编号和消息字符串,getSQLState()返回一个包含 SQLSTATE 值的字符串。 -
一些方法会生成
SQLWarning对象,以提供关于非致命警告的信息。SQLWarning是SQLException的子类,但警告会积累在一个列表中,而不是立即抛出。它们不会中断你的程序,你可以在方便时打印它们。
下面的示例程序,Error.java,展示了如何通过打印所有可用的错误信息来访问错误消息。它尝试连接到 MySQL 服务器,并在连接失败时打印异常信息。然后执行一个语句,并在语句执行失败时打印异常和警告信息:
// Error.java: demonstrate MySQL error handling
import java.sql.*;
public class Error {
public static void main(String[] args) {
Connection conn = null;
String url = "jdbc:mysql://localhost/cookbook";
String userName = "baduser";
String password = "badpass";
try {
conn = DriverManager.getConnection(url, userName, password);
System.out.println("Connected");
tryQuery(conn); // issue a query
} catch (Exception e) {
System.err.println("Cannot connect to server");
System.err.println(e);
if (e instanceof SQLException) // JDBC-specific exception?
{
// e must be cast from Exception to SQLException to
// access the SQLException-specific methods
printException((SQLException) e);
}
} finally {
if (conn != null) {
try {
conn.close ();
System.out.println("Disconnected");
} catch (SQLException e) {
printException (e);
}
}
}
}
public static void tryQuery(Connection conn) {
try {
// issue a simple query
Statement s = conn.createStatement();
s.execute("USE cookbook");
s.close();
// print any accumulated warnings
SQLWarning w = conn.getWarnings();
while (w != null) {
System.err.println("SQLWarning: " + w.getMessage());
System.err.println("SQLState: " + w.getSQLState());
System.err.println("Vendor code: " + w.getErrorCode());
w = w.getNextWarning();
}
} catch (SQLException e) {
printException(e);
}
}
public static void printException(SQLException e) {
// print general message, plus any database-specific message
System.err.println("SQLException: " + e.getMessage ());
System.err.println("SQLState: " + e.getSQLState ());
System.err.println("Vendor code: " + e.getErrorCode ());
}
}
4.3 编写库文件
问题
你会注意到,在多个程序中重复编写代码来执行常见操作。
解决方案
编写例程来执行这些操作,将它们放入库文件中,并安排让你的程序访问该库。这使你只需编写一次代码。你可能需要设置一个环境变量,以便你的脚本可以找到该库。
讨论
本节描述了如何将常见操作的代码放入库文件中。封装(或模块化)实际上不是一个“食谱”,而是一种编程技术。它的主要好处是你不需要在每个编写的程序中重复编写代码。相反,只需调用库中的一个例程即可。例如,通过将连接到 cookbook 数据库的代码放入库例程中,你无需在每个程序中写出与建立连接相关的所有参数。只需从程序中调用该例程,就可以连接。
当然,建立连接并不是你唯一可以封装的操作。本书的后续章节将开发其他实用函数,以放置在库文件中。所有这些文件,包括本节中显示的文件,都位于 recipes 发行版的 lib 目录下。在编写自己的程序时,要注意那些经常执行的操作,并考虑将其包含到库文件中。使用本节中的技术编写你自己的库文件。
除了使编写程序更容易之外,库文件还有其他好处,例如促进可移植性。如果你直接将连接参数写入每个连接到 MySQL 服务器的程序中,如果将它们移动到使用不同参数的另一台机器上,则必须更改所有这些程序。相反,如果你编写程序以通过调用库例程连接到数据库,只需要修改受影响的库例程,而不必修改所有使用它的程序。
代码封装也可以提高安全性。如果你将私有库文件设置为仅自己可读,那么只有你运行的脚本可以执行文件中的程序。或者假设你有一些位于 Web 服务器文档树中的脚本。一个正确配置的服务器会执行这些脚本并将它们的输出发送给远程客户端。但如果服务器某种方式配置错误,结果可能是将你的脚本作为纯文本发送给客户端,从而显示你的 MySQL 用户名和密码。如果你将与 MySQL 服务器建立连接的代码放在位于文档树之外的库文件中,这些参数就不会暴露给客户端。
警告
请注意,如果你将一个库文件安装为可被你的 Web 服务器读取,那么如果其他开发人员也使用同一台服务器,你的安全性就不高了。任何这些开发人员都可以编写一个 Web 脚本来读取和显示你的库文件,因为默认情况下,脚本以 Web 服务器的权限运行,因此可以访问该库。
接下来的示例演示了如何为每个 API 编写一个库文件,其中包含一个用于连接到 MySQL 服务器上的 cookbook 数据库的例程。调用程序可以使用第 4.2 节 中讨论的错误检查技术来确定连接尝试是否失败。每种语言的连接例程在成功时返回一个数据库句柄或连接对象,如果无法建立连接则抛出异常。
库本身没有任何用处,因此下面的讨论通过一个短的 测试驱动程序
程序来说明每个库的用途。要将任何这些驱动程序作为创建新程序的基础,请复制该文件并在连接和断开调用之间添加你自己的代码。
编写库文件不仅涉及文件中应该放入什么内容的问题,还包括诸如在何处安装文件以使其对你的程序可访问,以及(在类 Unix 的多用户系统上)如何设置其访问权限,以防止其内容暴露给不应查看它的人。
选择库文件安装位置
如果你将一个库文件安装在语言处理器默认搜索的目录中,那么使用该语言编写的程序无需特别操作即可访问该库。但是,如果你将一个库文件安装在语言处理器不默认搜索的目录中,你必须告诉你的脚本如何找到它。有两种常见的方法来做到这一点:
-
大多数语言提供了一种语句,可以在脚本内部使用,以将目录添加到语言处理器的搜索路径中。这需要你修改需要使用该库的每个脚本。
-
您可以设置一个环境或配置变量,以更改语言处理器的搜索路径。使用这种方法,每个执行需要库的脚本的用户必须设置适当的变量。或者,如果语言处理器有一个配置文件,您可能可以在文件中设置一个影响所有用户全局脚本的参数。
我们将使用第二种方法。对于我们的 API 语言,表 4-3 展示了相关变量。在每种情况下,变量值是一个目录或目录列表:
表 4-3. 默认库路径
| 语言 | 变量名 | 变量类型 |
|---|---|---|
| Perl | PERL5LIB |
环境变量 |
| Ruby | RUBYLIB |
环境变量 |
| PHP | include_path |
配置变量 |
| Python | PYTHONPATH |
环境变量 |
| Go | GOPATH |
环境变量 |
| Java | CLASSPATH |
环境变量 |
关于设置环境变量的一般信息,请阅读配方分发中的cmdline.pdf(见前言)。您可以使用这些说明将环境变量设置为下面讨论中的值。
假设您想要将库文件安装在语言处理器默认不搜索的目录中。作为示例,让我们在 Unix 上使用/usr/local/lib/mcb,在 Windows 上使用C:\lib\mcb。(要将文件放置在其他位置,请相应调整变量设置中的路径名。例如,您可能希望使用不同的目录,或者您可能希望将每种语言的库放在单独的目录中。)
在 Unix 下,如果您将 Perl 库文件放在/usr/local/lib/mcb目录中,请适当设置PERL5LIB环境变量。对于 Bourne shell 家族(sh、bash、ksh)的 shell,在适当的启动文件中设置变量如下:
export PERL5LIB=/usr/local/lib/mcb
注意
对于原始的 Bourne shell,sh,您可能需要将此分成两个命令:
PERL5LIB=/usr/local/lib/mcb
export PERL5LIB
对于 C shell 家族(csh、tcsh)的 shell,在您的.login文件中设置PERL5LIB如下:
setenv PERL5LIB /usr/local/lib/mcb
在 Windows 下,如果您将 Perl 库文件放在C:\lib\mcb中,请设置PERL5LIB如下:
PERL5LIB=C:\lib\mcb
在每种情况下,变量值告诉 Perl 在指定目录中查找库文件,除了默认搜索的任何其他目录。如果将PERL5LIB设置为多个目录名称,则在 Unix 上的分隔符为冒号(:),在 Windows 上为分号(;)。
使用相同的语法指定其他环境变量(RUBYLIB、PYTHONPATH和CLASSPATH)。
注意
如刚刚讨论的,设置这些环境变量应足以运行从命令行运行的脚本。对于打算由 Web 服务器执行的脚本,您可能还必须配置服务器,以便它可以找到库文件。
对于 PHP,搜索路径由 php.ini PHP 初始化文件中include_path变量的值定义。在 Unix 上,文件的路径名可能是 /usr/lib/php.ini 或 /usr/local/lib/php.ini。在 Windows 下,该文件可能位于 Windows 目录或主 PHP 安装目录下。要确定位置,请运行以下命令:
$ `php --ini`
使用如下行在 php.ini 中定义include_path的值:
include_path = "*`value`*"
使用与环境变量命名目录相同的语法指定 value。也就是说,它是一个目录名称列表,在 Unix 上用冒号分隔,在 Windows 上用分号分隔。在 Unix 上,如果您希望 PHP 在当前目录和 /usr/local/lib/mcb 中查找包含文件,请像这样设置include_path:
include_path = ".:/usr/local/lib/mcb"
在 Windows 上,要在当前目录和 C:\lib\mcb 中搜索,请像这样设置include_path:
include_path = ".;C:\lib\mcb"
如果 PHP 作为 Apache 模块运行,请重新启动 Apache 以使 php.ini 更改生效。
设置库文件的访问权限
如果您使用类 Unix 的多用户系统,必须对库文件的所有权和访问模式做出决策:
-
如果库文件是私有的,并且仅包含您自己使用的代码,请将该文件放在您自己的帐户下,并且仅对您可访问。假设名为 mylib 的库文件已由您拥有,您可以像这样使其私有:
$ `chmod 600 mylib` -
如果库文件仅由您的 Web 服务器使用,请将其安装在服务器库目录中,并使其由服务器用户 ID 拥有和仅限该用户访问。您可能需要以
root身份执行此操作。例如,如果 Web 服务器以wwwusr身份运行,则以下命令将文件设为仅该用户私有:# `chown wwwusr mylib` # `chmod 600 mylib` -
如果库文件是公共的,您可以将其放在编程语言在查找库时自动搜索的位置。 (大多数语言处理器在某些默认目录中搜索库,尽管可以通过设置环境变量来影响此集合,如前述所述。)您可能需要以
root身份在其中一个目录中安装文件。然后您可以将文件设为全局可读:# `chmod 444 mylib`
现在让我们为每个 API 构建一个库。本节的每个部分演示了如何编写库文件本身,并讨论了如何从程序内部使用该库。
Perl
在 Perl 中,库文件称为模块,通常具有 .pm 扩展名(Perl 模块)。约定的是模块文件的基本名称与文件中package行上的标识符相同。以下文件 Cookbook.pm 实现了一个名为Cookbook的模块:
package Cookbook;
# Cookbook.pm: library file with utility method for connecting to MySQL
# using the Perl DBI module
use strict;
use warnings;
use DBI;
my $db_name = "cookbook";
my $host_name = "localhost";
my $user_name = "cbuser";
my $password = "cbpass";
my $port_num = undef;
my $socket_file = undef;
# Establish a connection to the cookbook database, returning a database
# handle. Raise an exception if the connection cannot be established.
sub connect
{
my $dsn = "DBI:mysql:host=$host_name";
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
$dsn .= ";database=$db_name" if defined ($db_name);
$dsn .= ";mysql_socket=$socket_file" if defined ($socket_file);
$dsn .= ";port=$port_num" if defined ($port_num);
return DBI->connect ($dsn, $user_name, $password, $conn_attrs);
}
1; # return true
该模块将与建立与 MySQL 服务器的连接的代码封装到一个connect()方法中,而package标识符则为该模块建立了一个Cookbook命名空间。要调用connect()方法,请使用模块名称:
$dbh = Cookbook::connect ();
模块文件的最后一行是一个无关紧要的评估为真的语句。如果模块没有返回真值,Perl 会认为有问题并退出。
Perl 通过搜索其@INC数组中命名的目录列表来定位库文件。要检查系统上此变量的默认值,请在命令行上调用 Perl 如下:
$ `perl -V`
命令输出的最后部分显示了@INC中列出的目录。如果在这些目录中的一个中安装了库文件,则您的脚本会自动找到它。如果在其他地方安装了模块,请通过设置PERL5LIB环境变量来告诉您的脚本在哪里找到它,如本文档介绍的内容。
安装完Cookbook.pm模块后,请从测试工具脚本harness.pl尝试:
#!/usr/bin/perl
# harness.pl: test harness for Cookbook.pm library
use strict;
use warnings;
use Cookbook;
my $dbh;
eval
{
$dbh = Cookbook::connect ();
print "Connected\n";
};
die "$@" if $@;
$dbh->disconnect ();
print "Disconnected\n";
harness.pl没有use DBI语句。这是不必要的,因为Cookbook模块本身导入了 DBI;任何使用Cookbook的脚本也都可以访问 DBI。
如果没有使用eval显式捕获连接错误,则可以更简单地编写脚本主体:
my $dbh = Cookbook::connect ();
print "Connected\n";
$dbh->disconnect ();
print "Disconnected\n";
在这种情况下,Perl 捕获任何连接异常,并在打印由connect()方法生成的错误消息后终止脚本。
Ruby
下面的 Ruby 库文件Cookbook.rb定义了一个实现connect类方法的Cookbook类:
# Cookbook.rb: library file with utility method for connecting to MySQL
# using the Ruby Mysql2 module
require "mysql2"
# Establish a connection to the cookbook database, returning a database
# handle. Raise an exception if the connection cannot be established.
class Cookbook
@@host_name = "localhost"
@@db_name = "cookbook"
@@user_name = "cbuser"
@@password = "cbpass"
# Class method for connecting to server to access the
# cookbook database; returns a database handle object.
def Cookbook.connect
return Mysql2::Client.new(:host => @@host_name,
:database => @@db_name,
:username => @@user_name,
:password => @@password)
end
end
connect方法在库中被定义为Cookbook.connect,因为 Ruby 类方法被定义为class_name.method_name。
Ruby 通过搜索其名为$LOAD_PATH(也称为$:)的变量(数组)中命名的目录列表来定位库文件。要检查系统上此变量的默认值,请使用交互式 Ruby 执行此语句:
$ `irb`
>> `puts $LOAD_PATH`
如果在这些目录中的一个中安装了库文件,则您的脚本会自动找到它。如果您在其他地方安装了文件,请通过设置RUBYLIB环境变量来告诉您的脚本在哪里找到它,如本文档介绍的内容。
安装完Cookbook.rb库文件后,请从测试工具脚本harness.rb尝试:
#!/usr/bin/ruby -w
# harness.rb: test harness for Cookbook.rb library
require "Cookbook"
begin
client = Cookbook.connect
print "Connected\n"
rescue Mysql2::Error => e
puts "Cannot connect to server"
puts "Error code: #{e.errno}"
puts "Error message: #{e.message}"
exit(1)
ensure
client.close()
print "Disconnected\n"
end
harness.rb没有 Mysql2 模块的require语句。这是不必要的,因为Cookbook模块本身导入了 Mysql2;任何导入Cookbook的脚本也都可以访问 Mysql2。
如果希望脚本在出现错误时退出而不检查异常本身,请将脚本主体编写如下所示:
client = Cookbook.connect
print "Connected\n"
client.close
print "Disconnected\n"
PHP
PHP 库文件就像常规的 PHP 脚本一样编写。实现Cookbook类及其connect()方法的Cookbook.php文件如下所示:
<?php
# Cookbook.php: library file with utility method for connecting to MySQL
# using the PDO module
class Cookbook
{
public static $host_name = "localhost";
public static $db_name = "cookbook";
public static $user_name = "cbuser";
public static $password = "cbpass";
# Establish a connection to the cookbook database, returning a database
# handle. Raise an exception if the connection cannot be established.
# In addition, cause exceptions to be raised for errors.
public static function connect ()
{
$dsn = "mysql:host=" . self::$host_name . ";dbname=" . self::$db_name;
$dbh = new PDO ($dsn, self::$user_name, self::$password);
$dbh->setAttribute (PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
return ($dbh);
}
} # end Cookbook
?>
在类内部声明的connect()例程使用static关键字来将其声明为类方法,而不是实例方法。这使其可以直接调用,而无需通过实例化对象来调用它。
new PDO()构造函数如果连接尝试失败会引发异常。成功尝试后,connect()设置错误处理模式,使得其他 PDO 调用在失败时也会引发异常。这样,不需要为每个调用测试错误返回值。
虽然本书中大多数 PHP 示例没有显示<?php和?>标签,但我在这里显示它们作为Cookbook.php的一部分,以强调库文件必须将所有 PHP 代码置于这些标签内。PHP 解释器在开始解析库文件时不会对其内容做出任何假设,因为您可能包含一个只包含 HTML 的文件。因此,您必须使用<?php和?>显式指定库文件的哪些部分应被视为 PHP 代码而不是 HTML,就像在主脚本中所做的那样。
PHP 通过在 PHP 初始化文件中描述的include_path变量命名的目录中搜索库文件。
注意
PHP 脚本通常放置在您的 Web 服务器文档树中,客户端可以直接请求它们。对于 PHP 库文件,我们建议将它们放在文档树之外的某个地方,特别是像Cookbook.php这样的文件,如果包含用户名和密码的话。
将Cookbook.php安装在include_path目录之一后,可以从测试用例脚本harness.php中尝试它:
<?php
# harness.php: test harness for Cookbook.php library
require_once "Cookbook.php";
try
{
$dbh = Cookbook::connect ();
print ("Connected\n");
}
catch (PDOException $e)
{
print ("Cannot connect to server\n");
print ("Error code: " . $e->getCode () . "\n");
print ("Error message: " . $e->getMessage () . "\n");
exit (1);
}
$dbh = NULL;
print ("Disconnected\n");
?>
require_once语句访问所需使用Cookbook类的Cookbook.php文件。require_once是几个 PHP 文件包含语句之一:
-
require和include指示 PHP 读取指定的文件。它们类似,但是如果找不到文件,require会终止脚本;include只会产生警告。 -
require_once和include_once与require和include类似,但如果文件已经被读取,则不会再次处理其内容。这对于避免在库文件包含其他库文件时可能出现的多次声明问题非常有用。
Python
Python 库被编写为模块,并通过import语句从脚本中引用。要创建连接到 MySQL 的方法,请编写一个模块文件cookbook.py(Python 模块名称应为小写):
# cookbook.py: library file with utility method for connecting to MySQL
# using the Connector/Python module
import mysql.connector
conn_params = {
"database": "cookbook",
"host": "localhost",
"user": "cbuser",
"password": "cbpass",
}
# Establish a connection to the cookbook database, returning a connection
# object. Raise an exception if the connection cannot be established.
def connect():
return mysql.connector.connect(**conn_params)
文件名的基本名称确定模块名称,因此该模块被称为cookbook。可以通过模块名称访问模块方法;因此,导入cookbook模块并调用其connect()方法如下:
import cookbook
conn = cookbook.connect();
Python 解释器通过sys.path变量中命名的目录搜索模块。要检查系统上sys.path的默认值,请在 Python 交互模式下运行,并输入几个命令:
$ `python`
>>> `import sys`
>>> `sys.path`
如果您将cookbook.py安装在由sys.path命名的目录之一中,则您的脚本将无需特殊处理即可找到它。如果您将cookbook.py安装在其他地方,则必须设置PYTHONPATH环境变量,如本教程的介绍部分所述。
安装了 cookbook.py 库文件后,可以通过测试用的 harness.py 脚本进行尝试:
#!/usr/bin/python
# harness.py: test harness for cookbook.py library
import mysql.connector
import cookbook
try:
conn = cookbook.connect()
print("Connected")
except mysql.connector.Error as e:
print("Cannot connect to server")
print("Error code: %s" % e.errno)
print("Error message: %s" % e.msg)
else:
conn.close()
print("Disconnected")
cookbook.py 文件导入了 mysql.connector 模块,但导入 cookbook 的脚本并不能因此获得对 mysql.connector 的访问权限。如果脚本需要 Connector/Python 特定的信息(如 mysql.connector.Error),则脚本本身必须导入 mysql.connector。
如果希望脚本在发生错误时退出而无需自行检查异常,请像这样编写脚本体:
conn = cookbook.connect()
print("Connected")
conn.close()
print("Disconnected")
Go
Go 程序被组织成包,这些包是位于同一目录中的源文件集合。包又被组织成模块,这些模块是一起发布的 Go 包的集合。模块属于 Go 代码库。一个典型的 Go 代码库只包含一个模块,但在同一个代码库中可以有多个模块。
Go 解释器在名为 $GOPATH/src/{domain}/{project} 的目录中搜索包。但是,使用模块时,Go 不再使用 GOPATH。无论您的模块安装在何处,都不需要更改此变量。我们将在示例中使用模块。
要创建连接到 MySQL 的方法,编写一个名为 cookbook.go 的包文件:
package cookbook
import (
"database/sql"
_"github.com/go-sql-driver/mysql"
)
func Connect() (*sql.DB, error) {
db, err := sql.Open("mysql","cbuser:cbpass@tcp(127.0.0.1:3306)/cookbook")
if err != nil {
panic(err.Error())
}
err = db.Ping()
return db, err
}
文件名的基本名称不确定包的名称:Go 通过导入路径中的所有文件搜索,直到找到具有所需包声明的文件。包方法通过包名访问。
要测试包,可以指定包文件所在目录的相对路径:
import "../../lib"
这是一种非常简单的方法来快速测试您的库,但是像 go install 这样的命令不能用于以这种方式导入的包。因此,每次访问时都会从头开始重建您的程序。
更好的处理包的方式是将它们作为模块的一部分发布。要执行此操作,请在存储 cookbook.go 的目录中运行以下命令:
go mod init cookbook
这将创建一个包含您的模块名称和 Go 版本的 go.mod 文件。您可以根据需要命名模块。
您可以将模块发布到互联网并像处理任何其他模块一样从本地程序中访问它。但在开发过程中,仅在本地使用该模块将非常有用。在这种情况下,您需要在将使用它的程序目录中进行一些调整。
首先,创建一个将调用包的程序,harness.go:
package main
import (
"fmt"
"github.com/svetasmirnova/mysqlcookbook/recipes/lib"
)
func main() {
db, err := cookbook.Connect()
if err != nil {
fmt.Println("Cannot connect to server")
fmt.Printf("Error message: %s\n", err.Error())
} else {
fmt.Println("Connected")
}
defer db.Close()
}
然后,在安装包的目录中,初始化模块:
go mod init harness
模块初始化完成并创建了 go.mod 后,通过以下方式编辑它:
go mod edit -replace ↩
github.com/svetasmirnova/mysqlcookbook/recipes/lib=↩
/home/sveta/src/mysqlcookbook/recipes/lib
用您环境中有效的 URL 和本地路径替换它们。
此命令将告诉 Go 使用本地目录替换远程模块路径。
完成后,您可以测试您的连接:
$ `go run harness.go`
Connected
Java
Java 库文件在大多数方面与 Java 程序相似:
-
源文件中的
class行指示了一个类名。 -
文件应与类名相同(使用 .java 扩展名)。
-
编译.java文件以生成.class文件。
Java 库文件在某些方面也与 Java 程序不同:
-
与常规程序文件不同,Java 库文件没有
main()函数。 -
一个库文件应该以指定类在 Java 命名空间中位置的
package标识符开始。
Java 包标识符的常见约定是使用代码作者的域作为前缀;这有助于使标识符保持唯一,并避免与其他作者编写的类发生冲突。域名在域命名空间中从右向左逐渐具体化,而 Java 类命名空间则从左向右从一般到特定。因此,要在 Java 类命名空间中使用域作为包名的前缀,需要对其进行反转。例如,Paul 的域名是kitebird.com,因此如果他编写一个库文件并将其放在其域命名空间中的mcb下,则库文件以如下package语句开头:
package com.kitebird.mcb;
为了确保 Java 命名空间中的唯一性,本书开发的 Java 包位于com.kitebird.mcb命名空间内。
下面的库文件Cookbook.java定义了一个Cookbook类,实现了一个connect()方法用于连接到cookbook数据库。如果connect()成功,则返回一个Connection对象,否则抛出异常。为了帮助调用者处理失败,Cookbook类还定义了getErrorMessage()和printErrorMessage()实用方法,分别返回错误消息字符串并将其打印到System.err:
// Cookbook.java: library file with utility methods for connecting to MySQL
// using MySQL Connector/J and for handling exceptions
package com.kitebird.mcb;
import java.sql.*;
public class Cookbook {
// Establish a connection to the cookbook database, returning
// a connection object. Throw an exception if the connection
// cannot be established.
public static Connection connect() throws Exception {
String url = "jdbc:mysql://localhost/cookbook";
String user = "cbuser";
String password = "cbpass";
return (DriverManager.getConnection(url, user, password));
}
// Return an error message as a string
public static String getErrorMessage(Exception e) {
StringBuffer s = new StringBuffer ();
if (e instanceof SQLException) { // JDBC-specific exception?
// print general message, plus any database-specific message
s.append("Error message: " + e.getMessage () + "\n");
s.append("Error code: " + ((SQLException) e).getErrorCode() + "\n");
} else {
s.append (e + "\n");
}
return (s.toString());
}
// Get the error message and print it to System.err
public static void printErrorMessage(Exception e) {
System.err.println(Cookbook.getErrorMessage(e));
}
}
类中的例程使用static关键字声明,这使它们成为类方法而不是实例方法。之所以这样做是因为该类是直接使用而不是从中创建对象并通过对象调用方法。
要使用Cookbook.java文件,首先编译它以生成Cookbook.class,然后将类文件安装在与包标识符对应的目录中。
这意味着Cookbook.class应安装在名为com/kitebird/mcb(Unix)或com\kitebird\mcb(Windows)的目录中,该目录位于您的CLASSPATH设置中指定的某个目录下。例如,如果在 Unix 下CLASSPATH包含/usr/local/lib/mcb,则可以将Cookbook.class安装在/usr/local/lib/mcb/com/kitebird/mcb目录下。(有关CLASSPATH变量的更多信息,请参阅 Recipe 4.1 中的 Java 讨论。)
要从 Java 程序中使用Cookbook类,导入它并调用Cookbook.connect()方法。以下测试框架程序Harness.java展示了如何做到这一点:
// Harness.java: test harness for Cookbook library class
import java.sql.*;
import com.kitebird.mcb.Cookbook;
public class Harness {
public static void main(String[] args) {
Connection conn = null;
try {
conn = Cookbook.connect ();
System.out.println("Connected");
} catch (Exception e) {
Cookbook.printErrorMessage (e);
System.exit (1);
} finally {
if (conn != null) {
try {
conn.close();
System.out.println("Disconnected");
} catch (Exception e) {
String err = Cookbook.getErrorMessage(e);
System.out.println(err);
}
}
}
}
}
Harness.java还展示了当发生 MySQL 相关异常时如何使用Cookbook类的错误消息实用方法:
-
printErrorMessage()接受异常对象并用它打印错误消息到System.err。 -
getErrorMessage()返回错误消息作为字符串。你可以自行显示该消息,将其写入日志文件,或者其他操作。
4.4 执行语句和检索结果
问题
你需要一个程序向 MySQL 服务器发送 SQL 语句并检索其结果。
解决方案
一些语句仅返回状态代码;其他则返回结果集(一组行)。某些 API 提供了执行每种类型语句的不同方法。如果是这样,请使用适当的方法执行要执行的语句。
讨论
你可以执行两类通用的 SQL 语句。一些从数据库检索信息;其他则更改信息或数据库本身。这两类语句有不同的处理方式。此外,一些 API 提供多个例程来执行语句,进一步增加了复杂性。在我们演示如何从每个 API 内部执行语句的示例之前,我们将描述示例使用的数据库表,并进一步讨论这两类语句,并概述处理每类语句的一般策略。
在第一章中,我们创建了一个名为limbs的表来尝试一些示例语句。在本章中,我们将使用名为profile的不同表。它基于一个好友列表
的概念,即我们在线时想要保持联系的人。该表的定义如下:
CREATE TABLE profile
(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(20) NOT NULL,
birth DATE,
color ENUM('blue','red','green','brown','black','white'),
foods SET('lutefisk','burrito','curry','eggroll','fadge','pizza'),
cats INT,
PRIMARY KEY (id)
);
profile表指示我们关于每个好友重要的事情:姓名、年龄、喜欢的颜色、喜欢的食物和猫的数量。此外,该表使用多种不同的数据类型作为其列,并且这些类型对于说明如何解决特定数据类型相关的问题非常有用。
该表还包括一个id列,其中包含唯一的值,以便我们可以区分一行与另一行,即使两个好友有相同的名字。id和name被声明为NOT NULL,因为它们各自需要有一个值。其他列隐式允许为NULL(这也是它们的默认值),因为我们可能不知道为任何给定个体分配的值。也就是说,NULL表示未知
。
请注意,尽管我们想追踪年龄,但表中没有age列。相反,有一个DATE类型的birth列。年龄会变化,因此如果存储年龄值,我们将不得不不断更新它们。存储出生日期更好:它们不会改变,并且可以随时用来计算年龄(参见 Recipe 8.14)。color是一个ENUM列;颜色值可以是所列出的任何一个值。foods是一个SET,允许值是个别集合成员的任意组合。这样我们可以记录每个好友的多个喜欢的食物。
要创建表格,请使用recipes分发中tables目录下的profile.sql脚本。切换到该目录,然后运行以下命令:
$ `mysql cookbook < profile.sql`
脚本还将示例数据加载到表中。您可以对表进行实验,然后如果修改了其内容,可以再次运行脚本来恢复它。(请参阅本章末尾关于修改后重要性的profile表的恢复。)
由profile.sql脚本加载的profile表内容如下所示:
mysql> `SELECT * FROM profile;`
+----+---------+------------+-------+-----------------------+------+
| id | name | birth | color | foods | cats |
+----+---------+------------+-------+-----------------------+------+
| 1 | Sybil | 1970-04-13 | black | lutefisk,fadge,pizza | 0 |
| 2 | Nancy | 1969-09-30 | white | burrito,curry,eggroll | 3 |
| 3 | Ralph | 1973-11-02 | red | eggroll,pizza | 4 |
| 4 | Lothair | 1963-07-04 | blue | burrito,curry | 5 |
| 5 | Henry | 1965-02-14 | red | curry,fadge | 1 |
| 6 | Aaron | 1968-09-17 | green | lutefisk,fadge | 1 |
| 7 | Joanna | 1952-08-20 | green | lutefisk,fadge | 0 |
| 8 | Stephen | 1960-05-01 | white | burrito,pizza | 0 |
+----+---------+------------+-------+-----------------------+------+
尽管profile表中大多数列允许NULL值,但样本数据集中的行实际上都不包含NULL值。我们将NULL值处理的复杂性推迟到 Recipe 4.5 和 Recipe 4.7。
SQL 语句的分类
SQL 语句可以根据是否返回结果集(一组行)分为两大类:
-
不返回结果集的语句,例如
INSERT、DELETE或UPDATE。一般而言,这类语句通常会对数据库进行某种形式的更改。也有例外情况,比如USEdb_name,它会改变你会话的默认数据库而不对数据库本身进行任何更改。本节中用到的数据修改语句是UPDATE:UPDATE profile SET cats = cats+1 WHERE name = 'Sybil';我们将介绍如何执行此语句并确定它影响的行数。
-
返回结果集的语句,例如
SELECT、SHOW、EXPLAIN或DESCRIBE。我通常把这些语句泛称为SELECT语句,但你应理解这一类别包括返回行的任何语句。本节中用到的行检索语句是SELECT:SELECT id, name, cats FROM profile;我们将介绍如何执行此语句、获取结果集中的行,并确定结果集中的行数和列数。(要获取诸如列名或数据类型的信息,请访问结果集元数据。这在 Recipe 12.2 中。)
处理 SQL 语句的第一步是将其发送到 MySQL 服务器执行。某些 API(如 Perl 和 Java)识别这两类语句并为其提供单独的调用。其他 API(如 Python 或 Ruby)使用单一调用来执行所有语句。不过,所有 API 都共同点是没有特殊字符表示语句的结束。不需要终止符,因为语句字符串的结尾就是其终止符。这与在mysql程序中执行语句不同,在那里你使用分号(;)或\g来终止语句。(这也不同于本书通常在示例中包含分号以明确语句结束的方式。)
当你向服务器发送语句时,要准备好处理执行失败的错误。如果语句执行失败但你仍然基于其成功继续执行,你的程序将无法正常工作。大多数情况下,本节不显示错误检查代码,这是为了简洁。实际生产代码中应始终包括错误处理。示例脚本中的recipes分发从中提取示例包含基于 Recipe 4.2 展示的技术的错误处理。
如果语句成功执行且没有错误,接下来的步骤取决于语句类型。如果是不返回结果集的语句,则无需进一步操作,除非你想检查影响了多少行。如果语句返回结果集,则获取其行并关闭结果集。在不确定语句是否返回结果集的上下文中,Recipe 12.2 讨论了如何判断。
Perl
Perl DBI 模块提供了两种基本的 SQL 语句执行方法,取决于是否期望得到结果集。对于像INSERT或UPDATE这样不返回结果集的语句,使用数据库句柄的do()方法。它执行语句并返回受其影响的行数,或者如果发生错误则返回undef。如果 Sybil 有了一只新猫,下面的语句会将她的cats计数增加一:
my $count = $dbh->do ("UPDATE profile SET cats = cats+1
WHERE name = 'Sybil'");
if ($count) # print row count if no error occurred
{
$count += 0;
print "Number of rows updated: $count\n";
}
如果语句成功执行但未影响任何行,do()将返回特殊值"0E0"(科学计数法下的零,表示为字符串)。在测试语句执行状态时,可以使用"0E0"因为它在布尔上下文中为真(与undef不同)。对于成功的语句,它还可用于计算受影响行数,因为在数值上下文中它被视为零。当然,如果直接打印该值,会显示为"0E0",这可能对使用你的程序的人来说看起来很奇怪。前面的示例通过将零加到该值以强制其转换为数值形式来确保不会发生这种情况,以便显示为0。或者,使用printf和%d格式说明符以导致隐式数值转换:
if ($count) # print row count if no error occurred
{
printf "Number of rows updated: %d\n", $count;
}
如果启用了RaiseError,你的脚本会在 DBI 相关错误时自动终止,因此无需检查$count来查找do()是否失败,从而简化代码:
my $count = $dbh->do ("UPDATE profile SET cats = cats+1
WHERE name = 'Sybil'");
printf "Number of rows updated: %d\n", $count;
要处理像SELECT这样返回结果集的语句,需采用不同的方法:
-
通过使用数据库句柄调用
prepare()指定要执行的语句。prepare()返回一个语句句柄,用于后续所有操作。如果发生错误且启用了RaiseError,脚本将终止;否则,prepare()返回undef。 -
调用
execute()来执行语句并生成结果集。 -
循环获取语句返回的行。DBI 提供了几种方法,我们很快会介绍它们。
-
如果不获取整个结果集,请通过调用
finish()来释放与其关联的资源。
下面的示例说明了这些步骤,使用 fetchrow_array() 作为获取行的方法,并假定启用了 RaiseError 以便错误终止脚本:
my $sth = $dbh->prepare ("SELECT id, name, cats FROM profile");
$sth->execute ();
my $count = 0;
while (my @val = $sth->fetchrow_array ())
{
print "id: $val[0], name: $val[1], cats: $val[2]\n";
++$count;
}
$sth->finish ();
print "Number of rows returned: $count\n";
行数组大小表示结果集中的列数。
刚才显示的获取行循环后调用了 finish(),它关闭了结果集并告诉服务器释放与其关联的任何资源。如果获取了集合中的每一行,DBI 在到达末尾时会注意到并为你释放资源。因此,示例可以省略 finish() 调用而不会产生任何不良影响。
如示例所示,当获取行时同时计数可以确定结果集包含的行数。不要使用 DBI 的 rows() 方法来达到此目的。DBI 文档不建议这种做法,因为 rows() 对于 SELECT 语句并不一定可靠——由于不同数据库引擎和驱动程序的行为差异。
DBI 有几种一次获取一行的方法。在前面的例子中使用的 fetchrow_array() 方法返回一个包含下一行的数组,或者当没有更多行时返回一个空列表。数组元素按照 SELECT 语句中指定的顺序存在。可以使用 $val[0],$val[1] 等来访问它们。
fetchrow_array() 方法对于显式命名要选择的列的语句最为有用。(使用 SELECT *,不能保证数组中列的位置。)
fetchrow_arrayref() 类似于 fetchrow_array(),但它返回数组的引用,或者当没有更多行时返回undef。与 fetchrow_array() 一样,数组元素按照语句中指定的顺序存在。可以使用 $ref->[0],$ref->[1] 等来访问它们:
while (my $ref = $sth->fetchrow_arrayref ())
{
print "id: $ref->[0], name: $ref->[1], cats: $ref->[2]\n";
}
fetchrow_hashref() 返回一个指向哈希结构的引用,当没有更多行时返回undef:
while (my $ref = $sth->fetchrow_hashref ())
{
print "id: $ref->{id}, name: $ref->{name}, cats: $ref->{cats}\n";
}
要访问哈希的元素,使用语句选择的列名($ref->{id},$ref->{name} 等)。fetchrow_hashref() 对于 SELECT * 语句特别有用,因为可以访问行的元素,而无需知道列返回的顺序。你只需要知道它们的名称即可。另一方面,建立哈希比建立数组更昂贵,因此 fetchrow_hashref() 比 fetchrow_array() 或 fetchrow_arrayref() 更慢。如果列名重复,可能会丢失
行元素,因为列名必须是唯一的。在表连接时,同名列并不少见。有关此问题的解决方案,请参阅 Recipe 16.11。
除了刚刚描述的语句执行方法外,DBI 还提供了几种高级检索方法,这些方法执行语句并以单个操作返回结果集。所有这些方法都是数据库句柄方法,在返回结果集之前在内部创建和处理语句句柄。这些方法在返回结果集的形式上有所不同。有些返回整个结果集,其他返回集合的单行或单列,如 Table 4-4 所总结:
Table 4-4. 检索结果的 Perl 方法
| 方法 | 返回值 |
|---|---|
selectrow_array() |
结果集的第一行作为数组 |
selectrow_arrayref() |
结果集的第一行作为数组引用 |
selectrow_hashref() |
结果集的第一行作为哈希引用 |
selectcol_arrayref() |
结果集的第一列作为数组引用 |
selectall_arrayref() |
整个结果集作为数组引用的数组 |
selectall_hashref() |
整个结果集作为哈希引用的哈希 |
大多数这些方法返回一个引用。selectrow_array() 是个例外,它选择结果集的第一行并返回一个数组或标量,具体返回取决于调用方式。在数组上下文中,selectrow_array() 返回整行作为数组(如果未选择任何行则返回空列表)。这对于预期仅获取单行的语句很有用。返回值可用于确定结果集大小。列数为数组的元素个数,行数为 1 或 0:
my @val = $dbh->selectrow_array ("SELECT name, birth, foods FROM profile
WHERE id = 3");
my $ncols = @val;
my $nrows = $ncols ? 1 : 0;
selectrow_arrayref() 和 selectrow_hashref() 选择结果集的第一行并返回其引用,如果未选择任何行则返回undef。要访问列值,处理引用的方式与处理 fetchrow_arrayref() 或 fetchrow_hashref() 返回值相同。引用还提供了行数和列数:
my $ref = $dbh->selectrow_arrayref ($stmt);
my $ncols = defined ($ref) ? @{$ref} : 0;
my $nrows = $ncols ? 1 : 0;
my $ref = $dbh->selectrow_hashref ($stmt);
my $ncols = defined ($ref) ? keys (%{$ref}) : 0;
my $nrows = $ncols ? 1 : 0;
selectcol_arrayref() 返回指向结果集第一列的单列数组的引用。假设返回值不为undef,则可以使用 $ref->[$i] 访问数组的第 i 行值。数组的元素个数即为行数,列数为 1 或 0:
my $ref = $dbh->selectcol_arrayref ($stmt);
my $nrows = defined ($ref) ? @{$ref} : 0;
my $ncols = $nrows ? 1 : 0;
selectall_arrayref() 返回一个包含结果集每行元素的数组引用。每个元素都是一个数组的引用。要访问结果集的第 i 行,使用 $ref->[$i] 获得行的引用,然后像处理 fetchrow_arrayref() 返回值一样访问行中的各个列值。结果集的行数和列数可按如下方式获取:
my $ref = $dbh->selectall_arrayref ($stmt);
my $nrows = defined ($ref) ? @{$ref} : 0;
my $ncols = $nrows ? @{$ref->[0]} : 0;
selectall_hashref()返回对哈希的引用,其中每个元素都是结果行的哈希引用。要调用它,请指定一个参数,该参数指示要用于哈希键的列。例如,如果从profile表检索行,则主键是id列。
my $ref = $dbh->selectall_hashref ("SELECT * FROM profile", "id");
使用哈希的键访问行。对于键列值为12的行,行的哈希引用是$ref->{12}。该行值以列名为键,您可以使用它们来访问单个列元素(例如,$ref->{12}->{name})。结果集的行数和列数如下所示:
my @keys = defined ($ref) ? keys (%{$ref}) : ();
my $nrows = scalar (@keys);
my $ncols = $nrows ? keys (%{$ref->{$keys[0]}}) : 0;
当需要多次处理结果集时,selectall_XXX()方法非常有用,因为 Perl DBI 没有提供回放结果集的方法。通过将整个结果集分配给变量,可以多次迭代其元素。
如果禁用了RaiseError,在使用高级方法时要小心。在这种情况下,方法的返回值可能无法让您区分错误和空结果集。例如,如果以标量上下文调用selectrow_array()以检索单个值,则undef返回值是模棱两可的,因为它可能表示三种情况之一:错误、空结果集或由单个NULL值组成的结果集。要检测错误,请检查$DBI::errstr、$DBI::err或$DBI::state的值。
Ruby
Ruby 的 Mysql2 API 使用相同的调用来处理不返回结果集和返回结果集的 SQL 语句。在 Ruby 中处理语句时,使用query方法。如果语句因错误而失败,query会抛出异常。否则,affected_rows方法返回修改数据的最后一条语句的行数。
client.query("UPDATE profile SET cats = cats+1 WHERE name = 'Sybil'")
puts "Number of rows updated: #{client.affected_rows}"
对于返回结果集的SELECT等语句,query方法将结果集作为Mysql2::Result类的实例返回。对于这种语句,affected_rows方法将返回结果集中的行数。您还可以通过Mysql2::Result对象的count方法获取结果集中的行数。
result = client.query("SELECT id, name, cats FROM profile")
puts "Number of rows returned: #{client.affected_rows}"
puts "Number of rows returned: #{result.count}"
result.each do |row|
printf "id: %s, name: %s, cats: %s\n", row["id"], row["name"], row["cats"]
end
result.fields包含结果集中列的名称。
PHP
PDO 有两个连接对象方法用于执行 SQL 语句:exec()用于不返回结果集的语句,query()用于返回结果集的语句。如果启用了 PDO 异常,这两个方法在语句执行失败时都会抛出异常。(另一种方法是结合prepare()和execute()方法;请参见 Recipe 4.5。)
要执行诸如INSERT或UPDATE等不返回行的语句,请使用exec()。它返回一个计数,指示更改了多少行:
$count = $dbh->exec ("UPDATE profile SET cats = cats+1 WHERE name = 'Sybil'");
printf ("Number of rows updated: %d\n", $count);
对于返回结果集的SELECT等语句,query()方法返回一个语句句柄。通常,您使用此对象在循环中调用行提取方法,并计算行数(如果需要知道有多少行)。
$sth = $dbh->query ("SELECT id, name, cats FROM profile");
$count = 0;
while ($row = $sth->fetch (PDO::FETCH_NUM))
{
printf ("id: %s, name: %s, cats: %s\n", $row[0], $row[1], $row[2]);
$count++;
}
printf ("Number of rows returned: %d\n", $count);
要确定结果集中列的数量,请调用语句句柄的columnCount()方法。
示例演示了语句句柄的fetch()方法,该方法返回结果集的下一行或在没有更多行时返回FALSE。fetch()接受一个可选参数,指示它应返回什么类型的值。如示所示,使用PDO::FETCH_NUM作为参数,fetch()返回一个数组,可以使用数值下标访问其元素,从 0 开始。数组大小表示结果集列数。
使用PDO::FETCH_ASSOC作为参数,fetch()返回一个包含通过列名访问的值的关联数组($row["id"],$row["name"],$row["cats"])。
使用PDO::FETCH_OBJ作为参数,fetch()返回一个对象,可以使用列名访问其成员($row->id,$row->name,$row->cats)。
如果调用fetch()而不带参数,则使用默认的提取模式。除非已更改模式,否则默认为PDO::FETCH_BOTH,类似于PDO::FETCH_NUM和PDO::FETCH_ASSOC的组合。要为连接中执行的所有语句设置默认提取模式,请使用setAttribute数据库句柄方法:
$dbh->setAttribute (PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
要在给定语句中设置模式,请在执行语句之后并获取结果之前调用其setFetchMode()方法:
$sth->setFetchMode (PDO::FETCH_OBJ);
还可以将语句句柄用作迭代器。句柄使用当前默认的提取模式:
$sth->setFetchMode (PDO::FETCH_NUM);
foreach ($sth as $row)
printf ("id: %s, name: %s, cats: %s\n", $row[0], $row[1], $row[2]);
fetchAll()方法将整个结果集作为行数组提取并返回。它允许一个可选的提取模式参数:
$rows = $sth->fetchAll (PDO::FETCH_NUM);
foreach ($rows as $row)
printf ("id: %s, name: %s, cats: %s\n", $row[0], $row[1], $row[2]);
在这种情况下,行数是$rows中元素的数量。
Python
Python DB API 对不返回结果集的 SQL 语句和返回结果集的 SQL 语句使用相同的调用方式。要在 Python 中处理语句,使用数据库连接对象获取游标对象。然后使用游标的execute()方法将语句发送到服务器。如果语句出错,execute()会引发异常。否则,如果没有结果集,语句执行完成,并且游标的rowcount属性指示更改了多少行:
cursor = conn.cursor()
cursor.execute("UPDATE profile SET cats = cats+1 WHERE name = 'Sybil'")
print("Number of rows updated: %d" % cursor.rowcount)
conn.commit()
cursor.close()
注意
Python DB API 规范指示,数据库连接应以禁用自动提交模式开始,因此当 Connector/Python 连接到 MySQL 服务器时,它会禁用自动提交。如果使用事务表,在关闭连接之前未提交更改,这些修改将被回滚,因此前面的示例调用了commit()方法。有关自动提交模式的更多信息,请参阅第二十章,特别是 Recipe 20.7)。
如果语句返回结果集,则提取其行,然后关闭游标。fetchone()方法将下一行作为序列返回,或者在没有更多行时返回None:
cursor = conn.cursor()
cursor.execute("SELECT id, name, cats FROM profile")
while True:
row = cursor.fetchone()
if row is None:
break
print("id: %s, name: %s, cats: %s" % (row[0], row[1], row[2]))
print("Number of rows returned: %d" % cursor.rowcount)
cursor.close()
如前面的示例所示,rowcount 属性对 SELECT 语句也很有用;它指示结果集中的行数。
len(row) 告诉您结果集中的列数。
或者,将游标本身用作迭代器,依次返回每一行:
cursor = conn.cursor()
cursor.execute("SELECT id, name, cats FROM profile")
for (id, name, cats) in cursor:
print("id: %s, name: %s, cats: %s" % (id, name, cats))
print("Number of rows returned: %d" % cursor.rowcount)
cursor.close()
fetchall() 方法将整个结果集作为元组列表返回。通过列表迭代以访问行:
cursor = conn.cursor()
cursor.execute("SELECT id, name, cats FROM profile")
rows = cursor.fetchall()
for row in rows:
print("id: %s, name: %s, cats: %s" % (row[0], row[1], row[2]))
print("Number of rows returned: %d" % cursor.rowcount)
cursor.close()
DB API 提供了一种重置结果集的方法,因此当必须多次迭代结果集的行或直接访问单个值时,fetchall() 很方便。例如,如果 rows 包含结果集,则可以通过 rows[1][2] 访问第二行的第三列的值(索引从 0 开始,而不是 1)。
Go
Go 的 sql 接口有两个连接对象函数来执行 SQL 语句:Exec() 用于不返回结果集的语句,Query() 用于返回结果集的语句。如果语句失败,两者都会返回 error。
要执行不返回任何行的语句(如 INSERT、UPDATE 或 DELETE),请使用函数 Exec()。其返回值可以是 Result 或 error 类型。接口 Result 具有函数 RowsAffected(),指示更改了多少行。
sql := "UPDATE profile SET cats = cats+1 WHERE name = 'Sybil'"
res, err := db.Exec(sql)
if err != nil {
panic(err.Error())
}
affectedRows, err := res.RowsAffected()
if err != nil {
log.Fatal(err)
}
fmt.Printf("The statement affected %d rows\n", affectedRows)
对于返回结果集的语句,通常使用函数 Query()。该函数返回一个 Rows 类型的游标对象,其中保存了查询的结果。使用函数 Next() 来遍历结果,并使用函数 Scan() 将返回的值存储在变量中。如果 Next() 返回 false,表示没有结果。
res, err := db.Query("SELECT id, name, cats FROM profile")
defer res.Close()
if err != nil {
log.Fatal(err)
}
for res.Next() {
var profile Profile
err := res.Scan(&profile.id, &profile.name, &profile.cats)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", profile)
}
如果调用 Next() 并返回 false,则 Rows 会自动关闭。否则,需要使用函数 Close() 来关闭它们。
对于预期最多返回一行的查询,存在特殊函数 QueryRow(),返回一个 Row 对象,可以立即进行扫描。在调用 Scan() 之前,QueryRow() 不会返回错误。如果查询没有返回行,则 Scan() 返回 ErrNoRows。
row := db.QueryRow("SELECT id, name, cats FROM profile where id=3")
var profile Profile
err = row.Scan(&profile.id, &profile.name, &profile.cats)
if err == sql.ErrNoRows {
fmt.Println("No row matched!")
} else if err != nil {
log.Fatal(err)
} else {
fmt.Printf("%v\n", profile)
}
Java
JDBC 接口为 SQL 语句处理的各个阶段提供了特定的对象类型。在 JDBC 中,语句通过一种类型的 Java 对象执行。结果(如果有)以另一种类型的对象返回。
要执行语句,请先通过调用连接对象的 createStatement() 方法获取 Statement 对象:
Statement s = conn.createStatement ();
然后,使用 Statement 对象将语句发送到服务器。JDBC 提供了几种执行此操作的方法。选择适合语句类型的方法:executeUpdate() 用于不返回结果集的语句,executeQuery() 用于返回结果集的语句,execute() 用于其他情况。如果语句失败,每种方法都会引发异常。
executeUpdate() 方法向服务器发送不生成结果集的语句,并返回受影响行数。完成语句对象后,请关闭它:
Statement s = conn.createStatement ();
int count = s.executeUpdate(
"UPDATE profile SET cats = cats+1 WHERE name = 'Sybil'");
s.close(); // close statement
System.out.println("Number of rows updated: " + count);
对于返回结果集的语句,请使用executeQuery()。然后获取一个结果集对象,并使用它检索行值。完成后,关闭结果集和语句对象:
Statement s = conn.createStatement ();
s.executeQuery("SELECT id, name, cats FROM profile");
ResultSet rs = s.getResultSet();
int count = 0;
while (rs.next ()) { // loop through rows of result set\
int id = rs.getInt(1); // extract columns 1, 2, and 3
String name = rs.getString(2);
int cats = rs.getInt(3);
System.out.println("id: " + id
+ ", name: " + name
+ ", cats: " + cats);
++count;
}
rs.close (); // close result set
s.close (); // close statement
System.out.println ("Number of rows returned: " + count);
Statement对象的getResultSet()方法返回的ResultSet对象具有自己的方法,例如next()以获取行和各种getXXX()方法以访问当前行的列。最初,结果集位于集合的第一行之前。调用next()以连续获取每行,直到返回 false。要确定结果集中的行数,请自行计算,如前面的示例所示。
提示
对于返回单个结果集的查询,不需要调用getResultSet。上面的代码可以写成:
ResultSet rs = s.executeQuery("SELECT id, name, cats FROM profile");
. 当您的查询可能返回多个结果集时,例如,如果调用存储过程,需要单独调用。
要访问列值,使用诸如getInt()、getString()、getFloat()和getDate()等方法。要将列值作为通用对象获取,使用getObject()。getXXX()调用的参数可以指示列位置(从 1 开始,而不是 0)或列名。前面的示例展示了如何按位置检索id、name和cats列。要改为按名称访问列,编写行提取循环如下:
while (rs.next ()) { // loop through rows of result set
int id = rs.getInt("id");
String name = rs.getString("name");
int cats = rs.getInt("cats");
System.out.println("id: " + id
+ ", name: " + name
+ ", cats: " + cats);
++count;
}
要检索给定列值,请使用任何对于数据类型有意义的getXXX()调用。例如,getString()将任何列值作为字符串检索:
String id = rs.getString("id");
String name = rs.getString("name");
String cats = rs.getString("cats");
System.out.println("id: " + id
+ ", name: " + name
+ ", cats: " + cats);
或使用getObject()将值检索为通用对象,并根据需要转换值。以下示例使用toString()将对象值转换为可打印形式:
Object id = rs.getObject("id");
Object name = rs.getObject("name");
Object cats = rs.getObject("cats");
System.out.println("id: " + id.toString()
+ ", name: " + name.toString()
+ ", cats: " + cats.toString());
要确定结果集中的列数,请访问其元数据:
ResultSet rs = s.getResultSet();
ResultSetMetaData md = rs.getMetaData(); // get result set metadata
int ncols = md.getColumnCount(); // get column count from metadata
第三个 JDBC 语句执行方法execute()适用于任何类型的语句。当您从外部来源接收语句字符串并不知道它是否生成结果集或返回多个结果集时,它特别有用。execute()的返回值指示语句类型,以便您可以适当处理它:如果execute()返回 true,则存在结果集,否则没有。通常,您会像这样使用它,其中stmtStr表示任意 SQL 语句:
Statement s = conn.createStatement();
if (s.execute(stmtStr)) {
// there is a result set
ResultSet rs = s.getResultSe();
// ... process result set here ...
rs.close(); // close result set
} else {
// there is no result set, just print the row count
System.out.println("Number of rows affected: " + s.getUpdateCount ());
}
s.close(); // close statement
4.5 处理语句中的特殊字符和 NULL 值
问题
您需要构造引用包含特殊字符(如引号或反斜杠)或特殊值(如NULL)的数据值的 SQL 语句。或者您正在使用从外部来源获取的数据构造语句,并希望防止 SQL 注入攻击。
解决方案
使用 API 的占位符机制或引用函数使数据安全可插入。
讨论
到本章为止,我们的语句使用了不需要特殊处理的安全
数据值。例如,我们可以轻松地从程序内部编写数据值直接在语句字符串中构造以下 SQL 语句:
SELECT * FROM profile WHERE age > 40 AND color = 'green';
INSERT INTO profile (name,color) VALUES('Gary','blue');
然而,有些数据值不那么容易处理,如果不小心就会引起问题。语句可能使用包含特殊字符(如引号、反斜杠、二进制数据或NULL值)的数值。以下讨论描述了这些数值引起的困难以及正确的处理技术。
假设你想执行这个INSERT语句:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('Alison','1973-01-12','blue','eggroll',4);
如果你将name列的值更改为像De'Mont这样包含单引号的内容,这个语句就会变得语法无效:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De'Mont','1973-01-12','blue','eggroll',4);
问题在于单引号位于单引号字符串内部。为了通过转义引号使语句合法,可以在其前面加上单引号或反斜杠:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De''Mont','1973-01-12','blue','eggroll',4);
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De\'Mont','1973-01-12','blue','eggroll',4);
或者,用双引号引用name值本身,而不是单引号(假设未启用ANSI_QUOTES SQL 模式):
INSERT INTO profile (name,birth,color,foods,cats)
VALUES("De'Mont",'1973-01-12','blue','eggroll',4);
如果你在程序中直接编写语句,可以手动转义或引用name值,因为你知道该值是什么。但是如果名称存储在变量中,你并不一定知道变量的值。更糟糕的是,你必须准备处理的不仅仅是单引号;双引号和反斜杠也会引起问题。如果数据库存储二进制数据(如图像或音频剪辑),某个值可能包含任何内容——不仅仅是引号或反斜杠,还可能包含空值(零值字节)等其他字符。正确处理特殊字符的需求在 Web 环境中尤为迫切,因为语句是使用表单输入构建的(例如,如果你搜索与远程用户输入的搜索词匹配的行)。你必须能够以通用方式处理任何类型的输入,因为你无法预测用户将提供什么样的信息。恶意用户常常输入包含问题字符的垃圾值,试图攻击服务器安全性,甚至执行致命命令,比如DROP TABLE。这是一种利用不安全脚本的标准技术,称为SQL 注入。
SQL 中的NULL值不是特殊字符,但它也需要特殊处理。在 SQL 中,NULL表示无值
。这可能根据上下文有几种含义,例如未知
,丢失
,超出范围
等等。到目前为止,我们的语句还没有使用NULL值,以避免处理它们引入的复杂性,但现在是时候解决这些问题了。例如,如果你不知道 De’Mont 最喜欢的颜色,你可以将color列设为NULL,但不能这样写语句:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De''Mont','1973-01-12','NULL','eggroll',4);
相反,NULL值必须没有引号包围:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De''Mont','1973-01-12',NULL,'eggroll',4);
如果你在程序中直接写语句,你只需写单词NULL
而不加引号。但如果color值来自变量,正确的操作就不那么明显了。您必须知道变量的值是否表示NULL,以确定在构造语句时是否将其置于引号中。
您有两种方法来处理诸如引号和反斜杠之类的特殊字符以及NULL之类的特殊值:
-
在执行语句时,使用占位符在语句字符串中象征性地引用数据值,然后将数据值绑定到占位符。这是首选方法,因为 API 本身会根据需要为您提供引号,引用或转义数据值中的特殊字符,并可能解释特殊值以映射到
NULL而不用加引号。 -
使用引号函数(如果您的 API 提供)将数据值转换为适合在语句字符串中使用的安全形式。
本节展示了如何使用这些技术来处理每个 API 中的特殊字符和NULL值。这里展示的一个示例演示了如何插入包含De'Mont作为name值和NULL作为color值的profile表行。然而,这里展示的原则具有一般实用性,并处理任何特殊字符,包括二进制数据中的特殊字符。此外,这些原则不仅限于INSERT语句,它们同样适用于其他类型的语句,比如SELECT。这里展示的另一个示例演示了如何使用占位符执行SELECT语句。
处理特殊字符和NULL值是其他上下文中涉及到的内容:
-
此处描述的占位符和引用技术仅适用于数据值,而不适用于诸如数据库或表名之类的标识符。有关标识符引用的讨论,请参阅 Recipe 4.6。
-
比较
NULL值需要与非NULL值不同的运算符。Recipe 5.6 讨论如何从程序内部构造执行NULL比较的 SQL 语句。 -
本节涵盖了将特殊字符 插入 到你的数据库中的问题。相关问题是反向操作,即将值中的特殊字符 从 你的数据库中转换,以在各种上下文中进行显示。例如,如果你生成包含来自数据库的值的 HTML 页面,则必须执行输出编码,将这些值中的
<和>字符转换为 HTML 实体<和>以确保它们正确显示。
使用占位符
占位符允许你避免在 SQL 语句中直接写入数据值。使用这种方法,你可以使用占位符——特殊的标记,指示数值应该放在哪里。两种常见的参数标记是?和%s。根据标记,重写INSERT语句以使用如下的占位符:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?);
INSERT INTO profile (name,birth,color,foods,cats)
VALUES(%s,%s,%s,%s,%s);
然后将语句字符串传递给数据库服务器,并单独提供数据值。API 将这些值绑定到占位符上以替换它们,生成包含数据值的语句。
占位符的一个好处是参数绑定操作会自动处理转义字符,如引号和反斜杠。这对于将二进制数据(例如图像)插入到数据库中或使用具有未知内容的数据值(例如通过网页表单由远程用户提交的输入)特别有用。此外,通常有一些特殊值,你可以绑定到占位符,以指示你希望在结果语句中得到 SQL 的 NULL 值。
占位符的第二个好处是,你可以预先准备
一个语句,然后每次执行时绑定不同的值以重复使用它。预准备的语句鼓励语句重用。语句变得更通用,因为它们包含占位符而不是特定的数据值。如果你反复执行某个操作,可能可以重用一个预准备的语句,每次执行时只需绑定不同的数据值。某些数据库系统(MySQL 不在其中)具有在执行预准备语句之前执行某些预解析或执行计划的能力。对于稍后多次执行的语句,这减少了开销,因为只需在执行前完成一次工作,而不是每次执行都要完成。例如,如果一个程序在运行时多次执行特定类型的SELECT语句,这样的数据库系统可以构建该语句的计划,然后每次重用,而不是反复重建计划。MySQL 不预先构建查询计划,因此使用预准备语句不会带来性能提升。但是,如果你将程序移植到一个支持重用查询计划的数据库,并且你已经编写程序以使用预准备语句,那么你可以自动享受到预准备语句的这个优势。你无需从非预准备语句转换,就可以享受到这个好处。
第三个(诚然是主观的)好处是使用基于占位符的语句的代码可能更易于阅读。在您阅读本节时,请比较这里使用的语句与未使用占位符的 Recipe 4.4 中的语句,看看您更喜欢哪一种。
使用引号函数
一些 API 提供了一个引号函数,它以数据值作为其参数并返回一个适合安全插入到 SQL 语句中的正确引号和转义值。这种方法不如使用占位符常见,但在构造不打算立即执行的语句时可能很有用。但是,在使用此类引号函数时,必须保持对数据库服务器的连接打开,因为 API 无法在不知道数据库驱动程序的情况下选择正确的引号规则(这些规则在不同的数据库系统中有所不同)。
注意
正如我们稍后将指出的那样,某些 API 在将非 NULL 值绑定到参数标记时会将所有非 NULL 值(包括数字)都视为字符串引号。在需要 必须 数字的情况下,这可能会成为问题,如 Recipe 5.11 中进一步描述的那样。
Perl
要使用带有 Perl DBI 的占位符,请在 SQL 语句字符串中的每个数据值位置放置一个?。然后通过将这些值传递给 do() 或 execute(),或者调用专门用于占位符替换的 DBI 方法来绑定这些值到语句中。使用 undef 来绑定一个 NULL 值到占位符。
使用 do(),通过在同一调用中传递语句字符串和数据值,为 De’Mont 添加 profile 行:
my $count = $dbh->do ("INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?)",
undef,
"De'Mont", "1973-01-12", undef, "eggroll", 4);
跟在语句字符串后面的参数是 undef,然后是每个占位符对应的一个数据值。undef 参数是一个历史遗留物,但必须存在。
或者,将语句字符串传递给 prepare() 以获得语句句柄,然后使用该句柄将数据值传递给 execute():
my $sth = $dbh->prepare ("INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?)");
my $count = $sth->execute ("De'Mont", "1973-01-12", undef, "eggroll", 4);
在任何情况下,DBI 生成此语句:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De\'Mont','1973-01-12',NULL,'eggroll','4');
Perl DBI 占位符机制在将数据值绑定到语句字符串时会为其添加引号,因此不要在字符串中的 ? 字符周围加引号。
请注意,占位符机制会在数值周围添加引号。DBI 依赖于 MySQL 服务器根据需要将字符串转换为数字进行类型转换。如果将 undef 绑定到占位符,则 DBI 将 NULL 放入语句中,并正确地避免添加包围引号。
要重复执行同一语句,请先使用 prepare(),然后每次运行时使用适当的数据值调用 execute()。
您可以将这些方法用于其他类型的语句。例如,以下 SELECT 语句使用占位符来查找 cats 值大于 2 的行:
my $sth = $dbh->prepare ("SELECT * FROM profile WHERE cats > ?");
$sth->execute (2);
while (my $ref = $sth->fetchrow_hashref ())
{
print "id: $ref->{id}, name: $ref->{name}, cats: $ref->{cats}\n";
}
高级检索方法(如selectrow_array()和selectall_arrayref())也可以与占位符一起使用。与do()方法一样,参数是语句字符串、undef和要绑定到占位符的数据值。以下是一个例子:
my $ref = $dbh->selectall_arrayref (
"SELECT name, birth, foods FROM profile WHERE id > ? AND color = ?",
undef, 3, "green"
);
Perl DBI 的quote()数据库句柄方法是使用占位符的替代方法。以下是如何使用quote()创建插入profile表中新行的语句字符串。由于quote()会根据需要自动提供引号,因此请不要用引号括住%s格式说明符。非undef值会带有引号插入,而undef值会作为NULL值插入而不带引号:
my $stmt = sprintf ("INSERT INTO profile (name,birth,color,foods,cats)
VALUES(%s,%s,%s,%s,%s)",
$dbh->quote ("De'Mont"),
$dbh->quote ("1973-01-12"),
$dbh->quote (undef),
$dbh->quote ("eggroll"),
$dbh->quote (4));
my $count = $dbh->do ($stmt);
由此代码生成的语句字符串与使用占位符时相同。
Ruby
Ruby DBI 在 SQL 语句中使用?作为占位符字符,并使用nil作为将 SQL NULL值绑定到占位符的值。
要使用?,请将语句字符串传递给prepare以获取语句句柄,然后使用该句柄调用带有数据值的execute:
sth = client.prepare("INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?)")
sth.execute("De'Mont", "1973-01-12", nil, "eggroll", 4)
Mysql2 在结果语句中包含正确转义的引号和正确的未引用NULL值:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De\'Mont','1973-01-12',NULL,'eggroll',4);
Ruby 的 Mysql2 占位符机制在绑定到语句字符串时根据需要在数据值周围添加引号,因此不要在字符串中的?字符周围放置引号。
PHP
要使用 PDO 扩展的占位符,请将语句字符串传递给prepare()以获取语句对象。字符串可以包含?字符作为占位符标记。使用此对象调用execute(),向其传递要绑定到占位符的数据值数组。使用 PHP 的NULL值将 SQL NULL值绑定到占位符。用于为 De’Mont 添加profile表行的代码如下:
$sth = $dbh->prepare ("INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?)");
$sth->execute (array ("De'Mont","1973-01-12",NULL,"eggroll",4));
结果语句包含正确转义的引号和正确的未引用NULL值:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De\'Mont','1973-01-12',NULL,'eggroll','4');
PDO 占位符机制在绑定到语句字符串时在数据值周围添加引号,因此不要在字符串中的?字符周围放置引号。(请注意,甚至数字值4也会被引用;PDO 依赖 MySQL 在语句执行时进行必要的类型转换。)
Python
Connector/Python 模块通过在 SQL 语句字符串中使用%s格式说明符来实现占位符。(要在语句中插入字面上的%字符,请在语句字符串中使用%%。)要使用占位符,请使用两个参数调用execute()方法:包含格式说明符的语句字符串和包含要绑定到语句字符串的值的序列。使用None将NULL值绑定到占位符。用于为 De’Mont 添加profile表行的代码如下:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO profile (name,birth,color,foods,cats)
VALUES(%s,%s,%s,%s,%s)
''', ("De'Mont", "1973-01-12", None, "eggroll", 4))
cursor.close()
conn.commit()
由上述execute()调用发送到服务器的语句如下所示:
INSERT INTO profile (name,birth,color,foods,cats)
VALUES('De\'Mont','1973-01-12',NULL,'eggroll',4);
Connector/Python 的占位符机制在绑定到语句字符串时根据需要在数据值周围添加引号,因此不要在字符串中的%s格式说明符周围放置引号。
如果只有一个单值 val 需要绑定到占位符,可以使用以下语法将其写为序列: (val,):
cursor = conn.cursor()
cursor.execute("SELECT id, name, cats FROM profile WHERE cats = %s", (2,))
for (id, name, cats) in cursor:
print("id: %s, name: %s, cats: %s" % (id, name, cats))
cursor.close()
或者,将值写为列表,使用以下语法 [val]。
Go
Go 的 sql 包使用问号 (?) 作为占位符标记。你可以在单个 Exec() 或 Query() 调用中使用占位符,也可以预先准备语句并稍后执行。当需要多次执行语句时,后一种方法很有用。为了为 De’Mont 添加 profile 表行的代码看起来像这样:
stmt := `INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?)`
_, err = db.Exec(stmt, "De'Mont", "1973-01-12", nil, "eggroll", 4)
使用 Prepare() 调用的相同代码看起来像这样:
pstmt, err := db.Prepare(`INSERT INTO profile (name,birth,color,foods,cats)
VALUES(?,?,?,?,?)`)
if err != nil {
log.Fatal(err)
}
defer pstmt.Close()
_, err = pstmt.Exec("De'Mont", "1973-01-12", nil, "eggroll", 4)
Java
如果使用预编译语句,JDBC 提供了对占位符的支持。回顾在 JDBC 中执行非预编译语句的过程是创建 Statement 对象,然后将语句字符串传递给 executeUpdate()、executeQuery() 或 execute() 函数。相反,如果使用预编译语句,通过将包含 ? 占位符字符的语句字符串传递给连接对象的 prepareStatement() 方法来创建 PreparedStatement 对象。然后使用 setXXX() 方法将数据值绑定到语句。最后,通过调用 executeUpdate()、executeQuery() 或 execute() 执行语句,并使用空参数列表。
下面是一个示例,使用 executeUpdate() 执行 INSERT 语句,为 De’Mont 添加 profile 表行:
PreparedStatement s;
s = conn.prepareStatement(
"INSERT INTO profile (name,birth,color,foods,cats)"
+ " VALUES(?,?,?,?,?)");
s.setString(1, "De'Mont"); // bind values to placeholders
s.setString(2, "1973-01-12");
s.setNull(3, java.sql.Types.CHAR);
s.setString(4, "eggroll");
s.setInt(5, 4);
s.close(); // close statement
setXXX() 方法将数据值绑定到语句上,需要两个参数:占位符位置(从 1 开始,不是 0)和要绑定到占位符的值。选择每个值绑定调用以匹配绑定到的列的数据类型:setString() 绑定字符串到 name 列,setInt() 绑定整数到 cats 列,依此类推。 (实际上,我使用 setString() 将日期值绑定为字符串以处理 birth。)
JDBC 与其他 API 的一个区别是,你不需要指定特殊值(例如 Perl 中的 undef 或 Ruby 中的 nil)来将 NULL 绑定到占位符上。相反,调用 setNull() 并传递第二个参数指示列的类型:java.sql.Types.CHAR 表示字符串,java.sql.Types.INTEGER 表示整数,依此类推。
setXXX() 调用会根据需要为数据值添加引号,因此不要在语句字符串中的 ? 占位符字符周围加上引号。
要处理返回结果集的语句,流程类似,但要使用 executeQuery() 而不是 executeUpdate() 执行准备好的语句:
PreparedStatement s;
s = conn.prepareStatement("SELECT * FROM profile WHERE cats > ?");
s.setInt(1, 2); // bind 2 to first placeholder
s.executeQuery();
// ... process result set here ...
s.close(); // close statement
4.6 处理标识符中的特殊字符
问题
您需要构造引用包含特殊字符的标识符的 SQL 语句。
解决方案
对每个标识符进行引用,以便安全地插入到语句字符串中。
讨论
Recipe 4.5 讨论如何通过使用占位符或引用方法处理数据值中的特殊字符。标识符中也可能存在特殊字符,例如数据库、表和列名。例如,表名 some table 包含默认情况下不允许的空格:
mysql> `CREATE TABLE some table (i INT);`
ERROR 1064 (42000): You have an error in your SQL syntax near 'table (i INT)'
标识符中的特殊字符与数据值中的处理方式不同。为了使标识符能够安全地插入到 SQL 语句中,请使用反引号将其括起来进行引用:
mysql> ``CREATE TABLE `some table` (i INT);``
Query OK, 0 rows affected (0.04 sec)
在 MySQL 中,反引号始终允许用于标识符引用。如果启用了 ANSI_QUOTES SQL 模式,则也允许双引号字符。因此,在启用 ANSI_QUOTES 的情况下,以下两个语句等效:
CREATE TABLE `some table` (i INT);
CREATE TABLE "some table" (i INT);
如果需要知道哪些标识符引用字符是允许的,请执行 SELECT @@sql_mode 语句来检索您会话的 SQL 模式,并检查其值是否包含 ANSI_QUOTES。
如果引号字符出现在标识符本身中,请在引用标识符时加倍。例如,将 abc`def 引用为 `abc``def`。
请注意,尽管 MySQL 中的字符串数据值通常可以使用单引号或双引号字符 ('abc', "abc") 进行引用,但在启用 ANSI_QUOTES 的情况下并非如此。在这种情况下,MySQL 将 'abc' 解释为字符串,"abc" 解释为标识符,因此您必须仅使用单引号来引用字符串。
在程序中,如果 API 提供了标识符引用例程,您可以使用该例程,如果没有,则可以自己编写一个。Perl DBI 有一个 quote_identifier() 方法,返回一个正确引用的标识符。对于没有此类方法的 API,您可以通过将其用反引号括起来并加倍任何出现在标识符中的反引号来引用标识符。以下是一个执行此操作的 PHP 例程:
function quote_identifier ($ident)
{
return ('`' . str_replace('`', '``', $ident) . '`');
}
可移植性注意事项:如果编写自己的标识符引用例程,请记住其他 DBMS 可能需要不同的引用约定。
在将标识符用作数据值的上下文中,请相应处理它们。如果从 INFORMATION_SCHEMA 元数据数据库中选择信息,通常会通过在 WHERE 子句中指定数据库对象名称来指示返回哪些行。例如,此语句检索 cookbook 数据库中 profile 表的列名:
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = 'cookbook' AND TABLE_NAME = 'profile';
此处使用的数据库和表名作为数据值使用,而不是标识符。如果在程序中构造此语句,使用占位符参数化它们,而不是标识符引用。例如,在 Ruby 中,执行如下操作:
sth = client.prepare("SELECT COLUMN_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?")
names = sth.execute(db_name, tbl_name)
4.7 在结果集中识别 NULL 值
问题
查询结果包含 NULL 值,但你不确定如何识别它们。
解决方案
你的 API 可能通过惯例使用特殊值表示 NULL。你只需知道它是什么,以及如何测试它。
讨论
Recipe 4.5 描述了在向数据库服务器发送语句时如何引用 NULL 值。在本节中,我们将处理如何识别和处理从数据库服务器返回的 NULL 值的问题。通常,这涉及知道 API 将 NULL 值映射到什么特殊值,或者调用什么方法。Table 4-5 显示了这些值:
表 4-5. 检测到的 NULL 值
| 语言 | 检测 NULL 值的值或方法 |
|---|---|
| Perl DBI | undef 值 |
| Ruby Mysql2 gem | nil 值 |
| PHP PDO | NULL 值 |
| Python DB API | None 值 |
Go sql 接口 |
为可空数据类型实现的 Go Null 类型 |
| Java JDBC | wasNull() 方法 |
以下各节展示了检测 NULL 值的非常简单的应用程序示例。示例检索结果集并打印其中的所有值,将 NULL 值映射到可打印的字符串 "NULL"。
为了确保 profile 表中有包含一些 NULL 值的行,请使用 mysql 执行以下 INSERT 语句,然后执行 SELECT 语句以验证生成的行具有预期的值:
mysql> `INSERT INTO profile (name) VALUES('Amabel');`
mysql> `SELECT * FROM profile WHERE name = 'Amabel';`
+----+--------+-------+-------+-------+------+
| id | name | birth | color | foods | cats |
+----+--------+-------+-------+-------+------+
| 9 | Amabel | NULL | NULL | NULL | NULL |
+----+--------+-------+-------+-------+------+
id 列可能包含不同的数字,但其他列应该按照所示的方式出现,其中值为 NULL。
Perl
Perl DBI 使用 undef 表示 NULL 值。要检测这些值,请使用 defined() 函数;如果启用了 Perl 的 -w 选项或通过在脚本中包含 use warnings 行,则这样做尤为重要。否则,访问 undef 值会导致 Perl 发出 Use of uninitialized value 警告。
为了避免这些警告,请在使用之前使用 defined() 测试可能是 undef 的列值。以下代码从 profile 表中选择几列,并为每行中任何未定义的值打印 "NULL"。这样可以在输出中明确地表示 NULL 值,而不激活任何警告消息:
my $sth = $dbh->prepare ("SELECT name, birth, foods FROM profile");
$sth->execute ();
while (my $ref = $sth->fetchrow_hashref ())
{
printf "name: %s, birth: %s, foods: %s\n",
defined ($ref->{name}) ? $ref->{name} : "NULL",
defined ($ref->{birth}) ? $ref->{birth} : "NULL",
defined ($ref->{foods}) ? $ref->{foods} : "NULL";
}
不幸的是,测试多个列值很冗长,随着列数的增加而变得更糟。为了避免这种情况,在打印之前使用循环或 map 测试和设置未定义的值。以下示例使用 map:
my $sth = $dbh->prepare ("SELECT name, birth, foods FROM profile");
$sth->execute ();
while (my $ref = $sth->fetchrow_hashref ())
{
map { $ref->{$_} = "NULL" unless defined ($ref->{$_}); } keys (%{$ref});
printf "name: %s, birth: %s, foods: %s\n",
$ref->{name}, $ref->{birth}, $ref->{foods};
}
使用这种技术,执行测试的代码量是恒定的,而不是与要测试的列数成比例增加的。此外,没有引用特定的列名,因此它可以更轻松地用于其他程序或作为实用程序例程的基础。
如果你将行数据取出到一个数组而不是哈希表中,可以使用以下方式使用 map 来转换 undef 值:
my $sth = $dbh->prepare ("SELECT name, birth, foods FROM profile");
$sth->execute ();
while (my @val = $sth->fetchrow_array ())
{
@val = map { defined ($_) ? $_ : "NULL" } @val;
printf "name: %s, birth: %s, foods: %s\n",
$val[0], $val[1], $val[2];
}
Ruby
Ruby Mysql2 模块使用 nil 表示 NULL 值,可以通过将 nil? 方法应用于值来识别。以下示例使用 nil? 方法和三元运算符确定是否将结果集值原样打印,或者对于 NULL 值打印字符串 "NULL":
result = client.query("SELECT name, birth, foods FROM profile")
result.each do |row|
printf "name %s, birth: %s, foods: %s\n",
row["name"].nil? ? "NULL" : row["name"],
row["birth"].nil? ? "NULL" : row["birth"],
row["foods"].nil? ? "NULL" : row["foods"]
end
PHP
PHP 将 SQL 结果集中的 NULL 值表示为 PHP 的 NULL 值。要确定来自结果集的值是否表示 NULL 值,请使用 === 三重等
运算符进行比较:
if ($val === NULL)
{
# $val is a NULL value
}
在 PHP 中,三重等号运算符意味着 完全相等
。通常的 == 相等
比较运算符在这里不适用:使用 == 时,PHP 认为 NULL 值、空字符串和 0 都相等。
下面的代码使用 === 运算符来识别结果集中的 NULL 值并将它们打印为字符串 "NULL":
$sth = $dbh->query ("SELECT name, birth, foods FROM profile");
while ($row = $sth->fetch (PDO::FETCH_NUM))
{
foreach (array_keys ($row) as $key)
{
if ($row[$key] === NULL)
$row[$key] = "NULL";
}
print ("name: $row[0], birth: $row[1], foods: $row[2]\n");
}
用于 NULL 值测试的 === 的替代方法是 is_null()。
Python
Python DB API 程序使用 None 表示结果集中的 NULL。以下示例显示如何检测 NULL 值:
cursor = conn.cursor()
cursor.execute("SELECT name, birth, foods FROM profile")
for row in cursor:
row = list(row) # convert nonmutable tuple to mutable list
for i, value in enumerate(row):
if value is None: # is the column value NULL?
row[i] = "NULL"
print("name: %s, birth: %s, foods: %s" % (row[0], row[1], row[2]))
cursor.close()
内部循环通过查找 None 来检查 NULL 列值,并将它们转换为字符串 "NULL"。示例在循环之前将 row 转换为可变对象(列表),因为 fetchall() 返回行作为序列值,这些值是不可变的(只读)。
前往
Go 的 sql 接口提供了特殊的数据类型来处理结果集中可能包含 NULL 值的值。它们针对标准 Go 类型进行了定义。表 4-6 包含标准数据类型及其可为空的等效类型的列表。
表 4-6. 在 Go 中处理 NULL 值
| Go 标准类型 | 可包含 NULL 值的类型 |
|---|---|
bool |
NullBool |
float64 |
NullFloat64 |
int32 |
NullInt32 |
int64 |
NullInt64 |
string |
NullString |
time.Time |
NullTime |
要定义一个变量,它既可以接受 NULL 值也可以接受非 NULL 值作为传递给 Scan() 函数的参数,请使用相应的可为空类型。
所有可为空类型都包含两个函数:Valid() 如果值不为 NULL 则返回 true,如果值为 NULL 则返回 false。第二个函数是以大写字母开头的类型名称。例如,对于 string 值是 String(),对于 time.Time 值是 Time()。当值不为 NULL 时,此方法返回实际值。
这里是在 Go 中处理 NULL 值的示例。
// null-in-result.go : Selecting NULL values in Go
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/go-sql-driver/mysql"
)
type Profile struct {
name string
birth sql.NullString
foods sql.NullString
}
func main() {
db, err := sql.Open("mysql", "cbuser:cbpass@tcp(127.0.0.1:3306)/cookbook")
if err != nil {
log.Fatal(err)
}
defer db.Close()
sql := "SELECT name, birth, foods FROM profile"
res, err := db.Query(sql)
if err != nil {
log.Fatal(err)
}
defer res.Close()
for res.Next() {
var profile Profile
err = res.Scan(&profile.name, &profile.birth, &profile.foods)
if err != nil {
log.Fatal(err)
}
if (profile.birth.Valid && profile.foods.Valid) {
fmt.Printf("name: %s, birth: %s, foods: %s\n",
profile.name, profile.birth.String, profile.foods.String)
} else if profile.birth.Valid {
fmt.Printf("name: %s, birth: %s, foods: NULL\n",
profile.name, profile.birth.String)
} else if profile.foods.Valid {
fmt.Printf("name: %s, birth: NULL, foods: %s\n",
profile.name, profile.foods.String)
} else {
fmt.Printf("name: %s, birth: NULL, foods: NULL\n",
profile.name)
}
}
}
警告
我们在 birth 列中简单使用了类型 NullString。如果您想使用类型 NullTime,则需要在连接字符串中添加参数 parseTime=true。
提示
或者您可以在查询执行期间使用 MySQL 的 COALESCE() 函数将 NULL 值转换为字符串。
sql := `SELECT name,
COALESCE(birth, '') as birthday
FROM profile WHERE id = 9`
res, err := db.Query(sql)
defer res.Close()
Java
对于 JDBC 程序,如果结果集中的某列可能包含 NULL 值,最好显式检查它们。具体方法是获取该值,然后调用 wasNull() 方法,如果该列为 NULL 则返回 true,否则返回 false。例如:
Object obj = rs.getObject (index);
if (rs.wasNull ())
{ /* the value's a NULL */ }
上述示例使用了 getObject(),但同样适用于其他 getXXX() 调用。
下面是打印结果集中每行作为逗号分隔值列表的示例,对于每个 NULL 值都打印 "NULL":
Statement s = conn.createStatement();
s.executeQuery("SELECT name, birth, foods FROM profile");
ResultSet rs = s.getResultSet();
ResultSetMetaData md = rs.getMetaData();
int ncols = md.getColumnCount();
while (rs.next ()) { // loop through rows of result set
for (int i = 0; i < ncols; i++) { // loop through columns
String val = rs.getString(i+1);
if (i > 0)
System.out.print(", ");
if (rs.wasNull())
System.out.print("NULL");
else
System.out.print(val);
}
System.out.println();
}
rs.close(); // close result set
s.close(); // close statement
4.8 获取连接参数
问题
您需要获取一个脚本的连接参数,以便它可以连接到一个 MySQL 服务器。
解决方案
有几种方法可以做到这一点。从这里描述的备选方案中选择一种。
讨论
任何连接到 MySQL 的程序都会指定连接参数,例如用户名、密码和主机名。到目前为止显示的方法将连接参数直接放入尝试建立连接的代码中,但这并不是您的程序获取参数的唯一方式。本讨论简要概述了一些可用的技术:
将参数硬编码到程序中
参数可以在主源文件或程序使用的库文件中给出。这种技术非常方便,因为用户无需自己输入值,但也非常不灵活。要更改参数,必须修改程序。这也是不安全的,因为访问库的每个人都可以读取您的数据库凭据。
通过交互方式询问参数
在命令行环境中,您可以询问用户一系列问题。在 Web 或 GUI 环境中,您可以通过呈现表单或对话框来执行此操作。无论哪种方式,对于经常使用应用程序的人来说,由于每次需要输入参数,这变得很繁琐。
从命令行获取参数
您可以将此方法用于交互式运行的命令或从脚本中运行的命令。与交互式获取参数的方法一样,您必须为每个命令调用提供参数。(减轻这种负担的因素是,许多 Shell 允许您轻松地从历史列表中重新调用命令以重新执行。)如果以这种方式提供凭据,则此方法可能是不安全的。
从执行环境获取参数
这样做的最常见方法是在您的 Shell 启动文件之一中设置适当的环境变量(如.profile对于sh、bash、ksh;或者.login对于csh或tcsh)。然后在登录会话期间运行的程序可以通过检查它们的环境来获取参数值。
从一个单独的文件中获取参数
使用这种方法,将诸如用户名和密码之类的信息存储在程序连接到 MySQL 服务器之前可以读取的文件中。从与您的程序分离的文件中读取参数使您无需每次使用程序时都输入它们,而不需要将值硬编码到程序中。此外,将值存储在文件中使您能够集中存储用于多个程序的参数,并且出于安全目的,您可以设置文件访问模式以防止其他用户读取文件。
MySQL 客户端库本身支持一个选项文件机制,尽管并非所有的 API 都提供对其的访问。对于那些不支持的情况,可能存在解决方法。(例如,Java 支持使用属性文件,并提供用于读取它们的实用程序例程。)
使用多种方法的组合。
结合方法通常很有用,以便用户可以以不同的方式提供参数。例如,MySQL 客户端(如mysql和mysqladmin)在几个位置查找选项文件并读取所有存在的选项文件。然后,它们检查命令行参数以获取更多参数。这使用户可以在选项文件或命令行中指定连接参数。
获取连接参数的这些方法确实涉及安全问题:
-
任何将连接参数存储在文件中的方法都可能危及您系统的安全,除非该文件受到未经授权用户访问的保护。无论参数存储在源文件、选项文件还是调用命令并在命令行上指定参数的脚本中,这一点都是真实的。(如果其他用户具有对服务器的管理访问权限,则只能由 Web 服务器读取的 Web 脚本不算安全。)
-
在命令行或环境变量中指定的参数并不特别安全。当程序执行时,它的命令行参数和环境可能对运行
ps-e等进程状态命令的其他用户可见。特别是,将密码存储在环境变量中最好限制在你是机器上唯一的用户或者你信任所有其他用户的情况下。
本节的其余部分讨论如何处理命令行参数以获取连接参数以及如何从选项文件中读取参数。
从命令行获取参数
标准客户端(如mysql和mysqladmin)用于命令行参数的约定是允许使用短选项或长选项指定参数。例如,用户名cbuser可以指定为-u cbuser(或-ucbuser)或--user=cbuser。此外,对于密码选项(-p或--password),在选项名称后可能省略密码值,以导致程序交互式提示输入密码。
这些命令选项的标准标记是-h或--host,-u或--user,以及-p或--password。您可以编写自己的代码来遍历参数列表,但使用为此目的编写的现有选项处理模块要容易得多。在recipes分发的api目录下,您会找到示例程序,展示如何处理命令参数以获取 Perl、Ruby、Python 和 Java 的主机名、用户名和密码。附带的 PDF 文件解释了每个程序的工作原理。
注意
在尽可能大的程度上,程序模仿了标准 MySQL 客户端的选项处理行为。一个例外是选项处理库可能不允许使密码值变为可选,并且如果指定了密码选项但未提供密码值,则它们不提供以交互方式提示用户输入密码的方法。因此,程序是这样编写的:如果您使用 -p 或 --password,则必须提供该选项后面的密码值。
从选项文件获取参数
如果您的 API 支持,可以在 MySQL 选项文件中指定连接参数,并让 API 为您从文件中读取参数。对于不直接支持选项文件的 API,您可以安排读取存储参数的其他类型的文件,或者编写自己的函数来读取选项文件。
食谱 1.4 描述了 MySQL 选项文件的格式。我们假设您已经阅读了那里的讨论,并集中在如何在程序中使用选项文件。您可以在recipes分发的api目录下找到包含此处讨论代码的文件。
在 Unix 下,特定于用户的选项按约定规定在 ~/.my.cnf 中(即在您的主目录中的 .my.cnf 文件中)。但是,MySQL 选项文件机制可以在多个不同的文件中查找,尽管没有任何选项文件是 必须 存在的。(有关 MySQL 程序查找它们的标准位置列表,请参见 食谱 1.4。)如果存在多个选项文件,并且在其中几个文件中指定了给定参数,则最后找到的值优先。
您编写的程序不会使用 MySQL 选项文件,除非您告诉它们:
-
Perl DBI 和 Ruby Mysql2 gem 提供了直接的 API 支持以读取选项文件;只需在连接到服务器时指示您想使用它们即可。可以指定仅读取特定文件,或者使用标准搜索顺序来查找多个选项文件。
-
PHP PDO、Connector/Python、Java 和 Go 不支持选项文件。(PDO MySQL 驱动程序除外,但如果使用
mysqlnd作为底层库则不支持。)作为 PHP 的一种解决方法,我们将编写一个简单的选项文件解析函数。对于 Java,我们将采用使用属性文件的不同方法。对于 Go,我们将利用INI解析库。
尽管 Unix 下用户特定选项文件的常规名称是.my.cnf,位于当前用户的主目录下,但并没有规定你自己的程序必须使用这个特定的文件。你可以任意命名一个选项文件,并将其放置在任何你想要的位置。例如,你可以设置一个名为mcb.cnf的文件,并将其安装在/usr/local/lib/mcb目录下,以供访问cookbook数据库的脚本使用。在某些情况下,你可能甚至希望创建多个选项文件。然后,在任何给定的脚本中,选择适合脚本需要的访问权限的文件。例如,你可以有一个选项文件mcb.cnf,列出完全访问 MySQL 帐户的参数,以及另一个文件mcb-readonly.cnf,列出仅需要 MySQL 只读访问权限帐户的连接参数。另一种可能性是在同一个选项文件中列出多个组,并让你的脚本从适当的组中选择选项。
Perl
Perl DBI 脚本可以使用选项文件。为了利用这一点,请将适当的选项规范放置在数据源名称(DSN)字符串的第三个组件中:
-
要指定一个选项组,请使用
mysql_read_default_group=*`groupname`*。这告诉 MySQL 在标准选项文件中搜索指定组和[client]组中的选项。将groupname值写入,不包含周围的方括号。(如果选项文件中的组以[my_prog]行开始,则将groupname值指定为my_prog。)要搜索标准文件,但仅在[client]组中查找,groupname应为client。 -
要命名特定的选项文件,请在 DSN 中使用
mysql_read_default_file=*`filename`*。这样做时,MySQL 仅在该文件中查找并仅使用[client]组中的选项。 -
如果同时指定了选项文件和选项组,MySQL 只读取指定的文件,但在指定组和
[client]组中查找选项。
以下示例告诉 MySQL 使用标准选项文件搜索顺序来查找[cookbook]和[client]组中的选项:
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
# basic DSN
my $dsn = "DBI:mysql:database=cookbook";
# look in standard option files; use [cookbook] and [client] groups
$dsn .= ";mysql_read_default_group=cookbook";
my $dbh = DBI->connect ($dsn, undef, undef, $conn_attrs);
下一个示例明确指定了位于$ENV{HOME}(运行脚本的用户的主目录)中的选项文件。因此,MySQL 仅在该文件中查找并使用[client]组中的选项:
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
# basic DSN
my $dsn = "DBI:mysql:database=cookbook";
# look in user-specific option file owned by the current user
$dsn .= ";mysql_read_default_file=$ENV{HOME}/.my.cnf";
my $dbh = DBI->connect ($dsn, undef, undef, $conn_attrs);
如果在connect()调用的用户名或密码参数中传递空值(undef或空字符串),connect()将使用在选项文件中找到的任何值。在connect()调用中,非空的用户名或密码会覆盖选项文件中的任何值。类似地,DSN 中指定的主机会覆盖选项文件中的任何值。使用此行为使得 DBI 脚本可以从选项文件和命令行获取连接参数:
-
创建
$host_name、$user_name和$password变量,每个变量的值为undef。然后解析命令行参数,如果命令行中存在相应的选项,则将变量设置为非undef值。(api目录下的cmdline.pl Perl 脚本演示了如何执行此操作。) -
在解析命令参数后,构建 DSN 字符串,并调用
connect()。在 DSN 中使用mysql_read_default_group和mysql_read_default_file来指定选项文件的使用方式,如果$host_name不是undef,则将host=$host_name添加到 DSN 中。此外,将$user_name和$password作为用户名和密码参数传递给connect()。这些参数默认为undef;如果它们从命令行参数设置了值,则这些值将覆盖任何选项文件中的值。
如果脚本遵循此过程,则用户在命令行上提供的参数将传递给connect(),并优先于选项文件中的内容。
Ruby
Ruby Mysql2 脚本可以读取选项文件,通过default_file连接参数指定。如果要指定默认组,请使用选项default_group。
此示例使用标准的选项文件搜索顺序来查找[cookbook]和[client]组中的选项:
client = Mysql2::Client.new(:default_group => "cookbook", :database => "cookbook")
以下示例使用当前用户主目录中的.my.cnf文件获取[client]组中的参数:
client = Mysql2::Client.new(:default_file => "#{ENV['HOME']}/.my.cnf",↩
:database => "cookbook")
PHP
正如前面提到的,PDO MySQL 驱动程序不一定支持使用 MySQL 选项文件(如果使用mysqlnd作为底层库则不支持)。为了解决这个限制,可以使用一个函数来读取选项文件,例如下面列表中显示的read_mysql_option_file()函数。它的参数包括选项文件的名称和选项组名称或包含组名称的数组(组名称应该不带方括号)。然后,它读取文件中指定组或组的任何选项。如果没有给出选项组参数,则函数默认在[client]组中查找。返回值是一个选项名称/值对的数组,如果发生错误则返回FALSE。文件不存在并不是错误。(请注意,MySQL 选项文件中的带引号的选项值和选项值后的尾随#风格注释是合法的,但该函数不处理这些结构。)
function read_mysql_option_file ($filename, $group_list = "client")
{
if (is_string ($group_list)) # convert string to array
$group_list = array ($group_list);
if (!is_array ($group_list)) # hmm ... garbage argument?
return (FALSE);
$opt = array (); # option name/value array
if (!@($fp = fopen ($filename, "r"))) # if file does not exist,
return ($opt); # return an empty list
$in_named_group = 0; # set nonzero while processing a named group
while ($s = fgets ($fp, 1024))
{
$s = trim ($s);
if (preg_match ("/^[#;]/", $s)) # skip comments
continue;
if (preg_match ("/^\[([^]]+)]/", $s, $arg)) # option group line
{
# check whether we are in one of the desired groups
$in_named_group = 0;
foreach ($group_list as $group_name)
{
if ($arg[1] == $group_name)
{
$in_named_group = 1; # we are in a desired group
break;
}
}
continue;
}
if (!$in_named_group) # we are not in a desired
continue; # group, skip the line
if (preg_match ("/^([^ \t=]+)[ \t]*=[ \t]*(.*)/", $s, $arg))
$opt[$arg[1]] = $arg[2]; # name=value
else if (preg_match ("/^([^ \t]+)/", $s, $arg))
$opt[$arg[1]] = ""; # name only
# else line is malformed
}
return ($opt);
}
下面是两个示例展示如何使用read_mysql_option_file()。第一个示例读取用户的选项文件以获取[client]组参数,并将它们用于连接到服务器。第二个示例读取系统范围的选项文件,/etc/my.cnf,并打印在那里找到的服务器启动参数(即[mysqld]和[server]组中的参数):
$opt = read_mysql_option_file ("/home/paul/.my.cnf");
$dsn = "mysql:dbname=cookbook";
if (isset ($opt["host"]))
$dsn .= ";host=" . $opt["host"];
$user = $opt["user"];
$password = $opt["password"];
try
{
$dbh = new PDO ($dsn, $user, $password);
print ("Connected\n");
$dbh = NULL;
print ("Disconnected\n");
}
catch (PDOException $e)
{
print ("Cannot connect to server\n");
}
$opt = read_mysql_option_file ("/etc/my.cnf", array ("mysqld", "server"));
foreach ($opt as $name => $value)
print ("$name => $value\n");
PHP 确实有一个 parse_ini_file() 函数,用于解析 .ini 文件。这些文件的语法与 MySQL 选项文件类似,因此您可能会发现此函数有用。但是,需要注意一些区别。假设您有一个文件写成这样:
[client]
user=paul
[client]
host=127.0.0.1
[mysql]
no-auto-rehash
标准 MySQL 选项解析将 user 和 host 值都视为 [client] 组的一部分,而 parse_ini_file() 仅返回最终 [client] 节的内容;user 选项会丢失。此外,parse_ini_file() 忽略未附带值的选项,因此 no-auto-rehash 选项也会丢失。
Go
Go-MySQL-Driver 不支持选项文件。然而,INI 解析库支持读取包含以 name=value 格式为行的属性文件。以下是一个示例属性文件:
# this file lists parameters for connecting to the MySQL server
[client]
user=cbuser
password=cbpass
host=localhost
函数 MyCnf() 展示了读取名为 ~/.my.cnf 的属性文件以获取连接参数的一种方法:
import (
"fmt"
"os"
"gopkg.in/ini.v1"
)
// Configuration Parser
func MyCnf(client string) (string, error) {
cfg, err := ini.LoadSources(ini.LoadOptions{AllowBooleanKeys: true}, ↩
os.Getenv("HOME")+"/.my.cnf")
if err != nil {
return "", err
}
for _, s := range cfg.Sections() {
if client != "" && s.Name() != client {
continue
}
host := s.Key("host").String()
port := s.Key("port").String()
dbname := s.Key("dbname").String()
user := s.Key("user").String()
password := s.Key("password").String()
return fmt.Sprintf("%s:%s@tcp(%s:%s)/%s", user, password, host, port, dbname),↩
nil
}
return "", fmt.Errorf("No matching entry found in ~/.my.cnf")
}
在本章其他地方开发的 cookbook.go 中定义的 MyCnf() 函数(参见 Recipe 4.3)。它在 api/06_conn_params 目录中的 mycnf.go 文件中使用,在 recipes 发行版中找到:
// mycnf.go : Reads ~/.my.cnf file for DSN construct
package main
import (
"fmt"
"github.com/svetasmirnova/mysqlcookbook/recipes/lib"
)
func main() {
fmt.Println("Calling db.MyCnf()")
var dsn string
dsn, err := cookbook.MyCnf("client")
if err != nil {
fmt.Printf("error: %v\n", err)
} else {
fmt.Printf("DSN is: %s\n", dsn)
}
}
函数 MyCnf() 接受节名作为参数。如果要用任何其他名称替换 [client] 节,请调用 MyCnf(),如 MyCnf("other"),其中 other 是节的名称。
Java
JDBC MySQL Connector/J 驱动程序不支持选项文件。然而,Java 类库支持读取以 name=value 格式为行的属性文件。这与 MySQL 选项文件格式类似但并非完全相同(例如,属性文件不允许 [*`groupname`*] 行)。以下是一个简单的属性文件:
# this file lists parameters for connecting to the MySQL server
user=cbuser
password=cbpass
host=localhost
下面的程序 ReadPropsFile.java 展示了读取名为 Cookbook.properties 的属性文件以获取连接参数的一种方法。文件必须位于您的 CLASSPATH 变量命名的某个目录中,或者您必须使用完整路径名指定它(这里显示的示例假定文件位于 CLASSPATH 目录中):
import java.sql.*;
import java.util.*; // need this for properties file support
public class ReadPropsFile {
public static void main(String[] args) {
Connection conn = null;
String url = null;
String propsFile = "Cookbook.properties";
Properties props = new Properties();
try {
props.load(ReadPropsFile.class.getResourceAsStream(propsFile));
} catch (Exception e) {
System.err.println("Cannot read properties file");
System.exit (1);
}
try {
// construct connection URL, encoding username
// and password as parameters at the end
url = "jdbc:mysql://"
+ props.getProperty("host")
+ "/cookbook"
+ "?user=" + props.getProperty("user")
+ "&password=" + props.getProperty("password");
conn = DriverManager.getConnection(url);
System.out.println("Connected");
} catch (Exception e) {
System.err.println("Cannot connect to server");
} finally {
try {
if (conn != null) {
conn.close();
System.out.println("Disconnected");
}
} catch (SQLException e) { /* ignore close errors */ }
}
}
}
要在未找到命名属性时使 getProperty() 返回特定的默认值,请将该值作为第二个参数传递。例如,要将 127.0.0.1 作为默认的 host 值,可以像这样调用 getProperty():
String hostName = props.getProperty("host", "127.0.0.1");
本章其他地方开发的 Cookbook.java 库文件(参见 Recipe 4.3)在 lib 目录中的版本中包含一个额外的库调用:一个基于此处讨论的概念的 propsConnect() 程序。要使用它,请设置 Cookbook.properties 属性文件的内容,并将文件复制到安装 Cookbook.class 的相同位置。然后,通过导入 Cookbook 类并调用 Cookbook.propsConnect() 来在程序中建立连接,而不是调用 Cookbook.connect()。
4.9 重置 profile 表
问题
在本章的示例中,您改变了profile表的原始内容,现在希望将其恢复,以便在处理其他配方时使用。
解决方案
使用mysql客户端重新加载表格。
讨论
最好将本章中使用的profile表重置为已知状态。切换到recipes分发的tables目录,并运行以下命令:
$ `mysql cookbook < profile.sql`
$ `mysql cookbook < profile2.sql`
几个后续章节中的语句使用了profile表;通过重新初始化它,您可以在运行那些章节中显示的语句时获得相同的结果。
本章讨论了我们的每个 API 提供的基本操作,用于处理与 MySQL 服务器的交互的各个方面。这些操作使您能够编写执行任何类型语句并检索结果的程序。到目前为止,我们使用了简单的语句,因为重点是在 API 上而不是 SQL 上。下一章则专注于 SQL,展示如何向数据库服务器提出更复杂的问题。
第五章:从表中选择数据
5.0 引言
本章重点介绍使用SELECT语句从数据库中检索信息。如果你的 SQL 背景有限或想了解 MySQL 特定的SELECT语法扩展,本章对你会很有帮助。
有许多方法可以编写SELECT语句;我们只会讨论其中几种。有关SELECT语法以及提取和操作数据的函数和运算符的更多信息,请参阅MySQL 参考手册或一般的 MySQL 文本。
本章中的许多示例使用了一个名为mail的表,该表包含跟踪用户之间邮件消息流量的行。以下显示了如何创建该表:
CREATE TABLE mail
(
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
t DATETIME, # when message was sent
srcuser VARCHAR(8), # sender (source user and host)
srchost VARCHAR(20),
dstuser VARCHAR(8), # recipient (destination user and host)
dsthost VARCHAR(20),
size BIGINT, # message size in bytes
INDEX (t)
);
mail表的内容如下:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size FROM mail;`
+---------------------+---------+---------+---------+---------+---------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+---------+
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
| 2014-05-12 15:02:49 | phil | mars | phil | saturn | 1048 |
| 2014-05-12 18:59:18 | barb | saturn | tricia | venus | 271 |
| 2014-05-14 09:31:37 | gene | venus | barb | mars | 2291 |
| 2014-05-14 11:52:17 | phil | mars | tricia | saturn | 5781 |
| 2014-05-14 14:42:21 | barb | venus | barb | venus | 98151 |
| 2014-05-14 17:03:01 | tricia | saturn | phil | venus | 2394482 |
| 2014-05-15 07:17:48 | gene | mars | gene | saturn | 3824 |
| 2014-05-15 08:50:57 | phil | venus | phil | venus | 978 |
| 2014-05-15 10:25:52 | gene | mars | tricia | saturn | 998532 |
| 2014-05-15 17:35:31 | gene | saturn | gene | mars | 3856 |
| 2014-05-16 09:00:28 | gene | venus | barb | mars | 613 |
| 2014-05-16 23:04:19 | phil | venus | barb | venus | 10294 |
| 2014-05-19 12:49:23 | phil | mars | tricia | saturn | 873 |
| 2014-05-19 22:21:51 | gene | saturn | gene | venus | 23992 |
+---------------------+---------+---------+---------+---------+---------+
要创建并加载mail表,请进入recipes分发的tables目录,并运行以下命令:
$ `mysql cookbook < mail.sql`
本章有时还会使用其他表。有些表在之前的章节中已经用过,而其他表则是新的。要创建任何表,请像创建mail表那样,在tables目录中使用适当的脚本。此外,本章中使用的许多其他脚本和程序位于select目录中。该目录中的文件使您可以更轻松地尝试示例。
本章中的许多示例可以在mysql程序内部执行,该程序在第一章中有所讨论。少数示例涉及从编程语言的上下文中发布语句。有关编程技术的信息,请参阅第四章。
5.1 指定要选择的列和行
问题
你想要从表中显示特定的列和行。
解决方案
要指示显示哪些列,请在输出列列表中命名它们。要指示显示哪些行,请使用一个WHERE子句,指定行必须满足的条件。
讨论
从表中显示列的最简单方法是使用SELECT * FROM tbl_name。*说明符是一个快捷方式,意思是所有列
:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size FROM mail;`
+---------------------+---------+---------+---------+---------+---------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+---------+
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
| 2014-05-12 15:02:49 | phil | mars | phil | saturn | 1048 |
| 2014-05-12 18:59:18 | barb | saturn | tricia | venus | 271 |
…
使用*很容易,但你不能选择特定的列或控制列的显示顺序。明确命名列使你能够按任意顺序选择感兴趣的列。这个查询省略了收件人列,并在日期和大小之前显示发件人:
mysql> `SELECT srcuser, srchost, t, size FROM mail;`
+---------+---------+---------------------+---------+
| srcuser | srchost | t | size |
+---------+---------+---------------------+---------+
| barb | saturn | 2014-05-11 10:15:08 | 58274 |
| tricia | mars | 2014-05-12 12:48:13 | 194925 |
| phil | mars | 2014-05-12 15:02:49 | 1048 |
| barb | saturn | 2014-05-12 18:59:18 | 271 |
…
如果不限定或限制SELECT查询的方式,它将检索表中的每一行。为了更精确,请提供一个WHERE子句,指定行必须满足的一个或多个条件。
条件可以测试相等性、不等式或相对顺序。对于某些类型的数据,如字符串,可以使用模式匹配。以下语句从mail表中选择包含srchost值完全等于字符串'venus'或以字母s开头的行的列:
mysql> `SELECT t, srcuser, srchost FROM mail WHERE srchost = 'venus';`
+---------------------+---------+---------+
| t | srcuser | srchost |
+---------------------+---------+---------+
| 2014-05-14 09:31:37 | gene | venus |
| 2014-05-14 14:42:21 | barb | venus |
| 2014-05-15 08:50:57 | phil | venus |
| 2014-05-16 09:00:28 | gene | venus |
| 2014-05-16 23:04:19 | phil | venus |
+---------------------+---------+---------+
mysql> `SELECT t, srcuser, srchost FROM mail WHERE srchost LIKE 's%';`
+---------------------+---------+---------+
| t | srcuser | srchost |
+---------------------+---------+---------+
| 2014-05-11 10:15:08 | barb | saturn |
| 2014-05-12 18:59:18 | barb | saturn |
| 2014-05-14 17:03:01 | tricia | saturn |
| 2014-05-15 17:35:31 | gene | saturn |
| 2014-05-19 22:21:51 | gene | saturn |
+---------------------+---------+---------+
前一个查询中的LIKE运算符执行模式匹配,其中%充当通配符,匹配任何字符串。配方 7.10 进一步讨论了模式匹配。
WHERE子句可以测试多个条件,不同的条件可以测试不同的列。下面的语句查找由barb发送给tricia的消息:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size FROM mail`
-> `WHERE srcuser = 'barb' AND dstuser = 'tricia';`
+---------------------+---------+---------+---------+---------+-------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+-------+
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-12 18:59:18 | barb | saturn | tricia | venus | 271 |
+---------------------+---------+---------+---------+---------+-------+
输出列可以通过评估表达式来计算。此查询使用CONCAT()将srcuser和srchost列组合成电子邮件地址格式的复合值:
mysql> `SELECT t, CONCAT(srcuser,'@',srchost), size FROM mail;`
+---------------------+-----------------------------+---------+
| t | CONCAT(srcuser,'@',srchost) | size |
+---------------------+-----------------------------+---------+
| 2014-05-11 10:15:08 | barb@saturn | 58274 |
| 2014-05-12 12:48:13 | tricia@mars | 194925 |
| 2014-05-12 15:02:49 | phil@mars | 1048 |
| 2014-05-12 18:59:18 | barb@saturn | 271 |
…
您会注意到电子邮件地址列标签是计算它的表达式。为了提供更好的标签,请使用列别名(参见配方 5.2)。
自 MySQL 8.0.19 起,您可以使用TABLE语句从表中选择所有列。TABLE支持ORDER BY(参见配方 5.3)和LIMIT(参见配方 5.11)子句,但不允许对列或行进行任何其他过滤。
mysql> `TABLE` `mail` `ORDER` `BY` `size` `DESC` `LIMIT` `3``;`
+----+---------------------+---------+---------+---------+---------+---------+ | id | t | srcuser | srchost | dstuser | dsthost | size |
+----+---------------------+---------+---------+---------+---------+---------+ | 8 | 2014-05-14 17:03:01 | tricia | saturn | phil | venus | 2394482 |
| 11 | 2014-05-15 10:25:52 | gene | mars | tricia | saturn | 998532 |
| 2 | 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
+----+---------------------+---------+---------+---------+---------+---------+ 3 rows in set (0.00 sec)
5.2 命名查询结果列
问题
查询结果中的列名不合适、难看或难以处理,因此您希望自己命名它们。
解决方案
使用别名来选择您自己的列名。
讨论
当您检索结果集时,MySQL 为每个输出列指定一个名称。(这就是mysql程序获取在结果集输出的列标题初始行中看到的名称的方式。)默认情况下,MySQL 将CREATE TABLE或ALTER TABLE语句中指定的列名分配给输出列,但如果这些默认名称不合适,则可以使用列别名指定自己的名称。
本配方解释了别名,并展示了如何在语句中使用它们来分配列名。如果您正在编写一个必须确定名称的程序,请参阅配方 12.2 以获取有关访问列元数据的信息。
如果输出列直接来自表格,MySQL 会使用表列名作为输出列名。以下语句选择四个表列,其名称成为相应的输出列名:
mysql> `SELECT t, srcuser, srchost, size FROM mail;`
+---------------------+---------+---------+---------+
| t | srcuser | srchost | size |
+---------------------+---------+---------+---------+
| 2014-05-11 10:15:08 | barb | saturn | 58274 |
| 2014-05-12 12:48:13 | tricia | mars | 194925 |
| 2014-05-12 15:02:49 | phil | mars | 1048 |
| 2014-05-12 18:59:18 | barb | saturn | 271 |
…
如果通过评估表达式生成列,则表达式本身就是列名。这可能会在结果集中产生长而难以控制的名称,如下面的语句所示,该语句使用一个表达式重新格式化t列中的日期,并使用另一个表达式将srcuser和srchost组合成电子邮件地址格式:
mysql> `SELECT`
-> `DATE_FORMAT(t,'%M %e, %Y'), CONCAT(srcuser,'@',srchost), size`
-> `FROM mail;`
+----------------------------+-----------------------------+---------+
| DATE_FORMAT(t,'%M %e, %Y') | CONCAT(srcuser,'@',srchost) | size |
+----------------------------+-----------------------------+---------+
| May 11, 2014 | barb@saturn | 58274 |
| May 12, 2014 | tricia@mars | 194925 |
| May 12, 2014 | phil@mars | 1048 |
| May 12, 2014 | barb@saturn | 271 |
…
要选择自己的输出列名,使用AS name 子句指定列别名(关键字AS是可选的)。下面的语句检索与前一个语句相同的结果,但将第一列重命名为date_sent,第二列重命名为sender:
mysql> `SELECT`
-> `DATE_FORMAT(t,'%M %e, %Y') AS date_sent,`
-> `CONCAT(srcuser,'@',srchost) AS sender,`
-> `size FROM mail;`
+--------------+---------------+---------+
| date_sent | sender | size |
+--------------+---------------+---------+
| May 11, 2014 | barb@saturn | 58274 |
| May 12, 2014 | tricia@mars | 194925 |
| May 12, 2014 | phil@mars | 1048 |
| May 12, 2014 | barb@saturn | 271 |
…
别名使列名称更简洁,更易于阅读,更有意义。别名受到一些限制。例如,如果它们是 SQL 关键字、完全是数字或包含空格或其他特殊字符(如果您想使用描述性短语,别名可以由多个单词组成)。下面的语句检索与前一个语句相同的数据值,但使用短语命名输出列:
mysql> `SELECT`
-> `DATE_FORMAT(t,'%M %e, %Y') AS 'Date of message',`
-> `CONCAT(srcuser,'@',srchost) AS 'Message sender',`
-> `size AS 'Number of bytes' FROM mail;`
+-----------------+----------------+-----------------+
| Date of message | Message sender | Number of bytes |
+-----------------+----------------+-----------------+
| May 11, 2014 | barb@saturn | 58274 |
| May 12, 2014 | tricia@mars | 194925 |
| May 12, 2014 | phil@mars | 1048 |
| May 12, 2014 | barb@saturn | 271 |
…
如果 MySQL 对单词别名抱怨,则该单词可能是保留字。引用别名应该使其合法:
mysql> `SELECT 1 AS INTEGER;`
You have an error in your SQL syntax near 'INTEGER'
mysql> `SELECT 1 AS 'INTEGER';`
+---------+
| INTEGER |
+---------+
| 1 |
+---------+
列别名对编程目的也非常有用。如果您编写一个程序,将行获取到数组中并通过数值列索引访问它们,则列别名的存在与否不会有任何影响,因为别名不会改变结果集中列的位置。然而,如果您通过名称访问输出列,则别名会产生很大的影响,因为别名会改变这些名称。利用这一点,可以为程序提供更容易处理的名称。例如,如果您的查询使用表mail中的表达式DATE_FORMAT(t,'%M %e, %Y')显示重新格式化的消息时间值,那么该表达式也是在引用输出列时必须使用的名称。例如,在 Perl 的哈希引用中,您可以将其访问为$ref->{"DATE_FORMAT(t,'%M %e, %Y')}"。这很不方便。使用AS date_sent为列添加别名,您可以更轻松地引用它,例如$ref->{date_sent}。下面是一个示例,显示 Perl DBI 脚本如何处理这样的值。它将行检索到哈希中,并通过名称引用列值:
$sth = $dbh->prepare ("SELECT srcuser,
DATE_FORMAT(t,'%M %e, %Y') AS date_sent
FROM mail");
$sth->execute ();
while (my $ref = $sth->fetchrow_hashref ())
{
printf "user: %s, date sent: %s\n", $ref->{srcuser}, $ref->{date_sent};
}
在 Java 中,您可以这样做,其中getString()的参数命名要访问的列:
Statement s = conn.createStatement ();
s.executeQuery ("SELECT srcuser,"
+ " DATE_FORMAT(t,'%M %e, %Y') AS date_sent"
+ " FROM mail");
ResultSet rs = s.getResultSet ();
while (rs.next ()) // loop through rows of result set
{
String name = rs.getString ("srcuser");
String dateSent = rs.getString ("date_sent");
System.out.println ("user: " + name + ", date sent: " + dateSent);
}
rs.close ();
s.close ();
Recipe 4.4 显示了我们每种编程语言如何将行获取到允许按名称访问列值的数据结构中的示例。recipes分发的select目录有示例显示如何对mail表执行此操作。
您不能在WHERE子句中引用列别名。因此,以下语句是非法的:
mysql> `SELECT t, srcuser, dstuser, size/1024 AS kilobytes`
-> `FROM mail WHERE kilobytes > 500;`
ERROR 1054 (42S22): Unknown column 'kilobytes' in 'where clause'
错误发生是因为别名命名了一个输出列,而WHERE子句操作输入列来确定要为输出选择哪些行。要使语句合法,将WHERE子句中的别名替换为代表该别名的相同列或表达式即可。
mysql> `SELECT t, srcuser, dstuser, size/1024 AS kilobytes`
-> `FROM mail WHERE size/1024 > 500;`
+---------------------+---------+---------+-----------+
| t | srcuser | dstuser | kilobytes |
+---------------------+---------+---------+-----------+
| 2014-05-14 17:03:01 | tricia | phil | 2338.3613 |
| 2014-05-15 10:25:52 | gene | tricia | 975.1289 |
+---------------------+---------+---------+-----------+
5.3 排序查询结果
问题
您希望控制查询结果的排序方式。
解决方案
使用ORDER BY子句告诉它如何排序结果行。
讨论
在选择行时,MySQL 服务器可以按任意顺序返回它们,除非您通过排序结果来指示它。有许多排序技术可以使用,正如第九章详细探讨的那样。简言之,要对结果集排序,请在ORDER BY子句中指定要用于排序的列。以下语句在ORDER BY子句中命名多个列,按主机和每个主机内的用户排序行:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size`
-> `FROM mail WHERE dstuser = 'tricia'`
-> `ORDER BY srchost, srcuser;`
+---------------------+---------+---------+---------+---------+--------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+--------+
| 2014-05-15 10:25:52 | gene | mars | tricia | saturn | 998532 |
| 2014-05-14 11:52:17 | phil | mars | tricia | saturn | 5781 |
| 2014-05-19 12:49:23 | phil | mars | tricia | saturn | 873 |
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-12 18:59:18 | barb | saturn | tricia | venus | 271 |
+---------------------+---------+---------+---------+---------+--------+
MySQL 默认按升序对行进行排序。要按逆序(降序)排序列,请在ORDER BY子句中列名后添加关键字DESC:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size`
-> `FROM mail WHERE size > 50000 ORDER BY size DESC;`
+---------------------+---------+---------+---------+---------+---------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+---------+
| 2014-05-14 17:03:01 | tricia | saturn | phil | venus | 2394482 |
| 2014-05-15 10:25:52 | gene | mars | tricia | saturn | 998532 |
| 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
| 2014-05-14 14:42:21 | barb | venus | barb | venus | 98151 |
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
+---------------------+---------+---------+---------+---------+---------+
5.4 删除重复行
问题
查询的输出包含重复行。您希望消除它们。
解决方案
使用DISTINCT。
讨论
某些查询会生成包含重复行的结果。例如,要查看谁发送了邮件,请像这样查询mail表:
mysql> `SELECT srcuser FROM mail;`
+---------+
| srcuser |
+---------+
| barb |
| tricia |
| phil |
| barb |
| gene |
| phil |
| barb |
| tricia |
| gene |
| phil |
| gene |
| gene |
| gene |
| phil |
| phil |
| gene |
+---------+
那个结果非常冗余。要删除重复行并生成一组唯一值,请在查询中添加DISTINCT:
mysql> `SELECT DISTINCT srcuser FROM mail;`
+---------+
| srcuser |
+---------+
| barb |
| tricia |
| phil |
| gene |
+---------+
要计算列中唯一值的数量,请使用COUNT(DISTINCT):
mysql> `SELECT COUNT(DISTINCT srcuser) FROM mail;`
+-------------------------+
| COUNT(DISTINCT srcuser) |
+-------------------------+
| 4 |
+-------------------------+
DISTINCT也适用于多列输出。以下查询显示了mail表中表示的日期:
mysql> `SELECT DISTINCT YEAR(t), MONTH(t), DAYOFMONTH(t) FROM mail;`
+---------+----------+---------------+
| YEAR(t) | MONTH(t) | DAYOFMONTH(t) |
+---------+----------+---------------+
| 2014 | 5 | 11 |
| 2014 | 5 | 12 |
| 2014 | 5 | 14 |
| 2014 | 5 | 15 |
| 2014 | 5 | 16 |
| 2014 | 5 | 19 |
+---------+----------+---------------+
参见
第十章重新讨论了DISTINCT和COUNT(DISTINCT)。第十八章更详细地讨论了重复项的移除。
5.5 处理 NULL 值
问题
您试图比较列值和NULL,但却无法正常工作。
解决方案
使用适当的比较操作符:IS NULL、IS NOT NULL或<=>。
讨论
涉及NULL的条件很特殊,因为NULL意味着未知值。
因此,像value = NULL或value <> NULL这样的比较总是产生NULL(而不是真或假),因为无法确定它们是真还是假。即使NULL = NULL也产生NULL,因为您无法确定一个未知值是否与另一个相同。
要查找NULL或非NULL的值,请使用IS NULL或IS NOT NULL运算符。假设名为expt的表包含待给每个主题四次测试的实验结果,并且表示尚未进行的测试使用NULL:
+---------+------+-------+
| subject | test | score |
+---------+------+-------+
| Jane | A | 47 |
| Jane | B | 50 |
| Jane | C | NULL |
| Jane | D | NULL |
| Marvin | A | 52 |
| Marvin | B | 45 |
| Marvin | C | 53 |
| Marvin | D | NULL |
+---------+------+-------+
您可以看到=和<>无法识别NULL值:
mysql> `SELECT * FROM expt WHERE score = NULL;`
Empty set (0.00 sec)
mysql> `SELECT * FROM expt WHERE score <> NULL;`
Empty set (0.00 sec)
将语句写成这样:
mysql> `SELECT * FROM expt WHERE score IS NULL;`
+---------+------+-------+
| subject | test | score |
+---------+------+-------+
| Jane | C | NULL |
| Jane | D | NULL |
| Marvin | D | NULL |
+---------+------+-------+
mysql> `SELECT * FROM expt WHERE score IS NOT NULL;`
+---------+------+-------+
| subject | test | score |
+---------+------+-------+
| Jane | A | 47 |
| Jane | B | 50 |
| Marvin | A | 52 |
| Marvin | B | 45 |
| Marvin | C | 53 |
+---------+------+-------+
mysql> `SELECT * FROM expt WHERE score <=> NULL;`
+---------+------+-------+
| subject | test | score |
+---------+------+-------+
| Jane | C | NULL |
| Jane | D | NULL |
| Marvin | D | NULL |
+---------+------+-------+
MySQL 特定的<=>空安全比较运算符,与=``运算符不同,即使是两个NULL`值也为真:
mysql> `SELECT NULL = NULL, NULL <=> NULL;`
+-------------+---------------+
| NULL = NULL | NULL <=> NULL |
+-------------+---------------+
| NULL | 1 |
+-------------+---------------+
有时候将NULL值映射到应用程序上下文中具有更多含义的其他值是很有用的。例如,使用IF()将NULL映射到字符串Unknown:
mysql> `SELECT subject, test, IF(score IS NULL,'Unknown', score) AS 'score'`
-> `FROM expt;`
+---------+------+---------+
| subject | test | score |
+---------+------+---------+
| Jane | A | 47 |
| Jane | B | 50 |
| Jane | C | Unknown |
| Jane | D | Unknown |
| Marvin | A | 52 |
| Marvin | B | 45 |
| Marvin | C | 53 |
| Marvin | D | Unknown |
+---------+------+---------+
这种基于IF()的映射技术适用于任何类型的值,但对于NULL值尤其有用,因为NULL往往被赋予多种含义:未知、缺失、尚未确定、超出范围等等。选择在特定上下文中最合适的标签。
前面的查询可以更简洁地使用 IFNULL() 编写,它测试其第一个参数,如果不为 NULL 则返回该参数,否则返回其第二个参数:
SELECT subject, test, IFNULL(score,'Unknown') AS 'score'
FROM expt;
换句话说,这两个测试是等效的:
IF(*`expr1`* IS NOT NULL,*`expr1`*,*`expr2`*)
IFNULL(*`expr1`*,*`expr2`*)
从可读性角度来看,IF() 往往比 IFNULL() 更容易理解。从计算的角度来看,IFNULL() 更有效率,因为 expr1 不需要像 IF() 那样评估两次。
映射 NULL 值的另一种方法是使用函数 COALESCE,它从参数列表中返回第一个非空元素。
SELECT subject, test, COALESCE(score,'Unknown') AS 'score' FROM expt;
另请参阅
当用于排序和汇总操作时,NULL 值的行为也不同。参见 Recipe 9.11 和 Recipe 10.9。
5.6 编写涉及程序中的 NULL 的比较
问题
你正在编写一个程序,查找包含特定值的行,但当该值为 NULL 时失败。
解决方案
根据比较值是否为 NULL,选择适当的比较运算符。
讨论
Recipe 5.5 讨论了在 SQL 语句中使用与非 NULL 值不同比较运算符的需要。这个问题在构建程序内的语句字符串时可能会导致微妙的危险。如果存储在变量中的值可能表示 NULL 值,则在比较中使用该值时必须考虑到这一点。例如,在 Python 中,None 表示 NULL 值,因此要构建一个语句来查找 expt 表中与某个 score 变量中的任意值匹配的行,不能这样做:
cursor.execute("SELECT * FROM expt WHERE score = %s", (score,))
当 score 是 None 时,语句失败,因为结果语句变成了:
SELECT * FROM expt WHERE score = NULL;
score = NULL 的比较永远不成立,因此该语句不返回行。为了考虑到 score 可能是 None 的情况,构建语句时应使用适当的比较运算符,例如这样:
operator = "IS" if score is None else "="
cursor.execute("SELECT * FROM expt WHERE score {} %s".format(operator), (score,))
这导致对于 score 值为 None (NULL) 或 43(不为 NULL)的语句如下所示:
SELECT * FROM expt WHERE score IS NULL
SELECT * FROM expt WHERE score = 43;
对于不等式测试,应像这样设置 operator:
operator = "IS NOT" if score is None else "<>"
5.7 使用视图简化表访问
问题
您希望引用从表达式计算的值,而不是每次检索时都写入表达式。
解决方案
使用定义了其列执行所需计算的视图。
讨论
假设你从 mail 表中检索了几个值,使用表达式计算大多数值:
mysql> `SELECT`
-> `DATE_FORMAT(t,'%M %e, %Y') AS date_sent,`
-> `CONCAT(srcuser,'@',srchost) AS sender,`
-> `CONCAT(dstuser,'@',dsthost) AS recipient,`
-> `size FROM mail;`
+--------------+---------------+---------------+---------+
| date_sent | sender | recipient | size |
+--------------+---------------+---------------+---------+
| May 11, 2014 | barb@saturn | tricia@mars | 58274 |
| May 12, 2014 | tricia@mars | gene@venus | 194925 |
| May 12, 2014 | phil@mars | phil@saturn | 1048 |
| May 12, 2014 | barb@saturn | tricia@venus | 271 |
…
如果你经常要发出这样的语句,每次都写表达式很不方便。为了更容易访问语句结果,可以使用视图,这是一个不包含数据的虚拟表。相反,它被定义为检索感兴趣数据的 SELECT 语句。以下视图 mail_view 等效于刚刚显示的 SELECT 语句:
mysql> `CREATE VIEW mail_view AS`
-> `SELECT`
-> `DATE_FORMAT(t,'%M %e, %Y') AS date_sent,`
-> `CONCAT(srcuser,'@',srchost) AS sender,`
-> `CONCAT(dstuser,'@',dsthost) AS recipient,`
-> `size FROM mail;`
要访问视图内容,请像访问任何其他表一样引用它。你可以选择它的一些或所有列,添加WHERE子句以限制要检索的行,使用ORDER BY来对行进行排序,等等。例如:
mysql> `SELECT date_sent, sender, size FROM mail_view`
-> `WHERE size > 100000 ORDER BY size;`
+--------------+---------------+---------+
| date_sent | sender | size |
+--------------+---------------+---------+
| May 12, 2014 | tricia@mars | 194925 |
| May 15, 2014 | gene@mars | 998532 |
| May 14, 2014 | tricia@saturn | 2394482 |
+--------------+---------------+---------+
存储过程提供了另一种封装计算的方式(参见食谱 11.2)。
5.8 从多个表中选择数据
问题
答案需要从多个表中获取数据,因此需要从多个表中选择数据。
解决方案
使用连接或子查询。
讨论
到目前为止,所展示的查询从单个表中选择数据,但有时必须从多个表中检索信息。实现此目的的两种类型的语句是连接和子查询。连接将一个表中的行与另一个表中的行匹配,并使你能够检索包含来自任一表或两个表的列的输出行。子查询是嵌套在另一个查询中的查询,用于通过内部查询选择的值与外部查询选择的值进行比较。
本示例显示了几个简短的例子,以说明基本思想。其他示例出现在其他地方:子查询在本书的各处示例中使用(例如,食谱 5.10 和食谱 10.6)。第十六章详细讨论连接,包括从超过两个表中选择的连接。
下面的示例使用了在第四章介绍的profile表。回想一下,它列出了你的好友名单:
mysql> `SELECT * FROM profile;`
+----+---------+------------+-------+-----------------------+------+
| id | name | birth | color | foods | cats |
+----+---------+------------+-------+-----------------------+------+
| 1 | Sybil | 1970-04-13 | black | lutefisk,fadge,pizza | 0 |
| 2 | Nancy | 1969-09-30 | white | burrito,curry,eggroll | 3 |
| 3 | Ralph | 1973-11-02 | red | eggroll,pizza | 4 |
| 4 | Lothair | 1963-07-04 | blue | burrito,curry | 5 |
| 5 | Henry | 1965-02-14 | red | curry,fadge | 1 |
| 6 | Aaron | 1968-09-17 | green | lutefisk,fadge | 1 |
| 7 | Joanna | 1952-08-20 | green | lutefisk,fadge | 0 |
| 8 | Stephen | 1960-05-01 | white | burrito,pizza | 0 |
+----+---------+------------+-------+-----------------------+------+
让我们扩展使用profile表,包括另一个名为profile_contact的表。此第二个表示如何通过各种社交媒体服务联系profile表中列出的人员,并定义如下:
CREATE TABLE profile_contact
(
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
profile_id INT UNSIGNED NOT NULL, # ID from profile table
service VARCHAR(20) NOT NULL, # social media service name
contact_name VARCHAR(25) NOT NULL, # name to use for contacting person
INDEX (profile_id)
);
该表通过profile_id列将每行与适当的profile行关联起来。service和contact_name列命名了媒体服务和用于通过该服务联系给定人员的名称。对于示例,请假设该表包含以下行:
mysql> `SELECT profile_id, service, contact_name`
-> `FROM profile_contact ORDER BY profile_id, service;`
+------------+----------+--------------+
| profile_id | service | contact_name |
+------------+----------+--------------+
| 1 | Facebook | user1-fbid |
| 1 | Twitter | user1-twtrid |
| 2 | Facebook | user2-msnid |
| 2 | LinkedIn | user2-lnkdid |
| 2 | Twitter | user2-fbrid |
| 4 | LinkedIn | user4-lnkdid |
+------------+----------+--------------+
一个需要来自两个表信息的问题是:对于
为了回答这个问题,使用连接。从两个表中选择并通过比较profile表中的每个人员,向我展示我可以使用哪些服务进行联系,以及每个服务的联系人姓名。profile表的id列与profile_contact表的profile_id列来匹配行:
mysql> `SELECT profile.id, name, service, contact_name`
-> `FROM profile INNER JOIN profile_contact ON profile.id = profile_id;`
+----+---------+----------+--------------+
| id | name | service | contact_name |
+----+---------+----------+--------------+
| 1 | Sybil | Twitter | user1-twtrid |
| 1 | Sybil | Facebook | user1-fbid |
| 2 | Nancy | Twitter | user2-fbrid |
| 2 | Nancy | Facebook | user2-msnid |
| 2 | Nancy | LinkedIn | user2-lnkdid |
| 4 | Lothair | LinkedIn | user4-lnkdid |
+----+---------+----------+--------------+
FROM子句指示要从中选择数据的表,ON子句告诉 MySQL 使用哪些列在表之间查找匹配项。在结果中,行包括profile表的id和name列,以及profile_contact表的service和contact_name列。
这是另一个需要两个表才能回答的问题:列出所有 Nancy 的
要从 profile_contact 记录。profile_contact 表中提取正确的行,您需要 Nancy 的 ID,该 ID 存储在 profile 表中。要编写查询而不自行查找 Nancy 的 ID,请使用一个子查询,该子查询根据她的姓名为您查找:
mysql> `SELECT profile_id, service, contact_name FROM profile_contact`
-> `WHERE profile_id = (SELECT id FROM profile WHERE name = 'Nancy');`
+------------+----------+--------------+
| profile_id | service | contact_name |
+------------+----------+--------------+
| 2 | Twitter | user2-fbrid |
| 2 | Facebook | user2-msnid |
| 2 | LinkedIn | user2-lnkdid |
+------------+----------+--------------+
此处子查询显示为括在括号中的嵌套 SELECT 语句。
5.9 从查询结果的开始、结尾或中间选择行
问题
您只希望从结果集中获取特定的行,如第一行、最后五行或第 21 至 40 行。
解决方案
使用 LIMIT 子句,也许与 ORDER BY 子句结合使用。
讨论
MySQL 支持 LIMIT 子句,告诉服务器仅返回结果集的一部分。LIMIT 是 SQL 的 MySQL 特定扩展,在结果集包含比您想要一次看到的行数更多时非常有价值。它允许您检索结果集的任意部分。典型的 LIMIT 使用包括以下类型的问题:
-
回答有关第一个或最后一个、最大或最小、最新或最旧、最便宜或最昂贵等问题。
-
将结果集分成几个部分,以便可以逐部分处理它。此技术在 Web 应用程序中很常见,用于在多页上显示大型搜索结果。以部分显示结果可以显示更小、更易理解的页面。
以下示例使用 Recipe 5.8 中显示的 profile 表。要查看 SELECT 结果的前 n 行,将 LIMIT n 添加到语句的末尾:
mysql> `SELECT * FROM profile LIMIT 1;`
+----+-------+------------+-------+----------------------+------+
| id | name | birth | color | foods | cats |
+----+-------+------------+-------+----------------------+------+
| 1 | Sybil | 1970-04-13 | black | lutefisk,fadge,pizza | 0 |
+----+-------+------------+-------+----------------------+------+
mysql> `SELECT * FROM profile LIMIT 3;`
+----+-------+------------+-------+-----------------------+------+
| id | name | birth | color | foods | cats |
+----+-------+------------+-------+-----------------------+------+
| 1 | Sybil | 1970-04-13 | black | lutefisk,fadge,pizza | 0 |
| 2 | Nancy | 1969-09-30 | white | burrito,curry,eggroll | 3 |
| 3 | Ralph | 1973-11-02 | red | eggroll,pizza | 4 |
+----+-------+------------+-------+-----------------------+------+
LIMIT n 意味着 返回 最多
如果指定 n 行。LIMIT 10,而结果集只有四行,则服务器返回四行。
前述查询结果中的行未按任何特定顺序返回,因此可能无太大意义。更常见的技术是使用 ORDER BY 对结果集排序并使用 LIMIT 查找最小值和最大值。例如,要找到具有最小(最早)出生日期的行,请按 birth 列排序,然后添加 LIMIT 1 以检索第一行:
mysql> `SELECT * FROM profile ORDER BY birth LIMIT 1;`
+----+--------+------------+-------+----------------+------+
| id | name | birth | color | foods | cats |
+----+--------+------------+-------+----------------+------+
| 7 | Joanna | 1952-08-20 | green | lutefisk,fadge | 0 |
+----+--------+------------+-------+----------------+------+
这有效是因为 MySQL 处理 ORDER BY 子句来对行进行排序,然后应用 LIMIT。
要获取结果集末尾的行,请按相反顺序排序它们。查找最近出生日期的语句类似于前一个语句,但排序顺序是降序:
mysql> `SELECT * FROM profile ORDER BY birth DESC LIMIT 1;`
+----+-------+------------+-------+---------------+------+
| id | name | birth | color | foods | cats |
+----+-------+------------+-------+---------------+------+
| 3 | Ralph | 1973-11-02 | red | eggroll,pizza | 4 |
+----+-------+------------+-------+---------------+------+
要查找日历年内最早或最晚的生日,请按 birth 值的月和日排序:
mysql> `SELECT name, DATE_FORMAT(birth,'%m-%d') AS birthday`
-> `FROM profile ORDER BY birthday LIMIT 1;`
+-------+----------+
| name | birthday |
+-------+----------+
| Henry | 02-14 |
+-------+----------+
您可以通过在没有 LIMIT 的情况下运行这些语句并忽略除第一行以外的所有内容来获得相同的信息。LIMIT 的优点是服务器仅返回第一行,而额外的行根本不跨网络。这比检索整个结果集并丢弃除一个行以外的所有行要高效得多。
要从结果集的中间提取行,请使用 LIMIT 的两参数形式,这使您可以选择任意部分的行。参数指示要跳过多少行以及要返回多少行。这意味着您可以使用 LIMIT 来执行诸如跳过两行并返回下一行的操作,从而回答诸如“第三个最小值或第三个最大值是什么?”这些问题 MIN() 或 MAX() 不适合,但 LIMIT 却很容易实现:
mysql> `SELECT * FROM profile ORDER BY birth LIMIT 2,1;`
+----+---------+------------+-------+---------------+------+
| id | name | birth | color | foods | cats |
+----+---------+------------+-------+---------------+------+
| 4 | Lothair | 1963-07-04 | blue | burrito,curry | 5 |
+----+---------+------------+-------+---------------+------+
mysql> `SELECT * FROM profile ORDER BY birth DESC LIMIT 2,1;`
+----+-------+------------+-------+-----------------------+------+
| id | name | birth | color | foods | cats |
+----+-------+------------+-------+-----------------------+------+
| 2 | Nancy | 1969-09-30 | white | burrito,curry,eggroll | 3 |
+----+-------+------------+-------+-----------------------+------+
LIMIT 的两参数形式还可以将结果集分成较小的部分。例如,要从结果中每次检索 20 行,重复发出 SELECT 语句,但变化其 LIMIT 子句如下:
SELECT ... FROM ... ORDER BY ... LIMIT 0, 20;
SELECT ... FROM ... ORDER BY ... LIMIT 20, 20;
SELECT ... FROM ... ORDER BY ... LIMIT 40, 20;
…
警告
对于大数据集,使用 LIMIT 子句的这种方式可能会导致性能下降,因为它需要读取至少 OFFSET 加 LIMIT 行。这意味着要获得 LIMIT 0, 20 语句的结果,MySQL 必须从表中读取 20 行,要获得 LIMIT 20, 20 的结果,它需要读取 40 行,依此类推。
要确定结果集中的行数,以便确定部分的数量,请首先发出 COUNT() 语句。例如,要按名称顺序显示 profile 表行,每次三个,您可以使用以下语句找出行数:
mysql> `SELECT COUNT(*) FROM profile;`
+----------+
| COUNT(*) |
+----------+
| 8 |
+----------+
这告诉你有三组行(最后一组少于三行),你可以按以下方式检索:
SELECT * FROM profile ORDER BY name LIMIT 0, 3;
SELECT * FROM profile ORDER BY name LIMIT 3, 3;
SELECT * FROM profile ORDER BY name LIMIT 6, 3;
参见
结合 RAND() 使用 LIMIT 可以从一组项目中进行随机选择。请参阅 Recipe 17.8。
您可以使用 LIMIT 限制 DELETE 或 UPDATE 语句的影响范围,以防止删除或更新除特定行之外的所有行。有关使用 LIMIT 去重的更多信息,请参阅 Recipe 18.5。
5.10 当 LIMIT 和最终结果要求不同的排序顺序时该怎么办
问题
LIMIT 通常最好与排序行的 ORDER BY 子句结合使用。但有时候这种排序顺序与你希望的最终结果不同。
解决方案
在子查询中使用 LIMIT 检索所需的行,然后在外部查询中对它们进行排序。
讨论
如果要获取结果集的最后四行,可以通过以相反顺序排序并使用 LIMIT 4 轻松获取它们。以下语句返回 profile 表中最近出生的四个人的姓名和出生日期:
mysql> `SELECT name, birth FROM profile ORDER BY birth DESC LIMIT 4;`
+-------+------------+
| name | birth |
+-------+------------+
| Ralph | 1973-11-02 |
| Sybil | 1970-04-13 |
| Nancy | 1969-09-30 |
| Aaron | 1968-09-17 |
+-------+------------+
但这要求以降序排序 birth 值,以将它们放在结果集的前面。如果希望输出行按升序顺序显示怎么办?将 SELECT 用作外部语句的子查询,以按所需的最终顺序重新对行进行排序:
mysql> `SELECT * FROM`
-> `(SELECT name, birth FROM profile ORDER BY birth DESC LIMIT 4) AS t`
-> `ORDER BY birth;`
+-------+------------+
| name | birth |
+-------+------------+
| Aaron | 1968-09-17 |
| Nancy | 1969-09-30 |
| Sybil | 1970-04-13 |
| Ralph | 1973-11-02 |
+-------+------------+
因为 FROM 子句中引用的任何表都必须有一个名称,所以在此处使用 AS t,即使是从子查询生成的 派生
表也是如此。
5.11 从表达式计算 LIMIT 值
问题
您想使用表达式来指定 LIMIT 的参数。
解决方案
LIMIT 的参数必须是文字整数 —— 除非您在允许动态构建语句字符串的上下文中发出该语句。在这种情况下,您可以自行评估表达式,并将结果值插入语句字符串中。
讨论
LIMIT 的参数必须是文字整数,而不是表达式。类似以下语句是不合法的:
SELECT * FROM profile LIMIT 5+5;
SELECT * FROM profile LIMIT @skip_count, @show_count;
如果在构造语句字符串的程序中使用表达式计算 LIMIT 值,则同样适用 不允许表达式
原则。您必须先评估表达式,然后将结果值放入语句中。例如,如果您在 Perl 或 PHP 中生成语句字符串如下所示,在执行语句时会出现错误:
$str = "SELECT * FROM profile LIMIT $x + $y";
为了避免问题,请先评估表达式:
$z = $x + $y;
$str = "SELECT * FROM profile LIMIT $z";
或者这样做(不要省略括号,否则表达式将无法正确评估):
$str = "SELECT * FROM profile LIMIT " . ($x + $y);
要构造一个两个参数的 LIMIT 子句,请先评估这两个表达式,然后将它们放入语句字符串中。
5.12 结合两个或更多 SELECT 结果
问题
您希望将两个或更多 SELECT 语句检索的行合并到一个结果集中。
解决方案
使用 UNION 子句。
讨论
mail 表存储电子邮件发送者和接收者的用户名称和主机。但是,如果我们想知道所有可能的用户和主机组合呢?
一个简单的方法是选择发送者或接收者对。但是,如果我们进行基本测试,比较唯一用户-主机组合的数量,我们会发现每个方向的数量是不同的。
mysql> `SELECT` `COUNT``(``distinct` `srcuser``,` `srchost``)` `FROM` `mail``;`
+----------------------------------+ | count(distinct srcuser, srchost) |
+----------------------------------+ | 9 |
+----------------------------------+ 1 row in set (0.01 sec)
mysql> `select` `count``(``distinct` `dstuser``,` `dsthost``)` `from` `mail``;`
+----------------------------------+ | count(distinct dstuser, dsthost) |
+----------------------------------+ | 10 |
+----------------------------------+ 1 row in set (0.00 sec)
我们也不知道我们的表是否存储只发送而不接收邮件的用户,以及只接收但从不发送邮件的用户。
要获取完整列表,我们需要为发送者和接收者选择成对,然后删除重复项。SQL 子句 UNION DISTINCT 及其简短形式 UNION 正好做到这一点。它将两个或更多选择相同列类型的 SELECT 查询结果组合在一起。
默认情况下,UNION 使用第一个 SELECT 的列名作为完整结果集的标题,但是我们也可以像 Recipe 5.2 中讨论的那样使用别名。
mysql> `SELECT` `DISTINCT` `srcuser` `AS` `user``,` `srchost` `AS` `host` `FROM` `mail`
-> `UNION`
-> `SELECT` `DISTINCT` `dstuser` `AS` `user``,` `dsthost` `AS` `host` `FROM` `mail``;`
+--------+--------+ | user | host |
+--------+--------+ | barb | saturn |
| tricia | mars |
| phil | mars |
| gene | venus |
| barb | venus |
| tricia | saturn |
| gene | mars |
| phil | venus |
| gene | saturn |
| phil | saturn |
| tricia | venus |
| barb | mars |
+--------+--------+ 12 rows in set (0.00 sec)
您可以将每个独立查询排序,参与 UNION,以及整个结果。如果不希望从结果中删除重复项,请使用 UNION ALL 子句。
为了演示这一点,让我们创建一个查询,找出发送最多电子邮件的四个用户和接收最多电子邮件的四个用户,然后按用户名对联合结果进行排序。
mysql> `(``SELECT` `CONCAT``(``srcuser``,` `'@'``,` `srchost``)` `AS` `user``,` `COUNT``(``*``)` `AS` `emails` 
-> `FROM` `mail` `GROUP` `BY` `srcuser``,` `srchost` `ORDER` `BY` `emails` `DESC` `LIMIT` `4``)` 
-> `UNION` `ALL`
-> `(``SELECT` `CONCAT``(``dstuser``,` `'@'``,` `dsthost``)` `AS` `user``,` `COUNT``(``*``)` `AS` `emails`
-> `FROM` `mail` `GROUP` `BY` `dstuser``,` `dsthost` `ORDER` `BY` `emails` `DESC` `LIMIT` `4``)` 
-> `ORDER` `BY` `user``;`
+---------------+--------+ | user | emails |
+---------------+--------+ | barb@mars | 2 |
| barb@saturn | 2 |
| barb@venus | 2 |
| gene@saturn | 2 |
| gene@venus | 2 | 
| gene@venus | 2 |
| phil@mars | 3 |
| tricia@saturn | 3 |
+---------------+--------+ 8 rows in set (0.00 sec)
将用户和主机连接成用户的电子邮件地址。
首先按电子邮件数量降序排序第一个 SELECT 的结果,并限制检索到的行数。
对第二个SELECT结果进行排序。
按用户电子邮件地址对UNION的结果排序。
我们使用了UNION ALL子句而不是UNION [DISTINCT],因此结果中gene@venus有两个条目。这个用户是发送和接收电子邮件最多的名单中的一员。
5.13 选择子查询的结果
问题
你希望检索的不仅是表列,还包括使用这些列的查询的结果。
解决方案
在列列表中使用子查询。
讨论
假设您不仅想知道特定用户发送了多少封电子邮件,还想知道他们收到了多少封。您不能只访问mail表两次来实现这一点:一次用于计算发送的电子邮件数,第二次用于计算接收的电子邮件数。
解决此问题的一个解决方案是在列列表中使用子查询。
mysql> `SELECT` `CONCAT``(``srcuser``,` `'@'``,` `srchost``)` `AS` `user``,` `COUNT``(``*``)` `AS` `mails_sent``,` 
-> `(``SELECT` `COUNT``(``*``)` `FROM` `mail` `d` `WHERE` `d``.``dstuser``=``m``.``srcuser` `AND` `d``.``dsthost``=``m``.``srchost``)` 
-> `AS` `mails_received` 
-> `FROM` `mail` `m`
-> `GROUP` `BY` `srcuser``,` `srchost` 
-> `ORDER` `BY` `mails_sent` `DESC``;`
+---------------+------------+----------------+ | user | mails_sent | mails_received |
+---------------+------------+----------------+ | phil@mars | 3 | 0 |
| barb@saturn | 2 | 0 |
| gene@venus | 2 | 2 |
| gene@mars | 2 | 1 |
| phil@venus | 2 | 2 |
| gene@saturn | 2 | 1 |
| tricia@mars | 1 | 1 |
| barb@venus | 1 | 2 |
| tricia@saturn | 1 | 3 |
+---------------+------------+----------------+ 9 rows in set (0.00 sec)
首先,我们检索了发送者的用户名和主机,并计算了他们发送的电子邮件数。
为了找出此用户收到的电子邮件数量,我们在相同的mail表中使用子查询。在WHERE子句中,我们只选择接收者与主查询中的发送者具有相同凭据的行。
列列表中的子查询必须有自己的别名。
为了按用户显示统计信息,我们使用了GROUP BY子句,因此结果按每个用户名和主机分组。我们在第十章中详细讨论了GROUP BY子句。
第六章:表管理
6.0 引言
本章涵盖与创建和填充表相关的主题,包括:
-
克隆表
-
从一张表复制到另一张表
-
使用临时表
-
生成唯一表名
-
确定表使用的存储引擎或将其从一种存储引擎转换为另一种
本章中的许多示例使用名为mail的表,其中包含跟踪一组主机上用户之间邮件消息流量的行(参见 Recipe 5.0)。要创建并加载此表,请进入recipes分发的tables目录,并运行此命令:
$ `mysql cookbook < mail.sql`
6.1 克隆表
问题
您想创建一个与现有表完全相同结构的表。
解决方案
使用CREATE TABLE … LIKE来克隆表结构。如果还想将原始表的一些或全部行复制到新表中,请使用INSERT INTO … SELECT。
讨论
要创建与现有表完全相同的新表,请使用此语句:
CREATE TABLE *`new_table`* LIKE *`original_table`*;
新表的结构与原始表相同,但有几个例外:CREATE TABLE … LIKE不复制外键定义,也不复制表可能使用的任何DATA DIRECTORY或INDEX DIRECTORY表选项。
新表为空。如果还希望内容与原始表相同,请使用INSERT INTO … SELECT语句复制行:
INSERT INTO *`new_table`* SELECT * FROM *`original_table`*;
要复制表的部分内容,需要添加适当的WHERE子句,以确定要复制的行。例如,以下语句将创建一个名为mail2的mail表的副本,其中只包含由barb发送的邮件:
CREATE TABLE mail2 LIKE mail;
INSERT INTO mail2 SELECT * FROM mail WHERE srcuser = 'barb';
警告
从大表中选择所有内容可能很慢,不建议在生产服务器上执行此操作。我们讨论如何复制大表在 Recipe 6.7 和 Recipe 6.8 中。
参见
有关INSERT … SELECT的更多信息,请参阅 Recipe 6.2。
6.2 将查询结果保存到表中
问题
您希望将SELECT语句的结果保存到表中,而不是显示它。
解决方案
如果表存在,则使用INSERT INTO … SELECT将行检索到其中。如果表不存在,则使用CREATE TABLE … SELECT动态创建它。
讨论
MySQL 服务器通常将SELECT语句的结果返回给执行该语句的客户端。例如,当您在mysql程序内执行语句时,服务器将结果返回给mysql,后者将其显示在屏幕上。可以将SELECT语句的结果保存到表中,这在多种情况下都很有用:
-
您可以轻松地创建表的完整或部分副本。如果您正在为修改表的应用程序开发算法,最好使用表的副本,以免担心错误的后果。如果原始表很大,创建部分副本可以加快开发过程,因为对其运行的查询时间较短。
-
基于可能存在格式不正确的信息进行数据加载操作时,将新行加载到测试临时表中,执行一些初步检查,并根据需要更正行。当您确信新行没有问题时,将它们从临时表复制到主表中。
-
一些应用程序维护一个大的存储库表和一个较小的工作表,定期向其中插入行,周期性地将工作表行复制到存储库,并清除工作表。
-
要更高效地对大表执行汇总操作,请避免反复在其上运行昂贵的汇总操作。相反,将汇总信息仅选择一次到第二表中,并在进一步分析时使用该表。
此处演示如何将结果集检索到表中。示例中的表名src_tbl和dst_tbl分别指源表(从中选择行)和目标表(将其存储进去)。
如果目标表已经存在,请使用INSERT … SELECT将结果集复制到其中。例如,如果dst_tbl包含整数列i和字符串列s,则以下语句将行从src_tbl复制到dst_tbl,将列val赋给i,将列name赋给s:
INSERT INTO dst_tbl (i, s) SELECT val, name FROM src_tbl;
要插入的列数必须与选定的列数相匹配,列之间的对应关系基于位置而不是名称。要复制所有列,可以简化语句如下:
INSERT INTO dst_tbl SELECT * FROM src_tbl;
要仅复制特定行,请添加一个WHERE子句来选择这些行:
INSERT INTO dst_tbl SELECT * FROM src_tbl
WHERE val > 100 AND name LIKE 'A%';
SELECT语句也可以从表达式中产生值。例如,以下语句计算src_tbl中每个名称出现的次数,并将计数和名称都存储在dst_tbl中:
INSERT INTO dst_tbl (i, s) SELECT COUNT(*), name
FROM src_tbl GROUP BY name;
如果目标表不存在,请先使用CREATE TABLE语句创建它,然后使用INSERT … SELECT将行复制到其中。或者,直接从SELECT的结果创建目标表,例如,要创建dst_tbl并将src_tbl的整个内容复制到其中,可以这样做:
CREATE TABLE dst_tbl SELECT * FROM src_tbl;
警告
INSERT INTO ... SELECT ...
不会从源表复制索引。如果使用此语法且目标表应具有索引,请在语句完成后添加它们。我们在第 21.1 节中讨论索引。
MySQL 根据src_tbl中列的名称、数量和类型在dst_tbl中创建列。要仅复制特定行,请添加适当的WHERE子句。要创建一个空表,请使用选择不返回任何行的WHERE子句:
CREATE TABLE dst_tbl SELECT * FROM src_tbl WHERE FALSE;
要仅复制部分列,请在语句的SELECT部分中命名您想要的列。例如,如果src_tbl包含列a、b、c和d,则像这样仅复制b和d:
CREATE TABLE dst_tbl SELECT b, d FROM src_tbl;
要按与源表中出现顺序不同的顺序创建列,请按所需顺序命名它们。如果源表包含应以c、a、b顺序出现在目标表中的列,则执行以下操作:
CREATE TABLE dst_tbl SELECT c, a, b FROM src_tbl;
要在目标表中创建除源表中选定列之外的列,请在语句的CREATE TABLE部分提供适当的列定义。以下语句在dst_tbl中创建id作为AUTO_INCREMENT列,并从src_tbl添加列a、b和c:
CREATE TABLE dst_tbl
(
id INT NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
)
SELECT a, b, c FROM src_tbl;
结果表以id、a、b、c的顺序包含四列。已定义的列被分配它们的默认值。这意味着id作为AUTO_INCREMENT列,从 1 开始分配连续的序列号(见 Recipe 15.1)。
如果从表达式派生列的值,则其默认名称为表达式本身,稍后使用起来可能会很困难。在这种情况下,通过提供别名(见 Recipe 5.2)来为列命名更好是明智的选择。假设src_tbl包含列出每个发票中条目的发票信息。以下语句生成一个摘要,列出表中命名的每个发票及其条目的总费用,并使用表达式的别名:
CREATE TABLE dst_tbl
SELECT inv_no, SUM(unit_cost*quantity) AS total_cost
FROM src_tbl GROUP BY inv_no;
CREATE TABLE … SELECT非常方便,但由于结果集提供的信息不如CREATE TABLE语句中所能指定的那样全面,因此存在一些限制。例如,MySQL 不知道结果集列是否应该建立索引或其默认值是什么。如果在目标表中包含此信息很重要,请使用以下技术:
-
要使目标表成为源表的精确副本,请使用 Recipe 6.1 中描述的克隆技术。
-
要在目标表中包含索引,请明确指定它们。例如,如果
src_tbl在id列上有一个PRIMARY KEY,以及在state和city上有一个多列索引,请在dst_tbl中也指定它们:CREATE TABLE dst_tbl (PRIMARY KEY (id), INDEX(state,city)) SELECT * FROM src_tbl; -
不会复制诸如
AUTO_INCREMENT之类的列属性和列的默认值到目标表。要保留这些属性,请先创建表,然后使用ALTER TABLE来修改列定义。例如,如果src_tbl有一个id列不仅是PRIMARY KEY还是AUTO_INCREMENT列,请复制表并修改副本:CREATE TABLE dst_tbl (PRIMARY KEY (id)) SELECT * FROM src_tbl; ALTER TABLE dst_tbl MODIFY id INT UNSIGNED NOT NULL AUTO_INCREMENT;
6.3 创建临时表
问题
您仅需要一个表用于短时间,之后希望它自动消失。
解决方案
使用TEMPORARY关键字创建表,并让 MySQL 负责删除。
讨论
有些操作需要仅临时存在并在不再需要时应消失的表。当然,您可以显式执行DROP TABLE语句来在完成后移除表。另一个选择是使用CREATE TEMPORARY TABLE。此语句类似于CREATE TABLE,但创建一个在会话结束时自动消失的临时表,如果您还没有手动移除它的话。这种行为非常有用,因为 MySQL 会自动删除这个表;您不需要记住去做这件事。TEMPORARY可以与通常的表创建方法一起使用:
-
根据明确的列定义创建表:
CREATE TEMPORARY TABLE *`tbl_name`* (*`...column definitions...`*); -
根据现有表创建表:
CREATE TEMPORARY TABLE *`new_table`* LIKE *`original_table`*; -
根据结果集即时创建表:
CREATE TEMPORARY TABLE *`tbl_name`* SELECT ... ;
临时表是会话特定的,因此多个客户端可以分别创建同名的临时表而不会互相干扰。这使得编写使用临时表的应用程序更加容易,因为您不需要确保每个客户端的表都具有唯一的名称。(有关表命名问题的进一步讨论,请参见 Recipe 6.4。)
临时表可以与永久表同名。在这种情况下,临时表隐藏
了永久表,只要存在,就可以使其复制的表,并且可以在不意外影响原表的情况下进行修改。在以下示例中的DELETE语句从临时表mail中删除行,而不影响原始永久表:
mysql> `CREATE TEMPORARY TABLE mail SELECT * FROM mail;`
mysql> `SELECT COUNT(*) FROM mail;`
+----------+
| COUNT(*) |
+----------+
| 16 |
+----------+
mysql> `DELETE FROM mail;`
mysql> `SELECT COUNT(*) FROM mail;`
+----------+
| COUNT(*) |
+----------+
| 0 |
+----------+
mysql> `DROP TEMPORARY TABLE mail;`
mysql> `SELECT COUNT(*) FROM mail;`
+----------+
| COUNT(*) |
+----------+
| 16 |
+----------+
虽然使用CREATE TEMPORARY TABLE创建的临时表具有刚讨论的好处,但请记住以下注意事项:
-
要在给定会话中重新使用临时表,您必须在重新创建之前显式删除它。尝试使用相同名称创建第二个临时表将导致错误。
-
如果您修改一个临时表
隐藏
了同名永久表,请务必测试由于已断开连接导致的错误,特别是使用具有重新连接功能的编程接口时。如果客户端程序在检测到断开连接后自动重新连接,则在重新连接后修改将影响永久表,而不是临时表。 -
一些 API 支持持久连接或连接池。这些功能会阻止临时表在脚本结束时按照您的期望被删除,因为连接仍然保持开放以供其他脚本重用。您的脚本无法控制连接何时关闭。这意味着最好在创建临时表之前执行以下语句,以防它仍然存在于脚本的前一次执行中:
DROP TEMPORARY TABLE IF EXISTS *`tbl_name`*;TEMPORARY关键字在这里很有用,如果临时表已经被删除,可以避免删除任何与其同名的永久表。
6.4 生成唯一表名
问题
您需要创建一个表,其名称保证不存在。
解决方案
生成一个对您的客户端程序唯一的值,并将其合并到表名中。
讨论
MySQL 是一个多客户端数据库服务器,因此如果一个创建临时表的脚本可能同时被多个客户端调用,请注意多个脚本实例不要争夺相同的表名。如果脚本使用 CREATE TEMPORARY TABLE 创建表,那么不会有问题,因为不同客户端可以创建同名的临时表而不会发生冲突。
如果您不能或不想使用 TEMPORARY 表,请确保脚本的每次调用都创建一个唯一命名的表,并在不再需要时将其删除。为了实现这一点,将一些保证在每次调用中唯一的值合并到名称中。如果可能在时间戳分辨率内调用两个脚本实例,则时间戳将不起作用。随机数可能更好,但随机数只能减少名称冲突的可能性,而不能消除它。由函数 UUID 生成的值是唯一值的更好来源。函数 UUID 根据RFC 4122,“通用唯一标识符 (UUID) URN 命名空间”生成一个 128 位字符串,这个字符串在空间和时间上都是唯一的。虽然此函数生成的值不一定是唯一的,但足以生成唯一的临时表名。
可以通过使用准备好的语句将 UUID 合并到 SQL 中的表名中来将 UUID 集成到表名中。以下示例说明了这一点,在 CREATE TABLE 语句中引用表名和预防性 DROP TABLE 语句中引用表名:
SET @tbl_name = CONCAT('tmp_tbl_', UUID());
SET @stmt = CONCAT('CREATE TABLE `', @tbl_name, '` (i INT)');
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
6.5 检查或更改表的存储引擎
问题
您想要检查表使用的存储引擎,以便确定哪些引擎功能适用。或者您需要更改表的存储引擎,因为意识到另一个引擎的功能更适合您使用表的方式。
解决方案
要确定表的存储引擎,可以使用几种语句之一。要更改表的引擎,请使用带有 ENGINE 子句的 ALTER TABLE。
讨论
MySQL 支持多个存储引擎,这些引擎具有不同的特性。例如,InnoDB 引擎支持事务,而 Memory 引擎不支持。如果需要知道表是否支持事务,请检查它使用的存储引擎。如果表的引擎不支持事务,可以将表转换为使用支持事务的引擎。
要确定表格的当前引擎,请检查INFORMATION_SCHEMA或使用SHOW TABLE STATUS或SHOW CREATE TABLE语句。对于mail表格,获取引擎信息如下:
mysql> `SELECT ENGINE FROM INFORMATION_SCHEMA.TABLES`
-> `WHERE TABLE_SCHEMA = 'cookbook' AND TABLE_NAME = 'mail';`
+--------+
| ENGINE |
+--------+
| InnoDB |
+--------+
mysql> `SHOW TABLE STATUS LIKE 'mail'\G`
*************************** 1\. row ***************************
Name: mail
Engine: InnoDB
…
mysql> `SHOW CREATE TABLE mail\G`
*************************** 1\. row ***************************
Table: mail
Create Table: CREATE TABLE `mail` (
*`... column definitions ...`*
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
要更改表格的存储引擎,请使用带有ENGINE说明符的ALTER TABLE。例如,要将mail表格转换为使用 Memory 存储引擎,请使用以下语句:
ALTER TABLE mail ENGINE = Memory;
请注意,将大表格转换为不同的存储引擎可能需要很长时间,并且在 CPU 和 I/O 活动方面可能很昂贵。
要确定您的 MySQL 服务器支持哪些存储引擎,请检查SHOW ENGINES语句的输出或查询INFORMATION_SCHEMA ENGINES表。
6.6 使用 mysqldump 复制表格
问题
您希望复制一个或多个表格,无论是在 MySQL 服务器管理的数据库之间,还是从一个服务器复制到另一个。
解决方案
使用mysqldump程序。
讨论
mysqldump程序创建一个备份文件,可以重新加载以重新创建原始表格或表格:
$ `mysqldump cookbook mail > mail.sql`
输出文件mail.sql包括一个CREATE TABLE语句来创建mail表格和一组INSERT语句来插入其行。如果原始表格丢失,您可以重新加载文件来重新创建表格:
$ `mysql cookbook < mail.sql`
此方法还可轻松处理表格的任何触发器。默认情况下,mysqldump将触发器写入转储文件,因此重新加载文件会将触发器与表格一起复制,无需特殊处理。
默认情况下,mysqldump在CREATE TABLE之前包括DROP TABLE IF EXISTS语句。如果您不希望在加载转储时删除表格,并且宁愿操作失败,请使用选项--skip-add-drop-table运行mysqldump。
除了恢复表格外,mysqldump 还可用于通过将输出重新加载到不同的数据库来复制它们。(如果目标数据库不存在,请先创建。)以下示例展示了一些有用的表格复制命令。
复制单个 MySQL 服务器内的表格
-
将单个表格复制到不同的数据库中:
$ `mysqldump cookbook mail > mail.sql` $ `mysql other_db < mail.sql`要转储多个表格,请在数据库名称参数后命名它们。
-
复制数据库中的所有表格到另一个数据库:
$ `mysqldump cookbook > cookbook.sql` $ `mysql other_db < cookbook.sql`当您在数据库名称后没有指定表格名称时,mysqldump 会将它们全部转储。要同时包括存储过程和事件,请在 mysqldump 命令中添加
--routines和--events选项。(虽然还有--triggers选项,但因为mysqldump默认将触发器与其关联的表格一起转储,所以不需要。) -
复制表格,并使用不同的名称进行复制:
-
转储表格:
$ `mysqldump cookbook mail > mail.sql` -
将表格重新加载到不包含具有该名称的表格的不同数据库中:
$ `mysql other_db < mail.sql` -
重命名表格:
$ `mysql other_db` mysql> `RENAME mail TO mail2;`或者,同时将表格移动到另一个数据库中,请使用数据库名称限定新名称:
$ `mysql other_db` mysql> `RENAME mail TO cookbook.mail2;`
-
若要在没有中介文件的情况下执行表格复制操作,请使用管道连接mysqldump和mysql命令:
$ `mysqldump cookbook mail | mysql other_db`
$ `mysqldump cookbook | mysql other_db`
提示
您可以考虑使用较新的工具 mysqlpump,其工作方式类似于 mysqldump,但支持更智能的过滤器和并行处理。我们在 Recipe 13.13 中讨论了 mysqlpump。
在 MySQL 服务器之间复制表格
前述命令使用 mysqldump 在由单个 MySQL 服务器管理的数据库之间复制表格。mysqldump 的输出也可以用于从一个服务器复制表格到另一个服务器。假设您想要从本地主机的 cookbook 数据库复制 mail 表格到主机 other-host.example.com 上的 other_db 数据库。一种方法是将输出转储到一个文件:
$ `mysqldump cookbook mail > mail.sql`
然后将 mail.sql 复制到 other-host.example.com,在那里运行以下命令将表格加载到该 MySQL 服务器的 other_db 数据库中:
$ `mysql other_db < mail.sql`
要实现此目标而无需中间文件,请使用管道将 mysqldump 的输出直接通过网络发送到远程 MySQL 服务器。如果您可以从本地主机连接到两台服务器,请使用以下命令:
$ `mysqldump cookbook mail | mysql -h other-host.example.com other_db`
mysqldump 命令的前半部分连接到本地服务器并将转储输出写入管道。mysql 命令的后半部分连接到远程 MySQL 服务器 other-host.example.com。它从管道读取输入并将每个语句发送到 other-host.example.com 服务器。
如果无法直接使用本地主机上的 mysql 连接到远程服务器,请将转储输出发送到使用 ssh 在 other-host.example.com 上远程调用 mysql 的管道中:
$ `mysqldump cookbook mail | ssh other-host.example.com mysql other_db`
ssh 连接到 other-host.example.com 并在那里启动 mysql。然后,它从管道中读取 mysqldump 的输出并将其传递给远程的 mysql 进程。ssh 可以用来将转储通过网络发送到一个因防火墙而阻止 MySQL 端口连接但允许 SSH 端口连接的机器。
关于要复制哪些表格,适用于本地复制的类似原则同样适用。要通过网络复制多个表格,请在 mysqldump 命令的数据库参数后命名它们。要复制整个数据库,请在数据库名称后不指定任何表格名称;mysqldump 会转储其所有表格。要复制驻留在您的 MySQL 实例上的所有数据库,请指定选项 --all-databases。
6.7 使用可传输表空间复制 InnoDB 表格
问题
想要复制一个 InnoDB 表,但表太大了,以人类可读格式导出数据需要很长时间。重载也不够快。
解决方案
使用可传输表空间。
讨论
当处理较小的表格或者想要在将结果的 SQL 转储应用到目标服务器之前自行检查时,像 mysqldump 或 mysqlpump 这样的工具非常有效。然而,对于在磁盘上占据几十 GB 的表进行复制,将需要大量的时间。这也会给服务器增加额外的负载。更糟糕的是,保护机制会影响使用相同表的其他连接。
要解决这样的问题,存在二进制备份和恢复方法。这些方法在二进制表文件上工作,而不进行任何额外的数据操作,因此性能与在 Linux 上运行 cp 命令或在 Windows 上运行 copy 命令时相同。
从版本 8.0 开始,MySQL 将表定义存储在数据字典中,而数据存储在单独的文件中。这些文件的格式和名称取决于存储引擎。对于 InnoDB,它们是单独的、通用的和系统表空间。单独的表空间文件为每个表单独存储数据,可以用于我们在本节中描述的方法。如果您的表存储在系统或通用表空间中,则首先需要将它们转换为使用单独表空间格式。
ALTER TABLE tbl_name TABLESPACE = innodb_file_per_table;
要查看您的表是否位于系统或通用表空间中,请在信息模式中查询表 INNODB_TABLES:
mysql> `SELECT` `NAME``,` `SPACE_TYPE` `FROM` `INFORMATION_SCHEMA``.``INNODB_TABLES`
-> `WHERE` `NAME` `LIKE` `'test/%'``;`
+----------------------------------------+------------+ | NAME | SPACE_TYPE |
+----------------------------------------+------------+ | test/residing_in_system_tablespace | System |
| test/residing_in_individual_tablespace | Single |
| test/residing_in_general_tablespace | General |
+----------------------------------------+------------+
一旦准备好复制表空间,请登录到 mysql 客户端并执行:
FLUSH TABLES limbs FOR EXPORT;
此命令将准备表空间文件以供复制,并额外创建扩展名为 .cfg 的配置文件,其中包含表元数据。
保持 MySQL 客户端打开,在另一个终端窗口中将表空间和配置文件复制到所需位置。
cp /var/lib/mysql/cookbook/limbs.{cfg,ibd} .
复制完成后解锁表格。
UNLOCK TABLES;
现在您可以将表空间导入到远程服务器或同一本地服务器上的不同数据库中。
第一步是创建与原始表完全相同定义的表。您可以通过运行 SHOW CREATE TABLE 命令找到表定义。
source> `SHOW` `CREATE` `TABLE` `limbs``\``G`
*************************** 1. row ***************************
Table: limbs
Create Table: CREATE TABLE `limbs` (
`thing` varchar(20) DEFAULT NULL,
`legs` int DEFAULT NULL,
`arms` int DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
一旦获取它,请连接到目标数据库并创建表格。
destination> `USE` `cookbook_copy``;`
Database changed
destination> `CREATE` `TABLE` `` ` ```四肢``` ` `` `(`
-> `` ` ```东西``` ` `` `varchar``(``20``)` `DEFAULT` `NULL``,`
-> `` ` ```腿``` ` `` `int` `DEFAULT` `NULL``,`
-> `` ` ```臂``` ` `` `int` `DEFAULT` `NULL`
-> `)` `ENGINE``=``InnoDB` `DEFAULT` `CHARSET``=``utf8mb4` `COLLATE``=``utf8mb4_0900_ai_ci``;`
Query OK, 0 rows affected (0.03 sec)
创建新的空表后,丢弃其表空间:
ALTER TABLE limbs DISCARD TABLESPACE;
警告
DISCARD TABLESPACE 删除表空间文件。对于此命令要非常小心。如果输入错误并丢弃了错误表的表空间,则无法恢复。
在表空间被丢弃后,将表文件复制到新的数据库目录中。
$ `sudo cp limbs.{cfg,ibd} /var/lib/mysql/cookbook_copy`
$ `sudo chown mysql:mysql /var/lib/mysql/cookbook_copy/limbs.{cfg,ibd}`
然后导入表空间。
ALTER TABLE limbs IMPORT TABLESPACE;
参见
关于在 MySQL 数据库和服务器之间交换表空间文件的更多信息,请参阅 导入 InnoDB 表。
6.8 使用 sdi 文件复制 MyISAM 表
问题
您想在 MySQL 8.0 上复制一个大的 MyISAM 表。
解决方案
使用 IMPORT TABLE 命令。
讨论
使用 MyISAM 存储引擎的表支持使用 IMPORT TABLE 语句导入原始表文件。为了在迁移期间无风险地导出 MyISAM 表而不会损坏数据,请首先打开 MySQL 连接,并使用读锁定刷新表文件到磁盘。
FLUSH TABLES limbs_myisam WITH READ LOCK;
然后复制表数据、索引和元数据文件到备份位置。
$ `sudo cp /var/lib/mysql/cookbook/limbs_myisam.{MYD,MYI} .`
$ `sudo bash -c 'cp /var/lib/mysql/cookbook/limbs_myisam_*.sdi . '`
解锁原始表格。
元数据文件的扩展名为 .sdi,其名称中有随机的数字序列,因此使用 sudo 复制它以允许 shell 进程扩展文件全局模式。
要将 MyISAM 表格复制到所需目的地,请将带有扩展名sdi的表格元数据文件放入由选项--secure-file-priv指定的目录,或者放入任何目录,只要目标 MySQL 服务器可读取,如果未设置此选项。然后将索引和数据文件复制到目标数据库目录。
$ `sudo cp limbs_myisam.{MYD,MYI} /var/lib/mysql/cookbook_copy/`
$ `sudo chown mysql:mysql /var/lib/mysql/cookbook_copy/limbs_myisam.{MYD,MYI}`
然后连接到数据库并导入表格。
IMPORT TABLE FROM '/tmp/limbs_myisam_11560.sdi';
如果要将表格复制到具有不同名称的数据库中,您需要手动编辑sdi文件,并将schema_ref的值替换为目标数据库名称。
第七章:使用字符串
7.0 Introduction
与大多数数据类型一样,字符串值可以进行相等性或不等性比较,或相对顺序比较。然而,字符串有额外的属性需要考虑:
-
字符串可以是二进制或非二进制的。二进制字符串用于原始数据,如图像、音乐文件或加密值。非二进制字符串用于字符数据,如文本,并与字符集和排序规则(排序顺序)相关联。
-
字符集确定字符串中合法的字符。根据是否需要比较大小写敏感或不敏感,或使用特定语言的规则,可以选择排序规则。
-
二进制字符串的数据类型为
BINARY、VARBINARY和BLOB。非二进制字符串的数据类型为CHAR、VARCHAR和TEXT,每种类型都允许使用CHARACTERSET和COLLATE属性。 -
您可以将二进制字符串转换为非二进制字符串,反之亦然,或将非二进制字符串从一个字符集或排序规则转换为另一个字符集或排序规则。
-
您可以使用字符串的全部内容或从中提取子字符串。字符串可以与其他字符串组合。
-
您可以对字符串应用模式匹配操作。
-
可以对大量文本进行高效查询的全文搜索功能可用。
本章讨论如何利用这些属性,以便根据应用程序的任何要求存储、检索和操作字符串。
用于创建本章使用的表的脚本位于recipes发行版的tables目录中。
7.1 字符串属性
一个字符串的属性是它是二进制的还是非二进制的:
-
二进制字符串是字节序列。它可以包含任何类型的信息,如图像、MP3 文件或压缩或加密数据。即使存储看起来像普通文本的值(例如
abc),二进制字符串也不与字符集关联。二进制字符串按字节使用数值字节值进行逐字节比较。 -
非二进制字符串是字符序列。它存储具有特定字符集和排序规则的文本。字符集定义了可以存储在字符串中的字符。排序规则定义了字符的排序顺序,影响比较和排序操作。
要查看非二进制字符串可用的字符集,请使用以下语句:
mysql> `SHOW CHARACTER SET;`
+----------+-----------------------------+---------------------+--------+
| Charset | Description | Default collation | Maxlen |
+----------+-----------------------------+---------------------+--------+
| big5 | Big5 Traditional Chinese | big5_chinese_ci | 2 |
…
| koi8r | KOI8-R Relcom Russian | koi8r_general_ci | 1 |
| latin1 | cp1252 West European | latin1_swedish_ci | 1 |
| latin2 | ISO 8859-2 Central European | latin2_general_ci | 1 |
…
| utf8 | UTF-8 Unicode | utf8_general_ci | 3 |
| utf8mb4 | UTF-8 Unicode | utf8mb4_0900_ai_ci | 4 |
…
MySQL 8.0 中的默认字符集为utf8mb4,排序规则为utf8mb4_0900_ai_ci。如果必须在单个列中存储多种语言的字符,请考虑使用 Unicode 字符集之一(例如utf8mb4或utf16),因为它们可以表示多种语言的字符。
一些字符集只包含单字节字符,而其他字符集则允许多字节字符。一些多字节字符集中的字符长度各不相同,而另一些则所有字符长度固定。例如,Unicode 数据可以使用 utf8mb4 字符集存储,其中字符长度可以从一到四个字节不等,或者使用 utf16 字符集存储,其中所有字符长度均为两个字节。
注意
在 MySQL 中,要使用包括基本多语言平面(BMP)之外的补充字符在内的完整 Unicode 字符集,请使用 utf8mb4。在 utf8mb4 中,字符的字节长度可以从一到四个字节不等。其他包括补充字符的 Unicode 字符集还有 utf16、utf16le 和 utf32。
要确定给定字符串是否包含多字节字符,请使用 LENGTH() 和 CHAR_LENGTH() 函数,它们分别返回字符串的字节长度和字符长度。如果对于给定字符串,LENGTH() 大于 CHAR_LENGTH(),则表示存在多字节字符:
-
utf8Unicode 字符集包含多字节字符,但给定的utf8字符串可能只包含单字节字符,例如以下示例:mysql> `SET @s = CONVERT('abc' USING utf8mb4);` mysql> `SELECT LENGTH(@s), CHAR_LENGTH(@s);` +------------+-----------------+ | LENGTH(@s) | CHAR_LENGTH(@s) | +------------+-----------------+ | 3 | 3 | +------------+-----------------+ -
对于
utf16Unicode 字符集,所有字符均使用两个字节编码,即使它们在其他字符集如latin1中是单字节字符。因此,每个utf16字符串都包含多字节字符:mysql> `SET @s = CONVERT('abc' USING utf16);` mysql> `SELECT LENGTH(@s), CHAR_LENGTH(@s);` +------------+-----------------+ | LENGTH(@s) | CHAR_LENGTH(@s) | +------------+-----------------+ | 6 | 3 | +------------+-----------------+
非二进制字符串的另一个属性是排序规则(collation),它确定字符集中字符的排序顺序。使用 SHOW COLLATION 查看所有可用的排序规则;添加 LIKE 子句以查看特定字符集的排序规则:
mysql> `SHOW COLLATION LIKE 'utf8mb4%';`
+----------------------------+---------+-----+---------+----------+---------+---------------+
| Collation | Charset | Id | Default | Compiled | Sortlen | Pad_attribute |
+----------------------------+---------+-----+---------+----------+---------+---------------+
| utf8mb4_0900_ai_ci | utf8mb4 | 255 | Yes | Yes | 0 | NO PAD |
| utf8mb4_0900_as_ci | utf8mb4 | 305 | | Yes | 0 | NO PAD |
..
| utf8mb4_es_0900_ai_ci | utf8mb4 | 263 | | Yes | 0 | NO PAD |
| utf8mb4_es_0900_as_cs | utf8mb4 | 286 | | Yes | 0 | NO PAD |
| utf8mb4_es_trad_0900_ai_ci | utf8mb4 | 270 | | Yes | 0 | NO PAD |
| utf8mb4_es_trad_0900_as_cs | utf8mb4 | 293 | | Yes | 0 | NO PAD |
..
| utf8mb4_tr_0900_ai_ci | utf8mb4 | 265 | | Yes | 0 | NO PAD |
| utf8mb4_tr_0900_as_cs | utf8mb4 | 288 | | Yes | 0 | NO PAD |
| utf8mb4_turkish_ci | utf8mb4 | 233 | | Yes | 8 | PAD SPACE |
| utf8mb4_unicode_520_ci | utf8mb4 | 246 | | Yes | 8 | PAD SPACE |
在没有明确指定排序规则的情况下,给定字符集中的字符串使用 Default 列中带有 Yes 的排序规则。如图所示,utf8mb4 的默认排序规则是 utf8mb4_0900_ai_ci。(默认排序规则也可以通过 SHOW CHARACTER SET 显示。)
排序规则可以是区分大小写的(a 和 A 是不同的)、不区分大小写的(a 和 A 是相同的)、或二进制的(两个字符根据它们的数值是否相等来判断是相同还是不同)。以 _ci、_cs 或 _bin 结尾的排序规则名分别表示不区分大小写、区分大小写或二进制。
二进制字符串和二进制排序规则都使用数值。不同之处在于,二进制字符串比较总是基于单字节单位,而二进制排序规则使用字符数值比较非二进制字符串;根据字符集,其中一些可能是多字节值。
下面的示例说明了排序规则如何影响排序顺序。假设一个表包含一个 utf8mb4 字符串列,并具有以下行:
mysql> `CREATE TABLE t (c CHAR(3) CHARACTER SET utf8mb4);`
mysql> `INSERT INTO t (c) VALUES('AAA'),('bbb'),('aaa'),('BBB');`
mysql> `SELECT c FROM t;`
+------+
| c |
+------+
| AAA |
| bbb |
| aaa |
| BBB |
+------+
通过在列上应用 COLLATE 运算符,可以选择用于排序的排序规则,从而影响结果的顺序:
-
大小写不敏感排序将
a和A放在一起,并将它们放在b和B之前。但是,对于给定的字母,它不一定将一个字母的大小写顺序排在另一个字母的前面,如下结果所示:mysql> `SELECT c FROM t ORDER BY c COLLATE utf8mb4_turkish_ci;` +------+ | c | +------+ | AAA | | aaa | | bbb | | BBB | +------+ -
大小写敏感排序将
A和a放在B和b之前,并将小写字母排序在大写字母之前:mysql> `SELECT c FROM t ORDER BY c COLLATE utf8mb4_tr_0900_as_cs;` +------+ | c | +------+ | aaa | | AAA | | bbb | | BBB | +------+ -
二进制排序使用字符的数字值进行排序。假设大写字母的数字值小于小写字母的数字值,则二进制排序结果如下:
mysql> `SELECT c FROM t ORDER BY c COLLATE utf8mb4_bin;` +------+ | c | +------+ | AAA | | BBB | | aaa | | bbb | +------+请注意,由于不同大小写字符具有不同的数字值,二进制排序会产生大小写敏感的顺序。但是,该顺序与大小写敏感排序不同。
如果您要求比较和排序操作使用特定语言的排序规则,请选择特定语言的排序。例如,如果您使用utf8mb4字符集存储字符串,则默认排序(utf8mb4_0900_ai_ci)将ch和ll视为两个字符的字符串。要使用传统的西班牙排序(将ch和ll视为跟随c和l的单个字符),请指定utf8mb4_spanish2_ci排序。这两种排序产生不同的结果,如下所示:
mysql> `CREATE TABLE t (c CHAR(2) CHARACTER SET utf8mb4);`
mysql> `INSERT INTO t (c) VALUES('cg'),('ch'),('ci'),('lk'),('ll'),('lm');`
mysql> `SELECT c FROM t ORDER BY c COLLATE utf8mb4_general_ci;`
+------+
| c |
+------+
| cg |
| ch |
| ci |
| lk |
| ll |
| lm |
+------+
mysql> `SELECT c FROM t ORDER BY c COLLATE utf8mb4_spanish2_ci;`
+------+
| c |
+------+
| cg |
| ci |
| ch |
| lk |
| lm |
| ll |
+------+
如果您未使用默认排序,则最好在列定义中设置排序,方法如下;
mysql> `CREATE TABLE t (c CHAR(2) CHARACTER SET utf8mb4 COLLATE utf8mb4_spanish2_ci);`
这将确保在排序操作期间避免使用错误的排序时可能导致的查询性能下降。
7.2 选择字符串数据类型
问题
您希望存储字符串数据,但不确定哪种数据类型最合适。
解决方案
根据要存储的信息的特征和您需要使用它的方式选择数据类型。考虑以下问题:
-
字符串是二进制还是非二进制?
-
大小写是否重要?
-
最大字符串长度是多少?
-
您是否希望存储固定长度还是可变长度的值?
-
您是否需要保留尾随空格?
-
是否有一组固定的允许值?
讨论
MySQL 提供多种二进制和非二进制字符串数据类型。这些类型成对出现,如下表所示。最大长度以字节为单位,无论类型是二进制还是非二进制。对于非二进制类型,包含多字节字符的字符串的最大字符数较少,如我们在表 7-1 中所示
表 7-1. 每种数据类型的最大字符数
| 二进制数据类型 | 非二进制数据类型 | 最大长度 |
|---|---|---|
BINARY |
CHAR |
255 |
VARBINARY |
VARCHAR |
65,535 |
TINYBLOB |
TINYTEXT |
255 |
BLOB |
TEXT |
65,535 |
MEDIUMBLOB |
MEDIUMTEXT |
16,777,215 |
LONGBLOB |
LONGTEXT |
4,294,967,295 |
对于BINARY和CHAR数据类型,MySQL 使用固定宽度存储列值。例如,在BINARY(10)或CHAR(10)列中存储的值始终占据 10 个字节或 10 个字符。存储时必要时会将较短的值填充到所需长度。对于BINARY,填充值为0x00(零值字节,也称为 ASCII NUL)。CHAR值在存储时用空格填充,并且在检索时删除尾随空格。
对于VARBINARY、VARCHAR以及BLOB和TEXT类型,MySQL 仅使用所需的存储空间存储值,不超过最大列长度。在存储或检索值时不会添加或删除填充。
要保留存储的原始字符串中存在的尾随填充值,请使用不会执行剥离的数据类型。例如,如果存储可能以空格结尾的字符(非二进制)字符串,并且希望保留它们,请使用VARCHAR或其中一个TEXT数据类型。以下语句说明了CHAR和VARCHAR列处理尾随空格的差异:
mysql> `DROP TABLE IF EXISTS t;`
mysql> `CREATE TABLE t (c1 CHAR(10), c2 VARCHAR(10));`
mysql> `INSERT INTO t (c1,c2) VALUES('abc ','abc ');`
mysql> `SELECT c1, c2, CHAR_LENGTH(c1), CHAR_LENGTH(c2) FROM t;`
+------+------------+-----------------+-----------------+
| c1 | c2 | CHAR_LENGTH(c1) | CHAR_LENGTH(c2) |
+------+------------+-----------------+-----------------+
| abc | abc | 3 | 10 |
+------+------------+-----------------+-----------------+
这表明,如果将包含尾随空格的字符串存储到CHAR列中,检索该值时会删除这些空格。
表可以包含混合的二进制和非二进制字符串列,其非二进制列可以使用不同的字符集和排序规则。声明非二进制字符串列时,如果需要特定的字符集和排序规则,请使用CHARACTER SET和COLLATE属性。例如,如果需要存储utf8mb4(Unicode)和sjis(日语)字符串,可以像这样定义包含两列的表:
CREATE TABLE mytbl
(
utf8str VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_danish_ci,
sjisstr VARCHAR(100) CHARACTER SET sjis COLLATE sjis_japanese_ci
);
在列定义中,CHARACTER SET和COLLATE子句都是可选的:
-
如果指定了
CHARACTERSET并省略COLLATE,则使用字符集的默认排序规则。 -
如果指定了
COLLATE并省略CHARACTERSET,则使用排序规则名称隐含的字符集(名称的第一部分)。例如,utf8mb4_danish_ci和sjis_japanese_ci分别隐含utf8mb4和sjis。这意味着在前述CREATETABLE语句中可以省略CHARACTERSET属性。 -
如果你同时省略
CHARACTERSET和COLLATE,则该列将被赋予表的默认字符集和排序规则。表定义可以在CREATETABLE语句的右括号后包含这些属性。如果存在这些属性,则它们适用于没有显式字符集或排序规则的列。如果省略,则表的默认值来自数据库的默认值。您可以在使用CREATEDATABASE语句创建数据库时指定数据库的默认值。如果省略,则服务器默认值适用于数据库。
MySQL 8.0 的服务器默认字符集和排序规则分别为 utf8mb4 和 utf8mb4_0900_ai_ci,因此字符串默认使用 utf8mb4 字符集并且不区分大小写。要更改此设置,可以在服务器启动时设置 character_set_server 和 collation_server 系统变量(参见 Recipe 22.1)。
MySQL 还支持 ENUM 和 SET 字符串类型,用于具有固定允许值集的列。这些数据类型也适用于 CHARACTER SET 和 COLLATE 属性。
7.3 设置客户端连接字符集
问题
当你执行不使用默认字符集的 SQL 语句或生成查询结果时。
解决方案
使用 SET NAMES 或其等效方法设置连接的适当字符集。
讨论
当你在应用程序和服务器之间发送信息时,可能需要告诉 MySQL 使用适当的字符集。例如,默认字符集是 latin1,但这可能不是连接到服务器的正确字符集。如果有希腊数据,使用 latin1 在屏幕上显示将会产生乱码。如果在 utf8mb4 字符集中使用 Unicode 字符串,latin1 可能无法表示所有你可能需要的字符。
要解决此问题,请配置连接以使用适当的字符集。有几种方法可以实现这一点:
-
在连接后执行
SETNAMES语句:mysql> `SET NAMES 'utf8mb4';`SETNAMES允许指定连接排序规则:mysql> `SET NAMES 'utf8mb4' COLLATE 'utf8mb4_0900_ai_ci';` -
如果你的客户端程序支持
--default-character-set选项,可以在程序调用时使用它指定字符集。mysql 就是这样的程序。将选项放入选项文件中,以便每次连接到服务器时生效:[mysql] default-character-set=utf8mb4 -
如果你在 Unix 上使用
LANG或LC_ALL环境变量或在 Windows 上设置代码页,则 MySQL 客户端程序会自动检测要使用的字符集。例如,将LC_ALL设置为en_US.UTF-8会导致诸如 mysql 的程序使用utf8。 -
一些编程接口提供了它们自己设置字符集的方法。例如,MySQL Connector/J 用于 Java 客户端会在连接时自动检测服务器端使用的字符集,但你也可以在连接 URL 中明确指定不同的集合,使用
characterEncoding属性。该属性的值应为 Java 风格的字符集名称。要选择utf8mb4,你可以使用如下连接 URL:jdbc:mysql://localhost/cookbook?characterEncoding=UTF-8这比使用
SETNAMES更可取,因为 Connector/J 会代表应用程序执行字符集转换,但如果使用SETNAMES,它不知道适用哪个字符集。对于其他 API 编写的程序也适用类似原则。对于 PHP Data Objects (PDO),在数据源名称 (DSN) 字符串中使用charset选项(在 PHP 5.3.6 或更高版本中有效):$dsn = "mysql:host=localhost;dbname=cookbook;charset=utf8mb4";对于 Connector/Python,请指定
charset连接参数:conn_params = { "database": "cookbook", "host": "localhost", "user": "cbuser", "password": "cbpass", "charset": "utf8mb4", }对于 Go,请指定
charset连接参数:db, err := sql.Open("mysql", "cbuser:cbpass@tcp(127.0.0.1:3306)/cookbook?charset=utf8mb4")一些 API 也可以提供参数以指定校对顺序。
注意
一些字符集不能用作连接字符集:utf16,utf16le,utf32。
您还应确保显示设备使用的字符集与您用于 MySQL 的字符集匹配。否则,即使 MySQL 正确处理数据,它也可能显示为垃圾。假设您在终端窗口中使用mysql程序,并配置 MySQL 使用utf8mb4存储utf8mb4编码的土耳其数据。如果将终端窗口设置为使用euc-tr编码(也是土耳其语),但其土耳其字符的编码与utf8mb4不同,数据将不会按预期显示。(如果使用自动检测,这不应该是一个问题。)
在一个示例中,插入表中的土耳其字符将在使用不同字符集进行连接时显示为乱码。
mysql> `DROP TABLE IF EXISTS t;`
mysql> `CREATE TABLE t (c CHAR(3) CHARACTER SET utf8mb4);`
mysql> `INSERT INTO t (c) VALUES('iii'),('şşş'),('ööö'),('ççç');`
使用Latin1客户端字符集从另一个连接将得到以下结果。
mysql> `\s`
--------------
mysql Ver 8.0.27 for Linux on x86_64 (MySQL Community Server - GPL)
...
Server characterset: utf8mb4
Db characterset: utf8mb4
Client characterset: latin1
Conn. characterset: latin1
...
`SELECT c from t;`
+------+
| c |
+------+
| iii |
| ??? |
| ��� |
| ��� |
+------+
要验证您是否连接到 MySQL 命令行界面并显示正确的字符集,请执行以下操作。
mysql> `\s`
--------------
mysql Ver 8.0.27 for Linux on x86_64 (MySQL Community Server - GPL)
...
Server characterset: utf8mb4
Db characterset: utf8mb4
Client characterset: utf8mb4
Conn. characterset: utf8mb4
...
`SELECT c from t;`
+------+
| c |
+------+
| iii |
| şşş |
| ööö |
| ççç |
+------+
7.4 编写字符串文字
问题
您需要在 SQL 语句中编写文字字符串。
解决方案
学习规定字符串值的语法规则。
讨论
你可以按多种方式编写字符串:
-
将字符串的文本放在单引号或双引号中:
'my string' "my string"当启用
ANSI_QUOTESSQL 模式时,不能使用双引号引用字符串:服务器会将双引号解释为标识符(如表或列名)的引用字符,而不是字符串(见 Recipe 4.6)。如果采用始终使用单引号编写带引号的字符串的约定,MySQL 会将它们解释为字符串,而不管ANSI_QUOTES设置如何。 -
使用十六进制表示法。每对十六进制数字产生字符串的一个字节。
abcd可以用以下任一格式写入:0x61626364 X'61626364' x'61626364'MySQL 将使用十六进制表示法编写的字符串视为二进制字符串。不巧的是,应用程序在构造引用二进制值的 SQL 语句时通常使用十六进制字符串:
INSERT INTO t SET binary_col = 0xdeadbeef; -
要指定用于解释文字字符串的字符集,请使用由下划线引导的字符集名称:
_utf8mb4 'abcd' _utf16 'abcd'引导符告诉服务器如何解释其后的字符串。对于
_utf8mb4'abcd',服务器生成由四个单字节字符组成的字符串。对于_ucs2'abcd',服务器生成由两个双字节字符组成的字符串,因为ucs2是双字节字符集。
为确保字符串是二进制字符串或非二进制字符串具有特定字符集或校对顺序,请使用 Recipe 7.5 中给出的字符串转换说明。
当执行一个 API 或者在mysql批处理模式中运行时,包含相同引号字符的引用字符串会产生语法错误:
`mysql -e "SELECT 'I'm asleep'"`
ERROR 1064 (42000) at line 1: 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 'asleep'' at line 1
如果通过mysql客户端交互执行,则会等待闭合引号。
mysql> `SELECT` `'I'``m` `asleep``';` '>
'> `'``\``c`
mysql>
您有几种方法可以处理这个问题:
-
将包含单引号的字符串放在双引号内(假设未启用
ANSI_QUOTES),或将包含双引号的字符串放在单引号内。mysql> `SELECT "I'm asleep", 'He said, "Boo!"';` +------------+-----------------+ | I'm asleep | He said, "Boo!" | +------------+-----------------+ | I'm asleep | He said, "Boo!" | +------------+-----------------+ -
要在同一种引号内的字符串中包含引号字符,可以将引号字符加倍或在其前面加上反斜杠。当 MySQL 读取语句时,它会剥离额外的引号或反斜杠:
mysql> `SELECT 'I''m asleep', 'I\'m wide awake';` +------------+----------------+ | I'm asleep | I'm wide awake | +------------+----------------+ | I'm asleep | I'm wide awake | +------------+----------------+ mysql> `SELECT "He said, ""Boo!""", "And I said, \"Yikes!\"";` +-----------------+----------------------+ | He said, "Boo!" | And I said, "Yikes!" | +-----------------+----------------------+ | He said, "Boo!" | And I said, "Yikes!" | +-----------------+----------------------+反斜杠会关闭后续字符的任何特殊含义,包括它本身。要在字符串中写入文字反斜杠,请将其加倍:
mysql> `SELECT 'Install MySQL in C:\\mysql on Windows';` +--------------------------------------+ | Install MySQL in C:\mysql on Windows | +--------------------------------------+ | Install MySQL in C:\mysql on Windows | +--------------------------------------+反斜杠会暂时关闭正常字符串处理规则,因此称为转义序列的序列,例如
\'、\"和\\。MySQL 还识别\b(退格)、\n(换行,也称为换行符)、\r(回车)、\t(制表符)和\0(ASCII NUL)等其他转义序列。 -
将字符串写成十六进制值:
mysql> `SELECT 0x49276D2061736C656570;` +------------------------+ | 0x49276D2061736C656570 | +------------------------+ | I'm asleep | +------------------------+
警告
从 8.0 版本开始,默认情况下mysql客户端将使用选项 --binary-as-hex 运行。如果不禁用此选项,您将以十六进制值获取二进制输出。例如,对于上述命令,您将看到:
mysql> `SELECT` `0``x49276D2061736C656570``;`
+------------------------------------------------+ | 0x49276D2061736C656570 |
+------------------------------------------------+ | 0x49276D2061736C656570 |
+------------------------------------------------+ 1 row in set (0,00 sec)
要获取可读的人类输出,请使用选项 --binary-as-hex=0 启动mysql客户端。
另请参阅
如果您从程序内执行 SQL 语句,可以通过符号引用字符串或二进制值,并让编程接口处理引用:使用语言数据库访问 API 提供的占位符机制(参见 Recipe 4.5)。另外,可以使用 LOAD_FILE() 函数从文件中加载诸如图像之类的二进制值(参见 MySQL documentation)。
7.5 检查或更改字符串的字符集或排序规则
问题
您想了解字符串的字符集或排序规则,或者将一个字符串转换为其他字符集或排序规则。
解决方案
要检查字符串的字符集或排序规则,请使用 CHARSET() 或 COLLATION() 函数。要更改其字符集,请使用 CONVERT() 函数。要更改其排序规则,请使用 COLLATE 操作符。
讨论
对于如下创建的表,您知道存储在列 c 中的值具有字符集 utf8 和排序规则 utf8_danish_ci:
CREATE TABLE t (c CHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_danish_ci);
但有时不太清楚哪个字符集或排序规则适用于字符串。服务器配置可能会影响字面字符串和某些字符串函数,而其他字符串函数则会以特定字符集返回值。当出现排序规则不匹配错误以及比较操作失败时,或者字母大小写转换无法正常工作时,表明您可能使用了错误的字符集或排序规则。
要确定字符串的字符集或排序规则,请使用CHARSET()或COLLATION()函数。例如,您知道USER()函数返回一个 Unicode 字符串吗?
mysql> `SELECT USER(), CHARSET(USER()), COLLATION(USER());`
+------------------+-----------------+-------------------+
| USER() | CHARSET(USER()) | COLLATION(USER()) |
+------------------+-----------------+-------------------+
| cbuser@localhost | utf8mb3 | utf8_general_ci |
+------------------+-----------------+-------------------+
如果字符串值根据当前客户端配置获取其字符集和排序规则,则如果配置更改,则这些属性可能会改变。对于文字字符串,这是正确的:
mysql> `SET NAMES 'utf8mb4';`
mysql> `SELECT CHARSET('abc'), COLLATION('abc');`
+----------------+--------------------+
| CHARSET('abc') | COLLATION('abc') |
+----------------+--------------------+
| utf8mb4 | utf8mb4_0900_ai_ci|
+----------------+--------------------+
mysql> `SET NAMES 'utf8mb4' COLLATE 'utf8mb4_bin';`
mysql> `SELECT CHARSET('abc'), COLLATION('abc');`
+----------------+------------------+
| CHARSET('abc') | COLLATION('abc') |
+----------------+------------------+
| utf8mb4 | utf8mb4_bin |
+----------------+------------------+
对于二进制字符串,CHARSET()或COLLATION()函数返回一个binary值,这意味着字符串是基于数字字节值而不是字符排序值进行比较和排序的。
要将一个字符集转换为另一个字符集,请使用CONVERT()函数:
mysql> `SET @s1 = _latin1 'my string', @s2 = CONVERT(@s1 USING utf8mb4);`
mysql> `SELECT CHARSET(@s1), CHARSET(@s2);`
+--------------+--------------+
| CHARSET(@s1) | CHARSET(@s2) |
+--------------+--------------+
| latin1 | utf8mb4 |
+--------------+--------------+
要更改字符串的排序规则,请使用COLLATE运算符:
mysql> `SET @s1 = _latin1 'my string', @s2 = @s1 COLLATE latin1_spanish_ci;`
mysql> `SELECT COLLATION(@s1), COLLATION(@s2);`
+-------------------+-------------------+
| COLLATION(@s1) | COLLATION(@s2) |
+-------------------+-------------------+
| latin1_swedish_ci | latin1_spanish_ci |
+-------------------+-------------------+
新的排序规则必须适合字符串的字符集。例如,您可以将utf8_general_ci排序规则与utf8mb3字符串一起使用,但不能与latin1字符串一起使用:
mysql> `SELECT _latin1 'abc' COLLATE utf8_bin;`
ERROR 1253 (42000): COLLATION 'utf8_bin' is not valid for
CHARACTER SET 'latin1'
要同时转换字符串的字符集和排序规则,请使用CONVERT()来改变字符集,并对结果应用COLLATE运算符:
mysql> `SET @s1 = _latin1 'my string';`
mysql> `SET @s2 = CONVERT(@s1 USING utf8mb4) COLLATE utf8mb4_spanish_ci;`
mysql> `SELECT CHARSET(@s1), COLLATION(@s1), CHARSET(@s2), COLLATION(@s2);`
+--------------+-------------------+--------------+-----------------+
| CHARSET(@s1) | COLLATION(@s1) | CHARSET(@s2) | COLLATION(@s2) |
+--------------+-------------------+--------------+-----------------+
| latin1 | latin1_swedish_ci | utf8 | utf8_spanish_ci |
+--------------+-------------------+--------------+-----------------+
CONVERT()函数还可以将二进制字符串转换为非二进制字符串,反之亦然。要生成一个二进制字符串,请使用binary;任何其他字符集名称都会生成一个非二进制字符串:
mysql> `SET @s1 = _latin1 'my string';`
mysql> `SET @s2 = CONVERT(@s1 USING binary);`
mysql> `SET @s3 = CONVERT(@s2 USING utf8mb4);`
mysql> `SELECT CHARSET(@s1), CHARSET(@s2), CHARSET(@s3);`
+--------------+--------------+--------------+
| CHARSET(@s1) | CHARSET(@s2) | CHARSET(@s3) |
+--------------+--------------+--------------+
| latin1 | binary | utf8mb4 |
+--------------+--------------+--------------+
或者,使用CAST函数生成二进制字符串,其等效于CONVERT(str USING binary):
mysql> `SELECT CHARSET(CAST(_utf8mb4 'my string' AS binary));`
+-----------------------------------------------+
| CHARSET(CAST(_utf8mb4 'my string' AS binary)) |
+-----------------------------------------------+
| binary |
+-----------------------------------------------+
另请参阅 Recipe 7.3 获取有关字符集的更多信息。
7.6 转换字符串的大小写
问题
您想要将一个字符串转换为大写或小写。
解决方案
使用UPPER()或LOWER()函数。如果它们不起作用,您可能正在尝试转换一个二进制字符串。将其转换为具有字符集和排序规则并且受大小写映射影响的非二进制字符串。
讨论
UPPER()和LOWER()函数可以转换字符串的大小写:
mysql> `SELECT thing, UPPER(thing), LOWER(thing) FROM limbs;`
+--------------+--------------+--------------+
| thing | UPPER(thing) | LOWER(thing) |
+--------------+--------------+--------------+
| human | HUMAN | human |
| insect | INSECT | insect |
| squid | SQUID | squid |
| fish | FISH | fish |
| centipede | CENTIPEDE | centipede |
| table | TABLE | table |
| armchair | ARMCHAIR | armchair |
| phonograph | PHONOGRAPH | phonograph |
| tripod | TRIPOD | tripod |
| Peg Leg Pete | PEG LEG PETE | peg leg pete |
| space alien | SPACE ALIEN | space alien |
+--------------+--------------+--------------+
但是有些字符串是顽固的
,抵制大小写转换。要获取可读的输出,请使用选项binary-as-hex=0启动 mysql 客户端。
mysql> `CREATE TABLE t (b VARBINARY(10)) SELECT 'aBcD' AS b;`
mysql> `SELECT b, UPPER(b), LOWER(b) FROM t;`
+------+----------+----------+
| b | UPPER(b) | LOWER(b) |
+------+----------+----------+
| aBcD | aBcD | aBcD |
+------+----------+----------+
这个问题发生在具有BINARY或BLOB数据类型的字符串上。这些是没有字符集或排序规则的二进制字符串。大小写不适用,UPPER()和LOWER()不起作用。
要将二进制字符串映射到给定的大小写,将其转换为非二进制字符串,选择一个具有大写和小写字符的字符集。然后,大小写转换函数将按预期工作,因为排序规则提供了大小写映射:
mysql> `SELECT b,`
-> `UPPER(CONVERT(b USING utf8mb4)) AS upper,`
-> `LOWER(CONVERT(b USING utf8mb4)) AS lower`
-> `FROM t;`
+------+-------+-------+
| b | upper | lower |
+------+-------+-------+
| aBcD | ABCD | abcd |
+------+-------+-------+
例子使用了一个表列,但同样的原则适用于二进制字符串文字和字符串表达式。
如果您不确定字符串表达式是二进制还是非二进制,请使用CHARSET()函数进行确认;参见 Recipe 7.5。
要仅转换字符串的部分大小写,请将其分解为片段,转换相关片段,然后将片段组合在一起。假设您只想将字符串的初始字符转换为大写。以下表达式实现了这一点:
CONCAT(UPPER(LEFT(*`str`*,1)),MID(*`str`*,2))
但每次需要时写出这样的表达式是很麻烦的。为了方便起见,定义一个存储函数:
mysql> `CREATE FUNCTION initial_cap (s VARCHAR(255))`
-> `RETURNS VARCHAR(255) DETERMINISTIC`
-> `RETURN CONCAT(UPPER(LEFT(s,1)),MID(s,2));`
然后您可以更容易地将初始字符大写:
mysql> `SELECT thing, initial_cap(thing) FROM limbs;`
+--------------+--------------------+
| thing | initial_cap(thing) |
+--------------+--------------------+
| human | Human |
| insect | Insect |
| squid | Squid |
| fish | Fish |
| centipede | Centipede |
| table | Table |
| armchair | Armchair |
| phonograph | Phonograph |
| tripod | Tripod |
| Peg Leg Pete | Peg Leg Pete |
| space alien | Space alien |
+--------------+--------------------+
有关编写存储函数的更多信息,请参见第十一章。
7.7 比较字符串值
问题
您想知道字符串是否相等或不等,或者哪个在词法顺序中出现在前面。
解决方案
使用比较运算符。但请记住,字符串具有大小写敏感等属性,您必须考虑这些属性。字符串比较可能是大小写敏感的,这不是您想要的,反之亦然。
与其他数据类型类似,您可以比较字符串的相等性、不等性或相对顺序:
mysql> `SELECT 'cat' = 'cat', 'cat' = 'dog', 'cat' <> 'cat', 'cat' <> 'dog';`
+---------------+---------------+----------------+----------------+
| 'cat' = 'cat' | 'cat' = 'dog' | 'cat' <> 'cat' | 'cat' <> 'dog' |
+---------------+---------------+----------------+----------------+
| 1 | 0 | 0 | 1 |
+---------------+---------------+----------------+----------------+
mysql> `SELECT 'cat' < 'auk', 'cat' < 'dog', 'cat' BETWEEN 'auk' AND 'eel';`
+---------------+---------------+-------------------------------+
| 'cat' < 'auk' | 'cat' < 'dog' | 'cat' BETWEEN 'auk' AND 'eel' |
+---------------+---------------+-------------------------------+
| 0 | 1 | 1 |
+---------------+---------------+-------------------------------+
讨论
然而,字符串的比较和排序属性存在一些其他类型数据不适用的复杂情况。例如,有时您必须确保一个本来不区分大小写的字符串比较是区分大小写的,反之亦然。本节描述了如何做到这一点。
字符串比较的属性取决于操作数是二进制还是非二进制字符串:
-
二进制字符串是字节序列,并使用数字字节值进行比较。大小写没有意义。然而,因为不同大小写的字母具有不同的字节值,因此二进制字符串的比较实际上是大小写敏感的。(即,
a和A是不相等的。)为了比较二进制字符串,使字母大小写无关紧要,请将它们转换为具有不区分大小写排序规则的非二进制字符串。 -
非二进制字符串是字符序列,以字符单位进行比较。(根据字符集,某些字符可能有多个字节。)字符串具有定义法律字符和定义排序顺序的字符集,并由排序规则决定是否在比较中将不同大小写的字符视为相同。如果排序规则是大小写敏感的,并且您希望使用不区分大小写的排序规则(反之亦然),请将字符串转换为使用具有所需大小写比较属性的排序规则。
默认情况下,字符串的字符集为utf8mb4,排序规则为utf8mb4_0900_ai_ci,除非重新配置服务器(参见第 22.1 节)。这导致字符串比较时不区分大小写。
以下示例显示如何处理两个二进制字符串,使它们在比较时相等,即使它们作为不区分大小写的非二进制字符串进行比较:
mysql> `SET @s1 = CAST('cat' AS BINARY), @s2 = CAST('CAT' AS BINARY);`
mysql> `SELECT @s1 = @s2;`
+-----------+
| @s1 = @s2 |
+-----------+
| 0 |
+-----------+
mysql> `SET @s1 = CONVERT(@s1 USING utf8mb4) COLLATE utf8mb4_0900_ai_ci;`
mysql> `SET @s2 = CONVERT(@s2 USING utf8mb4) COLLATE utf8mb4_0900_ai_ci;`
mysql> `SELECT @s1 = @s2;`
+-----------+
| @s1 = @s2 |
+-----------+
| 1 |
+-----------+
在这种情况下,因为utf8mb4的默认排序规则是utf8mb4_0900_ai_ci,你可以省略COLLATE运算符:
mysql> `SET @s1 = CONVERT(@s1 USING utf8mb4);`
mysql> `SET @s2 = CONVERT(@s2 USING utf8mb4);`
mysql> `SELECT @s1 = @s2;`
+-----------+
| @s1 = @s2 |
+-----------+
| 1 |
+-----------+
下一个示例显示了如何以区分大小写的方式比较两个不区分大小写的字符串:
mysql> `SET @s1 = _latin1 'cat', @s2 = _latin1 'CAT';`
mysql> `SELECT @s1 = @s2;`
+-----------+
| @s1 = @s2 |
+-----------+
| 1 |
+-----------+
mysql> `SELECT @s1 COLLATE latin1_general_cs = @s2 COLLATE latin1_general_cs`
-> `AS '@s1 = @s2';`
+-----------+
| @s1 = @s2 |
+-----------+
| 0 |
+-----------+
如果比较二进制字符串和非二进制字符串,则比较将两个操作数视为二进制字符串:
mysql> `SELECT _latin1 'cat' = CAST('CAT' AS BINARY);`
+---------------------------------------+
| _latin1 'cat' = CAST('CAT' AS BINARY) |
+---------------------------------------+
| 0 |
+---------------------------------------+
因此,为了将两个非二进制字符串作为二进制字符串进行比较,请在比较它们时将它们转换为BINARY数据类型中的其中一个:
mysql> `SET @s1 = _latin1 'cat', @s2 = _latin1 'CAT';`
mysql> `SELECT @s1 = @s2, CAST(@s1 AS BINARY) = @s2, @s1 = CAST(@s2 AS BINARY);`
+-----------+---------------------------+---------------------------+
| @s1 = @s2 | CAST(@s1 AS BINARY) = @s2 | @s1 = CAST(@s2 AS BINARY) |
+-----------+---------------------------+---------------------------+
| 1 | 0 | 0 |
+-----------+---------------------------+---------------------------+
如果发现使用不适合通常使用的比较类型声明了列,请使用ALTER TABLE更改类型。假设此表存储新闻文章:
CREATE TABLE news
(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
article BLOB,
PRIMARY KEY (id)
);
此处article列声明为BLOB。这是一种二进制字符串类型,因此列中存储的文本比较不考虑字符集。(实际上,它们是区分大小写的。)如果这不是您想要的,请使用ALTER TABLE将列转换为具有不区分大小写排序规则的非二进制类型:
ALTER TABLE news
MODIFY article TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
7.8 十进制、八进制和十六进制格式之间的转换
问题
您想要在不同的数值基之间进行转换。
解决方案
使用此部分描述的CONV()函数和 SQL 模式。
讨论
在某些格式(如HEX)中,操作文本字符串作为文本字符串很困难。一种替代方法是将它们转换为二进制值。这将产生一个值为 BINARY(16)且长度为 128 位的数据类型。使用BIN()、OCT()、HEX()函数在十进制数和二进制、八进制和十六进制之间进行转换已经是可能的。如果我们要做反向转换呢?这就是CONV()函数发挥作用的地方。使用CONV()函数,我们可以从一个数值基系统转换为另一个数值基系统。
使用CONV()函数的语法:
CONV(number, from_base, to_base)
-
number是我们想要从一种数值基转换到另一种数值基的值。 -
from_base是数字基数的原始基值,限制在 2 到 36 之间的值。 -
to_base是数值基的目标值。此值可以介于 2 到 36 或-2 到-36 之间。
mysql> `SELECT CONV(8, 10, 2) AS DectoBin;`
+----------+
| DectoBin |
+----------+
| 1000 |
+----------+
类似于BIN()函数,我们获得相同的结果。尽管BIN()函数返回一个字符串。
mysql> `SELECT BIN(8);`
+--------+
| BIN(8) |
+--------+
| 1000 |
+--------+
同样,我们可以反向将值之间进行转换:
mysql> `SELECT CONV('F', 16, 10) AS Hex2Dec;`
+---------+
| Hex2Dec |
+---------+
| 15 |
+---------+
7.9 ASCII、BIT 和十六进制格式之间的转换
问题
您想要在一种字符串格式和另一种字符串格式之间进行转换。
解决方案
使用 MySQL 的CHAR()、ASCII()、BIT_LENGTH()函数和此部分描述的 SQL 模式。
讨论
MySQL 提供了许多强大的字符串函数来支持字符串操作。在不同的使用情况下,我们可能需要将它们转换为不同的结果。使用一些字符串函数如ASCII(),我们可以在BIT和HEX之间进行转换。
使用ASCII()函数的语法:
ASCII(character);
注意
ASCII()函数只返回字符串的最左字符的数字值。这类似于 MySQL 的ORD()函数。
mysql> `SELECT ASCII("LARA");`
+---------------+
| ASCII("LARA") |
+---------------+
| 76 |
+---------------+
mysql> `SELECT ASCII("L");`
+---------------+
| ASCII("L") |
+---------------+
| 76 |
+---------------+
如您所见,两个字符串的结果是相同的。该函数只取字符串的左侧字符。
在以下示例中,我们将一个字符串值转换为HEX格式:
mysql> `SELECT DISTINCT CONV(ASCII("LARA"),10,16) as ASCII2HEX;`
+-----------+
| ASCII2HEX |
+-----------+
| 4C |
+-----------+
假设我们有一个名为name的表,并且想要以HEX格式获取此表中所有唯一的last_name:
mysql> `SELECT DISTINCT CONV(ASCII(last_name),10,16) from name;`
+------------------------------+
| CONV(ASCII(last_name),10,16) |
+------------------------------+
| 42 |
| 47 |
| 57 |
+------------------------------+
MySQL 8.0 之前的位操作处理无符号 64 位整数值。MySQL 8.0 之后,位操作扩展到处理二进制字符串参数。这允许非整数或二进制字符串被转换。根据RFC 4122,UUID(通用唯一标识符)是一个全局的 128 位唯一值,当需要完全的唯一性时非常有用。UUID 还可以用于安全目的,因为它不会泄露任何关于数据的信息。它以人类可读的utf8mb4格式表示,由五个十六进制数字符串组成。一个很好的例子是使用UUID_TO_BIN函数将UUID值转换为二进制(我们使用 mysql 并选择了binary-as-hex选项):
mysql> `SELECT UUID();`
+--------------------------------------+
| UUID() |
+--------------------------------------+
| e52e0524-385b-11ec-99b1-054a662275e4 |
+--------------------------------------+
mysql> `SELECT UUID_TO_BIN(UUID());`
+------------------------------------------+
| UUID_TO_BIN(UUID()) |
+------------------------------------------+
| 0xB8D11A66134E11ECB46CC15B8175C680 |
+------------------------------------------+
稍后我们可以将此值转换为使用BIT_COUNT()函数比较的值。此函数主要用于识别给定输入中的活动位。
mysql> `select UUID_TO_BIN(UUID()) into @bin_uuid;`
mysql> `select BIT_COUNT(@bin_uuid);`
+----------------------+
| BIT_COUNT(@bin_uuid) |
+----------------------+
| 57 |
+----------------------+
BIT_COUNT()函数的目的是识别给定十进制值中的活动位。例如,如果我们想要找出数字 18 中的活动位。18 的二进制转换为10010,因此活动位仅有两个。
mysql> `select BIT_COUNT(18);`
+---------------+
| BIT_COUNT(18) |
+---------------+
| 2 |
+---------------+
BIT_COUNT()函数与BIT_OR()函数结合使用可以用来计算以下问题。BIT_OR()函数返回表达式中所有位的按位 OR 运算结果。假设我们想要找出十一月份的星期日数量。我们将创建一个名为sundays的表:
mysql> `CREATE TABLE sundays ( year YEAR, month INT UNSIGNED , day INT UNSIGNED );`
mysql> `INSERT INTO sundays VALUES(2021,11,7), (2021,11,14), (2021,11,21), (2021,11,28);`
mysql> `SELECT year, month, BIT_COUNT(BIT_OR(1 << day)) AS 'Number of Sundays'`
-> `FROM sundays GROUP BY year,month;`
+------+-------+-------------------+
| year | month | Number of Sundays |
+------+-------+-------------------+
| 2021 | 11 | 4 |
+------+-------+-------------------+
这个例子可以扩展到查找给定日历年或日期范围内的假期数量。
另一个用例是 IPv6 和 IPv4 网络地址是字符串值。为了返回表示它的二进制值以便在数值上使用,可以使用INET_ATON()函数。该函数将点分十进制 IPv4 地址字符串表示转换为数值。虽然此函数的用途可能各不相同,但它通常用于存储 IP 地址的来源和目标,主要是用于数据日志记录。一旦 IPv4 地址存储为数值,就可以更快地进行索引和处理。
mysql> `SELECT INET_ATON('10.0.2.1');`
+-----------------------+
| INET_ATON('10.0.2.1') |
+-----------------------+
| 167772673 |
+-----------------------+
mysql> `SELECT HEX(INET6_ATON('10.0.2.1'));`
+-----------------------------+
| HEX(INET6_ATON('10.0.2.1')) |
+-----------------------------+
| 0A000201 |
+-----------------------------+
7.10 使用 SQL 模式进行模式匹配
问题
您希望执行模式匹配,而不是字面比较。
解决方案
使用LIKE运算符和 SQL 模式在本节中描述。或者使用正则表达式模式匹配,在 Recipe 7.11 中描述。
讨论
模式是包含特殊字符的字符串,称为元字符,因为它们代表的是其他东西而不是它们自己。MySQL 提供两种模式匹配方法。一种基于 SQL 模式,另一种基于正则表达式。SQL 模式在不同数据库系统中更为标准,但正则表达式更为强大。这节描述了 SQL 模式。Recipe 7.11 描述了正则表达式。
此处的示例使用名为metal的表,其中包含以下行:
+----------+
| name |
+----------+
| gold |
| iron |
| lead |
| mercury |
| platinum |
| tin |
+----------+
SQL 模式匹配使用LIKE和NOT LIKE操作符,而不是=和<>来对模式字符串执行匹配。模式可以包含两个特殊元字符:_匹配任何单个字符,%匹配任何字符序列,包括空字符串。您可以使用这些字符来创建匹配各种值的模式:
-
以特定子字符串开头的字符串:
mysql> `SELECT name FROM metal WHERE name LIKE 'me%';` +---------+ | name | +---------+ | mercury | +---------+ -
以特定子字符串结尾的字符串:
mysql> `SELECT name FROM metal WHERE name LIKE '%d';` +------+ | name | +------+ | gold | | lead | +------+ -
包含特定子字符串的字符串:
mysql> `SELECT name FROM metal WHERE name LIKE '%in%';` +----------+ | name | +----------+ | platinum | | tin | +----------+ -
包含特定位置子字符串的字符串(只有在
name列的第三个位置出现at时,模式才匹配成功):mysql> `SELECT name FROM metal where name LIKE '__at%';` +----------+ | name | +----------+ | platinum | +----------+
只有当 SQL 模式完全匹配比较值时才能成功匹配。在以下两个模式匹配中,只有第二个成功:
'abc' LIKE 'b'
'abc' LIKE '%b%'
要反转模式匹配的意义,请使用NOT LIKE。以下语句找到不包含i字符的字符串:
mysql> `SELECT name FROM metal WHERE name NOT LIKE '%i%';`
+---------+
| name |
+---------+
| gold |
| lead |
| mercury |
+---------+
SQL 模式不匹配NULL值。这对于LIKE和NOT LIKE操作都是真的:
mysql> `SELECT NULL LIKE '%', NULL NOT LIKE '%';`
+---------------+-------------------+
| NULL LIKE '%' | NULL NOT LIKE '%' |
+---------------+-------------------+
| NULL | NULL |
+---------------+-------------------+
在某些情况下,模式匹配相当于子字符串比较。例如,使用模式在字符串的一端或另一端找到字符串,就像使用LEFT()或RIGHT()函数一样,如 Table 7-2 所示:
表 7-2. 模式匹配与子字符串比较
| 模式匹配 | 子字符串比较 |
|---|---|
str LIKE 'abc%' |
LEFT(str,3) = 'abc' |
str LIKE '%abc' |
RIGHT(str,3) = 'abc' |
如果您要对一个索引列进行匹配,并且可以选择使用模式或等效的LEFT()表达式,您可能会发现模式匹配速度更快。MySQL 可以使用索引来缩小搜索范围,以寻找以字面字符串开头的模式。而使用LEFT()则不能。此外,由于优化器需要检查字符串的整个内容,因此以%开头的LIKE比较可能会很慢。
模式匹配的大小写敏感性与字符串比较类似。也就是说,它取决于操作数是二进制还是非二进制字符串,对于非二进制字符串,还取决于它们的排序规则。详细讨论请参见 Recipe 7.7。
7.11 使用正则表达式进行模式匹配
问题
您希望执行模式匹配,而不是字面上的比较。
解决方案
使用REGEXP运算符和本节描述的正则表达式模式。或者使用在 Recipe 7.10 中描述的 SQL 模式。
讨论
SQL 模式(参见 Recipe 7.10)可能由其他数据库系统实现,因此除了 MySQL 外,它们也具有一定的可移植性。然而,它们的功能有所限制。例如,您可以轻松地编写一个 SQL 模式 %abc% 来查找包含 abc 的字符串,但是您无法编写一个单独的 SQL 模式来识别包含字符 a、b 或 c 中任何一个的字符串。也不能基于字符类型(如字母或数字)匹配字符串内容。对于这些操作,MySQL 支持基于正则表达式和 REGEXP 运算符(或 NOT REGEXP 反转匹配的意义)的另一种模式匹配操作。REGEXP 匹配使用表 7-4 中显示的模式元素:
表 7-4. 常用正则表达式语法
| 模式 | 模式匹配的内容 |
|---|---|
^ |
字符串开始位置 |
| ` | 模式 |
| --- | --- |
^ |
字符串开始位置 |
| 字符串结束位置 | |
. |
任意单个字符 |
[...] |
方括号内列出的任何字符 |
[^...] |
方括号内未列出的任何字符 |
p1|p2|p3 |
替换;匹配模式 p1, p2, 或 p3 中的任何一个 |
* |
前一元素的零个或多个实例 |
+ |
前一元素的一个或多个实例 |
{n} |
前一元素的 n 次实例 |
{m,n} |
前一元素的 m 到 n 次实例 |
您可能已经熟悉这些正则表达式模式字符;其中许多与 vi、grep、sed 和其他支持正则表达式的 Unix 实用工具使用的字符相同。大多数这些字符也用于编程语言理解的正则表达式中。(有关数据验证和转换程序中的模式匹配讨论,请参阅 Chapter 14。)
Recipe 7.10 显示如何使用 SQL 模式来匹配字符串的开头或结尾处的子字符串,或者在字符串的任意或特定位置。您可以使用正则表达式执行相同的操作:
-
以特定子字符串开头的字符串:
mysql> `SELECT name FROM metal WHERE name REGEXP '^me';` +---------+ | name | +---------+ | mercury | +---------+ -
以特定子字符串结尾的字符串:
mysql> `SELECT name FROM metal WHERE name REGEXP 'd$';` +------+ | name | +------+ | gold | | lead | +------+ -
在任何位置包含特定子字符串的字符串:
mysql> `SELECT name FROM metal WHERE name REGEXP 'in';` +----------+ | name | +----------+ | platinum | | tin | +----------+ -
在特定位置包含特定子字符串的字符串:
mysql> `SELECT name FROM metal WHERE name REGEXP '^..at';` +----------+ | name | +----------+ | platinum | +----------+
此外,正则表达式还具有其他功能,并且可以执行 SQL 模式无法完成的匹配。例如,正则表达式可以包含字符类,该类可以匹配类中的任何字符:
-
要编写字符类,请使用方括号并在括号内列出要匹配的字符。因此,模式
[abc]可以匹配a、b或c。 -
类可以指示字符范围;使用破折号表示范围的起始和结束。
[a-z]匹配任何字母,[0-9]匹配数字,[a-z0-9]匹配字母或数字。 -
要否定一个字符类(
匹配除了这些字符之外的任何字符
),请以^字符开始列表。例如,[⁰-9]匹配除了数字以外的任何内容。
MySQL 的正则表达式功能也支持 POSIX 字符类。这些类匹配特定的字符集,如 表 7-5 中所述:
表 7-5. POSIX 正则表达式语法
| POSIX 类 | 类匹配的内容 |
|---|---|
[:alnum:] |
字母和数字字符 |
[:alpha:] |
字母字符 |
[:blank:] |
空白字符(空格或制表符) |
[:cntrl:] |
控制字符 |
[:digit:] |
数字 |
[:graph:] |
图形(非空白)字符 |
[:lower:] |
小写字母字符 |
[:print:] |
图形或空格字符 |
[:punct:] |
标点字符 |
[:space:] |
空格、制表符、换行符、回车符 |
[:upper:] |
大写字母字符 |
[:xdigit:] |
十六进制数字 (0-9, a-f, A-F) |
POSIX 类用于字符类中,因此请在方括号内使用它们。以下表达式匹配包含任何十六进制数字字符的值:
mysql> `SELECT name, name REGEXP '[[:xdigit:]]' FROM metal;`
+----------+----------------------------+
| name | name REGEXP '[[:xdigit:]]' |
+----------+----------------------------+
| gold | 1 |
| iron | 0 |
| lead | 1 |
| mercury | 1 |
| platinum | 1 |
| tin | 0 |
+----------+----------------------------+
正则表达式可以使用以下语法指定选择项:
*`alternative1`*|*`alternative2`*|...
选择项类似于字符类,因为如果任何选择项匹配,则选择项将匹配。但与字符类不同,选择项不限于单个字符。它们可以是多字符字符串甚至是模式。以下选择项匹配以元音字母开头或以 d 结尾的字符串:
mysql> `SELECT name FROM metal WHERE name REGEXP '^[aeiou]|d$';`
+------+
| name |
+------+
| gold |
| iron |
| lead |
+------+
括号可用于分组选择项。例如,要匹配完全由数字或完全由字母组成的字符串,您可以尝试以下模式,使用一个选择项:
mysql> `SELECT '0m' REGEXP '^[[:digit:]]+|[[:alpha:]]+$';`
+-------------------------------------------+
| '0m' REGEXP '^[[:digit:]]+|[[:alpha:]]+$' |
+-------------------------------------------+
| 1 |
+-------------------------------------------+
然而,查询结果显示,该模式不起作用。这是因为 ^ 与第一个选择项分组,而 $ 与第二个选择项分组。因此,该模式实际上匹配以一个或多个数字开头的字符串,或以一个或多个字母结尾的字符串。如果在括号中分组选择项,则 ^ 和 $ 将适用于它们两个,并且模式将按预期行事:
mysql> `SELECT '0m' REGEXP '^([[:digit:]]+|[[:alpha:]]+)$';`
+---------------------------------------------+
| '0m' REGEXP '^([[:digit:]]+|[[:alpha:]]+)$' |
+---------------------------------------------+
| 0 |
+---------------------------------------------+
与 SQL 模式匹配不同,正则表达式在值的任意位置成功匹配模式即可,而不必匹配整个比较值。以下两个模式匹配在某种意义上等效,即每个模式仅成功匹配包含字符 b 的字符串,但第一个模式更有效率,因为模式更简单:
'abc' REGEXP 'b'
'abc' REGEXP '^.*b.*$'
正则表达式不匹配 NULL 值。对于 REGEXP 和 NOT REGEXP 都是如此:
mysql> `SELECT NULL REGEXP '.*', NULL NOT REGEXP '.*';`
+------------------+----------------------+
| NULL REGEXP '.*' | NULL NOT REGEXP '.*' |
+------------------+----------------------+
| NULL | NULL |
+------------------+----------------------+
因为正则表达式如果在字符串中找到模式则匹配该字符串,所以必须小心不要无意中指定一个匹配空字符串的模式。如果这样做,它会匹配任何非NULL值。例如,模式a*匹配任意数量的a字符,甚至是零个。如果您的目标是仅匹配包含非空a字符序列的字符串,请改用a+。+要求前面的模式元素至少出现一次才能匹配。
类似于使用LIKE执行的 SQL 模式匹配,使用REGEXP执行的正则表达式匹配有时等同于子字符串比较。如在表 7-6 中所示,^ 和 $ 元字符在寻找字面字符串时具有类似于 LEFT() 或 RIGHT() 的作用:
表 7-6. 正则表达式与子字符串比较函数
| 模式匹配 | 子字符串比较 |
|---|---|
str REGEXP '^abc' |
LEFT(str,3) = 'abc' |
str REGEXP 'abc |
RIGHT(str,3) = 'abc' |
对于非字面模式,通常不可能构造一个等效的子字符串比较。例如,要匹配以任意非空数字序列开头的字符串,请使用以下模式匹配:
*`str`* REGEXP '^[0-9]+'
这是LEFT()不能做到的(事实上,LIKE也不能做到)。
正则表达式匹配的大小写敏感性与字符串比较类似。也就是说,它取决于操作数是二进制还是非二进制字符串,对于非二进制字符串,还取决于它们的排序规则。参见 7.7 节讨论这些因素如何适用于比较。
注意
在 8.0.4 版本之前,正则表达式仅适用于单字节字符集。在 MySQL 8.0.4 中,这个限制被移除,现在您可以在诸如utf8mb4或sjis之类的多字节字符集中使用正则表达式。
7.12 反转字符串内容
问题
您想修改一个字符串,并找出它的反向形式。
解决方案
使用REVERSE()函数。
讨论
您可以通过使用REVERSE()函数来反转字符串或子字符串。该函数通过字符将任何字符串值转换为其反向形式。它经常在像本章其他许多函数一样的SELECT语句中使用。
使用REVERSE()函数的语法:
REVERSE(expression)
以下示例展示了REVERSE()函数的基本功能:
mysql> `SELECT REVERSE("sports flow");`
+------------------------+
| REVERSE("sports flow") |
+------------------------+
| wolf strops |
+------------------------+
mysql> `SELECT REVERSE(0123456789);`
+---------------------+
| REVERSE(0123456789) |
+---------------------+
| 987654321 |
+---------------------+
mysql> `SELECT REVERSE("0123456789");`
+-----------------------+
| REVERSE("0123456789") |
+-----------------------+
| 9876543210 |
+-----------------------+
下面的示例显示了当表达式为numeric值时,函数会省略零值。
mysql> `SELECT REVERSE(001122334455);`
+-----------------------+
| REVERSE(001122334455) |
+-----------------------+
| 5544332211 |
+-----------------------+
尽管我们可以反转任何表达式,但我们也有一些单词,它们反过来写时与原始单词相同,被称为回文。对于这样的字符串,函数REVERSE将返回与原始字符串相等的字符串。例如:
mysql> `SELECT REVERSE("STEP ON NO PETS");`
+----------------------------+
| REVERSE("STEP ON NO PETS") |
+----------------------------+
| STEP ON NO PETS |
+----------------------------+
更广泛的示例使用recipes发行版中的top_names表,该表存储了最常用的姓名。在这些姓名中,我们将找出回文姓名的数量。
mysql> `SELECT COUNT(*) FROM top_names WHERE REVERSE(top_name) = top_name;`
+----------+
| COUNT(*) |
+----------+
| 234 |
+----------+
仅仅为了从这个计数中获取一个样本,我们可以查看以“U”开头的名称。
mysql> `SELECT top_name FROM top_names`
-> `WHERE REVERSE(top_name) = top_name`
-> `AND top_name LIKE "U%";`
+----------+
| top_name |
+----------+
| ULU |
| UTU |
+----------+
7.13 搜索子字符串
问题
你想知道给定的字符串是否出现在另一个字符串中。
解决方案
使用 LOCATE() 或模式匹配。
讨论
LOCATE() 函数接受两个参数,分别表示要查找的子字符串和要在其中查找的字符串。返回值是子字符串出现的位置,如果不存在则返回 0。可选的第三个参数可以指定开始查找的位置。
mysql> `SELECT name, LOCATE('in',name), LOCATE('in',name,3) FROM metal;`
+----------+-------------------+---------------------+
| name | LOCATE('in',name) | LOCATE('in',name,3) |
+----------+-------------------+---------------------+
| gold | 0 | 0 |
| iron | 0 | 0 |
| lead | 0 | 0 |
| mercury | 0 | 0 |
| platinum | 5 | 5 |
| tin | 2 | 0 |
+----------+-------------------+---------------------+
如果只需确定子字符串是否存在且不关心其位置,则可以使用 LIKE 或 REGEXP 的替代方法:
mysql> `SELECT name, name LIKE '%in%', name REGEXP 'in' FROM metal;`
+----------+------------------+------------------+
| name | name LIKE '%in%' | name REGEXP 'in' |
+----------+------------------+------------------+
| gold | 0 | 0 |
| iron | 0 | 0 |
| lead | 0 | 0 |
| mercury | 0 | 0 |
| platinum | 1 | 1 |
| tin | 1 | 1 |
+----------+------------------+------------------+
LOCATE()、LIKE 和 REGEXP 使用它们参数的排序规则来决定搜索是否区分大小写。7.5 节 和 7.7 节 讨论了如果需要改变搜索行为,则可以更改参数比较属性。
7.14 分解或组合字符串
问题
你想要从字符串中提取一部分或者组合字符串以形成更大的字符串。
解决方案
要获取字符串的一部分,请使用子字符串提取函数。要组合字符串,请使用 CONCAT()。
讨论
你可以使用适当的子字符串提取函数来分解字符串。例如,LEFT()、MID() 和 RIGHT() 函数可以从字符串的左侧、中间或右侧提取子字符串:
mysql> `SET @date = '2015-07-21';`
mysql> `SELECT @date, LEFT(@date,4) AS year,`
-> `MID(@date,6,2) AS month, RIGHT(@date,2) AS day;`
+------------+------+-------+------+
| @date | year | month | day |
+------------+------+-------+------+
| 2015-07-21 | 2015 | 07 | 21 |
+------------+------+-------+------+
对于 LEFT() 和 RIGHT(),第二个参数指示从字符串左侧或右侧返回多少个字符。对于 MID(),第二个参数是你想要的子字符串的起始位置(从 1 开始),第三个参数表示要返回的字符数。
SUBSTRING() 函数接受一个字符串和一个起始位置,并返回该位置右侧的所有内容。如果省略 MID() 的第三个参数,它的行为与 SUBSTRING() 相同,因为 MID() 实际上是 SUBSTRING() 的同义词:
mysql> `SET @date = '2015-07-21';`
mysql> `SELECT @date, SUBSTRING(@date,6), MID(@date,6);`
+------------+--------------------+--------------+
| @date | SUBSTRING(@date,6) | MID(@date,6) |
+------------+--------------------+--------------+
| 2015-07-21 | 07-21 | 07-21 |
+------------+--------------------+--------------+
使用 SUBSTRING_INDEX(str,c,n) 来返回给定字符的右侧或左侧的所有内容。它会在字符串 str 中搜索第 n 次出现的字符 c,并返回其左侧的所有内容。如果 n 是负数,则从右侧开始搜索字符 c 并返回其右侧的所有内容:
mysql> `SET @email = 'postmaster@example.com';`
mysql> `SELECT @email,`
-> `SUBSTRING_INDEX(@email,'@',1) AS user,`
-> `SUBSTRING_INDEX(@email,'@',-1) AS host;`
+------------------------+------------+-------------+
| @email | user | host |
+------------------------+------------+-------------+
| postmaster@example.com | postmaster | example.com |
+------------------------+------------+-------------+
如果没有字符的第 n 次出现,SUBSTRING_INDEX() 返回整个字符串。SUBSTRING_INDEX() 区分大小写。
你可以使用子字符串来执行除了显示之外的其他目的,比如执行比较。以下语句查找第一个字母位于字母表后半部分的金属名称:
mysql> `SELECT name from metal WHERE LEFT(name,1) >= 'n';`
+----------+
| name |
+----------+
| platinum |
| tin |
+----------+
要组合而不是分解字符串,请使用 CONCAT() 函数。它连接其参数并返回结果:
mysql> `SELECT CONCAT(name,' ends in "d": ',IF(name LIKE '%d','YES','NO'))`
-> `AS 'ends in "d"?'`
-> `FROM metal;`
+--------------------------+
| ends in "d"? |
+--------------------------+
| gold ends in "d": YES |
| iron ends in "d": NO |
| lead ends in "d": YES |
| mercury ends in "d": NO |
| platinum ends in "d": NO |
| tin ends in "d": NO |
+--------------------------+
连接对于在原地修改列值 非常有用。
例如,以下UPDATE语句将字符串添加到metal表中每个name值的末尾:
mysql> `UPDATE metal SET name = CONCAT(name,'ide');`
mysql> `SELECT name FROM metal;`
+-------------+
| name |
+-------------+
| goldide |
| ironide |
| leadide |
| mercuryide |
| platinumide |
| tinide |
+-------------+
要撤销操作,请去掉最后三个字符(CHAR_LENGTH()函数返回字符串的字符长度):
mysql> `UPDATE metal SET name = LEFT(name,CHAR_LENGTH(name)-3);`
mysql> `SELECT name FROM metal;`
+----------+
| name |
+----------+
| gold |
| iron |
| lead |
| mercury |
| platinum |
| tin |
+----------+
对于ENUM或SET值,可以直接在原地修改列,尽管它们在内部存储为数字,但通常可以视为字符串值。例如,要将SET元素连接到现有的SET列中,使用CONCAT()将新值添加到现有值之前,并注意考虑现有值可能为NULL的情况。在这种情况下,将列值设置为新元素,而不带前导逗号:
UPDATE *`tbl_name`*
SET *`set_col`* = IF(*`set_col`* IS NULL,*`val`*,CONCAT(*`set_col`*,',',*`val`*));
7.15 使用全文搜索
问题
你想要搜索长文本列。
解决方案
使用FULLTEXT索引。
讨论
模式匹配使您能够查看任意数量的行,但随着文本量的增加,匹配操作可能会变得非常慢。在几个字符串列中搜索相同文本是常见任务,但使用模式匹配时,这会导致查询变得笨重:
SELECT * from *`tbl_name`*
WHERE *`col1`* LIKE '*`pat%`*' OR *`col2`* LIKE '*`pat%`*' OR *`col3`* LIKE '*`pat%`*' ...
一个有用的替代方案是全文搜索,专为搜索大量文本而设计,并可以同时搜索多个列。要使用此功能,需要在表上添加FULLTEXT索引,然后使用MATCH操作符在索引列或列中查找字符串。FULLTEXT索引可以用于 MyISAM 表或 InnoDB 表的非二进制字符串数据类型(CHAR、VARCHAR或TEXT)。
全文搜索最好用一个相当大的文本体来说明。如果没有样本数据集,可以在互联网上找到几个可自由获取的电子文本存储库。对于这里的示例,我们选择的是 Amazon 评论数据(2018 年)的样本转储,公众可以从中下载和抓取Amazon Review Data (2018). 由于其规模,这个数据集没有包含在recipes发行版中,但可以在 GitHub 仓库的说明中单独获取。Amazon分发包括一个名为Appliances_5.json的文件,其中包含每个类别的产品评论。这是更大数据集的子集。由于大多数基于文本的数据都可以在互联网上找到,因此这些数据仅以JSON数据格式提供。一些样本记录看起来像这样:
{
"overall": 2.0,
"verified": false,
"reviewTime": "07 6, 2017",
"reviewerID": "A3LGZ8M29PBNGG",
"asin": "B000N6302Q",
"style": {"Color:": " Stainless Steel"},
"reviewerName": "nerenttt",
"reviewText": "Luved it for the few months it worked!↩
great little diamond ice cubes...",
"unixReviewTime": 1499299200
}
我们感兴趣的是reviewText字段,它包含了我们想要检查的大量文本。
每个记录包含以下字段:
-
overall- 产品的评分 -
verified- 购买验证标志 -
reviewTime- 评论日期 -
reviewerID- 评论者的 ID(O或N),例如 A2SUAM1J3GNN3B -
asin- 产品的 ID,例如 0000013714 -
style- 产品元数据的自由选项 -
reviewerName评论者姓名。 -
评论的
reviewText文本。 -
unixReviewTime评论时间(Unix 时间)。
要将记录导入到 MySQL 中,请创建名为 reviews 的表,如下所示:
CREATE TABLE `reviews` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`appliences_review` JSON NOT NULL,
PRIMARY KEY (`id`)
);
为了将 json 数据加载到此表中,我们可以使用 MySQL 内置的 json 函数,稍后将在 Recipe 13.17 中介绍。对于包含大量文本数据的某些情况,可能包含换行符 /n/n 等转义字符,这会导致导入函数无法正常工作。为了解决这个问题,我们将使用提供在 GitHub 仓库中的名为 load_amazon_reviews.py 的简单脚本来加载数据。
加载数据后,我们将将 reviewText 列转换为生成列,并添加 FULLTEXT 索引以启用其在全文搜索中的使用。
ALTER TABLE `reviews` ADD COLUMN `reviews_virtual` TEXT
GENERATED ALWAYS AS (`appliences_review` ->> '$.reviewText') STORED NOT NULL;
ALTER TABLE `reviews` ADD FULLTEXT idx_ft_json(reviews_virtual);
该表现在具有 FULLTEXT 索引以启用其在全文搜索中的使用。
创建 reviews 表后,请使用以下语句将 Appliances_5.json 文件加载到其中:
`python3 load_amazon_reviews.py Appliances_5.json`
您会注意到 reviews 表包含完整的 appliences_review 数据列以及用于演示 FULLTEXT 索引的 reviews_virtual 列。
CREATE TABLE `reviews` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`appliences_review` json NOT NULL,
`reviews_virtual` text GENERATED ALWAYS AS
(json_unquote(json_extract(`appliences_review`,_utf8mb4'$.reviewText')))
STORED NOT NULL,
PRIMARY KEY (`id`),
FULLTEXT KEY `idx_ft_json` (`reviews_virtual`)
) ENGINE=InnoDB AUTO_INCREMENT=2278 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
要使用 FULLTEXT 索引进行搜索,使用 MATCH() 命名索引列和 AGAINST() 指定要查找的文本。例如,您可能想知道:“‘awesome’ 这个词出现了多少次?” 要回答这个问题,请使用以下语句搜索 reviews_virtual 列:
mysql> `SELECT COUNT(*) from reviews WHERE MATCH(reviews_virtual) AGAINST('awesome');`
+----------+
| COUNT(*) |
+----------+
| 3 |
+----------+
验证 FULLTEXT 索引是否被使用。
mysql> `EXPLAIN select reviews_virtual from reviews WHERE MATCH(reviews_virtual) -> AGAINST('awesome') \G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: reviews
partitions: NULL
type: fulltext
possible_keys: idx_ft_json
key: idx_ft_json
key_len: 0
ref: const
rows: 1
filtered: 100.00
Extra: Using where; Ft_hints: sorted
要查找在 reviews 中含有关键字 “excellent” 的产品,选择您想要查看的列(这里的示例截断了 reviews_virtual 列并使用 \G 使结果更适合页面):
mysql> `SELECT JSON_EXTRACT(appliences_review, "$.reviewerID") as ReviwerID,`
-> `JSON_EXTRACT(appliences_review, "$.asin") as ProductID,`
-> `JSON_EXTRACT(appliences_review, "$.overall") as Rating`
-> `from reviews WHERE MATCH(reviews_virtual) AGAINST('excellent') \G`
*************************** 1\. row ***************************
ReviwerID: "A2CIEGHZ7L1WWR"
ProductID: "B00009W3PA"
Rating: 5.0
*************************** 2\. row ***************************
ReviwerID: "A1T1YSCDW0PD25"
ProductID: "B0013DN4NI"
Rating: 5.0
*************************** 3\. row ***************************
ReviwerID: "A1T1YSCDW0PD25"
ProductID: "B0013DN4NI"
Rating: 5.0
*************************** 4\. row ***************************
ReviwerID: "A26M3TN8QICJ3K"
ProductID: "B004XLDE5A"
Rating: 5.0
*************************** 5\. row ***************************
ReviwerID: "A2CIEGHZ7L1WWR"
ProductID: "B004XLDHSE"
Rating: 5.0
默认情况下,全文搜索计算相关性排序并将其用于排序。为了确保搜索结果按您希望的方式排序,请添加显式的 ORDER BY 子句:
SELECT reviews_virtual
FROM reviews WHERE MATCH(reviews_virtual) AGAINST('*`search string`*')
ORDER BY {column}, {column};
要查看相关性排序,请在输出列列表中重复使用 MATCH() … AGAINST() 表达式。
要进一步缩小搜索范围,请包含额外的条件。为了在搜索中提供额外字段,我们将从 json 提取中添加以下虚拟列。
ALTER TABLE `reviews`
-> ADD COLUMN `reviews_virtual_vote` VARCHAR(10)
-> GENERATED ALWAYS AS (`appliences_review` ->> '$.vote') STORED;
ALTER TABLE `reviews`
-> ADD COLUMN `reviews_virtual_overall` VARCHAR(10)
-> GENERATED ALWAYS AS (`appliences_review` ->> '$.overall') STORED;
ALTER TABLE `reviews`
-> ADD COLUMN `reviews_virtual_verified` VARCHAR(10)
-> GENERATED ALWAYS AS (`appliences_review` ->> '$.verified') STORED;
以下查询逐步执行更具体的搜索,以确定每个关键字的出现频率。
mysql> `SELECT count(*) from reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('good');`
+----------+
| COUNT(*) |
+----------+
| 855 |
+----------+
mysql> `SELECT count(*) from reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('good')`
-> `AND reviews_virtual_vote > 5;`
+----------+
| COUNT(*) |
+----------+
| 620 |
+----------+
mysql> `SELECT COUNT(*) from reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('good')`
-> `AND reviews_virtual_overall = 5;`
+----------+
| COUNT(*) |
+----------+
| 646 |
+----------+
mysql> `SELECT COUNT(*) from reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('good')`
-> `AND reviews_virtual_overall = 5 AND reviews_virtual_verified = "True";`
+----------+
| COUNT(*) |
+----------+
| 645 |
+----------+
如果您经常使用包含其他非 FULLTEXT 列的搜索条件,请对这些列添加常规索引,以便查询性能更好。例如,要为投票、整体评分和已验证列创建索引,请执行以下操作:
mysql> `ALTER TABLE reviews ADD INDEX idx_vote (reviews_virtual_vote), -> ADD INDEX idx_overall(reviews_virtual_overall), -> ADD INDEX idx_verified(reviews_virtual_verified);`
全文查询中的搜索字符串可以包含多个词,您可能认为添加词会使搜索更具体。但实际上,这会扩大搜索范围,因为全文搜索返回包含任何词的行。事实上,查询对任何词执行 OR 搜索。以下查询说明了这一点;随着添加更多搜索词,它们标识出越来越多的评论:
mysql> `SELECT COUNT(*) from reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('excellent');`
+----------+
| COUNT(*) |
+----------+
| 11 |
+----------+
mysql> `SELECT COUNT(*) from reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('excellent product');`
+----------+
| COUNT(*) |
+----------+
| 1480 |
+----------+
mysql> `SELECT COUNT(*) from reviews`
-> `WHERE MATCH(reviews_text) AGAINST('excellent product for home');`
+----------+
| COUNT(*) |
+----------+
| 1486 |
+----------+
要执行搜索,搜索字符串中的每个单词必须存在,请参见 Recipe 7.17。
要同时搜索多个列,请在构建FULLTEXT索引时命名所有列:
ALTER TABLE *`tbl_name`* ADD FULLTEXT (*`col1`*, *`col2`*, *`col3`*);
要发出使用索引的搜索查询,请在MATCH()列表中命名相同的列:
SELECT ... FROM *`tbl_name`*
WHERE MATCH(*`col1`*, *`col2`*, *`col3`*) AGAINST('*`search string`*');
您需要为要搜索的每个不同列组合创建一个FULLTEXT索引。
参见
获取有关FULLTEXT索引的进一步信息,请参阅 Recipe 21.9。
7.16 使用全文搜索查找短词
问题
您对短词的全文搜索未返回任何行。
解决方案
更改索引引擎的最小词长参数。
讨论
在像reviews这样的文本中,某些单词具有特殊意义,如ok
和up
。您可能需要首先检查全文索引服务器变量,以确保引擎满足最小长度要求。
mysql> `SHOW GLOBAL VARIABLES LIKE 'innodb_ft_%';`
+---------------------------------+------------+
| Variable_name | Value |
+---------------------------------+------------+
| innodb_ft_aux_table | |
| innodb_ft_cache_size | 8000000 |
| innodb_ft_enable_diag_print | OFF |
| innodb_ft_enable_stopword | ON |
| innodb_ft_max_token_size | 84 |
| innodb_ft_min_token_size | 3 |
| innodb_ft_num_word_optimize | 2000 |
| innodb_ft_result_cache_limit | 2000000000 |
| innodb_ft_server_stopword_table | |
| innodb_ft_sort_pll_degree | 2 |
| innodb_ft_total_cache_size | 640000000 |
| innodb_ft_user_stopword_table | |
+---------------------------------+------------+
mysql> `SELECT count(*) FROM reviews WHERE MATCH(reviews_virtual) AGAINST('ok');`
+----------+
| count(*) |
+----------+
| 0 |
+----------+
`SELECT count(*) FROM reviews WHERE MATCH(reviews_virtual) AGAINST('up'); +----------+ | count(*) | +----------+ | 0 | +----------+`
索引引擎的一个特性是忽略太常见
的单词(即在超过半数行中出现的单词)。这会消除诸如the
或and
之类的词,但这里并非如此。您可以通过计算总行数并使用 SQL 模式匹配来计算包含每个单词的行数来验证(参见 Recipe 10.1 关于使用COUNT()从相同值集合生成多个计数):
mysql> `SELECT COUNT(*) AS Total_Reviews,`
-> `COUNT(IF(reviews_virtual LIKE '%good%',1,NULL)) AS Good_Reviews,`
-> `COUNT(IF(reviews_virtual LIKE '%great%',1,NULL)) AS Great_Reviews,`
-> `COUNT(IF(reviews_virtual LIKE '%excellent%',1,NULL)) AS Excellent_Reviews`
-> `FROM reviews;`
+---------------+--------------+---------------+-------------------+
| Total_Reviews | Good_Reviews | Great_Reviews | Excellent_Reviews |
+---------------+--------------+---------------+-------------------+
| 2277 | 855 | 1095 | 11 |
+---------------+--------------+---------------+-------------------+
InnoDB 全文索引引擎不包括少于三个字符长的单词。最小词长是一个可配置参数;要更改它,请设置 MyISAM 的ft_min_word_len或 InnoDB 存储引擎的innodb_ft_min_token_size系统变量。例如,要告诉索引引擎包括三个字符长的单词,可以在/etc/my.cnf文件的[mysqld]组中添加一行(或者您用于服务器设置的任何选项文件):
[mysqld]
ft_min_word_len=2 ##MyISAM
innodb_ft_min_token_size=2 ##InnoDB
改变这个设置后,重新启动服务器。接下来,重建FULLTEXT索引以利用显示新设置,首先设置innodb_optimize_fulltext_only参数并运行OPTIMIZE操作。
mysql> `SET GLOBAL innodb_optimize_fulltext_only=ON;`
mysql> `OPTIMIZE TABLE reviews;`
对于 MyISAM,此外运行REPAIR TABLE命令:
mysql> `REPAIR TABLE reviews QUICK;`
您还应使用REPAIR TABLE来重建所有其他具有FULLTEXT索引的 MyISAM 表的索引。
最后,尝试新索引以验证它是否包括较短的单词:
mysql> `SELECT count(*) from reviews WHERE MATCH(reviews_virtual) AGAINST('ok');`
+----------+
| count(*) |
+----------+
| 10 |
+----------+
mysql> `SELECT count(*) from reviews WHERE MATCH(reviews_virtual) AGAINST('up');`
+----------+
| COUNT(*) |
+----------+
| 1449 |
+----------+
7.17 要求或禁止全文搜索词
问题
您想要在全文搜索中要求或禁止特定单词。
解决方案
使用布尔模式搜索。
讨论
通常,全文搜索返回包含搜索字符串中任何单词的行,即使其中一些单词缺失也是如此。例如,以下语句查找包含单词good
或great
的行:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('good great');`
+----------+
| COUNT(*) |
+----------+
| 1330 |
+----------+
如果要求只包含同时包含这两个单词的行,则此行为是不合适的。做法之一是重写语句,查找每个单词并使用AND连接条件:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('good')`
-> `AND MATCH(reviews_virtual) AGAINST('great');`
+----------+
| COUNT(*) |
+----------+
| 620 |
+----------+
通过布尔模式搜索是要求多个单词的更简单方法。为此,在搜索字符串中的每个单词前面加上+字符,并在字符串后添加IN BOOLEAN MODE:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('+good +great' IN BOOLEAN MODE);`
+----------+
| COUNT(*) |
+----------+
| 620 |
+----------+
布尔模式搜索还允许您通过在每个词前面加上-字符来排除词。以下查询选择包含名称为good
但不包含great
的reviews行,反之亦然:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('+good -great' IN BOOLEAN MODE);`
+----------+
| COUNT(*) |
+----------+
| 235 |
+----------+
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('-good +great' IN BOOLEAN MODE);`
+----------+
| COUNT(*) |
+----------+
| 475 |
+----------+
在布尔搜索中,另一个有用的特殊字符是*;当附加到搜索词后面时,它作为通配符操作符。以下语句查找包含不仅use,还包括诸如user、useful和useless等单词的行:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('use*' IN BOOLEAN MODE);`
+----------+
| COUNT(*) |
+----------+
| 1475 |
+----------+
关于布尔全文操作符的完整列表,请参阅MySQL 参考手册。
7.18 执行全文短语搜索
问题
您希望对短语执行全文搜索;即,搜索相邻且按指定顺序出现的单词。
解决方案
使用全文短语搜索功能。
讨论
要查找包含特定短语的行,简单的全文搜索不起作用:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('great product');`
+----------+
| COUNT(*) |
+----------+
| 1725 |
+----------+
查询返回了结果,但不是您要查找的结果。全文搜索根据每个单词的存在情况计算相关性排名,不管它在reviews_virtual列中出现的位置,并且只要任何单词存在,排名就不为零。因此,这种语句往往会找到太多行。
相反,请使用全文布尔模式,它支持短语搜索。在搜索字符串内用双引号括起短语:
mysql> `SELECT COUNT(*) FROM reviews`
-> `WHERE MATCH(reviews_virtual) AGAINST('"great product"' IN BOOLEAN MODE);`
+----------+
| COUNT(*) |
+----------+
| 216 |
+----------+
如果列中包含与短语中相同的单词且顺序相同,则短语匹配成功。
第八章:处理日期和时间
8.0 介绍
MySQL 拥有多种数据类型来表示日期和时间,并提供许多操作这些数据的函数。MySQL 以特定的格式存储日期和时间,了解这些格式对于避免处理时间数据时的意外结果非常重要。本章涵盖了在 MySQL 中处理日期和时间值时的以下几个方面:
选择合适的时间数据类型
在创建表时,MySQL 提供了多种时间数据类型可供选择。了解它们的属性有助于适当地选择。
显示日期和时间
MySQL 默认使用特定格式显示时间值。您可以通过使用适当的函数生成其他格式。
更改客户端时区
服务器在客户端的当前时区解释TIMESTAMP和DATETIME值,而不是服务器自身的时区。不同时区的客户端应设置其时区,以便服务器能够正确解释它们的TIMESTAMP值。
确定当前日期和时间
MySQL 提供了返回日期和时间的函数。这些对于必须知道这些值或需要根据它们计算其他时间值的应用程序非常有用。
跟踪行修改时间
TIMESTAMP和DATETIME数据类型具有特殊属性,可以自动记录行的创建和最后修改时间。
将日期和时间拆分为组件值,从组件值创建日期和时间
当您只需要组件(如日期的月份部分或时间的小时部分)时,可以拆分日期和时间值。反之,您可以组合组件值来合成日期和时间。
在日期或时间之间进行转换以及与基本单位之间的转换
一些时间计算,如日期算术运算,通过使用日期或时间值所代表的天数或秒数而不是值本身,更容易执行。MySQL 可以在日期和时间值与天数或秒数等更基本的单位之间进行转换。
日期和时间算术
您可以添加或减去时间值以生成其他时间值或计算值之间的间隔。应用包括年龄确定、相对日期计算和日期偏移。
根据时间约束选择数据
在前述章节讨论的计算以产生输出值的同时,也可以用于WHERE子句中,指定如何使用时间条件选择行。
本章涵盖了几个 MySQL 函数,用于操作日期和时间值,但还有许多其他函数。要了解完整集合,请参阅MySQL 参考手册。您可以利用提供给您的多种函数进行给定的时间计算。我们有时会展示达到给定结果的替代方法,本章讨论的许多问题可以用其他方法解决。我们邀请您进行实验以寻找其他解决方案。您可能会找到更有效或更直观的方法。
本章讨论的配方实现脚本位于recipes源分发的dates目录中。创建这些表的脚本位于tables目录中。
8.1 选择一个时间数据类型
问题
您需要存储时间数据,但不确定哪种数据类型最合适。
解决方案
根据要存储的信息特性及其使用需求选择数据类型。
讨论
要选择时间数据类型,请考虑以下问题:
-
您只需要时间、日期或组合日期和时间值?
-
您需要哪个值范围?
-
您是否希望列自动初始化为当前日期和时间?
MySQL 提供DATE和TIME数据类型,用于分别表示日期和时间值,以及DATETIME和TIMESTAMP类型,用于表示组合的日期和时间值。这些数值具有以下特点:
-
DATE数值采用YYYY-MM-DD格式,其中YY、MM和DD分别表示年、月和日部分。支持的DATE数值范围是1000-01-01至9999-12-31。 -
TIME数值采用hh:mm:ss格式,其中hh、mm和ss分别表示小时、分钟和秒部分。TIME数值通常可以视为一天中的时间值,但 MySQL 实际上将它们视为经过的时间。因此,它们可以大于23:59:59甚至为负数。(TIME列的实际范围是-838:59:59至838:59:59。) -
DATETIME和TIMESTAMP是组合的日期和时间值,采用YYYY-MM-DDhh:mm:ss格式。DATETIME和TIMESTAMP数据类型在许多方面相似,但要注意以下差异:-
DATETIME支持范围为1000-01-01 00:00:00至9999-12-31 23:59:59,而TIMESTAMP数值仅在 1970 年部分到 2038 年之间有效。 -
TIMESTAMP和DATETIME具有特殊的自动初始化和自动更新属性(参见配方 8.8),但在 MySQL 5.6.5 之前,DATETIME不具备此功能。 -
当客户端插入
TIMESTAMP值时,服务器将其从与客户端会话关联的时区转换为 UTC 并存储 UTC 值。当客户端检索TIMESTAMP值时,服务器执行相反操作,将 UTC 值转换回客户端会话时区。如果客户端位于与服务器不同的时区,则可以配置其会话,使此转换适合其自己的时区(参见 Recipe 8.4)。
-
-
包含时间部分的类型可以具有用于毫秒级分辨率的分数秒部分(参见 Recipe 8.2)。
本章中许多示例使用以下表,其中包含表示时间、日期和日期时间值的列。(time_val表有两列,用于时间间隔计算示例。)
mysql> `SELECT t1, t2 FROM time_val;`
+----------+----------+
| t1 | t2 |
+----------+----------+
| 15:00:00 | 15:00:00 |
| 05:01:30 | 02:30:20 |
| 12:30:20 | 17:30:45 |
+----------+----------+
mysql> `SELECT d FROM date_val;`
+------------+
| d |
+------------+
| 1864-02-28 |
| 1900-01-15 |
| 1999-12-31 |
| 2000-06-04 |
| 2017-03-16 |
+------------+
mysql> `SELECT dt FROM datetime_val;`
+---------------------+
| dt |
+---------------------+
| 1970-01-01 00:00:00 |
| 1999-12-31 09:00:00 |
| 2000-06-04 15:45:30 |
| 2017-03-16 12:30:15 |
+---------------------+
在继续阅读之前,立即创建time_val、date_val和datetime_val表是个好主意。(在recipes分发的tables目录中使用适当的脚本。)
8.2 使用分数秒支持
问题
您的应用程序需要毫秒级时间值的分辨率。
解决方案
指定分数秒。
讨论
自 MySQL 5.6.4 起,支持包含时间部分的时间类型的分数秒:DATETIME、TIME和TIMESTAMP。对于需要毫秒级时间值分辨率的应用程序,这使您能够指定分数秒的精度,甚至可以达到微秒级别。
默认情况下没有分数秒部分,因此对于支持此功能的时间类型,需要在列声明中明确指定:在数据类型名称后包含(fsp)。fsp可以从 0 到 6,表示小数位数。0 表示无
(分辨率为秒),6 表示分辨率为微秒。例如,要创建带有两位小数的TIME列(分辨率为百分之一秒),请使用以下语法:
mycol TIME(2)
对于特定事件(如赛车),精确的时间跟踪至关重要。全球最流行且最具时间敏感性的赛事之一是世界各地的 F1 赛车比赛。对于最快的赛车运动,时间跟踪需要详细的计时和技术支持。简而言之,使用多个转发器,必须跟踪的时间在万分之一秒内。
表 8-1. 2021 年土耳其大奖赛 - F1 罗斯表格
| 驾驶员 | 车辆 | 时间 |
|---|---|---|
| 马克斯·维斯塔潘 | 红牛赛车本田 | 1:17.301 |
| 瓦尔特利·博塔斯 | 梅赛德斯 | 1:17.725 |
| 路易斯·汉密尔顿 | 梅赛德斯 | 1:17.810 |
返回当前时间或日期时间值的时间函数也支持分数秒。如果没有参数,默认值为无小数部分。否则,参数指定所需的分辨率。允许的值为 0 到 6,与声明时间列时相同:
mysql> `SELECT CURTIME(), CURTIME(2), CURTIME(6);`
+-----------+-------------+-----------------+
| CURTIME() | CURTIME(2) | CURTIME(6) |
+-----------+-------------+-----------------+
| 18:07:03 | 18:07:03.24 | 18:07:03.244950 |
+-----------+-------------+-----------------+
为了更好地演示,我们将以土耳其最近举行的一场 Formula 1 赛事的赛果为例(表 8-1)。
CREATE TABLE `formula1` (
id INT AUTO_INCREMENT PRIMARY KEY,
position INT UNSIGNED,
no INT UNSIGNED,
driver VARCHAR(25),
car VARCHAR(25),
laps SMALLINT,
time TIMESTAMP(3),
points SMALLINT
);
INSERT INTO formula1 VALUES(0,1,77,"Valtteri Bottas","MERCEDES",58,"2021-10-08
\ 1:31:04.103",26);
INSERT INTO formula1 VALUES(0,2,33,"Max Verstappen","RED BULL RACING HONDA",58,
\"2021-10-08 1:45:58.243",18);
INSERT INTO formula1 VALUES(0,3,11,"Sergio Perez","RED BULL RACING HONDA",58,
\"2021-10-08 1:46:10.342",15);
SELECT POSITION as pos,
no,
driver,
car,
laps,
date_format(time,'%H:%i:%s:%f') as time,
points as pts
FROM formula1 ORDER BY time;
+------+------+-----------------+-----------------------+------+-----------------+------+
| pos | no | driver | car | laps | time | pts |
+------+------+-----------------+-----------------------+------+-----------------+------+
| 1 | 77 | Valtteri Bottas | MERCEDES | 58 | 01:31:04:103000 | 26 |
| 2 | 33 | Max Verstappen | RED BULL RACING HONDA | 58 | 01:45:58:243000 | 18 |
| 3 | 11 | Sergio Perez | RED BULL RACING HONDA | 58 | 01:46:10:342000 | 15 |
+------+------+-----------------+-----------------------+------+-----------------+------+
为了得到驾驶员表现时间间隔的正确列表,我们将使用一个 CTE。我们将在食谱 10.18 中讨论 CTE(公共表达式)。以下是解决方案。
SELECT MIN(time) from formula1 into @fastest;
WITH time_gap AS (
SELECT
position,
car,
driver,
time,
TIMESTAMPDIFF(SECOND, time , @fastest) AS seconds
FROM formula1
),
DIFFERENCES AS (
SELECT
position as pos,
driver,
car,
time,
seconds,
MOD(seconds, 60) AS seconds_part,
MOD(seconds, 3600) AS minutes_part
FROM time_gap
)
SELECT
pos,
driver,
time,
CONCAT(
FLOOR(minutes_part / 60), ' min ',
SUBSTRING_INDEX(SUBSTRING_INDEX(seconds_part,'-',2),'-',-1),' secs'
) AS difference
FROM differences;
+------+-----------------+-------------------------+-----------------+
| pos | driver | time | difference |
+------+-----------------+-------------------------+-----------------+
| 1 | Valtteri Bottas | 2021-10-08 01:31:04.103 | 0 min 0 secs |
| 2 | Max Verstappen | 2021-10-08 01:45:58.243 | -15 min 54 secs |
| 3 | Sergio Perez | 2021-10-08 01:46:10.342 | -16 min 6 secs |
+------+-----------------+-------------------------+-----------------+
8.3 改变 MySQL 的日期格式
问题
您想要更改 MySQL 用于表示日期值的 ISO 格式。
解决方案
你不能。然而,当存储日期时,你可以将非 ISO 输入值重写为 ISO 格式,并且你可以使用DATE_FORMAT()函数将 ISO 值重写为其他格式以便显示。
讨论
MySQL 用于DATE值的YYYY-MM-DD格式遵循 ISO 8601 标准表示日期。由于日期字符串中年、月和日部分具有固定长度且从左到右显示,这种格式具有将日期自然排序到适当时间顺序的有用属性。食谱 9.5 和食谱 10.15 讨论了基于日期值的排序和分组技术。
尽管 ISO 格式很常见,但并非所有数据库系统都使用它,这可能会在不同系统之间移动数据时引发问题。此外,人们通常喜欢以MM/DD/YY或DD-MM-YYYY等其他格式表示日期。这也可能是问题的源头,因为人们对日期的期望与 MySQL 实际表示它们的方式不匹配。
MySQL 新手经常问的一个问题是,“如何告诉 MySQL 以特定格式(例如MM/DD/YYYY)存储日期?”这是错误的问题。相反,应该问,“如果我有一个特定格式的日期,如何将其存储为 MySQL 支持的格式,反之亦然?”MySQL 始终以 ISO 格式存储日期,这一事实对数据输入(输入)和显示查询结果(输出)都有影响:
-
对于数据输入目的,要存储不是 ISO 格式的值,通常必须首先重写它们。如果不想重写它们,可以将它们存储为字符串(例如,在
CHAR列中)。但是然后你无法将它们作为日期操作。第十三章涵盖了数据输入的日期重写主题,而第十四章讨论了检查日期以验证其有效性。在某些情况下,如果您的值接近 ISO 格式,则可能不需要重写。例如,当将它们存储到
DATE列中时,MySQL 将解释字符串值87-1-7和1987-1-7以及数字870107和19870107为日期1987-01-07。 -
为了显示目的,可以将日期重写为非 ISO 格式。
DATE_FORMAT()函数提供了将日期值转换为其他格式的灵活性(请参阅本节后面的详细信息)。您还可以使用函数如YEAR()来提取日期的部分以供显示(请参见 Recipe 8.9)。有关进一步讨论,请参见 Recipe 14.17。
重写非 ISO 格式日期输入的一种方法是使用 STR_TO_DATE() 函数,该函数接受表示时间值的字符串和指定值的语法的格式字符串。
在格式化字符串中,使用形式为 %c 的特殊序列,其中 c 指定要期望的日期部分。
例如,%Y、%M 和 %d 表示四位数年份、月份名称和两位数日期。
要将值 May 13, 2007 插入到 DATE 列中,请执行以下操作:
mysql> `INSERT INTO t (d) VALUES(STR_TO_DATE('May 13, 2007','%M %d, %Y'));`
mysql> `SELECT d FROM t;`
+------------+
| d |
+------------+
| 2007-05-13 |
+------------+
对于日期显示,MySQL 使用 ISO 格式(YYYY-MM-DD),除非另有说明。要以其他格式显示日期或时间,请使用 DATE_FORMAT() 或 TIME_FORMAT() 函数进行重写。如果需要更专业化的格式,这些函数无法提供,请编写存储函数。
DATE_FORMAT() 函数接受两个参数:DATE、DATETIME 或 TIMESTAMP 值,以及描述如何显示该值的字符串。格式字符串使用与 STR_TO_DATE() 相同类型的指示符。
以下语句显示了 date_val 表中的值,一是 MySQL 默认显示它们的方式,二是使用 DATE_FORMAT() 重新格式化它们:
mysql> `SELECT d, DATE_FORMAT(d,'%M %d, %Y') FROM date_val;`
+------------+----------------------------+
| d | DATE_FORMAT(d,'%M %d, %Y') |
+------------+----------------------------+
| 1864-02-28 | February 28, 1864 |
| 1900-01-15 | January 15, 1900 |
| 1999-12-31 | December 31, 1999 |
| 2000-06-04 | June 04, 2000 |
| 2017-03-16 | March 16, 2017 |
+------------+----------------------------+
由于 DATE_FORMAT() 生成的列标题很长,通常很有用为其提供别名(请参见 Recipe 5.2),以使标题更简洁或更有意义:
mysql> `SELECT d, DATE_FORMAT(d,'%M %d, %Y') AS date FROM date_val;`
+------------+-------------------+
| d | date |
+------------+-------------------+
| 1864-02-28 | February 28, 1864 |
| 1900-01-15 | January 15, 1900 |
| 1999-12-31 | December 31, 1999 |
| 2000-06-04 | June 04, 2000 |
| 2017-03-16 | March 16, 2017 |
+------------+-------------------+
MySQL 参考手册 提供了与 DATE_FORMAT()、TIME_FORMAT() 和 STR_TO_DATE() 一起使用的完整格式序列列表。
Table 8-2 显示了其中的一些:
Table 8-2. 用于日期和时间格式化函数的格式序列
| Sequence | Meaning |
|---|---|
%Y |
四位数年份 |
%y |
两位数年份 |
%M |
完整的月份名称 |
%b |
月份名称的前三个字母 |
%m |
两位数年份的月份(01..12) |
%c |
年份中的月份(1..12) |
%d |
两位数日期(01..31) |
%e |
月份中的日期(1..31) |
%W |
星期名称(Sunday..Saturday) |
%r |
带有 AM 或 PM 后缀的 12 小时制时间 |
%T |
24 小时制时间 |
%H |
两位数小时 |
%i |
两位数分钟 |
%s |
两位数秒 |
%f |
六位数微秒 |
%% |
字面上的 % |
表中显示的与时间相关的格式序列仅在您向DATE_FORMAT()传递同时包含日期和时间部分(DATETIME或TIMESTAMP)的值时才有用。以下语句显示datetime_val表中的DATETIME值,使用包含每天时间的格式:
mysql> `SELECT dt,`
-> `DATE_FORMAT(dt,'%c/%e/%y %r') AS format1,`
-> `DATE_FORMAT(dt,'%M %e, %Y %T') AS format2`
-> `FROM datetime_val;`
+---------------------+----------------------+----------------------------+
| dt | format1 | format2 |
+---------------------+----------------------+----------------------------+
| 1970-01-01 00:00:00 | 1/1/70 12:00:00 AM | January 1, 1970 00:00:00 |
| 1999-12-31 09:00:00 | 12/31/99 09:00:00 AM | December 31, 1999 09:00:00 |
| 2000-06-04 15:45:30 | 6/4/00 03:45:30 PM | June 4, 2000 15:45:30 |
| 2017-03-16 12:30:15 | 3/16/17 12:30:15 PM | March 16, 2017 12:30:15 |
+---------------------+----------------------+----------------------------+
TIME_FORMAT()类似于DATE_FORMAT()。它适用于TIME、DATETIME或TIMESTAMP值,但只理解格式字符串中与时间相关的说明符:
mysql> `SELECT dt,`
-> `TIME_FORMAT(dt, '%r') AS '12-hour time',`
-> `TIME_FORMAT(dt, '%T') AS '24-hour time'`
-> `FROM datetime_val;`
+---------------------+--------------+--------------+
| dt | 12-hour time | 24-hour time |
+---------------------+--------------+--------------+
| 1970-01-01 00:00:00 | 12:00:00 AM | 00:00:00 |
| 1999-12-31 09:00:00 | 09:00:00 AM | 09:00:00 |
| 2000-06-04 15:45:30 | 03:45:30 PM | 15:45:30 |
| 2017-03-16 12:30:15 | 12:30:15 PM | 12:30:15 |
+---------------------+--------------+--------------+
如果DATE_FORMAT()或TIME_FORMAT()无法生成您想要的结果,请编写一个存储函数来实现。假设您想将 24 小时制的TIME值转换为带有a.m.或p.m.后缀而不是AM或PM的 12 小时制格式。以下函数可以完成此任务。它使用TIME_FORMAT()来执行大部分工作,然后去掉%r提供的后缀并替换为所需的后缀:
CREATE FUNCTION time_ampm (t TIME)
RETURNS VARCHAR(13) # mm:dd:ss {a.m.|p.m.} format
DETERMINISTIC
RETURN CONCAT(LEFT(TIME_FORMAT(t, '%r'), 9),
IF(TIME_TO_SEC(t) < 12*60*60, 'a.m.', 'p.m.'));
使用如下函数:
mysql> `SELECT t1, time_ampm(t1) FROM time_val;`
+----------+---------------+
| t1 | time_ampm(t1) |
+----------+---------------+
| 15:00:00 | 03:00:00 p.m. |
| 05:01:30 | 05:01:30 a.m. |
| 12:30:20 | 12:30:20 p.m. |
+----------+---------------+
有关编写存储函数的更多信息,请参见第十一章。
8.4 设置客户端时区
问题
您有一个客户端应用程序,它从与服务器不同的时区连接。因此,当它存储TIMESTAMP值时,这些值没有正确的 UTC 值。
解决方案
客户端在连接到服务器后应设置time_zone系统变量。
讨论
时区设置对TIMESTAMP值有重要影响:
-
当 MySQL 服务器启动时,它会检查其操作环境以确定其时区。(要使用不同的值,请使用
--default-time-zone选项启动服务器。) -
对于每个连接的客户端,服务器根据客户端会话关联的时区解释
TIMESTAMP值。当客户端插入TIMESTAMP值时,服务器将其从客户端时区转换为 UTC 并存储 UTC 值。(在内部,服务器将TIMESTAMP值存储为自1970-01-01 00:00:00UTC 以来的秒数。)当客户端检索TIMESTAMP值时,服务器执行相反的操作,将 UTC 值转换回客户端时区。 -
每个客户端在连接时的默认会话时区是服务器时区。如果所有客户端与服务器处于同一时区,则无需为正确的
TIMESTAMP时区转换做任何特殊处理。但是,如果客户端位于与服务器不同的时区,并且在不进行正确时区校正的情况下插入TIMESTAMP值,则 UTC 值将不正确。
假设服务器和客户端 C1 处于同一时区,并且客户端 C1 发出以下语句:
mysql> `CREATE TABLE t (ts TIMESTAMP);`
mysql> `INSERT INTO t (ts) VALUES('2021-06-21 12:30:00');`
mysql> `SELECT ts FROM t;`
+---------------------+
| ts |
+---------------------+
| 2021-06-21 12:30:00 |
+---------------------+
在这里,客户端 C1 看到与其存储的相同值。不同的客户端 C2,如果检索它,也将看到相同的值,但是如果客户端 C2 位于不同的时区,则该值对其时区来说不正确。相反,如果客户端 C2 存储一个值,则当客户端 C1 检索时,该值对于客户端 C1 的时区也不正确。
为了解决这个问题,使得TIMESTAMP转换使用正确的时区,客户端应通过设置time_zone系统变量的会话值来显式设置其时区。假设服务器的全局时区比 UTC 提前六小时。每个客户端最初分配相同的值作为其会话时区:
mysql> `SELECT @@global.time_zone, @@session.time_zone;`
+--------------------+---------------------+
| @@global.time_zone | @@session.time_zone |
+--------------------+---------------------+
| SYSTEM | SYSTEM |
+--------------------+---------------------+
当客户端 C2 连接时,它看到与客户端 C1 相同的TIMESTAMP值:
mysql> `SELECT ts FROM t;`
+---------------------+
| ts |
+---------------------+
| 2021-06-21 12:30:00 |
+---------------------+
但如果客户端 C2 比 UTC 仅提前四小时,则该值是不正确的。C2 应在连接后设置其时区,以便检索到的TIMESTAMP值适当地调整为其自身的会话:
mysql> `SET SESSION time_zone = '+04:00';`
mysql> `SELECT @@global.time_zone, @@session.time_zone;`
+--------------------+---------------------+
| @@global.time_zone | @@session.time_zone |
+--------------------+---------------------+
| SYSTEM | +04:00 |
+--------------------+---------------------+
mysql> `SELECT ts FROM t;`
+---------------------+
| ts |
+---------------------+
| 2021-06-21 16:30:00 |
+---------------------+
要查看System Timezone,请检查全局变量。
mysql> `SHOW GLOBAL VARIABLES LIKE "system_time_zone";`
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| system_time_zone | UTC |
+------------------+-------+
客户端时区也会影响从返回当前日期和时间的函数中显示的值(请参阅 Recipe 8.7)。
另请参阅
要将一个时区的单个日期和时间值转换为另一个时区,请使用CONVERT_TZ()函数(请参阅 Recipe 8.6)。
8.5 设置服务器时区
问题
您有一个本地化的应用程序用于服务客户,但您希望拥有全局时区设置。
解决方案
服务器应该将time_zone系统变量设置为服务器上的SYSTEM值。该设置应该指向UTC值。因此系统时区system_time_zone值应设置为UTC。
讨论
MySQL 服务器维护多个时区设置:
-
服务器系统时区。当 MySQL 启动时,它会尝试确定
system_time_zone变量。为了显式设置 MySQL 的系统时区,请在启动mysqld之前设置TZ环境变量。或者使用mysqld_safe的--timezone选项启动。这些变量的值受您的操作系统设置的允许。 -
服务器当前时区由全局
time_zone值设置。在现代 Linux 操作系统上通常设置为SYSTEM。mysql> `SHOW GLOBAL VARIABLES LIKE "time_zone";` +---------------+--------+ | Variable_name | Value | +---------------+--------+ | time_zone | SYSTEM | +---------------+--------+您可以选择使用
SET GLOBAL设置全局时区变量。这不会更改@@session.time_zone的值。mysql> `SET GLOBAL time_zone = "+03:00";` mysql> `SELECT @@global.time_zone, @@session.time_zone;` +--------------------+---------------------+ | @@global.time_zone | @@session.time_zone | +--------------------+---------------------+ | +03:00 | SYSTEM | +--------------------+---------------------+表示与 UTC(协调世界时)相差的
time_zone值偏移的字符串。在 MySQL 8.0.19 之前,此值必须在'-12:59'到'+13:00'(含)的范围内;从 MySQL 8.0.19 开始,允许范围为'-13:59'到'+14:00'(含)。未填充的时区不允许使用,除非它们预先加载到 MySQL 表中,因此您不能使用UTC等名称:mysql> `SET GLOBAL time_zone = "US/Eastern" ;` ERROR 1298 (HY000): Unknown or incorrect time zone: 'US/Eastern'如需填充时区表的说明,请参阅MySQL 参考手册。
-
变量
system_time_zone在服务器继承机器默认时区设置时设置。与time_zone变量不同,此变量在服务器启动后无法动态设置。从 MySQL 8.0.26 开始,如果服务器主机的时区发生变化,例如夏令时期间,system_time_zone将反映这些更改。如果在执行查询期间发生更改,则将缓存先前的值。mysql> `SHOW GLOBAL VARIABLES LIKE "system_time_zone";` +---------------+------------+ | Variable_name | Value | +---------------+------------+ | time_zone | US/Eastern | +---------------+------------+
8.6 在时区之间转移时间值
问题
您有一个日期时间值,但需要知道在另一个时区中会是什么时间。例如,您正在与世界各地的人进行电话会议,必须告诉他们在其当地时区的会议时间。
解决方案
使用CONVERT_TZ()函数。
讨论
CONVERT_TZ()函数在不同时区之间转换时间值。它接受三个参数:日期时间值和两个时区指示器。该函数将日期时间值解释为第一个时区中的值,并返回转换为第二个时区的值。
假设我们居住在美国伊利诺伊州芝加哥,并且与世界各地的人有会议。表 8-3 显示了每个会议参与者的位置及其时区名称:
表 8-3 会议参与者
| 位置 | 时区名称 |
|---|---|
| 美国伊利诺伊州芝加哥 | US/Central |
| 土耳其伊斯坦布尔 | Europe/Istanbul |
| 英国伦敦 | Europe/London |
| 加拿大阿尔伯塔埃德蒙顿 | America/Edmonton |
| 澳大利亚布里斯班 | Australia/Brisbane |
如果会议计划在 2021 年 11 月 28 日我们当地时间上午 8 点进行,那么其他参与者的当地时间将是多少?以下语句使用CONVERT_TZ()计算每个时区的本地时间:
mysql> `SET @dt = '2021-11-28 08:00:00';`
mysql> `SELECT @dt AS Chicago,`
-> `CONVERT_TZ(@dt,'US/Central','Europe/Istanbul') AS Istanbul,`
-> `CONVERT_TZ(@dt,'US/Central','Europe/London') AS London,`
-> `CONVERT_TZ(@dt,'US/Central','America/Edmonton') AS Edmonton,`
-> `CONVERT_TZ(@dt,'US/Central','Australia/Brisbane') AS Brisbane\G`
*************************** 1\. row ***************************
Chicago: 2021-11-28 08:00:00
Istanbul: 2021-11-28 17:00:00
London: 2021-11-28 14:00:00
Edmonton: 2021-11-28 07:00:00
Brisbane: 2021-11-29 00:00:00
希望布里斯班的参与者不介意在午夜后参加。
前述示例使用时区名称,因此需要您在mysql数据库中初始化支持命名时区的时区表(有关设置时区表的信息,请参阅MySQL 参考手册)。如果无法使用命名时区,请用其相对于 UTC 的数字关系指定它们(这可能有些棘手;您可能需要考虑夏令时)。具有数字时区的相应语句如下所示:
mysql> `SELECT @dt AS Chicago,`
-> `CONVERT_TZ(@dt,'-06:00','+03:00') AS Istanbul,`
-> `CONVERT_TZ(@dt,'-06:00','+00:00') AS London,`
-> `CONVERT_TZ(@dt,'-06:00','-07:00') AS Edmonton,`
-> `CONVERT_TZ(@dt,'-06:00','+10:00') AS Brisbane\G`
*************************** 1\. row ***************************
Chicago: 2021-11-28 08:00:00
Istanbul: 2021-11-28 17:00:00
London: 2021-11-28 14:00:00
Edmonton: 2021-11-28 07:00:00
Brisbane: 2021-11-29 00:00:00
8.7 确定当前日期或时间
问题
您想知道今天的日期和/或时间。
解决方案
使用CURDATE()、CURTIME()或NOW()函数获取客户端会话时区中表达的值。使用UTC_DATE()、UTC_TIME()或UTC_TIMESTAMP()获取 UTC 时间的值。
讨论
某些应用程序必须知道当前日期或时间,例如写入时间戳日志记录的应用程序。这类信息对于与当前日期相关的日期计算也很有用,例如查找本月的第一天(或最后一天)或确定下周星期三的日期。
CURDATE()和CURTIME()函数分别返回当前日期和时间,而NOW()则返回合并的日期和时间值:
mysql> `SELECT CURDATE(), CURTIME(), NOW();`
+------------+-----------+---------------------+
| CURDATE() | CURTIME() | NOW() |
+------------+-----------+---------------------+
| 2021-11-28 | 08:42:57 | 2021-11-28 08:42:57 |
+------------+-----------+---------------------+
CURRENT_DATE、CURRENT_TIME和CURRENT_TIMESTAMP是CURDATE()、CURTIME()和NOW()的同义词,分别用于获取当前日期、时间或合并的日期和时间。
上述函数返回客户端会话时区中的值(参见 Recipe 8.4)。若需使用 UTC 时间的值,请改用UTC_DATE()、UTC_TIME()或UTC_TIMESTAMP()函数。
要确定任意时区的当前日期和时间,请将适当的 UTC 函数值传递给CONVERT_TZ()(参见 Recipe 8.6)。
要获取这些值的子部分,例如当前月份的日期或当前小时数,请使用 Recipe 8.9 中讨论的分解技术。
8.8 使用 TIMESTAMP 或 DATETIME 跟踪行修改时间
问题
你希望自动记录行创建时间或最后修改时间。
解决方案
使用TIMESTAMP和DATETIME数据类型的自动初始化和自动更新属性。
讨论
MySQL 支持存储日期时间值的TIMESTAMP和DATETIME数据类型。Recipe 8.1 介绍了这些类型的值范围。本篇重点介绍了能够自动跟踪行创建和更新时间的特殊列属性:
-
声明为带有
DEFAULTCURRENT_TIMESTAMP属性的TIMESTAMP或DATETIME列会自动初始化为新行。只需在INSERT语句中省略该列,MySQL 会将其设置为行创建时间。 -
当你改变行中的任何其他列的当前值时,声明为带有
ONUPDATECURRENT_TIMESTAMP属性的TIMESTAMP或DATETIME列会自动更新为当前日期和时间。
这些特殊属性使得TIMESTAMP和DATETIME数据类型特别适合需要记录插入或更新行时的时间的应用程序。以下讨论展示了如何利用TIMESTAMP列的这些特性。尽管有一些稍后需要注意的差异,但是这些讨论同样适用于DATETIME列。
注意
默认的SQL_MODE不允许NULL值,除非放宽条件。而 MySQL 8.0 之后不推荐使用NO_ZERO_DATE,应与STRICT MODE结合使用。
我们的示例表如下:
DROP TABLE IF EXISTS tsdemo;
CREATE TABLE `tsdemo` (
`val` INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
`ts_both` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`ts_create` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
`ts_update` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB ;
TIMESTAMP列具有以下属性:
-
ts_both自动初始化并自动更新。这对于跟踪行的任何更改时间(无论是插入还是更新)非常有用。 -
ts_create仅自动初始化。当您希望列设置为创建行时的时间,但在此后保持恒定时,这将非常有用。 -
ts_update仅自动更新。它在创建行时设置为列默认值(或您明确指定的值),并在之后对行的更改自动更新。这种用法更为有限,例如,单独跟踪行创建和最后修改时间(使用ts_update与ts_create结合),而不是在单个列中像ts_both一样一起。
要查看表的工作原理,请向表中插入一些行(时间间隔几秒钟,使时间戳不同),然后选择其内容:
mysql> `INSERT INTO tsdemo (val,ts_both,ts_create,ts_update) -> VALUES(0,NULL,NULL,NULL);`
mysql> `INSERT INTO tsdemo (val) VALUES(5);`
mysql> `INSERT INTO tsdemo (val,ts_both,ts_create,ts_update)`
-> `VALUES(10,NULL,NULL,NULL);`
mysql> `SELECT val, ts_both, ts_create, ts_update FROM tsdemo;`
+-----+---------------------+---------------------+---------------------+
| val | ts_both | ts_create | ts_update |
+-----+---------------------+---------------------+---------------------+
| 1 | 2022-03-06 14:34:17 | 2022-03-06 14:34:17 | 2022-03-06 14:34:17 |
| 5 | 2022-03-06 14:35:16 | 2022-03-06 14:35:16 | 2022-03-06 14:35:16 |
| 10 | 2022-03-06 14:35:34 | 2022-03-06 14:35:34 | 2022-03-06 14:35:34 |
+-----+---------------------+---------------------+---------------------+
前两个INSERT语句表明,通过完全省略它们从INSERT语句中省略它们,您可以将自动初始化列设置为当前日期和时间。第三个插入显示,您可以将TIMESTAMP列显式设置为当前日期和时间,即使它不自动初始化。这种NULL赋值行为不仅适用于INSERT语句;它也适用于UPDATE。您可以禁用对NULL赋值的特殊处理,我们将在本章后面介绍这个方法。
要查看自动更新的实际效果,请发出一个改变一行中val列的语句,并检查其对表内容的影响。结果显示自动更新列已更新(仅在修改的行中):
mysql> `UPDATE tsdemo SET val = 11 WHERE val = 10;`
mysql> `SELECT val, ts_both, ts_create, ts_update FROM tsdemo;`
+-----+---------------------+---------------------+---------------------+
| val | ts_both | ts_create | ts_update |
+-----+---------------------+---------------------+---------------------+
| 1 | 2022-03-06 14:34:17 | 2022-03-06 14:34:17 | 2022-03-06 14:34:17 |
| 5 | 2022-03-06 14:35:16 | 2022-03-06 14:35:16 | 2022-03-06 14:35:16 |
| 11 | 2022-03-06 14:38:04 | 2022-03-06 14:35:34 | 2022-03-06 14:38:04 |
+-----+---------------------+---------------------+---------------------+
如果修改多行,则每行中的自动更新列都会发生更新:
mysql> `UPDATE tsdemo SET val = val + 1;`
mysql> `SELECT val, ts_both, ts_create, ts_update FROM tsdemo;`
+-----+---------------------+---------------------+---------------------+
| val | ts_both | ts_create | ts_update |
+-----+---------------------+---------------------+---------------------+
| 2 | 2022-03-06 14:38:45 | 2022-03-06 14:34:17 | 2022-03-06 14:38:45 |
| 6 | 2022-03-06 14:38:45 | 2022-03-06 14:35:16 | 2022-03-06 14:38:45 |
| 12 | 2022-03-06 14:38:45 | 2022-03-06 14:35:34 | 2022-03-06 14:38:45 |
+-----+---------------------+---------------------+---------------------+
如果UPDATE语句实际上没有更改行中的任何值,则不会修改自动更新列。要查看这一点,请将每行的val列设置为其当前值,然后查看表内容,以查看自动更新列保留其值:
mysql> `UPDATE tsdemo SET val = val;`
mysql> `SELECT val, ts_both, ts_create, ts_update FROM tsdemo;`
+-----+---------------------+---------------------+---------------------+
| val | ts_both | ts_create | ts_update |
+-----+---------------------+---------------------+---------------------+
| 2 | 2022-03-06 14:38:45 | 2022-03-06 14:34:17 | 2022-03-06 14:38:45 |
| 6 | 2022-03-06 14:38:45 | 2022-03-06 14:35:16 | 2022-03-06 14:38:45 |
| 12 | 2022-03-06 14:38:45 | 2022-03-06 14:35:34 | 2022-03-06 14:38:45 |
+-----+---------------------+---------------------+---------------------+
如前所述,自动TIMESTAMP属性也适用于DATETIME,但有些差异:
-
对于表中的第一个
TIMESTAMP列,如果未指定DEFAULT或ON UPDATE属性,则该列隐式定义为两者都包括。对于DATETIME,自动属性从不隐式应用;只有明确指定的那些属性。 -
现在不再可能将
NULL设置为TIMESTAMP。要分配当前时间戳,请将列设置为CURRENT_TIMESTAMP或其同义词,例如NOW()。
要确定对于任何给定的TIMESTAMP列,当将NULL赋值给它时会发生什么,请使用SHOW CREATE TABLE查看列定义。如果定义包括NULL属性,则赋值NULL会存储NULL。如果定义包括NOT NULL属性,则可以指定NULL作为要赋值的值,但不能存储NULL,因为 MySQL 会存储当前日期和时间。
另请参阅
要模拟其他时间类型的TIMESTAMP自动初始化和自动更新属性,可以使用触发器(参见第十一章)。
8.9 提取日期或时间的部分
问题
您希望仅获取日期或时间的一部分。
解决方案
调用专门用于提取时间值部分的函数,如MONTH()或MINUTE()。如果您只需要值的单个组件,这通常是最快的提取方法。或者,使用包含您想获取的值部分的格式字符串的格式化函数,如DATE_FORMAT()或TIME_FORMAT()。
讨论
下面的讨论展示了提取时间值部分的不同方法。
使用组件提取函数分解日期或时间
MySQL 包括许多用于提取日期和时间子部分的函数。例如,DATE()和TIME()提取时间值的日期和时间组件:
mysql> `SELECT dt, DATE(dt), TIME(dt) FROM datetime_val;`
+---------------------+------------+----------+
| dt | DATE(dt) | TIME(dt) |
+---------------------+------------+----------+
| 1970-01-01 00:00:00 | 1970-01-01 | 00:00:00 |
| 1999-12-31 09:00:00 | 1999-12-31 | 09:00:00 |
| 2000-06-04 15:45:30 | 2000-06-04 | 15:45:30 |
| 2017-03-16 12:30:15 | 2017-03-16 | 12:30:15 |
+---------------------+------------+----------+
表 8-4 展示了一些组件提取函数;请参阅MySQL 参考手册获取完整列表。日期相关函数适用于DATE、DATETIME或TIMESTAMP值。时间相关函数适用于TIME、DATETIME或TIMESTAMP值:
表 8-4. 组件提取函数
| 函数 | 返回值 |
|---|---|
YEAR() |
年份 |
MONTH() |
月份数字(1 至 12) |
MONTHNAME() |
月份名称(一月至十二月) |
DAYOFMONTH() |
月中的天数(1 至 31) |
DAYNAME() |
星期几名称(星期日至星期六) |
DAYOFWEEK() |
星期几(1 至 7,分别对应星期日至星期六) |
WEEKDAY() |
星期几(0 至 6,分别对应星期一到星期日) |
DAYOFYEAR() |
年中的天数(1 至 366) |
HOUR() |
时间的小时(0 至 23) |
MINUTE() |
时间的分钟(0 至 59) |
SECOND() |
时间的秒(0 至 59) |
MICROSECOND() |
时间的微秒(0 至 59) |
EXTRACT() |
各不相同 |
以下是一个示例:
mysql> `SELECT dt, YEAR(dt), DAYOFMONTH(dt), HOUR(dt), SECOND(dt)`
-> `FROM datetime_val;`
+---------------------+----------+----------------+----------+------------+
| dt | YEAR(dt) | DAYOFMONTH(dt) | HOUR(dt) | SECOND(dt) |
+---------------------+----------+----------------+----------+------------+
| 1970-01-01 00:00:00 | 1970 | 1 | 0 | 0 |
| 1999-12-31 09:00:00 | 1999 | 31 | 9 | 0 |
| 2000-06-04 15:45:30 | 2000 | 4 | 15 | 30 |
| 2017-03-16 12:30:15 | 2017 | 16 | 12 | 15 |
+---------------------+----------+----------------+----------+------------+
mysql> `set @date_time="2021-11-24 22:11:12.000201";`
-> `SELECT HOUR(@date_time) as Hour, MINUTE(@date_time) -> as Minute,SECOND(@date_time) as Second, MICROSECOND(@date_time) as MicroSecond;`
+------+--------+--------+-------------+
| Hour | Minute | Second | MicroSecond |
+------+--------+--------+-------------+
| 22 | 11 | 12 | 201 |
+------+--------+--------+-------------+
函数如YEAR()或DAYOFMONTH()提取的值与应用它们的时间值的子字符串具有明显的对应关系。其他组件提取函数提供了访问没有此类对应关系的值。其中一个是一年中的日期值:
mysql> `SELECT d, DAYOFYEAR(d) FROM date_val;`
+------------+--------------+
| d | DAYOFYEAR(d) |
+------------+--------------+
| 1864-02-28 | 59 |
| 1900-01-15 | 15 |
| 1999-12-31 | 365 |
| 2000-06-04 | 156 |
| 2017-03-16 | 75 |
+------------+--------------+
另一个是星期几,可以按名称或数字获取:
-
DAYNAME()返回完整的星期几名称。有一个DATE_FORMAT(d, '%a')函数用于返回三字符名称缩写,您可以通过将全名传递给DATE_FORMAT()轻松获取它:mysql> `SELECT d, DAYNAME(d), DATE_FORMAT(d, '%a') FROM date_val;` +------------+------------+----------------------+ | d | DAYNAME(d) | DATE_FORMAT(d, '%a') | +------------+------------+----------------------+ | 1864-02-28 | Sunday | Sun | | 1900-01-15 | Monday | Mon | | 1999-12-31 | Friday | Fri | | 2000-06-04 | Sunday | Sun | | 2017-03-16 | Thursday | Thu | +------------+------------+----------------------+ -
要获得星期几的数字形式,请使用
DAYOFWEEK()或WEEKDAY(),但要注意每个函数返回的值范围。DAYOFWEEK()返回 1 至 7 的值,分别对应星期日到星期六。WEEKDAY()返回 0 至 6 的值,分别对应星期一到星期日:mysql> `SELECT d, DAYNAME(d), DAYOFWEEK(d), WEEKDAY(d) FROM date_val;` +------------+------------+--------------+------------+ | d | DAYNAME(d) | DAYOFWEEK(d) | WEEKDAY(d) | +------------+------------+--------------+------------+ | 1864-02-28 | Sunday | 1 | 6 | | 1900-01-15 | Monday | 2 | 0 | | 1999-12-31 | Friday | 6 | 4 | | 2000-06-04 | Sunday | 1 | 6 | | 2017-03-16 | Thursday | 5 | 3 | +------------+------------+--------------+------------+
EXTRACT()是另一个用于获取时间值各部分的函数:
mysql> `SELECT dt, EXTRACT(DAY FROM dt), EXTRACT(HOUR FROM dt)`
-> `FROM datetime_val;`
+---------------------+----------------------+-----------------------+
| dt | EXTRACT(DAY FROM dt) | EXTRACT(HOUR FROM dt) |
+---------------------+----------------------+-----------------------+
| 1970-01-01 00:00:00 | 1 | 0 |
| 1999-12-31 09:00:00 | 31 | 9 |
| 2000-06-04 15:45:30 | 4 | 15 |
| 2017-03-16 12:30:15 | 16 | 12 |
+---------------------+----------------------+-----------------------+
指示从值中提取什么的关键字应该是像 YEAR、MONTH、DAY、HOUR、MINUTE 或 SECOND 这样的单位说明符。单位说明符是单数形式,而不是复数形式。(请查看MySQL 参考手册获取完整列表。)
使用格式化函数分解日期或时间
DATE_FORMAT() 和 TIME_FORMAT() 函数重新格式化日期和时间值。通过指定适当的格式字符串,您可以提取时间值的各个部分:
mysql> `SELECT dt,`
-> `DATE_FORMAT(dt,'%Y') AS year,`
-> `DATE_FORMAT(dt,'%d') AS day,`
-> `TIME_FORMAT(dt,'%H') AS hour,`
-> `TIME_FORMAT(dt,'%s') AS second`
-> `TIME_FORMAT(dt,'%f') AS microsecond`
-> `FROM datetime_val;`
+---------------------+------+------+------+--------+-------------+
| dt | year | day | hour | second | microsecond |
+---------------------+------+------+------+--------+-------------+
| 1970-01-01 00:00:00 | 1970 | 01 | 00 | 00 | 000000 |
| 1999-12-31 09:00:00 | 1999 | 31 | 09 | 00 | 000000 |
| 2000-06-04 15:45:30 | 2000 | 04 | 15 | 30 | 000000 |
| 2017-03-16 12:30:15 | 2017 | 16 | 12 | 15 | 000000 |
+---------------------+------+------+------+--------+-------------+
当您希望提取值的多个部分或以与默认格式不同的格式显示提取的值时,格式化函数非常有利。例如,要从 DATETIME 值中提取整个日期或时间,请执行以下操作:
mysql> `SELECT dt,`
-> `DATE_FORMAT(dt,'%Y-%m-%d') AS 'date part',`
-> `TIME_FORMAT(dt,'%T') AS 'time part'`
-> `FROM datetime_val;`
+---------------------+------------+-----------+
| dt | date part | time part |
+---------------------+------------+-----------+
| 1970-01-01 00:00:00 | 1970-01-01 | 00:00:00 |
| 1999-12-31 09:00:00 | 1999-12-31 | 09:00:00 |
| 2000-06-04 15:45:30 | 2000-06-04 | 15:45:30 |
| 2017-03-16 12:30:15 | 2017-03-16 | 12:30:15 |
+---------------------+------------+-----------+
要以非 YYYY-MM-DD 格式显示日期或者不显示秒部分的时间,请执行以下操作:
mysql> `SELECT dt,`
-> `DATE_FORMAT(dt,'%M %e, %Y') AS 'descriptive date',`
-> `TIME_FORMAT(dt,'%H:%i') AS 'hours/minutes'`
-> `FROM datetime_val;`
+---------------------+-------------------+---------------+
| dt | descriptive date | hours/minutes |
+---------------------+-------------------+---------------+
| 1970-01-01 00:00:00 | January 1, 1970 | 00:00 |
| 1999-12-31 09:00:00 | December 31, 1999 | 09:00 |
| 2000-06-04 15:45:30 | June 4, 2000 | 15:45 |
| 2017-03-16 12:30:15 | March 16, 2017 | 12:30 |
+---------------------+-------------------+---------------+
8.10 从组件值合成日期或时间
问题
您希望将日期或时间的各部分组合起来以生成完整的日期或时间值。或者您希望替换日期的部分以生成另一个日期。
解决方案
您有几个选项:
-
使用
MAKETIME()从小时、分钟和秒部分构造TIME值。 -
使用
DATE_FORMAT()或TIME_FORMAT()来组合现有值的部分和您想要替换的部分。 -
使用组件提取函数提取所需的部分,并使用
CONCAT()重新组合这些部分。
讨论
将日期或时间值分解为组件的逆过程是从其组成部分合成时间值。合成日期和时间的技术包括使用组合函数、格式化函数和字符串连接。
MAKETIME() 函数将组件小时、分钟和秒值作为参数,并将它们组合成时间:
mysql> `SELECT MAKETIME(10,30,58), MAKETIME(-5,0,11);`
+--------------------+-------------------+
| MAKETIME(10,30,58) | MAKETIME(-5,0,11) |
+--------------------+-------------------+
| 10:30:58 | -05:00:11 |
+--------------------+-------------------+
日期合成通常是从给定日期开始执行,然后保留您希望使用的部分并替换其余部分。例如,要生成日期所在月份的第一天,请使用 DATE_FORMAT() 提取日期中的年份和月份部分,并将它们与 01 的日部分组合:
mysql> `SELECT d, DATE_FORMAT(d,'%Y-%m-01') FROM date_val;`
+------------+---------------------------+
| d | DATE_FORMAT(d,'%Y-%m-01') |
+------------+---------------------------+
| 1864-02-28 | 1864-02-01 |
| 1900-01-15 | 1900-01-01 |
| 1999-12-31 | 1999-12-01 |
| 2000-06-04 | 2000-06-01 |
| 2017-03-16 | 2017-03-01 |
+------------+---------------------------+
TIME_FORMAT() 可以类似地使用。以下示例生成秒部分设置为 00 的时间值:
mysql> `SELECT t1, TIME_FORMAT(t1,'%H:%i:00') FROM time_val;`
+----------+----------------------------+
| t1 | TIME_FORMAT(t1,'%H:%i:00') |
+----------+----------------------------+
| 15:00:00 | 15:00:00 |
| 05:01:30 | 05:01:00 |
| 12:30:20 | 12:30:00 |
+----------+----------------------------+
另一种构造时间值的方法是将日期部分提取函数与 CONCAT() 结合使用。然而,这种方法通常比刚讨论的 DATE_FORMAT() 技术更混乱,并且有时会产生稍微不同的结果:
mysql> `SELECT d, CONCAT(YEAR(d),'-',MONTH(d),'-01') FROM date_val;`
+------------+------------------------------------+
| d | CONCAT(YEAR(d),'-',MONTH(d),'-01') |
+------------+------------------------------------+
| 1864-02-28 | 1864-2-01 |
| 1900-01-15 | 1900-1-01 |
| 1999-12-31 | 1999-12-01 |
| 2000-06-04 | 2000-6-01 |
| 2017-03-16 | 2017-3-01 |
+------------+------------------------------------+
请注意,其中一些日期的月份值只有一个数字。为确保月份具有两位数(如 ISO 格式所需),请使用 LPAD() 根据需要添加前导零:
mysql> `SELECT d, CONCAT(YEAR(d),'-',LPAD(MONTH(d),2,'0'),'-01')`
-> `FROM date_val;`
+------------+------------------------------------------------+
| d | CONCAT(YEAR(d),'-',LPAD(MONTH(d),2,'0'),'-01') |
+------------+------------------------------------------------+
| 1864-02-28 | 1864-02-01 |
| 1900-01-15 | 1900-01-01 |
| 1999-12-31 | 1999-12-01 |
| 2000-06-04 | 2000-06-01 |
| 2017-03-16 | 2017-03-01 |
+------------+------------------------------------------------+
Recipe 8.18 展示了解决从非完全 ISO 日期生成 ISO 日期的问题的其他方法。
可以使用类似于创建DATE值的方法从小时、分钟和秒值生成TIME值。例如,要修改TIME值以使其秒部分为00,请提取小时和分钟部分,然后使用CONCAT()重新组合它们:
mysql> `SELECT t1,`
-> `CONCAT(LPAD(HOUR(t1),2,'0'),':',LPAD(MINUTE(t1),2,'0'),':00')`
-> `AS recombined`
-> `FROM time_val;`
+----------+------------+
| t1 | recombined |
+----------+------------+
| 15:00:00 | 15:00:00 |
| 05:01:30 | 05:01:00 |
| 12:30:20 | 12:30:00 |
+----------+------------+
要从单独的日期和时间值生成组合的日期时间值,只需将它们用空格分隔连接起来:
mysql> `SET @d = '2009-06-03', @t = '16:15:08';`
mysql> `SELECT @d, @t, CONCAT(@d,' ',@t);`
+------------+----------+---------------------+
| @d | @t | CONCAT(@d,' ',@t) |
+------------+----------+---------------------+
| 2009-06-03 | 16:15:08 | 2009-06-03 16:15:08 |
+------------+----------+---------------------+
8.11 将时间值和基本单位之间进行转换
问题
要将诸如时间或日期之类的临时值转换为基本单位,例如秒或天。这通常对执行时间算术运算(参见 Recipe 8.12 和 Recipe 8.13)非常有用或必要。
解决方案
转换方法取决于要转换的值类型:
-
要在时间值和秒之间进行转换,请使用
TIME_TO_SEC()和SEC_TO_TIME()函数。 -
要在日期值和天之间进行转换,请使用
TO_DAYS()和FROM_DAYS()函数。 -
要在日期时间值和秒之间进行转换,请使用
UNIX_TIMESTAMP()和FROM_UNIXTIME()函数。
讨论
以下讨论展示了如何将多种类型的时间值转换为基本单位,反之亦然。
在时间和秒之间进行转换
TIME值是较简单单位(秒)的专业表示。要在它们之间进行转换,请使用TIME_TO_SEC()和SEC_TO_TIME()函数。
TIME_TO_SEC()将TIME值转换为相应的秒数,SEC_TO_TIME()则反之。以下语句演示了如何简单地进行双向转换:
mysql> `SELECT t1,`
-> `TIME_TO_SEC(t1) AS 'TIME to seconds',`
-> `SEC_TO_TIME(TIME_TO_SEC(t1)) AS 'TIME to seconds to TIME'`
-> `FROM time_val;`
+----------+-----------------+-------------------------+
| t1 | TIME to seconds | TIME to seconds to TIME |
+----------+-----------------+-------------------------+
| 15:00:00 | 54000 | 15:00:00 |
| 05:01:30 | 18090 | 05:01:30 |
| 12:30:20 | 45020 | 12:30:20 |
+----------+-----------------+-------------------------+
要将时间值表示为分钟、小时或天,请执行适当的除法:
mysql> `SELECT t1,`
-> `TIME_TO_SEC(t1) AS 'seconds',`
-> `TIME_TO_SEC(t1)/60 AS 'minutes',`
-> `TIME_TO_SEC(t1)/(60*60) AS 'hours',`
-> `TIME_TO_SEC(t1)/(24*60*60) AS 'days'`
-> `FROM time_val;`
+----------+---------+----------+---------+--------+
| t1 | seconds | minutes | hours | days |
+----------+---------+----------+---------+--------+
| 15:00:00 | 54000 | 900.0000 | 15.0000 | 0.6250 |
| 05:01:30 | 18090 | 301.5000 | 5.0250 | 0.2094 |
| 12:30:20 | 45020 | 750.3333 | 12.5056 | 0.5211 |
+----------+---------+----------+---------+--------+
如果您希望整数值不包含小数部分,请在除法结果上使用FLOOR()。
如果将TIME_TO_SEC()传递给日期时间值,则会提取时间部分并丢弃日期部分。这提供了从DATETIME(或TIMESTAMP)值中提取时间的另一种方法,除了已在 Recipe 8.9 中讨论过的方法之外:
mysql> `SELECT dt,`
-> `TIME_TO_SEC(dt) AS 'time part in seconds',`
-> `SEC_TO_TIME(TIME_TO_SEC(dt)) AS 'time part as TIME'`
-> `FROM datetime_val;`
+---------------------+----------------------+-------------------+
| dt | time part in seconds | time part as TIME |
+---------------------+----------------------+-------------------+
| 1970-01-01 00:00:00 | 0 | 00:00:00 |
| 1999-12-31 09:00:00 | 32400 | 09:00:00 |
| 2000-06-04 15:45:30 | 56730 | 15:45:30 |
| 2017-03-16 12:30:15 | 45015 | 12:30:15 |
+---------------------+----------------------+-------------------+
在日期和天之间进行转换
如果您有一个日期但想要一个以天为单位的值,或反之,请使用TO_DAYS()和FROM_DAYS()函数。如果可以接受丢失时间部分,也可以将日期时间值转换为天自从公元前 0 年以来。
TO_DAYS()将日期转换为相应的天数,FROM_DAYS()则反之:
mysql> `SELECT d,`
-> `TO_DAYS(d) AS 'DATE to days',`
-> `FROM_DAYS(TO_DAYS(d)) AS 'DATE to days to DATE'`
-> `FROM date_val;`
+------------+--------------+----------------------+
| d | DATE to days | DATE to days to DATE |
+------------+--------------+----------------------+
| 1864-02-28 | 680870 | 1864-02-28 |
| 1900-01-15 | 693975 | 1900-01-15 |
| 1999-12-31 | 730484 | 1999-12-31 |
| 2000-06-04 | 730640 | 2000-06-04 |
| 2017-03-16 | 736769 | 2017-03-16 |
+------------+--------------+----------------------+
使用TO_DAYS()时,最好遵循MySQL 参考手册的建议,避免使用格里高利历(1582 年之前)开始的DATE值。在该日期之前的日历年和月份长度的变化使得谈论day 0
的值变得困难。这与TIME_TO_SEC()不同,后者在TIME值和结果秒值之间的对应关系是明显的,并且具有 0 秒的有意义参考点。
如果你向TO_DAYS()传递一个日期和时间值,它将提取日期部分并丢弃时间部分。这提供了从DATETIME(或TIMESTAMP)值中提取日期的另一种方法,除了已在配方 8.9 中讨论过的方法之外:
mysql> `SELECT dt,`
-> `TO_DAYS(dt) AS 'date part in days',`
-> `FROM_DAYS(TO_DAYS(dt)) AS 'date part as DATE'`
-> `FROM datetime_val;`
+---------------------+-------------------+-------------------+
| dt | date part in days | date part as DATE |
+---------------------+-------------------+-------------------+
| 1970-01-01 00:00:00 | 719528 | 1970-01-01 |
| 1999-12-31 09:00:00 | 730484 | 1999-12-31 |
| 2000-06-04 15:45:30 | 730640 | 2000-06-04 |
| 2017-03-16 12:30:15 | 736769 | 2017-03-16 |
+---------------------+-------------------+-------------------+
在日期和时间值与秒之间进行转换
对于DATETIME或TIMESTAMP值,它们在TIMESTAMP数据类型的范围内(从 1970 年初部分到 2038 年),UNIX_TIMESTAMP()和FROM_UNIXTIME()函数将秒数转换为 1970 年初以来经过的秒数,转换为秒比转换为天提供了更高的日期和时间值精度,尽管转换的值范围更为有限(TIME_TO_SEC()不适用于此,因为它丢弃日期):
mysql> `SELECT dt,`
-> `UNIX_TIMESTAMP(dt) AS seconds,`
-> `FROM_UNIXTIME(UNIX_TIMESTAMP(dt)) AS timestamp`
-> `FROM datetime_val;`
+---------------------+------------+---------------------+
| dt | seconds | timestamp |
+---------------------+------------+---------------------+
| 1970-01-01 00:00:00 | 21600 | 1970-01-01 00:00:00 |
| 1999-12-31 09:00:00 | 946652400 | 1999-12-31 09:00:00 |
| 2000-06-04 15:45:30 | 960151530 | 2000-06-04 15:45:30 |
| 2017-03-16 12:30:15 | 1489685415 | 2017-03-16 12:30:15 |
+---------------------+------------+---------------------+
函数名称中的UNIX
与 1970 年开始的适用值范围之间存在关系:Unix 纪元
始于1970-01-01 00:00:00 UTC。纪元是时间零点,或在 Unix 系统中测量时间的参考点。因此,你可能会发现前面的例子显示datetime_val表中第一个值的UNIX_TIMESTAMP()值为21600为什么不是0?这种明显的不一致是由于 MySQL 服务器将UNIX_TIMESTAMP()参数解释为客户端本地时区的值,并将其转换为 UTC(参见配方 8.4)。我们的服务器位于美国中部时区,比 UTC 西 6 小时(21600 秒)。根据时区解释DATETIME和数字,时间戳不会改变。将会话时区更改为'+00:00'以获取 UTC 时间,然后再次运行查询以观察不同的结果:
mysql> `set time_zone = '+00:00';`
mysql> `SELECT dt,`
-> `UNIX_TIMESTAMP(dt) AS seconds,`
-> `FROM_UNIXTIME(UNIX_TIMESTAMP(dt)) AS timestamp`
-> `FROM datetime_val;`
+---------------------+------------+---------------------+
| dt | seconds | timestamp |
+---------------------+------------+---------------------+
| 1970-01-01 00:00:00 | 0 | 1970-01-01 00:00:00 |
| 1999-12-31 09:00:00 | 946630800 | 1999-12-31 09:00:00 |
| 2000-06-04 15:45:30 | 960133530 | 2000-06-04 15:45:30 |
| 2017-03-16 12:30:15 | 1489667415 | 2017-03-16 12:30:15 |
+---------------------+------------+---------------------+
UNIX_TIMESTAMP()也可以将DATE值转换为秒。它将这些值视为具有隐式时间部分00:00:00:
mysql> `SELECT`
-> `CURDATE(),`
-> `UNIX_TIMESTAMP(CURDATE()),`
-> `FROM_UNIXTIME(UNIX_TIMESTAMP(CURDATE()))\G`
*************************** 1\. row ***************************
CURDATE(): 2021-11-28
UNIX_TIMESTAMP(CURDATE()): 1638046800
FROM_UNIXTIME(UNIX_TIMESTAMP(CURDATE())): 2021-11-28 00:00:00
8.12 计算日期或时间间隔
问题
想知道两个日期或时间之间的间隔长短;也就是说,它们之间的间隔。
解决方案
要计算间隔,请使用一个时间差函数,或将你的值转换为基本单位并取差值。允许的函数取决于你想知道间隔的值的类型。
讨论
下面的讨论展示了执行区间计算的几种方法。
使用时间差函数计算间隔
要计算两个日期值之间的天数间隔,使用 DATEDIFF() 函数:
mysql> `SET @d1 = '2010-01-01', @d2 = '2009-12-01';`
mysql> `SELECT DATEDIFF(@d1,@d2) AS 'd1 - d2', DATEDIFF(@d2,@d1) AS 'd2 - d1';`
+---------+---------+
| d1 - d2 | d2 - d1 |
+---------+---------+
| 31 | -31 |
+---------+---------+
DATEDIFF() 也适用于日期和时间值,但会忽略时间部分。这使其适用于为 DATE、DATETIME 或 TIMESTAMP 值生成日间隔。
要将 TIME 值作为另一个 TIME 值的间隔计算,使用 TIMEDIFF() 函数:
mysql> `SET @t1 = '12:00:00', @t2 = '16:30:00';`
mysql> `SELECT TIMEDIFF(@t1,@t2) AS 't1 - t2', TIMEDIFF(@t2,@t1) AS 't2 - t1';`
+-----------+----------+
| t1 - t2 | t2 - t1 |
+-----------+----------+
| -04:30:00 | 04:30:00 |
+-----------+----------+
TIMEDIFF() 也适用于日期和时间值。也就是说,它接受时间或日期和时间值,但参数的类型必须匹配。
将时间间隔表示为 TIME 值可以使用 Recipe 8.9 中展示的技术来分解其组件。例如,要按其组成的小时、分钟和秒值表达时间间隔,可以使用 HOUR()、MINUTE() 和 SECOND() 函数计算时间间隔的子部分。(别忘了,如果你的间隔可能为负数,你必须考虑这一点。)以下 SQL 语句展示了如何确定 time_val 表的 t1 和 t2 列之间间隔的组成部分:
mysql> `SELECT t1, t2,`
-> `TIMEDIFF(t2,t1) AS 't2 - t1 as TIME',`
-> `IF(TIMEDIFF(t2,t1) >= 0,'+','-') AS sign,`
-> `HOUR(TIMEDIFF(t2,t1)) AS hour,`
-> `MINUTE(TIMEDIFF(t2,t1)) AS minute,`
-> `SECOND(TIMEDIFF(t2,t1)) AS second`
-> `FROM time_val;`
+----------+----------+-----------------+------+------+--------+--------+
| t1 | t2 | t2 - t1 as TIME | sign | hour | minute | second |
+----------+----------+-----------------+------+------+--------+--------+
| 15:00:00 | 15:00:00 | 00:00:00 | + | 0 | 0 | 0 |
| 05:01:30 | 02:30:20 | -02:31:10 | - | 2 | 31 | 10 |
| 12:30:20 | 17:30:45 | 05:00:25 | + | 5 | 0 | 25 |
+----------+----------+-----------------+------+------+--------+--------+
如果你使用日期或日期和时间值,TIMESTAMPDIFF() 函数提供了另一种计算间隔的方式。它使你能够指定应以哪些单位表示间隔:
TIMESTAMPDIFF(*`unit`*,*`val1`*,*`val2`*)
unit 是区间单位,val1 和 val2 是计算区间的值。使用 TIMESTAMPDIFF(),你可以以多种不同的方式表示区间:
mysql> `SET @dt1 = '1900-01-01 00:00:00', @dt2 = '1910-01-01 00:00:00';`
mysql> `SELECT`
-> `TIMESTAMPDIFF(MINUTE,@dt1,@dt2) AS minutes,`
-> `TIMESTAMPDIFF(HOUR,@dt1,@dt2) AS hours,`
-> `TIMESTAMPDIFF(DAY,@dt1,@dt2) AS days,`
-> `TIMESTAMPDIFF(WEEK,@dt1,@dt2) AS weeks,`
-> `TIMESTAMPDIFF(YEAR,@dt1,@dt2) AS years;`
+---------+-------+------+-------+-------+
| minutes | hours | days | weeks | years |
+---------+-------+------+-------+-------+
| 5258880 | 87648 | 3652 | 521 | 10 |
+---------+-------+------+-------+-------+
允许的 unit 规范器包括 MICROSECOND、SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、QUARTER 或 YEAR。请注意,每个都是单数形式,而不是复数形式。
注意 TIMESTAMPDIFF() 的这些属性:
-
如果第一个时间值大于第二个时间值,则其值为负数,这与
DATEDIFF()和TIMEDIFF()的参数顺序相反。 -
尽管其名称中包含
TIMESTAMP,但TIMESTAMPDIFF()的参数并不限于TIMESTAMP数据类型的范围。
使用基本单位进行时间间隔计算
要计算时间值对之间的秒数间隔,可以使用 TIME_TO_SEC() 将它们转换为秒,并取其差值。要将结果的区间表达为 TIME 值,将其传递给 SEC_TO_TIME()。以下语句计算了 time_val 表的 t1 和 t2 列之间的区间,同时用秒和 TIME 值表示每个区间:
mysql> `SELECT t1, t2,`
-> `TIME_TO_SEC(t2) - TIME_TO_SEC(t1) AS 't2 - t1 (in seconds)',`
-> `SEC_TO_TIME(TIME_TO_SEC(t2) - TIME_TO_SEC(t1)) AS 't2 - t1 (as TIME)'`
-> `FROM time_val;`
+----------+----------+----------------------+-------------------+
| t1 | t2 | t2 - t1 (in seconds) | t2 - t1 (as TIME) |
+----------+----------+----------------------+-------------------+
| 15:00:00 | 15:00:00 | 0 | 00:00:00 |
| 05:01:30 | 02:30:20 | -9070 | -02:31:10 |
| 12:30:20 | 17:30:45 | 18025 | 05:00:25 |
+----------+----------+----------------------+-------------------+
使用基本单位进行日期或日期和时间间隔计算
当你计算日期之间的间隔时,通过将两个日期转换为相对于给定参考点的共同单位,并取其差值,你的值范围决定了可用的转换方式:
-
DATE、DATETIME或TIMESTAMP值可追溯到1970-01-0100:00:00UTC——Unix 纪元——可以转换为自纪元以来经过的秒数。在这个范围内的日期,您可以计算精确到一秒的间隔。 -
自 1582 年格里高利历开始的旧日期可以转换为日值,并用于计算以天为间隔的间隔。
-
早于这两个参考点的日期提出了更多问题。在这种情况下,您可能会发现您的编程语言提供了在 SQL 中不可用或难以执行的计算。如果是这样,请考虑直接从 API 语言中处理日期值。例如,Date::Calc 和 Date::Manip 模块可用于 Perl 脚本。
要计算日期或日期时间值之间的天数间隔,请使用TO_DAYS()将它们转换为天数,并取差值。对于一周的间隔,做同样的事情并将结果除以七:
mysql> `SET @days = TO_DAYS('1884-01-01') - TO_DAYS('1883-06-05');`
mysql> `SELECT @days AS days, @days/7 AS weeks;`
+------+---------+
| days | weeks |
+------+---------+
| 210 | 30.0000 |
+------+---------+
不能通过简单的除法将天数转换为月份或年份,因为这些单位的长度不同。要产生以这些单位表示的日期间隔,请使用之前在本配方中讨论过的TIMESTAMPDIFF()。
对于在 1970 年部分至 2038 年之间的TIMESTAMP范围内发生的日期时间值,您可以使用UNIX_TIMESTAMP()函数确定以秒为分辨率的间隔。对于以其他单位表示的间隔,可以轻松地将秒转换为分钟、小时、天或周,就像这个表达式展示的那样,这个表达式显示了两周之间的日期:
mysql> `SET @dt1 = '1984-01-01 09:00:00';`
mysql> `SET @dt2 = @dt1 + INTERVAL 14 DAY;`
mysql> `SET @interval = UNIX_TIMESTAMP(@dt2) - UNIX_TIMESTAMP(@dt1);`
mysql> `SELECT @interval AS seconds,`
-> `@interval / 60 AS minutes,`
-> `@interval / (60 * 60) AS hours,`
-> `@interval / (24 * 60 * 60) AS days,`
-> `@interval / (7 * 24 * 60 * 60) AS weeks;`
+---------+------------+----------+---------+--------+
| seconds | minutes | hours | days | weeks |
+---------+------------+----------+---------+--------+
| 1209600 | 20160.0000 | 336.0000 | 14.0000 | 2.0000 |
+---------+------------+----------+---------+--------+
如果您更喜欢没有小数部分的整数值,请在除法结果上使用FLOOR()。
对于超出TIMESTAMP范围的值,这种间隔计算方法更为通用(但更为混乱):
-
取值的日期部分之间的天数差,乘以 24 × 60 × 60 以转换为秒。
-
根据值的时间部分之间的秒数差调整结果。
这是一个示例,使用两个日期时间值,它们相隔略少于三天:
mysql> `SET @dt1 = '1800-02-14 07:30:00';`
mysql> `SET @dt2 = '1800-02-17 06:30:00';`
mysql> `SET @interval =`
-> `((TO_DAYS(@dt2) - TO_DAYS(@dt1)) * 24*60*60)`
-> `+ TIME_TO_SEC(@dt2) - TIME_TO_SEC(@dt1);`
mysql> `SELECT @interval AS seconds, SEC_TO_TIME(@interval) AS TIME;`
+---------+----------+
| seconds | TIME |
+---------+----------+
| 255600 | 71:00:00 |
+---------+----------+
8.13 添加日期或时间值
问题
您想要添加时间值。例如,您想要在时间中添加给定数量的秒,或者确定三周后的日期是什么。
解决方案
要添加日期或时间值,您有几个选项:
-
使用其中一种时间添加函数。
-
使用
+INTERVAL或-INTERVAL运算符。 -
将值转换为基本单位,并求和。
适用的函数或运算符取决于值的类型。
讨论
以下讨论展示了几种添加时间值的方法。
使用时间添加函数或运算符添加时间值
要将时间添加到时间或日期时间值中,请使用ADDTIME()函数:
mysql> `SET @t1 = '12:00:00', @t2 = '15:30:00';`
mysql> `SELECT ADDTIME(@t1,@t2);`
+------------------+
| ADDTIME(@t1,@t2) |
+------------------+
| 27:30:00 |
+------------------+
mysql> `SET @dt = '1984-03-01 12:00:00', @t = '12:00:00';`
mysql> `SELECT ADDTIME(@dt,@t);`
+----------------------------+
| TIMESTAMP(@d,@t) |
+----------------------------+
| 1984-03-01 15:30:00.000000 |
+----------------------------+
要将时间添加到日期或日期时间值中,请使用TIMESTAMP()函数:
mysql> `SET @d = '1984-03-01', @t = '15:30:00';`
mysql> `SELECT TIMESTAMP(@d,@t);`
+---------------------+
| TIMESTAMP(@d,@t) |
+---------------------+
| 1984-03-01 15:30:00 |
+---------------------+
mysql> `SET @dt = '1984-03-01 12:00:00', @t = '12:00:00';`
mysql> `SELECT TIMESTAMP(@dt,@t);`
+----------------------------+
| TIMESTAMP(@dt,@t) |
+----------------------------+
| 1984-03-02 00:00:00.000000 |
+----------------------------+
MySQL 还提供了DATE_ADD()和DATE_SUB()函数,用于向日期添加间隔或从日期减去间隔。每个函数接受一个日期(或日期时间)值d和一个使用以下语法表示的间隔:
DATE_ADD(d,INTERVAL *`val unit`*)
DATE_SUB(d,INTERVAL *`val unit`*)
+ INTERVAL和- INTERVAL运算符类似:
d + INTERVAL *`val unit`*
d - INTERVAL *`val unit`*
unit是间隔单位,val是指示单位数量的表达式。一些常见的单位指示器包括SECOND、MINUTE、HOUR、DAY、MONTH和YEAR。请注意,每个单位都是单数形式,而不是复数形式。(查看MySQL 参考手册获取完整列表。)
使用DATE_ADD()或DATE_SUB()执行日期算术运算,例如这些操作:
-
确定从今天起三天后的日期:
mysql> `SELECT CURDATE(), DATE_ADD(CURDATE(),INTERVAL 3 DAY);` ++------------+------------------------------------+ | CURDATE() | DATE_ADD(CURDATE(),INTERVAL 3 DAY) | +------------+------------------------------------+ | 2021-11-24 | 2021-11-27 | +------------+------------------------------------+ -
找到一周前的日期:
mysql> `SELECT CURDATE(), DATE_SUB(CURDATE(),INTERVAL 1 WEEK);` +------------+-------------------------------------+ | CURDATE() | DATE_SUB(CURDATE(),INTERVAL 1 WEEK) | +------------+-------------------------------------+ | 2021-11-24 | 2021-11-17 | +------------+-------------------------------------+ -
对于需要知道日期和时间的问题,请从
DATETIME或TIMESTAMP值开始。要回答“60 小时后是什么时间?”这个问题,请执行以下操作:mysql> `SELECT NOW(), DATE_ADD(NOW(),INTERVAL 60 HOUR);` +---------------------+----------------------------------+ | NOW() | DATE_ADD(NOW(),INTERVAL 60 HOUR) | +---------------------+----------------------------------+ | 2021-11-24 22:44:19 | 2021-11-27 10:44:19 | +---------------------+----------------------------------+ -
一些间隔指示器具有日期和时间部分。以下将 14.5 小时添加到当前日期和时间:
mysql> `SELECT NOW(), DATE_ADD(NOW(),INTERVAL '14:30' HOUR_MINUTE);` +---------------------+----------------------------------------------+ | NOW() | DATE_ADD(NOW(),INTERVAL '14:30' HOUR_MINUTE) | +---------------------+----------------------------------------------+ | 2021-11-24 22:46:37 | 2021-11-25 13:16:37 | +---------------------+----------------------------------------------+类似地,添加 3 天和 4 小时会产生以下结果:
mysql> `SELECT NOW(), DATE_ADD(NOW(),INTERVAL '3 4' DAY_HOUR);` +---------------------+-----------------------------------------+ | NOW() | DATE_ADD(NOW(),INTERVAL '3 4' DAY_HOUR) | +---------------------+-----------------------------------------+ | 2021-11-24 22:47:15 | 2021-11-28 02:47:15 | +---------------------+-----------------------------------------+
DATE_ADD()和DATE_SUB()可以互换使用,因为一个与另一个在间隔值的符号方面是相同的。对于任何日期值d,这两个表达式是等价的:
DATE_ADD(d,INTERVAL -3 MONTH)
DATE_SUB(d,INTERVAL 3 MONTH)
你也可以使用+ INTERVAL或- INTERVAL运算符执行日期间隔的加法或减法:
mysql> `SELECT CURDATE(), CURDATE() + INTERVAL 1 YEAR;`
+------------+-----------------------------+
| CURDATE() | CURDATE() + INTERVAL 1 YEAR |
+------------+-----------------------------+
| 2021-11-24 | 2022-11-24 |
+------------+-----------------------------+
mysql> `SELECT NOW(), NOW() - INTERVAL '1 12' DAY_HOUR;`
+---------------------+----------------------------------+
| NOW() | NOW() - INTERVAL '1 12' DAY_HOUR |
+---------------------+----------------------------------+
| 2021-11-24 22:48:31 | 2021-11-23 10:48:31 |
+---------------------+----------------------------------+
TIMESTAMPADD()是另一种用于向日期或日期时间值添加间隔的函数。它的参数与DATE_ADD()类似,以下等式成立:
TIMESTAMPADD(*`unit`*,*`interval`*,d) = DATE_ADD(d,INTERVAL *`interval`* *`unit`*)
使用基本单位添加时间值
另一种向日期或日期时间值添加间隔的方法是通过执行转换为基本单位的函数进行时间“移位”。有关适用函数的背景信息,请参阅 Recipe 8.11。
使用基本单位添加时间值
使用基本单位添加时间类似于计算时间间隔,不同之处在于计算的是总和而不是差值。要将一个以秒为单位的间隔值添加到TIME值中,将TIME转换为秒,以便两个值都表示为相同的单位,然后将这些值相加,并将结果转换回TIME。例如,两小时等于 7,200 秒(2 × 60 × 60),因此以下语句将两小时添加到time_val表中每个t1值:
mysql> `SELECT t1,`
-> `SEC_TO_TIME(TIME_TO_SEC(t1) + 7200) AS 't1 plus 2 hours'`
-> `FROM time_val;`
+----------+-----------------+
| t1 | t1 plus 2 hours |
+----------+-----------------+
| 15:00:00 | 17:00:00 |
| 05:01:30 | 07:01:30 |
| 12:30:20 | 14:30:20 |
+----------+-----------------+
如果间隔本身表示为TIME,在将这些值相加之前,它也应转换为秒。以下示例计算time_val表中每行的两个TIME值的总和:
mysql> `SELECT t1, t2,`
-> `TIME_TO_SEC(t1) + TIME_TO_SEC(t2)`
-> `AS 't1 + t2 (in seconds)',`
-> `SEC_TO_TIME(TIME_TO_SEC(t1) + TIME_TO_SEC(t2))`
-> `AS 't1 + t2 (as TIME)'`
-> `FROM time_val;`
+----------+----------+----------------------+-------------------+
| t1 | t2 | t1 + t2 (in seconds) | t1 + t2 (as TIME) |
+----------+----------+----------------------+-------------------+
| 15:00:00 | 15:00:00 | 108000 | 30:00:00 |
| 05:01:30 | 02:30:20 | 27110 | 07:31:50 |
| 12:30:20 | 17:30:45 | 108065 | 30:01:05 |
+----------+----------+----------------------+-------------------+
重要的是要认识到 MySQL TIME值表示经过的时间,而不是一天中的时间,因此它们在达到 24 小时后不会重置为 0。您可以在上一条语句的第一和第三输出行中看到这一点。要生成一天中的时间值,可以在将秒值转换回TIME值之前使用取模操作来执行 24 小时循环:一天中的秒数是 24 × 60 × 60,即 86,400。要将任何秒值s转换为在 24 小时范围内,使用MOD()函数或%取模运算符,如下所示:
MOD(s,86400)
s % 86400
s MOD 86400
这三个表达式是等效的。将它们中的第一个应用于前面示例中的时间计算,得到以下结果:
mysql> `SELECT t1, t2,`
-> `MOD(TIME_TO_SEC(t1) + TIME_TO_SEC(t2), 86400)`
-> `AS 't1 + t2 (in seconds)',`
-> `SEC_TO_TIME(MOD(TIME_TO_SEC(t1) + TIME_TO_SEC(t2), 86400))`
-> `AS 't1 + t2 (as TIME)'`
-> `FROM time_val;`
+----------+----------+----------------------+-------------------+
| t1 | t2 | t1 + t2 (in seconds) | t1 + t2 (as TIME) |
+----------+----------+----------------------+-------------------+
| 15:00:00 | 15:00:00 | 21600 | 06:00:00 |
| 05:01:30 | 02:30:20 | 27110 | 07:31:50 |
| 12:30:20 | 17:30:45 | 21665 | 06:01:05 |
+----------+----------+----------------------+-------------------+
注意
TIME列的允许范围是-838:59:59到838:59:59(即-3020399到3020399秒)。但是,TIME 表达式的范围可以更大,因此当您添加时间值时,可能会产生超出此范围的结果,并且不能直接存储到TIME列中。
或者可以使用TIMESTAMPDIFF()函数来超出TIMEDIFF()函数的限制。
mysql> `SELECT NOW(),TIMESTAMPDIFF(minute,now(), '2023-01-01 00:00:00');`
+---------------------+----------------------------------------------------+
| NOW() | TIMESTAMPDIFF(minute,now(), '2023-01-01 00:00:00') |
+---------------------+----------------------------------------------------+
| 2022-03-07 06:38:40 | 431601 |
+---------------------+----------------------------------------------------+
mysql> `SELECT NOW(),TIMESTAMPDIFF(day,now(), '2023-01-01 00:00:00');`
+---------------------+-------------------------------------------------+
| NOW() | TIMESTAMPDIFF(day,now(), '2023-01-01 00:00:00') |
+---------------------+-------------------------------------------------+
| 2022-03-07 06:38:50 | 299 |
+---------------------+-------------------------------------------------+
使用基本单位添加到日期或日期时间值
将日期或日期时间值转换为基本单位后,可以进行偏移以生成其他日期。例如,要将日期向前或向后移动一周(七天),可以使用TO_DAYS()和FROM_DAYS()函数:
mysql> `SET @d = '1980-01-01';`
mysql> `SELECT @d AS date,`
-> `FROM_DAYS(TO_DAYS(@d) + 7) AS 'date + 1 week',`
-> `FROM_DAYS(TO_DAYS(@d) - 7) AS 'date - 1 week';`
+------------+---------------+---------------+
| date | date + 1 week | date - 1 week |
+------------+---------------+---------------+
| 1980-01-01 | 1980-01-08 | 1979-12-25 |
+------------+---------------+---------------+
如果你不介意时间部分被截断,TO_DAYS()也可以将日期时间值转换为天数。
要保留时间部分,可以使用UNIX_TIMESTAMP()和FROM_UNIXTIME(),只要初始值和结果值都在TIMESTAMP值的允许范围内(从 1970 年到 2038 年部分)即可。以下语句将DATETIME值向前或向后移动一个小时(3,600 秒):
mysql> `SET @dt = '1980-01-01 09:00:00';`
mysql> `SELECT @dt AS datetime,`
-> `FROM_UNIXTIME(UNIX_TIMESTAMP(@dt) + 3600) AS 'datetime + 1 hour',`
-> `FROM_UNIXTIME(UNIX_TIMESTAMP(@dt) - 3600) AS 'datetime - 1 hour';`
+---------------------+---------------------+---------------------+
| datetime | datetime + 1 hour | datetime - 1 hour |
+---------------------+---------------------+---------------------+
| 1980-01-01 09:00:00 | 1980-01-01 10:00:00 | 1980-01-01 08:00:00 |
+---------------------+---------------------+---------------------+
8.14 计算年龄
问题
想要知道某人多大年龄。
解决方案
这是一个日期算术问题。它等同于计算日期间隔,但有所不同。对于年龄以年为单位的计算,需要考虑起始日期和结束日期在日历年中的相对位置。对于以月为单位的年龄计算,还需要考虑月份和日期在月份中的位置。
讨论
年龄确定是一种日期间隔计算类型。然而,您不能简单地计算天数差并除以 365,因为闰年会影响计算结果。(从 1995-03-01 到 1996-02-29 是 365 天,但在年龄计算中不算一年。)除以 365.25 略微更精确,但对于所有日期仍然不正确。
要计算年龄,请使用TIMESTAMPDIFF()函数。向其传递一个出生日期、当前日期和希望年龄以什么单位表达的参数:
TIMESTAMPDIFF(*`unit`*,*`birth`*,*`current`*)
TIMESTAMPDIFF()处理必要的计算,以调整不同月份和年份长度以及日期在日历年中的相对位置。假设一个sibling表列出了 Ilayda 和她妹妹 Lara 的出生日期。
mysql> `SELECT * FROM sibling;`
+--------+------------+
| name | birth |
+--------+------------+
| Ilayda | 2002-12-17 |
| Lara | 2009-06-03 |
+--------+------------+
使用 TIMESTAMPDIFF(),您可以回答以下类似的问题:
-
Alkin 的孩子今天多少岁、多少个月和多少天了?
mysql> `SELECT name,DATE_FORMAT(birth,'%Y-%m-%d') as dob,` -> `DATE_FORMAT(NOW(),'%Y-%m-%d') as today,` -> `TIMESTAMPDIFF( YEAR, birth, NOW() ) as age_years',` -> `FLOOR( TIMESTAMPDIFF( DAY, birth, now() ) % 30.4375 ) as age_days` -> `FROM sibling;` +--------+------------+------------+-----------+------------+----------+ | name | dob | today | age_years | age_months | age_days | +--------+------------+------------+-----------+------------+----------+ | Ilayda | 2002-12-17 | 2022-03-07 | 19 | 2 | 19 | | Lara | 2009-06-03 | 2022-03-07 | 12 | 9 | 3 | +--------+------------+------------+-----------+------------+----------+ -
当 Lara 出生时,Ilayda 多大了,以年和月计算?
mysql> `SELECT name, birth, '2009-06-03' AS 'Lara\'s birth',` -> `TIMESTAMPDIFF(YEAR,birth,'2009-06-03') AS 'age in years',` -> `TIMESTAMPDIFF( MONTH, birth,'2009-06-09' ) % 12 as age_months,` -> `FLOOR( TIMESTAMPDIFF( DAY, birth,'2009-06-09' ) % 30.4375 ) as age_days` -> `FROM sibling WHERE name <> 'Lara';` +--------+------------+--------------+-----------+------------+----------+ | name | birth | Lara's birth | age_years | age_months | age_days | +--------+------------+--------------+-----------+------------+----------+ | Ilayda | 2002-12-17 | 2009-06-09 | 6 | 5 | 22 | +--------+------------+--------------+-----------+------------+----------+
欲了解更多关于使用这些函数进行日期计算的信息,请参阅 MySQL 参考手册。
8.15 寻找月份的第一天、最后一天或长度
问题
给定一个日期,您想确定该日期所在月份的第一天或最后一天的日期,或者与该日期相隔 n 个月的月份的第一天或最后一天。相关问题是确定某月的天数。
解决方案
要确定某月的第一天的日期,请使用日期移动(日期算术的一种应用)。要确定最后一天的日期,请使用 LAST_DAY() 函数。要确定某月的天数,找到其最后一天的日期,并将其作为 DAYOFMONTH() 的参数使用。
讨论
有时您有一个参考日期,并希望达到一个与参考日期没有固定关系的目标日期。例如,当前月份的第一天或最后一天不是从当前日期固定天数的。
要找到给定日期的月份的第一天,请将日期向后移动比其 DAYOFMONTH() 值少一天:
mysql> `SELECT d, DATE_SUB(d,INTERVAL DAYOFMONTH(d)-1 DAY) AS '1st of month'`
-> `FROM date_val;`
+------------+--------------+
| d | 1st of month |
+------------+--------------+
| 1864-02-28 | 1864-02-01 |
| 1900-01-15 | 1900-01-01 |
| 1999-12-31 | 1999-12-01 |
| 2000-06-04 | 2000-06-01 |
| 2017-03-16 | 2017-03-01 |
+------------+--------------+
通常情况下,要找到距离给定日期 n 个月的任何月份的第一天,计算该日期的月初,并将结果移动 n 个月:
DATE_ADD(DATE_SUB(d,INTERVAL DAYOFMONTH(d)-1 DAY),INTERVAL *`n`* MONTH)
例如,要找到相对于给定日期的前一个和后一个月的第一天,n 分别为 -1 和 1:
mysql> `SELECT d,`
-> `DATE_ADD(DATE_SUB(d,INTERVAL DAYOFMONTH(d)-1 DAY),INTERVAL -1 MONTH)`
-> `AS '1st of previous month',`
-> `DATE_ADD(DATE_SUB(d,INTERVAL DAYOFMONTH(d)-1 DAY),INTERVAL 1 MONTH)`
-> `AS '1st of following month'`
-> `FROM date_val;`
+------------+-----------------------+------------------------+
| d | 1st of previous month | 1st of following month |
+------------+-----------------------+------------------------+
| 1864-02-28 | 1864-01-01 | 1864-03-01 |
| 1900-01-15 | 1899-12-01 | 1900-02-01 |
| 1999-12-31 | 1999-11-01 | 2000-01-01 |
| 2000-06-04 | 2000-05-01 | 2000-07-01 |
| 2017-03-16 | 2017-02-01 | 2017-04-01 |
+------------+-----------------------+------------------------+
对于给定日期,找到该月的最后一天更容易,因为有相应的函数:
mysql> `SELECT d, LAST_DAY(d) AS 'last of month'`
-> `FROM date_val;`
+------------+---------------+
| d | last of month |
+------------+---------------+
| 1864-02-28 | 1864-02-29 |
| 1900-01-15 | 1900-01-31 |
| 1999-12-31 | 1999-12-31 |
| 2000-06-04 | 2000-06-30 |
| 2017-03-16 | 2017-03-31 |
+------------+---------------+
对于一般情况,要找到任何日期 n 个月后的月末,先将日期移动该月数,然后将其传递给 LAST_DAY():
LAST_DAY(DATE_ADD(d,INTERVAL *`n`* MONTH))
例如,要找到相对于给定日期的前一个和后一个月的最后一天,n 分别为 -1 和 1:
mysql> `SELECT d,`
-> `LAST_DAY(DATE_ADD(d,INTERVAL -1 MONTH))`
-> `AS 'last of previous month',`
-> `LAST_DAY(DATE_ADD(d,INTERVAL 1 MONTH))`
-> `AS 'last of following month'`
-> `FROM date_val;`
+------------+------------------------+-------------------------+
| d | last of previous month | last of following month |
+------------+------------------------+-------------------------+
| 1864-02-28 | 1864-01-31 | 1864-03-31 |
| 1900-01-15 | 1899-12-31 | 1900-02-28 |
| 1999-12-31 | 1999-11-30 | 2000-01-31 |
| 2000-06-04 | 2000-05-31 | 2000-07-31 |
| 2017-03-16 | 2017-02-28 | 2017-04-30 |
+------------+------------------------+-------------------------+
要找到某月的天数长度,请确定其最后一天的日期并使用 DAYOFMONTH() 从结果中提取日-月组件:
mysql> `SELECT d, DAYOFMONTH(LAST_DAY(d)) AS 'days in month' FROM date_val;`
+------------+---------------+
| d | days in month |
+------------+---------------+
| 1864-02-28 | 29 |
| 1900-01-15 | 31 |
| 1999-12-31 | 31 |
| 2000-06-04 | 30 |
| 2017-03-16 | 31 |
+------------+---------------+
8.16 确定日期的星期几
问题
您想知道某个日期是星期几。
解决方案
使用 DAYNAME() 函数。
讨论
要确定给定日期的星期几名称,请使用 DAYNAME():
mysql> `SELECT CURDATE(), DAYNAME(CURDATE());`
+------------+--------------------+
| CURDATE() | DAYNAME(CURDATE()) |
+------------+--------------------+
| 2021-11-24 | Wednesday |
+------------+--------------------+
DAYNAME() 经常与其他日期相关技术结合使用。例如,要确定月初的星期几,使用来自 Recipe 8.15 的月初表达式作为 DAYNAME() 的参数:
mysql> `SET @d = CURDATE();`
mysql> `SET @first = DATE_SUB(@d,INTERVAL DAYOFMONTH(@d)-1 DAY);`
mysql> `SELECT @d AS 'starting date',`
-> `@first AS '1st of month date',`
-> `DAYNAME(@first) AS '1st of month day';`
+---------------+-------------------+------------------+
| starting date | 1st of month date | 1st of month day |
+---------------+-------------------+------------------+
| 2021-11-24 | 2021-11-01 | Monday |
+---------------+-------------------+------------------+
8.17 寻找给定周的任意工作日日期
问题
您想计算给定日期所在周的某个工作日的日期。假设您想知道与 2014-07-09 相同周内的星期二的日期。
解决方案
这是日期偏移的一个应用。找出给定日期的起始星期几与所需日期之间的天数,并将日期向前或向后偏移这么多天。
讨论
本节和下一节描述了在目标日期以星期天数指定时如何将一个日期转换为另一个日期。为了解决这类问题,您需要知道星期几的值。假设您从目标日期 2014-07-09 开始。要确定该日期所在星期中的星期二的日期,计算依赖于它是星期几。如果是星期一,则添加一天得到 2014-07-10,但如果是星期三,则减去一天得到 2014-07-08。
MySQL 提供了两个在此非常有用的函数。DAYOFWEEK() 将星期日视为一周的第一天,并返回 1 到 7,分别代表星期日到星期六。(这里的示例使用了 DAYOFWEEK()。)另一种星期几的操作涉及确定星期几的名称。DAYNAME() 可以用于此操作。
决定从某天到另一天的日期的计算,取决于您起始的那天以及您想要到达的那天。我发现最容易首先将参考日期移至一周的开始的已知点,然后再向前移动:
-
将参考日期向前移动其
DAYOFWEEK()值,这总是产生前一周的星期六日期。 -
将星期六的日期向前移动一天,得到星期日的日期;向前移动两天,得到星期一的日期,依此类推。
在 SQL 中,这些操作可以针对日期 d 表达如下,其中 n 为 1 到 7,分别代表星期日到星期六的日期:
DATE_ADD(DATE_SUB(d,INTERVAL DAYOFWEEK(d) DAY),INTERVAL *`n`* DAY)
该表达式将“返回到星期六”和“前进”阶段拆分为单独的操作,但由于DATE_SUB()和DATE_ADD()的间隔都是以天为单位,该表达式可以简化为单个DATE_ADD()调用:
DATE_ADD(d,INTERVAL *`n`*-DAYOFWEEK(d) DAY)
将此公式应用于我们的 date_val 表中的日期,使用 n 为 1 表示星期日,为 7 表示星期六,以找到一周的第一天和最后一天,得到如下结果:
mysql> `SELECT d, DAYNAME(d) AS day,`
-> `DATE_ADD(d,INTERVAL 1-DAYOFWEEK(d) DAY) AS Sunday,`
-> `DATE_ADD(d,INTERVAL 7-DAYOFWEEK(d) DAY) AS Saturday`
-> `FROM date_val;`
+------------+----------+------------+------------+
| d | day | Sunday | Saturday |
+------------+----------+------------+------------+
| 1864-02-28 | Sunday | 1864-02-28 | 1864-03-05 |
| 1900-01-15 | Monday | 1900-01-14 | 1900-01-20 |
| 1999-12-31 | Friday | 1999-12-26 | 2000-01-01 |
| 2000-06-04 | Sunday | 2000-06-04 | 2000-06-10 |
| 2017-03-16 | Thursday | 2017-03-12 | 2017-03-18 |
+------------+----------+------------+------------+
要确定目标日期所在星期的某个星期几的日期,相对于目标日期进行稍微修改。首先,确定包含目标日期的星期中所需星期几的日期,然后将结果偏移到所需的星期。
计算某周的某天日期是一个问题,可以分解为一个周内日期偏移(使用刚才给出的公式)和一个周偏移。这两个操作可以按任何顺序执行,因为在周内的偏移量不受是否首先将参考日期移到另一周中的影响。例如,按照上述公式计算一个星期的星期三,n 为 4。要计算两周前的星期三的日期,可以先执行周内日期偏移,如下所示:
mysql> `SET @target =`
-> `DATE_SUB(DATE_ADD(CURDATE(),INTERVAL 4-DAYOFWEEK(CURDATE()) DAY),`
-> `INTERVAL 14 DAY);`
mysql> `SELECT CURDATE(), @target, DAYNAME(@target);`
+------------+------------+------------------+
| CURDATE() | @target | DAYNAME(@target) |
+------------+------------+------------------+
| 2021-11-24 | 2021-11-10 | Wednesday |
+------------+------------+------------------+
或者您可以先执行星期转换:
mysql> `SET @target =`
-> `DATE_ADD(DATE_SUB(CURDATE(),INTERVAL 14 DAY),`
-> `INTERVAL 4-DAYOFWEEK(CURDATE()) DAY);`
mysql> `SELECT CURDATE(), @target, DAYNAME(@target);`
+------------+------------+------------------+
| CURDATE() | @target | DAYNAME(@target) |
+------------+------------+------------------+
| 2021-11-24 | 2021-11-10 | Wednesday |
+------------+------------+------------------+
一些应用程序需要确定特定工作日的第n个实例的日期。例如,管理发薪日为每月第二个和第四个星期四的工资单,您必须知道这些日期。为了在任何给定月份执行此操作,您可以从月初日期开始并向前移动。将日期移动到该周的星期四很容易;关键是要计算向前移动多少周才能达到第二个和第四个星期四。如果月初日期在周日到周四之间的任何一天,则向前移动一周即可到达第二个星期四。如果月初日期在周五或之后,则向前移动两周。第四个星期四当然是再往后两周。
下面的 Perl 代码实现了这一逻辑,以找到 2021 年的所有发薪日。它运行一个循环,构造每年月份的第一天日期。对于每个月,它执行一个语句来确定第二个和第四个星期四的日期:
my $year = 2021;
print "MM/YYYY 2nd Thursday 4th Thursday\n";
foreach my $month (1..12)
{
my $first = sprintf ("%04d-%02d-01", $year, $month);
my ($thu2, $thu4) = $dbh->selectrow_array (qq{
SELECT
DATE_ADD(
DATE_ADD(?,INTERVAL 5-DAYOFWEEK(?) DAY),
INTERVAL IF(DAYOFWEEK(?) <= 5, 7, 14) DAY),
DATE_ADD(
DATE_ADD(?,INTERVAL 5-DAYOFWEEK(?) DAY),
INTERVAL IF(DAYOFWEEK(?) <= 5, 21, 28) DAY)
}, undef, $first, $first, $first, $first, $first, $first);
printf "%02d/%04d %s %s\n", $month, $year, $thu2, $thu4;
}
该程序产生以下输出:
MM/YYYY 2nd Thursday 4th Thursday
MM/YYYY 2nd Thursday 4th Thursday
01/2021 2021-01-14 2021-01-28
02/2021 2021-02-11 2021-02-25
03/2021 2021-03-11 2021-03-25
04/2021 2021-04-08 2021-04-22
05/2021 2021-05-13 2021-05-27
06/2021 2021-06-10 2021-06-24
07/2021 2021-07-08 2021-07-22
08/2021 2021-08-12 2021-08-26
09/2021 2021-09-09 2021-09-23
10/2021 2021-10-14 2021-10-28
11/2021 2021-11-11 2021-11-25
12/2021 2021-12-09 2021-12-23
8.18 规范非完全 ISO 日期字符串
问题
您有一个接近但不完全符合 ISO 格式的日期,想要将其转换为 ISO 格式的日期。
解决方案
通过传递日期到一个始终返回 ISO 格式日期结果的函数来规范日期。
讨论
在配方 8.10 中,我们遇到了使用CONCAT()合成日期可能产生不完全符合 ISO 格式的问题。例如,以下语句生成的每月第一天的值可能月份部分只有一位数:
mysql> `SELECT d, CONCAT(YEAR(d),'-',MONTH(d),'-01') FROM date_val;`
+------------+------------------------------------+
| d | CONCAT(YEAR(d),'-',MONTH(d),'-01') |
+------------+------------------------------------+
| 1864-02-28 | 1864-2-01 |
| 1900-01-15 | 1900-1-01 |
| 1999-12-31 | 1999-12-01 |
| 2000-06-04 | 2000-6-01 |
| 2017-03-16 | 2017-3-01 |
+------------+------------------------------------+
配方 8.10 展示了使用LPAD()确保月份值为两位数的技术。另一种标准化接近 ISO 日期的方法是将其用于生成 ISO 日期结果的表达式中。对于日期d,以下任何表达式均可:
DATE_ADD(d,INTERVAL 0 DAY)
d + INTERVAL 0 DAY
FROM_DAYS(TO_DAYS(d))
STR_TO_DATE(d,'%Y-%m-%d')
使用这些表达式与CONCAT()操作的非 ISO 结果以多种方式产生 ISO 格式:
mysql> `SELECT`
-> `CONCAT(YEAR(d),'-',MONTH(d),'-01') AS 'non-ISO',`
-> `DATE_ADD(CONCAT(YEAR(d),'-',MONTH(d),'-01'),INTERVAL 0 DAY) AS 'ISO 1',`
-> `CONCAT(YEAR(d),'-',MONTH(d),'-01') + INTERVAL 0 DAY AS 'ISO 2',`
-> `FROM_DAYS(TO_DAYS(CONCAT(YEAR(d),'-',MONTH(d),'-01'))) AS 'ISO 3',`
-> `STR_TO_DATE(CONCAT(YEAR(d),'-',MONTH(d),'-01'),'%Y-%m-%d') AS 'ISO 4'`
-> `FROM date_val;`
+------------+------------+------------+------------+------------+
| non-ISO | ISO 1 | ISO 2 | ISO 3 | ISO 4 |
+------------+------------+------------+------------+------------+
| 1864-2-01 | 1864-02-01 | 1864-02-01 | 1864-02-01 | 1864-02-01 |
| 1900-1-01 | 1900-01-01 | 1900-01-01 | 1900-01-01 | 1900-01-01 |
| 1999-12-01 | 1999-12-01 | 1999-12-01 | 1999-12-01 | 1999-12-01 |
| 2000-6-01 | 2000-06-01 | 2000-06-01 | 2000-06-01 | 2000-06-01 |
| 2017-3-01 | 2017-03-01 | 2017-03-01 | 2017-03-01 | 2017-03-01 |
+------------+------------+------------+------------+------------+
8.19 基于时间特性选择行
问题
您希望基于时间条件选择行。
解决方案
在WHERE子句中使用日期或时间条件。这可能基于直接比较列值与已知值,或者可能需要对列值应用函数以将其转换为更适合测试的形式,例如使用MONTH()来测试日期的月份部分。
讨论
大多数前面基于日期的技术都是通过示例语句来说明输出日期或时间值。要在语句中选择行并基于日期进行限制,请在WHERE子句中使用相同的技术。例如,您可以通过查找发生在给定日期之前或之后、在日期范围内的值,或匹配特定月份或日期值来选择行。
比较日期之间的差异
下面的语句查找来自 date_val 表的在 1900 年之前或在 1900 年代期间发生的行:
mysql> `SELECT d FROM date_val where d < '1900-01-01';`
+------------+
| d |
+------------+
| 1864-02-28 |
+------------+
mysql> `SELECT d FROM date_val where d BETWEEN '1900-01-01' AND '1999-12-31';`
+------------+
| d |
+------------+
| 1900-01-15 |
| 1999-12-31 |
+------------+
当你不知道在 WHERE 子句中需要什么确切日期时,通常可以使用表达式来计算它。例如,执行一个“历史上的今天”语句来搜索名为 history 的表中恰好发生在 50 年前的事件时,可以这样做:
SELECT * FROM history WHERE d = DATE_SUB(CURDATE(),INTERVAL 50 YEAR);
你会在报纸上看到这种事情,它们会列出过去时间的新闻事件。(实质上,该语句标识了那些达到其n周年纪念的事件。)为了检索发生在“今天”任何年份而不是特定年份“这一日期”的事件,该语句有些不同。在这种情况下,您需要查找与当前日历日匹配的行,忽略年份。有关该主题的讨论见“将日期与日历日进行比较”。
计算日期对于范围测试也是有用的。例如,要查找晚于 20 年前的日期,请使用 DATE_SUB() 计算截止日期:
mysql> `SELECT d FROM date_val WHERE d >= DATE_SUB(CURDATE(),INTERVAL 20 YEAR);`
+------------+
| d |
+------------+
| 1999-12-31 |
| 2000-06-04 |
| 2017-03-16 |
+------------+
注意 WHERE 子句中的表达式将日期列 d 孤立在比较操作符的一侧。这通常是个好主意;如果列被索引,将其单独放在比较的一侧使得 MySQL 能够更高效地处理语句。为了说明这一点,前面的 WHERE 子句可以以逻辑上等效但对 MySQL 执行效率较低的方式编写:
WHERE DATE_ADD(d,INTERVAL 20 YEAR) >= CURDATE();
这里,d 列在表达式中被使用。这意味着必须检索每一行,以便可以评估和测试表达式,这使得列上的任何索引都变得无用。
有时候不太明显如何重写比较以将日期列孤立在一侧。例如,下面的 WHERE 子句仅在比较中使用了日期列的一部分:
WHERE YEAR(d) >= 1987 AND YEAR(d) <= 1991;
要重写第一个比较,请消除 YEAR() 调用,并将其右侧替换为完整日期:
WHERE d >= '1987-01-01' AND YEAR(d) <= 1991;
重写第二个比较有些棘手。你可以消除左侧的 YEAR() 调用,就像第一个表达式一样,但你不能只是在右侧的年份后面添加 -01-01。这会产生以下错误的结果:
WHERE d >= '1987-01-01' AND d <= '1991-01-01';
那样做失败了,因为从 1991-01-02 到 1991-12-31 的日期未通过测试,但应该通过。要正确重写第二个比较,请执行以下操作:
WHERE d >= '1987-01-01' AND d < '1992-01-01';
计算日期的另一种用途经常出现在创建有限生命周期行的应用程序中。这些应用程序必须能够确定在执行过期操作时要删除哪些行。你可以通过以下几种方式解决这个问题:
-
在每行中存储一个日期,指示其创建时间。(通过将列设置为
TIMESTAMP或将其设置为NOW()来实现;详情请参见 Recipe 8.8。)稍后执行过期操作时,通过将该日期与当前日期进行比较,确定哪些行的创建日期过早。例如,过期超过n天的行的语句可能如下所示:DELETE FROM mytbl WHERE create_date < DATE_SUB(NOW(),INTERVAL *`n`* DAY); -
在每行中存储一个明确的过期日期,通过在创建行时使用
DATE_ADD()计算过期日期。对于应在n天后过期的行,操作如下:INSERT INTO mytbl (expire_date,...) VALUES(DATE_ADD(NOW(),INTERVAL *`n`* DAY),...);在这种情况下执行过期操作,比较过期日期与当前日期,以查看哪些已到期:
DELETE FROM mytbl WHERE expire_date < NOW();
比较时间之间的差异
涉及时间的比较类似于涉及日期的比较。例如,要查找在t1列中发生在上午 9 点到下午 2 点的时间,可以使用以下表达式之一:
WHERE t1 BETWEEN '09:00:00' AND '14:00:00';
WHERE HOUR(t1) BETWEEN 9 AND 14;
对于索引的TIME列,第一种方法更有效。第二种方法的特性是不仅适用于TIME列,还适用于DATETIME和TIMESTAMP列。
比较日期与日历日期
要回答关于一年中特定日期的问题,请使用日历日期测试。以下示例演示了在查找生日时如何做到这一点:
-
今天谁过生日?这要求匹配特定的日历日期,因此在执行比较时提取月份和日期,但忽略年份:
WHERE MONTH(d) = MONTH(CURDATE()) AND DAYOFMONTH(d) = DAYOFMONTH(CURDATE());这种语句通常用于生物数据,以找出出生在特定日期的演员、政治家、音乐家等列表。
使用
DAYOFYEAR()来解决“在这一天”的问题很诱人,因为它会生成更简单的语句。但是DAYOFYEAR()对闰年的处理不正确。2 月 29 日的存在会导致 3 月到 12 月的日期值出错。 -
本月谁过生日?在这种情况下,只需检查月份即可:
WHERE MONTH(d) = MONTH(CURDATE()); -
下个月谁过生日?这里的技巧在于,不能简单地在当前月份上加一来获取符合日期的月份编号。这样会导致 12 月的日期得到 13。要确保获得 1(即 1 月),可以使用以下任一技术:
WHERE MONTH(d) = MONTH(DATE_ADD(CURDATE(),INTERVAL 1 MONTH)); WHERE MONTH(d) = MOD(MONTH(CURDATE()),12)+1;
第九章:排序查询结果
9.0 介绍
本章涵盖了排序操作,这是控制 MySQL 从SELECT语句显示结果的极其重要的操作。要对查询结果进行排序,请向查询添加ORDER BY子句。如果没有这样的子句,MySQL 可以自由地以任何顺序返回行,因此排序有助于使混乱的结果有序化,使查询结果更易于检查和理解。
你可以按多种方式对查询结果的行进行排序:
-
使用单个列,多个列的组合,甚至列或表达式结果的部分
-
使用升序或降序排序
-
使用区分大小写或不区分大小写的字符串比较
-
使用时间顺序排序
本章中的多个示例使用driver_log表,该表包含用于记录一组卡车司机每日里程日志的列:
mysql> `SELECT * FROM driver_log;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 1 | Ben | 2014-07-30 | 152 |
| 2 | Suzi | 2014-07-29 | 391 |
| 3 | Henry | 2014-07-29 | 300 |
| 4 | Henry | 2014-07-27 | 96 |
| 5 | Ben | 2014-07-29 | 131 |
| 6 | Henry | 2014-07-26 | 115 |
| 7 | Suzi | 2014-08-02 | 502 |
| 8 | Henry | 2014-08-01 | 197 |
| 9 | Ben | 2014-08-02 | 79 |
| 10 | Henry | 2014-07-30 | 203 |
+--------+-------+------------+-------+
许多其他示例使用mail表(在较早的章节中使用):
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size FROM mail;`
+---------------------+---------+---------+---------+---------+---------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+---------+
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
| 2014-05-12 15:02:49 | phil | mars | phil | saturn | 1048 |
| 2014-05-12 18:59:18 | barb | saturn | tricia | venus | 271 |
| 2014-05-14 09:31:37 | gene | venus | barb | mars | 2291 |
| 2014-05-14 11:52:17 | phil | mars | tricia | saturn | 5781 |
| 2014-05-14 14:42:21 | barb | venus | barb | venus | 98151 |
| 2014-05-14 17:03:01 | tricia | saturn | phil | venus | 2394482 |
| 2014-05-15 07:17:48 | gene | mars | gene | saturn | 3824 |
| 2014-05-15 08:50:57 | phil | venus | phil | venus | 978 |
| 2014-05-15 10:25:52 | gene | mars | tricia | saturn | 998532 |
| 2014-05-15 17:35:31 | gene | saturn | gene | mars | 3856 |
| 2014-05-16 09:00:28 | gene | venus | barb | mars | 613 |
| 2014-05-16 23:04:19 | phil | venus | barb | venus | 10294 |
| 2014-05-19 12:49:23 | phil | mars | tricia | saturn | 873 |
| 2014-05-19 22:21:51 | gene | saturn | gene | venus | 23992 |
+---------------------+---------+---------+---------+---------+---------+
有时也会偶尔使用其他表。要创建它们,请使用recipes分发的tables目录中的脚本。
9.1 使用 ORDER BY 对查询结果进行排序
问题
查询结果中的行不按你想要的顺序显示。
解决方案
在查询中添加ORDER BY子句来对其结果进行排序。
讨论
在章节介绍中显示的driver_log和mail表的内容是杂乱无章且难以理解的。id和t列中的值仅仅是巧合的顺序排列。
当你选择行时,它们会以服务器当前使用的任何顺序返回。关系数据库不保证以任何顺序返回行,除非你通过向你的SELECT语句添加ORDER BY子句来告诉它如何排序。如果没有ORDER BY,你可能会发现检索顺序随着时间的推移而更改,因为你修改表内容。使用ORDER BY子句,MySQL 始终按照你指定的方式对行进行排序。
ORDER BY具有以下一般特性:
-
你可以使用一个或多个列或表达式的值进行排序。
-
你可以独立按列升序(默认)或降序排序。
-
你可以通过名称或使用别名来引用排序列。
本篇介绍了一些基本的排序技巧,比如如何命名排序列和指定排序方向。本章后面的示例演示了如何执行更复杂的排序。具有讽刺意味的是,你甚至可以使用ORDER BY来打乱结果集,这对于随机化行或(与LIMIT一起)从结果集中随机选择行很有用(参见 Recipe 17.7 和 Recipe 17.8)。
下面的示例演示了如何按单个列或多个列进行排序,以及如何按升序或降序排序。这些示例选择driver_log表中的行,但以不同的ORDER BY子句来展示不同的效果。
此查询使用司机名称进行单列排序:
mysql> `SELECT * FROM driver_log ORDER BY name;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 1 | Ben | 2014-07-30 | 152 |
| 9 | Ben | 2014-08-02 | 79 |
| 5 | Ben | 2014-07-29 | 131 |
| 8 | Henry | 2014-08-01 | 197 |
| 6 | Henry | 2014-07-26 | 115 |
| 4 | Henry | 2014-07-27 | 96 |
| 3 | Henry | 2014-07-29 | 300 |
| 10 | Henry | 2014-07-30 | 203 |
| 7 | Suzi | 2014-08-02 | 502 |
| 2 | Suzi | 2014-07-29 | 391 |
+--------+-------+------------+-------+
默认的排序方向是升序。要使升序排序的方向明确,添加ASC到排序列名后:
SELECT * FROM driver_log ORDER BY name ASC;
升序排序的反义词(或反向)是降序排序,通过在排序列名后添加DESC来指定:
mysql> `SELECT * FROM driver_log ORDER BY name DESC;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 2 | Suzi | 2014-07-29 | 391 |
| 7 | Suzi | 2014-08-02 | 502 |
| 10 | Henry | 2014-07-30 | 203 |
| 8 | Henry | 2014-08-01 | 197 |
| 6 | Henry | 2014-07-26 | 115 |
| 4 | Henry | 2014-07-27 | 96 |
| 3 | Henry | 2014-07-29 | 300 |
| 5 | Ben | 2014-07-29 | 131 |
| 9 | Ben | 2014-08-02 | 79 |
| 1 | Ben | 2014-07-30 | 152 |
+--------+-------+------------+-------+
仔细检查刚才显示的查询的输出,你会注意到虽然按名称对行进行了排序,但任何给定名称的行没有特定的顺序。(例如,Henry 或 Ben 的trav_date值不按日期顺序排列。)这是因为 MySQL 不会对某些东西进行排序,除非你告诉它:
-
查询返回的行的整体顺序是不确定的,除非你指定了
ORDERBY子句。 -
在根据给定列中的值排序的行组内,除非在
ORDERBY子句中命名它们,否则其他列中的值的顺序也是不确定的。
要更全面地控制输出顺序,请通过逗号分隔的每个用于排序的列列表指定多列排序。以下查询按名称升序排序,并在每个名称的行内按trav_date升序排序:
mysql> `SELECT * FROM driver_log ORDER BY name, trav_date;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 5 | Ben | 2014-07-29 | 131 |
| 1 | Ben | 2014-07-30 | 152 |
| 9 | Ben | 2014-08-02 | 79 |
| 6 | Henry | 2014-07-26 | 115 |
| 4 | Henry | 2014-07-27 | 96 |
| 3 | Henry | 2014-07-29 | 300 |
| 10 | Henry | 2014-07-30 | 203 |
| 8 | Henry | 2014-08-01 | 197 |
| 2 | Suzi | 2014-07-29 | 391 |
| 7 | Suzi | 2014-08-02 | 502 |
+--------+-------+------------+-------+
多列排序也可以是降序,但必须在每个列名后指定DESC以执行完全降序排序。
多列ORDER BY子句可以执行混合顺序排序,其中某些列按升序排序,而其他列按降序排序。以下查询按名称降序排序,然后按每个名称的trav_date升序排序:
mysql> `SELECT * FROM driver_log ORDER BY name DESC, trav_date;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 2 | Suzi | 2014-07-29 | 391 |
| 7 | Suzi | 2014-08-02 | 502 |
| 6 | Henry | 2014-07-26 | 115 |
| 4 | Henry | 2014-07-27 | 96 |
| 3 | Henry | 2014-07-29 | 300 |
| 10 | Henry | 2014-07-30 | 203 |
| 8 | Henry | 2014-08-01 | 197 |
| 5 | Ben | 2014-07-29 | 131 |
| 1 | Ben | 2014-07-30 | 152 |
| 9 | Ben | 2014-08-02 | 79 |
+--------+-------+------------+-------+
到目前为止显示的查询中的ORDER BY子句是通过名称引用排序的列。你也可以使用别名命名列。也就是说,如果输出列有别名,你可以在ORDER BY子句中引用别名:
mysql> `SELECT name, trav_date, miles AS distance FROM driver_log`
-> `ORDER BY distance;`
+-------+------------+----------+
| name | trav_date | distance |
+-------+------------+----------+
| Ben | 2014-08-02 | 79 |
| Henry | 2014-07-27 | 96 |
| Henry | 2014-07-26 | 115 |
| Ben | 2014-07-29 | 131 |
| Ben | 2014-07-30 | 152 |
| Henry | 2014-08-01 | 197 |
| Henry | 2014-07-30 | 203 |
| Henry | 2014-07-29 | 300 |
| Suzi | 2014-07-29 | 391 |
| Suzi | 2014-08-02 | 502 |
+-------+------------+----------+
9.2 使用表达式进行排序
问题
你想根据从列计算而不是实际存储在列中的值对查询结果进行排序。
解决方案
将计算值的表达式放入ORDER BY子句。
讨论
mail表的一个列显示每个邮件消息的大小,以字节为单位:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size FROM mail;`
+---------------------+---------+---------+---------+---------+---------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+---------+
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
| 2014-05-12 15:02:49 | phil | mars | phil | saturn | 1048 |
| 2014-05-12 18:59:18 | barb | saturn | tricia | venus | 271 |
…
假设你想检索超过 50,000 字节的邮件(定义为大邮件),但希望它们按千字节显示和排序大小。在这种情况下,排序的值由表达式计算:
FLOOR((size+1023)/1024)
在FLOOR()表达式中的+1023将size值分组到最接近的 1,024 字节类别的上限。如果没有它,值将按下限分组(例如,2,047 字节的消息报告为 1 千字节大小而不是 2)。Recipe 10.13 更详细地讨论了这种技术。
要按该表达式排序,请直接将其放入ORDER BY子句中:
mysql> `SELECT t, srcuser, FLOOR((size+1023)/1024)`
-> `FROM mail WHERE size > 50000`
-> `ORDER BY FLOOR((size+1023)/1024);`
+---------------------+---------+-------------------------+
| t | srcuser | FLOOR((size+1023)/1024) |
+---------------------+---------+-------------------------+
| 2014-05-11 10:15:08 | barb | 57 |
| 2014-05-14 14:42:21 | barb | 96 |
| 2014-05-12 12:48:13 | tricia | 191 |
| 2014-05-15 10:25:52 | gene | 976 |
| 2014-05-14 17:03:01 | tricia | 2339 |
+---------------------+---------+-------------------------+
或者,如果排序表达式出现在输出列列表中,您可以在那里对其进行别名,并在ORDER BY子句中引用该别名:
mysql> `SELECT t, srcuser, FLOOR((size+1023)/1024) AS kilobytes`
-> `FROM mail WHERE size > 50000`
-> `ORDER BY kilobytes;`
+---------------------+---------+-----------+
| t | srcuser | kilobytes |
+---------------------+---------+-----------+
| 2014-05-11 10:15:08 | barb | 57 |
| 2014-05-14 14:42:21 | barb | 96 |
| 2014-05-12 12:48:13 | tricia | 191 |
| 2014-05-15 10:25:52 | gene | 976 |
| 2014-05-14 17:03:01 | tricia | 2339 |
+---------------------+---------+-----------+
由于几个原因,你可能更喜欢别名方法:
-
在
ORDER BY子句中,写别名比重复(冗长的)表达式更容易。 -
如果没有别名,如果你在一个地方更改表达式,那么你必须在另一个地方也进行更改。
-
别名可能对显示目的很有用,可以提供更好的列标签。注意前面两个查询中第三列标题的意义大不相同。
9.3 显示一组值但按另一组值排序
问题
你想使用不出现在输出列列表中的值对结果集进行排序。
解决方案
这不是问题。ORDER BY子句可以引用你不显示的列。
讨论
ORDER BY不仅限于仅对输出列列表中命名的列进行排序。它可以使用那些“隐藏”的值进行排序(即在查询输出中不显示的值)。当你有不同表示方式的值,并且想要显示一种类型的值但按另一种类型排序时,这种技术通常被使用。例如,你可能希望将邮件消息大小显示为103K而不是字节。你可以使用以下表达式将字节计数转换为这种类型的值:
CONCAT(FLOOR((size+1023)/1024),'K')
然而,这些值是字符串,所以它们按字典顺序而不是数字顺序排序。如果你用它们来排序,像96K这样的值将排在2339K之后,尽管它代表一个较小的数字:
mysql> `SELECT t, srcuser,`
-> `CONCAT(FLOOR((size+1023)/1024),'K') AS size_in_K`
-> `FROM mail WHERE size > 50000`
-> `ORDER BY size_in_K;`
+---------------------+---------+-----------+
| t | srcuser | size_in_K |
+---------------------+---------+-----------+
| 2014-05-12 12:48:13 | tricia | 191K |
| 2014-05-14 17:03:01 | tricia | 2339K |
| 2014-05-11 10:15:08 | barb | 57K |
| 2014-05-14 14:42:21 | barb | 96K |
| 2014-05-15 10:25:52 | gene | 976K |
+---------------------+---------+-----------+
为了达到期望的输出顺序,显示字符串,但使用实际的数字大小进行排序:
mysql> `SELECT t, srcuser,`
-> `CONCAT(FLOOR((size+1023)/1024),'K') AS size_in_K`
-> `FROM mail WHERE size > 50000`
-> `ORDER BY size;`
+---------------------+---------+-----------+
| t | srcuser | size_in_K |
+---------------------+---------+-----------+
| 2014-05-11 10:15:08 | barb | 57K |
| 2014-05-14 14:42:21 | barb | 96K |
| 2014-05-12 12:48:13 | tricia | 191K |
| 2014-05-15 10:25:52 | gene | 976K |
| 2014-05-14 17:03:01 | tricia | 2339K |
+---------------------+---------+-----------+
将值显示为字符串但按数字排序有助于解决一些其他情况下难以解决的问题。体育队成员通常会被分配一个球衣号码,通常你可能认为应该使用一个数字列来存储它。不那么快!一些球员喜欢球衣号码为零(0),一些喜欢双零(00)。如果一支队伍恰好有两种球员,即使两个值是不同的数字,你也不能使用数字列来表示它们。为了解决这个问题,将球衣号码存储为字符串:
CREATE TABLE roster
(
name CHAR(30), # player name
jersey_num CHAR(3), # jersey number
PRIMARY KEY(name)
);
然后球衣号码将以你输入的方式显示,并且0和00将被视为不同的值。不幸的是,虽然将数字表示为字符串解决了区分0和00的问题,但引入了另一个问题。假设一个球队有以下球员:
mysql> `SELECT name, jersey_num FROM roster;`
+-----------+------------+
| name | jersey_num |
+-----------+------------+
| Lynne | 29 |
| Ella | 0 |
| Elizabeth | 100 |
| Nancy | 00 |
| Jean | 8 |
| Sherry | 47 |
+-----------+------------+
现在尝试按球衣号码对团队成员进行排序。如果这些数字被存储为字符串,它们将按字典顺序排序,而字典顺序通常不同于数字顺序。对于所讨论的球队,这绝对是真实的:
mysql> `SELECT name, jersey_num FROM roster ORDER BY jersey_num;`
+-----------+------------+
| name | jersey_num |
+-----------+------------+
| Ella | 0 |
| Nancy | 00 |
| Elizabeth | 100 |
| Lynne | 29 |
| Sherry | 47 |
| Jean | 8 |
+-----------+------------+
值100和8错位了,但这很容易解决:显示字符串值,并使用数字值进行排序。为了实现这一点,将零添加到jersey_num的值中以强制进行字符串到数字的转换:
mysql> `SELECT name, jersey_num FROM roster ORDER BY jersey_num+0;`
+-----------+------------+
| name | jersey_num |
+-----------+------------+
| Ella | 0 |
| Nancy | 00 |
| Jean | 8 |
| Lynne | 29 |
| Sherry | 47 |
| Elizabeth | 100 |
+-----------+------------+
警告
请注意,由于此方法执行字符串到数字的转换,它不能使用索引,并且在表变大时会运行得更慢。作为替代方案,您可以创建一个列,用于保存此计算的结果,并在 ORDER BY 表达式中使用它。
当您显示一个值但按另一个值排序的技术,在显示由多个列组成且无法按您希望的方式排序的值时,也很有用。例如,mail 表列出使用单独的 srcuser 和 srchost 值的消息发送者。要以 srcuser@srchost 格式显示 mail 表中的消息发送者作为电子邮件地址,并确保用户名排在前面,请使用以下表达式构造这些值:
CONCAT(srcuser,'@',srchost)
然而,如果要将主机名视为比用户名更重要的内容来排序,那些值就不适合排序。而是使用底层列值而不是显示的复合值来排序结果:
mysql> `SELECT t, CONCAT(srcuser,'@',srchost) AS sender, size`
-> `FROM mail WHERE size > 50000`
-> `ORDER BY srchost, srcuser;`
+---------------------+---------------+---------+
| t | sender | size |
+---------------------+---------------+---------+
| 2014-05-15 10:25:52 | gene@mars | 998532 |
| 2014-05-12 12:48:13 | tricia@mars | 194925 |
| 2014-05-11 10:15:08 | barb@saturn | 58274 |
| 2014-05-14 17:03:01 | tricia@saturn | 2394482 |
| 2014-05-14 14:42:21 | barb@venus | 98151 |
+---------------------+---------------+---------+
同样的想法通常也适用于排序人名。假设 names 表包含姓和名。要按姓氏首先显示行,当分别显示列时,查询非常简单:
mysql> `SELECT last_name, first_name FROM name`
-> `ORDER BY last_name, first_name;`
+-----------+------------+
| last_name | first_name |
+-----------+------------+
| Blue | Vida |
| Brown | Kevin |
| Gray | Pete |
| White | Devon |
| White | Rondell |
+-----------+------------+
如果您想将每个姓名显示为由名字、一个空格和姓组成的单个字符串,查询可以如下开始:
SELECT CONCAT(first_name,' ',last_name) AS full_name FROM name ...
但是如何按姓氏顺序排序这些姓名呢?显示复合姓名,但在 ORDER BY 子句中引用各组成值:
mysql> `SELECT CONCAT(first_name,' ',last_name) AS full_name`
-> `FROM name`
-> `ORDER BY last_name, first_name;`
+---------------+
| full_name |
+---------------+
| Vida Blue |
| Kevin Brown |
| Pete Gray |
| Devon White |
| Rondell White |
+---------------+
9.4 控制字符串排序的大小写敏感性
问题
当您不希望字符串排序操作区分大小写时,或者反之。
解决方案
更改排序值的比较特性。
讨论
食谱 7.1 讨论了字符串比较属性如何取决于字符串是二进制还是非二进制的:
-
二进制字符串是字节序列。它们按照数值字节值逐字节比较。字符集和大小写对比较无意义。
-
非二进制字符串是字符序列。它们具有字符集和排序规则,并且按照由排序规则定义的顺序逐字符比较。
这些属性也适用于字符串排序,因为排序基于比较。要修改字符串列的排序属性,请修改其比较属性。(有关字符串数据类型是二进制还是非二进制的摘要,请参阅 食谱 7.2。)
本节中的示例使用一个具有大小写不敏感和大小写敏感的非二进制列以及一个二进制列的表:
CREATE TABLE str_val
(
ci_str CHAR(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci,
cs_str CHAR(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_as_cs,
bin_str BINARY(3)
);
假设表中有以下内容:
+--------+--------+---------+
| ci_str | cs_str | bin_str |
+--------+--------+---------+
| AAA | AAA | AAA |
| aaa | aaa | aaa |
| bbb | bbb | bbb |
| BBB | BBB | BBB |
+--------+--------+---------+
提示
自 MySQL 8.0.19 起,mysql 客户端以十六进制格式打印二进制数据。
mysql> `select` `*` `from` `str_val``;`
+--------+--------+------------------+ | ci_str | cs_str | bin_str |
+--------+--------+------------------+ | AAA | AAA | 0x414141 |
| aaa | aaa | 0x616161 |
| bbb | bbb | 0x626262 |
| BBB | BBB | 0x424242 |
+--------+--------+------------------+ 4 rows in set (0.00 sec)
要以 ASCII 格式打印值,请使用选项 --binary-as-hex=0 启动 mysql。
每列包含相同的值,但是不同数据类型的列数据的自然排序顺序会产生三种不同的结果:
-
不区分大小写的排序将
a和A放在一起,将它们排在b和B之前。然而,对于给定的字母,它并不一定会将一个大小写字母排在另一个字母之前,如下结果所示:mysql> `SELECT ci_str FROM str_val ORDER BY ci_str;` +--------+ | ci_str | +--------+ | AAA | | aaa | | bbb | | BBB | +--------+ -
大小写敏感的排序将
a和A排在b和B之前,并将小写字母排在大写字母之前:mysql> `SELECT cs_str FROM str_val ORDER BY cs_str;` +--------+ | cs_str | +--------+ | aaa | | AAA | | bbb | | BBB | +--------+ -
二进制字符串按数字顺序排序。假设大写字母的数值小于小写字母的数值,二进制排序将产生以下顺序:
mysql> `SELECT bin_str FROM str_val ORDER BY bin_str;` +---------+ | bin_str | +---------+ | AAA | | BBB | | aaa | | bbb | +---------+对于具有二进制排序的非二进制字符串列,如果列包含单字节字符(例如
CHAR(3)CHARACTERSETlatin1COLLATElatin1_bin),则会得到相同的结果。对于多字节字符,二进制排序仍然会产生数字排序,但字符值使用多字节数字。
要改变每列的排序属性,请使用食谱 7.7 中描述的技术来控制字符串比较:
-
要按区分大小写的方式排序不区分大小写的字符串,请使用区分大小写的排序来排序排序值:
mysql> `SELECT ci_str FROM str_val` -> `ORDER BY ci_str COLLATE utf8mb4_0900_as_cs;` +--------+ | ci_str | +--------+ | aaa | | AAA | | bbb | | BBB | +--------+ -
要按区分大小写的方式排序区分大小写的字符串,请使用不区分大小写的排序来排序排序值:
mysql> `SELECT cs_str FROM str_val` -> `ORDER BY cs_str COLLATE utf8mb4_0900_ai_ci;` +--------+ | cs_str | +--------+ | AAA | | aaa | | bbb | | BBB | +--------+或者,使用转换为相同大小写的值进行排序,使大小写无关紧要:
mysql> `SELECT cs_str FROM str_val` -> `ORDER BY UPPER(cs_str);` +--------+ | cs_str | +--------+ | AAA | | aaa | | bbb | | BBB | +--------+ -
二进制字符串按照数字字节值排序,因此没有字母大小写的概念。然而,由于不同大小写的字母具有不同的字节值,对二进制字符串的比较实际上是大小写敏感的(即
a和A不相等)。要使用不区分大小写的排序来排序二进制字符串,请将其转换为非二进制字符串并应用适当的排序。例如,要执行不区分大小写的排序,请使用以下语句:mysql> `SELECT bin_str FROM str_val` -> `ORDER BY CONVERT(bin_str USING utf8mb4) COLLATE utf8mb4_0900_ai_ci;` +---------+ | bin_str | +---------+ | AAA | | aaa | | bbb | | BBB | +---------+如果字符集默认的排序不区分大小写(如
utf8mb4),可以省略COLLATE子句。
9.5 按时间顺序排序
问题
您希望按时间顺序对行进行排序。
解决方案
使用日期或时间列进行排序。如果值的某些部分对于您想要完成的排序无关紧要,请忽略它们。
讨论
许多数据库表包括日期或时间信息,通常需要按时间顺序对结果进行排序。MySQL 知道如何对时间数据类型进行排序,因此没有特殊的技巧来对其进行排序。接下来的几个示例使用包含DATETIME列的mail表,但相同的排序原则适用于DATE、TIME和TIMESTAMP列。
这里是由phil发送的消息:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size`
-> `FROM mail WHERE srcuser = 'phil';`
+---------------------+---------+---------+---------+---------+-------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+-------+
| 2014-05-12 15:02:49 | phil | mars | phil | saturn | 1048 |
| 2014-05-14 11:52:17 | phil | mars | tricia | saturn | 5781 |
| 2014-05-15 08:50:57 | phil | venus | phil | venus | 978 |
| 2014-05-16 23:04:19 | phil | venus | barb | venus | 10294 |
| 2014-05-19 12:49:23 | phil | mars | tricia | saturn | 873 |
+---------------------+---------+---------+---------+---------+-------+
要显示最近发送的消息,先使用ORDER BY和DESC进行排序:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size`
-> `FROM mail WHERE srcuser = 'phil' ORDER BY t DESC;`
+---------------------+---------+---------+---------+---------+-------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+-------+
| 2014-05-19 12:49:23 | phil | mars | tricia | saturn | 873 |
| 2014-05-16 23:04:19 | phil | venus | barb | venus | 10294 |
| 2014-05-15 08:50:57 | phil | venus | phil | venus | 978 |
| 2014-05-14 11:52:17 | phil | mars | tricia | saturn | 5781 |
| 2014-05-12 15:02:49 | phil | mars | phil | saturn | 1048 |
+---------------------+---------+---------+---------+---------+-------+
有时,时间排序仅使用日期或时间列的一部分。在这种情况下,使用提取所需部分的表达式,并使用该表达式对结果进行排序。以下讨论中给出了一些示例。
按照一天中的时间排序
可以以不同的方式对时间进行排序,具体取决于列类型。如果值存储在名为timecol的TIME列中,只需直接使用ORDER BY timecol进行排序。要按天时序排序DATETIME或TIMESTAMP值,提取时间部分并进行排序。例如,mail表包含DATETIME值,可以按一天中的时间进行排序如下:
mysql> `SELECT t, srcuser, srchost, dstuser, dsthost, size FROM mail ORDER BY TIME(t);`
+---------------------+---------+---------+---------+---------+---------+
| t | srcuser | srchost | dstuser | dsthost | size |
+---------------------+---------+---------+---------+---------+---------+
| 2014-05-15 07:17:48 | gene | mars | gene | saturn | 3824 |
| 2014-05-15 08:50:57 | phil | venus | phil | venus | 978 |
| 2014-05-16 09:00:28 | gene | venus | barb | mars | 613 |
| 2014-05-14 09:31:37 | gene | venus | barb | mars | 2291 |
| 2014-05-11 10:15:08 | barb | saturn | tricia | mars | 58274 |
| 2014-05-15 10:25:52 | gene | mars | tricia | saturn | 998532 |
| 2014-05-14 11:52:17 | phil | mars | tricia | saturn | 5781 |
| 2014-05-12 12:48:13 | tricia | mars | gene | venus | 194925 |
…
按日历日排序
若要按日历顺序排序日期值,请忽略日期的年份部分,仅使用月份和日期按日历年中的位置排序值。假设occasion表如下所示,当值按日期排序时:
mysql> `SELECT date, description FROM occasion ORDER BY date;`
+------------+-------------------------------------+
| date | description |
+------------+-------------------------------------+
| 1215-06-15 | Signing of the Magna Carta |
| 1732-02-22 | George Washington's birthday |
| 1776-07-14 | Bastille Day |
| 1789-07-04 | US Independence Day |
| 1809-02-12 | Abraham Lincoln's birthday |
| 1919-06-28 | Signing of the Treaty of Versailles |
| 1944-06-06 | D-Day at Normandy Beaches |
| 1957-10-04 | Sputnik launch date |
| 1989-11-09 | Opening of the Berlin Wall |
+------------+-------------------------------------+
要按日历顺序排列这些项目,请按月份和每月内的日期排序:
mysql> `SELECT date, description FROM occasion`
-> `ORDER BY MONTH(date), DAYOFMONTH(date);`
+------------+-------------------------------------+
| date | description |
+------------+-------------------------------------+
| 1809-02-12 | Abraham Lincoln's birthday |
| 1732-02-22 | George Washington's birthday |
| 1944-06-06 | D-Day at Normandy Beaches |
| 1215-06-15 | Signing of the Magna Carta |
| 1919-06-28 | Signing of the Treaty of Versailles |
| 1789-07-04 | US Independence Day |
| 1776-07-14 | Bastille Day |
| 1957-10-04 | Sputnik launch date |
| 1989-11-09 | Opening of the Berlin Wall |
+------------+-------------------------------------+
MySQL 具有DAYOFYEAR()函数,你可能会认为它在日历日排序中很有用。然而,它可能会为不同的日历日期生成相同的值。例如,闰年的 2 月 29 日和非闰年的 3 月 1 日具有相同的一年中的日值:
mysql> `SELECT DAYOFYEAR('1996-02-29'), DAYOFYEAR('1997-03-01');`
+-------------------------+-------------------------+
| DAYOFYEAR('1996-02-29') | DAYOFYEAR('1997-03-01') |
+-------------------------+-------------------------+
| 60 | 60 |
+-------------------------+-------------------------+
这意味着DAYOFYEAR()可以将实际上出现在不同日历日期上的日期分组。
如果表使用单独的年、月和日列表示日期,那么日历排序不需要提取日期部分。直接按相关列进行排序即可。对于大型数据集,使用单独的日期部分列进行排序可能比基于提取DATE值片段的排序要快得多。这里的原则是,应设计表格以便轻松提取或按预期频繁使用的值排序。
按星期几排序
按星期几排序类似于按日历日排序,只是使用不同的函数来获取相关的排序值。
使用DAYNAME()可以获取星期几,但这会按字典顺序而不是按星期几的顺序排序(星期日、星期一、星期二等)。在这里,显示一个值但按另一个值排序的技术很有用(见配方 9.3)。使用DAYNAME()显示星期几,但使用DAYOFWEEK()按星期几排序,它返回从 1 到 7 的数字值,分别对应星期日到星期六:
mysql> `SELECT DAYNAME(date) AS day, date, description`
-> `FROM occasion`
-> `ORDER BY DAYOFWEEK(date);`
+----------+------------+-------------------------------------+
| day | date | description |
+----------+------------+-------------------------------------+
| Sunday | 1776-07-14 | Bastille Day |
| Sunday | 1809-02-12 | Abraham Lincoln's birthday |
| Monday | 1215-06-15 | Signing of the Magna Carta |
| Tuesday | 1944-06-06 | D-Day at Normandy Beaches |
| Thursday | 1989-11-09 | Opening of the Berlin Wall |
| Friday | 1957-10-04 | Sputnik launch date |
| Friday | 1732-02-22 | George Washington's birthday |
| Saturday | 1789-07-04 | US Independence Day |
| Saturday | 1919-06-28 | Signing of the Treaty of Versailles |
+----------+------------+-------------------------------------+
若要按星期几排序,但将星期一视为一周的第一天,星期日视为最后一天,则使用模运算和MOD()函数将星期一映射为 0,星期二映射为 1,……,星期日映射为 6:
mysql> `SELECT DAYNAME(date), date, description`
-> `FROM occasion`
-> `ORDER BY MOD(DAYOFWEEK(date)+5, 7);`
+---------------+------------+-------------------------------------+
| DAYNAME(date) | date | description |
+---------------+------------+-------------------------------------+
| Monday | 1215-06-15 | Signing of the Magna Carta |
| Tuesday | 1944-06-06 | D-Day at Normandy Beaches |
| Thursday | 1989-11-09 | Opening of the Berlin Wall |
| Friday | 1957-10-04 | Sputnik launch date |
| Friday | 1732-02-22 | George Washington's birthday |
| Saturday | 1789-07-04 | US Independence Day |
| Saturday | 1919-06-28 | Signing of the Treaty of Versailles |
| Sunday | 1776-07-14 | Bastille Day |
| Sunday | 1809-02-12 | Abraham Lincoln's birthday |
+---------------+------------+-------------------------------------+
表 9-1 显示了DAYOFWEEK()表达式,用于在排序中将任何一天作为第一天:
表 9-1. 使用模运算正确排序一周的日子
| 列出第一天 | DAYOFWEEK()表达式 |
|---|---|
| 星期日 | DAYOFWEEK(date) |
| 星期一 | MOD(DAYOFWEEK(date)+5, 7) |
| 星期二 | MOD(DAYOFWEEK(date)+4, 7) |
| 星期三 | MOD(DAYOFWEEK(date)+3, 7) |
| 星期四 | MOD(DAYOFWEEK(date)+2, 7) |
| 星期五 | MOD(DAYOFWEEK(date)+1, 7) |
| 星期六 | MOD(DAYOFWEEK(date)+0, 7) |
您还可以使用WEEKDAY()进行按星期几排序,尽管它返回不同的值集(星期一到星期日分别为 0 到 6)。
9.6 按列值的子字符串排序
问题
您希望使用每个值的一个或多个子字符串进行排序。
解决方案
提取您想要的部分并将它们单独排序。
讨论
这是排序表达式值的特定应用(见 Recipe 9.2)。要仅使用列值的特定部分对行进行排序,请提取您需要的子字符串,并在ORDER BY子句中使用它。如果子字符串在列内具有固定的位置和长度,则这样做最为简单。对于位置或长度可变的子字符串,如果您有可靠的识别方法,仍然可以用它们进行排序。接下来的几个示例展示如何使用子字符串提取来生成专门的排序顺序。
9.7 按固定长度子字符串排序
问题
您希望使用在列内给定位置出现的部分来进行排序。
解决方案
使用LEFT()、SUBSTRING()(MID())或RIGHT()拉出您需要的部分,并对它们进行排序。
讨论
假设housewares表格记录家居用品家具,每个都由 10 个字符的 ID 值标识,包括三个子部分:三个字符的类别缩写(例如用于DIN或KIT),五位数的序列号,以及指示零件制造地的两个字符的国家代码:
mysql> `SELECT * FROM housewares;`
+------------+------------------+
| id | description |
+------------+------------------+
| DIN40672US | dining table |
| KIT00372UK | garbage disposal |
| KIT01729JP | microwave oven |
| BED00038SG | bedside lamp |
| BTH00485US | shower stall |
| BTH00415JP | lavatory |
+------------+------------------+
这并不一定是存储复杂 ID 值的好方法,稍后我们将考虑如何使用单独的列来表示它们。现在假设这些值必须按照所示存储。
要根据id值对来自该表的行进行排序,请使用整个列值:
mysql> `SELECT * FROM housewares ORDER BY id;`
+------------+------------------+
| id | description |
+------------+------------------+
| BED00038SG | bedside lamp |
| BTH00415JP | lavatory |
| BTH00485US | shower stall |
| DIN40672US | dining table |
| KIT00372UK | garbage disposal |
| KIT01729JP | microwave oven |
+------------+------------------+
但您可能还需要按照这三个子部分的任意一个进行排序(例如,按制造国家排序)。对于这种操作,诸如LEFT()、MID()和RIGHT()的函数非常有用,用于提取id值的组件:
mysql> `SELECT id,`
-> `LEFT(id,3) AS category,`
-> `MID(id,4,5) AS serial,`
-> `RIGHT(id,2) AS country`
-> `FROM housewares;`
+------------+----------+--------+---------+
| id | category | serial | country |
+------------+----------+--------+---------+
| DIN40672US | DIN | 40672 | US |
| KIT00372UK | KIT | 00372 | UK |
| KIT01729JP | KIT | 01729 | JP |
| BED00038SG | BED | 00038 | SG |
| BTH00485US | BTH | 00485 | US |
| BTH00415JP | BTH | 00415 | JP |
+------------+----------+--------+---------+
提示
函数MID()是函数SUBSTRING()的同义词。
这些id值的固定长度子字符串可以单独或组合用于排序。例如,要按产品类别排序,请在ORDER BY子句中提取和使用类别:
mysql> `SELECT * FROM housewares ORDER BY LEFT(id,3);`
+------------+------------------+
| id | description |
+------------+------------------+
| BED00038SG | bedside lamp |
| BTH00485US | shower stall |
| BTH00415JP | lavatory |
| DIN40672US | dining table |
| KIT00372UK | garbage disposal |
| KIT01729JP | microwave oven |
+------------+------------------+
要按产品序列号排序,请使用MID()从id值中提取中间的五个字符,从第四个字符开始:
mysql> `SELECT * FROM housewares ORDER BY MID(id,4,5);`
+------------+------------------+
| id | description |
+------------+------------------+
| BED00038SG | bedside lamp |
| KIT00372UK | garbage disposal |
| BTH00415JP | lavatory |
| BTH00485US | shower stall |
| KIT01729JP | microwave oven |
| DIN40672US | dining table |
+------------+------------------+
这似乎是数字排序,但实际上是字符串排序,因为MID()返回字符串。在这种情况下,词汇和数字的排序顺序相同,因为
要按国家代码排序,请使用id值的最后两个字符(ORDER BY RIGHT(id,2))。
您还可以按组合的子字符串进行排序;例如,按国家代码和国内序列号排序:
mysql> `SELECT * FROM housewares ORDER BY RIGHT(id,2), MID(id,4,5);`
+------------+------------------+
| id | description |
+------------+------------------+
| BTH00415JP | lavatory |
| KIT01729JP | microwave oven |
| BED00038SG | bedside lamp |
| KIT00372UK | garbage disposal |
| BTH00485US | shower stall |
| DIN40672US | dining table |
+------------+------------------+
刚刚显示的ORDER BY子句足以按id值的子串排序,但如果此类操作在表上常见,则可能值得以不同方式表示家居设备 ID;例如,使用单独的列表示。此表housewares2与housewares类似,但使用从id列生成的category、serial和country列:
CREATE TABLE `housewares2` (
`id` varchar(20) NOT NULL,
`category` varchar(3) GENERATED ALWAYS AS (left(`id`,3)) STORED,
`serial` char(5) GENERATED ALWAYS AS (substr(`id`,4,5)) STORED,
`country` varchar(2) GENERATED ALWAYS AS (right(`id`,2)) STORED,
`description` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
在此示例中,我们使用了基于表达式生成的生成列,在列创建时定义。
将 ID 值拆分为单独的部分后,排序操作更容易指定;直接引用各个列而不是提取原始id列的子串。您还可以通过在这些列上添加索引,使对serial和country列进行排序的操作更加高效。
mysql> `SELECT category, serial, country, id`
-> `FROM housewares2 ORDER BY category, country, serial;`
+----------+--------+---------+------------+
| category | serial | country | id |
+----------+--------+---------+------------+
| BED | 00038 | SG | BED00038SG |
| BTH | 00415 | JP | BTH00415JP |
| BTH | 00485 | US | BTH00485US |
| DIN | 40672 | US | DIN40672US |
| KIT | 01729 | JP | KIT01729JP |
| KIT | 00372 | UK | KIT00372UK |
+----------+--------+---------+------------+
此示例说明了一个重要原则:您可能会以某种方式考虑值(id值作为单个字符串),但不一定需要在数据库中以此方式表示。如果另一种表示方式(单独列)更有效或更易于处理,那么即使必须重新格式化底层列使其看起来符合人们的期望,也可能值得使用。
9.8 变长子串排序
问题
您想使用不在列中给定位置出现的部分进行排序。
解决方案
确定如何识别您需要的部分,以便可以提取它们。
讨论
如果用于排序的子串长度不同,您需要一种可靠的方法来仅提取您想要的部分。为了了解其工作原理,让我们创建一个housewares3表,其类似于配方 9.7 中使用的housewares表,但id值的序号部分没有前导零:
mysql> `SELECT * FROM housewares3;`
+------------+------------------+
| id | description |
+------------+------------------+
| DIN40672US | dining table |
| KIT372UK | garbage disposal |
| KIT1729JP | microwave oven |
| BED38SG | bedside lamp |
| BTH485US | shower stall |
| BTH415JP | lavatory |
+------------+------------------+
可以使用LEFT()和RIGHT()提取和排序id值的类别和国家部分,就像housewares表一样。但是现在值的数值部分长度不同,不能使用简单的MID()调用来提取和排序。而是使用其完整版本SUBSTRING()跳过前三个字符。从从第四个字符开始的余下部分(第一个数字),取除最右侧两列之外的所有内容。一个方法如下:
mysql> `SELECT id, LEFT(SUBSTRING(id,4),CHAR_LENGTH(SUBSTRING(id,4)-2))`
-> `FROM housewares3;`
+------------+------------------------------------------------------+
| id | LEFT(SUBSTRING(id,4),CHAR_LENGTH(SUBSTRING(id,4)-2)) |
+------------+------------------------------------------------------+
| DIN40672US | 40672 |
| KIT372UK | 372 |
| KIT1729JP | 1729 |
| BED38SG | 38 |
| BTH485US | 485 |
| BTH415JP | 415 |
+------------+------------------------------------------------------+
但这比必要的更复杂。SUBSTRING()函数接受一个可选的第三个参数,指定所需的结果长度,我们知道中间部分的长度等于字符串长度减去五(开头三个字符和结尾两个字符)。以下查询演示了如何通过开始于 ID 并剥离最右侧后缀来获取数值中间部分:
mysql> `SELECT id, SUBSTRING(id,4), SUBSTRING(id,4,CHAR_LENGTH(id)-5)`
-> `FROM housewares3;`
+------------+-----------------+-----------------------------------+
| id | SUBSTRING(id,4) | SUBSTRING(id,4,CHAR_LENGTH(id)-5) |
+------------+-----------------+-----------------------------------+
| DIN40672US | 40672US | 40672 |
| KIT372UK | 372UK | 372 |
| KIT1729JP | 1729JP | 1729 |
| BED38SG | 38SG | 38 |
| BTH485US | 485US | 485 |
| BTH415JP | 415JP | 415 |
+------------+-----------------+-----------------------------------+
不幸的是,尽管最终表达式正确地从 ID 中提取了数值部分,但结果值是字符串。因此,它们按字典顺序而不是数值顺序排序:
mysql> `SELECT * FROM housewares3`
-> `ORDER BY SUBSTRING(id,4,CHAR_LENGTH(id)-5);`
+------------+------------------+
| id | description |
+------------+------------------+
| KIT1729JP | microwave oven |
| KIT372UK | garbage disposal |
| BED38SG | bedside lamp |
| DIN40672US | dining table |
| BTH415JP | lavatory |
| BTH485US | shower stall |
+------------+------------------+
如何处理?一种方法是添加零,告诉 MySQL 执行字符串到数字的转换,这样可以对序列号值进行数字排序:
mysql> `SELECT * FROM housewares3`
-> `ORDER BY SUBSTRING(id,4,CHAR_LENGTH(id)-5)+0;`
+------------+------------------+
| id | description |
+------------+------------------+
| BED38SG | bedside lamp |
| KIT372UK | garbage disposal |
| BTH415JP | lavatory |
| BTH485US | shower stall |
| KIT1729JP | microwave oven |
| DIN40672US | dining table |
+------------+------------------+
在前面的示例中,提取可变长度子字符串的能力基于id值中中间字符与两端字符的不同类型(即数字与非数字)。在其他情况下,您可能可以使用分隔符字符来拆分列值。对于下一个示例,请假设一个具有以下id值的housewares4表:
mysql> `SELECT * FROM housewares4;`
+---------------+------------------+
| id | description |
+---------------+------------------+
| 13-478-92-2 | dining table |
| 873-48-649-63 | garbage disposal |
| 8-4-2-1 | microwave oven |
| 97-681-37-66 | bedside lamp |
| 27-48-534-2 | shower stall |
| 5764-56-89-72 | lavatory |
+---------------+------------------+
要从这些值中提取段,使用SUBSTRING_INDEX(str,c,n)。它搜索字符串 str 中给定字符 c 的第 n 次出现,并返回该字符左边的所有内容。例如,以下调用返回13-478:
SUBSTRING_INDEX('13-478-92-2','-',2)
如果 n 为负数,则从右边开始搜索 c 并返回最右边的字符串。这个调用返回478-92-2:
SUBSTRING_INDEX('13-478-92-2','-',-3)
通过将SUBSTRING_INDEX()调用与正负索引结合使用,可以从每个id值中提取连续的片段:提取值的第一个 n 段并去掉最右边的一个。通过将 n 从 1 变化到 4,我们从左到右获取连续的片段:
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',1),'-',-1)
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',2),'-',-1)
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',3),'-',-1)
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',4),'-',-1)
那些表达式中的第一个可以进行优化,因为内部的SUBSTRING_INDEX()调用返回单段字符串,并且单独就足以返回最左边的id段:
SUBSTRING_INDEX(id,'-',1)
获取子字符串的另一种方法是提取值的最右边 n 段并去掉第一个。在这里,我们将 n 从 -4 变化到 -1:
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',-4),'-',1)
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',-3),'-',1)
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',-2),'-',1)
SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',-1),'-',1)
再次,可以进行优化。对于第四个表达式,内部SUBSTRING_INDEX()调用足以返回最终子字符串:
SUBSTRING_INDEX(id,'-',-1)
这 这些表达式可能很难阅读和理解,尝试几个实验看看它们的工作方式可能会有所帮助。以下是一个示例,展示如何从id值中获取第二和第四段:
mysql> `SELECT`
-> `id,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',2),'-',-1) AS segment2,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',4),'-',-1) AS segment4`
-> `FROM housewares4;`
+---------------+----------+----------+
| id | segment2 | segment4 |
+---------------+----------+----------+
| 13-478-92-2 | 478 | 2 |
| 873-48-649-63 | 48 | 63 |
| 8-4-2-1 | 4 | 1 |
| 97-681-37-66 | 681 | 66 |
| 27-48-534-2 | 48 | 2 |
| 5764-56-89-72 | 56 | 72 |
+---------------+----------+----------+
要使用子字符串进行排序,请在ORDER BY子句中使用适当的表达式。(如果要进行数字排序而不是词法排序,请记得通过添加零来强制进行字符串到数字的转换。)以下两个查询基于第二个id段对结果进行排序。第一个是按字母顺序排序,第二个是按数字顺序排序:
mysql> `SELECT * FROM housewares4`
-> `ORDER BY SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',2),'-',-1);`
+---------------+------------------+
| id | description |
+---------------+------------------+
| 8-4-2-1 | microwave oven |
| 13-478-92-2 | dining table |
| 873-48-649-63 | garbage disposal |
| 27-48-534-2 | shower stall |
| 5764-56-89-72 | lavatory |
| 97-681-37-66 | bedside lamp |
+---------------+------------------+
mysql> `SELECT * FROM housewares4`
-> `ORDER BY SUBSTRING_INDEX(SUBSTRING_INDEX(id,'-',2),'-',-1)+0;`
+---------------+------------------+
| id | description |
+---------------+------------------+
| 8-4-2-1 | microwave oven |
| 873-48-649-63 | garbage disposal |
| 27-48-534-2 | shower stall |
| 5764-56-89-72 | lavatory |
| 13-478-92-2 | dining table |
| 97-681-37-66 | bedside lamp |
+---------------+------------------+
这里的子字符串提取表达式很混乱,但至少我们应用表达式的列值具有一致的段数。对具有不同段数的值进行排序,这项工作可能更加困难。第 9.9 节展示了一个示例,说明了其中的原因。
9.9 按域名顺序对主机名排序
问题
你想按域名顺序对主机名进行排序,右边的名称部分比左边的部分更重要。
解决方案
拆分名称,并从右到左对这些部分进行排序。
讨论
主机名是字符串,因此它们的自然排序顺序是词法顺序。但通常希望按域顺序对主机名进行排序,其中主机名值的右侧段比左侧段更重要。假设一个hostname表包含以下名称:
mysql> `SELECT name FROM hostname ORDER BY name;`
+--------------------+
| name |
+--------------------+
| dbi.perl.org |
| jakarta.apache.org |
| lists.mysql.com |
| mysql.com |
| svn.php.net |
| www.kitebird.com |
+--------------------+
前面的查询展示了name值的自然词法排序顺序。这与域顺序不同,如Table 9-2所示:
表 9-2. 词法顺序与域顺序
| 词法顺序 | 域顺序 |
|---|---|
dbi.perl.org |
www.kitebird.com |
jakarta.apache.org |
mysql.com |
lists.mysql.com |
lists.mysql.com |
mysql.com |
svn.php.net |
svn.php.net |
jakarta.apache.org |
www.kitebird.com |
dbi.perl.org |
生产域排序的输出是一个子字符串排序问题,需要提取每个名称段,以便按照从右到左的顺序进行排序。如果您的值包含不同数量的段落,比如我们的示例主机名,这还会带来额外的复杂性(大多数有三段,但mysql.com只有两段)。
要提取主机名的各部分,请使用SUBSTRING_INDEX(),类似于Recipe 9.8中所描述的方式。主机名的值最多有三个段,可以从左到右提取这些部分,就像这样:
SUBSTRING_INDEX(SUBSTRING_INDEX(name,'.',-3),'.',1)
SUBSTRING_INDEX(SUBSTRING_INDEX(name,'.',-2),'.',1)
SUBSTRING_INDEX(name,'.',-1)
只要所有主机名都有三个组件,这些表达式就能正常工作。但是,如果名称少于三个组件,则不会得到正确的结果,如下面的查询所示:
mysql> `SELECT name,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(name,'.',-3),'.',1) AS leftmost,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(name,'.',-2),'.',1) AS middle,`
-> `SUBSTRING_INDEX(name,'.',-1) AS rightmost`
-> `FROM hostname;`
+--------------------+----------+----------+-----------+
| name | leftmost | middle | rightmost |
+--------------------+----------+----------+-----------+
| svn.php.net | svn | php | net |
| dbi.perl.org | dbi | perl | org |
| lists.mysql.com | lists | mysql | com |
| mysql.com | mysql | mysql | com |
| jakarta.apache.org | jakarta | apache | org |
| www.kitebird.com | www | kitebird | com |
+--------------------+----------+----------+-----------+
注意mysql.com行的输出;它在leftmost列的值是mysql,但应该是空字符串。段落提取表达式通过删除右侧的n个段落,然后返回结果的左侧段落来工作。对于mysql.com的问题在于,如果段落数量不足,表达式只返回其中有的最左侧段落。要解决此问题,请在主机名值的开头添加足够数量的句点,以保证它们具有必需的段落数量:
mysql> `SELECT name,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(CONCAT('..',name),'.',-3),'.',1)`
-> `AS leftmost,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(CONCAT('.',name),'.',-2),'.',1)`
-> `AS middle,`
-> `SUBSTRING_INDEX(name,'.',-1) AS rightmost`
-> `FROM hostname;`
+--------------------+----------+----------+-----------+
| name | leftmost | middle | rightmost |
+--------------------+----------+----------+-----------+
| svn.php.net | svn | php | net |
| dbi.perl.org | dbi | perl | org |
| lists.mysql.com | lists | mysql | com |
| mysql.com | | mysql | com |
| jakarta.apache.org | jakarta | apache | org |
| www.kitebird.com | www | kitebird | com |
+--------------------+----------+----------+-----------+
这看起来相当丑陋。但是这些表达式确实可以提取所需的子字符串,以正确地按从右到左的方式排序主机名值:
mysql> `SELECT name FROM hostname`
-> `ORDER BY`
-> `SUBSTRING_INDEX(name,'.',-1),`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(CONCAT('.',name),'.',-2),'.',1),`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(CONCAT('..',name),'.',-3),'.',1);`
+--------------------+
| name |
+--------------------+
| www.kitebird.com |
| mysql.com |
| lists.mysql.com |
| svn.php.net |
| jakarta.apache.org |
| dbi.perl.org |
+--------------------+
如果您的主机名最多有四个段而不是三个,那么在ORDER BY子句中添加另一个SUBSTRING_INDEX()表达式,以在主机名值的开头添加三个句点。
9.10 按数字顺序排序点分 IP 值
问题
您希望按照表示 IP 号码的字符串进行数字顺序排序。
解决方案
拆分字符串并按数字顺序排序各部分。或者只需使用INET_ATON()。或考虑将值存储为数字。
讨论
如果表中包含以点分十进制表示的 IP 数字字符串(192.168.1.10),则它们按词法顺序而不是数值顺序排序。为了产生数值排序,可以将它们作为四部分值进行排序,每部分按数值顺序排序。或者更高效地,将 IP 数字表示为 32 位无符号整数,这需要更少的空间并且可以通过简单的数值排序来排序。本节展示了两种方法。
要对字符串形式的点分十进制 IP 数字进行排序,可以使用类似于主机名排序的技术(见食谱 9.9),但有以下几点不同:
-
点分四段始终存在。在提取子字符串之前不需要向值添加点。
-
点分四段按从左到右排序。在
ORDER BY子句中使用的子字符串顺序与主机名排序相反。 -
点分十进制值的各段都是数字。对每个子字符串添加零以强制执行数值而不是词法排序。
假设 hostip 表具有一个字符串值 ip 列,其中包含 IP 数字:
mysql> `SELECT ip FROM hostip ORDER BY ip;`
+-----------------+
| ip |
+-----------------+
| 127.0.0.1 |
| 192.168.0.10 |
| 192.168.0.2 |
| 192.168.1.10 |
| 192.168.1.2 |
| 21.0.0.1 |
| 255.255.255.255 |
+-----------------+
前述查询生成的输出按词法顺序排序。要按 ip 值进行数值排序,提取每个段并加零以将其转换为数字,如下所示:
mysql> `SELECT ip FROM hostip`
-> `ORDER BY`
-> `SUBSTRING_INDEX(ip,'.',1)+0,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(ip,'.',-3),'.',1)+0,`
-> `SUBSTRING_INDEX(SUBSTRING_INDEX(ip,'.',-2),'.',1)+0,`
-> `SUBSTRING_INDEX(ip,'.',-1)+0;`
+-----------------+
| ip |
+-----------------+
| 21.0.0.1 |
| 127.0.0.1 |
| 192.168.0.2 |
| 192.168.0.10 |
| 192.168.1.2 |
| 192.168.1.10 |
| 255.255.255.255 |
+-----------------+
然而,尽管 ORDER BY 子句生成了正确的结果,但它很复杂。更简单的解决方案是使用 INET_ATON() 函数将网络地址从字符串形式转换为其基础数值,然后对这些数值进行排序:
mysql> `SELECT ip FROM hostip ORDER BY INET_ATON(ip);`
+-----------------+
| ip |
+-----------------+
| 21.0.0.1 |
| 127.0.0.1 |
| 192.168.0.2 |
| 192.168.0.10 |
| 192.168.1.2 |
| 192.168.1.10 |
| 255.255.255.255 |
+-----------------+
如果您试图通过简单地将零添加到 ip 值并在结果上使用 ORDER BY 进行排序,考虑一下该类型的字符串到数字转换实际产生的值:
mysql> `SELECT ip, ip+0 FROM hostip;`
+-----------------+---------+
| ip | ip+0 |
+-----------------+---------+
| 127.0.0.1 | 127 |
| 192.168.0.2 | 192.168 |
| 192.168.0.10 | 192.168 |
| 192.168.1.2 | 192.168 |
| 192.168.1.10 | 192.168 |
| 255.255.255.255 | 255.255 |
| 21.0.0.1 | 21 |
+-----------------+---------+
7 rows in set, 7 warnings (0.00 sec)
转换仅保留每个值的尽可能多的部分以解释为有效数字(因此会有警告)。其余部分因排序目的不可用,即使对于正确排序也是如此。
在 ORDER BY 子句中使用 INET_ATON() 比六个 SUBSTRING_INDEX() 调用更高效。此外,如果将 IP 地址存储为数字而不是字符串,则在排序时可以完全避免进行任何转换。这样做还有其他好处:数值 IP 地址有 32 位,因此可以使用 4 字节的 INT UNSIGNED 列来存储它们,这比字符串形式需要更少的存储空间。而且,如果对该列创建索引,查询优化器可能能够在某些查询中使用该索引。对于需要以点分十进制表示形式显示数值 IP 值的情况,可以使用 INET_NTOA() 函数进行转换。
9.11 将浮点值放在排序顺序的开头或结尾
问题
您希望按列的通常方式排序,但某些值应出现在排序顺序的开头或结尾。例如,您想按词法顺序排序列表,但希望某些高优先级值无论出现在正常排序顺序的何处都应首先显示。
解决方案
向 ORDER BY 子句添加一个初始排序列,以放置您希望它们出现的那些值。其余的排序列对其他值有正常效果。
讨论
要正常排序结果集,除非您希望特定值出现在首位,请创建一个额外的排序列,对于这些值为 0,对于其他一切为 1。这样可以使这些值浮动到升序排序顺序的最前面。若要将这些值放在末尾,请使用降序排序或者为希望在列表末尾的行存储 1,对于其他行存储 0。
假设某列包含 NULL 值:
mysql> `SELECT val FROM t;`
+------+
| val |
+------+
| 3 |
| 100 |
| NULL |
| NULL |
| 9 |
+------+
通常,对于升序排序,NULL 值会排在最前面:
mysql> `SELECT val FROM t ORDER BY val;`
+------+
| val |
+------+
| NULL |
| NULL |
| 3 |
| 9 |
| 100 |
+------+
要将它们放在末尾而不改变其他值的顺序,请引入额外的 ORDER BY 列,将 NULL 值映射到高于非 NULL 值的值:
mysql> `SELECT val FROM t ORDER BY IF(val IS NULL,1,0), val;`
+------+
| val |
+------+
| 3 |
| 9 |
| 100 |
| NULL |
| NULL |
+------+
IF() 表达式会为排序创建一个新列,该列作为主要排序值使用。
对于降序排序,NULL 值会排在最后面。若要将它们放在最前面,请使用相同的技术,但反转 IF() 函数的第二和第三参数的顺序,以将 NULL 值映射到低于非 NULL 值的值:
IF(val IS NULL,0,1)
同样的技术也适用于浮动除 NULL 之外的值到排序顺序的两端。假设您希望按发件人/收件人顺序排序 mail 表中的消息,但希望将某个发件人的消息放在首位。在实际世界中,最感兴趣的发件人可能是 postmaster 或 root。这些名称在表中不存在,因此我们将使用 phil 作为感兴趣的名称:
mysql> `SELECT t, srcuser, dstuser, size`
-> `FROM mail`
-> `ORDER BY IF(srcuser='phil',0,1), srcuser, dstuser;`
+---------------------+---------+---------+---------+
| t | srcuser | dstuser | size |
+---------------------+---------+---------+---------+
| 2014-05-16 23:04:19 | phil | barb | 10294 |
| 2014-05-12 15:02:49 | phil | phil | 1048 |
| 2014-05-15 08:50:57 | phil | phil | 978 |
| 2014-05-14 11:52:17 | phil | tricia | 5781 |
| 2014-05-19 12:49:23 | phil | tricia | 873 |
| 2014-05-14 14:42:21 | barb | barb | 98151 |
| 2014-05-11 10:15:08 | barb | tricia | 58274 |
| 2014-05-12 18:59:18 | barb | tricia | 271 |
| 2014-05-14 09:31:37 | gene | barb | 2291 |
| 2014-05-16 09:00:28 | gene | barb | 613 |
| 2014-05-15 17:35:31 | gene | gene | 3856 |
| 2014-05-15 07:17:48 | gene | gene | 3824 |
| 2014-05-19 22:21:51 | gene | gene | 23992 |
| 2014-05-15 10:25:52 | gene | tricia | 998532 |
| 2014-05-12 12:48:13 | tricia | gene | 194925 |
| 2014-05-14 17:03:01 | tricia | phil | 2394482 |
+---------------------+---------+---------+---------+
额外排序列的值对于 srcuser 值为 phil 的行为 0,对于所有其他行为 1。通过将其作为最重要的排序列,由 phil 发送的消息的行会浮动到输出的顶部。(若要将它们放在底部,可以使用 DESC 反向排序该列,或反转 IF() 函数的第二和第三参数的顺序。)
您还可以根据特定条件使用此技术,而不仅仅是特定值。要先放置那些发送消息给自己的行,请执行以下操作:
mysql> `SELECT t, srcuser, dstuser, size`
-> `FROM mail`
-> `ORDER BY IF(srcuser=dstuser,0,1), srcuser, dstuser;`
+---------------------+---------+---------+---------+
| t | srcuser | dstuser | size |
+---------------------+---------+---------+---------+
| 2014-05-14 14:42:21 | barb | barb | 98151 |
| 2014-05-19 22:21:51 | gene | gene | 23992 |
| 2014-05-15 17:35:31 | gene | gene | 3856 |
| 2014-05-15 07:17:48 | gene | gene | 3824 |
| 2014-05-12 15:02:49 | phil | phil | 1048 |
...
如果您对表的内容有很好的了解,有时可以省略额外的排序列。例如,在 mail 表中,srcuser 永远不会是 NULL,因此可以将前一个查询重写如下,以在 ORDER BY 子句中使用少一列(这依赖于 NULL 值在所有非 NULL 值之前排序的属性):
SELECT t, srcuser, dstuser, size
FROM mail
ORDER BY IF(srcuser=dstuser,NULL,srcuser), dstuser;
9.12 定义自定义排序顺序
问题
您想按非标准顺序对值进行排序。
解决方案
使用 FIELD() 将列值映射到一个序列,以便按照所需顺序排列这些值。
讨论
Recipe 9.11 展示了如何使一组特定行浮动到排序顺序的开头。要对列中的所有值施加特定顺序,请使用 FIELD() 函数将它们映射到一组数值,并使用这些数字进行排序。(当列包含少量不同的值时效果最佳。)
FIELD() 调用以下比较 value 与 str1, str2, str3, 和 str4,并根据 value 等于哪个值返回 1, 2, 3, 或 4。
FIELD(*`value`*,*`str1`*,*`str2`*,*`str3`*,*`str4`*)
如果 value 是 NULL 或没有匹配的值,FIELD() 返回 0。
可以使用 FIELD() 将任意一组值按任意顺序排序。例如,要按 Henry、Suzi 和 Ben 的顺序显示 driver_log 行,请执行以下操作:
mysql> `SELECT * FROM driver_log`
-> `ORDER BY FIELD(name,'Henry','Suzi','Ben');`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 10 | Henry | 2014-07-30 | 203 |
| 8 | Henry | 2014-08-01 | 197 |
| 6 | Henry | 2014-07-26 | 115 |
| 4 | Henry | 2014-07-27 | 96 |
| 3 | Henry | 2014-07-29 | 300 |
| 7 | Suzi | 2014-08-02 | 502 |
| 2 | Suzi | 2014-07-29 | 391 |
| 5 | Ben | 2014-07-29 | 131 |
| 9 | Ben | 2014-08-02 | 79 |
| 1 | Ben | 2014-07-30 | 152 |
+--------+-------+------------+-------+
9.13 排序 ENUM 值
问题
ENUM 值与其他字符串列不同,你希望它们按照预期顺序检索结果。
解决方案
研究 ENUM 如何存储数据,并利用这些属性为你所用。例如,可以定义自己的字符串排序顺序,存储在 ENUM 列中。
讨论
ENUM 是一种字符串数据类型,但 ENUM 值实际上是按照它们在表定义中列出的顺序存储的数值。这些数值影响枚举排序的方式,这可能非常有用。假设名为 weekday 的表包含一个名为 day 的枚举列,其成员为工作日名称:
CREATE TABLE weekday
(
day ENUM('Sunday','Monday','Tuesday','Wednesday',
'Thursday','Friday','Saturday')
);
在内部,MySQL 在该定义中定义枚举值 Sunday 到 Saturday 具有从 1 到 7 的数字值。要自行验证,请使用刚才展示的定义创建表,并为每周的每一天插入一行。为了使插入顺序与排序顺序不同(以便查看排序效果),以随机顺序添加这些天:
mysql> `INSERT INTO weekday (day) VALUES('Monday'),('Friday'),`
-> `('Tuesday'), ('Sunday'), ('Thursday'), ('Saturday'), ('Wednesday');`
然后选择这些值,既作为字符串,也作为内部数值(使用 +0 强制将字符串转换为数字):
mysql> `SELECT day, day+0 FROM weekday;`
+-----------+-------+
| day | day+0 |
+-----------+-------+
| Monday | 2 |
| Friday | 6 |
| Tuesday | 3 |
| Sunday | 1 |
| Thursday | 5 |
| Saturday | 7 |
| Wednesday | 4 |
+-----------+-------+
注意,由于查询不包括 ORDER BY 子句,行以未排序顺序返回。如果添加一个 ORDER BY day 子句,就会明显看出 MySQL 使用内部数字值进行排序:
mysql> `SELECT day, day+0 FROM weekday ORDER BY day;`
+-----------+-------+
| day | day+0 |
+-----------+-------+
| Sunday | 1 |
| Monday | 2 |
| Tuesday | 3 |
| Wednesday | 4 |
| Thursday | 5 |
| Friday | 6 |
| Saturday | 7 |
+-----------+-------+
当你希望按字典顺序排序 ENUM 值时怎么办?使用 CAST() 函数强制它们在排序时被视为字符串:
mysql> `SELECT day, day+0 FROM weekday ORDER BY CAST(day AS CHAR);`
+-----------+-------+
| day | day+0 |
+-----------+-------+
| Friday | 6 |
| Monday | 2 |
| Saturday | 7 |
| Sunday | 1 |
| Thursday | 5 |
| Tuesday | 3 |
| Wednesday | 4 |
+-----------+-------+
如果你总是(或几乎总是)按特定非字典顺序排序非枚举列,请考虑将数据类型更改为 ENUM,其值按所需的排序顺序列出。要了解其工作原理,请创建一个包含字符串列的 color 表,并填充一些示例行:
mysql> `CREATE TABLE color (name CHAR(10), PRIMARY KEY(name));`
mysql> `INSERT INTO color (name) VALUES ('blue'),('green'),`
-> `('indigo'),('orange'),('red'),('violet'),('yellow');`
此时按 name 列排序会产生字典顺序,因为该列包含 CHAR 值:
mysql> `SELECT name FROM color ORDER BY name;`
+--------+
| name |
+--------+
| blue |
| green |
| indigo |
| orange |
| red |
| violet |
| yellow |
+--------+
假设您想按照彩虹中颜色出现的顺序对列进行排序。(这是 Roy G. Biv
顺序;该名称的连续字母表示相应颜色名称的首字母。)产生彩虹排序的一种方法是使用 FIELD():
mysql> `SELECT name FROM color`
-> `ORDER BY`
-> `FIELD(name,'red','orange','yellow','green','blue','indigo','violet');`
+--------+
| name |
+--------+
| red |
| orange |
| yellow |
| green |
| blue |
| indigo |
| violet |
+--------+
要实现不使用 FIELD() 达到相同的目的,可以使用 ALTER TABLE 将 name 列转换为按照彩虹中颜色出现顺序列出的 ENUM:
mysql> `ALTER TABLE color`
-> `MODIFY name`
-> `ENUM('red','orange','yellow','green','blue','indigo','violet');`
在转换表后,对 name 列进行排序自然地产生彩虹排序,无需特殊处理:
mysql> `SELECT name FROM color ORDER BY name;`
+--------+
| name |
+--------+
| red |
| orange |
| yellow |
| green |
| blue |
| indigo |
| violet |
+--------+
请注意,一旦您切换到 ENUM 数据类型,将无法插入不属于列表的任何值。如果需要更改 ENUM 定义,例如添加新颜色,您将需要执行另一条 ALTER 命令。
第十章:生成摘要
10.0 简介
数据库系统不仅对数据存储和检索有用,还可以以更简洁的形式对数据进行总结。摘要在您想要整体图景而不是详细信息时非常有用。它们比长列表的记录更容易理解。它们使您能够回答诸如“有多少?”、“总共多少?”或“值的范围是多少?”这样的问题。如果您经营一家企业,您可能想知道每个州有多少客户,或者每个月的销售额是多少。
前述示例包括两种常见的摘要类型:计数摘要和内容摘要。第一种(每个州的客户记录数)是计数摘要。每条记录的内容仅在将其放入适当的组或类别以进行计数时才重要。这些摘要本质上是直方图,您将项目分类到一组箱中,并计算每个箱中的项目数。第二个示例(每月销售额)是内容摘要,其中销售总额基于订单记录中的销售值。
另一种摘要类型既不产生计数也不产生总和,而只是一个唯一值列表。如果您关心的是存在哪些值而不是每个值有多少个,这将非常有用。要确定您的客户所在的州,请获取记录中包含的唯一州名列表,而不是从每条记录中获取的州值列表。
可用于您的摘要类型取决于数据的性质。计数摘要可以从各种值生成,无论它们是数字、字符串还是日期。生成总和或平均值的摘要仅适用于数值。您可以计算客户州名称的实例数量,以生成客户基础的人口统计分析。有时,将一个摘要技术应用于另一个的结果是有意义的。例如,要确定客户居住在多少州中,请生成唯一客户州的列表,然后计数它们。
MySQL 中的摘要操作涉及以下 SQL 结构:
-
要从一组单独的值计算摘要值,请使用称为聚合函数之一的函数。它们之所以被称为聚合函数,是因为它们操作值的聚合(组)。聚合函数包括
COUNT(),用于计算查询结果中的行或值数量;MIN()和MAX(),用于查找最小和最大值;以及SUM()和AVG(),用于生成值的总和和平均值。这些函数可以用于计算整个结果集的值,或者与GROUP BY子句一起用于将行分组到子集中,并为每个子集获取聚合值。 -
要获取唯一值的列表,请使用
SELECT DISTINCT而不是SELECT。 -
要计算唯一值,请使用
COUNT(DISTINCT)而不是COUNT()。
本章的食谱首先说明了基本的汇总技术,然后展示了如何执行更复杂的汇总操作。您将在后续章节中找到更多汇总方法的示例,特别是涉及连接和统计操作的章节。(参见第十六章和第十七章.)
汇总查询有时涉及复杂的表达式。对于您经常执行的汇总,请记住视图可以使查询更容易使用。Recipe 5.7 演示了创建视图的基本技术。Recipe 10.5 展示了它如何应用于汇总简化,您将很容易看到它如何在本章的后续部分中使用。
本章中示例使用的主要表是driver_log和mail表。这些表也在第九章中使用过,所以应该看起来很熟悉。本章中经常使用的第三个表是states,其中每个美国州都有几列信息:
mysql> `SELECT * FROM states ORDER BY name;`
+----------------+--------+------------+----------+
| name | abbrev | statehood | pop |
+----------------+--------+------------+----------+
| Alabama | AL | 1819-12-14 | 5039877 |
| Alaska | AK | 1959-01-03 | 732673 |
| Arizona | AZ | 1912-02-14 | 7276316 |
| Arkansas | AR | 1836-06-15 | 3025891 |
| California | CA | 1850-09-09 | 39237836 |
| Colorado | CO | 1876-08-01 | 5812069 |
| Connecticut | CT | 1788-01-09 | 3605597 |
…
name和abbrev列列出完整的州名和相应的缩写。statehood列表示州加入联盟的日期。pop是 2010 年人口普查时的州人口,由美国人口普查局报告。
本章偶尔还使用其他表。您可以使用recipes发行版的tables目录中找到的脚本创建它们。Recipe 7.15 描述了reviews表。
10.1 使用 COUNT()进行汇总
问题
您想计算整个表中的行数或符合特定条件的行数。
解决方案
使用COUNT()函数。
讨论
COUNT()函数计算行数。例如,要显示表中的行,请使用SELECT *语句,但要计算行数,请使用SELECT COUNT(*)。如果没有WHERE子句,则该语句计算表中的所有行数,如下面显示的driver_log表包含多少行的语句:
mysql> `SELECT COUNT(*) FROM driver_log;`
+----------+
| COUNT(*) |
+----------+
| 10 |
+----------+
如果你不知道美国有多少个州(也许你认为有 57 个?),这个声明告诉你:
mysql> `SELECT COUNT(*) FROM states;`
+----------+
| COUNT(*) |
+----------+
| 50 |
+----------+
如果没有WHERE子句的COUNT(*)执行完整的表扫描,除非存储引擎优化了这个函数。对于存储确切行数的 MyISAM 表来说,这非常快速。对于需要扫描主键中所有条目来执行COUNT(*)的 InnoDB 表来说,您可能希望避免使用这个函数,因为对于大表来说速度可能会很慢。如果近似的行数足够好,请通过从INFORMATION_SCHEMA数据库提取TABLE_ROWS值来避免全表扫描:
SELECT TABLE_ROWS FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'cookbook' AND TABLE_NAME = 'states';
要仅计算符合特定条件的行数,请在SELECT COUNT(*)语句中包含适当的WHERE子句。可以选择条件以使COUNT(*)适用于回答许多种类的问题:
-
驾驶员一天内超过 200 英里的次数有多少次?
mysql> `SELECT COUNT(*) FROM driver_log WHERE miles > 200;` +----------+ | COUNT(*) | +----------+ | 4 | +----------+ -
Suzi 开车了多少天?
mysql> `SELECT COUNT(*) FROM driver_log WHERE name = 'Suzi';` +----------+ | COUNT(*) | +----------+ | 2 | +----------+ -
19 世纪有多少个美国州加入联盟?
mysql> `SELECT COUNT(*) FROM states` -> `WHERE statehood BETWEEN '1800-01-01' AND '1899-12-31';` +----------+ | COUNT(*) | +----------+ | 29 | +----------+
函数COUNT()实际上有两种形式。我们一直在使用的形式COUNT(*)用于计算行数。另一种形式COUNT(expr)接受列名或表达式作为参数,用于计算非NULL值的数量。以下语句展示了如何同时为表格计算行数和某列非NULL值的数量:
SELECT COUNT(*), COUNT(mycol) FROM mytbl;
COUNT(expr)不计算NULL值的事实在从同一组行中生成多个计数时非常有用。要在driver_log表中使用单个语句计算星期六和星期日的行程数,请执行以下操作:
mysql> `SELECT`
-> `COUNT(IF(DAYOFWEEK(trav_date)=7,1,NULL)) AS 'Saturday trips',`
-> `COUNT(IF(DAYOFWEEK(trav_date)=1,1,NULL)) AS 'Sunday trips'`
-> `FROM driver_log;`
+----------------+--------------+
| Saturday trips | Sunday trips |
+----------------+--------------+
| 3 | 1 |
+----------------+--------------+
或者要计算周末与工作日的行程数,请执行以下操作:
mysql> `SELECT`
-> `COUNT(IF(DAYOFWEEK(trav_date) IN (1,7),1,NULL)) AS 'weekend trips',`
-> `COUNT(IF(DAYOFWEEK(trav_date) IN (1,7),NULL,1)) AS 'weekday trips'`
-> `FROM driver_log;`
+---------------+---------------+
| weekend trips | weekday trips |
+---------------+---------------+
| 4 | 6 |
+---------------+---------------+
IF()表达式确定每个列值是否应计数。如果是,则表达式评估为1,COUNT()对其计数。如果不是,则表达式评估为NULL,COUNT()将忽略它。这样做的效果是计算满足IF()第一个参数给定条件的值的数量。
小贴士
函数COUNT()计算元素的数量,因此您可以用任何其他值替换1。结果将相同。
另请参阅
关于COUNT(*)和COUNT(expr)之间的区别进一步讨论,请参见 Recipe 10.9。
10.2 使用 MIN()和 MAX()进行汇总
问题
您想要找到数据集中最小或最大的值。
解决方案
使用函数MIN()和MAX()相应地。
讨论
在数据集中找到最小或最大的值有点类似于排序,不同之处在于它不是生成整个排序值集合,而是仅选择排序范围的一个端点的单个值。这种操作适用于关于最小、最大、最老、最新、最昂贵、最便宜等问题。查找这些值的一种方法是使用MIN()和MAX()函数。(另一种方法是使用LIMIT;请参见 Recipe 5.9。)
因为MIN()和MAX()确定集合中的极端值,它们用于表征范围:
-
邮件表中的行代表的是哪个日期范围?最早和最晚发送的消息是什么?
mysql> `SELECT` -> `MIN(t) AS earliest, MAX(t) AS latest,` -> `MIN(size) AS smallest, MAX(size) AS largest` -> `FROM mail;` +---------------------+---------------------+----------+---------+ | earliest | latest | smallest | largest | +---------------------+---------------------+----------+---------+ | 2014-05-11 10:15:08 | 2014-05-19 22:21:51 | 271 | 2394482 | +---------------------+---------------------+----------+---------+ -
美国各州人口中最小和最大的是哪些?
mysql> `SELECT MIN(pop) AS 'fewest people', MAX(pop) AS 'most people'` -> `FROM states;` +---------------+-------------+ | fewest people | most people | +---------------+-------------+ | 578803 | 39237836 | +---------------+-------------+ -
从字面上来看,第一个和最后一个州名是什么?最短和最长名称的长度是多少?
mysql> `SELECT` -> `MIN(name) AS first,` -> `MAX(name) AS last,` -> `MIN(CHAR_LENGTH(name)) AS shortest,` -> `MAX(CHAR_LENGTH(name)) AS longest` -> `FROM states;` +---------+---------+----------+---------+ | first | last | shortest | longest | +---------+---------+----------+---------+ | Alabama | Wyoming | 4 | 14 | +---------+---------+----------+---------+
最后一个查询示例说明了MIN()和MAX()不必直接应用于列值;它们还对从列值派生的表达式或值非常有用。
10.3 使用 SUM()和 AVG()进行汇总
问题
您想要计算一组值的总和或平均值(均值)。
解决方案
使用函数SUM()和AVG()。
讨论
SUM()和AVG()计算一组值的总和和平均值(均值):
-
邮件流量的总字节数及每条消息的平均大小是多少?
mysql> `SELECT` -> `SUM(size) AS 'total traffic',` -> `AVG(size) AS 'average message size'` -> `FROM mail;` +---------------+----------------------+ | total traffic | average message size | +---------------+----------------------+ | 3798185 | 237386.5625 | +---------------+----------------------+ -
driver_log表中的驾驶员行驶了多少英里?每天的平均行驶英里数是多少?mysql> `SELECT` -> `SUM(miles) AS 'total miles',` -> `AVG(miles) AS 'average miles/day'` -> `FROM driver_log;` +-------------+-------------------+ | total miles | average miles/day | +-------------+-------------------+ | 2166 | 216.6000 | +-------------+-------------------+ -
美国的总人口是多少?
mysql> `SELECT SUM(pop) FROM states;` +-----------+ | SUM(pop) | +-----------+ | 331223695 | +-----------+该值表示 2021 年人口普查报告的人口。
SUM()和AVG()是数值函数,因此不能与字符串或时间值一起使用。但有时可以将非数值值转换为有用的数值形式。假设一个表存储表示经过时间的TIME值:
mysql> `SELECT t1 FROM time_val;`
+----------+
| t1 |
+----------+
| 15:00:00 |
| 05:01:30 |
| 12:30:20 |
+----------+
要计算总经过时间,请使用TIME_TO_SEC()将值转换为秒,然后再求和。得到的总和也是秒数;将其传递给SEC_TO_TIME()以将其转换回TIME格式:
mysql> `SELECT SUM(TIME_TO_SEC(t1)) AS 'total seconds',`
-> `SEC_TO_TIME(SUM(TIME_TO_SEC(t1))) AS 'total time'`
-> `FROM time_val;`
+---------------+------------+
| total seconds | total time |
+---------------+------------+
| 117110 | 32:31:50 |
+---------------+------------+
另请参阅
SUM()和AVG()函数在统计应用中特别有用。它们在第十七章中进一步探讨,还有一个相关函数STD(),用于计算标准偏差。
10.4 使用 DISTINCT 消除重复项
问题
您希望在执行计算时跳过重复值。
解决方案
使用关键词DISTINCT。
讨论
不使用聚合函数的汇总操作是确定数据集中唯一值或行。可以使用DISTINCT(或其同义词DISTINCTROW)来实现这一点。DISTINCT将查询结果简化,并经常与ORDER BY结合使用以使值按更有意义的顺序排列。此查询按词汇顺序列出了driver_log表中的驾驶员名单:
mysql> `SELECT DISTINCT name FROM driver_log ORDER BY name;`
+-------+
| name |
+-------+
| Ben |
| Henry |
| Suzi |
+-------+
没有DISTINCT,该语句生成相同的名称,但即使是在小数据集中,也不容易理解:
mysql> `SELECT name FROM driver_log ORDER BY NAME;`
+-------+
| name |
+-------+
| Ben |
| Ben |
| Ben |
| Henry |
| Henry |
| Henry |
| Henry |
| Henry |
| Suzi |
| Suzi |
+-------+
要确定不同驾驶员的数量,请使用COUNT(DISTINCT):
mysql> `SELECT COUNT(DISTINCT name) FROM driver_log;`
+----------------------+
| COUNT(DISTINCT name) |
+----------------------+
| 3 |
+----------------------+
COUNT(DISTINCT)会忽略NULL值。如果需要将NULL视为集合中的一个值,请使用以下表达式之一:
COUNT(DISTINCT *`val`*) + IF(COUNT(IF(*`val`* IS NULL,1,NULL))=0,0,1)
COUNT(DISTINCT *`val`*) + IF(SUM(ISNULL(*`val`*))=0,0,1)
COUNT(DISTINCT *`val`*) + (SUM(ISNULL(*`val`*))<>0);
在这个例子中,我们首先计算非空值的不同值的数量,然后如果NULL值的总和大于零,则再加1。
DISTINCT查询通常与聚合函数结合使用,以更全面地描述您的数据。假设customer表包含一个指示客户位置的state列。对customer表应用COUNT(*)可以告诉您有多少客户,对state列应用DISTINCT可以告诉您有客户的州数,而对state列应用COUNT(DISTINCT)可以告诉您客户群体代表了多少州。
当与多列一起使用时,DISTINCT显示列中值的不同组合,COUNT(DISTINCT)计算组合的数量。以下语句显示了mail表中不同的发件人/收件人对及其数量:
mysql> `SELECT DISTINCT srcuser, dstuser FROM mail`
-> `ORDER BY srcuser, dstuser;`
+---------+---------+
| srcuser | dstuser |
+---------+---------+
| barb | barb |
| barb | tricia |
| gene | barb |
| gene | gene |
| gene | tricia |
| phil | barb |
| phil | phil |
| phil | tricia |
| tricia | gene |
| tricia | phil |
+---------+---------+
mysql> `SELECT COUNT(DISTINCT srcuser, dstuser) FROM mail;`
+----------------------------------+
| COUNT(DISTINCT srcuser, dstuser) |
+----------------------------------+
| 10 |
+----------------------------------+
10.5 创建一个视图以简化使用汇总
问题
您希望更容易地执行汇总操作。
解决方案
创建一个自动完成此操作的视图。
讨论
如果您经常需要特定的摘要,一种使您避免重复输入总结表达式的技术是使用视图(参见食谱 5.7)。例如,以下视图实现了周末与工作日行程总结的讨论(参见食谱 10.1):
mysql> `CREATE VIEW trip_summary_view AS`
-> `SELECT`
-> `COUNT(IF(DAYOFWEEK(trav_date) IN (1,7),1,NULL)) AS weekend_trips,`
-> `COUNT(IF(DAYOFWEEK(trav_date) IN (1,7),NULL,1)) AS weekday_trips`
-> `FROM driver_log;`
从此视图中选择比直接从底层表中选择更容易:
mysql> `SELECT * FROM trip_summary_view;`
+---------------+---------------+
| weekend_trips | weekday_trips |
+---------------+---------------+
| 4 | 6 |
+---------------+---------------+
虽然
10.6 寻找与最小值和最大值相关的值
问题
你想知道包含最小值或最大值的行中其他列的值。
解决方案
使用两个语句和一个用户定义的变量。或者一个子查询。或者一个连接。或者一个 CTE。
讨论
MIN() 和 MAX() 找到值范围的端点,但您可能也对包含该值的行中的其他值感兴趣。例如,您可以像这样找到最大的州人口:
mysql> `SELECT MAX(pop) FROM states;`
+----------+
| MAX(pop) |
+----------+
| 39237836 |
+----------+
但这并不能告诉你哪个州拥有这一人口。想要获取这些信息的显而易见的尝试如下:
mysql> `SELECT MAX(pop), name FROM states WHERE pop = MAX(pop);`
ERROR 1111 (HY000): Invalid use of group function
或许每个人迟早都会尝试这样做,但这是行不通的。聚合函数如 MIN() 和 MAX() 不能在 WHERE 子句中使用,后者需要适用于单个行的表达式。该语句的目的是确定哪一行具有最大的人口值,并显示相关的州名。问题在于,虽然你我都清楚地知道我们写这样一段话的意图,但在 SQL 中完全没有任何意义。该语句失败是因为 SQL 使用 WHERE 子句来确定选择哪些行,但聚合函数的值只有在选择确定该函数值的行后才能知道!因此,从某种意义上说,该语句是自相矛盾的。为了解决这个问题,将最大人口值保存在用户定义的变量中,然后将行与变量值进行比较:
mysql> `SET @max = (SELECT MAX(pop) FROM states);`
mysql> `SELECT pop AS 'highest population', name FROM states WHERE pop = @max;`
+--------------------+------------+
| highest population | name |
+--------------------+------------+
| 39237836 | California |
+--------------------+------------+
或者,对于单语句解决方案,请在 WHERE 子查询中使用子查询返回最大人口值:
SELECT pop AS 'highest population', name FROM states
WHERE pop = (SELECT MAX(pop) FROM states);
即使样本亚马逊评论数据中没有实际包含最小值或最大值本身,而是从中推导出来,这种技术也同样适用。要确定样本中最短评论的长度,请执行以下操作:
mysql> `SELECT MAX(CHAR_LENGTH(reviews_virtual)) FROM reviews;`
+-----------------------------------+
| MIN(CHAR_LENGTH(reviews_virtual)) |
+-----------------------------------+
| 2 |
+-----------------------------------+
如果你想知道这是哪一次评审?
请使用以下方法代替:
mysql> `SELECT JSON_EXTRACT(appliences_review, "$. reviewTime") as ReviewTime,`
-> `JSON_EXTRACT(appliences_review, "$.reviewerID") as ReviwerID,`
-> `JSON_EXTRACT(appliences_review, "$.asin") as ProductID`
-> `JSON_EXTRACT(appliences_review, "$.overall") as Rating FROM`
-> `reviews WHERE CHAR_LENGTH(reviews_virtual) =`
-> `(SELECT MIN(CHAR_LENGTH(reviews_virtual)) FROM reviews);`
+---------------+------------------+--------------+--------+
| ReviewTime | ReviwerID | ProductID | Rating |
+---------------+------------------+--------------+--------+
| "03 8, 2015" | "A3B1B4E184FSUZ" | "B000VL060M" | 5.0 |
| "03 8, 2015" | "A3B1B4E184FSUZ" | "B0015UGPWQ" | 5.0 |
| "03 8, 2015" | "A3B1B4E184FSUZ" | "B000VL060M" | 5.0 |
| "03 8, 2015" | "A3B1B4E184FSUZ" | "B0015UGPWQ" | 5.0 |
| "02 9, 2015" | "A3B1B4E184FSUZ" | "B0042U16YI" | 5.0 |
| "07 25, 2016" | "AJPRN1TD1A0SD" | "B00BIZDI0A" | 3.0 |
+---------------+------------------+--------------+--------+
选择包含最小值或最大值的行中的其他列的另一种方法是使用连接。将该值选入另一个表,然后将其与原始表连接以选择与该值匹配的行。要找到人口最多的州的行,请使用如下连接:
mysql> `CREATE TEMPORARY TABLE tmp SELECT MAX(pop) as maxpop FROM states;`
mysql> `SELECT states.* FROM states INNER JOIN tmp`
-> `ON states.pop = tmp.maxpop;`
+------------+--------+------------+----------+
| name | abbrev | statehood | pop |
+------------+--------+------------+----------+
| California | CA | 1850-09-09 | 39237836 |
+------------+--------+------------+----------+
自 MySQL 8.0 起,您可以使用公共表达式(CTE)执行相同的搜索。
mysql> `WITH` `maxpop`
-> `AS` `(``SELECT` `MAX``(``pop``)` `as` `maxpop` `FROM` `states``)`
-> `SELECT` `states``.``*` `FROM` `states`
-> `JOIN` `maxpop` `ON` `states``.``pop` `=` `maxpop``.``maxpop``;`
+------------+--------+------------+----------+ | name | abbrev | statehood | pop |
+------------+--------+------------+----------+ | California | CA | 1850-09-09 | 39237836 |
+------------+--------+------------+----------+ 1 row in set (0.00 sec)
上述两个代码片段使用了相同的思想:创建一个临时表来存储最大人口数,并将其与原始表连接。但后者在单个查询中执行此操作,因此您无需在获得结果后担心销毁临时表。我们将在配方 10.18 中详细讨论 CTE。
参见
配方 16.7 扩展了对在数据集中查找包含多个组的最小或最大值的行的讨论。
10.7 控制 MIN() 和 MAX() 的字符串大小写敏感性
问题
MIN() 和 MAX() 在您不希望它们以区分大小写的方式选择字符串时,或者反之时,以区分大小写的方式选择字符串。
解决方案
使用字符串的不同比较特性。
讨论
配方 7.1 讨论了字符串比较属性如何取决于字符串是二进制还是非二进制的:
-
二进制字符串是字节序列。它们按照数值字节值逐字节进行比较。字符集和大小写对比较没有意义。
-
非二进制字符串是字符序列。它们有字符集和排序规则,并按照排序规则定义的顺序逐字符进行比较。
这些属性也适用于作为 MIN() 或 MAX() 函数参数的字符串列,因为它们基于比较。要改变这些函数如何处理字符串列的方式,请修改列的比较属性。配方 7.7 讨论了如何控制这些属性,而配方 9.4 展示了它们如何应用于字符串排序。相同的原则适用于查找最小和最大字符串值,因此我在这里只做总结;阅读配方 9.4 获取更多详情。
-
要以区分大小写的方式比较大小写不敏感的字符串,请使用区分大小写的排序规则对值进行排序:
SELECT MIN(str_col COLLATE utf8mb4_0900_as_cs) AS min, MAX(str_col COLLATE utf8mb4_0900_as_cs) AS max FROM tbl; -
要以区分大小写的方式比较大小写敏感的字符串,请使用区分大小写的排序规则对值进行排序:
SELECT MIN(str_col COLLATE utf8mb4_0900_ai_ci) AS min, MAX(str_col COLLATE utf8mb4_0900_ai_ci) AS max FROM tbl;另一个可能性是比较已全部转换为相同大小写的值,从而使大小写无关紧要。但这也会改变检索到的值:
SELECT MIN(UPPER(str_col)) AS min, MAX(UPPER(str_col)) AS max FROM tbl; -
二进制字符串使用数值字节值进行比较,因此没有字母大小写的概念。然而,因为不同大小写的字母具有不同的字节值,所以二进制字符串的比较实际上是大小写敏感的。(即,
a和A是不相等的。)要以区分大小写的顺序比较二进制字符串,请将它们转换为非二进制字符串并应用适当的排序规则:SELECT MIN(CONVERT(str_col USING utf8mb4) COLLATE utf8mb4_0900_ai_ci) AS min, MAX(CONVERT(str_col USING utf8mb4) COLLATE utf8mb4_0900_ai_ci) AS max FROM tbl;如果默认排序规则不区分大小写(例如
utf8mb4),则可以省略COLLATE子句。
10.8 将摘要分成子组
问题
您希望为一组行的每个子组生成摘要,而不是整体摘要值。
解决方案
使用 GROUP BY 子句将行分组。
讨论
到目前为止显示的汇总语句计算了结果集中所有行的汇总值。例如,以下语句确定了mail表中的记录数,因此也是发送的邮件总数:
mysql> `SELECT COUNT(*) FROM mail;`
+----------+
| COUNT(*) |
+----------+
| 16 |
+----------+
要将一组行排列成子组并总结每个组,请与GROUP BY子句一起使用聚合函数。要确定每个发件人的消息数量,请按发件人名称对行进行分组,计算每个名称出现的次数,并显示名称及其计数:
mysql> `SELECT srcuser, COUNT(*) FROM mail GROUP BY srcuser;`
+---------+----------+
| srcuser | COUNT(*) |
+---------+----------+
| barb | 3 |
| gene | 6 |
| phil | 5 |
| tricia | 2 |
+---------+----------+
该查询汇总了用于分组的同一列(srcuser),但这并不总是必要的。假设您想要快速了解mail表,显示在其中列出的每个发送者发送的总流量(以字节为单位)及每条消息的平均字节数。在这种情况下,您仍然使用srcuser列对行进行分组,但汇总size值:
mysql> `SELECT srcuser,`
-> `SUM(size) AS 'total bytes',`
-> `AVG(size) AS 'bytes per message'`
-> `FROM mail GROUP BY srcuser;`
+---------+-------------+-------------------+
| srcuser | total bytes | bytes per message |
+---------+-------------+-------------------+
| barb | 156696 | 52232.0000 |
| gene | 1033108 | 172184.6667 |
| phil | 18974 | 3794.8000 |
| tricia | 2589407 | 1294703.5000 |
+---------+-------------+-------------------+
使用尽可能多的分组列以达到您需要的细粒度分组。早先显示每个发送者发送的消息数量的查询是一种粗略的汇总。要更具体地了解每个发送者从每个主机发送了多少消息,请使用两个分组列。这会生成一个带有嵌套组的结果(组中的组):
mysql> `SELECT srcuser, srchost, COUNT(srcuser) FROM mail`
-> `GROUP BY srcuser, srchost;`
+---------+---------+----------------+
| srcuser | srchost | COUNT(srcuser) |
+---------+---------+----------------+
| barb | saturn | 2 |
| barb | venus | 1 |
| gene | mars | 2 |
| gene | saturn | 2 |
| gene | venus | 2 |
| phil | mars | 3 |
| phil | venus | 2 |
| tricia | mars | 1 |
| tricia | saturn | 1 |
+---------+---------+----------------+
本节中的前述示例使用了COUNT()、SUM()和AVG()来进行每组汇总。您也可以使用MIN()或MAX()。配合GROUP BY子句,它们会返回每组的最小值或最大值。以下查询将mail表的行按消息发送者分组,显示每个发送者发送的最大消息的大小和最近消息的日期:
mysql> `SELECT srcuser, MAX(size), MAX(t) FROM mail GROUP BY srcuser;`
+---------+-----------+---------------------+
| srcuser | MAX(size) | MAX(t) |
+---------+-----------+---------------------+
| barb | 98151 | 2014-05-14 14:42:21 |
| gene | 998532 | 2014-05-19 22:21:51 |
| phil | 10294 | 2014-05-19 12:49:23 |
| tricia | 2394482 | 2014-05-14 17:03:01 |
+---------+-----------+---------------------+
您可以按多个列进行分组,并显示每对这些列值之间的最大值。此查询查找了在mail表中列出的每对发送者和接收者值之间发送的最大消息的大小:
mysql> `SELECT srcuser, dstuser, MAX(size) FROM mail GROUP BY srcuser, dstuser;`
+---------+---------+-----------+
| srcuser | dstuser | MAX(size) |
+---------+---------+-----------+
| barb | barb | 98151 |
| barb | tricia | 58274 |
| gene | barb | 2291 |
| gene | gene | 23992 |
| gene | tricia | 998532 |
| phil | barb | 10294 |
| phil | phil | 1048 |
| phil | tricia | 5781 |
| tricia | gene | 194925 |
| tricia | phil | 2394482 |
+---------+---------+-----------+
在使用聚合函数生成每组汇总值时,请注意以下陷阱,这涉及选择与分组列不相关的非汇总表列。假设您想知道driver_log表中每位驾驶员的最长行程:
mysql> `SELECT name, MAX(miles) AS 'longest trip'`
-> `FROM driver_log GROUP BY name;`
+-------+--------------+
| name | longest trip |
+-------+--------------+
| Ben | 152 |
| Henry | 300 |
| Suzi | 502 |
+-------+--------------+
但是如果你还想显示每位司机最长行程发生的日期怎么办?你只需将trav_date添加到输出列列表中吗?抱歉,这样不起作用:
mysql> `SELECT name, trav_date, MAX(miles) AS 'longest trip'`
-> `FROM driver_log GROUP BY name;`
+-------+------------+--------------+
| name | trav_date | longest trip |
+-------+------------+--------------+
| Ben | 2014-07-30 | 152 |
| Henry | 2014-07-29 | 300 |
| Suzi | 2014-07-29 | 502 |
+-------+------------+--------------+
查询确实产生了结果,但是如果您将其与完整表格(如此处所示)进行比较,您会看到虽然本和亨利的日期是正确的,但苏茜的日期却不正确:
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 1 | Ben | 2014-07-30 | 152 | ← Ben's longest trip
| 2 | Suzi | 2014-07-29 | 391 |
| 3 | Henry | 2014-07-29 | 300 | ← Henry's longest trip
| 4 | Henry | 2014-07-27 | 96 |
| 5 | Ben | 2014-07-29 | 131 |
| 6 | Henry | 2014-07-26 | 115 |
| 7 | Suzi | 2014-08-02 | 502 | ← Suzi's longest trip
| 8 | Henry | 2014-08-01 | 197 |
| 9 | Ben | 2014-08-02 | 79 |
| 10 | Henry | 2014-07-30 | 203 |
+--------+-------+------------+-------+
到底发生了什么?为什么汇总语句产生了不正确的结果?这是因为当您在查询中包含GROUP BY子句时,您只能有意义地选择分组列或从分组计算的汇总值。如果显示其他表列,它们与分组列不相关,显示的值是不明确的。(对于刚刚显示的语句,MySQL 可能会简单地选择每个驱动程序的第一个日期,而不管它是否与驱动程序的最大里程值匹配。)
若要避免不小心假定trav_date的值是正确的并且设置ONLY_FULL_GROUP_BY SQL 模式,使得选择不明确的值非法:
mysql> `SET sql_mode = 'ONLY_FULL_GROUP_BY';`
mysql> `SELECT name, trav_date, MAX(miles) AS 'longest trip'`
-> `FROM driver_log GROUP BY name;`
ERROR 1055 (42000): 'cookbook.driver_log.trav_date' isn't in GROUP BY
SQL 模式ONLY_FULL_GROUP_BY自 MySQL 5.7 默认设置的一部分。但我们已经看到许多旧版应用程序禁用了此选项。我们建议您始终启用ONLY_FULL_GROUP_BY并修复返回错误的查询。
解决显示与最小或最大组值相关联的行内容的一般问题涉及联接。该技术在 Recipe 16.7 中有描述。对于手头的问题,可以按以下方式生成所需的结果:
mysql> `CREATE TEMPORARY TABLE t`
-> `SELECT name, MAX(miles) AS miles FROM driver_log GROUP BY name;`
mysql> `SELECT d.name, d.trav_date, d.miles AS 'longest trip'`
-> `FROM driver_log AS d INNER JOIN t USING (name, miles) ORDER BY name;`
+-------+------------+--------------+
| name | trav_date | longest trip |
+-------+------------+--------------+
| Ben | 2014-07-30 | 152 |
| Henry | 2014-07-29 | 300 |
| Suzi | 2014-08-02 | 502 |
+-------+------------+--------------+
或者,通过使用 CTE:
mysql> `WITH` `t` `AS`
-> `(``SELECT` `name``,` `MAX``(``miles``)` `AS` `miles` `FROM` `driver_log` `GROUP` `BY` `name``)`
-> `SELECT` `d``.``name``,` `d``.``trav_date``,` `d``.``miles` `AS` `'longest trip'`
-> `FROM` `driver_log` `AS` `d` `INNER` `JOIN` `t` `USING` `(``name``,` `miles``)` `ORDER` `BY` `name``;`
+-------+------------+--------------+ | name | trav_date | longest trip |
+-------+------------+--------------+ | Ben | 2014-07-30 | 152 |
| Henry | 2014-07-29 | 300 |
| Suzi | 2014-08-02 | 502 |
+-------+------------+--------------+ 3 rows in set (0.01 sec)
10.9 使用聚合函数处理NULL值
问题
您正在汇总可能包含NULL值的一组值,并且需要知道如何解释结果。
解决方案
了解聚合函数如何处理NULL值。
讨论
大多数聚合函数会忽略NULL值。COUNT()不同:COUNT(expr)会忽略NULL实例的expr,但COUNT(*)会计算行数,不考虑内容。
假设一个expt表包含要为每个科目进行四次测试的实验结果,并且对于尚未进行的测试,将测试分数标记为NULL:
mysql> `SELECT subject, test, score FROM expt ORDER BY subject, test;`
+---------+------+-------+
| subject | test | score |
+---------+------+-------+
| Jane | A | 47 |
| Jane | B | 50 |
| Jane | C | NULL |
| Jane | D | NULL |
| Marvin | A | 52 |
| Marvin | B | 45 |
| Marvin | C | 53 |
| Marvin | D | NULL |
+---------+------+-------+
通过使用GROUP BY子句按科目名称排列行,可以像这样计算每个科目的测试次数以及总数、平均数、最低分和最高分:
mysql> `SELECT subject,`
-> `COUNT(score) AS n,`
-> `SUM(score) AS total,`
-> `AVG(score) AS average,`
-> `MIN(score) AS lowest,`
-> `MAX(score) AS highest`
-> `FROM expt GROUP BY subject;`
+---------+---+-------+---------+--------+---------+
| subject | n | total | average | lowest | highest |
+---------+---+-------+---------+--------+---------+
| Jane | 2 | 97 | 48.5000 | 47 | 50 |
| Marvin | 3 | 150 | 50.0000 | 45 | 53 |
+---------+---+-------+---------+--------+---------+
您可以从标记为n(测试次数)的列中的结果中看到,查询仅计算了五个值,即使表中包含了八个。为什么?因为该列中的值对应于每个科目的非NULL测试分数的数量。其他摘要列也仅显示从非NULL分数计算出的结果。
对于聚合函数忽略NULL值是有很多意义的。如果它们遵循通常的 SQL 算术规则,将NULL添加到任何其他值会产生一个NULL结果。这将使得聚合函数非常难以使用:为了避免得到NULL结果,您每次执行汇总时都必须过滤NULL值。通过忽略NULL值,聚合函数变得更加方便。
请注意,即使聚合函数可能会忽略NULL值,其中一些仍然可能会生成NULL作为结果。如果没有要总结的内容,就会发生这种情况,即值集合为空或仅包含NULL值。以下查询与前一个查询相同,只有一个小差异。它仅选择NULL测试分数,以说明当聚合函数没有要操作的内容时会发生什么:
mysql> `SELECT subject,`
-> `COUNT(score) AS n,`
-> `SUM(score) AS total,`
-> `AVG(score) AS average,`
-> `MIN(score) AS lowest,`
-> `MAX(score) AS highest`
-> `FROM expt WHERE score IS NULL GROUP BY subject;`
+---------+---+-------+---------+--------+---------+
| subject | n | total | average | lowest | highest |
+---------+---+-------+---------+--------+---------+
| Jane | 0 | NULL | NULL | NULL | NULL |
| Marvin | 0 | NULL | NULL | NULL | NULL |
+---------+---+-------+---------+--------+---------+
对于COUNT(),每个科目的分数数量为零,并以此方式报告。另一方面,当没有要总结的值时,SUM()、AVG()、MIN()和MAX()返回NULL。如果不希望将聚合值为NULL显示为NULL,请使用IFNULL()适当映射它:
mysql> `SELECT subject,`
-> `COUNT(score) AS n,`
-> `IFNULL(SUM(score),0) AS total,`
-> `IFNULL(AVG(score),0) AS average,`
-> `IFNULL(MIN(score),'Unknown') AS lowest,`
-> `IFNULL(MAX(score),'Unknown') AS highest`
-> `FROM expt WHERE score IS NULL GROUP BY subject;`
+---------+---+-------+---------+---------+---------+
| subject | n | total | average | lowest | highest |
+---------+---+-------+---------+---------+---------+
| Jane | 0 | 0 | 0.0000 | Unknown | Unknown |
| Marvin | 0 | 0 | 0.0000 | Unknown | Unknown |
+---------+---+-------+---------+---------+---------+
COUNT()在处理NULL值方面与其他聚合函数略有不同。与其他聚合函数一样,COUNT(expr)仅计算非NULL值,但COUNT(*)计算行数,无论其内容如何。您可以通过以下方式查看COUNT()的不同形式之间的区别:
mysql> `SELECT COUNT(*), COUNT(score) FROM expt;`
+----------+--------------+
| COUNT(*) | COUNT(score) |
+----------+--------------+
| 8 | 5 |
+----------+--------------+
这告诉我们expt表中有八行,但只有五行填写了score值。COUNT()的不同形式对于计算缺失值非常有用。只需进行差异计算:
mysql> `SELECT COUNT(*) - COUNT(score) AS missing FROM expt;`
+---------+
| missing |
+---------+
| 3 |
+---------+
还可以为子组确定缺失和非缺失计数。以下查询对每个科目都这样做,提供了一种评估实验完成程度的简便方法:
mysql> `SELECT subject,`
-> `COUNT(*) AS total,`
-> `COUNT(score) AS 'nonmissing',`
-> `COUNT(*) - COUNT(score) AS missing`
-> `FROM expt GROUP BY subject;`
+---------+-------+------------+---------+
| subject | total | nonmissing | missing |
+---------+-------+------------+---------+
| Jane | 4 | 2 | 2 |
| Marvin | 4 | 3 | 1 |
+---------+-------+------------+---------+
10.10 仅选择具有特定特征的群组
问题
您想计算群组摘要,但仅显示符合特定条件的群组结果。
解决方案
使用HAVING子句。
讨论
您熟悉使用WHERE来指定语句必须满足的行条件。因此,自然而然地使用WHERE编写涉及总结值的条件。唯一的麻烦在于这样做行不通。要识别在driver_log表中驾驶超过三天的驾驶员,您可能会像这样写语句:
mysql> `SELECT COUNT(*), name FROM driver_log`
-> `WHERE COUNT(*) > 3`
-> `GROUP BY name;`
ERROR 1111 (HY000): Invalid use of group function
问题在于WHERE指定了初始约束条件,确定了要选择哪些行,但只有在选择行后才能确定COUNT()的值。解决方案是将COUNT()表达式放在HAVING子句中。HAVING类似于WHERE,但它适用于组特征,而不是单个行。也就是说,HAVING在已选择和分组的行集上操作,根据聚合函数结果应用额外约束,这些结果在初始选择过程中是未知的。因此,前面的查询应该像这样编写:
mysql> `SELECT COUNT(*), name FROM driver_log`
-> `GROUP BY name`
-> `HAVING COUNT(*) > 3;`
+----------+-------+
| COUNT(*) | name |
+----------+-------+
| 5 | Henry |
+----------+-------+
当您使用HAVING时,仍然可以包括WHERE子句,但仅选择要总结的行,而不是测试已计算的汇总值。
HAVING可以引用别名,因此可以像这样重写先前的查询:
mysql> `SELECT COUNT(*) AS count, name FROM driver_log`
-> `GROUP BY name`
-> `HAVING count > 3;`
+-------+-------+
| count | name |
+-------+-------+
| 5 | Henry |
+-------+-------+
10.11 使用计数确定值是否唯一
问题
您想知道表中的值是否是唯一的。
解决方案
结合 COUNT() 使用 HAVING。
讨论
DISTINCT 可以消除重复项,但不显示原始数据中实际重复的值。您可以使用 HAVING 找到 DISTINCT 不适用的情况中的唯一值。HAVING 可以告诉您哪些值是唯一的或非唯一的。
下面的语句显示了仅有一名司机活跃的日期以及有多名司机活跃的日期。它们基于使用 HAVING 和 COUNT() 来确定哪些 trav_date 值是唯一或非唯一的:
mysql> `SELECT trav_date, COUNT(trav_date) FROM driver_log`
-> `GROUP BY trav_date HAVING COUNT(trav_date) = 1;`
+------------+------------------+
| trav_date | COUNT(trav_date) |
+------------+------------------+
| 2014-07-26 | 1 |
| 2014-07-27 | 1 |
| 2014-08-01 | 1 |
+------------+------------------+
mysql> `SELECT trav_date, COUNT(trav_date) FROM driver_log`
-> `GROUP BY trav_date HAVING COUNT(trav_date) > 1;`
+------------+------------------+
| trav_date | COUNT(trav_date) |
+------------+------------------+
| 2014-07-29 | 3 |
| 2014-07-30 | 2 |
| 2014-08-02 | 2 |
+------------+------------------+
这种技术也适用于值的组合。例如,要查找仅发送了一条消息的消息发送者/接收者对,请查找在 mail 表中仅出现一次的组合:
mysql> `SELECT srcuser, dstuser FROM mail`
-> `GROUP BY srcuser, dstuser HAVING COUNT(*) = 1;`
+---------+---------+
| srcuser | dstuser |
+---------+---------+
| barb | barb |
| gene | tricia |
| phil | barb |
| tricia | gene |
| tricia | phil |
+---------+---------+
注意,此查询不会打印计数。前面的示例这样做是为了展示计数的正确使用,但您可以在 HAVING 子句中引用聚合值,而无需将其包含在输出列列表中。
10.12 按表达式结果分组
问题
您想要根据从表达式计算的值将行分组为子组。
解决方案
在 GROUP BY 子句中,使用将值分类的表达式。
讨论
GROUP BY 和 ORDER BY 一样,可以引用表达式。这意味着您可以使用计算作为分组的依据。与 ORDER BY 类似,您可以直接在 GROUP BY 子句中编写分组表达式,或者在输出列列表中使用别名来引用该表达式,并在 GROUP BY 中引用该别名。
要找出一年中有多个州加入联盟的日期,请按州加入日期和日进行分组,然后使用 HAVING 和 COUNT() 找到非唯一组合:
mysql> `SELECT`
-> `MONTHNAME(statehood) AS month,`
-> `DAYOFMONTH(statehood) AS day,`
-> `COUNT(*) AS count`
-> `FROM states GROUP BY month, day HAVING count > 1;`
+----------+------+-------+
| month | day | count |
+----------+------+-------+
| February | 14 | 2 |
| June | 1 | 2 |
| March | 1 | 2 |
| May | 29 | 2 |
| November | 2 | 2 |
+----------+------+-------+
10.13 总结非分类数据
问题
您想要总结一组不自然分类的值。
解决方案
使用表达式将值分组为类别。
讨论
Recipe 10.12 展示了如何按表达式结果分组行。这一重要应用是对非分类值进行分类。这是有用的,因为 GROUP BY 最适合具有重复值的列。例如,您可以尝试使用 states 表中的值对行进行分组以执行人口分析。由于列中的独特值较多,这并不奏效。事实上,它们全部都是独特的:
mysql> `SELECT COUNT(pop), COUNT(DISTINCT pop) FROM states;`
+------------+---------------------+
| COUNT(pop) | COUNT(DISTINCT pop) |
+------------+---------------------+
| 50 | 50 |
+------------+---------------------+
在像这样的情况下,数值不能很好地分组到少数集合中,可以使用一种强制将其强制分为类别的转换。首先确定人口值的范围:
mysql> `SELECT MIN(pop), MAX(pop) FROM states;`
+----------+----------+
| MIN(pop) | MAX(pop) |
+----------+----------+
| 578803 | 39237836 |
+----------+----------+
您可以从该结果看出,如果将pop值除以 500 万,它们将分成八个类别——一个合理的数目。(类别范围将是 1 到 5,000,000,5,000,001 到 10,000,000 等等)。要将每个人口值放入正确的类别,请将其除以 500 万,并使用整数结果:
mysql> ``SELECT FLOOR(pop/5000000) AS `max population (millions)`,``
-> `` COUNT(*) AS `number of states` ``
-> `` FROM states GROUP BY `max population (millions)` ``
-> ``ORDER BY `max population (millions)`;``
+---------------------------+------------------+
| max population (millions) | number of states |
+---------------------------+------------------+
| 0 | 26 |
| 1 | 14 |
| 2 | 6 |
| 3 | 1 |
| 4 | 1 |
| 5 | 1 |
| 7 | 1 |
+---------------------------+------------------+
嗯。这还不太对。该表达式将人口值分组到少量类别中,但未正确报告类别值。让我们尝试将FLOOR()结果乘以五:
mysql> ``SELECT FLOOR(pop/5000000)*5 AS `max population (millions)`,``
-> `` COUNT(*) AS `number of states` ``
-> `` FROM states GROUP BY `max population (millions)` ``
-> ``ORDER BY `max population (millions)`;``
+---------------------------+------------------+
| max population (millions) | number of states |
+---------------------------+------------------+
| 0 | 26 |
| 5 | 14 |
| 10 | 6 |
| 15 | 1 |
| 20 | 1 |
| 25 | 1 |
| 35 | 1 |
+---------------------------+------------------+
这仍然不正确。最大州人口为 35,893,799,应该放入 4000 万类别中,而不是 3500 万类别中。问题在于生成类别表达式将值分组到每个类别的下限。为了将值分组到每个类别的上限,可以使用以下技术。对于大小为n的类别,使用以下表达式将值x放入正确的类别:
FLOOR((*`x`*+(*`n`*-1))/*`n`*)
因此,我们的查询的最终形式如下所示:
mysql> ``SELECT FLOOR((pop+4999999)/5000000)*5 AS `max population (millions)`,``
-> `` COUNT(*) AS `number of states` ``
-> `` FROM states GROUP BY `max population (millions)` ``
-> ``ORDER BY `max population (millions)`;``
+---------------------------+------------------+
| max population (millions) | number of states |
+---------------------------+------------------+
| 5 | 26 |
| 10 | 14 |
| 15 | 6 |
| 20 | 1 |
| 25 | 1 |
| 30 | 1 |
| 40 | 1 |
+---------------------------+------------------+
结果清楚地显示,大多数美国州的人口为五百万或更少。
在某些情况下,将群组按对数刻度分类可能更为合适。例如,如下处理州人口数值:
mysql> ``SELECT FLOOR(LOG10(pop)) AS `log10(population)`,``
-> `` COUNT(*) AS `number of states` ``
-> ``FROM states GROUP BY `log10(population)`;``
+-------------------+------------------+
| log10(population) | number of states |
+-------------------+------------------+
| 5 | 5 |
| 6 | 35 |
| 7 | 10 |
+-------------------+------------------+
查询显示了人口分别以数十万、百万和数千万进行测量的州的数量。
您可能已经注意到,前面的查询中使用反引号(标识符引用)编写别名,而不是单引号(字符串引用)。在GROUP BY子句中引用的别名必须使用标识符引用,否则别名将被视为常量字符串表达式,导致分组产生错误的结果。标识符引用使 MySQL 明确知道别名是指输出列。输出列列表中的别名可以使用字符串引用进行编写;我们在这里使用反引号是为了避免在给定查询中混合别名引用风格。
10.14 查找最小或最大的摘要值
问题
您想计算每个群组的摘要值,但仅显示其中最小或最大的值。
解决方案
在语句中添加LIMIT子句。或者使用用户定义变量或子查询来选择适当的摘要。
讨论
MIN()和MAX()可以找到值集的端点值,但是要找到摘要值集的端点值,这些函数将无法工作。它们的参数不能是另一个聚合函数。例如,您可以轻松找到每位驾驶员的里程总数:
mysql> `SELECT name, SUM(miles)`
-> `FROM driver_log`
-> `GROUP BY name;`
+-------+------------+
| name | SUM(miles) |
+-------+------------+
| Ben | 362 |
| Henry | 911 |
| Suzi | 893 |
+-------+------------+
要仅选择具有最多英里数的驾驶员行,以下方法不起作用:
mysql> `SELECT name, SUM(miles)`
-> `FROM driver_log`
-> `GROUP BY name`
-> `HAVING SUM(miles) = MAX(SUM(miles));`
ERROR 1111 (HY000): Invalid use of group function
相反,首先按最大的SUM()值对行进行排序,并使用LIMIT选择第一行:
mysql> `SELECT name, SUM(miles)`
-> `FROM driver_log`
-> `GROUP BY name`
-> `ORDER BY SUM(miles) DESC LIMIT 1;`
+-------+------------+
| name | SUM(miles) |
+-------+------------+
| Henry | 911 |
+-------+------------+
但是,如果多个行具有给定的摘要值,则LIMIT 1查询将不会告诉您。例如,您可能尝试查明州名字的最常见的初始字母:
mysql> `SELECT LEFT(name,1) AS letter, COUNT(*) FROM states`
-> `GROUP BY letter ORDER BY COUNT(*) DESC LIMIT 1;`
+--------+----------+
| letter | COUNT(*) |
+--------+----------+
| M | 8 |
+--------+----------+
但是,还有八个州名称以N开头。要找出可能有多个最频繁值时,请使用用户定义变量或子查询确定最大计数,然后选择计数等于最大值的这些值:
mysql> `SET @max = (SELECT COUNT(*) FROM states`
-> `GROUP BY LEFT(name,1) ORDER BY COUNT(*) DESC LIMIT 1);`
mysql> `SELECT LEFT(name,1) AS letter, COUNT(*) FROM states`
-> `GROUP BY letter HAVING COUNT(*) = @max;`
+--------+----------+
| letter | COUNT(*) |
+--------+----------+
| M | 8 |
| N | 8 |
+--------+----------+
mysql> `SELECT LEFT(name,1) AS letter, COUNT(*) FROM states`
-> `GROUP BY letter HAVING COUNT(*) =`
-> `(SELECT COUNT(*) FROM states`
-> `GROUP BY LEFT(name,1) ORDER BY COUNT(*) DESC LIMIT 1);`
+--------+----------+
| letter | COUNT(*) |
+--------+----------+
| M | 8 |
| N | 8 |
+--------+----------+
10.15 生成基于日期的摘要
问题
您希望根据日期或时间值生成摘要。
解决方案
使用GROUP BY将时间值放入适当时段的类别中。通常涉及使用提取日期或时间重要部分的表达式。
讨论
要根据时间对行进行排序,请使用带有时间列的ORDER BY。要根据时间间隔将行进行汇总,则需要确定如何将行分类到适当的间隔,并使用GROUP BY相应地对它们进行分组。
例如,要确定每天路上有多少驾驶员及每天驾驶了多少英里,请按日期将driver_log表中的行分组:^(1)
mysql> `SELECT trav_date,`
-> `COUNT(*) AS 'number of drivers', SUM(miles) As 'miles logged'`
-> `FROM driver_log GROUP BY trav_date;`
+------------+-------------------+--------------+
| trav_date | number of drivers | miles logged |
+------------+-------------------+--------------+
| 2014-07-26 | 1 | 115 |
| 2014-07-27 | 1 | 96 |
| 2014-07-29 | 3 | 822 |
| 2014-07-30 | 2 | 355 |
| 2014-08-01 | 1 | 197 |
| 2014-08-02 | 2 | 581 |
+------------+-------------------+--------------+
然而,随着表中行数的增加,这种按天汇总会变得越来越长。随着时间的推移,不同日期的数量将变得非常多,使得摘要失去实用性,您可能决定增加类别的大小。例如,此查询按月进行分类:
mysql> `SELECT YEAR(trav_date) AS year, MONTH(trav_date) AS month,`
-> `COUNT(*) AS 'number of drivers', SUM(miles) As 'miles logged'`
-> `FROM driver_log GROUP BY year, month;`
+------+-------+-------------------+--------------+
| year | month | number of drivers | miles logged |
+------+-------+-------------------+--------------+
| 2014 | 7 | 7 | 1388 |
| 2014 | 8 | 3 | 778 |
+------+-------+-------------------+--------------+
现在,随着时间的推移,摘要行的数量增长速度大大放缓。最终,您可以仅基于年份进行汇总,以进一步折叠行。
时间分类的用途很多:
-
要从可能包含许多唯一值的
DATETIME或TIMESTAMP列生成每日摘要,请去除一天中的时间部分,将所有出现在给定日期内的值折叠为相同值。以下任何一个GROUPBY子句都可以做到这一点,尽管最后一个可能最慢:GROUP BY DATE(*`col_name`*) GROUP BY FROM_DAYS(TO_DAYS(*`col_name`*)) GROUP BY YEAR(*`col_name`*), MONTH(*`col_name`*), DAYOFMONTH(*`col_name`*) GROUP BY DATE_FORMAT(*`col_name`*,'%Y-%m-%e') -
要生成每月或每季度的销售报告,请按照
MONTH(col_name)或QUARTER(col_name)对日期进行分组,以确保数据能正确地对应到年度的相应部分。
10.16 同时处理每组和总体摘要值
问题
您希望生成一个需要不同级别摘要细节的报告。或者您想将每组的摘要值与总体摘要值进行比较。
解决方案
使用两个语句检索不同级别的摘要信息。或者使用子查询检索一个摘要值,并在外部查询中引用其他摘要值。对于仅显示多个摘要级别(而不对其执行其他计算)的应用程序,WITH ROLLUP可能已足够。
讨论
一些报告涉及多层次的摘要信息。以下报告显示了从driver_log表中每位驾驶员的总英里数,以及每位驾驶员英里数占整个表总英里数的百分比:
+-------+--------------+------------------------+
| name | miles/driver | percent of total miles |
+-------+--------------+------------------------+
| Ben | 362 | 16.7128 |
| Henry | 911 | 42.0591 |
| Suzi | 893 | 41.2281 |
+-------+--------------+------------------------+
百分比表示每位驾驶员英里数占所有驾驶员总英里数的比例。为执行百分比计算,您需要每组摘要以获取每位驾驶员的英里数,并且还需要总体摘要以获取总英里数。首先运行一个查询来获取总里程数:
mysql> `SELECT @total := SUM(miles) AS 'total miles' FROM driver_log;`
+-------------+
| total miles |
+-------------+
| 2166 |
+-------------+
然后计算每组的值,并使用总体总计计算百分比:
mysql> `SELECT name,`
-> `SUM(miles) AS 'miles/driver',`
-> `(SUM(miles)*100)/@total AS 'percent of total miles'`
-> `FROM driver_log GROUP BY name;`
+-------+--------------+------------------------+
| name | miles/driver | percent of total miles |
+-------+--------------+------------------------+
| Ben | 362 | 16.7128 |
| Henry | 911 | 42.0591 |
| Suzi | 893 | 41.2281 |
+-------+--------------+------------------------+
要将两个语句合并为一个,使用子查询计算总英里数:
SELECT name,
SUM(miles) AS 'miles/driver',
(SUM(miles)*100)/(SELECT SUM(miles) FROM driver_log)
AS 'percent of total miles'
FROM driver_log GROUP BY name;
一个类似的问题使用多个摘要级别比较每组摘要值与相应的总体摘要值。假设您希望显示驾驶员的平均每日英里数低于组平均值。在子查询中计算总体平均值,然后使用 HAVING 子句将每个驾驶员的平均值与总体平均值进行比较:
mysql> `SELECT name, AVG(miles) AS driver_avg FROM driver_log`
-> `GROUP BY name`
-> `HAVING driver_avg < (SELECT AVG(miles) FROM driver_log);`
+-------+------------+
| name | driver_avg |
+-------+------------+
| Ben | 120.6667 |
| Henry | 182.2000 |
+-------+------------+
要显示不同级别的摘要值(而不是执行一个摘要级别相对于另一个的计算),请将 WITH ROLLUP 添加到 GROUP BY 子句中:
mysql> `SELECT name, SUM(miles) AS 'miles/driver'`
-> `FROM driver_log GROUP BY name WITH ROLLUP;`
+-------+--------------+
| name | miles/driver |
+-------+--------------+
| Ben | 362 |
| Henry | 911 |
| Suzi | 893 |
| NULL | 2166 |
+-------+--------------+
mysql> `SELECT name, AVG(miles) AS driver_avg FROM driver_log`
-> `GROUP BY name WITH ROLLUP;`
+-------+------------+
| name | driver_avg |
+-------+------------+
| Ben | 120.6667 |
| Henry | 182.2000 |
| Suzi | 446.5000 |
| NULL | 216.6000 |
+-------+------------+
在每种情况下,name 列中带有 NULL 的输出行代表所有驾驶员的总和或平均值。
如果按多列分组,则 WITH ROLLUP 会生成多个摘要级别。以下语句显示了每对用户之间发送的邮件消息数量:
mysql> `SELECT srcuser, dstuser, COUNT(*)`
-> `FROM mail GROUP BY srcuser, dstuser;`
+---------+---------+----------+
| srcuser | dstuser | COUNT(*) |
+---------+---------+----------+
| barb | barb | 1 |
| barb | tricia | 2 |
| gene | barb | 2 |
| gene | gene | 3 |
| gene | tricia | 1 |
| phil | barb | 1 |
| phil | phil | 2 |
| phil | tricia | 2 |
| tricia | gene | 1 |
| tricia | phil | 1 |
+---------+---------+----------+
添加 WITH ROLLUP 会导致输出包含每个 srcuser 值的中间计数(这些是 dstuser 列中带有 NULL 的行),以及最后的总计数:
mysql> `SELECT srcuser, dstuser, COUNT(*)`
-> `FROM mail GROUP BY srcuser, dstuser WITH ROLLUP;`
+---------+---------+----------+
| srcuser | dstuser | COUNT(*) |
+---------+---------+----------+
| barb | barb | 1 |
| barb | tricia | 2 |
| barb | NULL | 3 |
| gene | barb | 2 |
| gene | gene | 3 |
| gene | tricia | 1 |
| gene | NULL | 6 |
| phil | barb | 1 |
| phil | phil | 2 |
| phil | tricia | 2 |
| phil | NULL | 5 |
| tricia | gene | 1 |
| tricia | phil | 1 |
| tricia | NULL | 2 |
| NULL | NULL | 16 |
+---------+---------+----------+
10.17 生成包含摘要和列表的报告
问题
您希望创建一个报告,显示摘要以及与每个摘要值相关联的行的列表。
解决方案
使用两个语句检索不同级别的摘要信息。或者使用编程语言执行部分工作,以便您可以使用单个语句。
讨论
假设您想生成类似以下内容的报告:
Name: Ben; days on road: 3; miles driven: 362
date: 2014-07-29, trip length: 131
date: 2014-07-30, trip length: 152
date: 2014-08-02, trip length: 79
Name: Henry; days on road: 5; miles driven: 911
date: 2014-07-26, trip length: 115
date: 2014-07-27, trip length: 96
date: 2014-07-29, trip length: 300
date: 2014-07-30, trip length: 203
date: 2014-08-01, trip length: 197
Name: Suzi; days on road: 2; miles driven: 893
date: 2014-07-29, trip length: 391
date: 2014-08-02, trip length: 502
对于 driver_log 表中的每个驾驶员,报告显示以下信息:
-
汇总行显示驾驶员姓名、上路天数和驾驶里程数。
-
详细说明了从中计算摘要值的各个行程的日期和里程数的列表。
这种情况是配方 10.16 中讨论的“不同级别的摘要信息”问题的变体。一开始可能看起来不像是这样,因为其中一种信息类型是列表而不是摘要。但这实际上只是“级别零”摘要。这种问题以许多其他形式出现:
-
您有一个列出政党内候选人捐款的数据库。党主席请求打印出每位候选人的捐款数和捐款总额,以及捐赠者姓名和地址列表。
-
您希望为公司演示创建手册,其中总结每个销售区域的总销售额,并在每个区域下的列表中显示该区域每个州的销售额。
这类问题有多种解决方案:
-
运行单独的语句以获取您需要的每个详细级别的信息。(单个查询不会生成每组摘要值和每组的各行列表。)
-
检索构成列表并执行摘要计算的行,以消除摘要语句。
让我们使用每种方法来生成本节开头显示的驾驶员报告。以下实现(使用 Python)使用一个查询来汇总每个驾驶员的天数和英里数,另一个查询获取每个驾驶员的单独行程行:
# select total miles per driver and construct a dictionary that
# maps each driver name to days on the road and miles driven
name_map = {}
cursor = conn.cursor()
cursor.execute('''
SELECT name, COUNT(name), SUM(miles)
FROM driver_log GROUP BY name
''')
for (name, days, miles) in cursor:
name_map[name] = (days, miles)
# select trips for each driver and print the report, displaying the
# summary entry for each driver prior to the list of trips
cursor.execute('''
SELECT name, trav_date, miles
FROM driver_log ORDER BY name, trav_date
''')
cur_name = ""
for (name, trav_date, miles) in cursor:
if cur_name != name: # new driver; print driver's summary info
print("Name: %s; days on road: %d; miles driven: %d" %
(name, name_map[name][0], name_map[name][1]))
cur_name = name
print(" date: %s, trip length: %d" % (trav_date, miles))
cursor.close()
另一种实现在程序内执行摘要计算,从而减少所需的查询数量。如果您遍历行程列表并计算每个驾驶员的每日计数和里程总数,则只需一个查询即可:
# get list of trips for the drivers
cursor = conn.cursor()
cursor.execute('''
SELECT name, trav_date, miles FROM driver_log
ORDER BY name, trav_date
''')
# fetch rows into data structure because we
# must iterate through them multiple times
rows = cursor.fetchall()
cursor.close()
# iterate through rows once to construct a dictionary that
# maps each driver name to days on the road and miles driven
# (the dictionary entries are lists rather than tuples because
# we need mutable values that can be modified in the loop)
name_map = {}
for (name, trav_date, miles) in rows:
if name not in name_map: # initialize entry if nonexistent
name_map[name] = [0, 0]
name_map[name][0] += 1 # count days
name_map[name][1] += miles # sum miles
# iterate through rows again to print the report, displaying the
# summary entry for each driver prior to the list of trips
cur_name = ""
for (name, trav_date, miles) in rows:
if cur_name != name: # new driver; print driver's summary info
print("Name: %s; days on road: %d; miles driven: %d" %
(name, name_map[name][0], name_map[name][1]))
cur_name = name
print(" date: %s, trip length: %d" % (trav_date, miles))
如果您需要更多级别的摘要信息,则此类问题会变得更加复杂。例如,您可能希望在显示驾驶员摘要和行程日志之前,先显示显示所有驾驶员总英里数的报告:
Total miles driven by all drivers combined: 2166
Name: Ben; days on road: 3; miles driven: 362
date: 2014-07-29, trip length: 131
date: 2014-07-30, trip length: 152
date: 2014-08-02, trip length: 79
Name: Henry; days on road: 5; miles driven: 911
date: 2014-07-26, trip length: 115
date: 2014-07-27, trip length: 96
date: 2014-07-29, trip length: 300
date: 2014-07-30, trip length: 203
date: 2014-08-01, trip length: 197
Name: Suzi; days on road: 2; miles driven: 893
date: 2014-07-29, trip length: 391
date: 2014-08-02, trip length: 502
在这种情况下,您需要另一个查询以生成总里程,或者在您的程序中进行另一种计算来计算总体总数。
10.18 从临时结果集生成摘要
问题
想要生成摘要,但如果不使用临时结果集则无法实现。
解决方案
使用通用表达式(CTE)通过WITH子句。
讨论
我们已经讨论过临时表的情况,它保存来自查询的结果,有助于创建摘要。在这些情况下,我们从查询中引用了临时表,生成了结果摘要。参见 Recipe 10.6 和 Recipe 10.8 进行示例。
对于这样的任务,临时表并非总是最佳解决方案。它们有许多缺点,特别是:
-
您需要维护表格:在重新使用它时删除所有内容,并在完成使用后删除。
-
CREATE [TEMPORARY] TABLE ... SELECT语句隐式提交事务,因此当原始表的内容在数据插入临时表后可能发生变化时,不能使用它。您必须先创建表,然后插入数据并在多语句事务中生成摘要。例如,在 Recipe 10.8 中讨论的查找每位司机最长行程可能会得到以下代码:CREATE TEMPORARY TABLE t LIKE driver_log; START TRANSACTION; INSERT INTO t SELECT name, MAX(miles) AS miles FROM driver_log GROUP BY name; SELECT d.name, d.trav_date, d.miles AS 'longest trip' FROM driver_log AS d INNER JOIN t USING (name, miles) ORDER BY name; COMMIT; DROP TABLE t; -
优化器的选项较少,无法提高查询的性能。
通用表达式(CTE)允许在查询内部创建命名的临时结果集。CTE 的语法是
WITH *`result_name`* AS (SELECT ...)
SELECT ...
然后,您可以在以下查询中引用命名的结果,就像它是一个常规表一样。您可以定义多个公共表达式(CTE),在需要时多次引用相同的命名结果。
因此,Recipe 10.17 中的示例展示了每位司机的行程次数和总里程以及行程详细信息,可以通过 CTE 解决。
mysql> `WITH` 
-> `trips` `AS` `(``SELECT` `name``,` `trav_date``,` `miles` `FROM` `driver_log``)``,` 
-> `summaries` `AS` `(`
-> `SELECT` `name``,` `COUNT``(``name``)` `AS` `days_on_road``,` `SUM``(``miles``)` `AS` `miles_driven` 
-> `FROM` `driver_log` `GROUP` `BY` `name``)`
-> `SELECT` `trips``.``name``,` `days_on_road``,` `miles_driven``,` `trav_date``,` `miles` 
-> `FROM` `summaries` `LEFT` `JOIN` `trips` `USING``(``name``)``;`
+-------+--------------+--------------+------------+-------+ | name | days_on_road | miles_driven | trav_date | miles |
+-------+--------------+--------------+------------+-------+ | Ben | 3 | 362 | 2014-08-02 | 79 | 
| Ben | 3 | 362 | 2014-07-29 | 131 |
| Ben | 3 | 362 | 2014-07-30 | 152 |
| Suzi | 2 | 893 | 2014-08-02 | 502 |
| Suzi | 2 | 893 | 2014-07-29 | 391 |
| Henry | 5 | 911 | 2014-07-30 | 203 |
| Henry | 5 | 911 | 2014-08-01 | 197 |
| Henry | 5 | 911 | 2014-07-26 | 115 |
| Henry | 5 | 911 | 2014-07-27 | 96 |
| Henry | 5 | 911 | 2014-07-29 | 300 |
+-------+--------------+--------------+------------+-------+ 10 rows in set (0.00 sec)
关键词WITH开始 CTE。
将名称trips分配给SELECT,检索旅行数据。
第二个命名SELECT生成了每位司机的行程次数和总里程的摘要。
主查询引用了两个命名结果集,并使用LEFT JOIN将它们联接,就像它们是常规表一样。
每行结果包含行程次数、驾驶的总里程数和各个行程的详细信息。
^(1) 结果仅包括实际在表中表示的日期的条目。要生成包括表中日期范围内所有日期条目的摘要,请使用联接填充“缺失”值。参见 Recipe 16.8。
第十一章:使用存储例程、触发器和预定事件
11.0 简介
本书中,“存储程序”一词指的是存储例程、触发器和事件的总称,“存储例程”一词指的是存储函数和存储过程的总称。
本章讨论多种存储程序:
存储函数和存储过程
存储函数或过程对象封装了执行操作的代码,使您可以通过名称轻松调用对象,而不是每次需要时重复所有代码。存储函数执行计算并返回一个值,可以像内置函数(如RAND()、NOW()或LEFT())一样在表达式中使用。存储过程执行不需要返回值的操作。使用CALL语句调用过程,不在表达式中使用。过程可能会更新表中的行或生成发送到客户端程序的结果集。
触发器
触发器是一个对象,当表通过INSERT、UPDATE或DELETE语句修改时被激活。例如,可以在将值插入表之前检查它们,或者指定从表中删除的任何行应记录到另一个作为数据变更日志的表中。触发器自动化这些操作。
预定事件
事件是在预定时间或多个时间点执行 SQL 语句的对象。将预定事件视为 MySQL 内部类似 Unix cron作业的内容。例如,事件可帮助您执行如定期删除旧表行或创建每夜摘要等管理任务。
存储程序是用户定义的数据库对象,但存储在服务器端以便以后执行。这与从客户端向服务器发送 SQL 语句进行即时执行不同。每个对象还具有其被调用时执行的其他 SQL 语句的属性。对象体是一个单一的 SQL 语句,但该语句可以使用复合语句语法(一个BEGIN … END块),其中包含多个语句。因此,对象体可以从非常简单到极其复杂不等。下面的存储过程是一个微不足道的例程,只显示当前 MySQL 版本,使用的是一个包含单个SELECT语句的体:
CREATE PROCEDURE show_version()
SELECT VERSION() AS 'MySQL Version';
更复杂的操作使用BEGIN … END复合语句:
CREATE PROCEDURE show_part_of_day()
BEGIN
DECLARE cur_time, day_part TEXT;
SET cur_time = CURTIME();
IF cur_time < '12:00:00' THEN
SET day_part = 'morning';
ELSEIF cur_time = '12:00:00' THEN
SET day_part = 'noon';
ELSE
SET day_part = 'afternoon or night';
END IF;
SELECT cur_time, day_part;
END;
在这里,BEGIN … END块包含多个语句,但本身被视为单个语句。复合语句使您能够声明局部变量,并使用条件逻辑和循环结构。这些功能为算法表达提供了比在非复合语句(如SELECT或UPDATE)中编写内联表达式时更大的灵活性。
复合语句中的每个语句必须以;字符结尾。如果您使用mysql客户端定义使用复合语句的对象,则此要求会引起问题,因为mysql本身会解释;以确定语句的边界。解决方案是在定义复合语句对象时重新定义mysql的语句分隔符。第 11.1 节介绍了如何做到这一点;确保在继续后面的部分之前阅读该章节。
本章通过示例说明存储例程、触发器和事件,但由于空间限制,未详细介绍它们的广泛语法。有关完整的语法描述,请参阅MySQL 参考手册。
本章示例中显示的脚本位于recipes分发的例程、触发器和事件目录中。用于创建示例表的脚本位于表目录中。
除了本章中显示的存储程序外,还可以在本书的其他地方找到其他存储程序。例如,请参阅第 7.6 节、第 8.3 节、第 16.8 节和第 24.2 节。
此处使用的存储程序是在假设cookbook是默认数据库的情况下创建和调用的。要从另一个数据库调用程序,请使用数据库名称限定其名称:
CALL cookbook.show_version();
或者,为您的存储程序创建一个专门的数据库,在该数据库中创建它们,并始终使用该名称调用它们。请记住为将使用它们的用户授予该数据库的EXECUTE权限。
11.1 创建复合语句对象
问题
您想定义一个存储程序,但其主体包含;语句终止符的实例。mysql客户端程序默认使用相同的终止符,因此mysql会误解定义并产生错误。
解决方案
使用delimiter命令重新定义mysql语句终止符。
讨论
每个存储程序都是一个具有主体的对象,该主体必须是一个单个 SQL 语句。然而,这些对象经常执行需要多个语句的复杂操作。为了处理这种情况,将语句写在形成复合语句的BEGIN … END块内。也就是说,该块本身是一个单一语句,但可以包含多个语句,每个语句以;字符结尾。BEGIN … END块可以包含诸如SELECT或INSERT之类的语句,但复合语句还允许条件语句(如IF或CASE)、循环结构(如WHILE或REPEAT)或其他BEGIN … END块。
复合语句语法提供了灵活性,但如果在mysql客户端内定义复合语句对象,您会很快遇到问题:每个复合语句对象内部的语句必须以;字符结尾,但mysql本身解释;以确定语句的结束位置,从而逐一将其发送到服务器执行。因此,当mysql看到第一个;字符时,它会停止读取复合语句,这太早了。为了解决这个问题,告诉mysql识别不同的语句分隔符,以便忽略对象主体内部的;字符。使用新的分隔符终止对象本身,mysql会识别并将整个对象定义发送到服务器。在定义复合语句对象后,可以将mysql分隔符恢复为其原始值。
以下示例使用存储函数说明如何更改分隔符,但原则适用于定义任何类型的存储程序。
假设您想创建一个存储函数,计算并返回mail表中列出的邮件消息的平均大小(以字节为单位)。可以像这样定义函数,其中主体由单个 SQL 语句组成:
CREATE FUNCTION avg_mail_size()
RETURNS FLOAT READS SQL DATA
RETURN (SELECT AVG(size) FROM mail);
RETURNS FLOAT子句指示函数返回值的类型,而READS SQL DATA指示函数读取但不修改数据。函数主体遵循这些子句:一个执行子查询并将结果值返回给调用者的单个RETURN语句。(每个存储函数必须至少有一个RETURN语句。)
在mysql中,您可以如上所示输入该语句,没有问题。定义只需要在末尾的单个终止符,而不需要内部终止符,因此不会产生歧义。但是,假设您希望函数接受一个用户命名的参数,并按以下方式解释它:
-
如果参数为
NULL,函数返回所有消息的平均大小(与以前相同)。 -
如果参数非
NULL,函数返回该用户发送消息的平均大小。
为了实现这一点,函数具有更复杂的主体,使用BEGIN … END块:
CREATE FUNCTION avg_mail_size(user VARCHAR(8))
RETURNS FLOAT READS SQL DATA
BEGIN
DECLARE avg FLOAT;
IF user IS NULL
THEN # average message size over all users
SET avg = (SELECT AVG(size) FROM mail);
ELSE # average message size for given user
SET avg = (SELECT AVG(size) FROM mail WHERE srcuser = user);
END IF;
RETURN avg;
END;
如果您尝试仅输入所示的定义在mysql内部定义函数,mysql会错误地将函数体中的第一个分号解释为结束定义。相反,使用delimiter命令更改mysql分隔符,然后将分隔符恢复为其默认值:
mysql> `delimiter $$`
mysql> `CREATE FUNCTION avg_mail_size(user VARCHAR(8))`
-> `RETURNS FLOAT READS SQL DATA`
-> `BEGIN`
-> `DECLARE avg FLOAT;`
-> `IF user IS NULL`
-> `THEN # average message size over all users`
-> `SET avg = (SELECT AVG(size) FROM mail);`
-> `ELSE # average message size for given user`
-> `SET avg = (SELECT AVG(size) FROM mail WHERE srcuser = user);`
-> `END IF;`
-> `RETURN avg;`
-> `END;`
-> `$$`
Query OK, 0 rows affected (0.02 sec)
mysql> `delimiter ;`
定义存储函数后,调用它的方式与内置函数相同:
mysql> `SELECT avg_mail_size(NULL), avg_mail_size('barb');`
+---------------------+-----------------------+
| avg_mail_size(NULL) | avg_mail_size('barb') |
+---------------------+-----------------------+
| 237386.5625 | 52232 |
+---------------------+-----------------------+
使用存储函数简化计算(11.2 章节)
问题
特定的计算以产生值必须由不同的应用程序频繁执行,但您不希望每次需要时都编写表达式来执行它。或者计算在表达式内联中难以执行,因为它需要条件或循环逻辑。或者,如果计算逻辑发生变化,您不希望在每个使用它的应用程序中进行更改。
解决方案
使用存储函数可以在单个位置定义这些详细信息,并使计算变得简单易行。
讨论
存储函数使您能够简化应用程序。编写一次在函数定义中生成计算结果的代码,然后在需要执行计算时简单调用该函数。存储函数还使您能够使用比在表达式内联写计算更复杂的算法结构。本节说明存储函数在这些方面如何有用。(当然,这个例子不是那么复杂,但这里使用的原则同样适用于编写更复杂的函数。)
美国不同州对销售税收取不同的费率。如果向来自不同州的人们销售商品,则必须使用适合客户居住州的税率来收税。要处理税收计算,使用一个列出每个州销售税率的表,并使用一个存储函数来查找给定州的税率。
要设置sales_tax_rate表,请使用recipes分发中tables目录下的sales_tax_rate.sql脚本。该表有两列:state(两字母缩写)和tax_rate(DECIMAL值而不是FLOAT,以保证精度)。
定义查找税率的函数sales_tax_rate()如下:
CREATE FUNCTION sales_tax_rate(state_code CHAR(2))
RETURNS DECIMAL(3,2) READS SQL DATA
BEGIN
DECLARE rate DECIMAL(3,2);
DECLARE CONTINUE HANDLER FOR NOT FOUND SET rate = 0;
SELECT tax_rate INTO rate FROM sales_tax_rate WHERE state = state_code;
RETURN rate;
END;
假设佛蒙特州和纽约州的税率分别为 1%和 9%。尝试函数以检查是否正确返回了税率:
mysql> `SELECT sales_tax_rate('VT'), sales_tax_rate('NY');`
+----------------------+----------------------+
| sales_tax_rate('VT') | sales_tax_rate('NY') |
+----------------------+----------------------+
| 0.01 | 0.09 |
+----------------------+----------------------+
如果您从未在表中列出的位置进行销售,函数无法确定其税率。在这种情况下,函数假定税率为 0%:
mysql> `SELECT sales_tax_rate('ZZ');`
+----------------------+
| sales_tax_rate('ZZ') |
+----------------------+
| 0.00 |
+----------------------+
如果给定的state_param值没有行,SELECT语句无法找到销售税率,CONTINUE处理程序会设置税率为 0,并在SELECT后继续执行下一个语句。(这个处理程序是内联表达式中不可用的存储例程逻辑的示例。“在存储程序中处理错误”进一步讨论了处理程序。)
要计算购买的销售税,将购买价格乘以税率。例如,对于佛蒙特州和纽约州,对于一笔$150 的购买,税额为:
mysql> `SELECT 150*sales_tax_rate('VT'), 150*sales_tax_rate('NY');`
+--------------------------+--------------------------+
| 150*sales_tax_rate('VT') | 150*sales_tax_rate('NY') |
+--------------------------+--------------------------+
| 1.50 | 13.50 |
+--------------------------+--------------------------+
或者编写另一个函数来为您计算税款:
CREATE FUNCTION sales_tax(state_code CHAR(2), sales_amount DECIMAL(10,2))
RETURNS DECIMAL(10,2) READS SQL DATA
RETURN sales_amount * sales_tax_rate(state_code);
并像这样使用它:
mysql> `SELECT sales_tax('VT',150), sales_tax('NY',150);`
+---------------------+---------------------+
| sales_tax('VT',150) | sales_tax('NY',150) |
+---------------------+---------------------+
| 1.50 | 13.50 |
+---------------------+---------------------+
11.3 使用存储过程生成多个值
问题
您希望为操作生成多个值,但存储函数只能返回单个值。
解决方案
使用具有 OUT 或 INOUT 参数的存储过程,并在调用过程时为这些参数传递用户定义的变量。存储过程不像函数那样返回一个值,但它可以将值分配给这些参数,以便在过程返回时用户定义的变量具有所需的值。
讨论
与仅限于输入值的存储函数参数不同,存储过程参数可以是以下三种类型之一:
-
IN参数仅用于输入。如果未指定类型,则默认为此。 -
INOUT参数用于传递一个值进入,并且也可以传递一个值出去。 -
OUT参数用于传递一个值出去。
因此,为了从操作中生成多个值,您可以使用 INOUT 或 OUT 参数。以下示例说明了这一点,使用 IN 参数作为输入,并通过 OUT 参数传回三个值。
Recipe 11.1 展示了一个 avg_mail_size() 函数,返回给定发件人的平均邮件大小。该函数只返回一个值。要生成额外的信息,例如消息数量和总消息大小,函数无法胜任。您可以编写三个单独的函数,但这很麻烦。相反,请使用一个单独的过程来检索关于给定发件人的多个值。以下过程 mail_sender_stats() 在 mail 表上运行查询,以获取有关输入值(用户名)的邮件发送统计信息,通过三个 OUT 参数返回该用户发送的消息数量,以及消息的总大小和平均大小(以字节为单位):
CREATE PROCEDURE mail_sender_stats(IN user VARCHAR(8),
OUT messages INT,
OUT total_size INT,
OUT avg_size INT)
BEGIN
# Use IFNULL() to return 0 for SUM() and AVG() in case there are
# no rows for the user (those functions return NULL in that case).
SELECT COUNT(*), IFNULL(SUM(size),0), IFNULL(AVG(size),0)
INTO messages, total_size, avg_size
FROM mail WHERE srcuser = user;
END;
要使用该过程,请传递包含用户名和三个用户定义变量以接收 OUT 值的字符串。过程返回后,访问变量值:
mysql> `CALL mail_sender_stats('barb',@messages,@total_size,@avg_size);`
mysql> `SELECT @messages, @total_size, @avg_size;`
+-----------+-------------+-----------+
| @messages | @total_size | @avg_size |
+-----------+-------------+-----------+
| 3 | 156696 | 52232 |
+-----------+-------------+-----------+
此例程返回计算结果。通常也会使用 OUT 参数用于诊断目的,例如状态或错误指示器。
如果在存储程序中调用 mail_sender_stats(),您可以使用例程参数或程序本地变量传递变量给它,而不仅限于用户定义的变量。
11.4 使用触发器记录表格更改
问题
您有一个表格,用于维护您跟踪的项目(如正在竞标的拍卖)的当前值,但您还希望维护表格更改的日志(历史记录)。
解决方案
使用触发器来捕获表格更改并将其写入单独的日志表。
讨论
假设您进行在线拍卖,并在类似以下表格中维护每个当前活动拍卖的信息:
CREATE TABLE auction
(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
item VARCHAR(30) NOT NULL,
bid DECIMAL(10,2) NOT NULL,
PRIMARY KEY (id)
);
auction表包含关于当前活动拍卖的信息(正在竞价的物品以及每个拍卖的当前出价)。当拍卖开始时,向表中插入一行。对于物品的每次出价,更新其bid列,以便随着拍卖的进行,ts列更新以反映最近的出价时间。当拍卖结束时,bid值是最终价格,并且可以从表中删除该行。
为了维护一个展示从创建到移除的拍卖所有变化的日志,设置另一个表,用于记录拍卖变化的历史。可以通过触发器来实现这一策略。
为了记录每次拍卖的进展历史,请使用一个auction_log表,其包含以下列:
CREATE TABLE auction_log
(
action ENUM('create','update','delete'),
id INT UNSIGNED NOT NULL,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
item VARCHAR(30) NOT NULL,
bid DECIMAL(10,2) NOT NULL,
INDEX (id)
);
auction_log表与auction表有两个不同之处:
-
它包含一个
action列,用于指示每行所做的变更类型。 -
id列具有非唯一索引(而不是需要唯一值的主键)。这允许每个id值有多行,因为一个拍卖可以在日志表中生成多行。
为了确保将auction表的更改记录到auction_log表中,请创建一组触发器。这些触发器将信息写入auction_log表,如下所示:
-
对于插入操作,记录一次行创建操作,显示新行中的值。
-
对于更新操作,记录一次行更新操作,显示更新行中的新值。
-
对于删除操作,记录一次行删除操作,显示已删除行中的值。
对于此应用程序,使用AFTER触发器,因为它们仅在对auction表的更改成功后激活。(如果某些原因导致行更改操作失败,则BEFORE触发器可能会激活。)触发器定义如下:
CREATE TRIGGER ai_auction AFTER INSERT ON auction
FOR EACH ROW
INSERT INTO auction_log (action,id,ts,item,bid)
VALUES('create',NEW.id,NOW(),NEW.item,NEW.bid);
CREATE TRIGGER au_auction AFTER UPDATE ON auction
FOR EACH ROW
INSERT INTO auction_log (action,id,ts,item,bid)
VALUES('update',NEW.id,NOW(),NEW.item,NEW.bid);
CREATE TRIGGER ad_auction AFTER DELETE ON auction
FOR EACH ROW
INSERT INTO auction_log (action,id,ts,item,bid)
VALUES('delete',OLD.id,OLD.ts,OLD.item,OLD.bid);
INSERT和UPDATE触发器使用NEW.col_name来访问存储在行中的新值。DELETE触发器使用OLD.col_name来访问已删除行中的现有值。INSERT和UPDATE触发器使用NOW()来获取行修改时间;ts列会自动初始化为当前日期和时间,但NEW.ts不会包含该值。
假设拍卖以五美元的初始出价创建:
mysql> `INSERT INTO auction (item,bid) VALUES('chintz pillows',5.00);`
mysql> `SELECT LAST_INSERT_ID();`
+------------------+
| LAST_INSERT_ID() |
+------------------+
| 792 |
+------------------+
SELECT语句获取拍卖 ID 值,用于后续对拍卖的操作。然后物品在拍卖结束前接收到另外三个出价,并被移除:
mysql> `UPDATE auction SET bid = 7.50 WHERE id = 792;`
*`... time passes ...`*
mysql> `UPDATE auction SET bid = 9.00 WHERE id = 792;`
*`... time passes ...`*
mysql> `UPDATE auction SET bid = 10.00 WHERE id = 792;`
*`... time passes ...`*
mysql> `DELETE FROM auction WHERE id = 792;`
此时,在auction表中不再有拍卖的痕迹,但是auction_log表中包含了发生的所有操作历史:
mysql> `SELECT * FROM auction_log WHERE id = 792 ORDER BY ts;`
+--------+-----+---------------------+----------------+-------+
| action | id | ts | item | bid |
+--------+-----+---------------------+----------------+-------+
| create | 792 | 2014-01-09 14:57:41 | chintz pillows | 5.00 |
| update | 792 | 2014-01-09 14:57:50 | chintz pillows | 7.50 |
| update | 792 | 2014-01-09 14:57:57 | chintz pillows | 9.00 |
| update | 792 | 2014-01-09 14:58:03 | chintz pillows | 10.00 |
| delete | 792 | 2014-01-09 14:58:03 | chintz pillows | 10.00 |
+--------+-----+---------------------+----------------+-------+
使用刚刚概述的策略,auction表保持相对较小,您始终可以通过查看auction_log表来找到必要的拍卖历史信息。
11.5 使用事件调度数据库操作
问题
您希望设置一个定期运行的数据库操作,无需用户干预。
解决方案
MySQL 提供了一个事件调度器,使您能够设置在您定义的时间运行的数据库操作。创建一个根据时间表执行的事件。
讨论
本节描述了使用事件所必须做的事情,从一个简单的事件开始,以便在规则间隔时间内向表中写入一行。
从一个表开始来保存标记行。它包含一个TIMESTAMP列(MySQL 会自动初始化),还有一个用来存储消息的列:
CREATE TABLE mark_log
(
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
message VARCHAR(100)
);
我们的日志事件将会向新行写入一个字符串。要设置它,使用CREATE EVENT语句:
CREATE EVENT mark_insert
ON SCHEDULE EVERY 5 MINUTE
DO INSERT INTO mark_log (message) VALUES('-- MARK --');
mark_insert事件导致消息'-- MARK --'每五分钟记录到mark_log表中。为了更频繁或更少频繁地记录,可以使用不同的间隔。
此事件很简单,其主体仅包含一个单个 SQL 语句。对于执行多个语句的事件主体,请使用BEGIN…END复合语句语法。在这种情况下,如果您使用mysql来创建事件,请在定义事件时更改语句分隔符,如 Recipe 11.1 所讨论的那样。
此时,您应该等待几分钟,然后选择mark_log表的内容,以验证是否按计划写入了新行。但是,如果这是您设置的第一个事件,无论等待多长时间,该表可能仍然为空:
mysql> `SELECT * FROM mark_log;`
Empty set (0.00 sec)
如果情况如此,很可能事件调度器未运行(这是其默认状态,直到版本 8.0)。通过检查event_scheduler系统变量的值来检查调度器状态:
mysql> `SHOW VARIABLES LIKE 'event_scheduler';`
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| event_scheduler | OFF |
+-----------------+-------+
如果调度器未运行,可以通过执行以下语句来交互地启用它(这需要SYSTEM_VARIABLES_ADMIN或在版本 8.0 之前需要SUPER权限):
SET GLOBAL event_scheduler = 1;
那个语句启用调度器,但仅在服务器关闭之前。要在每次服务器启动时启动调度器,请在您的my.cnf选项文件中启用系统变量:
[mysqld]
event_scheduler=1
或使用SET PERSIST语句存储变量的修改值:
SET PERSIST event_scheduler = 1;
当事件调度器被启用时,mark_insert事件最终会在表中创建许多行。有几种方法可以影响事件执行,以防止表无限增长:
-
删除事件:
DROP EVENT mark_insert;这是停止事件发生的最简单方法。但是,如果你希望稍后恢复它,必须重新创建它。
-
禁用事件执行:
ALTER EVENT mark_insert DISABLE;这样会保留事件但导致其不运行,直到您重新激活它:
ALTER EVENT mark_insert ENABLE; -
让事件继续运行,但设置另一个事件来
<q>过期</q>旧的mark_log行。这第二个事件不需要运行得如此频繁(也许一天运行一次)。其主体应删除超过给定阈值的旧行。以下定义创建一个事件,删除两天以上的行:CREATE EVENT mark_expire ON SCHEDULE EVERY 1 DAY DO DELETE FROM mark_log WHERE ts < NOW() - INTERVAL 2 DAY;如果采用这种策略,您有协作事件:一个事件向
mark_log表添加行,另一个事件将其移除。它们共同维护一个包含最近行但不会变得过大的日志。
11.6 为执行动态 SQL 编写辅助例程
问题
准备好的 SQL 语句使您能够即兴构造和执行 SQL 语句,但您希望在一个步骤中运行它们,而不是执行三个命令:PREPARE、EXECUTE 和 DEALLOCATE PREPARE。
解决方案
编写一个处理单调工作的辅助过程。
讨论
使用准备的 SQL 语句涉及三个步骤:准备、执行和释放。例如,如果 @tbl_name 和 @val 变量分别保存表名和要插入表中的值,您可以这样创建表并插入值:
SET @stmt = CONCAT('CREATE TABLE ',@tbl_name,' (i INT)');
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @stmt = CONCAT('INSERT INTO ',@tbl_name,' (i) VALUES(',@val,')');
PREPARE stmt FROM @stmt;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
为了简化为每个动态创建的语句执行这些步骤的负担,使用一个辅助例程,它接受一个语句字符串,准备、执行和释放它:
CREATE PROCEDURE exec_stmt(stmt_str TEXT)
BEGIN
SET @_stmt_str = stmt_str;
PREPARE stmt FROM @_stmt_str;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END;
exec_stmt() 例程使得执行相同的语句变得更加简单:
CALL exec_stmt(CONCAT('CREATE TABLE ',@tbl_name,' (i INT)'));
CALL exec_stmt(CONCAT('INSERT INTO ',@tbl_name,' (i) VALUES(',@val,')'));
exec_stmt() 使用一个中间的用户定义变量 @_stmt_str,因为 PREPARE 只接受一个文字字符串或用户定义变量指定的语句。存储在例程参数中的语句不起作用。(至少在期望其值跨 exec_stmt() 调用持续时,避免将 @_stmt_str 用于您自己的目的。)
现在,怎样才能更安全地构造包含可能来自外部源(如 Web 表单输入或命令行参数)的值的语句字符串?这些信息不能信任,应视为潜在的 SQL 注入攻击向量:
-
QUOTE()函数用于引用数据值。 -
对于标识符没有相应的函数,但很容易编写一个函数,将内部反引号加倍,并在开头和结尾添加一个反引号:
CREATE FUNCTION quote_identifier(id TEXT) RETURNS TEXT DETERMINISTIC RETURN CONCAT('`',REPLACE(id,'`','``'),'`');
修改前述示例以确保数据值和标识符的安全性,我们有:
SET @tbl_name = quote_identifier(@tbl_name);
SET @val = QUOTE(@val);
CALL exec_stmt(CONCAT('CREATE TABLE ',@tbl_name,' (i INT)'));
CALL exec_stmt(CONCAT('INSERT INTO ',@tbl_name,' (i) VALUES(',@val,')'));
使用 exec_stmt() 的限制是并非所有的 SQL 语句都可以作为准备语句执行。有关限制,请参阅MySQL 参考手册。
11.7 使用条件处理程序检测没有更多的行
条件
问题
您希望检测到没有更多的行
条件,并优雅地处理它们,而不是中断存储程序的执行。
解决方案
条件处理程序的一个常见用途是检测没有更多的行
条件。为了一次处理一个查询结果行,请与捕获数据结束条件的条件处理程序结合使用基于游标的取行循环。该技术具有这些基本元素:
-
与读取行的
SELECT语句相关联的游标。打开游标开始读取,关闭游标停止。 -
当游标到达结果集末尾并引发数据末尾条件(
NOT FOUND)时激活的条件处理程序。我们在 Recipe 11.2 中使用了类似的处理程序。 -
一个指示循环终止的变量。将该变量初始化为
FALSE,然后在条件处理程序中,当出现数据末尾条件时将其设置为TRUE。 -
使用游标的循环,逐行抓取并在循环终止变量变为
TRUE时退出。
讨论
以下示例实现了一个抓取循环,逐行处理 _ch states表以计算总的美国人口:
CREATE PROCEDURE us_population()
BEGIN
DECLARE done BOOLEAN DEFAULT FALSE; 
DECLARE state_pop, total_pop BIGINT DEFAULT 0;
DECLARE cur CURSOR FOR SELECT pop FROM states; 
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; 
OPEN cur;
fetch_loop: LOOP
FETCH cur INTO state_pop; 
IF done THEN 
LEAVE fetch_loop;
END IF;
SET total_pop = total_pop + state_pop; 
END LOOP;
CLOSE cur;
SELECT total_pop AS 'Total U.S. Population'; 
END;
变量done用作标志,当过程决定是否继续执行或停止时进行检查。
用于抓取每个州人口的查询的游标。
当 MySQL 遇到未找到错误时,它会停止执行。为了防止这种情况,我们声明了一个CONTINUE处理程序,该处理程序将变量done的值设置为TRUE。
将每个州的人口抓取到变量state_pop中。
如果变量done不为真,则继续循环,否则退出循环。
我们将变量state_pop的值添加到代表美国人口的变量total_pop中。
离开循环后,打印变量total_pop的值。
虽然此示例主要用于说明,在任何实际应用程序中,您将使用聚合函数来计算总数。但这也为我们提供了一个独立的检查,以确定抓取循环是否计算了正确的值:
mysql> `CALL us_population();`
+-----------------------+
| Total U.S. Population |
+-----------------------+
| 331223695 |
+-----------------------+
mysql> `SELECT SUM(pop) AS 'Total U.S. Population' FROM states;`
+-----------------------+
| Total U.S. Population |
+-----------------------+
| 331223695 |
+-----------------------+
NOT FOUND处理程序也非常有用,用于检查SELECT ... INTO *var_name*语句是否返回任何结果。Recipe 11.2 显示了一个示例。
11.8 捕获并忽略带条件处理程序的错误
问题
您希望忽略良性错误或防止不存在用户的错误。
解决方案
使用条件处理程序捕获和处理您想要忽略的错误。
讨论
如果您认为错误是良性的,可以使用处理程序来忽略它。例如,MySQL 中的许多DROP语句都有一个IF EXISTS子句,以便在要删除的对象不存在时抑制错误。但有些DROP语句没有这样的子句,因此无法抑制错误。DROP INDEX就是其中之一:
mysql> `DROP INDEX bad_index ON limbs;`
ERROR 1091 (42000): Can't DROP 'bad_index'; check that column/key exists
为了防止针对不存在用户出现错误,请在捕获代码 1091 并忽略它的存储过程中调用DROP INDEX:
CREATE PROCEDURE drop_index(index_name VARCHAR(64), table_name VARCHAR(64))
BEGIN
DECLARE CONTINUE HANDLER FOR 1091
SELECT CONCAT('Unknown index: ', index_name) AS Message;
CALL exec_stmt(CONCAT('DROP INDEX ', index_name, ' ON ', table_name));
END;
如果索引不存在,drop_index()在条件处理程序中写入消息,但不会发生错误:
mysql> `CALL drop_index('bad_index', 'limbs');`
+--------------------------+
| Message |
+--------------------------+
| Unknown index: bad_index |
+--------------------------+
要完全忽略错误,请使用空的BEGIN … END块编写处理程序:
DECLARE CONTINUE HANDLER FOR 1091 BEGIN END;
另一种方法是生成警告,如下一个示例所示。
11.9 抛出错误和警告
问题
您希望对语句引发错误,对 MySQL 有效但不适用于您正在处理的应用程序。
解决方案
在检测到异常情况时,在存储程序内部生成自己的错误,请使用SIGNAL语句。
讨论
本示例展示了一些示例,而第 11.11 节展示了在触发器中使用SIGNAL拒绝错误数据的用法。
假设一个应用程序执行除法操作,您期望除数永远不为零,否则您希望引发错误。 您可能希望自从版本 5.7.4 起,默认启用 SQL 模式ERROR_FOR_DIVISION_BY_ZERO将自动执行此行为。 但是,仅在数据修改操作(例如INSERT)的上下文中,除零会产生警告:
mysql> `SELECT @@sql_mode\G`
*************************** 1\. row ***************************
@@sql_mode: ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,↩
ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION
1 row in set (0,00 sec)
mysql> `SELECT 1/0;`
+------+
| 1/0 |
+------+
| NULL |
+------+
1 row in set, 1 warning (0.00 sec)
mysql> `SHOW WARNINGS;`
+---------+------+---------------+
| Level | Code | Message |
+---------+------+---------------+
| Warning | 1365 | Division by 0 |
+---------+------+---------------+
要确保在任何情况下出现除零错误,请编写一个函数执行除法,但首先检查除数,并使用SIGNAL在出现“无法发生”条件时引发错误:
CREATE FUNCTION divide(numerator FLOAT, divisor FLOAT)
RETURNS FLOAT DETERMINISTIC
BEGIN
IF divisor = 0 THEN
SIGNAL SQLSTATE '22012'
SET MYSQL_ERRNO = 1365, MESSAGE_TEXT = 'unexpected 0 divisor';
END IF;
RETURN numerator / divisor;
END;
在非修改上下文中测试该函数,以验证它是否会产生错误:
mysql> `SELECT divide(1,0);`
ERROR 1365 (22012): unexpected 0 divisor
SIGNAL语句指定了 SQLSTATE 值以及一个可选的SET子句,您可以使用它来分配错误属性的值。 MYSQL_ERRNO对应于 MySQL 特定的错误代码,而MESSAGE_TEXT是您选择的字符串。
SIGNAL还可以引发警告条件,而不仅仅是错误。 下面的例程drop_user_warn()类似于之前显示的drop_user()例程,但是不会为不存在的用户打印消息,而是生成一个警告,可以使用SHOW WARNINGS显示。 SQLSTATE 值01000和错误 1642 指示用户定义的未处理异常,该例程会发出该异常,并附上适当的消息:
CREATE PROCEDURE drop_user_warn(user TEXT, host TEXT)
BEGIN
DECLARE account TEXT;
DECLARE CONTINUE HANDLER FOR 1396
BEGIN
DECLARE msg TEXT;
SET msg = CONCAT('Unknown user: ', account);
SIGNAL SQLSTATE '01000' SET MYSQL_ERRNO = 1642, MESSAGE_TEXT = msg;
END;
SET account = CONCAT(QUOTE(user),'@',QUOTE(host));
CALL exec_stmt(CONCAT('DROP USER ',account));
END;
进行测试:
mysql> `CALL drop_user_warn('bad-user','localhost');`
Query OK, 0 rows affected, 1 warning (0.00 sec)
mysql> `SHOW WARNINGS;`
+---------+------+--------------------------------------+
| Level | Code | Message |
+---------+------+--------------------------------------+
| Warning | 1642 | Unknown user: 'bad-user'@'localhost' |
+---------+------+--------------------------------------+
11.10 通过访问诊断区域记录错误
问题
您希望记录存储过程遇到的所有错误。
解决方案
使用GET DIAGNOSTICS语句访问诊断区域。 然后,将错误信息保存到变量中,并使用它们来记录错误。
讨论
您不仅可以在存储过程内部优雅地处理错误,还可以记录这些错误,以便查看并修复应用程序,以防止将来出现类似的错误。
表movies_actors_link在配方 Recipe 16.6 中用于演示多对多关系。它包含存储在表movies和movies_actors中的电影和电影演员的id。这两列都使用属性NOT NULL定义。每个movie_id和actor_id的组合应该是唯一的。虽然 Recipe 16.6 没有定义外键(“使用外键强制执行引用完整性并防止不匹配”),但我们可以定义它们,这样 MySQL 会拒绝没有对应条目的值。
ALTER TABLE movies_actors_link ADD FOREIGN KEY(movie_id) REFERENCES movies(id);
ALTER TABLE movies_actors_link ADD FOREIGN KEY(actor_id) REFERENCES actors(id);
当我们在mysql客户端执行语句时。
mysql> `INSERT` `INTO` `movies_actors_link` `VALUES``(``7``,` `1``)``;`
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint
fails (`cookbook`.`movies_actors_link`, CONSTRAINT `movies_actors_link_ibfk_1`
FOREIGN KEY (`movie_id`) REFERENCES `movies` (`id`))
另外,MySQL 提供对诊断区域的访问,因此您可以将其值存储在用户定义的变量中。使用命令GET DIAGNOSTICS来访问诊断区域。
mysql> `GET` `DIAGNOSTICS` `CONDITION` `1`
-> `@``err_number` `=` `MYSQL_ERRNO``,`
-> `@``err_sqlstate` `=` `RETURNED_SQLSTATE``,`
-> `@``err_message` `=` `MESSAGE_TEXT``;`
Query OK, 0 rows affected (0.01 sec)
条件CONDITION指定条件号码。我们的查询仅返回一个条件,因此我们使用编号 1。如果查询返回多个条件,诊断区域将包含每个条件的数据。例如,如果查询生成多个警告,则可能会发生这种情况。
要访问由GET DIAGNOSTICS检索的数据,只需选择用户定义变量的值。
mysql> `SELECT` `@``err_number``,` `@``err_sqlstate``,` `@``err_message``\``G`
*************************** 1. row ***************************
@err_number: 1452
@err_sqlstate: 23000
@err_message: Cannot add or update a child row: a foreign key constraint
fails (`cookbook`.`movies_actors_link`,
CONSTRAINT `movies_actors_link_ibfk_1`
FOREIGN KEY (`movie_id`) REFERENCES `movies` (`id`))
1 row in set (0.00 sec)
记录所有这些错误,当用户插入数据到表movies_actors_link时,创建一个过程,它接受两个参数:movie_id和actor_id,并将错误信息存储在日志表中。
首先创建将存储有关错误信息的表。
CREATE TABLE `movies_actors_log` (
`err_ts` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`err_number` int DEFAULT NULL,
`err_sqlstate` char(5) DEFAULT NULL,
`err_message` TEXT DEFAULT NULL,
`movie_id` int unsigned DEFAULT NULL,
`actor_id` int unsigned DEFAULT NULL
);
然后定义一个过程,将向表movies_actors_link插入一行,并在出现错误时将详细信息记录到表movies_actors_log中。
CREATE PROCEDURE insert_movies_actors_link(movie INT, actor INT)
BEGIN
DECLARE e_number INT; 
DECLARE e_sqlstate CHAR(5);
DECLARE e_message TEXT;
DECLARE CONTINUE HANDLER FOR SQLEXCEPTION 
BEGIN
GET DIAGNOSTICS CONDITION 1
e_number = MYSQL_ERRNO, 
e_sqlstate = RETURNED_SQLSTATE,
e_message = MESSAGE_TEXT;
INSERT INTO movies_actors_log(err_number, err_sqlstate, err_message, 
movie_id, actor_id)
VALUES(e_number, e_sqlstate, e_message, movie, actor);
RESIGNAL; 
END;
INSERT INTO movies_actors_link VALUES(movie, actor); 
END
声明变量,用于存储错误编号、SQLSTATE 和错误消息。
为SQLEXCEPTION创建一个CONTINUE HANDLER,因此过程首先记录错误,然后继续执行。
将诊断信息存储在变量中。
记录有关错误的详细信息到表movies_actors_log中。
使用命令RESIGNAL来为调用过程的客户端引发错误。
INSERT到表movies_actors_link,可能成功,也可能引发错误。
为了测试过程,用不同的参数调用它几次。
mysql> `CALL` `insert_movies_actors_link``(``7``,` `11``)``;`
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint
fails (`cookbook`.`movies_actors_link`, CONSTRAINT `movies_actors_link_ibfk_1`
FOREIGN KEY (`movie_id`) REFERENCES `movies` (`id`))
mysql> `CALL` `insert_movies_actors_link``(``6``,` `11``)``;`
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint
fails (`cookbook`.`movies_actors_link`, CONSTRAINT `movies_actors_link_ibfk_2`
FOREIGN KEY (`actor_id`) REFERENCES `actors` (`id`))
mysql> `CALL` `insert_movies_actors_link``(``null``,` `10``)``;`
ERROR 1048 (23000): Column 'movie_id' cannot be null
mysql> `CALL` `insert_movies_actors_link``(``6``,` `null``)``;`
ERROR 1048 (23000): Column 'actor_id' cannot be null
mysql> `CALL` `insert_movies_actors_link``(``6``,` `9``)``;`
ERROR 1062 (23000): Duplicate entry '6-9' for key 'movies_actors_link.movie_id'
预期地,因为我们使用了 RESIGNAL,该过程失败并显示错误。所有错误均已记录在 movies_actors_log 表中,连同我们尝试但未能插入的值及其尝试发生的时间戳。
mysql> `SELECT` `*` `FROM` `movies_actors_log``\``G`
*************************** 1. row ***************************
err_ts: 2021-03-12 21:11:30
err_number: 1452
err_sqlstate: 23000
err_message: Cannot add or update a child row: a foreign key constraint fails
(`cookbook`.`movies_actors_link`,
CONSTRAINT `movies_actors_link_ibfk_1`
FOREIGN KEY (`movie_id`) REFERENCES `movies` (`id`))
movie_id: 7
actor_id: 11
*************************** 2. row ***************************
err_ts: 2021-03-12 21:11:38
err_number: 1452
err_sqlstate: 23000
err_message: Cannot add or update a child row: a foreign key constraint fails
(`cookbook`.`movies_actors_link`,
CONSTRAINT `movies_actors_link_ibfk_2`
FOREIGN KEY (`actor_id`) REFERENCES `actors` (`id`))
movie_id: 6
actor_id: 11
*************************** 3. row ***************************
err_ts: 2021-03-12 21:11:49
err_number: 1048
err_sqlstate: 23000
err_message: Column 'movie_id' cannot be null
movie_id: NULL
actor_id: 10
*************************** 4. row ***************************
err_ts: 2021-03-12 21:11:56
err_number: 1048
err_sqlstate: 23000
err_message: Column 'actor_id' cannot be null
movie_id: 6
actor_id: NULL
*************************** 5. row ***************************
err_ts: 2021-03-12 21:12:00
err_number: 1062
err_sqlstate: 23000
err_message: Duplicate entry '6-9' for key 'movies_actors_link.movie_id'
movie_id: 6
actor_id: 9
5 rows in set (0.00 sec)
参见
有关诊断区域的更多信息,请参阅 GET DIAGNOSTICS 语句。
11.11 使用触发器预处理或拒绝数据
问题
有些条件您希望检查插入表中的数据,但又不想为每个 INSERT 编写验证逻辑。
解决方案
将输入测试逻辑集中到 BEFORE INSERT 触发器中。
讨论
您可以使用触发器执行多种类型的输入检查:
-
通过引发信号来拒绝不良数据。这使您可以访问存储的程序逻辑,以便在检查值方面比使用静态约束(如
NOT NULL)更加宽松。 -
预处理值并修改它们,如果您不想直接拒绝它们的话。例如,将超出范围的值映射到范围内,或者清理值以将其放置为规范形式,如果允许输入接近的变体。
假设您有一个包含姓名、居住州、电子邮件地址和网站 URL 的联系信息表:
CREATE TABLE contact_info
(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(30), # name of person
state CHAR(2), # state of residence
email VARCHAR(50), # email address
url VARCHAR(255), # web address
PRIMARY KEY (id)
);
对于新行的输入,您希望强制执行约束或执行预处理如下:
-
居住州的值是两位字母的美国州代码,仅当其存在于
states表中时才有效。(在这种情况下,您可以将列声明为具有 50 个成员的ENUM,因此更有可能使用此查找表技术,用于列的有效值集合任意大或随时间变化的情况。) -
电子邮件地址的值必须包含
@字符才有效。 -
对于网站 URL,去掉任何前导的
http://或https://以节省空间。
为了处理这些要求,创建一个 BEFORE INSERT 触发器:
CREATE TRIGGER bi_contact_info BEFORE INSERT ON contact_info
FOR EACH ROW
BEGIN
IF (SELECT COUNT(*) FROM states WHERE abbrev = NEW.state) = 0 THEN
SIGNAL SQLSTATE 'HY000'
SET MYSQL_ERRNO = 1525, MESSAGE_TEXT = 'invalid state code';
END IF;
IF INSTR(NEW.email,'@') = 0 THEN
SIGNAL SQLSTATE 'HY000'
SET MYSQL_ERRNO = 1525, MESSAGE_TEXT = 'invalid email address';
END IF;
SET NEW.url = TRIM(LEADING 'http://' FROM NEW.url);
SET NEW.url = TRIM(LEADING 'https://' FROM NEW.url);
END;
要同时处理更新,可以定义一个与 bi_contact_info 相同体的 BEFORE UPDATE 触发器。
通过执行一些 INSERT 语句来测试触发器,以验证它接受有效值,拒绝不良值并修剪 URL:
mysql> `INSERT INTO contact_info (name,state,email,url)`
-> `VALUES('Jen','NY','jen@example.com','http://www.example.com');`
mysql> `INSERT INTO contact_info (name,state,email,url)`
-> `VALUES('Jen','XX','jen@example.com','http://www.example.com');`
ERROR 1525 (HY000): invalid state code
mysql> `INSERT INTO contact_info (name,state,email,url)`
-> `VALUES('Jen','NY','jen','http://www.example.com');`
ERROR 1525 (HY000): invalid email address
mysql> `SELECT * FROM contact_info;`
+----+------+-------+-----------------+-----------------+
| id | name | state | email | url |
+----+------+-------+-----------------+-----------------+
| 1 | Jen | NY | jen@example.com | www.example.com |
+----+------+-------+-----------------+-----------------+
第十二章:处理元数据
12.0 介绍
到目前为止使用的大多数 SQL 语句都是为了与数据库中存储的数据一起工作而编写的。毕竟,这就是数据库设计用来保存的内容。但有时您需要的不仅仅是数据值,而是描述或表征这些值的信息,即语句元数据。元数据最常用于处理结果集,但也适用于与 MySQL 交互的其他方面。本章描述了如何获取和使用几种类型的元数据:
语句结果的信息
对于删除或更新行的语句,您可以确定更改了多少行。对于SELECT语句,可以获取结果集中的列数,以及有关结果集中每个列的信息,例如列名及其显示宽度。例如,为了格式化表格显示,可以确定每列的宽度以及是否将值左对齐或右对齐。
数据库和表的信息
可以查询 MySQL 服务器以确定它管理哪些数据库和表。这对于存在性测试或生成列表非常有用。例如,一个应用程序可能呈现一个显示,让用户选择可用数据库之一。可以检查表的元数据以确定列的定义;例如,确定ENUM或SET列的合法值,以生成对应于可用选择的 Web 表单元素。
MySQL 服务器的信息
数据库服务器提供关于自身和与其当前会话状态的信息。了解服务器版本对于确定其是否支持特定功能很有用,这有助于您构建适应性应用程序。
元数据与数据库系统的实现密切相关,因此往往依赖于特定的数据库系统。这意味着,如果一个应用程序使用本章展示的技术,那么如果将其移植到其他数据库系统,可能需要进行一些修改。例如,在 MySQL 中,可以通过执行SHOW语句来获取表和数据库列表。但是,SHOW是 SQL 的 MySQL 特定扩展,因此即使对于像 Perl DBI、PHP PDO、Python DB API 和 JDBC 这样的 API,它们提供了一个与数据库无关的执行语句的方式,SQL 本身仍然是 MySQL 特定的,必须进行修改才能与其他数据库系统一起工作。
更便携的元数据来源是INFORMATION_SCHEMA,这是一个包含有关数据库、表、列、字符集等信息的数据库。INFORMATION_SCHEMA相对于SHOW有一些优势:
-
其他数据库系统支持
INFORMATION_SCHEMA,因此使用它的应用程序可能比使用SHOW语句更具可移植性。 -
INFORMATION_SCHEMA与标准的SELECT语法一起使用,因此与其他数据检索操作更相似,而不是SHOW语句。
因为这些优势,本章中的示例使用 INFORMATION_SCHEMA 而不是大多数情况下的 SHOW。
INFORMATION_SCHEMA 的一个缺点是,访问它的语句比相应的 SHOW 语句更冗长。当你编写程序时,这并不重要,但对于交互使用而言,SHOW 语句可能更吸引人,因为需要输入更少。
注意
从 INFORMATION_SCHEMA 或 SHOW 检索到的结果取决于你的权限。你只会看到那些你拥有某些权限的数据库或表的信息。因此,如果存在对象但你无权访问它,对象的存在测试将返回 false。你可能需要使用具有管理权限的用户才能重复本章中我们提供的所有代码示例。
本章中使用的创建表的脚本位于 recipes 发行版的 tables 目录中。包含示例代码的脚本位于 metadata 目录中。(其中一些使用位于 lib 目录中的实用函数。)该发行版通常提供了除显示语言外的其他语言的实现。
12.1 确定语句影响的行数
问题
你想知道 SQL 语句改变了多少行。
解决方案
有些 API 将行数作为执行语句的函数的返回值返回。其他的则提供了一个单独的函数,在执行语句后调用。使用你使用的编程语言中可用的方法。
讨论
对于影响行的语句(UPDATE、DELETE、INSERT、REPLACE),每个 API 都提供了一种确定涉及行数的方法。对于 MySQL,受影响的
的默认含义是 更改的
,而不是 匹配的
。也就是说,语句未更改的行不会计数,即使它们匹配语句中指定的条件。例如,以下 UPDATE 语句的 受影响的
值为零,因为它没有更改列的当前值,无论 WHERE 子句匹配多少行:
UPDATE profile SET cats = 0 WHERE cats = 0;
MySQL 服务器允许客户端设置连接时的标志,以指示它想要匹配行数而不是更改行数的计数。在这种情况下,前面语句的行数将等于具有 cats 值为 0 的行数,即使语句对表没有净变化。但并非所有 MySQL API 都公开此标志。以下讨论指出了哪些 API 允许你选择所需的计数类型,哪些默认使用行匹配计数而不是行更改计数。
Perl
在 Perl DBI 脚本中,do() 返回修改行的行数:
my $count = $dbh->do ($stmt);
# report 0 rows if an error occurred
printf "Number of rows affected: %d\n", (defined ($count) ? $count : 0);
如果首先准备语句,然后执行它,execute() 将返回行数:
my $sth = $dbh->prepare ($stmt);
my $count = $sth->execute ();
printf "Number of rows affected: %d\n", (defined ($count) ? $count : 0);
要告知 MySQL 返回更改行数还是匹配行数,请在连接到 MySQL 服务器时,在数据源名称(DSN)参数的选项部分指定 mysql_client_found_rows。将该选项设置为 0 表示更改行数,设置为 1 表示匹配行数。以下是一个示例:
my $conn_attrs = {PrintError => 0, RaiseError => 1, AutoCommit => 1};
my $dsn = "DBI:mysql:cookbook:localhost;mysql_client_found_rows=1";
my $dbh = DBI->connect ($dsn, "cbuser", "cbpass", $conn_attrs);
mysql_client_found_rows 在会话期间更改行报告行为。
尽管 MySQL 本身的默认行为是返回更改的行数,但当前版本的 Perl DBI 驱动程序自动请求匹配的行数,除非另有指定。对于依赖特定行为的应用程序,最好在 DSN 中明确设置 mysql_client_found_rows 选项以获取适当的值。
Ruby
在 Ruby Mysql2 脚本中,affected_rows 方法返回修改行的行数:
client.query(stmt)
puts "Number of rows affected: #{client.affected_rows}"
如果使用准备语句的 execute 方法执行语句,则可以使用语句句柄的 affected_rows 方法在之后获取计数:
sth = client.prepare(stmt)
sth.execute()
puts "Number of rows affected: #{sth.affected_rows}"
Ruby MySQL 驱动程序默认返回更改的行数,但支持 Mysql2::Client::FOUND_ROWS 选项,该选项允许您控制服务器返回更改的行数或匹配的行数。例如,要请求匹配的行数,请执行以下操作:
client = Mysql2::Client.new(:flags=>Mysql2::Client::FOUND_ROWS, :database=>'cookbook')
PHP
在 PDO 中,数据库句柄的 exec() 方法返回受影响的行数:
$count = $dbh->exec ($stmt);
printf ("Number of rows updated: %d\n", $count);
如果使用 prepare() 加 execute(),则可以从语句句柄的 rowCount() 方法获取受影响的行数:
$sth = $dbh->prepare ($stmt);
$sth->execute ();
printf ("Number of rows updated: %d\n", $sth->rowCount ());
PDO MySQL 驱动程序默认返回更改的行数,但支持 PDO::MYSQL_ATTR_FOUND_ROWS 属性,您可以在连接时指定该属性来控制服务器返回更改的行数或匹配的行数。new PDO 类构造函数在密码参数之后接受一个可选的键值数组。在此数组中传递 PDO::MYSQL_ATTR_FOUND_ROWS => 1 来请求匹配的行数:
$dsn = "mysql:host=localhost;dbname=cookbook";
$dbh = new PDO ($dsn, "cbuser", "cbpass",
array (PDO::MYSQL_ATTR_FOUND_ROWS => 1));
Python
Python 的 DB API 通过语句游标的 rowcount 属性提供更改的行数:
cursor = conn.cursor()
cursor.execute(stmt)
print("Number of rows affected: %d" % cursor.rowcount)
cursor.close()
要获取匹配行数,请导入 Connector/Python 客户端标志常量,并在 connect() 方法的 client_flags 参数中传递 FOUND_ROWS 标志:
from mysql.connector.constants import ClientFlag
conn = mysql.connector.connect(
database="cookbook",
host="localhost",
user="cbuser",
password="cbpass",
client_flags=[ClientFlag.FOUND_ROWS]
)
Go
Go SQL 驱动程序提供了 Result 类型的 RowsAffected 方法,返回更改的行数。
res, err := db.Exec(sql)
// Check and handle err
affectedRows, err := res.RowsAffected()
// Check and handle err
fmt.Printf("The statement affected %d rows\n", affectedRows)
要检索匹配行数,请在连接字符串中添加参数 clientFoundRows=true:
db, err := ↩
sql.Open("mysql", "cbuser:cbpass@tcp(127.0.0.1:3306)/cookbook?clientFoundRows=true")
Java
对于修改行的语句,Connector/J 驱动程序提供了匹配行数而不是更改行数,以符合 Java JDBC 规范。
JDBC 接口提供两种不同的行数计数方式,取决于执行语句的方法。如果使用 executeUpdate(),则行数计数是其返回值:
Statement s = conn.createStatement ();
int count = s.executeUpdate (stmt);
s.close ();
System.out.println ("Number of rows affected: " + count);
如果使用了execute(),该方法返回 true 或 false 来指示语句是否产生了结果集。对于诸如UPDATE或DELETE这样不返回结果集的语句,execute()返回 false,并且可以通过调用getUpdateCount()方法获取行数:
Statement s = conn.createStatement ();
if (!s.execute (stmt))
{
// there is no result set, print the row count
System.out.println ("Number of rows affected: " + s.getUpdateCount ());
}
s.close ();
12.2 获取结果集元数据
问题
在检索行(参见 Recipe 4.4)之后,您需要了解关于结果集的其他细节,如列名和数据类型,或者有多少行和列。
解决方案
利用您的 API 提供的功能。
讨论
诸如SELECT这样生成结果集的语句会生成多种类型的元数据。本节讨论了通过每个 API 获取的信息,使用显示在执行示例语句(SELECT name, birth FROM profile)后可用的结果集元数据的程序。示例程序说明了此信息的最简单用法之一:当您从结果集检索行并希望在循环中处理列值时,存储在元数据中的列计数将作为循环迭代器的上限。
Perl
从 Perl DBI 获取的结果集元数据的范围取决于您如何处理查询:
-
使用语句句柄
在这种情况下,调用
prepare()以获取语句句柄。该句柄具有一个execute()方法。调用它生成结果集,然后在循环中获取行。使用这种方法,在结果集处于活动状态(即调用execute()之后,直到到达结果集的末尾)时,可以访问元数据。当行提取方法发现没有更多的行时,会隐式地调用finish(),导致元数据变得不可用。(如果您显式调用finish(),也会发生这种情况。)因此,通常最好在调用execute()后立即访问元数据,并复制任何您需要在提取循环结束后使用的值的副本。 -
使用将结果集一次性返回的数据库句柄方法
使用这种方法,处理语句时生成的任何元数据都将在方法返回时被丢弃。您仍然可以通过结果集的大小确定行数和列数。
当您使用语句句柄处理查询时,DBI 在调用句柄的execute()方法后会提供结果集元数据。这些信息主要以数组引用的形式提供。对于每种元数据类型,数组中每一列都有一个元素。可以将这些数组引用作为语句句柄的属性访问。例如,$sth->{NAME}指向列名数组,数组的各个元素即为列名:
$name = $sth->{NAME}->[$i];
或者像这样访问整个数组:
@names = @{$sth->{NAME}};
表 12-1 列出了通过数组访问元数据的属性名称以及每个数组中值的含义。以大写字母开头的名称是标准的 DBI 属性,应该适用于大多数数据库引擎。以 mysql_ 开头的属性名称是 MySQL 特定的,不可移植的:
表 12-1. Perl 中的元数据
| 属性名称 | 数组元素含义 |
|---|---|
NAME |
列名 |
NAME_lc |
列名小写 |
NAME_uc |
列名大写 |
NULLABLE |
0 或空字符串 = 列值不能为 NULL |
1 = 列值可以为 NULL |
|
| 2 = 未知 | |
PRECISION |
列宽度 |
SCALE |
小数位数(对于数字列) |
TYPE |
数据类型(数字 DBI 代码) |
mysql_is_blob |
如果列具有 BLOB(或 TEXT)类型则为真 |
mysql_is_key |
如果列是键的一部分则为真 |
mysql_is_num |
如果列具有数字类型则为真 |
mysql_is_pri_key |
如果列是主键则为真 |
mysql_max_length |
结果集中列值的实际最大长度 |
mysql_table |
列所属表的名称 |
mysql_type |
数据类型(数字内部 MySQL 代码) |
mysql_type_name |
数据类型名称 |
一些元数据类型,列在 表 12-2 中列出,作为哈希的引用而不是数组来访问。这些哈希每个列值有一个元素。元素键是列名,其值是结果集中的列位置。例如:
$col_pos = $sth->{NAME_hash}->{*`col_name`*};
表 12-2. Perl 中的元数据,可作为哈希引用访问
| 属性名称 | 哈希元素含义 |
|---|---|
NAME_hash |
列名 |
NAME_hash_lc |
列名小写 |
NAME_hash_uc |
列名大写 |
结果集中的列数可作为标量值获得:
$num_cols = $sth->{NUM_OF_FIELDS};
这个示例代码展示了如何执行语句并显示结果集元数据:
my $stmt = "SELECT name, birth FROM profile";
printf "Statement: %s\n", $stmt;
my $sth = $dbh->prepare ($stmt);
$sth->execute();
# metadata information becomes available at this point ...
printf "NUM_OF_FIELDS: %d\n", $sth->{NUM_OF_FIELDS};
print "Note: statement has no result set\n" if $sth->{NUM_OF_FIELDS} == 0;
for my $i (0 .. $sth->{NUM_OF_FIELDS}-1)
{
printf "--- Column %d (%s) ---\n", $i, $sth->{NAME}->[$i];
printf "NAME_lc: %s\n", $sth->{NAME_lc}->[$i];
printf "NAME_uc: %s\n", $sth->{NAME_uc}->[$i];
printf "NULLABLE: %s\n", $sth->{NULLABLE}->[$i];
printf "PRECISION: %d\n", $sth->{PRECISION}->[$i];
printf "SCALE: %d\n", $sth->{SCALE}->[$i];
printf "TYPE: %d\n", $sth->{TYPE}->[$i];
printf "mysql_is_blob: %s\n", $sth->{mysql_is_blob}->[$i];
printf "mysql_is_key: %s\n", $sth->{mysql_is_key}->[$i];
printf "mysql_is_num: %s\n", $sth->{mysql_is_num}->[$i];
printf "mysql_is_pri_key: %s\n", $sth->{mysql_is_pri_key}->[$i];
printf "mysql_max_length: %d\n", $sth->{mysql_max_length}->[$i];
printf "mysql_table: %s\n", $sth->{mysql_table}->[$i];
printf "mysql_type: %d\n", $sth->{mysql_type}->[$i];
printf "mysql_type_name: %s\n", $sth->{mysql_type_name}->[$i];
}
$sth->finish (); # release result set because we didn't fetch its rows
程序产生以下输出:
Statement: SELECT name, birth FROM profile
NUM_OF_FIELDS: 2
--- Column 0 (name) ---
NAME_lc: name
NAME_uc: NAME
NULLABLE:
PRECISION: 20
SCALE: 0
TYPE: 12
mysql_is_blob:
mysql_is_key:
mysql_is_num: 0
mysql_is_pri_key:
mysql_max_length: 7
mysql_table: profile
mysql_type: 253
mysql_type_name: varchar
--- Column 1 (birth) ---
NAME_lc: birth
NAME_uc: BIRTH
NULLABLE: 1
PRECISION: 10
SCALE: 0
TYPE: 9
mysql_is_blob:
mysql_is_key:
mysql_is_num: 0
mysql_is_pri_key:
mysql_max_length: 10
mysql_table: profile
mysql_type: 10
mysql_type_name: date
要从调用 execute() 生成的结果集中获取行数,请自行获取行并计数。在 DBI 文档中明确不推荐使用 $sth->rows() 来获取 SELECT 语句的计数。
您还可以通过调用使用数据库句柄而不是语句句柄的 DBI 方法之一(如 selectall_arrayref() 或 selectall_hashref())来获取结果集。这些方法不提供对列元数据的访问。该信息在方法返回时已被处理,无法在脚本中使用。但是,您可以通过检查结果集本身来推导列和行计数。Recipe 4.4 讨论了几种方法产生的结果集结构以及如何使用它们来获取行和列计数。
Ruby
Ruby Mysql2 gem 在执行语句后不提供自己的方法来访问结果集的元数据。你只能通过调用Mysql2::Result类的fields方法获取列名。
stmt = "SELECT name, birth FROM profile"
puts "Statement: #{stmt}"
sth = client.prepare(stmt)
res = sth.execute()
# metadata information becomes available at this point ...
puts "Number of columns: #{res.fields.size}"
puts "Note: statement has no result set" if res.count == 0
puts "Columns names: #{res.fields.join(", ")}"
res.free
要获取其他列的元数据,请参考我们在 Recipe 12.5 中建议的 Information Schema 查询。
PHP
在 PHP 中,对于SELECT语句的元数据可以在成功调用query()后从 PDO 中获取。如果您使用prepare()加execute()执行语句(适用于SELECT或非SELECT语句),则在execute()后元数据将可用。
要确定元数据的可用性,请检查语句句柄的columnCount()方法是否返回大于零的值。如果是,则句柄的getColumnMeta()方法将返回一个包含单个列的元数据的关联数组。下表显示了该数组的元素。(对于其他数据库系统,flags值的格式可能有所不同。)
表格 12-3. PHP 中的元数据
| Name | 值 |
|---|---|
pdo_type |
列类型(对应于PDO::PARAM_XXX的值) |
native_type |
列值的 PHP 本机类型 |
name |
列名 |
len |
列长度 |
precision |
列精度 |
flags |
描述列属性的标志数组 |
table |
表格的名称 |
这个示例代码展示了如何执行语句并显示结果集的元数据:
$stmt = "SELECT name, birth FROM profile";
print ("Statement: $stmt\n");
$sth = $dbh->prepare ($stmt);
$sth->execute ();
# metadata information becomes available at this point ...
$ncols = $sth->columnCount ();
print ("Number of columns: $ncols\n");
if ($ncols == 0)
print ("Note: statement has no result set\n");
for ($i = 0; $i < $ncols; $i++)
{
$col_info = $sth->getColumnMeta ($i);
$flags = implode (",", array_values ($col_info["flags"]));
printf ("--- Column %d (%s) ---\n", $i, $col_info["name"]);
printf ("pdo_type: %d\n", $col_info["pdo_type"]);
printf ("native_type: %s\n", $col_info["native_type"]);
printf ("len: %d\n", $col_info["len"]);
printf ("precision: %d\n", $col_info["precision"]);
printf ("flags: %s\n", $flags);
printf ("table: %s\n", $col_info["table"]);
}
程序产生如下输出:
Statement: SELECT name, birth FROM profile
Number of columns: 2
--- Column 0 (name) ---
PDO type: 2
native type: VAR_STRING
len: 20
precision: 0
flags: not_null
table: profile
--- Column 1 (birth) ---
PDO type: 2
native type: DATE
len: 10
precision: 0
flags:
table: profile
要从返回行的语句中获取行数,请获取行并自行计数。rowCount()方法不能保证适用于结果集。
Python
对于产生结果集的语句,Python 的 DB API 提供了行和列计数,以及有关各个列的少量信息项。
要获取结果集的行数,请访问游标的rowcount属性。这要求游标被缓冲,以便立即获取查询结果;否则,您必须在获取时计算行数。直接获取列数不可用,但在调用fetchone()或fetchall()后,可以将结果集行元组的长度作为列数。也可以在不获取任何行的情况下,通过使用cursor.description确定列数。这是一个元组,每个元素代表结果集中每一列的元数据,因此其长度告诉您结果集中有多少列。(如果语句生成没有结果集,例如对于UPDATE,description的值为None。)description元组的每个元素都是另一个元组,表示结果集相应列的元数据。对于 Connector/Python,只有少数几个description值是有意义的。以下代码显示了如何访问它们:
stmt = "SELECT name, birth FROM profile"
print("Statement: %s" % stmt)
# buffer cursor so that rowcount has usable value
cursor = conn.cursor(buffered=True)
cursor.execute(stmt)
# metadata information becomes available at this point ...
print("Number of rows: %d" % cursor.rowcount)
if cursor.description is None: # no result set
ncols = 0
else:
ncols = len(cursor.description)
print("Number of columns: %d" % ncols)
if ncols == 0:
print("Note: statement has no result set")
for i, col_info in enumerate(cursor.description):
# print name, then other information
name, type, _, _, _, _, nullable, flags, _ = col_info
print("--- Column %d (%s) ---" % (i, name))
print("Type: %d (%s)" % (type, FieldType.get_info(type)))
print("Nullable: %d" % (nullable))
print("Flags: %d" % (flags))
cursor.close()
代码使用了导入如下的FieldType类:
from mysql.connector import FieldType
程序产生如下输出:
Statement: SELECT name, birth FROM profile
Number of rows: 10
Number of columns: 2
--- Column 0 (name) ---
Type: 253 (VAR_STRING)
Nullable: 0
Flags: 4097
--- Column 1 (birth) ---
Type: 10 (DATE)
Nullable: 1
Flags: 128
Go
Go 通过Rows.ColumnTypes方法返回作为ColumnType值数组的列元数据。您可以查询数组的每个成员以获取列的特定特性。
表 12-4 包含ColumnType支持的方法。
表 12-4. Go 中的元数据
| 方法名称 | 描述 |
|---|---|
DatabaseTypeName |
数据库类型,如INT或VARCHAR。 |
DecimalSize |
十进制类型的比例和精度 |
Length |
变长文本和二进制列的列类型长度。MySQL 驱动程序不支持。 |
Name |
列的名称或别名。 |
Nullable |
列是否可为空。 |
ScanType |
适合扫描到Rows.Scan中的本地 Go 类型。 |
如果使用Rows.Columns方法,还可以获取列名列表。它返回包含列名或别名的字符串数组。
示例代码演示了如何在 Go 应用程序中获取列名和元数据。
package main
import (
"fmt"
"log"
"github.com/svetasmirnova/mysqlcookbook/recipes/lib/cookbook"
)
func main() {
db := cookbook.Connect()
defer db.Close()
stmt := "SELECT name, birth FROM profile"
fmt.Printf("Statement: %s\n", stmt)
rows, err := db.Query(stmt)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
// metadata information becomes available at this point ...
cols, err := rows.ColumnTypes()
if err != nil {
log.Fatal(err)
}
ncols := len(cols)
fmt.Printf("Number of columns: %d\n", ncols)
if (ncols == 0) {
fmt.Println("Note: statement has no result set")
}
for i := 0; i < ncols; i++ {
fmt.Printf("---- Column %d (%s) ----\n", i, cols[i].Name())
fmt.Printf("DatabaseTypeName: %s\n", cols[i].DatabaseTypeName())
collen, ok := cols[i].Length()
if ok {
fmt.Printf("Length: %d\n", collen)
}
precision, scale, ok := cols[i].DecimalSize()
if ok {
fmt.Printf("DecimalSize precision: %d, scale: %d\n", precision, scale)
}
colnull, ok := cols[i].Nullable()
if ok {
fmt.Printf("Nullable: %t\n", colnull)
}
fmt.Printf("ScanType: %s\n", cols[i].ScanType())
}
}
该程序生成以下输出:
Statement: SELECT name, birth FROM profile
Number of columns: 2
---- Column 0 (name) ----
DatabaseTypeName: VARCHAR
Nullable: false
ScanType: sql.RawBytes
---- Column 1 (birth) ----
DatabaseTypeName: DATE
Nullable: true
ScanType: sql.NullTime
Java
JDBC 通过调用您的ResultSet对象的getMetaData()方法获得结果集元数据,通过ResultSetMetaData对象提供对几种信息的访问。其getColumnCount()方法返回结果集中的列数。与我们的其他 API 不同,对于 JDBC,列索引从 1 开始而不是从 0 开始:
String stmt = "SELECT name, birth FROM profile";
System.out.println("Statement: " + stmt);
Statement s = conn.createStatement();
s.executeQuery(stmt);
ResultSet rs = s.getResultSet();
ResultSetMetaData md = rs.getMetaData();
// metadata information becomes available at this point ...
int ncols = md.getColumnCount();
System.out.println("Number of columns: " + ncols);
if (ncols == 0)
System.out.println ("Note: statement has no result set");
for (int i = 1; i <= ncols; i++) { // column index values are 1-based
System.out.println("--- Column " + i
+ " (" + md.getColumnName (i) + ") ---");
System.out.println("getColumnDisplaySize: " + md.getColumnDisplaySize (i));
System.out.println("getColumnLabel: " + md.getColumnLabel (i));
System.out.println("getColumnType: " + md.getColumnType (i));
System.out.println("getColumnTypeName: " + md.getColumnTypeName (i));
System.out.println("getPrecision: " + md.getPrecision (i));
System.out.println("getScale: " + md.getScale (i));
System.out.println("getTableName: " + md.getTableName (i));
System.out.println("isAutoIncrement: " + md.isAutoIncrement (i));
System.out.println("isNullable: " + md.isNullable (i));
System.out.println("isCaseSensitive: " + md.isCaseSensitive (i));
System.out.println("isSigned: " + md.isSigned (i));
}
rs.close();
s.close();
该程序生成以下输出:
Statement: SELECT name, birth FROM profile
Number of columns: 2
--- Column 1 (name) ---
getColumnDisplaySize: 20
getColumnLabel: name
getColumnType: 12
getColumnTypeName: VARCHAR
getPrecision: 20
getScale: 0
getTableName: profile
isAutoIncrement: false
isNullable: 0
isCaseSensitive: false
isSigned: false
--- Column 2 (birth) ---
getColumnDisplaySize: 10
getColumnLabel: birth
getColumnType: 91
getColumnTypeName: DATE
getPrecision: 10
getScale: 0
getTableName: profile
isAutoIncrement: false
isNullable: 1
isCaseSensitive: false
isSigned: false
结果集的行数不直接可用;您必须获取行并计数它们。
JDBC 有几个其他结果集元数据调用,但其中许多对 MySQL 没有实用信息。要尝试它们,请参考 JDBC 参考资料以查看这些调用,并修改程序以查看它们是否返回任何内容。
12.3 列出或检查数据库或表的存在性
问题
您希望列出 MySQL 服务器托管的数据库或数据库中的表,或者您想检查特定数据库或表是否存在。
解决方案
使用INFORMATION_SCHEMA获取这些信息。SCHEMATA表包含每个数据库的一行,TABLES表包含每个数据库中每个表或视图的一行。
讨论
要检索服务器托管的数据库列表,请使用以下语句:
SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA;
要对结果进行排序,请添加ORDER BY SCHEMA_NAME子句。
要检查特定数据库是否存在,请使用带有命名数据库的条件的WHERE子句。如果返回一行,则数据库存在。以下 Ruby 方法显示了如何执行数据库的存在性测试:
def database_exists(client, db_name)
sth = client.prepare("SELECT SCHEMA_NAME
FROM INFORMATION_SCHEMA.SCHEMATA
WHERE SCHEMA_NAME = ?")
return sth.execute(db_name).count > 0
end
要获取数据库中表的列表,请在选择TABLES表的语句的WHERE子句中命名数据库:
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = 'cookbook';
要对结果进行排序,请添加ORDER BY TABLE_NAME子句。
要获取默认数据库中表的列表,请改用此语句代替:
SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = DATABASE();
如果没有选择数据库,DATABASE() 将返回 NULL,没有匹配的行,这是正确的结果。
要检查特定表是否存在,请使用带有命名表名的条件的 WHERE 子句。以下是在给定数据库中执行表存在性测试的 Ruby 方法:
def table_exists(client, db_name, tbl_name)
sth = client.prepare("SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?")
return sth.execute(db_name, tbl_name).count > 0
end
一些 API 提供了一种与数据库无关的方法来获取数据库或表列表。在 Perl DBI 中,数据库句柄的 tables() 方法返回默认数据库中表的列表:
@tables = $dbh->tables ();
对于 Java,有专门设计用于返回数据库或表列表的 JDBC 方法。对于每种方法,调用连接对象的 getMetaData() 方法,并使用生成的 DatabaseMetaData 对象检索所需的信息。以下是生成数据库列表的方法:
// get list of databases
DatabaseMetaData md = conn.getMetaData ();
ResultSet rs = md.getCatalogs ();
while (rs.next ())
System.out.println (rs.getString (1)); // column 1 = database name
rs.close ();
要列出数据库中的表,请执行以下操作:
// get list of tables in database named by dbName; if
// dbName is the empty string, the default database is used
DatabaseMetaData md = conn.getMetaData ();
ResultSet rs = md.getTables (dbName, "", "%", null);
while (rs.next ())
System.out.println (rs.getString (3)); // column 3 = table name
rs.close ();
12.4 列出或检查视图的存在
问题
您想要检查您的数据库是否包含视图。
解决方案
仅从 INFORMATION_SCHEMA.TABLES 表中选择那些具有 TABLE_TYPE 等于 VIEW 的表。
讨论
方法,在 Recipe 12.3 中显示了物理表和视图。如果您需要区分它们,请使用子句 WHERE TABLE_TYPE='VIEW' 仅列出视图:
mysql> `SELECT` `TABLE_SCHEMA``,` `TABLE_NAME``,` `TABLE_TYPE`
-> `FROM` `INFORMATION_SCHEMA``.``TABLES`
-> `WHERE` `TABLE_TYPE``=``'VIEW'` `AND` `TABLE_SCHEMA``=``'cookbook'``;`
+--------------+---------------------+------------+ | TABLE_SCHEMA | TABLE_NAME | TABLE_TYPE |
+--------------+---------------------+------------+ | cookbook | patients_statistics | VIEW |
+--------------+---------------------+------------+ 1 row in set (0,00 sec)
如果您只想列出物理表,可以使用条件 TABLE_TYPE='BASE TABLE':
mysql> `SELECT` `TABLE_SCHEMA``,` `TABLE_NAME``,` `TABLE_TYPE`
-> `FROM` `INFORMATION_SCHEMA``.``TABLES`
-> `WHERE` `TABLE_TYPE``=``'BASE TABLE'` `AND` `TABLE_SCHEMA``=``'cookbook'`
-> `AND` `TABLE_NAME` `LIKE` `'trip%'``;`
+--------------+------------+------------+ | TABLE_SCHEMA | TABLE_NAME | TABLE_TYPE |
+--------------+------------+------------+ | cookbook | trip_leg | BASE TABLE |
| cookbook | trip_log | BASE TABLE |
+--------------+------------+------------+ 2 rows in set (0,00 sec)
12.5 访问表列定义
问题
您想要找出表具有哪些列及其定义方式。
解决方案
有几种方法可以做到这一点。您可以从 INFORMATION_SCHEMA、SHOW 语句或 mysqldump 中获取列定义。
讨论
关于表结构的信息使您能够回答诸如 表包含哪些列及其类型?
或
等问题。以下是这种信息的一些应用程序:ENUM 或 SET 列的合法值是什么?
显示列列表
表信息的一个简单用途是呈现表的列列表。这在基于 Web 或 GUI 的应用程序中很常见,用户可以通过从列表中选择表列并输入要与列值进行比较的值来交互地构造语句。
交互式记录编辑
对表结构的了解对于修改数据的交互应用非常有用。假设一个应用从数据库检索记录,显示包含记录内容的表单以便用户编辑,然后在用户修改表单并提交后更新数据库中的记录。您可以使用表结构信息来验证列值,确保不会尝试插入无效值到数据库中。如果某列是 ENUM 类型,您可以查找有效的枚举值,并检查用户提交的值是否合法。如果列是整数类型,检查提交的值确保完全由数字组成,可能前面带有 + 或 − 符号。如果列包含日期,查找合法的日期格式。
但如果用户将字段留空呢?如果字段对应于表中的 CHAR 列,您将列值设置为 NULL 还是空字符串?这也是可以通过检查表结构来回答的问题。确定列是否可以包含 NULL 值。如果可以,将列设置为 NULL;否则,将其设置为空字符串。
将列定义映射到网页元素
某些数据类型,如 ENUM 和 SET,自然对应于 Web 表单的元素:
-
ENUM具有固定的一组值,您可以从中选择单个值。这类似于一组单选按钮、弹出菜单或单选滚动列表。 -
SET列类似,但您可以选择多个值;这对应于一组复选框或多选滚动列表。
通过使用表元数据访问这些列类型的定义,您可以轻松确定列的合法值,并将它们映射到适当的表单元素上。Recipe 12.6 讨论了如何获取这些列类型的定义。
MySQL 提供多种方法来获取表结构的信息:
-
从
INFORMATION_SCHEMA检索信息。COLUMNS表包含列定义。 -
使用
SHOWCOLUMNS语句。 -
使用
SHOWCREATETABLE语句或 mysqldump 命令行程序获取显示表结构的CREATETABLE语句。
下面的讨论展示了如何使用每种方法向 MySQL 请求表信息。要尝试这些示例,请创建一个 item 表,列出每个项目的 ID、名称和颜色:
CREATE TABLE item
(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name CHAR(20),
colors ENUM('chartreuse','mauve','lime green','puce') DEFAULT 'puce',
PRIMARY KEY (id)
);
使用 INFORMATION_SCHEMA 获取表结构
要获取表中单个列的信息,请查询 INFORMATION_SCHEMA.COLUMNS 表:
mysql> `SELECT * FROM INFORMATION_SCHEMA.COLUMNS`
-> `WHERE TABLE_SCHEMA = 'cookbook' AND TABLE_NAME = 'item'`
-> `AND COLUMN_NAME = 'colors'\G`
*************************** 1\. row ***************************
TABLE_CATALOG: def
TABLE_SCHEMA: cookbook
TABLE_NAME: item
COLUMN_NAME: colors
ORDINAL_POSITION: 3
COLUMN_DEFAULT: puce
IS_NULLABLE: YES
DATA_TYPE: enum
CHARACTER_MAXIMUM_LENGTH: 10
CHARACTER_OCTET_LENGTH: 10
NUMERIC_PRECISION: NULL
NUMERIC_SCALE: NULL
DATETIME_PRECISION: NULL
CHARACTER_SET_NAME: utf8mb4
COLLATION_NAME: utf8mb4_0900_ai_ci
COLUMN_TYPE: enum('chartreuse','mauve','lime green','puce')
COLUMN_KEY:
EXTRA:
PRIVILEGES: select,insert,update,references
COLUMN_COMMENT:
要获取所有列的信息,请在 WHERE 子句中省略 COLUMN_NAME 条件。
下面是可能最有用的一些 COLUMNS 表列:
-
COLUMN_NAME:列名。 -
ORDINAL_POSITION:列在表定义中的位置。 -
COLUMN_DEFAULT:列的默认值。 -
IS_NULLABLE:YES或NO指示列是否可以包含NULL值。 -
DATA_TYPE,COLUMN_TYPE: 数据类型信息。DATA_TYPE是数据类型关键字,COLUMN_TYPE包含类型属性等附加信息。 -
CHARACTER_SET_NAME,COLLATION_NAME: 字符串列的字符集和排序规则。非字符串列的这些值为NULL. -
COLUMN_KEY: 列索引信息。
从程序内部使用 INFORMATION_SCHEMA 内容非常简单。以下是一个 PHP 函数示例,演示了这一过程。它接受数据库和表名参数,从 INFORMATION_SCHEMA 中选择以获取表的列名,并将列名作为数组返回。ORDER BY ORDINAL_POSITION 子句确保数组中的名称按照表定义顺序返回:
function get_column_names ($dbh, $db_name, $tbl_name)
{
$stmt = "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION";
$sth = $dbh->prepare ($stmt);
$sth->execute (array ($db_name, $tbl_name));
return ($sth->fetchAll (PDO::FETCH_COLUMN, 0));
}
get_column_names() 返回一个仅包含列名的数组。如果需要额外的列信息,可以编写一个更通用的 get_column_info() 程序,它返回列信息结构的数组。要查看 PHP 以及其他语言的实现,请查看 recipes 发行版中 lib 目录下的库文件。
使用 SHOW COLUMNS 获取表结构
SHOW COLUMNS 语句为表中的每列生成一行输出,每行提供有关相应列的各种信息。以下示例演示了 item 表 colors 列的 SHOW COLUMNS 输出:
mysql> `SHOW COLUMNS FROM item LIKE 'colors'\G`
*************************** 1\. row ***************************
Field: colors
Type: enum('chartreuse','mauve','lime green','puce')
Null: YES
Key:
Default: puce
Extra:
SHOW COLUMNS 显示所有列名匹配 LIKE 模式的信息。要获取所有列的信息,请省略 LIKE 子句。
SHOW COLUMNS 显示的值对应于 INFORMATION_SCHEMA COLUMNS 表的以下列:COLUMN_NAME, COLUMN_TYPE, COLUMN_KEY, IS_NULLABLE, COLUMN_DEFAULT, EXTRA.
SHOW FULL COLUMNS 显示每列的额外 Collation, Privileges, 和 Comment 字段。这些对应于 COLUMNS 表的 COLLATION_NAME, PRIVILEGES, 和 COLUMN_COMMENT 列。
SHOW 以与 SELECT 语句的 WHERE 子句中的 LIKE 操作符相同的方式解释模式。如果指定了文字列名,则字符串仅匹配该名称,并且 SHOW COLUMNS 仅显示该列的信息。如果列名包含 SQL 模式字符(% 或 _),您希望字面匹配它们,必须在模式字符串中使用反斜杠进行转义,以避免同时匹配其他名称。
需要转义 % 和 _ 字符以文字匹配 LIKE 模式,这也适用于其他允许 LIKE 子句中的名称模式的 SHOW 语句,如 SHOW TABLES 和 SHOW DATABASES.
在程序中,可以使用 API 语言的模式匹配功能在将列名放入 SHOW 语句之前转义 SQL 模式字符。在 Perl、Ruby 和 PHP 中,使用以下表达式。
Perl:
$name =~ s/([%_])/\\$1/g;
Ruby:
name = name.gsub(/([%_])/, '\\\\\1')
PHP:
$name = preg_replace ('/([%_])/', '\\\\$1', $name);
对于 Python,请导入 re 模块,并使用其 sub() 方法:
name = re.sub(r'([%_])', r'\\\1', name)
对于 Go,请使用 regexp 包中的方法:
import "regexp"
// ...
re := regexp.MustCompile(`([_%])`)
name = re.ReplaceAllString(name, "\\\\$1")
对于 Java,请使用 java.util.regex 包中的方法:
import java.util.regex.*;
Pattern p = Pattern.compile("([_%])");
Matcher m = p.matcher(name);
name = m.replaceAll ("\\\\$1");
如果这些表达式看起来有太多反斜杠,请记住 API 语言处理器本身解释反斜杠并在执行模式匹配之前剥离一级。要将字面反斜杠放入结果中,必须在模式中加倍。如果模式处理器剥离集,则需要在此之上添加另一级。
使用 SHOW CREATE TABLE 获取表结构
从 MySQL 获取表结构信息的另一种方法是使用定义表的 CREATE TABLE 语句。要获取此信息,请使用 SHOW CREATE TABLE 语句:
mysql> `SHOW CREATE TABLE item\G`
*************************** 1\. row ***************************
Table: item
Create Table: CREATE TABLE `item` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` char(20) DEFAULT NULL,
`colors` enum('chartreuse','mauve','lime green','puce') DEFAULT 'puce',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
从命令行,如果使用 --no-data 选项,则 mysqldump 提供的相同 CREATE TABLE 信息,这告诉 mysqldump 仅转储表的结构而不是其数据。
CREATE TABLE 格式非常信息丰富且易于阅读,因为它以与创建表时使用的格式相似的格式显示列信息。它还清楚显示索引结构,而其他方法则不然。但是,您可能会发现这种检查表结构的方法在交互中比在程序中更有用。该信息未以常规的行列格式提供,因此解析起来更困难。此外,格式在 MySQL 增强 CREATE TABLE 语句时可能会更改,这种情况偶尔会发生,因为 MySQL 的功能被扩展。
12.6 获取 ENUM 和 SET 列信息
问题
您想知道 ENUM 或 SET 列的成员。
解决方案
此问题是获取表结构元数据的子集。从表元数据中获取列定义,然后从定义中提取成员列表。
讨论
了解 ENUM 或 SET 列的允许值列表通常很有用。假设您想要呈现包含与 ENUM 列的每个合法值对应的选项的 Web 表单,例如可以订购服装的尺寸或递送包裹的可用递送方式。您可以将选择硬编码到生成表单的脚本中,但是如果稍后更改列(例如添加新的枚举值),则会在列和使用它的脚本之间引入差异。如果改为使用表元数据查找合法值,脚本始终可以生成包含正确值集的弹出菜单。类似的方法适用于 SET 列。
要确定ENUM或SET列的允许值,请使用食谱 12.5 中描述的技术之一获取其定义。例如,如果从INFORMATION_SCHEMA的COLUMNS表中选择,item表的colors列的COLUMN_TYPE值如下所示:
enum('chartreuse','mauve','lime green','puce')
SET列类似,只是它们说set而不是enum。对于任一数据类型,通过去除初始单词和括号、在逗号处分割,并从各个值中移除包围引号,可以提取允许的值。
让我们编写一个get_enumorset_info()例程来从数据类型定义中提取这些值。顺便说一下,我们可以使该例程返回列的类型、默认值以及值是否可以为NULL。然后可以通过可能需要不仅仅是值列表的脚本来使用该例程。以下是 Ruby 版本。它的参数是数据库句柄、数据库名称、表名称和列名称。它返回一个哈希,其中条目对应于列定义的各个方面(如果列不存在则为nil):
def get_enumorset_info(client, db_name, tbl_name, col_name)
sth = client.prepare(
"SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = ?")
res = sth.execute(db_name, tbl_name, col_name)
return nil if res.count == 0 # no such column
row = res.first
info = {}
info["name"] = row.values[0]
return nil unless row.values[1] =~ /^(ENUM|SET)\((.*)\)$/i # not ENUM or SET
info["type"] = $1
# split value list on commas, trim quotes from end of each word
info["values"] = $2.split(",").collect { |val| val.sub(/^'(.*)'$/, "\\1") }
# determine whether column can contain NULL values
info["nullable"] = (row.values[2].upcase == "YES")
# get default value (nil represents NULL)
info["default"] = row.values[3]
return info
end
在检查数据类型和可空属性时,此例程使用不区分大小写的匹配。这可以防止元数据结果中未来字母大小写的更改。
以下示例显示了如何访问并显示get_enumorset_info()返回的哈希的每个元素:
info = get_enumorset_info(client, db_name, tbl_name, col_name)
puts "Information for #{db_name}.#{tbl_name}.#{col_name}:"
if info.nil?
puts "No information available (not an ENUM or SET column?)"
else
puts "Name: " + info["name"]
puts "Type: " + info["type"]
puts "Legal values: " + info["values"].join(",")
puts "Nullable: " + (info["nullable"] ? "yes" : "no")
puts "Default value: " + (info["default"].nil? ? "NULL" : info["default"])
end
该代码生成了以下输出,显示了profile表的color列:
Information for cookbook.profile.color:
Name: color
Type: enum
Legal values: blue,red,green,brown,black,white
Nullable: yes
Default value: NULL
其他 API 的等效例程类似。您可以在recipes分发的lib目录中找到实现。这些例程对于验证输入值(参见食谱 14.11)非常有用。
12.7 获取服务器元数据
问题
您希望获取有关 MySQL 服务器本身的信息,例如其版本、配置以及其组件的当前状态。
解决方案
几个 SQL 函数和SHOW语句返回有关服务器的信息。
讨论
MySQL 具有几个 SQL 函数和语句,可以为您提供有关服务器本身以及当前客户端会话的信息。Table 12-5 显示了一些您可能发现有用的函数。两个SHOW语句允许使用GLOBAL或SESSION关键字选择全局服务器值或特定于您会话的值,并使用LIKE 'pattern'子句来限制结果仅包含与模式匹配的变量名:
表 12-5. 获取服务器元数据的 SQL 函数和语句
| 语句 | 由语句产生的信息 |
|---|---|
SELECT VERSION() |
服务器版本字符串 |
SELECT DATABASE() |
默认数据库名称(如果没有则为NULL) |
SELECT USER() |
连接时客户端给出的当前用户 |
SELECT CURRENT_USER() |
用于检查客户端权限的用户 |
SHOW [GLOBAL|SESSION] STATUS |
服务器全局或会话状态指示器 |
SHOW [GLOBAL|SESSION] VARIABLES |
服务器的全局或会话状态配置变量 |
要获取表中任何语句提供的信息,请执行它并处理其结果集。例如,SELECT DATABASE() 返回默认数据库的名称,如果没有选择数据库则返回 NULL。以下 Ruby 代码使用该语句来呈现包含当前会话信息的状态显示:
db = client.query("SELECT DATABASE()").first.values[0]
puts "Default database: " + (db.nil? ? "(no database selected)" : db)
给定的 API 可能提供替代方法来执行 SQL 语句以访问这些类型的信息。例如,JDBC 提供了几种数据库无关的方法来获取服务器元数据。使用您的连接对象获取数据库元数据,然后调用适当的方法以获取您感兴趣的信息。查阅 JDBC 参考手册获取完整列表,但以下是一些代表性示例:
DatabaseMetaData md = conn.getMetaData();
// can also get this with SELECT VERSION()
System.out.println("Product version: " + md.getDatabaseProductVersion());
// this is similar to SELECT USER() but doesn't include the hostname
System.out.println("Username: " + md.getUserName());
另请参阅
更多关于在服务器监控环境中使用 SHOW(和 INFORMATION_SCHEMA)的讨论,请参见第 23.2 章。
12.8 编写适应 MySQL 服务器版本的应用程序
问题
您希望使用的某个功能仅适用于特定版本的 MySQL。
解决方案
向服务器请求其版本号。如果服务器太旧无法支持特定功能,也许可以退而求其次,如果有可行的解决方案的话。或者建议用户升级。
讨论
每次 MySQL 的新发布都会添加新功能。如果您正在编写需要特定功能的应用程序,请检查服务器版本以确定其是否存在;如果不存在,则必须执行某种解决方案(假设存在)。
要获取服务器版本,请调用 VERSION() 函数。结果是一个字符串,看起来类似于 5.7.33-debug-log 或 8.0.25。换句话说,它返回一个由主要、次要和修订
版本号组成的字符串,可能在修订
版本的末尾有一些非数字字符,可能还有一些后缀。版本字符串可以直接用于演示目的,但是为了比较,用数字更简单——特别是使用 Mmmtt 格式的五位数,在该格式中,M、mm、tt 分别代表主要、次要和修订版本号。通过在句点处拆分字符串、去除第三部分中以第一个非数字字符开始的后缀,并连接这些部分来执行转换。例如,5.7.33-debug-log 变成 50733,而 8.0.25 变成 80025。
这是一个 Perl DBI 函数,它接受一个数据库句柄参数,并返回一个包含服务器版本的字符串和数字形式的两元素列表。该代码假设次要版本和修订版本部分都小于 100,因此每部分最多两位数。这应该是一个有效的假设,因为 MySQL 本身的源代码也使用相同的格式:
sub get_server_version
{
my $dbh = shift;
my ($ver_str, $ver_num);
my ($major, $minor, $patch);
# fetch result into scalar string
$ver_str = $dbh->selectrow_array ("SELECT VERSION()");
return undef unless defined ($ver_str);
($major, $minor, $patch) = split (/\./, $ver_str);
$patch =~ s/\D.*$//; # strip nonnumeric suffix if present
$ver_num = $major*10000 + $minor*100 + $patch;
return ($ver_str, $ver_num);
}
要一次获取两种形式的版本信息,请这样调用函数:
my ($ver_str, $ver_num) = get_server_version ($dbh);
要仅获取其中一个值,请按以下方式调用:
my $ver_str = (get_server_version ($dbh))[0]; # string form
my $ver_num = (get_server_version ($dbh))[1]; # numeric form
以下示例演示了如何使用数字版本值检查服务器是否支持某些功能:
my $ver_num = (get_server_version ($dbh))[1];
printf "Event scheduler: %s\n", ($ver_num >= 50106 ? "yes" : "no");
printf "4-byte Unicode: %s\n", ($ver_num >= 50503 ? "yes" : "no");
printf "Fractional seconds: %s\n", ($ver_num >= 50604 ? "yes" : "no");
printf "SHA-256 passwords: %s\n", ($ver_num >= 50606 ? "yes" : "no");
printf "ALTER USER: %s\n", ($ver_num >= 50607 ? "yes" : "no");
printf "INSERT DELAYED: %s\n", ($ver_num >= 50700 ? "no" : "yes");
recipes 发行版 metadata 目录中包含其他 API 语言中的 get_server_version() 实现,并且 routines 目录包含用于 SQL 语句的 server_version() 存储函数。后者仅返回数字值,因为 VERSION() 已经生成了字符串值。以下示例显示了如何使用它来实现一个存储过程,如果服务器支持 ALTER USER ... FAILED_LOGIN_ATTEMPTS 语句(MySQL 8.0.19 或更高版本),则对 N 次失败登录尝试进行密码锁定:
CREATE PROCEDURE enable_failed_login_attempts(
user TEXT, host TEXT, failed_atempts INT)
BEGIN
DECLARE account TEXT;
SET account = CONCAT(QUOTE(user),'@',QUOTE(host));
IF server_version() >= 80019 AND user <> '' THEN
CALL exec_stmt(CONCAT('ALTER USER ',account,'
FAILED_LOGIN_ATTEMPTS ', failed_atempts));
END IF;
END;
expire_password() 需要 exec_stmt() 辅助例程(参见 Recipe 11.6)。这两者都在 routines 目录中可用。关于密码过期的更多信息,请参见 Recipe 24.5。
12.9 获取通过外键约束引用特定表的子表
问题
您想知道哪些其他表通过外键约束引用您的表作为父表。
解决方案
查询表 INFORMATION_SCHEMA.TABLE_CONSTRAINTS 和 INFORMATION_SCHEMA.KEY_COLUMN_USAGE。
讨论
外键约束提供数据完整性检查,正如我们在 “使用外键强制实现参照完整性和防止不匹配” 中讨论的那样。它们通过阻止修改由链接表引用的数据的语句来执行,以防止语句的结果破坏完整性。外键帮助保持数据正确,但同时可能引发难以排查的 SQL 错误。虽然很容易找出特定子表的父表是哪个,但要找出特定父表的子表却并不容易。但如果你计划修改它,则知道表是否被子表引用将会很有帮助。
表 INFORMATION_SCHEMA.TABLE_CONSTRAINTS 包含为您的 MySQL 安装创建的所有约束。要选择外键约束,请使用子句 WHERE CONSTRAINT_TYPE='FOREIGN KEY' 来缩小搜索范围:
mysql> `SELECT` `TABLE_SCHEMA``,` `TABLE_NAME``,` `CONSTRAINT_NAME`
-> `FROM` `INFORMATION_SCHEMA``.``TABLE_CONSTRAINTS`
-> `WHERE` `CONSTRAINT_TYPE``=``'FOREIGN KEY'` `AND` `TABLE_SCHEMA``=``'cookbook'``;`
+--------------+--------------------+---------------------------+ | TABLE_SCHEMA | TABLE_NAME | CONSTRAINT_NAME |
+--------------+--------------------+---------------------------+ | cookbook | movies_actors_link | movies_actors_link_ibfk_1 |
| cookbook | movies_actors_link | movies_actors_link_ibfk_2 |
+--------------+--------------------+---------------------------+ 2 rows in set (0,00 sec)
上述列表打印了我们在 Recipe 11.10 示例中创建的外键。但是,此输出仍然只列出了子表。要找出哪个表是父表,我们需要将 INFORMATION_SCHEMA.TABLE_CONSTRAINTS 与表 INFORMATION_SCHEMA.KEY_COLUMN_USAGE 进行连接:
mysql> `SELECT` `ku``.``CONSTRAINT_NAME``,` `ku``.``TABLE_NAME``,` `ku``.``COLUMN_NAME``,`
-> `ku``.``REFERENCED_TABLE_NAME``,` `ku``.``REFERENCED_COLUMN_NAME`
-> `FROM` `INFORMATION_SCHEMA``.``TABLE_CONSTRAINTS` `tc`
-> `JOIN` `INFORMATION_SCHEMA``.``KEY_COLUMN_USAGE` `ku`
-> `USING` `(``CONSTRAINT_NAME``,` `TABLE_SCHEMA``,` `TABLE_NAME``)`
-> `WHERE` `CONSTRAINT_TYPE``=``'FOREIGN KEY'` `AND` `ku``.``TABLE_SCHEMA``=``'cookbook'``\``G`
*************************** 1. row ***************************
CONSTRAINT_NAME: movies_actors_link_ibfk_1
TABLE_NAME: movies_actors_link
COLUMN_NAME: movie_id
REFERENCED_TABLE_NAME: movies
REFERENCED_COLUMN_NAME: id
*************************** 2. row ***************************
CONSTRAINT_NAME: movies_actors_link_ibfk_2
TABLE_NAME: movies_actors_link
COLUMN_NAME: actor_id
REFERENCED_TABLE_NAME: actors
REFERENCED_COLUMN_NAME: id
2 rows in set (0,00 sec)
在上述列表中,列 TABLE_NAME 和 COLUMN_NAME 指代子表,列 REFERENCED_TABLE_NAME 和 REFERENCED_COLUMN_NAME 指代父表。
对于 InnoDB 表,您还可以查询表 INNODB_FOREIGN 和 INNODB_FOREIGN_COLS:
mysql> `SELECT` `ID``,` `FOR_NAME``,` `FOR_COL_NAME``,` `REF_NAME``,` `REF_COL_NAME`
-> `FROM` `INFORMATION_SCHEMA``.``INNODB_FOREIGN` `JOIN`
-> `INFORMATION_SCHEMA``.``INNODB_FOREIGN_COLS` `USING``(``ID``)`
-> `WHERE` `ID` `LIKE` `'cookbook%'``\``G`
*************************** 1. row ***************************
ID: cookbook/movies_actors_link_ibfk_1
FOR_NAME: cookbook/movies_actors_link
FOR_COL_NAME: movie_id
REF_NAME: cookbook/movies
REF_COL_NAME: id
*************************** 2. row ***************************
ID: cookbook/movies_actors_link_ibfk_2
FOR_NAME: cookbook/movies_actors_link
FOR_COL_NAME: actor_id
REF_NAME: cookbook/actors
REF_COL_NAME: id
2 rows in set (0,01 sec)
请注意,这些表从内部 InnoDB 数据字典中获取数据,该字典将数据库和表名存储在一个字段中。因此,您需要使用操作符 LIKE 来限制结果到特定的数据库或表。
12.10 列出触发器
问题
您希望列出为您的表定义的触发器。
解决方案
查询表 INFORMATION_SCHEMA.TRIGGERS。
讨论
在调优性能时,了解特定表定义的触发器非常有用,特别是在以下情况下:
-
简单的更新,影响了几行,运行时间比您预期的长。
-
未参与应用负载并且在进程列表中不可见的表,等待或持有锁。
-
磁盘 IO 很高。
例如,要列出为表 auction 创建的触发器,请使用以下查询:
mysql> `SELECT` `EVENT_MANIPULATION``,` `ACTION_TIMING``,` `TRIGGER_NAME``,` `ACTION_STATEMENT`
-> `FROM` `INFORMATION_SCHEMA``.``TRIGGERS`
-> `WHERE` `TRIGGER_SCHEMA``=``'cookbook'` `AND` `EVENT_OBJECT_TABLE` `=` `'auction'``\``G`
*************************** 1. row ***************************
EVENT_MANIPULATION: INSERT
ACTION_TIMING: AFTER
TRIGGER_NAME: ai_auction
ACTION_STATEMENT: INSERT INTO auction_log (action,id,ts,item,bid)
VALUES('create',NEW.id,NOW(),NEW.item,NEW.bid)
*************************** 2. row ***************************
EVENT_MANIPULATION: UPDATE
ACTION_TIMING: AFTER
TRIGGER_NAME: au_auction
ACTION_STATEMENT: INSERT INTO auction_log (action,id,ts,item,bid)
VALUES('update',NEW.id,NOW(),NEW.item,NEW.bid)
*************************** 3. row ***************************
EVENT_MANIPULATION: DELETE
ACTION_TIMING: AFTER
TRIGGER_NAME: ad_auction
ACTION_STATEMENT: INSERT INTO auction_log (action,id,ts,item,bid)
VALUES('delete',OLD.id,OLD.ts,OLD.item,OLD.bid)
3 rows in set (0,01 sec)
这样,您可以获取触发器何时被触发以及其体定义的信息。如果有多个触发器,您将看到它们所有的信息。
12.11 列出存储过程和计划事件
问题
您想知道在您的数据库中创建了哪些存储过程、函数和计划事件。
解决方案
查询表 INFORMATION_SCHEMA.ROUTINES 和 INFORMATION_SCHEMA.EVENTS。
讨论
要列出存储函数和存储过程,请查询表 INFORMATION_SCHEMA.ROUTINES。如果要区分是哪种类型的例程,可以通过 WHERE 条件指定 ROUTINE_TYPE 为 FUNCTION 或 PROCEDURE。
例如,在我们讨论 Recipe 15.17 中的序列生成中,列出所有参与的例程,请使用以下代码:
mysql> `SELECT` `ROUTINE_NAME``,` `ROUTINE_TYPE` `FROM` `INFORMATION_SCHEMA``.``ROUTINES`
-> `WHERE` `ROUTINE_SCHEMA``=``'cookbook'` `AND` `ROUTINE_NAME` `LIKE` `'%sequence%'``;`
+---------------------+--------------+ | ROUTINE_NAME | ROUTINE_TYPE |
+---------------------+--------------+ | sequence_next_value | FUNCTION |
| create_sequence | PROCEDURE |
| delete_sequence | PROCEDURE |
+---------------------+--------------+ 3 rows in set (0,01 sec)
您还可以选择列 ROUTINE_DEFINITION 以获取例程体。
要获取计划事件列表,请查询表 INFORMATION_SCHEMA.EVENTS:
mysql> `SELECT` `EVENT_NAME``,` `EVENT_TYPE``,` `INTERVAL_VALUE``,` `INTERVAL_FIELD``,` `LAST_EXECUTED``,`
-> `STATUS``,` `ON_COMPLETION``,` `EVENT_DEFINITION` `FROM` `INFORMATION_SCHEMA``.``EVENTS``\``G`
*************************** 1. row ***************************
EVENT_NAME: mark_insert
EVENT_TYPE: RECURRING
INTERVAL_VALUE: 5
INTERVAL_FIELD: MINUTE
LAST_EXECUTED: 2021-07-07 05:10:45
STATUS: ENABLED
ON_COMPLETION: NOT PRESERVE
EVENT_DEFINITION: INSERT INTO mark_log (message) VALUES('-- MARK --')
*************************** 2. row ***************************
EVENT_NAME: mark_expire
EVENT_TYPE: RECURRING
INTERVAL_VALUE: 1
INTERVAL_FIELD: DAY
LAST_EXECUTED: 2021-07-07 02:56:14
STATUS: ENABLED
ON_COMPLETION: NOT PRESERVE
EVENT_DEFINITION: DELETE FROM mark_log WHERE ts < NOW() - INTERVAL 2 DAY
2 rows in set (0,00 sec)
此表不仅保存事件定义,还保存了诸如上次执行时间、计划间隔及其是否启用或禁用等元数据。
12.12 列出安装的插件
问题
您想知道为您的 MySQL 服务器安装了哪些插件。
解决方案
查询表 INFORMATION_SCHEMA.PLUGINS。
讨论
MySQL 是高度模块化的系统。它的许多部分都是可插拔的。例如,所有存储引擎也是插件。因此,了解服务器上可用的插件非常重要。要获取有关已安装插件的信息,请查询表 INFORMATION_SCHEMA.PLUGINS 或运行命令 SHOW PLUGINS。虽然后者适用于交互式使用,但前者提供了更多信息。
mysql> `SELECT` `*` `FROM` `INFORMATION_SCHEMA``.``PLUGINS`
-> `WHERE` `PLUGIN_NAME` `IN` `(``'caching_sha2_password'``,` `'InnoDB'``,` `'Rewriter'``)``\``G`
*************************** 1. row ***************************
PLUGIN_NAME: caching_sha2_password
PLUGIN_VERSION: 1.0
PLUGIN_STATUS: ACTIVE
PLUGIN_TYPE: AUTHENTICATION
PLUGIN_TYPE_VERSION: 2.0
PLUGIN_LIBRARY: NULL
PLUGIN_LIBRARY_VERSION: NULL
PLUGIN_AUTHOR: Oracle Corporation
PLUGIN_DESCRIPTION: Caching sha2 authentication
PLUGIN_LICENSE: GPL
LOAD_OPTION: FORCE
*************************** 2. row ***************************
PLUGIN_NAME: InnoDB
PLUGIN_VERSION: 8.0
PLUGIN_STATUS: ACTIVE
PLUGIN_TYPE: STORAGE ENGINE
PLUGIN_TYPE_VERSION: 80025.0
PLUGIN_LIBRARY: NULL
PLUGIN_LIBRARY_VERSION: NULL
PLUGIN_AUTHOR: Oracle Corporation
PLUGIN_DESCRIPTION: Supports transactions, row-level locking, and foreign keys
PLUGIN_LICENSE: GPL
LOAD_OPTION: FORCE
*************************** 3. row ***************************
PLUGIN_NAME: Rewriter
PLUGIN_VERSION: 0.2
PLUGIN_STATUS: ACTIVE
PLUGIN_TYPE: AUDIT
PLUGIN_TYPE_VERSION: 4.1
PLUGIN_LIBRARY: rewriter.so
PLUGIN_LIBRARY_VERSION: 1.10
PLUGIN_AUTHOR: Oracle Corporation
PLUGIN_DESCRIPTION: A query rewrite plugin that rewrites queries using the parse tree.
PLUGIN_LICENSE: GPL
LOAD_OPTION: ON
3 rows in set (0,01 sec)
对于存储引擎,如果查询表 INFORMATION_SCHEMA.ENGINES 或运行命令 SHOW ENGINES,可以获取更多详细信息。以下是 InnoDB 存储引擎的表内容:
mysql> `SELECT` `*` `FROM` `INFORMATION_SCHEMA``.``ENGINES` `WHERE` `ENGINE` `=` `'InnoDB'``\``G`
*************************** 1. row ***************************
ENGINE: InnoDB
SUPPORT: DEFAULT
COMMENT: Supports transactions, row-level locking, and foreign keys
TRANSACTIONS: YES
XA: YES
SAVEPOINTS: YES
1 row in set (0,00 sec)
12.13 列出字符集和排序规则
问题
排序顺序定义了哪些字母是相等的,但对您不起作用,您想了解您还有哪些其他选项。
解决方案
通过查询表INFORMATION_SCHEMA.CHARACTER_SETS和INFORMATION_SCHEMA.COLLATIONS,获取字符集的列表,它们的默认排序和可用排序。
讨论
在 Recipe 7.5 中,我们讨论了如何更改或设置字符串的字符集和排序。但是如何选择最适合您应用程序要求的排序呢?
幸运的是,MySQL 本身可以帮助您找到答案。在 MySQL 客户端内部,从INFORMATION_SCHEMA.CHARACTER_SETS表中选择以获取所有可用字符集、它们的默认排序和它们可以存储的最大字符长度的列表。例如,要列出所有 Unicode 字符集,请运行以下查询:
mysql> `SELECT` `*` `FROM` `INFORMATION_SCHEMA``.``CHARACTER_SETS`
-> `WHERE` `DESCRIPTION` `LIKE` `'%Unicode%'` `ORDER` `BY` `MAXLEN` `DESC``;`
+--------------------+----------------------+------------------+--------+ | CHARACTER_SET_NAME | DEFAULT_COLLATE_NAME | DESCRIPTION | MAXLEN |
+--------------------+----------------------+------------------+--------+ | utf16 | utf16_general_ci | UTF-16 Unicode | 4 |
| utf16le | utf16le_general_ci | UTF-16LE Unicode | 4 |
| utf32 | utf32_general_ci | UTF-32 Unicode | 4 |
| utf8mb4 | utf8mb4_0900_ai_ci | UTF-8 Unicode | 4 |
| utf8 | utf8_general_ci | UTF-8 Unicode | 3 |
| ucs2 | ucs2_general_ci | UCS-2 Unicode | 2 |
+--------------------+----------------------+------------------+--------+ 6 rows in set (0,00 sec)
每个字符集不仅可能有默认排序,还可能有其他排序,允许您调整排序顺序。例如,土耳其大写字母İ和İ,以及Ş被认为是与默认排序的utf8mb4
字符集相等。这导致 MySQL 认为土耳其单词ISSIZ(荒凉的)和İŞSİZ(失业的)是相同的:
mysql> `CREATE` `TABLE` `two_words``(``deserted` `VARCHAR``(``100``)``,` `unemployed` `VARCHAR``(``100``)``)``;`
Query OK, 0 rows affected (0,03 sec)
mysql> `INSERT` `INTO` `two_words` `VALUES``(``'ISSIZ'``,` `'İŞSİZ'``)``;`
Query OK, 1 row affected (0,00 sec)
mysql> `SELECT` `deserted``=``unemployed` `FROM` `two_words``;`
+---------------------+ | deserted=unemployed |
+---------------------+ | 1 |
+---------------------+ 1 row in set (0,00 sec)
要解决此问题,请检查表INFORMATION_SCHEMA.COLLATIONS中utf8mb4字符集适用于土耳其语的排序。
mysql> `SELECT` `COLLATION_NAME``,` `CHARACTER_SET_NAME`
-> `FROM` `INFORMATION_SCHEMA``.``COLLATIONS`
-> `WHERE` `CHARACTER_SET_NAME``=``'utf8mb4'` `AND` `COLLATION_NAME` `LIKE` `'%\_tr\_%'``;`
+-----------------------+--------------------+ | COLLATION_NAME | CHARACTER_SET_NAME |
+-----------------------+--------------------+ | utf8mb4_tr_0900_ai_ci | utf8mb4 |
| utf8mb4_tr_0900_as_cs | utf8mb4 |
+-----------------------+--------------------+ 2 rows in set (0,00 sec)
如果我们尝试它们,我们将收到正确的结果:单词deserted和unemployed不再被认为是相同的:
mysql> `SELECT` `deserted``=``unemployed` `COLLATE` `utf8mb4_tr_0900_ai_ci` `FROM` `two_words``;`
+---------------------------------------------------+ | deserted=unemployed COLLATE utf8mb4_tr_0900_ai_ci |
+---------------------------------------------------+ | 0 |
+---------------------------------------------------+ 1 row in set (0,00 sec)
mysql> `SELECT` `deserted``=``unemployed` `COLLATE` `utf8mb4_tr_0900_as_cs` `FROM` `two_words``;`
+---------------------------------------------------+ | deserted=unemployed COLLATE utf8mb4_tr_0900_as_cs |
+---------------------------------------------------+ | 0 |
+---------------------------------------------------+ 1 row in set (0,00 sec)
字符集utf8mb4是默认的,并且适用于大多数设置。但是,如果您存储俄语单词совершенный(完美的)和совершённый(完成的)在具有默认排序的utf8mb4列中,MySQL 将认为这两个单词相等:
mysql > `CREATE` `TABLE` `` ` ```two_words``` ` `` `(`
-> `` ` ```perfect``` ` `` `varchar``(``100``)` `DEFAULT` `NULL``,`
-> `` ` ```accomplished``` ` `` `varchar``(``100``)` `DEFAULT` `NULL`
-> `)` `ENGINE``=``InnoDB` `DEFAULT` `CHARSET``=``utf8mb4` `COLLATE``=``utf8mb4_0900_ai_ci``;`
Query OK, 0 rows affected (0,04 sec)
mysql> `INSERT` `INTO` `two_words` `VALUES``(``'совершенный'``,` `'совершённый'``)``;`
Query OK, 1 row affected (0,01 sec)
mysql> `SELECT` `perfect` `=` `accomplished` `FROM` `two_words``;`
+------------------------+ | perfect = accomplished |
+------------------------+ | 1 |
+------------------------+ 1 row in set (0,00 sec)
解决此问题的直观方法是使用俄语语言的可用排序:utf8mb4_ru_0900_ai_ci。不幸的是,这并不起作用:
mysql> `SELECT` `perfect` `=` `accomplished` `COLLATE` `utf8mb4_ru_0900_ai_ci` `FROM` `two_words``;`
+------------------------------------------------------+ | perfect = accomplished COLLATE utf8mb4_ru_0900_ai_ci |
+------------------------------------------------------+ | 1 |
+------------------------------------------------------+ 1 row in set (0,00 sec)
utf8mb4_ru_0900_ai_ci排序是不区分重音的原因。其区分大小写和重音的变体utf8mb4_ru_0900_as_cs解决了这个问题:
mysql> `SELECT` `perfect` `=` `accomplished` `COLLATE` `utf8mb4_ru_0900_as_cs` `FROM` `two_words``;`
+------------------------------------------------------+ | perfect = accomplished COLLATE utf8mb4_ru_0900_as_cs |
+------------------------------------------------------+ | 0 |
+------------------------------------------------------+ 1 row in set (0,00 sec)
版本 8.0 中添加了排序utf8mb4_ru_0900_ai_ci和utf8mb4_ru_0900_as_cs。如果您仍在使用版本 5.7,并且正在处理此类差异至关重要的应用程序,则还可以检查表INFORMATION_SCHEMA.CHARACTER_SETS,查找支持西里尔字母的字符集,并尝试使用它:
mysql> `SELECT` `*` `FROM` `INFORMATION_SCHEMA``.``CHARACTER_SETS`
-> `WHERE` `DESCRIPTION` `LIKE` `'%Russian%'` `OR` `DESCRIPTION` `LIKE` `'%Cyrillic%'``;`
+--------------------+----------------------+-----------------------+--------+ | CHARACTER_SET_NAME | DEFAULT_COLLATE_NAME | DESCRIPTION | MAXLEN |
+--------------------+----------------------+-----------------------+--------+ | koi8r | koi8r_general_ci | KOI8-R Relcom Russian | 1 |
| cp866 | cp866_general_ci | DOS Russian | 1 |
| cp1251 | cp1251_general_ci | Windows Cyrillic | 1 |
+--------------------+----------------------+-----------------------+--------+ 3 rows in set (0,00 sec)
mysql> `drop` `table` `two_words``;`
Query OK, 0 rows affected (0,02 sec)
mysql> `CREATE` `TABLE` `two_words``(``perfect` `VARCHAR``(``100``)``,` `accomplished` `VARCHAR``(``100``)``)`
-> `CHARACTER` `SET` `cp1251``;`
Query OK, 0 rows affected (0,04 sec)
mysql> `INSERT` `INTO` `two_words` `VALUES``(``'совершенный'``,` `'совершённый'``)``;`
Query OK, 1 row affected (0,00 sec)
mysql> `SELECT` `perfect` `=` `accomplished` `FROM` `two_words``;`
+------------------------+ | perfect = accomplished |
+------------------------+ | 0 |
+------------------------+ 1 row in set (0,00 sec)
我们选择了字符集cp1251作为示例,但所有这些都解决了这个比较问题。
12.14 列出 CHECK 约束
问题
您想要查看数据库中定义了哪些CHECK约束。
解决方案
查询表INFORMATION_SCHEMA.CHECK_CONSTRAINTS和INFORMATION_SCHEMA.TABLE_CONSTRAINTS。
讨论
表 INFORMATION_SCHEMA.CHECK_CONSTRAINTS 包含所有约束条件的列表,以及定义它们的模式,以及实际约束定义的 CHECK_CLAUSE。然而,该表不存储约束是为哪个表创建的信息。要列出既包含约束条件又包含定义它们的表,请将表 INFORMATION_SCHEMA.CHECK_CONSTRAINTS 与表 INFORMATION_SCHEMA.TABLE_CONSTRAINTS 进行连接:
mysql> `SELECT` `TABLE_SCHEMA``,` `TABLE_NAME``,` `CONSTRAINT_NAME``,` `ENFORCED``,` `CHECK_CLAUSE`
-> `FROM` `INFORMATION_SCHEMA``.``CHECK_CONSTRAINTS`
-> `JOIN` `INFORMATION_SCHEMA``.``TABLE_CONSTRAINTS`
-> `USING``(``CONSTRAINT_NAME``)`
-> `WHERE` `CONSTRAINT_TYPE``=``'CHECK'` `ORDER` `BY` `CONSTRAINT_NAME` `DESC` `LIMIT` `2``\``G`
*************************** 1. row ***************************
TABLE_SCHEMA: cookbook
TABLE_NAME: even
CONSTRAINT_NAME: even_chk_1
ENFORCED: YES
CHECK_CLAUSE: ((`even_value` % 2) = 0)
*************************** 2. row ***************************
TABLE_SCHEMA: cookbook
TABLE_NAME: book_authors
CONSTRAINT_NAME: book_authors_chk_1
ENFORCED: YES
CHECK_CLAUSE: json_schema_valid(_utf8mb4\'{"id": ↩
"http://www.oreilly.com/mysqlcookbook", "$schema": ↩
"http://json-schema.org/draft-04/schema#", "description": ↩
"Schema for the table book_authors", "type": "object", "properties": ↩
{"name": {"type": "string"}, "lastname": {"type": "string"}, ↩
"books": {"type": "array"}}, "required":["name", "lastname"]} \',`author`)
2 rows in set (0,01 sec)
第十三章: 导入和导出数据
13.0 引言
假设名为somedata.csv的文件包含以逗号分隔的值(CSV)格式的 12 个数据列。 您希望从该文件中提取仅包含第 2、11、5 和 9 列,并将它们用于在包含name、birth、height和weight列的 MySQL 表中创建数据库行。 您必须确保身高和体重为正整数,并将出生日期从MM/DD/YY格式转换为YYYY-MM-DD格式。 您可以如何做到这一点?
在将数据传输到 MySQL 时经常会出现具有特定要求的数据传输问题。 数据文件并非总是经过格式化以便无需准备即可加载到 MySQL 中。 因此,通常需要预处理信息以使其符合 MySQL 可接受的格式。 反之亦然; 从 MySQL 导出的数据可能需要调整才能对其他程序有用。
虽然有些数据准备操作需要大量手动检查和重新格式化,但在大多数情况下,您至少可以自动完成部分工作。 几乎所有这些问题都涉及到一些公共转换问题的元素集。 本章和下一章讨论了这些问题是什么,如何通过利用您可以使用的现有工具来处理它们,以及在必要时如何编写您自己的工具。 思路不是覆盖所有可能的情况(这是不可能的任务),而是展示代表性的技术和实用工具。 您可以按原样使用它们或进行调整。(虽然有商业数据处理工具,但我们这里的目的是让您自己动手做。) 关于本引言开头提出的问题,请参阅食谱 14.18 以了解我们找到的解决方案。
讨论如何将数据传输到 MySQL 和从 MySQL 中传输数据始于本地 MySQL 用于导入数据的工具(LOAD DATA语句和mysqlimport命令行程序),以及用于导出数据的工具(SELECT … INTO OUTFILE语句)。 对于本地工具不足的情况,我们继续介绍使用外部支持工具(如sed和tr)以及编写自己的工具的技术。 有两组广泛的问题需要考虑:
-
如何操作数据文件的结构。 当文件格式不适合导入时,必须将其转换为不同的格式。 这可能涉及问题,如更改列分隔符或行结束序列,或删除或重新排列文件中的列。 本章介绍了这些技术。
-
如何操作数据文件的内容。 如果你不确定文件中的值是否合法,可能需要预处理以进行检查或重新格式化。 数值可能需要验证是否在特定范围内,日期可能需要转换为或从 ISO 格式进行转换,等等。 第十四章 讨论了这些技术。
本章讨论的程序片段和脚本的源代码位于recipes发行版的transfer目录中。
一般的导入和导出问题
不兼容的数据文件格式和不同的解释各种值的规则,在程序之间传输数据时会带来很多麻烦。尽管如此,某些问题经常重复出现。了解这些问题可以更轻松地确定解决特定导入或导出问题所需的步骤。
在其最基本的形式中,输入流只是一组没有特定含义的字节。成功导入到 MySQL 需要识别哪些字节代表结构信息,哪些字节代表由该结构框定的数据值。因为这种识别对将输入分解成适当单位至关重要,所以最基本的导入问题如下:
-
记录分隔符是什么?知道这个可以将输入流分成记录。
-
字段分隔符是什么?知道这个可以将每个记录分成字段值。识别数据值还可能包括去掉值周围的引号或识别其中的转义序列。
将输入分解为记录和字段的能力对从中提取数据值至关重要。如果值仍未以可直接使用的形式存在,则可能需要考虑其他问题:
-
列的顺序和数量是否与数据库表的结构匹配?不匹配可能需要重新排列或跳过列。
-
NULL或空值应该如何处理?它们允许存在吗?甚至可以检测到NULL值吗?(某些系统将NULL值导出为空字符串,这使得无法区分它们。) -
数据值是否需要验证或重新格式化?如果值的格式与 MySQL 的期望匹配,则无需进一步处理。否则,它们必须进行检查并可能需要重写。
对于从 MySQL 导出的情况,问题有些相反。您可以假设存储在数据库表中的值是有效的,但是需要添加列和记录分隔符,以形成其他程序可以识别的输出流,并且可能需要重新格式化以供其他程序使用。
文件格式
数据文件存在多种格式,其中两种在本章中频繁出现:
制表符分隔或制表符分隔值(TSV)格式
这是最简单的文件结构之一;行中包含由制表符分隔的值。一个简短的制表符分隔文件可能如下所示,其中列值之间的空格表示单个制表符字符:
a b c
a,b,c d e f
逗号分隔值(CSV)格式
以 CSV 格式编写的文件有所不同;似乎没有正式的标准描述这种格式。然而,一般的想法是,每行由逗号分隔的值组成,包含内部逗号的值用引号括起来,以防逗号被解释为值分隔符。同样,包含空格的值通常也会用引号括起来。在这个例子中,每行包含三个值:
a,b,c
"a,b,c","d e",f
处理 CSV 文件比制表符分隔文件更复杂,因为像引号和逗号这样的字符具有双重含义:它们可能代表文件结构,也可能包含在数据值的内容中。
另一个重要的数据文件特征是行尾序列。最常见的序列是回车(CR)、换行(LF)和回车/换行(CRLF)对。
数据文件通常以一行列标签开头。对于某些导入操作,必须丢弃标签行,以避免将其作为数据加载到您的表中。在其他情况下,标签非常有用:
-
对于导入到现有表中,如果数据文件列与表列的顺序不一致,标签有助于匹配它们。
-
当从数据文件自动或半自动创建新表时,这些标签可用作列名。例如,Recipe 13.20 讨论了一个工具,该工具检查数据文件并猜测用于从文件创建表的
CREATETABLE语句。如果存在标签行,则该工具使用这些标签作为列名。
关于调用 Shell 命令的注意事项
本章展示了许多程序,您可以通过像 bash 或 tcsh(Unix)或 cmd.exe(命令提示符
)(Windows)这样的 shell 从命令行调用。这些程序的示例命令中,很多选项值周围使用引号,有时选项值本身就是引号字符。引用约定因 shell 而异,但以下规则似乎适用于大多数 shell(包括 Windows 下的 cmd.exe):
-
对于包含空格的参数,用双引号括起来,以防 shell 将其解释为多个独立参数。Shell 去除引号并将参数完整传递给命令。
-
要在参数本身包含双引号字符,需在其前面加上反斜杠。
本章中的一些 shell 命令非常长,显示方式按照您输入它们时的多行格式,使用反斜杠字符作为行连续字符:
% *`prog_name`* \
*`argument1`* \
*`argument2 ...`*
这在 Unix 下有效。在 Windows 上,连续字符是 ^(或者对于 PowerShell 是 `)。或者,无论在任何平台上,都可以将整个命令输入到一行中:
C:\> *`prog_name argument1 argument2 ...`*
13.1 使用 LOAD DATA 和 mysqlimport 导入数据
问题
您想使用 MySQL 的内置导入功能将数据文件加载到表中。
解决方案
使用LOAD DATA语句或者 mysqlimport 命令行程序。
讨论
MySQL 提供了一个LOAD DATA语句,作为批量数据加载器。以下是一个示例语句,从当前目录(调用mysql客户端的目录)读取文件mytbl.txt并将其加载到默认数据库中的表mytbl中:
mysql> `LOAD DATA LOCAL INFILE 'mytbl.txt' INTO TABLE mytbl;`
警告
自 MySQL 8.0 起,默认情况下禁用了LOCAL加载功能,出于安全考虑。
要在测试服务器上启用它,请将变量local_infile设置为ON:
SET GLOBAL local_infile = 1;
并使用选项--local-infile启动mysql客户端:
mysql -ucbuser -p --local-infile
或者,从语句中省略LOCAL并指定文件的完整路径名,该文件必须可读取服务器。稍后将讨论本地与非本地数据加载的差异。
MySQL 实用程序mysqlimport充当了围绕LOAD DATA的包装器的角色,以便您可以直接从命令行加载输入文件。与前述LOAD DATA语句等效的mysqlimport命令如下,假设mytbl在cookbook数据库中:
$ `mysqlimport --local cookbook mytbl.txt`
对于mysqlimport,与其他 MySQL 程序一样,您可能需要指定连接参数选项,如--user或--host(参见 Recipe 1.4)。
LOAD DATA 提供了许多选项来解决章节介绍中提到的许多导入问题,例如用于识别如何将输入分割为记录的行结束序列,允许将记录分割为单独值的列值分隔符,可能围绕列值的引用字符,值内的引用和转义约定以及NULL值表示。
以下列表描述了LOAD DATA的一般特性和功能;mysqlimport共享大多数这些行为。我们会在进行时注意一些差异,但对于大多数情况,LOAD DATA所能做的与mysqlimport也可以做到。
-
默认情况下,
LOADDATA期望数据文件与加载数据的表具有相同数量的列,并且列的顺序与表中的列相同。如果文件的列数或顺序与表不同,您可以指定存在的列及其顺序。如果数据文件包含的列少于表中的列数,MySQL 会为缺少的列分配默认值。 -
LOADDATA假定数据值由制表符分隔,并且行以换行符(新行)结束。如果文件不符合这些约定,您可以明确指定其格式。 -
您可以指示数据值可能带有应该被剥离的引号,并且可以指定引号字符。
-
输入处理期间识别并转换多个特殊转义序列。默认的转义字符是反斜杠(
\),但您可以更改它。序列\N被解释为NULL值。序列\b、\n、\r、\t、\\和\0被解释为退格、换行、回车、制表符、反斜杠和 ASCII NUL 字符。(NUL 是一个零值字节;它与 SQL 中的NULL值不同。) -
LOADDATA提供关于哪些输入值导致问题的诊断信息。要显示此信息,请在LOADDATA语句之后执行SHOWWARNINGS语句。
本章及其后几篇配方描述了如何使用 LOAD DATA 或 mysqlimport 处理这些问题。由于需要涵盖的内容很多,因此篇幅较长。
指定数据文件的位置
您可以从发出 LOAD DATA 语句的服务器主机或客户端主机上加载文件。告知 MySQL 如何找到您的数据文件是了解它查找文件的规则的问题(对于不在当前目录中的文件特别重要)。
默认情况下,MySQL 服务器假定数据文件位于服务器主机上。您可以使用 LOAD DATA LOCAL 而不是 LOAD DATA 来加载位于客户端主机上的本地文件,除非默认情况下禁用了 LOCAL 能力。
注意
本章中的许多示例假定可以使用 LOCAL。如果您的系统不支持这一点,请调整示例:从语句中省略 LOCAL,确保文件位于 MySQL 服务器主机上并对服务器可读。
如果 LOAD DATA 语句中不包含 LOCAL 关键字,MySQL 服务器将根据以下规则在服务器主机上查找文件:
-
您的 MySQL 帐户必须具有
FILE权限,要加载的文件必须位于默认数据库的数据目录中或者对所有用户可读。 -
绝对路径名完全指定了文件在文件系统中的位置,服务器从指定位置读取它。
-
相对路径名根据其是否具有单个组件或多个组件两种方式进行解释。对于单个组件文件名(例如 mytbl.txt),服务器在默认数据库的数据库目录中查找文件。(如果未选择默认数据库,则操作失败。)对于多个组件文件名(例如 xyz/mytbl.txt),服务器从 MySQL 数据目录开始查找文件。也就是说,它期望在名为 xyz 的目录中找到 mytbl.txt。
-
如果选项
secure_file_priv设置为一个目录路径,MySQL 只能访问此目录中的导入和导出文件。如果使用secure_file_priv,请指定绝对路径。
数据库目录直接位于服务器数据目录下,因此如果默认数据库是 cookbook,这两个语句是等效的:
mysql> `LOAD DATA INFILE 'mytbl.txt' INTO TABLE mytbl;`
mysql> `LOAD DATA INFILE 'cookbook/mytbl.txt' INTO TABLE mytbl;`
如果 LOAD DATA 语句包括 LOCAL 关键字,则客户端程序将在客户端主机上读取文件并将其内容发送到服务器。客户端解释路径名的方式如下:
-
绝对路径名完全指定了文件在文件系统中的位置。
-
相对路径名指定文件位置相对于您指定 mysql 客户端的目录。
如果您的文件位于客户端主机上,但您忘记指示它是本地的,将会发生错误:
mysql> `LOAD DATA 'mytbl.txt' INTO TABLE mytbl;`
ERROR 1045 (28000): Access denied for user: '*`user_name`*@*`host_name`*'
(Using password: YES)
那个 Access denied 的消息可能会让人困惑:如果您能够连接到服务器并发出 LOAD DATA 语句,那么看起来您已经获得了对 MySQL 的访问权限,对吧?错误消息意味着服务器(而不是客户端)尝试在服务器主机上打开 mytbl.txt 并无法访问它。
如果您的 MySQL 服务器运行在发出 LOAD DATA 语句的主机上,则 <q>remote</q> 和 <q>local</q> 指的是同一主机。但是仍适用于定位数据文件的刚讨论的规则。没有 LOCAL,服务器直接读取数据文件。有 LOCAL,客户端程序读取文件并将其内容发送到服务器。
mysqlimport 使用与 LOAD DATA 相同的规则来查找文件。默认情况下,它假定数据文件位于服务器主机上。要指示文件位于客户端主机上,请在命令行上指定 --local(或 -L)选项。
LOAD DATA 假定表位于默认数据库中。要将文件加载到特定数据库中,请使用数据库名称限定表名。以下语句表示 mytbl 表位于 other_db 数据库中:
mysql> `LOAD DATA LOCAL 'mytbl.txt' INTO TABLE other_db.mytbl;`
mysqlimport 总是需要一个数据库参数:
$ `mysqlimport --local cookbook mytbl.txt`
LOAD DATA 假定数据文件的名称与加载文件内容的表的名称之间没有关系。mysqlimport 假定数据文件名的最后一个组成部分确定表名。例如,mysqlimport 将 mytbl、mytbl.dat、/home/paul/mytbl.csv 和 C:\projects\mytbl.txt 解释为包含 mytbl 表数据的文件。
13.2 指定列和行分隔符
问题
您的数据文件使用非标准的列或行分隔符。
解决方案
对于 LOAD DATA INFILE 语句,请使用 FIELDS TERMINATED BY 和 LINES TERMINATED BY 子句,对于 mysqlimport,使用 --fields-terminated-by 和 --lines-terminated-by 选项。
讨论
默认情况下,LOAD DATA假定数据文件的行以换行符(newline)结尾,行内的值由制表符分隔。要提供有关数据文件格式的明确信息,请使用FIELDS子句描述行内字段的特性,并使用LINES子句指定行结束序列。以下LOAD DATA语句指示输入文件包含以冒号分隔的数据值,并以回车符结尾:
mysql> `LOAD DATA LOCAL INFILE 'mytbl.txt' INTO TABLE mytbl`
-> `FIELDS TERMINATED BY ':' LINES TERMINATED BY '\r';`
每个子句都跟在表名之后。如果两者都存在,FIELDS必须在LINES之前。行和字段终止指示可以包含多个字符。例如,\r\n表示行以回车/换行对结尾。
LINES子句还有一个STARTING BY子句。它指定要从每个输入记录中剥离的序列。(从给定序列开始的所有内容都将被剥离。如果你指定STARTING BY 'X'并且记录以abcX开头,则所有四个前导字符都将被剥离。)像TERMINATED BY一样,该序列可以有多个字符。如果LINES子句中同时存在TERMINATED BY和STARTING BY,它们可以以任意顺序出现。
对于mysqlimport,命令选项提供了格式说明符。与前面两个LOAD DATA语句相对应的命令如下所示:
$ `mysqlimport --local cookbook mytbl.txt`
$ `mysqlimport --local --fields-terminated-by=":" --lines-terminated-by="\r" \`
`cookbook mytbl.txt`
对于mysqlimport,选项顺序并不重要。
FIELDS和LINES子句理解十六进制表示法来指定任意格式字符,这对于加载使用二进制格式代码的数据文件非常有用。假设数据文件中的行之间用 Ctrl-A 分隔字段,行末用 Ctrl-B 结束。Ctrl-A 和 Ctrl-B 的 ASCII 值分别为 1 和 2,因此你可以表示它们为0x01和0x02:
FIELDS TERMINATED BY 0x01 LINES TERMINATED BY 0x02
mysqlimport还能理解格式说明符的十六进制常量。如果你不喜欢在命令行上记住如何键入转义序列,或者在必须在其周围使用引号时,你可能会发现这种功能很有帮助。制表符是0x09,换行符是0x0a,回车符是0x0d。该命令指示数据文件包含以制表符分隔的行,以 CRLF 对结尾:
$ `mysqlimport --local --fields-terminated-by=0x09 \`
`--lines-terminated-by=0x0d0a cookbook mytbl.txt`
当你导入数据文件时,不要假设LOAD DATA(或mysqlimport)知道比它实际了解的更多。一些LOAD DATA的挫折是因为人们期望 MySQL 知道它可能不知道的东西。记住,LOAD DATA对于你的数据文件的格式一无所知。它对输入结构有一些假设,这些假设是基于行和字段终止符的默认设置,以及引号和转义字符的设置。如果你的输入与这些假设不同,你必须告诉 MySQL。
数据文件中使用的行结束序列通常由生成文件的系统决定。Unix 文件通常以换行符结尾,你可以这样指示:
LINES TERMINATED BY '\n'
因为 \n 恰好是默认的行终止符,因此在这种情况下,您不需要显式指定该子句,除非您希望显式指定行结束序列。如果您的系统上的文件不使用 Unix 默认(换行符)结尾,则必须显式指定行终止符。对于以回车符或回车符/换行符对结尾的文件,分别使用适当的 LINES TERMINATED BY 子句:
LINES TERMINATED BY '\r'
LINES TERMINATED BY '\r\n'
例如,要加载包含制表字段和以 CRLF 对结尾的 Windows 文件,请使用以下 LOAD DATA 语句:
mysql> `LOAD DATA LOCAL INFILE 'mytbl.txt' INTO TABLE mytbl`
-> `LINES TERMINATED BY '\r\n';`
相应的 mysqlimport 命令是:
$ `mysqlimport --local --lines-terminated-by="\r\n" cookbook mytbl.txt`
如果文件已从一台机器传输到另一台机器,则其内容可能以您不知道的微妙方式发生了更改。例如,如果在不同操作系统上运行的机器之间进行 FTP 传输,则通常将行尾转换为适合目标机器的行尾,前提是传输以文本模式而不是二进制(图像)模式执行。
如果不确定,可以使用十六进制转储程序或显示制表符、回车符和换行符等空白字符的其他实用程序来检查数据文件的内容。在 Unix 下,诸如 od 或 hexdump 的程序可以以各种格式显示文件内容。如果没有这些工具或类似的实用程序,recipes 分发目录中的 hexdump.pl、hexdump.rb 和 hexdump.py 是用 Perl、Ruby 和 Python 编写的十六进制转储程序,以及显示文件所有字符可打印表示的程序 (see.pl、see.rb 和 see.py) 可能对检查文件以查看其实际内容很有用。
13.3 处理引号和特殊字符
问题
由于您的数据文件包含引号或特殊字符,因此不能使用默认选项加载。
解决方案
使用 LOAD DATA INFILE 中的 FIELDS 子句,结合 TERMINATED BY、ECNLOSED BY 和 ESCAPED BY。对于 mysqlimport,使用选项 --fields-enclosed-by 和 --fields-escaped-by。
讨论
如果您的数据文件包含引号值或转义字符,请告知 LOAD DATA 以便它不会将未解释的数据值加载到数据库中。
FIELDS 子句除了 TERMINATED BY 外,还可以指定其他格式选项。默认情况下,LOAD DATA 假定值未引用,并且将反斜杠(\)解释为特殊字符的转义字符。要显式指示值引用字符,请使用 ENCLOSED BY;MySQL 在输入处理期间会去掉数据值两端的该字符。要更改默认的转义字符,请使用 ESCAPED BY。
可以按任意顺序使用子句 ENCLOSED BY、ESCAPED BY 和 TERMINATED BY。例如,这些 FIELDS 子句是等效的:
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
FIELDS ENCLOSED BY '"' TERMINATED BY ','
TERMINATED BY 值可以由多个字符组成。如果数据值在输入行内由 *@* 序列分隔,指定如下:
FIELDS TERMINATED BY '*@*'
要完全禁用转义处理,请指定空转义序列:
FIELDS ESCAPED BY ''
当您指定 ENCLOSED BY 表示应从数据值中剥离哪个引号字符时,可以通过将其加倍或在其前面加上转义字符的方式,直接在数据值中包含引号字符。例如,如果引号字符是 ",转义字符是 \,则输入值 "a""b\"c" 被解释为 a"b"c。
对于 mysqlimport,用于指定引号和转义值的相应命令选项是 --fields-enclosed-by 和 --fields-escaped-by。(当使用 mysqlimport 选项包含引号、反斜杠或其他对您的命令解释器特殊的字符时,您可能需要引用或转义引号或转义字符。)
13.4 处理重复键值
问题
您的数据文件中存在重复项,导入时出现错误。
解决方案
指示 LOAD DATA INFILE 和 mysqlimport 忽略或替换重复项。
讨论
默认情况下,如果输入记录在形成 PRIMARY KEY 或 UNIQUE 索引的列或列中复制现有行,则会出错。要控制此行为,请在文件名后指定 IGNORE 或 REPLACE,告诉 MySQL 要么忽略重复行,要么用新行替换旧行。
假设您定期从各种监测站接收当前天气条件的气象数据,并且您将这些站点的各种测量存储在看起来像这样的表中:
CREATE TABLE weatherdata
(
station INT UNSIGNED NOT NULL,
type ENUM('precip','temp','cloudiness','humidity','barometer') NOT NULL,
value FLOAT,
PRIMARY KEY (station, type)
);
表包括一个主键,组合是站点 ID 和测量类型,以确保每个站点每种测量类型只包含一行。该表旨在仅保存当前条件,因此当为给定站点加载新的测量值到表中时,它们应替换掉站点之前的测量值。为此,使用 REPLACE 关键字:
mysql> `LOAD DATA LOCAL INFILE 'data.txt' REPLACE INTO TABLE weatherdata;`
mysqlimport 具有与 LOAD DATA 中 IGNORE 和 REPLACE 关键字对应的 --ignore 和 --replace 选项。
13.5 关于坏输入数据的诊断获取
问题
您发现数据文件与导入到数据库中的数据之间存在差异,并希望了解为什么这些值导入失败。
解决方案
使用语句 SHOW WARNINGS。
讨论
LOAD DATA 显示一行信息,指示是否存在问题输入值。如果有问题,请使用 SHOW WARNINGS 查找其位置和问题内容。
当 LOAD DATA 语句完成时,它会返回一行信息,告诉您发生了多少错误或数据转换问题。例如:
Records: 134 Deleted: 0 Skipped: 2 Warnings: 13
这些值提供了关于导入操作的一般信息:
-
Records表示文件中找到的记录数。 -
Deleted和Skipped与处理输入记录相关,这些记录在唯一索引值上重复现有表行。Deleted指示从表中删除并由输入记录替换的行数,而Skipped指示忽略以 favor 存在的行数。 -
Warnings是一个总结,指示在加载数据值到列时发现的问题数量。如果一个值正确存储到列中,或者没有存储。在后一种情况下,该值以不同的方式存储在 MySQL 中,并且 MySQL 计为一个警告。(例如,将字符串abc存储到数值列中的存储值为0。)
这些值告诉您什么?Records 值通常应该与输入文件中的行数相匹配。如果不匹配,则表示 MySQL 解释文件格式与实际格式不同。在这种情况下,您可能还会看到一个较高的 Warnings 值,这表示许多值必须转换,因为它们与预期的数据类型不匹配。解决此问题的常见方法是指定适当的 FIELDS 和 LINES 子句。
假设您的 FIELDS 和 LINES 格式说明符是正确的,非零 Warnings 计数表示存在不良输入值。您无法从 LOAD DATA 信息行的数字中判断哪些输入记录存在问题或哪些列不良。要获取此信息,请发出 SHOW WARNINGS 语句。
假设表 t 有以下结构:
CREATE TABLE t
(
i INT,
c CHAR(3),
d DATE
);
并假设数据文件 data.txt 如下所示:
1 1 1
abc abc abc
2010-10-10 2010-10-10 2010-10-10
将文件加载到表中会导致每个三列加载一个数字、一个字符串和一个日期。这样做会产生多个数据转换和警告,您可以在 LOAD DATA 后立即使用 SHOW WARNINGS 查看这些警告:
mysql> `LOAD DATA LOCAL INFILE 'data.txt' INTO TABLE t;`
Query OK, 3 rows affected, 5 warnings (0.01 sec)
Records: 3 Deleted: 0 Skipped: 0 Warnings: 5
mysql> `SHOW WARNINGS;`
+---------+------+--------------------------------------------------------+
| Level | Code | Message |
+---------+------+--------------------------------------------------------+
| Warning | 1265 | Data truncated for column 'd' at row 1 |
| Warning | 1366 | Incorrect integer value: 'abc' for column 'i' at row 2 |
| Warning | 1265 | Data truncated for column 'd' at row 2 |
| Warning | 1265 | Data truncated for column 'i' at row 3 |
| Warning | 1265 | Data truncated for column 'c' at row 3 |
+---------+------+--------------------------------------------------------+
5 rows in set (0.00 sec)
SHOW WARNINGS 输出帮助您确定哪些值已转换及其原因。结果表如下所示:
mysql> `SELECT * FROM t;`
+------+------+------------+
| i | c | d |
+------+------+------------+
| 1 | 1 | 0000-00-00 |
| 0 | abc | 0000-00-00 |
| 2010 | 201 | 2010-10-10 |
+------+------+------------+
13.6 跳过数据文件行
问题
您希望从数据文件中跳过几行。
解决方案
对于 LOAD DATA INFILE 使用 IGNORE ... LINES 子句和对于 mysqlimport 使用 --ignore-lines 选项。
讨论
要跳过数据文件的前 n 行,请在 LOAD DATA 语句中添加一个 IGNORE n LINES 子句。例如,文件可能包含一个包含列标签的初始行。您可以像这样跳过它:
mysql> `LOAD DATA LOCAL INFILE 'mytbl.txt' INTO TABLE mytbl`
-> `IGNORE 1 LINES;`
mysqlimport 支持一个 --ignore-lines= n 选项,对应于 IGNORE n LINES。
13.7 指定输入列顺序
问题
数据文件和表中的列顺序不同,您需要为导入做出更改。
解决方案
导入时指定列的顺序。
讨论
LOAD DATA假设数据文件中的列与表中的列具有相同的顺序。如果不是这样,请指定一个列表来指示数据文件列应加载到哪些表列中。假设您的表具有列a、b和c,但数据文件的连续列对应于列b、c和a。像这样加载文件:
mysql> `LOAD DATA LOCAL INFILE 'mytbl.txt' INTO TABLE mytbl (b,c,a);`
mysqlimport有一个对应的--columns选项,用于指定列名列表:
$ `mysqlimport --local --columns=b,c,a cookbook mytbl.txt`
13.8 在插入之前预处理输入值
问题
数据文件中的值不能直接插入到数据库中。您需要在插入之前对它们进行修改。
解决方案
使用LOAD DATA INFILE和 MySQL 函数的SET子句来修改值。
讨论
LOAD DATA在插入之前可以对输入值进行有限的预处理,有时可以在加载到表之前将输入数据映射到更合适的值,这在值不适合加载到表中的情况下非常有用(例如,它们的单位不正确,或者两个输入字段必须组合并插入到单个列中)。
前一节展示了如何为LOAD DATA指定列名列表,以指示输入字段如何对应到表列。列名还可以命名用户定义变量,以便为每个输入记录将输入字段分配给变量。然后,您可以在一个SET子句中指定这些变量的计算,命名一个或多个col_name = expr赋值,用逗号分隔。
假设数据文件具有以下列,并且第一行提供列标签:
Date Time Name Weight State
2006-09-01 12:00:00 Bill Wills 200 Nevada
2006-09-02 09:00:00 Jeff Deft 150 Oklahoma
2006-09-04 03:00:00 Bob Hobbs 225 Utah
2006-09-07 08:00:00 Hank Banks 175 Texas
还假设文件将加载到具有以下列的表中:
CREATE TABLE t
(
dt DATETIME,
last_name CHAR(10),
first_name CHAR(10),
weight_kg FLOAT,
st_abbrev CHAR(2)
);
要导入文件,您必须解决其字段与表列之间的几个不匹配:
-
文件包含单独的日期和时间字段,必须将它们组合成日期时间值,以便插入到
DATETIME列中。 -
文件包含一个名字字段,必须将其拆分为单独的名和姓值,以便插入到
first_name和last_name列中。 -
文件包含一个以磅为单位的重量,必须将其转换为公斤,以便插入到
weight_kg列中(1 磅等于 0.454 公斤)。 -
文件包含州名,但表中包含两个字母的缩写。可以通过在
states表中进行查找来将名称映射到缩写。
要处理这些转换,请跳过包含列标签的第一行,将每个输入列分配给用户定义的变量,并编写一个SET子句来执行计算:
mysql> `LOAD DATA LOCAL INFILE 'data.txt' INTO TABLE t`
-> `IGNORE 1 LINES`
-> `(@date,@time,@name,@weight_lb,@state)`
-> `SET dt = CONCAT(@date,' ',@time),`
-> `first_name = SUBSTRING_INDEX(@name,' ',1),`
-> `last_name = SUBSTRING_INDEX(@name,' ',-1),`
-> `weight_kg = @weight_lb * .454,`
-> `st_abbrev = (SELECT abbrev FROM states WHERE name = @state);`
导入操作后,表中包含以下行:
mysql> `SELECT * FROM t;`
+---------------------+-----------+------------+-----------+-----------+
| dt | last_name | first_name | weight_kg | st_abbrev |
+---------------------+-----------+------------+-----------+-----------+
| 2006-09-01 12:00:00 | Wills | Bill | 90.8 | NV |
| 2006-09-02 09:00:00 | Deft | Jeff | 68.1 | OK |
| 2006-09-04 03:00:00 | Hobbs | Bob | 102.15 | UT |
| 2006-09-07 08:00:00 | Banks | Hank | 79.45 | TX |
+---------------------+-----------+------------+-----------+-----------+
LOAD DATA可以执行数据值重新格式化,就像刚才展示的那样。其他显示此功能用法的示例在其他地方也有出现。(例如,Recipe 13.12 将其用于映射NULL值,而 Recipe 14.16 在数据导入期间将非 ISO 日期重写为 ISO 格式。)然而,虽然LOAD DATA可以将输入值映射到其他值,但它不能完全拒绝包含不合适值的输入记录。要做到这一点,要么预处理输入文件以删除这些记录,要么在加载文件后发出DELETE语句。
13.9 忽略数据文件列
问题
你的数据文件包含额外的字段,不应添加到数据库中。
解决方案
在导入数据时指定列顺序。在需要忽略的列位置指定用户定义变量。
讨论
如果一行包含比表中列数更多的列,LOAD DATA会忽略它们(尽管它可能会生成非零的警告计数)。
在行中跳过中间的列涉及到更多步骤。为了处理这个问题,使用一个包含LOAD DATA指定要忽略列的列列表,并将它们分配给一个虚拟用户定义变量。假设你要加载 Unix 密码文件/etc/passwd中的信息,该文件包含以下格式的行:
account:password:UID:GID:GECOS:directory:shell
还假设你不想加载密码和目录列。用来保存剩余列信息的表如下所示:
CREATE TABLE passwd
(
account CHAR(8), # login name
uid INT, # user ID
gid INT, # group ID
gecos CHAR(60), # name, phone, office, etc.
shell CHAR(60), # command interpreter
PRIMARY KEY(account)
);
要加载文件,请指定列分隔符为冒号。还要告诉LOAD DATA跳过包含密码和目录的第二和第六字段。为此,在语句中添加一个列列表。列表应包括每个要加载到表中的列的名称,并且对于要忽略的列使用一个虚拟用户定义变量(你可以对所有这些列使用相同的变量)。生成的语句如下所示:
mysql> `LOAD DATA LOCAL INFILE '/etc/passwd' INTO TABLE passwd`
-> `FIELDS TERMINATED BY ':'`
-> `(account,@dummy,uid,gid,gecos,@dummy,shell);`
相应的mysqlimport命令包括--columns选项:
$ `mysqlimport --local \`
`--columns="account,@dummy,uid,gid,gecos,@dummy,shell" \`
`--fields-terminated-by=":" cookbook /etc/passwd`
参见
忽略列的另一种方法是预处理输入文件以删除列。实用程序yank_col.pl包含在配方分发中,可以按任意顺序提取和显示数据文件列。
13.10 导入 CSV 文件
问题
你想要加载一个 CSV 格式的文件。
解决方案
使用适当的格式指示符与LOAD DATA或mysqlimport。
讨论
CSV 格式的数据文件包含由逗号分隔而不是制表符的值,并且可能用双引号括起来。一个包含以回车/换行对结束的行的 CSV 文件mytbl.txt可以使用LOAD DATA加载到mytbl中:
mysql> `LOAD DATA LOCAL INFILE 'mytbl.txt' INTO TABLE mytbl`
-> `FIELDS TERMINATED BY ',' ENCLOSED BY '"'`
-> `LINES TERMINATED BY '\r\n';`
或者像这样使用mysqlimport:
$ `mysqlimport --local --lines-terminated-by="\r\n" \`
`--fields-terminated-by="," --fields-enclosed-by="\"" \`
`cookbook mytbl.txt`
13.11 从 MySQL 导出查询结果
问题
你想要将 MySQL 查询的结果导出到一个文件或另一个程序。
解决方案
使用SELECT … INTO OUTFILE语句,或者重定向mysql程序的输出。
讨论
SELECT … INTO OUTFILE 语句直接将查询结果导出到服务器主机上的文件中。要在客户端主机上捕获结果,可以重定向 mysql 程序的输出。这两种方法各有优劣;请了解它们并根据具体情况选择合适的方法。
使用 SELECT ... INTO OUTFILE 语句导出
SELECT ... INTO OUTFILE 语句的语法将普通的 SELECT 与 INTO OUTFILE file_name 结合在一起。默认输出格式与 LOAD DATA 相同,因此以下语句将 passwd 表导出为一个制表符分隔、换行符结束的文件 /tmp/passwd.txt:
mysql> `SELECT * FROM passwd INTO OUTFILE '/tmp/passwd.txt';`
要更改输出格式,请使用类似于 LOAD DATA 使用的选项,指示如何引用和分隔列和记录。例如,要以 CSV 格式导出先前在 Recipe 13.1 中创建的 passwd 表,并以 CRLF 结束行,请使用此语句:
mysql> `SELECT * FROM passwd INTO OUTFILE '/tmp/passwd.txt'`
-> `FIELDS TERMINATED BY ',' ENCLOSED BY '"'`
-> `LINES TERMINATED BY '\r\n';`
SELECT … INTO OUTFILE 具有以下特性:
-
输出文件由 MySQL 服务器直接创建,因此文件名应指示在服务器主机上写入文件的位置。文件位置的确定使用与未使用
LOCAL的LOADDATA相同的规则,如 Recipe 13.1 中所述。(该语句没有类似于LOADDATA的LOCAL版本。) -
执行
SELECT…INTOOUTFILE语句必须具有 MySQL 的FILE特权。 -
输出文件不能已经存在。(这可以防止 MySQL 覆盖可能很重要的文件。)
-
您应该在服务器主机上拥有登录帐户或某种方式访问该主机上的文件。如果您无法检索输出文件,
SELECT…INTOOUTFILE对您毫无价值。 -
在 Unix 环境下,在 MySQL 8.0.17 之前,该文件是全局可读的,并由用于运行 MySQL 服务器的帐户拥有。这意味着虽然您可以读取文件,但除非您可以使用该帐户登录,否则可能无法删除它。从 MySQL 8.0.17 开始,文件是全局可写的。
-
如果设置了选项
secure_file_priv,则只能导出到指定目录。
使用 mysql 客户端程序导出
因为 SELECT … INTO OUTFILE 在服务器主机上写入数据文件,所以除非您的 MySQL 帐户具有 FILE 特权,否则无法使用它。要将数据导出到您自己拥有的本地文件,请使用其他策略。如果您只需要制表符分隔的输出,请通过使用 mysql 程序执行 SELECT 语句并将输出重定向到文件来进行“穷人的导出”。这样,您可以将查询结果写入到本地主机上的文件,而无需 FILE 特权。以下是一个示例,导出 passwd 表中的登录名和命令解释器列:
$ `mysql -e "SELECT account, shell FROM passwd" --skip-column-names \`
`cookbook > shells.txt`
-e选项指定要执行的语句(参见 Recipe 1.5),--skip-column-names告诉 MySQL 不要写入通常在语句输出之前的列名行(参见 Recipe 1.7)。运算符>指示mysql将输出重定向到文件中。否则结果将被打印到屏幕上。
注意,MySQL 将NULL值写为字符串 NULL
。根据输出文件的处理方式,可能需要进行一些后处理来转换它们。我们在 Recipe 13.12 中讨论如何在导出和导入过程中处理NULL值。
可以通过将查询结果发送到后处理过滤器来生成除了制表符分隔之外的其他格式的输出。例如,要使用井号作为分隔符,将所有制表符转换为#字符(TAB指示输入命令时输入制表符字符的位置):
$ `mysql --skip-column-names -e "`*`your statement here`*`"` *`db_name`* `\`
`| sed -e "s/`*`TAB`*`/#/g" >` *`output_file`*
您也可以为此目的使用tr,尽管该实用程序的语法在不同实现中有所不同。对于 Mac OS X 或 Linux,命令如下所示:
$ `mysql --skip-column-names -e "`*`your statement here`*`"` *`db_name`* `\`
`| tr "\t" "#" >` *`output_file`*
刚才展示的mysql命令使用--skip-column-names来防止输出中出现列标签。在某些情况下,包含这些标签可能会有用(例如,在稍后导入文件时)。在这方面,使用mysql导出查询结果比SELECT … INTO OUTFILE更灵活,因为后者无法生成包含列标签的输出。
另一种将查询结果导出到客户端主机上的文件的方法是使用配方发布中提供的mysql_to_text.pl实用程序。该程序具有选项,使您能够明确指定输出格式。要将查询结果导出为 Excel 电子表格或 XML 文档,请使用mysql_to_excel.pl和mysql_to_xml.pl实用程序。
13.12 导入和导出 NULL 值
问题
您需要在数据文件中表示NULL值。
解决方案
使用一个在其他情况下不存在的值,这样你就可以区分NULL和所有其他合法的非NULL值。当你导入文件时,将该值的实例转换为NULL。
讨论
数据文件中没有关于表示NULL值的标准,这使得它们在导入和导出操作中变得棘手。困难之处在于NULL表示值的缺失,而这在数据文件中很难直接表示。使用空列值是最明显的做法,但对于字符串值列来说存在歧义,因为无法区分以这种方式表示的NULL和真正的空字符串。对于其他数据类型,空值也可能是个问题。例如,如果将空值加载到数值列中,则会将其存储为0而不是NULL,因此在输入中它变得无法与真正的0区分开。
此问题的通常解决方案是使用数据中未包含的值来表示NULL。这就是LOAD DATA和mysqlimport处理此问题的方式:它们按约定理解\N的值表示NULL。(只有当\N单独出现时,例如x\N或\Nx,\N才会被解释为NULL。)例如,如果使用LOAD DATA加载以下数据文件,则它将\N的实例视为NULL:
str1 13 1997-10-14
str2 \N 2009-05-07
\N 15 \N
\N \N 1973-07-14
但是您可能希望将\N以外的值解释为表示NULL,并且可能在不同列中有不同的约定。考虑以下数据文件:
str1 13 1997-10-14
str2 -1 2009-05-07
Unknown 15
Unknown -1 1973-07-15
第一列包含字符串,Unknown表示NULL。第二列包含整数,-1表示NULL。第三列包含日期,空值表示NULL。该怎么办?
要处理这种情况,请使用LOAD DATA的输入预处理功能:指定一个列列表,将输入值分配给用户定义的变量,并使用SET子句将特殊值映射到真正的NULL值。如果数据文件命名为has_nulls.txt,则以下LOAD DATA语句可以正确解释其内容:
mysql> `LOAD DATA LOCAL INFILE 'has_nulls.txt'`
-> `INTO TABLE t (@c1,@c2,@c3)`
-> `SET c1 = IF(@c1='Unknown',NULL,@c1),`
-> `c2 = IF(@c2=-1,NULL,@c2),`
-> `c3 = IF(@c3='',NULL,@c3);`
导入后的数据如下所示:
+------+------+------------+
| c1 | c2 | c3 |
+------+------+------------+
| str1 | 13 | 1997-10-14 |
| str2 | NULL | 2009-05-07 |
| NULL | 15 | NULL |
| NULL | NULL | 1973-07-15 |
+------+------+------------+
前述讨论涉及将NULL值解释为导入到 MySQL 中,但在将数据从 MySQL 传输到其他程序时,也需要考虑NULL值。以下是一些示例:
-
SELECT…INTO OUTFILE将NULL值写入为\N。其他程序是否能理解这种约定?如果不能,请将\N转换为程序理解的内容。例如,SELECT语句可以使用以下表达式导出列:IFNULL(*`col_name`*,'Unknown') -
您可以使用mysql的批处理模式来生成制表符分隔的输出(参见Recipe 13.11),但是
NULL值会作为单词NULL
的实例出现在输出中。如果在输出中该单词没有其他出现,您可以尝试后处理它以将其实例转换为更合适的内容。例如,您可以使用一行sed命令:$ `sed -e "s/NULL/\\N/g" data.txt > tmp`如果单词
NULL
出现在表示非NULL值的情况下,它是模棱两可的,您应该考虑以不同方式导出数据。例如,使用IFNULL()将NULL值映射为其他内容。
13.13 SQL 格式导出数据
问题
您希望以 SQL 格式导出数据。
解决方案
使用mysqldump或mysqlpump。
讨论
SQL 格式广泛用于导出和导入数据。它具有诸如可以在 MySQL 客户端中执行的优势,正如我们在 Recipe 1.6 和 Recipe 13.14 中讨论的那样。SQL 文件还可以包含特殊信息,例如复制源位置(Recipe 3.3)、默认字符集和其他信息。SQL 文件可以包含服务器上所有表格、触发器、事件和存储过程的数据,因此您可以使用它们来复制您的 MySQL 安装。
自 MySQL 分发的最早版本就包含了工具mysqldump,它允许将数据导出(转储)到 SQL 文件中。mysqldump使用非常简单。例如,要转储所有数据库,使用--all-databases选项运行它:
$ `mysqldump --all-databases > all-databases.sql`
要复制数据库cookbook中的所有表格,请将其名称作为mysqldump的参数:
$ `mysqldump cookbook > cookbook.sql`
若要仅导出数据库cookbook中的几张表,请在数据库名后指定它们的名称。因此,要复制limbs和patients表,运行以下命令:
$ `mysqldump cookbook limbs patients > limbs_patients.sql`
Shell 命令>将mysqldump的输出重定向到文件中。您还可以指定--result-file选项,指示mysqldump将结果存储在指定的文件中。
结果文件将包含 SQL 指令,允许重新创建数据库及其表格,并填充数据。
通常 MySQL 在高并发环境中工作。因此,mysqldump支持以下选项以确保备份文件的一致性:
--lock-all-tables
使用读锁在所有数据库中锁定所有表格,防止在完成转储之前对任何表格进行写入。
--lock-tables
分别为每个转储的数据库锁定所有表格。此保护仅阻止写入正在导出的数据库,但不能保证多数据库备份的结果一致性。
--single-transaction
在转储之前启动一个事务。该选项不会阻止任何写操作,但仍保证备份的一致性。这是备份使用事务存储引擎表格的推荐选项。
小贴士
由于保证一致性可能影响高并发写入的性能,建议在只读复制品上运行mysqldump。
mysqldump是一个成熟的工具,但它在单线程中导出数据。这可能不符合我们现在的性能期望。因此,自版本 5.7 起,MySQL 分发包括了另一个备份工具:mysqlpump。
mysqlpump类似于mysqldump。您可以使用与mysqldump相同的选项来导出所有数据库、单个数据库或只是几张表。但mysqlpump还支持并行处理以加快转储过程、进度指示器、更智能地导出用户账户、过滤器等功能,这些是mysqldump所不具备的。
因此,要在四个线程中创建整个 MySQL 实例的转储,使用--single-transaction选项保护转储,并查看进度条,请运行以下命令:
$ `mysqlpump --default-parallelism=4 --single-transaction \`
> `--watch-progress > all-databases.sql`
Dump progress: 1/2 tables, 0/7 rows
Dump progress: 142/143 tables, 2574113/4076473 rows
Dump completed in 1837
注意
mysqlpump 支持选项 --single-transaction,但不支持 --lock-all-tables 和 --lock-tables。它有选项 --add-locks,该选项会在每个转储表周围添加 LOCK TABLES 和 UNLOCK TABLES 语句。
另请参阅
关于 mysqldump 的更多信息,请参见 mysqldump — A Database Backup Program,关于 mysqlpump 的更多信息,请参见 mysqlpump — A Database Backup Program 在 MySQL 用户参考手册中。
13.14 导入 SQL 数据
问题
你有一个 SQL 转储文件并希望导入它。
解决方案
使用 mysql 客户端或 MySQL Shell 处理文件。
讨论
SQL 转储只是一个包含 SQL 命令的文件。因此,你可以像我们在 Recipe 1.6 中讨论的那样使用 mysql 客户端读取它。
$ `mysql -ucbuser -p cookbook < cookbook.sql`
MySQL Shell 在 SQL 模式下支持类似的功能。
要从命令行加载转储,请为 mysqlsh 客户端指定选项 --sql 并将输入重定向到其中。
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook --sql < all-databases.sql`
要在交互会话中加载转储,请切换到 SQL 模式并使用命令 \source 或其快捷方式 .。
MySQL cookbook SQL > `\source cookbook.sql`
13.15 将查询结果导出为 XML
问题
你想将查询结果导出为 XML 文档。
解决方案
使用 mysql 客户端或 mysqldump 并选择选项 --xml。
讨论
mysql 客户端可以从查询结果生成 XML 格式输出(参见 Recipe 1.7)。
假设名为 expt 的表包含实验中的测试分数:
mysql> `SELECT * FROM expt;`
+---------+------+-------+
| subject | test | score |
+---------+------+-------+
| Jane | A | 47 |
| Jane | B | 50 |
| Jane | C | NULL |
| Jane | D | NULL |
| Marvin | A | 52 |
| Marvin | B | 45 |
| Marvin | C | 53 |
| Marvin | D | NULL |
+---------+------+-------+
运行 mysql 客户端并选择选项 --xml:
$ `mysql --xml cookbook -e "SELECT * FROM expt;" < expt.xml`
生成的 XML 文档 expt.xml 如下所示:
<?xml version="1.0"?>
<resultset statement="SELECT * FROM expt"↩
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<row>
<field name="subject">Jane</field>
<field name="test">A</field>
<field name="score">47</field>
</row>
…
<row>
<field name="subject">Marvin</field>
<field name="test">D</field>
<field name="score" xsi:nil="true" />
</row>
</resultset>
要使用 mysqldump 以选项 --xml 导出类似的输出。生成的文件将包含表定义,除非你指定选项 --no-create-info:
$ `mysqldump --xml cookbook expt`
<?xml version="1.0"?>
<mysqldump >
SET @MYSQLDUMP_TEMP_LOG_BIN = @@SESSION.SQL_LOG_BIN;
SET @@SESSION.SQL_LOG_BIN= 0;
SET @@GLOBAL.GTID_PURGED=/*!80000 '+'*/ '910c760a-0751-11eb-9da8-0242dc638c6c:1-385,
9113f6b1-0751-11eb-9e7d-0242dc638c6c:1-385,
abf2d315-fb9a-11ea-9815-02421e8c78f1:1-52911';
<database name="cookbook">
<table_structure name="expt">
<field Field="subject" Type="varchar(10)"↩
Null="YES" Key="" Extra="" Comment="" />
<field Field="test" Type="varchar(5)"↩
Null="YES" Key="" Extra="" Comment="" />
<field Field="score" Type="int"↩
Null="YES" Key="" Extra="" Comment="" />
<options Name="expt" Engine="InnoDB" Version="10" Row_format="Dynamic"↩
Rows="8" Avg_row_length="2048" Data_length="16384" Max_data_length="0"↩
Index_length="0" Data_free="0" Create_time="2022-02-06 13:06:35"↩
Update_time="2022-02-06 13:06:35" Collation="utf8mb4_0900_ai_ci"↩
Create_options="" Comment="" />
</table_structure>
<table_data name="expt">
<row>
<field name="subject">Jane</field>
<field name="test">A</field>
<field name="score">47</field>
</row>
...
<row>
<field name="subject">Marvin</field>
<field name="test">D</field>
<field name="score" xsi:nil="true" />
</row>
</table_data>
</database>
SET @@SESSION.SQL_LOG_BIN = @MYSQLDUMP_TEMP_LOG_BIN;
13.16 将 XML 导入 MySQL
问题
你想将 XML 文档导入到 MySQL 表中。
解决方案
使用 LOAD XML 语句。
讨论
导入 XML 文档取决于能够解析文档并从中提取记录内容。如何执行此操作取决于文档的编写方式。要读取由 mysql 客户端创建的 XML 文件,请使用 LOAD XML 语句。
要加载我们在 Recipe 13.15 中创建的文件 expt.xml,请运行以下命令:
LOAD XML LOCAL INFILE 'expt.xml' INTO TABLE expt;
LOAD XML 语句会自动识别下面显示的三种不同 XML 格式。
列名作为属性,列值作为属性值
<row subject="Jane" test="A" score=47 />
列名作为标签,列值作为标签值。
<row>
<subject>Jane</subject>
<test>B</test>
<score>50</score>
</row>
列名作为标签 field 的属性 name 的值,列值作为它们的值。
<row>
<field name="subject">Jane</field>
<field name="test">C</field>
<field name="score" xsi:nil="true" />
</row>
这与 mysql、mysqldump 和其他 MySQL 工具使用的相同格式。
如果你的 XML 文件使用不同的标签名,请使用 ROWS IDENTIFIED BY 子句指定它。例如,如果表 expt 的行定义如下:
<test>
<field name="subject">Jane</field>
<field name="test">D</field>
<field name="score" xsi:nil="true" />
</test>
使用以下语句加载它们:
LOAD XML LOCAL INFILE 'expt.xml' INTO TABLE expt ROWS IDENTIFIED BY '<test>';
13.17 使用 JSON 格式导入数据
问题
你有一个 JSON 文件并希望将其导入到 MySQL 数据库。
解决方案
使用 MySQL Shell 实用程序 importJson。
讨论
JSON 是存储数据的流行格式。您可以让应用程序准备好它,或者希望从 MongoDB 导入数据。
实用程序 importJson 接受 JSON 文件的路径和选项字典作为参数。您可以将 JSON 导入到集合中,也可以导入到表中。在后一种情况下,除非默认值 doc 对您有效,否则需要指定要存储文档的 tableColumn。
文档应包含以新行分隔的 JSON 对象列表。此列表不应是 JSON 数组或其他对象的成员。
{"arms": 2, "legs": 2, "thing": "human" }
{"arms": 0, "legs": 6, "thing": "insect" }
{"arms": 10, "legs": 0, "thing": "squid" }
{"arms": 0, "legs": 0, "thing": "fish" }
{"arms": 0, "legs": 99, "thing": "centipede" }
{"arms": 0, "legs": 4, "thing": "table" }
{"arms": 2, "legs": 4, "thing": "armchair" }
{"arms": 1, "legs": 0, "thing": "phonograph" }
{"arms": 0, "legs": 3, "thing": "tripod" }
{"arms": 2, "legs": 1, "thing": "Peg Leg Pete" }
{"arms": null, "legs": null, "thing": "space alien" }
您将在 recipes 发行版的 collections/limbs.json 文件中找到集合 CollectionLimbs 的 JSON 转储。
要将 JSON 文件中的数据插入到 CollectionLimbs 集合中,请运行以下代码。
MySQL cookbook JS > `options = {`
-> `schema: "cookbook",`
-> `collection: "CollectionLimbs"`
-> `}`
->
{
"collection": "CollectionLimbs",
"schema": "cookbook"
}
MySQL cookbook JS > `util.importJson("limbs.json", options)`
Importing from file "limbscol.json" to collection `cookbook`.`CollectionLimbs` ↩
in MySQL Server at 127.0.0.1:33060
.. 11.. 11
Processed 1.42 KB in 11 documents in 0.0070 sec (11.00 documents/s)
Total successfully imported documents 11 (11.00 documents/s)
首先,创建一个带有选项的字典对象。最少需要指定集合名称和模式。
然后使用 JSON 文件的路径和选项字典作为参数调用 util.importJson。
您还可以从命令行调用实用程序 importJson,而无需进入交互式 MySQL Shell 会话。要执行此操作,请使用命令 mysqlsh 的选项 --import,并将 JSON 文件的路径以及目标集合作为参数指定。
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook \`
> `--import limbs.json CollectionLimbs`
WARNING: Using a password on the command line interface can be insecure.
Importing from file "limbs.json" to collection `cookbook`.`CollectionLimbs` ↩
in MySQL Server at 127.0.0.1:33060
.. 11.. 11
Processed 506 bytes in 11 documents in 0.0067 sec (11.00 documents/s)
Total successfully imported documents 11 (11.00 documents/s)
提示
如果数据库中不存在具有特定名称的集合或表,则实用程序 importJson 将为您创建它。
13.18 从 MongoDB 导入数据
问题
您希望从 MongoDB 集合导入数据。
解决方案
使用实用程序 mongoexport 将集合从 MongoDB 导出到文件,并使用 importJson 并选择选项 "convertBsonTypes": true 将集合导入到 MySQL 中。
解决方案
importJson 可以导入使用实用程序 mongoexport 从 MongoDB 导出的文档,并将 BSON 数据类型转换为 MySQL 格式。要探索此功能,请将 "convertBsonTypes": true 放入选项字典中,并执行导入操作。
MySQL cookbook JS > `options = {`
-> `"schema": "cookbook",`
-> `"collection": "blogs",`
-> `"convertBsonTypes": true`
-> `}`
->
{
"collection": "blogs",
"convertBsonTypes": true,
"schema": "cookbook"
}
MySQL cookbook JS > `util.importJson("blogs.json", options)`
Importing from file "blogs.json" to collection `cookbook`.`blogs` ↩
in MySQL Server at 127.0.0.1:33060
.. 2.. 2
Processed 240 bytes in 2 documents in 0.0070 sec (2.00 documents/s)
Total successfully imported documents 2 (2.00 documents/s)
结果集合 blogs 使用 MySQL 格式的数据。我们可以通过使用 MySQL Shell 选择该集合中的所有文档来验证它。
MySQL cookbook JS > `shell.getSession().`
-> `getSchema('cookbook').`
-> `getCollection('blogs').`
-> `find()`
->
{
"_id": "6029abb942e2e9c45760eabc", 
"author": "Ann Smith",
"comment": "That's Awesome!",
"date_created": "2021-02-13T23:01:13.154Z" 
}
{
"_id": "6029abd842e2e9c45760eabd",
"author": "John Doe",
"comment": "Love it!",
"date_created": "2021-02-14T11:20:03Z"
}
2 documents in set (0.0006 sec)
BSON OID 值 "_id":{"$oid":"6029abb942e2e9c45760eabc"} 转换为 MySQL ID 格式。
BSON 日期值 "date_created":{"$date":"2021-02-13T23:01:13.154Z"} 转换为 MySQL 日期格式。
您将在 recipes 发行版的 collections/blogs.json 文件中找到集合 blogs 的 JSON 转储。
13.19 以 JSON 格式导出数据
问题
您希望将 MySQL 集合导出到 JSON 文件中。
解决方案
使用 MySQL Shell 检索以 JSON 格式返回的结果。如有需要,将输出重定向到文件。
讨论
MySQL Shell 允许您以 JSON 格式检索数据。以下代码片段将集合 CollectionLimbs 转储并将结果重定向到文件中:
$ `mysqlsh cbuser:cbpass@127.0.0.1:33060/cookbook \`
> `-e "limbs=shell.getSession().getSchema('cookbook').`
> `getCollection('CollectionLimbs').` 
> `find().execute().fetchAll();` 
> `println(limbs);" > limbs.json` 
选择集合。
从集合中获取所有行。
打印结果并将命令输出重定向到文件中。
结果文件将包含一组 JSON 文档。这不是 MySQL Shell 实用程序 importJson 可以使用的相同格式。如果要将数据导入 MySQL,请修改结果文件。您可以借助实用程序 jq 来完成此操作。
$ `jq '.[]' limbs.json > limbs_fixed.json`
jq 读取文件 limbs.json 并将其每个数组元素打印到标准输出。然后,我们将结果重定向到文件 limbs_fixed.json 中。
另请参阅
有关实用程序 jq 的更多信息,请参阅 jq 手册。
13.20 从数据文件中猜测表结构
问题
有人给了您一个数据文件,并说:嗨,请把这个放到 MySQL 中。
但还没有表来存储数据。您需要创建一个可以容纳文件数据的表。
解决方案
使用一个通过检查数据文件内容来猜测表结构的实用程序。
讨论
有时您必须导入 MySQL 中尚未设置表的数据。您可以根据您对文件内容的了解自己创建表格。或者您可以使用 guess_table.pl,这是分发中 transfer 目录中的一个实用程序。guess_table.pl 读取数据文件以查看其中包含的信息类型,然后尝试生成与文件内容匹配的适当 CREATE TABLE 语句。这个脚本是不完美的,因为列内容有时是含糊不清的。(例如,包含少量不同字符串的列可能是 VARCHAR 列或 ENUM 列。)尽管如此,调整 guess_table.pl 生成的 CREATE TABLE 语句可能比从头开始编写更容易。尽管这个实用程序也具有诊断价值,但这并不是它的主要目的。例如,如果您认为某列只包含数字,但 guess_table.pl 表示它应该是 VARCHAR 列,这表明该列至少包含一个非数字值。
guess_table.pl 假设其输入为制表符分隔的换行终止格式。它还假设输入有效,因为基于可能存在缺陷的数据来猜测数据类型是注定失败的。这意味着,例如,如果要识别日期列,它应该采用 ISO 格式。否则,guess_table.pl 可能会将其描述为 VARCHAR 列。如果数据文件不满足这些假设,您可以首先使用配方分发中提供的实用程序 cvt_file.pl 和 cvt_date.pl 重新格式化它。
guess_table.pl 理解以下选项:
--labels
将第一行输入解释为列标签行,并将其用作表列名。如果没有此选项,guess_table.pl 将使用c1、c2等默认列名。
如果文件包含一个标签行而您省略此选项,guess_table.pl将标签视为数据值。由于列中存在非数字或非时间值,脚本可能将所有列描述为VARCHAR列。
--lower, --upper
强制在CREATE TABLE语句中使用小写或大写的列名。
--quote-names, --skip-quote-names
在CREATE TABLE语句中使用 ` 字符引用或不引用表和列标识符(例如,`mytbl`)。如果标识符是保留字,这可能很有用。默认情况是引用标识符。
--report
生成报告而不是CREATE TABLE语句。该脚本显示它收集到的有关每列的信息。
--table``=tbl_name
指定在CREATE TABLE语句中使用的表名称。默认名称为t。
这是guess_table.pl的工作示例。假设名为commodities.csv的文件以 CSV 格式存在,并且内容如下:
commodity,trade_date,shares,price,change
sugar,12-14-2014,1000000,10.50,-.125
oil,12-14-2014,96000,60.25,.25
wheat,12-14-2014,2500000,8.75,0
gold,12-14-2014,13000,103.25,2.25
sugar,12-15-2014,970000,10.60,.1
oil,12-15-2014,105000,60.5,.25
wheat,12-15-2014,2370000,8.65,-.1
gold,12-15-2014,11000,101,-2.25
第一行指示列标签,随后的行包含数据记录,每行一条。trade_date列中的值是日期,但它们使用的是MM-DD-YYYY格式,而不是 MySQL 期望的 ISO 格式。cvt_date.pl 可以将这些日期转换为 ISO 格式。但是,cvt_date.pl和guess_table.pl都要求以制表符分隔、以换行符结尾的格式输入,因此首先使用cvt_file.pl将输入转换为制表符分隔、以换行符结尾的格式,然后使用cvt_date.pl转换日期:
$ `cvt_file.pl --iformat=csv commodities.csv > tmp1.txt`
$ `cvt_date.pl --iformat=us tmp1.txt > tmp2.txt`
将生成的文件tmp2.txt输入到guess_table.pl中:
$ `guess_table.pl --labels --table=commodities tmp2.txt > commodities.sql`
guess_table.pl 写入 commodities.sql 的CREATE TABLE语句如下所示:
CREATE TABLE `commodities`
(
`commodity` VARCHAR(5) NOT NULL,
`trade_date` DATE NOT NULL,
`shares` BIGINT UNSIGNED NOT NULL,
`price` DOUBLE UNSIGNED NOT NULL,
`change` DOUBLE NOT NULL
);
guess_table.pl 根据以下启发性原则生成该语句:
-
如果只包含数字值的列不包含小数点,则假定为
BIGINT类型,否则为DOUBLE类型。 -
不包含负值的数值列可能是
UNSIGNED类型。 -
如果列不包含空值,则guess_table.pl假定它可能是
NOT NULL。 -
无法归类为数字或日期的列被视为
VARCHAR列,长度等于该列中出现的最长值。
您可能需要编辑guess_table.pl生成的CREATE TABLE语句,以进行修改,例如使用较小的整数类型,增加字符字段的大小,将VARCHAR更改为CHAR,添加索引或更改 MySQL 中的保留字列名。
要创建表,请使用guess_table.pl生成的语句:
$ `mysql cookbook < commodities.sql`
然后将数据文件加载到表中(跳过初始标签行):
mysql> `LOAD DATA LOCAL INFILE 'tmp2.txt' INTO TABLE commodities`
-> `IGNORE 1 LINES;`
导入后生成的表格内容如下:
mysql> `SELECT * FROM commodities;`
+-----------+------------+---------+--------+--------+
| commodity | trade_date | shares | price | change |
+-----------+------------+---------+--------+--------+
| sugar | 2014-12-14 | 1000000 | 10.5 | -0.125 |
| oil | 2014-12-14 | 96000 | 60.25 | 0.25 |
| wheat | 2014-12-14 | 2500000 | 8.75 | 0 |
| gold | 2014-12-14 | 13000 | 103.25 | 2.25 |
| sugar | 2014-12-15 | 970000 | 10.6 | 0.1 |
| oil | 2014-12-15 | 105000 | 60.5 | 0.25 |
| wheat | 2014-12-15 | 2370000 | 8.65 | -0.1 |
| gold | 2014-12-15 | 11000 | 101 | -2.25 |
+-----------+------------+---------+--------+--------+
第十四章:验证和重新格式化数据
14.0 介绍
前一章,第十三章,侧重于通过读取行并将其分解为单独列来将数据移入和移出 MySQL 的方法。在本章中,我们将重点放在内容而不是结构问题上。例如,如果您不知道文件中包含的值或通过 Web 表单接收的值是否合法,请预处理它们以进行检查或重新格式化:
-
验证数据值通常是一个好主意,以确保它们对于存储它们的数据类型是合法的。例如,您可以确保用于
INT、DATE和ENUM列的值分别是整数、ISO 格式的日期(YYYY-MM-DD)和合法的枚举值。 -
数据值可能需要重新格式化。您可能将信用卡的值存储为一串数字,但允许 Web 应用程序的用户通过空格或破折号分隔数字块。这些值在存储之前必须重新编写。将日期从一种格式重写为另一种格式是非常常见的;例如,如果程序以
MM-DD-YY格式编写日期以便导入到 MySQL 中的 ISO 格式。如果程序只理解日期和时间格式而不理解组合的日期和时间格式(例如 MySQL 用于DATETIME和TIMESTAMP数据类型的格式),则必须将日期和时间值拆分为单独的日期和时间值。
本章主要涉及在检查整个文件的上下文中的格式和验证问题,但这里讨论的许多技术也可以应用于一次性验证。考虑一个基于 Web 的应用程序,该应用程序提供一个表单供用户填写,然后处理其内容以在数据库中创建新行。 Web API 通常将表单内容作为一组已解析的离散值提供,因此应用程序可能不需要处理记录和列分隔符。另一方面,验证问题仍然至关重要。您真的不知道用户向您的脚本发送什么样的值,因此检查它们很重要。
前三个配方介绍了 MySQL 中可用的数据验证功能。从配方 14.4 开始,我们将重点放在验证和预处理应用程序端的数据上。我们介绍了一些技巧,可以有效地处理大量数据。
本章讨论的程序片段和脚本的源代码位于 recipes 分发的 transfer 目录中,但一些实用函数包含在位于 lib 目录中的库文件中。
14.1 使用 SQL 模式拒绝不良输入值
问题
MySQL 接受无效、超出范围或其他不适合插入的列的数据值。您希望服务器更具限制性,不接受不良数据。
解决方案
检查 SQL 模式并确保它不为空。有几种模式可以用来控制服务器对数据值的严格程度。有些模式适用于所有输入值,而其他模式适用于特定的数据类型,如日期。
讨论
当 SQL 模式未设置或设置为空值时,MySQL 允许所有输入值用于表列,即使输入数据类型与列的数据类型不匹配。考虑以下表,其中包含整数、字符串和日期列:
mysql> `SELECT @@sql_mode;`
+------------+
| @@sql_mode |
+------------+
| |
+------------+
1 row in set (0,00 sec)
mysql> `CREATE TABLE t (i INT, c CHAR(6), d DATE);`
向表中插入具有不合适数据值的行会导致警告(可以通过SHOW WARNINGS查看),但服务器会将这些值加载到表中,并将它们转换为适合该列的某个值:
mysql> `INSERT INTO t (i,c,d) VALUES('-1x','too-long string!','1999-02-31');`
mysql> `SHOW WARNINGS;`
+---------+------+--------------------------------------------+
| Level | Code | Message |
+---------+------+--------------------------------------------+
| Warning | 1265 | Data truncated for column 'i' at row 1 |
| Warning | 1265 | Data truncated for column 'c' at row 1 |
| Warning | 1264 | Out of range value for column 'd' at row 1 |
+---------+------+--------------------------------------------+
mysql> `SELECT * FROM t;`
+------+--------+------------+
| i | c | d |
+------+--------+------------+
| -1 | too-lo | 0000-00-00 |
+------+--------+------------+
防止这些转换发生的一种方法是在客户端检查输入数据,以确保其合法。在某些情况下,这是一种合理的策略(参见 Recipe 14.0 中的侧边栏),但也有一种替代方案:让服务器在服务器端检查数据值,并在无效时拒绝它们并显示错误。
要做到这一点,需要将sql_mode系统变量设置为启用服务器对输入数据的限制接受。通过适当的限制,否则会导致转换和警告的数据值将导致错误。在启用strict
SQL 模式后,再次尝试上一个示例中的INSERT语句:
mysql> `SET sql_mode = 'STRICT_ALL_TABLES';`
mysql> `INSERT INTO t (i,c,d) VALUES('-1x','too-long string!','1999-02-31');`
ERROR 1265 (01000): Data truncated for column 'i' at row 1
这里的语句甚至没有进展到第二和第三个数据值,因为第一个对于整数列来说是无效的,服务器会报错。
如果未启用输入限制,服务器将检查日期值的月份部分是否在 1 到 12 的范围内,并且日期值对于给定的月份是否合法。这意味着'2005-02-31'默认情况下会生成警告(转换为零日期'0000-00-00')。在严格模式下,会发生错误。
MySQL 仍然允许诸如'1999-11-00'或'1999-00-00'这样具有零部分的日期,或者称为零
日期('0000-00-00')。为了限制这些类型的日期值,启用NO_ZERO_IN_DATE和NO_ZERO_DATE SQL 模式会导致警告,或在严格模式下导致错误。例如,要禁止具有零部分或零
日期的日期,请像这样设置 SQL 模式:
mysql> `SET sql_mode = 'STRICT_ALL_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE';`
启用这些限制的更简单方法,以及其他几种方法,是启用TRADITIONAL SQL 模式。TRADITIONAL模式实际上是一组模式,可以通过设置和显示sql_mode值来查看:
mysql> `SET sql_mode = 'TRADITIONAL';`
mysql> `SELECT @@sql_mode\G`
*************************** 1\. row ***************************
@@sql_mode: STRICT_TRANS_TABLES,STRICT_ALL_TABLES,NO_ZERO_IN_DATE,
NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,TRADITIONAL,
NO_ENGINE_SUBSTITUTION
您可以在MySQL 参考手册中详细了解各种 SQL 模式。
所示的示例设置了sql_mode系统变量的会话值,因此它们仅更改当前会话的 SQL 模式。要为所有客户端全局设置模式,请在启动服务器时使用--sql_mode=mode_value选项。或者,如果具有SYSTEM_VARIABLES_ADMIN或SUPER特权,则可以在运行时设置全局模式。
mysql> `SET GLOBAL sql_mode = '`*`mode_value`*`';`
注意
在 MySQL 5.7 之前,默认情况下 SQL 模式是宽容的。更新的版本更加严格,SQL 模式设置为 ONLY_FULL_GROUP_BY, STRICT_TRANS_TABLES, NO_ZERO_IN_DATE, NO_ZERO_DATE, ERROR_FOR_DIVISION_BY_ZERO, NO_AUTO_CREATE_USER, NO_ENGINE_SUBSTITUTION。因此,如果您希望拥有严格的服务器,您不需要额外做任何事情,除非您有意放宽了之前的 SQL 模式。
14.2 使用 CHECK 约束拒绝无效值
问题
您希望验证数据以便符合应用程序的业务逻辑,并在不满足要求时拒绝值。
解决方案
使用 CHECK 约束。
讨论
如果一个值与 MySQL 数据类型格式匹配,并不意味着它与应用程序的逻辑匹配。例如,如果您只想存储偶数,您不能简单地使用整数数据类型,因为奇数和偶数都是有效的整数。
CHECK 约束在 8.0 版本中引入,允许在表列上设置自定义条件,并在值不满足条件时拒绝语句。因此,要创建一个只存储偶数的表,您需要使用 CHECK 来检查数字是否可以被二整除。
mysql> `CREATE` `TABLE` `even` `(`
-> `even_value` `INT` `CHECK``(``even_value` `%` `2` `=` `0``)`
-> `)` `ENGINE``=``InnoDB``;`
Query OK, 0 rows affected (0.03 sec)
现在,我们可以成功地插入偶数到这个表中。
mysql> `INSERT` `INTO` `even` `VALUES``(``2``)``;`
Query OK, 1 row affected (0.01 sec)
奇数值将被拒绝。
mysql> `INSERT` `INTO` `even` `VALUES``(``1``)``;`
ERROR 3819 (HY000): Check constraint 'even_chk_1' is violated.
您还可以为单个列创建多个 CHECK 约束。例如,为了仅接受小于 100 的偶数值,创建两个约束。
mysql> `CREATE` `TABLE` `even_100` `(`
-> `even_value` `INT` `CHECK``(``even_value` `%` `2` `=` `0``)` `CHECK``(``even_value` `<` `100``)`
-> `)` `ENGINE``=``InnoDB``;`
Query OK, 0 rows affected (0.02 sec)
在这种情况下,MySQL 将检查第一个条件,如果满足则处理第二个条件。
mysql> `INSERT` `INTO` `even_100` `VALUES``(``101``)``;`
ERROR 3819 (HY000): Check constraint 'even_100_chk_1' is violated.
mysql> `INSERT` `INTO` `even_100` `VALUES``(``102``)``;`
ERROR 3819 (HY000): Check constraint 'even_100_chk_2' is violated.
如果在定义列时指定 CHECK 约束,它将仅验证此列。如果您希望在单个约束中检查两个或更多列,则需要单独指定它。
常见的验证任务是检查出发日期是否晚于到达日期。我们可以将此检查添加到 patients 表中。
ALTER TABLE patients ADD CONSTRAINT date_check
CHECK((date_departed IS NULL) OR (date_departed >= date_arrived));
现在,它将不允许插入出发日期早于到达日期的记录。
mysql> `INSERT` `INTO` `patients` `(``national_id``,` `name``,` `surname``,` `gender``,` `age``,` `diagnosis``,`
-> `date_arrived``,` `date_departed``)`
-> `VALUES``(``'34GD429520'``,` `'John'``,` `'Doe'``,` `'M'``,` `45``,` `'Data Phobia'``,`
-> `'2020-07-20'``,` `'2020-05-31'``)``;`
ERROR 3819 (HY000): Check constraint 'date_check' is violated.
14.3 使用触发器拒绝输入值
问题
您希望验证要插入表中的数据是否遵循业务逻辑,但您的逻辑比 CHECK 约束能处理的更复杂。您可能还需要重写数据而不是拒绝它。或者您正在使用 MySQL 的较早版本,不支持 CHECK 约束。
解决方案
使用 BEFORE 触发器。
讨论
CHECK 约束有一些限制。它们不允许使用存储的或用户定义的函数、子查询或用户定义的变量。它们还不允许修改插入的数据。如果您想要格式化插入的值以满足业务标准,您可能需要探索其他解决方案,例如在应用程序端进行验证或在 MySQL 端使用 BEFORE 触发器。
要在 MySQL 端执行更复杂的验证,请创建触发器并在其中引发 SQL 异常。
让我们看一个例子。假设一个杂货表存储超市中产品的详细信息。在一些国家,超市在特定时间内禁止出售酒精制品。例如,在土耳其,您无法在晚上 10 点至早上 6 点之间在超市购买酒精。如果您遇到此类限制,您可能希望限制用户可以下订单的时间。
假设一个 groceries 表存储超市杂货的详细信息。
CREATE TABLE `groceries` (
`id` int NOT NULL,
`name` varchar(255) DEFAULT NULL,
`forbidden_after` time DEFAULT NULL,
`forbidden_before` time DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
列 forbidden_after 和 forbidden_before 定义了禁止销售特定物品的时间范围。
另一个名为 groceries_order_items 的表包含有关购买的信息。
CREATE TABLE groceries_order_items
(
order_id INT NOT NULL,
groceries_id INT NOT NULL,
quantity INT DEFAULT 0,
PRIMARY KEY (order_id,groceries_id)
) ENGINE=InnoDB;
要在某些时间禁止购买物品,您可以创建一个触发器,检查当前时间和对所选产品的任何限制。如果存在限制,则拒绝购买。
CREATE TRIGGER check_time
BEFORE INSERT ON groceries_order_items
FOR EACH ROW BEGIN
DECLARE forbidden_after_val TIME; 
DECLARE forbidden_before_val TIME;
DECLARE name_val VARCHAR(255);
DECLARE message VARCHAR(400);
SELECT forbidden_after, forbidden_before, name 
INTO forbidden_after_val, forbidden_before_val, name_val
FROM groceries WHERE id = NEW.groceries_id;
IF (forbidden_after_val IS NOT NULL AND TIME(NOW()) >= forbidden_after_val) 
OR (forbidden_before_val IS NOT NULL AND TIME(NOW()) <= forbidden_before_val)
THEN
SET message=CONCAT('It is forbidden to buy ', name_val,
' between ', forbidden_after_val, ' and ', forbidden_before_val); 
SIGNAL SQLSTATE '45000' 
SET MESSAGE_TEXT = message;
END IF;
END;
声明变量以存储禁止购买的时间范围、产品名称和错误消息。
选择限制的时间范围和产品名称存入变量。
检查当前时间是否在所选产品的禁止范围内。
如果时间落入禁止范围内,编制一条消息,解释该产品的限制。
引发错误并拒绝插入。
因此,您可以在凌晨 3 点购买奶酪或水,但不能在那时购买啤酒或葡萄酒。
mysql> `SELECT` `CURRENT_TIME``(``)``;`
+----------------+ | CURRENT_TIME() |
+----------------+ | 03:01:40 |
+----------------+ 1 row in set (0.00 sec)
mysql> `INSERT` `INTO` `groceries_order_items` `VALUES``(``1``,``3``,``1``)``;` `-- cheese`
Query OK, 1 row affected (0.03 sec)
mysql> `INSERT` `INTO` `groceries_order_items` `VALUES``(``1``,``8``,``3``)``;` `-- water`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `groceries_order_items` `VALUES``(``1``,``7``,``6``)``;` `-- beer`
ERROR 1644 (45000): It is forbidden to buy beer between 22:00:00 and 06:00:00
mysql> `INSERT` `INTO` `groceries_order_items` `VALUES``(``1``,``6``,``1``)``;` `-- wine`
ERROR 1644 (45000): It is forbidden to buy wine between 22:00:00 and 06:00:00
白天购买限制会放宽。
mysql> `SELECT` `CURRENT_TIME``(``)``;`
+----------------+ | CURRENT_TIME() |
+----------------+ | 14:00:35 |
+----------------+ 1 row in set (0.00 sec)
mysql> `INSERT` `INTO` `groceries_order_items` `VALUES``(``1``,``7``,``6``)``;` `-- beer`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `groceries_order_items` `VALUES``(``1``,``6``,``1``)``;` `-- wine`
Query OK, 1 row affected (0.01 sec)
参见
关于使用触发器拒绝或修改无效值的更多信息,请参见配方 11.11。
14.4 编写输入处理循环
问题
您希望确保文件中的数据值合法。
解决方案
编写一个输入处理循环,用于检查它们,可能会将它们重写为更合适的格式。
讨论
本章中展示的许多验证示例都是在读取文件并检查各列值的程序上执行的典型操作。这类文件处理实用程序的一般框架如下:
#!/usr/bin/python3
# loop.py: Typical input-processing loop.
# Assumes tab-delimited, linefeed-terminated input lines.
import sys
for line in sys.stdin:
line = line.rstrip()
# split line at tabs, preserving all fields
values = line.split("\t")
for val in values: # iterate through fields in line
# ... test val here ...
pass
for() 循环读取每个输入行。在循环内部,每行被分割为字段。内部的 for() 循环迭代处理每个字段,按顺序处理。如果不对所有字段统一应用特定测试,则用单独的基于列的测试替换 for() 循环。
此循环假设制表符分隔的、以换行符终止的输入,这是本章讨论的大多数实用程序共享的假设。要使用这些实用程序处理其他格式的数据文件,您可以尝试使用recipes分发中提供的cvt_file.pl脚本将这些文件转换为制表符分隔格式。
14.5 将常见测试放入库中
问题
您希望执行重复的验证操作。
解决方案
将验证操作封装为库例程。
讨论
对于某些验证操作频繁发生并且重复的情况并不罕见,此时您可能会发现构建函数库非常有用。通过将验证操作封装为库例程,编写基于它们的实用程序更加容易,这些实用程序使得在整个文件上执行命令行操作变得更加简单,因此您可以避免手动编辑它们。这也为操作赋予了一个比比较代码本身更清晰的名称。以下是在 Python 语言中进行的测试,执行模式匹配以检查val是否完全由数字(可选前导加号)组成,然后确保该值大于零:
p = re.compile('^\+?\d+$')
s = p.search(val)
valid = s and (s.group(0) != '0')
换句话说,该测试寻找表示正整数的字符串。为了使测试更易于使用并使其意图更清晰,请将其封装为以下形式的函数使用:
valid = is_positive_integer (val);
将函数定义如下:
def is_positive_integer(val):
p = re.compile('^\+?\d+$')
s = p.search(val)
return s and (s.group(0) != '0')
现在将函数定义放入库文件中,以便多个脚本可以轻松使用。cookbook_utils.py模块文件位于recipes发行版的lib目录中,是一个包含多个验证函数的库文件示例。浏览一下它,看看哪些函数可能对您自己的程序有用(或作为编写自己库文件的模板)。要从脚本内部访问此模块,请包含像这样的use语句:
import cookbook_utils as cu
当然,您必须将模块文件安装在 Python 能找到的目录中(参见 Recipe 4.3)。
将一系列实用程序例程放入库文件的显著好处之一是,您可以将其用于各种程序。数据处理问题很少是完全独特的。如果您可以从库中挑选至少几个验证例程,即使对于高度专业化的程序,也能减少必须编写的代码量。
提示
要避免编写自己的库例程,可以查看是否有其他人已经编写了可用的例程。例如,如果您查看 Perl CPAN(cpan.perl.org),您会找到一个 Data::Validate 模块层次结构。那里的模块提供了标准化多种常见验证任务的库例程。Data::Validate::MySQL 专门处理 MySQL 数据类型。
14.6 使用模式匹配验证数据
问题
您希望将值与难以在没有编写非常丑陋的表达式的情况下指定的一组值进行比较。
解决方案
使用模式匹配。
讨论
模式匹配是一个强大的验证工具,它使您能够使用单个表达式测试整个类别的值。您还可以使用模式测试将匹配值分解为子部分以进行进一步的个别测试,或者在替换操作中重新编写匹配的值。例如,您可以将匹配的日期分解为部分以验证月份是否在 1 到 12 的范围内,日期是否在该月的天数内。您可以使用替换来重新排列 MM-DD-YYYY 或 DD-MM-YYYY 值为 YYYY-MM-DD 格式。
接下来的几节描述如何使用模式来测试几种类型的值,但首先让我们回顾一些一般的模式匹配原则。以下讨论侧重于 Python 的正则表达式能力。Ruby、PHP、Go 和 Perl 中的模式匹配类似,但应查阅相关文档以了解任何差异。对于 Java,请使用 java.util.regex 包。
在 Python 中,正则表达式是 re 模块的一部分。模式构造函数是 re.compile(pat):
pattern = re.compile(*`pat`*)
要查找值是否与模式匹配,请使用 match 方法:
it_matched = pattern.match(val) # pattern match
您可以在 match 方法中构造正则表达式:
it_matched = re.match(*`pat`*, val) # pattern match
将标志 re.I 作为正则表达式构造函数的第二个参数,使模式匹配不区分大小写:
it_matched = re.match(*`pat`*, val, re.I) # case-insensitive match
要查找非匹配项,请用 = 操作符替换为 = 和 not 操作符的组合:
no_match = not re.match(*`pat`*, val) # negated pattern match
要根据模式匹配在 val 中执行替换,请使用 re.sub(/pat, replacement, val)replacement/。如果 val 中出现 pat,则将其替换为 replacement。要进行大小写不敏感匹配,加上 re.I 标志。要执行替换,只替换 pat 的几个实例而不是所有实例,请添加选项 count:
val = re.sub(*`pat`*, *`replacement`*, val) # substitution
val = re.sub(*`pat`*, *`replacement`*, val, flags = re.I) # case-insensitive substitution
val = re.sub(*`pat`*, *`replacement`*, val, count = 1) # substitution of the first match
val = re.sub(*`pat`*, *`replacement`*, val, count = 1, flags = re.I)
# case-insensitive and the first match
表 14-1 显示了 Python 正则表达式中可用的一些特殊模式元素:
表 14-1. Python 正则表达式中的模式元素
| 模式 | 模式匹配的内容 |
|---|---|
^ |
字符串的开始 |
| ` | 模式 |
| --- | --- |
^ |
字符串的开始 |
| 字符串的结尾 | |
. |
除换行符外的任意字符 |
\s, \S |
空白或非空白字符 |
\d, \D |
数字或非数字字符 |
\w, \W |
单词(字母数字或下划线)或非单词字符 |
[...] |
方括号内列出的任何字符 |
[^...] |
方括号内未列出的任何字符 |
p1|p2|p3 |
选择;匹配任何模式 p1、p2 或 p3 |
* |
前导元素的零个或多个实例 |
+ |
前导元素的一个或多个实例 |
{n} |
前导元素的 n 个实例 |
{m,n} |
m 到 n 个前导元素的实例 |
这些模式元素中的许多与 MySQL 的 REGEXP 正则表达式运算符可用元素相同(参见 Recipe 7.11)。
要匹配模式中特殊字符(如*、^或$)的文字实例,请在其前面加上反斜杠。类似地,要在字符类构造中包含在字符类中特殊的字符([、]或-),请在其前面加上反斜杠。要在字符类中包含文字^,请将其列在括号之间不作为第一个字符。
下面展示的许多验证模式的形式是^pat$。用^和$开头和结尾的模式的效果是要求pat匹配您测试的整个字符串。在数据验证环境中,这是常见的,因为通常希望知道模式是否完全匹配整个输入值,而不仅仅是部分匹配。但这不是一成不变的规则,有时根据需要可以省略适当的^和$字符执行更轻松的测试。例如,如果要从值中去除前导和尾随空格,请使用一个仅锚定到字符串开头的模式,以及另一个仅锚定到字符串结尾的模式:
val = re.sub('^\s+', '', val) # trim leading whitespace
val = re.sub('\s+$', '', val) # trim trailing whitespace
事实上,这是一个常见操作,可以作为实用函数编写。cookbook_utils.py文件包含一个执行两个替换并返回结果的trim_whitespace()函数:
val = trim_whitespace (val)
要记住模式匹配字符串的子部分,请在相关模式部分周围使用括号。成功匹配后,可以在正则表达式内部使用变量\1、\2等引用匹配的子字符串,或者作为group方法的参数使用匹配编号:
match = re.match('^(\d+)(.*)$', '2021-04-25')
if match:
first_part = match.group(1) # this is the year, 2021
the_rest = match.group(2) # this is the rest of the date, -04-25
如果要指示模式中的元素是可选的,请在其后面加上?字符。要匹配由一系列数字组成的值,可选择以负号开头,并可选择以句点结尾,请使用此模式:
^-?\d+\.?$
使用括号在模式内部分组交替项。以下模式匹配hh:mm格式的时间值,可选择后跟AM或PM:
^\d{1,2}:\d{2}\s*(AM|PM)?$
在该模式中使用括号还会记住\1中的可选部分。要抑制该副作用,请改用(?:pat):
^\d{1,2}:\d{2}\s*(?:AM|PM)?$
您现在具备了足够的 Python 模式匹配背景,可以构建用于多种数据值的有用验证测试。以下食谱提供可用于测试广泛内容类型、数字、时间值以及电子邮件地址或 URL 的模式。
recipes发行版的transfer目录包含一个test_pat.py脚本,该脚本读取输入值,将其与多个模式进行匹配,并报告每个值匹配的模式。该脚本易于扩展,因此您可以将其用作测试框架,以尝试自己的模式。
14.7 使用模式匹配广泛内容类型
问题
您希望将值分类到不同类别中。
解决方案
使用一个使用类似广泛类别的模式。
讨论
要检查值是否为空或非空,或仅由某些类型的字符组成,可使用 表 14-2 中列出的模式。
表 14-2. 常用字符类别
| 模式 | 该模式匹配的值类型 |
|---|---|
| `^ | 模式 |
| --- | --- |
| 空值 | |
. |
非空值 |
^\s*$ |
空格,可能为空 |
^\s+$ |
非空白格 |
\S |
非空,且非空白 |
^\d+$ |
仅数字,非空 |
^[a-zA-Z]+$ |
仅字母字符(不区分大小写),非空 |
^\w+$ |
仅字母数字或下划线字符,非空 |
14.8 使用模式匹配数值
问题
您希望确保字符串看起来像一个数字。
解决方案
使用匹配您要查找的数字类型的模式。
讨论
模式可用于将值分类为多种数字类型,如 表 14-3 所示
表 14-3. 匹配数字的模式
| 模式 | 该模式匹配的值类型 |
|---|---|
| `^\d+ | 模式 |
| --- | --- |
| 无符号整数 | |
| `^-?\d+ | 模式 |
| --- | --- |
| 负数或无符号整数 | |
| `[1]?\d+ | 模式 |
| --- | --- |
| 有符号或无符号整数 | |
| `[2]?(\d+(.\d*)?|.\d+) | 模式 |
| --- | --- |
| 浮点数 |
模式 ^\d+$ 通过要求一个非空值,其值从开始到结束只包含数字,匹配无符号整数。如果你只关心值以整数开头,你可以匹配字符串的初始数字部分并提取它。为此,只匹配字符串的初始部分(省略要求模式匹配到字符串结尾的 $),并在 \d+ 部分周围加上括号。然后,在成功匹配后,将匹配的数字称为 group(1):
match = re.match('^(\d+)', val)
if match:
val = match.group(1)
一些类型的数值具有特殊的格式或其他不寻常的约束。以下是一些示例及其处理方法:
ZIP(邮政编码)
ZIP 和 ZIP+4 是美国邮政编码,用于邮寄。它们的值如 12345 或 12345-6789(即五位数字,可能后跟一个短横线和四位数字)。要匹配其中一种形式或两种形式,请使用 表 14-4 中显示的模式:
表 14-4. 匹配邮政编码的模式
| 模式 | 该模式匹配的值类型 |
|---|---|
| `^\d | 模式 |
| --- | --- |
| ZIP 编码,仅五位数字 | |
| `^\d{5}-\d | 模式 |
| --- | --- |
| ZIP+4 编码 | |
| `^\d{5}(-\d{4})? | 模式 |
| --- | --- |
| ZIP 或 ZIP+4 编码 |
信用卡号
信用卡号通常由数字组成,但常见的写法是在数字组之间使用空格、短横线或其他字符。例如,以下号码是等效的:
0123456789012345
0123 4567 8901 2345
0123-4567-8901-2345
要匹配这样的值,请使用此模式:
^[- \d]+
(Python 允许在字符类中使用 \d 数字指定符号。)但是,该模式不能识别长度不正确的值,并且在存储值到 MySQL 前去除多余字符可能很有用。要求信用卡号必须包含 16 位数字,可以使用替换方法去除所有非数字字符,然后检查结果的长度:
val = re.sub('\D', '', val)
valid = len(val) == 16
14.9 使用模式匹配日期或时间
问题
您希望确保字符串看起来像是日期或时间。
解决方案
使用匹配您期望的时间值类型的模式。务必考虑诸如子部分之间的分隔符严格程度以及子部分的长度等问题。
讨论
日期是验证的头疼问题,因为它们有许多不同的格式。模式测试非常有用以清除非法值,但通常不足以进行完整验证:例如,日期可能在您期望的月份处有一个数字,但如果数字为 13,则日期无效。本节介绍了一些匹配几种常见日期格式的模式。食谱 14.14 更详细地重新讨论了这个主题,并讨论了如何将模式测试与内容验证结合起来。
要求值为 ISO(YYYY-MM-DD)格式的日期,请使用此模式:
^\d{4}-\d{2}-\d{2}$
该模式要求 - 字符作为日期部分之间的分隔符。要允许 - 或 / 作为分隔符,请在数字部分之间使用字符类:
^\d{4}[-/]\d{2}[-/]\d{2}$
此模式将匹配格式为 YYYY-MM-DD、YYYY/MM/DD、YYYY/MM-DD 和 YYYY-MM/DD 的日期。
要允许任何非数字分隔符(这与 MySQL 在将字符串解释为日期时的操作方式相对应),请使用此模式:
^\d{4}\D\d{2}\D\d{2}$
要允许类似 03 这样的值的前导零可能缺失,只需查找三个非空数字序列即可:
^\d+\D\d+\D\d+$
当然,该模式非常通用,也会匹配其他值,例如美国社会安全号码(其格式为 012-34-5678)。为了通过要求年部分中的二至四位数字和月日部分中的一到两位数字来约束子部分的长度,使用此模式:
^\d{2,4}?\D\d{1,2}\D\d{1,2}$
对于其他格式(如 MM-DD-YY 或 DD-MM-YY)的日期,类似的模式也适用,但子部分的排列顺序不同。该模式匹配这两种格式:
^\d{2}-\d{2}-\d{2}$
要检查单个日期部分的值,请在模式中使用括号,并在成功匹配后提取子字符串。例如,如果期望日期为 ISO 格式,请执行以下操作:
match = re.match('^(\d{2,4})\D(\d{1,2})\D(\d{1,2})$', val)
if match:
(year, month, day) = (match.group(1), match.group(2), match.group(3))
在 recipes 分发中的库文件 lib/cookbook_utils.py 包含几个这些模式测试,封装为函数调用。如果日期不匹配模式,它们将返回 None。否则,它们将返回一个包含年、月和日分解值的数组引用。这对于对日期组件执行进一步检查非常有用。例如,is_iso_date() 寻找匹配 ISO 格式的日期。定义如下:
def is_iso_date(val):
m = re.match('^(\d{2,4})\D(\d{1,2})\D(\d{1,2})$', val)
return [int(m.group(1)), int(m.group(2)), int(m.group(3))] if m else None
可以如下使用该函数:
ref = cu.is_iso_date(val)
if ref is not None:
# val matched ISO format pattern;
# check its subparts using ref[0] through ref[2]
pass
else:
# val didn't match ISO format pattern
pass
通常,需要对日期进行额外处理,因为日期匹配模式有助于淘汰语法上有问题的值,但不能评估各个组件是否包含合法值。为了做到这一点,需要进行一些范围检查。配方 14.14 涵盖了这个主题。
如果愿意跳过子部分测试,只想重写部分,请使用替换。例如,将假定为 MM-DD-YY 格式的值重写为 YY-MM-DD 格式,可以这样做:
val = re.sub('^(\d+)\D(\d+)\D(\d+)$', r'\3-\1-\2', val)
时间值通常比日期更有条理,通常先写小时,最后写秒,每部分两位数:
^\d{2}:\d{2}:\d{2}$
为了更宽松,允许小时部分为单个数字,或者允许秒部分缺失:
^\d{1,2}:\d{2}(:\d{2})?$
如果想要对单独的时间部分进行范围检查或重新格式化值以包含缺失的秒部分 00,可以在时间模式中使用括号标记这些部分。但是,如果第三个时间部分是可选的,则在模式中括号和 ? 字符需要特别小心。您希望整个 :\d{2} 在模式末尾是可选的,但不保存 \3 中的 : 字符,如果第三个时间部分存在。为了实现这一点,请使用 (?:pat),这是一种不保存匹配子串的分组符号。在这种符号内,使用括号将数字保存起来。然后,如果秒部分不存在,\3 就是 None,否则它包含秒数:
m = re.match('^(\d{1,2}):(\d{2})(?::(\d{2}))?$', val)
(hour, min, sec) = (m.group(1), m.group(2), m.group(3))
sec = '00' if sec is None else sec # seconds missing; use 00
val = hour + ':' + min + ':' + sec
要将带有 AM 和 PM 后缀的 12 小时制时间重写为 24 小时制,可以这样做:
m = re.match('^(\d{1,2})\D(\d{2})\D(\d{2})(?:\s*(AM|PM))?$', val, flags = re.I)
(hour, min, sec) = (m.group(1), m.group(2), m.group(3))
# supply missing seconds
sec = '00' if sec is None else sec
if int(hour) == 12 and (m.group(4) is None or m.group(4).upper() == "AM"):
hour = '00' # 12:xx:xx AM times are 00:xx:xx
elif int(hour) < 12 and (m.group(4) is not None) and m.group(4).upper() == "PM":
hour = int(hour) + 12 # PM times other than 12:xx:xx
return [hour, min, sec] # return hour, minute, second
时间部分被放置在 1、2 和 3 组中,如果秒部分缺失,则 3 组设为 None。后缀(如果存在)放入 4 组中。如果后缀是 AM 或缺失 (None),则将其解释为上午时间。如果后缀是 PM,则将其解释为下午时间。
参见
这个配方展示了在数据传输目的下处理日期时的初步内容。日期和时间的测试和转换可能会高度个性化,要考虑的问题数量之多令人难以置信:
-
基本日期格式是什么?日期有几种常见的风格,例如 ISO 格式(
YYYY-MM-DD)、美国格式(MM-DD-YY)和英国格式(DD-MM-YY)。这些只是更标准的格式之一。还有很多可能的格式。例如,数据文件中的日期可能写成June17,1959或17Jun'59。 -
日期末尾是否允许有时间,或者可能是必须的?期望时间时,是否需要全时间还是只需时和分?
-
是否允许特殊值如
now或today? -
日期部分是否需要由特定字符(如
-或/)分隔,或者允许使用其他分隔符? -
日期部分是否需要特定数量的数字?月份和年份的前导零是否允许缺失?
-
月份是用数字写的,还是用像
January或Jan这样的月份名表示? -
两位数年份应如何转换为四位数?在
00到99范围内,值在哪个转换点上从一个世纪变为另一个世纪? -
应该检查日期部分以确保它们的有效性吗?模式可以识别看起来像日期或时间的字符串,但是虽然它们非常有用于检测格式错误的值,但可能还不足够。例如,
1947-15-99这样的值可能会匹配一个模式,但它并不是一个合法的日期。因此,模式测试在与对日期的各个部分的范围检查结合使用时最为有用。
在数据传输问题中,这些问题的普遍存在意味着你可能会偶尔编写一些自定义验证器来处理非常特定的日期格式。本章的其他部分可以提供额外的帮助。例如,配方 14.13 讨论了如何将两位数年份转换为四位数形式,而 配方 14.14 则讨论了如何对日期或时间值的组件执行有效性检查。
你可能能够通过使用现有的日期检查模块来减少一些工作量,适用于你的 API 语言。一些可能的选择包括:Perl 的 Date 模块;Ruby 的 date 模块;Python 的 datetime 模块;PHP 的 DateTime 类;Java 的 GregorianCalendar 和 SimpleDateTime 类。
14.10 使用模式匹配电子邮件地址或 URL
问题
你想确定一个值是否看起来像是一个电子邮件地址或一个 URL。
解决方案
在你的应用程序中使用一个模式,调整到你接受哪些地址和不接受哪些地址的期望严格程度。
讨论
立即前面的配方使用模式来识别诸如数字和日期之类的值的类别,这些都是正则表达式的典型应用场景。但是,模式匹配在数据验证中具有更广泛的适用性。为了给出一些使用模式匹配的其他类型值的想法,本配方展示了一些用于电子邮件地址和 URL 的测试。
要检查预期为电子邮件地址的值,模式应至少要求一个带有非空字符串的@字符:
.@.
注意
完整的电子邮件地址规范由 RFC5322 定义,并包含许多部分。拒绝所有无效地址并接受所有有效地址的正则表达式相当复杂。参考 http://emailregex.com/ 获取流行编程语言的示例,以便了解更多。
在这个配方中,我们将展示一个非常简单的测试,但它仍然足以帮助纠正大多数无辜用户在输入地址时的拼写错误。
很难找到一个通用的模式,涵盖所有合法值并排除所有非法值,但写一个至少更加严格的模式却很容易。例如,除了非空外,用户名和域名应完全由@字符或空格以外的字符组成:
^[^@ ]+@[^@ ]+$
您可能还希望要求域名部分包含至少由点分隔的两部分:
^[^@ ]+@[^@ .]+\.[^@ .]+
要查找以http://、https://、ftp://或mailto:协议开头的 URL 值,请使用匹配任何一个在字符串开头的选择项。
re.compile('^(https?://|ftp://|mailto:)', flags=re.I)
括号内的模式选择项被分组,因为否则^锚定只适用于第一个。re.I标志跟随模式,因为 URL 中的协议标识符不区分大小写。除了允许协议标识符后跟任何内容外,该模式相当不限制。根据需要添加进一步限制。
14.11 使用表元数据验证数据
问题
您希望根据ENUM或SET列的合法成员检查输入值。
解决方案
获取列定义,从中提取成员列表,并检查数据值是否与列表匹配。
讨论
某些验证形式涉及检查输入值是否与存储在数据库中的信息相匹配。这包括要存储在ENUM或SET列中的值,可以与列定义中存储的有效成员进行比较。基于数据库的验证还适用于必须与查找表中列出的内容匹配的值以被视为合法的情况。例如,包含客户 ID 的输入记录可以要求匹配customers表中的行,地址中的州缩写可以验证是否与列出每个州的表匹配。本文档介绍了基于ENUM和SET的验证,而 Recipe 14.12 讨论了如何使用查找表。
检查与ENUM或SET列的合法值相对应的输入值的一种方法是使用INFORMATION_SCHEMA中的信息将合法列值列表转换为数组,然后执行数组成员测试。例如,profile表中的color列是作为以下定义的ENUM的最喜欢的颜色列:
mysql> `SELECT COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS`
-> `WHERE TABLE_SCHEMA = 'cookbook' AND TABLE_NAME = 'profile'`
-> `AND COLUMN_NAME = 'color';`
+----------------------------------------------------+
| COLUMN_TYPE |
+----------------------------------------------------+
| enum('blue','red','green','brown','black','white') |
+----------------------------------------------------+
如果您从COLUMN_TYPE值中提取枚举成员列表并将其存储在列表members中,则可以像这样执行成员测试:
valid = True ↩
if list(map(lambda v: v.upper(), members)).count(val.upper()) > 0 ↩
else False
我们可以将列表members和val转换为大写以执行大小写不敏感比较,因为默认排序规则是utf8mb4_0900_ai_ci,即不区分大小写。(如果列具有不同的排序规则,请相应调整。我们在 Recipe 7.5 中讨论了如何更改列排序规则)
在食谱 12.6 中,我们编写了一个函数get_enumorset_info(),它返回ENUM或SET列的元数据。这包括成员列表,因此很容易使用该函数编写另一个实用程序例程check_enum_value(),获取法律枚举值并执行成员测试。该例程接受四个参数:数据库句柄、ENUM列的表名和列名,以及要检查的值。它返回 true 或 false,指示该值是否合法:
def check_enum_value(conn, db_name, tbl_name, col_name, val):
valid = 0
info = get_enumorset_info(conn, db_name, tbl_name, col_name)
if info is not None and info['type'].upper() == 'ENUM':
# use case-insensitive comparison because default collation
# (utf8mb4_0900_ai_ci) is case-insensitive (adjust if you use
# a different collation)
valid = 1 ↩
if list(map(lambda v: v.upper(), info['values'])).count(val.upper()) > 0 ↩
else 0
return valid
对于单值测试,例如验证在网页表单中提交的值,每个值的列表查找效果很好。然而,对于测试大量值(如数据文件中的整列),最好是将枚举值一次性读入内存,然后重复使用它们来检查每个数据值。此外,在 Python 中,执行字典查找比列表查找效率要高得多。为此,获取法律枚举值并将它们存储为字典的键。然后通过检查每个输入值是否存在于字典键中来测试每个输入值。构建字典需要一些额外的工作量,这也是为什么check_enum_value()没有这样做的原因。但对于批量验证来说,改进的查找速度远远弥补了字典构建的开销。(要自行检查列表成员测试与字典查找的相对效率,请尝试recipes发行版的transfer目录中的lookup_time.py脚本。)
首先获取列的元数据,然后将法律枚举成员列表转换为字典:
info = get_enumorset_info(conn, db_name, tbl_name, col_name)
members={}
# convert dictionary key to consistent lettercase
for v in info['values']:
members[v.lower()] = 1
for循环使每个枚举成员存在于字典元素的键中。这里重要的是字典键;与之关联的值是无关的。(所示示例将值设置为1,但你可以使用None、0或任何其他值。)请注意,在存储之前,代码将字典键转换为小写。这是因为 Python 中的字典键查找是区分大小写的。如果你检查的值也是区分大小写的,那么这样做是可以接受的,但默认情况下,ENUM列不区分大小写。通过在存储枚举值之前将其转换为给定的大小写,然后类似地转换你想要检查的值,实际上进行了一个不区分大小写的键存在测试:
valid = 1 if val.lower() in members else 0
该示例将枚举值和输入值转换为小写。只要所有值保持一致,你也可以使用大写。
请注意,如果输入值是空字符串,则存在测试可能失败。您必须决定如何根据列处理这种情况。例如,如果列允许NULL值,您可能会将空字符串解释为等同于NULL,因此视为合法值。
对于SET值的验证过程与ENUM值类似,只是输入值可能包含任意数量的SET成员,用逗号分隔。 为了使值合法,其中的每个元素都必须合法。 另外,因为任意数量的成员
包括无
,所以空字符串是任何SET列的合法值。
对于单个输入值的一次性测试,请使用类似于check_enum_value()的实用程序例程check_set_value():
def check_set_value(conn, db_name, tbl_name, col_name, val):
valid = 0
info = get_enumorset_info(conn, db_name, tbl_name, col_name)
if info is not None and info['type'].upper() == 'SET':
if val == "":
return 1 # empty string is legal element
# use case-insensitive comparison because default collation
# (utf8mb4_0900_ai_ci) is case-insensitive (adjust if you use
# a different collation)
valid = 1 # assume valid until we find out otherwise
for v in val.split(','):
if list(map(lambda x: x.upper(), info['values'])).count(v.upper()) <= 0:
valid = 0
break
return valid
对于批量测试,请构建从合法SET成员到字典的转换。 该过程与以前显示的从ENUM元素生成字典的过程相同。
要根据SET成员字典验证给定的输入值,请将其转换为与哈希键相同的大小写,将其在逗号处分割为值的各个元素的列表,然后检查每个元素。 如果任何元素无效,则整个值无效:
valid = 1 # assume valid until we find out otherwise
for v in val.split(","):
if v.lower() not in members:
valid = 0
break
循环终止后,如果值对于SET列合法,则valid为 true,否则为 false。 空字符串始终是合法的SET值,但此代码不对空字符串执行特殊情况测试。 在这种情况下,不需要此类测试,因为split()操作返回空列表,循环永不执行,并且valid保持为 true。
14.12 使用查找表验证数据
Problem
您希望检查值以确保它们在查找表中列出。
解决方案
检查表中是否存在值的问题陈述。 最佳方法取决于输入值的数量和表的大小。 在这个示例中,我们将从发出单个语句开始讨论,然后从整个查找表创建哈希,最后通过记住已经看到的值来改进我们的算法,以避免在大数据集中多次查询数据库。
讨论
要针对查找表的内容验证输入值,技术与 Recipe 14.11 中用于检查ENUM和SET列的技术有些相似。 然而,虽然ENUM和SET列通常只有少量成员值,但查找表可以有数量基本上无限的值。 您可能不希望将它们全部读入内存中。
可以通过几种方法对输入值与查找表的内容进行验证,如下面的讨论所示。 在示例中显示的测试执行与存储在查找表中的值完全相同。 要执行不区分大小写的比较,请将所有值转换为一致的大小写。 (请参阅 Recipe 14.11 中的大小写转换讨论。)
发出单个语句
对于一次性操作,通过检查它是否在查找表中列出来测试一个值。 以下查询对于存在的值返回 true(非零),否则返回 false:
cursor.execute("select count(*) from *`tbl_name`* where val = %(val)s", {'val': *`value`*})
valid = cursor.fetchone()[0]
这种测试可能适用于诸如检查提交的 Web 表单中的值之类的目的,但对于验证大型数据集来说效率低下。它没有为先前看到的值的结果保存内存;因此,您需要为每个输入值执行查询。
从整个查找表构造哈希
要验证大量值,最好将查找值放入内存中,将它们保存在数据结构中,并检查每个输入值是否与该结构的内容匹配。使用内存中的查找可以避免执行每个值的查询的开销。
首先,运行一个查询以检索所有查找表值,并从中构建一个字典:
members = {} # dictionary for lookup values
cursor.execute("SELECT val FROM *`tbl_name`*");
rows = cursor.fetchall()
for row in rows:
members[row[0]] = 1
然后,执行字典键存在性测试以检查给定值:
valid = True if val in members else False
对于大型查找表,这种技术将数据库流量减少到单个查询。然而,这可能仍然是大量流量,并且您可能不希望将整个表保存在内存中。
记住已经看过的值,以避免数据库查找
另一种查找技术将单个语句与存储查找值存在信息的字典混合。如果你有一个非常庞大的查找表,这种方法可以很有用。从一个空字典开始:
members = {} # dictionary for lookup values
然后,对于要测试的每个值,请检查字典中是否存在。如果不存在,请执行查询以检查该值是否存在于查找表中,并记录查询结果在字典中。输入值的有效性由与键相关联的值决定,而不是键的存在:
if val not in members: # haven't seen this value yet
cursor.execute(f"SELECT COUNT(*) FROM {tbl_name} WHERE val = %(val)s",↩
{'val': val})
count = cursor.fetchone()[0]
# store true/false to indicate whether value was found
members[val] = True if count > 0 else False
valid = members[val]
对于这种方法,字典充当缓存,因此您只需为任何给定值执行一次查找查询,无论它在输入中出现多少次。对于具有重复值的数据集,这种方法避免了为每个单独的测试发出单独的查询,同时仅要求字典中的每个唯一值都有一个条目。因此,在数据库流量和程序内存需求之间的权衡方面,它处于其他两种方法之间。
请注意,对于此方法,字典的使用方式与之前的方法不同。以前,字典中输入值作为键的存在确定了值的有效性,字典键相关联的值是无关紧要的。对于字典作为缓存的方法,字典中键的存在意义从“它是有效的”变为“它已经被测试过了”。对于每个键,与之相关联的值指示输入值是否存在于查找表中。(如果你只存储那些在查找表中发现为无效值的键,那么你在输入数据集中的每个无效值实例上都会发出一个查询,这是低效的。)
14.13 将两位数年份值转换为四位数形式
问题
您想将日期值中的年份从两位数转换为四位数。
解决方案
让 MySQL 为您完成这些操作,或者如果 MySQL 的转换规则不合适,可以自行执行操作。
讨论
两位数年份值是一个问题,因为数据值中没有明确的世纪。如果你知道输入的年份范围,可以在不含歧义地添加世纪。否则,只能猜测。例如,日期 10/2/69 在大多数美国人眼中是 1969 年 10 月 2 日。但如果它代表圣雄甘地的生日,则年份实际上是 1869 年。
将年份转换为四位数的一种方法是让 MySQL 处理。如果你试图插入一个包含两位数年份的日期到YEAR列中,MySQL 会自动将其转换为四位数形式。MySQL 使用 1970 年作为过渡点;它将值从 00 到 69 解释为 2000 到 2069 年的年份,将值从 70 到 99 解释为 1970 到 1999 年的年份。这些规则适用于 1970 到 2069 年范围内的年份值。如果你的值超出此范围,存入 MySQL 之前请自行添加正确的世纪。
mysql> `SELECT` `CAST``(``69` `AS` `YEAR``)` `AS` `` ` ```69``` ` ```,`
-> `CAST``(``70` `AS` `YEAR``)` `AS` `` ` ```70``` ` ```,`
-> `CAST``(``22` `AS` `YEAR``)` `AS` `` ` ```22``` ` ```;`
+------+------+------+ | 69 | 70 | 22 |
+------+------+------+ | 2069 | 1970 | 2022 |
+------+------+------+
To use a different transition point, convert years to four-digit form yourself. Here’s a general-purpose routine that converts two-digit years to four digits and supports an arbitrary transition point:
def yy_to_yyyy(year, transition_point = 70):
if year < 100:
year += 1900 if year >= transition_point else 2000
return year
The function uses MySQL’s transition point (70) by default. An optional second argument may be given to provide a different transition point. yy_to_yyyy() also verifies that the year actually is less than 100 and needs converting before modifying it. That way you can pass year values regardless of whether they include the century. Some sample invocations using the default transition point have the following results:
val = yy_to_yyyy (60) # 返回 2060
val = yy_to_yyyy (1960) # 返回 1960(未进行转换)
Suppose that you want to convert year values as follows, using a transition point of 50:
00 .. 49 -> 2000 .. 2049
50 .. 99 -> 1950 .. 1999
To do this, pass an explicit transition point argument to yy_to_yyyy():
val = yy_to_yyyy (60, 50) # 返回 1960
val = yy_to_yyyy (1960, 50) # 返回 1960(未进行转换)
The yy_to_yyyy() function is included in the cookbook_utils.py library file of the recipes distribution.
14.14 Performing Validity Checking on Date or Time Subparts
Problem
A string passes a pattern test as a date or time, but you want to perform further validity checking.
Solution
Break the value into parts and perform the appropriate range checking on each part.
Discussion
Pattern matching may not be sufficient for date or time checking. For example, a value like 1947-15-19 might match a date pattern, but it’s not a legal date. To perform more rigorous value testing, combine pattern matching with range checking. Break out the year, month, and day values, then check whether each is within the proper range. Years should be less than 9999 (MySQL represents dates to an upper limit of 9999-12-31), month values must be in the range from 1 to 12, and days must be in the range from 1 to the number of days in the month. That last part is the trickiest: it’s month-dependent, and also year-dependent for February because it changes for leap years.
Suppose that you’re checking input dates in ISO format. In Recipe 14.9, we used the is_iso_date() function from the cookbook_utils.py library file to perform a pattern match on a date string and break it into component values. is_iso_date() returns None if the value doesn’t satisfy a pattern that matches ISO date format. Otherwise, it returns a reference to an array containing the year, month, and day values. The cookbook_utils.py file also contains is_mmddyy_date() and is_ddmmyy_date() routines that match dates in US or British format and return None or a reference to a list of date parts. (The parts returned are always in year, month, day order, not the order in which the parts appear in the input date string.)
To perform additional checking on the result returned by any of those routines (assuming that the result is not None), pass the date parts to is_valid_date(), another library function:
valid = is_valid_date(ref[0], ref[1], ref[2])
is_valid_date() returns nonzero if the date is valid, 0 otherwise. It checks the parts of a date like this:
def is_valid_date(year, month, day):
print(year, month, day)
if year < 0: # 或(month < 0)或(day < 1):
返回 0
if year > 9999 or month > 12 or day > days_in_month(year, month):
return 0
return 1
is_valid_date() requires separate year, month, and day values, not a date string. This requires that you break candidate values into components before invoking it, but makes it applicable in more contexts. For example, you can use it to check dates like 12 February 2003 by mapping the month to its numeric value before calling is_valid_date(). If is_valid_date() took a string argument assumed to be in a specific date format, it would be much less general.
is_valid_date() uses a subsidiary function days_in_month() to determine the number of days in the month represented by the date. days_in_month() requires both the year and the month as arguments because if the month is 2 (February), the number of days depends on whether the year is a leap year. This means you must pass a four-digit year value, two-digit years are ambiguous with respect to the century, which makes proper leap-year testing impossible. The days_in_month() and is_leap_year() functions are based on techniques taken from that recipe:
def is_leap_year(year):
return ((year % 4 == 0) and ((year % 100 != 0) or (year % 400 == 0) ) )
def days_in_month(year, month):
day_tbl = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
days = day_tbl[month - 1]
if month == 2 and is_leap_year(year):
days += 1
返回天数
To perform validity checking on time values, a similar procedure applies: verify that the value matches a time pattern and break it into components, then perform range-testing on the components. For times, the ranges are 0 to 23 for the hour, and 0 to 59 for the minute and second. Here is a function is_24hr_time() that checks for values in 24-hour format and returns the components:
def is_24hr_time(val):
m = re.match('^(\d{1,2})\D(\d{2})\D(\d{2})$', val)
if m is None:
return None
return[int(m.group(1)), int(m.group(2)), int(m.group(3))]
The following is_ampm_time() function is similar but looks for times in 12-hour format with an optional AM or PM suffix, converting PM times to 24-hour values:
def is_ampm_time(val):
m = re.match('^(\d{1,2})\D(\d{2})\D(\d{2})(?:\s*(AM|PM))?$', val, flags = re.I)
if m is None:
return None
(hour, min, sec) = (int(m.group(1)), (m.group(2)), (m.group(3)))
# supply missing seconds
sec = '00' if sec is None else sec
if hour == 12 and (m.group(4) is None or m.group(4).upper() == "AM"):
hour = '00' # 12:xx:xx AM times are 00:xx:xx
elif int(hour) < 12 and (m.group(4) is not None) and m.group(4).upper() == "PM":
hour = hour + 12 # PM 时间除了 12:xx:xx
return [hour, min, sec] # 返回小时、分钟、秒
Both functions return None for values that don’t match the pattern. Otherwise, they return a reference to a three-element array containing the hour, minute, and second values.
After you obtain the time components, pass them to is_valid_time(), another utility routine, to perform range checks.
14.15 Writing Date-Processing Utilities
Problem
There is a date-processing operation that you want to perform frequently.
Solution
Write a utility that performs the date-processing operation for you.
Discussion
Due to the idiosyncratic nature of dates, you might occasionally find it necessary to write date converters. This section shows some sample converters that serve various purposes:
-
isoize_date.py reads a file looking for dates in US format (
MM-DD-YY) and converts them to ISO format. -
cvt_date.py converts dates to and from any of ISO, US, or British formats. It is more general than isoize_date.py, but requires that you tell it what kind of input to expect and what kind of output to produce.
-
monddyyyy_to_iso.py looks for dates like
Feb.6,1788and converts them to ISO format. It illustrates how to map dates with nonnumeric parts to a format that MySQL understands.
All three scripts are located in the transfer directory of the recipes distribution. They assume datafiles are in tab-delimited, linefeed-terminated format. To work with files that have a different format, use cvt_file.pl, available in the recipes distribution.
Our first date-processing utility, isoize_date.py, looks for dates in US format and rewrites them into ISO format. You’ll recognize that it’s modeled after the general input-processing loop, with some extra stuff thrown in to perform a specific type of conversion:
#!/usr/bin/python3
# isoize_date.py:读取输入数据,查找与之匹配的值
# a date pattern, convert them to ISO format. Also converts
# 2-digit years to 4-digit years, using a transition point of 70.
# By default, this looks for dates in MM-DD-[CC]YY format.
# Does not check whether dates actually are valid (for example,
# won't complain about 13-49-1928).
# Assumes tab-delimited, linefeed-terminated input lines.
import sys
import re
import fileinput
# transition point at which 2-digit XX year values are assumed to be
# 19XX (below that, they are treated as 20XX)
transition = 70
for line in fileinput.input(sys.argv[1:]):
val = line.split("\t", 10000); # split, preserving all fields
for i in range(0, len(val)):
# look for strings in MM-DD-[CC]YY format
m = re.match('^(\d{1,2})\D(\d{1,2})\D(\d{2,4})$', val[i])
if not m:
continue
(month, day, year) = (int(m.group(1)), int(m.group(2)), int(m.group(3)))
# to interpret dates as DD-MM-[CC]YY instead, replace preceding
# line with the following one:
# (day, month, year) = (int(m.group(1)), int(m.group(2)), int(m.group(3)))
# convert 2-digit years to 4 digits, then update value in array
if year < 100:
year += 1900 if year >= transition else 2000
val[i] = "%04d-%02d-%02d" % (year, month, day)
print("\t".join (val))
If you feed isoize_date.py an input file that looks like this:
Sybil 04-13-70
Nancy 09-30-69
Ralph 11-02-73
Lothair 07-04-63
Henry 02-14-65
Aaron 09-17-68
Joanna 08-20-52
Stephen 05-01-60
It produces the following output:
Sybil 1970-04-13
Nancy 2069-09-30
Ralph 1973-11-02
Lothair 2063-07-04
Henry 2065-02-14
Aaron 2068-09-17
Joanna 2052-08-20
Stephen 2060-05-01
isoize_date.py serves a specific purpose: it converts only from US to ISO format. It does not perform validity checking on date subparts or permit the transition point for adding the century to be specified. A more general tool would be more useful. The next script, cvt_date.py, extends the capabilities of isoize_date.py; it recognizes input dates in ISO, US, or British formats and converts any of them to any other. It also can convert two-digit years to four digits, enable you to specify the conversion transition point, and warn about bad dates. As such, it can be used to preprocess input for loading into MySQL or postprocess data exported from MySQL for use by other programs.
cvt_date.py understands the following options:
--iformat``=format, --oformat``=format, --format``=format
Set the date format for input, output, or both. The default format value is iso; cvt_date.py also recognizes any string beginning with us or br as indicating US or British date format.
--add-century
Convert two-digit years to four digits.
--columns``=column_list
Convert dates only in the named columns. By default, cvt_date.py looks for dates in all columns. If this option is given, column_list should be a list of one or more column positions or ranges separated by commas. (Ranges can be given as m-n to specify columns m through n.) Positions begin at 1.
--transition``=n
Specify the transition point for two-digit to four-digit year conversions. The default transition point is 70. This option turns on --add-century.
--warn
Warn about bad dates. (This option can produce spurious warnings if the dates have two-digit years and you don’t specify --add-century, because leap-year testing won’t always be accurate in that case.)
We won’t show the code for cvt_date.py here (most of it is taken up with processing command-line options), but you can examine the source for yourself if you like. As an example of how cvt_date.py works, suppose that you have a file newdata.txt with the following contents:
name1 01/01/99 38
name2 12/31/00 40
name3 02/28/13 42
name4 01/02/18 44
Running the file through cvt_date.py with options indicating that the dates are in US format and that the century should be added produces this result:
$ `cvt_date.pl --iformat=us --oformat=br newdata.txt`
name1 1999-01-01 38
name2 2000-12-31 40
name3 2013-02-28 42
name4 2018-01-02 44
To produce dates in British format instead with no year conversion, do this:
$ `cvt_date.pl --iformat=us --oformat=br newdata.txt`
name1 01-01-99 38
name2 31-12-00 40
name3 28-02-13 42
name4 02-01-18 44
cvt_date.py has no knowledge of the meaning of each data column, of course. If you have a nondate column with values that match the pattern, it rewrites that column, too. To deal with that, specify a --columns option to limit the columns that cvt_date.py converts.
isoize_date.py and cvt_date.py both operate on dates written in all-numeric formats. But dates in datafiles often are written differently, and it may be necessary to write a special-purpose script to process them. Suppose an input file contains dates in the following format (these represent the dates on which US states were admitted to the Union):
Delaware Dec. 7, 1787
Pennsylvania Dec 12, 1787
New Jersey Dec. 18, 1787
Georgia Jan. 2, 1788
Connecticut Jan. 9, 1788
Massachusetts Feb. 6, 1788
…
The dates consist of a three-character month abbreviation (possibly followed by a period), a numeric day of the month, a comma, and a numeric year. To import this file into MySQL, you must convert the dates to ISO format, resulting in a file that looks like this:
Delaware 1787-12-07
Pennsylvania 1787-12-12
New Jersey 1787-12-18
Georgia 1788-01-02
Connecticut 1788-01-09
Massachusetts 1788-02-06
…
That’s a somewhat specialized kind of transformation, although this general type of problem (converting a particular date format to ISO format) is hardly uncommon. To perform the conversion, identify the dates as those values matching an appropriate pattern, map month names to the corresponding numeric values, and reformat the result. The following script, monddyyyy_to_iso.py, illustrates how:
#!/usr/bin/python3
# monddyyyy_to_iso.py: Convert dates from mon[.] dd, yyyy to ISO format.
# Assumes tab-delimited, linefeed-terminated input
import re
import sys
import fileinput
import warnings
map = {"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12
} # map 3-char month abbreviations to numeric month
for line in fileinput.input(sys.argv[1:]):
values = line.rstrip().split("\t", 10000) # split, preserving all fields
for i in range(0, len(values)):
# reformat the value if it matches the pattern, otherwise assume
# that it's not a date in the required format and leave it alone
m = re.match('^([^.]+)\.? (\d+), (\d+)$', values[i])
if m:
# use lowercase month name
(month, day, year) = (m.group(1).lower(), int(m.group(2)), int(m.group(3)))
#@ _CHECK_VALIDITY_
if month in map:
#@ _CHECK_VALIDITY_
values[i] = "%04d-%02d-%02d" % (year, map[month], day)
else:
# warn, but don't reformat
warnings.warn("%s bad date?" % (values[i]))
print("\t".join(values))
The script only does reformatting, it doesn’t validate the dates. To do that, modify the script to use the cookbook_utils.py module by adding this statement in the beginning of the script:
from cookbook_utils import *
That gives the script access to the module’s is_valid_date() routine. To use it, change this line:
if month in map:
To this:
if month in map and is_valid_date(year, map[month], day)):
14.16 Importing Non-ISO Date Values
Problem
You want to import date values, but they are not in the ISO (YYYY-MM-DD) format that MySQL expects.
Solution
Use an external utility to convert the dates to ISO format before importing the data into MySQL (cvt_date.py is useful here). Or use LOAD DATA’s capability for preprocessing input data prior to loading it into the database.
Discussion
Suppose that a table contains three columns, name, date, and value, where date is a DATE column requiring values in ISO format (YYYY-MM-DD). Suppose also that you’re given a datafile newdata.txt to be imported into the table, but its contents look like this:
name1 01/01/99 38
name2 12/31/00 40
name3 02/28/13 42
name4 01/02/18 44
The dates are in MM/DD/YY format and must be converted to ISO format to be stored as DATE values in MySQL. One way to do this is to run the file through the cvt_date.py script from Recipe 14.15:
$ `cvt_date.py --iformat=us --add-century newdata.txt > tmp.txt`
Then load the tmp.txt file into the table. This task also can be accomplished entirely in MySQL with no external utilities by using SQL to perform the reformatting operation. As discussed in Recipe 13.1, LOAD DATA can preprocess input values before inserting them. Applying that capability to the present problem, the date-rewriting LOAD DATA statement looks like this, using the STR_TO_DATE() function (see Recipe 8.3) to interpret the input dates:
mysql> `LOAD DATA LOCAL INFILE 'newdata.txt'`
-> `INTO TABLE t (name,@date,value)`
-> `SET date = STR_TO_DATE(@date,'%m/%d/%y');`
With the %y format specifier in STR_TO_DATE(), MySQL converts the two-digit years to four-digit years automatically, so the original MM/DD/YY values end up as ISO values in YYYY-MM-DD format. The resulting data after import looks like this:
+-------+------------+-------+
| name | date | value |
| --- | --- | --- |
+-------+------------+-------+
| name1 | 1999-01-01 | 38 |
| --- | --- | --- |
| name2 | 2000-12-31 | 40 |
| name3 | 2013-02-28 | 42 |
| name4 | 2018-01-02 | 44 |
+-------+------------+-------+
This procedure assumes that MySQL’s automatic conversion of two-digit years to four digits produces the correct century values. This means that the year part of the values must correspond to years in the range from 1970 to 2069. If that’s not true, you must convert the year values some other way. (For some ideas on how to do this, see Recipe 14.14.)
If the dates are not in a format that STR_TO_DATE() can interpret, perhaps you can write a stored function to handle them and return ISO date values. In that case, the LOAD DATA statement looks like this, where my_date_interp() is your stored function name:
mysql> `LOAD DATA LOCAL INFILE 'newdata.txt'`
-> `INTO TABLE t (name,@date,value)`
-> `SET date = my_date_interp(@date);`
14.17 Exporting Dates Using Non-ISO Formats
Problem
You want to export date values using a format other than MySQL’s default ISO (YYYY-MM-DD) format. This might be a requirement when exporting dates from MySQL to applications that don’t use ISO format.
Solution
Use an external utility to rewrite the dates to non-ISO format after exporting the data from MySQL (cvt_date.py is useful here). Or use the DATE_FORMAT() function to rewrite the values during the export operation.
Discussion
Suppose that you want to export data from MySQL into an application that doesn’t understand ISO-format dates. One way to do this is to export the data into a file, leaving the dates in ISO format. Then run the file through a utility such as cvt_date.py that rewrites the dates into the required format (see Recipe 14.15).
Another approach is to export the dates directly in the required format by rewriting them with DATE_FORMAT(). Suppose that you have the following table:
CREATE TABLE datetbl
(
i INT,
c CHAR(10),
d DATE,
dt DATETIME,
ts TIMESTAMP,
PRIMARY KEY(i)
);
Suppose also that you need to export data from this table, but with the dates in any DATE, DATETIME, or TIMESTAMP columns rewritten in US format (MM-DD-YYYY). A SELECT statement that uses the DATE_FORMAT() function to rewrite the dates as required looks like this:
SELECT
i,
c,
DATE_FORMAT(d, '%m-%d-%Y') AS d,
DATE_FORMAT(dt, '%m-%d-%Y %T') AS dt,
DATE_FORMAT(ts, '%m-%d-%Y %T') AS ts
FROM datetbl;
If datetbl contains the following rows:
3 abc 2005-12-31 2005-12-31 12:05:03 2005-12-31 12:05:03
4 xyz 2006-01-31 2006-01-31 12:05:03 2006-01-31 12:05:03
The statement generates output that looks like this:
3 abc 12-31-2005 12-31-2005 12:05:03 12-31-2005 12:05:03
4 xyz 01-31-2006 01-31-2006 12:05:03 01-31-2006 12:05:03
14.18 Pre-processing and Importing a File
Problem
Recall the scenario presented at the beginning of Chapter 13:
Suppose that a file named somedata.csv contains 12 data columns in comma-separated values (CSV) format. From this file you want to extract only columns 2, 11, 5, and 9, and use them to create database rows in a MySQL table that contains name
, birth
, height
, and weight
columns. You must make sure that the height and weight are positive integers, and convert the birth dates from MM/DD/YY format to YYYY-MM-DD format.
Solution
Combine techniques that we discussed in Chapter 13 and this chapter.
Discussion
Much of the work can be done using the utility programs developed in this chapter. Convert the file to tab-delimited format with cvt_file.pl, extract the columns in the desired order with yank_col.pl, and rewrite the date column to ISO format with cvt_date.py (see Recipe 14.15):
$ `cvt_file.pl --iformat=csv somedata.csv \`
`| yank_col.pl --columns=2,11,5,9 \`
`| cvt_date.py --columns=2 --iformat=us --add-century > tmp`
The resulting file, tmp, has four columns representing the name, birth, height, and weight values, in that order. It needs only to have its height and weight columns checked to make sure they contain positive integers. Using the is_positive_integer() library function from the cookbook_utils.py module file, that task can be achieved using a short special-purpose script that is little more than an input loop:
#!/usr/bin/python3
# validate_htwt.py: Height/weight validation example.
# Assumes tab-delimited, linefeed-terminated input lines.
# Input columns and the actions to perform on them are as follows:
# 1: name; echo as given
# 2: birth; echo as given
# 3: height; validate as positive integer
# 4: weight; validate as positive integer
import sys
import fileinput
import warnings
from cookbook_utils import *
line_num = 0
for line in fileinput.input(sys.argv[1:]):
line_num += 1
(name, birth, height, weight) = line.rstrip().split ("\t", 4)
if not is_positive_integer(height):
warnings.warn(f"line {line_num}:height {height} is not a positive integer")
if not is_positive_integer(weight):
warnings.warn(f"line {line_num}:weight {weight} is not a positive integer")
The validate_htwt.py script produces no output (except for warning messages) because it need not reformat any of the input values. If tmp passes validation with no errors, it can be loaded into MySQL with a simple LOAD DATA statement:
mysql> `LOAD DATA LOCAL INFILE 'tmp' INTO TABLE` *`tbl_name`*`;`
第十五章:生成和使用序列
15.0 引言
序列是按需生成的整数集合(1、2、3、...)。由于许多应用程序要求表中的每行包含唯一值,因此序列在数据库中经常使用,它们为生成这些值提供了一种简便方法。本章描述了如何在 MySQL 中以以下五种方式使用序列:
使用AUTO_INCREMENT列
AUTO_INCREMENT列是 MySQL 生成一系列行的机制。每次在包含AUTO_INCREMENT列的表中创建行时,MySQL 自动将序列中的下一个值生成为该列的值。该值作为唯一标识符,使序列成为创建项目(如客户 ID 号码、发货包裹运单号、发票或采购订单号、错误报告 ID、票号或产品序列号等)的简便方法。
检索序列值
对于许多应用程序,仅创建序列值是不够的。还必须确定刚插入行的序列值。Web 应用程序可能需要重新显示用户刚刚提交的表单内容创建的行的内容。可能需要检索该值,以便将其存储在相关表的行中。
重新排序技术
由于行删除而导致序列中存在空缺,可以重新编号序列,将删除的值重新使用到序列顶部,或者向表中没有序列列的情况下添加序列列。
管理多个同时进行的序列
在需要跟踪多个序列值(例如在创建具有AUTO_INCREMENT列的多个表行时)时,需要特别小心。
使用单行序列生成器
序列可用作计数器。例如,在投票中计算投票时,每当候选人获得一票,您可能会递增计数器。给定候选人的计数形成一个序列,但因为计数本身是唯一感兴趣的值,所以无需生成新行以记录每次投票。MySQL 提供了一种解决此问题的机制,使用此机制可以在单个表行内轻松生成序列。要在表中存储多个计数器,请使用标识每个计数器的列。相同的机制还可以使序列以非一或非均匀值递增。
大多数数据库系统的引擎提供序列生成功能,尽管实现方式可能依赖于引擎。MySQL 也是如此,因此即使在 SQL 层面上,本节中的材料几乎完全是特定于 MySQL 的。换句话说,生成序列的 SQL 本身是不可移植的,即使您使用像 DBI 或 JDBC 这样的 API 提供了一个抽象层。抽象接口可能帮助您可移植地处理 SQL 语句,但它们并不能使不可移植的 SQL 变得可移植。
本章示例相关的脚本位于recipes发行版的sequences目录中。对于在此处使用的创建表的脚本,请查看tables目录。
15.1 使用自增列生成序列
问题
你的表包含一个只能包含唯一 ID 的列,你需要插入值到这列中,确保它们是序列的一部分。
解决方案
使用AUTO_INCREMENT列生成一个序列。
讨论
这个配方提供了使用AUTO_INCREMENT列的基本背景,从展示序列生成机制的示例开始。这个示例围绕一个收集昆虫的场景展开:你的八岁儿子朱尼尔被分配了在学校的一个班级项目中收集昆虫的任务。对于每只昆虫,朱尼尔都要记录其名称(如“蚂蚁”,“蜜蜂”等),以及其收集的日期和位置。从朱尼尔还是个孩子的时候起,你就向他解释了 MySQL 在记录方面的优势,因此当你那天下班回家时,他立即宣布完成这个项目的必要性,然后直视你的眼睛,断言这显然是 MySQL 非常适合的任务。你又有什么好争论的呢?于是你们俩开始工作。小朱尼尔放学后在等你回家的时候已经收集了一些标本,并在他的笔记本中记录了以下信息:
| 名称 | 日期 | 来源 |
|---|---|---|
| 马陆 | 2014-09-10 | 车道 |
| 家蝇 | 2014-09-10 | 厨房 |
| 蚱蜢 | 2014-09-10 | 前院 |
| 臭虫 | 2014-09-10 | 前院 |
| 甘蓝蝴蝶 | 2014-09-10 | 菜园 |
| 蚂蚁 | 2014-09-10 | 后院 |
| 蚂蚁 | 2014-09-10 | 后院 |
| 白蚁 | 2014-09-10 | 厨房木制品 |
看着小朱尼尔的笔记,你高兴地发现,即使在他年幼的时候,他也已经学会了用 ISO 格式写日期。然而,你也注意到他收集了一只马陆和一只白蚁,但实际上它们都不是昆虫。你决定暂且放过他;小朱尼尔忘记带回家项目的书面说明,所以目前尚不清楚这些标本是否合适。 (你还有点惊讶地注意到小朱尼尔在家里发现了白蚁,并在心里做了个记号,准备找灭虫工处理。)
当你考虑如何创建一个表来存储这些信息时,显然你需要至少名称,日期和来源列,这些信息对应小朱尼尔需要记录的类型:
CREATE TABLE insect
(
name VARCHAR(30) NOT NULL, # type of insect
date DATE NOT NULL, # date collected
origin VARCHAR(30) NOT NULL # where collected
);
然而,这些列还不足以使表易于使用。请注意,迄今收集的记录并不唯一;两只蚂蚁是同时和地点采集的。如果将信息放入具有刚显示结构的insect表中,则无法单独引用任何蚂蚁行,因为没有什么可以区分一个蚂蚁和另一个蚂蚁。为了使行不同且提供使每行易于引用的值,唯一的 ID 将非常有帮助。AUTO_INCREMENT列非常适合此目的,因此更好的insect表的结构如下所示:
CREATE TABLE insect
(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id),
name VARCHAR(30) NOT NULL, # type of insect
date DATE NOT NULL, # date collected
origin VARCHAR(30) NOT NULL # where collected
);
继续使用这第二个CREATE TABLE语句创建insect表。(Recipe 15.2 讨论了id列定义的详细信息。)
现在,您有了一个AUTO_INCREMENT列,可以用它来生成新的序列值。AUTO_INCREMENT列的一个有用属性是,您无需自己分配其值:MySQL 会为您做这件事。有两种方法可以在id列中生成新的AUTO_INCREMENT值。一种方法是将id列明确设置为NULL。以下语句以这种方式将 Junior 的前四个标本插入insect表中:
mysql> `INSERT INTO insect (id,name,date,origin) VALUES`
-> `(NULL,'housefly','2014-09-10','kitchen'),`
-> `(NULL,'millipede','2014-09-10','driveway'),`
-> `(NULL,'grasshopper','2014-09-10','front yard'),`
-> `(NULL,'stink bug','2014-09-10','front yard');`
或者,完全省略INSERT语句中的id列。MySQL 允许创建行,而无需为具有默认值的列明确指定值。MySQL 为每个缺失的列分配其默认值,而AUTO_INCREMENT列的默认值是其下一个序列号。因此,此语句将 Junior 的其他四个标本添加到insect表中,并在根本不命名id列的情况下生成序列值:
mysql> `INSERT INTO insect (name,date,origin) VALUES`
-> `('cabbage butterfly','2014-09-10','garden'),`
-> `('ant','2014-09-10','back yard'),`
-> `('ant','2014-09-10','back yard'),`
-> `('termite','2014-09-10','kitchen woodwork');`
无论您使用哪种方法,MySQL 都会确定每行的序列号并将其分配给id列,您可以进行验证:
mysql> `SELECT * FROM insect ORDER BY id;`
+----+-------------------+------------+------------------+
| id | name | date | origin |
+----+-------------------+------------+------------------+
| 1 | housefly | 2014-09-10 | kitchen |
| 2 | millipede | 2014-09-10 | driveway |
| 3 | grasshopper | 2014-09-10 | front yard |
| 4 | stink bug | 2014-09-10 | front yard |
| 5 | cabbage butterfly | 2014-09-10 | garden |
| 6 | ant | 2014-09-10 | back yard |
| 7 | ant | 2014-09-10 | back yard |
| 8 | termite | 2014-09-10 | kitchen woodwork |
+----+-------------------+------------+------------------+
随着 Junior 收集更多标本,向表中添加更多行,它们将被分配为序列中的下一个值(9、10、…)。
AUTO_INCREMENT列背后的概念原则上非常简单:每次创建新行时,MySQL 生成序列中的下一个数字并将其分配给行。但是,有一些关于这些列需要了解的微妙之处,以及不同存储引擎处理AUTO_INCREMENT序列的差异。了解这些问题可以更有效地使用序列并避免意外。例如,如果将id列明确设置为非NULL值,则可能会发生以下两种情况之一:
-
如果该值已存在于表中,且该列不能包含重复项,则会出现错误。对于
insect表,id列是PRIMARYKEY,禁止重复:mysql> `INSERT INTO insect (id,name,date,origin) VALUES` -> `(3,'cricket','2014-09-11','basement');` ERROR 1062 (23000): Duplicate entry '3' for key 'PRIMARY' -
如果表中不存在该值,MySQL 将使用该值插入行。此外,如果该值大于当前序列计数器,表的计数器将重置为该值加一。在此时的
insect表中,序列值为 1 到 8。如果插入id列设为 20 的新行,则该值将成为新的最大值。随后自动生成id值的插入将从 21 开始。值 9 到 19 变为未使用,导致序列中出现间隔。
下一个示例将更详细地讨论如何定义AUTO_INCREMENT列及其行为。
15.2 选择序列列的数据类型
问题
您希望选择正确的数据类型来定义序列列。
解决方案
考虑序列应包含多少个唯一值,并相应选择数据类型。
讨论
在创建AUTO_INCREMENT列时,您应遵循特定的原则。例如,考虑一下 Recipe 15.1 如何声明insect表中的id列:
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
AUTO_INCREMENT关键字告知 MySQL 应为列的值生成连续的序列号,但其他信息同样重要:
-
INT是列的基本数据类型。您不一定要使用INT,但列应为整数类型之一:TINYINT、SMALLINT、MEDIUMINT、INT或BIGINT。 -
UNSIGNED禁止负列值。这不是AUTO_INCREMENT列的必需属性,但序列仅包含正整数(通常从 1 开始),因此无需允许负值。此外,不声明列为UNSIGNED会将序列范围减半。例如,TINYINT的范围是-128 到 127。因为序列仅包含正值,TINYINT序列的有效范围是 1 到 127。TINYINT UNSIGNED的范围是 0 到 255,这将序列的上限增加到 255。具体的整数类型决定了最大序列值。以下表显示了每种类型的最大无符号值;使用这些信息选择足够大以容纳您所需的最大值的类型:数据类型 最大无符号值 TINYINT255 SMALLINT65,535 MEDIUMINT16,777,215 INT4,294,967,295 BIGINT18,446,744,073,709,551,615 有时人们会省略
UNSIGNED,以便在序列列中创建包含负数的行(例如使用-1 表示没有 ID
)。这是一个坏主意。MySQL 不保证如何处理AUTO_INCREMENT列中的负数,因此使用它们相当于玩火。例如,如果重新排序列,所有负值都会变成正序列号。 -
AUTO_INCREMENT列不能包含NULL值,因此id被声明为NOTNULL。(确实,当您插入新行时,可以指定NULL作为列值,但对于AUTO_INCREMENT列,这实际上意味着生成下一个序列值。
)如果您忘记,MySQL 会自动将AUTO_INCREMENT列定义为NOTNULL。 -
AUTO_INCREMENT列必须被索引。通常情况下,由于存在顺序列来提供唯一标识符,您可以使用PRIMARYKEY或UNIQUE索引来确保唯一性。表只能有一个PRIMARYKEY,因此,如果表已经有其他PRIMARYKEY列,您可以声明AUTO_INCREMENT列为UNIQUE索引:id INT UNSIGNED NOT NULL AUTO_INCREMENT, UNIQUE (id)
当您创建包含AUTO_INCREMENT列的表时,还重要考虑使用哪种存储引擎(如 InnoDB、MyISAM 等)。引擎会影响诸如重用从顺序顶部删除的值等行为(见 Recipe 15.3)。
15.3 删除行而不更改序列
问题
您希望从包含AUTO_INCREMENT列的表中删除少数行。
解决方案
使用常规DELETE语句。MySQL 不会更改现有行的生成序列号。
讨论
到目前为止,我们考虑了 MySQL 在仅向表中添加行的情况下如何生成AUTO_INCREMENT列的序列值。但是假设永远不会删除行是不现实的。那么序列会发生什么变化?
再次参考 Junior 的昆虫收集项目,目前您拥有一个看起来像这样的insect表:
mysql> `SELECT * FROM insect ORDER BY id;`
+----+-------------------+------------+------------------+
| id | name | date | origin |
+----+-------------------+------------+------------------+
| 1 | housefly | 2014-09-10 | kitchen |
| 2 | millipede | 2014-09-10 | driveway |
| 3 | grasshopper | 2014-09-10 | front yard |
| 4 | stink bug | 2014-09-10 | front yard |
| 5 | cabbage butterfly | 2014-09-10 | garden |
| 6 | ant | 2014-09-10 | back yard |
| 7 | ant | 2014-09-10 | back yard |
| 8 | termite | 2014-09-10 | kitchen woodwork |
+----+-------------------+------------+------------------+
这将要发生变化,因为 Junior 记得带回项目的书面说明后,您阅读并发现两件影响表内容的事情:
-
标本应包括只有昆虫,而不是类似昆虫的生物,如千足虫和白蚁。
-
项目的目的是尽可能收集不同的标本,而不仅仅是尽可能多的标本。这意味着只允许一行蚂蚁。
这些指令要求从表中删除几行——具体来说,是id值为 2(千足虫)、8(白蚁)和 7(重复的蚂蚁)。因此,尽管 Junior 显然对他的收藏减少感到失望,你指示他通过发出DELETE语句来删除这些行:
mysql> `DELETE FROM insect WHERE id IN (2,8,7);`
这个声明说明为什么具有唯一 ID 值很有用:它们使您能够明确指定任何一行。蚂蚁行除了id值外完全相同。如果表中没有这一列,将更难删除其中的一行(虽然不是不可能;参见 Recipe 18.5)。
在移除不适当的行后,表中剩余的行如下:
mysql> `SELECT * FROM insect ORDER BY id;`
+----+-------------------+------------+------------+
| id | name | date | origin |
+----+-------------------+------------+------------+
| 1 | housefly | 2014-09-10 | kitchen |
| 3 | grasshopper | 2014-09-10 | front yard |
| 4 | stink bug | 2014-09-10 | front yard |
| 5 | cabbage butterfly | 2014-09-10 | garden |
| 6 | ant | 2014-09-10 | back yard |
+----+-------------------+------------+------------+
id 列现在有一个空隙(第 2 行缺失),顶部的值 7 和 8 也不再存在。这些删除操作会如何影响未来的插入操作?下一个新行会得到哪个序列号?
删除第 2 行会在序列中间创建一个空隙。这不会对后续的插入操作产生影响,因为 MySQL 不会尝试填补序列中的空隙。另一方面,删除第 7 和第 8 行会移除序列顶部的值。对于 InnoDB 或 MyISAM 表,这些值不会被重用。下一个序列号是未曾使用过的最小正整数。(例如,序列当前为 8,即使先删除第 7 和第 8 行,下一个行得到的值仍然是 9。)如果需要严格单调递增的序列,可以使用这些存储引擎。对于其他存储引擎,移除序列顶部的值可能会或者可能不会被重用。在使用之前,请检查引擎的属性。
如果表使用的引擎与您需要的值重用行为不同,请使用ALTER TABLE将表更改为更合适的引擎。例如,要更改表以使用 InnoDB(以防止在删除行后重新使用序列值),请执行以下操作:
ALTER TABLE *`tbl_name`* ENGINE = InnoDB;
如果不知道表使用的是哪种引擎,请查询INFORMATION_SCHEMA或使用SHOW TABLE STATUS或SHOW CREATE TABLE进行查询。例如,以下语句指示insect是一个 InnoDB 表:
mysql> `SELECT ENGINE FROM INFORMATION_SCHEMA.TABLES`
-> `WHERE TABLE_SCHEMA = 'cookbook' AND TABLE_NAME = 'insect';`
+--------+
| ENGINE |
+--------+
| InnoDB |
+--------+
要清空表并重置序列计数器(即使对于通常不重新使用值的引擎也是如此),请使用TRUNCATE TABLE:
TRUNCATE TABLE *`tbl_name`*;
15.4 检索序列值
问题
创建包含新序列号的行后,您希望知道该数字是多少。
解决方案
调用LAST_INSERT_ID()函数。如果您正在编写程序,MySQL API 可能提供一种直接获取值的方法,而无需发出 SQL 语句。
讨论
应用程序通常需要知道新创建行的AUTO_INCREMENT值。例如,如果您为 Junior 的insect表编写一个基于 Web 的前端用于输入行,可能会在按下提交按钮后立即在新页面上格式化显示每个新行。为了实现这一点,您必须知道新的id值,以便可以检索到正确的行。还有一种情况需要AUTO_INCREMENT值,即在使用多个表时:在主表中插入行后,需要其 ID 来创建与主表中行相关的其他相关表中的行。(Recipe 15.11 展示了如何实现这一点。)
当生成新的AUTO_INCREMENT值时,从服务器获取值的一种方法是执行调用LAST_INSERT_ID()函数的语句。此外,许多 MySQL API 提供了客户端机制,以使值在不发出其他语句的情况下可用。本篇讨论了这两种方法并比较了它们的特性。
使用 LAST_INSERT_ID() 获取 AUTO_INCREMENT 值
确定新行的 AUTO_INCREMENT 值的显而易见(但不正确)方法利用了 MySQL 生成值时成为列中最大序列号的事实。因此,您可能会尝试使用 MAX() 函数来检索它:
SELECT MAX(id) FROM insect;
这是不可靠的;如果另一个客户端在您发出 SELECT 语句之前插入一行,则 MAX(id) 返回该客户端的 ID,而不是您的。可以通过将 INSERT 和 SELECT 语句分组为事务或锁定表来解决此问题,但 MySQL 提供了一种更简单的方法来获取正确的值:调用 LAST_INSERT_ID() 函数。它返回在您的会话中生成的最近的 AUTO_INCREMENT 值,而不管其他客户端在做什么。例如,要将一行插入 insect 表并检索其 id 值,执行以下操作:
mysql> `INSERT INTO insect (name,date,origin)`
-> `VALUES('cricket','2014-09-11','basement');`
mysql> `SELECT LAST_INSERT_ID();`
+------------------+
| LAST_INSERT_ID() |
+------------------+
| 9 |
+------------------+
或者您可以使用新值检索整行,甚至不知道它是什么:
mysql> `INSERT INTO insect (name,date,origin)`
-> `VALUES('moth','2014-09-14','windowsill');`
mysql> `SELECT * FROM insect WHERE id = LAST_INSERT_ID();`
+----+------+------------+------------+
| id | name | date | origin |
+----+------+------------+------------+
| 10 | moth | 2014-09-14 | windowsill |
+----+------+------------+------------+
服务器基于会话特定方式维护由 LAST_INSERT_ID() 返回的值。这一属性是有意设计的,因为它防止客户端相互干扰。当您生成 AUTO_INCREMENT 值时,LAST_INSERT_ID() 返回该特定值,即使其他客户端在此期间向同一表中生成新行。
使用特定 API 方法获取 AUTO_INCREMENT 值
LAST_INSERT_ID() 是一个 SQL 函数,因此您可以在能够执行 SQL 语句的任何客户端中使用它。另一方面,您必须执行单独的语句以获取其值。当您编写自己的程序时,可能会有另一种选择。许多 MySQL 接口包含一个 API 特定扩展,该扩展可以返回 AUTO_INCREMENT 值,而无需执行额外的语句。我们大多数的 API 都具备此功能。
Perl
使用 mysql_insertid 属性获取语句生成的 AUTO_INCREMENT 值。此属性通过数据库句柄或语句句柄访问,具体取决于您如何发出语句。以下示例通过数据库句柄引用它:
$dbh->do ("INSERT INTO insect (name,date,origin)
VALUES('moth','2014-09-14','windowsill')");
my $seq = $dbh->{mysql_insertid};
要将 mysql_insertid 作为语句句柄属性访问,请使用 prepare() 和 execute():
my $sth = $dbh->prepare ("INSERT INTO insect (name,date,origin)
VALUES('moth','2014-09-14','windowsill')");
$sth->execute ();
my $seq = $sth->{mysql_insertid};
Ruby
Ruby Mysql2 gem 使用 last_id 方法公开客户端端 AUTO_INCREMENT 值:
client.query("INSERT INTO insect (name,date,origin)
VALUES('moth','2014-09-14','windowsill')")
seq = client.last_id
PHP
PDO 接口为 MySQL 提供了 lastInsertId() 数据库句柄方法,返回最近的 AUTO_INCREMENT 值:
$dbh->exec ("INSERT INTO insect (name,date,origin)
VALUES('moth','2014-09-14','windowsill')");
$seq = $dbh->lastInsertId ();
Python
DB API 提供的 Connector/Python 驱动程序提供了 lastrowid 游标对象属性,返回最近的 AUTO_INCREMENT 值:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO insect (name,date,origin)
VALUES('moth','2014-09-14','windowsill')
''')
seq = cursor.lastrowid
Java
Connector/J JDBC 驱动程序 getGeneratedKeys() 方法返回 AUTO_INCREMENT 值。如果在语句执行过程中提供额外的 Statement.RETURN_GENERATED_KEYS 参数以指示您要检索序列值,则它可以与 Statement 或 PreparedStatement 对象一起使用。
对于 Statement:
Statement s = conn.createStatement ();
s.executeUpdate ("INSERT INTO insect (name,date,origin)"
+ " VALUES('moth','2014-09-14','windowsill')",
Statement.RETURN_GENERATED_KEYS);
对于 PreparedStatement:
PreparedStatement s = conn.prepareStatement (
"INSERT INTO insect (name,date,origin)"
+ " VALUES('moth','2014-09-14','windowsill')",
Statement.RETURN_GENERATED_KEYS);
s.executeUpdate ();
然后,从getGeneratedKeys()生成一个新的结果集以访问序列值:
long seq;
ResultSet rs = s.getGeneratedKeys ();
if (rs.next ())
{
seq = rs.getLong (1);
}
else
{
throw new SQLException ("getGeneratedKeys() produced no value");
}
rs.close ();
s.close ();
Go
Go MySQL 驱动程序提供了Result接口的LastInsertId方法,返回最新的AUTO_INCREMENT值。
res, err := db.Exec(`INSERT INTO insect (name,date,origin)
VALUES ('moth','2014-09-14','windowsill')`)
seq, err := res.LastInsertId()
与服务器端和客户端端比较序列值检索
如前所述,服务器在会话特定基础上维护LAST_INSERT_ID()的值。相比之下,用于直接访问AUTO_INCREMENT值的 API 特定方法在客户端上实现。服务器端和客户端端序列值检索方法有一些相似之处,但也有一些不同之处。
所有方法,无论是服务器端还是客户端,都要求你在生成它的同一个 MySQL 会话中访问AUTO_INCREMENT值。如果生成了AUTO_INCREMENT值,然后在尝试访问该值之前断开与服务器的连接并重新连接,你将得到零值。在给定的会话中,服务器端的AUTO_INCREMENT值的持久性可能会更长:
-
在执行生成
AUTO_INCREMENT值的语句后,只要没有其他语句生成AUTO_INCREMENT值,该值仍然可通过LAST_INSERT_ID()访问。 -
使用客户端 API 方法可获得的序列值通常适用于每个语句,而不仅仅是生成
AUTO_INCREMENT值的语句。如果你执行生成新值的INSERT语句,然后在访问客户端序列值之前执行其他语句,那么它可能已经被设置为零。具体行为在不同的 API 之间有所不同,但为了安全起见,你可以这样做:当语句生成一个你暂时不会使用的序列值时,将该值保存在一个变量中,以便稍后引用。否则,当你尝试访问时,可能会发现序列值已被清除。(更多信息,请参见食谱 15.10。)
15.5 重新编号现有序列
问题
你的序列列中有间隙,你想重新排序它。
解决方案
首先,考虑是否需要重新排序。在许多情况下,这是不必要的。但如果必须这样做,请定期重新排序AUTO_INCREMENT列。
讨论
如果你向具有AUTO_INCREMENT列的表插入行,并且从不删除任何行,则列中的值形成一个连续的序列。如果删除行,则序列开始出现间隙。例如,Junior 的insect表目前看起来像是这样,序列中有间隙(假设你已经插入了食谱 15.4 中显示的蟋蟀和蛾行)。
mysql> `SELECT * FROM insect ORDER BY id;`
+----+-------------------+------------+------------+
| id | name | date | origin |
+----+-------------------+------------+------------+
| 1 | housefly | 2014-09-10 | kitchen |
| 3 | grasshopper | 2014-09-10 | front yard |
| 4 | stink bug | 2014-09-10 | front yard |
| 5 | cabbage butterfly | 2014-09-10 | garden |
| 6 | ant | 2014-09-10 | back yard |
| 9 | cricket | 2014-09-11 | basement |
| 10 | moth | 2014-09-14 | windowsill |
+----+-------------------+------------+------------+
MySQL 不会尝试通过填充未使用的值来消除这些间隙,当插入新行时。不喜欢这种行为的人倾向于定期重新排序AUTO_INCREMENT列以消除这些间隙。本文档中的示例展示了如何做到这一点。还可以扩展现有序列的范围(参见配方 15.6),强制顶部序列中删除的值被重复使用(参见配方 15.7),按特定顺序编号行(参见配方 15.8),或者向当前没有序列列的表添加序列列(参见配方 15.9)。
在决定重新排序AUTO_INCREMENT列之前,请考虑是否真的有必要。通常情况下并不需要,而且在某些情况下可能会导致实际问题。例如,不应该重新排序包含其他表引用值的列。重新编号这些值会破坏它们与其他表中值的对应关系,从而无法正确地将两个表中的行关联起来。
以下是一些推进重新排序列的高级原因:
美学
有些人更喜欢不间断的序列,而不是有间隙的序列。如果这是你想要重新排序的原因,我们可能无法说服你改变想法。尽管如此,这并不是一个特别好的理由。
性能
重新排序的动机可能源于这样的观念,即这样做可以通过移除间隙来“压缩”一个序列列,并使 MySQL 运行语句更快。这是不正确的。MySQL 并不关心是否有空白,而且通过重新编号AUTO_INCREMENT列并不会带来性能上的提升。事实上,重新排序会在某种意义上对性能产生负面影响,因为在 MySQL 执行此操作时表会保持锁定状态——对于大表来说可能需要相当长的时间。其他客户端在此过程中可以读取表中的数据,但试图插入新行的客户端会被阻塞,直到操作完成。
编号耗尽
序列列的数据类型和有符号性确定了其上限(参见配方 15.2)。如果一个AUTO_INCREMENT序列接近其数据类型的上限,重新编号会压缩序列并释放更多顶部的值。这可能是重新排序列的正当理由,但在许多情况下仍然是不必要的。您可以尝试更改列数据类型以增加其上限,而不改变列中存储的值;参见配方 15.6。
如果你仍然决定重新排序某列,这很容易做到:从表中删除该列,然后再放回。MySQL 会按照不间断的顺序重新编号列中的值。以下示例展示了如何使用这种技术来重新编号insect表中的id值:
mysql> `ALTER TABLE insect DROP id;`
mysql> `ALTER TABLE insect`
-> `ADD id INT UNSIGNED NOT NULL AUTO_INCREMENT FIRST,`
-> `ADD PRIMARY KEY (id);`
第一个ALTER TABLE语句删除了id列(因此也删除了PRIMARY KEY,因为它所引用的列不再存在)。第二个语句将列恢复到表中,并将其设为PRIMARY KEY。(FIRST关键字将列放在表中的第一位,这就是它最初的位置。通常情况下,ADD会将列放在表的末尾。)
当您向表中添加一个AUTO_INCREMENT列时,MySQL 会自动对所有行进行连续编号,因此insect表的内容如下所示:
mysql> `SELECT * FROM insect ORDER BY id;`
+----+-------------------+------------+------------+
| id | name | date | origin |
+----+-------------------+------------+------------+
| 1 | housefly | 2014-09-10 | kitchen |
| 2 | grasshopper | 2014-09-10 | front yard |
| 3 | stink bug | 2014-09-10 | front yard |
| 4 | cabbage butterfly | 2014-09-10 | garden |
| 5 | ant | 2014-09-10 | back yard |
| 6 | cricket | 2014-09-11 | basement |
| 7 | moth | 2014-09-14 | windowsill |
+----+-------------------+------------+------------+
使用单独的ALTER TABLE语句对列进行重新排序的一个问题是,在两次操作之间,表中将没有该列。这可能会导致其他客户端在此期间尝试访问表时遇到困难。为了防止这种情况发生,请使用单个ALTER TABLE语句执行这两个操作:
mysql> `ALTER TABLE insect`
-> `DROP id,`
-> `ADD id INT UNSIGNED NOT NULL AUTO_INCREMENT FIRST;`
MySQL 允许使用ALTER TABLE执行多个操作(并非所有数据库系统都支持)。但请注意,这种多操作语句并不简单地将两个单操作ALTER TABLE语句连接起来。不同之处在于,不需要重新建立PRIMARY KEY:除非在执行ALTER TABLE语句中指定的所有操作后,索引列已经丢失,否则 MySQL 不会删除它。
15.6 扩展序列列的范围
问题
您希望避免重新对列进行排序,但是您的新序列号已经用完了。
解决方案
检查您是否可以将列设置为UNSIGNED或更改为使用更大的整数类型。
讨论
通过扩展列的范围而不是其内容,可以避免重新排序AUTO_INCREMENT列的影响,这会改变表的结构而不是其内容:
-
如果数据类型为有符号的,请将其设置为
UNSIGNED,以加倍可用值的范围。假设当前一个id列被定义如下:id MEDIUMINT NOT NULL AUTO_INCREMENT有符号
MEDIUMINT列的上限范围为 8,388,607。要将其增加到 16,777,215,请使用ALTER TABLE使该列为UNSIGNED:ALTER TABLE *`tbl_name`* MODIFY id MEDIUMINT UNSIGNED NOT NULL AUTO_INCREMENT; -
如果您的列已经是
UNSIGNED,并且还不是最大的整数类型(BIGINT),将其转换为更大的类型会增加其范围。对于前面例子中的id列,也要使用ALTER TABLE进行转换,从MEDIUMINT转换为BIGINT,如下所示:ALTER TABLE *`tbl_name`* MODIFY id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT;
15.2 章节的配方展示了每种整数数据类型的范围,这可以帮助您选择合适的类型。
15.7 重新使用序列顶部的值
问题
您已删除了序列顶端的行,并且希望避免重新对列进行排序,但仍然要重用这些值。
解决方案
是的。使用ALTER TABLE重置序列计数器。新的序列号将从表中当前最大值加一开始。
讨论
如果您只从顺序的顶部删除了行,则剩余的行仍然按顺序排列而无间隙。(例如,如果您有从 1 到 100 编号的行,并删除了编号为 91 到 100 的行,则剩余的行仍然从 1 到 90 连续编号。)在这种特殊情况下,重新编号列是不必要的。而是告诉 MySQL 通过执行此语句恢复从最高现有序列号大一的值开始的序列计数器向下重置新行:
ALTER TABLE *`tbl_name`* AUTO_INCREMENT = 1;
如果一个序列列中间存在间隙,则可以使用ALTER TABLE重置序列计数器,但仍然只重用从顺序的顶部删除的值。它不会消除间隙。假设表包含从 1 到 10 的序列值,您删除了值为 3、4、5、9 和 10 的行。最大剩余值为 8,因此如果您使用ALTER TABLE重置序列计数器,则下一行将被赋值为 9,而不是 3。要重新排序表以消除间隙,请参见食谱 15.5。
15.8 确保行按特定顺序重新编号
问题
您重新排序了一列,但 MySQL 没有按您想要的方式对行进行编号。
解决方案
使用ORDER BY子句将行选择到另一张表中,按您想要的顺序排列,并让 MySQL 根据排序顺序对它们编号,同时执行操作。
讨论
当您重新排序AUTO_INCREMENT列时,MySQL 可以自由地从表中选择行,因此它不一定会按您期望的顺序重新编号它们。如果您唯一的要求是每行有唯一的标识符,则这一点无关紧要。但是您可能有一个应用程序,其中重要的是按特定顺序为行分配序列号。例如,您可能希望序列与创建行的顺序对应,如由TIMESTAMP列指示。要按特定顺序分配编号,请使用以下程序:
-
创建一个空表的克隆(参见食谱 6.1)。
-
使用
INSERTINTO…SELECT将行从原表复制到克隆表。除了AUTO_INCREMENT列外,复制所有列,使用ORDERBY子句指定复制行的顺序(从而指定 MySQL 分配AUTO_INCREMENT列号的顺序)。 -
删除原始表并将克隆重命名为原始表的名称。
-
如果表是一个大的 MyISAM 表并且具有多个索引,则最好最初创建新表时没有索引,除了
AUTO_INCREMENT列上的索引。然后将原表复制到新表中,并使用ALTERTABLE在之后添加其余索引。这也适用于 InnoDB。但是,InnoDB 的Change Buffer在内存中缓存对二级索引的更改,并在后台将其刷新到磁盘,从而保持插入性能良好。
另一种方法:
-
创建一个新表,其中包含原始表的所有列,除了
AUTO_INCREMENT列。 -
使用
INSERT INTO ... SELECT将原表的非AUTO_INCREMENT列复制到新表中。 -
使用
TRUNCATE TABLE在原表上清空它;这也将重置序列计数器为 1。 -
使用
ORDERBY子句将行从新表复制回原始表,以便按照所需顺序为行分配序列号。MySQL 为AUTO_INCREMENT列分配序列值。
15.9 对未排序表进行排序
问题
当您创建表时忘记包含序列列时,已经太晚了吗?可以对表行进行排序吗?
解决方案
不。使用ALTER TABLE添加AUTO_INCREMENT列;MySQL 将创建该列并对其行进行编号。
讨论
假设表包含name和age列,但没有序列列:
mysql> `SELECT * FROM t;`
+----------+------+
| name | age |
+----------+------+
| boris | 47 |
| clarence | 62 |
| abner | 53 |
+----------+------+
按以下方式向表添加名为id的序列列:
mysql> `ALTER TABLE t`
-> `ADD id INT NOT NULL AUTO_INCREMENT,`
-> `ADD PRIMARY KEY (id);`
mysql> `SELECT * FROM t ORDER BY id;`
+----------+------+----+
| name | age | id |
+----------+------+----+
| boris | 47 | 1 |
| clarence | 62 | 2 |
| abner | 53 | 3 |
+----------+------+----+
MySQL 为您编号行;您无需自行分配值。非常方便。
默认情况下,ALTER TABLE将新列添加到表的末尾。要将列放置在特定位置,请在ADD子句的末尾使用FIRST或AFTER。以下ALTER TABLE语句类似于刚刚显示的语句,但是将id列放在表中的第一位置或在name列之后。
ALTER TABLE t
ADD id INT NOT NULL AUTO_INCREMENT FIRST,
ADD PRIMARY KEY (id);
ALTER TABLE t
ADD id INT NOT NULL AUTO_INCREMENT AFTER name,
ADD PRIMARY KEY (id);
15.10 同时管理多个自增值
问题
您正在执行多个生成AUTO_INCREMENT值的语句,并且有必要独立跟踪它们。例如,您正在将行插入到具有各自AUTO_INCREMENT列的多个表中。
解决方案
将序列值保存在变量中以备后用。或者,如果您从程序内执行生成序列的语句,可能可以使用单独的连接或语句对象来发出语句,以避免混淆。
讨论
正如 Recipe 15.4 所述,LAST_INSERT_ID()服务器端序列值函数在每次语句生成AUTO_INCREMENT值时设置,而客户端序列指示器可能会在每个语句后重置。假设您发出生成AUTO_INCREMENT值的语句,但在发出第二个生成AUTO_INCREMENT值的语句之前不想引用该值呢?在这种情况下,原始值将不再可访问,无论是通过LAST_INSERT_ID()还是作为客户端值。为了保留对其的访问权,请在发出第二个语句之前先保存该值。有几种方法可以做到这一点:
-
在 SQL 级别,在生成
AUTO_INCREMENT值的语句后,将该值保存在用户定义的变量中:INSERT INTO *`tbl_name`* (id,...) VALUES(NULL,...); SET @saved_id = LAST_INSERT_ID();然后,您可以发布其他语句,而不考虑它们对
LAST_INSERT_ID()的影响。要在后续语句中使用原始的AUTO_INCREMENT值,请参考@saved_id变量。 -
在 API 级别,将
AUTO_INCREMENT值保存在 API 语言变量中。可以通过保存从LAST_INSERT_ID()或任何可用的 API 特定扩展返回的值来实现。 -
一些 API 允许您维护单独的客户端
AUTO_INCREMENT值。例如,Perl DBI 语句处理有一个mysql_insertid属性,一个处理的属性值不受另一个活动的影响。在 Java 中,使用单独的Statement或PreparedStatement对象。
参见 Recipe 15.11 以将这些技术应用于必须向包含AUTO_INCREMENT列的多个表中插入行的情况。
15.11 使用自增值关联表
问题
您可以从一张表中使用序列值作为第二张表中的键,以便将两张表中的行关联起来。但是这些关联没有正确设置。
解决方案
您可能没有按正确顺序插入行,或者丢失了序列值的跟踪。更改插入顺序,或保存序列值以便在需要时引用它们。
讨论
如果您还将AUTO_INCREMENT值用作主表中的 ID 值,并且还将该值存储在详细表行中以便将详细行链接到适当的主表行,则需要小心。假设invoice表列出了客户订单的发票信息,而inv_item表列出了与每个发票关联的各个项目。在这里,invoice是主表,inv_item是详细表。为了唯一标识每个订单,在invoice表中包括一个AUTO_INCREMENT列inv_id。还应在每个inv_item表行中存储适当的发票号码,以便确定其属于哪个发票。这些表可能如下所示:
CREATE TABLE invoice
(
inv_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (inv_id),
date DATE NOT NULL
# ... other columns could go here
# ... (customer ID, shipping address, etc.)
);
CREATE TABLE inv_item
(
inv_id INT UNSIGNED NOT NULL, # invoice ID (from invoice table)
INDEX (inv_id),
qty INT, # quantity
description VARCHAR(40) # description
);
对于这种表关系,通常首先向主表插入一行(以生成标识该行的AUTO_INCREMENT值),然后使用LAST_INSERT_ID()插入详细行以获取主行 ID。如果客户购买了一把锤子、三盒钉子以及(为了防止用锤子时砸伤手指)一打绷带,与订单相关的行可以像这样插入两张表:
INSERT INTO invoice (inv_id,date)
VALUES(NULL,CURDATE());
INSERT INTO inv_item (inv_id,qty,description)
VALUES(LAST_INSERT_ID(),1,'hammer');
INSERT INTO inv_item (inv_id,qty,description)
VALUES(LAST_INSERT_ID(),3,'nails, box');
INSERT INTO inv_item (inv_id,qty,description)
VALUES(LAST_INSERT_ID(),12,'bandage');
第一个INSERT将一行添加到invoice主表中,并为其inv_id列生成新的AUTO_INCREMENT值。随后的INSERT语句每个都向inv_item详细表中添加一行,并使用LAST_INSERT_ID()获取发票号码。这样可以将详细行与适当的主行关联起来。
如果您有多个要处理的发票怎么办?有正确的方法和错误的方法来输入信息。正确的方法是首先插入第一张发票的所有信息,然后继续下一张。错误的方法是将所有主行添加到invoice表中,然后将所有详细行添加到inv_item表中。如果这样做,inv_item表中所有新的详细行都将具有最近输入的invoice行的AUTO_INCREMENT值。因此,所有项目看起来都属于该发票,并且两个表中的行没有正确的关联。
如果详细表包含自己的AUTO_INCREMENT列,则在向表中添加行时必须更加小心。假设您希望inv_item表中的每一行都具有唯一标识符。为此,请按照以下方式创建inv_item表,其中包含名为item_id的AUTO_INCREMENT列:
CREATE TABLE inv_item
(
inv_id INT UNSIGNED NOT NULL, # invoice ID (from invoice table)
item_id INT UNSIGNED NOT NULL AUTO_INCREMENT, # item ID
PRIMARY KEY (item_id),
qty INT, # quantity
description VARCHAR(40) # description
);
inv_id列使每个inv_item行能够与正确的invoice表行关联,就像原始表结构一样。此外,item_id唯一标识每个项目行。但是,现在两个表都包含AUTO_INCREMENT列,您不能像以前那样输入发票信息了。如果执行之前显示的INSERT语句,由于inv_item表结构的更改,它们现在会产生不同的结果。对invoice表的INSERT正常工作。同样对inv_item表的第一个INSERT也是如此;LAST_INSERT_ID()返回invoice表中主行的inv_id值。然而,此INSERT也生成自己的AUTO_INCREMENT值(用于item_id列),这会改变LAST_INSERT_ID()的值,并导致主行的inv_id值“丢失”。因此,inv_item表中的每个后续插入将上一行的item_id值存储到inv_id列中。这导致第二行及后续行的inv_id值不正确。
为避免这种困难,请保存插入主表生成的序列值,并在插入详细表时使用保存的值。要保存该值,请使用用户定义的 SQL 变量或程序维护的变量。Recipe 15.10 描述了这些技术,在此适用如下:
-
使用用户定义的变量:将主行
AUTO_INCREMENT值保存在用户定义的变量中,以便在插入详细行时使用:INSERT INTO invoice (inv_id,date) VALUES(NULL,CURDATE()); SET @inv_id = LAST_INSERT_ID(); INSERT INTO inv_item (inv_id,qty,description) VALUES(@inv_id,1,'hammer'); INSERT INTO inv_item (inv_id,qty,description) VALUES(@inv_id,3,'nails, box'); INSERT INTO inv_item (inv_id,qty,description) VALUES(@inv_id,12,'bandage'); -
使用程序维护的变量:此方法类似于前面的方法,但仅适用于 API 内部。插入主行,然后将
AUTO_INCREMENT值保存到 API 变量中,在插入详细行时使用。例如,在 Ruby 中,使用last_id方法访问AUTO_INCREMENT值:client.query("INSERT INTO invoice (inv_id,date) VALUES(NULL,CURDATE())") inv_id = client.last_id sth = client.prepare("INSERT INTO inv_item (inv_id,qty,description) VALUES(?,?,?)") sth.execute(inv_id, 1, "hammer") sth.execute(inv_id, 3, "nails, box") sth.execute(inv_id, 12, "bandage")
15.12 使用序列生成器作为计数器
问题
您只对计数事件感兴趣,因此希望避免为每个序列值创建新的表行。
解决方案
每个计数器递增单行。
讨论
AUTO_INCREMENT 列对于在一组单独的行中生成序列非常有用。但有些应用程序仅需要计数事件发生的次数,并且创建单独行并不会有任何好处。例如,网页或横幅广告点击计数器、销售商品的计数或投票数量。这些应用程序只需一个行来保存随时间变化的计数。MySQL 提供了一种机制,使得可以像处理 AUTO_INCREMENT 值一样处理计数,因此不仅可以增加计数,还可以轻松检索更新后的值。
若要计数单一类型的事件,使用一个带有单行和单列的简单表。例如,记录出售书籍的副本数,创建如下表:
CREATE TABLE booksales (copies INT UNSIGNED);
然而,如果您要计算多本书的销售次数,这种方法效果不佳。您肯定不希望为每本书创建单独的单行计数表。相反,通过在单个表中包含一个唯一标识每本书的列来在同一表中计数它们所有。以下表格使用 title 列用于书名,另外一个 copies 列记录销售副本数:
CREATE TABLE booksales
(
title VARCHAR(60) NOT NULL, # book title
copies INT UNSIGNED NOT NULL, # number of copies sold
PRIMARY KEY (title)
);
记录给定书籍的销售,有多种方法:
-
为书籍初始化一行,
copies值为 0:INSERT INTO booksales (title,copies) VALUES('The Greater Trumps',0);然后为每个销售递增
copies值:UPDATE booksales SET copies = copies+1 WHERE title = 'The Greater Trumps';此方法要求您记得为每本书初始化一行,否则
UPDATE将失败。 -
使用
INSERT和ONDUPLICATEKEYUPDATE,它会为首次销售初始化计数为 1 的行,并递增后续销售的计数:INSERT INTO booksales (title,copies) VALUES('The Greater Trumps',1) ON DUPLICATE KEY UPDATE copies = copies+1;这更简单,因为相同的语句用于初始化和更新销售计数。
检索销售计数(例如,向客户显示消息,如你刚购买了此书的第
),发出 n 本SELECT 查询获取相同书名:
SELECT copies FROM booksales WHERE title = 'The Greater Trumps';
不幸的是,这并不完全正确。假设在您更新和检索计数之间的时间段内,其他人购买了这本书的一本副本(从而增加了 copies 值)。那么 SELECT 语句实际上不会产生您增加的销售计数值,而是最近的值。换句话说,其他客户端可能在您检索之前影响值。这类似于在 Recipe 15.4 中讨论的问题,如果您尝试通过调用 MAX(col_name) 而不是 LAST_INSERT_ID() 来检索列中最近的 AUTO_INCREMENT 值时可能会出现的问题。
有一些方法可以解决这个问题(例如将两个语句作为事务分组或锁定表),但 MySQL 基于 LAST_INSERT_ID() 提供了一个更简单的解决方案。如果您使用表 booksales,稍微修改增加计数的语句如下:
INSERT INTO booksales (title,copies)
VALUES('The Greater Trumps',LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE copies = LAST_INSERT_ID(copies+1);
该语句同时用于初始化和递增计数的 LAST_INSERT_ID(expr) 构造。MySQL 将表达式参数视为 AUTO_INCREMENT 值,因此稍后可以调用不带参数的 LAST_INSERT_ID() 来检索该值:
SELECT LAST_INSERT_ID();
通过这种方式设置和检索 copies 列,即使其他客户端在此期间对其进行了更新,您也始终可以获得您设置的值。如果您在提供直接获取最新 AUTO_INCREMENT 值机制的 API 内发出 INSERT 语句,甚至无需发出 SELECT 查询。例如,使用 Connector/Python 更新计数并使用 lastrowid 属性获取新值:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO booksales (title,copies)
VALUES('The Greater Trumps',LAST_INSERT_ID(1))
ON DUPLICATE KEY UPDATE copies = LAST_INSERT_ID(copies+1)
''')
count = cursor.lastrowid
cursor.close()
conn.commit()
在 Java 中,操作如下所示:
Statement s = conn.createStatement ();
s.executeUpdate (
"INSERT INTO booksales (title,copies)"
+ "VALUES('The Greater Trumps',LAST_INSERT_ID(1))"
+ "ON DUPLICATE KEY UPDATE copies = LAST_INSERT_ID(copies+1)",
Statement.RETURN_GENERATED_KEYS);
long count;
ResultSet rs = s.getGeneratedKeys ();
if (rs.next ())
{
count = rs.getLong (1);
}
else
{
throw new SQLException ("getGeneratedKeys() produced no value");
}
rs.close ();
s.close ();
使用 LAST_INSERT_ID(expr) 生成序列时具有某些与真正的 AUTO_INCREMENT 序列不同的属性:
-
AUTO_INCREMENT值每次递增 1,而由LAST_INSERT_ID(expr)生成的值可以是任何您想要的非负值。例如,要生成序列 10, 20, 30, …,每次递增 10。甚至您无需每次以相同的值递增计数器。如果您出售一本书的一打副本而不是单本,更新其销售计数如下:INSERT INTO booksales (title,copies) VALUES('The Greater Trumps',LAST_INSERT_ID(12)) ON DUPLICATE KEY UPDATE copies = LAST_INSERT_ID(copies+12); -
为了重置计数器,只需将其设置为所需的值。假设您想向图书买家报告本月的销售情况,而不是总销售额(例如,显示类似于
你是本月的第
的消息)。为了在每个月初将计数器清零,请使用以下语句:n位买家UPDATE booksales SET copies = 0; -
其中一个不太理想的属性是,由
LAST_INSERT_ID(expr)生成的值在所有情况下都不能通过客户端检索方法均匀地获取。您可以在UPDATE或INSERT语句之后获取它,但不能在SET语句中获取。如果您像以下示例(在 Ruby 中)生成值,则insert_id返回的客户端值为 0,而不是 48:client.query("SET @x = LAST_INSERT_ID(48)") seq = client.last_id在这种情况下获取值,向服务器请求即可:
seq = client.query("SELECT LAST_INSERT_ID()").first.values[0]
15.13 生成重复序列
问题
您需要一个包含周期的序列。
解决方案
使用除法和模运算在序列中创建周期。
讨论
有些序列生成问题需要经历循环值。假设您制造药品或汽车零件等物品,并且如果稍后发现需要召回特定批次内出售的物品,则必须能够通过批号跟踪它们。假设您将物品装箱并分发,每箱 12 个单位,每箱 6 个箱子,这种情况下,物品标识符是三部分值:单位号(值从 1 到 12)、箱子号(值从 1 到 6)和批号(值从 1 到当前最高箱数)。
这个项目跟踪问题似乎要求您维护三个计数器,因此您可以使用类似以下算法来生成下一个标识符值:
retrieve most recently used case, box, and unit numbers
unit = unit + 1 # increment unit number
if (unit > 12) # need to start a new box?
{
unit = 1 # go to first unit of next box
box = box + 1
}
if (box > 6) # need to start a new case?
{
box = 1 # go to first box of next case
case = case + 1
}
store new case, box, and unit numbers
或者,可以简单地为每个物品分配一个序列号标识符,并从中派生相应的案例、箱子和单位号。标识符可以来自于AUTO_INCREMENT列或单行序列生成器。用于根据其序列号确定任何物品的案例、箱子和单位号的公式如下所示:
unit_num = ((seq - 1) % 12) + 1
box_num = (int ((seq - 1) / 12) % 6) + 1
case_num = int ((seq - 1)/(6 * 12)) + 1
以下表格展示了一些样本序列号与相应的案例、箱子和单元号之间的关系:
| seq | case | box | unit |
|---|---|---|---|
| 1 | 1 | 1 | 1 |
| 12 | 1 | 1 | 12 |
| 13 | 1 | 2 | 1 |
| 72 | 1 | 6 | 12 |
| 73 | 2 | 1 | 1 |
| 144 | 2 | 6 | 12 |
15.14 使用自定义增量值
问题
您希望递增序列不是一,而是另一个数字。
解决方案
使用系统变量auto_increment_increment和auto_increment_offset。
讨论
默认情况下,MySQL 通过一个具有AUTO_INCREMENT选项的列递增值。这并不总是理想的。假设您有三台服务器的复制链(Recipe 3.9):Venus、Mars、Saturn,并且希望区分插入值的来源服务器。
解决此问题的最简单方法是为在Venus上插入的行分配1, 4, 7, 10, ...序列值;为在Mars上插入的行分配2, 5, 8, 11, ...序列值;为在Saturn上插入的行分配3, 6, 9, 12, ...序列值。
要执行此操作,请将系统变量auto_increment_increment的值设置为服务器数量:在我们的情况下为三(3),因此 MySQL 将按三递增序列值。然后在Venus上将auto_increment_offset设置为 1,在Mars上设置为 2,在Saturn上设置为 3。这将指示 MySQL 从指定值开始新的序列。
Venus> `SET` `auto_increment_offset``=``1``;`
Query OK, 0 rows affected (0.00 sec)
Venus> `SET` `auto_increment_increment``=``3``;`
Query OK, 0 rows affected (0.00 sec)
Mars> `SET` `auto_increment_offset``=``2``;`
Query OK, 0 rows affected (0.00 sec)
Mars> `SET` `auto_increment_increment``=``3``;`
Query OK, 0 rows affected (0.00 sec)
Saturn> `SET` `auto_increment_offset``=``3``;`
Query OK, 0 rows affected (0.00 sec)
Saturn> `SET` `auto_increment_increment``=``3``;`
Query OK, 0 rows affected (0.00 sec)
警告
我们为示例设置了会话变量,但如果您不仅想影响自己的会话,还想影响服务器上所有连接,您需要使用SET GLOBAL。要在重新启动后保留配置更改,请在配置文件中设置这些值,或者从版本 8.0 开始,使用SET PERSIST命令。
如果您已经有带有自增列的表,请使用语句指定偏移量:
ALTER TABLE *`mytable`* AUTO_INCREMENT = *`N`*;
警告
并非所有引擎都支持在CREATE TABLE和ALTER TABLE中使用AUTO_INCREMENT选项。在这种情况下,您可以通过插入具有所需值的行,然后删除它来设置自增列的起始值。
在准备工作完成后,MySQL 将使用auto_increment_increment值生成下一个序列号。
Venus> `CREATE` `TABLE` `offset``(`
-> `id` `INT` `NOT` `NULL` `AUTO_INCREMENT` `PRIMARY` `KEY``,`
-> `host` `CHAR``(``32``)`
-> `)``;`
Query OK, 0 rows affected (0.03 sec)
Venus> `INSERT` `INTO` `offset``(``host``)` `VALUES``(``@``@``hostname``)``;` 
Query OK, 1 row affected (0.01 sec)
Venus> `INSERT` `INTO` `offset``(``host``)` `VALUES``(``@``@``hostname``)``;`
Query OK, 1 row affected (0.01 sec)
Venus> `INSERT` `INTO` `offset``(``host``)` `VALUES``(``@``@``hostname``)``;`
Query OK, 1 row affected (0.01 sec)
Venus> `SELECT` `*` `FROM` `offset``;` 
+----+-------+ | id | host |
+----+-------+ | 1 | Venus |
| 4 | Venus |
| 7 | Venus |
+----+-------+ 3 rows in set (0.00 sec)
Mars> `ALTER` `TABLE` `offset` `AUTO_INCREMENT``=``2``;` 
Query OK, 0 rows affected (0.36 sec)
Records: 0 Duplicates: 0 Warnings: 0
Mars> `INSERT` `INTO` `offset``(``host``)` `VALUES``(``'Mars'``)``;`
Query OK, 1 row affected (0.00 sec)
Mars> `INSERT` `INTO` `offset``(``host``)` `VALUES``(``'Mars'``)``;`
Query OK, 1 row affected (0.01 sec)
Mars> `SELECT` `*` `FROM` `offset``;`
+----+-------+ | id | host |
+----+-------+ | 1 | Venus |
| 4 | Venus |
| 7 | Venus |
| 8 | Mars | 
| 11 | Mars |
+----+-------+ 5 rows in set (0.00 sec)
系统变量hostname包含 MySQL 主机的值。我们用它来区分不同的机器。
在Venus上,序列从一开始,我们有预期的值:1, 4, 7。
在Mars上的表已经存在。ALTER TABLE命令设置AUTO_INCREMENT序列的偏移量为所需的值。
由于offset表在 Mars 上已经有行,新的AUTO_INCREMENT值从 8 开始,属于序列2, 5, 8, 11, ...。
15.15 使用窗口函数为结果集编号行
问题
您希望枚举SELECT查询的结果。
解决方案
使用窗口函数ROW_NUMBER()。
讨论
序列不仅在将数据存储在表中时有用,而且在处理查询结果时也很有用。
假设您正在进行歌唱比赛。每个才能应该按顺序呈现。为了让每个人都有平等的机会,队列中的位置应该随机定义。
天赋存储在name表中。要以随机顺序检索它们,请使用函数RAND():
mysql> `SELECT` `first_name``,` `last_name` `FROM` `name` `ORDER` `BY` `RAND``(``)``;`
+------------+-----------+ | first_name | last_name |
+------------+-----------+ | Pete | Gray |
| Vida | Blue |
| Rondell | White |
| Kevin | Brown |
| Devon | White |
+------------+-----------+ 5 rows in set (0.00 sec)
每次调用此查询都会返回不同顺序的名称列表。
窗口函数可以针对结果集中的每一行执行计算,我们可以使用它们创建一个新列,其中包含歌手表演的顺序。
窗口函数在我们的情况下适用于特定窗口,即SELECT查询。它们在执行时可以访问多行,但为窗口中的每行生成结果。
mysql> SELECT
-> ROW_NUMBER() OVER *`win`* AS turn, 
-> first_name, last_name FROM name 
-> WINDOW *`win`* 
-> AS (ORDER BY RAND()); 
+------+------------+-----------+
| turn | first_name | last_name |
+------+------------+-----------+
| 1 | Devon | White |
| 2 | Kevin | Brown |
| 3 | Rondell | White |
| 4 | Vida | Blue |
| 5 | Pete | Gray |
+------+------------+-----------+
5 rows in set (0.00 sec)
函数ROW_NUMBER()定义了歌唱日程中的位置。
我们希望在查询结果中看到name表中的其他列。
关键字WINDOW定义了一个命名窗口,我们将在其中使用函数ROW_NUMBER。
将窗口随机排序以获得公平的队列分布。
函数ROW_NUMBER()的另一个常见用途是生成一系列标识符,稍后可以用于将SELECT结果与另一个表连接。我们在食谱 15.16 的一个示例中讨论了这种方法。
参见
有关窗口函数的更多信息,请参阅窗口函数概念和语法。
15.16 使用递归 CTE 生成系列
问题
您想创建一个自定义序列,例如几何级数或斐波那契数。
解决方案
使用递归公共表表达式(CTEs)根据自定义公式创建序列。
讨论
序列不应总是算术级数。它们可以是任何类型的级数,甚至是随机数或字符串。
创建自定义序列的一种方法是使用递归 CTE。它们是允许自引用的命名临时结果集。基本的递归 CTE 语法是:
WITH RECURSIVE *`name`*(*`column`*[, *`column`*])
(SELECT *`expressin`*[, *`expression`*]
UNION ALL
SELECT *`expressin`*[, *`expression`*]
FROM name WHERE ...)
SELECT * FROM name;
因此,要生成从二开始,公比为二的几何级数,请使用以下 CTE。
mysql> `WITH` `RECURSIVE` `geometric_progression``(``id``)` `AS`
-> `(``SELECT` `2` 
-> `UNION` `ALL`
-> `SELECT` `id` `*` `2` 
-> `FROM` `geometric_progression`
-> `LIMIT` `5``)` 
-> `SELECT` `*` `FROM` `geometric_progression``;`
+------+ | id |
+------+ | 2 |
| 4 |
| 8 |
| 16 |
| 32 |
+------+ 5 rows in set (0.00 sec)
序列的起始值。
几何级数中的所有后续值均为前一个数字乘以公比。
为了限制生成的数字数量并避免无限循环,可以使用LIMIT子句或任何有效的WHERE条件。
递归 CTE 允许同时创建多个序列。例如,我们可以使用它们来创建:
-
一个 ID,将使用常规算术级数,从一开始,公差为一
-
从三开始,公比为四的几何级数
-
一个介于一和五之间的随机数
要在单个查询中创建所有这些内容,请使用以下递归 CTE。
mysql> `WITH` `RECURSIVE` `sequences``(``id``,` `geo``,` `random``)` `AS`
-> `(``SELECT` `1``,` `3``,` `FLOOR``(``1``+``RAND``(``)``*``5``)`
-> `UNION` `ALL`
-> `SELECT` `id` `+` `1``,` `geo` `*` `4``,` `FLOOR``(``1``+``RAND``(``)``*``5``)`
-> `FROM` `sequences`
-> `WHERE` `id` `<` `5``)`
-> `SELECT` `*` `FROM` `sequences``;`
+------+------+--------+ | id | geo | random |
+------+------+--------+ | 1 | 3 | 4 |
| 2 | 12 | 4 |
| 3 | 48 | 2 |
| 4 | 192 | 2 |
| 5 | 768 | 3 |
+------+------+--------+ 5 rows in set (0.00 sec)
为了说明自定义序列的使用,假设我们正在开发一种新的数据恐惧疫苗,并希望开始其第三阶段试验。第三阶段包括对真实疫苗和安慰剂进行测试。剂量将随机分配给志愿者。为了执行此试验,我们将使用patients表,并选取尚未诊断为数据恐惧的志愿者。我们生成一系列两个随机值,并根据结果分配真实疫苗或安慰剂。
mysql> `WITH` `RECURSIVE` `trial``(``id``,` `dose``)` `AS`
-> `(``SELECT` `1``,` `IF``(``1``=``FLOOR``(``1``+``RAND``(``)``*``2``)``,` `'Vaccine'``,` `'Placebo'``)` 
-> `UNION` `ALL`
-> `SELECT` `id``+``1``,` `IF``(``1``=``FLOOR``(``1``+``RAND``(``)``*``2``)``,` `'Vaccine'``,` `'Placebo'``)`
-> `FROM` `trial`
-> `WHERE` `id` `<` `(``SELECT` `COUNT``(``*``)` `FROM` `patients`
-> `WHERE` `diagnosis` `!``=` `'Data Phobia'` `and` `result` `!``=` `'D'``)``)``,` 
-> `volunteers` `AS` 
-> `(``SELECT` `ROW_NUMBER``(``)` `OVER` `win` `AS` `id``,` 
-> `national_id``,` `name``,` `surname`
-> `FROM` `patients` `WHERE` `diagnosis` `!``=` `'Data Phobia'` `and` `result` `!``=` `'D'`
-> `WINDOW` `win` `AS` `(``ORDER` `BY` `surname``)``)`
-> `SELECT` `national_id``,` `name``,` `surname``,` `dose` 
-> `FROM` `trial` `JOIN` `volunteers` `USING``(``id``)``;`
+-------------+-----------+-----------+---------+ | national_id | name | surname | dose |
+-------------+-----------+-----------+---------+ | 84DC051879 | William | Brown | Vaccine |
| 78FS043029 | David | Davis | Vaccine |
| 38BP394037 | Catherine | Hernandez | Placebo |
| 28VU492728 | Alice | Jackson | Vaccine |
| 71GE601633 | John | Johnson | Vaccine |
| 09SK434607 | Richard | Martin | Placebo |
| 30NC108735 | Robert | Martinez | Placebo |
| 02WS884704 | Sarah | Miller | Placebo |
| 45MY529190 | Patricia | Rodriguez | Vaccine |
| 89AR642465 | Mary | Smith | Placebo |
| 99XC682639 | Emma | Taylor | Vaccine |
| 04WT954962 | Peter | Wilson | Vaccine |
+-------------+-----------+-----------+---------+ 12 rows in set (0.00 sec)
函数FLOOR(1+RAND()2)生成两个随机数:一或二。函数IF*充当三元运算符:如果第一个参数为真,则返回第二个参数,否则返回第三个参数。
我们不希望已经被诊断为数据恐惧的患者参与我们的测试,同样我们也不能在未康复的患者身上测试我们的疫苗。
尽管patients表具有AUTO_INCREMENT列id,但我们不能使用它,因为这样无法排除不参与测试的患者。因此,我们使用 CTE 创建命名结果集volunteers并为其生成自己的序列。
函数 ROW_NUMBER() 为参与测试的患者生成新的序列。
将随机值生成的剂量和命名结果集 volunteers 的生成 id 连接,但不包括在最终结果集中。
See Also
有关公共表达式的更多信息,请参见 Recipe 10.18。
15.17 创建和存储自定义序列
问题
您想要在表中使用自定义序列作为存储的 id 列。
解决方案
创建一个用于保存序列值的表,并创建一个函数来更新和选择这些值。
讨论
尽管 MySQL 不支持 SQL 的 SEQUENCE 对象,但模拟一个相当容易。
首先,您需要创建一个用于保存序列的表。
CREATE TABLE `sequences` (
`sequence_name` varchar(64) NOT NULL,
`maximum_value` bigint NOT NULL DEFAULT '9223372036854775807',
`minimum_value` bigint NOT NULL DEFAULT '-9223372036854775808',
`increment` bigint NOT NULL DEFAULT '1',
`start_value` bigint NOT NULL DEFAULT '-9223372036854775808',
`current_base_value` bigint NOT NULL DEFAULT '-9223372036854775808',
`cycle_option` enum('yes','no') NOT NULL DEFAULT 'no',
PRIMARY KEY (`sequence_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
对于这个示例,我们使用了与 MySQL 工程团队计划实现的 WL#827: SEQUENCE object as in Oracle, PostgreSQL, and/or SQL:2003 相同的表定义。这个定义对于实际序列实现并不是必须的,可能更简单或包含更多选项。
表 sequences 中的列都有特殊含义。 表 15-1 显示了它们的含义。
表 15-1. 表 sequences 中的列
| 列 | 描述 | 注释 |
|---|---|---|
sequence_name |
序列的名称。 | 必需字段,应该是唯一的。 |
maximum_value |
序列可以生成的最大值。 | 在我们的自定义序列中,我们允许负值,因此最大可能值是 9223372036854775807,这是BIGINT SIGNED数据类型的最大值。如果将此列设置为BIGINT UNSIGNED,则序列可以有两倍的值。这个选项对序列生成并不是关键,可以跳过。 |
minimum_value |
序列的最小值。 | 在我们的案例中,默认值是 -9223372036854775808,这是 BIGINT SIGNED 类型的最小值。根据您想要创建的自定义序列方式,此列可能会被省略或具有不同的类型或不同的默认值。 |
increment |
序列的增量。 | SQL 标准定义了使用算术级数的序列。该列包含了进度的公共差异。这是必需的字段。如果您创建自定义序列,比如等比级数,您可能在此字段中有一个公共比率或任何其他值,以便生成下一个值。 |
start_value |
序列将从哪个值开始。 | 对于实现句子并不是必要的字段。在我们的案例中,默认值是 minimum_value。 |
current_base_value |
当序列请求下一个值时应返回的值。返回后应替换为新生成的值。 | 这是必需的字段。默认值与 start_value 相同。 |
cycle_option |
序列是否支持循环? | 如果启用,当序列达到其minimum_value或maximum_value时,序列将重置为start_value。 |
然后我们需要创建一个存储过程,该过程将更新表sequences。
CREATE PROCEDURE create_sequence(
sequence_name VARCHAR(64), start_value BIGINT, increment BIGINT,
cycle_option ENUM('yes','no'), maximum_value BIGINT, minimum_value BIGINT)
BEGIN
INSERT INTO sequences
(sequence_name, maximum_value, minimum_value, increment, start_value,
current_base_value, cycle_option)
VALUES(
sequence_name,
COALESCE(maximum_value, 9223372036854775807),
COALESCE(minimum_value, -9223372036854775808),
COALESCE(increment, 1),
COALESCE(start_value, -9223372036854775808),
COALESCE(start_value, -9223372036854775808),
COALESCE(cycle_option, 'no'));
END;
注意
使用存储过程而不是直接更新表sequences具有许多优点:
-
每次使用序列时无需关心如何更新
current_base_value。 -
如果启用了
cycle_option,则无需放置代码,当达到序列边界时每次都会重置current_base_value。 -
你可以限制除管理员外的任何人对表
sequences的直接访问,同时允许应用程序用户使用序列。详细信息请参见 Recipe 24.13。
MySQL 不允许我们使用可变数量的参数调用存储函数。函数COALESCE允许在希望使用默认值的参数位置传递NULL值时放置默认值。
mysql> `CALL` `create_sequence``(``'bar'``,` `1``,` `1``,` `'no'``,` `9223372036854775807``,` `-``9223372036854775808``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `CALL` `create_sequence``(``'baz'``,` `1``,` `1``,` `'yes'``,` `10``,` `1``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `call` `create_sequence``(``'foo'``,``null``,``null``,``null``,` `null``,` `null``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `*` `FROM` `sequences``\``G`
*************************** 1. row ***************************
sequence_name: bar
maximum_value: 9223372036854775807
minimum_value: 1
increment: 1
start_value: 1
current_base_value: 1
cycle_option: no
*************************** 2. row ***************************
sequence_name: baz
maximum_value: 10
minimum_value: 1
increment: 1
start_value: 1
current_base_value: 1
cycle_option: yes
*************************** 3. row ***************************
sequence_name: foo
maximum_value: 9223372036854775807
minimum_value: -9223372036854775808
increment: 1
start_value: -9223372036854775808
current_base_value: -9223372036854775808
cycle_option: no
3 rows in set (0.00 sec)
在上面的示例中,我们首先创建了序列bar,从 1 开始,增量为 1,不具有循环选项,并具有默认的maximum_value 9223372036854775807。然后,我们创建了序列baz,也从 1 开始,增量为 1,但启用了cycle_option,maximum_value为 10,因此循环速度很快。最后,我们创建了序列foo,只具有自定义名称和所有其他默认值。
要获取下一个序列值并同时更新序列表,我们将使用一个存储函数。
CREATE FUNCTION sequence_next_value(name varchar(64)) RETURNS BIGINT
BEGIN
DECLARE retval BIGINT;
SELECT current_base_value INTO retval FROM sequences
WHERE sequence_name=name FOR UPDATE;
UPDATE sequences SET current_base_value=
IF((current_base_value+increment <= maximum_value
AND current_base_value+increment >= minimum_value),
current_base_value+increment,
IF('yes' = cycle_option, start_value, NULL)
) WHERE sequence_name=name;
RETURN retval;
END;
该函数首先使用语句SELECT ... FOR UPDATE检索序列的current_base_value,因此在我们返回值之前,其他连接不会修改序列。
我们的函数支持循环。在启用cycle_option且下一个序列值超出边界的情况下,它将current_base_value设置为由start_value定义的值。如果未启用cycle_option且下一个序列值超出边界,则会向列current_base_value插入NULL值,MySQL 将拒绝此操作并报错。您可以考虑抛出自定义异常。
为了演示cycle_option选项的工作原理,让我们看看当序列baz达到其边界时的行为。
mysql> `SELECT` `sequence_next_value``(``'baz'``)``;`
+----------------------------+ | sequence_next_value('baz') |
+----------------------------+ | 10 |
+----------------------------+ 1 row in set (0.00 sec)
mysql> `SELECT` `sequence_next_value``(``'baz'``)``;`
+----------------------------+ | sequence_next_value('baz') |
+----------------------------+ | 1 |
+----------------------------+ 1 row in set (0.01 sec)
mysql> `SELECT` `sequence_next_value``(``'baz'``)``;`
+----------------------------+ | sequence_next_value('baz') |
+----------------------------+ | 2 |
+----------------------------+ 1 row in set (0.01 sec)
为了演示cycle_option未启用时边界达到时函数的行为,我们创建了一个具有较小最大值的序列。
mysql> `CALL` `create_sequence``(``'boo'``,` `1``,` `1``,` `'no'``,` `3``,` `1``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `SELECT` `sequence_next_value``(``'boo'``)``;`
+----------------------------+ | sequence_next_value('boo') |
+----------------------------+ | 1 |
+----------------------------+ 1 row in set (0.01 sec)
mysql> `SELECT` `sequence_next_value``(``'boo'``)``;`
+----------------------------+ | sequence_next_value('boo') |
+----------------------------+ | 2 |
+----------------------------+ 1 row in set (0.01 sec)
mysql> `SELECT` `sequence_next_value``(``'boo'``)``;`
ERROR 1048 (23000): Column 'current_base_value' cannot be null
要在表上使用自定义序列,只需在需要下一个序列值时调用sequence_next_value即可。
mysql> `CREATE` `TABLE` `sequence_test``(`
-> `id` `BIGINT` `NOT` `NULL` `PRIMARY` `KEY``,`
-> `-- other fields`
-> `)``;`
Query OK, 0 rows affected (0.04 sec)
mysql> `CALL` `create_sequence``(``'sequence_test'``,` `10``,` `5``,` `'no'``,` `null``,` `null``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `INSERT` `INTO` `sequence_test` `VALUES``(``sequence_next_value``(``'sequence_test'``)``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `sequence_test` `VALUES``(``sequence_next_value``(``'sequence_test'``)``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `select` `*` `from` `sequence_test``;`
+----+ | id |
+----+ | 10 |
| 15 |
+----+ 2 rows in set (0.00 sec)
如果使用触发器,您可以自动化表的序列值生成。
CREATE TRIGGER sequence_test_bi BEFORE INSERT ON sequence_test
FOR EACH ROW SET NEW.id=IFNULL(NEW.id, sequence_next_value('sequence_test'));
在此示例中,当用户尝试将NULL插入到表sequence_test的id列时,我们生成新的序列值。如果用户决定显式指定值,则触发器不会更改它。
mysql> `INSERT` `INTO` `sequence_test` `VALUES``(``)``;`
Query OK, 1 row affected (0.01 sec)
mysql> `INSERT` `INTO` `sequence_test` `VALUES``(``13``)``;`
Query OK, 1 row affected (0.00 sec)
mysql> `select` `*` `from` `sequence_test``;`
+----+ | id |
+----+ | 10 |
| 13 |
| 15 |
| 20 |
+----+ 4 rows in set (0.00 sec)
最后,我们需要定义一个存储过程来在不需要时删除序列。
CREATE PROCEDURE delete_sequence(name VARCHAR(64))
DELETE FROM sequences WHERE sequence_name=name;
你会在recipes分发的文件sequences/custom_sequences.sql中找到维护自定义序列的代码。
第十六章:使用联接和子查询
16.0 介绍
大多数在前几章中的查询使用了单个表,但是对于任何稍微复杂的应用程序,您可能需要使用多个表。有些问题简单地无法使用单个表来回答,而当您将多个来源的信息结合在一起时,关系数据库的真正威力就显现出来:
-
结合表中的行以获得比仅从单个表中获得的信息更全面的信息
-
保持多阶段操作的中间结果
-
根据另一个表中的信息修改一个表中的行
本章重点介绍使用多个表的两种类型语句:表之间的联接和嵌套一个SELECT中的子查询。它涵盖以下主题:
比较表以查找匹配项或不匹配项
要解决这些问题,您应该了解适用哪些类型的联接。内联接显示一个表中与另一个表中的行匹配的行。外部联接显示匹配的行,但也找到一个表中未与另一个表中的行匹配的行。
删除不匹配的行
如果两个数据集相关但不完美,您可以确定哪些行不匹配并根据需要删除它们。
将表与自身进行比较
有些问题要求将表与自身进行比较。这类似于在不同表之间执行联接,但您必须使用表别名来消除表引用的歧义。
生成候选详细和多对多关系
当一个表中的每个项目可以与另一个表中的多个项目匹配,或者当每个表中的任一项目可以与另一个表中的多个项目匹配时,联接可以生成列表或摘要。
本章使用的创建表的脚本位于recipes分发的tables目录中。要查找实现本章讨论的技术的脚本,请查看joins目录。
16.1 在表之间查找匹配项
问题
您需要执行需要来自多个表的信息的任务。
解决方案
使用联接——即在其FROM子句中列出多个表并告诉 MySQL 如何匹配它们的查询。
讨论
联接背后的基本思想是它将一个表中的行与一个或多个其他表中的行进行匹配。当每个表只回答您感兴趣问题的一部分时,联接使您能够合并来自多个表的信息。
产生所有可能的行组合的完全连接称为笛卡尔积。例如,将一个有 100 行的表中的每一行与一个有 200 行的表中的每一行连接,产生的结果包含 100 × 200 = 20,000 行。对于更大的表或连接超过两个表的情况,笛卡尔积的结果集很容易变得巨大,因此连接通常包括一个 ON 或 USING 比较子句,仅生成表之间所需的匹配(这要求每个表都有一个或多个共同的信息列,逻辑上将它们联系在一起)。还可以包括一个 WHERE 子句,用于限制要选择的连接行。每个子句都可以缩小查询的焦点。
此处介绍连接语法并展示连接如何回答特定类型的问题,当您寻找表之间的匹配时。其他部分展示如何识别表之间的不匹配(参见 Recipe 16.2)以及如何将表与自身进行比较(参见 Recipe 16.4)。这些示例假定您拥有一个艺术收藏,并使用以下两个表记录您的收购信息。artist 列出了那些您希望收藏其作品的画家,而 painting 则列出了您实际购买的每幅绘画作品:
CREATE TABLE artist
(
a_id INT UNSIGNED NOT NULL AUTO_INCREMENT, # artist ID
name VARCHAR(30) NOT NULL, # artist name
PRIMARY KEY (a_id),
UNIQUE (name)
);
CREATE TABLE painting
(
a_id INT UNSIGNED NOT NULL, # artist ID
p_id INT UNSIGNED NOT NULL AUTO_INCREMENT, # painting ID
title VARCHAR(100) NOT NULL, # title of painting
state VARCHAR(2) NOT NULL, # state where purchased
price INT UNSIGNED, # purchase price (dollars)
INDEX (a_id),
PRIMARY KEY (p_id)
);
您刚刚开始收藏,因此表中只包含少量行:
mysql> `SELECT * FROM artist ORDER BY a_id;`
+------+----------+
| a_id | name |
+------+----------+
| 1 | Da Vinci |
| 2 | Monet |
| 3 | Van Gogh |
| 4 | Renoir |
+------+----------+
mysql> `SELECT * FROM painting ORDER BY a_id, p_id;`
+------+------+-------------------+-------+-------+
| a_id | p_id | title | state | price |
+------+------+-------------------+-------+-------+
| 1 | 1 | The Last Supper | IN | 34 |
| 1 | 2 | Mona Lisa | MI | 87 |
| 3 | 3 | Starry Night | KY | 48 |
| 3 | 4 | The Potato Eaters | KY | 67 |
| 4 | 5 | Les Deux Soeurs | NE | 64 |
+------+------+-------------------+-------+-------+
painting 表中 price 列中的低值揭示了您的收藏实际上只包含廉价的仿制品,而不是原作。好吧,没关系:谁能负担得起原作呢?
每张表包含关于您收藏的部分信息。例如,artist 表并未告诉您每位艺术家创作了哪些绘画作品,而 painting 表则列出了艺术家的 ID,但没有他们的名字。要使用这两个表中的信息,编写一个执行连接的查询。在 FROM 关键字后面命名两个或更多表来执行连接。在输出列列表中,使用 * 从所有表中选择所有列,tbl_name.* 从给定表中选择所有列,或者根据这些列的连接表或表达式命名特定列。
最简单的连接涉及两个表,并从每个表中选择所有列。下面连接 artist 和 painting 表展示了这一点(ORDER BY 子句使结果更易读):
mysql> `SELECT * FROM artist INNER JOIN painting ORDER BY artist.a_id;`
+------+----------+------+------+-------------------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+----------+------+------+-------------------+-------+-------+
| 1 | Da Vinci | 1 | 1 | The Last Supper | IN | 34 |
| 1 | Da Vinci | 3 | 3 | Starry Night | KY | 48 |
| 1 | Da Vinci | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 1 | Da Vinci | 1 | 2 | Mona Lisa | MI | 87 |
| 1 | Da Vinci | 3 | 4 | The Potato Eaters | KY | 67 |
| 2 | Monet | 1 | 2 | Mona Lisa | MI | 87 |
| 2 | Monet | 3 | 4 | The Potato Eaters | KY | 67 |
| 2 | Monet | 1 | 1 | The Last Supper | IN | 34 |
| 2 | Monet | 3 | 3 | Starry Night | KY | 48 |
| 2 | Monet | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 3 | Van Gogh | 1 | 2 | Mona Lisa | MI | 87 |
| 3 | Van Gogh | 3 | 4 | The Potato Eaters | KY | 67 |
| 3 | Van Gogh | 1 | 1 | The Last Supper | IN | 34 |
| 3 | Van Gogh | 3 | 3 | Starry Night | KY | 48 |
| 3 | Van Gogh | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 4 | Renoir | 1 | 1 | The Last Supper | IN | 34 |
| 4 | Renoir | 3 | 3 | Starry Night | KY | 48 |
| 4 | Renoir | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 4 | Renoir | 1 | 2 | Mona Lisa | MI | 87 |
| 4 | Renoir | 3 | 4 | The Potato Eaters | KY | 67 |
+------+----------+------+------+-------------------+-------+-------+
INNER JOIN 生成将一个表中的值与另一个表中的值组合的结果。前面的查询未指定行匹配的任何限制,因此连接生成所有行组合(即笛卡尔积)。这个结果说明为什么这样的连接通常是无用的:它产生大量无意义的输出。显然,您不会维护这些表以将每位艺术家与每幅绘画作品匹配。
小贴士
在 MySQL 中,JOIN、CROSS JOIN 和 INNER JOIN 是语法上的等价物,并可以互换使用。可以在我们使用 INNER JOIN 的所有地方使用 CROSS JOIN 或简单的 JOIN。
为了有意义地回答问题,通过包含适当的连接条件仅生成相关的匹配。例如,要生成包含艺术家名称的画作列表,使用简单的 WHERE 子句将来自两个表的行关联起来,该子句基于艺术家 ID 列的值进行匹配,该列是两个表共同的并用于链接它们的列:
mysql> `SELECT * FROM artist INNER JOIN painting`
-> `WHERE artist.a_id = painting.a_id`
-> `ORDER BY artist.a_id;`
+------+----------+------+------+-------------------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+----------+------+------+-------------------+-------+-------+
| 1 | Da Vinci | 1 | 1 | The Last Supper | IN | 34 |
| 1 | Da Vinci | 1 | 2 | Mona Lisa | MI | 87 |
| 3 | Van Gogh | 3 | 3 | Starry Night | KY | 48 |
| 3 | Van Gogh | 3 | 4 | The Potato Eaters | KY | 67 |
| 4 | Renoir | 4 | 5 | Les Deux Soeurs | NE | 64 |
+------+----------+------+------+-------------------+-------+-------+
WHERE 子句中的列名包括表限定符,以明确指出要比较的 a_id 值。结果显示了谁画了每幅画,反过来,每位艺术家的哪些画作在您的收藏中。
另一种编写相同连接的方式使用 ON 子句指示匹配条件:
SELECT * FROM artist INNER JOIN painting
ON artist.a_id = painting.a_id
ORDER BY artist.a_id;
特殊情况下,当两个表中具有相同名称的列进行等值比较时,可以使用 INNER JOIN 结合 USING 子句。这不需要表限定符,并且每个连接的列只命名一次:
SELECT * FROM artist INNER JOIN painting
USING (a_id)
ORDER BY a_id;
对于 SELECT * 查询,USING 形式产生的结果与 ON 形式不同:它仅返回每个连接列的一个实例,因此 a_id 只出现一次,而不是两次。
ON、USING 或 WHERE 中的任何一个都可以包括比较,那么如何确定每个子句中放置哪些连接条件呢?作为一个经验法则,通常使用 ON 或 USING 指定如何连接表,使用 WHERE 子句限制要选择的连接行。例如,要基于 a_id 列连接表,但仅选择在肯塔基州获取的画作的行,请使用 ON(或 USING)子句匹配两个表中的行,并使用 WHERE 子句测试 state 列:
mysql> `SELECT * FROM artist INNER JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `WHERE painting.state = 'KY';`
+------+----------+------+------+-------------------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+----------+------+------+-------------------+-------+-------+
| 3 | Van Gogh | 3 | 3 | Starry Night | KY | 48 |
| 3 | Van Gogh | 3 | 4 | The Potato Eaters | KY | 67 |
+------+----------+------+------+-------------------+-------+-------+
前面的查询使用 SELECT * 来显示所有列。为了更具选择性,只命名您感兴趣的那些列:
mysql> `SELECT artist.name, painting.title, painting.state, painting.price`
-> `FROM artist INNER JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `WHERE painting.state = 'KY';`
+----------+-------------------+-------+-------+
| name | title | state | price |
+----------+-------------------+-------+-------+
| Van Gogh | Starry Night | KY | 48 |
| Van Gogh | The Potato Eaters | KY | 67 |
+----------+-------------------+-------+-------+
Joins 可以使用超过两个表。假设您希望在前面的查询结果中看到完整的州名而不是缩写。在早期章节中使用的 states 表将州的缩写映射到名称;将其添加到前面的查询中,以显示名称而不是缩写:
mysql> `SELECT artist.name, painting.title, states.name, painting.price`
-> `FROM artist INNER JOIN painting INNER JOIN states`
-> `ON artist.a_id = painting.a_id AND painting.state = states.abbrev`
-> `WHERE painting.state = 'KY';`
+----------+-------------------+----------+-------+
| name | title | name | price |
+----------+-------------------+----------+-------+
| Van Gogh | Starry Night | Kentucky | 48 |
| Van Gogh | The Potato Eaters | Kentucky | 67 |
+----------+-------------------+----------+-------+
另一种常见的三表连接用途是枚举多对多关系(见 Recipe 16.6)。
通过在连接中包含适当的条件,您可以回答非常具体的问题:
-
凡·高画了哪些画?使用
a_id值查找匹配行,添加WHERE子句以限制输出到包含艺术家名称的行,并从这些行中选择标题:mysql> `SELECT painting.title` -> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id` -> `WHERE artist.name = 'Van Gogh';` +-------------------+ | title | +-------------------+ | Starry Night | | The Potato Eaters | +-------------------+ -
谁画了 蒙娜丽莎?再次使用
a_id列连接行,但这次使用WHERE子句将输出限制为包含标题的行,并从这些行中选择艺术家名称:mysql> `SELECT artist.name` -> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id` -> `WHERE painting.title = 'Mona Lisa';` +----------+ | name | +----------+ | Da Vinci | +----------+ -
您在肯塔基州或印第安纳州购买了哪些艺术家的画作?这与前一语句类似,但在
painting表中测试不同的列(state),以将输出限制为KY或IN的行:mysql> `SELECT DISTINCT artist.name` -> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id` -> `WHERE painting.state IN ('KY','IN');` +----------+ | name | +----------+ | Da Vinci | | Van Gogh | +----------+该语句还使用
DISTINCT显示每位艺术家的名称仅一次。尝试去掉DISTINCT;Van Gogh 会出现两次,因为您在肯塔基州获得了两幅 Van Gogh 的作品。 -
与聚合函数一起使用的连接生成摘要。此语句显示每位艺术家有多少幅绘画:
mysql> `SELECT artist.name, COUNT(*) AS 'number of paintings'` -> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id` -> `GROUP BY artist.name;` +----------+---------------------+ | name | number of paintings | +----------+---------------------+ | Da Vinci | 2 | | Renoir | 1 | | Van Gogh | 2 | +----------+---------------------+更为复杂的语句使用聚合,还显示了您为每位艺术家的绘画支付了多少,总计和平均数:
mysql> `SELECT artist.name,` -> `COUNT(*) AS 'number of paintings',` -> `SUM(painting.price) AS 'total price',` -> `AVG(painting.price) AS 'average price'` -> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id` -> `GROUP BY artist.name;` +----------+---------------------+-------------+---------------+ | name | number of paintings | total price | average price | +----------+---------------------+-------------+---------------+ | Da Vinci | 2 | 121 | 60.5000 | | Renoir | 1 | 64 | 64.0000 | | Van Gogh | 2 | 115 | 57.5000 | +----------+---------------------+-------------+---------------+
前述摘要语句仅为艺术家表中确实已获取其绘画的艺术家生成输出。(例如,Monet 在艺术家表中列出,但在摘要中未列出,因为您尚未拥有他的任何绘画。)要总结所有艺术家,包括那些您尚未获得其绘画的艺术家,您必须使用不同类型的连接,具体来说是外连接:
-
使用
INNER JOIN编写的连接是内连接。它们仅为一个表中与另一个表中的值匹配的值生成结果。 -
外连接也可以生成这些匹配项,但还可以显示一个表中缺少另一个表中的哪些值。Recipe 16.2 介绍了外连接。
在连接中始终允许以表名限定列名的 tbl_name.col_name 符号化表示,但如果该名称仅出现在连接的一个表中,则可以缩短为 col_name。在这种情况下,MySQL 可以明确确定列来自哪个表,无需表名限定符。我们无法在以下连接中这样做。两个表都有一个 a_id 列,因此 ON 子句列引用存在歧义:
mysql> `SELECT * FROM artist INNER JOIN painting ON a_id = a_id;`
ERROR 1052 (23000): Column 'a_id' in on clause is ambiguous
相比之下,以下查询是明确的。每个 a_id 实例都带有适当的表名限定,只有 artist 有一个 name 列,而只有 painting 有 title 和 state 列:
mysql> `SELECT name, title, state FROM artist INNER JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `ORDER BY name;`
+----------+-------------------+-------+
| name | title | state |
+----------+-------------------+-------+
| Da Vinci | The Last Supper | IN |
| Da Vinci | Mona Lisa | MI |
| Renoir | Les Deux Soeurs | NE |
| Van Gogh | Starry Night | KY |
| Van Gogh | The Potato Eaters | KY |
+----------+-------------------+-------+
为了让语句的含义对人类读者更加清晰,即使对于 MySQL 而言可能不是严格必需的,经常使用限定列名也是有用的。因此,我们倾向于在连接示例中使用限定名称。
为了在限定列引用时避免编写完整的表名,给每个表分配一个简短的别名,并使用该别名引用其列。以下两个语句是等效的:
SELECT artist.name, painting.title, states.name, painting.price
FROM artist INNER JOIN painting INNER JOIN states
ON artist.a_id = painting.a_id AND painting.state = states.abbrev;
SELECT a.name, p.title, s.name, p.price
FROM artist AS a INNER JOIN painting AS p INNER JOIN states AS s
ON a.a_id = p.a_id AND p.state = s.abbrev;
在 AS alias_name 子句中,AS 是可选的。
对于选择许多列的复杂语句,使用别名可以节省大量输入。此外,对于某些类型的语句,别名不仅方便而且必要,特别是在涉及自连接的情况下(参见 Recipe 16.4)。
16.2 寻找表之间的不匹配
问题
您想要查找一个表中没有与另一个表匹配的行。或者,您想要基于表之间的连接生成一个列表,并且希望该列表包括第一个表中的每一行条目,包括第二个表中没有匹配的行。
解决方案
使用外连接(LEFT JOIN或RIGHT JOIN)或NOT IN子查询。
讨论
Recipe 16.1 专注于内连接,它查找两个表之间的匹配项。然而,对于某些问题的答案需要确定哪些行没有匹配(或者换句话说,另一个表中缺少值的行)。例如,你可能想知道artist表中哪些艺术家还没有作品。在其他情况下也会出现类似的问题:
-
你有一个潜在客户列表,还有一个已下订单的人员列表。为了将销售努力集中在尚未成为实际客户的人员上,生成第一个列表中存在但第二个列表中不存在的人员集合。
-
你有一个棒球运动员列表,还有一个击出全垒打的运动员列表。为了确定第一个列表中哪些运动员没有击出全垒打,生成第一个列表中存在但第二个列表中不存在的运动员集合。
这些类型的问题需要使用外连接。像内连接一样,外连接查找表之间的匹配项。但与内连接不同,外连接还确定一个表中哪些行在另一个表中没有匹配。外连接有两种类型,分别是LEFT JOIN和RIGHT JOIN。
要了解外连接的实用性,考虑一下以下问题:确定artist表中哪些艺术家在painting表中不存在。目前,这些表很小,所以可以通过目测轻松查看,发现你没有莫奈的绘画作品(painting表中没有a_id值为 2 的行):
mysql> `SELECT * FROM artist ORDER BY a_id;`
+------+----------+
| a_id | name |
+------+----------+
| 1 | Da Vinci |
| 2 | Monet |
| 3 | Van Gogh |
| 4 | Renoir |
+------+----------+
mysql> `SELECT * FROM painting ORDER BY a_id, p_id;`
+------+------+-------------------+-------+-------+
| a_id | p_id | title | state | price |
+------+------+-------------------+-------+-------+
| 1 | 1 | The Last Supper | IN | 34 |
| 1 | 2 | Mona Lisa | MI | 87 |
| 3 | 3 | Starry Night | KY | 48 |
| 3 | 4 | The Potato Eaters | KY | 67 |
| 4 | 5 | Les Deux Soeurs | NE | 64 |
+------+------+-------------------+-------+-------+
随着你获得更多绘画作品和表变得更大,通过目测来回答问题将不再那么容易。你能用 SQL 来回答吗?当然可以,尽管首次尝试的解决方案通常看起来像以下语句,它使用不等条件来查找两个表之间的不匹配:
mysql> `SELECT * FROM artist INNER JOIN painting`
-> `ON artist.a_id <> painting.a_id`
-> `ORDER BY artist.a_id;`
+------+----------+------+------+-------------------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+----------+------+------+-------------------+-------+-------+
| 1 | Da Vinci | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 1 | Da Vinci | 3 | 4 | The Potato Eaters | KY | 67 |
| 1 | Da Vinci | 3 | 3 | Starry Night | KY | 48 |
| 2 | Monet | 1 | 1 | The Last Supper | IN | 34 |
| 2 | Monet | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 2 | Monet | 3 | 4 | The Potato Eaters | KY | 67 |
| 2 | Monet | 3 | 3 | Starry Night | KY | 48 |
| 2 | Monet | 1 | 2 | Mona Lisa | MI | 87 |
| 3 | Van Gogh | 1 | 2 | Mona Lisa | MI | 87 |
| 3 | Van Gogh | 1 | 1 | The Last Supper | IN | 34 |
| 3 | Van Gogh | 4 | 5 | Les Deux Soeurs | NE | 64 |
| 4 | Renoir | 3 | 3 | Starry Night | KY | 48 |
| 4 | Renoir | 1 | 2 | Mona Lisa | MI | 87 |
| 4 | Renoir | 1 | 1 | The Last Supper | IN | 34 |
| 4 | Renoir | 3 | 4 | The Potato Eaters | KY | 67 |
+------+----------+------+------+-------------------+-------+-------+
查询看起来可能是合理的,但其结果显然是错误的。例如,它错误地表明每幅画都是由几位不同的艺术家绘制的。问题在于该语句列出了两个表中艺术家 ID 值不相同的所有值组合。实际上,你需要的是artist中在painting中根本不存在的值列表,但内连接只能基于两个表中都存在的值生成结果。它无法告诉你有关其中一个表中缺少的值的任何信息。
当面对需要在一个表中找到在另一个表中没有匹配或缺失的值时,你应该养成思维的习惯,啊哈,这是一个
LEFT JOIN 的问题。LEFT JOIN 是一种外连接的类型:它类似于内连接,它将第一个(左)表中的行与第二个(右)表中的行进行匹配。此外,如果左表的行在右表中没有匹配,LEFT JOIN 仍然会生成一行——其中来自右表的所有列都设置为 NULL。这意味着你可以通过查找 NULL 来找到在右表中缺失的值。通过逐步进行,你更容易理解这是如何发生的。从显示匹配行的内连接开始:
mysql> `SELECT * FROM artist INNER JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `ORDER BY artist.a_id;`
+------+----------+------+------+-------------------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+----------+------+------+-------------------+-------+-------+
| 1 | Da Vinci | 1 | 1 | The Last Supper | IN | 34 |
| 1 | Da Vinci | 1 | 2 | Mona Lisa | MI | 87 |
| 3 | Van Gogh | 3 | 3 | Starry Night | KY | 48 |
| 3 | Van Gogh | 3 | 4 | The Potato Eaters | KY | 67 |
| 4 | Renoir | 4 | 5 | Les Deux Soeurs | NE | 64 |
+------+----------+------+------+-------------------+-------+-------+
在这个输出中,第一个 a_id 列来自 artist 表,第二个来自 painting 表。
现在将 INNER 替换为 LEFT,看看外连接的结果:
mysql> `SELECT * FROM artist LEFT JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `ORDER BY artist.a_id;`
+------+----------+------+------+-------------------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+----------+------+------+-------------------+-------+-------+
| 1 | Da Vinci | 1 | 1 | The Last Supper | IN | 34 |
| 1 | Da Vinci | 1 | 2 | Mona Lisa | MI | 87 |
| 2 | Monet | NULL | NULL | NULL | NULL | NULL |
| 3 | Van Gogh | 3 | 3 | Starry Night | KY | 48 |
| 3 | Van Gogh | 3 | 4 | The Potato Eaters | KY | 67 |
| 4 | Renoir | 4 | 5 | Les Deux Soeurs | NE | 64 |
+------+----------+------+------+-------------------+-------+-------+
与内连接相比,外连接为每个没有 painting 表匹配的 artist 行生成了额外的行,其中所有 painting 列都设置为 NULL。
接下来,为了仅限制输出到不匹配的 artist 行,添加一个 WHERE 子句,在其中查找任何 painting 列中的 NULL 值,这些列本来不可能包含 NULL。这样就会过滤掉内连接生成的行,只留下外连接生成的行:
mysql> `SELECT * FROM artist LEFT JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `WHERE painting.a_id IS NULL;`
+------+-------+------+------+-------+-------+-------+
| a_id | name | a_id | p_id | title | state | price |
+------+-------+------+------+-------+-------+-------+
| 2 | Monet | NULL | NULL | NULL | NULL | NULL |
+------+-------+------+------+-------+-------+-------+
最后,为了仅显示在 painting 表中缺失的 artist 表值,编写输出列列表以仅命名来自 artist 表的列。结果是 LEFT JOIN 列出了那些包含右表中不存在的 a_id 值的左表行:
mysql> `SELECT artist.* FROM artist LEFT JOIN painting`
-> `ON artist.a_id = painting.a_id`
-> `WHERE painting.a_id IS NULL;`
+------+-------+
| a_id | name |
+------+-------+
| 2 | Monet |
+------+-------+
类似的操作报告每个左表值以及指示它是否在右表中存在的指标。为此,执行一个计算每个左表值在右表中出现次数的 LEFT JOIN。计数为零表示该值不存在。下面的语句列出了 artist 表中的每位艺术家,并显示该艺术家是否有任何绘画作品:
mysql> `SELECT artist.name,`
-> `IF(COUNT(painting.a_id)>0,'yes','no') AS 'in collection?'`
-> `FROM artist LEFT JOIN painting ON artist.a_id = painting.a_id`
-> `GROUP BY artist.name;`
+----------+----------------+
| name | in collection? |
+----------+----------------+
| Da Vinci | yes |
| Monet | no |
| Renoir | yes |
| Van Gogh | yes |
+----------+----------------+
RIGHT JOIN 是一种外连接,类似于 LEFT JOIN,但是反转了左右表的角色。语义上,RIGHT JOIN 强制匹配过程生成右表中每个表的行,即使在左表中没有相应的行也是如此。从语法上讲,tbl1 LEFT JOIN tbl2 等效于 tbl2 RIGHT JOIN tbl1。因此,本书中对 LEFT JOIN 的引用如果反转表的角色也适用于 RIGHT JOIN。
另一种识别一个表中存在但另一个表中缺失值的方法是使用 NOT IN 子查询。下面的示例查找在 painting 表中没有代表的艺术家;与之前回答同样问题的 LEFT JOIN 进行对比:
mysql> `SELECT * FROM artist`
-> `WHERE a_id NOT IN (SELECT a_id FROM painting);`
+------+-------+
| a_id | name |
+------+-------+
| 2 | Monet |
+------+-------+
另见
正如本节所示,LEFT JOIN对于查找另一张表中没有匹配的值或显示每个值是否匹配非常有用。LEFT JOIN还可用于生成包含列表中所有项目的摘要,即使在汇总期间没有要汇总的内容也是如此。这在候选表与详细表之间的关系中非常常见。例如,LEFT JOIN可以生成每位客户的总销售额
报告,列出所有客户,即使在汇总期间没有购买任何商品的客户也包括在内。(有关候选-详细列表的信息,请参见 Recipe 16.5。)
当您收到两个应该相关的数据文件并且想要确定它们是否真的相关时,LEFT JOIN也非常有用于一致性检查。也就是说,您希望检查它们的关系是否完整。将每个文件导入到 MySQL 表中,然后运行一对LEFT JOIN语句以确定一个表中是否有未连接的行,即另一个表中没有匹配的行。Recipe 16.3 讨论了如何识别(并可选地删除)这些未连接的行。
16.3 标识和删除不匹配或未连接的行
问题
您有两个相关的数据集,但可能不完全相关。您希望确定任一数据集中是否存在记录是未连接
的(没有任何另一个数据集中的记录匹配),并且如果有,则可能删除这些记录。
解决方案
要在每个表中标识不匹配的值,使用LEFT JOIN或NOT IN子查询。要删除它们,请使用带有NOT IN子查询的DELETE语句。
讨论
内连接对于识别匹配项很有用,外连接对于识别不匹配项很有用。当您有相关数据集,并且这些数据集之间的关系可能不完美时,外连接的这个属性非常有价值。例如,在必须验证来自外部来源的两个数据文件的完整性时,可能会发现不匹配。
当您有相关的未匹配行的表时,可以使用 SQL 语句来分析和修改它们。具体来说,恢复它们的关系是识别未连接的行,然后删除它们:
-
要识别未连接的行,请使用
LEFTJOIN,因为这是一个查找未匹配行
的问题;或者,可以使用NOTIN子查询(参见 Recipe 16.2)。 -
要删除不匹配的行,使用带有
NOTIN子查询的DELETE语句。
了解未匹配的数据很有用,因为您可以提醒提供数据的人。数据收集方法可能存在需要纠正的缺陷。例如,对于销售数据,缺少的地区可能意味着某些地区经理没有报告,而这个遗漏被忽视了。
以下示例展示了如何使用描述销售区域和每个区域销售量的两个数据集来识别和删除不匹配的行。一个数据集包含每个区域的 ID 和位置:
mysql> `SELECT * FROM sales_region ORDER BY region_id;`
+-----------+------------------------+
| region_id | name |
+-----------+------------------------+
| 1 | London, United Kingdom |
| 2 | Madrid, Spain |
| 3 | Berlin, Germany |
| 4 | Athens, Greece |
+-----------+------------------------+
另一个数据集包含销售量数据。每行包含一年四分之一的销售额,并指示适用于该行的销售区域:
mysql> `SELECT region_id, year, quarter, volume`
-> `FROM sales_volume ORDER BY region_id, year, quarter;`
+-----------+------+---------+--------+
| region_id | year | quarter | volume |
+-----------+------+---------+--------+
| 1 | 2014 | 1 | 100400 |
| 1 | 2014 | 2 | 120000 |
| 3 | 2014 | 1 | 280000 |
| 3 | 2014 | 2 | 250000 |
| 5 | 2014 | 1 | 18000 |
| 5 | 2014 | 2 | 32000 |
+-----------+------+---------+--------+
一点视觉检查显示,两个表都没有完全匹配对方。销售区域 2 和 4 在销售量表中没有表示,而销售量表包含销售区域表中不存在的区域 5 的行。但我们不想通过视觉检查来检查表格。我们希望通过使用执行工作的 SQL 语句来查找不匹配的行。
识别不匹配是使用外连接的问题。例如,要查找没有销售量行的销售区域,请使用以下LEFT JOIN:
mysql> `SELECT sales_region.region_id AS 'unmatched region row IDs'`
-> `FROM sales_region LEFT JOIN sales_volume`
-> `ON sales_region.region_id = sales_volume.region_id`
-> `WHERE sales_volume.region_id IS NULL;`
+--------------------------+
| unmatched region row IDs |
+--------------------------+
| 2 |
| 4 |
+--------------------------+
相反,要查找未与任何已知区域关联的销售量行,请反转两个表的角色:
mysql> `SELECT sales_volume.region_id AS 'unmatched volume row IDs'`
-> `FROM sales_volume LEFT JOIN sales_region`
-> `ON sales_volume.region_id = sales_region.region_id`
-> `WHERE sales_region.region_id IS NULL;`
+--------------------------+
| unmatched volume row IDs |
+--------------------------+
| 5 |
| 5 |
+--------------------------+
在这种情况下,如果缺少区域的多个体积行,则一个 ID 在列表中出现多次。要仅查看每个不匹配的 ID 一次,请使用SELECT DISTINCT:
mysql> `SELECT DISTINCT sales_volume.region_id AS 'unmatched volume row IDs'`
-> `FROM sales_volume LEFT JOIN sales_region`
-> `ON sales_volume.region_id = sales_region.region_id`
-> `WHERE sales_region.region_id IS NULL`
+--------------------------+
| unmatched volume row IDs |
+--------------------------+
| 5 |
+--------------------------+
您还可以使用NOT IN子查询识别不匹配:
mysql> `SELECT region_id AS 'unmatched region row IDs'`
-> `FROM sales_region`
-> `WHERE region_id NOT IN (SELECT region_id FROM sales_volume);`
+--------------------------+
| unmatched region row IDs |
+--------------------------+
| 2 |
| 4 |
+--------------------------+
mysql> `SELECT region_id AS 'unmatched volume row IDs'`
-> `FROM sales_volume`
-> `WHERE region_id NOT IN (SELECT region_id FROM sales_region);`
+--------------------------+
| unmatched volume row IDs |
+--------------------------+
| 5 |
| 5 |
+--------------------------+
要摆脱不匹配的行,请在DELETE语句中使用NOT IN子查询。要删除与没有sales_volume行匹配的sales_region行,请执行以下操作:
DELETE FROM sales_region
WHERE region_id NOT IN (SELECT region_id FROM sales_volume);
要删除不匹配的sales_volume行,这些行不与任何sales_region行匹配,可以使用类似的语句,只是表的角色相反:
DELETE FROM sales_volume
WHERE region_id NOT IN (SELECT region_id FROM sales_region);
16.4 比较表与自身
问题
您想要比较表中的行与同一表中的其他行。例如,您想找出您收藏的所有由绘制了《土豆食者》的艺术家的绘画。或者您想知道列在states表中的哪些州与纽约在同一年加入了联盟。或者您想知道哪些州没有与其他任何州在同一年加入联盟。
解决方案
需要比较表与自身的问题涉及一种称为自连接的操作。它的执行方式与其他连接类似,但您必须使用表别名,以便在语句中可以以不同方式引用同一表。
讨论
当一个表连接到另一个表时发生的一种特殊情况是,当两个表相同时。这被称为自连接。起初可能会感到困惑或奇怪,但这是完全合法的。您可能会经常使用自连接,因为它们非常重要。
需要自连接的一个提示是,您想知道表中哪些行对某些条件满足。假设您最喜欢的绘画是《土豆食者》,您想识别出您收藏的所有由同一位艺术家绘制的物品。我们从艺术家 ID 和绘画标题开始看起来像这样:
mysql> `SELECT a_id, title FROM painting ORDER BY a_id;`
+------+-------------------+
| a_id | title |
+------+-------------------+
| 1 | The Last Supper |
| 1 | Mona Lisa |
| 3 | Starry Night |
| 3 | The Potato Eaters |
| 4 | Les Deux Soeurs |
+------+-------------------+
解决问题的方法如下:
-
确定包含标题The Potato Eaters的
painting表行,以便引用其a_id值。 -
匹配表中具有相同
a_id值的其他行。 -
显示那些匹配行的标题。
关键在于使用正确的表示法。首次尝试将表与自身连接通常看起来像这样:
mysql> `SELECT title`
-> `FROM painting INNER JOIN painting`
-> `ON a_id = a_id`
-> `WHERE title = 'The Potato Eaters';`
ERROR 1066 (42000): Not unique table/alias: 'painting'
在该语句中,列引用是模糊的,因为 MySQL 无法确定给定列名指向哪个painting表的实例。解决方案是至少给表的一个实例设置别名,这样你就可以通过使用不同的表限定符来区分列引用。以下语句显示了如何做到这一点,使用别名p1和p2来区分painting表的不同实例:
mysql> `SELECT p2.title`
-> `FROM painting AS p1 INNER JOIN painting AS p2`
-> `ON p1.a_id = p2.a_id`
-> `WHERE p1.title = 'The Potato Eaters';`
+-------------------+
| title |
+-------------------+
| Starry Night |
| The Potato Eaters |
+-------------------+
该语句输出显示了自连接的典型特征:当你从一个表实例(The Potato Eaters)中的一个参考值开始,去查找第二个表实例(同一艺术家的绘画作品)中的匹配行时,输出包括参考值本身。这是有道理的:毕竟,该参考值与自身匹配。为了仅找到同一艺术家的其他绘画作品,请明确从输出中排除参考值:
mysql> `SELECT p2.title`
-> `FROM painting AS p1 INNER JOIN painting AS p2`
-> `ON p1.a_id = p2.a_id`
-> `WHERE p1.title = 'The Potato Eaters' AND p2.title <> p1.title`
+--------------+
| title |
+--------------+
| Starry Night |
+--------------+
前面的语句使用 ID 值比较来匹配两个表实例中的行,但可以使用任何类型的值。例如,要使用states表回答问题哪些州与纽约同年加入联邦?
,请基于表中statehood列中日期的年份部分执行时间上的成对比较:
mysql> `SELECT s2.name, s2.statehood`
-> `FROM states AS s1 INNER JOIN states AS s2`
-> `ON YEAR(s1.statehood) = YEAR(s2.statehood) AND s1.name <> s2.name`
-> `WHERE s1.name = 'New York'`
-> `ORDER BY s2.name;`
+----------------+------------+
| name | statehood |
+----------------+------------+
| Connecticut | 1788-01-09 |
| Georgia | 1788-01-02 |
| Maryland | 1788-04-28 |
| Massachusetts | 1788-02-06 |
| New Hampshire | 1788-06-21 |
| South Carolina | 1788-05-23 |
| Virginia | 1788-06-25 |
+----------------+------------+
注意
在上面的示例中,我们没有指定纽约加入联邦的年份。相反,我们比较了“New York”州名称行的statehood列的值和其他州相同的statehood列的值。
现在假设你想找到每对在同一年加入联邦的州。在这种情况下,输出可能包括states表中任意一对行。自连接非常适合解决这个问题:
mysql> `SELECT YEAR(s1.statehood) AS year,`
-> `s1.name AS name1, s1.statehood AS statehood1,`
-> `s2.name AS name2, s2.statehood AS statehood2`
-> `FROM states AS s1 INNER JOIN states AS s2`
-> `ON YEAR(s1.statehood) = YEAR(s2.statehood) AND s1.name <> s2.name`
-> `ORDER BY year, name1, name2;`
+------+----------------+------------+----------------+------------+
| year | name1 | statehood1 | name2 | statehood2 |
+------+----------------+------------+----------------+------------+
| 1787 | Delaware | 1787-12-07 | New Jersey | 1787-12-18 |
| 1787 | Delaware | 1787-12-07 | Pennsylvania | 1787-12-12 |
| 1787 | New Jersey | 1787-12-18 | Delaware | 1787-12-07 |
| 1787 | New Jersey | 1787-12-18 | Pennsylvania | 1787-12-12 |
| 1787 | Pennsylvania | 1787-12-12 | Delaware | 1787-12-07 |
| 1787 | Pennsylvania | 1787-12-12 | New Jersey | 1787-12-18 |
…
| 1912 | Arizona | 1912-02-14 | New Mexico | 1912-01-06 |
| 1912 | New Mexico | 1912-01-06 | Arizona | 1912-02-14 |
| 1959 | Alaska | 1959-01-03 | Hawaii | 1959-08-21 |
| 1959 | Hawaii | 1959-08-21 | Alaska | 1959-01-03 |
+------+----------------+------------+----------------+------------+
在ON子句中的条件要求州名对不相同,从而消除了显示每个州与自己同年加入联邦的重复行。但你会注意到每对剩余的州仍然出现两次。例如,有一行列出了特拉华州和新泽西州,另一行列出了新泽西州和特拉华州。自连接经常会产生这种情况:它们生成包含相同值但值的顺序不同的行对。
因为值在行内未按相同顺序列出,它们并不相同,你无法通过在语句中添加DISTINCT来摆脱这些近似重复项
。为了解决这个问题,请以一种只有一行从每对中出现在查询结果中的方式选择行。稍微修改ON子句,从:
ON YEAR(s1.statehood) = YEAR(s2.statehood) AND s1.name <> s2.name
to:
ON YEAR(s1.statehood) = YEAR(s2.statehood) AND s1.name < s2.name
使用<而不是<>只选择第一个州名称字母顺序小于第二个州名称的行,并消除名称按相反顺序出现的行(以及州名称相同的行)。由此产生的查询输出所需的输出而无重复项:
mysql> `SELECT YEAR(s1.statehood) AS year,`
-> `s1.name AS name1, s1.statehood AS statehood1,`
-> `s2.name AS name2, s2.statehood AS statehood2`
-> `FROM states AS s1 INNER JOIN states AS s2`
-> `ON YEAR(s1.statehood) = YEAR(s2.statehood) AND s1.name < s2.name`
-> `ORDER BY year, name1, name2;`
+------+----------------+------------+----------------+------------+
| year | name1 | statehood1 | name2 | statehood2 |
+------+----------------+------------+----------------+------------+
| 1787 | Delaware | 1787-12-07 | New Jersey | 1787-12-18 |
| 1787 | Delaware | 1787-12-07 | Pennsylvania | 1787-12-12 |
| 1787 | New Jersey | 1787-12-18 | Pennsylvania | 1787-12-12 |
…
| 1912 | Arizona | 1912-02-14 | New Mexico | 1912-01-06 |
| 1959 | Alaska | 1959-01-03 | Hawaii | 1959-08-21 |
+------+----------------+------------+----------------+------------+
对于表中哪些值不与其他行匹配?
类型的自连接问题,使用LEFT JOIN而不是INNER JOIN。这种情况的一个实例是问题哪些州没有在同一年加入联盟?
在这种情况下,解决方案使用states表与其自身的LEFT JOIN:
mysql> `SELECT s1.name, s1.statehood`
-> `FROM states AS s1 LEFT JOIN states AS s2`
-> `ON YEAR(s1.statehood) = YEAR(s2.statehood) AND s1.name <> s2.name`
-> `WHERE s2.name IS NULL`
-> `ORDER BY s1.name;`
+----------------+------------+
| name | statehood |
+----------------+------------+
| Alabama | 1819-12-14 |
| Arkansas | 1836-06-15 |
| California | 1850-09-09 |
| Colorado | 1876-08-01 |
| Illinois | 1818-12-03 |
| Indiana | 1816-12-11 |
| Iowa | 1846-12-28 |
| Kansas | 1861-01-29 |
| Kentucky | 1792-06-01 |
…
| Tennessee | 1796-06-01 |
| Utah | 1896-01-04 |
| Vermont | 1791-03-04 |
| West Virginia | 1863-06-20 |
| Wisconsin | 1848-05-29 |
+----------------+------------+
对于states表中的每一行,该语句选择具有与该州在同一年具有statehood值的行,但不包括该州本身。对于没有这种匹配的行,LEFT JOIN强制输出仍然包含一行,其中所有s2列设置为NULL。这些行标识出在同一年没有其他州加入联盟的州。
16.5 生成候选-详细列表和摘要
问题
两个表之间有一种关系,即一个表中的行,通常称为具有候选键的父表,被另一个表中的一个或多个行引用,通常称为具有详细行的子表。在这种情况下,您希望生成一个显示每个父行及其详细行的列表,或者生成一个显示每个父行的详细行摘要的列表。
解决方案
这是一种一对多的关系。解决此问题的方法涉及连接,但连接的类型取决于您想要回答的问题。要生成仅包含存在某些详细行的父行的列表,请使用基于父表中的主键的内连接。要生成包含所有父行的列表,甚至包括没有详细行的父行,请使用外连接。
讨论
要从具有候选-详细或父-子关系的两个表中生成列表,一个表中的给定行可能与另一个表中的多个行匹配。这些关系经常发生。例如,在业务背景下,一对多的关系涉及每个客户的发票或每张发票的项目。
此处的示例提供了一些候选-详细问题,您可以使用本章前面的artist和painting表来询问(并回答)。
这是这些表的一种候选-详细问题,每位艺术家绘制了哪些绘画?
这是一个简单的内连接(见第 16.1 节)。根据艺术家 ID 值,将每个artist行与其相应的painting行进行匹配:
mysql> `SELECT artist.name, painting.title`
-> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id`
-> `ORDER BY name, title;`
+----------+-------------------+
| name | title |
+----------+-------------------+
| Da Vinci | Mona Lisa |
| Da Vinci | The Last Supper |
| Renoir | Les Deux Soeurs |
| Van Gogh | Starry Night |
| Van Gogh | The Potato Eaters |
+----------+-------------------+
要列出您没有绘画的艺术家,连接输出应包括一个表中没有与另一个表匹配的行。这是一种需要外连接的查找非匹配行
问题(见第 16.2 节)。因此,要列出每个artist行,无论是否匹配任何painting行,请使用LEFT JOIN:
mysql> `SELECT artist.name, painting.title`
-> `FROM artist LEFT JOIN painting ON artist.a_id = painting.a_id`
-> `ORDER BY name, title;`
+----------+-------------------+
| name | title |
+----------+-------------------+
| Da Vinci | Mona Lisa |
| Da Vinci | The Last Supper |
| Monet | NULL |
| Renoir | Les Deux Soeurs |
| Van Gogh | Starry Night |
| Van Gogh | The Potato Eaters |
+----------+-------------------+
结果中在 title 列中有 NULL 的行对应于 artist 表中列出的艺术家,对于这些艺术家,您没有绘画。
生成使用候选表和详细表的汇总时,同样的原则适用。 例如,要按艺术家的绘画数量汇总您的艺术收藏,您可以问,“在 painting 表中每位艺术家有多少幅画?” 要根据艺术家 ID 找到答案但显示艺术家的姓名(来自 artist 表),请使用此语句计算绘画数量:
mysql> `SELECT artist.name, COUNT(painting.a_id) AS paintings`
-> `FROM artist INNER JOIN painting ON artist.a_id = painting.a_id`
-> `GROUP BY artist.name;`
+----------+-----------+
| name | paintings |
+----------+-----------+
| Da Vinci | 2 |
| Renoir | 1 |
| Van Gogh | 2 |
+----------+-----------+
另一方面,您可能会问,“每位艺术家画了多少幅画?” 这与前一个问题相同(同一个语句回答),只要 artist 表中的每位艺术家至少有一行对应的 painting 表。 但是,如果 artist 表中有尚未由您收藏中的任何绘画代表的艺术家,则它们不会出现在语句输出中。 要生成包括在 painting 表中没有绘画的艺术家的汇总,请使用 LEFT JOIN:
mysql> `SELECT artist.name, COUNT(painting.a_id) AS paintings`
-> `FROM artist LEFT JOIN painting ON artist.a_id = painting.a_id`
-> `GROUP BY artist.name;`
+----------+-----------+
| name | paintings |
+----------+-----------+
| Da Vinci | 2 |
| Monet | 0 |
| Renoir | 1 |
| Van Gogh | 2 |
+----------+-----------+
在撰写这种类型的语句时,要注意一个微妙的错误,这种错误很容易犯。 假设您稍微不同地编写了 COUNT() 函数,如下所示:
mysql> `SELECT artist.name, COUNT(*) AS paintings`
-> `FROM artist LEFT JOIN painting ON artist.a_id = painting.a_id`
-> `GROUP BY artist.name;`
+----------+-----------+
| name | paintings |
+----------+-----------+
| Da Vinci | 2 |
| Monet | 1 |
| Renoir | 1 |
| Van Gogh | 2 |
+----------+-----------+
现在每位艺术家似乎至少有一幅画。 为什么会有这种差异? 问题在于使用 COUNT(*) 而不是 COUNT(painting.a_id)。 LEFT JOIN 对于左表中未匹配的行的处理方式是生成一个行,其中右表的所有列均设置为 NULL。 在此示例中,右表是 painting。 使用 COUNT(painting.a_id) 的语句运行正确,因为 COUNT(expr) 仅计算非 NULL 值。 使用 COUNT(*) 的语句是错误的,因为它计算包括那些对应于缺失艺术家的 NULL 的行在内的行。
LEFT JOIN 也适用于其他类型的汇总。 要为 artist 表中每位艺术家的绘画总价和平均价添加额外列,请使用以下语句:
mysql> `SELECT artist.name,`
-> `COUNT(painting.a_id) AS 'number of paintings',`
-> `SUM(painting.price) AS 'total price',`
-> `AVG(painting.price) AS 'average price'`
-> `FROM artist LEFT JOIN painting ON artist.a_id = painting.a_id`
-> `GROUP BY artist.name;`
+----------+---------------------+-------------+---------------+
| name | number of paintings | total price | average price |
+----------+---------------------+-------------+---------------+
| Da Vinci | 2 | 121 | 60.5000 |
| Monet | 0 | NULL | NULL |
| Renoir | 1 | 64 | 64.0000 |
| Van Gogh | 2 | 115 | 57.5000 |
+----------+---------------------+-------------+---------------+
请注意,对于未被代表的艺术家,COUNT() 为零,但 SUM() 和 AVG() 为 NULL。 当应用于没有非 NULL 值的值集时,后两个函数返回 NULL。 在这种情况下显示零的和平均值,请用 IFNULL(SUM(expr),0) 和 IFNULL(AVG(expr),0) 替换 SUM(expr) 和 AVG(expr)。
16.6 枚举一对多关系
问题
当任一表中的任何行可能与另一表中的多行匹配时,您希望显示表之间的关系。
解决方案
这是一对多关系。 它需要一个第三个表来关联您的两个主表,并进行三向连接以生成它们之间的对应关系。
讨论
在之前的部分中使用的artist和painting表有一对多的关系:一个艺术家可能制作了许多绘画,但每幅绘画只由一个艺术家创作。一对多关系相对简单,可以使用两个相关表中共有的列进行连接。
表之间的多对多关系更复杂。当一个表中的一行可以在另一个表中有多个匹配项,反之亦然时,就会出现多对多关系。电影和演员之间的关系是一个例子:每部电影可能有多位演员,每位演员可能出演过多部电影。表示这种关系的一种方法是使用以下结构的表,每个电影-演员组合有一行:
mysql> `SELECT * FROM movies_actors ORDER BY year, movie, actor;`
+------+----------------------------+---------------+
| year | movie | actor |
+------+----------------------------+---------------+
| 1997 | The Fifth Element | Bruce Willis |
| 1997 | The Fifth Element | Gary Oldman |
| 1997 | The Fifth Element | Ian Holm |
| 1999 | The Phantom Menace | Ewan McGregor |
| 1999 | The Phantom Menace | Liam Neeson |
| 2001 | The Fellowship of the Ring | Elijah Wood |
| 2001 | The Fellowship of the Ring | Ian Holm |
| 2001 | The Fellowship of the Ring | Ian McKellen |
| 2001 | The Fellowship of the Ring | Orlando Bloom |
| 2005 | Kingdom of Heaven | Liam Neeson |
| 2005 | Kingdom of Heaven | Orlando Bloom |
| 2010 | Red | Bruce Willis |
| 2010 | Red | Helen Mirren |
| 2011 | Unknown | Diane Kruger |
| 2011 | Unknown | Liam Neeson |
+------+----------------------------+---------------+
此表捕捉了这种多对多关系的本质,但也是在非规范化形式,因为它不必要地存储了重复的信息。例如,每部电影的信息被记录了多次。为了更好地表示这种多对多关系,使用多个表:
-
在名为
movies的表中仅存储每部电影的年份和名称一次。 -
在名为
actors的表中仅存储每个演员的姓名一次。 -
创建第三个表
movies_actors_link,它存储电影-演员关联,并作为两个主要表之间的链接或桥梁。为了最小化存储在此表中的信息,分别为每部电影和每位演员分配唯一的 ID,并仅在movies_actors_link表中存储这些 ID。
结果的movie和actor表如下所示:
mysql> `SELECT * FROM movies ORDER BY id;`
+----+------+----------------------------+
| id | year | movie |
+----+------+----------------------------+
| 1 | 1997 | The Fifth Element |
| 2 | 1999 | The Phantom Menace |
| 3 | 2001 | The Fellowship of the Ring |
| 4 | 2005 | Kingdom of Heaven |
| 5 | 2010 | Red |
| 6 | 2011 | Unknown |
+----+------+----------------------------+
mysql> `SELECT * FROM actors ORDER BY id;`
+----+---------------+
| id | actor |
+----+---------------+
| 1 | Bruce Willis |
| 2 | Diane Kruger |
| 3 | Elijah Wood |
| 4 | Ewan McGregor |
| 5 | Gary Oldman |
| 6 | Helen Mirren |
| 7 | Ian Holm |
| 8 | Ian McKellen |
| 9 | Liam Neeson |
| 10 | Orlando Bloom |
+----+---------------+
movies_actors_link 表将电影和演员关联如下:
mysql> `SELECT * FROM movies_actors_link ORDER BY movie_id, actor_id;`
+----------+----------+
| movie_id | actor_id |
+----------+----------+
| 1 | 1 |
| 1 | 5 |
| 1 | 7 |
| 2 | 4 |
| 2 | 9 |
| 3 | 3 |
| 3 | 7 |
| 3 | 8 |
| 3 | 10 |
| 4 | 9 |
| 4 | 10 |
| 5 | 1 |
| 5 | 6 |
| 6 | 2 |
| 6 | 9 |
+----------+----------+
你一定会注意到,从人类的角度来看,movies_actors_link 表的内容完全毫无意义。这没关系:我们永远不需要明确显示它。它的实用性在于能够在查询中链接两个主要表,而不会出现在查询输出中。接下来的几个例子说明了这个原理。它们通过使用三表连接来回答关于电影或演员的问题,将两个主要表关联到链接表。
-
列出所有显示每部电影及其演员的配对。此语句枚举了
movie和actor表之间的所有对应关系,并复制了最初位于非规范化movies_actors表中的信息:mysql> `SELECT m.year, m.movie, a.actor` -> `FROM movies AS m INNER JOIN movies_actors_link AS l` -> `INNER JOIN actors AS a` -> `ON m.id = l.movie_id AND a.id = l.actor_id` -> `ORDER BY m.year, m.movie, a.actor;` +------+----------------------------+---------------+ | year | movie | actor | +------+----------------------------+---------------+ | 1997 | The Fifth Element | Bruce Willis | | 1997 | The Fifth Element | Gary Oldman | | 1997 | The Fifth Element | Ian Holm | | 1999 | The Phantom Menace | Ewan McGregor | | 1999 | The Phantom Menace | Liam Neeson | | 2001 | The Fellowship of the Ring | Elijah Wood | | 2001 | The Fellowship of the Ring | Ian Holm | | 2001 | The Fellowship of the Ring | Ian McKellen | | 2001 | The Fellowship of the Ring | Orlando Bloom | | 2005 | Kingdom of Heaven | Liam Neeson | | 2005 | Kingdom of Heaven | Orlando Bloom | | 2010 | Red | Bruce Willis | | 2010 | Red | Helen Mirren | | 2011 | Unknown | Diane Kruger | | 2011 | Unknown | Liam Neeson | +------+----------------------------+---------------+ -
列出给定电影中的演员:
mysql> `SELECT a.actor` -> `FROM movies AS m INNER JOIN movies_actors_link AS l` -> `INNER JOIN actors AS a` -> `ON m.id = l.movie_id AND a.id = l.actor_id` -> `WHERE m.movie = 'The Fellowship of the Ring'` -> `ORDER BY a.actor;` +---------------+ | actor | +---------------+ | Elijah Wood | | Ian Holm | | Ian McKellen | | Orlando Bloom | +---------------+ -
列出一个给定演员参演的所有电影:
mysql> `SELECT m.year, m.movie` -> `FROM movies AS m INNER JOIN movies_actors_link AS l` -> `INNER JOIN actors AS a` -> `ON m.id = l.movie_id AND a.id = l.actor_id` -> `WHERE a.actor = 'Liam Neeson'` -> `ORDER BY m.year, m.movie;` +------+--------------------+ | year | movie | +------+--------------------+ | 1999 | The Phantom Menace | | 2005 | Kingdom of Heaven | | 2011 | Unknown | +------+--------------------+
16.7 查找每组的最小或最大值
问题
你希望找出表中每个组中包含给定列的最大或最小值的行。例如,你想要确定你收藏的每位艺术家的最贵的绘画。
解决方案
创建一个临时表来保存每组的最大或最小值,然后将临时表与原始表连接以提取每组的匹配行。如果你更喜欢单查询解决方案,则在FROM子句中使用子查询,而不是临时表。
讨论
许多问题涉及查找特定表列中的最大或最小值,但通常还想知道包含该值的行中的其他值。例如,使用来自第 10.6 节的artist和painting表的技术,可以回答诸如收藏品中最昂贵的画作是什么,由谁绘制?
的问题。一种解决方案是将最高价格存储在用户定义变量中,然后使用该变量标识包含价格的行,以便可以从中检索其他列:
mysql> `SET @max_price = (SELECT MAX(price) FROM painting);`
mysql> `SELECT artist.name, painting.title, painting.price`
-> `FROM artist INNER JOIN painting`
-> `ON painting.a_id = artist.a_id`
-> `WHERE painting.price = @max_price;`
+----------+-----------+-------+
| name | title | price |
+----------+-----------+-------+
| Da Vinci | Mona Lisa | 87 |
+----------+-----------+-------+
可以通过创建一个临时表来执行相同的操作,以存储最大价格并将其与其他表连接:
CREATE TABLE tmp SELECT MAX(price) AS max_price FROM painting;
SELECT artist.name, painting.title, painting.price
FROM artist INNER JOIN painting INNER JOIN tmp
ON painting.a_id = artist.a_id
AND painting.price = tmp.max_price;
表面上看,使用临时表和连接只是比使用用户定义变量回答问题更复杂的一种方式。这种技术是否有实际价值呢?有,因为它可以导致一种更一般的技术来回答更难的问题。前面的陈述仅显示整个painting表中最昂贵的单幅画信息。如果你的问题是:每位艺术家最昂贵的画作是什么?
你不能使用用户定义变量来回答这个问题,因为答案需要找到每位艺术家的一个价格,并且变量只能保存单个值。但是使用临时表的技术效果很好,因为表可以保存多行,连接可以找到所有匹配项。
要回答问题,选择每个艺术家 ID 及其对应的最大画作价格放入临时表中。该表不仅包含最大画作价格,而且在每个组内都是最大的,其中组
定义为某位艺术家的画作。
然后使用临时表中存储的艺术家 ID 和价格与painting表中的行匹配,并将结果与artist表连接以获取艺术家姓名:
mysql> `CREATE TABLE tmp`
-> `SELECT a_id, MAX(price) AS max_price FROM painting GROUP BY a_id;`
mysql> `SELECT artist.name, painting.title, painting.price`
-> `FROM artist INNER JOIN painting INNER JOIN tmp`
-> `ON painting.a_id = artist.a_id`
-> `AND painting.a_id = tmp.a_id`
-> `AND painting.price = tmp.max_price;`
+----------+-------------------+-------+
| name | title | price |
+----------+-------------------+-------+
| Da Vinci | Mona Lisa | 87 |
| Van Gogh | The Potato Eaters | 67 |
| Renoir | Les Deux Soeurs | 64 |
+----------+-------------------+-------+
要避免显式创建临时表并以单个语句获得相同结果,请使用通用表达式(CTEs)。
WITH tmp AS (SELECT a_id, MAX(price) AS max_price FROM painting GROUP BY a_id)
SELECT artist.name, painting.title, painting.price
FROM artist INNER JOIN painting INNER JOIN tmp
ON painting.a_id = artist.a_id AND
painting.a_id = tmp.a_id AND painting.price = tmp.max_price;
我们在第 10.18 节中详细讨论了 CTEs。
通过在FROM子句中使用子查询检索与临时表中包含的相同行,可以以单个语句获得相同结果的另一种方法:
SELECT artist.name, painting.title, painting.price
FROM artist INNER JOIN painting INNER JOIN
(SELECT a_id, MAX(price) AS max_price FROM painting GROUP BY a_id) AS tmp
ON painting.a_id = artist.a_id
AND painting.a_id = tmp.a_id
AND painting.price = tmp.max_price;
另一种回答每组最大值问题的方法是使用LEFT JOIN将表与自身连接。以下语句识别每位艺术家 ID 的最高价画作(使用IS NULL选择p1中所有没有在p2中有更高价格行的行):
mysql> `SELECT p1.a_id, p1.title, p1.price`
-> `FROM painting AS p1 LEFT JOIN painting AS p2`
-> `ON p1.a_id = p2.a_id AND p1.price < p2.price`
-> `WHERE p2.a_id IS NULL;`
+------+-------------------+-------+
| a_id | title | price |
+------+-------------------+-------+
| 1 | Mona Lisa | 87 |
| 3 | The Potato Eaters | 67 |
| 4 | Les Deux Soeurs | 64 |
+------+-------------------+-------+
要显示艺术家姓名而不是 ID 值,请将LEFT JOIN的结果与artist表连接:
mysql> `SELECT artist.name, p1.title, p1.price`
-> `FROM painting AS p1 LEFT JOIN painting AS p2`
-> `ON p1.a_id = p2.a_id AND p1.price < p2.price`
-> `INNER JOIN artist ON p1.a_id = artist.a_id`
-> `WHERE p2.a_id IS NULL;`
+----------+-------------------+-------+
| name | title | price |
+----------+-------------------+-------+
| Da Vinci | Mona Lisa | 87 |
| Van Gogh | The Potato Eaters | 67 |
| Renoir | Les Deux Soeurs | 64 |
+----------+-------------------+-------+
使用临时表来回答最大-每组问题可能比使用临时表、CTE 或子查询更不直观的自连接LEFT JOIN方法。
另请参见
本篇介绍了如何通过将摘要信息选择到临时表中并将该表与原始表连接,或者通过在FROM子句中使用子查询来回答最大-每组问题的技术。这些技术在许多场景中都有应用。其中之一是计算团队排名,其中每个团队组的排名是通过将该组中的每个团队与成绩最佳的团队进行比较来确定的。第 17.12 节讨论了如何做到这一点。
16.8 使用联接填充或标识列表中的空白
问题
您希望按类别生成总结,但是一些类别在要总结的数据中不存在。因此,总结中也缺少这些类别。
解决方案
创建一个列出每个类别的参考表,并基于列表和包含数据的表之间的LEFT JOIN生成总结。结果中将显示参考表中的每个类别,即使这些类别在要总结的数据中不存在。
讨论
通常,总结查询仅对实际存在于数据中的类别生成条目。假设您想要总结driver_log表(见第九章介绍),以确定每天上路的司机数量。表具有以下行:
mysql> `SELECT * FROM driver_log ORDER BY rec_id;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 1 | Ben | 2014-07-30 | 152 |
| 2 | Suzi | 2014-07-29 | 391 |
| 3 | Henry | 2014-07-29 | 300 |
| 4 | Henry | 2014-07-27 | 96 |
| 5 | Ben | 2014-07-29 | 131 |
| 6 | Henry | 2014-07-26 | 115 |
| 7 | Suzi | 2014-08-02 | 502 |
| 8 | Henry | 2014-08-01 | 197 |
| 9 | Ben | 2014-08-02 | 79 |
| 10 | Henry | 2014-07-30 | 203 |
+--------+-------+------------+-------+
简单的总结显示每天活跃司机的数量如下:
mysql> `SELECT trav_date, COUNT(trav_date) AS drivers`
-> `FROM driver_log GROUP BY trav_date ORDER BY trav_date;`
+------------+---------+
| trav_date | drivers |
+------------+---------+
| 2014-07-26 | 1 |
| 2014-07-27 | 1 |
| 2014-07-29 | 3 |
| 2014-07-30 | 2 |
| 2014-08-01 | 1 |
| 2014-08-02 | 2 |
+------------+---------+
在这里,总结类别是日期,但从字面上来看,总结是不完整的
,因为它仅包括driver_log表中表示的日期的条目。要生成一个包含所有类别(表中日期范围内的所有日期)的总结,包括那些没有活跃司机的日期,请创建一个参考表,列出每个日期:
mysql> `CREATE TABLE dates (d DATE);`
mysql> `INSERT INTO dates (d)`
-> `VALUES('2014-07-26'),('2014-07-27'),('2014-07-28'),`
-> `('2014-07-29'),('2014-07-30'),('2014-07-31'),`
-> `('2014-08-01'),('2014-08-02');`
然后,使用LEFT JOIN将参考表与driver_log表联接:
mysql> `SELECT dates.d, COUNT(driver_log.trav_date) AS drivers`
-> `FROM dates LEFT JOIN driver_log ON dates.d = driver_log.trav_date`
-> `GROUP BY d ORDER BY d;`
+------------+---------+
| d | drivers |
+------------+---------+
| 2014-07-26 | 1 |
| 2014-07-27 | 1 |
| 2014-07-28 | 0 |
| 2014-07-29 | 3 |
| 2014-07-30 | 2 |
| 2014-07-31 | 0 |
| 2014-08-01 | 1 |
| 2014-08-02 | 2 |
+------------+---------+
现在,总结包括范围内的每个日期的行,因为LEFT JOIN强制输出包含参考表中的每个日期的行,即使在driver_log表中缺少这些日期也是如此。
刚才展示的示例使用参考表与LEFT JOIN来填充总结中的空白。还可以使用参考表来检测数据集中的空白,即确定哪些类别在要总结的数据中不存在。以下语句显示了通过查找没有与类别值匹配的driver_log表行的参考行来找出在哪些日期上没有活跃司机:
mysql> `SELECT dates.d`
-> `FROM dates LEFT JOIN driver_log ON dates.d = driver_log.trav_date`
-> `WHERE driver_log.trav_date IS NULL;`
+------------+
| d |
+------------+
| 2014-07-28 |
| 2014-07-31 |
+------------+
包含类别列表的参考表在总结上下文中非常有用,就像刚才展示的那样。但是手动创建这样的表格是枯燥乏味且容易出错的。使用递归 CTE 来实现这一目的要简单得多。
WITH RECURSIVE dates (d) AS (
SELECT '2014-07-26'
UNION ALL
SELECT d + INTERVAL 1 day
FROM dates
WHERE d < '2014-08-02')
SELECT dates.d, COUNT(driver_log.trav_date) AS drivers
FROM dates LEFT JOIN driver_log ON dates.d = driver_log.trav_date
GROUP BY d ORDER BY d;
我们在第 15.16 节中更详细地讨论了递归 CTE。
如果您需要一个非常长的日期列表,预计会经常重复使用,您可能更喜欢将它们存储在表中,而不是每次都生成系列。在这种情况下,使用类别值范围的端点来生成参考表的存储过程将帮助自动化该过程。实质上,这种类型的存储过程充当一个迭代器,为范围内的每个值生成一行。以下存储过程 make_date_list() 展示了这种方法的一个示例。它创建一个包含特定日期范围内每个日期的参考表。它还对表进行索引,以便在大型连接中速度快:
CREATE PROCEDURE make_date_list(db_name TEXT, tbl_name TEXT, col_name TEXT,
min_date DATE, max_date DATE)
BEGIN
DECLARE i, days INT;
SET i = 0, days = DATEDIFF(max_date,min_date)+1;
# Make identifiers safe for insertion into SQL statements. Use db_name
# and tbl_name to create qualified table name.
SET tbl_name = CONCAT(quote_identifier(db_name),'.',
quote_identifier(tbl_name));
SET col_name = quote_identifier(col_name);
CALL exec_stmt(CONCAT('DROP TABLE IF EXISTS ',tbl_name));
CALL exec_stmt(CONCAT('CREATE TABLE ',tbl_name,'(',
col_name,' DATE NOT NULL, PRIMARY KEY(',
col_name,'))'));
WHILE i < days DO
CALL exec_stmt(CONCAT('INSERT INTO ',tbl_name,'(',col_name,') VALUES(',
QUOTE(min_date),' + INTERVAL ',i,' DAY)'));
SET i = i + 1;
END WHILE;
END;
使用 make_date_list() 生成参考表 dates,如下所示:
CALL make_date_list('cookbook', 'dates', 'd', '2014-07-26', '2014-08-02');
然后像本节前面展示的那样使用 dates 表,以填补摘要中的空洞或检测数据集中的空洞。
您可以在 recipes 发行版的 joins 目录中找到 make_date_list() 存储过程。它需要 routines 目录中的 exec_stmt() 和 quote_identifier() 辅助例程(参见 Recipe 11.6)。joins 目录还包含一个 Perl 脚本 make_date_list.pl,实现了一种替代方法;它可以从命令行生成日期参考表。
16.9 使用连接控制查询排序顺序
问题
您希望使用输出的特性对语句的输出进行排序,但无法使用 ORDER BY 指定输出的特性。例如,您希望按子组对一组行进行排序,首先放置具有最多行的组,最后放置具有最少行的组。但是,“每个组中的行数”不是单个行的属性,因此无法用于排序。
解决方案
派生排序信息并将其存储在辅助表中。然后将原始表与辅助表连接,使用辅助表来控制排序顺序。
讨论
大多数时候,您会使用 ORDER BY 子句对查询结果进行排序,指定用于排序的列。但有时,要排序的值并不在要排序的行中。这种情况出现在您希望使用组特征来排序行时。以下示例使用 driver_log 表来说明这一点。以下查询通过 ID 列对表进行排序,该列存在于行中:
mysql> `SELECT * FROM driver_log ORDER BY rec_id;`
+--------+-------+------------+-------+
| rec_id | name | trav_date | miles |
+--------+-------+------------+-------+
| 1 | Ben | 2014-07-30 | 152 |
| 2 | Suzi | 2014-07-29 | 391 |
| 3 | Henry | 2014-07-29 | 300 |
| 4 | Henry | 2014-07-27 | 96 |
| 5 | Ben | 2014-07-29 | 131 |
| 6 | Henry | 2014-07-26 | 115 |
| 7 | Suzi | 2014-08-02 | 502 |
| 8 | Henry | 2014-08-01 | 197 |
| 9 | Ben | 2014-08-02 | 79 |
| 10 | Henry | 2014-07-30 | 203 |
+--------+-------+------------+-------+
但是,如果您想显示一个列表并根据行中不存在的汇总值对其进行排序,那就有点棘手了。假设您想按日期显示每个司机的行,但要先显示驾驶里程最多的司机。您无法使用汇总查询来实现这一点,因为那样您将无法获得单个司机的行。但是,如果没有汇总查询,也无法实现,因为排序需要汇总值。摆脱困境的方法是创建另一个表,其中包含每个司机的汇总值,并将其与原始表连接。这样,您可以生成单个行,并根据汇总值对它们进行排序。
要将司机总计汇总到另一个表中,请执行以下操作:
mysql> `CREATE TABLE tmp`
-> `SELECT name, SUM(miles) AS driver_miles FROM driver_log GROUP BY name;`
这将产生我们需要按正确总英里数排序名称的值:
mysql> `SELECT * FROM tmp ORDER BY driver_miles DESC;`
+-------+--------------+
| name | driver_miles |
+-------+--------------+
| Henry | 911 |
| Suzi | 893 |
| Ben | 362 |
+-------+--------------+
然后使用name值将汇总表连接到driver_log表,并使用driver_miles值对结果进行排序:
mysql> `SELECT tmp.driver_miles, driver_log.*`
-> `FROM driver_log INNER JOIN tmp ON driver_log.name = tmp.name`
-> `ORDER BY tmp.driver_miles DESC, driver_log.trav_date;`
+--------------+--------+-------+------------+-------+
| driver_miles | rec_id | name | trav_date | miles |
+--------------+--------+-------+------------+-------+
| 911 | 6 | Henry | 2014-07-26 | 115 |
| 911 | 4 | Henry | 2014-07-27 | 96 |
| 911 | 3 | Henry | 2014-07-29 | 300 |
| 911 | 10 | Henry | 2014-07-30 | 203 |
| 911 | 8 | Henry | 2014-08-01 | 197 |
| 893 | 2 | Suzi | 2014-07-29 | 391 |
| 893 | 7 | Suzi | 2014-08-02 | 502 |
| 362 | 5 | Ben | 2014-07-29 | 131 |
| 362 | 1 | Ben | 2014-07-30 | 152 |
| 362 | 9 | Ben | 2014-08-02 | 79 |
+--------------+--------+-------+------------+-------+
上述声明显示了结果中的英里总数。这只是为了澄清数值如何排序。实际上并不需要显示它们;它们只需要用于ORDER BY子句。
为了避免使用临时表,可以使用 CTE:
WITH tmp AS
(SELECT name, SUM(miles) AS driver_miles FROM driver_log GROUP BY name)
SELECT tmp.driver_miles, driver_log.*
FROM driver_log INNER JOIN tmp ON driver_log.name = tmp.name
ORDER BY tmp.driver_miles DESC, driver_log.trav_date;
或者,在FROM子句中使用子查询选择相同的行:
SELECT tmp.driver_miles, driver_log.*
FROM driver_log INNER JOIN
(SELECT name, SUM(miles) AS driver_miles
FROM driver_log GROUP BY name) AS tmp
ON driver_log.name = tmp.name
ORDER BY tmp.driver_miles DESC, driver_log.trav_date;
16.10 连接多个查询的结果
问题
您希望连接两个或多个查询的结果。
解决方案
运行查询并将结果存储在临时表中,然后访问这些临时表以获取最终结果。或者,使用命名子查询,然后连接它们的结果。或者,使用我们最喜欢的方法:使用 CTE 以最简单和清晰的方式执行此任务。
讨论
您可能不仅需要连接表,还需要连接其他查询的结果。假设您正在使用recipes分发中的city和states表,并希望找到属于人口最多的 10 个州的首府名称。同时,您希望仅将最大城市与首府相同的州包含在搜索结果中。
这个任务如果你首先将其分解为三个部分,非常容易解决:
-
查找所有首府和最大城市相同的州。可以通过以下查询完成:
SELECT * FROM city WHERE capital=largest; -
找出人口最多的 10 个州:
SELECT * FROM states ORDER BY pop DESC LIMIT 10; -
连接结果以选择存在于两者中的行。
有三种方法可以做到这一点:通过创建中间临时表、通过连接子查询结果以及使用 CTE。
使用中间临时表
将查询结果存储到临时表中,然后从中选择。
mysql> `CREATE` `TEMPORARY` `TABLE` `large_capitals`
-> `SELECT` `*` `FROM` `city` `WHERE` `capital``=``largest``;`
Query OK, 17 rows affected (0,00 sec)
Records: 17 Duplicates: 0 Warnings: 0
mysql> `CREATE` `TEMPORARY` `TABLE` `top10states`
-> `SELECT` `*` `FROM` `states` `ORDER` `BY` `pop` `DESC` `LIMIT` `10``;`
Query OK, 10 rows affected (0,00 sec)
Records: 10 Duplicates: 0 Warnings: 0
mysql> `SELECT` `state``,` `capital``,` `pop` `FROM`
-> `large_capitals` `JOIN` `top10states`
-> `ON``(``large_capitals``.``state` `=` `top10states``.``name``)``;`
+---------+----------+----------+ | state | capital | pop |
+---------+----------+----------+ | Georgia | Atlanta | 10799566 |
| Ohio | Columbus | 11780017 |
+---------+----------+----------+ 2 rows in set (0,00 sec)
提示
CREATE TABLE语句中的关键字TEMPORARY指示 MySQL 创建一个表,仅对当前会话可见,会话关闭后将被销毁。有关详细信息,请参见 Recipe 6.3。
使用命名子查询
如果您只需要访问一次中间结果,可以通过使用子查询和连接它们的结果来避免创建临时表。
mysql> `SELECT` `state``,` `capital``,` `pop` `FROM` 
-> `(``SELECT` `*` `FROM` `city` `WHERE` `capital``=``largest``)` `AS` `large_capitals``,` 
-> `(``SELECT` `*` `FROM` `states` `ORDER` `BY` `pop` `DESC` `LIMIT` `10``)` `AS` `top10states` 
-> `WHERE` `large_capitals``.``state` `=` `top10states``.``name``;` 
+---------+----------+----------+ | state | capital | pop |
+---------+----------+----------+ | Georgia | Atlanta | 10799566 |
| Ohio | Columbus | 11780017 |
+---------+----------+----------+ 2 rows in set (0,00 sec)
从选择最终结果中所需的列开始查询
将第一个子查询放入括号中,并分配一个唯一名称。
对第二个子查询执行相同操作。
使用WHERE子句缩小搜索范围。
使用通用表达式(CTEs)
使用 CTEs 首先为您的子查询命名,然后像处理常规 MySQL 表一样连接它们的结果。
mysql> `WITH`
-> `large_capitals` `AS` `(``SELECT` `*` `FROM` `city` `WHERE` `capital``=``largest``)``,`
-> `top10states` `AS` `(``SELECT` `*` `FROM` `states` `ORDER` `BY` `pop` `DESC` `LIMIT` `10``)`
-> `SELECT` `state``,` `capital``,` `pop`
-> `FROM` `large_capitals` `JOIN` `top10states`
-> `ON` `(``large_capitals``.``state` `=` `top10states``.``name``)``;`
+---------+----------+----------+ | state | capital | pop |
+---------+----------+----------+ | Georgia | Atlanta | 10799566 |
| Ohio | Columbus | 11780017 |
+---------+----------+----------+ 2 rows in set (0,00 sec)
16.11 在程序中引用连接输出列名
问题
您需要在程序内部处理连接的结果,但结果集中的列名不唯一。
解决方案
重写查询,使用列别名使每列具有唯一名称。或者,按位置引用列。
讨论
连接通常从相关表中检索列,并且从不同表中选择的列具有相同的名称并不罕见。考虑以下显示艺术品收藏中项目的联接的情况。对于每幅绘画,它显示艺术家姓名,绘画标题,您获取项目的州份及其价格:
mysql> `SELECT artist.name, painting.title, states.name, painting.price`
-> `FROM artist INNER JOIN painting INNER JOIN states`
-> `ON artist.a_id = painting.a_id AND painting.state = states.abbrev;`
+----------+-------------------+----------+-------+
| name | title | name | price |
+----------+-------------------+----------+-------+
| Da Vinci | The Last Supper | Indiana | 34 |
| Da Vinci | Mona Lisa | Michigan | 87 |
| Van Gogh | Starry Night | Kentucky | 48 |
| Van Gogh | The Potato Eaters | Kentucky | 67 |
| Renoir | Les Deux Soeurs | Nebraska | 64 |
+----------+-------------------+----------+-------+
该语句对每个输出列使用表限定符,但 MySQL 不包括表名在列标题中,因此输出中并非所有列名都是唯一的。如果在程序中处理连接结果,并将行提取到引用列值的数据结构中,非唯一列名会导致值无法访问。假设您在 Perl DBI 脚本中这样提取行:
while (my $ref = $sth->fetchrow_hashref ())
{
*`... process row hash here ...`*
}
将行提取到哈希中产生三个哈希元素(name,title,price);其中一个name元素丢失了。为了解决此问题,提供使列名唯一的别名:
SELECT artist.name AS painter, painting.title,
states.name AS state, painting.price
FROM artist INNER JOIN painting INNER JOIN states
ON artist.a_id = painting.a_id AND painting.state = states.abbrev;
现在将行提取到哈希中产生四个哈希元素(painter,title,state,price)。
为了解决不更改列名的问题,将行提取到除哈希之外的其他内容。例如,将行提取到数组中,并按数组中的序数位置引用列:
while (my @val = $sth->fetchrow_array ())
{
print "painter: $val[0], title: $val[1], "
. "state: $val[2], price: $val[3]\n";
}
第十七章:统计技术
17.0 介绍
本章涵盖了几个与基本统计技术相关的主题。在很大程度上,这些配方是建立在早期章节描述的技术之上的,比如在第十章中描述的摘要技术,以及在第十六章中的连接技术。因此,这里的示例展示了应用这些章节材料的其他方法。总体而言,本章讨论的主题包括:
-
技术,如计算描述性统计、生成频率分布、计数缺失值以及计算最小二乘回归或相关系数,来描述数据集。
-
随机化方法,例如如何生成随机数并将其应用于随机化一组行或从行中随机选择个体项
-
计算连续观测差异、累积和和移动平均的技术。
-
生成排名分配和生成团队排名的方法
统计涵盖了如此广泛和多样的主题,以至于本章只能浅尝辄止,并且只是简单地说明了 MySQL 可以应用于统计分析的一些潜在领域。请注意,某些统计量可以以不同的方式定义(例如,您是基于 n 自由度还是 n–1 来计算标准差?)。如果我用于某个术语的定义与您偏好的定义不符,请相应地调整此处展示的查询或算法。
您可以在recipes分发的stats目录中找到与此处讨论的示例相关的脚本,并且在tables目录中找到创建示例表格的脚本。
17.1 计算描述性统计
问题
您希望通过计算一般的描述性或摘要统计来描述数据集。
解决方案
许多常见的描述性统计,如均值和标准差,都是通过将聚合函数应用于您的数据来获得的。其他的,如中位数或众数,则是基于计数查询计算的。
讨论
假设一个testscore表包含表示主题 ID、年龄、性别和测试分数的观测:
mysql> `SELECT subject, age, sex, score FROM testscore ORDER BY subject;`
+---------+-----+-----+-------+
| subject | age | sex | score |
+---------+-----+-----+-------+
| 1 | 5 | M | 5 |
| 2 | 5 | M | 4 |
| 3 | 5 | F | 6 |
| 4 | 5 | F | 7 |
| 5 | 6 | M | 8 |
| 6 | 6 | M | 9 |
| 7 | 6 | F | 4 |
| 8 | 6 | F | 6 |
| 9 | 7 | M | 8 |
| 10 | 7 | M | 6 |
| 11 | 7 | F | 9 |
| 12 | 7 | F | 7 |
| 13 | 8 | M | 9 |
| 14 | 8 | M | 6 |
| 15 | 8 | F | 7 |
| 16 | 8 | F | 10 |
| 17 | 9 | M | 9 |
| 18 | 9 | M | 7 |
| 19 | 9 | F | 10 |
| 20 | 9 | F | 9 |
+---------+-----+-----+-------+
分析一组观察结果的一个良好的第一步是生成一些总结其整体特征的描述性统计。这类常见的统计值包括:
-
观测数量、它们的总和和它们的范围(最小和最大)
-
中心趋势的度量,如均值、中位数和众数
-
变异度量,如标准差和方差
除了中位数和众数外,所有这些都可以通过调用聚合函数轻松计算:
mysql> `SELECT COUNT(score) AS n,`
-> `SUM(score) AS sum,`
-> `MIN(score) AS minimum,`
-> `MAX(score) AS maximum,`
-> `AVG(score) AS mean,`
-> `STDDEV_SAMP(score) AS 'std. dev.',`
-> `VAR_SAMP(score) AS 'variance'`
-> `FROM testscore;`
+----+------+---------+---------+--------+-----------+----------+
| n | sum | minimum | maximum | mean | std. dev. | variance |
+----+------+---------+---------+--------+-----------+----------+
| 20 | 146 | 4 | 10 | 7.3000 | 1.8382 | 3.3789 |
+----+------+---------+---------+--------+-----------+----------+
STDDEV_SAMP()和VAR_SAMP()函数生成样本度量而不是总体度量。也就是说,对于一组n个值,它们生成基于n–1 自由度的结果。对于基于n自由度的总体度量,请使用STDDEV_POP()和VAR_POP()。STDDEV()和VARIANCE()是STDDEV_POP()和VAR_POP()的同义词。
标准差可用于识别异常值——与平均值相距异常远的值。例如,要选择距离平均值超过一个标准差的值,请执行以下操作:
SELECT AVG(score), STDDEV_SAMP(score) INTO @mean, @std FROM testscore;
SELECT score FROM testscore WHERE ABS(score-@mean) > @std;
MySQL 没有内置函数来计算一组值的众数或中位数,但您可以自行计算它们。要确定众数(最常出现的值),请计算每个值的计数并查看哪个最常见:
mysql> `SELECT score, COUNT(score) AS frequency`
-> `FROM testscore GROUP BY score ORDER BY frequency DESC;`
+-------+-----------+
| score | frequency |
+-------+-----------+
| 9 | 5 |
| 6 | 4 |
| 7 | 4 |
| 4 | 2 |
| 8 | 2 |
| 10 | 2 |
| 5 | 1 |
+-------+-----------+
在这种情况下,9 是模态分数值。
可以像这样计算一组有序值的中位数:^(1)
-
如果值的数量为奇数,则中位数是中间值。
-
如果值的数量为偶数,则中位数是两个中间值的平均值。
基于该定义,使用以下过程确定存储在数据库中的一组观察值的中位数:
-
发出查询以计算观察次数。从计数中,您可以确定中位数计算是否需要一个或两个值,并且它们在有序观察值集合中的索引是什么。
-
发出包含
ORDER BY子句以对观察进行排序和包含LIMIT子句以提取中间值或值的查询。 -
如果有单个中间值,则它是中位数。否则,取中间值的平均值。
假设表t包含一个带有 37 个值(奇数)的score列。要获取中位数,请使用类似以下语句的选择单个值:
SELECT score FROM t ORDER BY score LIMIT 18,1;
如果列包含 38 个值(偶数),则选择两个值:
SELECT score FROM t ORDER BY score LIMIT 18,2;
然后从语句返回的值中取值,并从它们的平均值计算中位数。
下面的 Perl 函数实现了中位数计算。它接受一个数据库句柄以及包含一组观察值的数据库、表和列的名称。然后它生成检索相关值并返回它们平均值的语句:
sub median
{
my ($dbh, $db_name, $tbl_name, $col_name) = @_;
my ($count, $limit);
$db_name = $dbh->quote_identifier ($db_name);
$tbl_name = $dbh->quote_identifier ($tbl_name);
$col_name = $dbh->quote_identifier ($col_name);
$count = $dbh->selectrow_array (qq{
SELECT COUNT($col_name) FROM $db_name.$tbl_name
});
return undef unless $count > 0;
if ($count % 2 == 1) # odd number of values; select middle value
{
$limit = sprintf ("LIMIT %d,1", ($count-1)/2);
}
else # even number of values; select middle two values
{
$limit = sprintf ("LIMIT %d,2", $count/2 - 1);
}
my $sth = $dbh->prepare (qq{
SELECT $col_name FROM $db_name.$tbl_name ORDER BY $col_name $limit
});
$sth->execute ();
my ($n, $sum) = (0, 0);
while (my $ref = $sth->fetchrow_arrayref ())
{
++$n;
$sum += $ref->[0];
}
return $sum / $n;
}
前面的技术适用于存储在数据库中的一组值。如果您已经将有序的一组值提取到数组@val中,可以像这样计算中位数:
if (@val == 0) # array is empty, median is undefined
{
$median = undef;
}
elsif (@val % 2 == 1) # array size is odd, median is middle number
{
$median = $val[(@val-1)/2];
}
else # array size is even; median is average
{ # of two middle numbers
$median = ($val[@val/2 - 1] + $val[@val/2]) / 2;
}
该代码适用于具有初始下标为 0 的数组;对于使用基于 1 的数组索引的语言,请相应调整算法。
17.2 为组计算描述性统计信息
问题
您想为一组观察值的每个子组生成描述性统计信息。
解决方案
使用聚合函数,但使用GROUP BY子句将观察值排列成适当的组。
讨论
配方 17.1 展示了如何为testscore表中的整组分数计算描述性统计数据。更具体地说,使用GROUP BY将观察结果分组,并为每组计算统计数据。例如,testscore表中的对象按年龄和性别列出,因此可以通过适当的GROUP BY子句按年龄或性别(或两者)计算类似的统计数据。
下面是按年龄计算的方法:
mysql> `SELECT age, COUNT(score) AS n,`
-> `SUM(score) AS sum,`
-> `MIN(score) AS minimum,`
-> `MAX(score) AS maximum,`
-> `AVG(score) AS mean,`
-> `STDDEV_SAMP(score) AS 'std. dev.',`
-> `VAR_SAMP(score) AS 'variance'`
-> `FROM testscore`
-> `GROUP BY age;`
+-----+---+------+---------+---------+--------+-----------+----------+
| age | n | sum | minimum | maximum | mean | std. dev. | variance |
+-----+---+------+---------+---------+--------+-----------+----------+
| 5 | 4 | 22 | 4 | 7 | 5.5000 | 1.2910 | 1.6667 |
| 6 | 4 | 27 | 4 | 9 | 6.7500 | 2.2174 | 4.9167 |
| 7 | 4 | 30 | 6 | 9 | 7.5000 | 1.2910 | 1.6667 |
| 8 | 4 | 32 | 6 | 10 | 8.0000 | 1.8257 | 3.3333 |
| 9 | 4 | 35 | 7 | 10 | 8.7500 | 1.2583 | 1.5833 |
+-----+---+------+---------+---------+--------+-----------+----------+
按性别:
mysql> `SELECT sex, COUNT(score) AS n,`
-> `SUM(score) AS sum,`
-> `MIN(score) AS minimum,`
-> `MAX(score) AS maximum,`
-> `AVG(score) AS mean,`
-> `STDDEV_SAMP(score) AS 'std. dev.',`
-> `VAR_SAMP(score) AS 'variance'`
-> `FROM testscore`
-> `GROUP BY sex;`
+-----+----+------+---------+---------+--------+-----------+----------+
| sex | n | sum | minimum | maximum | mean | std. dev. | variance |
+-----+----+------+---------+---------+--------+-----------+----------+
| M | 10 | 71 | 4 | 9 | 7.1000 | 1.7920 | 3.2111 |
| F | 10 | 75 | 4 | 10 | 7.5000 | 1.9579 | 3.8333 |
+-----+----+------+---------+---------+--------+-----------+----------+
按年龄和性别:
mysql> `SELECT age, sex, COUNT(score) AS n,`
-> `SUM(score) AS sum,`
-> `MIN(score) AS minimum,`
-> `MAX(score) AS maximum,`
-> `AVG(score) AS mean,`
-> `STDDEV_SAMP(score) AS 'std. dev.',`
-> `VAR_SAMP(score) AS 'variance'`
-> `FROM testscore`
-> `GROUP BY age, sex;`
+-----+-----+---+------+---------+---------+--------+-----------+----------+
| age | sex | n | sum | minimum | maximum | mean | std. dev. | variance |
+-----+-----+---+------+---------+---------+--------+-----------+----------+
| 5 | M | 2 | 9 | 4 | 5 | 4.5000 | 0.7071 | 0.5000 |
| 5 | F | 2 | 13 | 6 | 7 | 6.5000 | 0.7071 | 0.5000 |
| 6 | M | 2 | 17 | 8 | 9 | 8.5000 | 0.7071 | 0.5000 |
| 6 | F | 2 | 10 | 4 | 6 | 5.0000 | 1.4142 | 2.0000 |
| 7 | M | 2 | 14 | 6 | 8 | 7.0000 | 1.4142 | 2.0000 |
| 7 | F | 2 | 16 | 7 | 9 | 8.0000 | 1.4142 | 2.0000 |
| 8 | M | 2 | 15 | 6 | 9 | 7.5000 | 2.1213 | 4.5000 |
| 8 | F | 2 | 17 | 7 | 10 | 8.5000 | 2.1213 | 4.5000 |
| 9 | M | 2 | 16 | 7 | 9 | 8.0000 | 1.4142 | 2.0000 |
| 9 | F | 2 | 19 | 9 | 10 | 9.5000 | 0.7071 | 0.5000 |
+-----+-----+---+------+---------+---------+--------+-----------+----------+
17.3 生成频率分布
问题
您想知道表中每个值的发生频率。
解决方案
汇总数据集内容的频率分布。
讨论
常见的按组汇总技术应用包括生成频率分布,显示每个值出现的频率。对于testscore表,频率分布如下:
mysql> `SELECT score, COUNT(score) AS counts`
-> `FROM testscore GROUP BY score;`
+-------+--------+
| score | counts |
+-------+--------+
| 4 | 2 |
| 5 | 1 |
| 6 | 4 |
| 7 | 4 |
| 8 | 2 |
| 9 | 5 |
| 10 | 2 |
+-------+--------+
将结果表达为百分比而不是计数,得到相对频率分布。为了显示每个计数占总数的百分比,使用一个查询获取观测总数,另一个查询计算每组的百分比:
mysql> `SET @n = (SELECT COUNT(score) FROM testscore);`
mysql> `SELECT score, (COUNT(score)*100)/@n AS percent`
-> `FROM testscore GROUP BY score;`
+-------+---------+
| score | percent |
+-------+---------+
| 4 | 10.0000 |
| 5 | 5.0000 |
| 6 | 20.0000 |
| 7 | 20.0000 |
| 8 | 10.0000 |
| 9 | 25.0000 |
| 10 | 10.0000 |
+-------+---------+
上述分布总结了个别分数值的数量。然而,如果数据集包含大量不同的值,并且您希望显示只有少量类别的分布,则可能希望将值合并到类别中,并为每个类别生成计数。配方 10.13 讨论了“合并”技术。
频率分布的一个典型用途是将结果导出到图形程序中使用。但 MySQL 本身可以生成一个简单的 ASCII 图表,作为分布的视觉表示。要显示测试分数计数的 ASCII 条形图,请将计数转换为*字符的字符串:
mysql> `SELECT score, REPEAT('*',COUNT(score)) AS 'count histogram'`
-> `FROM testscore GROUP BY score;`
+-------+-----------------+
| score | count histogram |
+-------+-----------------+
| 4 | ** |
| 5 | * |
| 6 | **** |
| 7 | **** |
| 8 | ** |
| 9 | ***** |
| 10 | ** |
+-------+-----------------+
要绘制相对频率分布而不是频数值,使用百分比值:
mysql> `SET @n = (SELECT COUNT(score) FROM testscore);`
mysql> `SELECT score,`
-> `REPEAT('*',(COUNT(score)*100)/@n) AS 'percent histogram'`
-> `FROM testscore GROUP BY score;`
+-------+---------------------------+
| score | percent histogram |
+-------+---------------------------+
| 4 | ********** |
| 5 | ***** |
| 6 | ******************** |
| 7 | ******************** |
| 8 | ********** |
| 9 | ************************* |
| 10 | ********** |
+-------+---------------------------+
显然,ASCII 图表方法很粗糙,但是它是获取观察分布图的快速方式,不需要其他工具。
如果为一系列类别生成频率分布,其中某些类别在您的观测中没有表示,则输出中不显示缺少的类别。要强制显示每个类别,请使用参考表和LEFT JOIN(在配方 16.8 中讨论的技术)。对于testscore表,可能的分数范围从 0 到 10,因此参考表应包含每一个这些值:
mysql> `CREATE TABLE ref (score INT);`
mysql> `INSERT INTO ref (score)`
-> `VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9),(10);`
然后将参考表与测试分数连接,生成频率分布。此查询显示计数以及直方图:
mysql> `SELECT ref.score, COUNT(testscore.score) AS counts,`
-> `REPEAT('*',COUNT(testscore.score)) AS 'count histogram'`
-> `FROM ref LEFT JOIN testscore ON ref.score = testscore.score`
-> `GROUP BY ref.score;`
+-------+--------+-----------+
| score | counts | histogram |
+-------+--------+-----------+
| 0 | 0 | |
| 1 | 0 | |
| 2 | 0 | |
| 3 | 0 | |
| 4 | 2 | ** |
| 5 | 1 | * |
| 6 | 4 | **** |
| 7 | 4 | **** |
| 8 | 2 | ** |
| 9 | 5 | ***** |
| 10 | 2 | ** |
+-------+--------+-----------+
此分布包括分数 0 到 3 的行,而在前面显示的频率分布中没有出现。
相对频率分布也适用相同的原则:
mysql> `SET @n = (SELECT COUNT(score) FROM testscore);`
mysql> `SELECT ref.score, (COUNT(testscore.score)*100)/@n AS percent,`
-> `REPEAT('*',(COUNT(testscore.score)*100)/@n) AS 'percent histogram'`
-> `FROM ref LEFT JOIN testscore ON ref.score = testscore.score`
-> `GROUP BY ref.score;`
+-------+---------+---------------------------+
| score | percent | percent histogram |
+-------+---------+---------------------------+
| 0 | 0.0000 | |
| 1 | 0.0000 | |
| 2 | 0.0000 | |
| 3 | 0.0000 | |
| 4 | 10.0000 | ********** |
| 5 | 5.0000 | ***** |
| 6 | 20.0000 | ******************** |
| 7 | 20.0000 | ******************** |
| 8 | 10.0000 | ********** |
| 9 | 25.0000 | ************************* |
| 10 | 10.0000 | ********** |
+-------+---------+---------------------------+
17.4 计数缺失值
问题
一组观察结果不完整。您想知道缺失多少个值。
解决方案
计算集合中的NULL值数量。
讨论
对于任何数量的原因,观察结果集中可能缺少值:可能尚未进行测试,测试过程中可能出现错误导致无效观察等。您可以在数据集中表示这样的观察结果,将它们表示为NULL值,表示它们缺失或无效,然后使用摘要语句来描述数据集的完整性。
如果表testscore_withmisses包含要在单个维度上汇总的值,则简单摘要足以描述缺失的值。假设testscore_withmisses如下所示:
mysql> `SELECT subject, score FROM testscore_withmisses ORDER BY subject;`
+---------+-------+
| subject | score |
+---------+-------+
| 1 | 38 |
| 2 | NULL |
| 3 | 47 |
| 4 | NULL |
| 5 | 37 |
| 6 | 45 |
| 7 | 54 |
| 8 | NULL |
| 9 | 40 |
| 10 | 49 |
+---------+-------+
COUNT(*)计算总行数,而COUNT(score)计算非缺失分数的数量。两个值之间的差异是缺失分数的数量,并且与总数的关系提供了缺失分数的百分比。进行以下计算:
mysql> `SELECT COUNT(*) AS 'n (total)',`
-> `COUNT(score) AS 'n (nonmissing)',`
-> `COUNT(*) - COUNT(score) AS 'n (missing)',`
-> `((COUNT(*) - COUNT(score)) * 100) / COUNT(*) AS '% missing'`
-> `FROM testscore_withmisses;`
+-----------+----------------+-------------+-----------+
| n (total) | n (nonmissing) | n (missing) | % missing |
+-----------+----------------+-------------+-----------+
| 10 | 7 | 3 | 30.0000 |
+-----------+----------------+-------------+-----------+
作为计数NULL值的替代方法,直接使用SUM(ISNULL(score))来计数它们。ISNULL()函数在其参数为NULL时返回 1,否则返回零:
mysql> `SELECT COUNT(*) AS 'n (total)',`
-> `COUNT(score) AS 'n (nonmissing)',`
-> `SUM(ISNULL(score)) AS 'n (missing)',`
-> `(SUM(ISNULL(score)) * 100) / COUNT(*) AS '% missing'`
-> `FROM testscore_withmisses;`
+-----------+----------------+-------------+-----------+
| n (total) | n (nonmissing) | n (missing) | % missing |
+-----------+----------------+-------------+-----------+
| 10 | 7 | 3 | 30.0000 |
+-----------+----------------+-------------+-----------+
如果值按组排列,则可以按组评估NULL值的发生次数。假设testscore_withmisses2包含分数,分布在两个因素 A 和 B 的条件中的主题,每个因素有两个水平:
mysql> `SELECT subject, A, B, score FROM testscore_withmisses2 ORDER BY subject;`
+---------+------+------+-------+
| subject | A | B | score |
+---------+------+------+-------+
| 1 | 1 | 1 | 18 |
| 2 | 1 | 1 | NULL |
| 3 | 1 | 1 | 23 |
| 4 | 1 | 1 | 24 |
| 5 | 1 | 2 | 17 |
| 6 | 1 | 2 | 23 |
| 7 | 1 | 2 | 29 |
| 8 | 1 | 2 | 32 |
| 9 | 2 | 1 | 17 |
| 10 | 2 | 1 | NULL |
| 11 | 2 | 1 | NULL |
| 12 | 2 | 1 | 25 |
| 13 | 2 | 2 | NULL |
| 14 | 2 | 2 | 33 |
| 15 | 2 | 2 | 34 |
| 16 | 2 | 2 | 37 |
+---------+------+------+-------+
为每个条件组合生成摘要,使用GROUP BY子句:
mysql> `SELECT A, B, COUNT(*) AS 'n (total)',`
-> `COUNT(score) AS 'n (nonmissing)',`
-> `COUNT(*) - COUNT(score) AS 'n (missing)',`
-> `((COUNT(*) - COUNT(score)) * 100) / COUNT(*) AS '% missing'`
-> `FROM testscore_withmisses2`
-> `GROUP BY A, B;`
+------+------+-----------+----------------+-------------+-----------+
| A | B | n (total) | n (nonmissing) | n (missing) | % missing |
+------+------+-----------+----------------+-------------+-----------+
| 1 | 1 | 4 | 3 | 1 | 25.0000 |
| 1 | 2 | 4 | 4 | 0 | 0.0000 |
| 2 | 1 | 4 | 2 | 2 | 50.0000 |
| 2 | 2 | 4 | 3 | 1 | 25.0000 |
+------+------+-----------+----------------+-------------+-----------+
计算线性回归或相关系数 17.5
问题
您要计算两个变量的最小二乘回归线或表达它们之间关系强度的相关系数。
解决方案
应用摘要函数进行这些计算。
讨论
当存储两个变量 X 和 Y 的数据值在数据库中时,可以使用聚合函数轻松计算它们的最小二乘回归。相关系数也是如此。这两个计算实际上非常相似,并且执行这两个过程的许多术语是共通的。
假设您要使用testscore表中观察到的年龄和测试分数值计算最小二乘回归:
mysql> `SELECT age, score FROM testscore;`
+-----+-------+
| age | score |
+-----+-------+
| 5 | 5 |
| 5 | 4 |
| 5 | 6 |
| 5 | 7 |
| 6 | 8 |
| 6 | 9 |
| 6 | 4 |
| 6 | 6 |
| 7 | 8 |
| 7 | 6 |
| 7 | 9 |
| 7 | 7 |
| 8 | 9 |
| 8 | 6 |
| 8 | 7 |
| 8 | 10 |
| 9 | 9 |
| 9 | 7 |
| 9 | 10 |
| 9 | 9 |
+-----+-------+
以下方程表示回归线,其中a和b是线条的截距和斜率:
*`Y`* = *`bX`* + *`a`*
让age为X,score为Y,首先计算回归方程所需的项。这些项包括观测数量;每个变量的均值、总和和平方和;以及每个变量乘积的总和:^(2)
mysql> `SELECT COUNT(score), AVG(age), SUM(age), SUM(age*age),`
-> `AVG(score), SUM(score), SUM(score*score), SUM(age*score)`
-> `INTO @n, @meanX, @sumX, @sumXX, @meanY, @sumY, @sumYY, @sumXY`
-> `FROM testscore;`
Query OK, 1 row affected (0,00 sec)
mysql> `SELECT`
-> `@n AS N,`
-> `@meanX AS 'X mean',`
-> `@sumX AS 'X sum',`
-> `@sumXX AS 'X sum of squares',`
-> `@meanY AS 'Y mean',`
-> `@sumY AS 'Y sum',`
-> `@sumYY AS 'Y sum of squares',`
-> `@sumXY AS 'X*Y sum'`
-> `FROM testscore\G`
*************************** 1\. row ***************************
N: 20
X mean: 7.000000000
X sum: 140
X sum of squares: 1020
Y mean: 7.300000000
Y sum: 146
Y sum of squares: 1130
X*Y sum: 1053
从这些术语中,按以下方式计算回归斜率和截距:
mysql> `SET @b := (@n*@sumXY - @sumX*@sumY) / (@n*@sumXX - @sumX*@sumX);`
mysql> `SET @a := (@meanY - @b*@meanX);`
mysql> `SELECT @b AS slope, @a AS intercept;`
+-------------+----------------------+
| slope | intercept |
+-------------+----------------------+
| 0.775000000 | 1.875000000000000000 |
+-------------+----------------------+
则回归方程为:
mysql> `SELECT CONCAT('Y = ',@b,'X + ',@a) AS 'least-squares regression';`
+-----------------------------------------+
| least-squares regression |
+-----------------------------------------+
| Y = 0.775000000X + 1.875000000000000000 |
+-----------------------------------------+
要计算相关系数,请使用许多相同的术语:
mysql> `SELECT`
-> `(@n*@sumXY - @sumX*@sumY)`
-> `/ SQRT((@n*@sumXX - @sumX*@sumX) * (@n*@sumYY - @sumY*@sumY))`
-> `AS correlation;`
+--------------------+
| correlation |
+--------------------+
| 0.6117362044219903 |
+--------------------+
17.6 生成随机数
问题
您需要一组随机数来源。
解决方案
使用RAND()函数。
讨论
MySQL 有一个RAND()函数,可以生成 0 到 1 之间的随机数:
mysql> `SELECT RAND(), RAND(), RAND();`
+---------------------+--------------------+---------------------+
| RAND() | RAND() | RAND() |
+---------------------+--------------------+---------------------+
| 0.37415416573561183 | 0.9068914557871329 | 0.41199481246247405 |
+---------------------+--------------------+---------------------+
当使用整数参数调用RAND()时,会用该值来种子化随机数生成器。您可以利用这一特性为查询结果的列生成可重复的数字系列。以下示例显示,没有参数的RAND()在每次查询时产生不同的列值,而RAND(N)则生成一个可重复的列:
mysql> `SELECT i, RAND(), RAND(10), RAND(20) FROM numbers;`
+------+---------------------+---------------------+---------------------+
| i | RAND() | RAND(10) | RAND(20) |
+------+---------------------+---------------------+---------------------+
| 1 | 0.00708185882035816 | 0.6570515219653505 | 0.15888261251047497 |
| 2 | 0.5417692908474889 | 0.12820613023657923 | 0.6355305003333189 |
| 3 | 0.6876009085100152 | 0.6698761160204896 | 0.7010046948688149 |
| 4 | 0.8126967007412544 | 0.9647622201263553 | 0.5984320040777623 |
+------+---------------------+---------------------+---------------------+
mysql> `SELECT i, RAND(), RAND(10), RAND(20) FROM numbers;`
+------+----------------------+---------------------+---------------------+
| i | RAND() | RAND(10) | RAND(20) |
+------+----------------------+---------------------+---------------------+
| 1 | 0.059957268703689115 | 0.6570515219653505 | 0.15888261251047497 |
| 2 | 0.9068000166740269 | 0.12820613023657923 | 0.6355305003333189 |
| 3 | 0.35412830799271194 | 0.6698761160204896 | 0.7010046948688149 |
| 4 | 0.050241520675124156 | 0.9647622201263553 | 0.5984320040777623 |
+------+----------------------+---------------------+---------------------+
为了以随机方式种子化RAND(),请选择基于熵源的种子值。可能的来源包括当前时间戳或连接标识符,可以单独使用,也可以组合使用:
RAND(UNIX_TIMESTAMP())
RAND(CONNECTION_ID())
RAND(UNIX_TIMESTAMP()+CONNECTION_ID())
但是,如果有其他种子值来源,则最好使用它们。例如,如果您的系统有/dev/random或/dev/urandom设备,请读取该设备并使用它生成种子值以种子化RAND()。
17.7 随机化一组行
问题
您希望随机化一组行或值。
解决方案
使用ORDER BY RAND()。
讨论
MySQL 的RAND()函数可用于随机化查询返回行的顺序。有些讽刺的是,通过在查询中添加ORDER BY子句来实现此随机化。该技术与电子表格随机化方法大致相当。假设电子表格包含以下一组值:
Patrick
Penelope
Pertinax
Polly
要将这些值随机排序,首先添加另一列,其中包含随机选择的数字:
Patrick .73
Penelope .37
Pertinax .16
Polly .48
然后根据随机数的值对行进行排序:
Pertinax .16
Penelope .37
Polly .48
Patrick .73
此时,原始值已随机排序;根据随机数排序行的效果是使与之相关联的值随机化。为重新随机化这些值,选择另一组随机数,并再次对行进行排序。
在 MySQL 中,通过将一组随机数与查询结果关联并按这些数字排序来实现类似效果。为此,请添加一个ORDER BY RAND()子句:
mysql> `SELECT name FROM rand_names ORDER BY RAND();`
+----------+
| name |
+----------+
| Pertinax |
| Patrick |
| Polly |
| Penelope |
+----------+
mysql> `SELECT name FROM rand_names ORDER BY RAND();`
+----------+
| name |
+----------+
| Polly |
| Pertinax |
| Penelope |
| Patrick |
+----------+
随机化一组行的应用包括任何使用无替换选择(从一组项目中选择每个项目直到没有项目为止)的情况。其中一些例子包括:
-
确定事件参与者的起始顺序。在表中列出参与者,然后随机选择他们的顺序。
-
在比赛中为参与者分配起始车道或门。在表中列出车道,然后随机选择一个车道顺序。
-
选择展示一组测验问题的顺序。
-
洗牌一副牌。在表中以每张牌一行的方式表示每张牌,并通过以随机顺序选择行来洗牌整副牌。逐张发牌直到牌组耗尽。
要使用最后一个示例作为说明,让我们实现一个卡牌洗牌算法。洗牌和发牌是随机化加上无重复选择:每张卡牌在再次发牌之前只发出一次;当牌堆用完时,重新洗牌以重新随机化其发牌顺序。在程序内,可以使用名为 deck 的 MySQL 表来执行此任务,该表具有 52 行,假设每张卡牌有 13 个面值和 4 种花色的组合:
-
选择整个表,并将其存储到数组中。
-
每次需要一张卡时,从数组中取下一个元素。
-
当数组耗尽时,所有卡牌已发放。重新洗牌表以生成新的卡牌顺序。
如果您通过手动编写所有 INSERT 语句插入 52 张卡片记录来设置 deck 表格,这将是一项乏味的任务。在程序中,可以更轻松地以组合方式生成 deck 内容,生成每个面值和花色的配对。以下是一些 PHP 代码,创建了一个带有 face 和 suit 列的 deck 表,并使用嵌套循环为 INSERT 语句生成配对:
$sth = $dbh->exec ("DROP TABLE IF EXISTS deck");
$sth = $dbh->exec ("
CREATE TABLE deck
(
face ENUM('A', 'K', 'Q', 'J', '10', '9', '8',
'7', '6', '5', '4', '3', '2') NOT NULL,
suit ENUM('hearts', 'diamonds', 'clubs', 'spades') NOT NULL
)
");
$face_array = array ("A", "K", "Q", "J", "10", "9", "8",
"7", "6", "5", "4", "3", "2");
$suit_array = array ("hearts", "diamonds", "clubs", "spades");
# insert a "card" into the deck for each combination of suit and face
$sth = $dbh->prepare ("INSERT INTO deck (face,suit) VALUES(?,?)");
foreach ($face_array as $face)
foreach ($suit_array as $suit)
$sth->execute (array ($face, $suit));
洗牌牌堆只需发出以下语句即可:
SELECT face, suit FROM deck ORDER BY RAND();
要执行此操作并将结果存储在脚本内的数组中,请编写一个 shuffle_deck() 函数,该函数发出查询并将返回的值存储在数组中(再次以 PHP 显示):
function shuffle_deck ($dbh)
{
$sth = $dbh->query ("SELECT face, suit FROM deck ORDER BY RAND()");
$sth->setFetchMode (PDO::FETCH_OBJ);
return ($sth->fetchAll ());
}
使用一个计数器,其范围从 0 到 51,来表示选择哪张卡牌。当计数器达到 52 时,牌堆耗尽,应重新洗牌。
警告
仅适用于行数较少的表格。按 RAND() 排序不允许 MySQL 使用索引解析 ORDER BY,因此在大表上执行此类查询将会很慢。
17.8 从一组行中选择随机项目
问题
您想从一组值中随机选择一个或多个项目。
解决方案
随机化值,然后选择第一个(或更多,如果需要多个)。
讨论
如果一组项目存储在 MySQL 中,请按以下方式随机选择其中一个:
-
按照 配方 17.7 中描述的
ORDER BY RAND()的随机顺序选择集合中的项目。 -
在查询中添加
LIMIT 1来选择第一个项目。
例如,要执行掷骰子的简单模拟,请创建一个包含值从 1 到 6 的行的 die 表,对应骰子的六个面:
CREATE TABLE die (n INT\);
然后随机选择表中的行:
mysql> `SELECT n FROM die ORDER BY RAND() LIMIT 1;`
+------+
| n |
+------+
| 6 |
+------+
mysql> `SELECT n FROM die ORDER BY RAND() LIMIT 1;`
+------+
| n |
+------+
| 4 |
+------+
mysql> `SELECT n FROM die ORDER BY RAND() LIMIT 1;`
+------+
| n |
+------+
| 5 |
+------+
mysql> `SELECT n FROM die ORDER BY RAND() LIMIT 1;`
+------+
| n |
+------+
| 4 |
+------+
当您重复此操作时,您将从集合中选择随机序列的项目。这是一种带替换的选择形式:从项目池中选择一个项目,然后将其返回到池中进行下一次选择。因为项目被替换,所以在连续选择时可能多次选择相同的项目。其他带替换的选择示例包括:
-
在网页上选择要显示的横幅广告
-
为每日引用应用选择一行
-
挑一张牌,随便挑
每次以一副完整的牌开始的魔术技巧
要选择多个项目,请更改 LIMIT 参数。例如,要从包含比赛参赛作品的名为 drawing 的表中随机抽取五个获奖条目,请使用 RAND() 结合 LIMIT:
SELECT * FROM drawing ORDER BY RAND() LIMIT 5;
当您从包含在某一列中包含从 1 到n的值的表中选择单个行时,会出现特殊情况。在这些情况下,可以避免对整个表进行 ORDER BY 操作。选择该范围内的一个随机数并选择匹配的行:
SET @id = FLOOR(RAND()**`n`*)+1;
SELECT ... FROM *`tbl_name`* WHERE id = @id;
这比随机选择一个表的 ORDER BY RAND() LIMIT 1 更快,特别是在表的大小增加时。
17.9 计算连续行差值
问题
表格中的行包含连续的累积值,并且您希望计算连续行之间的差值。
解决方案
使用自连接匹配相邻行对并计算每对成员之间的差值。
讨论
当您有一组绝对(或累积)值,并希望将其转换为表示连续行对之间差值的相对值时,自连接非常有用。例如,如果您进行一次汽车旅行并在每个停靠点记录总行驶英里数,则可以计算连续点之间的差值以确定从一个停靠点到下一个停靠点的距离。以下是这样一张表格,显示了从得克萨斯州圣安东尼奥到威斯康星州麦迪逊的旅行停靠点。每一行显示了每次停靠时的总英里数:
mysql> `SELECT seq, city, miles FROM trip_log ORDER BY seq;`
+-----+------------------+-------+
| seq | city | miles |
+-----+------------------+-------+
| 1 | San Antonio, TX | 0 |
| 2 | Dallas, TX | 263 |
| 3 | Benton, AR | 566 |
| 4 | Memphis, TN | 745 |
| 5 | Portageville, MO | 878 |
| 6 | Champaign, IL | 1164 |
| 7 | Madison, WI | 1412 |
+-----+------------------+-------+
自连接可以将这些累积值转换为连续的差值,这些差值表示从每个城市到下一个城市的距离。以下语句显示了如何使用行中的序列号来匹配连续行对并计算每对里程值之间的差值:
mysql> `SELECT t1.seq AS seq1, t2.seq AS seq2,`
-> `t1.city AS city1, t2.city AS city2,`
-> `t1.miles AS miles1, t2.miles AS miles2,`
-> `t2.miles-t1.miles AS dist`
-> `FROM trip_log AS t1 INNER JOIN trip_log AS t2`
-> `ON t1.seq+1 = t2.seq`
-> `ORDER BY t1.seq;`
+------+------+------------------+------------------+--------+--------+------+
| seq1 | seq2 | city1 | city2 | miles1 | miles2 | dist |
+------+------+------------------+------------------+--------+--------+------+
| 1 | 2 | San Antonio, TX | Dallas, TX | 0 | 263 | 263 |
| 2 | 3 | Dallas, TX | Benton, AR | 263 | 566 | 303 |
| 3 | 4 | Benton, AR | Memphis, TN | 566 | 745 | 179 |
| 4 | 5 | Memphis, TN | Portageville, MO | 745 | 878 | 133 |
| 5 | 6 | Portageville, MO | Champaign, IL | 878 | 1164 | 286 |
| 6 | 7 | Champaign, IL | Madison, WI | 1164 | 1412 | 248 |
+------+------+------------------+------------------+--------+--------+------+
trip_log 表中的 seq 列的存在对于计算连续差值值非常重要。它用于确定哪一行是另一行的前置行,并将每一行 n 与行 n +1 进行匹配。这意味着,要使用包含绝对或累积值的表格执行相对差值计算,必须包括一个没有间隙的序列列。如果表中包含序列列但存在间隙,请重新编号它(参见 Recipe 15.5)。如果表中不包含这样的列,请添加一个(参见 Recipe 15.9)。
当您为多个列计算连续差值并在计算中使用结果时,情况会变得更加复杂。以下表 player_stats 显示了一个棒球选手在赛季的每个月末的累积数字。ab 表示总打数,h 表示截至某一日期球员的总安打数。(第一行显示球员赛季的起始点,因此 ab 和 h 值为零。)
mysql> `SELECT id, date, ab, h, TRUNCATE(IFNULL(h/ab,0),3) AS ba`
-> `FROM player_stats ORDER BY id;`
+----+------------+-----+----+-------+
| id | date | ab | h | ba |
+----+------------+-----+----+-------+
| 1 | 2013-04-30 | 0 | 0 | 0.000 |
| 2 | 2013-05-31 | 38 | 13 | 0.342 |
| 3 | 2013-06-30 | 109 | 31 | 0.284 |
| 4 | 2013-07-31 | 196 | 49 | 0.250 |
| 5 | 2013-08-31 | 304 | 98 | 0.322 |
+----+------------+-----+----+-------+
查询结果的最后一列还显示了每个日期的球员击球平均数。这一列未存储在表中,但可以轻松计算为击中数与上场数的比率。结果提供了球员赛季内击球表现变化的总体印象,但并未展示球员在每个单独月份的表现。要确定这一点,计算成对行之间的相对差异。通过将行n与行n+1自连接,可以轻松完成这些差异的计算,以计算每月的击球平均数:
mysql> `SELECT`
-> `t1.id AS id1, t2.id AS id2,`
-> `t2.date,`
-> `t1.ab AS ab1, t2.ab AS ab2,`
-> `t1.h AS h1, t2.h AS h2,`
-> `t2.ab-t1.ab AS abdiff,`
-> `t2.h-t1.h AS hdiff,`
-> `TRUNCATE(IFNULL((t2.h-t1.h)/(t2.ab-t1.ab),0),3) AS ba`
-> `FROM player_stats AS t1 INNER JOIN player_stats AS t2`
-> `ON t1.id+1 = t2.id`
-> `ORDER BY t1.id;`
+-----+-----+------------+-----+-----+----+----+--------+-------+-------+
| id1 | id2 | date | ab1 | ab2 | h1 | h2 | abdiff | hdiff | ba |
+-----+-----+------------+-----+-----+----+----+--------+-------+-------+
| 1 | 2 | 2013-05-31 | 0 | 38 | 0 | 13 | 38 | 13 | 0.342 |
| 2 | 3 | 2013-06-30 | 38 | 109 | 13 | 31 | 71 | 18 | 0.253 |
| 3 | 4 | 2013-07-31 | 109 | 196 | 31 | 49 | 87 | 18 | 0.206 |
| 4 | 5 | 2013-08-31 | 196 | 304 | 49 | 98 | 108 | 49 | 0.453 |
+-----+-----+------------+-----+-----+----+----+--------+-------+-------+
这些结果比原始表格清楚地显示了球员赛季初表现不错,但在中段有所下滑,尤其是在七月份。它们还显示了球员在八月份表现多么出色。
17.10 查找累积和与移动平均
问题
您有一组随时间测量的观测值,想要计算每个测量点的累积和。或者您想要计算每个点的移动平均。
解决方案
使用自连接在每个测量点生成连续观测值集,然后应用聚合函数计算其总和或平均值。
讨论
配方 17.9 展示了如何通过自连接从绝对值计算相对值。自连接也可以相反地产生一系列观测值的累积值。以下表格显示了一系列连续几天内的降水测量值。每行的值显示了观测日期和英寸单位的降水量:
mysql> `SELECT date, precip FROM rainfall ORDER BY date;`
+------------+--------+
| date | precip |
+------------+--------+
| 2014-06-01 | 1.50 |
| 2014-06-02 | 0.00 |
| 2014-06-03 | 0.50 |
| 2014-06-04 | 0.00 |
| 2014-06-05 | 1.00 |
+------------+--------+
要计算特定日期的累积降水量,将该日的降水量值加到所有先前日期的值中。例如,像这样确定截至2014-06-03的累积降水量:
mysql> `SELECT SUM(precip) FROM rainfall WHERE date <= '2014-06-03';`
+-------------+
| SUM(precip) |
+-------------+
| 2.00 |
+-------------+
要获取表中表示的所有日期的累积数据,单独为每一天计算数值是很繁琐的。通过一个自连接语句可以在一次操作中完成所有日期的计算。使用rainfall表的一个实例作为参照,并确定每行中的日期在另一个表的所有行中到该日期的precip值的总和。以下语句显示了每天的日常和累积降水量:
mysql> `SELECT t1.date, t1.precip AS 'daily precip',`
-> `SUM(t2.precip) AS 'cum. precip'`
-> `FROM rainfall AS t1 INNER JOIN rainfall AS t2`
-> `ON t1.date >= t2.date`
-> `GROUP BY t1.date;`
+------------+--------------+-------------+
| date | daily precip | cum. precip |
+------------+--------------+-------------+
| 2014-06-01 | 1.50 | 1.50 |
| 2014-06-02 | 0.00 | 1.50 |
| 2014-06-03 | 0.50 | 2.00 |
| 2014-06-04 | 0.00 | 2.00 |
| 2014-06-05 | 1.00 | 3.00 |
+------------+--------------+-------------+
自连接可以扩展到显示每个日期的经过的天数,以及每天降水量的移动平均值:
mysql> `SELECT t1.date, t1.precip AS 'daily precip',`
-> `SUM(t2.precip) AS 'cum. precip',`
-> `COUNT(t2.precip) AS 'days elapsed',`
-> `AVG(t2.precip) AS 'avg. precip'`
-> `FROM rainfall AS t1 INNER JOIN rainfall AS t2`
-> `ON t1.date >= t2.date`
-> `GROUP BY t1.date;`
+------------+--------------+-------------+--------------+-------------+
| date | daily precip | cum. precip | days elapsed | avg. precip |
+------------+--------------+-------------+--------------+-------------+
| 2014-06-01 | 1.50 | 1.50 | 1 | 1.500000 |
| 2014-06-02 | 0.00 | 1.50 | 2 | 0.750000 |
| 2014-06-03 | 0.50 | 2.00 | 3 | 0.666667 |
| 2014-06-04 | 0.00 | 2.00 | 4 | 0.500000 |
| 2014-06-05 | 1.00 | 3.00 | 5 | 0.600000 |
+------------+--------------+-------------+--------------+-------------+
在前面的语句中,可以通过COUNT()和AVG()轻松计算经过的天数和降水的移动平均值,因为表中没有缺失的天数。如果允许缺失的天数,则计算会变得更加复杂,因为每次计算的天数不再与行数相同。您可以通过删除没有降水的天的行来看到这一点,以生成表中的空洞
:
mysql> `DELETE FROM rainfall WHERE precip = 0;`
mysql> `SELECT date, precip FROM rainfall ORDER BY date;`
+------------+--------+
| date | precip |
+------------+--------+
| 2014-06-01 | 1.50 |
| 2014-06-03 | 0.50 |
| 2014-06-05 | 1.00 |
+------------+--------+
删除这些行不会改变保留日期的累积总和或移动平均值,但必须改变它们的计算方法。如果再次执行自连接,则会导致天数和平均降水列的结果不正确:
mysql> `SELECT t1.date, t1.precip AS 'daily precip',`
-> `SUM(t2.precip) AS 'cum. precip',`
-> `COUNT(t2.precip) AS 'days elapsed',`
-> `AVG(t2.precip) AS 'avg. precip'`
-> `FROM rainfall AS t1 INNER JOIN rainfall AS t2`
-> `ON t1.date >= t2.date`
-> `GROUP BY t1.date;`
+------------+--------------+-------------+--------------+-------------+
| date | daily precip | cum. precip | days elapsed | avg. precip |
+------------+--------------+-------------+--------------+-------------+
| 2014-06-01 | 1.50 | 1.50 | 1 | 1.500000 |
| 2014-06-03 | 0.50 | 2.00 | 2 | 1.000000 |
| 2014-06-05 | 1.00 | 3.00 | 3 | 1.000000 |
+------------+--------------+-------------+--------------+-------------+
要修复这个问题,用另一种方法确定经过的天数。获取每个求和中涉及的最小和最大日期,并从中计算天数:
DATEDIFF(MAX(t2.date),MIN(t2.date)) + 1
那个数值必须用于天数列和计算移动平均值。生成的语句如下:
mysql> `SELECT t1.date, t1.precip AS 'daily precip',`
-> `SUM(t2.precip) AS 'cum. precip',`
-> `DATEDIFF(MAX(t2.date),MIN(t2.date)) + 1 AS 'days elapsed',`
-> `SUM(t2.precip) / (DATEDIFF(MAX(t2.date),MIN(t2.date)) + 1)`
-> `AS 'avg. precip'`
-> `FROM rainfall AS t1 INNER JOIN rainfall AS t2`
-> `ON t1.date >= t2.date`
-> `GROUP BY t1.date;`
+------------+--------------+-------------+--------------+-------------+
| date | daily precip | cum. precip | days elapsed | avg. precip |
+------------+--------------+-------------+--------------+-------------+
| 2014-06-01 | 1.50 | 1.50 | 1 | 1.500000 |
| 2014-06-03 | 0.50 | 2.00 | 3 | 0.666667 |
| 2014-06-05 | 1.00 | 3.00 | 5 | 0.600000 |
+------------+--------------+-------------+--------------+-------------+
正如这个例子所示,从相对值计算累积值只需要一个能够将行放置到正确顺序中的列(对于rainfall表来说,这是date列)。列中的值不必是连续的,甚至不必是数值。这与产生累积值的差异值的计算不同(参见 Recipe 17.9),后者需要一个包含不间断序列的表。
雨量示例中的移动平均值基于将累积降水总和除以截至每天的天数。当表中没有间隔时,天数与求和的值数相同,因此可以轻松找到连续的平均值。当行缺失时,计算变得更加复杂。这表明有必要考虑数据的性质并适当计算平均值。下一个示例在概念上与前面的示例类似,它计算累积总和和移动平均值,但以另一种方式执行计算。
下表显示了马拉松选手在 26 公里赛程的每个阶段的表现。每行中的值显示了每个阶段的长度(以公里为单位)以及选手完成该阶段所需的时间。换句话说,这些值涉及马拉松内的间隔,因此相对于整体而言是相对的:
mysql> `SELECT stage, km, t FROM marathon ORDER BY stage;`
+-------+----+----------+
| stage | km | t |
+-------+----+----------+
| 1 | 5 | 00:15:00 |
| 2 | 7 | 00:19:30 |
| 3 | 9 | 00:29:20 |
| 4 | 5 | 00:17:50 |
+-------+----+----------+
要计算每个阶段的累积距离(以公里为单位),可以使用类似于这样的自连接:
mysql> `SELECT t1.stage, t1.km, SUM(t2.km) AS 'cum. km'`
-> `FROM marathon AS t1 INNER JOIN marathon AS t2`
-> `ON t1.stage >= t2.stage`
-> `GROUP BY t1.stage;`
+-------+----+---------+
| stage | km | cum. km |
+-------+----+---------+
| 1 | 5 | 5 |
| 2 | 7 | 12 |
| 3 | 9 | 21 |
| 4 | 5 | 26 |
+-------+----+---------+
累积距离易于计算,因为它们可以直接求和。累积时间值的计算更为复杂:将时间转换为秒,对结果值进行总和,并将总和转换回时间值。要计算每个阶段结束时选手的平均速度,取累积距离除以累积时间的比率。将所有这些内容组合在一起得到以下语句:
mysql> `SELECT t1.stage, t1.km, t1.t,`
-> `SUM(t2.km) AS 'cum. km',`
-> `SEC_TO_TIME(SUM(TIME_TO_SEC(t2.t))) AS 'cum. t',`
-> `SUM(t2.km)/(SUM(TIME_TO_SEC(t2.t))/(60*60)) AS 'avg. km/hour'`
-> `FROM marathon AS t1 INNER JOIN marathon AS t2`
-> `ON t1.stage >= t2.stage`
-> `GROUP BY t1.stage;`
+-------+----+----------+---------+----------+--------------+
| stage | km | t | cum. km | cum. t | avg. km/hour |
+-------+----+----------+---------+----------+--------------+
| 1 | 5 | 00:15:00 | 5 | 00:15:00 | 20.0000 |
| 2 | 7 | 00:19:30 | 12 | 00:34:30 | 20.8696 |
| 3 | 9 | 00:29:20 | 21 | 01:03:50 | 19.7389 |
| 4 | 5 | 00:17:50 | 26 | 01:21:40 | 19.1020 |
+-------+----+----------+---------+----------+--------------+
从这可以看出,运动员在比赛第二阶段的平均速度略有增加,但之后又下降,这可能是疲劳导致的结果。
17.11 分配排名
问题
您希望对一组值分配排名。
解决方案
决定排名方法,然后按所需顺序排列值并应用该方法。
讨论
一些统计测试需要对数值进行排名。本节描述了三种排名方法,并展示了如何使用窗口函数实现每种方法。示例假设表ranks包含以下要按降序排名的分数:
mysql> `SELECT score FROM ranks ORDER BY score DESC;`
+-------+
| score |
+-------+
| 5 |
| 4 |
| 4 |
| 3 |
| 2 |
| 2 |
| 2 |
| 1 |
+-------+
一种排名方法简单地将每个值分配到其值有序集合内的行号。要生成这样的排名,请使用窗口函数ROW_NUMBER():
mysql> `SELECT ROW_NUMBER() OVER win AS 'rank',`
-> `score FROM ranks WINDOW win AS (ORDER BY score DESC);`
+------+-------+
| rank | score |
+------+-------+
| 1 | 5 |
| 2 | 4 |
| 3 | 4 |
| 4 | 3 |
| 5 | 2 |
| 6 | 2 |
| 7 | 2 |
| 8 | 1 |
+------+-------+
8 rows in set (0,00 sec)
这种排名方法不考虑可能出现的并列情况(数值相同的情况)。窗口函数DENSE_RANK()通过仅在数值变化时提升排名来解决这一问题:
mysql> `SELECT DENSE_RANK() OVER win AS 'rank',`
> `score FROM ranks WINDOW win AS (ORDER BY score DESC);`
+------+-------+
| rank | score |
+------+-------+
| 1 | 5 |
| 2 | 4 |
| 2 | 4 |
| 3 | 3 |
| 4 | 2 |
| 4 | 2 |
| 4 | 2 |
| 5 | 1 |
+------+-------+
窗口函数RANK()有点像其他两种方法的组合。它按行号对值进行排名,但在出现并列情况时,每个并列的值都获得与第一个值行号相同的排名。
mysql> `SELECT ROW_NUMBER() OVER win AS 'row',`
-> `RANK() OVER win AS 'rank',`
-> `score FROM ranks WINDOW win AS (ORDER BY score DESC);`
+------+------+-------+
| row | rank | score |
+------+------+-------+
| 1 | 1 | 5 |
| 2 | 2 | 4 |
| 3 | 2 | 4 |
| 4 | 4 | 3 |
| 5 | 5 | 2 |
| 6 | 5 | 2 |
| 7 | 5 | 2 |
| 8 | 8 | 1 |
+------+------+-------+
在程序中分配排名也很容易。例如,以下 Ruby 片段使用第三种排名方法对ranks中的分数进行排名:
res = client.query("SELECT score FROM ranks ORDER BY score DESC")
rownum = 0
rank = 0
prev_score = nil
puts "Row\tRank\tScore\n"
res.each do |row|
score = row.values[0]
rownum += 1
rank = rownum if rownum == 1 || prev_score != score
prev_score = score
puts "#{rownum}\t#{rank}\t#{score}"
end
第三种排名方法通常用于体育赛事。以下表格包含了 2001 棒球赛季中赢得 15 场或更多比赛的美国联盟投手:
mysql> `SELECT name, wins FROM al_winner ORDER BY wins DESC, name;`
+----------------+------+
| name | wins |
+----------------+------+
| Mulder, Mark | 21 |
| Clemens, Roger | 20 |
| Moyer, Jamie | 20 |
| Garcia, Freddy | 18 |
| Hudson, Tim | 18 |
| Abbott, Paul | 17 |
| Mays, Joe | 17 |
| Mussina, Mike | 17 |
| Sabathia, C.C. | 17 |
| Zito, Barry | 17 |
| Buehrle, Mark | 16 |
| Milton, Eric | 15 |
| Pettitte, Andy | 15 |
| Radke, Brad | 15 |
| Sele, Aaron | 15 |
+----------------+------+
可以按第三种方法对这些投手进行排名如下:
mysql> `SELECT ROW_NUMBER() OVER win AS 'row',`
-> `RANK() OVER win AS 'rank',`
-> `name, wins`
-> `FROM al_winner WINDOW win AS (ORDER BY wins DESC);`
+------+------+----------------+------+
| row | rank | name | wins |
+------+------+----------------+------+
| 1 | 1 | Mulder, Mark | 21 |
| 2 | 2 | Clemens, Roger | 20 |
| 3 | 2 | Moyer, Jamie | 20 |
| 4 | 4 | Garcia, Freddy | 18 |
| 5 | 4 | Hudson, Tim | 18 |
| 6 | 6 | Zito, Barry | 17 |
| 7 | 6 | Sabathia, C.C. | 17 |
| 8 | 6 | Mussina, Mike | 17 |
| 9 | 6 | Mays, Joe | 17 |
| 10 | 6 | Abbott, Paul | 17 |
| 11 | 11 | Buehrle, Mark | 16 |
| 12 | 12 | Milton, Eric | 15 |
| 13 | 12 | Pettitte, Andy | 15 |
| 14 | 12 | Radke, Brad | 15 |
| 15 | 12 | Sele, Aaron | 15 |
+------+------+----------------+------+
参见
关于窗口函数的更多信息,请参见 Recipe 15.15。
17.12 计算球队排名
问题
您希望根据胜负记录计算球队的排名,包括落后的比赛(GB)值。
解决方案
确定哪个队伍处于第一名,然后将该结果连接到原始行中。
讨论
竞争对手之间的排名问题,但排名不基于单一度量,如 Recipe 17.11 中所述。排名基于两个值,即胜利和失败。球队根据谁有最佳的胜负记录进行排名,不在第一名的球队被分配一个“落后比赛”的值,指示他们落后第一名多少比赛。本节展示了如何计算这些值。第一个示例使用一个包含单个球队记录的表来说明计算逻辑。第二个示例使用一个包含多组记录的表(即联盟的两个分区以及赛季的两半)来说明计算逻辑。在这种情况下,需要使用连接来独立为每组球队执行计算。
考虑以下表格standings1,其中包含了一组单一的棒球队记录,代表了 1902 年北方联盟的最终排名:
mysql> `SELECT team, wins, losses FROM standings1`
-> `ORDER BY wins-losses DESC;`
+-------------+------+--------+
| team | wins | losses |
+-------------+------+--------+
| Winnipeg | 37 | 20 |
| Crookston | 31 | 25 |
| Fargo | 30 | 26 |
| Grand Forks | 28 | 26 |
| Devils Lake | 19 | 31 |
| Cavalier | 15 | 32 |
+-------------+------+--------+
行按胜负差异排序,这是将球队按照从第一名到最后一名的顺序排列的方法。但是球队排名的显示通常包括每支球队的胜率以及一个指示其他所有球队落后领先者多少场的数字。因此,让我们在输出中添加这些信息。计算百分比很容易。它是赢得的比赛数与总比赛数的比率,可以使用以下表达式确定:
wins / (wins + losses)
当一个球队还没有打过比赛时,这个表达式会涉及除以零的除法。为简单起见,我将假设至少有一个非零的比赛数。要处理这种情况,您可以使用一个更通用的表达式:
IF(wins=0,0,wins/(wins+losses))
这个表达式依赖于一个事实,即除非球队至少赢了一场比赛,否则不需要除法运算。
确定 GB 值有点棘手。它基于两支球队的胜负记录的关系,计算为两个值的平均数:
-
第一名球队比第二名球队多赢多少场比赛
-
第一名球队比第二名球队少多少场败仗
假设两支球队 A 和 B 的胜负记录如下:
+------+------+--------+
| team | wins | losses |
+------+------+--------+
| A | 17 | 11 |
| B | 14 | 12 |
+------+------+--------+
在这里,B 队必须再赢三场比赛,而 A 队必须再输一场比赛,才能使两支队伍平均。三和一的平均数是二,因此 B 队落后 A 队两场比赛。在数学上,计算两支球队的 GB 值如下:
((winsA - winsB) + (lossesB - lossesA)) / 2
稍微调整一下术语,表达式变成:
((winsA - lossesA) - (winsB - lossesB)) / 2
第二个表达式等同于第一个,但它将每个因素写为单个球队的胜负差异,而不是作为两支球队之间的比较。这样做更容易处理,因为可以独立确定每个因素从单一球队记录中。第一个因素代表第一名球队的胜负差异,因此如果首先计算该值,其他球队的 GB 值可以与之关联确定。
第一名球队是胜负差异最大的球队。要找到该值并将其保存在一个变量中,请使用以下语句:
mysql> `SET @wl_diff = (SELECT MAX(wins-losses) FROM standings1);`
然后按如下方式使用微分来生成包括胜率和 GB 值在内的球队排名:
mysql> `SELECT team, wins AS W, losses AS L,`
-> `wins/(wins+losses) AS PCT,`
-> `(@wl_diff - (wins-losses)) / 2 AS GB`
-> `FROM standings1`
-> `ORDER BY wins-losses DESC, PCT DESC;`
+-------------+------+------+--------+---------+
| team | W | L | PCT | GB |
+-------------+------+------+--------+---------+
| Winnipeg | 37 | 20 | 0.6491 | 0.0000 |
| Crookston | 31 | 25 | 0.5536 | 5.5000 |
| Fargo | 30 | 26 | 0.5357 | 6.5000 |
| Grand Forks | 28 | 26 | 0.5185 | 7.5000 |
| Devils Lake | 19 | 31 | 0.3800 | 14.5000 |
| Cavalier | 15 | 32 | 0.3191 | 17.0000 |
+-------------+------+------+--------+---------+
此时需要解决几个微小的格式问题。通常,排名列表将百分比显示为三位小数,并将 GB 值显示为一位小数(但是第一名球队的 GB 值显示为-)。要显示n位小数,使用TRUNCATE(expr,n)。为了正确显示第一名球队的 GB 值,请使用IF()表达式将 0 映射到破折号:
mysql> `SELECT team, wins AS W, losses AS L,`
-> `TRUNCATE(wins/(wins+losses),3) AS PCT,`
-> `IF(@wl_diff = wins-losses,`
-> `'-',TRUNCATE((@wl_diff - (wins-losses))/2,1)) AS GB`
-> `FROM standings1`
-> `ORDER BY wins-losses DESC, PCT DESC;`
+-------------+------+------+-------+------+
| team | W | L | PCT | GB |
+-------------+------+------+-------+------+
| Winnipeg | 37 | 20 | 0.649 | - |
| Crookston | 31 | 25 | 0.553 | 5.5 |
| Fargo | 30 | 26 | 0.535 | 6.5 |
| Grand Forks | 28 | 26 | 0.518 | 7.5 |
| Devils Lake | 19 | 31 | 0.380 | 14.5 |
| Cavalier | 15 | 32 | 0.319 | 17.0 |
+-------------+------+------+-------+------+
这些语句按照胜负差异对团队进行排序,使用胜率作为打结者以防存在具有相同差异值的团队。当然,按百分比排序更简单,但这样做并不总能得到正确的排序。有趣的是,一个胜率较低的团队实际上可能在排名中高于一个胜率较高的团队。 (这通常发生在赛季初期,当团队可能已经相对不均匀地进行了大量比赛时。)考虑以下情况,即两支团队 A 和 B,它们具有以下行:
+------+------+--------+
| team | wins | losses |
+------+------+--------+
| A | 4 | 1 |
| B | 2 | 0 |
+------+------+--------+
应用 GB 和百分比计算到这些团队记录上得到以下结果,其中第一名的团队实际上比第二名的团队拥有更低的胜率:
+------+------+------+-------+------+
| team | W | L | PCT | GB |
+------+------+------+-------+------+
| A | 4 | 1 | 0.800 | - |
| B | 2 | 0 | 1.000 | 0.5 |
+------+------+------+-------+------+
到目前为止展示的排名计算可以在没有 join 的情况下完成。它们仅涉及单一组团队记录,因此第一名团队的胜负差异可以存储在一个变量中。当数据集包含多组团队记录时,则会出现更复杂的情况。例如,1997 年北部联赛有两个分区(东部和西部)。此外,因为赛季的上半部分和下半部分的赢家在每个分区内争夺联赛冠军的权利,所以单独的排名保持了分别的排名。以下表standings2按赛季半程、分区和胜负差异排序,展示了这些行:
mysql> `SELECT half, division, team, wins, losses FROM standings2`
-> `ORDER BY half, division, wins-losses DESC;`
+------+----------+-----------------+------+--------+
| half | division | team | wins | losses |
+------+----------+-----------------+------+--------+
| 1 | Eastern | St. Paul | 24 | 18 |
| 1 | Eastern | Thunder Bay | 18 | 24 |
| 1 | Eastern | Duluth-Superior | 17 | 24 |
| 1 | Eastern | Madison | 15 | 27 |
| 1 | Western | Winnipeg | 29 | 12 |
| 1 | Western | Sioux City | 28 | 14 |
| 1 | Western | Fargo-Moorhead | 21 | 21 |
| 1 | Western | Sioux Falls | 15 | 27 |
| 2 | Eastern | Duluth-Superior | 22 | 20 |
| 2 | Eastern | St. Paul | 21 | 21 |
| 2 | Eastern | Madison | 19 | 23 |
| 2 | Eastern | Thunder Bay | 18 | 24 |
| 2 | Western | Fargo-Moorhead | 26 | 16 |
| 2 | Western | Winnipeg | 24 | 18 |
| 2 | Western | Sioux City | 22 | 20 |
| 2 | Western | Sioux Falls | 16 | 26 |
+------+----------+-----------------+------+--------+
为这些行生成排名需要分别为赛季的四个组合计算 GB 值。首先,计算每组的第一名团队的胜负差异并将其值保存到单独的firstplace表中:
mysql> `CREATE TEMPORARY TABLE firstplace`
-> `SELECT half, division, MAX(wins-losses) AS wl_diff`
-> `FROM standings2`
-> `GROUP BY half, division;`
然后将firstplace表与原始排名表连接,将每个团队记录与正确的胜负差异关联起来计算其 GB 值:
mysql> `SELECT wl.half, wl.division, wl.team, wl.wins AS W, wl.losses AS L,`
-> `TRUNCATE(wl.wins/(wl.wins+wl.losses),3) AS PCT,`
-> `IF(fp.wl_diff = wl.wins-wl.losses,`
-> `'-',TRUNCATE((fp.wl_diff - (wl.wins-wl.losses)) / 2,1)) AS GB`
-> `FROM standings2 AS wl INNER JOIN firstplace AS fp`
-> `ON wl.half = fp.half AND wl.division = fp.division`
-> `ORDER BY wl.half, wl.division, wl.wins-wl.losses DESC, PCT DESC;`
+------+----------+-----------------+------+------+-------+------+
| half | division | team | W | L | PCT | GB |
+------+----------+-----------------+------+------+-------+------+
| 1 | Eastern | St. Paul | 24 | 18 | 0.571 | - |
| 1 | Eastern | Thunder Bay | 18 | 24 | 0.428 | 6.0 |
| 1 | Eastern | Duluth-Superior | 17 | 24 | 0.414 | 6.5 |
| 1 | Eastern | Madison | 15 | 27 | 0.357 | 9.0 |
| 1 | Western | Winnipeg | 29 | 12 | 0.707 | - |
| 1 | Western | Sioux City | 28 | 14 | 0.666 | 1.5 |
| 1 | Western | Fargo-Moorhead | 21 | 21 | 0.500 | 8.5 |
| 1 | Western | Sioux Falls | 15 | 27 | 0.357 | 14.5 |
| 2 | Eastern | Duluth-Superior | 22 | 20 | 0.523 | - |
| 2 | Eastern | St. Paul | 21 | 21 | 0.500 | 1.0 |
| 2 | Eastern | Madison | 19 | 23 | 0.452 | 3.0 |
| 2 | Eastern | Thunder Bay | 18 | 24 | 0.428 | 4.0 |
| 2 | Western | Fargo-Moorhead | 26 | 16 | 0.619 | - |
| 2 | Western | Winnipeg | 24 | 18 | 0.571 | 2.0 |
| 2 | Western | Sioux City | 22 | 20 | 0.523 | 4.0 |
| 2 | Western | Sioux Falls | 16 | 26 | 0.380 | 10.0 |
+------+----------+-----------------+------+------+-------+------+
然而,那种输出很难阅读。为了使其更易理解,您可以在程序内执行语句并重新格式化其结果,以单独显示每组团队记录。以下是一些 Perl 代码,通过在遇到新的排名组时开始新的输出组来实现这一点。代码假设刚刚执行了 join 语句,并且其结果通过语句句柄$sth可用:
my ($cur_half, $cur_div) = ("", "");
while (my ($half, $div, $team, $wins, $losses, $pct, $gb)
= $sth->fetchrow_array ())
{
if ($cur_half ne $half || $cur_div ne $div) # new group of standings?
{
# print standings header and remember new half/division values
print "\n$div Division, season half $half\n";
printf "%-20s %3s %3s %5s %s\n", "Team", "W", "L", "PCT", "GB";
$cur_half = $half;
$cur_div = $div;
}
printf "%-20s %3d %3d %5s %s\n", $team, $wins, $losses, $pct, $gb;
}
重新格式化的输出如下所示:
Eastern Division, season half 1
Team W L PCT GB
St. Paul 24 18 0.571 -
Thunder Bay 18 24 0.428 6.0
Duluth-Superior 17 24 0.414 6.5
Madison 15 27 0.357 9.0
Western Division, season half 1
Team W L PCT GB
Winnipeg 29 12 0.707 -
Sioux City 28 14 0.666 1.5
Fargo-Moorhead 21 21 0.500 8.5
Sioux Falls 15 27 0.357 14.5
Eastern Division, season half 2
Team W L PCT GB
Duluth-Superior 22 20 0.523 -
St. Paul 21 21 0.500 1.0
Madison 19 23 0.452 3.0
Thunder Bay 18 24 0.428 4.0
Western Division, season half 2
Team W L PCT GB
Fargo-Moorhead 26 16 0.619 -
Winnipeg 24 18 0.571 2.0
Sioux City 22 20 0.523 4.0
Sioux Falls 16 26 0.380 10.0
刚刚显示的代码来自recipes发行版的stats目录中的calc_standings.pl脚本。该目录还包含一个 PHP 脚本calc_standings.php,以 HTML 表格的形式生成输出,您可能更喜欢在 Web 环境中生成排名。
^(1) 此处给出的中位数定义并不完全通用;它没有解决数据集中中间值重复的情况。
^(2) 要了解这些术语的来源,请参阅任何一本标准统计学教材。
第十八章:处理重复行
18.0 Introduction
表格或结果集有时会包含重复行。在某些情况下这是可以接受的。例如,如果你进行了记录日期、客户 IP 地址以及投票结果的网络民意调查,重复行可能是允许的,因为大量的投票可能看起来来自同一个 IP 地址,这是因为某些互联网服务通过单一代理主机路由客户流量。在其他情况下,重复行是不可接受的,你会想要采取措施来避免它们。处理重复行涉及以下操作:
-
首先防止创建重复行。如果表中的每一行都代表一个单一实体(例如一个人、目录中的一个项目或实验中的特定观察),重复的发生会使得无法明确地引用每一行,因此最好确保永远不会发生重复。
-
计算重复行的数量,以确定它们的存在程度。
-
识别重复值(或包含它们的行),以便看到它们出现的位置。
-
消除重复行以确保每一行都是唯一的。这可能包括从表中删除行,只留下唯一的行,或者选择一个结果集以使输出中不出现重复。例如,要显示你的客户所在的各州列表,你可能不希望看到来自所有客户记录的长长的州名称列表。只显示每个州名称一次就足够了,而且更容易理解。
有多种工具可供处理重复行。根据你想要实现的目标选择这些工具:
-
创建表时,包括主键或唯一索引,以防止重复行添加到表中。MySQL 使用索引作为约束来强制要求表中的每一行在索引列或列组中包含唯一键。
-
与唯一索引结合使用,
INSERT IGNORE和REPLACE语句使您能够优雅地处理重复行的插入,而不会产生错误。对于批量加载操作,可以使用LOAD DATA语句的IGNORE或REPLACE修饰符来实现相同的选项。 -
要确定表是否包含重复值,可以使用
GROUP BY将行分组,并使用COUNT()查看每个组中有多少行。第十章在生成摘要的上下文中描述了这些技术,但它们也适用于重复计数和识别。计数摘要将值分组为类别,以确定每个值出现的频率。 -
SELECTDISTINCT从结果集中删除重复行(参见 Recipe 5.4 获取更多信息)。对于已包含重复项的现有表,您可以选择唯一行到第二个表并用其替换原始表。或者,如果确定表中有n个相同行,可以使用DELETE…LIMIT从该特定行集中消除n–1 个实例。
本章节展示的示例相关脚本位于recipes发行版的dups目录中。要查找创建这里使用的表的脚本,请查看tables目录。
18.1 在表中防止重复项的出现
问题
您希望防止表中永远包含重复项。
解决方案
使用PRIMARY KEY或UNIQUE索引。
讨论
要确保表中的行是唯一的,某些列或列组合必须要求每行包含唯一值。当满足此要求时,可以使用其唯一标识符明确引用表中的任何行。为确保表具有此特性,请在表结构中包含PRIMARY KEY或UNIQUE索引。以下表格不包含此类索引,因此允许重复行:
CREATE TABLE person
(
last_name CHAR(20),
first_name CHAR(20),
address CHAR(40)
);
要防止在此表中创建具有相同名字和姓氏值的多行,请在其定义中添加PRIMARY KEY。在执行此操作时,索引列必须为NOT NULL,因为PRIMARY KEY不允许NULL值:
CREATE TABLE person
(
last_name CHAR(20) NOT NULL,
first_name CHAR(20) NOT NULL,
address CHAR(40),
PRIMARY KEY (last_name, first_name)
);
表中的唯一索引存在时,如果向表中插入与定义索引的列中的现有行重复的行,则通常会导致错误。Recipe 18.3 讨论如何处理此类错误或修改 MySQL 的重复处理行为。
强制实现唯一性的另一种方法是向表中添加UNIQUE索引而不是PRIMARY KEY。这两种索引类型类似,但可以在允许NULL值的列上创建UNIQUE索引。对于person表,可能需要填写名字和姓氏。如果是这样,仍然将列声明为NOT NULL,并且以下表定义与前面的表定义实际上是等效的:
CREATE TABLE person
(
last_name CHAR(20) NOT NULL,
first_name CHAR(20) NOT NULL,
address CHAR(40),
UNIQUE (last_name, first_name)
);
如果UNIQUE索引确实允许NULL值,那么NULL是特殊的,因为它是唯一可能多次出现的值。其理由在于无法确定一个未知值是否与另一个相同,因此允许多个未知值。
当然,您可能希望 person 表反映现实世界,其中人们有时确实会有相同的姓名。在这种情况下,您不能基于姓名列设置唯一索引,因为必须允许重复的姓名。相反,每个人必须被分配某种唯一标识符,这成为区分一行与另一行的值。在 MySQL 中,通常通过使用 AUTO_INCREMENT 列来实现这一点:
CREATE TABLE person
(
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
last_name CHAR(20),
first_name CHAR(20),
address CHAR(40),
PRIMARY KEY (id)
);
在这种情况下,如果使用 NULL 值的 id 创建行,则 MySQL 会自动分配该列的唯一 ID。另一种可能性是外部分配标识符并将这些 ID 用作唯一键。例如,特定国家的公民可能具有唯一的纳税人身份证号码。如果是这样,这些号码可以作为唯一索引的基础:
CREATE TABLE person
(
tax_id INT UNSIGNED NOT NULL,
last_name CHAR(20),
first_name CHAR(20),
address CHAR(40),
PRIMARY KEY (tax_id)
);
参见
如果现有表已包含您希望删除的重复行,请参见 Recipe 18.5。第十五章 进一步讨论了 AUTO_INCREMENT 列。
18.2 在表中拥有多个唯一键
问题
您需要在表中具有两个或更多列集合,这些列集合需要具有唯一值。
解决方案
定义所需的多个唯一键。
讨论
可能存在两个或多个需要独立具有唯一值的列组合。例如,在 Recipe 18.1 中的最后一个示例中的 person 表具有代表纳税人 ID 的 tax_id 列,因此需要存储唯一值。仍然可能希望在 (last_name, first_name) 上保持唯一索引。这样,您可以确保每个人都有自己的纳税人 ID,而任何纳税人 ID 都属于唯一的人。
任何表最多只能有一个主键。因此,您需要选择哪个键将成为主键,哪个键将成为次要的唯一键。正如我们在 Recipe 21.2 中描述的那样,对于 InnoDB 存储引擎,主键包含在所有次要索引中,使用尽可能小的数据类型来定义它们对性能至关重要。因此,可以直接为 tax_id 列定义主键,以及在 (last_name, first_name) 上定义一个次要的唯一索引。
结果表定义如下所示:
CREATE TABLE `person` (
`tax_id` INT UNSIGNED NOT NULL,
`last_name` CHAR(20) DEFAULT NULL,
`first_name` CHAR(20) DEFAULT NULL,
`address` CHAR(40) DEFAULT NULL,
PRIMARY KEY (`tax_id`),
UNIQUE KEY `last_name` (`last_name`,`first_name`)
);
18.3 在将行加载到表中时处理重复项
问题
您已创建了一个带有唯一索引的表,以防止索引列或列中的重复值。但是,如果尝试插入重复行,则会出现错误,您希望避免处理此类错误。
解决方案
一种方法是简单地忽略错误。另一种方法是使用 INSERT IGNORE、REPLACE 或 INSERT ... ON DUPLICATE KEY UPDATE 语句,每种方法都会修改 MySQL 的重复处理行为。对于批量加载操作,LOAD DATA 有修饰符,允许您指定如何处理重复项。
讨论
默认情况下,当插入重复现有唯一键值的行时,MySQL 会生成错误。假设person表具有以下结构,并在last_name和first_name列上有唯一索引:
CREATE TABLE person
(
last_name CHAR(20) NOT NULL,
first_name CHAR(20) NOT NULL,
address CHAR(40),
PRIMARY KEY (last_name, first_name)
);
尝试在索引列中插入具有重复值的行会导致错误:
mysql> `INSERT INTO person (last_name, first_name)`
-> `VALUES('Pinter', 'Marlene');`
Query OK, 1 row affected (0.00 sec)
mysql> `INSERT INTO person (last_name, first_name)`
-> `VALUES('Pinter', 'Marlene');`
ERROR 1062 (23000): Duplicate entry 'Pinter-Marlene' for key 'person.PRIMARY'
如果您在mysql程序中交互地发出这些语句,您可以简单地说:好吧,那不起作用,
忽略错误,然后继续。但如果你编写一个程序来插入行,错误可能会终止程序。避免这种情况的一种方法是修改程序的错误处理行为以捕获错误,然后忽略它。参见 Recipe 4.2 获取有关错误处理技术的信息。
要在第一次就防止错误发生,可以考虑使用两个查询方法来解决重复行问题:
-
发出
SELECT以检查行是否已存在。 -
如果行不存在,则发出
INSERT。
但这并不起作用:另一个客户可能会在SELECT和INSERT之间插入相同的行,这样你的INSERT仍然会出错。为了确保这种情况不会发生,你可以使用事务或锁定表,但这样一来,你的两个语句就变成了四个。MySQL 提供了处理重复行问题的三种单查询解决方案。根据你想要的重复处理行为,从中选择一种:
-
若要在重复出现时保留原始行,请使用
INSERTIGNORE而不是INSERT。如果行不重复现有行,则 MySQL 会像往常一样插入它。如果行是重复的,则IGNORE关键字告诉 MySQL 静默丢弃它,而不生成错误:mysql> `INSERT IGNORE INTO person (last_name, first_name)` -> `VALUES('Brown', 'Bartholomew');` Query OK, 1 row affected (0.00 sec) mysql> `INSERT IGNORE INTO person (last_name, first_name)` -> `VALUES('Brown', 'Bartholomew');` Query OK, 0 rows affected, 1 warning (0.00 sec)行计数值指示是否插入或忽略了行。从程序内部,您可以通过检查 API 提供的影响行数函数来获取此值(参见 Recipe 4.4 和 Recipe 12.1)。
-
若要在重复出现时用新行替换原始行,请使用
REPLACE而不是INSERT。如果行是新的,则像INSERT一样插入它。如果是重复的,则新行将替换旧行:mysql> `REPLACE INTO person (last_name, first_name, address)` -> `VALUES('Baxter', 'Wallace', '57 3rd Ave.');` Query OK, 1 row affected (0.00 sec) mysql> `REPLACE INTO person (last_name, first_name, address)` -> `VALUES('Baxter', 'Wallace', '57 3rd Ave., Apt 102');` Query OK, 2 rows affected (0.00 sec)在第二种情况下,受影响的行数为 2,因为原始行被删除,新行插入到其位置。
-
要在重复出现时修改现有行的列,请使用
INSERT…ONDUPLICATEKEYUPDATE。如果行是新的,则插入它。如果是重复的,则ONDUPLICATEKEYUPDATE子句指示如何在表中修改现有行。换句话说,此语句可以根据需要插入或更新行。受影响的行数指示发生了什么:插入为 1,更新为 2。
INSERT IGNORE比REPLACE更高效,因为它实际上不会插入重复项。因此,在您只想确保表中存在给定行的副本时,它最为适用。另一方面,REPLACE通常更适用于需要替换其他非关键列的表。INSERT … ON DUPLICATE KEY UPDATE在必须在记录不存在时插入记录,但如果新记录在索引列中是重复的,则仅更新其中一些列时非常适用。
假设您为包含电子邮件地址和密码哈希值的 Web 应用程序维护名为passtbl的表,并且该表由电子邮件地址索引:
CREATE TABLE passtbl
(
email VARCHAR(60) NOT NULL,
password VARBINARY(60) NOT NULL,
PRIMARY KEY (email)
);
如何为新用户创建新行,但为现有用户更改现有行的密码?以下是处理行维护的典型算法:
-
发出
SELECT以检查是否已存在具有给定email值的行。 -
如果不存在这样的行,请使用
INSERT添加新行。 -
如果行存在,则使用
UPDATE更新它。
必须在事务内执行这些步骤或锁定表,以防其他用户在您使用表时更改表。在 MySQL 中,您可以使用REPLACE来简化这两种情况为同一个单语句操作:
REPLACE INTO passtbl (email,password) VALUES(*`address`*,*`hash_value`*);
如果不存在具有给定电子邮件地址的行,则 MySQL 创建一个新行。否则,MySQL 替换它,实际上更新与该地址相关的行的password列。
INSERT IGNORE和REPLACE在您尝试插入行时知道确切的值应存储在表中时很有用。情况并非总是如此。例如,您可能希望仅在行不存在时插入一行,否则仅更新其中的某些部分。这在您使用表进行计数时经常发生。假设您在选举中记录候选人的投票,使用以下表格:
CREATE TABLE poll_vote
(
poll_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
candidate_id INT UNSIGNED,
vote_count INT UNSIGNED,
PRIMARY KEY (poll_id, candidate_id)
);
主键是投票和候选人编号的组合。应该像这样使用表:
-
对于给定投票候选人的第一次投票收到,插入一个新行,投票计数为 1。
-
对于该候选人的后续投票,增加现有记录的投票计数。
在这里既不适用INSERT IGNORE也不适用REPLACE,因为除第一次投票外,您不知道投票计数应该是多少。在这种情况下,INSERT … ON DUPLICATE KEY UPDATE更为合适。以下示例显示了它的工作原理,从空表开始:
mysql> `SELECT * FROM poll_vote;`
Empty set (0.00 sec)
mysql> `INSERT INTO poll_vote (poll_id,candidate_id,vote_count) VALUES(14,3,1)`
-> `ON DUPLICATE KEY UPDATE vote_count = vote_count + 1;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT * FROM poll_vote;`
+---------+--------------+------------+
| poll_id | candidate_id | vote_count |
+---------+--------------+------------+
| 14 | 3 | 1 |
+---------+--------------+------------+
1 row in set (0.00 sec)
mysql> `INSERT INTO poll_vote (poll_id,candidate_id,vote_count) VALUES(14,3,1)`
-> `ON DUPLICATE KEY UPDATE vote_count = vote_count + 1;`
Query OK, 2 rows affected (0.00 sec)
mysql> `SELECT * FROM poll_vote;`
+---------+--------------+------------+
| poll_id | candidate_id | vote_count |
+---------+--------------+------------+
| 14 | 3 | 2 |
+---------+--------------+------------+
1 row in set (0.00 sec)
对于第一个INSERT,不存在候选人的行,因此插入该行。对于第二个INSERT,行存在,因此 MySQL 只更新投票计数。使用INSERT … ON DUPLICATE KEY UPDATE,您无需检查行是否存在;MySQL 会为您执行。行计数指示INSERT语句执行的操作:对于新行为 1,对于现有行的更新为 2。
刚才描述的技术的好处是减少可能需要的事务开销。但是,这种好处是以可移植性为代价的,因为它们都涉及特定于 MySQL 的语法。如果可移植性是高优先级的话,您可能更喜欢使用我们在 第二十章 中讨论的事务性方法。
参见
对于使用 LOAD DATA 语句从文件加载一组行到表中进行批量记录加载操作,请使用该语句的 IGNORE 和 REPLACE 修饰符来控制重复行处理。这些修饰符的行为类似于 INSERT IGNORE 和 REPLACE 语句。有关更多信息,请参阅 Recipe 13.1。
Recipe 15.12 进一步展示了使用 INSERT … ON DUPLICATE KEY UPDATE 来初始化和更新计数的方法。
18.4 计数和识别重复项
问题
您想确定表中是否存在重复项,以及它们的程度。或者您想查看包含重复值的行。
解决方案
使用显示重复值的计数摘要。要查看包含重复值的行,请将摘要与原始表连接,以显示匹配的行。
讨论
假设您的网站有一个注册页面,访客可以在该页面上将自己添加到邮寄产品目录的邮件列表中。但是,当您创建表时,忘记在表中包含唯一索引,现在您怀疑有些人多次注册。也许他们忘记了已经在列表上,或者可能有人添加了已经在列表上的朋友。无论如何,重复行的结果是您会重复邮寄目录。这对您来说是额外的开支,也会令收件人感到烦恼。本节讨论如何确定表中是否存在重复行,它们的普遍程度以及如何显示它们。(对于包含重复项的表,Recipe 18.5 描述了如何消除它们。)
要确定表中是否存在重复项,请使用计数摘要(在 第十章 中讨论)。摘要技术可应用于通过 GROUP BY 对行进行分组并使用 COUNT() 计数每个组中的行来识别和计数重复项。在这里的示例中,假设收件人目录在名为 catalog_list 的表中列出,其内容如下:
+-----------+-------------+--------------------------+
| last_name | first_name | street |
+-----------+-------------+--------------------------+
| Isaacson | Jim | 515 Fordam St., Apt. 917 |
| Baxter | Wallace | 57 3rd Ave. |
| McTavish | Taylor | 432 River Run |
| Pinter | Marlene | 9 Sunset Trail |
| BAXTER | WALLACE | 57 3rd Ave. |
| Brown | Bartholomew | 432 River Run |
| Pinter | Marlene | 9 Sunset Trail |
| Baxter | Wallace | 57 3rd Ave., Apt 102 |
+-----------+-------------+--------------------------+
假设您使用 last_name 和 first_name 列来定义 重复
。也就是说,拥有相同姓名的收件人被视为同一人。以下语句描述了表格并评估了重复值的存在和程度:
-
表中的总行数:
mysql> `SELECT COUNT(*) AS rows FROM catalog_list;` +------+ | rows | +------+ | 8 | +------+ -
不同名称的数量:
mysql> `SELECT COUNT(DISTINCT last_name, first_name) AS 'distinct names'` -> `FROM catalog_list;` +----------------+ | distinct names | +----------------+ | 5 | +----------------+ -
含有重复名称的行数:
mysql> `SELECT COUNT(*) - COUNT(DISTINCT last_name, first_name)` -> `AS 'duplicate names'` -> `FROM catalog_list;` +-----------------+ | duplicate names | +-----------------+ | 3 | +-----------------+ -
包含唯一或非唯一名称的行的比例:
mysql> `SELECT COUNT(DISTINCT last_name, first_name) / COUNT(*)` -> `AS 'unique',` -> `1 - (COUNT(DISTINCT last_name, first_name) / COUNT(*))` -> `AS 'nonunique'` -> `FROM catalog_list;` +--------+-----------+ | unique | nonunique | +--------+-----------+ | 0.6250 | 0.3750 | +--------+-----------+
这些语句帮助您描述重复项的范围,但不显示重复的值。要查看catalog_list表中重复的名称,请使用显示非唯一值以及计数的汇总语句:
mysql> `SELECT COUNT(*), last_name, first_name`
-> `FROM catalog_list`
-> `GROUP BY last_name, first_name`
-> `HAVING COUNT(*) > 1;`
+----------+-----------+------------+
| COUNT(*) | last_name | first_name |
+----------+-----------+------------+
| 3 | Baxter | Wallace |
| 2 | Pinter | Marlene |
+----------+-----------+------------+
语句包含一个HAVING子句,该子句限制输出仅包括出现多次的名称。通常,要识别重复的值集,请执行以下操作:
-
确定包含可能重复值的列。
-
在列选择列表中列出这些列,并包括
COUNT(*)。 -
在
GROUPBY子句中列出列。 -
添加一个
HAVING子句,通过要求组计数大于一来消除唯一值。
以以下形式构建的查询:
SELECT COUNT(*), *`column_list`*
FROM *`tbl_name`*
GROUP BY *`column_list`*
HAVING COUNT(*) > 1;
在程序内很容易生成此类查找重复项的查询,只需提供数据库和表名以及非空列名集。例如,下面是一个 Perl 函数make_dup_count_query(),用于生成查找和计算指定列中重复值的正确查询:
sub make_dup_count_query
{
my ($db_name, $tbl_name, @col_name) = @_;
return "SELECT COUNT(*)," . join (",", @col_name)
. "\nFROM $db_name.$tbl_name"
. "\nGROUP BY " . join (",", @col_name)
. "\nHAVING COUNT(*) > 1";
}
make_dup_count_query()将查询作为字符串返回。如果像这样调用它:
$str = make_dup_count_query ("cookbook", "catalog_list",
"last_name", "first_name");
$str的结果是:
SELECT COUNT(*),last_name,first_name
FROM cookbook.catalog_list
GROUP BY last_name,first_name
HAVING COUNT(*) > 1;
对查询字符串的处理方式取决于您。您可以在创建它的脚本中执行它,将它传递给另一个程序,或者将其写入文件以供稍后执行。recipes分发的dups目录包含一个名为dup_count.pl的脚本,您可以使用它来尝试该函数(以及其他语言的翻译)。Recipe 18.5 讨论了使用make_dup_count_query()来实现重复删除技术。
摘要技术对于评估重复项的存在性、频率以及显示哪些值重复是有用的。但是,如果仅使用表的一部分列确定重复项,则汇总本身无法显示包含重复值的行的整个内容。(例如,迄今显示的摘要仅显示了catalog_list表中重复名称的计数或名称本身,但没有显示与这些名称相关联的地址。)要查看包含重复名称的原始行,请将汇总信息与生成它的表进行连接。以下示例显示如何执行此操作以显示包含重复名称的catalog_list行。摘要写入临时表,然后与catalog_list表连接以生成与这些名称匹配的行:
mysql> `CREATE TABLE tmp`
-> `SELECT COUNT(*) AS count, last_name, first_name FROM catalog_list`
-> `GROUP BY last_name, first_name HAVING count > 1;`
mysql> `SELECT catalog_list.*`
-> `FROM tmp INNER JOIN catalog_list USING (last_name, first_name)`
-> `ORDER BY last_name, first_name;`
+-----------+------------+----------------------+
| last_name | first_name | street |
+-----------+------------+----------------------+
| Baxter | Wallace | 57 3rd Ave. |
| BAXTER | WALLACE | 57 3rd Ave. |
| Baxter | Wallace | 57 3rd Ave., Apt 102 |
| Pinter | Marlene | 9 Sunset Trail |
| Pinter | Marlene | 9 Sunset Trail |
+-----------+------------+----------------------+
18.5 从表中消除重复项
问题
您希望从表中删除重复行,仅保留唯一行。
解决方案
从表中选择唯一行到第二个表中,然后使用该表替换原始表。或者使用DELETE … LIMIT n来删除特定一组重复行的所有实例之外的所有行。
讨论
配方 18.1 讨论了如何通过创建具有唯一索引的表来防止将重复项添加到表中。然而,如果在创建表时忘记包括索引,则可能会后来发现它包含重复项,并且需要应用某种去重技术。之前使用的catalog_list表就是一个例子,因为它包含多个相同人物出现多次的情况:
mysql> `SELECT * FROM catalog_list ORDER BY last_name, first_name;`
+-----------+-------------+--------------------------+
| last_name | first_name | street |
+-----------+-------------+--------------------------+
| Baxter | Wallace | 57 3rd Ave. |
| BAXTER | WALLACE | 57 3rd Ave. |
| Baxter | Wallace | 57 3rd Ave., Apt 102 |
| Brown | Bartholomew | 432 River Run |
| Isaacson | Jim | 515 Fordam St., Apt. 917 |
| McTavish | Taylor | 432 River Run |
| Pinter | Marlene | 9 Sunset Trail |
| Pinter | Marlene | 9 Sunset Trail |
+-----------+-------------+--------------------------+
要消除重复项,您可以使用以下两种选项之一:
-
选择表的唯一行并插入另一个表中,然后使用该表替换原始表。这在“重复”表示“整行与另一行完全相同”的情况下有效。
-
对于特定集合的重复行,使用
DELETE…LIMITn来删除除一行外的所有行。
本配方讨论了每种去重方法。在为您的情况选择方法时,请考虑以下问题:
-
方法是否要求表具有唯一索引?
-
如果表中可能包含
NULL的列中有重复值,该方法会移除重复的NULL值吗? -
该方法是否能防止将来发生重复项?
使用表替换删除重复项
如果仅当整行完全相同时才认为一行是另一行的副本,那么从一个表中选择其唯一行到一个具有相同结构的新表中,然后用新表替换原始表是消除重复项的一种方法:
-
创建具有与原始表相同结构的新表。使用
CREATETABLE…LIKE对此非常有用(见配方 6.1):mysql> `CREATE TABLE tmp LIKE catalog_list;` -
使用
INSERTINTO…SELECTDISTINCT将原始表中的唯一行选择到新表中:mysql> `INSERT INTO tmp SELECT DISTINCT * FROM catalog_list;`从
tmp表中选择行以验证新表不包含重复项:mysql> `SELECT * FROM tmp ORDER BY last_name, first_name;` +-----------+-------------+--------------------------+ | last_name | first_name | street | +-----------+-------------+--------------------------+ | Baxter | Wallace | 57 3rd Ave. | | Baxter | Wallace | 57 3rd Ave., Apt 102 | | Brown | Bartholomew | 432 River Run | | Isaacson | Jim | 515 Fordam St., Apt. 917 | | McTavish | Taylor | 432 River Run | | Pinter | Marlene | 9 Sunset Trail | +-----------+-------------+--------------------------+ -
创建包含唯一行的新
tmp表后,使用它来替换原始catalog_list表:mysql> `DROP TABLE catalog_list;` mysql> `RENAME TABLE tmp TO catalog_list;`
此过程的有效结果是catalog_list不再包含重复项。
这种表替换方法适用于没有索引的情况(尽管对于大表可能会比较慢)。对于包含重复NULL值的表,它会删除这些重复项。它不能防止未来重复项的出现。
该方法要求行完全相同才被视为重复。因此,它将对 Wallace Baxter 的那些street值略有不同的行视为不同。
如果仅当表中列的子集存在重复值时,可以创建一个具有这些列的唯一索引的新表,使用INSERT IGNORE将行插入其中,并用新表替换原始表:
mysql> `CREATE TABLE tmp LIKE catalog_list;`
mysql> `ALTER TABLE tmp ADD PRIMARY KEY (last_name, first_name);`
mysql> `INSERT IGNORE INTO tmp SELECT * FROM catalog_list;`
mysql> `SELECT * FROM tmp ORDER BY last_name, first_name;`
+-----------+-------------+--------------------------+
| last_name | first_name | street |
+-----------+-------------+--------------------------+
| Baxter | Wallace | 57 3rd Ave. |
| Brown | Bartholomew | 432 River Run |
| Isaacson | Jim | 515 Fordam St., Apt. 917 |
| McTavish | Taylor | 432 River Run |
| Pinter | Marlene | 9 Sunset Trail |
+-----------+-------------+--------------------------+
mysql> `DROP TABLE catalog_list;`
mysql> `RENAME TABLE tmp TO catalog_list;`
唯一索引防止具有重复键值的行插入到 tmp 中,而 IGNORE 告诉 MySQL 如果发现重复项,则不要停止并显示错误。这种方法的一个缺点是,如果索引列可以包含 NULL 值,则必须使用 UNIQUE 索引而不是 PRIMARY KEY,在这种情况下,索引将不会删除重复的 NULL 键(UNIQUE 索引允许多个 NULL 值)。这种方法确实可以防止将来出现重复。
删除特定行的重复项
您可以使用 LIMIT 限制 DELETE 语句的影响范围,以便仅适用于删除重复行的子集。假设原始的未索引 catalog_list 表包含重复项:
mysql> `SELECT COUNT(*), last_name, first_name`
-> `FROM catalog_list`
-> `GROUP BY last_name, first_name`
-> `HAVING COUNT(*) > 1;`
+----------+-----------+------------+
| COUNT(*) | last_name | first_name |
+----------+-----------+------------+
| 3 | Baxter | Wallace |
| 2 | Pinter | Marlene |
+----------+-----------+------------+
要删除每个名称的额外实例,请执行以下操作:
mysql> `DELETE FROM catalog_list WHERE last_name = 'Baxter'`
-> `AND first_name = 'Wallace' LIMIT 2;`
mysql> `DELETE FROM catalog_list WHERE last_name = 'Pinter'`
-> `AND first_name = 'Marlene' LIMIT 1;`
mysql> `SELECT * FROM catalog_list;`
+-----------+-------------+--------------------------+
| last_name | first_name | street |
+-----------+-------------+--------------------------+
| Isaacson | Jim | 515 Fordam St., Apt. 917 |
| McTavish | Taylor | 432 River Run |
| Brown | Bartholomew | 432 River Run |
| Pinter | Marlene | 9 Sunset Trail |
| Baxter | Wallace | 57 3rd Ave., Apt 102 |
+-----------+-------------+--------------------------+
在没有唯一索引的情况下,这种技术可以消除重复的 NULL 值。这对于仅删除表内特定行集的重复项非常方便。但是,如果需要移除多个不同集合的重复项,则不建议手动进行此过程。可以通过使用食谱 18.4 中讨论的技术自动化此过程,以确定哪些值是重复的。在那里,我们编写了一个 make_dup_count_query() 函数来生成需要计算表中给定列集中重复值数量的语句。该语句的结果可以用来生成一组 DELETE … LIMIT n 语句,以删除重复行并仅保留唯一行。recipes 发行版的 dups 目录包含显示如何生成这些语句的代码。
通常情况下,使用 DELETE … LIMIT n 可能比通过使用第二个表或添加唯一索引来删除重复项要慢。这些方法将数据保留在服务器端,并让服务器完成所有工作。 DELETE … LIMIT n 包含大量的客户端-服务器交互,因为它使用 SELECT 语句来检索关于重复项的信息,然后使用多个 DELETE 语句来删除重复行的实例。此外,这种技术不能防止将来出现重复。
第十九章:使用 JSON
19.0 介绍
几十年来,关系数据库已被证明非常有效。它们防止了数据的重复和丢失,并且能够快速访问存储的值。然而,业务不断创造新的情景,其中数据需要比关系模型允许的更加灵活。
例如,让我们考虑一个可以访问仅订阅数字内容并留下评论的用户记录。对于这样的用户,只有基本信息 - 姓名、电子邮件地址和密码 - 就足以开始使用。然而,一旦用户开始探索更多选项(例如,需要交付物品),他们可能需要存储其邮寄地址。邮寄地址可能与账单地址不同。用户可能还希望添加一个或几个社交网络账号。
在关系数据库中存储灵活数据的一种方法是将附加数据片段存储在共享每个用户详细信息的引用表中。我们在配方 16.5 和配方 16.6 中讨论了这种技术。
然而,在以下情况下,这种技术可能不是最佳选择。
当主表中只有少数项目在引用表中具有详细信息时
如果您仍然需要了解这些详细信息,当您查询主表中所需字段时,您将需要每次将其与引用表进行连接。这将使查询复杂化并影响性能。
当大多数具体细节可能被忽略时
用户的区域或楼号等详细信息仅适用于请求物品实体交付的用户。对于其他用户,这些字段可以为空,但仍然需要在数据库中保留这些空字段的空间。随着数据库的增长,这将增加显著成本。
当您可能不知道未来需要哪些额外数据时
业务可能需要向数据集合添加附加详细信息。在关系模型中追加这些详细信息意味着在现有表格中创建新的表格和列。这需要模式重新设计和实施更改的维护窗口。这并不总是可行且成本/空间高效。
要解决这些问题,灵活的数据结构(如 JSON)是最佳选择。MySQL 始终允许使用字符串数据类型在文本字段中存储 JSON 值。自 MySQL 版本 5.7 以来,还支持 JSON 数据类型和函数,可以有效地操作 JSON 值。MySQL 结合了 SQL 和 NoSQL 世界的优势。
19.1 选择正确的数据类型
问题
您希望存储 JSON 值但不知道选择哪种数据类型。
解决方案
使用 JSON 数据类型。
讨论
JSON 数据可以存储在任何文本或二进制列中。JSON 函数可以正常工作,但是 JSON 特殊数据类型具有许多优点,特别是:
优化性能
JSON 数据被转换为一种格式,允许在文档中快速查找值。
部分更新
对 JSON 元素的更新在原地进行,无需重新编写完整文档。
自动数据验证
当将一个值插入 JSON 数据类型的列时,MySQL 会自动验证它,并在文档无效时产生错误。
以下代码将创建一个具有 JSON 列author的表。
CREATE TABLE book_authors (
id INT NOT NULL AUTO_INCREMENT,
author JSON NOT NULL,
PRIMARY KEY (id)
);
另请参阅
想要关于 JSON 数据类型的更多信息,请参阅 MySQL 用户参考手册中的JSON 数据类型。
19.2 插入 JSON 值
问题
你希望在 MySQL 中存储 JSON 文档。
解决方案
使用常规的INSERT语句。
讨论
JSON 与其他数据类型无异。使用常规的INSERT语句将文档添加到表中。
mysql> `INSERT` `INTO` `` ` ```书籍作者``` ` `` `VALUES`
-> `(``1``,``'{"id": 1, "name": "Paul",`
'> `"books"``:` `[`
'> `"Software Portability with imake: Practical Software Engineering",` '> `"Mysql: The Definitive Guide to Using, Programming,` ↩ `and Administering Mysql 4 (Developer\'s Library)"``,`
'> `"MySQL Certification Study Guide",`
'> `"MySQL (OTHER NEW RIDERS)"``,`
'> `"MySQL Cookbook",`
'> `"MySQL 5.0 Certification Study Guide"``,`
'> `"Using csh & tcsh: Type Less, Accomplish More` ↩ `(Nutshell Handbooks)",`
'> `"MySQL (Developer\'s Library)"``]``,`
'> `"lastname": "DuBois"}'``)``,`
-> `(``2``,``'{"id": 2, "name": "Alkin",`
'> `"books"``:` `[``"MySQL Cookbook"``]``,`
'> `"lastname": "Tezuysal"}'``)``,`
-> `(``3``,``'{"id": 3, "name": "Sveta",`
'> `"books"``:` `[``"MySQL Troubleshooting"``,` `"MySQL Cookbook"``]``,`
'> `"lastname": "Smirnova"}'``)``;`
Query OK, 3 rows affected (0,01 sec)
Records: 3 Duplicates: 0 Warnings: 0
19.3 验证 JSON
问题
你希望确保一个给定的字符串是有效的 JSON。
解决方案
使用 JSON 数据类型执行自动验证。使用函数JSON_VALID验证字符串。使用 JSON 模式定义 JSON 文档的模式。
讨论
函数JSON_VALID检查给定文档是否为有效的 JSON。
mysql> `SELECT` `JSON_VALID``(``'"name": "Sveta"'``)``;`
+-------------------------------+ | JSON_VALID('"name": "Sveta"') |
+-------------------------------+ | 0 |
+-------------------------------+ 1 row in set (0,00 sec)
mysql> `SELECT` `JSON_VALID``(``'{"name": "Sveta"}'``)``;`
+---------------------------------+ | JSON_VALID('{"name": "Sveta"}') |
+---------------------------------+ | 1 |
+---------------------------------+ 1 row in set (0,00 sec)
如果一个列定义为 JSON,MySQL 将不允许你插入无效值。此外,错误消息将定位到第一个错误,因此你可以更快地修复它。
mysql> `INSERT` `INTO` `book_authors``(``author``)`
-> `VALUES` `(``'{"name": "Sveta" "lastname": "Smirnova"'``)``;`
ERROR 3140 (22032): Invalid JSON text: "Missing a comma or '}' after an object↩
member." at position 17 in value for column 'book_authors.author'.
如果你不仅想验证一个 JSON 文档,还想使其满足一个模式,请使用函数JSON_SCHEMA_VALID。该函数支持 JSON 模式,如JSON 模式规范草案第 4 版所述。使用它之前,你需要先定义一个模式,然后将 JSON 值与之进行比较。
函数JSON_SCHEMA_VALIDATION_REPORT不仅检查给定的文档是否符合模式,还会报告违反模式的具体部分。
对于表book_authors,我们可以定义一个模式,要求必须包含name和lastname作为必填字段,并且可以选择包含书籍标题数组作为可选元素books。我们可以使用以下代码来定义模式:
{
"id": "http://www.oreilly.com/mysqlcookbook", 
"$schema": "http://json-schema.org/draft-04/schema#", 
"description": "Schema for the table book_authors", 
"type": "object", 
"properties": { 
"name": {"type": "string"},
"lastname": {"type": "string"},
"books": {"type": "array"}
},
"required":["name", "lastname"] 
}
模式的唯一标识符
JSON 模式规范。应始终为http://json-schema.org/draft-04/schema#
模式的描述
根元素的类型
属性列表。每个属性都应该被描述。它们应该有一个定义的类型,并可以指定其他验证,如最小和最大值。
必填字段列表
如果我们将刚刚定义的模式赋给一个变量,比如@schema,我们可以根据这个模式检查 JSON 数据。
mysql> `SET` `@``schema` `=` `'{` '> `"id"``:` `"http://www.oreilly.com/mysqlcookbook"``,`
'> `"$schema": "http://json-schema.org/draft-04/schema#",` '> `"description"``:` `"Schema for the table book_authors"``,`
'> `"type": "object",` '> `"properties"``:` `{`
'> `"name": {"type": "string"},` '> `"lastname"``:` `{``"type"``:` `"string"``}``,`
'> `"books": {"type": "array"}` '> `}``,`
'> `"required":["name", "lastname"]` '> `}``';` Query OK, 0 rows affected (0,00 sec)
mysql> `SELECT JSON_SCHEMA_VALIDATION_REPORT(@schema,` -> `'``{``"name"``:` `"Sveta"``}``') AS '``Valid``?``'\G` *************************** 1\. row ***************************
Valid?: {"valid": false, "reason": "The JSON document location '#' failed requirement ↩
'required' at JSON Schema location '#'", "schema-location": "#", ↩
"document-location": "#", "schema-failed-keyword": "required"}
1 row in set (0,00 sec)
在这种情况下,验证失败,因为文档仅包含name字段,而没有包含另一个必填字段lastname。
mysql> `SELECT` `JSON_SCHEMA_VALIDATION_REPORT``(``@``schema``,`
-> `'{"name": "Sveta", "lastname": "Smirnova"}'``)` `AS` `'Valid?'``;`
+-----------------+ | Valid? |
+-----------------+ | {"valid": true} |
+-----------------+ 1 row in set (0,00 sec)
在这种情况下,文档是有效的,因为它包含所有必需的字段。字段books是可选的,不是必需的。
mysql> `SELECT` `JSON_SCHEMA_VALIDATION_REPORT``(``@``schema``,`
-> `'{"name": "Sveta", "lastname": "Smirnova",` -> `"books": "MySQL Cookbook"}'``)` `AS` `'Valid?'``\``G`
*************************** 1. row ***************************
Valid?: {"valid": false, "reason": "The JSON document location '#/books' failed ↩
requirement 'type' at JSON Schema location '#/properties/books'", ↩
"schema-location": "#/properties/books", "document-location": "#/books", ↩
"schema-failed-keyword": "type"}
1 row in set (0,00 sec)
在这种情况下,文档无效,因为成员books的类型为string而不是模式中定义的array。
mysql> `SELECT` `JSON_SCHEMA_VALIDATION_REPORT``(``@``schema``,`
-> `'{"name": "Sveta", "lastname": "Smirnova",` -> `"books": ["MySQL Troubleshooting", "MySQL Cookbook"]}'``)` `AS` `'Valid?'``;`
+-----------------+ | Valid? |
+-----------------+ | {"valid": true} |
+-----------------+ 1 row in set (0,00 sec)
这份文件修正了元素books的类型错误,因此有效。
mysql> `SELECT` `JSON_SCHEMA_VALID``(``@``schema``,` `'{"name": "Sveta", "lastname": "Smirnova",` -> `"vehicles": ["Honda CRF 250L"]}'``)` `AS` `'Valid 1?'``,`
-> `JSON_SCHEMA_VALID``(``@``schema``,` `'{"name": "Alkin", "lastname": "Tezuysal",` -> `"vehicles": "boat"}'``)` `AS` `'Valid 2?'``;`
+----------+----------+ | Valid 1? | Valid 2? |
+----------+----------+ | 1 | 1 |
+----------+----------+ 1 row in set (0,00 sec)
这些文档也是有效的,因为vehicles属性没有要求:它可能存在,也可能不存在,并且可以是任何类型。
如果您想自动验证表中的 JSON 字段是否符合定义的模式,请使用CHECK约束。
ALTER TABLE book_authors
ADD CONSTRAINT CHECK(JSON_SCHEMA_VALID('
{"id": "http://www.oreilly.com/mysqlcookbook",
"$schema": "http://json-schema.org/draft-04/schema#",
"description": "Schema for the table book_authors",
"type": "object",
"properties": {
"name": {"type": "string"},
"lastname": {"type": "string"},
"books": {"type": "array"}},
"required":["name", "lastname"]} ',
author));
19.4 格式化 JSON 值
问题
您希望将 JSON 打印成漂亮的格式。
解决方案
使用函数JSON_PRETTY。
讨论
默认情况下,JSON 被打印为一个长字符串,可能很难阅读。如果希望 MySQL 将其以人类可读的格式打印,请使用函数JSON_PRETTY。
mysql> `SELECT` `JSON_PRETTY``(``author``)` `FROM` `book_authors``\``G`
*************************** 1. row ***************************
JSON_PRETTY(author): {
"id": 1,
"name": "Paul",
"books": [
"Software Portability with imake: Practical Software Engineering",
"Mysql: The Definitive Guide to Using, Programming, ↩
and Administering Mysql 4 (Developer's Library)",
"MySQL Certification Study Guide",
"MySQL (OTHER NEW RIDERS)",
"MySQL Cookbook",
"MySQL 5.0 Certification Study Guide",
"Using csh & tcsh: Type Less, Accomplish More (Nutshell Handbooks)",
"MySQL (Developer's Library)"
],
"lastname": "DuBois"
}
*************************** 2. row ***************************
JSON_PRETTY(author): {
"id": 2,
"name": "Alkin",
"books": [
"MySQL Cookbook"
],
"lastname": "Tezuysal"
}
*************************** 3. row ***************************
JSON_PRETTY(author): {
"id": 3,
"name": "Sveta",
"books": [
"MySQL Troubleshooting",
"MySQL Cookbook"
],
"lastname": "Smirnova"
}
3 rows in set (0,00 sec)
19.5 从 JSON 提取值
问题
您希望从 JSON 文档中提取值。
解决方案
使用函数JSON_EXTRACT,或者操作符->和->>。
讨论
JSON 本身没有用处,如果无法从文档中提取值。MySQL 中的 JSON 支持 JSON 路径,可以用于指向 JSON 中的特定元素。JSON 文档的根元素由$符号表示。对象成员通过操作符.访问,数组成员通过索引在方括号中访问。索引从零开始。您可以使用关键字to引用多个数组元素(例如$.[3 to 5]。last关键字是数组中的最后一个元素的同义词。
通配符*表示所有对象成员的所有值,如果在点后使用:.*或者所有数组元素如果在方括号内:[*]。
表达式[prefix]**suffix表示所有以prefix开头且以suffix结尾的路径。请注意,虽然suffix部分是必需的,但prefix是可选的。换句话说,JSON Path 表达式不应以双星号结束。
要访问 JSON 元素,请使用函数JSON_EXTRACT。
例如,要选择作者的名称,请使用以下 SQL:
mysql> `SELECT` `JSON_EXTRACT``(``author``,` `'$.name'``)` `AS` `author` `FROM` `book_authors``;`
+---------+ | author |
+---------+ | "Paul" |
| "Alkin" |
| "Sveta" |
+---------+ 3 rows in set (0,00 sec)
要从值中删除引号,请使用函数JSON_UNQUOTE。
mysql> `SELECT` `JSON_UNQUOTE``(``JSON_EXTRACT``(``author``,` `'$.name'``)``)` `AS` `author` `FROM` `book_authors``;`
+--------+ | author |
+--------+ | Paul |
| Alkin |
| Sveta |
+--------+ 3 rows in set (0,00 sec)
操作符->是函数JSON_EXTRACT的别名。
mysql> `SELECT` `author``-``>``'$.name'` `AS` `author` `FROM` `book_authors``;`
+---------+ | author |
+---------+ | "Paul" |
| "Alkin" |
| "Sveta" |
+---------+ 3 rows in set (0,00 sec)
操作符->>是JSON_UNQUOTE(JSON_EXTRACT(...))的别名。
mysql> `SELECT` `author``-``>``>``'$.name'` `AS` `author` `FROM` `book_authors``;`
+--------+ | author |
+--------+ | Paul |
| Alkin |
| Sveta |
+--------+ 3 rows in set (0,00 sec)
要分别使用数组索引0和last提取作者的第一本和最后一本书。
mysql> `SELECT` `CONCAT``(``author``-``>``>``'$.name'``,` `' '``,` `author``-``>``>``'$.lastname'``)` `AS` `author``,`
-> `author``-``>``>``'$.books[0]'` `AS` `` ` ```First` `Book``` ` ```,`
-> `author``-``>``>``'$.books[last]'` `AS` `` ` ```Last` `Book``` ` `` `FROM` `book_authors``\``G`
*************************** 1. 行 ***************************
作者:Paul DuBois
第一本书:使用 imake 进行软件可移植性的实际软件工程
最后一本书:MySQL(开发者图书馆)
*************************** 2. 行 ***************************
作者:Alkin Tezuysal
第一本书:MySQL Cookbook
最后一本书:MySQL Cookbook
*************************** 3. 行 ***************************
作者:Sveta Smirnova
第一本书:MySQL 故障排除
最后一本书:MySQL Cookbook
一共 3 行(0,00 秒)
JSON Path $.books[*] will return full array of books. Same will happen if you omit a wildcard and simply refer books array as $.books. Expression $.* will return all elements of the JSON object as an array.
mysql> `SELECT` `author``-``>``'$.*'` `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'Sveta'``;`
+-----------------------------------------------------------------------+ | author->'$.*' |
+-----------------------------------------------------------------------+ | [3, "Sveta", ["MySQL 故障排除", "MySQL 食谱"], "Smirnova"] |
+-----------------------------------------------------------------------+ 1 行 (0.00 秒)
See Also
For additional information about JSON Path, see JSON Path Syntax in the MySQL User Reference Manual.
19.6 Searching inside JSON
Problem
You want to search for JSON documents, containing particular values.
Solution
Use function JSON_SEARCH.
Discussion
Accessing by key works great but you may want to search for particular values in the JSON documents. MySQL allows you to do it. For example, to find all authors of the book “MySQL Cookbook”, run query:
mysql> `SELECT` `author``-``>``>``'$.name'` `AS` `author` `FROM` `book_authors`
-> `WHERE` `JSON_SEARCH``(``author``,` `'one'``,` `'MySQL 食谱'``)``;`
+--------+ | 作者 |
+--------+ | Paul |
| Alkin |
| --- |
| Sveta |
+--------+ 3 行, 1 警告 (0.00 秒)
Function JSON_SEARCH takes a JSON document, keyword one or all and a search string as required arguments and returns found path of the element or elements that contain the searched value. It also supports optional escape character and JSON path arguments.
Similarly to the operator LIKE function JSON_SEARCH supports wildcards % and _.
Thus, to search all books, which names start from MySQL, use expression:
mysql> `SELECT` `author``-``>``>``'$.name'` `AS` `author``,`
-> `JSON_SEARCH``(``author``,` `'all'``,` `'MySQL%'``)` `AS` `books`
-> `FROM` `book_authors``\``G`
*************************** 1. 行 ***************************
author: Paul
books: ["$.books[2]", "$.books[3]", "$.books[4]", "$.books[5]", "$.books[7]"]
*************************** 2. 行 ***************************
author: Alkin
books: "$.books[0]"
*************************** 3. 行 ***************************
author: Sveta
books: ["$.books[0]", "$.books[1]"]
3 行 (0.00 秒)
When searching for single match you can use return value of the function JSON_SEARCH as an argument for the function JSON_EXTRACT:
mysql> `SELECT` `author``-``>``>``'$.name'` `AS` `author``,`
-> `JSON_EXTRACT``(``author``,`
-> `JSON_UNQUOTE``(``JSON_SEARCH``(``author``,` `'one'``,` `'MySQL%'``)``)``)` `AS` `book`
-> `FROM` `book_authors``;`
+--------+-----------------------------------+ | 作者 | 书籍 |
+--------+-----------------------------------+ | Paul | "MySQL 认证考试指南" |
| Alkin | "MySQL 食谱" |
| --- | --- |
| Sveta | "MySQL 故障排除" |
+--------+-----------------------------------+ 3 行 (0.00 秒)
19.7 Inserting New Elements into a JSON Document
Problem
You want to insert new elements into a JSON document.
Solution
Use functions JSON_INSERT, JSON_ARRAY_APPEND and JSON_ARRAY_INSERT.
Discussion
You may want to not only search inside JSON values but modify them. MySQL supports a number of functions which can modify JSON. The most wonderful thing about them is that they do not replace the JSON document as regular string functions do. They rather perform updates in place. This allows you to modify JSON values effectively.
MySQL functions allow you to append, remove, and replace parts of JSON as well as merge two or more documents into one. They all take the original document as an argument, a path that needs to be modified, and a new value.
To insert new value into a JSON object, use the function JSON_INSERT. Thus, to add information about current author’s work place call the function as follow.
UPDATE book_authors SET author = JSON_INSERT(author, '$.work', 'Percona')
WHERE author->>'$.name' IN ('Sveta', 'Alkin');
To add a book into the end of the book array use function JSON_ARRAY_APPEND.
UPDATE book_authors SET author = JSON_ARRAY_APPEND(author, '$.books',
'MySQL 性能模式实战') WHERE author->>'$.name' = 'Sveta';
This will add new book into the end of the array.
mysql> `SELECT` `JSON_PRETTY``(``author``)` `FROM` `book_authors`
-> `WHERE` `author``-``>``>``'$.name'` `=` `'Sveta'``\``G`
*************************** 1. 行 ***************************
JSON_PRETTY(author): {
"id": 3,
"name": "Sveta",
"work": "Percona",
"books": [
"MySQL 故障排除",
"MySQL 食谱",
"MySQL 性能模式实战"
],
"lastname": "Smirnova"
}
1 行 (0.00 秒)
To add an element into a specific place, use the function JSON_ARRAY_INSERT.
UPDATE book_authors SET author = JSON_ARRAY_INSERT(author, '$.books[0]',
'MySQL 初学者入门') WHERE author->>'$.name' = 'Alkin';
This will insert a new book into the beginning of the array.
mysql> `SELECT` `JSON_PRETTY``(``author``)`
-> `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'Alkin'``\``G`
*************************** 1. 行 ***************************
JSON_PRETTY(author): {
"id": 2,
"name": "Alkin",
"work": "Percona",
"books": [
"MySQL 初学者入门",
"MySQL Cookbook"
],
"lastname": "Tezuysal"
}
1 行数据(0,00 秒)
19.8 Updating JSON
Problem
You want to update a JSON value.
Solution
Use the functions JSON_REPLACE and JSON_SET.
Discussion
While we were working on this book, Alkin switched his work place. So the content of the table needs to be updated. The function JSON_REPLACE replaces a given path with the new value.
UPDATE book_authors SET author = JSON_REPLACE(author, '$.work', 'PlanetScale')
WHERE author->>'$.name' = 'Alkin';
However, the function JSON_REPLACE will do nothing if a record, that needs to be replaced, does not exist in the document.
mysql> `UPDATE` `book_authors` `SET` `author` `=` `JSON_REPLACE``(``author``,` `'$.work'``,` `'Oracle'``)`
-> `WHERE` `author``-``>``>``'$.name'` `=` `'Paul'``;`
查询成功,0 行受影响(0,00 秒)
匹配行数: 1 修改行数: 0 警告数: 0
mysql> `SELECT` `author``-``>``>``'$.work'` `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'Paul'``;`
+-------------------+ | author->>'$.work' |
+-------------------+ | NULL |
+-------------------+ 1 行数据(0,00 秒)
To resolve this problem, use the function JSON_SET to update the document if the path exists, or to insert a new value if the path does not.
mysql> `UPDATE` `book_authors` `SET` `author` `=` `JSON_SET``(``author``,` `'$.work'``,` `'MySQL'``)`
-> `WHERE` `author``-``>``>``'$.name'` `=` `'Paul'``;`
查询成功,1 行受影响(0,01 秒)
匹配行数: 1 修改行数: 1 警告数: 0
mysql> `SELECT` `author``-``>``>``'$.work'` `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'Paul'``;`
+-------------------+ | author->>'$.work' |
+-------------------+ | MySQL |
+-------------------+ 1 行数据(0,00 秒)
mysql> `UPDATE` `book_authors` `SET` `author` `=` `JSON_SET``(``author``,` `'$.work'``,` `'Oracle'``)`
-> `WHERE` `author``-``>``>``'$.name'` `=` `'Paul'``;`
查询成功,1 行受影响(0,00 秒)
匹配行数: 1 修改行数: 1 警告数: 0
mysql> `SELECT` `author``-``>``>``'$.work'` `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'Paul'``;`
+-------------------+ | author->>'$.work' |
+-------------------+ | Oracle |
+-------------------+ 1 行数据(0,00 秒)
19.9 Removing Elements from JSON
Problem
You want to remove elements from a JSON document.
Solution
Use the function JSON_REMOVE.
Discussion
The function JSON_REMOVE removes specified element from JSON.
For example, to remove unpublished books from the book_authors table use following code.
UPDATE book_authors SET author = JSON_REMOVE(author, '$.books[0]')
WHERE author->>'$.name' = 'Alkin';
UPDATE book_authors SET author = JSON_REMOVE(author, '$.books[last]')
WHERE author->>'$.name' = 'Sveta';
19.10 Merging Two or More JSON Documents into One
Problem
You want to combine two or more JSON documents into one.
Solution
Use functions family JSON_MERGE_*.
Discussion
Two functions: JSON_MERGE_PATCH and JSON_MERGE_PRESERVE are available for combining multiple JSON documents into one. JSON_MERGE_PATCH removes duplicates when merging two documents while JSON_MERGE_PRESERVE keeps them. Both functions take two or more arguments that should be valid JSON text.
For examples in this recipe we will store values of the author column in the table book_authors into user-defined variables: one for each author. Additionally we will store arrays of books for Sveta in a variable sveta_books.
SELECT author INTO @paul FROM book_authors WHERE author->>'$.name'='Paul';
SELECT author INTO @sveta FROM book_authors WHERE author->>'$.name'='Sveta';
SELECT author INTO @alkin FROM book_authors WHERE author->>'$.name'='Alkin';
SELECT author->>'$.books' INTO @sveta_books FROM book_authors
WHERE author->>'$.name'='Sveta';
JSON_MERGE_PRESERVE combines documents, provided by its arguments, into a single object. You can use this function to add new elements to your objects or arrays. Thus, to add array of countries where the author lived you can just provide an object, containing such an array as an argument.
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PRESERVE``(``@``sveta``,`
-> `'{"places lived": ["俄罗斯", "土耳其"]}'``)``)``\``G`
*************************** 1. 行 ***************************
JSON_PRETTY(JSON_MERGE_PRESERVE(@sveta, '{"places lived": ["俄罗斯", "土耳其"]}')): {
"id": 3,
"name": "Sveta",
"work": "Percona",
"books": [
"MySQL Troubleshooting",
"MySQL Cookbook"
],
"lastname": "Smirnova",
"places lived": [
"俄罗斯",
"土耳其"
]
}
1 行数据(0,00 秒)
To add a new book into the array books pass it as a part of the books array in the object as a second argument.
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PRESERVE``(``@``sveta``,`
-> `'{"books": ["MySQL Performance Schema in Action"]}'``)``)``\``G`
*************************** 1. 行 ***************************
JSON_PRETTY(JSON_MERGE_PRESERVE(@sveta, ↩
'{"books": ["MySQL Performance Schema in Action"]}')): {
"id": 3,
"name": "Sveta",
"work": "Percona",
"books": [
"MySQL Troubleshooting",
"MySQL Cookbook",
"MySQL Performance Schema in Action"
],
"lastname": "Smirnova"
}
1 row in set (0,00 sec)
Content of the array books in the second argument will be added to the end of the array with the same name in the first argument.
If two objects have scalar values with the same key they will be merged into an array.
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PRESERVE``(``@``paul``,` `@``sveta``,` `@``alkin``)``)` `AS` `authors``\``G`
*************************** 1. row ***************************
authors: {
"id": [
1,
3,
2
],
"name": [
"Paul",
"Sveta",
"Alkin"
],
"work": [
"Oracle",
"Percona",
"PlanetScale"
],
"books": [
"Software Portability with imake: Practical Software Engineering",
"Mysql: The Definitive Guide to Using, Programming, ↩
and Administering Mysql 4 (Developer's Library)",
"MySQL Certification Study Guide",
"MySQL (OTHER NEW RIDERS)",
"MySQL Cookbook",
"MySQL 5.0 Certification Study Guide",
"Using csh & tcsh: Type Less, Accomplish More (Nutshell Handbooks)",
"MySQL (Developer's Library)",
"MySQL Troubleshooting",
"MySQL Cookbook",
"MySQL Cookbook"
],
"lastname": [
"DuBois",
"Smirnova",
"Tezuysal"
]
}
1 row in set (0,00 sec)
Note
Note that function JSON_MERGE_PRESERVE does not try to handle duplicates anyhow, so book title MySQL Cookbook repeats in the resulting array three times.
Function JSON_MERGE_PATCH, instead, removes duplicates in favor of its latest argument. Same combination of merging three authors will just return the one, specified as the last argument.
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PATCH``(``@``paul``,` `@``sveta``,` `@``alkin``)``)` `AS` `authors``\``G`
*************************** 1. row ***************************
authors: {
"id": 2,
"name": "Alkin",
"work": "PlanetScale",
"books": [
"MySQL Cookbook"
],
"lastname": "Tezuysal"
}
1 row in set (0,00 sec)
This feature could be used to remove unneeded elements from JSON. For example, if we decide that it does not matter in which company the author works, we can remove work element by passing it as an object with value null:
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PATCH``(``@``sveta``,` `'{"work": null}'``)``)``\``G`
*************************** 1. row ***************************
JSON_PRETTY(JSON_MERGE_PATCH(@sveta, '{"work": null}')): {
"id": 3,
"name": "Sveta",
"books": [
"MySQL Troubleshooting",
"MySQL Cookbook"
],
"lastname": "Smirnova"
}
1 row in set (0,00 sec)
When the latest document of the function is not an object JSON_MERGE_PRESERVE will add it as a latest element of an array. For example, to add a new book to the array of books by Sveta you may use following code:
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PRESERVE``(``@``sveta_books``,`
-> `'"MySQL Performance Schema in Action"'``)``)` `AS` `'Books by Sveta'``\``G`
*************************** 1. row ***************************
Books by Sveta: [
"MySQL Troubleshooting",
"MySQL Cookbook",
"MySQL Performance Schema in Action"
]
1 row in set (0,00 sec)
JSON_MERGE_PATCH, instead, will replace elements in the original document with the new one:
mysql> `SELECT` `JSON_PRETTY``(``JSON_MERGE_PATCH``(``@``sveta_books``,`
-> `'"MySQL Performance Schema in Action"'``)``)` `AS` `'Books by Sveta'``;`
+--------------------------------------+ | Books by Sveta |
+--------------------------------------+ | "MySQL Performance Schema in Action" |
+--------------------------------------+ 1 row in set (0,00 sec)
19.11 Creating JSON from Relational Data
Problem
You have relational data and want to create JSON from it.
Solution
Use the function JSON_OBJECT, the function JSON_ARRAY and their aggregate variants JSON_OBJECTAGG, JSON_ARRAYAGG.
Discussion
It could be useful to create JSON out of relational data. MySQL provides the function JSON_OBJECT that combines pairs of values into JSON object.
mysql> `SELECT` `JSON_PRETTY``(`
-> `JSON_OBJECT``(``"string"``,` `"Some String"``,` `"number"``,` `42``,` `"null"``,` `NULL``)``)` `AS` `my_object``\``G`
*************************** 1. row ***************************
my_object: {
"null": null,
"number": 42,
"string": "Some String"
}
1 row in set (0,00 sec)
The function JSON_ARRAY creates a JSON array from its arguments.
mysql> `SELECT` `JSON_PRETTY``(``JSON_ARRAY``(``"one"``,` `"two"``,` `"three"``,` `4``,` `5``)``)` `AS` `my_array``\``G`
*************************** 1. row ***************************
my_array: [
"one",
"two",
"three",
4,
5
]
1 row in set (0,00 sec)
You can combine both functions, make nesting objects and arrays.
mysql> `选择` `JSON_PRETTY``(``JSON_OBJECT``(``"示例"``,` `"嵌套对象和数组"``,`
-> `"人类"``,` `JSON_OBJECT``(``"名称"``,` `"斯韦塔"``,` `"姓"``,` `"斯米尔诺娃"``)``,`
-> `"数字"``,` `JSON_ARRAY``(``"一个"``,` `"两"``,` `"三"``)``)``)` `AS` `my_object``\``G`
*************************** 1. row ***************************
my_object: {
"人类": {
"名称": "斯韦塔",
"姓": "斯米尔诺娃"
},
"示例": "嵌套对象和数组",
"数字": [
"一个",
"两",
"三"
]
}
1 行设置(0,00 秒)
The functions JSON_OBJECTAGG and JSON_ARRAYAGG are aggregate versions of JSON_OBJECT and JSON_ARRAY that allow you to create JSON objects and arrays out of data, returned by GROUP BY queries.
The database cookbook has a table movies_actors that contains a list of movies and actors that were starred in them. The table has few rows for the single movie and few others for the single actor.
If you want to have a JSON object that will list a movie and all actors who starred in that movie in as an array, combine the functions JSON_OBJECT and JSON_ARRAYAGG.
mysql> `选择` `JSON_PRETTY``(``JSON_OBJECT``(``'电影'``,` `电影``,`
-> `'主演'``,` `JSON_ARRAYAGG``(``演员``)``)``)` `AS` `主演`
-> `从` `电影演员` `组` `BY` `电影``\``G`
*************************** 1. row ***************************
starred: {
"电影": "天国王朝",
"主演": [
"连姆·尼森",
"奥兰多·布鲁姆"
]
}
*************************** 2. row ***************************
starred: {
"电影": "红色",
"主演": [
"海伦·米伦",
"布鲁斯·威利斯"
]
}
*************************** 3. row ***************************
starred: {
"电影": "指环王",
"主演": [
"伊恩·麦克莱恩",
"伊恩·霍尔姆",
"奥兰多·布鲁姆",
"伊莱贾·伍德"
]
}
*************************** 4. row ***************************
starred: {
"电影": "第五元素",
"主演": [
"布鲁斯·威利斯",
"加里·奥德曼",
"伊恩·霍尔姆"
]
}
*************************** 5. row ***************************
starred: {
"电影": "幽灵危机",
"主演": [
"伊万·麦克格雷格",
"连姆·尼森"
]
}
*************************** 6. row ***************************
starred: {
"电影": "未知",
"主演": [
"黛安·克鲁格",
"连姆·尼森"
]
}
6 行设置(0,00 秒)
Function JSON_OBJECTAGG can take table values in one column as member names and values in another column as their arguments.
mysql> `选择` `JSON_PRETTY``(``JSON_OBJECTAGG``(``名称``,` `网站``)``)` `AS` `网站`
-> `从` `书籍供应商``\``G`
*************************** 1. row ***************************
网站:{
"亚马逊": "www.amazon.com",
"巴恩斯与贵族": "www.barnesandnoble.com",
"O'Reilly Media": "www.oreilly.com"
}
1 行设置(0,00 秒)
19.12 Converting JSON into Relational Format
Problem
You have JSON data and want to work with it in the same way as you do with relational structure.
Solution
Use function JSON_TABLE.
Discussion
In the previous recipe we converted relational data into JSON. You may need to do the opposite: convert JSON into relational format. In this case, the function JSON_TABLE will help.
The function JSON_TABLE takes a JSON document and a list of columns with paths as its arguments. It returns a table as a result.
For example, for a document
{
"空": null,
"号码": 42,
"字符串": "一些字符串"
}
JSON_TABLE can be called as:
mysql> `选择` `*` 
-> `从` `JSON_TABLE``(` 
-> `'{"空": null, "号码": 42, "字符串": "一些字符串"}'``,` 
-> `'$'` 
-> `COLUMNS``(` 
-> `number` `INT` `PATH` `'$.number'``,` 
-> `字符串` `VARCHAR``(``255``)` `PATH` `'$.string'` `错误` `ON` `错误` 
-> `)``)` `AS` `jt``;` 
+--------+-------------+ | number | string |
+--------+-------------+ | 42 | 一些字符串 |
+--------+-------------+ 1 行设置(0,00 秒)
Start the query by selecting everything from the resulting table.
The function JSON_TABLE can be used only in the FROM clause.
The first argument to the function is a JSON document. In this example, the function takes a string. If you want to pass a column name in another table, you need to specify this table prior the JSON_TABLE call:
选择* FROM mytable, JSON_TABLE(mytable.mycolumn ...
A path that will be used as a document root. In this example we are using the whole document but you can simplify expressions for the columns if you specify the path to the part of the JSON document here.
Definition of columns.
Column number has type INT and default error handling: the column set to NULL in case an error happens. We use JSON path $.number to set a value for this column.
For the column string we decided to raise an error, therefore we used clause ERROR ON ERROR.
Any function in the FROM clause should have an alias, so we used jt as an alias.
To call the function JSON_TABLE on an existing table add it to the query prior calling the function. Practically, perform a CROSS JOIN of two tables. The COLUMNS clause also supports nested paths, so you can expand arrays into multiple rows.
The column author in the table book_authors contains list of books in the array books. To expand each row into the own row use clause NESTED PATH.
mysql> `选择` `jt``.``*` `从` `书籍作者` `ba``,` 
-> `JSON_TABLE``(``ba``.``作者``,`
-> `'$'` `COLUMNS` `(`
-> `名称` `VARCHAR``(``255``)` `PATH` `'$.name'``,`
-> `lastname` `VARCHAR``(``255``)` `PATH` `'$.lastname'``,`
-> `NESTED` `PATH` `'$.books[*]'` `COLUMNS` `(` 
-> `book` `VARCHAR``(``255``)` `PATH` `'$'` `)` 
-> `)``)` `AS` `jt``;`
+-------+----------+-----------------------------------------------------------------+ | 名称 | 姓氏 | 书籍 |
+-------+----------+-----------------------------------------------------------------+ | 保罗 | 杜博伊斯 | 使用 imake 进行软件可移植性: 实用软件工程 |
| 保罗 | 杜博伊斯 | Mysql: 详细使用指南, 编程, ↩
和管理 Mysql 4 (开发者图书馆) |
| 保罗 | 杜博伊斯 | MySQL 认证学习指南 |
| --- | --- | --- |
| 保罗 | 杜博伊斯 | MySQL (OTHER NEW RIDERS) |
| 保罗 | 杜博伊斯 | MySQL Cookbook |
| 保罗 | 杜博伊斯 | MySQL 5.0 认证学习指南 |
| 保罗 | 杜博伊斯 | 使用 csh & tcsh: 更少打字, 更多成就 ↩
(Nutshell Handbooks) |
| 保罗 | 杜博伊斯 | MySQL (Developer's Library) |
| --- | --- | --- |
| 阿尔金 | 特祖萨尔 | MySQL Cookbook |
| 斯维塔 | 斯米尔诺娃 | MySQL 故障排除 |
| 斯维塔 | 斯米尔诺娃 | MySQL Cookbook |
+-------+----------+-----------------------------------------------------------------+ 11 行结果 (0.01 秒)
To use a column in the existing table put the table name before the JSON_TABLE call.
The clause NESTED PATH expands the following path pattern into several columns. In our case the path is $.books[*] that points to the each element of the array books.
Define the nested column as any other column. Note that PATH should be relative to the NESTED PATH.
See Also
For additional information about function JSON_TABLE, see JSON Table Functions in the MySQL User Reference Manual.
19.13 Investigating JSON
Problem
You want to know details about your JSON data structure, such as how deep the value is, how many children a particular element has, and so on.
Solution
Use JSON attribute functions.
Discussion
The function JSON_LENGTH returns a number of elements in the JSON document or a path, if specified. For scalars it is always 1, for objects and arrays this is the number of elements. You can use this function to perform such tasks as calculating number of books, written by a particular author:
mysql> `SELECT` `CONCAT``(``author``-``>``>``'$.name'``,` `' '``,` `author``-``>``>``'$.lastname'``)` `AS` `'author'``,`
-> `JSON_LENGTH``(``author``-``>``>``'$.books'``)` `AS` `'书籍数量'` `FROM` `book_authors``;`
+----------------+-----------------+ | 作者 | 书籍数量 |
+----------------+-----------------+ | 保罗 杜博伊斯 | 8 |
| 阿尔金·特祖萨尔 | 1 |
| --- | --- |
| 斯维塔 斯米尔诺娃 | 2 |
+----------------+-----------------+ 3 行结果 (0.01 秒)
Function JSON_DEPTH returns maximum depth of the JSON document. It returns one for a scalar, empty object or empty array. For objects and arrays with inner elements it counts all nested levels. For the author column in the book_authors table it returns three:
mysql> `SELECT` `JSON_DEPTH``(``author``)` `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'斯维塔'``;`
+--------------------+ | JSON_DEPTH(author) |
+--------------------+ | 3 |
+--------------------+ 1 行结果 (0.00 秒)
To understand why so let’s examine example value in detail:
{ 
"id": 3,
"name": "斯维塔",
"work": "Percona", 
"books":
"MySQL 故障排除", ![3
"MySQL Cookbook"
],
"lastname": "斯米尔诺娃"
}
Level one: the object that contains all the elements.
Level two: object element.
Level three: element of the nested array.
Function JSON_DEPTH is useful when you need to understand how complex your JSON data is.
Function JSON_STORAGE_SIZE returns the number of bytes that the JSON data takes. It is useful to plan storage use for your data.
mysql> `SELECT` `JSON_STORAGE_SIZE``(``author``)` `FROM` `book_authors``;`
+---------------------------+ | JSON_STORAGE_SIZE(author) |
+---------------------------+ | 475 |
| 144 |
| --- |
| 171 |
+---------------------------+ 3 行结果 (0.00 秒)
The function JSON_TYPE returns the type of the JSON element. Thus, for the author column in the table book_authors the types are as shown:
mysql> `SELECT` `JSON_TYPE``(``author``)``,` `JSON_TYPE``(``author``-``>``'$.id'``)``,`
-> `JSON_TYPE``(``author``-``>``'$.name'``)``,` `JSON_TYPE``(``author``-``>``'$.books'``)`
-> `FROM` `book_authors` `WHERE` `author``-``>``>``'$.name'` `=` `'Sveta'``\``G`
*************************** 1. row ***************************
JSON_TYPE(author): OBJECT
JSON_TYPE(author->'$.id'): INTEGER
JSON_TYPE(author->'$.name'): STRING
JSON_TYPE(author->'$.books'): ARRAY
1 row in set (0,00 sec)
Warning
Note that we used operator -> instead of ->> to preserve quotes in scalar values.
19.14 Working with JSON in MySQL as a Document Store
Problem
You want to work with JSON in MySQL in the same way as NoSQL databases do.
Solution
Use X DevAPI. The following clients and connectors support X DevAPI and can work with JSON as a Document Store.
- MySQL Shell in JavaScript and Python mode
- Connector/C++
- Connector/J
- Connector/Node.js
- Connector/NET
- Connector/Python
Discussion
We will use MySQL Shell for examples in this recipe. We assume that you are connected to the MySQL server, thus have the default objects available. See Recipe 2.1 for instructions on how to connect to MySQL Server via MySQL Shell.
MySQL Document Store is a collection, stored in a table, defined as
CREATE TABLE `MyCollection` (
`doc` json DEFAULT NULL,
`_id` varbinary(32) GENERATED ALWAYS AS (json_unquote(
json_extract(`doc`,_utf8mb4'$._id'))) STORED NOT NULL,
`_json_schema` json GENERATED ALWAYS AS (_utf8mb4'{"type":"object"}') VIRTUAL,
PRIMARY KEY (`_id`),
CONSTRAINT `$val_strict_2190F99D7C6BE98E2C1EFE4E110B46A3D43C9751`
CHECK (json_schema_valid(`_json_schema`,`doc`)) /*!80016 NOT ENFORCED */
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
where doc is a JSON column, storing the document. _id is unique identifier, generated by extracting value of the member _id and optional _json_schema is a schema which you can enforce when creating collection. See Recipe 2.9 for the details and example.
X DevAPI will create such a table when you call createCollection method.
MySQL cookbook JS > `session.getDefaultSchema().createCollection('MyCollection')`
<Collection:MyCollection>
Tip
We use syntax which MySQL Shell in JavaScript mode understands for examples in this recipe. Syntax for different languages slightly differs. Refer to your implementation documentation for details.
Once the collection is created you can insert documents into it, update, remove and search them.
It is handy to store collection object in a variable.
MySQL cookbook JS > `MyCollection = session.getDefaultSchema().
-> `getCollection('MyCollection')`
<Collection:MyCollection>
Collection class in X DevAPI supports four basic CRUD (Create, Read, Update, Delete) operations:
addfindmodifyremove
We already showed them in action when we discussed MySQL Shell in Recipe 2.9 and Recipe 2.10. In this recipe we will cover details, missed there.
Adding documents to the collection
To add documents into the collection, use a method add that accepts either a JSON object or an array of JSON objects, or a mysqlx.expr as an argument. The following code snippet demonstrates all three flavours of the syntax.
MySQL cookbook JS > `MyCollection.add({"document": "one"}).`
-> `add([{"document": "two"}, {"document": "three"}]).`
-> `add(mysqlx.expr('{"document": "four"}'))`
->
Query OK, 4 items affected (0.0083 sec)
Records: 4 Duplicates: 0 Warnings: 0
Searching for documents
To search for documents, use the method find. If called without arguments it will return a list of all documents in the collection.
MySQL cookbook JS > `MyCollection.find()`
{
"_id": "000060d5ab750000000000000012",
"document": "one"
}
{
"_id": "000060d5ab750000000000000013",
"document": "two"
}
{
"_id": "000060d5ab750000000000000014",
"document": "three"
}
{
"_id": "000060d5ab750000000000000015",
"document": "four"
}
4 documents in set (0.0007 sec)
Each of the documents contains an automatically generated _id that is also a primary key for the collection.
The method find narrows a result set by using search conditions, limiting the number of the documents, and grouping, sorting, and modifying the resulting values. These are basic methods, available to modify result of any SQL SELECT operation. However, it is not possible to join two collections like you can do with SQL tables.
To search for a particular document pass a condition as an argument of the method find. You can use the operator LIKE and others to perform creative comparisons.
MySQL cookbook JS > `MyCollection.find("document LIKE 't%'")`
{
"_id": "000060d5ab750000000000000013",
"document": "two"
}
{
"_id": "000060d5ab750000000000000014",
"document": "three"
}
2 documents in set (0.0009 sec)
To modify the result, pass the expression to the method fields:
MySQL cookbook JS > `MyCollection.find("document LIKE 't%'").`
-> `fields(mysqlx.expr('{"Document": upper(document)}'))`
->
{
"Document": "TWO"
}
{
"Document": "THREE"
}
2 documents in set (0.0009 sec)
To group documents, use the method groupBy and narrow the result with the method having. To illustrate how they work we will use collection CollectionLimbs.
MySQL cookbook JS > `limbs = session.getDefaultSchema().getCollection('CollectionLimbs')`
<Collection:CollectionLimbs>
MySQL cookbook JS > `limbs.find().fields('arms', 'COUNT(thing)').groupBy('arms')`
{
"arms": 2,
"COUNT(thing)": 3
}
{
"arms": 0,
"COUNT(thing)": 5
}
{
"arms": 10,
"COUNT(thing)": 1
}
{
"arms": 1,
"COUNT(thing)": 1
}
{
"arms": null,
"COUNT(thing)": 1
}
5 documents in set (0.0010 sec)
The code above prints the number of things with a specific number of arms. To limit this list to only things that have both arms and legs, we can use the method having:
MySQL cookbook JS > `limbs.find().fields('arms', 'COUNT(thing)').`
-> `groupBy('arms').having('MIN(legs) > 0')`
{
"arms": 2,
"COUNT(thing)": 3
}
1 document in set (0.0006 sec)
To print the three things with the highest number of legs, use the method sort with the keyword DESC and limit:
MySQL cookbook JS > `limbs.find().sort('legs DESC').limit(3)`
{
"_id": "000060d5ab75000000000000001a",
"arms": 0,
"legs": 99,
"thing": "centipede"
}
{
"_id": "000060d5ab750000000000000017",
"arms": 0,
"legs": 6,
"thing": "insect"
}
{
"_id": "000060d5ab75000000000000001b",
"arms": 0,
"legs": 4,
"thing": "table"
}
3 documents in set (0.0006 sec)
You may also bind values if you pass the parameter name after a colon sign in the method find and pass values in the method bind. You may bind as many arguments as you want.
MySQL cookbook JS > `limbs.find('legs = :legs').bind('legs', 4)`
{
"_id": "000060d5ab75000000000000001b",
"arms": 0,
"legs": 4,
"thing": "table"
}
{
"_id": "000060d5ab75000000000000001c",
"arms": 2,
"legs": 4,
"thing": "armchair"
}
2 documents in set (0.0008 sec)
MySQL cookbook JS > `limbs.find('legs = :legs and arms = :arms').`
-> `bind('legs', 4).bind('arms', 2)`
{
"_id": "000060d5ab75000000000000001c",
"arms": 2,
"legs": 4,
"thing": "armchair"
}
1 document in set (0.0005 sec)
Modifying documents
To modify documents in the collection, use the method modify. It accepts search condition and allows you to bind parameters similarly to the method find. To modify found elements use the methods set and unset to set or unset values of the object member. Use the methods arrayInsert, arrayAppend and arrayDelete to modify arrays, and use the method patch to merge JSON documents.
For the illustrations we will use the collection MyCollection.
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)` 
{
"_id": "000060d5ab750000000000000012",
"document": "one"
}
1 document in set (0.0005 sec)
MySQL cookbook JS > `MyCollection``.``modify``(``'document = "one"'``)``.`
-> `set``(``'array'``,` `[``2``,` `3``,` `4``]``)`
Query OK, 1 item affected (0.0054 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)`
{
"_id": "000060d5ab750000000000000012",
"array": [
2,
3,
4
],
"document": "one"
}
1 document in set (0.0005 sec)
MySQL cookbook JS > `MyCollection``.``modify``(``'document = "one"'``)``.``arrayAppend``(``'array'``,` `5``)`
Query OK, 1 item affected (0.0073 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)`
{
"_id": "000060d5ab750000000000000012",
"array": [
2,
3,
4,
5
],
"document": "one"
}
1 document in set (0.0007 sec)
MySQL cookbook JS > `MyCollection``.``modify``(``'document = "one"'``)``.`
-> `arrayInsert``(``'array[0]'``,` `1``)`
Query OK, 1 item affected (0.0072 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)`
{
"_id": "000060d5ab750000000000000012",
"array": [
1,
2,
3,
4,
5
],
"document": "one"
}
1 document in set (0.0008 sec)
MySQL cookbook JS > `MyCollection``.``modify``(``'document = "one"'``)``.``arrayDelete``(``'array[2]'``)`
Query OK, 1 item affected (0.0059 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)`
{
"_id": "000060d5ab750000000000000012",
"array": [
1,
2,
4
5
],
"document": "one"
}
1 document in set (0.0009 sec)
MySQL cookbook JS > `MyCollection``.``modify``(``'document = "one"'``)``.``unset``(``'array'``)`
Query OK, 1 item affected (0.0080 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)`
{
"_id": "000060d5ab750000000000000012",
"document": "one"
}
1 document in set (0.0007 sec)
MySQL cookbook JS > `MyCollection``.``modify``(``'document = "one"'``)``.`
-> `patch``(``{``'number'``:` `42``,` `'array'``:` `[``1``,``2``,``3``]``}``)``.`
-> `patch``(``{``'array'``:` `[``4``,``5``]``}``)`
Query OK, 1 item affected (0.0063 sec)
Rows matched: 1 Changed: 1 Warnings: 0
MySQL cookbook JS > `MyCollection``.``find``(``'document = "one"'``)`
{
"_id": "000060d5ab750000000000000012",
"array": [
4,
5
],
"number": 42,
"document": "one"
}
1 document in set (0.0007 sec)
We will experiment with this document from the collection.
The mehtod set adds or changes an element in the object.
The method arrayAppend adds new element to the end of the array.
For the method arrayInsert you can specify the position in the array where you want to add new element.
The method arrayDelete removes an element from the specified position.
The method unset removes an element from the object.
The method patch works similarly to the JSON function JSON_MERGE_PATCH. In our case it first added two elements: number and array to the original document, then replaced content of the element array with the content of the element with the same name in the object, passed as a parameter to the second invocation of the method patch.
Removing documents and collections.
To remove documents use the method remove.
MyCollection.remove('document = :number').bind('number', 'one')
To drop a collection, use the method dropCollection in your API.
session.getSchema('cookbook').dropCollection('MyCollection')
查看也可以
关于 X DevAPI 的更多信息,请参见X DevAPI 参考手册。
第二十章:执行事务
20.0 简介
MySQL 服务器可以同时处理多个客户端,因为它是多线程的。为了处理客户端之间的竞争,服务器执行任何必要的锁定,以防止两个客户端同时修改同一数据。然而,当服务器执行 SQL 语句时,来自同一客户端的连续语句可能与来自其他客户端的语句交错执行。如果一个客户端执行多个依赖于彼此的语句,其他客户端在这些语句之间更新表可能会引发困难。
如果多语句操作未能完成,语句失败也可能会带来问题。假设flight表包含有关航班时间表的信息,并且您想通过从可用飞行员中选择一个来更新 Flight 578 的行。您可以使用以下三个语句来执行:
SET @p_val = (SELECT pilot_id FROM pilot WHERE available = 'yes' LIMIT 1);
UPDATE pilot SET available = 'no' WHERE pilot_id = @p_val;
UPDATE flight SET pilot_id = @p_val WHERE flight_id = 578;
第一条语句选择一个可用的飞行员,第二条将该飞行员标记为不可用,第三条将该飞行员分配给航班。原则上说这很简单,但实际操作中存在显著困难:
并发问题
如果两个客户端想要安排飞行员,它们可以在设置飞行员状态为不可用之前同时运行初始的SELECT查询并检索相同的飞行员 ID 号。如果发生这种情况,同一飞行员会同时安排两个航班。
完整性问题
所有三个语句必须作为一个单元成功执行。例如,如果SELECT和第一个UPDATE成功运行,但第二个UPDATE失败,则飞行员的状态被设置为不可用,但飞行员未被分配到航班。数据库变得不一致。
为了在这些类型的情况下预防并发和完整性问题,事务是有帮助的。事务组合了一组语句并保证以下属性:
-
当事务正在进行时,其他客户端不能更新事务中使用的数据;就像你独占了服务器一样。例如,在您为一个航班预订飞行员时,其他客户端不能修改飞行员或航班记录。事务解决了 MySQL 服务器多客户端性质带来的并发问题。事实上,事务序列化了多语句操作对共享资源的访问。
-
事务中分组的语句将作为一个单元提交(生效),但仅当它们全部成功时。如果发生错误,则会回滚发生错误之前的所有操作,使相关表保持不受影响,就好像没有执行任何语句一样。这可以防止数据库变得不一致。例如,如果更新
flights表失败,则回滚会取消对pilots表的更改,使飞行员仍然可用。回滚使您无需自行解决部分完成的操作的撤销问题。
本章展示了开始和结束事务的 SQL 语句的语法。还描述了如何从程序内部实现事务操作,使用错误检测来确定是提交还是回滚。
此处显示的示例相关脚本位于recipes发行版的transactions目录中。
20.1 选择事务存储引擎
问题
您想要使用事务。
解决方案
要使用事务,必须使用事务安全引擎。检查您的 MySQL 服务器以确定它支持哪些事务存储引擎。
讨论
MySQL 支持多种存储引擎。当前的事务引擎,包括 InnoDB 和 NDB,已随标准分发。要查看您的 MySQL 服务器支持哪些,请使用此语句:
mysql> `SELECT ENGINE FROM INFORMATION_SCHEMA.ENGINES`
-> `WHERE SUPPORT IN ('YES','DEFAULT') AND TRANSACTIONS='YES';`
+--------+
| ENGINE |
+--------+
| InnoDB |
+--------+
如果启用了 MySQL Cluster,则还会看到一行显示ndbcluster。
事务引擎是那些具有TRANSACTIONS值为YES的引擎;实际可用的引擎具有SUPPORT值为YES或DEFAULT。
在确定了哪些事务存储引擎可用后,要创建使用特定引擎的表,请在您的CREATE TABLE语句中添加一个ENGINE = tbl_engine 子句:
CREATE TABLE *`t`* (i INT) ENGINE = InnoDB;
如果需要修改现有应用程序以执行事务,但它使用非事务表,则可以修改表以使用事务存储引擎。例如,MyISAM 表是非事务性的,试图将它们用于事务将产生错误的结果,因为它们不支持回滚。在这种情况下,您可以使用ALTER TABLE将表转换为事务类型。假设t是一个 MyISAM 表。要将其转换为 InnoDB 表,请执行以下操作:
ALTER TABLE *`t`* ENGINE = InnoDB;
在修改表之前要考虑的一件事是,将其更改为使用事务存储引擎可能会影响其在其他方面的行为。例如,MyISAM 引擎对AUTO_INCREMENT列提供了比其他存储引擎更灵活的处理方式。如果依赖于仅支持 MyISAM 的序列特性,更改存储引擎将会导致问题。
20.2 使用 SQL 执行事务
问题
一组语句必须作为一个单元成功或失败,也就是说,您需要一个事务。
解决方案
修改 MySQL 的自动提交模式以启用多语句事务,然后根据成功或失败来提交或回滚语句。
讨论
本方法描述了在 MySQL 中控制事务行为的 SQL 语句。紧接着的方法讨论了如何从程序内部执行事务。一些 API 要求您通过执行本方法中讨论的 SQL 语句来实现事务;其他 API 则提供了一种特殊机制,允许在不直接编写 SQL 的情况下进行事务管理。然而,即使在后一种情况下,API 机制也会将程序操作映射到事务性 SQL 语句上,因此阅读本方法将让您更好地了解 API 在您的代表下所做的事情。
MySQL 通常在自动提交模式下运行,这会在每次执行语句时提交其效果。(实际上,每个语句都是其自己的事务。)要执行一个事务,必须禁用自动提交模式,执行构成事务的语句,然后要么提交,要么回滚更改。在 MySQL 中,可以通过两种方式实现这一点:
-
执行
START TRANSACTION(或BEGIN)语句以暂停自动提交模式,然后执行构成事务的语句。如果语句成功,则记录其在数据库中的效果,并通过执行COMMIT语句终止事务:mysql> `CREATE TABLE t (i INT) ENGINE = InnoDB;` mysql> `START TRANSACTION;` mysql> `INSERT INTO t (i) VALUES(1);` mysql> `INSERT INTO t (i) VALUES(2);` mysql> `COMMIT;` mysql> `SELECT * FROM t;` +------+ | i | +------+ | 1 | | 2 | +------+如果发生错误,请不要使用
COMMIT。而是通过执行ROLLBACK语句来取消事务。在下面的例子中,由于INSERT语句的效果被回滚,t保持为空:mysql> `CREATE TABLE t (i INT) ENGINE = InnoDB;` mysql> `START TRANSACTION;` mysql> `INSERT INTO t (i) VALUES(1);` mysql> `INSERT INTO t (x) VALUES(2);` ERROR 1054 (42S22): Unknown column 'x' in 'field list' mysql> `ROLLBACK;` mysql> `SELECT * FROM t;` Empty set (0.00 sec) -
另一种分组语句的方式是通过将
autocommit会话变量显式设置为 0 来关闭自动提交模式。之后,您执行的每个语句都成为当前事务的一部分。要结束事务并开始下一个事务,请执行COMMIT或ROLLBACK语句:mysql> `CREATE TABLE t (i INT) ENGINE = InnoDB;` mysql> `SET autocommit = 0;` mysql> `INSERT INTO t (i) VALUES(1);` mysql> `INSERT INTO t (i) VALUES(2);` mysql> `COMMIT;` mysql> `SELECT * FROM t;` +------+ | i | +------+ | 1 | | 2 | +------+要重新启用自动提交模式,请使用以下语句:
mysql> `SET autocommit = 1;`
警告
事务有其局限性,因为并非所有语句都可以成为事务的一部分。例如,如果执行 DROP DATABASE 语句,则不要期望通过执行 ROLLBACK 来恢复数据库。
20.3 从程序内部执行事务
问题
您正在编写一个必须实现事务操作的程序。
解决方案
如果您的语言 API 提供了事务抽象化,可以使用该功能。如果没有,则使用 API 的常规语句执行机制直接执行事务性 SQL 语句。
讨论
要从程序内部执行事务处理,使用您的 API 语言检测错误并采取适当的操作。这个方法提供了关于如何执行这一操作的一般背景。接下来的方法将为 Perl、Ruby、PHP、Python、Go 和 Java 的 MySQL API 提供语言特定的详细信息。
每个 MySQL API 都支持事务,即使仅仅是显式地执行与事务相关的 SQL 语句如START TRANSACTION和COMMIT。然而,一些 API 还提供了事务抽象,使得可以控制事务行为,而无需直接使用 SQL。这种方法隐藏了细节,并且提供了更好的可移植性,以适应具有不同底层事务 SQL 语法的其他数据库引擎。本书中使用的每种语言都有相应的 API 抽象。
接下来的几个示例展示了如何执行基于程序的事务的相同示例,它们使用一个包含以下初始行的money表,展示了两个人拥有的金额:
+------+------+
| name | amt |
+------+------+
| Eve | 10 |
| Ida | 0 |
+------+------+
样本交易是一笔简单的金融转账,使用两个UPDATE语句将伊娃的六美元转给艾达:
UPDATE money SET amt = amt - 6 WHERE name = 'Eve';
UPDATE money SET amt = amt + 6 WHERE name = 'Ida';
预期的结果是表应该如下所示:
+------+------+
| name | amt |
+------+------+
| Eve | 4 |
| Ida | 6 |
+------+------+
必须在事务中执行这两个语句以确保它们同时生效。如果没有事务,如果第二个语句失败,则伊娃的钱将不会被记入艾达的账户。通过使用事务,如果语句失败,表将保持不变。
每种语言的示例程序位于recipes发布的transactions目录中。如果你进行比较,你会发现它们都使用类似的框架执行事务处理:
-
事务语句被包含在一个控制结构内,以及一个提交操作。
-
如果控制结构的状态表明它未成功执行完成,则回滚事务。
可以将该逻辑表达如下,其中block表示用于分组语句的控制结构:
block:
statement 1
statement 2
...
statement *`n`*
commit
if the block failed:
roll back
如果块中的语句成功执行,你将到达块的末尾并执行提交。否则,出现错误会引发异常,触发错误处理代码的执行,其中你会回滚事务。
将代码结构化为刚才描述的方式的好处在于,它最大限度地减少了确定是否回滚所需的测试数量。另一种方法——在事务中检查每个语句的结果,并在个别语句错误时回滚——很快会使你的代码变得难以阅读。
在引发异常的语言中回滚时需要注意的一个微妙点是,回滚本身可能会失败,导致另一个异常被引发。如果你不处理这个问题,你的程序本身可能会终止。为了处理这个问题,在另一个没有异常处理程序的块内执行回滚是必要的。示例程序在必要时会这样做。
那些在执行事务时显式禁用自动提交模式的示例程序在之后执行事务后启用自动提交。在将所有数据库处理以事务方式执行的应用程序中,不必这样做。只需在连接到数据库服务器后一次禁用自动提交模式,并保持禁用状态。
20.4 Perl 程序中执行事务
问题
您希望在 Perl DBI 脚本中执行事务。
解决方案
使用标准的 DBI 事务支持机制。
讨论
Perl DBI 事务机制基于显式操作自动提交模式:
-
如果
RaiseError属性未启用,则打开它;如果启用了PrintError,则禁用它。您希望错误引发异常而不打印任何内容,保持PrintError启用可能会干扰某些情况下的故障检测。 -
禁用
AutoCommit属性,以便仅在您说时提交。 -
在一个
eval块中执行组成事务的语句,以便于错误会引发异常并终止该块。该块的最后一件事应该是调用commit(),如果所有语句成功完成,则提交事务。 -
在执行
eval后,检查$@变量。如果$@包含空字符串,则事务成功。否则,由于出现错误,eval将失败,$@将包含错误消息。调用rollback()取消事务。要显示错误消息,请在调用rollback()之前打印$@。 -
如果需要,恢复
RaiseError和PrintError属性的原始值。
如果应用程序执行多个事务,更改和恢复错误处理和自动提交属性可能会很混乱,让我们将开始和结束事务的代码放入方便处理前后处理的函数中,这些函数处理 eval 前后的处理:
sub transaction_init
{
my $dbh = shift;
my $attr_ref = {}; # create hash in which to save attributes
$attr_ref->{RaiseError} = $dbh->{RaiseError};
$attr_ref->{PrintError} = $dbh->{PrintError};
$attr_ref->{AutoCommit} = $dbh->{AutoCommit};
$dbh->{RaiseError} = 1; # raise exception if an error occurs
$dbh->{PrintError} = 0; # don't print an error message
$dbh->{AutoCommit} = 0; # disable auto-commit
return $attr_ref; # return attributes to caller
}
sub transaction_finish
{
my ($dbh, $attr_ref, $error) = @_;
if ($error) # an error occurred
{
print "Transaction failed, rolling back. Error was:\n$error\n";
# roll back within eval to prevent rollback
# failure from terminating the script
eval { $dbh->rollback (); };
}
# restore error-handling and auto-commit attributes
$dbh->{AutoCommit} = $attr_ref->{AutoCommit};
$dbh->{PrintError} = $attr_ref->{PrintError};
$dbh->{RaiseError} = $attr_ref->{RaiseError};
}
通过使用这两个函数,我们的示例事务可以轻松执行如下:
$ref = transaction_init ($dbh);
eval
{
# move some money from one person to the other
$dbh->do ("UPDATE money SET amt = amt - 6 WHERE name = 'Eve'");
$dbh->do ("UPDATE money SET amt = amt + 6 WHERE name = 'Ida'");
# all statements succeeded; commit transaction
$dbh->commit ();
};
transaction_finish ($dbh, $ref, $@);
在 Perl DBI 中,手动操作 AutoCommit 属性的替代方法是通过调用 begin_work() 开始事务。此方法禁用 AutoCommit,并在稍后调用 commit() 或 rollback() 时自动启用它。
20.5 Ruby 程序中执行事务
问题
您希望在 Ruby Mysql2 脚本中执行事务。
解决方案
发送事务管理语句,例如 START TRANSACTIONS、BEGIN、COMMIT 和 ROLLBACK 作为常规查询。
讨论
Ruby Mysql2 模块没有内置的事务支持功能。相反,它期望其用户将事务管理语句作为常规查询运行。
要开始事务,请执行 client.query("START TRANSACTION"),然后执行所需的更新,并用 client.query("COMMIT") 结束块。
将您的事务放入 begin ... rescue 块中,以便在出现问题时调用 ROLLBACK。
begin
client.query("START TRANSACTION")
client.query("UPDATE money SET amt = amt - 6 WHERE name = 'Eve'")
client.query("UPDATE money SET amt = amt + 6 WHERE name = 'Ida'")
client.query("COMMIT")
rescue Mysql2::Error => e
puts "Transaction failed, rolling back. Error was:"
puts "#{e.errno}: #{e.message}"
begin # empty exception handler in case rollback fails
client.query("ROLLBACK")
rescue
end
end
20.6 在 PHP 程序中执行事务
问题
您希望在 PHP 脚本中执行事务。
解决方案
使用标准的 PDO 事务支持机制。
讨论
PDO 扩展支持事务抽象,可用于执行事务。要开始事务,请使用beginTransaction()方法。然后,在执行语句后,调用commit()或rollback()来提交或回滚事务。以下代码说明了这一点。它使用异常来检测事务失败,因此假定 PDO 错误已启用异常:
try
{
$dbh->beginTransaction ();
$dbh->exec ("UPDATE money SET amt = amt - 6 WHERE name = 'Eve'");
$dbh->exec ("UPDATE money SET amt = amt + 6 WHERE name = 'Ida'");
$dbh->commit ();
}
catch (Exception $e)
{
print ("Transaction failed, rolling back. Error was:\n");
print ($e->getMessage () . "\n");
# empty exception handler in case rollback fails
try
{
$dbh->rollback ();
}
catch (Exception $e2) { }
}
20.7 在 Python 程序中执行事务
问题
您希望在 Python DB API 脚本中执行事务。
解决方案
使用标准的 DB API 事务支持机制。
讨论
Python DB API 抽象通过连接对象方法提供事务处理控制。DB API 规范指出,数据库连接应该以禁用自动提交模式开始。因此,当您打开与数据库服务器的连接时,Connector/Python 会禁用自动提交模式,这隐式地开始了一个事务。每个事务要么在commit()语句中结束,要么在except子句中的rollback()中取消事务,以取消事务如果发生错误:
try:
cursor = conn.cursor()
# move some money from one person to the other
cursor.execute("UPDATE money SET amt = amt - 6 WHERE name = 'Eve'")
cursor.execute("UPDATE money SET amt = amt + 6 WHERE name = 'Ida'")
cursor.close()
conn.commit()
except mysql.connector.Error as e:
print("Transaction failed, rolling back. Error was:")
print(e)
try: # empty exception handler in case rollback fails
conn.rollback()
except:
pass
20.8 在 Go 程序中执行事务
问题
您希望在 Go 程序中执行事务
解决方案
使用由database/sql包提供的标准事务支持机制。
讨论
Go sql接口支持事务抽象,可用于执行事务。要开始事务,请使用DB.Begin()函数。然后,在执行语句后,调用Tx.Commit()或Tx.Rollback()来提交或回滚事务。以下代码说明了这一点。
var queries = []string{
"UPDATE money SET amt = amt - 6 WHERE name = 'Eve'",
"UPDATE money SET amt = amt + 6 WHERE name = 'Ida'",
}
tx, err := db.Begin()
if err != nil {
log.Fatal(err)
}
for _, query := range queries {
_, err := tx.Exec(query)
if err != nil {
fmt.Printf("Transaction failed, rolling back.\nError was: %s\n",
err.Error())
if txerr := tx.Rollback(); txerr != nil {
fmt.Println("Rollback failed")
log.Fatal(txerr)
}
}
}
if err := tx.Commit(); err != nil {
log.Fatal(err)
}
20.9 使用上下文感知函数处理 Go 中的事务
问题
您希望在 Go 程序中自动回滚事务。
解决方案
Go-MySQL-Driver 支持上下文取消。这意味着您可以取消数据库操作,例如取消运行查询,如果取消上下文。
讨论
要使用context包与 SQL,您需要首先创建Context类型的对象,然后将其传递给数据库函数。支持上下文的sql接口函数与不支持的函数名称相似,但具有前缀Context。例如,函数Query()不支持Context,而函数QueryContext()支持。
下面的示例使用Context来处理数据库事务。您可以在recipes分发的transactions目录中的文件transaction_context.go中找到其代码。
// transaction_context.go: simple transaction demonstration
// with use of Context
// By default, this creates an InnoDB table. If you specify a storage
// engine on the command line, that will be used instead. Normally,
// this should be a transaction-safe engine that is supported by your
// server. However, you can pass a nontransactional storage engine
// to verify that rollback doesn't work properly for such engines.
// The script uses a table named "money" and drops it if necessary.
// Change the name if you have a valuable table with that name. :-)
package main
import (
"log"
"fmt"
"flag"
"context" 
"database/sql"
"github.com/svetasmirnova/mysqlcookbook/recipes/lib"
)
func initTable(ctx context.Context, db *sql.DB, tblEngine string) (error) { 
queries := [4]string {
"DROP TABLE IF EXISTS money",
"CREATE TABLE money (name CHAR(5), amt INT, PRIMARY KEY(name)) ENGINE = " + tblEngine,
"INSERT INTO money (name, amt) VALUES('Eve', 10)",
"INSERT INTO money (name, amt) VALUES('Ida', 0)",
}
for _, query := range queries {
_, err = db.ExecContext(ctx, query) 
if err != nil {
fmt.Println("Cannot initialize test table")
fmt.Printf("Error: %s\n", err.Error())
return err
}
}
return nil
}
func displayTable(ctx context.Context, db *sql.DB) (error) {
rows, err := db.QueryContext(ctx, "SELECT name, amt FROM money") 
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var (
name string
amt int32
)
if err := rows.Scan(&name, &amt); err != nil {
fmt.Println("Cannot display contents of test table")
fmt.Printf("Error: %s\n", err.Error())
return err
}
fmt.Printf("%s has $%d\n", name, amt)
}
return nil
}
func runTransaction(ctx context.Context,
db *sql.DB, queries []string) (error) {
tx, err := db.BeginTx(ctx, nil) 
if err != nil {
return err
}
for _, query := range queries {
_, err := tx.ExecContext(ctx, query) 
if err != nil {
fmt.Printf("Transaction failed, rolling back.\nError was: %s\n",
err.Error())
if txerr := tx.Rollback(); err != nil {
return txerr
}
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func main() {
db, err := cookbook.Connect()
if err != nil {
log.Fatal(err)
}
defer db.Close()
var tblEngine string = "InnoDB"
flag.Parse()
values := flag.Args()
if len(values) > 0 {
tblEngine = values[0]
}
fmt.Printf("Using storage engine %s to test transactions\n", tblEngine)
ctx, cancel := context.WithCancel(context.Background()) 
defer cancel()
fmt.Println("----------")
fmt.Println("This transaction should succeed.")
fmt.Println("Table contents before transaction:")
if err := initTable(ctx, db, tblEngine); err != nil {
log.Fatal(err)
}
if err = displayTable(ctx, db); err != nil {
log.Fatal(err)
}
var trx = []string{
"UPDATE money SET amt = amt - 6 WHERE name = 'Eve'",
"UPDATE money SET amt = amt + 6 WHERE name = 'Ida'",
}
if err = runTransaction(ctx, db, trx); err != nil {
log.Fatal(err)
}
fmt.Println("Table contents after transaction:")
if err = displayTable(ctx, db); err != nil {
log.Fatal(err)
}
fmt.Println("----------")
fmt.Println("This transaction should fail.")
fmt.Println("Table contents before transaction:")
if err := initTable(ctx, db, tblEngine); err != nil {
log.Fatal(err)
}
if err = displayTable(ctx, db); err != nil {
log.Fatal(err)
}
trx = []string{
"UPDATE money SET amt = amt - 6 WHERE name = 'Eve'",
"UPDATE money SET xamt = amt + 6 WHERE name = 'Ida'",
}
if err = runTransaction(ctx, db, trx); err != nil {
log.Fatal(err)
}
fmt.Println("Table contents after transaction:")
if err = displayTable(ctx, db); err != nil {
log.Fatal(err)
}
}
上下文支持的导入语句。
我们的用户定义函数以 context.Context 作为参数。
要执行不返回结果集的语句,请使用支持上下文的函数 ExecContext()。
要执行返回结果集的查询,请使用支持上下文的函数 QueryContext()。
要启动一个如果上下文取消将自动回滚的事务,请使用支持上下文的函数 BeginTx()。
要执行可能在事务中取消的语句,请使用支持上下文的函数 Tx.ExecContext()。
在使用上下文之前,您需要创建它。在我们的示例中,我们创建了一个可取消的上下文。函数 context.WithCancel() 接受父上下文作为参数,并返回刚创建的新上下文及一个 cancel() 函数。我们推迟了它在 main() 函数执行结束时的调用。您可以在代码的任何适当位置调用 cancel() 函数。您可以选择使用 context.WithDeadline() 或 context.WithTimeout(),以便在超过一定时间后取消 SQL 执行代码。
20.10 在 Java 程序中执行事务
问题
您希望在 JDBC 应用程序中执行事务。
解决方案
使用标准的 JDBC 事务支持机制。
讨论
要在 Java 中执行事务,请使用您的 Connection 对象关闭自动提交模式。然后,在执行语句后,使用该对象的 commit() 方法提交事务或 rollback() 取消事务。通常,您在事务的 try 块中执行语句,并在块末尾调用 commit()。若出现故障,请在相应的异常处理程序中调用 rollback():
try
{
conn.setAutoCommit (false);
Statement s = conn.createStatement ();
// move some money from one person to the other
s.executeUpdate ("UPDATE money SET amt = amt - 6 WHERE name = 'Eve'");
s.executeUpdate ("UPDATE money SET amt = amt + 6 WHERE name = 'Ida'");
s.close ();
conn.commit ();
conn.setAutoCommit (true);
}
catch (SQLException e)
{
System.err.println ("Transaction failed, rolling back. Error was:");
Cookbook.printErrorMessage (e);
// empty exception handler in case rollback fails
try
{
conn.rollback ();
conn.setAutoCommit (true);
}
catch (Exception e2) { }
}
第二十一章:查询性能
21.0 引言
如果创建并按预期使用,索引用于快速查找行。以下是使用索引的主要原因。
-
高效利用
SELECT语句中的WHERE子句来查找行。 -
通过索引中存储的值的唯一性(称为基数)和返回的最少行数来找到最佳的查询执行计划。
-
启用不同表之间的连接操作。
索引对于高效地扫描和搜索表中的值至关重要。没有索引,当执行查询时,MySQL 需要读取给定表中的所有行。由于不同的表大小,MySQL 必须将从表中读取的所有数据带入内存,并且仅能对选择的数据进行排序、过滤和返回值。此操作可能需要额外的资源将数据复制到新的临时表以执行排序操作。索引对查询性能至关重要;因此,未索引的表对数据库而言是一个重大负担,除非它们是小型参考表。
为了快速查询性能,每个表都需要一个代表一个或多个列的主键。在使用 InnoDB 存储引擎时,表的数据是物理排序的,以便使用主键列进行快速查找和排序。理想的表设计使用覆盖索引,在查询结果中使用索引列进行计算。MySQL 使用的大多数索引存储在 B 树中,这使得由于减少数据访问时间而实现快速数据访问成为可能。
如果表的数据量很大,并且没有任何键创建额外的字段,如table_name_id作为主键,在进行连接操作时可以带来显著的好处。InnoDB 表始终具有代表主键的聚簇索引,如果用户尚未创建。聚簇索引是一个表,其中数据和行按照主键值的顺序存储在表中的一方向。
注意
如果查询中没有使用 WHERE 子句,则 MySQL 优化器将进行全表扫描。例如:
SELECT * FROM customer;
这不会改变customer表是否存在索引。
在开始使用索引策略之前,以下是一些您需要了解的关键术语:
表扫描
表扫描意味着在执行查询时读取给定表中的所有行。开发人员应尽量避免全表扫描,包括执行COUNT(*)操作。
树遍历
树遍历是索引用于访问数据的一种方法。索引的目标是通过遍历尽量少的跳跃来获取数据。叶节点越少,索引遍历速度越快。
叶节点
叶节点是 B 树索引结构的一部分。它们随着数据变化而维护索引的变化。它们建立了一个双向链接列表以连接索引叶节点。
B 树结构
B 树是一种自平衡树数据结构,它保持数据排序并允许以对数时间进行搜索、顺序访问、插入和删除。B 树是二叉搜索树的一种泛化形式,一个节点可以有多于两个的子节点。
虽然 B 树索引通常在 MySQL 存储引擎中使用,但哈希索引使用不同类型的数据结构。哈希索引具有不同的特性,并且具有其自己的用例。有关详细信息,请参阅 MySQL 用户参考手册中的“B-Tree 和 Hash 索引比较”。
警告
虽然索引有助于更快地检索行,但是过度创建或保留未使用的索引会对数据库的 I/O 操作构成负担。每个索引叶页(索引的最低级别,其中所有键按排序顺序出现)必须针对所有UPDATE/INSERT/DELETE操作进行维护,因此会产生额外的开销。
21.1 创建索引
问题
您的查询响应非常慢。
解决方案
在您的列上创建一个索引,以仅检索您正在寻找的行。
讨论
没有索引的表只是随机写入的日志数据,没有查找的参考。因此,对这样的表的大多数查询都很慢。唯一的例外是仅适用于具有有限行数的参考表,具体取决于模式设计。
MySQL 建议为每个表的每一行都提供一个具有NOT NULL特性的主键列。
我们有一张名为top_names的表,来自Names_2010Census.csv数据。
mysql> `CREATE` `TABLE` `` ` ```top_names``` ` `` `(`
`` ` ```top_name``` ` `` `varchar``(``25``)` `DEFAULT` `NULL``,`
`` ` ```name_rank``` ` `` `smallint` `DEFAULT` `NULL``,`
`` ` ```name_count``` ` `` `int` `DEFAULT` `NULL``,`
`` ` ```prop100k``` ` `` `decimal``(``8``,``2``)` `DEFAULT` `NULL``,`
`` ` ```cum_prop100k``` ` `` `decimal``(``8``,``2``)` `DEFAULT` `NULL``,`
`` ` ```pctwhite``` ` `` `decimal``(``5``,``2``)` `DEFAULT` `NULL``,`
`` ` ```pctblack``` ` `` `decimal``(``5``,``2``)` `DEFAULT` `NULL``,`
`` ` ```pctapi``` ` `` `decimal``(``5``,``2``)` `DEFAULT` `NULL``,`
`` ` ```pctaian``` ` `` `decimal``(``5``,``2``)` `DEFAULT` `NULL``,`
`` ` ```pct2prace``` ` `` `decimal``(``5``,``2``)` `DEFAULT` `NULL``,`
`` ` ```pcthispanic``` ` `` `decimal``(``5``,``2``)` `DEFAULT` `NULL`
`)` `ENGINE``=``InnoDB` `DEFAULT` `CHARSET``=``utf8mb4` `COLLATE``=``utf8mb4_0900_ai_ci``;`
然后加载数据:
mysql> `LOAD` `DATA` `LOCAL` `INFILE` `'Names_2010Census.csv'` `into` `table` `top_names`
-> `FIELDS` `TERMINATED` `BY` `','` `ENCLOSED` `BY` `'"'` `LINES` `TERMINATED` `BY` `'\n'``;`
Query OK, 162255 rows affected, 65535 warnings (0.93 sec)
Records: 162255 Deleted: 0 Skipped: 0 Warnings: 444813
现在我们已经创建并加载了我们的表,可以继续进行以下查询。
mysql> `SELECT` `names_id``,``top_name``,``name_rank`
`-``>` `FROM` `top_names` `WHERE` `top_name` `=` `"BROWN"``;`
+----------+----------+-----------+ | names_id | top_name | name_rank |
+----------+----------+-----------+ | 5 | BROWN | 4 |
+----------+----------+-----------+ 1 row in set (0.04 sec)
如下所示,MySQL 必须对该表进行完整的表扫描,以查找其PRIMARY KEY之外的任何行。
mysql> `EXPLAIN SELECT names_id,top_name,name_rank -> FROM top_names WHERE top_name = "BROWN"\G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 161533
filtered: 10.00
Extra: Using where
1 row in set, 1 warning (0.01 sec)
我们的示例查询在top_name字段上寻找字符串匹配;因此,在这种数据类型上创建索引将提高查询性能。首先,我们创建一个索引以满足该查询的WHERE子句。
mysql> `CREATE` `INDEX` `idx_names` `ON` `top_names``(``top_name``)``;`
Query OK, 0 rows affected (0.28 sec)
Records: 0 Duplicates: 0 Warnings: 0
然后我们检查优化器是否选择了同一查询的这个新索引。
mysql> `EXPLAIN` `SELECT` `names_id``,``top_name``,``name_rank`
`-``>` `FROM` `top_names` `WHERE` `top_name` `=` `"BROWN"``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ref
possible_keys: idx_names
key: idx_names
key_len: 103
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
可能需要删除索引的原因有几个。确保不再需要索引或需要重新创建索引后,可以使用以下语法删除它们。
DROP INDEX index_name ON tbl_name;
21.2 创建代理主键
问题
没有主键的表性能不够好。
解决方案
在所有 InnoDB 表上添加主键。
讨论
主键为您提供了在表中唯一标识行的方式。在 InnoDB 中,主键等同于聚集索引:一种特殊的索引,用于存储行数据。当用户在没有明确定义主键的情况下创建 InnoDB 表时,InnoDB 会选择表中存在的第一个带有 B-Tree 结构的唯一索引,并将其作为聚集索引。聚集索引通常也称为记录在磁盘上的物理顺序。如果没有唯一索引存在,则 InnoDB 会创建一个名为GEN_CLUST_INDEX的代理键,这是一个自动生成的唯一 6 字节标识符。
当 InnoDB 创建辅助索引时,复制主键列到每个辅助索引行是有用的,因为它能解决查询。如果主键不必要地大,则所有辅助索引也会很大。因此,选择适合的列作为主键非常重要。
在我们的示例中,在 Recipe 21.1 中,自然主键是top_name,占用 26 字节。将top_name定义为主键将使每个辅助索引中的每一行的大小增加 26 字节。因此,我们在这里展示了一种创建 4 字节整数代理键的技术,其属性为AUTO_INCREMENT,因此它是单调递增的。这比 InnoDB 显式创建的代理键更好,因为它更小且我们完全控制其值。
我们的表相对较小,但对于大表,这种差异可能是关键的。除了空间外,更大的索引需要更多时间进行搜索。
这张表缺少一个带有PRIMARY KEY的字段。包含这种表的最佳方法是添加一个具有AUTO INCREMENT NOT NULL属性的id字段。理想情况下,在加载任何数据到表之前创建这个字段,以便在表空间中物理上对表进行排序。
mysql> `` ALTER TABLE `top_names` ``
-> ``ADD COLUMN `names_id` int unsigned NOT NULL -> AUTO_INCREMENT PRIMARY KEY FIRST;``
Query OK, 0 rows affected (0.44 sec)
Records: 0 Duplicates: 0 Warnings: 0
尽管以下是一个完整的索引扫描,它将使用我们创建的新PRIMARY KEY字段来计算表中行的数量。
mysql> `SELECT COUNT(names_id) FROM top_names;`
+-----------------+
| count(names_id) |
+-----------------+
| 162255 |
+-----------------+
1 row in set (0.04 sec)
mysql> `EXPLAIN SELECT COUNT(names_id) FROM top_names\G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: index
possible_keys: NULL
key: idx_name_rank_count
key_len: 8
ref: NULL
rows: 161533
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.00 sec)
参见
若要获取更多信息,请参阅 MySQL 文档中关于主键优化的详细信息。
21.3 索引维护
问题
您想知道现有的索引是否对您的查询有效,并且丢弃那些无效的索引。
解决方案
学习基本的索引操作。
讨论
为了更好地控制您的数据,通过研究模式数据和访问类型有效地使用索引。继续我们之前示例中的例子,我们将检查top_names表的现有索引。
mysql> `SHOW` `INDEXES` `FROM` `top_names` `\``G`
*************************** 1. row ***************************
Table: top_names
Non_unique: 0
Key_name: PRIMARY
Seq_in_index: 1
Column_name: names_id
Collation: A
Cardinality: 161807
Sub_part: NULL
Packed: NULL
Null:
Index_type: BTREE
Comment:
Index_comment:
Visible: YES
Expression: NULL
*************************** 2. row ***************************
Table: top_names
Non_unique: 1
Key_name: idx_names
Seq_in_index: 1
Column_name: top_name
Collation: A
Cardinality: 161708
Sub_part: NULL
Packed: NULL
Null: YES
Index_type: BTREE
Comment:
Index_comment:
Visible: YES
Expression: NULL
2 rows in set (0.00 sec)
在这里,最重要的是索引的基数。如果列具有许多不同的值,则索引的利用率会更高。总之,索引在布尔和冗余值上效率低下。
在我们的情况中,idx_names 索引的基数接近主键的基数。这显示该索引具有良好的选择性。实际上,这个索引也可以是 unique 的,我们可以通过查询该列中不同值的数量来确认。
mysql> `SELECT` `COUNT``(``DISTINCT` `top_name``)``,` `COUNT``(``*``)` `FROM` `top_names``;`
+--------------------------+----------+ | count(distinct top_name) | count(*) |
+--------------------------+----------+ | 162254 | 162254 |
+--------------------------+----------+ 1 row in set (0,18 sec)
由于我们已经在 top_name 列上创建了一个索引,我们可以删除该索引,然后创建一个新的唯一索引。首先执行以下命令来删除索引。
mysql> `DROP` `INDEX` `idx_names` `ON` `top_names``;`
Query OK, 0 rows affected (0.02 sec)
Records: 0 Duplicates: 0 Warnings: 0
或者,也可以使用 ALTER TABLE 语法。
ALTER TABLE tbl_name DROP INDEX name;
若要在 CREATE INDEX 命令中创建唯一索引,请指定关键字 UNIQUE:
CREATE UNIQUE INDEX idx_names ON top_names(top_name);
您可以重命名在表上创建的现有索引。
mysql> `ALTER TABLE top_names RENAME`
-> `INDEX idx_names to idx_top_name, ALGORITHM=INPLACE, LOCK=NONE;`
警告
并非所有的索引操作都是原地的;一些索引操作会导致表重建,这可能会对大数据量下的服务器性能产生负面影响。在执行 DDL 操作之前必须小心。DDL(数据定义语言)指的是更改表定义或称为数据库架构的结构。详细信息请参阅 MySQL Documentation.
21.4 决定查询何时可以使用索引
问题
您的表有一个索引,但查询仍然很慢。
解决方案
使用 EXPLAIN 检查查询计划,确保使用了正确的索引。
讨论
索引是查询计划的一部分,通过最短可能的路径访问数据以加快访问速度。当 MySQL 优化器做出决策时,它考虑索引、基数、行数等因素。以下是一个查询的示例,其中存在一个列的索引,但 MySQL 无法利用它。
mysql> `EXPLAIN` `SELECT` `name_rank``,``top_name``,``name_count` `FROM` `top_names`
-> `WHERE` `name_rank` `<` `10` `ORDER` `BY` `name_count``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 161604
filtered: 33.33
Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)
从 Explain 计划输出来看,我们没有符合查询关键条件的索引。表上有索引,看起来我们需要在 name_rank 字段上再创建一个索引。
mysql> `CREATE` `INDEX` `idx_name_rank` `ON` `top_names``(``name_rank``)``;`
Query OK, 0 rows affected (0.16 sec)
Records: 0 Duplicates: 0 Warnings: 0
创建新索引后再次检查查询计划:
mysql> `EXPLAIN` `SELECT` `name_rank``,``top_name``,``name_count` `FROM` `top_names`
-> `WHERE` `name_rank` `<` `10` `ORDER` `BY` `name_count``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: range
possible_keys: idx_name_rank
key: idx_name_rank
key_len: 3
ref: NULL
rows: 11
filtered: 100.00
Extra: Using index condition; Using filesort
1 row in set, 1 warning (0.00 sec)
我们的查询在 top_names 表中寻找小于十的 name_rank。如果没有新创建的 idx_name_rank 索引,优化器必须评估表中的所有 161604 行才能返回 11 行。有了索引,它只需访问这 11 行。
21.5 决定多列索引的顺序
问题
您希望加快多列查询的速度。
解决方案
使用包含多列的覆盖索引。
讨论
如果查询结果完全是从索引页计算而不是读取实际表数据,则可以实现最佳查询性能。覆盖索引是针对引用多个列的查询的解决方案。这种类型的索引包含所需的数据,因此不需要在表上执行额外的读取。
在以下示例中,我们有一个查询,需要在一个列(name_rank)上添加过滤器,并按另一个列(name_count)排序。
mysql> `SELECT` `name_rank``,``top_name``,``name_count` `FROM` `top_names`
-> `WHERE` `name_rank` `<` `10` `ORDER` `BY` `name_count``\``G`
我们将在认为优化器选择最快路径所需的列上创建一个索引。
mysql> `CREATE` `INDEX` `idx_name_rank_count` `ON` `top_names``(``name_count``,``name_rank``)``;`
Query OK, 0 rows affected (0.18 sec)
Records: 0 Duplicates: 0 Warnings: 0
在这种情况下,MySQL 无法对以下查询使用索引,最终需要再次执行全表扫描。原因是,尽管查询的两列都在过滤器中,但索引的顺序是反向的。
mysql> `EXPLAIN` `SELECT` `name_rank``,``top_name``,``name_count` `FROM` `top_names`
-> `WHERE` `name_rank` `<` `10` `ORDER` `BY` `name_count``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 161604
filtered: 33.33
Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)
为了演示索引列顺序的重要性,让我们看下面的例子。
对于 KEY `idx_name_rank_count` (`name_rank`,`name_count`),首先按相反顺序删除先前的索引,然后创建一个新索引。
mysql> `DROP` `INDEX` `idx_name_rank_count` `ON` `top_names``;`
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `CREATE` `INDEX` `idx_name_rank_count` `ON` `top_names``(``name_rank``,``name_count``)``;`
Query OK, 0 rows affected (0.15 sec)
Records: 0 Duplicates: 0 Warnings: 0
我们已为我们的 SELECT 语句提议的 name_rank 和 name_count 过滤器创建了覆盖索引。
mysql> `EXPLAIN` `SELECT` `name_rank``,``top_name``,``name_count` `FROM` `top_names`
-> `WHERE` `name_rank` `<` `10` `ORDER` `BY` `name_count``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: range
possible_keys: idx_name_rank_count
key: idx_name_rank_count
key_len: 3
ref: NULL
rows: 11
filtered: 100.00
Extra: Using index condition; Using filesort
1 row in set, 1 warning (0.00 sec)
正如您从 EXPLAIN 输出中可以看到的那样,优化器为此查询选择了 idx_name_rank_count,并创建了一个新的覆盖索引。
21.6 使用升序和降序索引
问题
您希望在没有性能惩罚的情况下按升序或降序扫描数据。
解决方案
使用升序和降序索引。
讨论
MySQL 可以以反向顺序扫描索引,这会导致性能下降,因为索引页面是物理排序的。为了为 ORDER BY 子句创建匹配的索引,使用 DESC 表示降序,ASC 表示升序索引类型。
理想的查询性能来源于避免反向扫描索引。这也是排序和使用 DESC 索引进行过滤的结合体。当 MySQL 优化器选择查询计划时,它评估是否可以在需要降序时利用这些优势。
请记住,降序索引仅受 InnoDB 存储引擎支持,并且在使用时存在一些限制。
此外,降序索引具有以下特性:
-
它们受所有数据类型支持。
-
DISTINCT子句可以使用具有匹配列的任何索引。 -
它们可以在不与
GROUP BY子句一起使用时用于MIN()/MAX()优化。 -
它们仅限于
BTREE和HASH索引。 -
它们不支持
FULLTEXT或SPATIAL索引类型。
以下示例从创建所需字段的覆盖索引开始。
CREATE INDEX idx_desc_01 ON top_names(top_name, prop100k ASC, cum_prop100K DESC);
mysql> `SELECT` `top_name``,``prop100k``,``cum_prop100k` `FROM` `top_names`
-> `ORDER` `BY` `` ` ```top_name``` ` ```,``` ` ```prop100k``` ` ```,``` ` ```cum_prop100k``` ` `` `DESC` `LIMIT` `10``;`
+----------+----------+--------------+ | top_name | prop100k | cum_prop100k |
+----------+----------+--------------+ | NULL | 2.43 | 60231.65 |
| AAB | 0.05 | 88770.96 |
| AABERG | 0.16 | 82003.18 |
| AABY | 0.07 | 86239.41 |
| AADLAND | 0.13 | 83329.35 |
| AAFEDT | 0.05 | 88567.34 |
| AAGAARD | 0.10 | 84574.52 |
| AAGARD | 0.12 | 83769.42 |
| AAGESEN | 0.06 | 87383.27 |
| AAKER | 0.12 | 83574.66 |
+----------+----------+--------------+ 10 rows in set (0.00 sec)
在为所有 ORDER BY 子句创建覆盖索引后,优化器选择 idx_desc_01。这对于索引优化和排序特别有利。
mysql> `EXPLAIN SELECT top_name,prop100k,cum_prop100k FROM top_names`
-> ``ORDER BY `top_name`,`prop100k`,`cum_prop100k` DESC LIMIT 10\G``
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: index
possible_keys: NULL
key: idx_desc_01
key_len: 113
ref: NULL
rows: 10
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.00 sec)
当我们执行 SELECT * FROM top_names 时,而不是指定 top_name 字段的列顺序,它使用先前创建的索引,默认为升序排列。
mysql> `EXPLAIN` `SELECT` `*` `FROM` `top_names` `ORDER` `BY` `top_name` `ASC` `LIMIT` `10` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: index
possible_keys: NULL
key: idx_top_name
key_len: 103
ref: NULL
rows: 10
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
为了演示降序索引的使用,我们将创建一个新索引,并使用 DESC 应用它。
mysql> `CREATE` `INDEX` `idx_desc_02` `ON` `top_names``(``top_name` `DESC``,` `prop100k``,` `cum_prop100K``)``;`
Query OK, 0 rows affected (0.38 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `EXPLAIN` `SELECT` `*` `FROM` `top_names` `ORDER` `BY` `top_name` `DESC` `LIMIT` `10``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: index
possible_keys: NULL
key: idx_desc_02
key_len: 113
ref: NULL
rows: 10
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
再次使用 top_name 和另一列 prop100k 来说明在 top_name 列上使用 DESC 索引的用法。
mysql> `EXPLAIN` `SELECT` `top_name` `FROM` `top_names`
-> `ORDER` `BY` `top_name` `DESC``,``prop100k` `ASC` `LIMIT` `10` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: index
possible_keys: NULL
key: idx_desc_02
key_len: 113
ref: NULL
rows: 10
filtered: 100.00
Extra: Using index
1 row in set, 1 warning (0.00 sec)
注意
顺序很重要,因为 MySQL 使用左侧最左匹配规则来与ORDER BY子句中的索引进行比较。在复合索引中更改列的顺序将改变查询结果的行为。此外,在排序多个字段时小心使用SELECT * FROM,因为*将使用表定义中的列顺序,可能导致与ORDER BY子句意图不符的字段。
21.7 使用基于函数的索引
问题
您需要按表达式搜索或排序,但 MySQL 为每行计算表达式的结果,因此无法使用索引。查询的性能较差。
解决方案
使用功能索引。
讨论
有些类型的信息更容易通过不是原始值而是从它们计算得到的表达式来分析。例如,表mail中的列size存储的是字节大小,在第一次查看时很难解释。如果使用千字节(KB)会更容易处理。但是,您可能不想失去存储在字节中提供的精度。
如果您将数据存储在字节中并使用表达式查询表,则可以兼顾精度和可用性。例如,要查找大于 100 KB 的消息,请使用以下查询。
mysql> `SELECT` `t``,` `srcuser``,` `srchost``,` `size``,` `ROUND``(``size``/``1024``)` `AS` `size_KB`
-> `FROM` `mail` `WHERE` `ROUND``(``size``/``1024``)` `>` `100``;`
+---------------------+---------+---------+---------+---------+ | t | srcuser | srchost | size | size_KB |
+---------------------+---------+---------+---------+---------+ | 2014-05-12 12:48:13 | tricia | mars | 194925 | 190 |
| 2014-05-14 17:03:01 | tricia | saturn | 2394482 | 2338 |
| 2014-05-15 10:25:52 | gene | mars | 998532 | 975 |
+---------------------+---------+---------+---------+---------+ 3 rows in set (0.00 sec)
然而,MySQL 无法使用列size上的索引来解决此查询,因为它为每行计算表达式。为解决此问题,请使用基于函数的索引。
函数索引的语法如下:
CREATE INDEX *`index_name`* ON *`table_name`* ((*`expression`*));
注意双括号:如果省略一对括号,MySQL 将认为您传递的是列名而不是表达式,并返回错误。
让我们在ROUND(size/1024)上创建一个索引,并检查 MySQL 是否会使用它来解决查询。
mysql> `CREATE` `INDEX` `size_KB` `ON` `mail` `(``(``ROUND``(``size``/``1024``)``)``)``;`
Query OK, 0 rows affected (0.02 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `EXPLAIN` `SELECT` `t``,` `srcuser``,` `srchost``,` `size``,` `ROUND``(``size``/``1024``)` `AS` `size_KB`
-> `FROM` `mail` `WHERE` `ROUND``(``size``/``1024``)` `>` `100``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: mail
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 16
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
该索引将不会被用于解决查询,因为函数ROUND返回具有浮点的数据类型NEWDECIMAL和100是LONGLONG。如果您使用选项--column-type-info启动 mysql 客户端,您可以检查结果:
$ `unbuffer mysql --column-type-info -e "SELECT ROUND(10.5)" | grep Type`
Type: NEWDECIMAL
$ `unbuffer mysql --column-type-info -e "SELECT 100" | grep Type`
Type: LONGLONG
小贴士
我们需要使用命令unbuffer,因为mysql会缓冲--column-type-info的结果,否则不能将其传输到grep。
为了澄清,强制 MySQL 使用索引,您需要将表达式的结果与浮点值进行比较。
mysql> `EXPLAIN` `SELECT` `t``,` `srcuser``,` `srchost``,` `size``,` `ROUND``(``size``/``1024``)` `AS` `size_KB`
-> `FROM` `mail` `WHERE` `ROUND``(``size``/``1024``)` `>` `100``.``0``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: mail
partitions: NULL
type: range
possible_keys: size_KB
key: size_KB
key_len: 10
ref: NULL
rows: 3
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
或者,在创建索引时将函数ROUND的结果转换为整数值。这也会强制 MySQL 使用索引来解决查询。
mysql> `DROP` `INDEX` `size_KB` `ON` `mail``;`
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `CREATE` `INDEX` `size_KB` `ON` `mail` `(``(``CAST``(``ROUND``(``size``/``1024``)` `AS` `SIGNED``)``)``)``;`
Query OK, 0 rows affected (0.02 sec)
Records: 0 Duplicates: 0 Warnings: 0
mysql> `EXPLAIN` `SELECT` `t``,` `srcuser``,` `srchost``,` `size``,` `ROUND``(``size``/``1024``)` `AS` `size_KB`
-> `FROM` `mail` `WHERE` `ROUND``(``size``/``1024``)` `>` `100``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: mail
partitions: NULL
type: range
possible_keys: size_KB
key: size_KB
key_len: 9
ref: NULL
rows: 3
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
21.8 使用生成列上的索引与 JSON 数据
问题
您想在 JSON 数据内执行搜索,但速度较慢。
解决方案
使用一个从搜索 JSON 值的表达式创建的生成列,并在此列上创建索引。
讨论
在这个示例中,我们将讨论一个表book_authors。
CREATE TABLE `book_authors` (
`id` int NOT NULL AUTO_INCREMENT,
`author` json NOT NULL,
PRIMARY KEY (`id`)
);
表中包含每位作者的书籍记录在 JSON 列中。
mysql> `SELECT` `*` `FROM` `book_authors``\``G`
*************************** 1. row ***************************
id: 1
author: {"id": 1, "name": "Paul", ↩
"books": [ ↩
"Software Portability with imake: Practical Software Engineering", ↩
"Mysql: The Definitive Guide to Using, Programming, ↩
and Administering Mysql 4 (Developer's Library)", ↩
"MYSQL Certification Study Guide", ↩
"MySQL (OTHER NEW RIDERS)", ↩
"MySQL Cookbook", ↩
"MySQL 5.0 Certification Study Guide", ↩
"Using csh & tcsh: Type Less, Accomplish More (Nutshell Handbooks)", ↩
"MySQL (Developer's Library)"], ↩
"lastname": "DuBois"}
lastname: "DuBois"
*************************** 2. row ***************************
id: 2
author: {"id": 2, "name": "Alkin", "books": ["MySQL Cookbook"],↩
"lastname": "Tezuysal"}
lastname: "Tezuysal"
*************************** 3. row ***************************
id: 3
author: {"id": 3, "name": "Sveta", ↩
"books": ["MySQL Troubleshooting", "MySQL Cookbook"], ↩
"lastname": "Smirnova"}
lastname: "Smirnova"
3 rows in set (0,00 sec)
如果要搜索特定作者,可以考虑按其名和姓进行搜索。
CREATE INDEX命令在表的列上创建索引。JSON 数据存储在单列中,因此使用简单的CREATE INDEX命令创建的任何索引都将索引整个 JSON 文档,而您可能只需要搜索其中的一部分。
此外,CREATE INDEX命令将无法用于 JSON 列。
mysql> `CREATE` `INDEX` `author_name` `ON` `book_authors``(``author``)``;`
ERROR 3152 (42000): JSON column 'author' supports indexing only ↩
via generated columns on a specified JSON path.
解决此问题的方法是使用生成列并在其上创建索引。生成列的值是在列创建时定义的表达式生成的。
ALTER TABLE book_authors ADD COLUMN lastname VARCHAR(255)
GENERATED ALWAYS AS(JSON_UNQUOTE(JSON_EXTRACT(author, '$.lastname')));
在此示例中,我们创建了一个从表达式JSON_EXTRACT(author, '$.lastname')生成的列。我们还可以使用运算符->和->>来提取 JSON 值:
ALTER TABLE book_authors ADD COLUMN name VARCHAR(255)
GENERATED ALWAYS AS (author->>'$.name');
我们在表达式中使用了函数JSON_UNQUOTE和运算符->>来删除作者姓名末尾的引号(如果存在)。
两个新列name和lastname不占用空间,并且每次查询访问表时都会生成。
提示
如果要通过增加存储空间和写入时的延迟来提高SELECT查询性能,请定义带有关键字STORED的生成列。在这种情况下,表达式仅在插入或修改时执行一次,并且物理存储在磁盘上。
现在我们可以在新生成的列上创建索引。
CREATE INDEX author_name ON book_authors(lastname, name);
使用新创建的索引访问数据时,请将新列视为其他任何列。
mysql> `SELECT` `author``-``>``'$.books'` `FROM` `book_authors`
-> `WHERE` `name` `=` `'Sveta'` `AND` `lastname``=``'Smirnova'``;`
+---------------------------------------------+ | author->'$.books' |
+---------------------------------------------+ | ["MySQL Troubleshooting", "MySQL Cookbook"] |
+---------------------------------------------+ 1 row in set (0,00 sec)
EXPLAIN确认新索引的使用情况。
mysql> `EXPLAIN` `SELECT` `author``-``>``'$.books'` `FROM` `book_authors`
-> `WHERE` `name` `=` `'Sveta'` `AND` `lastname``=``'Smirnova'``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: book_authors
partitions: NULL
type: ref
possible_keys: author_name
key: author_name
key_len: 2046
ref: const,const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0,00 sec)
另请参阅
关于在 MySQL 中使用 JSON 的更多信息,请参见第十九章。
21.9 使用全文索引
问题
您希望利用关键词搜索,但文本字段的查询速度较慢。
解决方案
使用FULLTEXT索引进行全文搜索。
讨论
MySQL 支持FULLTEXT索引,但仅限于流行的存储引擎(如 InnoDB 和 MyISAM)。尽管这两种存储引擎原本不设计用于索引大文本操作,但仍可用于特定查询的性能优化。
FULLTEXT索引还有另外两个条件。
-
它只能用于
CHAR、VARCHAR或TEXT列。 -
只能在
SELECT语句中的MATCH()或AGAINST()子句中使用。
在 MySQL 中,MATCH()函数通过接受一个逗号分隔的列名列表来执行全文搜索,而AGAINST()则接受一个要搜索的字符串。
注意
FULLTEXT索引可以与同一列上的 B-tree 索引结合使用,因为它们的目的不同。FULLTEXT用于查找关键词,而不是匹配字段中的值。
FULLTEXT文本搜索也有三种不同的模式:
-
自然语言模式(默认)是用于简单短语的搜索模式。
SELECT top_name,name_rank FROM top_names WHERE MATCH(top_name) AGAINST("ANDREW" IN NATURAL LANGUAGE MODE) \G -
布尔模式用于在搜索模式中使用布尔运算符。回想一下,与 Recipe 7.17 中讨论的策略类似,在这里也使用了操作符。SELECT top_name,name_rank FROM top_names WHERE MATCH(top_name) AGAINST("+ANDREW +ANDY -ANN" IN BOOLEAN MODE) \G -
Query expansion mode是用于搜索与搜索表达式相似或相关值的搜索模式。简而言之,这种模式将返回与搜索关键词相关的匹配项。SELECT top_name,name_rank FROM top_names WHERE MATCH(top_name) AGAINST("ANDY" WITH QUERY EXPANSION) \G
InnoDB 存储引擎可以利用以下优化。
-
只返回搜索排名的
ID字段的查询。搜索排名定义为关联性排名,用作显示匹配质量的度量。 -
对匹配行进行降序排序的查询
优化器将选择在 top_name 列上选择非全文索引。这是因为查询被编写为优化 InnoDB 的 B-Tree 索引,使用模式匹配而不是文字比较。有关更多信息,请参见 Recipe 7.10。在这个示例中,这种类型的查询非常高效,因为我们的数据类型具有在 top_name 列中索引的唯一字符串值。
mysql> `EXPLAIN` `SELECT` `top_name``,``name_rank` `FROM` `top_names`
-> `WHERE` `top_name``=``"ANDREW"` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ref
possible_keys: idx_top_name,idx_desc_01,idx_desc_02
key: idx_top_name
key_len: 103
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
mysql> `CREATE` `FULLTEXT` `INDEX` `idx_fulltext` `ON` `top_names``(``top_name``)``;`
Query OK, 0 rows affected, 1 warning (1.94 sec)
Records: 0 Duplicates: 0 Warnings: 1
mysql> `EXPLAIN` `SELECT` `top_name``,``name_rank` `FROM` `top_names`
-> `WHERE` `top_name``=``"ANDREW"` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ref
possible_keys: idx_top_name,idx_desc_01,idx_desc_02,idx_fulltext
key: idx_top_name
key_len: 103
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0.00 sec)
现在,如果我们尝试对同一列进行模式匹配,我们将能够利用给定列的全文索引。
mysql> `EXPLAIN` `SELECT` `top_name``,``name_rank` `FROM` `top_names`
-> `MATCH``(``top_name``)` `AGAINST``(``"ANDREW"``)` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: fulltext
possible_keys: idx_fulltext
key: idx_fulltext
key_len: 0
ref: const
rows: 1
filtered: 100.00
Extra: Using where; Ft_hints: sorted
1 row in set, 1 warning (0.01 sec)
在这种情况下,我们可以看到 MySQL 选择使用FULLTEXT索引。尽管在 MySQL 中拥有FULLTEXT索引是有用的,但它带来了许多限制。请参考 MySQL 文档了解全文检索的限制的详细信息。
注意
尽管 InnoDB 存储引擎中有全文索引,但在市场上可能有更好的选择来减轻 MySQL 的工作负荷,并将其放在另一个优化的存储系统中。
21.10 利用空间索引和地理数据
问题
您希望有效地存储和查询地理坐标。
解决方案
使用 MySQL 的改进空间参考系统。
讨论
MySQL 8 包含来自 EPSG(欧洲石油调查集团)机构的所有 SRS(空间参考系统)标识。这些 SRS 标识以唯一名称和 SRID 存储在 information_schema 中。
这些系统代表了地理数据参考的不同变体。您可以从 information_schema 查询这些的详细信息。
mysql> `SELECT` `*` `FROM` `INFORMATION_SCHEMA``.``ST_SPATIAL_REFERENCE_SYSTEMS`
-> `WHERE` `SRS_ID``=``4326` `OR` `SRS_ID``=``3857` `ORDER` `BY` `SRS_ID` `DESC``\``G`
*************************** 1. row ***************************
SRS_NAME: WGS 84
SRS_ID: 4326
ORGANIZATION: EPSG
ORGANIZATION_COORDSYS_ID: 4326
DEFINITION: GEOGCS["WGS 84",DATUM["World Geodetic System 1984",
SPHEROID["WGS 84",6378137,298.257223563,AUTHORITY["EPSG","7030"]],
AUTHORITY["EPSG","6326"]],PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],
UNIT["degree",0.017453292519943278,AUTHORITY["EPSG","9122"]],
AXIS["Lat",NORTH],AXIS["Lon",EAST],AUTHORITY["EPSG","4326"]]
DESCRIPTION: NULL
*************************** 2. row ***************************
SRS_NAME: WGS 84 / Pseudo-Mercator
SRS_ID: 3857
ORGANIZATION: EPSG
ORGANIZATION_COORDSYS_ID: 3857
DEFINITION: PROJCS["WGS 84 / Pseudo-Mercator",GEOGCS["WGS 84",
DATUM["World Geodetic System 1984",SPHEROID["WGS 84",6378137,
298.257223563,AUTHORITY["EPSG","7030"]],AUTHORITY["EPSG","6326"]],
PRIMEM["Greenwich",0,AUTHORITY["EPSG","8901"]],UNIT["degree",
0.017453292519943278,AUTHORITY["EPSG","9122"]],AXIS["Lat",NORTH]
,AXIS["Lon",EAST],AUTHORITY["EPSG","4326"]],PROJECTION["Popular
Visualisation Pseudo Mercator",AUTHORITY["EPSG","1024"]],
PARAMETER["Latitude of natural origin",0,AUTHORITY["EPSG","8801"]],
PARAMETER["Longitude of natural origin",0,AUTHORITY["EPSG","8802"]],
PARAMETER["False easting",0,AUTHORITY["EPSG","8806"]],↩
PARAMETER["False northing",
0,AUTHORITY["EPSG","8807"]],UNIT["metre",1,AUTHORITY["EPSG","9001"]],↩
AXIS["X",EAST],
AXIS["Y",NORTH],AUTHORITY["EPSG","3857"]]
DESCRIPTION: NULL
2 rows in set (0.00 sec)
SRS_ID 4326 表示广泛使用的网络地图投影,如谷歌地图、OpenStreetMaps 等,而 4326 则是用于跟踪位置的 GPS 坐标。
假设我们有兴趣点数据存储在我们的数据库中。我们将创建一张表,并使用 SRID 4326 加载样本数据到其中。
mysql> `CREATE` `TABLE` `poi`
-> `(` `poi_id` `INT` `UNSIGNED` `AUTO_INCREMENT` `NOT` `NULL` `PRIMARY` `KEY``,`
-> `position` `POINT` `NOT` `NULL` `SRID` `4326``,` `name` `VARCHAR``(``200``)``)``;`
Query OK, 0 rows affected (0.02 sec)
mysql> `INSERT` `INTO` `poi` `VALUES` `(``1``,` `ST_GeomFromText``(``'POINT(41.0211 29.0041)'``,` `4326``)``,`
-> `'Maiden\'``s` `Tower``');` Query OK, 1 row affected (0.00 sec)
msyql> `INSERT INTO poi VALUES (2, ST_GeomFromText('``POINT``(``41``.``0256` `28``.``9742``)``', 4326),` -> `'``Galata` `Tower``'``)``;`
Query OK, 1 row affected (0.00 sec)
现在在几何列上创建索引。
mysql> `CREATE` `SPATIAL` `INDEX` `position` `ON` `poi` `(``position``)``;`
Query OK, 0 rows affected (0.01 sec)
Records: 0 Duplicates: 0 Warnings: 0
我们将演示如何测量这两个兴趣点之间的距离。
mysql> `SELECT` `ST_AsText``(``position``)` `FROM` `poi` `WHERE` `poi_id` `=` `1` `INTO` `@``tower1``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `ST_AsText``(``position``)` `FROM` `poi` `WHERE` `poi_id` `=` `2` `INTO` `@``tower2``;`
Query OK, 1 row affected (0.00 sec)
mysql> `SELECT` `ST_Distance``(``ST_GeomFromText``(``@``tower1``,` `4326``)``,`
-> `ST_GeomFromText``(``@``tower2``,` `4326``)``)` `AS` `distance``;`
+--------------------+ | distance |
+--------------------+ | 2563.9276036976544 |
+--------------------+ 1 row in set (0.00 sec)
这是两个兴趣点之间的直线表示。当然,这不是汽车路径规划的示例;这更像是从点 A 到点 B 的鸟瞰飞行距离(以米为单位)。
让我们检查 MySQL 在查询优化中使用了什么。
mysql> `EXPLAIN` `SELECT` `ST_Distance``(``ST_GeomFromText``(``@``tower1``,` `4326``)``,`
-> `ST_GeomFromText``(``@``tower2``,` `4326``)``)` `AS` `dist` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: NULL
partitions: NULL
type: NULL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: NULL
filtered: NULL
Extra: No tables used
1 row in set, 1 warning (0.00 sec)
由于 ST_Distance 函数不使用表来计算两个位置之间的距离,它在查询中不使用表;因此不允许进行索引优化。
您可以进一步改进关于地球球形的距离计算,应使用ST_Distance_Sphere,这将导致略有不同的结果。
mysql> `SELECT` `ST_Distance_Sphere``(``ST_GeomFromText``(``@``tower1``,` `4326``)``,`
-> `ST_GeomFromText``(``@``tower2``,` `4326``)``)` `AS` `dist``;`
+--------------------+ | dist |
+--------------------+ | 2557.7412439442496 |
+--------------------+ 1 row in set (0.00 sec)
假设我们有一个围绕伊斯坦布尔的多边形,用于覆盖我们的目标搜索区域。可以通过另一个应用程序生成所需的多边形坐标。
mysql> `SET` `@``poly` `:``=` `ST_GeomFromText` `(` `'POLYGON(( 41.104897239651905 28.876082545638166,` -> `41.05727989444261 29.183699733138166,` -> `40.90384226781947 29.137007838606916,` -> `40.94119778455447 28.865096217513166,` -> `41.104897239651905 28.876082545638166))'``,` `4326``)``;`
这次我们将使用ST_Within函数从该多边形区域搜索兴趣点。MySQL 的空间引用实现内置了许多函数。详情请参阅 MySQL 文档空间分析函数。空间函数可以分为几类,例如:
-
以各种格式创建几何。
-
在各种格式之间转换几何。
-
访问几何的定性和定量属性。
-
描述两个几何之间的关系。
-
从现有几何创建新几何。
这些函数允许开发人员更快地访问数据,并更好地利用 MySQL 中的空间分析。
在下面的查询中,我们同时使用ST_AsText和ST_Within函数。
mysql> `SELECT` `poi_id``,` `name``,` `ST_AsText``(``` ` ```position``` ` ```)`
-> `AS` `` ` ```towers``` ` `` `FROM` `poi` `WHERE` `ST_Within``(` `` ` ```position``` ` ```,` `@``poly``)` `;`
+--------+----------------+------------------------+ | poi_id | 名称 | 塔 |
+--------+----------------+------------------------+ | 1 | 少女塔 | POINT(41.0211 29.0041) |
| 2 | 加拉塔塔 | POINT(41.0256 28.9742) |
| --- | --- | --- |
+--------+----------------+------------------------+ 2 行集(0.00 秒)
Check whether the spatial index used or not?
mysql> `EXPLAIN` `SELECT` `poi_id``,` `name``,`
-> `ST_AsText``(``` ` ```position``` ` ```)` `AS` `` ` ```towers``` ` `` `FROM` `poi` `WHERE` `ST_Within``(` `` ` ```position``` ` ```,` `@``poly``)` `\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: poi
partitions: NULL
type: range
possible_keys: position
key: position
key_len: 34
ref: NULL
rows: 2
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
21.11 创建和使用直方图
问题
您想要连接两个或更多表,但 MySQL 的优化器未选择正确的查询计划。
解决方案
使用优化器直方图来辅助决策。
讨论
索引有助于解决查询计划,但它们并不总是用于创建最佳的查询执行计划。当优化器需要确定两个或更多表的连接顺序时,情况就会如此。
假设您有两个表。一个存储商店中的产品类别,另一个存储销售数据。类别数量少,而已售出的物品数量巨大。您可能有数十个类别和数百万个已售出的物品。当您连接两个表时,MySQL 必须决定首先查询哪个表。如果它首先查询小表,它必须检索所选类别中的所有物品,然后过滤它们。如果所选类别中的物品数量巨大,而过滤后的物品数量较少,则查询将不会有效。另一方面,如果您需要来自较大表的范围内的单个类别的物品,并且所选范围返回所有类别的许多行,则您将不得不丢弃除单个类别之外的所有行。
解决此问题的一个方法是在较大表中使用将类别 ID 和WHERE条件及JOIN子句组合的联合索引。但是,当此联合索引不适用于WHERE条件和JOIN子句的组合时,此解决方案可能无效。
另一个索引的问题在于它们根据基数(唯一值的数量)进行操作。但当数据分布不均匀时,优化器仅使用基数可能会得出错误的结论。假设您有一百万个具有某种特征的项目和十个具有另一种特征的项目。如果优化器决定选择满足第一个条件的数据,则与首先选择满足第二个条件的数据相比,查询将需要更多的时间。不幸的是,仅通过基数的信息无法得出正确的结论。
为了解决这个问题,MySQL 8.0 引入了优化器直方图。它们是一种轻量级数据结构,用于存储每个数据桶中存在多少个唯一值的信息。
为了说明优化器直方图的工作原理,让我们考虑一个包含六行的表。
mysql> `CREATE` `TABLE` `histograms``(``f1` `INT``)``;`
Query OK, 0 rows affected (0,03 sec)
mysql> `INSERT` `INTO` `histograms` `VALUES``(``1``)``,``(``2``)``,``(``2``)``,``(``3``)``,``(``3``)``,``(``3``)``;`
Query OK, 6 rows affected (0,00 sec)
Records: 6 Duplicates: 0 Warnings: 0
mysql> `SELECT` `f1``,` `COUNT``(``f1``)` `FROM` `histograms` `GROUP` `BY` `f1``;`
+------+-----------+ | f1 | COUNT(f1) |
+------+-----------+ | 1 | 1 |
| 2 | 2 |
| 3 | 3 |
+------+-----------+ 3 rows in set (0,00 sec)
如您所见,该表包含一个值为 1 的行,两个值为 2 的行和三个值为 3 的行。
如果我们在查询中选择此表中的不同行并运行EXPLAIN,我们将注意到从结果中过滤的行数是相同的,无论我们寻找哪个值。
mysql> `\``P` `grep` `filtered`
PAGER set to 'grep filtered'
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `WHERE` `f1``=``1``\``G`
filtered: 16.67
1 row in set, 1 warning (0,00 sec)
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `WHERE` `f1``=``2``\``G`
filtered: 16.67
1 row in set, 1 warning (0,00 sec)
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `WHERE` `f1``=``3``\``G`
filtered: 16.67
1 row in set, 1 warning (0,00 sec)
过滤的行数显示从检索结果中将被过滤的行数。由于我们的表没有索引,MySQL 首先从表中检索所有行,然后过滤满足条件的行。在没有任何提示的情况下,优化器认为 MySQL 将从结果中留下只一行,无论使用哪个条件。
创建一个直方图,检查是否有任何变化。
mysql> `ANALYZE` `TABLE` `histograms` `UPDATE` `HISTOGRAM` `ON` `f1``\``G`
*************************** 1. row ***************************
Table: cookbook.histograms
Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'f1'.
1 row in set (0,01 sec)
直方图存储在数据字典表column_statistics中,并且可以通过查询信息模式中的COLUMN_STATISTICS表来检查。
mysql> `SELECT` `*` `FROM` `information_schema``.``column_statistics`
-> `WHERE` `table_name``=``'histograms'``\``G`
*************************** 1. row ***************************
SCHEMA_NAME: cookbook
TABLE_NAME: histograms
COLUMN_NAME: f1
HISTOGRAM: {"buckets": [[1, 0.16666666666666666], [2, 0.5], [3, 1.0]],
"data-type": "int",
"null-values": 0.0,
"collation-id": 8,
"last-updated": "2021-05-23 17:29:46.595599",
"sampling-rate": 1.0,
"histogram-type": "singleton",
"number-of-buckets-specified": 100}
1 row in set (0,00 sec)
三个桶包含关于数据范围的信息。值 1 占表的 1/6(六分之一)(六行中的一行),值 1 和 2 各占一半(0.5)的表,并与值 3 一起填充表。每个桶中的项目数量存储为一的分数。字段number-of-buckets-specified包含在创建直方图时指定的桶数。默认值为 100,但您可以自由地指定介于 1 和 1024 之间的任何数字。如果列中唯一元素的数量超过桶的数量,则histogram-type将从singleton更改为equi-height,并且每个桶可以包含值范围,而不是仅在singleton情况下包含一个值。
直方图会影响EXPLAIN输出中filtered字段的值。
在下面的示例中,过滤行的值是正确的,并反映了表的内容。如果我们搜索值 1,则预计将从结果集中删除六个表行中的五个,这是正确的。对于值 2,只有两行(33.33%)将留在结果集中,而对于值 3,表的一半将被过滤。
mysql> `\``P` `grep` `filtered`
PAGER set to 'grep filtered'
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `WHERE` `f1``=``1``\``G`
filtered: 16.67
1 row in set, 1 warning (0,00 sec)
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `WHERE` `f1``=``2``\``G`
filtered: 33.33
1 row in set, 1 warning (0,00 sec)
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `WHERE` `f1``=``3``\``G`
filtered: 50.00
1 row in set, 1 warning (0,00 sec)
直方图不会帮助访问数据:它们只是统计信息,不是像索引那样的物理结构。它们影响查询执行计划,特别是表连接的顺序。例如,如果我们决定将 histograms 表与自身连接,根据条件顺序会有所不同。
mysql> `\``P` `grep` `-``B` `3` `table`
PAGER set to 'grep -B 3 table'
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `h1` `JOIN` `histograms` `h2`
-> `WHERE` `h1``.``f1``=``1` `and` `h2``.``f1``=``3``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: h1
-- *************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: h2
2 rows in set, 1 warning (0,00 sec)
mysql> `EXPLAIN` `SELECT` `*` `FROM` `histograms` `h1` `JOIN` `histograms` `h2`
-> `WHERE` `h1``.``f1``=``3` `and` `h2``.``f1``=``1``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: h2
-- *************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: h1
2 rows in set, 1 warning (0,00 sec)
直方图的真正威力在于大表上得到了展示。相关的 GitHub 仓库有两个表的数据:goods_shops 和 goods_characteristics。它们默认情况下没有使用直方图,但有索引。
CREATE TABLE `goods_shops` (
`id` int NOT NULL AUTO_INCREMENT,
`good_id` varchar(30) DEFAULT NULL,
`location` varchar(30) DEFAULT NULL,
`delivery_options` varchar(30) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `good_id` (`good_id`,`location`,`delivery_options`),
KEY `location` (`location`,`delivery_options`)
);
CREATE TABLE `goods_characteristics` (
`id` int NOT NULL AUTO_INCREMENT,
`good_id` varchar(30) DEFAULT NULL,
`size` int DEFAULT NULL,
`manufacturer` varchar(30) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `good_id` (`good_id`,`size`,`manufacturer`),
KEY `size` (`size`,`manufacturer`)
);
如果我们想找到屏幕尺寸小于 13 英寸且制造商是联想、戴尔、东芝、三星或宏碁中的一家的笔记本电脑的数量,并且可以通过高级或紧急交付在莫斯科或基辅获取,我们可以使用以下查询。
mysql> `SELECT` `COUNT``(``*``)` `FROM` `goods_shops`
-> `JOIN` `goods_characteristics` `USING` `(``good_id``)`
-> `WHERE` `size` `<` `13` `AND` `manufacturer`
-> `IN` `(``'Lenovo'``,` `'Dell'``,` `'Toshiba'``,` `'Samsung'``,` `'Acer'``)`
-> `AND` `(``location` `IN` `(``'Moscow'``,` `'Kiev'``)`
-> `OR` `delivery_options` `IN` `(``'Premium'``,` `'Urgent'``)``)``;`
+----------+ | count(*) |
+----------+ | 816640 |
+----------+ 1 row in set (6 min 31,75 sec)
查询耗时超过 6 分钟,对于少于 50 万行的两个表来说,这是相当长的时间。原因在于 goods_shops 表只包含少数满足商店位置和交付选项条件的行,而 goods_characteristics 表中有更多满足笔记本电脑尺寸和制造商条件的行。在这种情况下,最好先从 goods_shops 表中选择数据,而优化器则选择了相反的方法。
mysql> `EXPLAIN` `SELECT` `COUNT``(``*``)` `FROM` `goods_shops` `JOIN` `goods_characteristics`
-> `USING``(``good_id``)` `WHERE` `size` `<` `13` `AND`
-> `manufacturer` `IN` `(``'Lenovo'``,` `'Dell'``,` `'Toshiba'``,` `'Samsung'``,` `'Acer'``)` `AND`
-> `(``location` `IN` `(``'Moscow'``,` `'Kiev'``)` `OR` `delivery_options` `IN` `(``'Premium'``,` `'Urgent'``)``)``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: goods_characteristics
partitions: NULL
type: index
possible_keys: good_id,size
key: good_id
key_len: 251
ref: NULL
rows: 137026
filtered: 25.00
Extra: Using where; Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: goods_shops
partitions: NULL
type: ref
possible_keys: good_id,location
key: good_id
key_len: 123
ref: cookbook.goods_characteristics.good_id
rows: 66422
filtered: 36.00
Extra: Using where; Using index
2 rows in set, 1 warning (0,00 sec)
索引在这里不会起作用,因为它们使用的基数对于索引列中的任何值都是相同的。这就是直方图可以展示其威力的时候。
mysql> `ANALYZE` `TABLE` `goods_shops` `UPDATE` `HISTOGRAM` `ON` `location``,` `delivery_options``\``G`
*************************** 1. row ***************************
Table: cookbook.goods_shops
Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'delivery_options'.
*************************** 2. row ***************************
Table: cookbook.goods_shops
Op: histogram
Msg_type: status
Msg_text: Histogram statistics created for column 'location'.
2 rows in set (0,24 sec)
mysql> `SELECT` `COUNT``(``*``)` `FROM` `goods_shops` `JOIN` `goods_characteristics`
-> `USING``(``good_id``)` `WHERE` `size` `<` `13` `AND`
-> `manufacturer` `IN` `(``'Lenovo'``,` `'Dell'``,` `'Toshiba'``,` `'Samsung'``,` `'Acer'``)` `AND`
-> `(``location` `IN` `(``'Moscow'``,` `'Kiev'``)` `OR` `delivery_options` `IN` `(``'Premium'``,` `'Urgent'``)``)``;`
+----------+ | COUNT(*) |
+----------+ | 816640 |
+----------+ 1 row in set (1,42 sec)
mysql> `EXPLAIN` `SELECT` `COUNT``(``*``)` `FROM` `goods_shops` `JOIN` `goods_characteristics`
-> `USING``(``good_id``)` `WHERE` `size` `<` `13` `AND`
-> `manufacturer` `IN` `(``'Lenovo'``,` `'Dell'``,` `'Toshiba'``,` `'Samsung'``,` `'Acer'``)` `AND`
-> `(``location` `IN` `(``'Moscow'``,` `'Kiev'``)` `OR` `delivery_options` `IN` `(``'Premium'``,` `'Urgent'``)``)``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: goods_shops
partitions: NULL
type: index
possible_keys: good_id,location
key: good_id
key_len: 369
ref: NULL
rows: 66422
filtered: 0.09
Extra: Using where; Using index
*************************** 2. row ***************************
id: 1
select_type: SIMPLE
table: goods_characteristics
partitions: NULL
type: ref
possible_keys: good_id,size
key: good_id
key_len: 123
ref: cookbook.goods_shops.good_id
rows: 137026
filtered: 25.00
Extra: Using where; Using index
2 rows in set, 1 warning (0,00 sec)
一旦创建了直方图,优化器按照有效的顺序连接表,并且查询时间略长于之前的运行中的六秒钟。
参见
有关在 MySQL 中使用直方图的更多信息,请参阅Billion Goods in Few Categories: How Histograms Save a Life?。
21.12 编写高性能查询
问题
您希望编写高效的查询。
解决方案
研究 MySQL 如何访问数据,并调整您的查询以帮助 MySQL 更快地完成其工作。
讨论
正如我们在本章中所看到的,MySQL 中索引实现有许多迭代。虽然我们利用了这些索引类型,但我们也需要知道 MySQL 如何访问数据。优化器是 MySQL 中非常先进的部分,但仍然不能总是做出正确的决策。当它没有选择正确的路径时,我们将面临查询性能不佳的问题,这可能导致生产中的服务降级或中断。编写高性能查询的最佳方式是了解 MySQL 如何访问数据。
另一个要点是,在规模上的不同与在单体环境中使用应用程序不同。随着并发性增加和数据规模,决策优化器将选择更复杂但更快的数据路由。
MySQL 使用基于成本的模型来估计查询执行过程中各种操作的成本,顺序如下。
-
找到最佳方法。
-
检查访问方法是否有用。
-
估算使用访问方法的成本。
-
选择可能的最低成本访问方法。
以下是 MySQL 选择的查询执行顺序:
-
表扫描
-
索引扫描
-
索引查找
-
范围扫描
-
索引合并
-
松散索引扫描
以下是一些已知的使用索引但性能差的慢索引查找的原因。
低基数性
当数据不足以识别快速遍历时,MySQL 最终会执行全表扫描。
大数据集
返回大数据集通常会导致问题。即使它们经过正确过滤,由于应用程序无法快速处理它们,它们可能是无用的。只针对查询中需要的数据,并过滤其余数据。
多索引遍历
如果查询命中多个索引,通过页面进行额外的 I/O 操作会导致查询性能变慢。
非主导列查找
如果不使用主导列来创建覆盖索引,则无法使用覆盖索引。
数据类型不匹配
当查询列的数据类型不匹配时,索引无法帮助。
字符集/校对规则不匹配
数据访问应统一围绕查询的字符集和校对规则进行。
后缀查找
查找后缀会显著降低性能。
索引作为参数
使用索引列作为参数将不能有效使用该索引。
过期统计信息
MySQL 根据索引基数更新统计信息。这有助于优化器做出尽可能快的路径决策。
MySQL 错误
这种情况很少见,但可能存在 MySQL 的错误,可能导致索引查找变慢。
查询类型
在设计应用程序时,识别常见的查询模式及其适用性和非适用性是有用的。
点选
访问数据的最快方法之一是直接针对索引列进行点选,这种情况下,如果索引存在于该列中,优化器已经知道数据所在的页面。
mysql> `SELECT names_id,top_name,name_rank FROM top_names`
-> `WHERE names_id=699 \G`
*************************** 1\. row ***************************
names_id: 699
top_name: KOCH
name_rank: 698
1 row in set (0.00 sec)
在这种情况下,names_id列是表的主键列,因此优化器直接访问该页面路径。
范围选择
当需要从数据集中获取一系列行时,就会使用这种SELECT。MySQL 仍然可以使用索引直接访问数据,使用与查询的WHERE子句中相同列的索引。这种访问方法使用单个索引或来自索引的值子集。范围索引也被称为使用单个或多个部分索引。在以下示例中,优化器使用name_rank字段的<和>运算符进行比较。对于所有索引类型,AND或OR组合都将成为范围条件。对于 MySQL 来说,最快的查找是主键。记住这也是表的物理顺序。
mysql> `EXPLAIN SELECT names_id,top_name,name_rank FROM top_names`
-> `WHERE names_id>800 AND names_id < 900 \G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: range
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: NULL
rows: 99
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
覆盖索引
覆盖索引是可以用于解析查询而无需访问行数据的索引。为了确保其他支持索引覆盖您的查询,我们有时应该使用次要索引。索引应该从最左边的字段开始,并且每个附加字段在复合键中。查询不应访问索引中不存在的列(参见Recipe 21.5)。
下面的示例中,索引用于解析查询条件而不访问表数据,但最终仍访问了表数据,因为我们要求的top_name列在索引中不存在。在EXPLAIN输出的Extra字段中,语句Using index condition确认了这一点。
mysql> `EXPLAIN SELECT name_rank,top_name,name_count`
-> `FROM top_names WHERE name_rank < 10 ORDER BY name_count\G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: range
possible_keys: idx_name_rank_count
key: idx_name_rank_count
key_len: 3
ref: NULL
rows: 10
filtered: 100.00
Extra: Using index condition; Using filesort
1 row in set, 1 warning (0.00 sec)
此查询使用了覆盖索引。语句Using index确认了这一点。主键已经是覆盖索引的一部分,因此不需要将names_id包含在覆盖索引中。
mysql> `EXPLAIN` `SELECT` `names_id``,` `name_rank``,` `name_count` `FROM` `top_names`
-> `WHERE` `name_rank` `<` `10` `ORDER` `BY` `name_count``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: range
possible_keys: idx_name_rank_count
key: idx_name_rank_count
key_len: 3
ref: NULL
rows: 10
filtered: 100.00
Extra: Using where; Using index; Using filesort
1 row in set, 1 warning (0,00 sec)
数据类型匹配
数据类型是有效使用索引的另一个关键因素。对于优化器而言,使用数字进行数值比较至关重要。以下查询是一个不好的例子,展示了当涉及到具有字符串的names_id字段的数据类型转换时,MySQL 并不喜欢这种情况。请看下面我们得到的警告消息。
mysql> `EXPLAIN SELECT names_id,top_name,name_rank FROM`
-> `top_names WHERE names_id= '123 names' \G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: const
possible_keys: PRIMARY
key: PRIMARY
key_len: 4
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning (0,00 sec)
mysql> `SHOW WARNINGS\G`
*************************** 1\. row ***************************
Level: Warning
Code: 1292
Message: Truncated incorrect DOUBLE value: '123 names'
*************************** 2\. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select '123' AS `names_id`,'WALLACE' AS `top_name`, ↩
'123' AS `name_rank` from `cookbook`.`top_names` where true
2 rows in set (0,00 sec)
当查询返回结果时,MySQL 必须执行任务将字符串转换为数字并丢失精度。
负条件
在这些类型的查询中,通常最有效的索引无法使用。这是因为 MySQL 必须从表或索引中选择所有行,然后过滤那些不在列表中的行。
如果可能的话,尽量避免使用否定子句,因为它们效率低下:
-
IS NOT -
IS NOT NULL -
NOT IN -
NOT LIKE
mysql> `EXPLAIN SELECT names_id,name_rank, top_name FROM`
-> `top_names WHERE top_name NOT IN ("LARA","ILAYDA","ASLIHAN") \G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ALL
possible_keys: idx_top_name,idx_desc_01,idx_desc_02,idx_fulltext
key: NULL
key_len: NULL
ref: NULL
rows: 161533
filtered: 100.00
Extra: Using where
1 row in set, 1 warning (0.00 sec)
ORDER BY 操作
排序操作可能会随着数据集的增长变得昂贵,特别是如果查询无法使用索引来解析ORDER BY。
mysql> `EXPLAIN SELECT names_id,name_rank, top_name FROM`
-> `top_names WHERE name_rank > 15000 ORDER BY top_name \G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 161533
filtered: 33.33
Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)
同样适用于LIMIT操作。这类查询通常返回少量数据,但成本很高。
mysql> `EXPLAIN SELECT names_id,name_rank, top_name FROM top_names WHERE`
-> `name_rank > 15000 ORDER BY name_rank LIMIT 10\G`
*************************** 1\. row ***************************
id: 1
select_type: SIMPLE
table: top_names
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 161533
filtered: 33.33
Extra: Using where; Using filesort
1 row in set, 1 warning (0.00 sec)
上面的查询选择大量行,然后丢弃其中大多数。
连接
连接操作是一种将来自两个或多个表的数据进行组合或引用的原始方法。虽然 SQL 连接服务于特定目的,但如果不正确使用,它们可能会在查询结果上创建笛卡尔积。强烈建议在 SELECT 语句中使用 INNER 连接来仅过滤表的交集,而不是使用 LEFT JOIN。尽管使用 INNER JOIN 并不总能符合所需的业务逻辑。在这些情况下,仍然针对索引字段进行目标查询将有利于查询执行时间。
SELECT a.col_a, a.col_b, b.col_a FROM table_a a
INNER JOIN table_b b
ON a.key = b.key;
提示
在 MySQL 中,JOIN 是 INNER JOIN 的同义词。
为了帮助优化器通过创建正确的索引和编写高效的查询来选择访问数据的最佳路径。这种方法将全面提高你的吞吐量。只添加你需要的索引,不要对表进行过度索引。避免重复索引是实现高性能查询的另一种最佳实践。识别你的表中是否存在相同的索引可能会导致读写都变慢。
第二十二章:服务器管理
22.0 简介
本章涵盖了如何执行涉及管理 MySQL 服务器的操作:
-
通用服务器配置
-
插件接口
-
控制服务器日志记录
-
配置存储引擎
本章不涵盖管理 MySQL 用户帐户。这是一个管理任务,在第二十四章中有详细介绍。
注意
这里展示的许多技术需要管理员访问权限,例如修改 mysql 系统数据库中的表或使用需要 SUPER 特权的语句。因此,为了执行这里描述的操作,您可能需要以 root 而不是 cbuser 身份连接到服务器。
22.1 配置服务器
问题
您希望更改服务器设置,并验证您的更改是否生效。
解决方案
要更改设置,请在服务器启动时或运行时指定它们。要验证更改,请在运行时检查相关的系统变量。
讨论
MySQL 服务器提供了许多配置参数供您控制。例如,需要内存资源可以调整为更高或更低以调整资源使用。一个使用频繁的服务器需要更多的内存;一个使用较少的服务器需要更少的内存。您可以在服务器启动时设置命令选项和系统变量,并且许多系统变量也可以在运行时设置。您还可以在运行时检查您的设置,以验证配置是否如您所愿。
在服务器启动时的配置控制
要在启动时配置服务器,请在命令行或选项文件中指定选项。通常情况下,后者更可取,因为您可以指定设置一次,并且它们将在每次启动时生效。(有关使用命令行选项和选项文件的背景,请参阅食谱 1.4。)
命令选项名称通常使用连字符,而系统变量名称使用下划线。但是,在启动时,服务器更宽松,可以识别使用连字符或下划线写的命令选项和系统变量。例如,在命令行或选项文件中,sql_mode 和 sql-mode 是等效的。这与运行时不同,运行时对系统变量的引用必须使用下划线。
要在选项文件中指定服务器参数,请在服务器读取的文件的[mysqld]组中列出它们。例如,这里是您可能设置的一些参数:
-
默认字符集为
utf8mb4,从 MySQL 8.0 开始。此字符集默认使用utf8mb4_0900_ai_ci作为默认排序规则。 -
默认的 SQL 模式是
STRICT_TRANS_TABLES(MySQL 5.7 之后)。要默认更宽松,请删除严格的 SQL 模式,这不推荐。 -
在 MySQL 8.0 之后,默认启用事件调度程序。如果计划使用定期事件(参见食谱 11.5),必须在之前的版本上启用它。
-
对于 InnoDB 引擎,缓冲池大小默认为 128MB,在开发和测试之外是不足够的。考虑增加到足够运行数据集的内存大小。
-
时区设置为系统默认,除非在启动时指定。如果不打算使用系统时区,则需要通过设置
--timezone=timezone_name在启动时设置它。
要实现这些配置想法,像这样在你的选项文件中写入 [mysqld] 组:
[mysqld]
character_set_server=utf8mb4
sql_mode=STRICT_TRANS_TABLES
event_scheduler=1
innodb_buffer_pool_size=512M
这些只是建议;根据自己的需求调整服务器配置。特别是有关插件和日志选项的信息,请参阅 Recipe 22.2 和 Recipe 23.0。
在运行时进行配置控制和验证
服务器启动后,您可以通过使用 SET 语句更改系统变量来进行运行时调整:
SET GLOBAL *`var_name`* = *`value`*;
这个语句设置了 var_name 的全局值;也就是说,默认情况下适用于所有客户端。在运行时更改全局值需要 SUPER 权限。许多系统变量还有会话值,这是特定客户端会话的值。给定变量的会话值在客户端连接时从全局值初始化,但之后客户端可以更改它。例如,DBA 可能在服务器启动时设置最大连接数:
[mysqld]
max_connections=1000
这设置了全局值。具有 SUPER 权限的 DBA 可以在运行时更改全局值:
SET GLOBAL max_connections = 1000;
后续连接的每个客户端的会话变量初始化为相同的值,但可以根据需要更改值。DBA 可能会增加此值以解决连接问题。
SET SESSION max_connections = 1000;
包含没有 GLOBAL 或 SESSION 修饰符的 SET 语句将更改会话值(如果有)。
在 MySQL 8.0 之后,您可以设置和持久化全局变量。许多全局变量是动态的,可以在运行时设置。使用 PERSIST 选项可以在不保存到配置文件的情况下永久设置此值,即使服务器重新启动也不会丢失。
SET PERSISTS max_connections = 1000;
SET PERSISTS_ONLY max_connections = 1000;
mysql> `SELECT @@GLOBAL.max_connections;`
+--------------------------+
| @@GLOBAL.max_connections |
+--------------------------+
| 1000 |
+--------------------------+
要重置持久化的值,请使用:
RESET PERSIST;
RESET PERSIST max_connections;
还有另一种写系统变量引用的语法:
SET @@GLOBAL.*`var_name`* = *`value`*;
SET @@SESSION.*`var_name`* = *`value`*;
@@ 语法更加灵活。除了 SET 语句之外,它还可以用于其他语句,使您能够检索或检查单个系统变量:
mysql> `SELECT @@GLOBAL.max_connections;`
+--------------------------+
| @@GLOBAL.max_connections |
+--------------------------+
| 1000 |
+--------------------------+
使用 @@ 语法引用系统变量时,如果没有 GLOBAL. 或 SESSION. 修饰符,将访问会话值(如果有),否则将访问全局值。
访问系统变量的其他方法包括 SHOW VARIABLES 语句以及从 INFORMATION_SCHEMA 的 GLOBAL_VARIABLES 和 SESSION_VARIABLES 表中选择。
如果一个设置仅作为命令选项存在,没有对应的系统变量,你无法在运行时检查其值。幸运的是,这种选项很少见。如今,大多数新的设置都被创建为可以在运行时检查的系统变量。
22.2 管理插件接口
问题
您希望利用某些服务器插件提供的功能。
解决方案
学习如何控制插件接口。
讨论
MySQL 支持使用扩展服务器功能的插件。有些插件实现存储引擎、身份验证方法、密码策略、PERFORMANCE_SCHEMA表等。服务器允许您指定要使用的插件,因此您可以只加载希望使用的插件,对于不需要的插件不会产生内存或处理开销。
本节介绍控制服务器加载哪些插件的一般背景。其他地方的讨论描述了特定插件及其对您的用处,包括身份验证插件(参见 Recipe 24.1),以及validate_password(参见 Recipe 24.3 和 Recipe 24.4)。
此处的示例使用.so(共享对象
)文件名后缀引用插件文件。如果您的系统上后缀不同,请相应地调整名称(例如,在 Windows 上使用.dll)。如果您不知道给定插件文件的名称,请查看由plugin_dir系统变量命名的目录,服务器期望在此目录中找到插件文件。例如:
mysql> `SELECT @@plugin_dir;`
+------------------------------+
| @@plugin_dir |
+------------------------------+
| /usr/local/mysql/lib/plugin/ |
+------------------------------+
要查看已安装的插件,请使用SHOW PLUGINS或查询INFORMATION_SCHEMA PLUGINS表。
注意
一些插件是内置的,无需显式启用,并且无法禁用。mysql_native_password和sha256_password身份验证插件属于此类别。
服务器启动时的插件控制
为了仅在给定服务器调用期间安装插件,请在服务器启动时使用--plugin-load-add选项,指定包含插件的文件名。如果选项值命名多个插件,请用分号分隔它们。或者,多次使用该选项,每次指定一个插件。这样可以通过使用#字符有选择性地注释相应的行来轻松启用或禁用单个插件:
[mysqld]
plugin-load-add=caching_sha2_password.so
plugin-load-add=adt_null.so
#plugin-load-add=semisync_master.so
#plugin-load-add=semisync_slave.so
--plugin-load-add选项在 MySQL 5.6 中引入。在 MySQL 8.0 中,您可以使用单个--plugin-load选项,该选项列出要加载的所有插件的分号分隔列表:
[mysqld]
plugin-load=validate_password.so;caching_sha2_password.so
显然,对于处理多个插件,使用--plugin-load-add更便于管理。
运行时的插件控制
要在运行时安装插件并使其持久化,请使用INSTALL PLUGIN。服务器加载插件(立即可用),并在mysql.plugin系统表中注册它,以使其在后续重启时自动加载。例如:
INSTALL PLUGIN caching_sha2_password SONAME 'caching_sha2_password.so';
SONAME(共享对象名称
)子句指定包含插件的文件。
要在运行时禁用插件,请使用UNINSTALL PLUGIN。服务器卸载插件并从mysql.plugin表中删除其注册:
UNINSTALL PLUGIN caching_sha2_password;
INSTALL PLUGIN和UNINSTALL PLUGIN需要分别对mysql.plugin表具有INSERT和DELETE权限。
22.3 控制服务器日志记录
问题
您希望利用服务器提供的日志信息。
解决方案
了解控制日志记录的服务器选项。
讨论
MySQL 服务器可以生成多个日志:
错误日志
错误日志包含服务器遇到的问题或异常条件的信息。这是调试的有用信息。特别是,如果服务器退出,请检查错误日志查找原因。例如,如果在启动后立即发生退出,则可能是服务器选项文件中某些设置拼写错误或设置为无效值。错误日志将包含相关消息。
一般查询日志
一般查询日志显示每个客户端连接和断开连接的时间,以及它执行的 SQL 语句。这告诉您每个客户端参与的活动量和活动内容。
慢查询日志
慢查询日志记录执行时间较长的语句(请参阅MySQL 参考手册了解“较长时间”的含义,因为它可能受到几个选项的影响)。在此日志中重复出现的查询可能是值得调查的瓶颈,以查看是否可以使其更有效率。
二进制日志
二进制日志包含服务器进行的数据更改记录。要设置复制,必须在源服务器上启用二进制日志:它作为将更改发送到副本服务器的存储介质。在数据恢复操作期间,二进制日志与备份文件一起使用。
每个日志都有不同的用途,大多数可以根据需要打开,以满足您的管理需求。每个日志可以写入文件,有些可以写入其他目的地。错误日志可以发送到您的终端或syslog设施。一般和慢查询日志可以写入文件,也可以写入mysql数据库中的表,或者两者兼而有之。
要控制服务器日志记录,请在服务器选项文件中添加指定所需日志类型的行(有些设置也可以在运行时更改)。例如,服务器选项文件中的以下行将错误日志发送到数据目录中的err.log文件,启用将一般查询和慢查询日志写入mysql数据库中的表,并启用将二进制日志写入/var/mysql-logs目录,文件名以binlog开头:
[mysqld]
log_error=err.log
log_output=TABLE
general_log=1
slow_query_log=1
log-bin=/var/mysql-logs/binlog
对于生成日志输出文件的选项中的文件名,除非使用完整路径名指定,否则日志文件将写入数据目录下。使用完整路径名的通常原因是将日志文件写入与包含数据目录的文件系统不同的文件系统,这对于在物理设备之间分割磁盘空间使用和 I/O 活动是一种有用的技术。
本节的其余部分提供了控制各个日志的具体细节。示例显示了在服务器选项文件中包含的行,以生成特定的日志记录行为。关于使用日志进行诊断或活动评估的一些想法,请参见 Recipe 22.6。
警告
对于启用的任何日志,请参见 Recipe 22.4 和 Recipe 22.5 获取日志维护技术。随着时间的推移,日志大小会增加,因此您需要有一个管理计划。
错误日志
错误日志无法禁用,但您可以控制其写入位置。默认情况下,在 Unix 上,错误输出转到您的终端,或者如果使用mysqld_safe启动服务器,则在数据目录中为host_name**.err。在 Windows 上,默认为数据目录中的host_name**.err。要指定错误日志文件名,请设置log_error系统变量。
示例:
-
将错误日志写入数据目录中的err.log文件:
[mysqld] log_error=err.log -
自 MySQL 5.7.2 起,您可以通过设置
log_error_verbosity系统变量来影响错误日志输出的数量。允许的值范围从 1(仅错误)到 3(错误、警告、注释;默认)。要仅查看错误,请执行以下操作:[mysqld] log_error=err.log log_error_verbosity=1 -
在 Unix 上,如果使用mysqld_safe启动服务器,则可以将错误日志重定向到
syslog设施:[mysqld_safe] syslog
一般查询和慢查询日志
几个系统变量控制一般查询和慢查询日志。每个变量可以在服务器启动时设置或在运行时更改:
-
log_output控制日志目标。其值为FILE(日志写入文件,默认)、TABLE(日志写入表)、NONE(禁用日志记录),或者以逗号分隔的值的组合,顺序任意。如果值为NONE,则其他这些日志的设置无效。目标控制适用于一般查询和慢查询日志;您不能将一个写入文件,另一个写入表。 -
general_log和slow_query_log启用或禁用各自的日志。默认情况下,每个日志都被禁用。如果启用其中任何一个,则服务器将日志写入由log_output指定的目标,除非该变量为NONE。 -
general_log_file和slow_query_log_file指定日志文件名。默认名称为host_name**.log和host_name**-slow.log;但是,除非log_output指定FILE记录,否则这些设置无效。
示例:
-
将一般查询日志写入数据目录中的query.log文件:
[mysqld] log_output=FILE general_log=1 general_log_file=query.log -
将一般查询和慢查询日志写入
mysql数据库中的表(表名为general_log和slow_log,且不能更改):[mysqld] log_output=TABLE general_log=1 slow_query_log=1 -
将一般查询日志写入名为query.log的文件和
general_log表:[mysqld] log_output=FILE,TABLE general_log=1 general_log_file=query.log
二进制日志
在 MySQL 8 之前,默认情况下禁用了二进制日志记录。要启用二进制日志,请使用--log-bin选项,可选择性地指定日志文件的基本名称作为选项值。在 MySQL 8.0 中,要禁用二进制日志记录,可以使用--skip-log-bin选项或--disable-log-bin选项。默认基本名称是binlog。此选项的值为基本名称,因为服务器会自动为二进制日志文件添加编号顺序后缀,例如.000001,.000002等。服务器在启动时、刷新日志时以及当前文件达到最大日志文件大小(由max_binlog_size系统变量控制)时,会切换到序列中的下一个文件。在 MySQL 8.0 中,expire_logs_days已被弃用,并替换为binlog_expire_logs_seconds。要让服务器为您过期日志文件,请将binlog_expire_logs_seconds系统变量设置为文件变为可删除状态的秒数。binlog_expire_logs_seconds的默认值为 30 天(30246060 秒)。要禁用二进制日志的自动清除,请将binlog_expire_logs_seconds*设置为 0。
例子:
-
启用二进制日志,在数据目录中写入以binlog开头的编号文件。此外,一周后过期日志文件:
[mysqld] max_binlog_size=4G binlog_expire_logs_seconds=604800
二进制日志是 MySQL 服务器的重要组件,管理员需要小心处理。二进制日志包含所有数据更改事件,因此用于以下领域。
-
复制设置
-
时间点恢复
-
调试特定事件
22.4 旋转或过期日志文件
问题
除非管理,否则用于日志记录的文件会无限增长。
解决方案
管理日志文件的可用策略包括通过一组名称旋转日志文件和按年龄删除文件。但不同的策略适用于不同类型的日志,因此在选择策略之前请考虑日志类型。
讨论
日志文件旋转是一种通过一系列一个或多个名称重命名日志文件的技术。这保留文件一定数量的旋转,到达序列末尾时,文件内容通过被覆盖来丢弃。旋转可应用于错误日志、常规查询日志或慢查询日志。
日志文件到期会在文件达到一定年龄时移除文件。此技术适用于二进制日志。
两种日志管理方法都依赖于日志刷新,以确保当前日志文件已正确关闭。刷新日志时,服务器关闭并重新打开正在写入的文件。如果首先重命名错误、常规查询或慢查询日志文件,服务器将关闭当前文件并使用原始名称重新打开一个新文件;这就是在服务器运行时启用当前文件旋转的方式。服务器还会关闭当前二进制日志文件,并打开序列中下一个编号的新文件。
要刷新服务器日志,请执行FLUSH LOGS语句或使用mysqladmin flush-logs命令。(日志刷新需要RELOAD权限。)以下讨论显示了在命令行执行的维护操作,因此使用了mysqladmin。示例中使用mv作为文件重命名命令,在 Unix 上适用。在 Windows 上,请改用rename。
旋转错误日志、通用查询日志或慢查询日志
要在日志轮换中保持单个文件,请重命名当前的日志文件并刷新日志。假设错误日志文件在数据目录中命名为err.log。要进行轮换,请切换到数据目录,然后执行以下命令:
$ `mv err.log err.log.old`
$ `mysqladmin flush-logs`
当你刷新日志时,服务器会打开一个新的err.log文件。您可以随意删除err.log.old。要保留存档副本,请在删除它之前将其包含在文件系统备份中。
要维护一组多个旋转文件,可以使用一系列编号后缀很方便。例如,要维护一组三个旧的通用查询日志文件,请执行以下操作:
$ `mv query.log.2 query.log.3`
$ `mv query.log.1 query.log.2`
$ `mv query.log query.log.1`
$ `mysqladmin flush-logs`
初次执行命令序列时,只有在相应的query.log.**N文件存在时才不需要初始命令。
连续执行该命令序列将通过名称query.log.1、query.log.2和query.log.3轮换query.log;然后query.log.3将被覆盖并且其内容丢失。要保留存档副本,请在删除它们之前将轮换的文件包括在文件系统备份中。
旋转二进制日志
服务器按编号顺序创建二进制日志文件。要使其过期,只需安排在文件够旧时删除它们。几个因素影响服务器创建和维护的文件数量:
-
服务器重启和日志刷新操作的频率:每次发生其中一种情况时都会生成一个新文件。
-
文件可以增长到的大小:较大的大小导致较少的文件。要控制此大小,请设置
max_binlog_size系统变量。 -
允许旧文件变得多老:较长的过期时间导致更多的文件。要控制此年龄,请设置
binlog_expire_logs_seconds系统变量。服务器在启动时和打开新的二进制日志文件时进行过期检查。
以下设置启用二进制日志,设置最大文件大小为 4GB,并在四天后过期文件:
[mysqld]
log-bin=binlog
max_binlog_size=4G
binlog_expire_logs_seconds=4
你还可以通过PURGE BINARY LOGS语句手动删除二进制日志文件。例如,要删除直到包括名为binlog.001028的文件为止的所有文件,请执行以下操作:
PURGE BINARY LOGS TO 'binlog.001028';
如果你的服务器是复制源,请不要过于急于删除二进制日志文件。在确定其内容已完全传输到所有副本之前,不应删除任何文件。
自动化日志文件轮换
为了更容易执行旋转操作,将实施操作的命令放入文件以创建一个 shell 脚本。要自动执行旋转操作,请安排在作业调度程序(如 cron)中执行该脚本。该脚本将需要访问连接参数,使其能够连接到服务器以刷新日志,使用具有 RELOAD 特权的帐户。一个策略是将参数放入选项文件,并通过 --defaults-file=file_name 选项将文件传递给 mysqladmin。例如:
#!/bin/sh
mv err.log err.log.old
mysqladmin --defaults-file=/usr/local/mysql/data/flush-opts.cnf flush-logs
22.5 旋转日志表或过期日志表行
问题
用于日志记录的表会无限增长,除非进行管理。
解决方案
旋转表或在其中过期行。
讨论
Recipe 22.4 讨论了日志文件的轮换和过期。类似的技术也适用于日志表:
-
要旋转日志表,请重命名它并打开一个新表,以使用原始名称。
-
要过期日志表中的内容,请删除超过一定年龄的行。
这里的示例演示了如何使用通用查询日志表 mysql.general_log 实施这些方法。相同的方法适用于慢查询日志表 mysql.slow_log 或任何包含具有时间戳的行的其他表。
要使用日志表轮换,创建原始表的空副本作为新表(参见 Recipe 6.1),然后重命名原始表并重命名新表以取代原始表:
DROP TABLE IF EXISTS mysql.general_log_old, mysql.general_log_new;
CREATE TABLE mysql.general_log_new LIKE mysql.general_log;
RENAME TABLE mysql.general_log TO mysql.general_log_old,
mysql.general_log_new TO mysql.general_log;
要使用日志行过期,可以完全清空表或选择性地清空:
-
要完全清空日志表,请将其截断:
TRUNCATE TABLE mysql.general_log; -
要选择性地过期表,仅删除早于给定年龄的行,必须知道指示行创建时间的列名:
DELETE FROM mysql.general_log WHERE event_time < NOW() - INTERVAL 1 WEEK;
对于自动过期,可以在计划事件中执行上述任何技术的语句(参见 Recipe 11.5)。例如:
CREATE EVENT expire_general_log
ON SCHEDULE EVERY 1 WEEK
DO DELETE FROM mysql.general_log
WHERE event_time < NOW() - INTERVAL 1 WEEK;
22.6 配置存储引擎
问题
您希望确保所选择的引擎已正确配置。
解决方案
根据用例理解和配置每个存储引擎。
讨论
MySQL 默认提供几种存储引擎,例如 MyISAM 和 InnoDB。从 MySQL 8.0 开始,InnoDB 作为默认数据库引擎。除了这种流行的存储引擎,您可能还想探索其他一些。每个存储引擎都将使用来自操作系统的共享资源以及专用资源。在混合使用时,必须注意不要提供太多资源。
-
InnoDB:支持事务和行级锁定,具有完全的 ACID 兼容性引擎。
-
MyISAM:表级锁定和简单引擎。
-
MyRocks:基于 LSM 的 B 树键/值存储引擎。^(1)
-
CSV:逗号分隔值引擎。
-
Blackhole:所有写入都发送到 /dev/null 无数据存储引擎。
-
内存:为内存工作负载存储引擎优化。
-
Archive:用于存档数据的只写引擎,以压缩格式存储引擎。
警告
同时使用多个存储引擎可能会引发问题,并可能导致数据丢失,如果在同一事务中使用时请注意应用程序及其周围工具的兼容性。
由于每个引擎都以不同方式存储数据,我们必须相应地配置它们。InnoDB 使用重做日志和撤销日志空间来存储修改后的数据。这允许在硬件或服务器故障时最小化数据丢失的情况下进行恢复和时间点还原。MyRocks 是另一种先进的存储引擎,首先写入恢复日志 Write Ahead Log (WAL),并支持每个事务的回滚。MyISAM 和 CSV 类型存储引擎直接写入数据文件。虽然二进制备份和传输更容易,但这些引擎不支持回滚操作。
要在 MySQL 8.0 中检查默认存储引擎:
mysql> `SELECT @@default_storage_engine;`
+--------------------------+
| @@default_storage_engine |
+--------------------------+
| InnoDB |
+--------------------------+
通过检查模式定义,我们可以看到表存储引擎类型。
mysql> `SHOW CREATE TABLE limbs\G`
Table: limbs
Create Table: CREATE TABLE `limbs` (
`thing` varchar(20) DEFAULT NULL,
`legs` int DEFAULT NULL,
`arms` int DEFAULT NULL,
PRIMARY KEY(thing)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci
如果我们想在创建表后更改存储引擎类型,可以发出ALTER语句。
mysql> `ALTER TABLE cookbook.limbs ENGINE=MYISAM;`
Query OK, 11 rows affected (0.16 sec)
Records: 11 Duplicates: 0 Warnings: 0
mysql> SHOW CREATE TABLE limbs\G
*************************** 1\. row ***************************
Table: limbs
Create Table: CREATE TABLE `limbs` (
`thing` varchar(20) DEFAULT NULL,
`legs` int DEFAULT NULL,
`arms` int DEFAULT NULL,
PRIMARY KEY(thing)
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4
COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
注意
虽然可以在创建表并加载数据后在存储引擎之间进行切换,ALTER TABLE 操作会为所有存储引擎锁定。对于大型数据集,请考虑使用在线模式更改工具,例如pt-online-schema-change 或 gh-ost。这些工具允许模式迁移完成而不创建元数据锁,并以受控方式应用更改。
可以按以下方式检查其他存储引擎设置:
mysql> `SHOW GLOBAL VARIABLES LIKE "%engine%" ;`
+---------------------------------+---------------+
| Variable_name | Value |
+---------------------------------+---------------+
| default_storage_engine | InnoDB |
| default_tmp_storage_engine | InnoDB |
| disabled_storage_engines | |
| internal_tmp_mem_storage_engine | TempTable |
| secondary_engine_cost_threshold | 100000.000000 |
+---------------------------------+---------------+
^(1) 通过 Percona 和 MariaDB 设计的第三方存储引擎,旨在处理具有节省空间优势的写入密集型工作负载。
第二十三章:监控 MySQL 服务器
23.0 简介
本章介绍如何使用各种命令行工具监控 MySQL 服务器:
-
mysqladmin 接口
-
系统变量
-
状态变量
-
信息和性能模式
-
存储引擎诊断
-
日志文件
本章不涵盖管理任务。相反,它专注于服务器的可观察性。管理员或开发人员在采取行动和修改 第二十二章 中列出的配置更改之前,应仔细评估 MySQL 服务器上各种命令行工具的结果。它讨论了您可以了解的内容及其调查类型信息以及如何使用该信息来回答问题。其目的不是考虑特定的监控问题,而是展示您的选择,以便您开始回答您的问题,无论是什么情况。在响应性监控问题的情况下,请选择以下其中一种选项。
-
确定哪些可用的信息源与手头的问题相关。
-
选择一种使用信息的方法:您是否提出了一个一次性的问题?如果是这样,也许几个交互式查询就足够了。如果您正在尝试解决可能会重复出现或需要持续监控的问题,最好采用面向程序的方法。一个完全使用 SQL 编写的脚本能否完成任务,或者您是否需要编写一个查询服务器并对获取的信息进行额外处理的程序?(这在纯 SQL 无法完成的操作,具有特殊的输出格式要求等情况下很典型。)如果一个任务必须定期运行,也许您需要设置一个定期事件或 cron 作业。对于浏览器显示,请编写一个 Web 脚本。
注意
这里展示的一些技术需要管理员访问权限,例如访问操作系统中的 MySQL 日志文件或使用需要 SUPER 特权的语句。因此,为了执行这里描述的操作,您可能需要连接到服务器作为 root 用户,而不是 cbuser 用户,或者授予 cbuser SUPER 权限。MySQL 安装创建了一个名为 ‘root'@'localhost’ 的超级用户账户,该账户作为数据库用户拥有所有权限。
23.1 为什么要监控 MySQL 服务器?
问题
您希望监视服务器以捕获其状态,从而验证或更改在 第二十二章 中解释的设置。了解 MySQL 服务器的等待事件和状态计数器的状态可以提供大量关于服务器限制的信息。等待事件是服务器的性能指标。监控可以在两个不同的领域中使用。监控需求最常见的原因是故障排除错误、崩溃或故障。其他原因可能包括更好地利用用于内存、I/O 子系统、CPU 利用率和网络带宽的可用资源的硬件层。由于硬件限制,MySQL 可能会在性能上遭受显著降级,因此硬件在数据库操作中起着重要作用。
解决方案
要监视 MySQL 服务器,请使用 MySQL 客户端的内置功能和其他内置工具(如 mysaladmin)。
讨论
当您的 MySQL 服务器运行时,您希望了解底层硬件是否满足您的需求。
操作系统
在进入特定于 MySQL 的监控和故障排除之前,建议根据需要验证操作系统(OS)的关键状态。在四个主要类别内存、输入/输出(I/O)、CPU 和网络资源可以被视为 MySQL 操作行为的主要影响因素。
内存利用
mysqld 的内存利用情况可以通过操作系统命令行检查。每个服务器都应有专用的 MySQL 主机,因此没有竞争操作系统资源,包括内存。经验法则是为专用 MySQL 服务器分配高达 %80 的内存,但您必须检查您的工作负载和数据大小以计算所需的内存。
$ `sudo pmap $(pidof mysqld) |grep total`
total 1292476K
您可以通过 sys 模式使用 mysql 客户端来确认这一点。
mysql> `USE sys`
Reading table information for completion of table and column names
You can turn off this feature to get a quicker startup with -A
Database changed
mysql> `SELECT * FROM memory_global_total;`
+-----------------+
| total_allocated |
+-----------------+
| 476.81 MiB |
+-----------------+
1 row in set (0.00 sec)
还要注意虚拟内存的利用情况,并确保主机操作系统首先不进行交换。
$ `free -m`
total used free shared buff/cache available
Mem: 1993 453 755 5 784 1382
Swap: 979 0 979
$ `cat /proc/$(pidof mysqld)/status | grep Swap`
VmSwap: 0 kB
关于内存利用的以下操作系统配置对 MySQL 的内存分配至关重要。确保已相应配置这些内容。
swappiness
这是一个允许物理内存移动到交换区的概念,由内核执行。建议将此值设置为 1(一个),以便内核执行最少量的交换。
$ `sudo sysctl vm.swappiness=1`
vm.swappiness = 1
NUMA
这是一个在多个 CPU 核心可用时支持启用 NUMA 交错模式的概念。此值默认为关闭。通过启用 NUMA 交错模式,操作系统在多个 CPU 核心之间平衡分配内存,以实现更好的利用。
mysql> `SHOW GLOBAL VARIABLES LIKE "innodb_numa_interleave";`
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| innodb_numa_interleave | ON |
+------------------------+-------+
1 row in set (0.00 sec)
OOM killer
在 Linux 系统中,MySQL 通常有一个由内核控制的内存杀手概念,即防止操作系统中可能出现的暴走进程,以避免竞争条件和服务器崩溃。由于 MySQL 及其优化的内存缓冲区是内存占用量巨大的,操作系统可能会经常杀死 mysqld 进程,以避免系统范围的崩溃,如果未进行调整。正如我们之前提到的,我们可以控制 MySQL 从操作系统分配多少内存。但是,如果发生内存溢出,可能可以在系统级别配置或完全禁用(不建议)。
$ `pidof mysqld`
25046
$ `sudo cat /proc/25046/oom_score`
34
$ `sudo echo -100 > /proc/24633/oom_score_adj`
$ `sudo cat /proc/24633/oom_score`
0
文件系统缓存
操作系统将为所有内存操作使用缓存,而 MySQL 则具有自己优化的缓存,包括 InnoDB 缓冲池。由于没有必要对数据进行两次缓存,我们通过将 innodb_flush_method 设置为 O_DIRECT 来选择不使用文件系统缓存,并且其值需要在启动时进行更改。
警告
虽然 O_DIRECT 刷新方法适用于大多数安装,但并不适用于所有存储子系统。在设置此值之前,您可能需要进行测试。
mysql> `SHOW GLOBAL VARIABLES LIKE "innodb_flush_method";`
+---------------------+-------+
| Variable_name | Value |
+---------------------+-------+
| innodb_flush_method | fsync |
+---------------------+-------+
1 row in set (0.00 sec)
I/O 利用率
I/O 性能对于 MySQL 数据库至关重要。数据从磁盘读取并写回会导致 I/O 操作。根据可用缓冲区的大小,所有在缓冲区内处理的数据最终都会被刷新到磁盘,这在数据传输方面是非常昂贵的操作。尽管数据在最佳情况下被缓存,也必须定期刷新到磁盘上。此外,无法完全存入内存的大数据集必须从磁盘读取。在现代硬件中,我们通过固态硬盘(SSD)获得了更好的性能,但了解底层瓶颈的位置仍然是有益的。您可以使用 iotop 观察系统上每个进程的 I/O 影响,因此可以深入了解每个操作的方法。
注意
你可以交互地使用 iotop 工具来监视 I/O 操作。在这个例子中,我们看到了一个 MySQL 线程的磁盘活动。
$ `sudo iotop --only`
Total DISK READ : 2.93 M/s | Total DISK WRITE : 9.24 M/s
Actual DISK READ: 2.93 M/s | Actual DISK WRITE: 12.01 M/s
TID PRIO USER DISK READ DISK WRITE SWAPIN IO> COMMAND
10692 be/4 vagrant 0.00 B/s 0.00 B/s 0.00 % 56.11 % mysqld --defaults-file=/home/sandboxes
~-socket=/tmp/mysql_sandbox8021.sock --port=8021
10684 be/4 vagrant 0.00 B/s 0.00 B/s 0.00 % 53.33 % mysqld --defaults-file=/home/sandboxes
~-socket=/tmp/mysql_sandbox8021.sock --port=8021
10688 be/4 vagrant 0.00 B/s 6.96 M/s 0.00 % 30.12 % mysqld --defaults-file=/home/sandboxes
~-socket=/tmp/mysql_sandbox8021.sock --port=8021
10685 be/4 vagrant 0.00 B/s 0.00 B/s 0.00 % 26.89 % mysqld --defaults-file=/home/sandboxes
~-socket=/tmp/mysql_sandbox8021.sock --port=8021
...
与此同时,我们可以从 MySQL 命令行界面检查进程列表,看看哪些线程优先级较高:
mysql> `SELECT THREAD_OS_ID, PROCESSLIST_ID, PROCESSLIST_USER,`
-> `PROCESSLIST_DB, PROCESSLIST_COMMAND`
-> `FROM performance_schema.threads WHERE PROCESSLIST_COMMAND IS NOT NULL;`
+-------+-----+----------+--------------------+---------+
| TOSID | PID | PUSR | PDB | PCMD |
+-------+-----+----------+--------------------+---------+
| 1964 | 5 | NULL | NULL | Sleep |
| 1968 | 7 | NULL | NULL | Daemon |
| 1971 | 8 | msandbox | performance_schema | Query |
| 2003 | 9 | root | test | Execute |
| 2002 | 10 | root | test | Execute |
| 2004 | 11 | root | test | Execute |
| 2001 | 12 | root | test | Execute |
| 2000 | 13 | root | test | Execute |
+-------+-----+----------+--------------------+---------+
8 rows in set (0.00 sec)
我们还可以精确地确定进程 ID,以获取关于此查询的详细信息。
mysql> `EXPLAIN FOR CONNECTION 10\G`
*************************** 1\. row ***************************
id: 1
select_type: INSERT
table: sbtest25
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: NULL
filtered: NULL
Extra: NULL
1 row in set (0.00 sec)
我们还可以通过查询 'table_io_waits_summary_by_table' 从 performance_schema 中收集关于此线程的更多信息,该表汇总了所有表 I/O 等待事件,由 wait/io/table/sql/handler 仪表生成。
table_io_waits_summary_by_table 表具有以下列,用于指示表聚合事件的方式:OBJECT_TYPE、OBJECT_SCHEMA 和 OBJECT_NAME。这些列的含义与 events_waits_current 表中相同。它们标识适用于哪个表的行。此表还包含有关以下组的信息:
COUNT_*
从此表中请求读取/写入/等待的用户有多少次。
SUM_*
此表的总读/写请求数有多少次。
MIN_*/MAX_*/AVG_*
此表的最小、最大和平均值。
mysql> `SELECT * FROM performance_schema.table_io_waits_summary_by_table`
-> `WHERE object_schema='test' AND object_name='sbtest25'\G`
*************************** 1\. row ***************************
OBJECT_TYPE: TABLE
OBJECT_SCHEMA: test
OBJECT_NAME: sbtest25
COUNT_STAR: 3200367
SUM_TIMER_WAIT: 1970633326256
MIN_TIMER_WAIT: 1505980
AVG_TIMER_WAIT: 615412
MAX_TIMER_WAIT: 2759234856
COUNT_READ: 3200367
SUM_TIMER_READ: 1970633326256
MIN_TIMER_READ: 1505980
AVG_TIMER_READ: 615412
MAX_TIMER_READ: 2759234856
COUNT_WRITE: 0
SUM_TIMER_WRITE: 0
MIN_TIMER_WRITE: 0
AVG_TIMER_WRITE: 0
MAX_TIMER_WRITE: 0
COUNT_FETCH: 3200367
SUM_TIMER_FETCH: 1970633326256
MIN_TIMER_FETCH: 1505980
AVG_TIMER_FETCH: 615412
MAX_TIMER_FETCH: 2759234856
COUNT_INSERT: 0
...
此表还被sys模式中的schema_table_statistics%视图使用。 (有关详细信息,请参阅table_io_waits_summary_by_table的文档)
mysql> `SELECT * FROM sys.schema_table_statistics`
-> `WHERE table_schema="test" AND table_name="sbtest23"\G`
*************************** 1\. row ***************************
table_schema: test
table_name: sbtest23
total_latency: 14.46 s
rows_fetched: 8389964
fetch_latency: 14.46 s
rows_inserted: 0
insert_latency: 0 ps
rows_updated: 0
update_latency: 0 ps
rows_deleted: 0
delete_latency: 0 ps
io_read_requests: 3006
io_read: 46.97 MiB
io_read_latency: 19.48 ms
io_write_requests: 737
io_write: 11.61 MiB
io_write_latency: 21.09 ms
io_misc_requests: 284
io_misc_latency: 1.72 s
1 row in set (0.01 sec)
网络利用率
网络也是数据库配置中非常重要的一部分。通常,测试和开发系统在本地配置上运行,省略了节点之间的网络跳跃。如果 MySQL 运行在专用主机上,所有对数据库的请求将通过应用程序层或代理服务器进行。由于监控需要持续的数据流,最好使用具有至少 30 天历史数据的时间序列工具来进行分析。为此,我们强烈推荐使用Percona 监控与管理工具 (PMM)来监控网络利用率,如图 23-1 所示。

图 23-1. Percona 监控与管理 - MySQL 实例摘要
23.2 发现 MySQL 监控信息的来源
问题
您希望检查服务器在可用资源下的运行情况。
解决方案
让服务器使用内置实用程序告诉您自己的情况。
讨论
当 MySQL 服务器运行时,您可能会对其操作或性能的某些方面有疑问。或者它可能未在运行,您想知道原因。
要了解可用的信息源,以便评估其适用性及其对特定问题的可用性,以下是几个内置实用程序和信息资源可供查看:
-
系统变量告诉您服务器的配置方式。(Recipe 22.1 详细介绍了如何检查这些值。)
-
状态变量提供有关服务器正在执行的操作的信息,例如执行的语句数量,磁盘访问次数,内存使用情况或缓存效率。状态信息可以帮助指示何时需要进行配置更改,例如增加过小的缓冲区大小以提高性能,或减少少用资源的大小以减少服务器的内存占用。
-
性能模式专为监控而设计,并提供丰富的测量数据,从高级信息(例如连接的客户端)到细粒度信息(例如语句持有的锁或打开的文件)。性能模式自 MySQL 5.7 起默认启用。在先前版本中使用性能模式,必须启用它。要在服务器启动时显式启用它,请使用以下配置设置:
[mysqld] performance_schema=1 -
由于性能模式专注于 MySQL 服务器的性能数据,并可以用于类似于高度特定或复杂查询,包括联接。它还有助于在运行时澄清一切mysql> `SELECT EVENT_NAME, COUNT_STAR` -> `FROM performance_schema.events_waits_summary_global_by_event_name` -> `ORDER BY COUNT_STAR DESC LIMIT 10;` +---------------------------------------+------------+ | EVENT_NAME | COUNT_STAR | +---------------------------------------+------------+ | wait/io/file/innodb/innodb_log_file | 6439 | | wait/io/file/innodb/innodb_data_file | 5994 | | idle | 5309 | | wait/io/table/sql/handler | 3263 | | wait/io/file/innodb/innodb_dblwr_file | 1356 | | wait/io/file/sql/binlog | 798 | | wait/lock/table/sql/handler | 683 | | wait/io/file/innodb/innodb_temp_file | 471 | | wait/io/file/sql/io_cache | 203 | | wait/io/file/sql/binlog_index | 75 | +---------------------------------------+------------+ 10 rows in set (0.16 sec) -
Sys schema 是一个独特的模式,不包含物理表,而是 Performance Schema 表上的视图和存储过程。 Performance Schema 提供了内存仪表化信息,通过 SYS 模式中的视图可以更轻松地访问。对于内存使用情况,使用 SYS 模式要容易得多;因此,我们建议使用提供内存分配详细信息的五个视图。
mysql> `SHOW TABLES like "memory%";` +-----------------------------------+ | Tables_in_sys (memory%) | +-----------------------------------+ | memory_by_host_by_current_bytes | | memory_by_thread_by_current_bytes | | memory_by_user_by_current_bytes | | memory_global_by_current_bytes | | memory_global_total | +-----------------------------------+ 5 rows in set (0.00 sec) -
PERFORMANCE_SCHEMA数据库中的SHOW语句和表提供的信息范围从服务器中运行的进程到活动存储引擎和插件再到系统和状态变量。在许多情况下,这两个来源提供相同或类似的信息,但显示格式不同。 (例如,SHOWPLUGINS语句和PLUGINS表相关联。)熟悉这两个来源帮助您选择在特定情况下更易于使用的那个:-
对于交互式使用,
SHOW通常比PERFORMANCE_SCHEMA查询更方便,因为输入更少。比较这两个产生相同结果的语句:SHOW GLOBAL STATUS LIKE 'Threads_connected';SELECT VARIABLE_VALUE FROM PERFORMANCE_SCHEMA.GLOBAL_STATUS WHERE VARIABLE_NAME = 'Threads_connected'; -
INFORMATION_SCHEMA查询使用SELECT,比SHOW更具表达力,可以用于非常具体或复杂的查询,包括连接。SELECT t.table_schema, t.table_name, c.column_name FROM information_schema.tables t, information_schema.columns c WHERE t.table_schema = c.table_schema AND t.table_name = c.table_name AND t.engine='InnoDB'; -
SHOW输出不能仅通过 SQL 保存。如果您需要进一步处理PERFORMANCE_SCHEMA查询结果,可以使用INSERTINTO…SELECT将结果保存到表中以供进一步分析(参见 Recipe 6.2)。要获取单个值,将标量子查询结果赋给变量:mysql> `SET` `@``queries` `=` -> `(``SELECT` `VARIABLE_VALUE` `FROM` `PERFORMANCE_SCHEMA``.``GLOBAL_STATUS` -> `WHERE` `VARIABLE_NAME` `=` `'Queries'``)``;` Query OK, 0 rows affected (0.00 sec) mysql> `SELECT` `@``queries``;` +----------+ | @queries | +----------+ | 5338 | +----------+ 1 row in set (0.00 sec)
-
-
一些存储引擎可以提供有关自身的信息。例如,InnoDB 有自己的系统和状态变量。它还提供了自己的
INFORMATION_SCHEMA表和一组 InnoDB Monitors。INFORMATION_SCHEMA表提供更结构化的信息,因此更适合使用 SQL 进行分析,如果它们包含您想要的信息。要查看哪些与 InnoDB 相关的表可用,请使用此语句:SHOW TABLES FROM INFORMATION_SCHEMA LIKE 'innodb%';Monitors 生成非结构化输出。您可以用眼睛看,但对于程序使用,必须以某种方式解析或提取信息。在某些情况下,简单的 grep 命令可能足够:
$ `mysql -E -e "SHOW ENGINE INNODB STATUS" | grep "Free buffers"` Free buffers 4733 -
服务器日志提供多种类型的信息。以下是使用它们的一些建议:
-
错误日志会警示服务器遇到的严重问题。它最适合视觉检查,因为消息可能来自服务器的任何位置,并且没有固定的格式来帮助程序化分析。通常,只有文件的最后部分是感兴趣的,因为您通常检查此文件以查找最近问题的原因。这些问题可能包括导致崩溃的损坏表,甚至与未运行 mysql_upgrade 进一步引起的问题有关。
-
一般查询日志显示客户端运行的查询。它有助于评估服务器工作负载的性质。它是唯一捕获所有内容的日志;因此在启用此日志时必须小心。根据服务器的活动情况,它可能会快速填满磁盘空间并造成非常严重的 I/O,导致在监视 MySQL 时事情变得更糟。建议根据需要在线启用,然后在之后禁用。
-
慢查询日志包含可能效率低下的查询。它可以帮助您找到优化的候选项。
服务器能够将一般查询和慢查询日志写入文件、表或两者。日志表比文件更有利于分析;它们更为结构化,因此可以使用 SQL 语句进行分析。内容也更容易解释。
general_log表中的每个查询行显示与其关联的用户。使用日志文件时,用户仅在连接行上命名。要识别用户的查询,必须从连接行中提取连接 ID,并查找具有相同 ID 的后续查询行。此外,日志表由 CSV 存储引擎管理,因此表数据文件以逗号分隔值格式写入。在服务器数据目录下的mysql目录中查找名为general_log.CSV和slow_log.CSV的文件。您可以使用读取 CSV 文件的工具处理它们。
要从日志中获取信息,必须启用它(参见 Recipe 22.3 获取指南)。
-
-
EXPLAIN语句可用于检查运行时间较长的查询。尽管通常用于查看潜在查询的执行计划,但 MySQL 5.7.2 及更高版本能够使用EXPLAIN来检查其他会话中当前执行的查询。如果查询似乎被卡住,这可能帮助您理解原因。使用SHOW PROCESSLIST或INFORMATION_SCHEMA中的PROCESSLIST表确定运行问题查询的会话连接 ID,然后指向该会话执行EXPLAIN:EXPLAIN FOR CONNECTION *`connection_id`*;EXPLAIN可以生成表格、树形或JSON格式的输出。后者可以通过您选择的编程语言中的标准 JSON 模块进行解析和操作。
23.3 检查服务器运行时间和进度
问题
您想知道服务器是否正在运行,如果是,它已经运行了多长时间。
解决方案
使用 mysqladmin 和 MySQL CLI 工具查找它是否启动。
讨论
要判断服务器是否正在运行,只需尝试连接它。如果连接成功或收到来自服务器本身的错误,服务器正在运行。mysqladmin ping在这里是一个不错的选择,可供交互使用或在 Shell 脚本中使用。这个结果表明服务器正在运行,尽管您应该通过监控系统被警告服务器已宕机:
$ `mysqladmin ping`
mysqld is alive
这次连接尝试失败,但服务器本身返回了第二个错误消息,所以它并没有宕机:
$ `mysqladmin -u baduser ping`
mysqladmin: connect to server at '127.0.0.1' failed
error: 'Access denied for user 'baduser'@'localhost' (using password: YES)'
这个结果表明完全连接失败;服务器宕机了:
$ `mysqladmin ping`
mysqladmin: connect to server at '127.0.0.1' failed
error: 'Can't connect to MySQL server on '127.0.0.1' (61)'
如果服务器没有启动,请检查错误日志找出原因。
如果服务器正在运行,可以通过多种方式确定它的运行时间(以秒为单位):
-
使用 mysqladmin
status:$ `mysqladmin status` Uptime: 22158655 Threads: 2 Questions: 65733141 Slow queries: 34 Opens: 6570 Flush tables: 1 Open tables: 95 Queries per second avg: 2.966该方法在程序使用上的一个缺点是你必须解析输出以提取感兴趣的值。
-
检查
Uptime状态变量:mysql> `SHOW` `GLOBAL` `STATUS` `LIKE` `'Uptime'``;` +---------------+---------+ | Variable_name | Value | +---------------+---------+ | Uptime | 1640724 | +---------------+---------+ 1 row in set (0.00 sec)使用内置的 CLI 命令来显示当前连接的状态
mysql> `\status` ... Uptime: 18 days 23 hours 45 min 43 sec ... --------------
显然服务器没有运行是值得关注的原因。但即使它正在运行,可能也会出现问题。如果你经常发现在没有计划重启的情况下服务器正常运行时间重置,可能有些原因导致服务器退出,你应该调查一下。再次检查错误日志以查看原因。
当你的 MySQL 服务器运行时,你可能会对其操作或性能的各个方面有疑问。或者它 没有 运行,你想知道原因。
23.4 解决服务器启动问题
问题
服务器在启动后很快退出,你想知道是什么原因导致了这一情况以及你可以采取什么措施。
解决方案
检查错误日志以获取详细信息。
讨论
如果服务器在启动后不久就停止,可能的原因是服务器选项文件中的配置错误。错误日志会在这里帮助你。但不要被纯粹的警告所误导,这并不表示服务器已经退出。例如,以下消息仅表示 innodb_ft_min_token_size 需要更正以消除警告:
2022-02-17T15:05:25.482596Z 0 [Warning] [MY-013746]
[Server] A deprecated TLS version TLSv1.1 is enabled for channel
mysql_main 2022-02-17T15:05:25.487543Z 0 [Warning] [MY-010068]
[Server] CA certificate ca.pem is self signed.
相反,检查如下所示的 [ERROR] 行:
2022-02-17T15:05:25.495461Z 0 [ERROR] [MY-000067]
[Server] unknown variable 'innodb_ft_min_toke_size=2'.
正如你所看到的,服务器抱怨有一个打字错误 innodb_ft_min_token_size,导致它无法正常启动。
其他服务器启动问题包括:
-
my.cnf 变量的配置错误。
-
多个配置文件。
-
缺少操作系统权限。
-
不正确的路径设置。
-
超配可用内存。
-
升级后版本缺少
mysql_upgrade步骤。
注意
自 8.0.16 版本起,mysql_upgrade 不再需要。但在升级到任何 8.0.16 之前的版本时,您必须运行此实用程序。
23.5 确定 MySQL 服务器的 IO 利用率
问题
你想知道击中 MySQL 服务器的查询数量。
解决方案
检查详细的利用率状态变量。
讨论
这个问题可能是简单好奇引起的,也可能是性能问题。随时间监视语句执行并总结结果可以揭示模式,比如活动笨重的某个时间或某天。也许几个报告生成器配置为同时启动。将它们错开启动将有助于通过分散负载来帮助服务器。捕获基线数据以比较给定时期的几个读取数据是至关重要的。
在编程上下文中,您可能会编写一个长时间运行的应用程序,定期探测服务器的Queries和Uptime值,以确定语句执行活动的运行情况。为了避免每次发出语句时重新连接,请询问服务器的会话超时期限,并在比该值更短的间隔内进行探测。要获取会话超时值(以秒为单位),请使用以下语句:
SELECT @@wait_timeout;
默认值是 28,800(8 小时)。如果配置的值比您期望的探测间隔短,请将其设置更高:
SET wait_timeout = *`seconds`*;
前面的讨论使用了Queries,表示执行的总语句数。还有更精细的分析选项可供选择。
服务器维护一组Com_xxx状态变量,用于计算特定语句的执行次数。例如,Com_insert和Com_update分别计数INSERT和UPDATE语句的执行次数。
mysql> `SHOW GLOBAL STATUS LIKE "Com_select";`
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Com_select | 100 |
+---------------+-------+
1 row in set (0.00 sec)
mysql> `SHOW GLOBAL STATUS LIKE "Com_insert";`
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| Com_insert | 3922 |
+---------------+-------+
1 row in set (0.00 sec)
MySQL 版本 5.7 后,information_schema中的一些仪器迁移到了performance_schema,因此建议使用performance_schema进行此类监控查询。
注意
由于 Performance Schema 具有关于事件的详细信息,不再具有Com Stats值。
mysql> `SELECT EVENT_NAME, COUNT_STAR`
-> `FROM performance_schema.events_statements_summary_global_by_event_name`
-> `WHERE EVENT_NAME LIKE 'statement/sql/%';`
+-----------------------------------------------+------------+
| EVENT_NAME | COUNT_STAR |
+-----------------------------------------------+------------+
| statement/sql/select | 106 |
...
您还可以计算 InnoDB 缓冲池缓存命中率,以回答 InnoDB 可在不访问磁盘的情况下解决多少请求的问题。
要回答这个问题,使用状态变量信息:
mysql> `SHOW GLOBAL STATUS LIKE 'innodb_buffer_pool_read%s';`
+----------------------------------+----------+
| Variable_name | Value |
+----------------------------------+----------+
| Innodb_buffer_pool_read_requests | 50350973 |
| Innodb_buffer_pool_reads | 1622447 |
+----------------------------------+----------+
2 rows in set (0.00 sec)
状态变量Innodb_buffer_pool_read_requests保存了 SQL 查询从 InnoDB 缓冲池请求数据的次数。这个值也可以理解为对 InnoDB 的查询次数。变量Innodb_buffer_pool_reads保存了解析这些查询而无需触及磁盘上表空间文件的指标。
SHOW GLOBAL STATUS计算自服务器启动以来的查询次数,但这是一个可变的值。如果您等待一定时间并重新运行相同的查询,您将得到一个命中率。
mysql> `SHOW GLOBAL STATUS LIKE 'innodb_buffer_pool_read%s';`↩
`SELECT SLEEP(60); SHOW GLOBAL STATUS LIKE 'innodb_buffer_pool_read%s';`
+----------------------------------+----------+
| Variable_name | Value |
+----------------------------------+----------+
| Innodb_buffer_pool_read_requests | 51504330 |
| Innodb_buffer_pool_reads | 1830647 |
+----------------------------------+----------+
2 rows in set (0.00 sec)
+-----------+
| sleep(60) |
+-----------+
| 0 |
+-----------+
1 row in set (1 min 0.00 sec)
+----------------------------------+----------+
| Variable_name | Value |
+----------------------------------+----------+
| Innodb_buffer_pool_read_requests | 53626254 |
| Innodb_buffer_pool_reads | 2214763 |
+----------------------------------+----------+
2 rows in set (0.00 sec)
在这个例子中,InnoDB 收到了53626254 - 51504330 = 2121924次数据请求,并且仅使用缓冲区解析了2214763 - 1830647 = 384116个请求。因此,InnoDB 缓冲池命中率为384116 / 2121924 = 0.18。这意味着服务器可能刚刚启动,InnoDB 缓冲池还没有包含活跃数据集,或者缓冲池过小,导致 InnoDB 经常需要清除页面并重新读取。理想情况下,InnoDB 缓冲池命中率应接近 1(一个)。
警告
如果您的 OLTP 内存工作负载,您可能会有 100%的查询在内存中。查询的概要可能会显著变化,这可能使得命中率指标不准确。仅仅监视内存操作的命中率是不足够的。
23.6 确定 MySQL 线程的 CPU 利用率
问题
您想找出导致服务器高 CPU 利用率的进程。
解决方案
使用THREAD_OS_ID值来从 Performance Schema 的THREADS表中关联。
讨论
进程的 CPU 利用率在找出由单个查询引起的缓慢时有些问题。有时这可能是一个失控的作业或运行大数据集的进程。在月末,您可能会看到这种行为,其中查询或作业仅在每月运行一次以处理季度或统计计算。threads表包含在服务器启动后创建的每个线程的信息。它包含线程是否为历史记录(如果被检测到请参见按线程预过滤)。
mysql> `DESC performance_schema.threads;`
+---------------------+------------------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+---------------------+------------------+------+-----+---------+-------+
| THREAD_ID | bigint unsigned | NO | PRI | NULL | |
| NAME | varchar(128) | NO | MUL | NULL | |
| TYPE | varchar(10) | NO | | NULL | |
| PROCESSLIST_ID | bigint unsigned | YES | MUL | NULL | |
| PROCESSLIST_USER | varchar(32) | YES | MUL | NULL | |
| PROCESSLIST_HOST | varchar(255) | YES | MUL | NULL | |
| PROCESSLIST_DB | varchar(64) | YES | | NULL | |
| PROCESSLIST_COMMAND | varchar(16) | YES | | NULL | |
| PROCESSLIST_TIME | bigint | YES | | NULL | |
| PROCESSLIST_STATE | varchar(64) | YES | | NULL | |
| PROCESSLIST_INFO | longtext | YES | | NULL | |
| PARENT_THREAD_ID | bigint unsigned | YES | | NULL | |
| ROLE | varchar(64) | YES | | NULL | |
| INSTRUMENTED | enum('YES','NO') | NO | | NULL | |
| HISTORY | enum('YES','NO') | NO | | NULL | |
| CONNECTION_TYPE | varchar(16) | YES | | NULL | |
| THREAD_OS_ID | bigint unsigned | YES | MUL | NULL | |
| RESOURCE_GROUP | varchar(64) | YES | MUL | NULL | |
+---------------------+------------------+------+-----+---------+-------+
在 Linux 系统中,THREAD_OS_ID对应于gettid()函数的值。此值暴露在top或proc文件系统(/proc/[pid]/task/[tid])中,以帮助识别相关的THREAD_OS_ID,还有通过使用内置命令行工具从proc文件系统中抓取。ps -L aux提供了与使用更高CPU的相应线程相关的足够详细信息。MySQL 的父 ID mysqld_pid也可以通过结合使用pidof mysqld和ps命令来确定:
$ `` ps -L aux |grep -e PID -e `pidof mysqld` ``
USER PID LWP %CPU NLWP %MEM VSZ RSS TTY STAT START↩
TIME COMMAND
mysql 740282 740282 0.0 68 20.9 1336272 209440 ? Rsl 2021 0:05 ↩
/usr/sbin/mysqld
mysql 740282 740285 0.0 68 20.9 1336272 209440 ? Ssl 2021 1:50 ↩
/usr/sbin/mysqld
mysql 740282 740286 0.0 68 20.9 1336272 209440 ? Ssl 2021 1:52 ↩
/usr/sbin/mysqld
mysql 740282 740287 0.0 68 20.9 1336272 209440 ? Ssl 2021 1:53 ↩
/usr/sbin/mysqld
mysql 740282 740288 0.0 68 20.9 1336272 209440 ? Ssl 2021 1:50 ↩
/usr/sbin/mysqld
....
mysql 740282 1353650 0.0 48 21.0 1336272 210456 ? Ssl 09:35 0:00 ↩
/usr/sbin/mysqld
mysql 740282 1533749 6.6 48 21.0 1336272 210456 ? Dsl 10:11 0:18 ↩
/usr/sbin/mysqld
mysql 740282 1558301 0.8 48 21.0 1336272 210456 ? Ssl 10:15 0:00 ↩
/usr/sbin/mysqld
mysql 740282 1558459 1.0 48 21.0 1336272 210456 ? Ssl 10:15 0:00 ↩
/usr/sbin/mysqld
mysql 740282 1559291 0.7 48 21.0 1336272 210456 ? Ssl 10:15 0:00 ↩
/usr/sbin/mysqld
这将为我们提供一个thread_os_id的提示,我们将用它来弄清楚它在做什么?
mysql> `SELECT * from performance_schema.threads`
-> `WHERE THREAD_OS_ID = 1533749 \G`
mysql> `SELECT * FROM performance_schema.threads where THREAD_OS_ID = 1533749 \G`
*************************** 1\. row ***************************
THREAD_ID: 213957
NAME: thread/sql/one_connection
TYPE: FOREGROUND
PROCESSLIST_ID: 213905
PROCESSLIST_USER: root
PROCESSLIST_HOST: localhost
PROCESSLIST_DB: mysqlslap
PROCESSLIST_COMMAND: Query
PROCESSLIST_TIME: 0
PROCESSLIST_STATE: waiting for handler commit
PROCESSLIST_INFO: INSERT INTO t1 VALUES (964445884,
'DPh7kD1E6f4MMQk1ioopsoIIcoD83DD8Wu7689K6oHTAjD3Hts6lYGv8x9G0EL0k87q8G2ExJ
jz2o3KhnIJBbEJYFROTpO5pNvxgyBT9nSCbNO9AiKL9QYhi0x3hL9')
PARENT_THREAD_ID: NULL
ROLE: NULL
INSTRUMENTED: YES
HISTORY: YES
CONNECTION_TYPE: Socket
THREAD_OS_ID: 1533749
RESOURCE_GROUP: USR_default
另一种选择是使用pidstat命令(需要 sysstat 包)。首先找到mysqld的进程 ID 并执行以下操作:pidstat -t -p {mysqld_pid}
$ `pidstat -t -p 740282`
Linux 5.8.0-63-generic (localhost) 01/02/2022 _x86_64_ (1 CPU)
06:57:13 PM UID TGID TID %usr %system %guest %wait %CPU CPU
06:57:14 PM 113 740282 - 24.75 11.88 0.00 0.00 36.63 0
06:57:14 PM 113 - 740282 0.00 0.00 0.00 0.00 0.00 0
06:57:14 PM 113 - 740285 0.00 0.00 0.00 0.00 0.00 0
....
06:57:19 PM 113 - 759641 0.00 0.00 0.00 0.00 0.00 0
06:57:19 PM 113 - 839592 1.00 0.00 0.00 1.00 1.00 0
06:57:19 PM 113 - 839647 17.00 4.00 0.00 14.00 21.00 0
06:57:20 PM 113 740282 - 24.00 14.00 0.00 0.00 38.00 0
06:57:20 PM 113 - 740282 0.00 0.00 0.00 0.00 0.00 0
06:57:20 PM 113 - 740285 0.00 0.00 0.00 0.00 0.00 0
我们在测试运行中可以看到一个thread_os_id从上述输出中消耗了%21 的 CPU。为了将其与 MySQL 运行的线程相关联,我们遵循性能模式查询。
mysql> `SELECT * from performance_schema.threads where THREAD_OS_ID = 839647 \G`
*************************** 1\. row ***************************
THREAD_ID: 2326
NAME: thread/sql/one_connection
TYPE: FOREGROUND
PROCESSLIST_ID: 2282
PROCESSLIST_USER: root
PROCESSLIST_HOST: localhost
PROCESSLIST_DB: mysqlslap
PROCESSLIST_COMMAND: Query
PROCESSLIST_TIME: 0
PROCESSLIST_STATE: waiting for handler commit
PROCESSLIST_INFO: INSERT INTO t1 VALUES (964445884,'DPh7kD1E6f4MMQk1ioopso
IIcoD83DD8Wu7689K6oHTAjD3Hts6lYGv8x9G0EL0k87q8G2ExJjz2o3KhnIJBbEJYFROTpO5pN
vxgyBT9nSCbNO9AiKL9QYhi0x3hL9')
PARENT_THREAD_ID: NULL
ROLE: NULL
INSTRUMENTED: YES
HISTORY: YES
CONNECTION_TYPE: Socket
THREAD_OS_ID: 839647
RESOURCE_GROUP: USR_default
参见
有关THREADS表的更多信息,请参阅threads 表。
23.7 确定 MySQL 是否已达到其连接限制
问题
您想知道 MySQL 服务器处理连接的限制
解决方案
检查配置参数。
讨论
通常,服务器函数的评估使用配置设置与当前操作状态的组合。前者通常来自系统变量,而后者来自状态变量。连接管理就是这个概念的一个例子。max_connections系统变量指示服务器允许的最大同时连接数,而Threads_connected状态变量显示当前连接的客户端数,Threads_running状态变量显示当前活动的客户端数。此外,Threads_running对于以下几个原因非常重要。
-
如果运行线程的数量增加到超过 CPU 核心数量,它们开始争夺 CPU 资源。
-
如果两个线程(无论连接了多少线程)竞争同一行、表或其他数据库对象,则在服务器级别设置引擎级表锁或元数据锁(MD)。
由于 MySQL 是单进程应用程序,具有多线程架构,每个连接创建一个线程。要监视达到的最大连接数,请执行以下命令。
mysql> `SHOW GLOBAL STATUS LIKE 'Max_used_connections';`
+----------------------+-------+
| Variable_name | Value |
+----------------------+-------+
| Max_used_connections | 6 |
+----------------------+-------+
1 row in set (0.00 sec)
mysql> `SHOW GLOBAL STATUS LIKE 'Max_used_connections_time';`
+---------------------------+---------------------+
| Variable_name | Value |
+---------------------------+---------------------+
| Max_used_connections_time | 2020-12-27 17:09:59 |
+---------------------------+---------------------+
1 row in set (0.00 sec)
mysql > `SHOW GLOBAL STATUS LIKE 'threads_connected';`
+-------------------+-------+
| Variable_name | Value |
+-------------------+-------+
| Threads_connected | 6 |
+-------------------+-------+
1 row in set (0.00 sec)
如果 threads_connected 经常接近 max_connections 的值,则可能需要增加后者的值。如果间隙总是很大,可以减少 max_connections。要进一步了解 MySQL 处理连接及其能力,请参阅MySQL 连接处理和扩展。
MySQL 的性能也会受到互斥锁(Mutex)和元数据锁在高并发环境下的影响。如上所述,在某些时候,读取线程将开始竞争数据库请求相同资源时。InnoDB 处理此问题的方式是对特定的内存资源施加排他锁,其他线程必须等待相同资源。虽然 MySQL 中的互斥操作处理这一点,所有的数据定义语言(DDL,也就是表结构更改操作)均由元数据锁处理。
23.8 确认缓冲池大小是否适当
问题
您需要了解 MySQL 服务器处理连接的限制。
解决方案
确定存储引擎内存分配。
讨论
InnoDB 存储引擎具有数据缓冲区。为了最小化物理 I/O,DBA 应确保有效利用服务器内存。InnoDB 缓冲池提高索引键查找和数据读取操作的效率;因此,大多数数据访问将在内存中进行。
要确定缓存大小,请检查相关的系统变量:
mysql> `SELECT @@innodb_buffer_pool_size;`
+---------------------------+
| @@innodb_buffer_pool_size |
+---------------------------+
| 134217728 |
+---------------------------+
1 row in set (0.00 sec)
你还可以使用 SHOW VARIABLES 或 PERFORMANCE_SCHEMA GLOBAL_VARIABLES 表。例如:
mysql> `SELECT * from performance_schema.global_variables`
-> `WHERE variable_name='innodb_buffer_pool_size';`
+-------------------------+----------------+
| VARIABLE_NAME | VARIABLE_VALUE |
+-------------------------+----------------+
| innodb_buffer_pool_size | 134217728 |
+-------------------------+----------------+
1 row in set (0.00 sec)
衡量读取比例运行情况的有效性指标是其命中率:从 InnoDB 缓冲池中满足读取请求的速率,而无需从磁盘读取数据。如果数据在缓存中,就是命中;如果不在,就是未命中。命中率是高度相关但不是保证的度量标准;因此 OLTP(在线事务处理)速率更为重要。还可以通过性能模式验证 InnoDB 缓冲池的利用情况。
mysql> `SELECT CONCAT(FORMAT(A.num * 100.0 / B.num,2),"%") BufferPoolFullPct FROM`
-> `(SELECT variable_value num FROM performance_schema.GLOBAL_STATUS`
-> `WHERE variable_name = 'Innodb_buffer_pool_pages_data') A,`
-> `(SELECT variable_value num FROM performance_schema.GLOBAL_STATUS`
-> `WHERE variable_name = 'Innodb_buffer_pool_pages_total') B;`
+-------------------+
| BufferPoolFullPct |
+-------------------+
| 23.46% |
+-------------------+
1 row in set (0.02 sec)
我们还可以使用 sys 架构确定缓冲池的内存分配。在启动时配置缓冲池以适当分配内存资源至关重要。
mysql> `SELECT * FROM sys.memory_global_by_current_bytes`
-> `WHERE event_name like 'memory/innodb_buf_buf_pool'\G`
*************************** 1\. row ***************************
event_name: memory/innodb/buf_buf_pool
current_count: 1
current_alloc: 131.00 MiB
current_avg_alloc: 131.00 MiB
high_count: 1
high_alloc: 131.00 MiB
high_avg_alloc: 131.00 MiB
1 row in set (0.00 sec)
所需的信息可以从 SHOW STATUS 或 GLOBAL_STATUS 表中获取。但是,在程序内执行查询并保存结果时,必须考虑 SHOW 语句和从 performance_schema 表中选择的差异。以下查询检索类似的信息,但是列标题在大小写和名称上有所不同,并且变量名称在大小写上也有所不同:
mysql> `SHOW GLOBAL STATUS;`
+-----------------------------------------------+-------------+
| Variable_name | Value |
+-----------------------------------------------+-------------+
| Aborted_clients | 1 |
| Aborted_connects | 6 |
…
…
为了使应用程序在变量信息来自 SHOW 还是 information_schema 时都能够保持不可知性,将变量名称强制统一为一个一致的字母大小写,并在引用变量的表达式中使用该大小写。选择任何大小写都无所谓,只要保持一致即可。以下讨论使用大写。
这是一个简单的例程(使用 Ruby),它接受数据库句柄,获取状态变量,并将它们作为值键入名称的哈希返回:
def get_status_variables(client)
vars = {}
query = "SELECT VARIABLE_NAME, VARIABLE_VALUE FROM
performance_schema.global_status"
client.query(query).each { |row| vars[row["VARIABLE_NAME"]↩
.upcase] = row["VARIABLE_VALUE"] }
return vars
end
要使用 SHOW 语句获取信息,可以用以下查询替换原来的查询:
query = "SHOW GLOBAL STATUS"
代码对变量名称应用了 upcase 方法。这样,无论例程使用 GLOBAL_STATUS 还是 SHOW 获取信息,结果的哈希都可以通过大写变量名称访问元素。
要计算命中率,请将变量哈希和读取和请求变量的名称传递给此例程:
def cache_hit_rate(vars,reads_name,requests_name)
reads = vars[reads_name].to_f
requests = vars[requests_name].to_f
hit_rate = requests == 0 ? 0 : 1 - (reads/requests)
printf " Key reads: %12d (%s)\n", reads, reads_name
printf "Key read requests: %12d (%s)\n", requests, requests_name
printf " Hit rate: %12.4f\n", hit_rate
end
现在我们准备好了。调用获取状态信息并计算命中率的例程如下:
statvars = get_status_variables(client)
cache_hit_rate(statvars,
"INNODB_BUFFER_POOL_READS",
"INNODB_BUFFER_POOL_READ_REQUESTS")
cache_hit_rate(statvars,
"KEY_READS",
"KEY_READ_REQUESTS")
运行脚本以查看服务器的命中率:
$ `hitrate.rb`
Key reads: 6280 (INNODB_BUFFER_POOL_READS)
Key read requests: 70138276 (INNODB_BUFFER_POOL_READ_REQUESTS)
Hit rate: 0.9999
Key reads: 23269 (KEY_READS)
Key read requests: 8902674 (KEY_READ_REQUESTS)
Hit rate: 0.9974
对于涉及系统变量的任务,类似于 get_status_variables() 的代码足以满足要求。这个实现使用 GLOBAL_VARIABLES 表:
def get_system_variables(client)
vars = {}
query = "SELECT VARIABLE_NAME, VARIABLE_VALUE FROM
performance_schema.global_variables"
client.query(query).each { |row| vars[row["VARIABLE_NAME"].upcase]↩
= row["VARIABLE_VALUE"] }
return vars
end
要使用 SHOW 替换查询,请使用以下查询:
query = "SHOW GLOBAL VARIABLES"
23.9 查找关于存储引擎的信息
问题
您想要针对 MySQL 的可插入存储引擎架构的特定问题进行定位。
解决方案
使用 MySQL 的 mysql 客户端直接与存储引擎交互。
讨论
现在我们准备好了。从 mysql 客户端调用 SHOW ENGINE 命令。
mysql> `help show engine`
Name: 'SHOW ENGINE'
Description:
Syntax:
SHOW ENGINE engine_name {STATUS | MUTEX}
SHOW ENGINE
SHOW ENGINE 显示有关存储引擎的操作信息。它需要 PROCESS 权限。该语句在 INNODB 方面有以下变体:
SHOW ENGINE INNODB STATUS;
SHOW ENGINE INNODB MUTEX;
第一个命令 SHOW ENGINE INNODB STATUS 显示有关 InnoDB 存储引擎的广泛信息。为了消化这些信息,可以捕获此命令的输出并通过命令行解析它。
mysql> `SHOW ENGINE INNODB STATUS\G`
*************************** 1\. row ***************************
Type: InnoDB
Name:
Status:
=====================================
2020-10-28 23:43:12 0x70000d0ae000 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 6 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 34 srv_active, 0 srv_shutdown, 768286 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
例如,可以使用相同的命令轻松地访问缓冲池信息。当您需要快速、准确地获取信息且不对正在运行的服务器造成任何影响时,这些信息非常有用。
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 137363456
Dictionary memory allocated 1539651
Buffer pool size 8191
Free buffers 6250
Database pages 1924
Old database pages 725
Modified db pages 0
Pending reads 0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 131, not young 1806
0.00 youngs/s, 0.00 non-youngs/s
Pages read 913, created 1105, written 3138
0.00 reads/s, 0.00 creates/s, 0.00 writes/s
No buffer pool page gets since the last printout
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 1924, unzip_LRU len: 0
I/O sum[0]:cur[0], unzip sum[0]:cur[0]
如果您正在监视单个事件,可以设置分页器并重复监视其值。
mysql> `PAGER` `grep` `-``i` `history`
PAGER set to 'grep -i history'
mysql> `SHOW` `ENGINE` `INNODB` `STATUS``\``G`
History list length 0
1 row in set (0.00 sec)
让我们来看看空闲系统上的 Mutex 信息。如果线程竞争资源,生成的 SHOW 语句会更长。
mysql> `SHOW ENGINE INNODB MUTEX;`
+--------+----------------------------+---------+
| Type | Name | Status |
+--------+----------------------------+---------+
| InnoDB | rwlock: fil0fil.cc:3206 | waits=4 |
| InnoDB | sum rwlock: buf0buf.cc:778 | waits=3 |
+--------+----------------------------+---------+
2 rows in set (0.00 sec)

Figure 23-2. InnoDB 架构 © 2021, Oracle Corporation and/or its affiliates. (2021). InnoDB Architecture [Figure]. https://dev.mysql.com/doc/refman/8.0/en/innodb-architecture.html
如上所示,InnoDB 包括两种类型的结构。第一部分是内存中的,第二部分是在磁盘上的。InnoDB 通过其内部内存管理协议有效地利用主机操作系统的内存。正如本章节介绍中提到的,内存利用是 MySQL 监控中的重要因素。
作为 MySQL 生态系统中最复杂和广泛采用的存储引擎,InnoDB 还配备了Components,用于进一步调试内部。虽然这是一个高级话题,但知道您可以向 MySQL 服务器添加插件是很好的。未来阅读请参考MySQL 文档。
23.10 使用错误日志文件来排查 MySQL 服务器崩溃
问题
我的应用程序报告“MySQL 服务器已断开连接”(错误 2006)。
解决方案
这是一个非常常见的错误的可能场景。它们包括:
-
OOM(内存耗尽)Killer
-
MySQL 信号
-
崩溃 bug
-
其他原因,如服务器超时、删除的系统文件等。
有时甚至错误日志也会误导进行故障排除。因此建议检查系统日志,如/var/log/messages。
讨论
错误日志是监控 MySQL 服务器状态最关键的一部分。从启动到关闭,它将记录所有事件到这个文件中。主动监控这个文件将提供关于当前和过去事件的足够信息。
注意
错误日志在 MySQL 8.0 中是可调整的,并且可以通过给定的条件来记录和过滤事件。详情请参阅MySQL 文档。
下面是监控和解决此错误的一些指针。
服务器崩溃
服务器在执行大查询时可能会断开连接。在这种情况下,客户端在长时间运行的查询期间超时。
$ ``( echo -n "SELECT '" ; for i in `seq 1 110000` ; \``
`do echo -n "1234567890" ; done ; echo -n "' a") | mysql | wc`
ERROR 2006 (HY000) at line 1: MySQL server has gone away
0 0 0
这可能是要检查的几个原因之一。通常max_allowed_packet大小对于类似上面崩溃的for循环的大查询来说太小。
$ `mysql -e "SHOW GLOBAL VARIABLES LIKE 'max_allowed_packet'"`
+--------------------+---------+
| Variable_name | Value |
+--------------------+---------+
| max_allowed_packet | 1048576 |
+--------------------+---------+
$ `mysql -e "SET GLOBAL max_allowed_packet=67108864"`
$ `mysql -e "SHOW GLOBAL VARIABLES LIKE 'max_allowed_packet'"`
+--------------------+----------+
| Variable_name | Value |
+--------------------+----------+
| max_allowed_packet | 67108864 |
+--------------------+----------+
$ ``( echo -n "SELECT '" ; for i in `seq 1 110000` ; do echo -n "1234567890" ; done ; \``
> `echo -n "' a") | mysql | wc`
2 2 1100003
服务器超时
应用程序与每个请求返回的查询结果之间的连接具有超时变量。监视的常见超时变量之一是wait_timeout。
$ `mysql -e "SHOW GLOBAL VARIABLES LIKE 'wait_timeout'"`
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| wait_timeout | 28800 |
+---------------+-------+
为了演示这一点,我们将wait_timeout值设置为非常低的 4 秒,然后重新运行相同的查询。
$ `mysql -e "SET GLOBAL wait_timeout=4"`
$ ``time ( echo -n "SELECT '" ; for i in `seq 1 1100000` ; do echo -n "1234567890" ; done ; \``
> `echo -n "' a") | mysql | wc`
ERROR 2006 (HY000) at line 1: MySQL server has gone away
0 0 0
real 0m8.062s
user 0m7.506s
sys 0m2.581s
$ mysql -e "SHOW GLOBAL VARIABLES LIKE 'wait_timeout'"
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| wait_timeout | 4 |
+---------------+-------+
23.11 慢查询日志文件
问题
使用慢查询日志识别慢查询。
解决方案
启用慢查询日志并设置阈值以过滤查询以解决问题。
讨论
MySQL 可以记录所有查询。通过调整慢查询记录的方式,可以捕获并分析所有查询。默认的慢查询日志设置为 10 秒,意味着任何超过 10 秒的查询只会在日志文件中显示。
您可以使用多个变量控制慢查询日志的行为。
mysql> `SHOW GLOBAL VARIABLES LIKE '%slow%';`
+---------------------------+---------------------------------------+
| Variable_name | Value |
+---------------------------+---------------------------------------+
| log_slow_admin_statements | OFF |
| log_slow_extra | OFF |
| log_slow_slave_statements | OFF |
| slow_launch_time | 2 |
| slow_query_log | OFF |
| slow_query_log_file | /usr/local/mysql/data/askdba-slow.log |
+---------------------------+---------------------------------------+
6 rows in set (0.01 sec)
最重要的是slow_query_log,它启用或禁用慢查询日志记录。默认情况下是OFF。
慢查询日志阈值由变量long_query_time控制。您可以从默认阈值开始调整记录的查询,然后逐步减少。最后将long_query_time设置为 0 以记录所有查询。
记录所有查询。
将long_query_time设置为 0 并运行慢查询日志是常见做法。这样您将获得所有查询性能的信息。然后,您可以运行诸如pt-query-digest或mysqldumpslow之类的程序来创建查询摘要。
要启用所有查询的记录,请将long_query_time设置为 0。
mysql> `SHOW GLOBAL VARIABLES LIKE 'long_query_time';`
+-----------------+-----------+
| Variable_name | Value |
+-----------------+-----------+
| long_query_time | 10.000000 |
+-----------------+-----------+
1 row in set (0.00 sec)
mysql> `SET GLOBAL LONG_QUERY_TIME=0;`
Query OK, 0 rows affected (0.00 sec)
mysql> `SET GLOBAL SLOW_QUERY_LOG=1;`
Query OK, 0 rows affected (0.02 sec)
现在我们已准备好测试简单查询,因为将long_query_time设置为 0 会记录所有内容。
mysql> `SELECT thing,legs,arms FROM limbs WHERE legs>=2;`
$ `sudo tail -f /usr/local/mysql/data/askdba-slow.log`
# Time: 2020-11-21T15:15:12.873279Z
# User@Host: root[root] @ localhost [127.0.0.1] Id: 326
# Query_time: 0.000239 Lock_time: 0.000098 Rows_sent: 6 Rows_examined: 11
SET timestamp=1605971712;
SELECT thing,legs,arms FROM limbs WHERE legs>=2;
在此示例中,我们可以看到Query_time非常小,这是预期的,因为表本身很小。但 MySQL 需要检查的行数(Rows_examined)比查询发送到客户端的行数(Rows_sent: 6)要多(11)。这意味着有很大可能需要优化查询。
我们可以通过运行EXPLAIN来开始优化查询。
mysql> `EXPLAIN` `SELECT` `thing``,``legs``,``arms` `FROM` `limbs` `WHERE` `legs``>``=``2``\``G`
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: limbs
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 11
filtered: 33.33
Extra: Using where
1 row in set, 1 warning (0.00 sec)
警告
将long_query_time值设置为 0 会启用对每个单独查询的记录。在繁忙的系统上,您需要小心,因为您的文件系统可能因 I/O 操作而填满或变慢。
当将long_query_time设置为 0 时,请勿使用表格记录,因为 CSV 存储引擎不适用于高并发环境,并可能影响性能。
23.12 使用一般查询日志进行监控
问题
您希望识别每个客户端所参与的活动。
解决方案
启用一般查询日志以进行调查。
讨论
MySQL 一般查询日志是 mysqld 正在执行的记录证明。通过启用此日志,管理员可以监控用户连接与 mysqld 的交互情况。
警告
通过启用一般查询日志,您指示 MySQL 服务器记录其接收到的所有查询。在繁忙的系统上,您需要小心,因为您的文件系统可能因增加的 I/O 操作而填满或变慢。
mysql> `SHOW GLOBAL VARIABLES LIKE 'general%';`
+------------------+-----------------------------------------------------+
| Variable_name | Value |
+------------------+-----------------------------------------------------+
| general_log | OFF |
| general_log_file | /home/vagrant/sandboxes/msb_8_0_21/data/vagrant.log |
+------------------+-----------------------------------------------------+
2 rows in set (0.01 sec)
若要在运行时启用general_log,请使用SET命令。
mysql> `SET GLOBAL general_log = 'ON';`
Query OK, 0 rows affected (0.00 sec)
mysql> `SHOW GLOBAL VARIABLES LIKE 'general_log';`
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| general_log | ON |
+---------------+-------+
1 row in set (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
现在我们已准备好监控所有事务。此值是动态的,并在运行时设置。如果您希望在启动时进行持久设置,请参阅第 22.1 节:
$ `tail -f /home/vagrant/sandboxes/msb_8_0_21/data/vagrant.log`
/home/vagrant/opt/mysql/8.0.21/bin/mysqld, Version: 8.0.21 (MySQL Community Server - GPL). ↩
started with:
Tcp port: 8021 Unix socket: /tmp/mysql_sandbox8021.sock
Time Id Command Argument
2020-12-06T14:27:26.739541Z 8 Query show global variables like "general_log"
2020-12-06T14:51:08.660453Z 8 Quit
连接另一个会话并在尾随一般查询日志文件时运行以下命令:
mysql> `SHOW PROCESSLIST \G`
*************************** 1\. row ***************************
Id: 5
User: event_scheduler
Host: localhost
db: NULL
Command: Daemon
Time: 2015
State: Waiting on empty queue
Info: NULL
*************************** 2\. row ***************************
Id: 10
User: msandbox
Host: localhost
db: NULL
Command: Query
Time: 0
State: starting
Info: show processlist
2 rows in set (0.00 sec)
$ `tail -f /home/vagrant/sandboxes/msb_8_0_21/data/vagrant.log`
2020-12-06T14:51:45.765019Z 10 Connect msandbox@localhost on using Socket
2020-12-06T14:51:45.765785Z 10 Query select @@version_comment limit 1
2020-12-06T14:51:45.769113Z 10 Query select USER()
2020-12-06T14:52:29.130072Z 10 Query show processlist
注意
与 MySQL 慢查询日志不同,一般查询日志不记录查询执行时间。相反,它按顺序记录每个会话的完整记录。这些信息对于调试 MySQL 崩溃或查明应用程序发送的查询很有用。
23.13 使用二进制日志来识别更改
问题
您希望跟踪给定时间段内的数据更改。
解决方案
启用二进制日志以进行调查。
讨论
MySQL 可以将所有数据更改记录到二进制日志格式中,其具有三个目的。
-
配置主副本设置。通过启用此功能,我们可以设置 MySQL 复制的拓扑结构,详见第三章
-
在进行完整备份后进行时点恢复。
-
对特定时间段内的事件进行故障排除或调查。
通过在启动时设置 --log-bin 来启用二进制日志。设置此值允许 MySQL 将数据更改追踪到一个二进制日志文件中。日志文件包括一组顺序日志文件和一个索引文件。
To read binary logs we must use verbose option `--verbose` or `-v`
by using mysqlbinlog command.
###### Note
To see statement representation of row events use option `--verbose` (`-v`).
To see metadata of columns specify `--verbose` twice: `--verbose --verbose`
or `-vv`. To suppress output of row events specify the option
`--base64-output=DECODE-ROWS`
$ /usr/bin/mysqlbinlog binlog.000003 -v |more
210208 19:39:03 服务器 ID 1 end_log_pos 517272 CRC32 0x043a9ff4 ↩
Write_rows: 表 ID 112 标志: STMT_END_F
INSERT INTO test.sbtest1
设置
@1=1
@2=21417
@3='83868641912-28773972837-60736120486-75162659906-27563526494-↩
20381887404-41576422241-93426793964-56405065102-33518432330'
@4='67847967377-48000963322-62604785301-91415491898-96926520291'
To give specific start time:
/usr/bin/mysqlbinlog --start-datetime="2020-11-29 10:50:32"
binlog.000003 -v |more
To filter specific DML type:
$ `/usr/bin/mysqlbinlog --start-datetime="2020-11-29 10:50:32" binlog.000003 \`
> `-v| grep -i -e "update" -e "insert"`
> `-e "delete" -e "drop" -e "alter" |cut -c1-100 | tr '[A-Z]' '[a-z]'`
> ``| sed -e "s/\t/ /g;s/\`//g;s/(.*$//;s/ set .*$//;s/ as .*$//"``
> `| sed -e "s/ where .*$//" | sort | uniq -c | sort -nr`
50000 ### 插入到 test.sbtest9
50000 ### 插入到 test.sbtest9
50000 ### 插入到 test.sbtest8
50000 ### 插入到 test.sbtest7
...
第二十四章:安全
24.0 简介
本章涵盖与安全相关的主题:
-
包含 MySQL 账户信息的
mysql.user表 -
管理 MySQL 用户账户的语句
-
密码强度检查和策略
-
密码过期
-
查找和移除允许从多个主机连接的匿名账户和账户
如果你愿意,可以跳过描述mysql.user表的初始部分,但我认为阅读它将有助于你更好地理解后面的部分,这些部分通常讨论 SQL 操作如何映射到该表的基础变更。
本章展示的脚本位于recipes分发的routines目录中。
注意
无论你使用 MySQL 5.7 还是 8.0 版本系列,最好使用系列内的最新版本。早期开发版本对身份验证系统的更改可能会导致与这里描述不同的结果。
提示
这里展示的许多技术需要管理访问权限,例如修改mysql系统数据库中的表或使用需要管理员权限的语句来管理 MySQL 服务器。因此,为了执行这里描述的操作,请以root身份连接到服务器,而不是cbuser。
24.1 理解 mysql.user 表
MySQL 将用户账户信息存储在mysql系统数据库的表中。user表最为重要,因为它包含账户名和凭据。要查看其结构,请使用以下语句:
SHOW CREATE TABLE mysql.user;
这里关注的user表列指定了账户名称和身份验证信息:
-
User和Host列标识账户。MySQL 账户名由用户名和主机名值的组合构成。例如,在'cbuser'@'localhost'账户的user表行中,User和Host列的值分别为cbuser和localhost。对于'myuser'@'myhost.example.com'账户,这些列分别为myuser和myhost.example.com。 -
plugin和authentication_string列存储身份验证凭据。MySQL 不会在user系统表中存储明文密码,因为那样是不安全的。相反,服务器会从密码计算哈希值并存储哈希字符串。-
plugin列指示服务器使用的身份验证插件,用于检查试图使用账户的客户端的凭据。不同的插件实现了不同加密强度的密码哈希方法。表 24-1 显示了本章讨论的插件。表 24-1 身份验证插件
插件 身份验证方法 mysql_native_password原生密码哈希 sha256_password使用 SHA-256 密码哈希(从 MySQL 5.6.6 到 MySQL 8.0) caching_sha2_password使用 SHA-256 密码哈希和服务器端缓存(MySQL 5.7 或更高版本) MySQL Enterprise,MySQL 的商业版本,包含额外的插件,用于使用 PAM 或 Windows 凭据进行身份验证。这些插件使得可以使用 MySQL 外部的密码,例如 Unix 登录密码或本机 Windows 服务。
-
authentication_string列以所需的格式表示哈希密码。例如,sha256_password使用authentication_string存储 SHA-256 密码哈希值,这比mysql_native_password插件使用的本机哈希方法更为安全。空的authentication_string值表示“无密码”,这是不安全的。
-
在 MySQL 5.7.2 之前,服务器允许 plugin 值为空。从 MySQL 5.7.2 开始,plugin 列必须非空,并且服务器将禁用任何空插件帐户,直到分配了非空插件。
24.2 管理用户帐户
问题
您负责在 MySQL 服务器上设置帐户。
解决方案
学习使用帐户管理的 SQL 语句。
讨论
可以直接使用 SQL 语句(如 INSERT 或 UPDATE)修改 mysql 数据库中的授权表,但是使用 MySQL 的帐户管理语句更为方便。本节描述了它们的使用,并涵盖了以下主题:
-
创建帐户(
CREATEUSER,SETPASSWORD,ALTER USER) -
分配和检查权限(
GRANT,REVOKE,SHOWGRANTS) -
移除和重命名帐户(
DROPUSER,RENAMEUSER)
创建帐户
要创建帐户,请使用 CREATE USER 语句,在 mysql.user 表中创建一行。但在执行此操作之前,请决定以下三件事:
-
帐户名以
'user_name'@'host_name'格式表示,命名了用户和用户将连接的主机 -
帐户密码
-
客户端尝试使用帐户时服务器应执行的身份验证插件
身份验证插件使用哈希算法加密密码以进行存储和传输。MySQL 提供了几个内置的插件供选择:
-
mysql_native_password在 8.0 版本之前实现了默认的密码哈希方法。 -
sha256_password使用 SHA-256 密码哈希值进行身份验证,这比由mysql_native_password生成的哈希在密码学上更安全。该插件从 MySQL 5.6.6 开始可用,在 8.0 版本中被其改进版本caching_sha2_password取代。它提供比mysql_native_password更高的安全性,但需要额外的设置才能使用(客户端必须使用 SSL 连接或提供 RSA 证书)。 -
caching_sha2_password类似于sha256_password,但在服务器端使用缓存以获得更好的性能。这是 MySQL 8.0 以后的默认身份验证插件。
CREATE USER 语句通常以以下形式之一使用:
CREATE USER '*`user_name`*'@'*`host_name`*' IDENTIFIED BY '*`password`*';
CREATE USER '*`user_name`*'@'*`host_name`*' IDENTIFIED WITH '*`auth_plugin`*' BY '*`auth_string`*';
第一个语法用单个语句创建账户并设置其密码。它还隐式地将认证插件分配给由--default-authentication-plugin设置指定的插件(默认为caching_sha2_password,除非在服务器启动时更改)。
要为初始没有任何权限的新账户分配权限,请使用本节后面描述的GRANT语句。
如果账户已经存在,则CREATE USER会失败。
分配和检查权限
假设您刚刚创建了一个名为'user1'@'localhost'的账户。您可以使用GRANT为其分配权限,使用REVOKE撤销其权限,并使用SHOW GRANTS检查其权限。
GRANT的语法如下:
GRANT *`privileges`* ON *`scope`* TO *`account`*;
在这里,account表示要授予权限的账户,privileges表示权限内容,scope表示权限的范围或适用级别。privileges的值可以是ALL(或ALL PRIVILEGES)以指定在给定级别上所有可用的权限,或是一个逗号分隔的一个或多个权限名称,如SELECT或CREATE。(有关可用权限和未在此处显示的GRANT语法的全面讨论,请参阅MySQL 参考手册。)
以下示例说明了在每个级别授予权限的语法。
-
在全局级别授予权限使得该账户能够执行管理操作或在任何数据库上执行操作:
GRANT FILE ON *.* TO 'user1'@'localhost'; GRANT CREATE TEMPORARY TABLES, LOCK TABLES ON *.* TO 'user1'@'localhost'; -
在数据库级别授予权限使得该账户能够在指定数据库中的对象上执行操作:
GRANT ALL ON cookbook.* TO 'user1'@'localhost'; -
在表级别授予权限使得该账户能够在指定表上执行操作:
GRANT SELECT ON mysql.user TO 'user1'@'localhost'; -
在列级别授予权限使得该账户能够在指定表列上执行操作:
GRANT SELECT(User,Host), UPDATE(password_expired) ON mysql.user TO 'user1'@'localhost'; -
在过程级别授予权限使得该账户能够在指定存储过程上执行操作:
GRANT EXECUTE ON PROCEDURE cookbook.exec_stmt TO 'user1'@'localhost';如果例程是存储函数,请使用
FUNCTION而不是PROCEDURE。
要验证权限分配,请使用SHOW GRANTS:
mysql> `SHOW GRANTS FOR 'user1'@'localhost';`
+--------------------------------------------------------------------------+
| Grants for user1@localhost |
+--------------------------------------------------------------------------+
| GRANT FILE, CREATE TEMPORARY TABLES, LOCK TABLES |
| ON *.* TO 'user1'@'localhost' |
| GRANT ALL PRIVILEGES ON `cookbook`.* TO 'user1'@'localhost' |
| GRANT SELECT, SELECT (User, Host), UPDATE (password_expired) |
| ON `mysql`.`user` TO 'user1'@'localhost' |
| GRANT EXECUTE ON PROCEDURE `cookbook`.`exec_stmt` TO 'user1'@'localhost' |
+--------------------------------------------------------------------------+
要查看自己的权限,请省略FOR子句。
REVOKE语法通常与GRANT类似,但使用FROM而不是TO:
REVOKE *`privileges`* ON *`scope`* FROM *`account`*;
因此,要撤销刚刚授予给'user1'@'localhost'的权限,请使用这些REVOKE语句(并使用SHOW GRANTS来验证它们是否已被移除):
mysql> `REVOKE FILE ON *.* FROM 'user1'@'localhost';`
mysql> `REVOKE CREATE TEMPORARY TABLES, LOCK TABLES`
-> `ON *.* FROM 'user1'@'localhost';`
mysql> `REVOKE ALL ON cookbook.* FROM 'user1'@'localhost';`
mysql> `REVOKE SELECT ON mysql.user FROM 'user1'@'localhost';`
mysql> `REVOKE SELECT(User,Host), UPDATE(password_expired)`
-> `ON mysql.user FROM 'user1'@'localhost';`
mysql> `REVOKE EXECUTE ON PROCEDURE cookbook.exec_stmt`
-> `FROM 'user1'@'localhost';`
mysql> `SHOW GRANTS FOR 'user1'@'localhost';`
+-------------------------------------------+
| Grants for user1@localhost |
+-------------------------------------------+
| GRANT USAGE ON *.* TO 'user1'@'localhost' |
+-------------------------------------------+
删除账户
要删除一个账户,请使用DROP USER语句:
DROP USER 'user1'@'localhost';
该语句从所有授权表中移除与账户关联的所有行;您无需使用REVOKE先删除其权限。如果账户不存在,则会发生错误。
重命名账户
要更改账户名称,请使用RENAME USER,指定当前名称和新名称:
RENAME USER 'currentuser'@'localhost' TO 'newuser'@'localhost';
如果当前账户不存在或新账户已经存在,则会发生错误。
24.3 实施密码策略
问题
您希望确保 MySQL 账户不使用弱密码。
解决方案
使用validate_password插件实施密码策略。新密码必须符合策略,无论是由 DBA 为新帐户选择还是由现有用户更改密码。
讨论
此技术要求启用validate_password插件。有关插件安装说明,请参见 Recipe 22.2。
当启用validate_password时,它会暴露一组系统变量,使您能够对其进行配置。这些是默认值:
mysql> `SHOW VARIABLES LIKE 'validate_password%';`
+--------------------------------------+--------+
| Variable_name | Value |
+--------------------------------------+--------+
| validate_password_dictionary_file | |
| validate_password_length | 8 |
| validate_password_mixed_case_count | 1 |
| validate_password_number_count | 1 |
| validate_password_policy | MEDIUM |
| validate_password_special_char_count | 1 |
+--------------------------------------+--------+
假设您希望实施一个策略,强制执行这些密码要求:
-
至少为 10 个字符长
-
包含大写和小写字符
-
包含至少两个数字
-
包含至少一个特殊(非字母数字)字符
要实施该策略,可以在服务器选项文件中添加启用插件并设置配置策略要求的值的选项。例如,将以下行放入您的服务器选项文件中:
[mysqld]
plugin-load-add=validate_password.so
validate_password_length=10
validate_password_mixed_case_count=1
validate_password_number_count=2
validate_password_special_char_count=1
启动服务器后,请验证这些设置:
mysql> `SHOW VARIABLES LIKE 'validate_password%';`
+--------------------------------------+--------+
| Variable_name | Value |
+--------------------------------------+--------+
| validate_password_dictionary_file | |
| validate_password_length | 10 |
| validate_password_mixed_case_count | 1 |
| validate_password_number_count | 2 |
| validate_password_policy | MEDIUM |
| validate_password_special_char_count | 1 |
+--------------------------------------+--------+
现在validate_password插件防止分配太弱的密码:
mysql> `SET PASSWORD = 'weak-password';`
ERROR 1819 (HY000): Your password does not satisfy the current
policy requirements
mysql> `SET PASSWORD = 'Str0ng-Pa33w@rd';`
Query OK, 0 rows affected (0.00 sec)
前述说明使validate_password_policy系统变量保持其默认值(MEDIUM),但您可以更改以控制服务器如何测试密码:
-
MEDIUM启用密码长度和数字、大写/小写字符以及特殊字符的测试。 -
要减少严格性,将策略设置为
LOW,仅启用长度测试。还可以允许更短的密码,减少所需长度(validate_password_length)。 -
要更严格地设置策略为
STRONG,类似于MEDIUM但还允许您根据字典文件检查密码,以防止使用文件中的任何单词作为密码。比较不区分大小写。要使用字典文件,请在服务器启动时将
validate_password_dictionary_file的值设置为文件名。该文件应包含每行一个小写单词。MySQL 发行版包括一个dictionary.txt文件在share目录中,您可以使用,Unix 系统通常有一个/usr/share/dict/words文件。
设定密码策略对现有密码没有影响。若要求用户选择符合策略的新密码,需将当前密码过期(见 Recipe 24.5)。
24.4 检查密码强度
问题
您想要分配或更改密码,但首先验证它是否强密码。
解决方案
使用VALIDATE_PASSWORD_STRENGTH()函数。
讨论
validate_password插件不仅实施新密码的策略,还提供了一个 SQL 函数,VALIDATE_PASSWORD_STRENGTH(),可用于测试潜在密码的强度。此函数的用途包括:
-
管理员希望检查分配给新帐户的密码。
-
一个个体用户想要选择一个新密码,但事先希望得到它的强度保证。
要使用VALIDATE_PASSWORD_STRENGTH(),必须启用validate_password插件。有关插件安装说明,请参见 Recipe 22.2。
VALIDATE_PASSWORD_STRENGTH()返回一个从 0(弱)到 100(强)的值:
mysql> `SELECT VALIDATE_PASSWORD_STRENGTH('abc') ;`
+-----------------------------------+
| VALIDATE_PASSWORD_STRENGTH('abc') |
+-----------------------------------+
| 0 |
+-----------------------------------+
mysql> `SELECT VALIDATE_PASSWORD_STRENGTH('weak-password');`
+---------------------------------------------+
| VALIDATE_PASSWORD_STRENGTH('weak-password') |
+---------------------------------------------+
| 50 |
+---------------------------------------------+
mysql> `SELECT VALIDATE_PASSWORD_STRENGTH('Str0ng-Pa33w@rd');`
+-----------------------------------------------+
| VALIDATE_PASSWORD_STRENGTH('Str0ng-Pa33w@rd') |
+-----------------------------------------------+
| 100 |
+-----------------------------------------------+
24.5 密码过期
问题
您希望用户选择一个新的 MySQL 密码。
解决方案
ALTER USER语句会使密码过期。
讨论
MySQL 5.6.7 及以上版本提供了一个ALTER USER语句,使管理员可以使账户的密码过期:
ALTER USER 'cbuser'@'localhost' PASSWORD EXPIRE;
这里有一些密码过期的用途:
-
您可以实施一个策略,要求新用户在首次连接时选择一个新密码:立即使每个新创建的账户的密码过期。
-
如果您对可接受密码施加了更严格的策略(参见 Recipe 24.3),您可以使所有现有密码过期,要求每个用户选择一个符合更严格要求的新密码。
ALTER USER 影响单个账户。它通过将适当的mysql.user行的password_expired列设置为Y来工作。为了 欺骗
并立即使所有非匿名账户的密码过期,请执行以下操作(匿名用户无法重置其密码,因此使这些用户的密码过期会产生相同效果):
UPDATE mysql.user SET password_expired = 'Y' WHERE User <> '';
FLUSH PRIVILEGES;
或者,为了影响所有账户但避免直接修改授权表,使用一个存储过程,循环遍历所有账户并为每个执行ALTER USER:
CREATE PROCEDURE expire_all_passwords()
BEGIN
DECLARE done BOOLEAN DEFAULT FALSE;
DECLARE account TEXT;
DECLARE cur CURSOR FOR
SELECT CONCAT(QUOTE(User),'@',QUOTE(Host)) AS account
FROM mysql.user WHERE User <> '';
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
expire_loop: LOOP
FETCH cur INTO account;
IF done THEN
LEAVE expire_loop;
END IF;
CALL exec_stmt(CONCAT('ALTER USER ',account,' PASSWORD EXPIRE'));
END LOOP;
CLOSE cur;
END;
该过程需要exec_stmt()辅助程序(参见 Recipe 11.6)。用于创建这些例程的脚本位于recipes分发的routines目录中。
24.6 分配一个新密码给自己
问题
您想要更改您的密码。
解决方案
使用ALTER USER或SET PASSWORD语句。
讨论
要分配一个新密码给自己,请使用SET PASSWORD语句:
SET PASSWORD = '*`my-new-password`*';
SET PASSWORD 允许使用FOR子句,您可以指定哪个账户获得新密码:
SET PASSWORD FOR '*`user_name`*'@'*`host_name`*' = '*`my-new-password`*';
这种后续语法主要面向 DBA,因为它需要对mysql数据库具有UPDATE权限。
或者,使用ALTER USER语句:
ALTER USER '*`user_name`*'@'*`host_name`*' IDENTIFIED BY '*`my-new-password`*';
如果您想要使用ALTER USER语句为自己分配密码,您可以首先通过运行函数CURRENT_USER检查您的账户名:
mysql> `SELECT` `CURRENT_USER``(``)``;`
+----------------+ | CURRENT_USER() |
+----------------+ | cbuser@% |
+----------------+ 1 row in set (0.00 sec)
要检查您考虑的密码的强度,请使用VALIDATE_PASSWORD_STRENGTH()函数(参见 Recipe 24.4)。
24.7 重置已过期密码
问题
由于您的 DBA 使您的密码过期,您无法使用 MySQL。
解决方案
分配一个新密码给自己。
讨论
如果 MySQL 管理员已经使您的密码过期,MySQL 会允许您连接,但不能做其他任何事情:
$ `mysql --user=cbuser --password`
Enter password: `******`
mysql> `SELECT CURRENT_USER();`
ERROR 1820 (HY000): You must SET PASSWORD before executing this statement
如果您看到该消息,请重置您的密码,以便您可以正常工作:
mysql> `SET PASSWORD = 'my-new-password';`
Query OK, 0 rows affected (0.00 sec)
mysql> `SELECT CURRENT_USER();` -- now you can work again
+------------------+
| CURRENT_USER() |
+------------------+
| cbuser@localhost |
+------------------+
1 row in set (0.00 sec)
从技术上讲,MySQL 不要求用一个 新 密码替换过期的密码,因此你可以用当前的密码来取消过期状态。唯一的例外是,如果密码策略变得更加严格,你当前的密码不再符合条件,就必须选择一个更强的密码。
关于如何修改密码的更多信息,请参见 Recipe 24.6。
24.8 查找和移除匿名账户
问题
你希望确保你的 MySQL 服务器只能被与特定用户名关联的账户使用。
解决方案
识别并移除匿名账户。
讨论
一个 匿名
账户是指账户名中用户部分为空,例如 ''@'localhost'。空用户匹配任何名称,因为匿名账户的目的是允许知道其密码的任何人从指定的主机连接(在这种情况下是 localhost)。这样做是为了方便,因为数据库管理员不需要为不同的用户设置单独的账户。但这也存在安全风险:
-
这类账户通常没有密码,使得它们可以在无需任何认证的情况下使用。
-
你无法将数据库活动与特定用户关联起来(例如,通过检查服务器查询日志或检查
SHOW PROCESSLIST输出),这使得更难以确定谁在执行何种操作。
如果以上几点让你确信匿名账户并不是好事,请使用以下说明来识别并移除它们:
-
对于匿名账户,
mysql.user行中的User列为空,因此你可以这样识别它们:mysql> `SELECT User, Host FROM mysql.user WHERE User = '';` +------+---------------+ | User | Host | +------+---------------+ | | %.example.com | | | localhost | +------+---------------+ -
SELECT输出显示了两个匿名账户。使用相应的账户名和DROP USER语句分别移除它们:mysql> `DROP USER ''@'localhost';` mysql> `DROP USER ''@'%.example.com';`
24.9 修改 任何主机
和 多主机
账户
问题
你希望确保 MySQL 账户不能从过于广泛的主机使用。
解决方案
查找和修复主机部分包含 % 或 _ 的账户。
讨论
MySQL 账户名的主机部分可以包含 SQL 模式字符 % 和 _(见 Recipe 7.10)。这些名称匹配任何与模式匹配的主机的客户端连接尝试。例如,账户 'user1'@'%' 允许 user1 从任何主机连接,而 'user2'@'%.example.com' 允许 user2 从 example.com 域中的任何主机连接。
账户名中主机部分的模式提供了便利,使得数据库管理员可以创建一个允许从多个主机连接的账户。但这同时增加了安全风险,因为这样做会增加入侵者尝试连接的主机数量。如果你认为这是一个问题,识别这些账户并删除它们或将主机部分修改为更具体的。
有几种方法可以查找在主机部分包含 % 或 _ 的账户。以下是两种方法:
WHERE Host LIKE '%\%%' OR Host LIKE '%\_%';
WHERE Host REGEXP '[%_]';
LIKE 表达式更复杂,因为我们必须单独查找每个模式字符并转义它以搜索文字实例。REGEXP 表达式不需要转义,因为这些字符在正则表达式中不是特殊字符,字符类允许使用单个模式找到这两个。因此,让我们使用该表达式:
-
在
mysql.user表中如此识别模式主机账户:mysql> `SELECT User, Host FROM mysql.user WHERE Host REGEXP '[%_]';` +-------+---------------+ | User | Host | +-------+---------------+ | user1 | % | | user2 | %.example.com | | user3 | _.example.com | +-------+---------------+ -
要移除已识别的账户,使用
DROPUSER:mysql> `DROP USER 'user1'@'%';` mysql> `DROP USER 'user3'@'_.example.com';`或者,重命名账户以使主机部分更具体:
mysql> `RENAME USER 'user2'@'%.example.com' TO 'user2'@'host17.example.com';`
24.10 使用 TLS(SSL)
问题
您希望在 MySQL 客户端和服务器之间加密流量。
解决方案
使用 TLS(传输层安全)协议。
讨论
MySQL 在客户端和服务器之间加密流量时,并不使用除标准 TCP 协议外的任何其他内容。因此,如果有人想要读取传输的数据,无论是从客户端到服务器还是反过来,他们可以轻松地使用 tcpdump 和类似工具完成。任何敏感信息,比如用户密码或存储的信用卡号码,都可能被泄露。为了防止这种情况发生,MySQL 支持 TLS 协议来保护通信。
注意
现代版本的 MySQL 使用 TLS 协议来加密客户端和服务器之间的流量。然而,由于历史原因,配置选项和用户参考手册通常将 TLS 称为 SSL(安全套接字层),尽管后者已不再使用,因为其加密较弱。在本书中,我们将尽可能在文本中使用 TLS 这一术语。
要在 MySQL 客户端和服务器之间保护流量,您需要:
在服务器端
-
启用
ssl选项。这是默认值,您只需确保在配置文件中未禁用它。 -
可用于验证证书的证书颁发机构(CA)文件。它可以是单个文件,由
ssl_ca选项指定,或者是包含多个此类文件的目录路径,由ssl_capath选项指定。 -
由
ssl_cert选项指定的公钥证书文件。此证书将发送给客户端,用于对抗客户端的证书颁发机构进行身份验证。 -
由
ssl_key选项指定的私钥。
在客户端
-
为
ssl-mode选项指定以下值之一:PREFERRED如果服务器支持 TLS,则建立加密连接,并在不支持时回退到未加密连接。这是默认值。
REQUIRED如果服务器支持 TLS,则建立加密连接,并在不支持时失败连接尝试。
VERIFY_CA执行与
REQUIRED相同的检查,并额外验证服务器 CA 文件与配置的 CA 证书的一致性。VERIFY_IDENTITY执行与
VERIFY_CA相同的检查,并额外进行主机名验证。也就是说,服务器证书应在"Subject Alternative Name"或"Common Name"字段中包含客户端主机名。值
DISABLED会禁用 TLS 连接,如果需要加密客户端-服务器流量,则不应使用。 -
可用于验证证书的证书颁发机构(CA)文件。可以是单个文件,由选项
ssl_ca指定,也可以是包含多个此类文件的目录路径,由选项ssl_capath指定。 -
由选项
ssl_cert指定的公钥证书文件。此证书将发送到服务器,用于根据服务器的证书颁发机构进行身份验证。 -
由选项
ssl_key指定的私钥。
注意
所有的证书颁发机构、证书和密钥文件应该采用PEM格式。
如果 MySQL 服务器启用了ssl选项,但其他与加密相关的选项为空值,则会在数据目录中搜索 TLS 密钥和证书。如果找到,将使用它们。否则,将禁用 TLS 支持。
一旦您具备所有这些先决条件,可以测试 TLS 连接:
$ `mysql`
mysql> `\s`
--------------
../bin/mysql Ver 8.0.21 for Linux on x86_64 (Source distribution)
Connection id: 534
Current database:
Current user: root@localhost
SSL: Cipher in use is TLS_AES_256_GCM_SHA384
...
mysql> `SHOW VARIABLES LIKE 'have_ssl';`
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| have_ssl | YES |
+---------------+-------+
1 row in set (0.01 sec)
MySQL 支持进一步限制 TLS 连接的选项,比如ssl-cipher,要求只使用指定的加密算法。详细信息请参考 MySQL 用户参考手册中的“配置 MySQL 使用加密连接”。
创建自签名证书
MySQL 分发包含mysql_ssl_rsa_setup命令,可用于创建自签名密钥和证书。它调用openssl命令,可以这样使用:
$ `mysql_ssl_rsa_setup --datadir=./data`
Ignoring -days; not generating a certificate
Generating a RSA private key
..............................................................................+++++
..............+++++
writing new private key to 'ca-key.pem'
-----
Ignoring -days; not generating a certificate
Generating a RSA private key
....................................+++++
...........................................+++++
writing new private key to 'server-key.pem'
-----
Ignoring -days; not generating a certificate
Generating a RSA private key
..............................................................................+++++
.....................................+++++
writing new private key to 'client-key.pem'
-----
完成后,将在数据目录中创建以下表中列出的文件 Table 24-2
表 24-2. mysql_ssl_rsa_setup创建的文件
| 文件 | 描述 |
|---|---|
ca.pem |
自签名证书颁发机构(CA) |
ca-key.pem |
CA 私钥 |
server-cert.pem |
服务器证书 |
server-key.pem |
服务器密钥 |
client-cert.pem |
客户端证书 |
client-key.pem |
客户端密钥 |
private_key.pem |
RSA 私钥,用于未加密连接的帐户,由sha256_password或caching_sha2_password插件认证 |
public_key.pem |
RSA 公钥,用于未加密连接的帐户,由sha256_password或caching_sha2_password插件认证 |
mysql_ssl_rsa_setup创建的密钥和证书非常基础,不包含像"Common Name"这样的字段。如果希望向 TLS 文件添加这些自定义值,需要手动创建。本书不包含此过程的详细说明,因为网上有大量文档,包括 MySQL 用户参考手册中的创建 SSL 和 RSA 证书及密钥。或者,您可以使用mysql_ssl_rsa_setup命令的--verbose选项进行测试运行,它会打印出执行的openssl命令。然后您只需重复执行这些命令并加入自定义选项即可。
提示
如果您只想测试 MySQL TLS 连接的工作原理,并且不想创建任何新的密钥和证书,您可以使用 MySQL 安装中mysql-test/std_data目录中的MySQL 测试套件中的标准密钥和证书。
24.11 使用角色
问题
您希望将相同的权限集授予不同的用户,但不希望他们共享同一个用户帐户。
解决方案
使用角色。
讨论
当多人使用 MySQL 安装时,您可能需要为其中一些人提供类似的特权。例如,应用程序用户可能需要访问其应用程序数据库中的表,而管理员可能需要执行管理命令。当您只有一个应用程序用户或一个数据库管理员时,可以简单地创建两个用户帐户。但是,当您的组织和 MySQL 使用增长时,您可能需要允许不同的人执行相同的任务。
您可以通过将单个用户帐户共享给不同的人来解决此类问题。但由于各种原因,包括用户离开公司或应该失去对数据库的访问权限的情况,这种做法是不安全的。或者,如果组中的某人泄露了他们的访问凭据,所有数据库用户都会受到威胁。
另一个解决方案是为各个用户帐户复制特权列表。虽然这样更安全,但在需要添加或删除权限时容易出错。对几十个用户手动执行此操作可能很容易导致错误。
为解决这些缺点,MySQL 8.0 引入了角色,实际上是特权的命名集合。
您可以像创建任何其他用户帐户一样创建角色。您只需不需要为其指定访问凭据。
mysql> `CREATE ROLE cookbook, admin;`
Query OK, 0 rows affected (0.00 sec)
在上面的列表中,我们创建了一个名为cookbook的角色,该角色将访问 cookbook 数据库,以及一个名为admin的角色,用于数据库管理。
下一步是为我们的新角色分配权限。
mysql> `GRANT SELECT, INSERT, UPDATE, DELETE, EXECUTE ON cookbook.* TO 'cookbook';`
Query OK, 0 rows affected (0.00 sec)
mysql> `GRANT GROUP_REPLICATION_ADMIN, PERSIST_RO_VARIABLES_ADMIN,`
-> `REPLICATION_SLAVE_ADMIN, RESOURCE_GROUP_ADMIN,`
-> `ROLE_ADMIN, SYSTEM_VARIABLES_ADMIN, SYSTEM_USER,`
-> `RELOAD, SHUTDOWN ON *.* to 'admin';`
Query OK, 0 rows affected (0.01 sec)
设置角色后,我们可以将其分配给不同的用户。例如,要将对cookbook数据库的访问权限分配给用户cbuser、sveta和alkin,请使用以下命令:
mysql> `GRANT cookbook TO cbuser;`
Query OK, 0 rows affected (0.01 sec)
mysql> `GRANT cookbook TO sveta;`
Query OK, 0 rows affected (0.00 sec)
mysql> `GRANT cookbook TO alkin;`
Query OK, 0 rows affected (0.00 sec)
要授予用户paul和amelia管理员访问权限,请使用以下命令:
mysql> `GRANT admin TO paul;`
Query OK, 0 rows affected (0.00 sec)
mysql> `GRANT admin TO amelia;`
Query OK, 0 rows affected (0.01 sec)
撤销角色访问权限与授予访问权限一样简单:
mysql> `REVOKE cookbook FROM sveta;`
Query OK, 0 rows affected (0.01 sec)
另请参阅
MySQL 支持其他与角色相关的操作,例如为新添加的用户设置默认角色或激活和停用角色。有关 MySQL 中角色的更多信息,请参见MySQL 用户参考手册中的使用角色。
24.12 使用视图保护数据访问
问题
您希望仅允许用户访问某些查询结果,但不希望他们看到存储在表中的实际数据。
解决方案
使用视图。
讨论
您可能希望某些用户能够访问查询结果,但不希望他们访问存储在表中的实际数据。例如,统计部门可能希望了解医院内的患者数量、其性别和年龄分布,以及这些数据与康复率的相关性,但不应访问患者的实际数据,例如他们的姓名、身份证号或能够关联其身份和诊断的信息。
要实现此目标,您可以创建一个视图,查询特定数据,并仅为此视图授予特定用户访问权限。
考虑一个patients表:
mysql> `SHOW CREATE TABLE patients\G`
*************************** 1\. row ***************************
Table: patients
Create Table: CREATE TABLE `patients` (
`id` int NOT NULL AUTO_INCREMENT,
`national_id` char(32) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`surname` varchar(255) DEFAULT NULL,
`gender` enum('F','M') DEFAULT NULL,
`age` tinyint unsigned DEFAULT NULL,
`additional_data` json DEFAULT NULL,
`diagnosis` varchar(255) DEFAULT NULL,
`result` enum('R','N','D') DEFAULT NULL ↩
COMMENT 'R=Recovered, N=Not Recovered, D=Dead',
`date_arrived` date NOT NULL,
`date_departed` date DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=21 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
1 row in set (0.00 sec)
如果您希望为统计部门提供对gender、age、diagnosis、result列的访问权限,但希望限制对national_id、name、surname和additional_data的访问权限,您还可以让他们了解患者在医院中停留了多少天,以及他们到达的月份和年份,但不想让他们探索实际的到达和离开日期。换句话说,限制对date_arrived和date_departed的访问,同时提供基于这些列中存储的值计算出的数据。
您可以通过创建视图来实现此目标:
mysql> `CREATE VIEW patients_statistics AS`
-> `SELECT age, gender, diagnosis, result,`
-> `datediff(date_departed, date_arrived) as recovered_time,`
-> `MONTH(date_arrived) AS month_arrived,`
-> `YEAR(date_arrived) AS year_arrived`
-> `FROM patients;`
Query OK, 0 rows affected (0.03 sec)
然后为统计部门创建一个用户,该用户只能对此视图进行只读访问,并且不能访问底层表。
mysql> `CREATE USER statistics;`
Query OK, 0 rows affected (0.03 sec)
mysql> `GRANT SELECT ON cookbook.patients_statistics TO statistics;`
Query OK, 0 rows affected (0.02 sec)
现在统计部门可以登录并运行分析查询,例如找出最常见的诊断或每月有多少这样的诊断患者到达。
# `mysql cookbook -A -ustatistics`
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 17
...
mysql> `SELECT diagnosis, AVG(recovered_time) AS recovered_time_avg, COUNT(*) AS cases`
-> `FROM patients_statistics WHERE year_arrived='2020'`
-> `GROUP BY diagnosis ORDER BY cases DESC;`
+---------------+--------------------+-------+
| diagnosis | recovered_time_avg | cases |
+---------------+--------------------+-------+
| Data Phobia | 24.8333 | 6 |
| Diabetes | 10.0000 | 4 |
| Asthma | 10.3333 | 3 |
| Arthritis | 22.0000 | 3 |
| Appendicitis | 5.5000 | 2 |
| Breast Cancer | 75.0000 | 2 |
+---------------+--------------------+-------+
6 rows in set (0.00 sec)
mysql> `SELECT diagnosis, month_arrived, COUNT(*) AS month_cases`
-> `FROM patients_statistics WHERE diagnosis='Data Phobia' AND year_arrived='2020'`
-> `GROUP BY diagnosis, month_arrived ORDER BY month_arrived;`
+--------------+---------------+-------------+
| diagnosis | month_arrived | month_cases |
+--------------+---------------+-------------+
| Data Phobia | 4 | 1 |
| Data Phobia | 9 | 2 |
| Data Phobia | 10 | 2 |
| Data Phobia | 11 | 1 |
+--------------+---------------+-------------+
4 rows in set (0.00 sec)
但他们将无法直接访问表数据:
mysql> `SELECT * FROM patients;`
ERROR 1142 (42000): SELECT command denied to user 'statistics'@'localhost' ↩
for table 'patients'
mysql> `SELECT diagnosis, result, date_arrived FROM patients;`
ERROR 1142 (42000): SELECT command denied to user 'statistics'@'localhost' ↩
for table 'patients'
注意
视图支持SQL SECURITY子句,允许在执行视图时指定安全上下文。有关详细信息,请参见 Recipe 24.13。
另请参阅
获取有关使用视图的更多信息,请参阅 Recipe 5.7。
24.13 使用存储过程来保护数据修改
问题
您希望让用户修改其个人数据,但希望防止他们访问其他人的类似数据。
解决方案
使用存储过程。
讨论
您可能希望让用户查看和更改自己的个人信息。例如,患者可以结婚、改姓或决定添加关于自己的新附加信息,例如地址、体重等等。但您不希望他们看到其他患者的类似信息。在这种情况下,仅在列级别上限制访问不起作用。
存储过程支持SQL SECURITY子句,允许您指定是否要以创建过程的DEFINER用户的访问权限执行该过程,还是以当前执行该过程的INVOKER用户的访问权限执行。
在我们的情况下,我们不希望授予INVOKER访问敏感列中存储的数据的任何特权。因此,我们需要将这些特权授予过程的DEFINER,并指定参数SQL SECURITY DEFINER。
提示
SQL SECURITY的默认值为DEFINER,因此此子句可以省略。
为了说明这一点,让我们从上一个示例中获取patients表。但现在我们将仅访问包含敏感数据的列。
mysql> `SHOW CREATE TABLE patients\G`
*************************** 1\. row ***************************
Table: patients
Create Table: CREATE TABLE `patients` (
...
`national_id` char(32) DEFAULT NULL,
`name` varchar(255) DEFAULT NULL,
`surname` varchar(255) DEFAULT NULL,
...
`additional_data` json DEFAULT NULL,
...
首先,让我们为我们的存储过程准备一个用户,该用户将作为DEFINER。我们不希望任何人使用此帐户,除非是存储过程,所以首先让我们安装mysql_no_login认证插件。
mysql> `INSTALL PLUGIN mysql_no_login SONAME 'mysql_no_login.so';`
Query OK, 0 rows affected (0.01 sec)
然后让我们创建用户帐户,并授予它对patients表的访问权限。
mysql> `CREATE USER sp_access IDENTIFIED WITH mysql_no_login;`
Query OK, 0 rows affected (0.00 sec)
mysql> `GRANT SELECT, UPDATE ON cookbook.patients TO sp_access;`
Query OK, 0 rows affected (0.01 sec)
现在让我们创建一个过程,该过程将返回由national_id标识的患者的敏感数据。
mysql> `delimiter $$`
mysql> `CREATE DEFINER='sp_access'@'%' PROCEDURE get_patient_data(IN nat_id CHAR(32))`
-> `SQL SECURITY DEFINER`
-> `BEGIN`
-> `SELECT name, surname, gender, age, additional_data`
-> `FROM patients WHERE national_id=nat_id;`
-> `END`
-> `$$`
Query OK, 0 rows affected (0.01 sec)
mysql> `delimiter ;`
和一个更新记录的过程。
mysql> `delimiter $$`
mysql> `CREATE DEFINER='sp_access'@'%' PROCEDURE update_patient_data(`
-> `IN nat_id CHAR(32),`
-> `IN new_name varchar(255),`
-> `IN new_surname varchar(255),`
-> `IN new_additional_data JSON)`
-> `SQL SECURITY DEFINER`
-> `BEGIN`
-> `UPDATE patients`
-> `SET name=COALESCE(new_name, name),`
-> `surname=COALESCE(new_surname, surname),`
-> `additional_data=JSON_MERGE_PATCH(COALESCE(additional_data, '{}'),`
-> `COALESCE(new_additional_data, '{}'))`
-> `WHERE national_id=nat_id;`
-> `END`
-> `$$`
Query OK, 0 rows affected (0.01 sec)
mysql> `delimiter ;`
然后,为我们的DEFINER添加执行这些过程的权限。
mysql> `GRANT EXECUTE ON PROCEDURE cookbook.get_patient_data TO sp_access;`
Query OK, 0 rows affected (0.01 sec)
mysql> `GRANT EXECUTE ON PROCEDURE cookbook.update_patient_data TO sp_access;`
Query OK, 0 rows affected (0.00 sec)
最后,让我们创建一个用户,该用户将在没有任何其他特权的情况下使用这些过程。
mysql> `CREATE USER patient;`
Query OK, 0 rows affected (0.01 sec)
mysql> `GRANT EXECUTE ON PROCEDURE cookbook.get_patient_data TO patient;`
Query OK, 0 rows affected (0.02 sec)
mysql> `GRANT EXECUTE ON PROCEDURE cookbook.update_patient_data TO patient;`
Query OK, 0 rows affected (0.01 sec)
现在让我们登录为这个用户,并检查我们的过程如何工作。
mysql> `SHOW GRANTS;`
+------------------------------------------------------------------------------+
| Grants for patient@% |
+------------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `patient`@`%` |
| GRANT EXECUTE ON PROCEDURE `cookbook`.`get_patient_data` TO `patient`@`%` |
| GRANT EXECUTE ON PROCEDURE `cookbook`.`update_patient_data` TO `patient`@`%` |
+------------------------------------------------------------------------------+
3 rows in set (0.00 sec)
mysql> `CALL update_patient_data('89AR642465', NULL, 'Johnson',`
-> `' {"Height": 165, "Weight": 55, "Hair color": "Blonde"}');`
Query OK, 1 row affected (0.00 sec)
mysql> `CALL get_patient_data('89AR642465')\G`
*************************** 1\. row ***************************
name: Mary
surname: Johnson
gender: F
age: 24
additional_data: {"Height": 165, "Weight": 55, "Hair color": "Blonde"}
1 row in set (0.00 sec)
Query OK, 0 rows affected (0.00 sec)
正如您所见,我们可以通过国家身份证号更改特定患者的详细信息,而无需访问其他患者的数据。
参见
有关存储过程的更多信息,请参阅第十一章。


浙公网安备 33010602011771号