182-MySQL 必知必会

开篇词 | 在实战中学习，是解锁MySQL技能的最佳方法

你好，我是朱晓峰。

工作二十多年来，我一直在和 MySQL 打交道。我曾任摩根大通银行技术部副总裁，带领团队为纽约、东京等分支银行提供数据存储和安全服务。目前，正致力于开发基于 MySQL 的管理信息系统，率领团队为包括国家开发银行、百度在线、北京西站等大型企业在内的客户提供了信息服务，并获得了 11 项软件著作权（比如商业数据管理系统、云 POS 系统等）。

因为具备丰富的 MySQL 开发经验，从 2015 年起，我受聘担任数据应用学院客座讲师，开始制作职业技术培训课程。我和团队开发了一个为期 2 周、30 个课时的 MySQL 入门集训课。我们打破了传统的教学模式，不去讲零碎的知识点，而是借助一个实际项目去讲必备技能，帮助数百名初学者迅速掌握了 MySQL 的基本操作和核心技能。经过 2 周的集中培训，有几十位学生顺利进入谷歌、苹果和亚马逊等公司。

多年的项目开发以及培训经历，让我深刻地认识到，熟练使用 MySQL，对技术人来说变得越来越重要，是我们拿到心仪 Offer 的敲门砖。

要知道，MySQL 的入门门槛非常低，还具有免费、开放源码等优势，可以满足我们的多样化需求，是目前被广泛使用的数据库之一。

看到这里，你可能会问：“我知道学习 MySQL 很重要，也花了很多时间去学习，可是学来学去，还是连最简单的实际问题都无法解决，该怎么办呢？”

别着急，接下来我们就来聊聊高效的 MySQL 学习方法。

为什么学了很多知识，你依然不会用 MySQL 解决实际问题?

很多人刚开始学习 MySQL 时，都会面临一系列问题。

市面上的 MySQL 资料这么多，该怎么挑选呢？

我花了很多时间学习 MysQL，但是最后真的遇到问题时，我发现我根本不知道怎么解决。

我会一些基本的操作，但还是很容易踩到坑里。比如，我曾经把字段设置成浮点数，但我不知道它是不精准的，幸亏领导发现了，不然很可能会给项目带来损失。

我储备了不少面试题，为什么一到面试就卡壳呢？

……

其实，这些问题，本质上都是一个原因导致的：传统的资料都是在讲授一个个零碎的知识点，最多给出一些基础的小练习，让你进行一些简单的训练。所以，很多人花了很多时间去学习，好像懂得了很多东西，但是一遇到真实的项目问题，就会一头雾水，不知道如何用所学的知识去解决实际问题，更没有能力给出完整的解决方案。

我做过项目主管，也长期带团队，深知在工作中，最重要的绝对不是你的知识储备量，而是你解决实际问题的能力。但不幸的是，我见过太多面试时表现优异的人，最终却连试用期都过不了。

说到这里，我特别想和你分享一下我曾面试过的一个应届生的故事。

他是一名计算机专业的研究生，讲起 MySQL 数据库的相关知识，他说得非常清楚，也很有条理，所以我对他的期望值特别高。但是，等他真正上手做项目时，我才发现，他的知识都停留在理论层面。

举个小例子，一次，我们需要开发一个餐厅的点餐系统，我就请他做数据库设计。没想到他设计出来的订单表，居然没有包含客户编号，这就导致无法通过关联查询获取客户信息。这样的数据库根本不满足业务的需求，自然是不能用的。

这并不是个例，很多人在谈到某些知识时可以出口成章，但是一遇到真实的商业环境，就会毫无头绪，或者是犯这样那样的错误。

在我看来，正确的学习方法，远比你投入的时间更重要。而实战，就是最高效的方法。

为此，我特意选择了一个连锁超市的实战项目，手把手带你从 0 到 1 走完项目全流程，不仅帮你掌握核心操作，还能让你真正拥有实战能力，能够迅速上手任何一个项目。无论你是刚刚走入职场，想要迅速解锁 MySQL 这项技能，还是对它感兴趣，想要转岗到 MySQL 开发，都可以在这个课程中达到你理想的目标。

之所以选择采用连锁超市的项目，有两个原因。

你对超市这个场景足够熟悉。我们都有去超市购物的经历，会看到货架上摆着玲琅满目的商品，各种各样的促销招牌，还有忙着扫码收银的店员……借助熟悉的场景来讲解，可以最大程度减轻你的理解成本。

超市背后的业务环节非常复杂，产生的数据也多种多样，而 MySQL 是处理这类业务的利器。当我们完整地解决了超市项目中所遇见的复杂数据问题时，你再去做其他任何业务，就可以更游刃有余一些。

总之，我会从实际问题出发，带你学习技术点，让你能举一反三，快速应用在实战项目中。如果用一个公式去概括，就是：项目的实际需求 --> 解决问题所需的知识点 --> 用好这些知识的实战经验。

举个例子，超市的商品非常多，这些商品的名称、数量等，必须要被准确地存储、及时地更新，才能保证正常地售卖。这就是真实需求。要解决这个问题，就要用到 MySQL 的数据存储功能，我们就要掌握设计数据表、定义字段等知识，确保数据的存储效率最高以及数据的唯一性，同时减少错误。

不只是数据存储，我会带着你解决连锁超市所面临的一系列实际问题，从商品进货到库存查验，再到店面售卖、会员营销，等等。在这个过程中，我会给你讲解 MySQL 是怎样存储数据的、如何才能高效查询、如何提供经营决策的依据、如何确保数据的可靠性和安全性……

即使你没有数据库的知识基础，也完全不用担心，只要你跟着我的思路，就一定能真正地在短时间内入门 MySQL，拥有解决问题的能力。

这门课是怎么设计的？

说了这么多，课程的具体设计是怎样的呢？我来介绍一下。

课程总共有四个核心模块。

实践篇：我会从项目最基本的数据存储和操作开始讲起，包括创建数据库、数据表、对表中的数据进行增删改查操作、使用函数、表与表之间的关联操作等，帮你快速掌握最基本的用法。

进阶篇：随着用户管理水平的不断提升，对系统的要求也越来越多，越来越复杂，会用到 MySQL 的许多高级功能。我会手把手带你实现这些功能，包括把程序存储在服务器上、利用突发事件来调用程序、在不改变存储结构的前提下创建虚拟表以方便查询，等等。

优化篇：项目投入运营以后，随着数据的积累，性能优化的问题逐步凸显。在这个模块呢，我会给你讲一讲数据库的设计规范，还会带你创建数据模型，帮助你来理清设计思路。同时，我还会讲到提升性能的具体方法。

案例篇：在课程的最后，我会手把手带你从 0 到 1 设计一个连锁超市的信息系统数据库，把前面讲到的知识点都融入到项目设计中，不仅帮你巩固所学的知识，更教会你如何灵活使用。

除此之外，在课程正式开始之前，我会用图片 + 音频 + 视频的形式，带着你安装 MySQL 及必备的图形化管理工具 Workbench。同时，我还特意设置了一个特别放送模块，给你讲解 MySQL 8.0 的新特性、空间定位的方法，以及大厂的高频面试题，帮你轻松拿下面试。

最后，我还想说，MySQL 是一个非常优秀的数据库，里面包含了很多经典的设计思想。虽然现在你不需要掌握得这么深，但是我还是建议你多多体会这些思想，这会让你提前建立起大局观，还可以帮助你从更高的层面去看待所遇见的实际问题。

在这门课程里，我会把我这么多年的经验毫无保留地分享给你，欢迎你来学习这门课，也欢迎你把咱们的课程分享给你的朋友或同事，邀请他们和你一起学习，共同成长。

环境准备 | 带你安装MySQL和图形化管理工具Workbench

你好，我是朱晓峰。这节课，我来手把手带你安装和配置 MySQL。

俗话说，“巧妇难为无米之炊”，我们不能凭空学习理论知识，必须要在实际的 MySQL 环境中进行操作，这是一切操作的基础。同时，我还会带你安装 MySQL 自带的图形化管理工具 Workbench，我们之后要学习的表的关联、聚合、事务，以及存储过程等，都会用到它。

我会借助图文和音频给你介绍知识重点和操作要领，同时，我还录制了相应的视频，来展示具体的操作细节。你可以根据自己的习惯和需求，选择喜欢的形式来学习。

Sorry, your browser doesn't support embedded videos.



00:00 / 18:39



好了，话不多说，我们先来安装 MySQL。

安装与配置

首先，我们要下载 MySQL 的安装包，具体做法是，打开浏览器，输入网址：https://dev.mysql.com，进入 MySQL 的开发者专区进行下载。

在下载界面，你会看到需要选择操作系统。这是因为，MySQL 可以在多种操作系统平台上运行，包括 Windows、Linux、macOS 等，因此，MySQL 准备了针对不同操作系统平台的安装程序。这里我们主要介绍 MySQL 在 Windows 操作系统上的安装。因为 Windows 平台应用得最广泛，而且图形化操作也比较简单。

当然，如果你想了解 Linux 平台和 macOS 平台上的安装和配置，也可以通过官网https://dev.mysql.com/doc/refman/8.0/en/linux-installation.html 和https://dev.mysql.com/doc/refman/8.0/en/osx-installation.html 来进行查看。不同平台上的 MySQL 会略有不同，比如，同样的机器配置，Linux 上的 MySQL 运行速度就比 Windows 快一些，不过它们支持的功能和 SQL 语法都是一样的，即使你使用的是其他系统，也不会影响到我们的学习。

好了，下载完成之后，我们就可以开始安装了。接下来我给你介绍下安装步骤。

第一步：点击运行下载的安装程序，安装 MySQL 数据库服务器及相关组件

我给你介绍下这些关键组件的作用。

MySQL Server：是 MySQL 数据库服务器，这是 MySQL 的核心组件。

MySQL Workbench：是一个管理 MySQL 的图形工具，一会儿我还会带你安装它。

MySQL Shell：是一个命令行工具。除了支持 SQL 语句，它还支持 JavaScript 和 Python 脚本，并且支持调用 MySQL API 接口。

MySQL Router：是一个轻量级的插件，可以在应用和数据库服务器之间，起到路由和负载均衡的作用。听起来有点复杂，我们来想象一个场景：假设你有多个 MySQL 数据库服务器，而前端的应用同时产生了很多数据库访问请求，这时，MySQL Router 就可以对这些请求进行调度，把访问均衡地分配给每个数据库服务器，而不是集中在一个或几个数据库服务器上。

Connector/ODBC：是 MySQL 数据库的 ODBC 驱动程序。ODBC 是微软的一套数据库连接标准，微软的产品（比如 Excel）就可以通过 ODBC 驱动与 MySQL 数据库连接。

其他的组件，主要用来支持各种开发环境与 MySQL 的连接，还有 MySQL 帮助文档和示例。你一看就明白了，我就不多说了。

好了，知道这些作用，下面我们来点击“Execute”，运行安装程序，把这些组件安装到电脑上。

第二步：配置服务器

等所有组件安装完成之后，安装程序会提示配置服务器的类型（Config Type）、连接（Connectivity）以及高级选项（Advanced Configuration）等，如下图所示。这里我重点讲一下配置方法。

我们主要有 2 个部分需要配置，分别是服务器类别和服务器连接。

先说服务器类别配置。我们有 3 个选项，分别是开发计算机（Development Computer）、服务器计算机（Sever Computer）和专属计算机（Dedicated Computer）。它们的区别在于，MySQL 数据库服务器会占用多大的内存。

如果选择开发计算机，MySQL 数据库服务会占用所需最小的内存，以便其他应用可以正常运行。

服务器计算机是假设在这台计算机上有多个 MySQL 数据库服务器实例在运行，因此会占用中等程度的内存。

专属计算机则会占用计算机的全部内存资源。

这里我们选择配置成“开发计算机”，因为我们安装 MySQL 是为了学习它，因此，只需要 MySQL 占有运行所必需的最小资源就可以了。如果你要把它作为项目中的数据库服务器使用，就应该配置成服务器计算机或者专属计算机。

再来说说 MySQL 数据库的连接方式配置。我们也有 3 个选项：网络通讯协议（TCP/IP）、命名管道（Named Pipe）和共享内存（Shared Memory）。命名管道和共享内存的优势是速度很快，但是，它们都有一个局限，那就是只能从本机访问 MySQL 数据库服务器。所以，这里我们选择默认的网络通讯协议方式，这样的话，MySQL 数据库服务就可以通过网络进行访问了。

MySQL 默认的 TCP/IP 协议访问端口是 3306，后面的 X 协议端口默认是 33060，这里我们都不做修改。MySQL 的 X 插件会用到 X 协议，主要是用来实现类似 MongoDB 的文件存储服务。这方面的知识，我会在课程后面具体讲解，这里就不多说了。

高级配置（Show Advanced）和日志配置（Logging Options），在咱们的课程中用不到，这里不用勾选，系统会按照默认值进行配置。

第三步：身份验证配置

关于 MySQL 的身份验证的方式，我们选择系统推荐的基于 SHA256 的新加密算法 caching_sha2_password。因为跟老版本的加密算法相比，新的加密算法具有相同的密码也不会生成相同的加密结果的特点，因此更加安全。

第四步：设置密码和用户权限

接着，我们要设置 Root 用户的密码。Root 是 MySQL 的超级用户，拥有 MySQL 数据库访问的最高权限。这个密码很重要，我们之后会经常用到，你一定要牢记。

第五步：配置 Windows 服务

最后，我们要把 MySQL 服务器配置成 Windows 服务。Windows 服务的好处在于，可以让 MySQL 数据库服务器一直在 Windows 环境中运行。而且，我们可以让 MySQL 数据库服务器随着 Windows 系统的启动而自动启动。

图形化管理工具 Workbench

安装完成之后，我再给你介绍一下 MySQL 自带的图形化管理工具 Workbench。同时，我还会用 Workbench 的数据导入功能，带你导入一个 Excel 数据文件，创建出我们的第一个数据库和数据表。

首先，我们点击 Windows 左下角的“开始”按钮，如果你是 Win10 系统，可以直接看到所有程序，如果你是 Win7 系统，需要找到“所有程序”按钮，点击它就可以看到所有程序了。

接着，找到“MySQL”，点开，找到“MySQL Workbench 8.0 CE”。点击打开 Workbench，如下图所示：

左下角有个本地连接，点击，录入 Root 的密码，登录本地 MySQL 数据库服务器，如下图所示：

这是一个图形化的界面，我来给你介绍下这个界面。

上方是菜单。左上方是导航栏，这里我们可以看到 MySQL 数据库服务器里面的数据库，包括数据表、视图、存储过程和函数；左下方是信息栏，可以显示上方选中的数据库、数据表等对象的信息。

中间上方是工作区，你可以在这里写 SQL 语句，点击上方菜单栏左边的第三个运行按钮，就可以执行工作区的 SQL 语句了。

中间下方是输出区，用来显示 SQL 语句的运行情况，包括什么时间开始运行的、运行的内容、运行的输出，以及所花费的时长等信息。

好了，下面我们就用 Workbench 实际创建一个数据库，并且导入一个 Excel 数据文件，来生成一个数据表。数据表是存储数据的载体，有了数据表以后，我们就能对数据进行操作了。

创建数据表

第一步：录入 Excel 数据

我们打开 Excel，在工作簿里面录入数据。

我们这个工作表包括 3 列，分别是 barcode、goodsname、price，代表商品条码、商品名称和售价。然后，我们再录入 2 条数据。

0001，book，3：表示条码为“0001”，商品名称是“book”，价格是 3 元。

0002，pen，2：表示条码是“0002”，商品名称是“pen”，价格是 2 元。

注意，我在录入商品条码的时候，打头用了一个单引号，这是为了告诉 Excel，后面是文本，这样系统就不会把 0001 识别为数字了。

录入完成之后，我们把这个文件存起来，名称是 test，格式采用“CSVUTF-8（逗号分隔）”。这样，我们就有了一个 CSV 文件 test.csv。

第二步：编码转换

用记事本打开文件，再用 UTF-8 格式保存一次，这是为了让 Workbench 能够识别文件的编码。

第三步：数据导入

准备好数据文件以后，我们回到 Workbench，在工作区录入命令：create database demo;，在工作区的上方，有一排按钮，找到闪电标识的运行按钮，点击运行。

这时，下方的输出区域的运行结果会提示“OK”，表示运行成功。此时，把光标放到左边的导航区，点击鼠标右键，刷新全部，新创建的数据库“demo”就出现了。

点击数据库 demo 左边的向右箭头，就可以看到数据库下面的数据表、视图、存储过程和函数。当然，现在都是空的。光标选中数据表，鼠标右键，选择“Table Data Import Wizard”，这时会弹出数据文件选择界面。选中刚才准备的 test.csv 文件，点击下一步，Workbench 会提示导入目标数据表，我们现在什么表也没有，所以要选择创建新表“test”。点击下一步，Workbench 会提示配置表的字段，其实它已经按照数据的类别帮我们配置好了。

这时候，再次点击下一步，点击运行，完成数据表导入。光标放到左边的导航区，选中我们刚刚创建的数据库“demo”中的数据表，鼠标右键，点击刷新全部，刚刚导入的数据表“test”就显示出来了。

进行查询

现在我们已经有了数据库，也有了数据表，下面让我们尝试一个简单的查询。

在工作区，录入SELECT * FROM demo.test;（这里的 demo 是数据库名称，test 是数据表名称，* 表示全部字段）。

用鼠标选中这行查询命令，点击运行。工作区的下半部分，会显示查询的结果。我们录入的 2 条数据，都可以看到了。

再尝试插入一条语句：

INSERT INTO demo.test
VALUES ('0003','橡皮',5);

鼠标选中这条语句，点击运行。看到了吗？输出区提示“OK”，运行成功了。现在回过头来选中上面那条查询语句“SELECT * FROM demo.test;”，点击运行，刚才我们插入的那条记录也查询出来了。

到这里，我们就完成了数据库创建、数据表导入和简单的查询。是不是觉得很简单呢？

最后，我还想再讲一下源码获取方法。咱们的课程不要求你阅读源码，但是你可以先学会获取源码的方法，毕竟，这是帮助你提升的重要工具。

MySQL 源代码获取

首先，你要进入 MySQL下载界面。 这里你不要选择用默认的“Microsoft Windows”，而是要通过下拉栏，找到“Source Code”，在下面的操作系统版本里面，选择 Windows（Architecture Independent），然后点击下载。

接下来，把下载下来的压缩文件解压，我们就得到了 MySQL 的源代码。

MySQL 是用 C++ 开发而成的，我简单介绍一下源代码的组成。

mysql-8.0.22 目录下的各个子目录，包含了 MySQL 各部分组件的源代码：

sql 子目录是 MySQL 核心代码；

libmysql 子目录是客户端程序 API；

mysql-test 子目录是测试工具；

mysys 子目录是操作系统相关函数和辅助函数；

……

源代码可以用记事本打开查看，如果你有 C++ 的开发环境，也可以在开发环境中打开查看。

如上图所示，源代码并不神秘，就是普通的 C++ 代码，跟你熟悉的一样，而且有很多注释，可以帮助你理解。阅读源代码就像在跟 MySQL 的开发人员对话一样，十分有趣。

小结

好了，我们来小结下今天的内容。

这节课，我带你完成了 MySQL 的安装和配置，同时我还介绍了图形化管理工具 Workbench 的使用，并且创建了第一个数据库、数据表，也尝试了初步的 SQL 语句查询。

我建议你用自己的电脑，按照这节课的内容，实际操作一下 MySQL 的安装、配置，并尝试不同的配置，看看有什么不同，体会课程的内容，加深理解。

最后，还有几点我要着重提醒你一下。

我们的 MySQL 是按照开发计算机进行的最小配置，实际做项目的时候，如果 MySQL 是核心数据库，你要给 MySQL 配置更多的资源，就要选择服务器计算机，甚至是专属计算机。

Root 超级用户的密码，你不要忘了，否则只好卸载重新安装。

你还可以在 Workbench 中，尝试一下不同的 SQL 语句，同时看看不同的工作区、菜单栏的各种按钮，看看它们都是做什么用的。熟悉 Workbench，对理解我们后面的知识点，会很有帮助。

课程的最后，我还要给你推荐一下 MySQL 的官方论坛。这里面有很多主题，比如新产品的发布、各种工具的使用、MySQL 各部分组件的介绍，等等。如果你有不清楚的内容，也可以在里面提问，和大家交流，建议你好好利用起来。

思考题

在导入数据的时候，如果不采用 MySQL 默认的表名，而是把导入之后的表改个名字，比如说叫 demo.sample，该如何操作呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

01 | 存储：一个完整的数据存储过程是怎样的？

你好，我是朱晓峰。今天，我想跟你聊一聊 MySQL 是怎么存储数据的。

存储数据是处理数据的第一步。在咱们的超市项目中，每天都要处理大量的商品，比如说进货、卖货、盘点库存，商品的种类很多，而且数量也比较大。只有正确地把数据存储起来，我们才能进行有效的处理和分析，进而对经营情况进行科学的评估，超市负责人在做决策时，就能够拿到数据支持。否则，只能是一团乱麻，没有头绪，也无从着手。

那么，怎样才能把用户各种经营相关的、纷繁复杂的数据，有序和高效地存储起来呢？

在 MySQL 中，一个完整的数据存储过程总共有 4 步，分别是创建数据库、确认字段、创建数据表、插入数据。

接下来，我就给你详细讲解一下这个过程的每一步，帮你掌握 MySQL 的数据存储机制。

先提醒你一句，这节课最后有一个视频，我在视频里演示了今天讲到的所有操作。我建议你学完文字以后，跟着视频实操一下。

好了，话不多说，我们现在开始。

创建 MySQL 数据库

数据存储的第一步，就是创建数据库。

你可能会问，为啥我们要先创建一个数据库，而不是直接创建数据表呢？

这是个很好的问题。其实啊，这是因为，从系统架构的层次上看，MySQL 数据库系统从大到小依次是数据库服务器、数据库、数据表、数据表的行与列。

安装程序已经帮我们安装了 MySQL 数据库服务器，所以，我们必须从创建数据库开始。

数据库是 MySQL 里面最大的存储单元。数据表、数据表里的数据，以及我们以后会学到的表与表之间的关系，还有在它们的基础上衍生出来的各种工具，都存储在数据库里面。没有数据库，数据表就没有载体，也就无法存储数据。

下面我就来给你具体介绍下，怎么在我们安装的 MySQL 服务器里面创建、删除和查看数据库。

1. 如何创建数据库？

创建数据库，我们已经在上节课介绍过了，你可以在 Workbench 的工作区，通过下面的 SQL 语句创建数据库“demo”：

CREATE DATABASE demo；

2. 如何查看数据库？

下面我们来看一下，如何查看数据库。

在 Workbench 的导航栏，我们可以看到数据库服务器里的所有数据库，如下图所示：

你也可以在 Workbench 右边的工作区，通过查询语句，查看所有的数据库：

mysql> SHOW DATABASES;
+--------------------+
| Database           |
+--------------------+
| demo               |
| information_schema |
| mysql              |
| performance_schema |
| sys                |
+--------------------+
5 rows in set (0.00 sec)

看到这儿，你是不是觉得很奇怪，为什么 Workbench 导航栏里面的数据库只有两个（我们创建的数据库“demo”和安装完 MySQL 就有的数据库“sys”）呢？

换句话说，为什么有的数据库我们可以在 Workbench 里面看到，有的数据库却必须通过查询语句才可以看到呢？要弄明白这个问题，你必须要知道这些数据库都是干什么的。

“demo”是我们通过 SQL 语句创建的数据库，是我们用来存储用户数据的，也是我们使用的主要数据库。

“information_schema”是 MySQL 系统自带的数据库，主要保存 MySQL 数据库服务器的系统信息，比如数据库的名称、数据表的名称、字段名称、存取权限、数据文件所在的文件夹和系统使用的文件夹，等等。

“performance_schema”是 MySQL 系统自带的数据库，可以用来监控 MySQL 的各类性能指标。

“sys”数据库是 MySQL 系统自带的数据库，主要作用是，以一种更容易被理解的方式展示 MySQL 数据库服务器的各类性能指标，帮助系统管理员和开发人员监控 MySQL 的技术性能。

“mysql”数据库保存了 MySQL 数据库服务器运行时需要的系统信息，比如数据文件夹、当前使用的字符集、约束检查信息，等等。

如果你是 DBA，或者是 MySQL 数据库程序员，想深入了解 MySQL 数据库系统的相关信息，可以看下官方文档。

话说回来，为什么 Workbench 里面我们只能看到“demo”和“sys”这 2 个数据库呢？其实啊，这是因为，Workbench 是图形化的管理工具，主要面向开发人员，“demo”和“sys”这 2 个数据库已经够用了。如果有特殊需求，比如，需要监控 MySQL 数据库各项性能指标、直接操作 MySQL 数据库系统文件等，可以由 DBA 通过 SQL 语句，查看其它的系统数据库。

确认字段

数据存储流程的第二步是确认表的字段。

创建好数据库之后，我们选择要导入的 Excel 数据文件，MySQL 会让我们确认新表中有哪些列，以及它们的数据类型。这些列就是 MySQL 数据表的字段。

MySQL 数据表由行与列组成，一行就是一条数据记录，每一条数据记录都被分成许多列，一列就叫一个字段。每个字段都需要定义数据类型，这个数据类型叫做字段类型。

这样一来，每一条数据记录的每一个片段，就按照字段的定义被严格地管理起来了，从而使数据有序而且可靠。MySQL 支持多种字段类型，字段的定义会影响数据的取值范围、精度，以及系统的可靠性，下节课我会重点给你讲一讲字段的定义。这里你只要选择系统默认的字段类型，就可以了。

创建数据表

数据存储流程的第三步，是创建数据表。

当我们确认好了表的字段，点击下一步，Workbench 就帮助我们创建了一张表。

MySQL 中的数据表是什么呢？你可以把它看成用来存储数据的最主要工具。数据表对存储在里面的数据进行组织和管理，使数据变得有序，并且能够实现高效查询和处理。

虽然 Workbench 帮助我们创建了一个表，但大多数情况下，我们是不会先准备一个 Excel 文件，再通过 Workbench 的数据导入来创建表的，这样太麻烦了。我们可以通过 SQL 语句，自己来创建表。

具体咋做呢？我来介绍一下。

首先，在 Workbench 的工作区，输入以下 SQL 语句：

CREATE TABLE demo.test
(
  barcode text,
  goodsname text,
  price int
);

执行这个 SQL 语句之后，就能创建出一个与导入的 Excel 表一样的 MySQL 数据表了。

这里有 2 点需要你格外注意一下。

创建表的时候，最好指明数据库。否则，如果你没有选中数据库，Workbench 会提示错误；要是你当前选中的数据库不对，还可能把表创建到错误的数据库中。

不要在最后一个字段的后面加逗号“,”，这也是初学者容易犯的错误。

下面我们就来聊一聊，查看数据表的结构、查看数据库中的表的方法。

1. 如何查看表的结构？

创建好了表，再来看看如何查看表的结构。

我们运行下面的 SQL 语句：

DESCRIBE demo.test;

运行结果如下：

mysql> DESCRIBE demo.test;
+-----------+------+------+-----+---------+-------+
| Field     | Type | Null | Key | Default | Extra |
+-----------+------+------+-----+---------+-------+
| barcode   | text | YES  |     | NULL    |       |
| goodsname | text | YES  |     | NULL    |       |
| price     | int  | YES  |     | NULL    |       |
+-----------+------+------+-----+---------+-------+
3 rows in set (0.00 sec)

我来解释下这些信息都代表什么意思。

Field：表示字段名称。

Type：表示字段类型，这里 barcode、goodsname 是文本型的，price 是整数类型的。

Null：表示这个字段是否允许是空值（NULL）。这里你一定要注意，在 MySQL 里面，空值不等于空字符串。一个空字符串的长度是 0，而一个空值的长度是空。而且，在 MySQL 里面，空值是占用空间的。

Key：我们暂时把它叫做键。

Default：表示默认值。我们导入的表的所有的字段都允许是空，默认值都是 NULL。

Extra：表示附加信息。

关于字段类型和 Key，后面我会具体讲解，这里你只需要知道它们的含义就可以了。

2. 如何查看数据库中的表？

创建完成后，怎么查看表有没有真的被创建出来呢？

我们可以通过 Workbench 的导航栏，点击数据库下面的“Tables”，找到这个数据库中的所有数据表。另外，我们也可以在工作区，通过 SQL 语句查询某个数据库中的数据表。

先用 USE 语句选择数据库：

USE demo;

运行后，进入 demo 数据库，然后用 SHOW 语句查询这个数据库中所有的表：

SHOW TABLES;

运行后结果如下：

mysql> SHOW TABLES;
+----------------+
| Tables_in_demo |
+----------------+
| test           |
+----------------+
1 row in set (0.00 sec)

这样我们就可以看到数据库“demo”里面，只有一个数据表“test”。

3. 如何设置主键？

讲到了数据表，我就一定要给你讲一讲主键。因为主键可以确保数据的唯一性，而且能够减少数据错误。

举个简单的小例子。主键就像是咱们的身份证号码，它是唯一的。每个身份证号码只对应唯一的一个人。同样，每一个人只有唯一的身份证号码。

MySQL 中数据表的主键，是表中的一个字段或者几个字段的组合。它主要有 3 个特征：

必须唯一，不能重复；

不能是空；

必须可以唯一标识数据表中的记录。

一个 MySQL 数据表中只能有一个主键。虽然 MySQL 也允许创建没有主键的表，但是，我建议你一定要给表定义主键，并且养成习惯。因为主键可以帮助你减少错误数据，并且提高查询的速度（后面我会专门用一节课的时间介绍“怎么用好主键”）。

我来举个例子，假设我们有这样一张表：

我们给它起个名字，叫“goodsmaster”，意思是商品表。说到这儿，你可能注意到了，我的表名、字段名都用的是英文。其实，MySQL 也允许数据表名称、字段名称用中文，但我还是建议你用英文，原因有 2 个：一是书写方便；二是不容易出错。如果用汉字，涉及到编码问题，就会容易出现错误。

那么，在这个表里，哪个字段是主键呢？

商品名称“goodsname”行不行呢？不行，原因是重名的商品太多了，比如“笔”，大家都可以生产一种叫“笔”的商品，各种各样的笔，不同规格的，比如长的、短的；不同材料的，比如墨水的、铅芯的……由于可能重复，商品名称和数据记录之间不能形成一一对应的关系，所以“goodsname”不能作为主键。同样，价格“price”重复的可能性很大，也不能做主键。

商品条码“barcode”能不能成为这个数据表的主键呢？

好像可以。商品的条码都是由中国物品编码中心统一编制的，一种商品对应一个条码，一个条码对应一种商品。这不就是一一对应的关系吗？

在实际操作中，有例外的情况。比较典型的就是用户的门店里面有很多自己生产或者加工的商品。比如，馒头、面条等自产食品，散装的糕点、糖果等称重商品，等等。为了管理方便，门店往往会自己给它们设置条码。这样，很容易产生重复、重用的现象。

这么说，商品条码“barcode”也有重复的可能，也不能用做主键。

那么，如果数据表中所有的字段都有重复的可能，我们怎么设置主键呢？答案是我们可以自己添加一个不会重复的字段来做主键。

比如在上面的例子中，我们就可以添加一个字段，字段类型是整数，我们给它取个名字叫商品编号“itemnumber”。而且，我们可以每次增加一条新数据的时候，让这个字段的值自动加 1，这样就永远不会重复了，如下表所示：

我们添加字段商品编号“itemnumber”为主键，这样，我们的商品表“goodsmaster”就有了主键。

在 Workbench 中，我们可以通过修改表结构，来增加一个主键字段：

你也可以通过一条 SQL 语句，修改表的结构，来增加一个主键字段：

ALTER TABLE demo.test
ADD COLUMN itemnumber int PRIMARY KEY AUTO_INCREMENT;

我简单解释下这些关键字的含义。

ALTER TABLE，表示修改表；

ADD COLUMN，表示增加一列；

PRIMARY KEY，表示这一列是主键；

AUTO_INCREMENT，表示每增加一条记录，这个值自动增加。一会儿讲到添加数据的时候，我还会详细介绍一下它。

插入数据

数据存储流程的第四步，也是最后一步，是把数据插入到表当中去。

Workbench 的数据导入功能，可以帮助我们把 Excel 的数据导入到表里面，那么，我们自己怎么向数据表中插入一条数据呢？我们可以借助 SQL 语句。

INSERT INTO demo.test
(barcode,goodsname,price)
VALUES ('0001','本',3);

这里的 INSERT INTO 表示向 demo.test 中插入数据，后面是要插入数据的字段名，VALUES 表示对应的值。

在添加数据的时候，有 2 点需要你格外注意一下。

要插入数据的字段名也可以不写，但是我建议你不要怕麻烦，一定要每次都写。这样做的好处是可读性好，不易出错，而且容易修改。否则，如果你记不住表的字段，就只能去查表的结构，才能知道值所对应的字段了。

由于字段 itemnumber 定义了 AUTO_INCREMENT，所以我们插入一条记录的时候，不给它赋值，系统也会自动给它赋值。而且，每次赋值，都会在上次的赋值基础上，自动增加 1。你也可以在插入一条记录的时候给 itemnumber 赋值，由于它是主键，新的值必须与已有记录的 itemnumber 值不同，否则系统会提示错误。

最后，我还专门录制了一段视频，把今天讲到的实际操作演示了一遍，你可以跟着视频再演练下。

Sorry, your browser doesn't support embedded videos.



00:00 / 14:51



总结

今天，我们学习了数据存储的完整过程，包括创建数据库、创建数据表、确认字段和插入数据。建议你跟着文字和视频实际操作一下，熟练掌握存储数据的方法。

在进行具体操作的时候，我们会用到 8 种 SQL 语句，我再给你汇总下。

-- 创建数据库
CREATE DATABASE demo；
-- 删除数据库
DROP DATABASE demo；
-- 查看数据库
SHOW DATABASES;
-- 创建数据表：
CREATE TABLE demo.test
(
  barcode text,
  goodsname text,
  price int
);
-- 查看表结构
DESCRIBE demo.test;
-- 查看所有表
SHOW TABLES;
-- 添加主键
ALTER TABLE demo.test
ADD COLUMN itemnumber int PRIMARY KEY AUTO_INCREMENT;
-- 向表中添加数据
INSERT INTO demo.test
(barcode,goodsname,price)
VALUES ('0001','本',3);

最后，我还想再给你讲一讲 MySQL 中 SQL 语句的书写规范。

MySQL 以分号来识别一条 SQL 语句结束，所以，你写的每一条 SQL 语句的最后，都必须有一个分号，否则，MySQL 会认为这条语句没有完成，提示语法错误。

所以，我建议你写在 SQL 语句时遵循统一的样式，以增加可读性，减少错误。如果你不是很清楚具体的规范，可以点击这个链接学习下。

思考题

我想请你思考一下，假设用户现在要销售商品，你能不能帮它设计一个销售表，把销售信息（商品名称、价格、数量、金额等）都保存起来？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享你的朋友或同事，我们下节课见。

02 | 字段：这么多字段类型，该怎么定义？

你好，我是朱晓峰。

MySQL 中有很多字段类型，比如整数、文本、浮点数，等等。如果类型定义合理，就能节省存储空间，提升数据查询和处理的速度，相反，如果数据类型定义不合理，就有可能会导致数据超出取值范围，引发系统报错，甚至可能会出现计算错误的情况，进而影响到整个系统。

之前，我们就遇到过这样一个问题：在销售流水表中，需要定义商品销售的数量。由于有称重商品，不能用整数，我们想当然地用了浮点数，为了确保精度，我们还用了 DOUBLE 类型。结果却造成了在没有找零的情况下，客人无法结账的重大错误。经过排查，我们才发现，原来 DOUBLE 类型是不精准的，不能使用。

你看，准确地定义字段类型，不但关系到数据存储的效率，而且会影响整个信息系统的可靠性。所以，我们必须要掌握不同字段的类型，包括它们的适用场景、定义方法，这节课，我们就聊一聊这个问题。

首先，我要说的是 MySQL 中最简单的数据类型：整数类型。

整数类型

整数类型一共有 5 种，包括 TINYINT、SMALLINT、MEDIUMINT、INT（INTEGER）和 BIGINT，它们的区别如下表所示：

这么多整数类型，咱们该怎么选择呢？

其实，在评估用哪种整数类型的时候，你需要考虑存储空间和可靠性的平衡问题：一方面，用占用字节数少的整数类型可以节省存储空间；另一方面，要是为了节省存储空间，使用的整数类型取值范围太小，一旦遇到超出取值范围的情况，就可能引起系统错误，影响可靠性。

举个例子，在我们的项目中，商品编号采用的数据类型是 INT。

我们之所以没有采用占用字节更少的 SMALLINT 类型整数，原因就在于，客户门店中流通的商品种类较多，而且，每天都有旧商品下架，新商品上架，这样不断迭代，日积月累。如果使用 SMALLINT 类型，虽然占用字节数比 INT 类型的整数少，但是却不能保证数据不会超出范围 65535。相反，使用 INT，就能确保有足够大的取值范围，不用担心数据超出范围影响可靠性的问题。

你要注意的是，在实际工作中，系统故障产生的成本远远超过增加几个字段存储空间所产生的成本。因此，我建议你首先确保数据不会超过取值范围，在这个前提之下，再去考虑如何节省存储空间。

接下来，我再给你介绍下浮点数类型和定点数类型。

浮点数类型和定点数类型

浮点数和定点数类型的特点是可以处理小数，你可以把整数看成小数的一个特例。因此，浮点数和定点数的使用场景，就比整数大多了。

我们先来了解下 MySQL 支持的浮点数类型，分别是 FLOAT、DOUBLE、REAL。

FLOAT 表示单精度浮点数；

DOUBLE 表示双精度浮点数；

REAL 默认就是 DOUBLE。如果你把 SQL 模式设定为启用“REAL_AS_FLOAT”，那么，MySQL 就认为 REAL 是 FLOAT。如果要启用“REAL_AS_FLOAT”，就可以通过以下 SQL 语句实现：

SET sql_mode = “REAL_AS_FLOAT”;

FLOAT 和 DOUBLE 这两种数据类型的区别是啥呢？其实就是，FLOAT 占用字节数少，取值范围小；DOUBLE 占用字节数多，取值范围也大。

看到这儿，你有没有发现一个问题：为什么浮点数类型的无符号数取值范围，只相当于有符号数取值范围的一半，也就是只相当于有符号数取值范围大于等于零的部分呢？

其实，这里的原因是，MySQL 是按照这个格式存储浮点数的：符号（S）、尾数（M）和阶码（E）。因此，无论有没有符号，MySQL 的浮点数都会存储表示符号的部分。因此，所谓的无符号数取值范围，其实就是有符号数取值范围大于等于零的部分。

不过，我要提醒你的是，浮点数类型有个缺陷，就是不精准。因此，在一些对精确度要求较高的项目中，千万不要使用浮点数，不然会导致结果错误，甚至是造成不可挽回的损失。下面我来重点解释一下为什么 MySQL 的浮点数不够精准。

为了方便你理解，我来借助一个实际的例子演示下。

我们先创建一个表，如下所示：

CREATE TABLE demo.goodsmaster
(
  barcode TEXT,
  goodsname TEXT,
  price DOUBLE,
  itemnumber INT PRIMARY KEY AUTO_INCREMENT
);

运行这个语句，我们就创建了一个表，其中的字段“price”就是浮点数类型。我们再通过下面的 SQL 语句，给这个表插入几条数据：

-- 第一条
INSERT INTO demo.goodsmaster
(
  barcode,
  goodsname,
  price
)
VALUES
(
  '0001',
  '书',
  0.47
);
-- 第二条
INSERT INTO demo.goodsmaster
(
  barcode,
  goodsname,
  price
)
VALUES
(
  '0002',
  '笔',
  0.44
);
-- 第三条
INSERT INTO demo.goodsmaster
(
  barcode,
  goodsname,
  price
)
VALUES
(
  '0002',
  '胶水',
  0.19
);

现在，我们运行一个查询语句，看看现在表里数据的情况：

SELECT * from demo.goodsmaster;

这个时候，我们可以得到下面的结果：

mysql> SELECT *
    -> FROM demo.goodsmaster;
+---------+-----------+-------+------------+
| barcode | goodsname | price | itemnumber |
+---------+-----------+-------+------------+
| 0001    | 书        |  0.47 |          1 |
| 0002    | 笔        |  0.44 |          2 |
| 0002    | 胶水      |  0.19 |          3 |
+---------+-----------+-------+------------+
3 rows in set (0.00 sec)

然后我们用下面的 SQL 语句，把这 3 个价格加在一起，看看得到了什么：

SELECT SUM(price)
FROM demo.goodsmaster;

这里我们又用到一个关键字 SUM，这是 MySQL 中的求和函数，是 MySQL 聚合函数的一种，你只要知道这个函数表示计算字段值的和就可以了。

我们期待的运行结果是：0.47 + 0.44 + 0.19 = 1.1，可是，我们得到的是：

mysql> SELECT SUM(price)
    -> FROM demo.goodsmaster;
+--------------------+
| SUM(price)         |
+--------------------+
| 1.0999999999999999 |
+--------------------+

查询结果是 1.0999999999999999。看到了吗？虽然误差很小，但确实有误差。

你也可以尝试把数据类型改成 FLOAT，然后运行求和查询，得到的是，1.0999999940395355。显然，误差更大了。

虽然 1.10 和 1.0999999999999999 好像差不多，但是我们有时候需要以通过数值对比为条件进行查询，一旦出现误差，就查不出需要的结果了：

SELECT *
FROM demo.goodsmaster
WHERE SUM(price)=1.1

那么，为什么会存在这样的误差呢？问题还是出在 MySQL 对浮点类型数据的存储方式上。

MySQL 用 4 个字节存储 FLOAT 类型数据，用 8 个字节来存储 DOUBLE 类型数据。无论哪个，都是采用二进制的方式来进行存储的。比如 9.625，用二进制来表达，就是 1001.101，或者表达成 1.001101×2^3。看到了吗？如果尾数不是 0 或 5（比如 9.624），你就无法用一个二进制数来精确表达。怎么办呢？就只好在取值允许的范围内进行近似（四舍五入）。

现在你一定明白了，为什么数据类型是 DOUBLE 的时候，我们得到的结果误差更小一些，而数据类型是 FLOAT 的时候，误差会更大一下。原因就是，DOUBLE 有 8 位字节，精度更高。

说到这里，我想你已经彻底理解了浮点数据类型不精准的原因了。

那么，MySQL 有没有精准的数据类型呢？当然有，这就是定点数类型：DECIMAL。

就像浮点数类型的存储方式，决定了它不可能精准一样，DECIMAL 的存储方式决定了它一定是精准的。

浮点数类型是把十进制数转换成二进制数存储，DECIMAL 则不同，它是把十进制数的整数部分和小数部分拆开，分别转换成十六进制数，进行存储。这样，所有的数值，就都可以精准表达了，不会存在因为无法表达而损失精度的问题。

MySQL 用 DECIMAL（M,D）的方式表示高精度小数。其中，M 表示整数部分加小数部分，一共有多少位，M<=65。D 表示小数部分位数，D<M。

下面我们就用刚才的表 demo.goodsmaster 验证一下。

首先，我们运行下面的语句，把字段“price”的数据类型修改为 DECIMAL(5,2)：

ALTER TABLE demo.goodsmaster
MODIFY COLUMN price DECIMAL(5,2);

然后，我们再一次运行求和语句：

SELECT SUM(price)
FROM demo.goodsmaster;

这次，我们得到了完美的结果：1.10。

由于 DECIMAL 数据类型的精准性，在我们的项目中，除了极少数（比如商品编号）用到整数类型外，其他的数值都用的是 DECIMAL，原因就是这个项目所处的零售行业，要求精准，一分钱也不能差。

当然，在一些对精度要求不高的场景下，比起占用同样的字节长度的定点数，浮点数表达的数值范围可以更大一些。

简单小结下浮点数和定点数的特点：浮点类型取值范围大，但是不精准，适用于需要取值范围大，又可以容忍微小误差的科学计算场景（比如计算化学、分子建模、流体动力学等）；定点数类型取值范围相对小，但是精准，没有误差，适合于对精度要求极高的场景（比如涉及金额计算的场景）。

文本类型

在实际的项目中，我们还经常遇到一种数据，就是字符串数据。比如，刚刚那个简单的表 demo.goodsmaster 中，有两个字段“barcode”和“goodsname”。它们当中存储的条码、商品名称，都是字符串数据。这两个字段的数据类型，我们都选择了 TEXT 类型。

TEXT 类型是 MySQL 支持的文本类型的一种。此外，MySQL 还支持 CHAR、VARCHAR、ENUM 和 SET 等文本类型。我们来看看它们的区别。

CHAR(M)：固定长度字符串。CHAR(M) 类型必须预先定义字符串长度。如果太短，数据可能会超出范围；如果太长，又浪费存储空间。

VARCHAR(M)： 可变长度字符串。VARCHAR(M) 也需要预先知道字符串的最大长度，不过只要不超过这个最大长度，具体存储的时候，是按照实际字符串长度存储的。

TEXT：字符串。系统自动按照实际长度存储，不需要预先定义长度。

ENUM： 枚举类型，取值必须是预先设定的一组字符串值范围之内的一个，必须要知道字符串所有可能的取值。

SET：是一个字符串对象，取值必须是在预先设定的字符串值范围之内的 0 个或多个，也必须知道字符串所有可能的取值。

对于 ENUM 类型和 SET 类型来说，你必须知道所有可能的取值，所以只能用在某些特定场合，比如某个参数设定的取值范围只有几个固定值的场景。

因为不需要预先知道字符串的长度，系统会按照实际的数据长度进行存储，所以 TEXT 类型最为灵活方便，所以下面我们重点学习一下它。

TEXT 类型也有 4 种，它们的区别就是最大长度不同。

TINYTEXT：255 字符（这里假设字符是 ASCII 码，一个字符占用一个字节，下同）。

TEXT： 65535 字符。

MEDIUMTEXT：16777215 字符。

LONGTEXT： 4294967295 字符（相当于 4GB）。

不过，需要注意的是，TEXT 也有一个问题：由于实际存储的长度不确定，MySQL 不允许 TEXT 类型的字段做主键。遇到这种情况，你只能采用 CHAR(M)，或者 VARCHAR(M)。

所以，我建议你，在你的项目中，只要不是主键字段，就可以按照数据可能的最大长度，选择这几种 TEXT 类型中的的一种，作为存储字符串的数据类型。

日期与时间类型

除了刚刚说的这 3 种类型，还有一类也是经常用到的，那就是日期与时间类型。

日期与时间是重要的信息，在我们的系统中，几乎所有的数据表都用得到。原因是客户需要知道数据的时间标签，从而进行数据查询、统计和处理。

用得最多的日期时间类型，就是 DATETIME。虽然 MySQL 也支持 YEAR（年）、TIME（时间）、DATE（日期），以及 TIMESTAMP 类型，但是我建议你，在实际项目中，尽量用 DATETIME 类型。因为这个数据类型包括了完整的日期和时间信息，使用起来比较方便。毕竟，如果日期时间信息分散在好几个字段，就会很不容易记，而且查询的时候，SQL 语句也会更加复杂。

这里我也给你列出了 MySQL 支持的其他日期时间类型的一些参数：

可以看到，不同数据类型表示的时间内容不同、取值范围不同，而且占用的字节数也不一样，你要根据实际需要灵活选取。

不过，我也给你一条小建议：为了确保数据的完整性和系统的稳定性，优先考虑使用 DATETIME 类型。因为虽然 DATETIME 类型占用的存储空间最多，但是它表达的时间最为完整，取值范围也最大。

另外，这里还有个问题，为什么时间类型 TIME 的取值范围不是 -23:59:59～23:59:59 呢？原因是 MySQL 设计的 TIME 类型，不光表示一天之内的时间，而且可以用来表示一个时间间隔，这个时间间隔可以超过 24 小时。

时间类型的应用场景还是比较广的，后面我会单独用一节课来讲在数据库中处理时间的问题。这节课，你一定要知道 MySQL 支持哪几种时间类型，它们的区别是什么，这样在学后面的内容时，才能游刃有余。

总结

今天，我给你介绍了几个常用的字段数据类型，包括整数类型、浮点数类型、定点数类型、文本类型和日期时间类型。同时，我们还清楚了为什么整数类型用得少，浮点数为什么不精准，以及常用的日期时间类型。

另外，我们还学习了几个新的 SQL 语句。尤其是第 2 条，我们在项目中会经常用到，你一定要重点牢记。

-- 修改字段类型语句
ALTER TABLE demo.goodsmaster
MODIFY COLUMN price DOUBLE;
-- 计算字段合计函数：
SELECT SUM(price)
FROM demo.goodsmaster;

最后，我还想再给你分享 1 个小技巧。在定义数据类型时，如果确定是整数，就用 INT；如果是小数，一定用定点数类型 DECIMAL；如果是字符串，只要不是主键，就用 TEXT；如果是日期与时间，就用 DATETIME。

整数：INT。

小数：DECIMAL。

字符串：TEXT。

日期与时间：DATETIME。

这样做的好处是，首先确保你的系统不会因为数据类型定义出错。

不过，凡事都是有两面的，可靠性好，并不意味着高效。比如，TEXT 虽然使用方便，但是效率不如 CHAR(M) 和 VARCHAR(M)。如果你有进一步优化的需求，我再给你分享一份文档，你可以对照着看下。

思考题

假设用户需要一个表来记录会员信息，会员信息包括会员卡编号、会员名称、会员电话、积分值。如果要你为这些字段定义数据类型，你会如何选择呢？为什么？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享你的朋友或同事，我们下节课见。

03 | 表：怎么创建和修改表？

你好，我是朱晓峰。今天，我们来聊一聊怎么创建和修改数据表。

创建和修改数据表，是数据存储过程中的重要一环。我们不仅需要把表创建出来，还需要正确地设置限定条件，这样才能确保数据的一致性和完整性。同时，表中的数据会随着业务需求的变化而变化，添加和修改相应的字段也是常见的操作。这节课，我们就来学习下具体的方法。

在我们的超市项目里，客户经常需要进货，这就需要在 MySQL 数据库里面创建一个表，来管理进货相关的数据。我们先看看这个表里有什么内容。

假设这个表叫做进货单头表（importhead），如下图所示：

这里的 1、2、3 表示门店的 3 种进货方式，分别是配送中心配送、门店采买和供货商直供。

其中，“1（配送中心配送）”是标准进货方式。因为超市是连锁经营，为了确保商品质量和品类一致，超过 9 成的门店进货，是通过配送中心进行配送的。因此，我们希望这个字段的值能够默认是 1，这样一来，除非有特别的指定，否则，门店进货单的进货方式，就自动设置成“1”了。

现在，客户需要一个类似的表来存储进货数据，而且进货方式还有 3 个可能的取值范围，需要设置默认值，那么，应该怎么创建这个表呢？另外，创建好表以后，又该怎么进行修改呢？

如何创建数据表？

首先，我们要知道 MySQL 创建表的语法结构：

CREATE TABLE <表名>
{
字段名1 数据类型 [字段级别约束] [默认值]，
字段名2 数据类型 [字段级别约束] [默认值]，
......
[表级别约束]
};

在这里，我们通过定义表名、表中的字段、表的属性等，把一张表创建出来。

你可能注意到了，在 MySQL 创建表的语法结构里面，有一个词叫做“约束”。“约束”限定了表中数据应该满足的条件。MySQL 会根据这些限定条件，对表的操作进行监控，阻止破坏约束条件的操作执行，并提示错误，从而确保表中数据的唯一性、合法性和完整性。这是创建表时不可缺少的一部分。

下面我来带你创建刚刚提到的进货单表。需要注意的是，这里我们需要定义默认值，也就是要定义默认值约束，除此之外，还有很多种约束，一会儿我再细讲。

我们先来看基本的数据表创建流程，创建代码如下：

CREATE TABLE demo.importhead
(
listnumber INT,
supplierid INT,
stocknumber INT,
--我们在字段importype定义为INT类型的后面，按照MySQL创建表的语法，加了默认值1。
importtype INT DEFAULT 1,
quantity DECIMAL(10,3),
importvalue DECIMAL(10,2),
recorder INT,
recordingdate DATETIME
);

运行这个 SQL 语句，表 demo.importhead 就按照我们的要求被创建出来了。

在创建表的时候，字段名称要避开 MySQL 的系统关键字，原因是 MySQL 系统保留的关键字都有特定的含义，如果作为字段名称出现在 SQL 语句中，MySQL 会把这个字段名称理解为系统关键字，从而导致 SQL 语句无法正常运行。比如，刚刚我们把进货金额设置为“importvalue”，而不是“value”，就是因为，“value”是 MySQL 的系统关键字。

好了，现在我们尝试往刚刚创建的表里插入一条记录，来验证一下对字段“importtype”定义的默认值约束是否起了作用。

INSERT INTO demo.importhead
(
listnumber,
supplierid,
stocknumber,
-- 这里我们没有插入字段importtype的值
quantity,
importvalue,
recorder,
recordingdate
)
VALUES
(
3456,
1,
1,
10,
100,
1,
'2020-12-10'
);

插入完成后，我们来查询一下表的内容：

SELECT *
FROM demo.importhead

运行结果如下：

mysql> select * from demo.importhead;
+------------+------------+-------------+------------+----------+-------------+----------+---------------------+
| listnumber | supplierid | stocknumber | importtype | quantity | importvalue | recorder | recordingdate       |
+------------+------------+-------------+------------+----------+-------------+----------+---------------------+
|       1234 |          1 |           1 |          1 |   10.000 |      100.00 |        1 | 2020-12-10 00:00:00 |
|       2345 |          1 |           1 |          2 |   20.000 |     2000.00 |        1 | 2020-12-10 00:00:00 |
|       3456 |          1 |           1 |          1 |   20.000 |     2000.00 |        1 | 2020-12-10 00:00:00 |
+------------+------------+-------------+------------+----------+-------------+----------+---------------------+
3 rows in set (0.00 sec)

你会发现，字段 importtype 的值已经是 1 了。这样，通过在创建表的时候设置默认值，我们就实现了将字段的默认值定义为 1 的目的。

到这里，表就被创建出来了。

都有哪些约束？

刚刚这种给字段设置默认值的做法，就是默认约束。设置了默认约束，插入数据的时候，如果不明确给字段赋值，那么系统会把设置的默认值自动赋值给字段。

除了默认约束，还有主键约束、外键约束、非空约束、唯一性约束和自增约束。

我们在上节课里学的主键，其实就是主键约束，我就不多说了。外键约束涉及表与表之间的关联，以及确保表的数据一致性的问题，内容比较多，后面我在讲“关联表”的时候，再给你具体解释。

现在，我来重点给你介绍一下非空约束、唯一性约束和自增约束。

1. 非空约束

非空约束表示字段值不能为空，如果创建表的时候，指明某个字段非空，那么添加数据的时候，这个字段必须有值，否则系统就会提示错误。

2. 唯一性约束

唯一性约束表示这个字段的值不能重复，否则系统会提示错误。跟主键约束相比，唯一性约束要更加弱一些。

在一个表中，我们可以指定多个字段满足唯一性约束，而主键约束则只能有一个，这也是 MySQL 系统决定的。另外，满足主键约束的字段，自动满足非空约束，但是满足唯一性约束的字段，则可以是空值。

为了方便你理解，我来举个例子。比如，我们有个商品信息表 goodsmaster，如下所示：

barcode 代表条码，goodsname 代表名称。为了防止条码重复，我们可以定义字段“barcode”满足唯一性约束。这样一来，条码就不能重复，但是可以为空，而且只能有一条记录条码为空。

同样道理，为了防止名称重复，我们也可以定义字段“goodsname”满足唯一性约束。但是，由于无论名称和条码都可能重用，或者可能为空，都不适合做主键。因此，对这个表来说，可以添加一个满足唯一性要求的新字段来做主键。

3. 自增约束

自增约束可以让 MySQL 自动给字段赋值，且保证不会重复，非常有用，只是不容易用好。所以，我借助一个例子来给你具体讲一讲。

假如我们有这样一个商品信息表：

从这个表中，我们可以看到，barcode、goodsname 和 price 都不能确保唯一性，所以没有任何一个字段可以做主键，因此，我们可以自己添加一个字段 itemnumber，并且每次添加一条数据的时候，要给值增加 1。怎么实现呢？我们就可以通过定义自增约束的方式，让系统自动帮我们赋值，从而满足唯一性，这样就可以做主键了。

这里，有 2 个问题需要你注意一下。

第一，在数据表中，只有整数类型的字段（包括 TINYINT、SMALLINT、MEDIUMINT、INT 和 BIGINT），才可以定义自增约束。自增约束的字段，每增加一条数据，值自动增加 1。

第二，你可以给自增约束的字段赋值，这个时候，MySQL 会重置自增约束字段的自增基数，下次添加数据的时候，自动以自增约束字段的最大值加 1 为新的字段值。

举个例子，我们通过 Workbench 把数据表 demo.goodsmaster 中的字段 itemnumber，定义为满足自增约束，如下图所示：

然后，我们插入一条测试记录：

INSERT INTO demo.goodsmaster
(
itemnumber,
barcode,
goodsname,
specification,
unit,
price
)
VALUES
(
-- 指定商品编号为100：
100,
'0003',
'测试1',
'',
'个',
10
);

运行这条语句之后，查看表的内容，我们得到：

mysql> select * from demo.goodsmaster;
+------------+---------+-----------+---------------+------+----+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          1 | 0001    | 书        | 16开          | 本   | 89.00 |
|          2 | 0002    | 地图      | NULL          | 张   |  9.90 |
|          3 | 0003    | 笔        | 10支          | 包   |  3.00 |
|        100 | 0003    | 测试1     |               | 个   | 10.00 |
+------------+---------+-----------+---------------+------+-------+
4 rows in set (0.02 sec)

这个时候，字段“itemnumber”的值不连续，最大值是我们刚刚插入的 100。

接着，我们再插入一条数据：

INSERT INTO demo.goodsmaster
(
-- 不指定自增字段itemnumber的值
barcode,
goodsname,
specification,
unit,
supplierid,
price
)
VALUES
(
'0004',
'测试2',
'',
'个',
1,
20
);

运行这条语句之后，我们再查看表的内容：

mysql> select * from demo.goodsmaster;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          1 | 0001    | 书        | 16开          | 本   | 89.00 |
|          2 | 0002    | 地图      | NULL          | 张   |  9.90 |
|          3 | 0003    | 笔        | 10支          | 包   |  3.00 |
|        100 | 0003    | 测试1     |               | 个   | 10.00 |
|        101 | 0004    | 测试2     |               | 个   | 20.00 |
+------------+---------+-----------+---------------+------+-------+
5 rows in set (0.00 sec)

可以看到，系统自动给自增字段“itemnumber”，在最大值的基础之上加了 1，赋值为 101。

好了，到这里，我们就学会了创建表和定义约束的方法。约束要根据业务需要定义在相应的字段上，这样才能保证数据是准确的，你一定要注意它的使用方法。

如何修改表？

创建完表以后，我们经常还需要修改表，下面我们就来学习下修改表的方法。

在咱们的超市项目中，当我们创建新表的时候，会出现这样的情况：我们前面创建的进货单表，是用来存储进货数据的。但是，我们还要创建一个进货单历史表（importheadhist），用来存储验收过的进货数据。这个表的结构跟进货单表类似，只是多了 2 个字段，分别是验收人（confirmer）和验收时间（confirmdate）。针对这种情况，我们很容易想到可以通过复制表结构，然后在这个基础上通过修改表结构，来创建新的表。具体怎么实现呢？接下来，我就给你讲解一下。

首先，我们要把原来的表结构复制一下，代码如下：

CREATE TABLE demo.importheadhist
LIKE demo.importhead;

运行这个语句之后，一个跟 demo.importhead 有相同表结构的空表 demo.importheadhist，就被创建出来了。

这个新创建出来的表，还不是我们需要的表，我们需要对这个表进行修改，通过添加字段和修改字段，来获得我们需要的“进货单历史表”。

添加字段

你可能会想到，我们可以通过 Workbench，用可视化操作来修改表的结构，如下图所示：

这样当然没问题，但是我想给你讲一个更方便灵活的方式：用 SQL 语句来修改表的结构。

现在我要给这个新的表增加 2 个字段：confirmer 和 confirmdate，就可以用下面的代码：

mysql> ALTER TABLE demo.importheadhist
    -> ADD confirmer INT; -- 添加一个字段confirmer，类型INT
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> ALTER TABLE demo.importheadhist
    -> ADD confirmdate DATETIME; -- 添加一个字段confirmdate，类型是DATETIME
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

运行这个 SQL 语句，查看表的结构，你会发现，在字段的最后，多了两个字段：

“confirmer”，数据类型是 INT；

“confirmdate"，类型是 DATETIME。

我们来查看一下表结构：

mysql> DESCRIBE demo.importheadhist;
+----------------+---------------+------+-----+---------+-------+
| Field          | Type          | Null | Key | Default | Extra |
+----------------+---------------+------+-----+---------+-------+
| listnumber     | int           | NO   | PRI | NULL    |       |
| supplierid     | int           | NO   |     | NULL    |       |
| stocknumber    | int           | NO   |     | NULL    |       |
| importtype     | int           | YES  |     | 1       |       |
| quantity       | decimal(10,3) | YES  |     | NULL    |       |
| importvalue    | decimal(10,2) | YES  |     | NULL    |       |
| recorder       | int           | YES  |     | NULL    |       |
| recordingdate  | datetime      | YES  |     | NULL    |       |
| confirmer      | int           | YES  |     | NULL    |       |
| confirmdate    | datetime      | YES  |     | NULL    |       |
+----------------+---------------+------+-----+---------+-------+
10 rows in set (0.02 sec)

这样，通过简单增加 2 个字段，我们就获得了进货单历史表。

修改字段

除了添加字段，我们可能还要修改字段，比如，我们要把字段名称“quantity”改成“importquantity”，并且把字段类型改为 DOUBLE，该怎么操作呢？

我们可以通过修改表结构语句 ALTER TABLE，来修改字段：

mysql> ALTER TABLE demo.importheadhist
    -> CHANGE quantity importquantity DOUBLE;
Query OK, 0 rows affected (0.15 sec)
Records: 0  Duplicates: 0  Warnings: 0

运行这个 SQL 语句，查看表的结构，我们会得到下面的结果：

mysql> DESCRIBE demo.importheadhist;
+----------------+---------------+------+-----+---------+-------+
| Field          | Type          | Null | Key | Default | Extra |
+----------------+---------------+------+-----+---------+-------+
| listnumber     | int           | NO   | PRI | NULL    |       |
| supplierid     | int           | NO   |     | NULL    |       |
| stocknumber    | int           | NO   |     | NULL    |       |
| importtype     | int           | YES  |     | 1       |       |
| importquantity | double        | YES  |     | NULL    |       |
| importvalue    | decimal(10,2) | YES  |     | NULL    |       |
| recorder       | int           | YES  |     | NULL    |       |
| recordingdate  | datetime      | YES  |     | NULL    |       |
| confirmer      | int           | YES  |     | NULL    |       |
| confirmdate    | datetime      | YES  |     | NULL    |       |
+----------------+---------------+------+-----+---------+-------+
10 rows in set (0.02 sec)

可以看到，字段名称和字段类型全部都改过来了。

如果你不想改变字段名称，只想改变字段类型，例如，把字段“importquantity”类型改成 DECIMAL(10,3)，你可以这样写：

ALTER TABLE demo.importheadhist
MODIFY importquantity DECIMAL(10,3);

运行 SQL 语句，查看表结构，你会发现，已经改过来了。

mysql> DESCRIBE demo.importheadhist;
+----------------+---------------+------+-----+---------+-------+
| Field          | Type          | Null | Key | Default | Extra |
+----------------+---------------+------+-----+---------+-------+
| listnumber     | int           | NO   | PRI | NULL    |       |
| supplierid     | int           | NO   |     | NULL    |       |
| stocknumber    | int           | NO   |     | NULL    |       |
| importtype     | int           | YES  |     | 1       |       |
| importquantity | decimal(10,3) | YES  |     | NULL    |       |
| importvalue    | decimal(10,2) | YES  |     | NULL    |       |
| recorder       | int           | YES  |     | NULL    |       |
| recordingdate  | datetime      | YES  |     | NULL    |       |
| confirmer      | int           | YES  |     | NULL    |       |
| confirmdate    | datetime      | YES  |     | NULL    |       |
+----------------+---------------+------+-----+---------+-------+
10 rows in set (0.02 sec)

我们还可以通过 SQL 语句向表中添加一个字段，我们甚至可以指定添加字段在表中的位置。

比如说，在字段 supplierid 之后，添加一个字段 suppliername，数据类型是 TEXT。我们可以这样写 SQL 语句：

ALTER TABLE demo.importheadhist
ADD suppliername TEXT AFTER supplierid;

运行 SQL 语句，查看表结构：

mysql> DESCRIBE demo.importheadhist;
+----------------+---------------+------+-----+---------+-------+
| Field          | Type          | Null | Key | Default | Extra |
+----------------+---------------+------+-----+---------+-------+
| listnumber     | int           | NO   | PRI | NULL    |       |
| supplierid     | int           | NO   |     | NULL    |       |
| suppliername   | text          | YES  |     | NULL    |       |
| stocknumber    | int           | NO   |     | NULL    |       |
| importtype     | int           | YES  |     | 1       |       |
| importquantity | decimal(10,3) | YES  |     | NULL    |       |
| importvalue    | decimal(10,2) | YES  |     | NULL    |       |
| recorder       | int           | YES  |     | NULL    |       |
| recordingdate  | datetime      | YES  |     | NULL    |       |
| confirmer      | int           | YES  |     | NULL    |       |
| confirmdate    | datetime      | YES  |     | NULL    |       |
+----------------+---------------+------+-----+---------+-------+
11 rows in set (0.02 sec)

到这里，我们就完成了修改字段在表中位置的操作。

总结

这节课，我们学习了创建和修改数据表的具体方法。在讲创建表时，我讲到了一个重要的概念，就是约束，包括默认约束、非空约束、唯一性约束和自增约束等。

默认值约束：就是给字段设置一个默认值。

非空约束：就是声明字段不能为空值。

唯一性约束：就是声明字段不能重复。

自增约束：就是声明字段值能够自动加 1，且不会重复。

在修改表时，我们可以通过修改已经存在的表创建新表，也可以通过添加字段、修改字段的方式来修改数据表。

最后，我给你汇总了一些常用的创建表的 SQL 语句，你一定要牢记。

CREATE TABLE
(
字段名 字段类型 PRIMARY KEY
);
CREATE TABLE
(
字段名 字段类型 NOT NULL
);
CREATE TABLE
(
字段名 字段类型 UNIQUE
);
CREATE TABLE
(
字段名 字段类型 DEFAULT 值
);
-- 这里要注意自增类型的条件，字段类型必须是整数类型。
CREATE TABLE
(
字段名 字段类型 AUTO_INCREMENT
);
-- 在一个已经存在的表基础上，创建一个新表
CREATE demo.importheadhist LIKE demo.importhead;
-- 修改表的相关语句
ALTER TABLE 表名 CHANGE 旧字段名 新字段名 数据类型;
ALTER TABLE 表名 ADD COLUMN 字段名 字段类型 FIRST|AFTER 字段名;
ALTER TABLE 表名 MODIFY 字段名 字段类型 FIRST|AFTER 字段名;

对于初学者来说，掌握了今天的内容，就足够对数据表进行操作了。不过，MySQL 支持的数据表操作不只这些，我来举几个简单的小例子，你可以了解一下，有个印象。

比如，你可以在表一级指定表的存储引擎：

ALTER TABLE 表名 ENGINE=INNODB;

你还可以通过指定关键字 AUTO_EXTENDSIZE，来指定存储文件自增空间的大小，从而提高存储空间的利用率。

在 MySQL 8.0.23 之后的版本中，你甚至还可以通过 INVISIBLE 关键字，使字段不可见，但却可以使用。

如果你想了解更多有关数据表的操作，我也给你提供两份资料：MySQL 创建表文档和MySQL 修改表文档。这些都是 MySQL 的官方文档，相信会对你有所帮助。

思考题

请你写一个 SQL 语句，将表 demo.goodsmaster 中的字段“salesprice”改成不能重复，并且不能为空。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

04 | 增删改查：如何操作表中的数据？

你好，我是朱晓峰。今天，我们来聊一聊如何操作数据表里的数据。

在咱们的超市项目中，我们给用户设计好了一个数据表 demo.goodsmaster，定义好了里面的字段，以及各种约束，如下所示：

mysql> DESCRIBE demo.goodsmaster;
+---------------+--------------+------+-----+---------+--+
| Field         | Type         | Null | Key | Default |Extra        |
+---------------+------------+------+-----+---------+------------+
| itemnumber    | int        | NO   | PRI | NULL    |auto_increment |
| barcode       | text       | NO   |     | NULL    |                |
| goodsname     | text       | NO   |     | NULL    |                |
| specification | text       | YES  |     | NULL    |                |
| unit          | text       | YES  |     | NULL    |                |
| price         | decimal(10,2)| NO   |     | NULL    |              |
+---------------+------------+------+-----+---------+----------------+
6 rows in set (0.02 sec)

现在，我们需要使用这个表来存取数据。那么，如何对表里面的数据进行操作呢？接下来，我就给你讲讲操作表中的数据的方法，也就是常说的“增删改查”。

添加数据

我们先来看一下添加数据的语法结构：

INSERT INTO 表名 [(字段名 [,字段名] ...)] VALUES (值的列表);

这里方括号“[]”表示里面的内容可选，也就是说，根据 MySQL 的语法要求，写不写都可以。

添加数据分为两种情况：插入数据记录和插入查询结果。下面我分别来介绍下。

插入数据记录

其实，MySQL 支持的数据插入操作十分灵活。你既可以通过给表里面所有的字段赋值，完整地插入一条数据记录，也可以在插入记录的时候，只给部分字段赋值。

这是什么意思呢？我借助一个例子来给你讲解下。

比如，我们有一个叫 demo.goodsmaster 的表，包括了 itemnumber、barcode、goodsname、specification、unit 和 price 共 6 个字段。我想要插入一条数据记录，其中包含了所有字段的值，就可以这样操作：

INSERT INTO demo.goodsmaster
(
  itemnumber,
  barcode,
  goodsname,
  specification,
  unit,
  price
)
VALUES
(
  4,
  '0003',
  '尺子',
  '三角型',
  '把',
  5
);

运行这个 SQL 语句，然后对数据表进行查询，可以得到下面的结果：

mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          4 | 0003    | 尺子      | 三角型        | 把   |  5.00 |
+------------+---------+-----------+---------------+------+-------+
1 row in set (0.01 sec)

如果我想插入一条记录，但是只给部分字段赋值，可不可以呢？比如说，客户有个商品，需要马上上线销售，目前只知道条码、名称和价格，其它的信息先不录入，等之后再补，可以实现吗？

我们来尝试一下只给 3 个字段赋值，看看实际操作的结果：

INSERT INTO demo.goodsmaster
(
-- 这里只给3个字段赋值，itemnumber、specification、unit不赋值
  barcode,
  goodsname,
  price
)
VALUES
(
  '0004',
  '测试',
  10
);

运行这条 SQL 语句，我们来查询表的内容，就会发现，显然是可以的。

mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          4 | 0003    | 尺子      | 三角型        | 把   |  5.00 |
|          5 | 0004    | 测试      | NULL          | NULL | 10.00 |
+------------+---------+-----------+---------------+------+-------+
2 rows in set (0.00 sec)

我们之所以能够在插入一条数据记录的时候，只给部分字段赋值，原因就在于我们对字段的定义方式。

那字段是怎么定义的呢？我们来查看一下表的结构，看看各个字段的定义：

mysql> DESCRIBE demo.goodsmaster;
+---------------+---------------+------+-----+---------+----------------+
| Field         | Type          | Null | Key | Default | Extra          |
+---------------+---------------+------+-----+---------+----------------+
| itemnumber    | int           | NO   | PRI | NULL    | auto_increment |
| barcode       | text          | NO   |     | NULL    |                |
| goodsname     | text          | NO   |     | NULL    |                |
| specification | text          | YES  |     | NULL    |                |
| unit          | text          | YES  |     | NULL    |                |
| price         | decimal(10,2) | NO   |     | NULL    |                |
+---------------+---------------+------+-----+---------+----------------+
6 rows in set (0.01 sec)

可以看到，我们在插入数据时没有明确赋值的 3 个字段，都有着各自的特点。“specification”和“unit”都可以是空值，而 itemnumber 则定义了自增约束。

我们在插入一条数据记录的时候，必须要考虑字段约束的 3 种情况。

第一种情况是，如果字段允许为空，而我们没有给它赋值，那么 MySQL 会自动给它们赋予空值。在刚刚的代码中，“specification”“unit”字段都允许为空，因此，如果数据插入语句没有指定这几个字段的值，MySQL 会自动插入空值。

第二种情况是，如果字段是主键，就不能为空，这个时候，MySQL 会按照我们添加的约束进行处理。比如字段“itemnumber”是主键，不能为空，而我们定义了自增约束，所以 MySQL 自动在之前的最大值基础上加了 1。因此，“itemnumber”也有了自己该有的值。

第三种情况是，如果有一个字段定义不能为空，又不是主键，当你插入一条数据记录的时候，就必须给这个记录赋值。

如果我们的操作违反了字段的约束限制，会出现什么情况呢？

比如说，我们尝试把表 demo.goodsmaster 的字段“speicification”改为不能为空：

ALTER TABLE demo.goodsmaster
MODIFY specification TEXT NOT NULL;

运行这个 SQL 语句，系统会提示错误，原因就是我们刚才部分插入了一条数据记录，没有给字段“specification”赋值，这跟我们给字段“specification”添加非空约束的操作冲突了。

因此，我们要把字段“speicification”的值为空的数据记录删除，然后再修改字段约束：

DELETE
FROM demo.goodsmaster
WHERE itemnumber=5;

删除数据记录之后，再运行上面的语句，给字段“specification”添加非空约束，就成功了。

现在我们来验证一下非空约束。我们尝试部分插入一条数据记录，不给字段“specification”赋值：

INSERT INTO demo.goodsmaster
(
  barcode,
  goodsname,
  price
)
VALUES
(
  '0004',
  '测试',
  10
);

运行这个 SQL 语句，MySQL 报告错误，提示字段“specification”没有默认值。也就是说，这个字段不能为空，如果插入数据时不给它赋值，就必须给它一个默认值。

现在，你一定清楚了，部分插入一条数据记录是可以的，但前提是，没有赋值的字段，一定要让 MySQL 知道如何处理，比如可以为空、有默认值，或者是自增约束字段，等等，否则，MySQL 会提示错误的。

好了，到这里，我们就学会了给 MySQL 的数据表插入一条数据记录。但是，在实际工作中，一次只插入一条数据，有时候会不够用。

我们的项目就有这样的场景：门店每天的销售流水都很多，日积月累，流水表变得越来越大。如果就让它这么不断地增长，数据量甚至会达到数亿条，占据的存储空间达到几十个 G。虽然 MySQL 可以处理这样比较大的数据表，但是每次操作的响应时间就会延长，这会导致系统的整体效率下降。

刚开始的时候，我们开发了一个日结处理，当天算清所有账目。其中一个步骤就是，把当天流水表的数据全部转到历史流水表当中去。现在，我们就可以用上数据插入语句了。具体有 2 步：

从流水表中取出一条数据；

把这条数据插入到历史流水表中。

然后不断重复这两个步骤，一直到把今天流水表的数据全部插入到历史流水表当中去。

你肯定会说，这种做法的效率也太低了吧？有没有更好的办法呢？

当然是有的。这个时候，我们就要用到 MySQL 的另一种数据插入操作了：把查询结果插入到数据表中。

插入查询结果

MySQL 支持把查询的结果插入到数据表中，我们可以指定字段，甚至是数值，插入到数据表中。语法结构如下：

INSERT INTO 表名 （字段名）
SELECT 字段名或值
FROM 表名
WHERE 条件

举个例子，在我们的超市信息系统的 MySQL 数据库中，历史流水表设计与流水表非常类似。不同的是，历史流水表增加了一些字段来标识历史流水的状态，比如日结时间字段，是用来记录日结操作是什么时候进行的。

用 INSERT 语句实现起来也很简单：

INSERT INTO 历史流水表 （日结时间字段，其他字段）
SELECT 获取当前时间函数，其他字段
FROM 流水表

好了，添加数据的操作就讲完了，现在你知道了，我们给一张数据表插入一条数据记录的时候，可以给所有的字段赋值，也可以给部分字段赋值。这取决于字段的定义。如果字段不能为空并且没有默认值，就必须赋值。另外，我们还可以通过把一个查询结果插入数据表中的方式，提高添加数据的效率。

接下来，我们再来看看如何删除数据。

删除数据

数据删除的语法很简单，如下所示：

DELETE FROM 表名
WHERE 条件

如果我们现在想把刚才用过的表 demo.goodsmaster 里的内容清理一下，删除全部数据，可以通过下面的 SQL 语句来实现：

DELETE FROM demo.goodsmaster;

因为我们的查询主要是在 MySQL 的图形化管理工具中进行，现在，我们在 Workbench 中运行上面的 SQL 语句。这个时候，Workbench 会提示错误。

这是因为，Workbench 自动处于安全模式，它要求对数据的删除或修改操作中必须包含 WHERE 条件。而且，这个 WHERE 条件中，必须用到主键约束或者唯一性约束的字段。MySQL 的这种安全性设置，主要就是为了防止删除或者修改数据时出现误操作，导致删除或修改了不相关的数据。

因此，我们要习惯在删除数据的时候，添加条件语句 WHERE，防止误操作。

假设我们的数据表 demo.goodsmaster 中有如下数据记录：

mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          4 | 0003    | 尺子      | 三角型        | 把   |  5.00 |
|          5 | 0004    | 测试      | NULL          | NULL | 10.00 |
+------------+---------+-----------+---------------+------+-------+
2 rows in set (0.00 sec)

如果我们要删除所有数据，可以这样写：

DELETE FROM demo.goodsmaster
WHERE itemnumber > 1;

好了，下面我们来学习一下修改数据。

修改数据

我们来看一下 MySQL 的数据修改语法：

UPDATE 表名
SET 字段名=值
WHERE 条件

语法很简单，需要注意的一点是，不要修改主键字段的值。因为主键是数据记录的唯一标识，如果修改了主键的值，就有可能会破坏数据的完整性。

下面我们来看一个通过主键查询商品信息的例子，看看修改主键的值，会产生什么样的结果。

mysql> SELECT *
    -> FROM demo.goodsmaster
    -> WHERE itemnumber = 3;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          3 | 0003    | 尺子      | 三角型        | 把   |  5.00 |
+------------+---------+-----------+---------------+------+-------+
1 row in set (0.00 sec)

我们能查询到一条商品编号是 3 的数据记录：条码是“0003”，名称是“尺子”，价格是 5 元。

如果我们修改了主键的值，就可能会改变刚才的查询结果：

mysql> UPDATE demo.goodsmaster
    -> SET itemnumber=2
    -> WHERE itemnumber = 3;
Query OK, 1 row affected (0.02 sec)
Rows matched: 1  Changed: 1  Warnings: 0

mysql> SELECT *
    -> FROM demo.goodsmaster
    -> WHERE itemnumber = 3;
Empty set (0.00 sec)

可以看到，查询结果是空，因为商品编号是 3 的数据记录，已经不存在了。

当然，如果你必须要修改主键的值，那有可能就是主键设置得不合理。关于主键的设置问题，我会在下节课给你详细介绍。这里你只需要知道，不要随便修改表的主键值，就可以了。

查询数据

经过了前面的操作之后，现在，我们知道了把数据存入数据表里，以及删除和修改表里数据的方法。那么如何知道数据操作的结果呢？这就要用到数据查询了。

我们先来看下查询语句的语法结构：

SELECT *|字段列表
FROM 数据源
WHERE 条件
GROUP BY 字段
HAVING 条件
ORDER BY 字段
LIMIT 起始点，行数

在这些字段中，SELECT、WHERE、GROUP BY 和 HAVING 比较好理解，你只要知道它们的含义就可以了。

SELECT：是查询关键字，表示我们要做一个查询。“*”是一个通配符，表示我们要查询表中所有的字段。你也可以把要查询的字段罗列出来，这样，查询的结果可以只显示你想要查询的字段内容。

WHERE：表示查询条件。你可以把你要查询的数据所要满足的条件，放在 WHERE 关键字之后。

GROUP BY：作用是告诉 MySQL，查询结果要如何分组，经常与 MySQL 的聚合函数一起使用。

HAVING：用于筛选查询结果，跟 WHERE 类似。

FROM、ORDER BY 和 LIMIT 相对来说比较复杂，需要注意的地方比较多，我来具体给你解释一下。

FROM

FROM 关键字表示查询的数据源。我们现在只学习了单个数据表，你可以把你要查询的数据表名，直接写在 FROM 关键字之后。以后我们在学到关联表的时候，你就会知道，FROM 关键字后面，还可以跟着更复杂的数据表联接。

这里需要你要注意的是，数据源也不一定是表，也可以是一个查询的结果。比如下面的查询：

这里你要注意的是，红色框里的部分叫做派生表（derived table），或者子查询（subquery），意思是我们把一个查询结果数据集当做一个虚拟的数据表来看待。MySQL 规定，必须要用 AS 关键字给这个派生表起一个别名。在这张图中，我给这个派生表起了个名字，叫做“a”。

ORDER BY

ORDER BY 的作用，是告诉 MySQL，查询结果如何排序。ASC 表示升序，DESC 表示降序。

我来举个简单的小例子，带你看看 ORDER BY 是怎么使用的（这里我们仍然假设字段“specification”和“unit”允许为空）。我们向表 demo.goodsmaster 中插入 2 条数据：

INSERT INTO demo.goodsmaster
(
  barcode,
  goodsname,
  price
)
VALUES
(
  '0003',
  '尺子1',
  15
);
INSERT INTO demo.goodsmaster
(
  barcode,
  goodsname,
  price
)
VALUES
(
  '0004',
  '测试1',
  20
);

如果我们不控制查询结果的顺序，就会得到这样的结果：

mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          4 | 0003    | 尺子      | 三角型        | 把   |  5.00 |
|          5 | 0004    | 测试      | NULL          | NULL | 10.00 |
|          6 | 0003    | 尺子1     | NULL          | NULL | 15.00 |
|          7 | 0004    | 测试1     | NULL          | NULL | 20.00 |
+------------+---------+-----------+---------------+------+-------+
4 rows in set (0.00 sec)

如果我们使用 ORDER BY 对查询结果进行控制，结果就不一样了：

mysql> SELECT *
    -> FROM demo.goodsmaster
    -> ORDER BY barcode ASC,price DESC;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          6 | 0003    | 尺子1     | NULL          | NULL | 15.00 |
|          4 | 0003    | 尺子      | 三角型        | 把   |  5.00 |
|          7 | 0004    | 测试1     | NULL          | NULL | 20.00 |
|          5 | 0004    | 测试      | NULL          | NULL | 10.00 |
+------------+---------+-----------+---------------+------+-------+
4 rows in set (0.00 sec)

可以看到，查询结果会先按照字段 barcode 的升序排序，相同 barcode 里面的字段，按照 price 的降序排序。

LIMIT

LIMIT 的作用是告诉 MySQL 只显示部分查询的结果。比如，现在我们的数据表 demo.goodsmaster 中有 4 条数据，我们只想要显示第 2、3 条数据，就可以用 LIMIT 关键字来实现：

mysql> SELECT *
    -> FROM demo.goodsmaster
    -> LIMIT 1,2;
+------------+---------+-----------+---------------+------+-------+
| itemnumber | barcode | goodsname | specification | unit | price |
+------------+---------+-----------+---------------+------+-------+
|          5 | 0004    | 测试      | NULL          | NULL | 10.00 |
|          6 | 0003    | 尺子1     | NULL          | NULL | 15.00 |
+------------+---------+-----------+---------------+------+-------+
2 rows in set (0.00 sec)

这里的“LIMIT 1,2”中，“1”表示起始位置，MySQL 中，起始位置的起点是 0，1 表示从第 2 条记录开始；“2”表示 2 条数据。因此，“LIMIT 1,2”就表示从第 2 条数据开始，显示 2 条数据，也就是显示了第 2、3 条数据。

总结

今天，我们学习了添加、删除、修改和查询数据的方法，这些都是我们经常遇到的操作，你一定要好好学习。下面的这些 SQL 语句，是我总结的数据操作语法，你一定要重点掌握。

INSERT INTO 表名 [(字段名 [,字段名] ...)] VALUES (值的列表);

INSERT INTO 表名 （字段名）
SELECT 字段名或值
FROM 表名
WHERE 条件

DELETE FROM 表名
WHERE 条件

UPDATE 表名
SET 字段名=值
WHERE 条件

SELECT *|字段列表
FROM 数据源
WHERE 条件
GROUP BY 字段
HAVING 条件
ORDER BY 字段
LIMIT 起始点，行数

最后，我再补充一点。如果我们把查询的结果插入到表中时，导致主键约束或者唯一性约束被破坏了，就可以用“ON DUPLICATE”关键字进行处理。这个关键字的作用是，告诉 MySQL，如果遇到重复的数据，该如何处理。

我来给你举个例子。

假设用户有 2 个各自独立的门店，分别有自己的系统。现在需要引入连锁经营的模式，把 2 个店用一套系统统一管理。那么首先遇到的问题就是，需要进行数据整合。下面我们就以商品信息表为例，来说明如何通过使用“ON DUPLICATE”关键字，把两个门店的商品信息数据整合到一起。

假设门店 A 的商品信息表是“demo.goodsmaster”，代码如下：

mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
|          1 | 0001    | 书        | 16开          | 本   |      89.00 |
|          2 | 0002    | 笔        | 10支装        | 包   |       5.00 |
|          3 | 0003    | 橡皮      | NULL          | 个   |       3.00 |
+------------+---------+-----------+---------------+------+------------+
3 rows in set (0.00 sec)

门店 B 的商品信息表是“demo.goodsmaster1”：

mysql> SELECT *
    -> FROM demo.goodsmaster1;
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
|          1 | 0001    | 教科书    | NULL          | NULL |      89.00 |
|          4 | 0004    | 馒头      |               |      |       1.50 |
+------------+---------+-----------+---------------+------+------------+
2 rows in set (0.00 sec)

假设我们要把门店 B 的商品数据，插入到门店 A 的商品表中去，如果有重复的商品编号，就用门店 B 的条码，替换门店 A 的条码，用门店 B 的商品名称，替换门店 A 的商品名称；如果没有重复的编号，就直接把门店 B 的商品数据插入到门店 A 的商品表中。这个操作，就可以用下面的 SQL 语句实现：

INSERT INTO demo.goodsmaster
SELECT *
FROM demo.goodsmaster1 as a
ON DUPLICATE KEY UPDATE barcode = a.barcode,goodsname=a.goodsname;
-- 运行结果如下
mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
|          1 | 0001    | 教科书    | 16开          | 本   |      89.00 |
|          2 | 0002    | 笔        | 10支装        | 包   |       5.00 |
|          3 | 0003    | 橡皮      | NULL          | 个   |       3.00 |
|          4 | 0004    | 馒头      |               |      |       1.50 |
+------------+---------+-----------+---------------+------+------------+
4 rows in set (0.00 sec)

最后，我再跟你分享 3 份资料，分别是MySQL 数据插入、MySQL 数据更新和MySQL 数据查询，如果你在工作中遇到了更加复杂的操作需求，就可以参考一下。

思考题

我想请你思考一个问题：商品表 demo.goodsmaster 中，字段“itemnumber”是主键，而且满足自增约束，如果我删除了一条记录，再次插入数据的时候，就会出现字段“itemnumber”的值不连续的情况。请你想一想，如何插入数据，才能防止这种情况的发生呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享你的朋友或同事，我们下节课见。

05 | 主键：如何正确设置主键？

你好，我是朱晓峰，今天我们来聊一聊如何用好 MySQL 的主键。

前面在讲存储的时候，我提到过主键，它可以唯一标识表中的某一条记录，对数据表来说非常重要。当我们需要查询和引用表中的一条记录的时候，最好的办法就是通过主键。只有合理地设置主键，才能确保我们准确、快速地找到所需要的数据记录。今天我就借助咱们的超市项目的实际需求，来给你讲一讲怎么正确设置主键。

在我们的项目中，客户要进行会员营销，相应的，我们就需要处理会员信息。会员信息表（demo.membermaster）的设计大体如下：

为了能够唯一地标识一个会员的信息，我们需要为会员信息表设置一个主键。那么，怎么为这个表设置主键，才能达到我们理想的目标呢？

今天，我就带你在解决这个实际问题的过程中，学习下三种设置主键的思路：业务字段做主键、自增字段做主键和手动赋值字段做主键。

业务字段做主键

针对这个需求，我们最容易想到的，是选择表中已有的字段，也就是跟业务相关的字段做主键。那么，在这个表里，哪个字段比较合适呢？我们来分析一下。

会员卡号（cardno）看起来比较合适，因为会员卡号不能为空，而且有唯一性，可以用来标识一条会员记录。我们来尝试一下用会员卡号做主键。

我们可以用下面的代码，在创建表的时候，设置字段 cardno 为主键：

mysql> CREATE TABLE demo.membermaster
-> (
-> cardno CHAR(8) PRIMARY KEY, -- 会员卡号为主键
-> membername TEXT,
-> memberphone TEXT,
-> memberpid TEXT,
-> memberaddress TEXT,
-> sex TEXT,
-> birthday DATETIME
-> );
Query OK, 0 rows affected (0.06 sec)

我们来查询一下表的结构，确认下主键是否创建成功了：

mysql> DESCRIBE demo.membermaster;
+---------------+----------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+---------------+----------+------+-----+---------+-------+
| cardno | char(8) | NO | PRI | NULL | |
| membername | text | YES | | NULL | |
| memberphone | text | YES | | NULL | |
| memberpid | text | YES | | NULL | |
| memberaddress | text | YES | | NULL | |
| sex | text | YES | | NULL | |
| birthday | datetime | YES | | NULL | |
+---------------+----------+------+-----+---------+-------+
7 rows in set (0.02 sec)

可以看到，字段 cardno 在表示键值的 key 这一列的值是“PRI”，意思是 PRIMARY KEY，这就表示它已经被设置成主键了。这里需要注意的一点是，除了字段 cardno，所有的字段都允许为空。这是因为，这些信息有可能当时不知道，要稍后补齐。

会员卡号做主键有没有什么问题呢？我们插入 2 条数据来验证下：

mysql> INSERT INTO demo.membermaster
-> (
-> cardno,
-> membername,
-> memberphone,
-> memberpid,
-> memberaddress,
-> sex,
-> birthday
-> )
-> VALUES
-> (
-> '10000001',
-> '张三',
-> '13812345678',
-> '110123200001017890',
-> '北京',
-> '男',
-> '2000-01-01'
-> );
Query OK, 1 row affected (0.01 sec)

mysql> INSERT INTO demo.membermaster
-> (
-> cardno,
-> membername,
-> memberphone,
-> memberpid,
-> memberaddress,
-> sex,
-> birthday
-> )
-> VALUES
-> (
-> '10000002',
-> '李四',
-> '13512345678',
-> '123123199001012356',
-> '上海',
-> '女',
-> '1990-01-01'
-> );
Query OK, 1 row affected (0.01 sec)

插入成功后，我们来看一下表的内容：

mysql> SELECT *
    -> FROM demo.membermaster;
+----------+------------+-------------+--------------------+---------------+------+---------------------+
| cardno   | membername | memberphone | memberpid          | memberaddress | sex  | birthday            |
+----------+------------+-------------+--------------------+---------------+------+---------------------+
| 10000001 | 张三       | 13812345678 | 110123200001017890 | 北京          | 男   | 2000-01-01 00:00:00 |
| 10000002 | 李四       | 13512345678 | 123123199001012356 | 上海          | 女   | 1990-01-01 00:00:00 |
+----------+------------+-------------+--------------------+---------------+------+---------------------+
2 rows in set (0.00 sec)

我们发现，不同的会员卡号对应不同的会员，字段“cardno”唯一地标识某一个会员。如果都是这样，会员卡号与会员一一对应，系统是可以正常运行的。

但是实际情况是，上线不到一周，就发生了“cardno”无法唯一识别某一个会员的问题。原来，会员卡号存在重复使用的情况。

这也很好理解，比如，张三因为工作变动搬离了原来的地址，不再到商家的门店消费了（退还了会员卡），于是张三就不再是这个商家门店的会员了。但是，商家不想让这个会员卡空着，就把卡号是“10000001”的会员卡发给了王五。

从系统设计的角度看，这个变化只是修改了会员信息表中的卡号是“10000001”这个会员信息，并不会影响到数据一致性。也就是说，修改会员卡号是“10000001”的会员信息，系统的各个模块，都会获取到修改后的会员信息，不会出现“有的模块获取到修改之前的会员信息，有的模块获取到修改后的会员信息，而导致系统内部数据不一致”的情况。因此，从信息系统层面上看是没问题的。但是从使用系统的业务层面来看，就有很大的问题了，会对商家造成影响。

下面，我们就来看看这种修改，是如何影响到商家的。

比如，我们有一个销售流水表，记录了所有的销售流水明细。2020 年 12 月 01 日，张三在门店购买了一本书，消费了 89 元。那么，系统中就有了张三买书的流水记录，如下所示：

我们可以用下面的代码创建销售流水表。因为需要引用会员信息和商品信息，所以表中要包括商品编号字段和会员卡号字段。

mysql> CREATE table demo.trans
-> (
-> transactionno INT,
-> itemnumber INT,  -- 为了引用商品信息
-> quantity DECIMAL(10,3),
-> price DECIMAL(10,2),
-> salesvalue DECIMAL(10,2),
-> cardno CHAR(8),  -- 为了引用会员信息
-> transdate DATETIME
-> );
Query OK, 0 rows affected (0.10 sec)

创建好表以后，我们来插入一条销售流水：

mysql> INSERT INTO demo.trans
-> (
-> transactionno,
-> itemnumber,
-> quantity,
-> price,
-> salesvalue,
-> cardno,
-> transdate
-> )
-> VALUES
-> (
-> 1,
-> 1,
-> 1,
-> 89,
-> 89,
-> '10000001',
-> '2020-12-01'
-> );
Query OK, 1 row affected (0.01 sec)

接着，我们查询一下 2020 年 12 月 01 日的会员销售记录：

mysql> SELECT b.membername,c.goodsname,a.quantity,a.salesvalue,a.transdate
-> FROM demo.trans AS a
-> JOIN demo.membermaster AS b
-> JOIN demo.goodsmaster AS c
-> ON (a.cardno = b.cardno AND a.itemnumber=c.itemnumber);
+------------+-----------+----------+------------+---------------------+
| membername | goodsname | quantity | salesvalue | transdate |
+------------+-----------+----------+------------+---------------------+
| 张三 | 书 | 1.000 | 89.00 | 2020-12-01 00:00:00 |
+------------+-----------+----------+------------+---------------------+
1 row in set (0.00 sec)

我们得到的查询结果是：张三，在 2020 年 12 月 01 日买了一本书，花了 89 元。

需要注意的是，这里我用到了 JOIN，也就是表的关联，目的是为了引用其他表的信息，包括会员信息表（demo.membermaster）和商品信息表（demo.goodsmaster）。有关关联表查询的具体细节，我会在下节课讲到，这里你只要知道，通过关联查询，可以从会员信息表中获取会员信息，从商品信息表中获取商品信息，就可以了。

下面，我们假设会员卡“10000001”又发给了王五，我们需要更改会员信息表：

mysql> UPDATE demo.membermaster
-> SET membername = '王五',
-> memberphone = '13698765432',
-> memberpid = '475145197001012356',
-> memberaddress='天津',
-> sex='女',
-> birthday = '1970-01-01'
-> WHERE cardno = '10000001';
Query OK, 1 row affected (0.02 sec)
Rows matched: 1 Changed: 1 Warnings: 0

会员记录改好了，我们再次运行之前的会员消费流水查询：

mysql> SELECT b.membername,c.goodsname,a.quantity,a.salesvalue,a.transdate
-> FROM demo.trans AS a
-> JOIN demo.membermaster AS b
-> JOIN demo.goodsmaster AS c
-> ON (a.cardno = b.cardno AND a.itemnumber=c.itemnumber);
+------------+-----------+----------+------------+---------------------+
| membername | goodsname | quantity | salesvalue | transdate |
+------------+-----------+----------+------------+---------------------+
| 王五 | 书 | 1.000 | 89.00 | 2020-12-01 00:00:00 |
+------------+-----------+----------+------------+---------------------+
1 row in set (0.01 sec)

这次得到的结果是：王五在 2020 年 12 月 01 日，买了一本书，消费 89 元。

很明显，这个结果把张三的消费行为放到王五身上去了，肯定是不对的。这里的原因就是，我们把会员卡号是“10000001”的会员信息改了，而会员卡号是主键，会员消费查询通过会员卡号关联到会员信息，得到了完全错误的结果。

现在你知道了吧，千万不能把会员卡号当做主键。

那么，会员电话可以做主键吗？不行的。在实际操作中，手机号也存在被运营商收回，重新发给别人用的情况。

那身份证号行不行呢？好像可以。因为身份证决不会重复，身份证号与一个人存在一一对应的关系。可问题是，身份证号属于个人隐私，顾客不一定愿意给你。对门店来说，顾客就是上帝，要是强制要求会员必须登记身份证号，会把很多客人赶跑的。其实，客户电话也有这个问题，这也是我们在设计会员信息表的时候，允许身份证号和电话都为空的原因。

这样看来，任何一个现有的字段都不适合做主键。

所以，我建议你尽量不要用业务字段，也就是跟业务有关的字段做主键。毕竟，作为项目设计的技术人员，我们谁也无法预测在项目的整个生命周期中，哪个业务字段会因为项目的业务需求而有重复，或者重用之类的情况出现。

既然业务字段不可以，那我们再来试试自增字段。

使用自增字段做主键

我们来给会员信息表添加一个字段，比如叫 id，给这个字段定义自增约束，这样，我们就有了一个具备唯一性的，而且不为空的字段来做主键了。

接下来，我们就来修改一下会员信息表的结构，添加一个自增字段做主键。

第一步，修改会员信息表，删除表的主键约束，这样，原来的主键字段，就不再是主键了。不过需要注意的是，删除主键约束，并不会删除字段。

mysql> ALTER TABLE demo.membermaster
-> DROP PRIMARY KEY;
Query OK, 2 rows affected (0.12 sec)
Records: 2 Duplicates: 0 Warnings: 0

第二步，修改会员信息表，添加字段“id”为主键，并且给它定义自增约束：

mysql> ALTER TABLE demo.membermaster
-> ADD id INT PRIMARY KEY AUTO_INCREMENT;
Query OK, 0 rows affected (0.12 sec)
Records: 0 Duplicates: 0 Warnings: 0

第三步，修改销售流水表，添加新的字段 memberid，对应会员信息表中的主键：

mysql> ALTER TABLE demo.trans
-> ADD memberid INT;
Query OK, 0 rows affected (0.04 sec)
Records: 0 Duplicates: 0 Warnings: 0

第四步，我们更新一下销售流水表，给新添加的字段“memberid”赋值，让它指向对应的会员信息：

mysql> UPDATE demo.trans AS a,demo.membermaster AS b
-> SET a.memberid=b.id
-> WHERE a.transactionno > 0
--> AND a.cardno = b.cardno; -- 这样操作可以不用删除trans的内容，在实际工作中更适合
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

这个更新语句包含了 2 个关联的表，看上去有点复杂。其实，你完全可以通过删除表 demo.trans、重建表，再插入一条数据的操作，来达到同样的目的，但是我不建议你这么做。

在实际操作中，你不一定能删掉 demo.trans 这个表，因为这个表里面可能已经有了很多重要的数据。所以，你一定要认真学习一下我给你介绍的这个更新数据的方法，这种复杂一点的更新语句在实战中更有用。

好了，到这里，我们就完成了数据表的重新设计，让我们看一下新的数据表 demo.membermaster 和 demo.trans 的结构：

mysql> DESCRIBE demo.membermaster;
+---------------+----------+------+-----+---------+----------------+
| Field         | Type     | Null | Key | Default | Extra          |
+---------------+----------+------+-----+---------+----------------+
| cardno        | char(8)  | NO   |     | NULL    |                |
| membername    | text     | YES  |     | NULL    |                |
| memberphone   | text     | YES  |     | NULL    |                |
| memberpid     | text     | YES  |     | NULL    |                |
| memberaddress | text     | YES  |     | NULL    |                |
| sex           | text     | YES  |     | NULL    |                |
| birthday      | datetime | YES  |     | NULL    |                |
| id            | int      | NO   | PRI | NULL    | auto_increment |
+---------------+----------+------+-----+---------+----------------+
8 rows in set (0.02 sec)

mysql> DESCRIBE demo.trans;
+---------------+---------------+------+-----+---------+-------+
| Field         | Type          | Null | Key | Default | Extra |
+---------------+---------------+------+-----+---------+-------+
| transactionno | int           | NO   | PRI | NULL    |       |
| itemnumber    | int           | YES  |     | NULL    |       |
| quantity      | decimal(10,3) | YES  |     | NULL    |       |
| price         | decimal(10,2) | YES  |     | NULL    |       |
| salesvalue    | decimal(10,2) | YES  |     | NULL    |       |
| cardno        | char(8)       | YES  |     | NULL    |       |
| transdate     | datetime      | YES  |     | NULL    |       |
| memberid      | int           | YES  |     | NULL    |       |
+---------------+---------------+------+-----+---------+-------+
8 rows in set (0.00 sec)

现在，如果我们再次面对卡号重用的情况，该如何应对呢（这里我们假设回到修改会员卡 10000001 为王五之前的状态）？

如果张三的会员卡“10000001”不再使用，发给了王五，我们就在会员信息表里面增加一条记录：

mysql> INSERT INTO demo.membermaster
-> (
-> cardno,
-> membername,
-> memberphone,
-> memberpid,
-> memberaddress,
-> sex,
-> birthday
-> )
-> VALUES
-> (
-> '10000001',
-> '王五',
-> '13698765432',
-> '475145197001012356',
-> '天津',
-> '女',
-> '1970-01-01'
-> );
Query OK, 1 row affected (0.02 sec)

下面我们看看现在的会员信息：

mysql> SELECT *
    -> FROM demo.membermaster;
+----------+------------+-------------+--------------------+---------------+------+---------------------+----+
| cardno   | membername | memberphone | memberpid          | memberaddress | sex  | birthday            | id |
+----------+------------+-------------+--------------------+---------------+------+---------------------+----+
| 10000001 | 张三       | 13812345678 | 110123200001017890 | 北京          | 男   | 2000-01-01 00:00:00 |  1 |
| 10000002 | 李四       | 13512345678 | 123123199001012356 | 上海          | 女   | 1990-01-01 00:00:00 |  2 |
| 10000001 | 王五       | 13698765432 | 475145197001012356 | 天津          | 女   | 1970-01-01 00:00:00 |  3 |
+----------+------------+-------------+--------------------+---------------+------+---------------------+----+
3 rows in set (0.00 sec)

由于字段“cardno”不再是主键，可以允许重复，因此，我们可以在保留会员“张三”信息的同时，添加使用同一会员卡号的“王五”的信息。

现在再来查会员消费，就不会出问题了：

mysql> SELECT b.membername,c.goodsname,a.quantity,a.salesvalue,a.transdate
-> FROM demo.trans AS a
-> JOIN demo.membermaster AS b
-> JOIN demo.goodsmaster AS c
-> ON (a.memberid = b.id AND a.itemnumber=c.itemnumber);
+------------+-----------+----------+------------+---------------------+
| membername | goodsname | quantity | salesvalue | transdate |
+------------+-----------+----------+------------+---------------------+
| 张三 | 书 | 1.000 | 89.00 | 2020-12-01 00:00:00 |
+------------+-----------+----------+------------+---------------------+
1 row in set (0.01 sec)

可以看到，结果是 2020 年 12 月 01 日，张三买了一本书，消费 89 元，是正确的。

如果是一个小项目，只有一个 MySQL 数据库服务器，用添加自增字段作为主键的办法是可以的。不过，这并不意味着，在任何情况下你都可以这么做。

举个例子，用户要求把增加新会员的工作放到门店进行，因为发展新会员的工作一般是在门店进行的，毕竟，人们一般都是在购物的同时申请会员。解决的办法是，门店的信息系统添加新增会员的功能，把新的会员信息先存放到本地 MySQL 数据库中，再上传到总部，进行汇总。

可是问题来了，如果会员信息表的主键是自增的，那么各个门店新加的会员就会出现“id”冲突的可能。

比如，A 店的 MySQL 数据库中的 demo.membermaster 中，字段“id”的值是 100，这个时候，新增了一个会员，“id”是 101。同时，B 店的字段“id”值也是 100，要加一个新会员，“id”也是 101，毕竟，B 店的 MySQL 数据库与 A 店相互独立。等 A 店与 B 店都把新的会员上传到总部之后，就会出现两个“id”是 101，但却是不同会员的情况，这该如何处理呢？

手动赋值字段做主键

为了解决这个问题，我们想了一个办法：取消字段“id”的自增属性，改成信息系统在添加会员的时候对“id”进行赋值。

具体的操作是这样的：在总部 MySQL 数据库中，有一个管理信息表，里面的信息包括成本核算策略，支付方式等，还有总部的系统参数，我们可以在这个表中添加一个字段，专门用来记录当前会员编号的最大值。

门店在添加会员的时候，先到总部 MySQL 数据库中获取这个最大值，在这个基础上加 1，然后用这个值作为新会员的“id”，同时，更新总部 MySQL 数据库管理信息表中的当前会员编号的最大值。

这样一来，各个门店添加会员的时候，都对同一个总部 MySQL 数据库中的数据表字段进行操作，就解决了各门店添加会员时会员编号冲突的问题，同时也避免了使用业务字段导致数据错误的问题。

总结

今天，我给你介绍了设置数据表主键的三种方式：数据表的业务字段做主键、添加自增字段做主键，以及添加手动赋值字段做主键。

用业务字段做主键，看起来很简单，但是我们应该尽量避免这样做。因为我们无法预测未来会不会因为业务需要，而出现业务字段重复或者重用的情况。

自增字段做主键，对于单机系统来说是没问题的。但是，如果有多台服务器，各自都可以录入数据，那就不一定适用了。因为如果每台机器各自产生的数据需要合并，就可能会出现主键重复的问题。

我们可以采用手动赋值的办法，通过一定的逻辑，确保字段值在全系统的唯一性，这样就可以规避主键重复的问题了。

刚开始使用 MySQL 时，很多人都很容易犯的错误是喜欢用业务字段做主键，想当然地认为了解业务需求，但实际情况往往出乎意料，而更改主键设置的成本非常高。所以，如果你的系统比较复杂，尽量给表加一个字段做主键，采用手动赋值的办法，虽然系统开发的时候麻烦一点，却可以避免后面出大问题。

思考题

在刚刚的例子中，如果我想把销售流水表 demo.trans 中，所有单位是“包”的商品的价格改成原来价格的 80%，该怎么实现呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

06 | 外键和连接：如何做关联查询？

你好，我是朱晓峰。今天我来和你聊一聊关联查询的问题。

在实际的数据库应用开发过程中，我们经常需要把 2 个或 2 个以上的表进行关联，以获取需要的数据。这是因为，为了提高存取效率，我们会把不同业务模块的信息分别存放在不同的表里面。但是，从业务层面上看，我们需要完整全面的信息为经营决策提供数据支撑。

就拿咱们的超市项目来说，数据库里面的销售流水表一般只保存销售必需的信息（比如商品编号、数量、价格、金额和会员卡号等）。但是，在呈现给超市经营者的统计报表里面，只包括这些信息是不够的，比如商品编号、会员卡号，这些数字经营者就看不懂。因此，必须要从商品信息表提取出商品信息，从会员表中提取出会员的相关信息，这样才能形成一个完整的报表。这种把分散在多个不同的表里的数据查询出来的操作，就是多表查询。

不过，这种查询可不简单，我们需要建立起多个表之间的关联，然后才能去查询，同时还需要规避关联表查询中的常见错误。具体怎么做呢？我来借助实际的项目给你讲一讲。

在我们项目的进货模块，有这样 2 个数据表，分别是进货单头表（importhead）和进货单明细表（importdetails），我们每天都要对这两个表进行增删改查的操作。

进货单头表记录的是整个进货单的总体信息：

进货单明细表记录了每次进货的商品明细信息。一条进货单头数据记录，对应多条进货商品的明细数据，也就是所谓的一对多的关系。具体信息如下表所示：

现在我们需要查询一次进货的所有相关数据，包括进货单的总体信息和进货商品的明细，这样一来，我们就需要把 2 个表关联起来，那么，该怎么操作呢？

在 MySQL 中，为了把 2 个表关联起来，会用到 2 个重要的功能：外键（FOREIGN KEY）和连接（JOIN）。外键需要在创建表的阶段就定义；连接可以通过相同意义的字段把 2 个表连接起来，用在查询阶段。

接下来，我就先和你聊聊外键。

如何创建外键？

我先来解释一下什么是外键。

假设我们有 2 个表，分别是表 A 和表 B，它们通过一个公共字段“id”发生关联关系，我们把这个关联关系叫做 R。如果“id”在表 A 中是主键，那么，表 A 就是这个关系 R 中的主表。相应的，表 B 就是这个关系中的从表，表 B 中的“id”，就是表 B 用来引用表 A 中数据的，叫外键。所以，外键就是从表中用来引用主表中数据的那个公共字段。

为了方便你理解，我画了一张图来展示：

如图所示，在关联关系 R 中，公众字段（字段 A）是表 A 的主键，所以表 A 是主表，表 B 是从表。表 B 中的公共字段（字段 A）是外键。

在 MySQL 中，外键是通过外键约束来定义的。外键约束就是约束的一种，它必须在从表中定义，包括指明哪个是外键字段，以及外键字段所引用的主表中的主键字段是什么。MySQL 系统会根据外键约束的定义，监控对主表中数据的删除操作。如果发现要删除的主表记录，正在被从表中某条记录的外键字段所引用，MySQL 就会提示错误，从而确保了关联数据不会缺失。

外键约束可以在创建表的时候定义，也可以通过修改表来定义。我们先来看看外键约束定义的语法结构：

[CONSTRAINT <外键约束名称>] FOREIGN KEY 字段名
REFERENCES <主表名> 字段名

你可以在创建表的时候定义外键约束：

CREATE TABLE 从表名
(
  字段名 类型,
  ...
-- 定义外键约束，指出外键字段和参照的主表字段
CONSTRAINT 外键约束名
FOREIGN KEY (字段名) REFERENCES 主表名 (字段名)
)

当然，你也可以通过修改表来定义外键约束：

ALTER TABLE 从表名 ADD CONSTRAINT 约束名 FOREIGN KEY 字段名 REFERENCES 主表名 （字段名）;

一般情况下，表与表的关联都是提前设计好了的，因此，会在创建表的时候就把外键约束定义好。不过，如果需要修改表的设计（比如添加新的字段，增加新的关联关系），但没有预先定义外键约束，那么，就要用修改表的方式来补充定义。

下面，我就来讲一讲怎么创建外键约束。

先创建主表 demo.importhead：

CREATE TABLE demo.importhead (
    listnumber INT PRIMARY KEY,
    supplierid INT,
    stocknumber INT,
    importtype INT,
    importquantity DECIMAL(10 , 3 ),
    importvalue DECIMAL(10 , 2 ),
    recorder INT,
    recordingdate DATETIME
);

然后创建从表 demo.importdetails，并且给它定义外键约束：

CREATE TABLE demo.importdetails
(
  listnumber INT,
  itemnumber INT,
  quantity DECIMAL(10,3),
  importprice DECIMAL(10,2),
  importvalue DECIMAL(10,2),
  -- 定义外键约束，指出外键字段和参照的主表字段
  CONSTRAINT fk_importdetails_importhead
  FOREIGN KEY (listnumber) REFERENCES importhead (listnumber)
);

运行这个 SQL 语句，我们就在创建表的同时定义了一个名字叫“fk_importdetails_importhead”的外键约束。同时，我们声明，这个外键约束的字段“listnumber”引用的是表 importhead 里面的字段“listnumber”。

外键约束创建好之后，我们可以通过 Workbench，来查看外键约束是不是创建成功了：

当然，我们也可以通过 SQL 语句来查看，这里我们要用到 MySQL 自带的、用于存储系统信息的数据库：information_schema。我们可以查看外键约束的相关信息：

mysql> SELECT
    ->     constraint_name, -- 表示外键约束名称
    ->     table_name, -- 表示外键约束所属数据表的名称
    ->     column_name, -- 表示外键约束的字段名称
    ->     referenced_table_name, -- 表示外键约束所参照的数据表名称
    ->     referenced_column_name -- 表示外键约束所参照的字段名称
    -> FROM
    ->     information_schema.KEY_COLUMN_USAGE
    -> WHERE
    ->     constraint_name = 'fk_importdetails_importhead';
+-----------------------------+---------------+-------------+-----------------------+------------------------+
| CONSTRAINT_NAME             | TABLE_NAME    | COLUMN_NAME | REFERENCED_TABLE_NAME | REFERENCED_COLUMN_NAME |
+-----------------------------+---------------+-------------+-----------------------+------------------------+
| fk_importdetails_importhead | importdetails | listnumber  | importhead            | listnumber             |
+-----------------------------+---------------+-------------+-----------------------+------------------------+
1 row in set (0.05 sec)

通过查询，我们可以看到，外键约束所在的表是“importdetails”，外键字段是“listnumber”，参照的主表是“importhead”，参照的主表字段是“listnumber”。这样，通过定义外键约束，我们已经建立起了 2 个表之间的关联关系。

关联关系建立起来之后，如何才能获取我们需要的数据呢？这时，我们就需要用到连接查询了。

连接

在 MySQL 中，有 2 种类型的连接，分别是内连接（INNER JOIN）和外连接（OUTER JOIN）。

内连接表示查询结果只返回符合连接条件的记录，这种连接方式比较常用；

外连接则不同，表示查询结果返回某一个表中的所有记录，以及另一个表中满足连接条件的记录。

下面我先来讲一下内连接。

在 MySQL 里面，关键字 JOIN、INNER JOIN、CROSS JOIN 的含义是一样的，都表示内连接。我们可以通过 JOIN 把两个表关联起来，来查询两个表中的数据。

我借助一个小例子，来帮助你理解。

咱们的项目中有会员销售的需求，所以，我们的流水表中的数据记录，既包括非会员的普通销售，又包括会员销售。它们的区别是，会员销售的数据记录包括会员编号，而在非会员销售的数据记录中，会员编号为空。

来看一下项目中的销售表（demo.trans)。实际的销售表比较复杂，为了方便你理解，我把表进行了简化，并且假设业务字段 cardno 是会员信息表的主键。简化以后的结构如下所示：

再看下简化后的会员信息表（demo.membermaster）：

这两个表之间存在关联关系，表 demo.trans 中的字段“cardno”是这个关联关系中的外键。

我们可以通过内连接，查询所有会员销售的流水记录：

mysql> SELECT
    ->     a.transactionno,
    ->     a.itemnumber,
    ->     a.quantity,
    ->     a.price,
    ->     a.transdate,
    ->     b.membername
    -> FROM
    ->     demo.trans AS a
    ->         JOIN
    ->     demo.membermaster AS b ON (a.cardno = b.cardno);
+---------------+------------+----------+-------+---------------------+------------+
| transactionno | itemnumber | quantity | price | transdate           | membername |
+---------------+------------+----------+-------+---------------------+------------+
|             1 |          1 |    1.000 | 89.00 | 2020-12-01 00:00:00 | 张三       |
+---------------+------------+----------+-------+---------------------+------------+
1 row in set (0.00 sec)

可以看到，我们通过公共字段“cardno”把两个表关联到了一起，查询出了会员消费的数据。

在这里，关键字 JOIN 与关键字 ON 配对使用，意思是查询满足关联条件“demo.trans 表中 cardno 的值与 demo.membermaster 表中的 cardno 值相等”的两个表中的所有记录。

知道了内连接，我们再来学习下外连接。跟内连接只返回符合连接条件的记录不同的是，外连接还可以返回表中的所有记录，它包括两类，分别是左连接和右连接。

左连接，一般简写成 LEFT JOIN，返回左边表中的所有记录，以及右表中符合连接条件的记录。

右连接，一般简写成 RIGHT JOIN，返回右边表中的所有记录，以及左表中符合连接条件的记录。

当我们需要查询全部流水信息的时候，就会用到外连接，代码如下：

SELECT
    a.transactionno,
    a.itemnumber,
    a.quantity,
    a.price,
    a.transdate,
    b.membername
FROM demo.trans AS a
LEFT JOIN demo.membermaster AS b -- LEFT JOIN，以demo.transaction为主
ON (a.cardno = b.cardno);

可以看到，我用到了 LEFT JOIN，意思是以表 demo.trans 中的数据记录为主，这个表中的数据记录要全部出现在结果集中，同时给出符合连接条件（a.cardno=b.cardno) 的表 demo.membermaster 中的字段 membername 的值。

我们也可以使用 RIGHT JOIN 实现同样的效果，代码如下：

SELECT
    a.transactionno,
    a.itemnumber,
    a.quantity,
    a.price,
    a.transdate,
    b.membername
FROM
    demo.membermaster AS b
        RIGHT JOIN
    demo.trans AS a ON (a.cardno = b.cardno); -- RIGHT JOIN，顺序颠倒了，还是以demo.trans为主

其实，这里就是把顺序颠倒了一下，意思是一样的。运行之后，我们都能得到下面的结果：

mysql> SELECT
    ->     a.transactionno,
    ->     a.itemnumber,
    ->     a.quantity,
    ->     a.price,
    ->     a.transdate,
    ->     b.membername
    -> FROM
    ->     demo.trans AS a
    ->         LEFT JOIN   -- 左连接
    ->     demo.membermaster AS b ON (a.cardno = b.cardno);
+---------------+------------+----------+-------+---------------------+------------+
| transactionno | itemnumber | quantity | price | transdate           | membername |
+---------------+------------+----------+-------+---------------------+------------+
|             1 |          1 |    1.000 | 89.00 | 2020-12-01 00:00:00 | 张三       |
|             2 |          2 |    1.000 | 12.00 | 2020-12-02 00:00:00 | NULL       |
+---------------+------------+----------+-------+---------------------+------------+
2 rows in set (0.00 sec)

mysql> SELECT
    ->     a.transactionno,
    ->     a.itemnumber,
    ->     a.quantity,
    ->     a.price,
    ->     a.transdate,
    ->     b.membername
    -> FROM
    ->     demo.membermaster AS b
    ->         RIGHT JOIN   -- 右连接
    ->     demo.trans AS a
    ->     ON (a.cardno = b.cardno);
+---------------+------------+----------+-------+---------------------+------------+
| transactionno | itemnumber | quantity | price | transdate           | membername |
+---------------+------------+----------+-------+---------------------+------------+
|             1 |          1 |    1.000 | 89.00 | 2020-12-01 00:00:00 | 张三       |
|             2 |          2 |    1.000 | 12.00 | 2020-12-02 00:00:00 | NULL       |
+---------------+------------+----------+-------+---------------------+------------+
2 rows in set (0.00 sec)

通过关联查询，销售流水数据里就补齐了会员的名称，我们也就获取到了需要的数据。

关联查询的误区

有了连接，我们就可以进行 2 个表的关联查询了。你可能会有疑问：关联查询必须在外键约束的基础上，才可以吗？

其实，在 MySQL 中，外键约束不是关联查询的必要条件。很多人往往在设计表的时候，觉得只要连接查询就可以搞定一切了，外键约束太麻烦，没有必要。如果你这么想，就进入了一个误区。

下面我就以超市进货的例子，来实际说明一下，为什么这种思路不对。

假设一次进货数据是这样的：供货商编号是 1，进货仓库编号是 1。我们进货的商品编号是 1234，进货数量是 1，进货价格是 10，进货金额是 10。

我先插入单头数据：

INSERT INTO demo.importhead
(
listnumber,
supplierid,
stocknumber
)
VALUES
(
1234,
1,
1
);

运行成功后，查看一下表的内容：

mysql> SELECT *
    -> FROM demo.importhead;
+------------+------------+-------------+------------+----------+-------------+-------------+
| listnumber | supplierid | stocknumber | importtype | quantity | importprice | importvalue |
+------------+------------+-------------+------------+----------+-------------+-------------+
|       1234 |          1 |           1 |          1 |     NULL |        NULL |        NULL |
+------------+------------+-------------+------------+----------+-------------+-------------+
1 row in set (0.00 sec)

可以看到，我们有了一个进货单头，单号是 1234，供货商是 1 号供货商，进货仓库是 1 号仓库。

接着，我们向进货单明细表中插入进货明细数据：

INSERT INTO demo.importdetails
(
listnumber,
itemnumber,
quantity,
importprice,
importvalue
)
VALUES
(
1234,
1,
1,
10,
10
);

运行成功，查看一下表的内容：

mysql> SELECT *
    -> FROM demo.importdetails;
+------------+------------+----------+-------------+-------------+
| listnumber | itemnumber | quantity | importprice | importvalue |
+------------+------------+----------+-------------+-------------+
|       1234 |          1 |    1.000 |       10.00 |       10.00 |
+------------+------------+----------+-------------+-------------+
1 row in set (0.00 sec)

这样，我们就有了 1234 号进货单的明细数据：进货商品是 1 号商品，进货数量是 1 个，进货价格是 10 元，进货金额是 10 元。

这个时候，如果我删除进货单头表的数据，就会出现只有明细、没有单头的数据缺失情况。我们来看看会发生什么：

DELETE FROM demo.importhead
WHERE listnumbere = 1234;

运行这条语句，MySQL 会提示错误，因为数据删除违反了外键约束。看到了吗？MySQL 阻止了数据不一致的情况出现。

不知道你有没有注意我插入数据的顺序：为什么我要先插入进货单头表的数据，再插入进货单明细表的数据呢？其实，这是因为，如果我先插入数据到从表，也就是进货单明细表，会导致 MySQL 找不到参照的主表信息，会提示错误，因为添加数据违反了外键约束。

你可能会不以为然，觉得按照信息系统的操作逻辑，生成一张进货单的时候，一定是先生成单头，再插入明细。同样，删除一张进货单的时候，一定是先删除明细，再删除单头。

要是你这么想，可能就会“中招”了。原因很简单，既然我们把进货数据拆成了 2 个表，这就决定了无论是数据添加，还是数据删除，都不能通过一条 SQL 语句实现。实际工作中，什么突发情况都是有可能发生的。你认为一定会完成的操作，完全有可能只执行了一部分。

我们曾经就遇到过这么一个问题：用户月底盘点的时候，盘点单无法生成，系统提示“有未处理的进货单”。经过排查，发现是进货单数据发生了数据缺失，明细数据还在，对应的单头数据却被删除了。我们反复排查之后，才发现是缺少了防止数据缺失的机制。最后通过定义外键约束，解决了这个问题。

所以，虽然你不用外键约束，也可以进行关联查询，但是有了它，MySQL 系统才会保护你的数据，避免出现误删的情况，从而提高系统整体的可靠性。

现在来回答另外一个问题，为什么在 MySQL 里，没有外键约束也可以进行关联查询呢？原因是外键约束是有成本的，需要消耗系统资源。对于大并发的 SQL 操作，有可能会不适合。比如大型网站的中央数据库，可能会因为外键约束的系统开销而变得非常慢。所以，MySQL 允许你不使用系统自带的外键约束，在应用层面完成检查数据一致性的逻辑。也就是说，即使你不用外键约束，也要想办法通过应用层面的附加逻辑，来实现外键约束的功能，确保数据的一致性。

总结

这节课，我给你介绍了如何进行多表查询，我们重点学习了外键和连接。

外键约束，可以帮助我们确定从表中的外键字段与主表中的主键字段之间的引用关系，还可以确保从表中数据所引用的主表数据不会被删除，从而保证了 2 个表中数据的一致性。

连接可以帮助我们对 2 个相关的表进行连接查询，从 2 个表中获取需要的信息。左连接表示连接以左边的表为主，结果集中要包括左边表中的所有记录；右连接表示连接以右边的表为主，结果集中要包括右边表中的所有记录。

我汇总了常用的 SQL 语句，你一定要重点掌握。

-- 定义外键约束：
CREATE TABLE 从表名
(
字段 字段类型
....
CONSTRAINT 外键约束名称
FOREIGN KEY (字段名) REFERENCES 主表名 (字段名称)
);
ALTER TABLE 从表名 ADD CONSTRAINT 约束名 FOREIGN KEY 字段名 REFERENCES 主表名 （字段名）;

-- 连接查询
SELECT 字段名
FROM 表名 AS a
JOIN 表名 AS b
ON (a.字段名称=b.字段名称);

SELECT 字段名
FROM 表名 AS a
LEFT JOIN 表名 AS b
ON (a.字段名称=b.字段名称);

SELECT 字段名
FROM 表名 AS a
RIGHT JOIN 表名 AS b
ON (a.字段名称=b.字段名称);

刚开始学习 MySQL 的同学，很容易忽略在关联表中定义外键约束的重要性，从而导致数据缺失，影响系统的可靠性。我建议你尽量养成在关联表中定义外键约束的习惯。不过，如果你的业务场景因为高并发等原因，无法承担外键约束的成本，也可以不定义外键约束，但是一定要在应用层面实现外键约束的逻辑功能，这样才能确保系统的正确可靠。

思考题

如果你的业务场景因高并发等原因，不能使用外键约束，在这种情况下，你怎么在应用层面确保数据的一致性呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

07 | 条件语句：WHERE 与 HAVING有什么不同?

你好，我是朱晓峰。

我们在进行查询的时候，经常需要按条件对查询结果进行筛选，这就要用到条件语句 WHERE 和 HAVING 了。

WHERE 是直接对表中的字段进行限定，来筛选结果；HAVING 则需要跟分组关键字 GROUP BY 一起使用，通过对分组字段或分组计算函数进行限定，来筛选结果。虽然它们都是对查询进行限定，却有着各自的特点和适用场景。很多时候，我们会遇到 2 个都可以用的情况。一旦用错，就很容易出现执行效率低下、查询结果错误，甚至是查询无法运行的情况。

下面我就借助项目实施过程中的实际需求，给你讲讲 WHERE 和 HAVING 分别是如何对查询结果进行筛选的，以及它们各自的优缺点，来帮助你正确地使用它们，使你的查询不仅能够得到正确的结果，还能占用更少的资源，并且速度更快。

一个实际查询需求

超市的经营者提出，要查单笔销售金额超过 50 元的商品。我们来分析一下这个需求：需要查询出一个商品记录集，限定条件是单笔销售金额超过 50 元。这个时候，我们就需要用到 WHERE 和 HAVING 了。

这个问题的条件很明确，查询的结果也只有“商品”一个字段，好像很容易实现。

假设我们有一个这样的商品信息表（demo.goodsmaster），里面有 2 种商品：书和笔。

mysql> SELECT *
    -> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
|          1 | 0001    | 书        |               | 本   |      89.00 |
|          2 | 0002    | 笔        |               | 支   |       5.00 |
+------------+---------+-----------+---------------+------+------------+
2 rows in set (0.00 sec)

同时，我们还有一个商品销售明细表（demo.transactiondetails），里面有 4 条销售记录：

mysql> SELECT *
    -> FROM demo.transactiondetails;
+---------------+------------+----------+-------+------------+
| transactionid | itemnumber | quantity | price | salesvalue |
+---------------+------------+----------+-------+------------+
|             1 |          1 |    1.000 | 89.00 |      89.00 |
|             1 |          2 |    2.000 |  5.00 |      10.00 |
|             2 |          1 |    2.000 | 89.00 |     178.00 |
|             3 |          2 |   10.000 |  5.00 |      50.00 |
+---------------+------------+----------+-------+------------+
4 rows in set (0.01 sec)

接下来，我们分别用 WHERE 和 HAVING 进行查询，看看它们各自是如何查询的，是否能够得到正确的结果。

第一步，用 WHERE 关键字进行查询：

mysql> SELECT DISTINCT b.goodsname
-> FROM demo.transactiondetails AS a
-> JOIN demo.goodsmaster AS b
-> ON (a.itemnumber=b.itemnumber)
-> WHERE a.salesvalue > 50;
+-----------+
| goodsname |
+-----------+
| 书 |
+-----------+
1 row in set (0.00 sec)

第二步，用 HAVING 关键字进行查询：

mysql> SELECT b.goodsname
-> FROM demo.transactiondetails AS a
-> JOIN demo.goodsmaster AS b
-> ON (a.itemnumber=b.itemnumber)
-> GROUP BY b.goodsname
-> HAVING max(a.salesvalue)>50;
+-----------+
| goodsname |
+-----------+
| 书 |
+-----------+
1 row in set (0.00 sec)

可以发现，两次查询的结果是一样的。那么，这两种查询到底有什么区别，哪个更好呢？要弄明白这个问题，我们要先学习下 WHERE 和 HAVING 的执行过程。

WHERE

我们先来分析一下刚才使用 WHERE 条件的查询语句，来看看 MySQL 是如何执行这个查询的。

首先，MySQL 从数据表 demo.transactiondetails 中抽取满足条件“a.salesvalue>50”的记录：

mysql> SELECT *
    -> FROM demo.transactiondetails AS a
    -> WHERE a.salesvalue > 50;
+---------------+------------+----------+-------+------------+
| transactionid | itemnumber | quantity | price | salesvalue |
+---------------+------------+----------+-------+------------+
|             1 |          1 |    1.000 | 89.00 |      89.00 |
|             2 |          1 |    2.000 | 89.00 |     178.00 |
+---------------+------------+----------+-------+------------+
2 rows in set (0.00 sec)

为了获取到销售信息所对应的商品名称，我们需要通过公共字段“itemnumber”与数据表 demo.goodsmaster 进行关联，从 demo.goodsmaster 中获取商品名称：

mysql> SELECT
    ->     a.*, b.goodsname
    -> FROM
    ->     demo.transactiondetails a
    ->         JOIN
    ->     demo.goodsmaster b ON (a.itemnumber = b.itemnumber)
    -> WHERE
    ->     a.salesvalue > 50;
+---------------+------------+----------+-------+------------+-----------+
| transactionid | itemnumber | quantity | price | salesvalue | goodsname |
+---------------+------------+----------+-------+------------+-----------+
|             1 |          1 |    1.000 | 89.00 |      89.00 | 书        |
|             2 |          1 |    2.000 | 89.00 |     178.00 | 书        |
+---------------+------------+----------+-------+------------+-----------+
2 rows in set (0.00 sec)

这个时候，如果查询商品名称，就会出现两个重复的记录：

mysql> SELECT
    ->     b.goodsname
    -> FROM
    ->     demo.transactiondetails AS a
    ->         JOIN
    ->     demo.goodsmaster AS b ON (a.itemnumber = b.itemnumber)
    -> WHERE
    ->     a.salesvalue > 50;
+-----------+
| goodsname |
+-----------+
| 书        |
| 书        |
+-----------+
2 rows in set (0.00 sec)

需要注意的是，为了消除重复的语句，这里我们需要用到一个关键字：DISTINCT，它的作用是返回唯一不同的值。比如，DISTINCT 字段 1，就表示返回所有字段 1 的不同的值。

下面我们尝试一下加上 DISTINCT 关键字的查询：

mysql> SELECT
    ->     DISTINCT(b.goodsname)  -- 返回唯一不同的值
    -> FROM
    ->     demo.transactiondetails AS a
    ->         JOIN
    ->     demo.goodsmaster AS b ON (a.itemnumber = b.itemnumber)
    -> WHERE
    ->     a.salesvalue > 50;
+-----------+
| goodsname |
+-----------+
| 书        |
+-----------+
1 row in set (0.00 sec)

这样，我们就得到了需要的结果：单笔销售金额超过 50 元的商品就是“书”。

总之，WHERE 关键字的特点是，直接用表的字段对数据集进行筛选。如果需要通过关联查询从其他的表获取需要的信息，那么执行的时候，也是先通过 WHERE 条件进行筛选，用筛选后的比较小的数据集进行连接。这样一来，连接过程中占用的资源比较少，执行效率也比较高。

HAVING

讲完了 WHERE，我们再说说 HAVING 是如何执行的。不过，在这之前，我要先给你介绍一下 GROUP BY，因为 HAVING 不能单独使用，必须要跟 GROUP BY 一起使用。

我们可以把 GROUP BY 理解成对数据进行分组，方便我们对组内的数据进行统计计算。

下面我举个小例子，具体讲一讲 GROUP BY 如何使用，以及如何在分组里面进行统计计算。

假设现在有一组销售数据，我们需要从里面查询每天、每个收银员的销售数量和销售金额。我们通过下面的代码，来查看一下数据的内容：

mysql> SELECT *
    -> FROM demo.transactionhead;
+---------------+------------------+------------+---------------------+
| transactionid | transactionno    | operatorid | transdate           |
+---------------+------------------+------------+---------------------+
|             1 | 0120201201000001 |          1 | 2020-12-10 00:00:00 |
|             2 | 0120201202000001 |          2 | 2020-12-11 00:00:00 |
|             3 | 0120201202000002 |          2 | 2020-12-12 00:00:00 |
+---------------+------------------+------------+---------------------+
3 rows in set (0.00 sec)

mysql> SELECT *
    -> FROM demo.transactiondetails;
+---------------+------------+----------+-------+------------+
| transactionid | itemnumber | quantity | price | salesvalue |
+---------------+------------+----------+-------+------------+
|             1 |          1 |    1.000 | 89.00 |      89.00 |
|             1 |          2 |    2.000 |  5.00 |      10.00 |
|             2 |          1 |    2.000 | 89.00 |     178.00 |
|             3 |          2 |   10.000 |  5.00 |      50.00 |
+---------------+------------+----------+-------+------------+
4 rows in set (0.01 sec)

mysql> SELECT *
    -> FROM demo.operator;
+------------+----------+--------+--------------+-------------+---------+--------------------+--------+
| operatorid | branchid | workno | operatorname | phone       | address | pid                | duty   |
+------------+----------+--------+--------------+-------------+---------+--------------------+--------+
|          1 |        1 | 001    | 张静         | 18612345678 | 北京    | 110392197501012332 | 店长   |
|          2 |        1 | 002    | 李强         | 13312345678 | 北京    | 110222199501012332 | 收银员 |
+------------+----------+--------+--------------+-------------+---------+--------------------+--------+
2 rows in set (0.01 sec)

mysql> SELECT
    -> a.transdate,   -- 交易时间
    -> c.operatorname,-- 操作员
    -> d.goodsname,   -- 商品名称
    -> b.quantity,    -- 销售数量
    -> b.price,       -- 价格
    -> b.salesvalue   -- 销售金额
    -> FROM
    ->   demo.transactionhead AS a
    -> JOIN
    ->   demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    -> JOIN
    ->  demo.operator AS c ON (a.operatorid = c.operatorid)
    -> JOIN
    ->  demo.goodsmaster AS d ON (b.itemnumber = d.itemnumber);
+---------------------+--------------+-----------+----------+-------+------------+
| transdate           | operatorname | goodsname | quantity | price | salesvalue |
+---------------------+--------------+-----------+----------+-------+------------+
| 2020-12-10 00:00:00 | 张静         | 书        |    1.000 | 89.00 |      89.00 |
| 2020-12-10 00:00:00 | 张静         | 笔        |    2.000 |  5.00 |      10.00 |
| 2020-12-11 00:00:00 | 李强         | 书        |    2.000 | 89.00 |     178.00 |
| 2020-12-12 00:00:00 | 李强         | 笔        |   10.000 |  5.00 |      50.00 |
+---------------------+--------------+-----------+----------+-------+------------+
4 rows in set (0.00 sec)

如果我想看看每天的销售数量和销售金额，可以按照一个字段“transdate”对数据进行分组和统计：

mysql> SELECT
    -> a.transdate,
    -> SUM(b.quantity), -- 统计分组的总计销售数量
    -> SUM(b.salesvalue) -- 统计分组的总计销售金额
    -> FROM
    ->   demo.transactionhead AS a
    -> JOIN
    -> demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    -> GROUP BY a.transdate;
+---------------------+-----------------+-------------------+
| transdate           | SUM(b.quantity) | SUM(b.salesvalue) |
+---------------------+-----------------+-------------------+
| 2020-12-10 00:00:00 |           3.000 |             99.00 |
| 2020-12-11 00:00:00 |           2.000 |            178.00 |
| 2020-12-12 00:00:00 |          10.000 |             50.00 |
+---------------------+-----------------+-------------------+
3 rows in set (0.00 sec)

如果我想看每天、每个收银员的销售数量和销售金额，就可以按 2 个字段进行分组和统计，分别是“transdate”和“operatorname”：

mysql>  SELECT
    ->     a.transdate,
    ->     c.operatorname,
    ->     SUM(b.quantity), -- 数量求和
    ->     SUM(b.salesvalue)-- 金额求和
    -> FROM
    ->     demo.transactionhead AS a
    ->         JOIN
    ->     demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    ->         JOIN
    ->     demo.operator AS C ON (a.operatorid = c.operatorid)
    -> GROUP BY a.transdate , c.operatorname; -- 按照交易日期和操作员分组
+---------------------+--------------+-----------------+-------------------+
| transdate           | operatorname | SUM(b.quantity) | SUM(b.salesvalue) |
+---------------------+--------------+-----------------+-------------------+
| 2020-12-10 00:00:00 | 张静         |           3.000 |             99.00 |
| 2020-12-11 00:00:00 | 李强         |           2.000 |            178.00 |
| 2020-12-12 00:00:00 | 李强         |          10.000 |             50.00 |
+---------------------+--------------+-----------------+-------------------+
3 rows in set (0.00 sec)

可以看到，通过对销售数据按照交易日期和收银员进行分组，再对组内数据进行求和统计，就实现了对每天、每个收银员的销售数量和销售金额的查询。

好了，知道了 GROUP BY 的使用方法，我们就来学习下 HAVING。

回到开头的超市经营者的需求：查询单笔销售金额超过 50 元的商品。现在我们来使用 HAVING 来实现，代码如下：

mysql> SELECT b.goodsname
    -> FROM demo.transactiondetails AS a
    -> JOIN demo.goodsmaster AS b
    -> ON (a.itemnumber=b.itemnumber)
    -> GROUP BY b.goodsname
    -> HAVING max(a.salesvalue)>50;
+-----------+
| goodsname |
+-----------+
| 书        |
+-----------+
1 row in set (0.00 sec)

这种查询方式在 MySQL 里面是分四步实现的。

第一步，把流水明细表和商品信息表通过公共字段“itemnumber”连接起来，从 2 个表中获取数据：

mysql> SELECT
    ->     a.*, b.*
    -> FROM
    ->     demo.transactiondetails a
    ->         JOIN
    ->     demo.goodsmaster b ON (a.itemnumber = b.itemnumber);
+---------------+------------+----------+-------+------------+------------+---------+-----------+---------------+------+------------+
| transactionid | itemnumber | quantity | price | salesvalue | itemnumber | barcode | goodsname | specification | unit | salesprice |
+---------------+------------+----------+-------+------------+------------+---------+-----------+---------------+------+------------+
|             1 |          1 |    1.000 | 89.00 |      89.00 |          1 | 0001    | 书        | NULL          | 本   |      89.00 |
|             1 |          2 |    2.000 |  5.00 |      10.00 |          2 | 0002    | 笔        | NULL          | 支   |       5.00 |
|             2 |          1 |    2.000 | 89.00 |     178.00 |          1 | 0001    | 书        | NULL          | 本   |      89.00 |
|             3 |          2 |   10.000 |  5.00 |      50.00 |          2 | 0002    | 笔        | NULL          | 支   |       5.00 |
+---------------+------------+----------+-------+------------+------------+---------+-----------+---------------+------+------------+
4 rows in set (0.00 sec)

查询的结果有点复杂，为了方便你理解，我对结果进行了分类，并加了注释，如下图所示：

第二步，把结果集按照商品名称分组，分组的示意图如下所示：

组 1：

组 2：

第三步，对分组后的数据集进行筛选，把组中字段“salesvalue”的最大值 >50 的组筛选出来。筛选后的结果集如下所示：

第四步，返回商品名称。这时，我们就得到了需要的结果：单笔销售金额超过 50 元的商品就是“书”。

现在我们来简单小结下使用 HAVING 的查询过程。首先，我们要把所有的信息都准备好，包括从关联表中获取需要的信息，对数据集进行分组，形成一个包含所有需要的信息的数据集合。接着，再通过 HAVING 条件的筛选，得到需要的数据。

怎么正确地使用 WHERE 和 HAVING？

现在，你已经知道了 WHERE 和 HAVING 的具体使用方法。那么，在查询时，我们怎样才能正确地使用它们呢？

首先，你要知道它们的 2 个典型区别。

第一个区别是，如果需要通过连接从关联表中获取需要的数据，WHERE 是先筛选后连接，而 HAVING 是先连接后筛选。

这一点，就决定了在关联查询中，WHERE 比 HAVING 更高效。因为 WHERE 可以先筛选，用一个筛选后的较小数据集和关联表进行连接，这样占用的资源比较少，执行效率也就比较高。HAVING 则需要先把结果集准备好，也就是用未被筛选的数据集进行关联，然后对这个大的数据集进行筛选，这样占用的资源就比较多，执行效率也较低。

第二个区别是，WHERE 可以直接使用表中的字段作为筛选条件，但不能使用分组中的计算函数作为筛选条件；HAVING 必须要与 GROUP BY 配合使用，可以把分组计算的函数和分组字段作为筛选条件。

这决定了，在需要对数据进行分组统计的时候，HAVING 可以完成 WHERE 不能完成的任务。这是因为，在查询语法结构中，WHERE 在 GROUP BY 之前，所以无法对分组结果进行筛选。HAVING 在 GROUP BY 之后，可以使用分组字段和分组中的计算函数，对分组的结果集进行筛选，这个功能是 WHERE 无法完成的。

这么说你可能不太好理解，我来举个小例子。假如超市经营者提出，要查询一下是哪个收银员、在哪天卖了 2 单商品。这种必须先分组才能筛选的查询，用 WHERE 语句实现就比较难，我们可能要分好几步，通过把中间结果存储起来，才能搞定。但是用 HAVING，则很轻松，代码如下：

mysql> SELECT
    ->   a.transdate, c.operatorname
    -> FROM
    ->   demo.transactionhead AS a
    -> JOIN
    ->   demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    -> JOIN
    ->   demo.operator AS c ON (a.operatorid = c.operatorid)
    -> GROUP BY a.transdate,c.operatorname
    -> HAVING count(*)=2;  -- 销售了2单
+---------------------+--------------+
| transdate           | operatorname |
+---------------------+--------------+
| 2020-12-10 00:00:00 | 张静         |
+---------------------+--------------+
1 row in set (0.01 sec)

我汇总了 WHERE 和 HAVING 各自的优缺点，如下图所示：

不过，需要注意的是，WHERE 和 HAVING 也不是互相排斥的，我们可以在一个查询里面同时使用 WHERE 和 HAVING。

举个例子，假设现在我们有一组销售数据，包括交易时间、收银员、商品名称、销售数量、价格和销售金额等信息，超市的经营者要查询“2020-12-10”和“2020-12-11”这两天收银金额超过 100 元的销售日期、收银员名称、销售数量和销售金额。

mysql> SELECT
    ->     a.transdate,
    ->    c.operatorname,
    ->     d.goodsname,
    ->     b.quantity,
    ->     b.price,
    ->     b.salesvalue
    -> FROM
    ->     demo.transactionhead AS a
    ->         JOIN
    ->     demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    ->        JOIN
    ->     demo.operator AS c ON (a.operatorid = c.operatorid)
    ->        JOIN
    ->    demo.goodsmaster as d on (b.itemnumber=d.itemnumber);
+---------------------+--------------+-----------+----------+-------+------------+
| transdate           | operatorname | goodsname | quantity | price | salesvalue |
+---------------------+--------------+-----------+----------+-------+------------+
| 2020-12-10 00:00:00 | 张静         | 书        |    1.000 | 89.00 |      89.00 |
| 2020-12-10 00:00:00 | 张静         | 笔        |    2.000 |  5.00 |      10.00 |
| 2020-12-11 00:00:00 | 李强         | 书        |    2.000 | 89.00 |     178.00 |
| 2020-12-12 00:00:00 | 李强         | 笔        |   10.000 |  5.00 |      50.00 |
+---------------------+--------------+-----------+----------+-------+------------+
4 rows in set (0.00 sec)

我们来分析一下这个需求：由于是要按照销售日期和收银员进行统计，所以，必须按照销售日期和收银员进行分组，因此，我们可以通过使用 GROUP BY 和 HAVING 进行查询：

mysql> SELECT
    ->     a.transdate,
    ->     c.operatorname,
    ->     SUM(b.quantity), -- 销售数量求和
    ->     SUM(b.salesvalue)-- 销售金额求和
    -> FROM
    ->     demo.transactionhead AS a
    ->         JOIN
    ->     demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    ->         JOIN
    ->     demo.operator AS c ON (a.operatorid = c.operatorid)
    -> GROUP BY a.transdate , operatorname  -- 按照日期、收银员分组
    -> HAVING a.transdate IN ('2020-12-10' , '2020-12-11')
    ->     AND SUM(b.salesvalue) > 100;   -- 最后筛选数据
+---------------------+--------------+-----------------+-------------------+
| transdate           | operatorname | SUM(b.quantity) | SUM(b.salesvalue) |
+---------------------+--------------+-----------------+-------------------+
| 2020-12-11 00:00:00 | 李强         |           2.000 |            178.00 |
+---------------------+--------------+-----------------+-------------------+
1 row in set (0.00 sec)

如果你仔细看 HAVING 后面的筛选条件，就会发现，条件 a.transdate IN ('2020-12-10' , '2020-12-11')，其实可以用 WHERE 来限定。我们把查询改一下试试：

 mysql> SELECT
    ->     a.transdate,
    ->     c.operatorname,
    ->     SUM(b.quantity),
    ->     SUM(b.salesvalue)
    -> FROM
    ->     demo.transactionhead AS a
    ->         JOIN
    ->     demo.transactiondetails AS b ON (a.transactionid = b.transactionid)
    ->         JOIN
    ->     demo.operator AS c ON (a.operatorid = c.operatorid)
    -> WHERE a.transdate in ('2020-12-12','2020-12-11') -- 先按日期筛选
    -> GROUP BY a.transdate , operatorname
    -> HAVING SUM(b.salesvalue)>100;  -- 后按金额筛选
+---------------------+--------------+-----------------+-------------------+
| transdate           | operatorname | SUM(b.quantity) | SUM(b.salesvalue) |
+---------------------+--------------+-----------------+-------------------+
| 2020-12-11 00:00:00 | 李强         |           2.000 |            178.00 |
+---------------------+--------------+-----------------+-------------------+
1 row in set (0.00 sec)

很显然，我们同样得到了需要的结果。这是因为我们把条件拆分开，包含分组统计函数的条件用 HAVING，普通条件用 WHERE。这样，我们就既利用了 WHERE 条件的高效快速，又发挥了 HAVING 可以使用包含分组统计函数的查询条件的优点。当数据量特别大的时候，运行效率会有很大的差别。

总结

今天，我给你介绍了条件语句 WHERE 和 HAVING 在 MySQL 中的执行原理。WHERE 可以先按照条件对数据进行筛选，然后进行数据连接，所以效率更高。HAVING 可以在分组之后，通过使用分组中的计算函数，实现 WHERE 难以完成的数据筛选。

了解了 WHERE 和 HAVING 各自的特点，我们就可以在查询中，充分利用它们的优势，更高效地实现我们的查询目标。

最后，我想提醒你的是，很多人刚开始学习 MySQL 的时候，不太喜欢用 HAVING，一提到条件语句，就想当然地用 WHERE。其实，HAVING 是非常有用的，特别是在做一些复杂的统计查询的时候，经常要用到分组，这个时候 HAVING 就派上用场了。

当然，你也可以不用 HAVING，而是把查询分成几步，把中间结果存起来，再用 WHERE 筛选，或者干脆把这部分筛选功能放在应用层面，用代码来实现。但是，这样做的效率很低，而且会增加工作量，加大维护成本。所以，学会使用 HAVING，对你完成复杂的查询任务非常有帮助。

思考题

有这样一种说法：HAVING 后面的条件，必须是包含分组中的计算函数的条件，你觉得对吗？为什么？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享你的朋友或同事，我们下节课见。

08 | 聚合函数：怎么高效地进行分组统计？

你好，我是朱晓峰。今天，我来和你聊一聊聚合函数。

MySQL 中有 5 种聚合函数较为常用，分别是求和函数 SUM()、求平均函数 AVG()、最大值函数 MAX()、最小值函数 MIN() 和计数函数 COUNT()。接下来，我就结合超市项目的真实需求，来带你掌握聚合函数的用法，帮你实现高效的分组统计。

咱们的项目需求是这样的：超市经营者提出，他们需要统计某个门店，每天、每个单品的销售情况，包括销售数量和销售金额等。这里涉及 3 个数据表，具体信息如下所示：

销售明细表（demo.transactiondetails)：

销售单头表（demo.transactionhead)：

商品信息表（demo.goodsmaster）：

要统计销售，就要用到数据求和，那么我们就先来学习下求和函数 SUM()。

SUM（）

SUM（）函数可以返回指定字段值的和。我们可以用它来获得用户某个门店，每天，每种商品的销售总计数据：

mysql>  SELECT
    ->     LEFT(b.transdate, 10), -- 从关联表获取交易时间，并且通过LEFT函数，获取交易时间字符串的左边10个字符，得到年月日的数据
    ->     c.goodsname,           -- 从关联表获取商品名称
    ->     SUM(a.quantity),       -- 数量求和
    ->     SUM(a.salesvalue)      -- 金额求和
    -> FROM
    ->     demo.transactiondetails a
    ->         JOIN
    ->     demo.transactionhead b ON (a.transactionid = b.transactionid)
    ->         JOIN
    ->     demo.goodsmaster c ON (a.itemnumber = c.itemnumber)
    -> GROUP BY LEFT(b.transdate, 10) , c.goodsname      -- 分组
    -> ORDER BY LEFT(b.transdate, 10) , c.goodsname;     -- 排序
+-----------------------+-----------+-----------------+-------------------+
| LEFT(b.transdate, 10) | goodsname | SUM(a.quantity) | SUM(a.salesvalue) |
+-----------------------+-----------+-----------------+-------------------+
| 2020-12-01            | 书        |           2.000 |            178.00 |
| 2020-12-01            | 笔        |           5.000 |             25.00 |
| 2020-12-02            | 书        |           4.000 |            356.00 |
| 2020-12-02            | 笔        |          16.000 |             80.00 |
+-----------------------+-----------+-----------------+-------------------+
4 rows in set (0.01 sec)

可以看到，我们引入了 2 个关键字：LEFT 和 ORDER BY，你可能对它们不熟悉，我来具体解释下。

LEFT(str，n)：表示返回字符串 str 最左边的 n 个字符。我们这里的 LEFT（a.transdate,10），表示返回交易时间字符串最左边的 10 个字符。在 MySQL 中，DATETIME 类型的默认格式是：YYYY-MM-DD，也就是说，年份 4 个字符，之后是“-”，然后是月份 2 个字符，之后又是“-”，然后是日 2 个字符，所以完整的年月日是 10 个字符。用户要求按照日期统计，所以，我们需要从日期时间数据中，把年月日的部分截取出来。

ORDER BY：表示按照指定的字段排序。超市经营者指定按照日期和单品统计，那么，统计的结果按照交易日期和商品名称的顺序排序，会更加清晰。

知道了 2 个关键字之后，刚刚的查询就容易理解了。接下来我们就再拆解一下，看看这个查询是如何执行的。我用图表来直观地演示一下各个步骤。

第一步，完成 3 个表的连接（由于字段比较多，为了你理解，我省略了一些在这一步不重要的字段）：

第二步，对结果集按照交易时间和商品名称进行分组，我们可以分成下面 4 组。

第一组：

第二组

第三组

第四组

第三步，对各组的销售数量和销售金额进行统计，并且按照交易日期和商品名称排序。这样就得到了我们需要的结果，如下所示：

+-----------------------+-----------+-----------------+-------------------+
| LEFT(b.transdate, 10) | goodsname | SUM(a.quantity) | SUM(a.salesvalue) |
+-----------------------+-----------+-----------------+-------------------+
| 2020-12-01            | 书        |           2.000 |            178.00 |
| 2020-12-01            | 笔        |           5.000 |             25.00 |
| 2020-12-02            | 书        |           4.000 |            356.00 |
| 2020-12-02            | 笔        |          16.000 |             80.00 |
+-----------------------+-----------+-----------------+-------------------+
4 rows in set (0.01 sec)

如果用户需要知道全部商品销售的总计数量和总计金额，我们也可以把数据集的整体看作一个分组，进行计算。这样就不需要分组关键字 GROUP BY，以及排序关键字 ORDER BY 了。你甚至不需要从关联表中获取数据，也就不需要连接了。就像下面这样：

mysql> SELECT
    ->  SUM(quantity), -- 总计数量
    ->  SUM(salesvalue)-- 总计金额
    -> FROM
    ->  demo.transactiondetails;
+---------------+-----------------+
| SUM(quantity) | SUM(salesvalue) |
+---------------+-----------------+
|        27.000 |          639.00 |
+---------------+-----------------+
1 row in set (0.05 sec)

到这里呢，求和函数 SUM() 的使用方法我就讲完了。需要提醒你的是，求和函数获取的是分组中的合计数据，所以你要对分组的结果有准确的把握，否则就很容易搞错。这也就是说，你要知道是按什么字段进行分组的。如果是按多个字段分组，你要知道字段之间有什么样的层次关系；如果是按照以字段作为变量的某个函数进行分组的，你要知道这个函数的返回值是什么，返回值又是如何影响分组的等。

AVG（）、MAX（）和 MIN（）

接下来，我们来计算一下分组中数据的平均值、最大值和最小值。这个时候，就要用到 AVG()、MAX() 和 MIN() 了。

1.AVG（）

首先，我们来学习下计算平均值的函数 AVG（）。它的作用是，通过计算分组内指定字段值的和，以及分组内的记录数，算出分组内指定字段的平均值。

举个例子，如果用户需要计算每天、每种商品，平均一次卖出多少个、多少钱，这个时候，我们就可以用到 AVG（）函数了，如下所示：

mysql> SELECT
-> LEFT(a.transdate, 10),
-> c.goodsname,
-> AVG(b.quantity),    -- 平均数量
-> AVG(b.salesvalue)   -- 平均金额
-> FROM
-> demo.transactionhead a
-> JOIN
-> demo.transactiondetails b ON (a.transactionid = b.transactionid)
-> JOIN
-> demo.goodsmaster c ON (b.itemnumber = c.itemnumber)
-> GROUP BY LEFT(a.transdate,10),c.goodsname
-> ORDER BY LEFT(a.transdate,10),c.goodsname;
+-----------------------+-----------+-----------------+-------------------+
| LEFT(a.transdate, 10) | goodsname | AVG(b.quantity) | AVG(b.salesvalue) |
+-----------------------+-----------+-----------------+-------------------+
| 2020-12-01 | 书 | 2.0000000 | 178.000000 |
| 2020-12-01 | 笔 | 5.0000000 | 25.000000 |
| 2020-12-02 | 书 | 2.0000000 | 178.000000 |
| 2020-12-02 | 笔 | 8.0000000 | 40.000000 |
+-----------------------+-----------+-----------------+-------------------+
4 rows in set (0.00 sec)

2.MAX（）和 MIN（）

MAX() 表示获取指定字段在分组中的最大值，MIN() 表示获取指定字段在分组中的最小值。它们的实现原理差不多，下面我就重点讲一下 MAX()，知道了它的用法，MIN() 也就很好理解了。

我们还是来看具体的例子。假如用户要求计算每天里的一次销售的最大数量和最大金额，就可以用下面的代码，得到我们需要的结果：

mysql> SELECT
-> LEFT(a.transdate, 10),
-> MAX(b.quantity),     -- 数量最大值
-> MAX(b.salesvalue)    -- 金额最大值
-> FROM
-> demo.transactionhead a
-> JOIN
-> demo.transactiondetails b ON (a.transactionid = b.transactionid)
-> JOIN
-> demo.goodsmaster c ON (b.itemnumber = c.itemnumber)
-> GROUP BY LEFT(a.transdate,10)
-> ORDER BY LEFT(a.transdate,10);
+-----------------------+-----------------+-------------------+
| LEFT(a.transdate, 10) | MAX(b.quantity) | MAX(b.salesvalue) |
+-----------------------+-----------------+-------------------+
| 2020-12-01 | 5.000 | 178.00 |
| 2020-12-02 | 10.000 | 267.00 |
+-----------------------+-----------------+-------------------+
2 rows in set (0.00 sec)

代码很简单，你一看就明白了。但是，这里有个问题你要注意：千万不要以为 MAX（b.quantity）和 MAX（b.salesvalue）算出的结果一定是同一条记录的数据。实际上，MySQL 是分别计算的。下面我们就来分析一下刚刚的查询。

查询中用到 3 个相互关联的表：销售流水明细表、销售流水单头表和商品信息表。这 3 个表连接完成之后，MySQL 进行了分组。我用图示的办法给你展示出来：

第一组

第二组

在第一组中，最大数量出现在第 2 条记录，是 5；最大金额出现在第 1 条记录，是 178。同样道理，在第二组中，最大数量出现在第 4 条记录，是 10；最大金额则出现在第 1 条记录，是 267。

所以，MAX（字段）这个函数返回分组集中最大的那个值。如果你要查询 MAX（字段 1）和 MAX（字段 2），而它们是相互独立、分别计算的，你千万不要想当然地认为结果在同一条记录上。那样的话，你就掉坑里了。

COUNT（）

通过 COUNT（），我们可以了解数据集的大小，这对系统优化十分重要。

举个小例子，在项目实施的过程中，我们遇到了这么一个问题：由于用户的销售数据很多，而且每天都在增长，因此，在做销售查询的时候，经常会遇到卡顿的问题。这是因为，查询的数据量太大了，导致系统不得不花很多时间来处理数据，并给数据集分配资源，比如内存什么的。

怎么解决卡顿的问题呢？我们想到了一个分页的策略。

所谓的分页策略，其实就是，不把查询的结果一次性全部返回给客户端，而是根据用户电脑屏幕的大小，计算一屏可以显示的记录数，每次只返回用户电脑屏幕可以显示的数据集。接着，再通过翻页、跳转等功能按钮，实现查询目标的精准锁定。这样一来，每次查询的数据量较少，也就大大提高了系统响应速度。

这个策略能够实现的一个关键，就是要计算出符合条件的记录一共有多少条，之后才能计算出一共有几页、能不能翻页或跳转。

要计算记录数，就要用到 COUNT() 函数了。这个函数有两种情况。

COUNT（*）：统计一共有多少条记录；

COUNT（字段）：统计有多少个不为空的字段值。

1.COUNT(*)

如果 COUNT（*）与 GROUP BY 一起使用，就表示统计分组内有多少条数据。它也可以单独使用，这就相当于数据集全体是一个分组，统计全部数据集的记录数。

我举个小例子，假设我有个销售流水明细表如下：

mysql> SELECT *
    -> FROM demo.transactiondetails;
+---------------+------------+----------+-------+------------+
| transactionid | itemnumber | quantity | price | salesvalue |
+---------------+------------+----------+-------+------------+
|             1 |          1 |    2.000 | 89.00 |     178.00 |
|             1 |          2 |    5.000 |  5.00 |      25.00 |
|             2 |          1 |    3.000 | 89.00 |     267.00 |
|             2 |          2 |    6.000 |  5.00 |      30.00 |
|             3 |          1 |    1.000 | 89.00 |      89.00 |
|             3 |          2 |   10.000 |  5.00 |      50.00 |
+---------------+------------+----------+-------+------------+
6 rows in set (0.00 sec)

如果我们一屏可以显示 30 行，需要多少页才能显示完这个表的全部数据呢？

mysql> SELECT COUNT(*)
-> FROM demo.transactiondetails;
+----------+
| COUNT(*) |
+----------+
| 6 |
+----------+
1 row in set (0.03 sec)

我们这里只有 6 条数据，一屏就可以显示了，所以一共 1 页。

那么，如果超市经营者想知道，每天、每种商品都有几次销售，我们就需要按天、按商品名称，进行分组查询：

mysql> SELECT
-> LEFT(a.transdate, 10), c.goodsname, COUNT(*) -- 统计销售次数
-> FROM
-> demo.transactionhead a
-> JOIN
-> demo.transactiondetails b ON (a.transactionid = b.transactionid)
-> JOIN
-> demo.goodsmaster c ON (b.itemnumber = c.itemnumber)
-> GROUP BY LEFT(a.transdate, 10) , c.goodsname
-> ORDER BY LEFT(a.transdate, 10) , c.goodsname;
+-----------------------+-----------+----------+
| LEFT(a.transdate, 10) | goodsname | COUNT(*) |
+-----------------------+-----------+----------+
| 2020-12-01 | 书 | 1 |
| 2020-12-01 | 笔 | 1 |
| 2020-12-02 | 书 | 2 |
| 2020-12-02 | 笔 | 2 |
+-----------------------+-----------+----------+
4 rows in set (0.00 sec)

运行这段代码，我们就得到了每天、每种商品有几次销售的全部结果。

2.COUNT（字段）

COUNT（字段）用来统计分组内这个字段的值出现了多少次。如果字段值是空，就不统计。

为了说明它们的区别，我举个小例子。假设我们有这样的一个商品信息表，里面包括了商品编号、条码、名称、规格、单位和售价的信息。

mysql> SELECT *
-> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 |
| 2 | 0002 | 笔 | NULL | 支 | 5.00 |
| 3 | 0002 | 笔 | NULL | 支 | 10.00 |
+------------+---------+-----------+---------------+------+------------+
3 rows in set (0.01 sec)

如果我们要统计字段“goodsname”出现了多少次，就要用到函数 COUNT（goodsname），结果是 3 次：

mysql> SELECT COUNT(goodsname) -- 统计商品名称字段
-> FROM demo.goodsmaster;
+------------------+
| COUNT(goodsname) |
+------------------+
| 3 |
+------------------+
1 row in set (0.00 sec)

如果我们统计字段“specification”，用 COUNT(specification)，结果是 1 次：

mysql> SELECT COUNT(specification) -- 统计规格字段
-> FROM demo.goodsmaster;
+----------------------+
| COUNT(specification) |
+----------------------+
| 1 |
+----------------------+
1 row in set (0.00 sec)

你可能会问，为啥计数字段“goodsname”的结果是 3，计数字段“specification”却只有 1 呢？其实，这里的原因就是，3 条记录里面的字段“goodsname”没有空值，因此被统计了 3 次；而字段“specification”有 2 个空值，因此只统计了 1 次。

理解了这一点，你就可以利用计数函数对某个字段计数时，不统计空值的特点，对表中字段的非空值进行计数了。

总结

今天，我们学习了聚合函数 SUM（）、AVG（）、MAX（）、MIN（）和 COUNT（）。我们在对分组数据进行统计的时候，可以用这些函数来对分组数据求和、求平均值、最大值、最小值，以及统计分组内的记录数，或者分组内字段的值不为空的次数。

这些函数，为我们对数据库中的数据进行统计和计算提供了方便。因为计算直接在数据库中执行，比在应用层面完成相同的工作，效率高很多。

最后，我还想多说一句，不知道你注意到没有，这节课我还提到了 LEFT 和 ORDER BY。其实，聚合函数可以和其他关键字、函数一起使用，这样会拓展它的使用场景，让原本复杂的计算变简单。所以，我建议你不仅要认真学习这节课的聚合函数，还要掌握 MySQL 的各种关键字的功能和用法，并且根据实际工作的需要，尝试把它们组合在一起使用，这样就能利用好数据库的强大功能，更好地满足用户的需求。

思考题

如果用户想要查询一下，在商品信息表中，到底是哪种商品的商品名称有重复，分别重复了几次，该如何查询呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享你的朋友或同事，我们下节课见。

09 | 时间函数：时间类数据，MySQL是怎么处理的？

你好，我是朱晓峰。今天，咱们来聊一聊 MySQL 的时间函数。

顾名思义，时间函数就是用来处理时间的函数。时间，几乎可以说是各类项目中都会存在的数据，项目需求不同，我们需要的时间函数也不一样，比如：

如果我们要统计一天之中不同时间段的销售情况，就要获取时间值中的小时值，这就会用到函数 HOUR()；

要计算与去年同期相比的增长率，这就要计算去年同期的日期时间，会用到函数 DATE_ADD()；

要计算今天是周几、有没有优惠活动，这就要用到函数 DAYOFWEEK() 了；

……

这么多不同类型的时间函数，该怎么选择呢？这节课，我就结合不同的项目需求，来讲一讲不同的时间函数的使用方法，帮助你轻松地处理各类时间数据。

获取日期时间数据中部分信息的函数

我先举个小例子。超市的经营者提出，他们希望通过实际的销售数据，了解到一天当中什么时间段卖得好，什么时间段卖得不好，这样他们就可以根据不同时间的销售情况，合理安排商品陈列和人员促销，以实现收益最大化。

要达到这个目标，我们就需要统计一天中每小时的销售数量和销售金额。

这里涉及 3 组数据，分别是销售单头表（demo.transactionhead)、销售单明细表 (demo.transactiondetails) 和商品信息表（demo.goodsmaster）（为了便于你理解，表的结构和表里的记录都是经过简化的）。

销售单头表包含了销售单的整体信息，包括流水单号、交易时间、收款机编号、会员编号和收银员编号等。

销售单明细表中保存的是交易明细数据，包括商品编号、销售数量、价格、销售金额等。

商品信息表主要包括商品编号、条码、商品名称、规格、单位和售价。

需要注意的是，销售单明细表通过流水编号与销售单头表关联，其中流水编号是外键。通过流水编号，销售单明细表引用销售单头表里的交易时间、会员编号等信息，同时，通过商品编号与商品信息表关联，引用商品信息表里的商品名称等信息。

首先，我们来分析一下“统计一天中每小时的销售数量和销售金额”的这个需求。

要统计一天中每小时的销售情况，实际上就是要把销售数据按照小时进行分组统计。那么，解决问题的关键，就是把交易时间的小时部分提取出来。这就要用到 MySQL 的日期时间处理函数 EXTRACT（）和 HOUR（）了。

为了获取小时的值，我们要用到 EXTRACT() 函数。EXTRACT（type FROM date）表示从日期时间数据“date”中抽取“type”指定的部分。

有了这个函数，我们就可以获取到交易时间的小时部分，从而完成一天中每小时的销售数量和销售金额的查询：

mysql> SELECT
    -> EXTRACT(HOUR FROM b.transdate) AS 时段,
    -> SUM(a.quantity) AS 数量,
    -> SUM(a.salesvalue) AS 金额
    -> FROM
    -> demo.transactiondetails a
    -> JOIN
    -> demo.transactionhead b ON (a.transactionid = b.transactionid)
    -> GROUP BY EXTRACT(HOUR FROM b.transdate)
    -> ORDER BY EXTRACT(HOUR FROM b.transdate);
+------+--------+--------+
| 时段 | 数量   | 金额   |
+------+--------+--------+
|    9 | 16.000 | 500.00 |
|   10 | 11.000 | 139.00 |
|   11 | 10.000 |  30.00 |
|   12 | 40.000 | 200.00 |
|   13 |  5.000 | 445.00 |
|   15 |  6.000 |  30.00 |
|   17 |  1.000 |   3.00 |
|   18 |  2.000 | 178.00 |
|   19 |  2.000 |   6.00 |
+------+--------+--------+
9 rows in set (0.00 sec)

查询的过程是这样的：

从交易时间中抽取小时信息：EXTRACT(HOUR FROM b.transdate)；

按交易的小时信息分组；

按分组统计销售数量和销售金额的和；

按交易的小时信息排序。

这里我是用“HOUR”提取时间类型 DATETIME 中的小时信息，同样道理，你可以用“YEAR”获取年度信息，用“MONTH”获取月份信息，用“DAY”获取日的信息。如果你需要获取其他时间部分的信息，可以参考下时间单位。

这个查询，我们也可以通过使用日期时间函数 HOUR() 来达到同样的效果。HOUR（time）表示从日期时间“time”中，获取小时部分信息。

需要注意的是，EXTRACT() 函数中的“HOUR”表示要获取时间的类型，而 HOUR() 是一个函数，HOUR(time) 可以单独使用，表示返回 time 的小时部分信息。

我们可以通过在代码中，把 EXTRACT 函数改成 HOUR 函数，来实现相同的功能，如下所示：

mysql> SELECT
-> HOUR(b.transdate) AS 时段, -- 改为使用HOUR函数
-> SUM(a.quantity) AS 数量,
-> SUM(a.salesvalue) AS 金额
-> FROM
-> demo.transactiondetails a
-> JOIN
-> demo.transactionhead b ON (a.transactionid = b.transactionid)
-> GROUP BY HOUR(b.transdate) -- 改写为HOUR函数
-> ORDER BY HOUR(b.transdate);-- 改写为HOUR函数
+------+--------+--------+
| 时段 | 数量   | 金额   |
+------+--------+--------+
|    9 | 16.000 | 500.00 |
|   10 | 11.000 | 139.00 |
|   11 | 10.000 |  30.00 |
|   12 | 40.000 | 200.00 |
|   13 |  5.000 | 445.00 |
|   15 |  6.000 |  30.00 |
|   17 |  1.000 |   3.00 |
|   18 |  2.000 | 178.00 |
|   19 |  2.000 |   6.00 |
+------+--------+--------+
9 rows in set (0.00 sec)

除了获取小时信息，我们往往还会遇到要统计年度信息、月度信息等情况，MySQL 也提供了支持的函数。

YEAR（date）：获取 date 中的年。

MONTH（date）：获取 date 中的月。

DAY（date）：获取 date 中的日。

HOUR（date）：获取 date 中的小时。

MINUTE（date）：获取 date 中的分。

SECOND（date）：获取 date 中的秒。

这些函数的使用方法和提取小时信息的方法一样，我就不多说了，你只要知道这些函数的含义就可以了，下面我再讲一讲计算日期时间的函数。

计算日期时间的函数

我先来介绍 2 个常用的 MySQL 的日期时间计算函数。

DATE_ADD（date, INTERVAL 表达式 type）：表示计算从时间点“date”开始，向前或者向后一段时间间隔的时间。“表达式”的值为时间间隔数，正数表示向后，负数表示向前，“type”表示时间间隔的单位（比如年、月、日等）。

LAST_DAY（date）：表示获取日期时间“date”所在月份的最后一天的日期。

这两个函数怎么用呢？接下来，我还是借助咱们项目的实际需求，来给你讲解下。假设今天是 2020 年 12 月 10 日，超市经营者提出，他们需要计算这个月单品销售金额的统计，以及与去年同期相比的增长率。

这里的关键点是需要获取 2019 年 12 月的销售数据。因此，计算 2019 年 12 月的起始和截止时间点，就是查询的关键。这个时候，就要用到计算日期时间函数了。

下面我重点讲解一下如何通过 2 个计算日期时间函数，来计算 2019 年 12 月的起始时间和截止时间。

我们先来尝试获取 2019 年 12 月份的起始时间。

第一步，用 DATE_ADD 函数，获取到 2020 年 12 月 10 日上一年的日期：2019 年 12 月 10 日。

mysql> SELECT DATE_ADD('2020-12-10', INTERVAL - 1 YEAR);
+-------------------------------------------+
| DATE_ADD('2020-12-10', INTERVAL - 1 YEAR) |
+-------------------------------------------+
| 2019-12-10                                |
+-------------------------------------------+
1 row in set (0.00 sec)

第二步，获取 2019 年 12 月 10 日这个时间节点开始上个月的日期，这样做的目的是方便获取月份的起始时间：

mysql>  SELECT DATE_ADD(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR),INTERVAL - 1 MONTH);
+------------------------------------------------------------------------+
| DATE_ADD(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR),INTERVAL - 1 MONTH) |
+------------------------------------------------------------------------+
| 2019-11-10                                                             |
+------------------------------------------------------------------------+
1 row in set (0.00 sec)

第三步，获取 2019 年 11 月 10 日这个时间点月份的最后一天，继续接近我们的目标：2019 年 12 月 01 日。

mysql>  SELECT LAST_DAY(DATE_ADD(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR),INTERVAL - 1 MONTH));
+----------------------------------------------------------------------------------+
| LAST_DAY(DATE_ADD(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR),INTERVAL - 1 MONTH)) |
+----------------------------------------------------------------------------------+
| 2019-11-30                                                                       |
+----------------------------------------------------------------------------------+
1 row in set (0.00 sec)

到这里，我们获得了 2019 年 11 月 30 日这个日期。你是不是觉得我们已经达到目的了呢？要是这样的话，你就错了。因为 2019 年 11 月 30 日可能会有销售的。如果用这个日期作为统计销售额的起始日期，你就多算了这一天的销售。怎么办呢？我们还要进行下一步。

第四步，计算 2019 年 11 月 30 日后一天的日期：

mysql> SELECT DATE_ADD(LAST_DAY(DATE_ADD(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR),INTERVAL - 1 MONTH)),INTERVAL 1 DAY);
+-----------------------------------------------------------------------------------------------------------+
| DATE_ADD(LAST_DAY(DATE_ADD(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR),INTERVAL - 1 MONTH)),INTERVAL 1 DAY) |
+-----------------------------------------------------------------------------------------------------------+
| 2019-12-01                                                                                                |
+-----------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

你看，我们终于获得了正确的起始日期：2019 年 12 月 01 日。

同样，我们可以用下面的方法，获得截止日期：

mysql> SELECT DATE_ADD(LAST_DAY(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR)),INTERVAL 1 DAY);
+------------------------------------------------------------------------------+
| DATE_ADD(LAST_DAY(DATE_ADD('2020-12-10', INTERVAL - 1 YEAR)),INTERVAL 1 DAY) |
+------------------------------------------------------------------------------+
| 2020-01-01                                                                   |
+------------------------------------------------------------------------------+
1 row in set (0.00 sec)

简单小结下：我们可以用 DATE_ADD() 来计算从某个时间点开始，过去或者未来一个时间间隔的时间；通过 LAST_DAY() 函数，获得某个时间节点当月的最后一天的日期。借助它们，我们就可以获取从某个时间节点出发的指定月份的起始日期和截止日期。

除了 DATE_ADD()，ADDDATE()、DATE_SUB() 和 SUBDATE() 也能达到同样的效果。

ADDDATE()：跟 DATE_ADD() 用法一致；

DATE_SUB()，SUBDATE()：与 DATE_ADD() 用法类似，方向相反，执行日期的减操作。

其他日期时间函数

学习了刚刚的时间函数，我们已经可以应对大部分有关时间的场景了。但是这还不够，有的时候，我们还需要其他的日期时间信息，比如：

今天是几月几号，星期几；

两个时间点之间隔了几天；

把时间按照一定的格式显示出来；

……

这时就要用到其他日期时间函数了，主要包括 CURDATE()、DAYOFWEEK()、DATE_FORMAT 和 DATEDIFF()。

我来借助一个例子，具体解释下这些函数怎么用。

超市经营者为了吸引顾客，经常要进行一些促销活动。具体来讲就是以周为单位，按照周中不同的日期进行促销，比如周一如何打折、周二如何打折、周末如何打折等。那么如何计算当天的价格呢？我们来看下单品促销信息（demo.discountrule）。

这个表中的信息表示单品打折的时间和折扣率：

编号是 1 的商品，周一、周三和周五打折，折扣率分别是 9 折、75 折和 88 折；

编号是 2 的商品，周二、周四和周六打折，折扣率分别是 5 折、65 折和 8 折。

周日，所有商品打 5 折。

如果我们想要查到具体的价格，我们首先要知道当前的日期，以及今天是星期几。这就要用到 2 个 MySQL 的时间函数：CURDATE（）和 DAYOFWEEK（）。

CURDATE（）：获取当前的日期。日期格式为“YYYY-MM-DD”，也就是年月日的格式。

DAYOFWEEK（date）：获取日期“date”是周几。1 表示周日，2 表示周一，以此类推，直到 7 表示周六。

假设今天是 2021 年 02 月 06 日，通过下面的代码，我们就可以查到今天商品的全部折后价格了：

mysql> SELECT
    -> CURDATE() AS 日期,
    -> CASE DAYOFWEEK(CURDATE()) - 1 WHEN 0 THEN 7 ELSE DAYOFWEEK(CURDATE()) - 1 END AS 周几,
    -> a.goodsname AS 商品名称,
    -> a.salesprice AS 价格,
    -> IFNULL(b.discountrate,1) AS 折扣率,
    -> a.salesprice * IFNULL(b.discountrate, 1) AS 折后价格
    -> FROM
    -> demo.goodsmaster a
    -> LEFT JOIN
    -> demo.discountrule b ON (a.itemnumber = b.itemnumber
    -> AND CASE DAYOFWEEK(CURDATE()) - 1 WHEN 0 THEN 7 ELSE DAYOFWEEK(CURDATE()) - 1 END = b.weekday);
+------------+------+----------+-------+--------+----------+
| 日期       | 周几 | 商品名称 | 价格  | 折扣率 | 折后价格 |
+------------+------+----------+-------+--------+----------+
| 2021-02-06 |    6 | 书       | 89.00 |   1.00 |  89.0000 |
| 2021-02-06 |    6 | 笔       |  5.00 |   0.80 |   4.0000 |
| 2021-02-06 |    6 | 橡皮     |  3.00 |   1.00 |   3.0000 |
+------------+------+----------+-------+--------+----------+
3 rows in set (0.00 sec)

这个查询，我们用到了 CURDATE（）函数来获取当前日期，也用到了 DAYOFWEEK（）函数来获取当前是周几的信息。由于 DAYOFWEEK() 函数，以周日为 1 开始计，周一是 2……，周六是 7，而数据表中是从周一为 1 开始计算，为了对齐，我用到了条件判断函数 CASE，我来解释下这个函数。

MySQL 中 CASE 函数的语法如下：

CASE 表达式 WHEN 值1 THEN 表达式1 [ WHEN 值2 THEN 表达式2] ELSE 表达式m END

在我们这个查询中，“表达式”有 7 种可能的值。通过 CASE 函数，我们可以根据 DAYOFWEEK() 函数返回的值对每个返回值进行处理，从而跟促销信息表中的字段 weekday 对应。

除了获取特定的日期，咱们还经常需要把日期按照一定的格式显示出来，这就要用到日期时间格式化的函数 DATE_FORMAT()，它表示将日期时间“date”按照指定格式显示。

举个小例子，张三希望用 24 小时制来查看时间，那么他就可以通过使用 DATE_FORMAT() 函数，指定格式“%T”来实现：

mysql> SELECT DATE_FORMAT("2020-12-01 13:25:50","%T");
+-----------------------------------------+
| DATE_FORMAT("2020-12-01 13:25:50","%T") |
+-----------------------------------------+
| 13:25:50                                |
+-----------------------------------------+
1 row in set (0.00 sec)

李四习惯按照上下午的方式来查看时间，同样，他可以使用 DATE_FORMAT() 函数，通过指定格式“%r”来实现：

mysql> SELECT DATE_FORMAT("2020-12-01 13:25:50","%r");
+-----------------------------------------+
| DATE_FORMAT("2020-12-01 13:25:50","%r") |
+-----------------------------------------+
| 01:25:50 PM                             |
+-----------------------------------------+
1 row in set (0.00 sec

格式的详细内容非常丰富，我就不一一介绍了，我给你分享一个链接，你可以随时查看一下。

另外一个重要的时间函数是 DATEDIFF（date1,date2），表示日期“date1”与日期“date2”之间差几天。假如你要计算某段时间的每天交易金额的平均值，只需要把起始日期和截止日期传给这个函数，就可以得到中间隔了几天。再用总计金额除以这个天数，就可以算出来了：

mysql> SELECT DATEDIFF("2021-02-01","2020-12-01");
+-------------------------------------+
| DATEDIFF("2021-02-01","2020-12-01") |
+-------------------------------------+
|                                  62 |
+-------------------------------------+
1 row in set (0.00 sec)

总结

今天，我们学习了 MySQL 的时间处理函数，包括获取日期时间类型数据中部分信息的函数、计算日期时间的函数和获取特定日期的函数，我用图片来帮你汇总了下。

最后，我还想多说一句，MySQL 中获取的时间，其实就是 MySQL 服务器计算机的系统时间。如果你的系统有一定规模，需要在多台计算机上运行，就要注意时间校准的问题。比如我们的信息系统受门店经营环境和操作人员的素质所限，有时会遇到误操作、停电等故障而导致的计算机系统时间失准问题。这对整个信息系统的可靠性影响非常大。

针对这个问题，有 2 种解决办法。

第一种方法是，可以利用 Windows 系统自带的网络同步的方式，来校准系统时间。

另一种办法就是，门店统一从总部 MySQL 服务器获取时间。由于总部的服务器的配置和运维状况一般要好于门店，所以系统时间出现误差的可能性也较小。如果采用云服务器，系统时间的可靠性会更高。

思考题

假如用户想查一下今天是星期几（不能用数值，要用英文显示），你可以写一个简单的查询语句吗？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

10 | 如何进行数学计算、字符串处理和条件判断？

你好，我是朱晓峰。

MySQL 提供了很多功能强大，而且使用起来非常方便的函数，包括数学函数、字符串处理函数和条件判断函数等。

在很多场景中 ，我们都会用到这些函数，比如说，在超市项目的实际开发过程中，会有这样的需求：

会员积分的规则是一元积一分，不满一元不积分，这就要用到向下取整的数学函数 FLOOR()；

在打印小票的时候，收银纸的宽度是固定的，怎么才能让打印的结果清晰而整齐呢？这个时候，就要用到 CONCAT() 等字符串处理函数；

不同数据的处理方式不同，怎么选择正确的处理方式呢？这就会用到 IF(表达式，V1，V2) 这样的条件判断函数；

……

这些函数对我们管理数据库、提高数据处理的效率有很大的帮助。接下来，我就带你在解决实际问题的过程中，帮你掌握使用这些函数的方法。

数学函数

我们先来学习下数学函数，它主要用来处理数值数据，常用的主要有 3 类，分别是取整函数 ROUND()、CEIL()、FLOOR()，绝对值函数 ABS() 和求余函数 MOD()。

知道了这些函数，我们来看看超市经营者的具体需求。他们提出，为了提升销量，要进行会员营销，主要是给会员积分，并以积分数量为基础，给会员一定的优惠。

积分的规则也很简单，就是消费一元积一分，不满一元不积分，那我们就需要对销售金额的数值进行取整。

这里主要用到四个表，分别是销售单明细表、销售单头表、商品信息表和会员信息表。为了方便你理解，我对表结构和数据进行了简化。

销售单明细表：

销售单头表：

商品信息表：

会员信息表：

这个场景下，可以用到 MySQL 数学函数中的取整函数，主要有 3 种。

向上取整 CEIL(X) 和 CEILING(X)：返回大于等于 X 的最小 INT 型整数。

向下取整 FLOOR(X)：返回小于等于 X 的最大 INT 型整数。

舍入函数 ROUND(X,D)：X 表示要处理的数，D 表示保留的小数位数，处理的方式是四舍五入。ROUND(X) 表示保留 0 位小数。

现在积分的规则是一元积一分，不满一元不积分，显然是向下取整，那就可以用 FLOOR（）函数。

首先，我们要通过关联查询，获得会员消费的相关信息：

mysql>  SELECT
    -> c.membername AS '会员',   -- 从会员表获取会员名称
    -> b.transactionno AS '单号',-- 从销售单头表获取单号
    -> b.transdate AS '交易时间', -- 从销售单头表获取交易时间
    -> d.goodsname AS '商品名称', -- 从商品信息表获取商品名称
    -> a.salesvalue AS '交易金额'
    -> FROM
    -> demo.transactiondetails a
    -> JOIN
    -> demo.transactionhead b ON (a.transactionid = b.transactionid)
    -> JOIN
    -> demo.membermaster c ON (b.memberid = c.memberid)
    -> JOIN
    -> demo.goodsmaster d ON (a.itemnumber = d.itemnumber);
+------+------------------+---------------------+----------+----------+
| 会员 | 单号             | 交易时间            | 商品名称 | 交易金额 |
+------+------------------+---------------------+----------+----------+
| 张三 | 0120201201000001 | 2020-12-01 14:25:56 | 书       |   176.22 |
| 张三 | 0120201201000001 | 2020-12-01 14:25:56 | 笔       |    24.75 |
| 李四 | 0120201202000001 | 2020-12-02 10:50:50 | 书       |   234.96 |
| 李四 | 0120201202000001 | 2020-12-02 10:50:50 | 笔       |    26.40 |
+------+------------------+---------------------+----------+----------+
4 rows in set (0.01 sec)

接着，我们用 FLOOR（a.salesvalue），对销售金额向下取整，获取会员积分值，代码如下：

mysql> SELECT
    -> c.membername AS '会员',
    -> b.transactionno AS '单号',
    -> b.transdate AS '交易时间',
    -> d.goodsname AS '商品名称',
    -> a.salesvalue AS '交易金额',
    -> FLOOR(a.salesvalue) AS '积分'  -- 使用FLOOR函数向下取整
    -> FROM
    -> demo.transactiondetails a
    -> JOIN
    -> demo.transactionhead b ON (a.transactionid = b.transactionid)
    -> JOIN
    -> demo.membermaster c ON (b.memberid = c.memberid)
    -> JOIN
    -> demo.goodsmaster d ON (a.itemnumber = d.itemnumber);
+------+------------------+---------------------+----------+----------+------+
| 会员 | 单号             | 交易时间            | 商品名称 | 交易金额 | 积分 |
+------+------------------+---------------------+----------+----------+------+
| 张三 | 0120201201000001 | 2020-12-01 14:25:56 | 书       |   176.22 |  176 |
| 张三 | 0120201201000001 | 2020-12-01 14:25:56 | 笔       |    24.75 |   24 |
| 李四 | 0120201202000001 | 2020-12-02 10:50:50 | 书       |   234.96 |  234 |
| 李四 | 0120201202000001 | 2020-12-02 10:50:50 | 笔       |    26.40 |   26 |
+------+------------------+---------------------+----------+----------+------+
4 rows in set (0.01 sec)

你看，通过 FLOOR()，我们轻松地获得了超市经营者需要的积分数据。

类似的，如果用户的积分规则改为“不满一元积一分”，其实就是对金额数值向上取整，这个时候，我们就可以用 CEIL() 函数。操作方法和前面是一样的，我就不具体解释了。

最后，我再来讲一讲舍入函数 ROUND（）的使用方法。

超市经营者提出，收银的时候，应收金额可以被设定四舍五入到哪一位。比如，可以设定四舍五入到元、到角，或者到分。

按照指定的位数，对小数进行四舍五入计算，这样的场景就要用到 ROUND（X,D）了。它的作用是通过四舍五入，对数值 X 保留 D 位小数。

根据超市经营者的要求，我们把函数 ROUND(X,D) 中的保留小数的位数 D 设置成 0、1 和 2。

如果要精确到分，我们可以设置保留 2 位小数：

mysql> SELECT ROUND(salesvalue,2) -- D设置成2，表示保留2位小数，也就是精确到分
-> FROM demo.transactiondetails
-> WHERE transactionid=1 AND itemnumber=1;
+---------------------+
| ROUND(salesvalue,2) |
+---------------------+
| 176.22 |
+---------------------+
1rows in set (0.00 sec)

如果要精确到角，可以设置保留 1 位小数：

mysql> SELECT ROUND(salesvalue,1) -- D设置成1，表示保留1位小数，也就是精确到角
-> FROM demo.transactiondetails
-> WHERE transactionid=1 AND itemnumber=1;
+---------------------+
| ROUND(salesvalue,1) |
+---------------------+
| 176.2 |
+---------------------+
1 rows in set (0.00 sec

如果要精确到元，可以设置保留 0 位小数：

mysql> SELECT ROUND(salesvalue,0)-- D设置成0，表示保留0位小数，也就是精确到元
-> FROM demo.transactiondetails
-> WHERE transactionid=1 AND itemnumber=1;
+---------------------+
| ROUND(salesvalue,0) |
+---------------------+
| 176 |
+---------------------+
1 rows in set (0.00 se

除了刚刚我们所学习的函数，MySQL 还支持绝对值函数 ABS（）和求余函数 MOD（），ABS（X）表示获取 X 的绝对值；MOD（X，Y）表示获取 X 被 Y 除后的余数。

这些函数使用起来都比较简单，你重点掌握它们的含义就可以了，下面我再带你学习下字符串函数。

字符串函数

除了数学计算，我们还经常会遇到需要对字符串进行处理的场景，比如我们想要在金额前面加一个“￥”的符号，就会用到字符串拼接函数；再比如，我们需要把一组数字以字符串的形式在网上传输，就要用到类型转换函数。

常用的字符串函数有 4 个。

CONCAT（s1,s2,...）：表示把字符串 s1、s2……拼接起来，组成一个字符串。

CAST（表达式 AS CHAR）：表示将表达式的值转换成字符串。

CHAR_LENGTH（字符串）：表示获取字符串的长度。

SPACE（n）：表示获取一个由 n 个空格组成的字符串。

接下来我还是借助超市项目中的实际应用场景，来说明一下怎么使用这些字符串函数。

顾客交了钱，完成交易之后，系统必须要打出一张小票。打印小票时，对格式有很多要求。比如说，一张小票纸，57 毫米宽，大概可以打 32 个字符，也就是 16 个汉字。用户要求一条流水打 2 行，第一行是商品信息，第二行要包括数量、价格、折扣和金额 4 种信息。那么，怎么才能清晰地在小票上打印出这些信息，并且打印得整齐漂亮呢？这就涉及对字符串的处理了。

首先，我们来看一下如何打印第一行的商品信息。商品信息包括：商品名称和商品规格，而且商品规格要包含在括号里面。这样就必须把商品名称和商品规格拼接起来，变成一个字符串。

这时，我们就可以用合并字符串函数 CONCAT（），如下所示：

mysql> SELECT
-> CONCAT(goodsname, '(', specification, ')') AS 商品信息 -- 这里把商品名称、括号和规格拼接起来
-> FROM
-> demo.goodsmaster
-> WHERE itemnumber = 1;
+----------+
| 商品信息 |
+----------+
| 书(16开) |
+----------+
1 row in set (0.00 sec)

这样，我们就得到了商品编号是 1 的商品，它的商品信息是：“书（16 开）”。

第二步，我们来看一下如何打印第二行。第二行包括数量、价格、折扣和金额，一共是 4 种信息。

因为一行最多是 32 个字符，我们给数量分配 7 个字符，价格分配 7 个字符，折扣分配 6 个字符，金额分配 9 个字符，加上中间 3 个空格，正好是 32 个字符。

为啥这么分配呢？我简单解释下。

数量 7 个字符，就是小数点前面给 3 位，小数点后面给 3 位，外加小数点 1 位，最大 999.999，基本满足零售的需求了。

同样道理，价格给 7 位，意思是小数点前面 4 位，小数点后面 2 位，外加小数点，这样最大可以表示 9999.99。

折扣 6 位，小数点后面 2 位，小数点前面 2 位，加上小数点和“%”，这样是够用的。

金额 9 位，最大可以显示到 999999.99，也够用了。

分配好了各部分信息的字符串大小，我再讲一下格式处理，因为数据的取值每次都会不同，如果直接打印，会参差不齐。这里我以数量为例，来具体说明一下。因为数量比较有代表性，而且比较简单，不像金额或者折扣率那样，有时还要根据用户的需求，加上“￥”或者“%”。

第一步，把数量转换成字符串。这里我们需要用到把数值转换成字符串的 CAST（）函数，如下所示：

mysql> SELECT
-> CAST(quantity AS CHAR) -- 把decimal类型转换成字符串
-> FROM
-> demo.transactiondetails
-> WHERE
-> transactionid = 1 AND itemnumber =1;
+---------------------+
| CAST(price AS CHAR) |
+---------------------+
| 2.000 |
+---------------------+
1 rows in set (0.00 sec)

第二步，计算字符串的长度，这里我们要用到 CHAR_LENGTH（）函数。

需要注意的是，虽然每个汉字打印的时候占 2 个字符长度，但是这个函数获取的是汉字的个数。因此，如果字符串中有汉字，函数获取的字符串长度跟实际打印的长度是不一样的，需要用空格来补齐。

我们可以通过下面的查询，获取数量字段转换成字符串后的字符串长度：

mysql> SELECT
-> CHAR_LENGTH(CAST(quantity AS CHAR)) AS 长度
-> FROM
-> demo.transactiondetails
-> WHERE
-> transactionid = 1 AND itemnumber =1;
+---------------------+
| 长度 |
+---------------------+
| 5 |
+---------------------+
1 rows in set (0.00 sec)

第三步，用空格补齐 7 位长度。这时，我们要用到 SPACE（）函数。

因为我们采用左对齐的方式打印（左对齐表示字符串从左边开始，右边空余的位置用空格补齐），所以就需要先拼接字符串，再在字符串的后面补齐空格：

mysql> SELECT
-> CONCAT(CAST(quantity AS CHAR),
-> SPACE(7 - CHAR_LENGTH(CAST(quantity AS CHAR)))) AS 数量
-> FROM
-> demo.transactiondetails
-> WHERE
-> transactionid = 1 AND itemnumber = 1;
+----------+
| 数量 |
+----------+
| 2.000 |
+----------+
1 row in set (0.00 sec)

除此以外，MySQL 还支持 SUBSTR（）、MID（）、TRIM（）、LTRIM（）、RTRIM（）。我画了一张图来展示它们的含义，你可以了解一下。

一般来说，关于字符串函数，你掌握这些就足够了。不过，MySQL 支持的字符串函数还有很多，如果你在实际工作中，遇到了更复杂的情况，可以参考 MySQL 官方的文档。

条件判断函数

我们刚才在对商品信息字符串进行拼接的时候，会有一种例外的情况，那就是当规格为空的时候，商品信息会变成“NULL”。这个结果显然不是我们想要的，因为名称变成 NULL，顾客会觉得奇怪，也不知道买了什么商品。我们希望，如果规格是空值，就不用加规格了。怎么实现呢？这就要用到条件判断函数了。

条件判断函数的主要作用，就是根据特定的条件返回不同的值，常用的有两种。

IFNULL（V1，V2）：表示如果 V1 的值不为空值，则返回 V1，否则返回 V2。

IF（表达式，V1，V2）：如果表达式为真（TRUE），则返回 V1，否则返回 V2。

我们希望规格是空的商品，拼接商品信息字符串的时候，规格不要是空。这个问题，可以通过 IFNULL(specification, '') 函数来解决。具体点说就是，对字段“specification”是否为空进行判断，如果为空，就返回空字符串，否则就返回商品规格 specification 的值。代码如下所示：

mysql> SELECT
    -> goodsname,
    -> specification,
    -> CONCAT(goodsname,'(', IFNULL(specification, ''),')') AS 拼接 -- 用条件判断函数，如果规格是空，则括号中是空字符串
    -> FROM
    -> demo.goodsmaster;
+-----------+---------------+----------+
| goodsname | specification | 拼接     |
+-----------+---------------+----------+
| 书        | 16开          | 书(16开) |
| 笔        | NULL          | 笔()     |
+-----------+---------------+----------+
2 rows in set (0.00 sec)

结果是，如果规格为空，商品信息就变成了“商品信息（）”，好像还不错。但是也存在一点问题：商品名称后面的那个空括号“()”会让客人觉得奇怪，能不能去掉呢？

如果用 IFNULL（V1，V2）函数，就不容易做到，但是没关系，我们可以尝试用另一个条件判断函数 IF（表达式，V1，V2）来解决。这里表达式是 ISNULL(specification)，这个函数用来判断字段"specificaiton"是否为空，V1 是返回商品名称，V2 是返回商品名称拼接规格。代码如下所示：

mysql> SELECT
    -> goodsname,
    -> specification,
    -> -- 这里做判断，如果是空值，返回商品名称，否则就拼接规格
    -> IF(ISNULL(specification),
    -> goodsname,
    -> CONCAT(goodsname, '(', specification, ')')) AS 拼接
    -> FROM
    -> demo.goodsmaster;
+-----------+---------------+----------+
| goodsname | specification | 拼接     |
+-----------+---------------+----------+
| 书        | 16开          | 书(16开) |
| 笔        | NULL          | 笔       |
+-----------+---------------+----------+
2 rows in set (0.02 sec)

这个结果就是，如果规格为空，商品信息就是商品名称；如果规格不为空，商品信息是商品名称拼接商品规格，这就达到了我们的目的。

总结

今天，我们学习了用于提升数据处理效率的数学函数、字符串函数和条件判断函数。

这些函数看起来很容易掌握，但是有很多坑。比如说，ROUND（X）是对 X 小数部分四舍五入，那么在“五入”的时候，返回的值是不是一定比 X 大呢？其实不一定，因为当 X 为负数时，五入的值会更小。你可以看看下面的代码：

mysql> SELECT ROUND(-1.5);
+-------------+
| ROUND(-1.5) |
+-------------+
|          -2 |
+-------------+
1 row in set (0.00 sec)

所以，我建议你在学习的时候，多考虑边界条件的场景，实际测试一下。就像这个问题，对于 ROUND(X,0)，并没有指定 X 是正数，那如果是负数，会怎样呢？你去测试一下，就明白了。

思考题

这节课，我介绍了如何用 FLOOR（）函数来计算会员积分，那么，如果不用 FLOOR（），有没有其他办法来实现会员积分的计算呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

11 | 索引：如何提高查询的速度？

你好，我是朱晓峰。

在我们的超市信息系统刚刚开始运营的时候，因为数据量很少，每一次的查询都能很快拿到结果。但是，系统运转时间长了以后，数据量不断地累积，变得越来越庞大，很多查询的速度就变得特别慢。这个时候，我们就采用了 MySQL 提供的高效访问数据的方法—— 索引，有效地解决了这个问题，甚至之前的一个需要 8 秒钟才能完成的查询，现在只用 0.3 秒就搞定了，速度提升了 20 多倍。

那么，索引到底是啥呢？该怎么使用呢？这节课，我们就来聊一聊。

索引是什么？

如果你去过图书馆，应该会知道图书馆的检索系统。图书馆为图书准备了检索目录，包括书名、书号、对应的位置信息，包括在哪个区、哪个书架、哪一层。我们可以通过书名或书号，快速获知书的位置，拿到需要的书。

MySQL 中的索引，就相当于图书馆的检索目录，它是帮助 MySQL 系统快速检索数据的一种存储结构。我们可以在索引中按照查询条件，检索索引字段的值，然后快速定位数据记录的位置，这样就不需要遍历整个数据表了。而且，数据表中的字段越多，表中数据记录越多，速度提升越是明显。

我来举个例子，进一步解释下索引的作用。这里要用到销售流水表（demo.trans），表结构如下：

mysql> describe demo.trans;
+---------------+----------+------+-----+---------+-------+
| Field         | Type     | Null | Key | Default | Extra |
+---------------+----------+------+-----+---------+-------+
| itemnumber    | int      | YES  | MUL | NULL    |       |
| quantity      | text     | YES  |     | NULL    |       |
| price         | text     | YES  |     | NULL    |       |
| transdate     | datetime | YES  | MUL | NULL    |       |
| actualvalue   | text     | YES  |     | NULL    |       |
| barcode       | text     | YES  |     | NULL    |       |
| cashiernumber | int      | YES  | MUL | NULL    |       |
| branchnumber  | int      | YES  | MUL | NULL    |       |
| transuniqueid | text     | YES  |     | NULL    |       |
+---------------+----------+------+-----+---------+-------+
9 rows in set (0.02 sec)

某个门店的销售流水表有 400 万条数据，现在我要查看一下商品编号是 100 的商品在 2020-12-12 这一天的销售情况，查询代码如下：

mysql> SELECT
-> quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> transdate > '2020-12-12'
-> AND transdate < '2020-12-13'
-> AND itemnumber = 100;
+----------+--------+---------------------+
| quantity | price | transdate |
+----------+--------+---------------------+
| 1.000 | 220.00 | 2020-12-12 19:45:36 |
| 1.000 | 220.00 | 2020-12-12 08:56:37 |
+----------+--------+---------------------+
2 rows in set (8.08 sec)

可以看到，结果总共有 2 条记录，可是却花了 8 秒钟，非常慢。同时，这里我没有做表的关联，这只是单表的查询，而且只是一个门店几个月的数据而已。而总部是把所有门店的数据都汇总到一起，查询速度更慢，这样的查询效率，我们肯定是不能接受的。

怎么解决这个问题呢？这时，我们就可以给数据表添加索引。

单字段索引

MySQL 支持单字段索引和组合索引，而单字段索引比较常用，我们先来学习下创建单字段索引的方法。

如何创建单字段索引？

创建单字段索引，一般有 3 种方式：

你可以通过 CREATE 语句直接给已经存在的表创建索引，这种方式比较简单，我就不多解释了；

可以在创建表的同时创建索引；

可以通过修改表来创建索引。

直接给数据表创建索引的语法如下：

CREATE INDEX 索引名 ON TABLE 表名 (字段);

创建表的同时创建索引的语法如下所示：

CREATE TABLE 表名
(
字段 数据类型,
….
{ INDEX | KEY } 索引名(字段)
)

修改表时创建索引的语法如下所示：

ALTER TABLE 表名 ADD { INDEX | KEY } 索引名 (字段);

这里有个小问题要提醒你一下，给表设定主键约束或者唯一性约束的时候，MySQL 会自动创建主键索引或唯一性索引。这也是我建议你在创建表的时候，一定要定义主键的原因之一。

举个小例子，我们可以给表 demo.trans 创建索引如下：

mysql> CREATE INDEX index_trans ON demo.trans (transdate(10));
Query OK, 0 rows affected (1 min 8.71 sec)
Records: 0 Duplicates: 0 Warnings: 0

mysql> SELECT
-> quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> transdate > '2020-12-12'
-> AND transdate < '2020-12-13'
-> AND itemnumber = 100;
+----------+--------+---------------------+
| quantity | price | transdate |
+----------+--------+---------------------+
| 1.000 | 220.00 | 2020-12-12 19:45:36 |
| 1.000 | 220.00 | 2020-12-12 08:56:37 |
+----------+--------+---------------------+
2 rows in set (0.30 sec)

可以看到，加了索引之后，这一次我们只用了 0.3 秒，比没有索引的时候，快了 20 多倍。这么大的差距，说明索引对提高查询的速度确实很有帮助。那么，索引是如何做到这一点的呢？下面我们来学习下单字段索引的作用原理。

单字段索引的作用原理

要知道索引是怎么起作用的，我们需要借助 MySQL 中的 EXPLAIN 这个关键字。

EXPLAIN 关键字能够查看 SQL 语句的执行细节，包括表的加载顺序，表是如何连接的，以及索引使用情况等。

mysql> EXPLAIN SELECT
-> quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> transdate > '2020-12-12'
-> AND transdate < '2020-12-13'
-> AND itemnumber = 100;
+----+-------------+-------------+------------+-------+-------------------+-------------------+---------+------+------+----------+-----------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------------+------------+-------+-------------------+-------------------+---------+------+------+----------+-----------------------------------------------+
| 1 | SIMPLE | trans | NULL | range | index_trans | index_trans | 6 | NULL | 5411 | 10.00 | Using index condition; Using where; Using MRR |
+----+-------------+-------------+------------+-------+-------------------+-------------------+---------+------+------+----------+-----------------------------------------------+
1 row in set, 1 warning (0.00 sec)

我来解释下代码里的关键内容。

type=range：表示使用索引查询特定范围的数据记录。

rows=5411：表示需要读取的记录数。

possible_keys=index_trans：表示可以选择的索引是 index_trans。

key=index_trans：表示实际选择的索引是 index_trans。

extra=Using index condition;Using where;Using MRR：这里面的信息对 SQL 语句的执行细节做了进一步的解释，包含了 3 层含义：第一个是执行时使用了索引，第二个是执行时通过 WHERE 条件进行了筛选，第三个是使用了顺序磁盘读取的策略。

通过这个小例子，我们可以发现，有了索引之后，MySQL 在执行 SQL 语句的时候多了一种优化的手段。也就是说，在查询的时候，可以先通过查询索引快速定位，然后再找到对应的数据进行读取，这样就大大提高了查询的速度。

如何选择索引字段？

在刚刚的查询中，我们是选择 transdate（交易时间）字段来当索引字段，你可能会问，为啥不选别的字段呢？这是因为，交易时间是查询条件。MySQL 可以按照交易时间的限定“2020 年 12 月 12 日”，在索引中而不是数据表中寻找满足条件的索引记录，再通过索引记录中的指针来定位数据表中的数据。这样，索引就能发挥作用了。

不过，你有没有想过，itemnumber 字段也是查询条件，能不能用 itemnumber 来创建一个索引呢？我们来试一试：

mysql> CREATE INDEX index_trans_itemnumber ON demo.trans (itemnumber);
Query OK, 0 rows affected (43.88 sec)
Records: 0 Duplicates: 0 Warnings: 0

然后看看效果：

mysql> SELECT
-> quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> transdate > '2020-12-12'      -- 对交易时间的筛选，可以在transdate的索引中定位
-> AND transdate < '2020-12-13'
-> AND itemnumber = 100;         -- 对商品编号的筛选，可以在itemnumber的索引中定位
+----------+--------+---------------------+
| quantity | price | transdate |
+----------+--------+---------------------+
| 1.000 | 220.00 | 2020-12-12 19:45:36 |
| 1.000 | 220.00 | 2020-12-12 08:56:37 |
+----------+--------+---------------------+
2 rows in set (0.38 sec)

我们发现，用 itemnumber 创建索引之后，查询速度跟之前差不多，基本在同一个数量级。

这是为啥呢？我们来看看 MySQL 的运行计划：

mysql> EXPLAIN SELECT
-> quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> transdate > '2020-12-12'
-> AND transdate < '2020-12-13'
-> AND itemnumber = 100;                 -- 对itemnumber 进行限定
+----+-------------+-------------+------------+------+------------------------------------------------+------------------------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------------+------------+------+------------------------------------------------+------------------------------+---------+-------+------+----------+-------------+
| 1 | SIMPLE | trans | NULL | ref | index_trans,index_trans_itemnumber | index_trans_itemnumber | 5 | const | 1192 | 0.14 | Using where |
+----+-------------+-------------+------------+------+------------------------------------------------+------------------------------+---------+-------+------+----------+-------------+
1 row in set, 1 warning (0.01 sec)

我们发现，“possible_keys= index_trans,index_trans_itemnumber ”，就是说 MySQL 认为可以选择的索引确实有 2 个，一个是用 transdate 字段创建的索引 index_trans，另一个是用 itemnumber 字段创建的索引 index_trans_itemnumber。

key= index_trans_itemnumber， 说明 MySQL 实际选择使用的索引是 itemnumber 字段创建的索引 index_trans_itemnumber。而 rows=1192，就表示实际读取的数据记录数只有 1192 个，比用 transdate 创建的索引 index_trans 的实际读取记录数要少，这就是 MySQL 选择使用 itemnumber 索引的原因。

所以，我建议你在选择索引字段的时候，要选择那些经常被用做筛选条件的字段。这样才能发挥索引的作用，提升检索的效率。

组合索引

在实际工作中，有时会遇到比较复杂的数据表，这种表包括的字段比较多，经常需要通过不同的字段筛选数据，特别是数据表中包含多个层级信息。比如我们的销售流水表就包含了门店信息、收款机信息和商品信息这 3 个层级信息。门店对应多个门店里的收款机，每个收款机对应多个从这台收款机销售出去的商品。我们经常要把这些层次信息作为筛选条件，来进行查询。这个时候单字段的索引往往不容易发挥出索引的最大功效，可以使用组合索引。

现在，先看看单字段索引的效果，我们分别用 branchnumber 和 cashiernumber 来创建索引：

mysql> CREATE INDEX index_trans_branchnumber ON demo.trans (branchnumber);
Query OK, 0 rows affected (41.49 sec)
Records: 0 Duplicates: 0 Warnings: 0

 mysql> CREATE INDEX index_trans_cashiernumber ON demo.trans (cashiernumber);
Query OK, 0 rows affected (41.95 sec)
Records: 0 Duplicates: 0 Warnings: 0

有了门店编号和收款机编号的索引，现在我们就尝试一下以门店编号、收款机编号和商品编号为查询条件，来验证一下索引是不是起了作用。

mysql> SELECT
-> itemnumber,quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> branchnumber = 11 AND cashiernumber = 1 -- 门店编号和收款机号为筛选条件
-> AND itemnumber = 100;    -- 商品编号为筛选条件
+------------+----------+--------+---------------------+
| itemnumber | quantity | price | transdate |
+------------+----------+--------+---------------------+
| 100 | 1.000 | 220.00 | 2020-07-11 09:18:35 |
| 100 | 1.000 | 220.00 | 2020-09-06 21:21:58 |
| 100 | 1.000 | 220.00 | 2020-11-10 15:00:11 |
| 100 | 1.000 | 220.00 | 2020-12-25 14:28:06 |
| 100 | 1.000 | 220.00 | 2021-01-09 20:21:44 |
| 100 | 1.000 | 220.00 | 2021-02-08 10:45:05 |
+------------+----------+--------+---------------------+
6 rows in set (0.31 sec)

结果有 6 条记录，查询时间是 0.31 秒，跟只创建商品编号索引差不多。下面我们就来查看一下执行计划，看看新建的索引有没有起作用。

mysql> EXPLAIN SELECT
-> itemnumber,quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> branchnumber = 11 AND cashiernumber = 1
-> AND itemnumber = 100;
+----+-------------+-------+------------+------+---------------------------------------------------------------------------+------------------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------------------------------------------------------------------+------------------------+---------+-------+------+----------+-------------+
| 1 | SIMPLE | trans | NULL | ref | index_trans_itemnumber,index_trans_branchnumber,index_trans_cashiernumber | index_trans_itemnumber | 5 | const | 1192 | 20.50 | Using where |
+----+-------------+-------+------------+------+---------------------------------------------------------------------------+------------------------+---------+-------+------+----------+-------------+
1 row in set, 1 warning (0.01 sec)

MySQL 有 3 个索引可以用，分别是用 branchnumber 创建的 index_trans_branchnumber、用 cashiernumber 创建的 index_trans_cashiernumber 和用 itemnumber 创建的 index_trans_itemnumber。

最后，MySQL 还是选择了 index_trans_itemnumber，实际筛选的记录数是 1192，花费了 0.31 秒。

为什么 MySQL 会这样选呢？这是因为，优化器现在有 3 种索引可以用，分别是商品编号索引、门店编号索引和收款机号索引。优化器发现，商品编号索引实际搜索的记录数最少，所以最后就选择了这种索引。

所以，如果有多个索引，而这些索引的字段同时作为筛选字段出现在查询中的时候，MySQL 会选择使用最优的索引来执行查询操作。

能不能让这几个筛选字段同时发挥作用呢？这就用到组合索引了。组合索引，就是包含多个字段的索引。MySQL 最多支持由 16 个字段组成的组合索引。

如何创建组合索引？

创建组合索引的语法结构与创建单字段索引相同，不同的是相比单字段索引，组合索引使用了多个字段。

直接给数据表创建索引的语法如下：

CREATE INDEX 索引名 ON TABLE 表名 (字段1，字段2，...);

创建表的同时创建索引：

CREATE TABLE 表名
(
字段 数据类型,
….
{ INDEX | KEY } 索引名(字段1，字段2，...)
)

修改表时创建索引：

ALTER TABLE 表名 ADD { INDEX | KEY } 索引名 (字段1，字段2，...);

现在，针对刚刚的查询场景，我们就可以通过创建组合索引，发挥多个字段的筛选作用。

具体做法是，我们给销售流水表创建一个由 3 个字段 branchnumber、cashiernumber、itemnumber 组成的组合索引，如下所示：

mysql> CREATE INDEX Index_branchnumber_cashiernumber_itemnumber ON demo.trans (branchnumber,cashiernumber,itemnumber);
Query OK, 0 rows affected (59.26 sec)
Records: 0 Duplicates: 0 Warnings: 0

有了组合索引，刚刚的查询速度就更快了：

mysql> SELECT
-> itemnumber,quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> branchnumber = 11 AND cashiernumber = 1
-> AND itemnumber = 100;
+------------+----------+--------+---------------------+
| itemnumber | quantity | price | transdate |
+------------+----------+--------+---------------------+
| 100 | 1.000 | 220.00 | 2020-07-11 09:18:35 |
| 100 | 1.000 | 220.00 | 2020-09-06 21:21:58 |
| 100 | 1.000 | 220.00 | 2020-11-10 15:00:11 |
| 100 | 1.000 | 220.00 | 2020-12-25 14:28:06 |
| 100 | 1.000 | 220.00 | 2021-01-09 20:21:44 |
| 100 | 1.000 | 220.00 | 2021-02-08 10:45:05 |
+------------+----------+--------+---------------------+
6 rows in set (0.00 sec)

几乎是瞬间就完成了，不超过 10 毫秒。我们看看 MySQL 的执行计划：

mysql> EXPLAIN SELECT
-> itemnumber,quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE                         -- 同时筛选门店编号、收款机号和商品编号
-> branchnumber = 11 AND cashiernumber = 1
-> AND itemnumber = 100;
+----+-------------+-------+------------+------+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------+---------+-------------------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------+---------+-------------------+------+----------+-------+
| 1 | SIMPLE | trans | NULL | ref | index_trans_itemnumber,index_trans_branchnumber,index_trans_cashiernumber,index_branchnumber_cashiernumber_itemnumber | index_branchnumber_cashiernumber_itemnumber | 15 | const,const,const | 6 | 100.00 | NULL |
+----+-------------+-------+------------+------+-----------------------------------------------------------------------------------------------------------------------+---------------------------------------------+---------+-------------------+------+----------+-------+
1 row in set, 1 warning (0.01 sec)

这个查询，MySQL 可以用到的索引有 4 个：

index_trans_itemnumber；

index_trans_branchnumber；

index_trans_cashiernumber；

我们刚才用 branchnumber、cashiernumber 和 itemnumber 创建的组合索引 Index_branchnumber_cashiernumber_itemnumber。

MySQL 选择了组合索引，筛选后读取的记录只有 6 条。组合索引被充分利用，筛选更加精准，所以非常快。

组合索引的原理

下面我就来讲讲组合索引的工作原理。

组合索引的多个字段是有序的，遵循左对齐的原则。比如我们创建的组合索引，排序的方式是 branchnumber、cashiernumber 和 itemnumber。因此，筛选的条件也要遵循从左向右的原则，如果中断，那么，断点后面的条件就没有办法利用索引了。

比如说我们刚才的条件，branchnumber = 11 AND cashiernumber = 1 AND itemnumber = 100，包含了从左到右的所有字段，所以可以最大限度使用全部组合索引。

假如把条件换成“cashiernumber = 1 AND itemnumber = 100”，由于我们的组合索引是按照 branchnumber、cashiernumber 和 itemnumber 的顺序建立的，最左边的字段 branchnumber 没有包含到条件当中，中断了，所以这个条件完全不能使用组合索引。

类似的，如果筛选的是一个范围，如果没有办法无法精确定位，也相当于中断。比如“branchnumber > 10 AND cashiernumber = 1 AND itemnumber = 100”这个条件，只能用到组合索引中 branchnumber>10 的部分，后面的索引就都用不上了。我们来看看 MySQL 的运行计划：

mysql> EXPLAIN SELECT
-> itemnumber,quantity,price,transdate
-> FROM
-> demo.trans
-> WHERE
-> branchnumber > 10 AND cashiernumber = 1 AND itemnumber = 100;
+----+-------------+-------+------------+------+-----------------------------------------------------------------------------------------------------------------------+------------------------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+-----------------------------------------------------------------------------------------------------------------------+------------------------+---------+-------+------+----------+-------------+
| 1 | SIMPLE | trans | NULL | ref | index_trans_itemnumber,index_trans_branchnumber,index_trans_cashiernumber,index_branchnumber_cashiernumber_itemnumber | index_trans_itemnumber | 5 | const | 1192 | 20.50 | Using where |
+----+-------------+-------+------------+------+-----------------------------------------------------------------------------------------------------------------------+------------------------+---------+-------+------+----------+-------------+
1 row in set, 1 warning (0.02 sec)

果然，MySQL 没有选择组合索引，而是选择了用 itemnumber 创建的普通索引 index_trans_itemnumber。因为如果只用组合索引的一部分，效果没有单字段索引那么好。

总结

这节课，我们学习了什么是索引、如何创建和使用索引。索引可以非常显著地提高数据查询的速度，数据表里包含的数据越多，效果越显著。我们应该选择经常被用做筛选条件的字段来创建索引，这样才能通过索引缩小实际读取数据表中数据的范围，发挥出索引的优势。如果有多个筛选的字段，而且经常一起出现，也可以用多个字段来创建组合索引。

如果你要删除索引，就可以用：

DROP INDEX 索引名 ON 表名;

当然， 有的索引不能用这种方法删除，比如主键索引，你就必须通过修改表来删除索引。语法如下：

ALTER TABLE 表名 DROP PRIMARY KEY；

最后，我来跟你说说索引的成本。索引能够提升查询的效率，但是建索引也是有成本的，主要有 2 个方面，一个存储空间的开销，还有一个是数据操作上的开销。

存储空间的开销，是指索引需要单独占用存储空间。

数据操作上的开销，是指一旦数据表有变动，无论是插入一条新数据，还是删除一条旧的数据，甚至是修改数据，如果涉及索引字段，都需要对索引本身进行修改，以确保索引能够指向正确的记录。

因此，索引也不是越多越好，创建索引有存储开销和操作开销，需要综合考虑。

思考题

假如我有一个单品销售统计表，包括门店编号、销售日期（年月日）、商品编号、销售数量、销售金额、成本、毛利，而用户经常需要对销售情况进行查询，你会对这个表建什么样的索引呢？为什么？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

12 | 事务：怎么确保关联操作正确执行？

你好，我是朱晓峰。

我们经常会遇到这样的场景：几个相互关联的数据操作，必须是全部执行，或者全部不执行，不可以出现部分执行的情况。比如说，你从微信账号里提现 100 元到银行卡上，这个动作就包括了相互关联的 2 个步骤，首先是微信账号减 100 元，然后是银行卡账号加 100 元（这里假设没有手续费）。假如因为某种异常，这 2 个操作只执行了一个，另外一个没有执行，就会出现你的钱少了 100 元，或者你的钱多了 100 元的情况，这肯定是不能接受的。

如何才能确保多个关联操作全部执行呢？这时就要用到事务了。接下来我就重点讲一讲什么是事务，以及如何正确使用事务。

什么是事务？

事务是 MySQL 的一项功能，它可以使一组数据操作（也叫 DML 操作，是英文 Data Manipulation Language 的缩写，包括 SELECT、INSERT、UPDATE 和 DELETE），要么全部执行，要么全部不执行，不会因为某种异常情况（比如硬件故障、停电、网络中断等）出现只执行一部分操作的情况。

事务的语法结构如下所示：

START TRANSACTION 或者 BEGIN （开始事务）
一组DML语句
COMMIT（提交事务）
ROLLBACK（事务回滚）

我解释一下这几个关键字。

START TRANSACTION 和 BEGIN：表示开始事务，意思是通知 MySQL，后面的 DML 操作都是当前事务的一部分。

COMMIT：表示提交事务，意思是执行当前事务的全部操作，让数据更改永久有效。

ROLLBACK：表示回滚当前事务的操作，取消对数据的更改。

事务有 4 个主要特征，分别是原子性（atomicity）、一致性（consistency）、持久性（durability）和隔离性（isolation）。

原子性：表示事务中的操作要么全部执行，要么全部不执行，像一个整体，不能从中间打断。

一致性：表示数据的完整性不会因为事务的执行而受到破坏。

隔离性：表示多个事务同时执行的时候，不互相干扰。不同的隔离级别，相互独立的程度不同。

持久性：表示事务对数据的修改是永久有效的，不会因为系统故障而失效。

持久性非常好理解，我就不多说了，接下来我重点讲一讲事务的原子性、一致性和隔离性，这是确保关联操作正确执行的关键。

如何确保操作的原子性和数据的一致性？

我借助一个超市的收银员帮顾客结账的简单场景来讲解。在系统中，结算的动作主要就是销售流水的产生和库存的消减。这里会涉及销售流水表和库存表，如下所示：

销售流水表（demo.mytrans）：

库存表（demo.inventory）：

现在，假设门店销售了 5 个商品编号是 1 的商品，这个动作实际上包括了 2 个相互关联的数据库操作：

向流水表中插入一条“1 号商品卖了 5 个”的销售流水；

把库存表中的 1 号商品的库存减 5。

这里包含了 2 个 DML 操作，为了避免意外事件导致的一个操作执行了而另一个没有执行的情况，我把它们放到一个事务里面，利用事务中数据操作的原子性，来确保数据的一致性。

mysql> START TRANSACTION;   -- 开始事务
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO demo.mytrans VALUES (1,1,5); -- 插入流水
Query OK, 1 row affected (0.00 sec)
mysql> UPDATE demo.inventory SET invquantity = invquantity - 5 WHERE itemnumber = 1;                         -- 更新库存
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> COMMIT;                          -- 提交事务
Query OK, 0 rows affected (0.06 sec)

然后我们查询一下结果：

mysql> SELECT * FROM demo.mytrans;   -- 流水插入成功了
+---------+------------+----------+
| transid | itemnumber | quantity |
+---------+------------+----------+
| 1 | 1 | 5.000 |
+---------+------------+----------+
1 row in set (0.00 sec)
mysql> SELECT * FROM demo.inventory; -- 库存消减成功了
+------------+-------------+
| itemnumber | invquantity |
+------------+-------------+
| 1 | 5.000 |
+------------+-------------+
1 row in set (0.00 sec)

这样，通过把 2 个相关操作放到事务里面，我们就实现了一个事务操作。

这里有一个坑，我要提醒你一下。事务并不会自动帮你处理 SQL 语句执行中的错误，如果你对事务中的某一步数据操作发生的错误不做处理，继续提交的话，仍然会导致数据不一致。

为了方便你理解，我举个小例子。

假如我们的插入一条销售流水的语句少了一个字段，执行的时候出现错误了，如果我们不对这个错误做回滚处理，继续执行后面的操作，最后提交事务，结果就会出现没有流水但库存消减了的情况：

mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)

mysql> INSERT INTO demo.mytrans VALUES (1,5); -- 这个插入语句出错了
ERROR 1136 (21S01): Column count doesn't match value count at row 1

mysql> UPDATE demo.inventory SET invquantity = invquantity - 5 WHERE itemnumber = 1;
Query OK, 1 row affected (0.00 sec)    -- 后面的更新语句仍然执行成功了
Rows matched: 1 Changed: 1 Warnings: 0

mysql> COMMIT;
Query OK, 0 rows affected (0.03 sec)   -- 事务提交成功了

我们查一下表的内容：

mysql> SELECT * FROM demo.mytrans;    -- 流水没有插入成功
Empty set (0.16 sec)
mysql> SELECT * FROM demo.inventory;   -- 库存消减成功了
+------------+-------------+
| itemnumber | invquantity |
+------------+-------------+
| 1 | 5.000 |
+------------+-------------+
1 row in set (0.00 sec)

结果显示，流水插入失败了，但是库存更新成功了，这时候没有销售流水，但是库存却被消减了。

这就是因为没有正确使用事务导致的数据不完整问题。那么，如何使用事务，才能避免这种由于事务中的某一步或者几步操作出现错误，而导致数据不完整的情况发生呢？这就要用到事务中错误处理和回滚了：

如果发现事务中的某个操作发生错误，要及时使用回滚；

只有事务中的所有操作都可以正常执行，才进行提交。

那这里的关键就是判断操作是不是发生了错误。我们可以通过 MySQL 的函数 ROW_COUNT() 的返回，来判断一个 DML 操作是否失败，-1 表示操作失败，否则就表示影响的记录数。

mysql> INSERT INTO demo.mytrans VALUES (1,5);
ERROR 1136 (21S01): Column count doesn't match value count at row 1
mysql> SELECT ROW_COUNT();
+-------------+
| ROW_COUNT() |
+-------------+
| -1 |
+-------------+
1 row in set (0.00 sec)

另外一个经常会用到事务的地方是存储过程。由于存储过程中包含很多相互关联的数据操作，所以会大量使用事务。我们可以在 MySQL 的存储过程中，通过获取 SQL 错误，来决定事务是提交还是回滚：

mysql> DELIMITER //                   -- 修改分隔符为 //
mysql> CREATE PROCEDURE demo.mytest() -- 创建存储过程
-> BEGIN                              -- 开始程序体
-> DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK; -- 定义SQL操作发生错误是自动回滚
-> START TRANSACTION;                              -- 开始事务
-> INSERT INTO demo.mytrans VALUES (1,5);
-> UPDATE demo.inventory SET invquantity = invquantity - 5;
-> COMMIT;                                         -- 提交事务
-> END
-> //                                              -- 完成创建存储过程
Query OK, 0 rows affected (0.05 sec)

mysql> DELIMITER ;                                 -- 恢复分隔符为；
mysql> CALL demo.mytest();                         -- 调用存储过程
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM demo.mytrans;                 -- 销售流水没有插入
Empty set (0.00 sec)
mysql> SELECT * FROM demo.inventory;               -- 库存也没有消减，说明事务回滚了
+------------+-------------+
| itemnumber | invquantity |
+------------+-------------+
| 1 | 10.000 |
+------------+-------------+
1 row in set (0.00 sec)

这里，我们要先通过“DELIMITER //”语句把 MySQL 语句的结束标识改为“//”（默认语句的结束标识是“;”）。这样做的目的是告诉 MySQL 一直到“//”才是语句的结束，否则，MySQL 会在遇到第一个“;”的时候认为语句已经结束，并且执行。这样就会报错，自然也就没办法创建存储过程了。

创建结束以后，我们还要录入“//”，告诉 MySQL 存储过程创建完成了，并且通过“DELIMITER ;”，再把语句结束标识改回到“;”。

关于存储过程，我会在后面的课程里给你详细介绍。这里你只需要知道，在这个存储过程中，我使用了“DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK;”这个语句，来监控 SQL 语句的执行结果，一旦发发生错误，就自动回滚并退出。通过这个机制，我们就实现了对事务中的 SQL 操作进行监控，如果发现事务中的任何 SQL 操作发生错误，就自动回滚。

总之，我们要把重要的关联操作放在事务中，确保操作的原子性，并且对失败的操作进行回滚处理。只有这样，才能真正发挥事务的作用，保证关联操作全部成功或全部失败，最终确保数据的一致性。

如何用好事务的隔离性？

接下来，我们再学习下如何用好事务的隔离性。

超市经营者提出，门店要支持网上会员销售，现在我们假设会员张三是储值会员，他的会员卡里有 100 元。张三用会员卡到门店消费 100 元，他爱人用他的会员卡在网上消费 100 元。

张三在门店消费结算的时候，开启了一个事务 A，包括这样 3 个操作：

读取卡内金额为 100；

更新卡内金额为 0；

插入一条销售流水。

张三的爱人在网上购物，开启了一个事务 B，也来读取卡内金额。如果 B 读取卡内金额的操作，发生在 A 更新卡内金额之后，并且在插入销售流水之前，那么 B 读出的金额应该是多少呢？如果 B 读出 0 元，那么，A 有可能由于后面的操作失败而回滚。因此，B 可能会读到一条错误信息，而导致本来可以成功的交易失败。有什么办法可以解决这个问题呢？

这个时候，就会用到 MySQL 的另外一种机制：“锁”。MySQL 可以把 A 中被修改过而且还没有提交的数据锁住，让 B 处于等待状态，一直到 A 提交完成，或者失败回滚，再释放锁，允许 B 读取这个数据。这样就可以防止因为 A 回滚而导致 B 读取错误的可能了。

MySQL 中的锁有很多种，功能也十分强大。咱们这门课里不要求你掌握锁，你只要知道，MySQL 可以用锁来控制事务对数据的操作，就可以了。

通过对锁的使用，可以实现事务之间的相互隔离。锁的使用方式不同，隔离的程度也不同。

MySQL 支持 4 种事务隔离等级。

READ UNCOMMITTED：可以读取事务中还未提交的被更改的数据。

READ COMMITTED：只能读取事务中已经提交的被更改的数据。

REPEATABLE READ：表示一个事务中，对一个数据读取的值，永远跟第一次读取的值一致，不受其他事务中数据操作的影响。这也是 MySQL 的默认选项。

SERIALIZABLE：表示任何一个事务，一旦对某一个数据进行了任何操作，那么，一直到这个事务结束，MySQL 都会把这个数据锁住，禁止其他事务对这个数据进行任何操作。

一般来讲，使用 MySQL 默认的隔离等级 REPEATABLE READ，就已经够了。不过，也不排除需要对一些关键的数据操作，使用最高的隔离等级 SERIALIZABLE。

举个例子，在我们的超市项目中，就对每天的日结操作设置了最高的隔离等级。因为日结要进行大量的核心数据计算，包括成本、毛利、毛利率、周转率，等等，并把结果保存起来，作为各类查询、报表系统、决策支持模块的基础，绝对不能出现数据错误。

当然，计算完成之后，你也不要忘记把隔离等级恢复到系统默认的状态，否则，会对日常的系统营运效率产生比较大的影响。

事务的隔离性对并发操作非常有用。当许多用户同时操作数据库的时候，隔离性可以确保各个连接之间互相不影响。这里我要提醒你的是，正确设置事务的隔离等级很重要。

一方面，对于一些核心的数据更改操作，你可能需要较高的隔离等级，比如涉及金额的修改；另一方面，你要考虑资源的消耗，不能使系统整体的效率受到太大的影响。所以，要根据具体的应用场景，正确地使用事务。

总结

事务可以确保事务中的一系列操作全部被执行，不会被打断；或者全部不被执行，等待再次执行。事务中的操作，具有原子性、一致性、永久性和隔离性的特征。但是这并不意味着，被事务包裹起来的一系列 DML 数据操作就一定会全部成功，或者全部失败。你需要对操作是否成功的结果进行判断，并通知 MySQL 针对不同情况，分别完成事务提交或者回滚操作，才能最终确保事务中的操作全部成功或全部失败。

MySQL 支持 4 种不同的事务隔离等级，等级越高，消耗的系统资源也越多，你要根据实际情况进行设定。

在 MySQL 中，并不是所有的操作都可以回滚。比如创建数据库、创建数据表、删除数据库、删除数据表等，这些操作是不可以回滚的，所以，你在操作的时候要特别小心，特别是在删除数据库、数据表时，最好先做备份，防止误操作。

思考题

学完了这节课以后，如果现在有人对你说，事务就是确保事务中的数据操作，要么全部正确执行，要么全部失败，你觉得这句话对吗？为什么？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

13 | 临时表：复杂查询，如何保存中间结果？

你好，我是朱晓峰。今天，我来和你聊一聊临时表。

当我们遇到一些复杂查询的时候，经常无法一步到位，或者是一步到位会导致查询语句太过复杂，开发和维护的成本过高。这个时候，就可以使用临时表。

下面，我就结合实际的项目来讲解一下，怎么拆解一个复杂的查询，通过临时表来保存中间结果，从而把一个复杂查询变得简单而且容易实现。

临时表是什么？

临时表是一种特殊的表，用来存储查询的中间结果，并且会随着当前连接的结束而自动删除。MySQL 中有 2 种临时表，分别是内部临时表和外部临时表：

内部临时表主要用于性能优化，由系统自动产生，我们无法看到；

外部临时表通过 SQL 语句创建，我们可以使用。

因为我们不能使用内部临时表，所以我就不多讲了。今天，我来重点讲一讲我们可以创建和使用的外部临时表。

首先，你要知道临时表的创建语法结构：

CREATE TEMPORARY TABLE 表名
(
字段名 字段类型,
...
);

跟普通表相比，临时表有 3 个不同的特征：

临时表的创建语法需要用到关键字 TEMPORARY；

临时表创建完成之后，只有当前连接可见，其他连接是看不到的，具有连接隔离性；

临时表在当前连接结束之后，会被自动删除。

因为临时表有连接隔离性，不同连接创建相同名称的临时表也不会产生冲突，适合并发程序的运行。而且，连接结束之后，临时表会自动删除，也不用担心大量无用的中间数据会残留在数据库中。因此，我们就可以利用这些特点，用临时表来存储 SQL 查询的中间结果。

如何用临时表简化复杂查询？

刚刚提到，临时表可以简化复杂查询，具体是怎么实现的呢？我来介绍一下。

举个例子，超市经营者想要查询 2020 年 12 月的一些特定商品销售数量、进货数量、返厂数量，那么，我们就要先把销售、进货、返厂这 3 个模块分开计算，用临时表来存储中间计算的结果，最后合并在一起，形成超市经营者想要的结果集。

首先，我们统计一下在 2020 年 12 月的商品销售数据。

假设我们的销售流水表（mysales）如下所示：

我们可以用下面的 SQL 语句，查询出每个单品的销售数量和销售金额，并存入临时表：

mysql> CREATE TEMPORARY TABLE demo.mysales
-> SELECT                        -- 用查询的结果直接生成临时表
-> itemnumber,
-> SUM(quantity) AS QUANTITY,
-> SUM(salesvalue) AS salesvalue
-> FROM
-> demo.transactiondetails
-> GROUP BY itemnumber
-> ORDER BY itemnumber;
Query OK, 2 rows affected (0.01 sec)
Records: 2 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM demo.mysales;
+------------+----------+------------+
| itemnumber | QUANTITY | salesvalue |
+------------+----------+------------+
| 1 | 5.000 | 411.18 |
| 2 | 5.000 | 24.75 |
+------------+----------+------------+
2 rows in set (0.01 sec)

需要注意的是，这里我是直接用查询结果来创建的临时表。因为创建临时表就是为了存放某个查询的中间结果。直接用查询语句创建临时表比较快捷，而且连接结束后临时表就会被自动删除，不需要过多考虑表的结构设计问题（比如冗余、效率等）。

到这里，我们就有了一个存储单品销售统计的临时表。接下来，我们计算一下 2020 年 12 月的进货信息。

我们的进货数据包括进货单头表（importhead）和进货单明细表（importdetails）。

进货单头表包括进货单编号、供货商编号、仓库编号、操作员编号和验收日期：

进货单明细表包括进货单编号、商品编号、进货数量、进货价格和进货金额：

我们用下面的 SQL 语句计算进货数据，并且保存在临时表里面：

mysql> CREATE TEMPORARY TABLE demo.myimport
-> SELECT b.itemnumber,SUM(b.quantity) AS quantity,SUM(b.importvalue) AS importvalue
-> FROM demo.importhead a JOIN demo.importdetails b
-> ON (a.listnumber=b.listnumber)
-> GROUP BY b.itemnumber;
Query OK, 3 rows affected (0.01 sec)
Records: 3 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM demo.myimport;
+------------+----------+-------------+
| itemnumber | quantity | importvalue |
+------------+----------+-------------+
| 1 | 5.000 | 290.00 |
| 2 | 5.000 | 15.00 |
| 3 | 8.000 | 40.00 |
+------------+----------+-------------+
3 rows in set (0.00 sec)

这样，我们又得到了一个临时表 demo.myimport，里面保存了我们需要的进货数据。

接着，我们来查询单品返厂数据，并且保存到临时表。

我们的返厂数据表有 2 个，分别是返厂单头表（returnhead）和返厂单明细表（returndetails）。

返厂单头表包括返厂单编号、供货商编号、仓库编号、操作员编号和验收日期：

返厂单明细表包括返厂单编号、商品编号、返厂数量、返厂价格和返厂金额：

我们可以使用下面的 SQL 语句计算返厂信息，并且保存到临时表中。

mysql> CREATE TEMPORARY TABLE demo.myreturn
-> SELECT b.itemnumber,SUM(b.quantity) AS quantity,SUM(b.returnvalue) AS returnvalue
-> FROM demo.returnhead a JOIN demo.returndetails b
-> ON (a.listnumber=b.listnumber)
-> GROUP BY b.itemnumber;
Query OK, 3 rows affected (0.01 sec)
Records: 3 Duplicates: 0 Warnings: 0

mysql> SELECT * FROM demo.myreturn;
+------------+----------+-------------+
| itemnumber | quantity | returnvalue |
+------------+----------+-------------+
| 1 | 2.000 | 115.00 |
| 2 | 1.000 | 3.00 |
| 3 | 1.000 | 5.00 |
+------------+----------+-------------+
3 rows in set (0.00 sec)

这样，我们就获得了单品的返厂信息。

有了前面计算出来的数据，现在，我们就可以把单品的销售信息、进货信息和返厂信息汇总到一起了。

如果你跟着实际操作的话，你可能会有这样一个问题：我们现在有 3 个临时表，分别存储单品的销售信息、进货信息和返厂信息。那么，能不能把这 3 个表相互关联起来，把这些信息都汇总到对应的单品呢？

答案是不行，不管是用内连接、还是用外连接，都不可以。因为无论是销售信息、进货信息，还是返厂信息，都存在商品信息缺失的情况。换句话说，就是在指定时间段内，某些商品可能没有销售，某些商品可能没有进货，某些商品可能没有返厂。如果仅仅通过这 3 个表之间的连接进行查询，我们可能会丢失某些数据。

为了解决这个问题，我们可以引入商品信息表。因为商品信息表包含所有的商品，因此，把商品信息表放在左边，与其他的表进行左连接，就可以确保所有的商品都包含在结果集中。凡是不存在的数值，都设置为 0，然后再筛选一下，把销售、进货、返厂都是 0 的商品去掉，这样就能得到我们最终希望的查询结果：2020 年 12 月的商品销售数量、进货数量和返厂数量。

代码如下所示：

mysql> SELECT
-> a.itemnumber,
-> a.goodsname,
-> ifnull(b.quantity,0) as salesquantity,    -- 如果没有销售记录，销售数量设置为0
-> ifnull(c.quantity,0) as importquantity,   -- 如果没有进货，进货数量设为0
-> ifnull(d.quantity,0) as returnquantity    -- 如果没有返厂，返厂数量设为0
-> FROM
-> demo.goodsmaster a               -- 商品信息表放在左边进行左连接，确保所有的商品都包含在结果集中
-> LEFT JOIN demo.mysales b
-> ON (a.itemnumber=b.itemnumber)
-> LEFT JOIN demo.myimport c
-> ON (a.itemnumber=c.itemnumber)
-> LEFT JOIN demo.myreturn d
-> ON (a.itemnumber=d.itemnumber)
-> HAVING salesquantity>0 OR importquantity>0 OR returnquantity>0; -- 在结果集中剔除没有销售，没有进货，也没有返厂的商品
+------------+-----------+---------------+----------------+----------------+
| itemnumber | goodsname | salesquantity | importquantity | returnquantity |
+------------+-----------+---------------+----------------+----------------+
| 1 | 书 | 5.000 | 5.000 | 2.000 |
| 2 | 笔 | 5.000 | 5.000 | 1.000 |
| 3 | 橡皮 | 0.000 | 8.000 | 1.000 |
+------------+-----------+---------------+----------------+----------------+
3 rows in set (0.00 sec)

总之，通过临时表，我们就可以把一个复杂的问题拆分成很多个前后关联的步骤，把中间的运行结果存储起来，用于之后的查询。这样一来，就把面向集合的 SQL 查询变成了面向过程的编程模式，大大降低了难度。

内存临时表和磁盘临时表

由于采用的存储方式不同，临时表也可分为内存临时表和磁盘临时表，它们有着各自的优缺点，下面我来解释下。

关于内存临时表，有一点你要注意的是，你可以通过指定引擎类型（比如 ENGINE=MEMORY），来告诉 MySQL 临时表存储在内存中。

好了，现在我们先来创建一个内存中的临时表：

mysql> CREATE TEMPORARY TABLE demo.mytrans
-> (
-> itemnumber int,
-> groupnumber int,
-> branchnumber int
-> ) ENGINE = MEMORY; （临时表数据存在内存中）
Query OK, 0 rows affected (0.00 sec)

接下来，我们在磁盘上创建一个同样结构的临时表。在磁盘上创建临时表时，只要我们不指定存储引擎，MySQL 会默认存储引擎是 InnoDB，并且把表存放在磁盘上。

mysql> CREATE TEMPORARY TABLE demo.mytransdisk
-> (
-> itemnumber int,
-> groupnumber int,
-> branchnumber int
-> );
Query OK, 0 rows affected (0.00 sec)

现在，我们向刚刚的两张表里都插入同样数量的记录，然后再分别做一个查询：

mysql> SELECT COUNT(*) FROM demo.mytrans;
+----------+
| count(*) |
+----------+
| 4355 |
+----------+
1 row in set (0.00 sec)

mysql> SELECT COUNT(*) FROM demo.mytransdisk;
+----------+
| count(*) |
+----------+
| 4355 |
+----------+
1 row in set (0.21 sec)

可以看到，区别是比较明显的。对于同一条查询，内存中的临时表执行时间不到 10 毫秒，而磁盘上的表却用掉了 210 毫秒。显然，内存中的临时表查询速度更快。

不过，内存中的临时表也有缺陷。因为数据完全在内存中，所以，一旦断电，数据就消失了，无法找回。不过临时表只保存中间结果，所以还是可以用的。

我画了一张图，汇总了内存临时表和磁盘临时表的优缺点：

总结

这节课，我们学习了临时表的概念，以及使用临时表来存储中间结果以拆分复杂查询的方法。临时表可以存储在磁盘中，也可以通过指定引擎的办法存储在内存中，以加快存取速度。

其实，临时表有很多好处，除了可以帮助我们把复杂的 SQL 查询拆分成多个简单的 SQL 查询，而且，因为临时表是连接隔离的，不同的连接可以使用相同的临时表名称，相互之间不会受到影响。除此之外，临时表会在连接结束的时候自动删除，不会占用磁盘空间。

当然，临时表也有不足，比如会挤占空间。我建议你，在使用临时表的时候，要从简化查询和挤占资源两个方面综合考虑，既不能过度加重系统的负担，同时又能够通过存储中间结果，最大限度地简化查询。

思考题

我们有这样的一个销售流水表：

假设有多个门店，每个门店有多台收款机，每台收款机销售多种商品，请问如何查询每个门店、每台收款机的销售金额占所属门店的销售金额的比率呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

14 | 视图：如何简化查询？

你好，我是朱晓峰。今天，我们来聊一聊视图。

视图是一种虚拟表，我们可以把一段查询语句作为视图存储在数据库中，在需要的时候，可以把视图看做一个表，对里面的数据进行查询。

举个小例子，在学校的信息系统里面，为了减少冗余数据，学生档案（包括姓名、年龄等）和考试成绩（包括考试时间、科目、分数等）是分别存放在不同的数据表里面的，但是，我们经常需要查询学生的考试成绩（包括学生姓名、科目、分数）。这个时候，我们就可以把查询学生考试成绩的这个关联查询，用视图的形式保存起来。这样一来，我们不仅可以从视图中直接查询学生考试成绩，让查询变得简单，而且，视图没有实际存储数据，还避免了数据存储过程中可能产生的冗余，提高了存储的效率。

今天，我就结合超市的项目，来具体讲解一下怎么创建和操作视图，来帮助你提高查询效率。

视图的创建及其好处

首先，我们来学习下创建视图的方法，以及使用视图的一些好处。

创建视图的语法结构：

CREATE [OR REPLACE]
VIEW 视图名称 [(字段列表)]
AS 查询语句

现在，假设我们要查询一下商品的每日销售明细，这就要从销售流水表（demo.trans）和商品信息表（demo.goodsmaster）中获取到销售数据和对应的商品信息数据。

销售流水表包含流水单号、商品编号、销售数量、销售金额和交易时间等信息：

商品信息表包含商品编号、条码、名称和售价等信息：

在不使用视图的情况下，我们可以通过对销售流水表和商品信息表进行关联查询，得到每天商品销售统计的结果，包括销售日期、商品名称、每天销售数量的合计和每天销售金额的合计，如下所示：

mysql> SELECT
-> a.transdate,
-> a.itemnumber,
-> b.goodsname,
-> SUM(a.quantity) AS quantity,   -- 统计销售数量
-> SUM(a.salesvalue) AS salesvalue -- 统计销售金额
-> FROM
-> demo.trans AS a
-> LEFT JOIN                 -- 连接查询
-> demo.goodsmaster AS b ON (a.itemnumber = b.itemnumber)
-> GROUP BY a.transdate , a.itemnumber;
+---------------------+------------+-----------+----------+------------+
| transdate | itemnumber | goodsname | quantity | salesvalue |
+---------------------+------------+-----------+----------+------------+
| 2020-12-01 00:00:00 | 1 | 本 | 1.000 | 89.00 |
| 2020-12-01 00:00:00 | 2 | 笔 | 1.000 | 5.00 |
| 2020-12-02 00:00:00 | 3 | 胶水 | 2.000 | 20.00 |
+---------------------+------------+-----------+----------+------------+
3 rows in set (0.00 sec)

在实际项目中，我们发现，每日商品销售查询使用的频次很高，而且经常需要以这个查询的结果为基础，进行更进一步的统计。

举个例子，超市经营者要查一下“每天商品的销售数量和当天库存数量的对比”，如果用一个 SQL 语句查询，就会比较复杂。历史库存表（demo.inventoryhist）如下所示：

接下来我们的查询步骤会使用到子查询和派生表，很容易理解，你知道含义就行了。

子查询：就是嵌套在另一个查询中的查询。

派生表：如果我们在查询中把子查询的结果作为一个表来使用，这个表就是派生表。

这个查询的具体步骤是：

通过子查询获得单品销售统计的查询结果；

把第一步中的查询结果作为一个派生表，跟历史库存表进行连接，查询获得包括销售日期、商品名称、销售数量和历史库存数量在内的最终结果。

mysql> SELECT
-> a.transdate,
-> a.itemnumber,
-> a.goodsname,
-> a.quantity,       -- 获取单品销售数量
-> b.invquantity     -- 获取历史库存数量
-> FROM
-> (SELECT           -- 子查询，统计单品销售
-> a.transdate,
-> a.itemnumber,
-> b.goodsname,
-> SUM(a.quantity) AS quantity,
-> SUM(a.salesvalue) AS salesvalue
-> FROM
-> demo.trans AS a
-> LEFT JOIN demo.goodsmaster AS b ON (a.itemnumber = b.itemnumber)
-> GROUP BY a.transdate , a.itemnumber
-> ) AS a -- 派生表，与历史库存进行连接
-> LEFT JOIN
-> demo.inventoryhist AS b
-> ON (a.transdate = b.invdate
-> AND a.itemnumber = b.itemnumber);
+---------------------+------------+-----------+----------+-------------+
| transdate | itemnumber | goodsname | quantity | invquantity |
+---------------------+------------+-----------+----------+-------------+
| 2020-12-01 00:00:00 | 1 | 本 | 1.000 | 100.000 |
| 2020-12-01 00:00:00 | 2 | 笔 | 1.000 | 99.000 |
| 2020-12-02 00:00:00 | 3 | 胶水 | 2.000 | 200.000 |
+---------------------+------------+-----------+----------+-------------+
3 rows in set (0.00 sec)

可以看到，这个查询语句是比较复杂的，可读性和可维护性都比较差。那该怎么办呢？其实，针对这种情况，我们就可以使用视图。

我们可以把商品的每日销售统计查询做成一个视图，存储在数据库里，代码如下所示：

 mysql> CREATE VIEW demo.trans_goodsmaster AS  -- 创建视图
-> SELECT
-> a.transdate,
-> a.itemnumber,
-> b.goodsname,                           -- 从商品信息表中获取名称
-> SUM(a.quantity) AS quantity,           -- 统计销售数量
-> SUM(a.salesvalue) AS salesvalue        -- 统计销售金额
-> FROM
-> demo.trans AS a
-> LEFT JOIN
-> demo.goodsmaster AS b ON (a.itemnumber = b.itemnumber) -- 与商品信息表关联
-> GROUP BY a.transdate , a.itemnumber;   -- 按照销售日期和商品编号分组
Query OK, 0 rows affected (0.01 sec)

这样一来，我们每次需要查询每日商品销售数据的时候，就可以直接查询视图，不需要再写一个复杂的关联查询语句了。

我们来试试用一个查询语句直接从视图中进行查询：

mysql> SELECT *                 -- 直接查询
-> FROM demo.trans_goodsmaster; -- 视图
+---------------------+------------+-----------+----------+------------+
| transdate | itemnumber | goodsname | quantity | salesvalue |
+---------------------+------------+-----------+----------+------------+
| 2020-12-01 00:00:00 | 1 | 本 | 1.000 | 89.00 |
| 2020-12-01 00:00:00 | 2 | 笔 | 1.000 | 5.00 |
| 2020-12-02 00:00:00 | 3 | 胶水 | 2.000 | 20.00 |
+---------------------+------------+-----------+----------+------------+
3 rows in set (0.01 sec)

结果显示，这两种查询方式得到的结果是一样的。

如果我们要进一步查询“每日单品销售的数量与当日的库存数量的对比”，就可以把刚刚定义的视图作为一个数据表来使用。我们把它跟历史库存表连接起来，来获取销售数量和历史库存数量。就像下面的代码这样，查询就简单多了：

mysql> SELECT
-> a.transdate,               -- 从视图中获取销售日期
-> a.itemnumber,              -- 从视图中获取商品编号
-> a.goodsname,               -- 从视图中获取商品名称
-> a.quantity,                -- 从视图中获取销售数量
-> b.invquantity              -- 从历史库存表中获取历史库存数量
-> FROM
-> demo.trans_goodsmaster AS a -- 视图
-> LEFT JOIN
-> demo.inventoryhist AS b ON (a.transdate = b.invdate
-> AND a.itemnumber = b.itemnumber);  -- 直接连接库存历史表
+---------------------+------------+-----------+----------+-------------+
| transdate | itemnumber | goodsname | quantity | invquantity |
+---------------------+------------+-----------+----------+-------------+
| 2020-12-01 00:00:00 | 1 | 本 | 1.000 | 100.000 |
| 2020-12-01 00:00:00 | 2 | 笔 | 1.000 | 99.000 |
| 2020-12-02 00:00:00 | 3 | 胶水 | 2.000 | 200.000 |
+---------------------+------------+-----------+----------+-------------+
3 rows in set (0.00 sec)

结果显示，这里的查询结果和我们刚刚使用派生表的查询结果是一样的。但是，使用视图的查询语句明显简单多了，可读性更好，也更容易维护。

如何操作视图和视图中的数据？

创建完了视图，我们还经常需要对视图进行一些操作，比如修改、查看和删除视图。同时，我们可能还需要修改视图中的数据。具体咋操作呢？我来介绍下。

如何操作视图？

修改、查看、删除视图的操作比较简单，你只要掌握具体的语法就行了。

修改视图的语法如下所示：

ALTER VIEW 视图名
AS 查询语句;

查看视图的语法是：

查看视图：
DESCRIBE 视图名；

删除视图要使用 DROP 关键词，具体方法如下：

删除视图：
DROP VIEW 视图名;

好了，到这里，对视图的操作我就介绍完了，下面我再讲讲怎么操作视图中的数据。

如何操作视图中的数据？

刚刚说过，视图本身是一个虚拟表，所以，对视图中的数据进行插入、修改和删除操作，实际都是通过对实际数据表的操作来实现的。

1. 在视图中插入数据

我借用刚刚的视图 demo.view_goodsmaster 来给你解释下。假设商品信息表中的规格字段（specification）被删除了，当我们尝试用 INSERT INTO 语句向视图中插入一条记录的时候，就会提示错误了：

mysql> INSERT INTO demo.view_goodsmaster
-> (itemnumber,barcode,goodsname,salesprice)
-> VALUES
-> (5,'0005','测试',100);
ERROR 1471 (HY000): The target table view_goodsmaster of the INSERT is not insertable-into

这是因为，只有视图中的字段跟实际数据表中的字段完全一样，MySQL 才允许通过视图插入数据。刚刚的视图中包含了实际数据表所没有的字段“specification”，所以在插入数据时，系统就会提示错误。

为了解决这个问题，我们来修改一下视图，让它只包含实际数据表中有的字段，也就是商品编号、条码、名称和售价。代码如下：

mysql> ALTER VIEW demo.view_goodsmaster
-> AS
-> SELECT itemnumber,barcode,goodsname,salesprice -- 只包含实际表中存在的字段
-> FROM demo.goodsmaster
-> WHERE salesprice > 50;
Query OK, 0 rows affected (0.01 sec)

对视图进行修改之后，我们重新尝试向视图中插入一条记录：

mysql> INSERT INTO demo.view_goodsmaster
-> (itemnumber,barcode,goodsname,salesprice)
-> VALUES
-> (5,'0005','测试',100);
Query OK, 1 row affected (0.02 sec)

结果显示，插入成功了。

现在我们来查看一下视图中的数据：

mysql> SELECT *
-> FROM demo.view_goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 本 | 89.00 |
| 5 | 0005 | 测试 | 100.00 |                        -- 通过视图插入的数据
+------------+---------+-----------+------------+
2 rows in set (0.01 sec)

结果显示，表中确实包含了我们插入的商品编号是 5 的商品信息。

现在，视图中已经包括了刚才插入的数据，那么，实际数据表中的数据情况又是怎样的呢？我们再来看一下：

mysql> SELECT *
-> FROM demo.goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 本 | 89.00 |
| 2 | 0002 | 笔 | 5.00 |
| 3 | 0003 | 胶水 | 10.00 |
| 5 | 0005 | 测试 | 100.00 |                      -- 通过视图插入的数据
+------------+---------+-----------+------------+
4 rows in set (0.00 sec)

可以看到，实际数据表 demo.goodsmaster 中，也已经包含通过视图插入的商品编号是 5 的商品数据了。

2. 删除视图中的数据

我们可以通过 DELETE 语句，删除视图中的数据：

mysql> DELETE FROM demo.view_goodsmaster   -- 直接在视图中删除数据
-> WHERE itemnumber = 5;
Query OK, 1 row affected (0.02 sec)

现在我们来查看视图和实际数据表的内容，会发现商品编号是 5 的商品都已经被删除了。

mysql> SELECT *
-> FROM demo.view_goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 本 | 89.00 |                         -- 视图中已经没有商品编号是5的商品了
+------------+---------+-----------+------------+
1 row in set (0.00 sec)

mysql> SELECT *
-> FROM demo.goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 本 | 89.00 |
| 2 | 0002 | 笔 | 5.00 |
| 3 | 0003 | 胶水 | 10.00 |                        -- 实际表中也已经没有商品编号是5的商品了
+------------+---------+-----------+------------+
3 rows in set (0.00 sec)

3. 修改视图中的数据

我们可以通过 UPDATE 语句对视图中的数据进行修改：

mysql> UPDATE demo.view_goodsmaster             -- 更新视图中的数据
-> SET salesprice = 100
-> WHERE itemnumber = 1;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

结果显示，更新成功了。现在我们来查看一下视图和实际数据表，代码如下所示：

mysql> SELECT *
-> FROM demo.view_goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 本 | 100.00 |                        -- 视图中的售价改过了
+------------+---------+-----------+------------+
1 row in set (0.01 sec)

mysql> SELECT *
-> FROM demo.goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 本 | 100.00 |                        -- 实际数据表中的售价也改过了
| 2 | 0002 | 笔 | 5.00 |
| 3 | 0003 | 胶水 | 10.00 |
+------------+---------+-----------+------------+
3 rows in set (0.00 sec)

可以发现，视图和原来的数据表都已经改过来了。

需要注意的是，我不建议你对视图的数据进行更新操作，因为 MySQL 允许用比较复杂的 SQL 查询语句来创建视图（比如 SQL 查询语句中使用了分组和聚合函数，或者是 UION 和 DISTINCT 关键字），所以，要通过对这个结果集的更新来更新实际数据表，有可能不被允许，因为 MySQL 没办法精确定位实际数据表中的记录。就比如刚刚讲到的那个“每日销售统计查询”视图就没办法更改，因为创建视图的 SQL 语句是一个包含了分组函数（GROUP BY）的查询。

视图有哪些优缺点？

到这里，视图的操作我就讲完了，现在我们把视线拔高一点，来看看视图都有哪些优缺点。只有全面掌握视图的特点，我们才能充分享受它的高效，避免踩坑。

首先，我来介绍下视图的优点。

第一，因为我们可以把视图看成一张表来进行查询，所以在使用视图的时候，我们不用考虑视图本身是如何获取数据的，里面有什么逻辑，包括了多少个表，有哪些关联操作，而是可以直接使用。这样一来，实际上就把查询模块化了，查询变得更加简单，提高了开发和维护的效率。所以，你可以把那些经常会用到的查询和复杂查询的子查询定义成视图，存储到数据库中，这样可以为你以后的使用提供方便。

第二，视图跟实际数据表不一样，它存储的是查询语句。所以，在使用的时候，我们要通过定义视图的查询语句来获取结果集。而视图本身不存储数据，不占用数据存储的资源。

第三，视图具有隔离性。视图相当于在用户和实际的数据表之间加了一层虚拟表。也就是说，用户不需要查询数据表，可以直接通过视图获取数据表中的信息。这样既提高了数据表的安全性，同时也通过视图把用户实际需要的信息汇总在了一起，查询起来很轻松。

第四，视图的数据结构相对独立，即便实际数据表的结构发生变化，我们也可以通过修改定义视图的查询语句，让查询结果集里的字段保持不变。这样一来，针对视图的查询就不受实际数据表结构变化的影响了。

这一点不容易理解，我举个小例子来说明一下。

假设我们有一个实际的数据表（demo.goodsmaster），包括商品编号、条码、名称、规格和售价等信息：

在这个表的基础上，我们建一个视图，查询所有价格超过 50 元的商品：

mysql> CREATE VIEW demo.view_goodsmaster AS
-> SELECT *
-> FROM demo.goodsmaster
-> WHERE salesprice > 50;
Query OK, 0 rows affected (0.03 sec)

接着，我们在这个视图的基础上做一个查询，来验证一下视图的内容：

mysql> SELECT barcode,goodsname,specification
-> FROM demo.view_goodsmaster;
+---------+-----------+---------------+
| barcode | goodsname | specification |
+---------+-----------+---------------+
| 0001 | 本 | 16开 |
+---------+-----------+---------------+
1 row in set (0.00 sec)

结果显示，我们得到了商品信息表中售价大于 50 元的商品：本（16 开）。

假设现在我们需要把数据表 demo.goodsmaster 中的字段“specification”删掉，就可以用下面的代码：

mysql> ALTER TABLE demo.goodsmaster DROP COLUMN specification;
Query OK, 0 rows affected (0.13 sec)
Records: 0 Duplicates: 0 Warnings: 0

这样一来，因为少了一个字段，而我们的语句又是直接查询数据表的，代码就会提示错误：

mysql> SELECT barcode,goodsname,specification
-> FROM demo.goodsmaster;
ERROR 1054 (42S22): Unknown column 'specification' in 'field list'

你看，代码提示字段“specification”不存在。

但是，如果查询的是视图，就可以通过修改视图来规避这个问题。我们可以用下面的代码把刚才的视图修改一下：

mysql> ALTER VIEW demo.view_goodsmaster
-> AS
-> SELECT
-> itemnumber,
-> barcode,
-> goodsname,
-> '' as specification, -- 由于字段不存在，插入一个长度是0的空字符串作为这个字段的值
-> salesprice
-> FROM demo.goodsmaster
-> WHERE salesprice > 50;
Query OK, 0 rows affected (0.02 sec)

你看，虽然实际数据表中已经没有字段“specification”了，但是视图中却保留了这个字段，而且字段值始终是空字符串。所以，我们不用修改原有视图的查询语句，它也会正常运行。下面的代码查询的结果中，就包括了实际数据表没有的字段“specification”。

mysql> SELECT barcode,goodsname,specification
-> FROM demo.view_goodsmaster;
+---------+-----------+---------------+
| barcode | goodsname | specification |
+---------+-----------+---------------+
| 0001 | 本 | |
+---------+-----------+---------------+
1 row in set (0.00 sec)

结果显示，运行成功了。这个视图查询，就没有受到实际数据表中删除字段的影响。

看到这儿，你可能会说，视图有这么多好处，那我以后都用视图可以吗？其实不是的，视图也有自身的不足。

如果我们在实际数据表的基础上创建了视图，那么，如果实际数据表的结构变更了，我们就需要及时对相关的视图进行相应的维护。特别是当视图是由视图生成的时候，维护会变得比较复杂。因为创建视图的 SQL 查询可能会对字段重命名，也可能包含复杂的逻辑，这些都会增加维护的成本。

所以，在创建视图的时候，你要结合实际项目需求，综合考虑视图的优点和不足，这样才能正确使用视图，使系统整体达到最优。

为了方便你掌握，我用一张图来汇总下视图的优缺点：

总结

今天，我给你介绍了简化查询的工具：视图，我们学习了创建视图、操作视图和视图中的数据的方法以及视图的优缺点。你要重点掌握操作的语法结构。

最后，我还是想提醒你一下，虽然可以更新视图数据，但总的来说，视图作为虚拟表，主要用于方便查询。我不建议你更新视图的数据，因为对视图数据的更改，都是通过对实际数据表里数据的操作来完成的，而且有很多限制条件。

视图虽然有很多优点。但是在创建视图、简化查询的同时，也要考虑到视图太多而导致的数据库维护成本的问题。

视图不是越多越好，特别是嵌套的视图（就是在视图的基础上创建视图），我不建议你使用，因为逻辑复杂，可读性不好，容易变成系统的潜在隐患。

思考题

假设某公园售票系统包括门票信息表和类别信息表，这两个表之间通过类别编号相关联。

门票信息表包含门票编号、名称、类别编号和剩余数量等信息。

类别信息表包含类别编号、开门时间和闭馆时间。

请编写一个视图，视图返回的结果集包括：当前时间可以卖的门票名称和剩余数量（说明：开门前 30 分钟开始售票，结束前 30 分钟停止售票）。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

15 | 存储过程：如何提高程序的性能和安全性？

你好，我是朱晓峰。今天呢，我们来聊一聊 MySQL 的存储过程。

在我们的超市项目中，每天营业结束后，超市经营者都要计算当日的销量，核算成本和毛利等营业数据，这也就意味着每天都要做重复的数据统计工作。其实，这种数据量大，而且计算过程复杂的场景，就非常适合使用存储过程。

简单来说呢，存储过程就是把一系列 SQL 语句预先存储在 MySQL 服务器上，需要执行的时候，客户端只需要向服务器端发出调用存储过程的命令，服务器端就可以把预先存储好的这一系列 SQL 语句全部执行。

这样一来，不仅执行效率非常高，而且客户端不需要把所有的 SQL 语句通过网络发给服务器，减少了 SQL 语句暴露在网上的风险，也提高了数据查询的安全性。

今天，我就借助真实的超市项目，给你介绍一下如何创建和使用存储过程，帮助你提升查询的效率，并且让你开发的应用更加简洁安全。

如何创建存储过程？

在创建存储过程的时候，我们需要用到关键字 CREATE PROCEDURE。具体的语法结构如下：

CREATE PROCEDURE 存储过程名 （[ IN | OUT | INOUT] 参数名称 类型）程序体

接下来，我以超市的日结计算为例，给你讲一讲怎么创建存储过程。当然，为了方便你理解，我对计算的过程进行了简化。

假设在日结计算中，我们需要统计每天的单品销售，包括销售数量、销售金额、成本、毛利、毛利率等。同时，我们还要把计算出来的结果存入单品统计表中。

这个计算需要用到几个数据表，我分别来展示下这些表的基本信息。

销售单明细表（demo.transactiondetails）中包括了每笔销售中的商品编号、销售数量、销售价格和销售金额。

mysql> SELECT *
-> FROM demo.transactiondetails;
+---------------+------------+----------+------------+------------+
| transactionid | itemnumber | quantity | salesprice | salesvalue |
+---------------+------------+----------+------------+------------+
| 1 | 1 | 1.000 | 89.00 | 89.00 |
| 1 | 2 | 2.000 | 5.00 | 10.00 |
| 2 | 1 | 2.000 | 89.00 | 178.00 |
| 3 | 2 | 10.000 | 5.00 | 50.00 |
| 3 | 3 | 3.000 | 15.00 | 45.00 |
+---------------+------------+----------+------------+------------+
5 rows in set (0.00 sec)

销售单头表（demo.transactionhead）中包括流水单号、收款机编号、会员编号、操作员编号、交易时间。

mysql> SELECT *
-> FROM demo.transactionhead;
+---------------+------------------+-----------+----------+------------+---------------------+
| transactionid | transactionno | cashierid | memberid | operatorid | transdate |
+---------------+------------------+-----------+----------+------------+---------------------+
| 1 | 0120201201000001 | 1 | 1 | 1 | 2020-12-01 00:00:00 |
| 2 | 0120201201000002 | 1 | NULL | 1 | 2020-12-01 00:00:00 |
| 3 | 0120201202000001 | 1 | NULL | 1 | 2020-12-02 00:00:00 |
+---------------+------------------+-----------+----------+------------+---------------------+
3 rows in set (0.00 sec)

商品信息表（demo.goodsmaster）中包括商品编号、商品条码、商品名称、规格、单位、售价和平均进价。

mysql> SELECT *
-> FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+----------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice | avgimportprice |
+------------+---------+-----------+---------------+------+------------+----------------+
| 1 | 0001 | 书 | NULL | 本 | 89.00 | 33.50 |
| 2 | 0002 | 笔 | NULL | 支 | 5.00 | 3.50 |
| 3 | 0003 | 胶水 | NULL | 瓶 | 15.00 | 11.00 |
+------------+---------+-----------+---------------+------+------------+----------------+
3 rows in set (0.00 sec)

存储过程会用刚刚的三个表中的数据进行计算，并且把计算的结果存储到下面的这个单品统计表中。

mysql> DESCRIBE demo.dailystatistics;
+-------------+---------------+------+-----+---------+----------------+
| Field | Type | Null | Key | Default | Extra |
+-------------+---------------+------+-----+---------+----------------+
| id | int | NO | PRI | NULL | auto_increment |
| itemnumber | int | YES | MUL | NULL | |
| quantity | decimal(10,3) | YES | | NULL | |
| actualvalue | decimal(10,2) | YES | | NULL | |
| cost | decimal(10,2) | YES | | NULL | |
| profit | decimal(10,2) | YES | | NULL | |
| profitratio | decimal(10,4) | YES | | NULL | |
| salesdate | datetime | YES | MUL | NULL | |
+-------------+---------------+------+-----+---------+----------------+
8 rows in set (0.01 sec)

我们现在就来创建一个存储过程，完成单品销售统计的计算。我来讲一讲具体的思路。

第一步，我们把 SQL 语句的分隔符改为“//”。因为存储过程中包含很多 SQL 语句，如果不修改分隔符的话，MySQL 会在读到第一个 SQL 语句的分隔符“;”的时候，认为语句结束并且执行，这样就会导致错误。

第二步，我们来创建存储过程，把要处理的日期作为一个参数传入（关于参数，下面我会具体讲述）。同时，用 BEGIN 和 END 关键字把存储过程中的 SQL 语句包裹起来，形成存储过程的程序体。

第三步，在程序体中，先定义 2 个数据类型为 DATETIME 的变量，用来记录要计算数据的起始时间和截止时间。

第四步，删除保存结果数据的单品统计表中相同时间段的数据，目的是防止数据重复。

第五步，计算起始时间和截止时间内单品的销售数量合计、销售金额合计、成本合计、毛利和毛利率，并且把结果存储到单品统计表中。

这五个步骤，我们就可以用下面的代码来实现。

mysql> DELIMITER // -- 设置分割符为//
-> CREATE PROCEDURE demo.dailyoperation(transdate TEXT)
-> BEGIN -- 开始程序体
-> DECLARE startdate,enddate DATETIME; -- 定义变量
-> SET startdate = date_format(transdate,'%Y-%m-%d'); -- 给起始时间赋值
-> SET enddate = date_add(startdate,INTERVAL 1 DAY); -- 截止时间赋值为1天以后
-> -- 删除原有数据
-> DELETE FROM demo.dailystatistics
-> WHERE
-> salesdate = startdate;
-> -- 插入新计算的数据
-> INSERT into dailystatistics
-> (
-> salesdate,
-> itemnumber,
-> quantity,
-> actualvalue,
-> cost,
-> profit,
-> profitratio
-> )
-> SELECT
-> LEFT(b.transdate,10),
-> a.itemnumber,
-> SUM(a.quantity), -- 数量总计
-> SUM(a.salesvalue), -- 金额总计
-> SUM(a.quantity*c.avgimportprice), -- 计算成本
-> SUM(a.salesvalue-a.quantity*c.avgimportprice), -- 计算毛利
-> CASE sum(a.salesvalue) WHEN 0 THEN 0
-> ELSE round(sum(a.salesvalue-a.quantity*c.avgimportprice)/sum(a.salesvalue),4) END -- 计算毛利率
-> FROM
-> demo.transactiondetails AS a
-> JOIN
-> demo.transactionhead AS b
-> ON (a.transactionid = b.transactionid)
-> JOIN
-> demo.goodsmaster c
-> ON (a.itemnumber=c.itemnumber)
-> WHERE
-> b.transdate>startdate AND b.transdate<enddate
-> GROUP BY
-> LEFT(b.transdate,10),a.itemnumber
-> ORDER BY
-> LEFT(b.transdate,10),a.itemnumber;
-> END
-> // -- 语句结束，执行语句
Query OK, 0 rows affected (0.01 sec)
-> DELIMITER ; -- 恢复分隔符为；

这样，我们的存储过程就创建成功了。

在这个存储过程中，我们用到了存储过程的参数定义和程序体，这些具体是什么意思呢？我们来学习下。

存储过程的参数定义

存储过程可以有参数，也可以没有参数。一般来说，当我们通过客户端或者应用程序调用存储过程的时候，如果需要与存储过程进行数据交互，比如，存储过程需要根据输入的数值为基础进行某种数据处理和计算，或者需要把某个计算结果返回给调用它的客户端或者应用程序，就需要设置参数。否则，就不用设置参数。

参数有 3 种，分别是 IN、OUT 和 INOUT。

IN 表示输入的参数，存储过程只是读取这个参数的值。如果没有定义参数种类，默认就是 IN，表示输入参数。

OUT 表示输出的参数，存储过程在执行的过程中，把某个计算结果值赋给这个参数，执行完成之后，调用这个存储过程的客户端或者应用程序就可以读取这个参数返回的值了。

INOUT 表示这个参数既可以作为输入参数，又可以作为输出参数使用。

除了定义参数种类，还要对参数的数据类型进行定义。在这个存储过程中，我定义了一个参数 transdate 的数据类型是 TEXT。这个参数的用处是告诉存储过程，我要处理的是哪一天的数据。我没有指定参数种类是 IN、OUT 或者 INOUT，这是因为在 MySQL 中，如果不指定参数的种类，默认就是 IN，表示输入参数。

知道了参数，下面我具体讲解一下这个存储过程的程序体。存储过程的具体操作步骤都包含在程序体里面，我们来分析一下程序体中 SQL 操作的内容，就可以知道存储过程到底在做什么。

存储过程的程序体

程序体中包含的是存储过程需要执行的 SQL 语句，一般通过关键字 BEGIN 表示 SQL 语句的开始，通过 END 表示 SQL 语句的结束。

在程序体的开始部分，我定义了 2 个变量，分别是 startdate 和 enddate。它们都是 DATETIME 类型，作用是根据输入参数 transdate，计算出需要筛选的数据的时间区间。

后面的代码分 3 步完成起始时间和截止时间的计算，并且分别赋值给变量 startdate 和 enddate。

第一步，使用 DATE_FROMAT（）函数，把输入的参数，按照 YYYY 年 MM 月 DD 日的格式转换成了日期时间类型数据，比如输入参数是“2020-12-01”，那么，转换成的日期时间值是“2020-12-01 00:00:00”，表示 2020 年 12 月 01 日 00 点 00 分 00 秒。

第二步，把第一步中计算出的值，作为起始时间赋值给变量 startdate。

第三步，把第一步中计算出的值，通过 DATE_ADD（）函数，计算出 1 天以后的时间赋值给变量 enddate。

这样，我就获得了需要计算的销售时段。计算出了起始时间和截止时间之后，我们先删除需要计算日期的单品统计数据，以防止数据重复。接着，我们重新计算单品的销售统计，并且把计算的结果插入到单品统计表。

在计算单品销售统计的时候，也分为 3 步：

按照“成本 = 销售数量×平均价格”的方式计算成本；

按照“毛利 = 销售金额 - 成本”的方式计算毛利；

按照“毛利率 = 毛利 ÷销售金额”的方式计算毛利率。

需要注意的是，这里我使用 CASE 函数来解决销售金额为 0 时计算毛利的问题。这是为了防止计算出现被 0 除而报错的情况。不要以为销售金额就一定大于 0，在实际项目运行的过程中，会出现因为优惠而导致实际销售金额为 0 的情况。我建议你在实际工作中，把这些极端情况都考虑在内，提前进行防范，这样你的代码才能稳定可靠。

存储过程通过开始时定义的分隔符“//”结束，MySQL 执行这段 SQL 语句，就创建出了一个存储过程：demo.dailyoperation. 最后，你不要忘了把分隔符改回到“；”。

创建完之后，怎么知道我们创建的存储过程是否成功了呢？下面我介绍一下查看存储过程的方法。

如何查看存储过程？

我们可以通过 SHOW CREATE PROCEDURE 存储过程名称，来查看刚刚创建的存储过程：

mysql> SHOW CREATE PROCEDURE demo.dailyoperation \G
*************************** 1. row ***************************
Procedure: dailyoperation      -- 存储过程名
sql_mode: STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
Create Procedure: CREATE DEFINER=`root`@`localhost` PROCEDURE `dailyoperation`(transdate TEXT)   -- 存储过程名
BEGIN                              -- 开始程序体
DECLARE startdate,enddate DATETIME;
SET startdate = date_format(transdate,'%Y-%m-%d');
SET enddate = date_add(startdate,INTERVAL 1 DAY);
DELETE FROM demo.dailystatistics   -- 删除重复
WHERE
salesdate = startdate;
INSERT into dailystatistics        -- 将计算结果插入每日统计表
(
salesdate,
itemnumber,
quantity,
actualvalue,
cost,
profit,
profitratio
)
SELECT
LEFT(b.transdate,10),
a.itemnumber,
SUM(a.quantity),
SUM(a.salesvalue),
SUM(a.quantity*c.avgimportprice),
SUM(a.salesvalue-a.quantity*c.avgimportprice),
CASE sum(a.salesvalue) WHEN 0 THEN 0
ELSE round(sum(a.salesvalue-a.quantity*c.avgimportprice)/sum(a.salesvalue),4) END
FROM
demo.transactiondetails AS a
JOIN
demo.transactionhead AS b
ON (a.transactionid = b.transactionid)
JOIN
demo.goodsmaster c
ON (a.itemnumber=c.itemnumber)
WHERE
b.transdate>startdate AND b.transdate<enddate
GROUP BY
LEFT(b.transdate,10),a.itemnumber
ORDER BY
LEFT(b.transdate,10),a.itemnumber;
END                               -- 结束程序体
character_set_client: gbk         -- 采用的字符集gbk
collation_connection: gbk_chinese_ci  -- 连接校对采用的字符集
Database Collation: utf8mb4_0900_ai_ci -- 数据校对字符集
1 row in set (0.00 sec)

如何调用存储过程？

下面我们来尝试调用一下这个存储过程，并且给它传递一个参数“2020-12-01”，也就是计算 2020 年 12 月 01 日的单品统计数据：

mysql> CALL demo.dailyoperation('2020-12-01');
Query OK, 2 rows affected (0.03 sec)

存储过程执行结果提示“Query OK”，表示执行成功了。“2 rows affected”表示执行的结果影响了 2 条数据记录。

我们用 SELECT 语句来查看一下单品统计表，看看有没有把单品统计的结果存入单品统计表中。

mysql> SELECT *                     -- 查询单品统计表中的数据
-> FROM demo.dailystatistics;
+----+------------+----------+-------------+--------+--------+-------------+---------------------+
| id | itemnumber | quantity | actualvalue | cost | profit | profitratio | salesdate |
+----+------------+----------+-------------+--------+--------+-------------+---------------------+
| 13 | 1 | 3.000 | 267.00 | 100.50 | 166.50 | 0.6236 | 2020-12-01 00:00:00 |         -- 编号是1的商品，单品统计结果
| 14 | 2 | 2.000 | 10.00 | 7.00 | 3.00 | 0.3000 | 2020-12-01 00:00:00 |                  -- 编号是2的商品，单品统计的结果
+----+------------+----------+-------------+--------+--------+-------------+---------------------+
2 rows in set (0.00 sec)

看到了吗？我们已经能够在单品统计表中，查询到 2020 年 12 月 01 日的单品统计结果了。这也就意味着我们的存储过程被执行了，它计算出了我们需要的单品统计结果，并且把统计结果存入了单品统计表中。

如何修改和删除存储过程？

如果你需要修改存储过程的内容，我建议你在 Workbench 中操作。这是因为你可以在里面直接修改存储过程，而如果用 SQL 命令来修改存储过程，就必须删除存储过程再重新创建，相比之下，在 Workbench 中修改比较简单。

具体的做法是：在左边的导航栏，找到数据库 demo，展开之后，找到存储过程 stored procedure，然后找到我们刚刚创建的 dailyoperation，点击右边的设计按钮，就可以在右边的工作区进行修改了。

修改完成之后，点击工作区右下方的按钮“Apply”，保存修改。

在 MySQL 中，存储过程不像普通的编程语言（比如 VC++、Java 等）那样有专门的集成开发环境。因此，你可以通过 SELECT 语句，把程序执行的中间结果查询出来，来调试一个 SQL 语句的正确性。调试成功之后，把 SELECT 语句后移到下一个 SQL 语句之后，再调试下一个 SQL 语句。这样逐步推进，就可以完成对存储过程中所有操作的调试了。当然，你也可以把存储过程中的 SQL 语句复制出来，逐段单独调试。

删除存储过程很简单，你知道具体的语法就行了：

DROP PROCEDURE 存储过程名称;

总结

这节课，我们学习了创建、查看、修改和删除存储过程的具体方法。

存储过程的优点就是执行效率高，而且更加安全，不过，它也有着自身的缺点，那就是开发和调试的成本比较高，而且不太容易维护。

在存储过程开发的过程中，虽然也有一些第三方工具可以对存储过程进行调试，但要收费。我建议你通过 SELECT 语句输出变量值的办法进行调试，虽然有点麻烦，但是成本低，而且简单可靠。如果你的存储过程需要随产品一起分发，可以考虑把脚本放在安装程序中，在产品安装的过程中创建需要的存储过程。

思考题

请写一个简单的存储过程，要求是定义 2 个参数，一个输入参数 a，数据类型是 INT；另一个输出参数是 b，类型是 INT。程序体完成的操作是：b = a + 1。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

16 | 游标：对于数据集中的记录，该怎么逐条处理？

你好，我是朱晓峰。今天，我来和你聊一聊游标。

咱们前面学习的 MySQL 数据操作语句，都是针对结果集合的。也就是说，每次处理的对象都是一个数据集合。如果需要逐一处理结果集中的记录，就会非常困难。

虽然我们也可以通过筛选条件 WHERE 和 HAVING，或者是限定返回记录的关键字 LIMIT 返回一条记录，但是，却无法在结果集中像指针一样，向前定位一条记录、向后定位一条记录，或者是随意定位到某一条记录，并对记录的数据进行处理。

这个时候，就可以用到游标。所谓的游标，也就是能够对结果集中的每一条记录进行定位，并对指向的记录中的数据进行操作的数据结构。

这么说可能有点抽象，我举一个生活中的例子，你一看就明白了。比如，你想去某个城市旅游，现在需要订酒店。你打开预订酒店的 App，设置好价格区间后进行搜索，得到了一个酒店列表。接下来，你可能要逐条查看列表中每个酒店的客户评价，最后选择一个口碑不错的酒店。这个逐条搜索并对选中的数据进行操作的过程，就相当于游标对数据记录进行操作的过程。

今天我就来给你讲一讲游标的使用方法，同时还会通过一个案例串讲，帮助你更好地使用游标，让你能够轻松地处理数据集中的记录。

游标的使用步骤

游标只能在存储程序内使用，存储程序包括存储过程和存储函数。关于存储过程，我们上节课刚刚学过，这里我简单介绍一下存储函数。创建存储函数的语法是：

CREATE FUNCTION 函数名称 （参数）RETURNS 数据类型 程序体

存储函数与存储过程很像，但有几个不同点：

存储函数必须返回一个值或者数据表，存储过程可以不返回。

存储过程可以通过 CALL 语句调用，存储函数不可以。

存储函数可以放在查询语句中使用，存储过程不行。

存储过程的功能更加强大，包括能够执行对表的操作（比如创建表，删除表等）和事务操作，这些功能是存储函数不具备的。

这节课，我们主要学习下游标在存储过程中的使用方法，因为游标在存储过程中更常用。游标在存储函数中的使用方法和在存储过程中的使用方法是一样的。

在使用游标的时候，主要有 4 个步骤。

第一步，定义游标。语法结构如下：

DECLARE 游标名 CURSOR FOR 查询语句

这里就是声明一个游标，它可以操作的数据集是“查询语句”返回的结果集。

第二步，打开游标。语法结构如下：

OPEN 游标名称；

打开游标之后，系统会为游标准备好查询的结果集，为后面游标的逐条读取结果集中的记录做准备。

第三步，从游标的数据结果集中读取数据。语法结构是这样的：

FETCH 游标名 INTO 变量列表；

这里的意思是通过游标，把当前游标指向的结果集中那一条记录的数据，赋值给列表中的变量。

需要注意的是，游标的查询结果集中的字段数，必须跟 INTO 后面的变量数一致，否则，在存储过程执行的时候，MySQL 会提示错误。

第四步，关闭游标。语法结构如下：

CLOSE 游标名；

用完游标之后，你一定要记住及时关闭游标。因为游标会占用系统资源，如果不及时关闭，游标会一直保持到存储过程结束，影响系统运行的效率。而关闭游标的操作，会释放游标占用的系统资源。

知道了基本步骤，下面我就结合超市项目的实际案例，带你实战一下。

案例串讲

在超市项目的进货模块中，有一项功能是对进货单数据进行验收。其实就是在对进货单的数据确认无误后，对进货单的数据进行处理，包括增加进货商品的库存，并修改商品的平均进价。下面我用实际数据来演示一下这个操作流程。

这里我们要用到进货单头表（demo.importheadl）、进货单明细表（demo.importdetails）、库存表（demo.inventory）和商品信息表（demo.goodsmaster）。

进货单头表：

进货单明细表：

库存表：

商品信息表：

要验收进货单，我们就需要对每一个进货商品进行两个操作：

在现有库存数量的基础上，加上本次进货的数量；

根据本次进货的价格、数量，现有商品的平均进价和库存，计算新的平均进价：（本次进货价格 * 本次进货数量 + 现有商品平均进价 * 现有商品库存）/（本次进货数量 + 现有库存数量）。

针对这个操作，如果只用我们在第 4 讲里学习的 SQL 语句，完成起来就比较困难。

因为我们需要通过应用程序来控制操作流程，做成一个循环操作，每次只查询一种商品的数据记录并进行处理，一直到把进货单中的数据全部处理完。这样一来，应用必须发送很多的 SQL 指令到服务器，跟服务器的交互多，不仅代码复杂，而且也不够安全。

这个时候，如果使用游标，就很容易了。因为所有的操作都可以在服务器端完成，应用程序只需要发送一个命令调用存储过程就可以了。现在，我们就来看看如何用游标来解决这个问题。

我用代码创建了一个存储过程 demo.mytest（）。当然，你也完全可以在 Workbench 中创建存储过程，非常简单，我就不多说了。创建存储过程的代码如下：

mysql> DELIMITER //
mysql> CREATE PROCEDURE demo.mytest(mylistnumber INT)
-> BEGIN
-> DECLARE mystockid INT;
-> DECLARE myitemnumber INT;
-> DECLARE myquantity DECIMAL(10,3);
-> DECLARE myprice DECIMAL(10,2);
-> DECLARE done INT DEFAULT FALSE; -- 用来控制循环结束
-> DECLARE cursor_importdata CURSOR FOR -- 定义游标
-> SELECT b.stockid,a.itemnumber,a.quantity,a.importprice
-> FROM demo.importdetails AS a
-> JOIN demo.importhead AS b
-> ON (a.listnumber=b.listnumber)
-> WHERE a.listnumber = mylistnumber;
-> DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE; -- 条件处理语句
->
-> OPEN cursor_importdata; -- 打开游标
-> FETCH cursor_importdata INTO mystockid,myitemnumber,myquantity,myprice; -- 读入第一条记录
-> REPEAT
-> -- 更新进价
-> UPDATE demo.goodsmaster AS a,demo.inventory AS b
-> SET a.avgimportprice = (a.avgimportprice*b.invquantity+myprice*myquantity)/(b.invquantity+myquantity)
-> WHERE a.itemnumber=b.itemnumber AND b.stockid=mystockid AND a.itemnumber=myitemnumber;
-> -- 更新库存
-> UPDATE demo.inventory
-> SET invquantity = invquantity + myquantity
-> WHERE stockid = mystockid AND itemnumber=myitemnumber;
-> -- 获取下一条记录
-> FETCH cursor_importdata INTO mystockid,myitemnumber,myquantity,myprice;
-> UNTIL done END REPEAT;
-> CLOSE cursor_importdata;
-> END
-> //
Query OK, 0 rows affected (0.02 sec)
-> DELIMITER ;

这段代码比较长，核心操作有 6 步，我来给你详细解释下。

把 MySQL 的分隔符改成“//”。

开始程序体之后，我定义了 4 个变量，分别是 mystockid、myitemnumber、myquantity 和 myprice，这几个变量的作用是，存储游标中读取的仓库编号、商品编号、进货数量和进货价格数据。

定义游标。这里我指定了游标的名称，以及游标可以处理的数据集（mylistnumber 指定的进货单的全部进货商品明细数据）。

定义条件处理语句“DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;”。

打开游标，读入第一条记录，然后开始执行数据操作。

关闭游标，结束程序。

可以看到，在这个存储过程中，我用到了条件处理语句，它的作用是告诉系统，在存储程序遇到问题的时候，应该如何处理。

条件处理语句

条件处理语句的语法结构：

DECLARE 处理方式 HANDLER FOR 问题 操作；

下面我结合刚刚的“DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;”，来解释一下条件处理语句是如何工作的。

语法结构中的“问题”是指 SQL 操作中遇到了什么问题。比如这里的问题是“NOT FOUND”，意思就是游标走到结果集的最后，没有记录了。也就是说，数据集中的所有记录都已经处理完了。

执行的操作是“SET done=TRUE”，done 是我定义的用来标识数据集中的数据是否已经处理完成的一个标记。done=TRUE，意思是数据处理完成了。

处理方式有 2 种选择，分别是“CONTINUE”和“EXIT”，表示遇到问题，执行了语法结构中的“操作”之后，是选择继续运行程序，还是选择退出，结束程序。

所以，这个条件处理语句的意思就是：当游标读到结果集的最后，没有记录了，设置操作完成标识为真，然后继续运行程序。

在存储过程的第 5 步，为了逐一处理每一条记录，我还使用了流程控制语句。

解决复杂问题不可能通过一个 SQL 语句完成，我们需要执行多个 SQL 操作。流程控制语句的作用就是控制存储过程中 SQL 语句的执行顺序，是我们完成复杂操作必不可少的一部分。下面我就给你具体讲解一下。

流程控制语句

MySQL 的流程控制语句也只能用于存储程序。主要有 3 类。

跳转语句：ITERATE 和 LEAVE 语句。

循环语句：LOOP、WHILE 和 REPEAT 语句。

条件判断语句：IF 语句和 CASE 语句。

接下来我依次讲解一下跳转语句、循环语句和条件判断语句。

跳转语句

ITERATE 语句：只能用在循环语句内，表示重新开始循环。

LEAVE 语句：可以用在循环语句内，或者以 BEGIN 和 END 包裹起来的程序体内，表示跳出循环或者跳出程序体的操作。

循环语句

LOOP 语句的语法结构是：

标签：LOOP
操作
END LOOP 标签;

关于这个语句，需要注意的是，LOOP 循环不能自己结束，需要用跳转语句 ITERATE 或者 LEAVE 来进行控制。

WHILE 语句的语法结构：

WHILE 条件 DO
操作
END WHILE;

WHILE 循环通过判断条件是否为真来决定是否继续执行循环中的操作，你要注意一点，WHILE 循环是先判断条件，再执行循环体中的操作。

REPEAT 语句的语法结构：

REPEAT
操作
UNTIL 条件 END REPEAT；

REPEAT 循环也是通过判断条件是否为真来决定是否继续执行循环内的操作的，与 WHILE 不同的是，REPEAT 循环是先执行操作，后判断条件。

最后我来讲讲条件判断语句：IF 语句和 CASE 语句。

条件判断语句

IF 语句的语法结构是：

IF 表达式1 THEN 操作1
[ELSEIF 表达式2 THEN 操作2]……
[ELSE 操作N]
END IF

这里“[]”中的内容是可选的。IF 语句的特点是，不同的表达式对应不同的操作。

CASE 语句的语法结构是：

CASE 表达式
WHEN 值1 THEN 操作1
[WHEN 值2 THEN 操作2]……
[ELSE 操作N]
END CASE;

这里“[]”中的内容是可选的。CASE 语句的特点是，表达式不同的值对应不同的操作。

到这里，我们处理进货单验收的存储过程就创建好了。现在，让我们来运行一下这个存储过程，看看能不能得到我们想要的结果：

mysql> CALL demo.mytest(1234);          -- 调用存储过程，验收单号是1234的进货单
Query OK, 0 rows affected (11.68 sec)   -- 执行成功了

mysql> select * from demo.inventory;    -- 查看库存，已经改过来了
+---------+------------+-------------+
| stockid | itemnumber | invquantity |
+---------+------------+-------------+
| 1 | 1 | 15.000 |
| 1 | 2 | 23.000 |
+---------+------------+-------------+
2 rows in set (0.00 sec)

mysql> select * from demo.goodsmaster;     -- 查看商品信息表，平均进价也改过来了
+------------+---------+-----------+---------------+------+------------+----------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice | avgimportprice |
+------------+---------+-----------+---------------+------+------------+----------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 | 31.00 |
| 2 | 0002 | 笔 | NULL | 包 | 5.00 | 2.87 |
+------------+---------+-----------+---------------+------+------------+----------------+
2 rows in set (0.00 sec)

很显然，库存和平均价格都被正确地计算出来了。

最后，有个小问题要提醒你注意：如果一个操作要用到另外一个操作的结果，那我们一定不能搞错操作的顺序。比如，在刚刚的例子中，我是先计算平均价格，后消减库存数量，这就是因为，计算平均价格的时候会用到库存数量，如果先消减库存数量，平均价格的计算就会不准。

总结

这节课，我们学习了游标的使用方法，包括在存储过程中使用游标的 4 个步骤，分别是定义游标、打开游标、读取游标数据和关闭游标。除此之外，我还介绍了经常与游标结合使用的流程控制语句，包括循环语句 LOOP、WHILE 和 REPEAT；条件判断语句 IF 和 CASE；还有跳转语句 LEAVE 和 ITERATE。

游标是 MySQL 的一个重要的功能，为逐条读取结果集中的数据，提供了完美的解决方案。跟在应用层面实现相同的功能相比，游标可以在存储程序中使用，效率高，程序也更加简洁。但是游标会消耗系统资源，所以我建议你养成用完之后就关闭的习惯，这样才能提高系统的整体效率。

思考题

假设我有一个数据表 demo.test，具体信息如下所示：

你能自己写一个简单的存储过程，用游标来逐一处理一个数据表中的数据吗？

要求：编号为偶数的记录，myquant=myquant+1；编号是奇数的记录，myquant=myquant+2。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

17 | 触发器：如何让数据修改自动触发关联操作，确保数据一致性？

你好，我是朱晓峰。今天，我来和你聊一聊触发器。

在实际开发中，我们经常会遇到这样的情况：有 2 个或者多个相互关联的表，如商品信息和库存信息分别存放在 2 个不同的数据表中，我们在添加一条新商品记录的时候，为了保证数据的完整性，必须同时在库存表中添加一条库存记录。

这样一来，我们就必须把这两个关联的操作步骤写到程序里面，而且要用事务包裹起来，确保这两个操作成为一个原子操作，要么全部执行，要么全部不执行。要是遇到特殊情况，可能还需要对数据进行手动维护，这样就很容易忘记其中的一步，导致数据缺失。

这个时候，其实咱们可以使用触发器。你可以创建一个触发器，让商品信息数据的插入操作自动触发库存数据的插入操作。这样一来，就不用担心因为忘记添加库存数据而导致的数据缺失了。

听上去好像很不错，那触发器到底怎么使用呢？接下来，我就重点给你聊聊。我会先给你讲解创建、查看和删除触发器的具体操作，然后借助一个案例带你实战一下。

如何操作触发器？

首先，咱们来学习下触发器的基本操作。

创建触发器

创建触发器的语法结构是：

CREATE TRIGGER 触发器名称 {BEFORE|AFTER} {INSERT|UPDATE|DELETE}
ON 表名 FOR EACH ROW 表达式；

在创建时，你一定要注意触发器的三个要素。

表名：表示触发器监控的对象。

INSERT|UPDATE|DELETE：表示触发的事件。INSERT 表示插入记录时触发；UPDATE 表示更新记录时触发；DELETE 表示删除记录时触发。

BEFORE|AFTER：表示触发的时间。BEFORE 表示在事件之前触发；AFTER 表示在事件之后触发。

只有把这三个要素定义好，才能正确使用触发器。

创建好触发器之后，咱们还要知道触发器是不是创建成功了。怎么查看呢？我来介绍下。

查看触发器

查看触发器的语句是：

SHOW TRIGGERS\G;

删除触发器

删除触发器很简单，你只要知道语法结构就可以了：

DROP TRIGGER 触发器名称;

知道了触发器的操作方法，接下来咱们就借助超市项目的实际案例，在真实的场景中实战一下，毕竟，实战是掌握操作的最好方法。

案例讲解

超市项目实际实施过程中，客户经常要查询储值余额变动的明细，但是，查询会员消费流水时，存在数据汇总不及时、查询速度比较慢的问题。这时，我们就想到用触发器，及时把会员储值金额的变化信息记录到一个专门的表中。

我先用咱们熟悉的 SQL 语句来实现记录储值金额变动的操作，后面再带你使用触发器来操作。通过两种操作的对比，你就能更好地理解，在什么情况下，触发器能够比普通的 SQL 语句更加简洁高效，从而帮助你用好触发器这个工具，提高开发的能力。

下面我就借助具体数据，来详细说明一下。这里我们需要用到会员信息表（demo.membermaster）和会员储值历史表（demo.deposithist）。

会员信息表：

会员储值历史表：

假如在 2020 年 12 月 20 日这一天，会员编号是 2 的会员李四，到超市的某家连锁店购买了一条烟，消费了 150 元。现在，我们用之前学过的 SQL 语句，把这个会员储值余额的变动情况记录到会员储值历史表中。

第一步，查询出编号是 2 的会员卡的储值金额是多少。我们可以用下面的代码来实现：

mysql> SELECT memberdeposit
-> FROM demo.membermaster
-> WHERE memberid = 2;
+---------------+
| memberdeposit |
+---------------+
| 200.00 |
+---------------+
1 row in set (0.00 sec)

第二步，我们把会员编号是 2 的会员的储值金额减去 150。

mysql> UPDATE demo.membermaster
-> SET memberdeposit = memberdeposit - 150
-> WHERE memberid = 2;
Query OK, 1 row affected (0.01 sec)
Rows matched: 1 Changed: 1 Warnings: 0

第三步，读出会员编号是 2 的会员当前的储值金额。

mysql> SELECT memberdeposit
-> FROM demo.membermaster
-> WHERE memberid = 2;
+---------------+
| memberdeposit |
+---------------+
| 50.00 |
+---------------+
1 row in set (0.00 sec)

第四步，把会员编号和前面查询中获得的储值起始金额、储值余额和储值金额变化值，写入会员储值历史表。

mysql> INSERT INTO demo.deposithist
-> (
-> memberid,
-> transdate,
-> oldvalue,
-> newvalue,
-> changedvalue
-> )
-> SELECT 2,NOW(),200,50,-150;
Query OK, 1 row affected (0.02 sec)
Records: 1 Duplicates: 0 Warnings: 0

这样，我们就完成了记录会员储值金额变动的操作。现在，我们来查询一下记录的结果：

mysql> SELECT *
-> FROM demo.deposithist;
+----+----------+---------------------+----------+----------+--------------+
| id | memberid | transdate | oldvalue | newvalue | changedvalue |
+----+----------+---------------------+----------+----------+--------------+
| 1 | 2 | 2020-12-20 10:37:51 | 200.00 | 50.00 | -150.00 |
+----+----------+---------------------+----------+----------+--------------+
1 row in set (0.00 sec)

结果显示，会员编号是 2 的会员卡储值金额在 2020-12-20 10:37:51 时有变动，变动前是 200 元，变动后是 50 元，减少了 150 元。

你看这个记录会员储值金额变动的操作非常复杂，我们用了 4 步才完成。而且为了确保数据的一致性，我们还要用事务把这几个关联的操作包裹起来，这样一来，消耗的资源就比较多。

那如果用触发器来实现，效果会怎样呢？我们来实操一下。

首先，我们创建一个数据表 demo.membermaster 的触发器。每当更新表中的数据时，先触发触发器，如果发现会员储值金额有变化，就把会员编号信息、更新的时间、更新前的储值金额、更新后的储值金额和变动金额，写入会员储值历史表。然后，再执行会员信息表的更新操作。

创建触发器的代码如下所示：

DELIMITER //
CREATE TRIGGER demo.upd_membermaster BEFORE UPDATE  -- 在更新前触发
ON demo.membermaster
FOR EACH ROW                              -- 表示每更新一条记录，触发一次
BEGIN                                     -- 开始程序体
IF (new.memberdeposit <> old.memberdeposit)  -- 如果储值金额有变化
THEN
INSERT INTO demo.deposithist
(
memberid,
transdate,
oldvalue,
newvalue,
changedvalue
)
SELECT
NEW.memberid,
NOW(),
OLD.memberdeposit,                  -- 更新前的储值金额
NEW.memberdeposit,                  -- 更新后的储值金额
NEW.memberdeposit-OLD.memberdeposit; -- 储值金额变化值
END IF;
END
//
DELIMITER ;

创建完成之后，我们查看一下触发器，看看是不是真的创建成功了：

mysql> SHOW TRIGGERS \G;
*************************** 1. row ***************************
Trigger: upd_membermaster -- 触发器名称
Event: UPDATE -- 触发的事件
Table: membermaster -- 表名称
Statement: BEGIN -- 被触发时要执行的程序体
IF (new.memberdeposit <> old.memberdeposit) -- 储值金额变动时插入历史储值表
THEN
INSERT INTO demo.deposithist
(
memberid,
transdate,
oldvalue,
newvalue,
changedvalue
)
SELECT
NEW.memberid,
NOW(),
OLD.memberdeposit,
NEW.memberdeposit,
NEW.memberdeposit-OLD.memberdeposit;
END IF;
END
Timing: BEFORE
Created: 2021-04-03 15:02:48.18
sql_mode: STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION
Definer: root@localhost
character_set_client: utf8mb4
collation_connection: utf8mb4_0900_ai_ci
Database Collation: utf8mb4_0900_ai_ci
1 row in set (0.00 sec)

从代码中，我们可以知道触发器的具体信息：

Trigger 表示触发器名称，这里是 upd_membermaster；

Event 表示触发事件，这里是 UPDATE，表示更新触发；

Table 表示定义触发器的数据表，这里是 membermaster；

Statement 表示触发时要执行的程序体。

看到这些信息，我们就可以确认，触发器创建成功了。

创建成功以后，我们尝试更新一下会员编号是 1 的会员的储值金额，这里假设把会员 1 的储值金额增加 10 元。简单说明一下，会员也可以把钱存到会员卡里，需要的时候进行消费，对应的就是储值金额的增加。

我们用下面的代码来完成这个操作：

mysql> UPDATE demo.membermaster
-> SET memberdeposit = memberdeposit + 10
-> WHERE memberid = 1;
Query OK, 1 row affected (0.03 sec)
Rows matched: 1 Changed: 1 Warnings: 0

现在，我们来查看一下会员信息表和会员储值历史表：

mysql> SELECT *
-> FROM demo.membermaster;
+----------+----------+------------+---------+-------------+---------------+
| memberid | cardno | membername | address | phone | memberdeposit |
+----------+----------+------------+---------+-------------+---------------+
| 1 | 10000001 | 张三 | 北京 | 13812345678 | 110.00 |
| 2 | 10000002 | 李四 | 天津 | 18512345678 | 50.00 |
+----------+----------+------------+---------+-------------+---------------+
2 rows in set (0.00 sec)

mysql> SELECT *
-> FROM demo.deposithist;
+----+----------+---------------------+----------+----------+--------------+
| id | memberid | transdate | oldvalue | newvalue | changedvalue |
+----+----------+---------------------+----------+----------+--------------+
| 1 | 2 | 2020-12-20 10:37:51 | 200.00 | 50.00 | -150.00 |
| 2 | 1 | 2020-12-20 11:32:09 | 100.00 | 110.00 | 10.00 |
+----+----------+---------------------+----------+----------+--------------+
2 rows in set (0.01 sec)

结果显示，触发器正确地记录了修改会员储值金额的操作。

如果你一直跟着我进行操作，到这里，你可能会有疑问，在会员历史储值表中记录修改会员储值金额的操作，和实际修改会员信息表是 2 个操作，有没有可能一个成功，一个失败？比如说，记录修改会员储值金额的操作失败了，但是会员的储值金额被修改了。

其实，在 MySQL 中，如果触发器中的操作失败了，那么触发这个触发器的数据操作也会失败，不会出现一个成功、一个失败的情况。

我还是借助一个例子来解释下。假设我们创建了一个触发器，这个触发器的程序体中的 SQL 语句有误，比如多了一个字段。在这种情况下，触发器执行会失败，因此，数据更新的操作也不会执行。我们用下面的代码来验证一下：

DELIMITER //
CREATE TRIGGER demo.upd_membermaster BEFORE UPDATE
ON demo.membermaster
FOR EACH ROW
BEGIN
IF (new.memberdeposit <> old.memberdeposit)
THEN
INSERT INTO demo.deposithist
(
aa, -- 不存在的字段
memberid,
transdate,
oldvalue,
newvalue,
changedvalue
)
SELECT
1, -- 给不存在的字段赋值
NEW.memberid,
NOW(),
OLD.memberdeposit,
NEW.memberdeposit,
NEW.memberdeposit-OLD.memberdeposit;
END IF;
END
//
DELIMITER ;

现在，假设我们要把会员编号是 2 的会员卡的储值金额更新为 20：

mysql> update demo.membermaster set memberdeposit=20 where memberid = 2;
ERROR 1054 (42S22): Unknown column 'aa' in 'field list'

系统提示：因为字段“aa”不存在，导致触发器执行失败。现在我们来看看会员储值金额有没有被修改：

mysql> select * from demo.membermaster;
+----------+----------+------------+---------+-------------+---------------+
| memberid | cardno | membername | address | phone | memberdeposit |
+----------+----------+------------+---------+-------------+---------------+
| 1 | 10000001 | 张三 | 北京 | 13812345678 | 110.00 |
| 2 | 10000002 | 李四 | 天津 | 18512345678 | 50.00 |
+----------+----------+------------+---------+-------------+---------------+
2 rows in set (0.00 sec)

结果显示，会员储值金额不变。这个时候，为了让应用程序知道触发器是否执行成功，我们可以通过 ROW_COUNT() 函数来发现错误：

mysql> select row_count();
+-------------+
| row_count() |
+-------------+
| -1 |
+-------------+
1 row in set (0.00 sec)

结果是 -1，说明我们可以通过 ROW_COUNT() 函数捕获到错误。

我们回顾一下：对于记录会员储值金额变化的操作，可以通过应用层发出 SQL 语句指令，或者用一个存储过程来实现。无论哪种方式，都要通过好几步相互关联的操作，而且要做成一个事务，处理过程复杂，消耗的资源也较多。如果用触发器，效率就会提高很多，消耗的资源也少。同时，还可以起到事务的类似功能，保证关联操作全部完成或全部失败。

触发器的优缺点

通过刚刚的案例，你应该感受到触发器的高效了。现在，咱们把视角拔高一下，来看看触发器具体都有什么优缺点。毕竟，知己知彼，才能百战不殆。只有非常清楚它的优缺点，你才能充分发挥它的作用。

我先来说说触发器的优点。

首先，触发器可以确保数据的完整性。这是怎么体现的呢？我来举个小例子。

假设我们用进货单头表（demo.importhead）来保存进货单的总体信息，包括进货单编号、供货商编号、仓库编号、总计进货数量、总计进货金额和验收日期。

用进货单明细表（demo.importdetails）来保存进货商品的明细，包括进货单编号、商品编号、进货数量、进货价格和进货金额。

每当我们录入、删除和修改一条进货单明细数据的时候，进货单明细表里的数据就会发生变动。这个时候，在进货单头表中的总计数量和总计金额就必须重新计算，否则，进货单头表中的总计数量和总计金额就不等于进货单明细表中数量合计和金额合计了，这就是数据不一致。

为了解决这个问题，我们就可以使用触发器，规定每当进货单明细表有数据插入、修改和删除的操作时，自动触发 2 步操作：

重新计算进货单明细表中的数量合计和金额合计；

用第一步中计算出来的值更新进货单头表中的合计数量与合计金额。

这样一来，进货单头表中的合计数量与合计金额的值，就始终与进货单明细表中计算出来的合计数量与合计金额的值相同，数据就是一致的，不会互相矛盾。

其次，触发器可以帮助我们记录操作日志。

利用触发器，可以具体记录什么时间发生了什么。我们前面的记录修改会员储值金额的触发器，就是一个很好的例子。这对我们还原操作执行时的具体场景，更好地定位问题原因很有帮助。

另外，触发器还可以用在操作数据前，对数据进行合法性检查。

举个小例子。超市进货的时候，需要库管录入进货价格。但是，人为操作很容易犯错误，比如说在录入数量的时候，把条形码扫进去了；录入金额的时候，看串了行，录入的价格远超售价，导致账面上的巨亏……这些都可以通过触发器，在实际插入或者更新操作之前，对相应的数据进行检查，及时提示错误，防止错误数据进入系统。

说了这么多触发器的优点，那是不是所有事件可以驱动的操作，都应该用触发器呢？要是你这么想，就掉坑里了。

下面我来说说触发器的缺点。

触发器最大的一个问题就是可读性差。

因为触发器存储在数据库中，并且由事件驱动，这就意味着触发器有可能不受应用层的控制。这对系统维护是非常有挑战的。

这是啥意思呢？我举个例子，你一看就明白了。

还是拿我们创建触发器时讲到的修改会员储值操作的那个触发器为例。如果触发器中的操作出了问题，会导致会员储值金额更新失败。我用下面的代码演示一下：

mysql> update demo.membermaster set memberdeposit=20 where memberid = 2;
ERROR 1054 (42S22): Unknown column 'aa' in 'field list'

结果显示，系统提示错误，字段“aa”不存在。

这是因为，触发器中的数据插入操作多了一个字段，系统提示错误。可是，如果你不了解这个触发器，很可能会认为是更新语句本身的问题，或者是会员信息表的结构出了问题。说不定你还会给会员信息表添加一个叫“aa”的字段，试图解决这个问题，结果只能是白费力。

另外，相关数据的变更，特别是数据表结构的变更，都可能会导致触发器出错，进而影响数据操作的正常运行。这些都会由于触发器本身的隐蔽性，影响到应用中错误原因排查的效率。

总结

今天这节课，我给你介绍了如何操作触发器。为了方便你学习，我汇总了相关的语法结构：

创建触发器的语法结构是
CREATE TRIGGER 触发器名称 {BEFORE|AFTER} {INSERT|UPDATE|DELETE}
ON 表名 FOR EACH ROW 表达式；

查看触发器的语句是：
SHOW TRIGGERS\G;

删除触发器的语法结构是：
DROP TRIGGER 触发器名称;

除此之外，我们还学习了触发器的优缺点。它的优点是可以确保数据一致性、记录操作日志和检查数据合法性。不过，它也存在可读性差，会增加系统维护的成本的缺点。在使用触发器的时候，你一定要综合考量。

最后，我还想再给你提一个小建议：维护一个完整的数据库设计文档。因为运维人员可能会经常变动，如果有一个完整的数据库设计文档，就可以帮助新人快速了解触发器的设计思路，从而减少错误，降低系统维护的成本。

思考题

我在课程中提到，每当在进货单明细表中插入或修改数据的时候，都要更新进货单头表中的总计数量和总计金额，这个问题可以用触发器来解决。你能不能创建一个触发器，要求是当操作人员更新进货单明细表中相关数据的时候，自动触发对进货单头表中相关数据的更改，确保数据的一致性？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，欢迎你把它分享给你的朋友或同事，我们下节课见。

18 | 权限管理：如何控制数据库访问，消除安全隐患？

你好，我是朱晓峰，今天，我来和你聊一聊权限管理。

我们在开发应用的时候，经常会遇到一种需求，就是要根据用户的不同，对数据进行横向和纵向的分组。

所谓横向的分组，就是指用户可以接触到的数据的范围，比如可以看到哪些表的数据；所谓纵向的分组，就是指用户对接触到的数据能访问到什么程度，比如能看、能改，甚至是删除。

我们把具有相同数据访问范围和程度的用户归成不同的类别，这种类别就叫角色。通过角色，管理用户对数据库访问的范围和程度就更加方便了。这也就是对用户的数据访问权限的管理。

恰当的权限设定，可以确保数据的安全性，这是至关重要的。

那么，怎么进行权限管理呢？这节课，我就结合超市项目的实际案例，给你讲一下权限管理的具体操作，包括怎么操作角色和用户，怎么通过角色给用户授权，怎么直接给用户授权，从而帮助你管理好用户的权限，提升数据库的安全性。

下面我就来先讲讲角色。我们可以通过角色对相同权限的用户进行分组管理，这样可以使权限管理更加简单高效。

角色的作用

角色是在 MySQL 8.0 中引入的新功能，相当于一个权限的集合。引入角色的目的是方便管理拥有相同权限的用户。

下面我举个超市项目中的例子，来具体说明一下如何通过角色管理用户权限。

超市项目中有库管、营运和财务等不同的模块，它们各自对应不同的数据表。比如库存模块中的盘点表（demo.invcount）、营运模块中的商品信息表（demo.goodsmaster），还有财务模块中的应付账款表（demo.settlement）。下面是这些表的具体信息。

盘点表：

商品信息表：

应付账款表：

在超市项目中，员工的职责不同，包括库管、营运和财务等，不同的职责有不同的数据访问权限。比如：

张三是库管，他就可以查询商品信息表，对盘点表有增删改查的权限，但无权访问应付账款表；

李四是营运，他就拥有对商品信息表有增删改查的权限，而对库存表和应付账款表，只有查看的权限；

王五是财务，他就有对应付账款表有增删改查的权限，对商品信息表和库存表，只有查看的权限。

所以，我们需要为每一个职责创建一个对应的角色，为每个员工创建一个对应的数据库用户。然后通过给角色赋予相关的权限，再把角色赋予用户，实现对超市员工访问数据权限的管理，从而保证数据的安全性。

这样说有点抽象，下面我们具体操作一下角色和用户。

如何操作角色？

首先，我们要创建一个角色，为后面的授权做好准备。

如何创建角色？

MySQL 中的角色名称由角色名称加主机名称组成。创建角色的语法结构如下：

CREATE ROLE 角色名;

假设我们现在需要创建一个经理的角色，就可以用下面的代码：

mysql> CREATE ROLE 'manager'@'localhost';
Query OK, 0 rows affected (0.06 sec)

这里的意思是，创建一个角色，角色名称是“manager”，角色可以登录的主机是“localhost”，意思是只能从数据库服务器运行的这台计算机登录这个账号。你也可以不写主机名，直接创建角色“manager”：

mysql> CREATE ROLE 'manager';
Query OK, 0 rows affected (0.01 sec)

如果不写主机名，MySQL 默认是通配符“%”，意思是这个账号可以从任何一台主机上登录数据库。

同样道理，如果我们要创建库管的角色，就可以用下面的代码：

mysql> CREATE ROLE 'stocker';
Query OK, 0 rows affected (0.02 sec)

创建角色之后，默认这个角色是没有任何权限的，我们需要给角色授权。

怎么给角色赋予权限？

给角色授权的语法结构是：

GRANT 权限 ON 表名 TO 角色名;

假设我们现在想给经理角色授予商品信息表、盘点表和应付账款表的只读权限，就可以用下面的代码来实现：

mysql> GRANT SELECT ON demo.settlement TO 'manager';
Query OK, 0 rows affected (0.03 sec)

mysql> GRANT SELECT ON demo.goodsmaster TO 'manager';
Query OK, 0 rows affected (0.01 sec)

mysql> GRANT SELECT ON demo.invcount TO 'manager';
Query OK, 0 rows affected (0.01 sec)

如果我们需要赋予库管角色盘点表的增删改查权限、商品信息表的只读权限，对应付账款表没有权限，就可以这样：

mysql> GRANT SELECT,INSERT,DELETE,UPDATE ON demo.invcount TO 'stocker';
Query OK, 0 rows affected (0.02 sec)

mysql> GRANT SELECT ON demo.goodsmaster TO 'stocker';
Query OK, 0 rows affected (0.02 sec)

查看角色权限

赋予角色权限之后，我们可以通过 SHOW GRANTS 语句，来查看权限是否创建成功了：

mysql> SHOW GRANTS FOR 'manager';
+-------------------------------------------------------+
| Grants for manager@% |
+-------------------------------------------------------+
| GRANT USAGE ON *.* TO `manager`@`%` |
| GRANT SELECT ON `demo`.`goodsmaster` TO `manager`@`%` |
| GRANT SELECT ON `demo`.`invcount` TO `manager`@`%` |
| GRANT SELECT ON `demo`.`settlement` TO `manager`@`%` |
+-------------------------------------------------------+
4 rows in set (0.00 sec)

只要你创建了一个角色，系统就会自动给你一个“USAGE”权限，意思是连接登录数据库的权限。代码的最后三行代表了我们给角色“manager”赋予的权限，也就是对商品信息表、盘点表和应付账款表的只读权限。

再来看看库管角色的权限:

mysql> SHOW GRANTS FOR 'stocker';
+----------------------------------------------------------------------------+
| Grants for stocker@% |
+----------------------------------------------------------------------------+
| GRANT USAGE ON *.* TO `stocker`@`%` |
| GRANT SELECT ON `demo`.`goodsmaster` TO `stocker`@`%` |
| GRANT SELECT, INSERT, UPDATE, DELETE ON `demo`.`invcount` TO `stocker`@`%` |
+----------------------------------------------------------------------------+
3 rows in set (0.00 sec)

结果显示，库管角色拥有商品信息表的只读权限和盘点表的增删改查权限。

当我们需要对业务重新整合的时候，可能就需要对之前创建的角色进行清理，删除一些不会再使用的角色。

删除角色

删除角色的操作很简单，你只要掌握语法结构就行了。

DROP ROLE 角色名称;

这个操作十分简单，我就不多说了。

到这里，关于角色的操作，我就介绍完了，下面我们来看看关于用户的操作。

如何操作用户？

我们可以把数据库中的角色看作是一个权限集。角色本身不能操作数据，这个任务还是要靠用户来完成。在操作用户前，我们先要创建一个用户。

创建用户

创建用户的语法结构是这样的：

CREATE USER 用户名 [IDENTIFIED BY 密码];

“[ ]”表示可选，也就是说，可以指定用户登录时需要密码验证，也可以不指定密码验证，这样用户可以直接登录。不过，不指定密码的方式不安全，不推荐使用。

举个例子，假设我们要给张三创建一个用户，用户名是“zhangsan”，密码是“mysql”，可以通过下面的代码来实现：

mysql> CREATE USER 'zhangsan' IDENTIFIED BY 'mysql';
Query OK, 0 rows affected (0.02 sec)

这样，张三的用户就创建成功了。

给用户授权

给用户授权的方式有 2 种，分别是通过把角色赋予用户给用户授权，和直接给用户授权。

通过把角色赋予用户给用户授权的语法结构如下：

GRANT 角色名称 TO 用户名称;

举个小例子，我们想要给张三赋予库管的角色，可以通过下面的代码实现：

mysql> GRANT 'stocker' TO 'zhangsan';
Query OK, 0 rows affected (0.01 sec)

我们也可以直接给用户授权，语法结构如下：

GRANT 权限 ON 表名 TO 用户名;

这种方式简单直接，我就不多说了。下面我们来查看一下这个用户的权限有哪些。

查看用户权限

查看用户权限的语法结构是：

SHOW GRANTS FOR 用户名;

我们可以通过下面的代码来查看张三的权限：

mysql> SHOW GRANTS FOR 'zhangsan';
+---------------------------------------+
| Grants for zhangsan@% |
+---------------------------------------+
| GRANT USAGE ON *.* TO `zhangsan`@`%` |
| GRANT `stocker`@`%` TO `zhangsan`@`%` |
+---------------------------------------+
2 rows in set (0.00 sec)

结果显示，张三拥有库管角色的权限。

说到这里，我必须要提醒你一个常见的坑。

如果现在你用张三的这个用户去登录，你会发现，这个账号是没有任何权限的。你是不是觉得很奇怪，我不是把角色“stocker”赋予用户“zhangsan”了吗？那用户“zhangsan”应该有角色“stocker”的权限啊。其实，这是因为，MySQL 中创建了角色之后，默认都是没有被激活的，也就是不能用，必须要用下面的语句激活：

SET global activate_all_roles_on_login=ON;

这条 SQL 语句的意思是，对所有角色永久激活。运行这条语句之后，用户“zhangsan”才真正拥有了角色“stocker”的所有权限。

下面我们就用张三的账号登录，确认一下他有没有相应的权限：

H:\>mysql -u zhangsan -p
Enter password: *****
Welcome to the MySQL monitor. Commands end with ; or \g.
Your MySQL connection id is 24
Server version: 8.0.23 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> select * from demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+----------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice | avgimportprice |
+------------+---------+-----------+---------------+------+------------+----------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 | 31.00 |
| 2 | 0002 | 笔 | NULL | 包 | 5.00 | 2.87 |
+------------+---------+-----------+---------------+------+------------+----------------+
2 rows in set (0.02 sec)

结果显示，我们可以正常登录，并且可以查询商品信息表的内容。

删除用户

当用户不再使用的时候，我们也可以删除用户。操作起来很简单，你只要知道语法结构就行了：

DROP USER 用户名;

总结

今天这节课，我们学习了权限管理的方法，包括如何操作角色和用户，如何通过角色给用户授权，如何直接给用户授权等。

角色是权限的集合。你可以直接给用户授予访问数据库的权限，也可以通过把角色授予用户，从而把角色对数据库的访问权限全部授予给用户。而用户是数据库的使用者，我们可以通过给用户授予访问数据库中资源的权限，来控制使用者对数据库的访问，消除安全隐患。

需要注意的是，角色在刚刚创建出来的时候，默认是没有激活的，需要手动激活，才可以使用。如果你把角色赋予了用户，那么用户就拥有了角色的全部权限。但是，如果你删除了角色，那么用户也就失去了通过这个角色所获得的所有权限。

我知道，有一些程序员喜欢使用 Root 超级用户来访问数据库，完全把权限控制放在应用层面实现。这样当然也是可以的。不过我建议你，尽量使用数据库自己的角色和用户机制来控制访问权限，不要轻易用 Root 账号。因为 Root 账号密码放在代码里面不安全，一旦泄露，数据库就会完全失去保护。而且，MySQL 的权限控制功能十分完善，应该尽量利用，可以提高效率，而且安全可靠。

思考题

在今天的课里，我举了一个例子，提到超市运营中的一个职责“财务”。财务可以对商品信息表、盘点表有只读的权限，对应付账款表有增删改查的权限。请你设计一个角色：财务“accountant”具备这些权限。给会计“李四”创建一个用户账号“lisi”，使李四通过财务的角色获得对应付账款表增删改查的权限，和对商品信息表、盘点表有只读的权限。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

19 | 日志（上）：系统出现问题，如何及时发现？

你好，我是朱晓峰。

我们曾经开发过一个数据库应用系统，但是却突然遭遇了数据库宕机。在这种情况下，定位宕机的原因就非常关键，毕竟，知道了问题，才能确定解决方案。

这时，我们就想到了查看数据库的错误日志，因为日志中记录了数据库运行中的诊断信息，包括了错误、警告和注释等信息。从日志中，我们发现，原来某个连接中的 SQL 操作发生了死循环，导致内存不足，被系统强行终止了。知道了原因，处理起来也就比较轻松了，系统很快就恢复了运行。

除了发现错误，日志在数据复制、数据恢复、操作审计，以及确保数据的永久性和一致性等方面，都有着不可替代的作用，对提升你的数据库应用的开发能力至关重要。

今天，我就结合超市项目的实际案例，给你讲解一下怎么通过查看系统日志，来了解数据库中实际发生了什么，从而快速定位原因。

MySQL 的日志种类非常多，包括通用查询日志、慢查询日志、错误日志、二进制日志、中继日志、重做日志和回滚日志，内容比较多，而且都很重要，所以我们来花两节课的时间学习一下。

这节课，我会先具体讲一讲通用查询日志、慢查询日志和错误日志。

通用查询日志

通用查询日志记录了所有用户的连接开始时间和截止时间，以及发给 MySQL 数据库服务器的所有 SQL 指令。当我们的数据发生异常时，开启通用查询日志，还原操作时的具体场景，可以帮助我们准确定位问题。

举个小例子，在超市项目实施的过程中，我们曾遇到过这样一件事：超市经营者月底查账的时候发现，超市的 1 号门店在 12 月 1 日销售了 5 件化妆品，但是当天对应的历史库存并没有减少。化妆品的金额都比较大，库存不对的话，会在报表查询中产生巨额差异，触发到报警机制，对超市经营者的决策产生影响。超市经营者找到我们，对系统的可靠性提出质疑。

我们对系统进行了仔细检查，没有发现数据问题。可是商品确实卖出去了，当天的历史库存也确实没有消减。这个时候，我们想到了检查通用查询日志，看看当天到底发生了什么。

查看之后，我们就复原了当天的情况：12 月 1 日下午，门店的收银台销售了 5 件化妆品，但是由于网络故障，流水没有及时上传到总部。12 月 1 日晚上 11:59，总部的历史库存被保存下来，但是因为没有收到门店的流水，所以没有消减库存。12 月 2 日上午，门店的网络恢复了，流水得以上传总部，这个时候，对应化妆品的库存才被消减掉。

这样，我们就确定了故障的原因，也就是超市的网络问题，而系统本身是没有问题的。

你看，通用查询日志可以帮助我们了解操作发生的具体时间和操作的细节，对找出异常发生的原因极其关键。

下面我来具体介绍一下控制通用查询日志的系统变量。通过这些变量，你会清楚怎么控制通用查询日志的开启和关闭，以及保存日志的文件是哪个。

mysql> SHOW VARIABLES LIKE '%general%';
+------------------+---------------+
| Variable_name | Value |
+------------------+---------------+
| general_log | OFF | -- 通用查询日志处于关闭状态
| general_log_file | GJTECH-PC.log | -- 通用查询日志文件的名称是GJTECH-PC.log
+------------------+---------------+
2 rows in set, 1 warning (0.00 sec)

在这个查询的结果中，有 2 点需要我们注意一下。

系统变量 general_log 的值是 OFF，表示通用查询日志处于关闭状态。在 MySQL 中，这个参数的默认值是关闭的。因为一旦开启记录通用查询日志，MySQL 会记录所有的连接起止和相关的 SQL 操作，这样会消耗系统资源并且占用磁盘空间。我们可以通过手动修改变量的值，在需要的时候开启日志。

通用查询日志文件的名称是 GJTECH-PC.log。这样我们就知道在哪里可以查看通用查询日志的内容了。

下面我们来看看如何开启通用查询日志，把所有连接的起止和连接的 SQL 操作都记录下来。这个操作可以帮助我们追踪 SQL 操作故障的原因。

开启通用查询日志

我们可以通过设置系统变量的值，来开启通用查询日志，并且指定通用查询日志的文件夹和文件名为“H:\mytest.log”。这个操作如下：

mysql> SET GLOBAL general_log = 'ON';
Query OK, 0 rows affected (0.00 sec)

mysql> SET @@global.general_log_file = 'H:\mytest.log';
Query OK, 0 rows affected (0.02 sec)

为了确认我们的设定是否已经生效，我们再来查询一下通用查询日志的状态：

mysql> SHOW VARIABLES LIKE '%general%';
+------------------+--------------+
| Variable_name | Value |
+------------------+--------------+
| general_log | ON | -- 通用查询日志开启
| general_log_file | H:mytest.log |  -- 日志名称也改过了
+------------------+--------------+
2 rows in set, 1 warning (0.00 sec)

结果显示，通用查询日志已经开启，文件是“H:\mytest.log”，这就意味着我们的操作成功了。

查看通用查询日志

通用查询日志都是文本型数据，可以用记事本打开。下面我们就用记事本打开我电脑上的通用查询日志，实际看一看通用查询日志的内容，包括都有哪些连接，什么时候登录了数据库，都做了哪些操作等信息。

2021-04-05T06:39:53.621980Z 28 Connect zhangsan@localhost on using SSL/TLS   -- 账号zhangsan从本地登录
2021-04-05T06:39:53.622085Z 28 Connect Access denied for user 'zhangsan'@'localhost' (using password: NO)  -- 没有使用密码，连接被拒绝了
2021-04-05T06:40:02.522303Z 29 Connect zhangsan@localhost on using SSL/TLS
2021-04-05T06:40:02.522913Z 29 Query select @@version_comment limit 1
2021-04-05T06:40:14.211511Z 29 Query SELECT *
FROM demo.invcount                         -- 查询数据表demo.invcount内容
2021-04-05T06:40:37.647625Z 29 Query UPDATE demo.invcount
SET plquant = - 5                          -- 更新数据表demo.invcount
WHERE itemnumber = 1
2021-04-05T06:41:15.047067Z 29 Query SELECT *
FROM demo.goodsmaster                      -- 查询数据表demo.goodsmaster

在通用查询日志里面，我们可以清楚地看到，账号“zhangsan”是什么时间登录的服务器，登录之后做了什么 SQL 操作，针对的是哪个数据表等信息。

删除通用查询日志

当用户对数据库的操作比较频繁时，通用查询日志文件会不断变大。为了节省磁盘空间，我们可以移除旧的日志文件，创建新的日志文件，来对通用查询日志文件进行维护。

第一步，关闭通用查询日志：

mysql> SET GLOBAL general_log = 'OFF'; -- 关闭通用查询日志
Query OK, 0 rows affected (0.01 sec)

mysql> SHOW VARIABLES LIKE '%general_log%'; -- 查看通用查询日志状态
+------------------+--------------+
| Variable_name | Value |
+------------------+--------------+
| general_log | OFF |
| general_log_file | H:mytest.log |
+------------------+--------------+
2 rows in set, 1 warning (0.00 sec)

第二步，把通用查询日志文件“H:\mytest.log”移至备份文件夹，空出磁盘 H 的空间。

第三步，开启通用查询日志：

mysql> SET GLOBAL general_log = 'ON';
Query OK, 0 rows affected (0.01 sec)

mysql> SHOW VARIABLES LIKE '%general_log%';
+------------------+--------------+
| Variable_name | Value |
+------------------+--------------+
| general_log | ON |
| general_log_file | H:mytest.log |
+------------------+--------------+
2 rows in set, 1 warning (0.00 sec)

这个时候，你会发现，MySQL 已经给我们准备好了一个新的通用查询日志文件“H:\mytest.log”，并且记录了我们第一个查询的语句：“SHOW VARIABLES LIKE '%general_log%';”。

文件内容如下：

C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld.exe, Version: 8.0.23 (MySQL Community Server - GPL). started with:
TCP Port: 3306, Named Pipe: MySQL
Time Id Command Argument
2021-04-05T07:02:03.007394Z 30 Query SHOW VARIABLES LIKE '%general_log%'

总之，开启了通用查询日志之后，如果遇到用户对数据产生质疑的情况，我们就可以通过查看通用查询日志，还原当时的场景，快速定位并解决问题。

慢查询日志

慢查询日志用来记录执行时间超过指定时长的查询。它的主要作用是，帮助我们发现那些执行时间特别长的 SQL 查询，并且有针对性地进行优化，从而提高系统的整体效率。当我们的数据库服务器发生阻塞、运行变慢的时候，检查一下慢查询日志，找到那些慢查询，对解决问题很有帮助。

慢查询日志是由 MySQL 的配置文件进行控制的。下面我先简单介绍一下 MySQL 的配置文件。

在 MySQL 的安装目录中（C:\ProgramData\MySQL\MySQL Server 8.0），我们可以找到 MySQL 的配置文件“my.ini”。这个文件是一个文本格式的文件，可以直接用记事本打开来阅读。

我们来看看配置文件中关于慢查询日志变量的相关设定：

slow-query-log=1 -- 表示开启慢查询日志，系统将会对慢查询进行记录。

slow_query_log_file="GJTECH-PC-slow.log"  -- 表示慢查询日志的名称是"GJTECH-PC-slow.log"。这里没有指定文件夹，默认就是数据目录："C:\ProgramData\MySQL\MySQL Server 8.0\Data"。

long_query_time=10  -- 表示慢查询的标准是查询执行时间超过10秒

除了刚刚的这些变量，控制慢查询日志的还有一个系统变量：min_examined_row_limit。这个变量的意思是，查询扫描过的最少记录数。这个变量和查询执行时间，共同组成了判别一个查询是否是慢查询的条件。如果查询扫描过的记录数大于等于这个变量的值，并且查询执行时间超过 long_query_time 的值，那么，这个查询就被记录到慢查询日志中；反之，则不被记录到慢查询日志中。

如果要查看当前这个系统变量的值，我们就可以用下面的代码：

mysql> show variables like 'min%';
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| min_examined_row_limit | 0 |
+------------------------+-------+
1 row in set, 1 warning (0.00 sec)

这个值默认是 0。与 long_query_time=10 合在一起，表示只要查询的执行时间超过 10 秒钟，哪怕一个记录也没有扫描过，都要被记录到慢查询日志中。你也可以根据需要，通过修改“my.ini”文件，来修改查询时长，或者通过 SET 指令，用 SQL 语句修改“min_examined_row_limit”的值。

只是你要注意，如果修改了 MySQL 的配置文件“my.ini”，就需要重启服务器，这样才能使修改生效。

来看一个例子：之前我运行的一个慢查询，被记录到了慢查询日志中。这个例子记录了一个运行时间超过 10 秒的慢查询的发生时间、连接所属的用户、执行的时长、锁表的时长和扫描过的记录数等相关信息。

C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld.exe, Version: 8.0.23 (MySQL Community Server - GPL). started with:
TCP Port: 3306, Named Pipe: MySQL
Time Id Command Argument
# Time: 2021-03-25T07:20:33.412260Z     -- 执行开始时间
# User@Host: root[root] @ localhost [::1] Id: 13   -- 用户
# Query_time: 10.166435 Lock_time: 0.000000 Rows_sent: 0  Rows_examined: 0                        -- 执行时长、锁表时长、检查的记录数
use demo;
SET timestamp=1616656823;

通过这个慢查询日志的记录，我们就可以发现是哪个查询消耗了大量的系统资源，是哪个连接里面的查询，具体什么时间开始的。有了这些信息，我们就可以对慢查询进行分析，决定优化的方式，避免出现同样的问题。

好了，到这里，通用查询日志和慢查询日志我就讲完了，咱们最后再来学习一种重要的日志：错误日志。

错误日志

错误日志记录了 MySQL 服务器启动、停止运行的时间，以及系统启动、运行和停止过程中的诊断信息，包括错误、警告和提示等。当我们的数据库服务器发生系统故障时，错误日志是发现问题、解决故障的首选。

错误日志默认是开启的。我们可以在 MySQL 的配置文件“my.ini”中配置它：

# Error Logging.
log-error="GJTECH-PC.err"

这段代码指定了错误日志的文件名。如果没有指定文件夹，默认就是数据目录：“C:\ProgramData\MySQL\MySQL Server 8.0\Data”。

下面我们查看一下错误日志的内容：

2021-02-28T08:07:07.228880Z 0 [System] [MY-010116] [Server] C:\Program Files\MySQL\MySQL Server 8.0\bin\mysqld.exe (mysqld 8.0.23) starting as process 7652
2021-02-28T08:07:07.270982Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
2021-02-28T08:07:08.116433Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.

可以看到，错误日志文件中记录了服务器启动的时间，以及存储引擎 InnoDB 启动和停止的时间等。

总结

今天这节课，我们学习了通用查询日志、慢查询日志和错误日志。

通用查询日志：可以记录所有连接的起始时间和终止时间，以及连接发送给数据库服务器的所有指令，对我们复原操作的实际场景、发现问题，甚至是对数据库操作的审计都有很大的帮助。

慢查询日志：可以记录运行时间和检查记录数超过指定值的查询，方便我们对查询进行优化。

错误日志：它记录了服务器启动、运行和停止过程中的诊断信息，方便我们了解服务器的状态，从而对服务器进行维护。

最后，我还是想提醒你一句，千万不要小看日志。很多看似奇怪的问题，答案往往就藏在日志里。很多情况下，只有通过查看日志才能发现问题的原因，真正解决问题。所以，一定要学会查看日志，养成检查日志的习惯。

思考题

请你思考一下：怎么设置开启慢查询日志，并且把慢查询日志的指定时长设置为 5 秒，最少扫描记录数为 1？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

20 | 日志（下）：系统故障，如何恢复数据？

你好，我是朱晓峰。

上节课，咱们学习了通用查询日志、慢查询日志和错误日志，它们可以帮助我们快速定位系统问题。但实际上，日志也可以帮助我们找回由于误操作而丢失的数据，比如二进制日志（binary log）、中继日志（relay log）、回滚日志（undo log）和重做日志（redo log）。

这节课，我们就来学习下这 4 种日志。

二进制日志

二进制日志主要记录数据库的更新事件，比如创建数据表、更新表中的数据、数据更新所花费的时长等信息。通过这些信息，我们可以再现数据更新操作的全过程。而且，由于日志的延续性和时效性，我们还可以利用日志，完成无损失的数据恢复和主从服务器之间的数据同步。

可以说，二进制日志是进行数据恢复和数据复制的利器。所以，接下来我就结合一个实际案例，重点给你讲一讲怎么操作它。

如何操作二进制日志？

操作二进制日志，主要包括查看、刷新二进制日志，用二进制日志恢复数据，以及删除二进制日志。

1. 查看二进制日志

查看二进制日志主要有 3 种情况，分别是查看当前正在写入的二进制日志、查看所有的二进制日志和查看二进制日志中的所有数据更新事件。

查看当前正在写入的二进制日志的 SQL 语句是：

SHOW MASTER STATUS;

我们可以通过这条语句，查看当前正在写入的二进制日志的名称和当前写入的位置：

mysql> SHOW MASTER STATUS;
+----------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+----------------------+----------+--------------+------------------+-------------------+
| GJTECH-PC-bin.000011 | 2207 | | | |
+----------------------+----------+--------------+------------------+-------------------+   -- 当前正在写入的二进制日志是“GJTECH-PC-bin.000011”，当前的位置是2207。
1 row in set (0.00 sec)

查看所有的二进制日志的 SQL 语句是：

SHOW BINARY LOGS;

查看二进制日志中所有数据更新事件的 SQL 语句是：

SHOW BINLOG EVENTS IN 二进制文件名;

2. 刷新二进制日志

刷新二进制日志的 SQL 语句是：

FLUSH BINARY LOGS;

这条语句的意思是，关闭服务器正在写入的二进制日志文件，并重新打开一个新文件，文件名的后缀在现有的基础上加 1。

3. 用二进制日志恢复数据

我们可以用 mysqlbinlog 工具进行数据恢复：

mysqlbinlog –start-positon=xxx –end-position=yyy 二进制文件名 | mysql -u 用户 -p

这条命令的意思是，执行二进制日志中从位置 xxx 开始，到 yyy 截止的所有数据更新操作。这里的截止位置也可以不写，意思是从位置 xxx 开始，执行二进制文件中的所有数据更新操作。

4. 删除二进制日志

如果我们已经把日志文件保存到了安全的地方，就可以通过下面的 SQL 语句删除所有二进制日志文件，以释放磁盘空间：

mysql> RESET MASTER;
Query OK, 0 rows affected (0.20 sec)

mysql> SHOW BINARY LOGS;
+----------------------+-----------+-----------+
| Log_name | File_size | Encrypted |
+----------------------+-----------+-----------+
| GJTECH-PC-bin.000001 | 156 | No |
+----------------------+-----------+-----------+
1 row in set (0.00 sec)

结果显示，所有二进制日志文件都被删除了，MySQL 从头准备了一个“.000001”为后缀的新的二进制日志文件。

我们也可以通过 SQL 语句，删除比指定二进制日志文件编号小的所有二进制日志文件：

mysql> PURGE MASTER LOGS TO 'GJTECH-PC-bin.000005';
Query OK, 0 rows affected (0.02 sec)

好了，知道了二进制日志的操作方法，下面我们借助一个案例，来实操一下。我们来看看怎么通过二进制日志恢复数据，避免因故障或异常等导致数据损失。

案例讲解

假设数据库 demo 中有一个商品信息表（demo.goodsmaster），我先对数据库 demo 做了一个全量备份。所谓的全量备份，就是指对数据库中存储的全部数据进行备份。备份完成之后，我又在商品信息表中插入了新数据。

这个时候，数据库 demo 出现异常，数据全部丢失。现在咱们需要把所有的数据，包括备份前的数据和备份之后新插入的数据都恢复回来。我来介绍下具体的操作步骤。

商品信息表的信息如下所示：

可以看到，表中有一条记录：编号是 1 的商品，名称是“书”，售价是 89 元。

第一步，做数据库备份。

你可以用 MySQL 的数据备份工具 mysqldump，来备份数据。这个工具的语法结构如下所示：

mysqldump -u 用户 -p 密码 数据库 > 备份文件

在这个场景中，我们可以使用 mysqldump 工具，把数据库 demo 中的全部信息备份到文件“mybackup.sql”中，来完成对数据库 demo 的全量备份：

H:\>mysqldump -u root -p demo > mybackup.sql
Enter password: *****

这个命令的意思是，把数据库 demo 中的全部数据，备份到文件 mybackup.sql 中。

第二步，用“FLUSH BINARY LOGS;”语句刷新一下日志。

mysql> FLUSH BINARY LOGS;
Query OK, 0 rows affected (0.06 sec)

这步操作的目的是：产生一个新的二进制日志文件，使这个文件只保存数据备份之后的数据更新事件，这样可以方便我们查看文件的内容。

第三步，给商品信息表插入一条新的数据记录“笔”。

mysql> INSERT INTO demo.goodsmaster
-> (
-> itemnumber,
-> barcode,
-> goodsname,
-> salesprice
-> )
-> VALUES
-> (
-> 2,
-> '0002',
-> '笔',
-> 3
-> );
Query OK, 1 row affected (0.03 sec)

这样我们就增加了一个新的商品“笔”。

现在，我们来查看一下数据表里的内容：

mysql> SELECT * FROM demo.goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 书 | 89.00 |
| 2 | 0002 | 笔 | 3.00 |
+------------+---------+-----------+------------+
2 rows in set (0.00 sec)

结果显示，我们有了 2 条记录，分别是“书”和“笔”。

假设这个时候，系统突然宕机，数据库无法启动，为了使系统恢复正常，我们重启机器，重新创建数据库，并且需要进行数据恢复。

第四步，准备从备份文件恢复数据。

每当服务器启动、刷新二进制日志或者二进制日志大小超过系统变量 max_binlog_size 指定的值时，系统就会生成一个新的二进制日志文件。

我们先查看一下机器上的二进制日志文件，以确定哪个文件是我们正在使用的。

mysql> SHOW BINARY LOGS;
+----------------------+-----------+-----------+
| Log_name | File_size | Encrypted |
+----------------------+-----------+-----------+
| GJTECH-PC-bin.000005 | 179 | No |
| GJTECH-PC-bin.000006 | 113316452 | No |
| GJTECH-PC-bin.000007 | 12125 | No |
| GJTECH-PC-bin.000008 | 1544 | No |
| GJTECH-PC-bin.000009 | 207 | No |
| GJTECH-PC-bin.000010 | 1758 | No |
| GJTECH-PC-bin.000011 | 2207 | No |
| GJTECH-PC-bin.000012 | 462 | No |
+----------------------+-----------+-----------+
12 rows in set (0.01 sec)

结果显示，最新的，也就是记录了数据插入操作的二进制日志文件是“GJTECH-PC-bin.000012”，这就是接下来我们要用的日志文件。

接着，我们来刷新一下二进制日志文件，这样做的目的是防止后面数据恢复的事件全都被写入这个二进制日志文件，妨碍我们理解文件的内容。

mysql> FLUSH BINARY LOGS;
Query OK, 0 rows affected (0.08 sec)

现在，我们查看一下当前正在写入的二进制文件和位置，确认一下系统是否创建了新的二进制日志文件：

mysql> SHOW MASTER STATUS;
+----------------------+----------+--------------+------------------+-------------------+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+----------------------+----------+--------------+------------------+-------------------+
| GJTECH-PC-bin.000013 | 156 | | | |
+----------------------+----------+--------------+------------------+-------------------+
1 row in set (0.00 sec)

结果显示，当前在使用的二进制日志文件是“GJTECH-PC-bin.000013”，这样保存增量数据的二进制日志文件就不会受到后面操作的影响了。

下面我来删除并重建一个数据库 demo，给你演示一下如何使用二进制日志恢复数据。

mysql> DROP DATABASE demo;
Query OK, 1 row affected (0.07 sec)

mysql> CREATE DATABASE demo;
Query OK, 1 row affected (0.01 sec)

通过上面的操作，就有了一个空数据库 demo。接下来，就可以恢复数据了。

第五步，从备份恢复数据。

可以通过 mysql 命令来恢复数据，语法结构如下：

mysql -u 用户 -p 密码 数据库名称 < 备份文件

现在我用刚才的备份文件，通过下面的指令来恢复数据：

H:\>mysql -u root -p demo<mybackup.sql
Enter password: *****

这个指令的意思是，在数据库 demo 中执行备份文件“mybackup.sql”中的所有 SQL 操作，这样就可以把 demo 中的数据恢复到备份时的状态了。

我们，来看一下现在商品信息表中的数据：

mysql> SELECT * FROM demo.goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 书 | 89.00 |
+------------+---------+-----------+------------+
1 row in set (0.00 sec)

结果显示，只有一条数据记录“书”，那么，怎么才能把我们备份之后插入的那条数据“笔”给找回呢？这个时候，就要进入下一步，使用二进制日志了。

第六步，从保存增量信息的二进制日志文件“GJTECH-PC-bin.000012”中恢复增量数据。

通过查看二进制日志中的事件，你会发现，写入第二条记录的时候，MySQL 使用了一个事务操作，起始位置是 306，截止位置是 462。这样，就可以用 mysqlbinlog 工具进行数据恢复了。日志名称是“C:\ProgramData\MySQL\MySQL Server 8.0\Data\GJTECH-PC-bin.000012”，读取日志的起始位置是 306。

H:\>mysqlbinlog --start-position=306 "C:\ProgramData\MySQL\MySQL Server 8.0\Data\GJTECH-PC-bin.000012" | mysql -u root -p
Enter password: *****

现在我们查看一下商品信息表，确认一下备份之后插入的商品数据记录是不是恢复回来了。

mysql> SELECT * FROM demo.goodsmaster;
+------------+---------+-----------+------------+
| itemnumber | barcode | goodsname | salesprice |
+------------+---------+-----------+------------+
| 1 | 0001 | 书 | 89.00 |
| 2 | 0002 | 笔 | 3.00 |
+------------+---------+-----------+------------+
2 rows in set (0.00 sec)

结果显示，备份之后插入的记录“笔”也找回来了。

到这里，二进制日志恢复数据的功能我就介绍完了。需要提醒你注意的是，在实际工作中，用二进制日志文件找回数据时经常会遇到问题，主要就是不容易找准起始位置或者截止位置。找早了，会导致数据冲突、重复；找晚了，又会丢失数据。所以，我建议你在数据备份结束之后，把当前的二进制日志位置记录下来，存放在一个安全的地方，这关系到数据恢复的完整性，一定不要怕麻烦。

二进制日志还有一个重要的功能，就是在主从服务器的架构中，把主服务器的操作复制到从服务器。而这个操作要借助中继日志一起完成。

中继日志

中继日志只在主从服务器架构的从服务器上存在。从服务器为了与主服务器保持一致，要从主服务器读取二进制日志的内容，并且把读取到的信息写入本地的日志文件中，这个从服务器本地的日志文件就叫中继日志。然后，从服务器读取中继日志，并根据中继日志的内容对从服务器的数据进行更新，完成主从服务器的数据同步。

搭建好主从服务器之后，中继日志默认会保存在从服务器的数据目录（C:\ProgramData\MySQL\MySQL Server 8.0\Data）下，文件名的格式是：从服务器名 -relay-bin. 序号。

中继日志还有一个索引文件：从服务器名 -relay-bin.index，用来定位当前正在使用的中继日志。

中继日志与二进制日志的格式相同，可以用 mysqlbinlog 工具进行查看。下面是中继日志的一个片段：

SET TIMESTAMP=1618558728/*!*/;
BEGIN
/*!*/;
# at 950
#210416 15:38:48 server id 1  end_log_pos 832 CRC32 0xcc16d651   Table_map: `demo`.`test` mapped to number 91
# at 1000
#210416 15:38:48 server id 1  end_log_pos 872 CRC32 0x07e4047c   Delete_rows: table id 91 flags: STMT_END_F   -- server id 1 是主服务器，意思是主服务器删了一行数据

BINLOG '
CD95YBMBAAAAMgAAAEADAAAAAFsAAAAAAAEABGRlbW8ABHRlc3QAAQMAAQEBAFHWFsw=
CD95YCABAAAAKAAAAGgDAAAAAFsAAAAAAAEAAgAB/wABAAAAfATkBw==
'/*!*/;
# at 1040

这一段的意思是，主服务器（“server id 1”）对表 demo.test 进行了 2 步操作：

定位到表 demo.test 编号是 91 的记录，日志位置是 832；

删除编号是 91 的记录，日志位置是 872。

关于中继日志，有一个很容易踩到的坑。如果从服务器宕机，有的时候为了系统恢复，你要重装操作系统，这样就可能会导致你的服务器名称与之前不同。而中继日志的名称里面是包含从服务器名的。因此，在这种情况下，就可能导致你恢复从服务器的时候，无法从宕机前的中继日志里读取数据，以为是日志文件损坏了，其实是文件名不对了。解决的方法也很简单，只要把从服务器的名称改回之前的名称就可以了。

下面我再介绍一下回滚日志。

回滚日志

回滚日志的作用是进行事务回滚。

当事务执行的时候，回滚日志中记录了事务中每次数据更新前的状态。当事务需要回滚的时候，可以通过读取回滚日志，恢复到指定的位置。另一方面，回滚日志也可以让其他的事务读取到这个事务对数据更改之前的值，从而确保了其他事务可以不受这个事务修改数据的影响。

回滚日志的设置是啥样的呢？我们来学习下相关变量值，包括文件大小、所在的文件夹、是否加密、是否自动截断回收以及是否有独立的表空间等。这些都是我们了解事务回滚的机制的关键。

mysql> SHOW VARIABLES LIKE '%innodb_max_undo_log_size%';
+--------------------------+------------+
| Variable_name | Value |
+--------------------------+------------+
| innodb_max_undo_log_size | 1073741824 |
+--------------------------+------------+
1 row in set, 1 warning (0.00 sec)

变量“innodb_max_undo_log_size”的意思是，单个回滚日志最大可占用 1G 字节存储空间。

下面几个变量定义了回滚日志所在的文件夹、是否加密、是否自动截断回收空间和是否有独立的表空间等。

mysql> SHOW VARIABLES LIKE '%innodb_undo%';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_undo_directory | .\ |  -- 表示回滚日志的存储目录是数据目录。
| innodb_undo_log_encrypt | OFF | -- 表示回滚日志不加密。
| innodb_undo_log_truncate | ON | -- 表示回滚日志是否自动截断回收，前提是设置了独立表空间。
| innodb_undo_tablespaces | 2 |  -- 表示回滚日志有自己的独立表空间，而不是在共享表空间ibdata文件中。
+--------------------------+-------+
4 rows in set, 1 warning (0.00 sec)

这里的结果显示了这 4 个变量的默认值。下面我来分别解释一下。

innodb_undo_directory=.\ ，表示回滚日志的存储目录是数据目录，数据目录的位置可以通过查询变量“datadir”来查看。

innodb_undo_log_encrypt = OFF，表示回滚日志不加密。

innodb_undo_log_truncate = ON，表示回滚日志是否自动截断回收，这个变量有效的前提是设置了独立表空间。

innodb_undo_tablespaces = 2，表示回滚日志有自己的独立表空间，而不是在共享表空间 ibdata 文件中。

下面的截图显示了回滚日志的存储目录，以及在文件夹中的名称等信息：

最后，我来介绍一下 MySQL 的重做日志。

重做日志

重做日志是存储在磁盘上的一种日志文件，主要有 2 个作用。

在系统遇到故障的恢复过程中，可以修复被未完成的事务修改的数据。

MySQL 为了提高数据存取的效率，减少磁盘操作的频率，对数据的更新操作不会立即写到磁盘上，而是把数据更新先保存在内存中，积累到一定程度，再集中进行磁盘读写操作。这样就存在一个问题：一旦出现宕机或者停电等异常情况，内存中保存的数据更新操作可能会丢失。这个时候就可以通过读取重做日志中记录的数据更新操作，把没来得及写到磁盘上的数据更新写到磁盘上，确保数据的完整性。

我们可以通过系统变量的值，了解重做日志所在的文件夹和文件的数量。这些是我们进一步了解系统运行机制的必要条件，有助于我们开发出高效的数据库应用。

mysql> SHOW VARIABLES LIKE '%innodb_log_files_in_group%';
+---------------------------+-------+
| Variable_name | Value |
+---------------------------+-------+
| innodb_log_files_in_group | 2 |
+---------------------------+-------+
1 row in set, 1 warning (0.00 sec)

结果显示，变量 innodb_log_files_in_group 值是 2，表示有 2 个重做日志文件。

需要注意的是，变量 innodb_log_files_in_group 值的取值范围是 1~4，这四个文件分别用于记录不同的操作.

用户创建表的插入操作；

用户创建表的更新和删除操作；

临时表的插入操作；

临时表的更新和删除操作。

那么，为什么在我的电脑上，变量 innodb_log_files_in_group 值是 2 呢？其实这是因为，我只执行了对用户创建表的插入操作和更新删除操作，所以，只用到了 2 个文件。如果我还执行了临时表的插入和更新删除的操作，那么这个变量的值就会变成 4，也就是会有 4 个重做日志文件了。

总结

这节课，我们学习了二进制日志、中继日志、回滚日志和重做日志。

二进制日志：主要用于主从服务器之间的数据同步，以及服务器遇到故障时数据的无损失恢复。

中继日志：就是主从服务器架构中，从服务器用来存放主服务器二进制日志内容的一个中间文件。从服务器通过读取中继日志的内容，来同步主服务器上的操作。

回滚日志：用来存储事务中数据更新前的状态，以便回滚和保持其他事务的数据一致性。

重做日志：是为了确保数值持久性、防止数据更新丢失的一种日志。

在这几种日志中，你一定要格外注意二进制日志的用法。有了它，我们就可以通过数据库的全量备份和二进制日志中保存的增量信息，完成数据库的无损失恢复。不过，我要提醒你的是，如果你遇到数据量大、数据库和数据表很多（比如分库分表的应用）的场景，用二进制日志进行数据恢复，是很有挑战性的，因为起止位置不容易管理。

在这种情况下，一个有效的解决办法是配置主从数据库服务器，甚至是一主多从的架构，把二进制日志文件的内容通过中继日志，同步到从数据库服务器中，这样就可以有效避免数据库故障导致的数据异常等问题。

思考题

下面是一段二进制日志中事件的内容：

mysql> SHOW BINLOG EVENTS IN 'GJTECH-PC-bin.000013';
+----------------------+------+----------------+-----------+-------------+--------------------------------------+
| Log_name             | Pos  | Event_type     | Server_id | End_log_pos | Info                                 |
+----------------------+------+----------------+-----------+-------------+--------------------------------------+
| GJTECH-PC-bin.000013 |  556 | Query          |         1 |         627 | BEGIN                                |
| GJTECH-PC-bin.000013 |  627 | Table_map      |         1 |         696 | table_id: 114 (demo.goodsmaster)     |
| GJTECH-PC-bin.000013 |  696 | Delete_rows    |         1 |         773 | table_id: 114 flags: STMT_END_F      |
| GJTECH-PC-bin.000013 |  773 | Xid            |         1 |         804 | COMMIT /* xid=253 */                 |
| GJTECH-PC-bin.000013 |  804 | Anonymous_Gtid |         1 |         894 | SET @@SESSION.GTID_NEXT= 'ANONYMOUS' |
| GJTECH-PC-bin.000013 |  894 | Query          |         1 |         969 | BEGIN                                |
| GJTECH-PC-bin.000013 |  969 | Table_map      |         1 |        1038 | table_id: 114 (demo.goodsmaster)     |
| GJTECH-PC-bin.000013 | 1038 | Write_rows     |         1 |        1094 | table_id: 114 flags: STMT_END_F      |
| GJTECH-PC-bin.000013 | 1094 | Xid            |         1 |        1125 | COMMIT /* xid=259 */                 |

观察一下，其中包括了哪几个 SQL 数据操作呢？为了从二进制日志中恢复这些操作，我们应该如何设置起始位置和截止位置呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

21 | 数据备份：异常情况下，如何确保数据安全？

你好，我是朱晓峰。今天，我来和你聊一聊数据备份。

数据备份，对咱们技术人员来说十分重要。当成千上万的用户，每天使用我们开发的应用做着他们的日常工作的时候，数据的安全性就不光是你一个人的事了。要是有一天，突然发生了某种意想不到的情况，导致数据库服务器上的数据全部丢失，所有使用这个应用的人都会受到严重影响。

所以，我们必须“未雨绸缪”，及时把数据备份到安全的地方。这样，当突发的异常来临时，我们就能把数据及时恢复回来，就不会造成太大损失。

MySQL 的数据备份有 2 种，一种是物理备份，通过把数据文件复制出来，达到备份的目的；另外一种是逻辑备份，通过把描述数据库结构和内容的信息保存起来，达到备份的目的。逻辑备份这种方式是免费的，广泛得到使用；而物理备份的方式需要收费，用得比较少。所以，这节课我重点和你聊聊逻辑备份。

我还会给你介绍一下 MySQL 中的数据备份工具 mysqldump、数据恢复的命令行客户端工具 mysql，以及数据表中数据导出到文件和从文件导入的 SQL 语句，帮助你提高你所开发的应用中的数据安全性。

如何进行数据备份？

首先，我们来学习下用于数据备份的工具 mysqldump。它总共有三种模式：

备份数据库中的表；

备份整个数据库；

备份整个数据库服务器。

接下来，我就来介绍下这 3 种备份的具体方法。

如何备份数据库中的表？

mysqldump 备份数据库中的表的语法结构是：

mysqldump -h 服务器 -u 用户 -p 密码 数据库名称 [表名称 … ] > 备份文件名称

我简单解释一下这里的核心内容。

“-h”后面跟的服务器名称，如果省略，默认是本机“localhost”。

“-u”后面跟的是用户名。

“-p”后面跟的是密码，如果省略，执行的时候系统会提示录入密码。

我举个小例子，带你看看怎么使用这个工具。

假设数据库 demo 中有 2 个表，分别是商品信息表（demo.goodsmaster）和会员表（demo.membermaster）。

商品信息表：

会员表：

现在，我需要把数据库 demo 备份到文件中，就可以用下面的代码实现：

H:\>mysqldump -u root -p demo goodsmaster membermaster > test.sql
Enter password: *****

这个指令的意思，就是备份本机数据库服务器上 demo 数据库中的商品信息表和会员信息表的所有信息。

备份文件是以文本格式保存的，我们可以用记事本打开，看一下备份的内容：

-- MySQL dump 10.13 Distrib 8.0.23, for Win64 (x86_64)
--
-- Host: localhost Database: demo -- 表示从本地进行备份，数据库是demo
-- ------------------------------------------------------
-- Server version 8.0.23

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Table structure for table `goodsmaster` -- 商品信息表的结构
--

DROP TABLE IF EXISTS `goodsmaster`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `goodsmaster` (
`itemnumber` int NOT NULL,
`barcode` text,
`goodsname` text,
`specification` text,
`unit` text,
`salesprice` decimal(10,2) DEFAULT NULL,
PRIMARY KEY (`itemnumber`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `goodsmaster` -- 商品信息表的内容
--

LOCK TABLES `goodsmaster` WRITE;
/*!40000 ALTER TABLE `goodsmaster` DISABLE KEYS */;
INSERT INTO `goodsmaster` VALUES (1,'0001','书','16开','本',89.00),(2,'0002','笔','10支装','包',5.00),(3,'0003','橡皮',NULL,'个',3.00);
/*!40000 ALTER TABLE `goodsmaster` ENABLE KEYS */;
UNLOCK TABLES;

--
-- Table structure for table `membermaster` -- 会员表的结构
--

DROP TABLE IF EXISTS `membermaster`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `membermaster` (
`id` int NOT NULL AUTO_INCREMENT,
`cardno` char(8) NOT NULL,
`membername` text,
`memberphone` text,
`memberpid` text,
`memberaddress` text,
`sex` text,
`birthday` datetime DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `membermaster` -- 会员表的内容
--

LOCK TABLES `membermaster` WRITE;
/*!40000 ALTER TABLE `membermaster` DISABLE KEYS */;
INSERT INTO `membermaster` VALUES ('10000001','张三','13812345678','110123200001017890','北京','男','2000-01-01 00:00:00',1),('10000002','李四','13512345678','123123199001012356','上海','女','1990-01-01 00:00:00',2);
/*!40000 ALTER TABLE `membermaster` ENABLE KEYS */;
UNLOCK TABLES;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2021-04-11 10:43:04

从这个文件中，我们可以看到，它相当于一个 SQL 执行脚本，里面包括了创建商品信息表和会员表的 SQL 语句，以及把表里的数据插入到这两个表中的 SQL 语句。这样一来，商品信息表和会员信息表的结构信息和全部数据信息就都备份出来了。

下面我来介绍一下备份整个数据库的方法。

如何备份数据库？

mysqldump 备份数据库的语法结构是：

mysqldump -h 服务器 -u 用户 -p 密码 --databases 数据库名称 … > 备份文件名

举个小例子，假设我现在需要对本机的数据库服务器中的 2 个数据库 demo 和 demo1 进行备份，就可以用下面的指令：

H:\>mysqldump -u root -p --databases demo demo1 > test1.sql
Enter password: *****

现在，我们来查看一下备份文件的内容：

-- MySQL dump 10.13 Distrib 8.0.23, for Win64 (x86_64)
--
-- Host: localhost Database: demo
-- ------------------------------------------------------
-- Server version 8.0.23

/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8mb4 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;

--
-- Current Database: `demo` -- 备份数据库demo
--

CREATE DATABASE /*!32312 IF NOT EXISTS*/ `demo` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;

USE `demo`; -- 备份数据库中的表

--
-- Table structure for table `dailystatistics`
--

DROP TABLE IF EXISTS `dailystatistics`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `dailystatistics` (
`id` int NOT NULL AUTO_INCREMENT,
`itemnumber` int DEFAULT NULL,
`quantity` decimal(10,3) DEFAULT NULL,
`actualvalue` decimal(10,2) DEFAULT NULL,
`cost` decimal(10,2) DEFAULT NULL,
`profit` decimal(10,2) DEFAULT NULL,
`profitratio` decimal(10,4) DEFAULT NULL,
`salesdate` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `index_dailystatistic_salesdate` (`salesdate`),
KEY `index_dailystatistic_itemnumber` (`itemnumber`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
/*!40101 SET character_set_client = @saved_cs_client */;

--
-- Dumping data for table `dailystatistics`
--

LOCK TABLES `dailystatistics` WRITE;
/*!40000 ALTER TABLE `dailystatistics` DISABLE KEYS */;
INSERT INTO `dailystatistics` VALUES (15,1,3.000,267.00,100.50,166.50,0.6236,'2020-12-01 00:00:00'),(16,2,2.000,10.00,7.00,3.00,0.3000,'2020-12-01 00:00:00');
/*!40000 ALTER TABLE `dailystatistics` ENABLE KEYS */;
UNLOCK TABLES;

-- 这里省略了其他表的备份语句
--
-- Current Database: `demo1` -- 备份数据库demo1
--

CREATE DATABASE /*!32312 IF NOT EXISTS*/ `demo1` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */;

USE `demo1`;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;

/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;

-- Dump completed on 2021-04-11 11:02:09

可以看到，这个文件里面包含了创建数据库 demo 和 demo1 的 SQL 语句，以及创建数据库中所有表、插入所有表中原有数据的 SQL 语句。

如何备份整个数据库服务器？

mysqldump 备份整个数据库服务器的语法结构是：

mysqldump -h 服务器 -u 用户 -p 密码 --all-databases > 备份文件名

举个小例子，假设我要把本机上整个 MySQL 服务器备份下来，可以用下面的代码：

H:\>mysqldump -u root -p --all-databases > test2.sql
Enter password: *****

这个指令表示，备份本机上运行的 MySQL 数据库服务器的全部内容，包含系统数据库和用户创建的数据库中的库结构信息、表结构信息和表里的数据。这种备份方式会把系统数据库也全部备份出来，而且消耗的资源也比较多，一般来说没有必要，我就不展开细说了。

备份文件有了，如何用它进行数据恢复呢？下面我就来给你介绍下具体的方法。

如何进行数据恢复？

mysqldump 的备份文件包含了创建数据库、数据表，以及插入数据表里原有数据的 SQL 语句，我们可以直接运行这些 SQL 语句，来进行数据恢复。

数据恢复的方法主要有 2 种：

使用“mysql”命令行客户端工具进行数据恢复；

使用“SOURCE”语句进行数据恢复。

使用“mysql”命令行客户端工具，进行数据恢复的命令如下：

H:\>mysql -u root -p demo < test.sql
Enter password: *****

我来简单介绍下这个数据恢复命令。

mysql 是一个命令行客户端工具，可以与 MySQL 服务器之间进行连接，执行 SQL 语句。

“-u”后面跟的是用户。

“-p”后面跟的是密码。

在这个命令里面，我指定了数据库，因为备份文件 test.sql 里面只有数据表的备份信息，需要指定恢复到哪个数据库中。如果使用的备份文件备份的是数据库的信息（比如 test1.sql），或者是整个 MySQL 数据库服务器的信息（比如 test2.sql），则不需要指定数据库。

第二种数据恢复的方法是，使用“SOURCE”语句恢复数据，语法结构如下：

SOURCE 备份文件名

举个小例子，刚才我们对商品信息表和会员信息表进行了备份，现在想用备份的文件进行恢复，就可以用下面的语句：

mysql> USE demo;
Database changed
mysql> SOURCE H:\\test.sql
Query OK, 0 rows affected (0.00 sec)

注意，这里需要先用“USE”语句把当前的数据库变更为 demo，这样商品信息表和会员表才能恢复到正确的数据库里面。否则，可能会恢复错误。

除此之外，你还可以通过这种方式，用整个数据库的备份文件把数据库恢复回来，甚至是用整个数据库服务器的备份文件，恢复整个 MySQL 服务器。

到这里，我们就掌握了备份和恢复整个数据库服务器、数据库和数据库中的表的方法。不过，有的时候，我们只关心表里的数据本身，希望能够把表里的数据，按照一定的格式保存下来。这个时候，mysqldump 就不够用了。所以，接下来我再给你介绍下 MySQL 数据导出和导入的方法。

如何导出和导入表里的数据？

先来学习下怎么把一个表的数据按照一定的格式，导出成一个文件。

SELECT 语句导出数据

使用“SELECT … INTO OUTFILE”语句导出数据表的语法结构是：

SELECT 字段列表 INTO OUTFILE 文件名称
FIELDS TERMINATED BY 字符
LINES TERMINATED BY 字符
FROM 表名;

我来解释下这段代码。

INTO OUTFILE 文件名称，表示查询的结果保存到文件名称指定的文件中；

FIELDS TERMINATED BY 字符，表示列之间的分隔符是“字符”；

LINES TERMINATED BY 字符，表示行之间的分隔符是“字符”。

举个小例子，假设我们要把商品信息表导出到文件 H:\goodsmaster.txt 中，该如何实现呢？按照我刚刚介绍的语法结构来尝试一下：

mysql> SELECT * INTO OUTFILE 'H:\goodsmaster.txt'
-> FIELDS TERMINATED BY ','
-> LINES TERMINATED BY '\n'
-> FROM demo.goodsmaster;
ERROR 1290 (HY000): The MySQL server is running with the --secure-file-priv option so it cannot execute this statement

结果，系统提示错误。其实，这是因为服务器的“secure-file-priv”参数选项，不允许把文件写入到 H:\goodsmaster.txt 中。那怎么解决这个问题呢？

这个时候，我们可以通过 MySQL 的配置文件 my.ini，来查看一下“secure-file-priv”参数的设定，并且按照这个参数设定的要求准备导入文件。

打开 C:\ProgramData\MySQL\MySQL Server 8.0\my.ini，找到“secure-file-priv”参数设定，如下所示：

# Secure File Priv.
secure-file-priv="C:/ProgramData/MySQL/MySQL Server 8.0/Uploads"

这个意思是说，只能把数据导出到“C:/ProgramData/MySQL/MySQL Server 8.0/Uploads”这个文件夹中，所以，如果我们把数据导出到 H:\goodsmaster.txt 中，就违反了系统参数的设定，导致发生错误。

现在，我们来修改一下数据导出的 SQL 语句，把导出文件的路径改到系统要求的文件目录，看看结果如何：

mysql> SELECT * INTO OUTFILE 'C:/ProgramData/MySQL/MySQL Server 8.0/Uploads/goodsmaster.txt'
-> FIELDS TERMINATED BY ','
-> LINES TERMINATED BY '\n'
-> FROM demo.goodsmaster;
Query OK, 3 rows affected (0.00 sec)

结果显示，执行成功了。下面我们来看一下结果文件的内容：

1,0001,书,16开,本,89.00
2,0002,笔,10支装,包,5.00
3,0003,橡皮,\N,个,3.00

很显然，这很符合我们希望的导出格式：行与行之间用回车“\n”分隔，列与列之间用逗号“,”分隔。

到这里，我们就知道怎么把数据表中的数据按照一定的格式导出到文件了。那在实际工作中，我们还经常需要把一定格式的数据从文件中导入到数据表中。

“LOAD DATA”语句，就是 MySQL 提供的一种快速数据读入的方法，在实际工作中常用于大量数据的导入，效率极高。

使用“LOAD DATA”语句导入数据

“LOAD DATA”是与“SELECT … INTO OUTFILE”相对应的数据导入语句。语句结构是：

LOAD DATA INFILE 文件名
INTO TABLE 表名
FIELDS TERMINATED BY 字符
LINES TERMINATED BY 字符;

我举个小例子来演示一下“LOAD DATA”语句是如何工作的。

还是以我们刚才导出的那个文件 goodsmaster.txt 为例，现在我们把这个文件内的数据导入到商品信息表（demo.goodsmaster）中去。

为了演示方便，我会先把 demo.goodsmaster 中的数据先删除，然后使用“LOAD DATA”语句，把刚才的导出文件 goodsmaster.txt 的内容导入进来，再与删除之前的数据进行对比，来验证“LOAD DATA”语句的执行效果。

首先，我们把商品信息表中的数据删除：

mysql> DELETE FROM demo.goodsmaster
-> WHERE itemnumber>0;
Query OK, 3 rows affected (0.03 sec)

然后，我们尝试把文件 goodsmaster.txt 中的数据导入进来：

mysql> LOAD DATA INFILE 'C:/ProgramData/MySQL/MySQL Server 8.0/Uploads/goodsmaster.txt'
-> INTO TABLE demo.goodsmaster
-> FIELDS TERMINATED BY ','
-> LINES TERMINATED BY '\n';
Query OK, 3 rows affected (0.02 sec)
Records: 3 Deleted: 0 Skipped: 0 Warnings: 0

结果显示，导入成功了。我们再查看一下数据表中的内容：

mysql> SELECT * FROM demo.goodsmaster;
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 |
| 2 | 0002 | 笔 | 10支装 | 包 | 5.00 |
| 3 | 0003 | 橡皮 | NULL | 个 | 3.00 |
+------------+---------+-----------+---------------+------+------------+
3 rows in set (0.00 sec)

结果显示，与我们删除之前的数据完全一致。这说明，“LOAD DATA”语句成功导入了数据文件 goodsmaster.txt 中的数据。

总结

今天，我们重点学习了数据备份，包括数据备份的工具 mysqldump，以及用命令行客户端工具“mysql”和 SQL 语句“SOURCE”进行数据恢复的方法。同时，我还给你介绍了用于导出数据表中数据的语句“SELECT … INTO OUTFILE”和导入的语句“LOAD DATA”。这些都是你在备份数据时必不可少的，对确保数据的安全性至关重要。

最后提醒你一点，“LOAD DATA”是很好用的工具，因为它的数据导入速度是非常惊人的。一个 400 万条数据的文件，用“LOAD DATA”语句，只需要几分钟就可以完成，而其他的方法，比如使用 Workbench 来导入数据，就需要花费好几个小时。

思考题

这节课，我介绍了数据导出语句“SELECT … INTO OUTFILE”，并且在第一次数据导出时遇到了一个系统参数“secure-file-priv”设定的目录与导出文件目录不一致，从而导致导出失败的问题。最后，我通过修改导出文件的文件夹，解决了这个问题。

我想请你思考一下，如果还是想把导出文件保存到 H:\目录下，有没有办法实现呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

22 | 范式：如何消除冗余和高效存取？

你好，我是朱晓峰。今天，我们来聊一聊数据表设计的范式。

在超市项目的设计阶段，超市经营者把他们正在用的 Excel 表格给到我们，要求我们把这些数据存储到超市管理系统的数据库中。为了方便你理解，我挑选了 1 个有代表性的表来举例说明。

进货单表（import）：

这个表中的字段很多，包含了各种信息，表里的数据量也很惊人。我们刚拿到这个表的时候，光是打开表这个操作，就需要等十几秒。

仔细一看，发现表里重复的数据非常多：比如第一行和第二行的数据，同样是 3478 号单据，供货商编号、供货商名称和仓库，这 3 个字段的信息完全相同。可是这 2 条数据的后半部分又不相同，因此，并不能认为它们是冗余数据而删除。

其实，造成这种问题的原因是这张表的设计非常不合理，大量重复导致表变得庞大，效率极低。

在我们的工作场景中，这种由于数据表结构设计不合理，而导致的数据重复的现象并不少见，往往是系统虽然能够运行，承载能力却很差，稍微有点流量，就会出现内存不足、CUP 使用率飙升的情况，甚至会导致整个项目失败。

所以，高效可靠的设计是提升数据库工作效率的关键。那该怎么设计呢？有没有什么可以参考的设计规范呢？自然是有了。

接下来，我就带你重新设计一下刚刚的进货单表，在这个过程中给你具体介绍一下数据表设计的三大范式，分别是第一范式（1NF）、第二范式（2NF）和第三范式（3NF），这些范式可以帮助我们设计出简洁高效的数据表，进而提高系统的效率。

我先来介绍一下最基本的第一范式。

第一范式

我们对这张进货单表重新设计的第一步，就是要把所有的列，也就是字段，都确认一遍，确保每个字段只包含一种数据。如果各种数据都混合在一起，就无法通过后面的拆解，把重复的数据去掉。

其实，这就是第一范式所要求的：所有的字段都是基本数据字段，不可进一步拆分。

在我们的这张表里，“property”这一字段可以继续拆分。其他字段已经都是基本数据字段，不能再拆了。

经过优化，我们把“property”这一字段，拆分成“specification（规格）”和“unit（单位）”，这 2 个字段如下：

这样处理之后，字段多了一个，但是每一个字段都成了不可拆分的最小信息单元，我们就可以在这个表的基础之上，着手进行进一步的优化了。这就要用到数据表设计的第二范式了。

第二范式

通过观察，我们可以发现，这个表的前 2 条记录的前 4 个字段完全一样。那可不可以通过拆分，把它们变成一条记录呢？当然是可以的，而且为了优化，必须要进行拆分。

具体怎么拆分呢？第二范式就告诉了我们拆分的原则。

第二范式要求，在满足第一范式的基础上，还要满足数据表里的每一条数据记录，都是可唯一标识的。而且所有字段，都必须完全依赖主键，不能只依赖主键的一部分。

根据这个要求，我们可以对表进行重新设计。

重新设计的第一步，就是要确定这个表的主键。通过观察发现，字段“listnumber”+“barcode”可以唯一标识每一条记录，可以作为主键。确定好了主键以后，我们判断一下，哪些字段完全依赖主键，哪些字段只依赖于主键的一部分。同时，把只依赖于主键一部分的字段拆分出去，形成新的数据表。

首先，进货单明细表里面的“goodsname”“specification”“unit”这些信息是商品的属性，只依赖于“barcode”，不完全依赖主键，可以拆分出去。我们把这 3 个字段加上它们所依赖的字段“barcode”，拆分形成一个新的数据表“商品信息表”。

这样一来，原来的数据表就被拆分成了两个表。

商品信息表：

进货单表：

同样道理，字段“supplierid”“suppliername”“stock”只依赖于“listnumber”，不完全依赖于主键，所以，我们可以把“supplierid”“suppliername”“stock”这 3 个字段拆出去，再加上它们依赖的字段“listnumber”，就形成了一个新的表“进货单头表”。剩下的字段，会组成新的表，我们叫它“进货单明细表”。

这样一来，原来的数据表就拆分成了 3 个表。

进货单头表：

进货单明细表：

商品信息表：

到这里，我们就按照第二范式的要求，把原先的一个数据表拆分成了 3 个数据表。

现在，我们再来分析一下拆分后的 3 个表，保证这 3 个表都满足第二范式的要求。

在“商品信息表”中，字段“barcode”是有可能存在重复的，比如，用户门店可能有散装称重商品和自产商品，会存在条码共用的情况。所以，所有的字段都不能唯一标识表里的记录。这个时候，我们必须给这个表加上一个主键，比如说是自增字段“itemnumber”。

现在，我们就可以把进货单明细表里面的字段“barcode”都替换成字段“itemnumber”，这就得到了新的进货单明细表和商品信息表。

进货单明细表：

商品信息表：

这样一来，我们拆分后的 3 个数据表中的数据都不存在重复，可以唯一标识。而且，表中的其他字段，都完全依赖于表的主键，不存在部分依赖的情况。所以，拆分后的 3 个数据表就全部满足了第二范式的要求。

第三范式

如果你仔细看的话，会发现，我们的进货单头表，还有数据冗余的可能。因为“suppliername”依赖“supplierid”。那么，这个时候，就可以按照第三范式的原则进行拆分了。

第三范式要求数据表在满足第二范式的基础上，不能包含那些可以由非主键字段派生出来的字段，或者说，不能存在依赖于非主键字段的字段。

在刚刚的进货单头表中，字段“suppliername”依赖于非主键字段“supplierid”。因此，这个表不满足第三范式的要求。

那接下来，我们就进一步拆分下进货单头表，把它拆解成供货商表和进货单头表。

供货商表：

进货单头表：

这样一来，供货商表和进货单头表中的所有字段，都完全依赖于主键，不存在任何一个字段依赖于非主键字段的情况了。所以，这 2 个表就都满足第三范式的要求了。

但是，在进货单明细表中，quantity * importprice = importvalue，“importprice”“quantity”和“importvalue”这 3 个字段，可以通过任意两个计算出第三个来，这就存在冗余字段。如果严格按照第三范式的要求，现在我们应该进行进一步优化。优化的办法是删除其中一个字段，只保留另外 2 个，这样就没有冗余数据了。

可是，真的可以这样做吗？要回答这个问题，我们就要先了解下实际工作中的业务优先原则。

业务优先的原则

所谓的业务优先原则，就是指一切以业务需求为主，技术服务于业务。完全按照理论的设计不一定就是最优，还要根据实际情况来决定。这里我们就来分析一下不同选择的利与弊。

对于 quantity * importprice = importvalue，看起来“importvalue”似乎是冗余字段，但并不会导致数据不一致。可是，如果我们把这个字段取消，是会影响业务的。

因为有的时候，供货商会经常进行一些促销活动，按金额促销，那他们拿来的进货单只有金额，没有价格。而“importprice”反而是通过“importvalue”÷“quantity”计算出来的。因此，如果不保留“importvalue”字段，只有“importprice”和“quantity”的话，经过四舍五入，会产生较大的误差。这样日积月累，最终会导致查询结果出现较大偏差，影响系统的可靠性。

我借助一个例子来说明下为什么会有偏差。

假设进货金额是 25.5 元，数量是 34，那么进货价格就等于 25.5÷34=0.74 元，但是如果用这个计算出来的进货价格来计算进货金额，那么，进货金额就等于 0.74×34=25.16 元，其中相差了 25.5-25.16=0.34 元。代码如下所示：

“importvalue”=25.5元，“quantity”=34，“importprice”=25.5÷34=0.74
“importprice”=0.74元，“quantity”=34，“importvalue”=0.74*34=25.16
误差 = 25.5 - 25.16 = 0.34

现在你知道了，在我们这个场景下，“importvalue”是必须要保留的。

那么，换一种思路，如果我们保留“quantity”和“importvalue”，取消“importprice”，这样不是既能节省存储空间，又不会影响精确度吗？

其实不是的。“importprice”是系统的核心指标，涉及成本核算。几乎所有的财务、营运和决策支持模块，都要涉及到成本问题，如果取消“importprice”这个字段，那么系统的运算成本、开发和运维成本，都会大大提高，得不偿失。

所以，本着业务优先的原则，在不影响系统可靠性的前提下，可以容忍一定程度的数据冗余，保留“importvalue”“importprice”和“quantity"。

因此，最后的结果是，我们可以把进货单表拆分成下面的 4 个表：

供货商表：

进货单头表：

进货单明细表：

商品信息表：

这样一来，我们就避免了冗余数据，而且还能够满足业务的需求，这样的数据表设计，才是合格的设计。

总结

今天，我们通过具体案例的分析，学习了 MySQL 数据库设计的范式规范，包括第一范式、第二范式和第三范式。

我再给你汇总下 MySQL 数据库规范化设计的三个范式。

第一范式：数据表中所有字段都是不可拆分的基本数据项。

第二范式：在满足第一范式的基础上，数据表中所有非主键字段，必须完全依赖全部主键字段，不能存在部分依赖主键字段的字段。

第三范式：在满足第二范式的基础上，数据表中不能存在可以被其他非主键字段派生出来的字段，或者说，不能存在依赖于非主键字段的字段。

遵循范式的要求，可以减少冗余，结合外键约束，可以防止添加、删除、修改数据时产生数据的不一致问题。

除此之外，我还给你解释了为什么有的时候不能简单按照规范要求设计数据表，因为有的数据看似冗余，其实对业务来说十分重要。这个时候，我们就要遵循业务优先的原则，首先满足业务需求，在这个前提下，再尽量减少冗余。

一般来说，MySQL 的数据库设计满足第三范式，就足够了。不过，第三范式，并不是终极范式，还有BCNF 范式（也叫 BC 范式）、第四范式和第五范式。如果你想进一步研究数据库设计，课下可以看下我分享的链接，拓展下思路。

思考题

假设有这样一个销售流水表，如下所示：

这个表存在数据冗余，应该如何优化设计呢？为什么？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

23 | ER模型：如何理清数据库设计思路？

你好，我是朱晓峰。

在超市项目的数据库设计阶段，超市经营者交给我们一大堆 Excel 表格。我们需要把这些表格的数据都整理清楚，并且按照一定的规则存储起来，从而进行高效的管理。

比如，当时我们有这样一张进货表：

为了提高数据存储的效率，我们按照第三范式的原则进行拆分，这样就得到了 4 个表，分别是供货商表、进货单头表、进货单明细表和商品信息表。

供货商表：

进货单头表：

进货单明细表：

商品信息表：

其中，商品信息表、供货商表和进货单头表都满足第三范式的原则，进货单明细表虽然不满足第三范式的原则，但是满足第二范式的要求，而且保留的冗余字段也是基于业务优先的原则保留的。因此，超市经营者给我们提供的进货单表，经过我们的拆解，已经是存取效率最佳的方案了。在进货管理这个局部模块中，是最优的数据库设计方案。

但是，当我们按照这样的方式拆分一连串数据表时，却发现越拆越多，而且支离破碎。事实上，局部最优的表，不仅有可能存在进一步拆分的情况，还有可能会出现数据缺失。

毕竟，数据库设计是牵一发而动全身的。那有没有什么办法提前看到数据库的全貌呢？比如需要哪些数据表、数据表中应该有哪些字段，数据表与数据表之间有什么关系、通过什么字段进行连接，等等。这样我们才能进行整体的梳理和设计。

其实，ER 模型就是一个这样的工具。ER 模型也叫作实体关系模型，是用来描述现实生活中客观存在的事物、事物的属性，以及事物之间关系的一种数据模型。在开发基于数据库的信息系统的设计阶段，通常使用 ER 模型来描述信息需求和信息特性，帮助我们理清业务逻辑，从而设计出优秀的数据库。

今天，我还是借助实际案例，带你使用 ER 模型分析一下超市的业务流程，具体给你讲一讲怎么通过 ER 模型来理清数据库设计的思路，从而设计出优秀的数据库。

在使用之前，咱们得先知道 ER 模型里都包括啥。

ER 模型包括哪些要素？

在 ER 模型里面，有三个要素，分别是实体、属性和关系。

实体，可以看做是数据对象，往往对应于现实生活中的真实存在的个体。比如，这个连锁超市就可以看做一个实体。在 ER 模型中，用矩形来表示。实体分为两类，分别是强实体和弱实体。强实体是指不依赖于其他实体的实体；弱实体是指对另一个实体有很强的依赖关系的实体。

属性，则是指实体的特性。比如超市的地址、联系电话、员工数等。在 ER 模型中用椭圆形来表示。

关系，则是指实体之间的联系。比如超市把商品卖给顾客，就是一种超市与顾客之间的联系。在 ER 模型中用菱形来表示。

需要注意的是，有的时候，实体和属性不容易区分。比如刚刚商品信息表中的商品的单位，到底是实体还是属性呢？如果从进货的角度出发，单位是商品的属性，但是从超市信息系统的整体出发，单位可以看做一个实体。

那么，该如何区分实体和属性呢？

我给你提供一个原则：我们要从系统整体的角度出发去看，可以独立存在的是实体，不可再分的是属性。也就是说，属性不需要进一步描述，不能包含其他属性。

在 ER 模型的 3 个要素中，关系又可以分为 3 种类型，分别是 1 对 1、1 对多和多对多。

1 对 1：指实体之间的关系是一一对应的，比如个人与身份证信息之间的关系就是 1 对 1 的关系。一个人只能有一个身份证信息，一个身份证信息也只属于一个人。

1 对多：指一边的实体通过关系，可以对应多个另外一边的实体。相反，另外一边的实体通过这个关系，则只能对应唯一的一边的实体。比如超市与超市里的收款机之间的从属关系，超市可以拥有多台收款机，但是每一条收款机只能从属于一个超市。

多对多：指关系两边的实体都可以通过关系对应多个对方的实体。比如在进货模块中，供货商与超市之间的关系就是多对多的关系，一个供货商可以给多个超市供货，一个超市也可以从多个供货商那里采购商品。

知道了这些要素，我们就可以给超市业务创建 ER 模型了，如下图所示：

我来简单解释一下这个图。

在这个图中，供货商和超市之间的供货关系，两边的数字都不是 1，表示多对多的关系。同样，超市和顾客之间的零售关系，也是多对多的关系。

这个 ER 模型，包括了 3 个实体之间的 2 种关系：

超市从供货商那里采购商品；

超市把商品卖给顾客。

有了这个 ER 模型，我们就可以从整体上理解超市的业务了。但是，这里没有包含属性，这样就无法体现实体和关系的具体特征。现在，我们需要把属性加上，用椭圆来表示，这样我们得到的 ER 模型就更加完整了。

ER 模型的细化

刚刚的 ER 模型展示了超市业务的框架，但是只包括了供货商、超市和顾客这三个实体，以及它们之间的关系，还不能对应到具体的表，以及表与表之间的关联。

因此，我们需要进一步去设计一下这个 ER 模型的各个局部，也就是细化下超市的具体业务流程，然后把它们综合到一起，形成一个完整的 ER 模型。这样可以帮助我们理清数据库的设计思路。

我们刚才的超市业务模型，包括了两个模块，分别是进货模块和销售模块。下面我们分别对这 2 个模块进行细化。

首先，我们来看一下超市业务中的进货模块的 ER 模型，整理一下其中包含哪些实体、哪些关系和哪些属性。

在我们的进货模块里，有 5 个实体：

供货商

商品

门店

仓库

员工

其中，供货商、商品和门店是强实体，因为它们不需要依赖其他任何实体。而仓库和员工是弱实体，因为它们虽然都可以独立存在，但是它们都依赖门店这个实体，因此都是弱实体。

接下来，我们再分析一下各个实体都有哪些属性。

供货商：名称、地址、电话、联系人。

商品：条码、名称、规格、单位、价格。

门店：编号、地址、电话、联系人。

仓库：编号、名称。

员工：工号、姓名、住址、电话、身份证号、职位。

这样细分之后，我们就可以重新设计进货模块了，ER 模型如下：

需要注意的是，这里我是用粗框矩形表示弱实体，用粗框菱形，表示弱实体与它依赖的强实体之间的关系。

第二步，我们再分析一下零售模块。

经过分析，我们发现，在超市的业务流程中，零售业务包括普通零售和会员零售两种模式。普通零售包含的实体，包括门店、商品和收银款台；会员零售包含的实体，包括门店、商品、会员和收银款台。

这样我们就可以提炼出零售业务模块中的实体：

商品

门店

会员

收银款台。

其中，商品和门店不依赖于任何其他实体，所以是强实体；会员和收银款台都依赖于门店，所以是弱实体。

有了实体之后，我们就可以确定实体的属性了。

商品：条码、名称、规格、单位、价格。

会员：卡号、发卡门店、名称、电话、身份证、地址、积分、储值。

门店：编号、地址、电话、联系人。

收银款台：编号、名称。

现在，我们就可以重新设计零售模块的 ER 模型了：

现在，我们把这两个图整合到一起，形成一个完整的 ER 模型：

如何把 ER 模型图转换成数据表？

通过绘制 ER 模型，我们已经理清了业务逻辑，现在，我们就要进行非常重要的一步了：把绘制好的 ER 模型，转换成具体的数据表。

我来介绍下转换的原则。

一个实体通常转换成一个数据表；

一个多对多的关系，通常也转换成一个数据表；

一个 1 对 1，或者 1 对多的关系，往往通过表的外键来表达，而不是设计一个新的数据表；

属性转换成表的字段。

好了，下面我就结合前面的表格，给你具体讲解一下怎么运用这些转换的原则，把 ER 模型转换成具体的数据表，从而把抽象出来的数据模型，落实到具体的数据库设计当中。

一个实体转换成一个数据表

先来看一下强实体转换成数据表。

供货商实体转换成供货商表（demo.supplier）的代码如下所示：

mysql> CREATE TABLE demo.supplier
-> (
-> -- 我们给它添加一个与业务无关的字段“supplierid”为主键，并且设置自增约束。
-> supplierid INT PRIMARY KEY AUTO_INCREMENT,
-> suppliername TEXT,
-> address TEXT,
-> phone TEXT
-> );
Query OK, 0 rows affected (0.06 sec)

商品实体转换成商品表（demo.goodsmaster）：

mysql> CREATE TABLE demo.goodsmaster
-> (
-> --我们给商品信息表添加一个与业务无关的字段“itemnumber”为主键，采用手动赋值的方式，原因是可能存在多个门店录入新品，导致冲突的情况
-> itemnumber INT PRIMARY KEY,
-> barcode TEXT,
-> goodsname TEXT,
-> specification TEXT,
-> unit TEXT,
-> salesprice DECIMAL(10,2)
-> );
Query OK, 0 rows affected (0.07 sec)

门店实体转换成门店表（demo.branch）：

mysql> CREATE TABLE demo.branch
-> (
-> -- 增加一个与业务无关的字段为主键，并且设置自增约束
-> branchid INT PRIMARY KEY AUTO_INCREMENT,
-> branchno TEXT,
-> branchname TEXT,
-> address TEXT,
-> phone TEXT,
-> contacter TEXT
-> );
Query OK, 0 rows affected (0.06 sec)

下面我们再把弱实体转换成数据表。

仓库转换成仓库表（demo.stock）：

mysql> CREATE TABLE demo.stock
-> (
-> --添加与业务无关的自增约束字段为主键
-> stockid INT PRIMARY KEY AUTO_INCREMENT,
-> -- 仓库是弱实体，依赖于强实体门店表，所以要把门店表的主键字段包括进来，作为与门店表关联的外键
-> branchid INT NOT NULL,
-> stockno TEXT NOT NULL,
-> stockname TEXT NOT NULL,
-> -- 设置外键约束，与门店表关联
-> CONSTRAINT fk_stock_branch FOREIGN KEY (branchid) REFERENCES branch (branchid)
-> );
Query OK, 0 rows affected (0.07 sec)

收银款台实体转换成收银款台表（demo.cashier）：

mysql> CREATE TABLE demo.cashier
-> (
-> -- 添加与业务无关的自增字段为主键
-> cashierid INT PRIMARY KEY AUTO_INCREMENT,
-> -- 收银款台是弱实体，依赖于强实体门店表，所以要把门店表的主键字段包括进来，所为与门店表关联的外键
-> branchid INT NOT NULL,
-> cashierno TEXT NOT NULL,
-> cashiername TEXT NOT NULL,
-> -- 设置外键约束，与门店表关联
-> CONSTRAINT fk_cashier_branch FOREIGN KEY (branchid) REFERENCES branch (branchid)
-> );
Query OK, 0 rows affected (0.07 sec)

员工实体转换成员工表（demo.operator）：

mysql> CREATE TABLE demo.operator
-> (
-> -- 添加与业务无关的自增字段为主键
-> operatorid INT PRIMARY KEY AUTO_INCREMENT,
-> -- 员工是弱实体，依赖于强实体门店表，所以要把门店表的主键字段包括进来，所为与门店表关联的外键
-> branchid INT NOT NULL,
-> workno TEXT NOT NULL,
-> operatorname TEXT NOT NULL,
-> phone TEXT,
-> address TEXT,
-> pid TEXT,
-> duty TEXT,
-> -- 设置外键约束，与门店表关联
-> CONSTRAINT fk_operator_branch FOREIGN KEY (branchid) REFERENCES branch (branchid)
-> );
Query OK, 0 rows affected (0.11 sec)

会员实体转换成会员表（demo.membermaster）：

mysql> CREATE TABLE demo.membermaster
-> (
-> -- 添加与业务无关的自增字段为主键
-> memberid INT PRIMARY KEY,
-> -- 会员是弱实体，依赖于强实体门店表，所以要把门店表的主键字段包括进来，所为与门店表关联的外键
-> branchid INT NOT NULL,
-> cardno TEXT NOT NULL,
-> membername TEXT,
-> address TEXT,
-> phone TEXT,
-> pid TEXT,
-> -- 设置默认约束，积分默认为0
-> memberpoints DECIMAL(10,1) NOT NULL DEFAULT 0,
-> -- 设置默认约束，储值默认为0
-> memberdeposit DECIMAL(10,2) NOT NULL DEFAULT 0,
-> -- 设置外键约束，与门店表关联
-> CONSTRAINT fk_member_branch FOREIGN KEY (branchid) REFERENCES branch (branchid)
-> );
Query OK, 0 rows affected (0.07 sec)

一个多对多的关系转换成一个数据表

这个 ER 模型中的多对多的关系有 2 个，分别是零售和进货。我们分别设计一个独立的表来表示，这种表一般称为中间表。

我们可以设计一个独立的进货单表，来代表进货关系。这个表关联到 4 个实体，分别是供货商、商品、仓库、员工，所以，表中必须要包括这 4 个实体转换成的表的主键。除此之外，我们还要包括进货关系自有的属性：进货时间、供货数量和进货价格。

按照数据表设计的第三范式的要求和业务优先的原则，我们把这个进货单表拆分成 2 个表，分别是进货单头表和进货单明细表：

CREATE TABLE demo.importhead
(
importid INT PRIMARY KEY,   -- 添加与业务无关的字段为主键
listnumber TEXT NOT NULL,
supplierid INT NOT NULL,    -- 供货商表的主键，反映了参与进货关系的供货商信息
stockid INT NOT NULL,       -- 仓库表的主键，反映了参与进货关系的参考信息
operatorid INT NOT NULL,    -- 员工表的主键，反映了参与进货关系的员工信息
recordingdate DATETIME NOT NULL,
totalquantity DECIMAL(10,3) NOT NULL DEFAULT 0,
totalvalue DECIMAL(10,3) NOT NULL DEFAULT 0,
CONSTRAINT fk_importhead_supplier FOREIGN KEY (supplierid) REFERENCES supplier (supplierid),
CONSTRAINT fk_importhead_stock FOREIGN KEY (stockid) REFERENCES stock (stockid),
CONSTRAINT fk_importhead_operator FOREIGN KEY (operatorid) REFERENCES operator (operatorid)
);
CREATE TABLE demo.importdetails
(
importid INT,
itemnumber INT,              -- 商品表的主键，反映了参与进货关系的商品信息
importquantity DECIMAL(10,3) NOT NULL DEFAULT 0,
importprice DECIMAL(10,2) NOT NULL DEFAULT 0,
importvalue DECIMAL(10,2) NOT NULL DEFAULT 0,
PRIMARY KEY (importid,itemnumber),
CONSTRAINT fk_importdetails_goodsmaster FOREIGN KEY (itemnumber) REFERENCES goodsmaster (itemnumber)
);

对于零售关系，我们可以设计一张流水单来表示。

这个表关联 4 个实体，分别是收银款台、商品、会员和员工。所以，表中也必须要包括这 4 个实体转换成的表的主键。除此之外，表中还要包括进货关系自有的属性：交易时间、数量、价格等。

按照数据表设计的第三范式的要求，我们把这个流水单表拆分成 2 个表，分别是流水单头表和流水单明细表：

CREATE TABLE demo.transactionhead
(
transactionid INT PRIMARY KEY,   -- 添加与业务无关的字段为主键
transactionno TEXT NOT NULL,
cashierid INT NOT NULL,          -- 收款机表的主键，反映了参与零售关系的收款机信息
memberid INT,                    -- 会员表的主键，反映了参与零售关系的会员的信息
operatorid INT NOT NULL,         -- 员工表的主键，反映了参与零售关系的员工信息
transdate DATETIME NOT NULL,
CONSTRAINT fk_transactionhead_cashier FOREIGN KEY (cashierid) REFERENCES cashier (cashierid),
CONSTRAINT fk_transactionhead_member FOREIGN KEY (memberid) REFERENCES member (memberid),
CONSTRAINT fk_transactionhead_operator FOREIGN KEY (operatorid) REFERENCES operator (operatorid)
);
CREATE TABLE demo.transactiondetails
(
transactionid INT,
itemnumber INT,                 -- 商品表的主键，反映了参与零售关系的商品信息
quantity DECIMAL(10,3) NOT NULL DEFAULT 0,
price DECIMAL(10,2) NOT NULL DEFAULT 0,
salesvalue DECIMAL(10,2) NOT NULL DEFAULT 0,
PRIMARY KEY (transactionid,itemnumber),
CONSTRAINT fk_transactiondetails_goodsmaster FOREIGN KEY (itemnumber) REFERENCES goodsmaster (itemnumber)
);

通过外键来表达 1 对多的关系

在上面的表的设计中，我们已经完成了用外键来表达 1 对多的关系。比如：在流水单头表中，我们分别把 cashierid、memberid 和 operatorid 定义成了外键：

CONSTRAINT fk_transactionhead_cashier FOREIGN KEY (cashierid) REFERENCES cashier (cashierid),
CONSTRAINT fk_transactionhead_member FOREIGN KEY (memberid) REFERENCES member (memberid),
CONSTRAINT fk_transactionhead_operator FOREIGN KEY (operatorid) REFERENCES operator (operatorid)

在流水单明细表中，我们把商品编号定义成了外键：

CONSTRAINT fk_transactiondetails_goodsmaster FOREIGN KEY (itemnumber) REFERENCES goodsmaster (itemnumber)

把属性转换成表的字段

在刚刚的设计中，我们也完成了把属性都转换成了表的字段，比如把商品属性（包括条码、名称、规格、单位、价格）转换成了商品信息表中的字段：

mysql> CREATE TABLE demo.goodsmaster
-> (
-> --我们给商品信息表添加一个与业务无关的字段“itemnumber”为主键，采用手动赋值的方式，原因是可能存在多个门店录入新品，导致冲突的情况
-> itemnumber INT PRIMARY KEY,
-> barcode TEXT,                -- 条码属性
-> goodsname TEXT,              -- 名称属性
-> specification TEXT,          -- 规格属性
-> unit TEXT,                   -- 单位属性
-> salesprice DECIMAL(10,2)     -- 价格属性
-> );
Query OK, 0 rows affected (0.07 sec)

这样，我们就完成了 ER 模型到 MySQL 数据表的转换。

到这里，我们通过创建超市项目业务流程的 ER 模型，再把 ER 模型转换成具体的数据表的过程，完成了利用 ER 模型设计超市项目数据库的工作。

其实，任何一个基于数据库的应用项目，都可以通过这种先建立 ER 模型，再转换成数据表的方式，完成数据库的设计工作。ER 模型是一种工具。创建 ER 模型不是目的，目的是把业务逻辑梳理清楚，设计出优秀的数据库。我建议你不是为了建模而建模，要利用创建 ER 模型的过程来整理思路，这样创建 ER 模型才有意义。

总结

今天，我们学习了通过绘制 ER 模型图理清业务逻辑，以及怎么把 ER 模型转换成 MySQL 数据表，最终完成项目数据库设计。

这节课的知识点比较多，我用一张图来帮你回顾下重点。

最后，我还想再提醒你一下，ER 模型看起来比较麻烦，但是对我们把控项目整体非常重要。如果你只是开发一个小应用，或许简单设计几个表够用了，一旦要设计有一定规模的应用，在项目的初始阶段，建立完整的 ER 模型就非常关键了。开发应用项目的实质，其实就是建模。胸中有丘壑，才能下笔如有神。道理其实是一样的。

思考题

超市经营者每个月都要进行库房盘点，也就是在一个月的最后一天的营业结束之后，所有的员工一起把库房里的货品都数一遍，然后跟电脑上的库存比对，查看库存损耗。

我想请你思考一下，在这个业务模块中，涉及了哪些实体、属性和关系？另外，请你设计一下 ER 模型，通过它来整理一下数据库设计的思路。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

24 | 查询有点慢，语句该如何写？

你好，我是朱晓峰。这节课，我想和你聊一聊怎么对查询语句进行调优。

你肯定遇到过这样的情况：你写的 SQL 语句执行起来特别慢，要等好久才出结果，或者是干脆就“死”在那里，一点反应也没有。一旦遇到这种问题，你就要考虑进行优化了。

如果你开发过数据库应用，肯定会有这样的体会：让应用运行起来不难，但是要运行得又快又好，就没那么不容易了。这很考验我们的内功。

而要想提高应用的运行效率，你就必须掌握优化查询的方法。今天，我就给你讲一下 MySQL 的查询分析语句和 2 种优化查询的方法。

查询分析语句

虽然 MySQL 的查询分析语句并不能直接优化查询，但是却可以帮助你了解 SQL 语句的执行计划，有助于你分析查询效率低下的原因，进而有针对性地进行优化。查询分析语句的语法结构是：

{ EXPLAIN | DESCRIBE | DESC }查询语句;

下面我借助一个小例子，给你详细地讲解一下，怎么使用查询分析语句，来分析一个查询的执行计划。

假设有一个销售流水表（demo.trans），里面有 400 万条数据，如下所示：

现在，我要查询一下商品编号是 1 的商品，在 2020 年 6 月 18 日上午 9 点到 12 点之间的销售明细。代码如下所示：

mysql> SELECT itemnumber,quantity,price,transdate
-> FROM demo.trans
-> WHERE itemnumber=1
-> AND transdate>'2020-06-18 09:00:00'
-> AND transdate<'2020-06-18 12:00:00';
+------------+----------+-------+---------------------+
| itemnumber | quantity | price | transdate |
+------------+----------+-------+---------------------+
| 1 | 0.276 | 70.00 | 2020-06-18 11:04:00 |
| 1 | 1.404 | 70.00 | 2020-06-18 11:10:57 |
| 1 | 0.554 | 70.00 | 2020-06-18 11:18:12 |
| 1 | 0.431 | 70.00 | 2020-06-18 11:27:39 |
| 1 | 0.446 | 70.00 | 2020-06-18 11:42:08 |
| 1 | 0.510 | 70.00 | 2020-06-18 11:56:43 |
+------------+----------+-------+---------------------+
6 rows in set (6.54 sec)

结果显示，有 6 条记录符合条件。这个简单的查询一共花去了 6.54 秒，这个速度显然太慢了。

为了找到查询运行慢的原因，咱们来分析一下它，看看为什么会用这么多时间，有没有办法优化。现在，我们用下面的语句分析一下这个查询的具体细节：

mysql> EXPLAIN SELECT itemnumber,quantity,price,transdate -- 分析查询执行情况
-> FROM demo.trans
-> WHERE itemnumber=1                  -- 通过商品编号筛选
-> AND transdate>'2020-06-18 09:00:00' -- 通过交易时间筛选
-> AND transdate<'2020-06-18 12:00:00';
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key |key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
| 1 | SIMPLE | trans | NULL | ALL | NULL | NULL | NULL | NULL | 4157166 | 1.11 | Using where |      -- 没有索引，扫描4157166条记录
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

这个结果集中包含了很多个字段，理解这些字段的意思，是我们优化查询的关键。

字段比较多，我先说说简单的字段，你只要知道概念就可以了。另外一类比较复杂的字段，一会儿我再具体讲。

id：是一个查询序列号。

table：表示与查询结果相关的表的名称。

partition：表示查询访问的分区。

key：表示优化器最终决定使用的索引是什么。

key_len：表示优化器选择的索引字段按字节计算的长度。如果没有使用索引，这个值就是空。

ref：表示哪个字段或者常量被用来与索引字段比对，以读取表中的记录。如果这个值是“func”，就表示用函数的值与索引字段进行比对。

rows：表示为了得到查询结果，必须扫描多少行记录。

filtered：表示查询筛选出的记录占全部表记录数的百分比。

possible_key：表示 MySQL 可以通过哪些索引找到查询的结果记录。如果这里的值是空，就说明没有合适的索引可用。你可以通过查看 WHERE 条件语句中使用的字段，来决定是否可以通过创建索引提高查询的效率

Extra：表示 MySQL 执行查询中的附加信息。你可以点击这个链接查询详细信息。

type：表示表是如何连接的。至于具体的内容，你可以参考下查询分析语句输出内容说明。

除了刚刚这些字段，还有 1 个比较重要，那就是 select_type。它表示查询的类型，主要有 4 种取值。

SIMPLE：表示简单查询，不包含子查询和联合查询。

PRIMARY：表示是最外层的查询。

UNION：表示联合查询中的第二个或者之后的查询。

DEPENDENTUNION：表示联合查询中的第二个或者之后的查询，而且这个查询受外查询的影响。

关于这个 DEPENDENTUNION 取值，不是很好理解，我举个小例子。

假设我们有下面的查询语句：

mysql> SELECT *
-> FROM demo.goodsmaster a
-> WHERE itemnumber in
-> (
-> SELECTb.itemnumber
-> FROM demo.goodsmaster b
-> WHERE b.goodsname = '书'
-> UNION
-> SELECTc.itemnumber
-> FROM demo.goodsmaster c
-> WHERE c.goodsname = '笔'
-> );
+------------+---------+-----------+---------------+------+------------+----------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice | avgimportprice |
+------------+---------+-----------+---------------+------+------------+----------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 | 31.00 |
| 2 | 0002 | 笔 | NULL | 包 | 5.00 | 2.87 |
+------------+---------+-----------+---------------+------+------------+----------------+
2 rows in set (0.00 sec)

对这个语句的执行进行分析，得到如下的结果：

mysql> EXPLAIN SELECT *
-> FROM demo.goodsmaster a
-> WHERE a.itemnumber in
-> (
-> SELECTb.itemnumber
-> FROM demo.goodsmaster b
-> WHERE b.goodsname = '书'
-> UNION
-> SELECTc.itemnumber
-> FROM demo.goodsmaster c
-> WHERE c.goodsname = '笔'
-> );
+----+--------------------+------------+------------+--------+---------------+---------+---------+------+------+----------+-----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+--------------------+------------+------------+--------+---------------+---------+---------+------+------+----------+-----------------+
| 1 | PRIMARY | a | NULL | ALL | NULL | NULL | NULL | NULL | 2 | 100.00 | Using where |
| 2 | DEPENDENT SUBQUERY | b | NULL | eq_ref | PRIMARY | PRIMARY | 4 | func | 1 | 50.00 | Using where |
| 3 | DEPENDENT UNION | c | NULL | eq_ref | PRIMARY | PRIMARY | 4 | func | 1 | 50.00 | Using where |
| NULL | UNION RESULT | <union2,3> | NULL | ALL | NULL | NULL | NULL | NULL | NULL | NULL | Using temporary |
+----+--------------------+------------+------------+--------+---------------+---------+---------+------+------+----------+-----------------+
4 rows in set, 1 warning (0.00 sec)

MySQL 在执行的时候，会把这个语句进行优化，重新写成下面的语句：

SELECT *
FROM demo.goodsmaster a
WHERE EXISTS
(
SELECT b.id
FROM demo.goodsmaster b
WHERE b.goodsname = '书' ANDa.itemnumber=b.itemnumber
UNION
SELECT c.id
FROM demo.goodsmaster c
WHERE c.goodsname = '笔' AND a.itemnumber=c.itemnumber
);

在这里，子查询中的联合查询是：

SELECT c.id
FROM demo.goodsmaster c
WHERE c.goodsname = '笔' AND a.itemnumber=c.itemnumber

这个查询就用到了与外部查询相关的条件 a.itemnumber=c.itemnumber，因此，查询类别就变成了“UNION DEPENDENT”。

好了，现在，我们已经知道了查询分析语句的结果集中各个字段的含义。现在来分析一下刚刚的查询语句。

我们发现，这个查询是一个简单查询，涉及的表是 demo.trans，没有分区，连接类型是扫描全表，没有索引，一共要扫描的记录数是 4157166。因此，查询速度慢的主要原因是没有索引，导致必须要对全表进行扫描才能完成查询。所以，针对这个问题，可以通过创建索引的办法，来提高查询的速度。

下面，我们用条件语句中的筛选字段 itemnumber 和 transdate 分别创建索引：

mysql> CREATE INDEX itemnumber_trans ON demo.trans(itemnumber);
Query OK, 0 rows affected (59.86 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> CREATE INDEX transdate_trans ON demo.trans(transdate);
Query OK, 0 rows affected (56.75 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后我们再次运行刚才的查询，看看优化有没有起作用：

mysql> SELECT itemnumber,quantity,price,transdate
    -> FROM demo.trans
    -> WHERE itemnumber=1
    -> AND transdate>'2020-06-18 09:00:00'
    -> AND transdate<'2020-06-18 12:00:00';
+------------+----------+-------+---------------------+
| itemnumber | quantity | price | transdate           |
+------------+----------+-------+---------------------+
|          1 | 0.276    | 70.00 | 2020-06-18 11:04:00 |
|          1 | 1.404    | 70.00 | 2020-06-18 11:10:57 |
|          1 | 0.554    | 70.00 | 2020-06-18 11:18:12 |
|          1 | 0.431    | 70.00 | 2020-06-18 11:27:39 |
|          1 | 0.446    | 70.00 | 2020-06-18 11:42:08 |
|          1 | 0.510    | 70.00 | 2020-06-18 11:56:43 |
+------------+----------+-------+---------------------+
6 rows in set (0.09 sec)

结果显示，查询只运行了 0.09 秒，跟之前的 6.54 秒相比，快了很多。这说明我们的优化措施起了作用。下面我们再次运行查询分析语句，查看一下现在的查询执行计划。

mysql> EXPLAIN SELECT itemnumber,quantity,price,transdate
    -> FROM demo.trans
    -> WHERE itemnumber=1                 -- 按商品编号筛选
    -> AND transdate>'2020-06-18 09:00:00'-- 按照交易时间筛选
    -> AND transdate<'2020-06-18 12:00:00';
+----+-------------+-------+------------+-------+----------------------------------+-----------------+---------+------+------+----------+------------------------------------+
| id | select_type | table | partitions | type  | possible_keys                    | key             | key_len | ref  | rows | filtered | Extra                              |
+----+-------------+-------+------------+-------+----------------------------------+-----------------+---------+------+------+----------+------------------------------------+
|  1 | SIMPLE      | trans | NULL       | range | itemnumber_trans,transdate_trans | transdate_trans | 6       | NULL |  552 |     1.92 | Using index condition; Using where | -- 使用了索引
+----+-------------+-------+------------+-------+----------------------------------+-----------------+---------+------+------+----------+------------------------------------+
1 row in set, 1 warning (0.01 sec)

结果显示，这一次的查询执行计划有了改变。系统发现，有 2 个索引 itemnumber_trans 和 transdate_trans 可以使用，并且最终选择了使用交易时间字段创建的索引 transdate_trans 来执行查询。扫描的方式也不再是全表扫描，而是改为了区域扫描，实际的扫描记录数也减少到了 552 个。这样一来，查询更加精准，查询的速度自然也就大幅提高了。

至此，我给你介绍了查询分析语句，并且演示了如何通过使用查询分析语句对慢查询的执行计划进行分析，并且利用分析的结果对慢查询进行优化。接下来，我再给你介绍 2 种优化查询的方法。

2 种查询优化的方法

优化查询最有效的方法就是创建索引。关于如何创建索引，我已经在第 11 讲中介绍过了，这里就不多说了。下面我来讲讲怎么在包含关键字“LIKE”和“OR”的条件语句中，利用索引提高查询效率。

使用关键字“LIKE”

“LIKE”经常被用在查询的限定条件中，通过通配符“%”来筛选符合条件的记录。比如，

WHERE字段 LIKE ‘%aa’，表示筛选出所有以字段以“aa”结尾的记录；

WHERE字段 LIKE ‘aa%’，表示筛选出所有以“aa”开始的记录；

WHERE字段 LIKE ‘%aa%’，表示所有字段中包含“aa”的记录。

这里你要注意的是，通配符在前面的筛选条件是不能用索引的。也就是说，WHERE字段LIKE‘%aa’和WHERE字段 LIKE ‘%aa%’都不能使用索引，但是通配符在后面的筛选条件，就可以使用索引。

下面，我举个小例子，通过查询分析语句来验证一下索引的使用情况。

假设我用商品流水表的字段商品条码“barcode”创建了一个索引：

mysql> CREATE INDEX trans_barcode ON demo.trans(barcode);
Query OK, 0 rows affected (1 min 20.78 sec)
Records: 0 Duplicates: 0 Warnings: 0

我们来看看如果把通配符放在前面，能不能用到索引：

mysql> EXPLAIN SELECT * FROM demo.trans
-> WHERE barcode LIKE '%182505';
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key |key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
| 1 | SIMPLE | trans | NULL | ALL | NULL | NULL | NULL | NULL | 4144028 | 11.11 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

查询分析的结果显示，type 的值是“ALL”，key 是空，表示需要进行全表扫描，没有索引可用，rows 的值是 4144028，基本上要检索全部记录，效率非常低。

再看看通配符在后面的情况：

mysql> EXPLAIN SELECT * FROM demo.trans
-> WHERE barcode LIKE '6953150%';
+----+-------------+-------+------------+-------+---------------+---------------+---------+------+------+----------+-----------------------+
| id | select_type | table | partitions | type |possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+---------------+---------+------+------+----------+-----------------------+
| 1 | SIMPLE | trans | NULL | range | trans_barcode | trans_barcode | 803 | NULL | 563 | 100.00 | Using index condition |
+----+-------------+-------+------------+-------+---------------+---------------+---------+------+------+----------+-----------------------+
1 row in set, 1 warning (0.00 sec)

type 的值是“range”，意思是使用索引检索一个给定范围的记录。rows 的值是 563，也就是只需要扫描 563 条记录就行了，这样效率就高多了。

使用关键字“OR”

关键字“OR”表示“或”的关系，“WHERE 表达式 1 OR 表达式 2”，就表示表达式 1 或者表达式 2 中只要有一个成立，整个 WHERE 条件就是成立的。

需要注意的是，只有当条件语句中只有关键字“OR”，并且“OR”前后的表达式中的字段都建有索引的时候，查询才能用到索引。

同样，为了方便你理解，我还是举个小例子，通过查询分析语句来实际验证一下。

我刚才已经用字段条码给商品流水表创建了一个索引，现在我再用商品编号“itemnumber”创建一个索引：

mysql> CREATE INDEX trans_itemnumber ON demo.trans(itemnumber);
Query OK, 0 rows affected (20.24 sec)
Records: 0 Duplicates: 0 Warnings: 0

我们先看一下关键字“OR”前后的表达式中的字段都创建了索引的情况：

mysql> EXPLAIN SELECT * FROM demo.trans
-> WHERE barcode LIKE '6953150%'
-> OR itemnumber = 1;
+----+-------------+-------+------------+-------------+--------------------------------+--------------------------------+---------+------+-------+----------+---------------------------------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+-------------+--------------------------------+--------------------------------+---------+------+-------+----------+---------------------------------------------------------------+
| 1 | SIMPLE | trans | NULL | index_merge | trans_barcode,trans_itemnumber | trans_barcode,trans_itemnumber | 803,5 | NULL | 81099 | 100.00 | Using sort_union(trans_barcode,trans_itemnumber); Using where |
+----+-------------+-------+------------+-------------+--------------------------------+--------------------------------+---------+------+-------+----------+---------------------------------------------------------------+
1 row in set, 1 warning (0.01 sec)

查询分析结果显示，有 2 个索引可以使用，分别是“trans_barcode”和“trans_itemnumber”。key=index_merge，就说明优化器选择了合并索引的方式。因此，这个关键字“OR”前后的表达式中的字段都创建了索引的查询，是可以用到索引的。

在下面的例子中，表达式goodsname LIKE '%海鲜菇%'中的字段 goodsname 没有创建索引，我们来验证一下查询是否能够利用索引：

mysql> EXPLAIN SELECT * FROM demo.trans
-> WHERE barcode LIKE '6953150%'
-> OR goodsname LIKE '%海鲜菇%';
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key |key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
| 1 | SIMPLE | trans | NULL | ALL | trans_barcode | NULL | NULL | NULL | 4144028 | 20.99 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

查询分析的结果显示，type=ALL，是全表扫描，不能用索引。

总结

今天，我们学习了优化查询的方法，包括用来分析查询执行情况的查询分析语句，语法结构是：

{ EXPLAIN | DESCRIBE | DESC }查询语句;

同时，我们还学习了在使用关键字“LIKE”和“OR”的情况下，用索引来提高查询效率的方法。

特别需要提醒你注意的是，在使用“LIKE”关键字的条件语句中，通配符“%”在前面的筛选条件不能使用索引，通配符“%”在后面的筛选条件可以使用索引。在使用“OR”关键字的条件语句中，只有关键字“OR”前后的表达式中的字段都创建了索引，条件语句才能使用索引。

关于优化查询，还有一个值得关注的点，就是子查询。这是 MySQL 的一项重要的功能，可以帮助我们通过一个 SQL 语句实现比较复杂的查询。但是，子查询的执行效率不高。因为 MySQL 会用临时表把子查询的结果保存起来，然后再使用临时表的内容完成查询。这样一来，查询就多了一个创建临时表的过程，执行效率没有连接查询高。针对这种情况，建议你把子查询转换成连接查询，这样可以进一步提高查询的效率。

思考题

假设现在有一个这样的查询，请你把这个包含子查询的查询改成 2 个表的连接查询。

mysql> EXPLAIN SELECT * FROM demo.trans
-> WHERE itemnumber IN
-> (
-> SELECTitemnumber FROM demo.goodsmaster
-> WHERE goodsname LIKE '%书%'
-> );
+----+-------------+-------------+------------+------+------------------+------------------+---------+-----------------------------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------------+------------+------+------------------+------------------+---------+-----------------------------+------+----------+-------------+
| 1 | SIMPLE | goodsmaster | NULL | ALL | PRIMARY | NULL | NULL | NULL | 2 | 50.00 | Using where |
| 1 | SIMPLE | trans | NULL | ref | trans_itemnumber | trans_itemnumber | 5 | demo.goodsmaster.itemnumber | 889 | 100.00 | NULL |
+----+-------------+-------------+------------+------+------------------+------------------+---------+-----------------------------+------+----------+-------------+
2 rows in set, 1 warning (0.00 sec)

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

25 | 表太大了，如何设计才能提高性能？

你好，我是朱晓峰。

随着数据量的不断增加，表会变得越来越大，查询的速度也会越来越慢。针对这种情况，该怎么处理呢？

咱们上节课学习的优化查询语句是一种方法，不过它并不足以解决所有问题。如果表的设计不合理，会导致数据记录占用不必要的存储空间。

MySQL 在存取数据时，并不是一条条去处理的，而是会按照固定大小的页进行处理，如果数据记录占用了不必要的存储空间，就会导致一次读入的有效数据很少。那么，无论怎么改写语句，都无法提升这步操作的效率。这个时候，对表的设计进行优化，就是必不可少的了。

所以，今天，我就给你介绍一下怎么通过优化数据类型、合理增加冗余字段、拆分表和使用非空约束等方法，来改进表的设计，从而提高查询性能。

数据类型优化

在改进表的设计时，首先可以考虑优化字段的数据类型。下面我就来讲解 2 种方法，一种是针对整数类型数据，尽量使用小的整数类型来定义；另外一种是，如果字段既可以用文本类型，也可以用整数类型，尽量使用整数类型。

先说第一种方法，对整数类型数据进行优化。

在第 2 讲中，我建议你，遇到整数类型的字段可以用 INT 型。这样做的理由是，INT 型数据有足够大的取值范围，不用担心数据超出取值范围的问题。刚开始做项目的时候，首先要保证系统的稳定性，这样设计字段类型是可以的。

但是，随着你的经验越来越丰富，参与的项目越来越大，数据量也越来越多的时候，你就不能只从系统稳定性的角度来思考问题了，还要考虑到系统整体的效率。

这是因为，在数据量很大的时候，数据类型的定义，在很大程度上会影响到系统整体的执行效率。这个时候，你就必须同时考虑稳定性和效率。

第 2 种优化方法，就是既可以使用文本类型也可以使用整数类型的字段，要使用整数类型，而不要用文本类型。

跟文本类型数据相比，大整数往往占用更少的存储空间，因此，在存取和比对的时候，可以占用更少的内存。所以，遇到既可以使用文本类型，又可以使用整数类型来定义的字段，尽量使用整数类型，这样可以提高查询的效率。

接下来，我就结合超市项目的案例来讲解下具体的优化方法。

在这个项目中，我们有一个 400 万条记录的流水数据。为了方便你理解，这里我只保留 2 个字段，分别是商品编号字段 itemnumber 和流水唯一编号字段 transuniqueid。流水唯一编号用于在系统中唯一标识一条流水。

为了对比方便，我创建了 2 个表 demo.test 和 demo.test1：

在 demo.test 的表中，我给商品编号设定的数据类型是 INT，给流水唯一编号设定的数据类型是 TEXT；

在 demo.test1 中，我给商品编号设定的数据类型是 MEDIUMINT，给流水唯一编号设定的数据类型是 BIGINT。

这样设定的原因是，MEDIUMINT 类型的取值范围是“无符号数 0 – 16777215”。对于商品编号来说，其实够用了。我的 400 万条数据中没有超过这个范围的值。而流水唯一编号是一个长度为 18 位的数字，用字符串数据类型 TEXT 肯定是可以的，大整数类型 BIGINT 的取值范围是“无符号数 0 – 18446744083709551616”，有 20 位，所以，用大整数类型数据来定义流水唯一编号，也是可以的。

创建表 demo.test 和 demo.test1 的语句如下所示：

mysql> CREATE TABLE demo.test (itemnumber INT,transuniqueid TEXT);
Query OK, 0 rows affected (0.23 sec)

mysql> CREATE TABLE demo.test1 (itemnumber MEDIUMINT,transuniqueid BIGINT);
Query OK, 0 rows affected (0.25 sec)

然后，我们来对这两个表进行数据导入和查询操作，看看哪个效率更高：

mysql> LOAD DATA INFILE 'C:\\ProgramData\\MySQL\\MySQL Server 8.0\\Uploads\\trans.txt' INTO TABLE demo.test FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n';
Query OK, 4328021 rows affected (3 min 23.47 sec)
Records: 4328021 Deleted: 0 Skipped: 0 Warnings: 0
mysql> LOAD DATA INFILE 'C:\\ProgramData\\MySQL\\MySQL Server 8.0\\Uploads\\trans.txt' INTO TABLE demo.test1 FIELDS TERMINATED BY ',' LINES TERMINATED BY '\n';
Query OK, 4328021 rows affected (3 min 1.84 sec)
Records: 4328021 Deleted: 0 Skipped: 0 Warnings: 0

结果显示，同样导入 400 万条数据，demo.test 用时 3 分 23.47 秒，而 demo.test1 用时 3 分 1.84 秒。显然，demo.test1 的数据导入速度比 demo.test 快了将近 21 秒。

在保存相同数量的数据记录的情况下，优化过的表的查询的效率也更高一些。下面我们来验证一下：

mysql> SELECT COUNT(*)
-> FROM demo.test
-> WHERE itemnumber = 1;
+----------+
| COUNT(*) |
+----------+
| 40742 |
+----------+
1 row in set (5.18 sec)

mysql> SELECT COUNT(*)
-> FROM demo.test1
-> WHERE itemnumber = 1;
+----------+
| COUNT(*) |
+----------+
| 40742 |
+----------+
1 row in set (3.86 sec)

结果显示，这个差别更大。demo.test 用了 5.18 秒，而 demo.test1 只用了 3.86 秒，速度提升得非常明显。

这是为啥呢？我们来分析下。

原来，INT 类型占用 4 个字节存储空间，而 MEDIUMINT 类型只占用 3 个字节的存储空间，比 INT 类型节省了 25% 的存储空间。demo.test1 的第一个字段的数据类型是 MEDIUMINT，demo.test 的第一个字段的数据类型是 INT。因此，我们来对比下两个表的第一个字段 ，demo.test1 占用的存储空间就比 demo.test 节省了 25%。

再来看看这两个表的第二个字段：流水唯一编号 transuniqueid。

在 demo.test 中，这个字段的类型是 TEXT，而 TEXT 类型占用的字节数等于“实际字符串长度 + 2”，在咱们的这个场景中，流水唯一编号的长度是 18，所占用的存储空间就是 20 个字节。在 demo.test1 中，流水唯一编号的数据类型是 BIGINT，占用的存储空间就是 8 个字节。这样一来，demo.test1 在第二个字段上面占用的存储空间就比 demo.test 节省了（20-8）÷20=60%。很明显，对于流水唯一编号字段，demo.test1 比 demo.test 更加节省空间。

因此，我建议你，遇到数据量大的项目时，一定要在充分了解业务需求的前提下，合理优化数据类型，这样才能充分发挥资源的效率，使系统达到最优。

合理增加冗余字段以提高效率

在数据量大，而且需要频繁进行连接的时候，为了提升效率，我们也可以考虑增加冗余字段来减少连接。

为了方便你理解，我举个小例子。

假如我们有 2 个表，分别是商品流水表（demo.trans）和商品信息表（demo.goodsmaster）。商品流水表里有 400 万条流水记录，商品信息表里有 2000 条商品记录。

商品流水表：

商品信息表：

可以看到，这两个表中不存在冗余数据，都是符合第三范式的要求的。但是，在我们项目的实施过程中，对流水的查询频率很高，而且为了获取商品名称，基本都会用到与商品信息表的连接查询。

假设我现在要查询一下 2020 年 04 月 11 日上午 9:00 到中午 12:00、商品编号是 355 的商品的销售数量明细，就必须要使用连接查询：

mysql> SELECT b.goodsname,a.quantity
-> FROM demo.trans AS a
-> JOIN demo.goodsmaster AS b
-> ON (a.itemnumber=b.itemnumber)
-> WHERE a.transdate>'2021-04-11 09:00:00'
-> AND a.transdate<'2021-04-11 12:00:00'
-> AND a.itemnumber = 355;
+--------------+----------+
| goodsname | quantity |
+--------------+----------+
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
+--------------+----------+
5 rows in set (6.64 sec)

结果显示，一共有 5 笔销售，各卖了 1 个，商品名称是一种叫做“贵烟（跨越）”的香烟。这个查询用掉了 6.64 秒。

为了减少连接，我们可以直接把商品名称字段加到流水表里面。这样一来，我们就可以直接从流水表中获取商品名称字段了。虽然增加了冗余字段，但是避免了连接，这样对提高查询效率有没有什么影响呢？我们来验证一下。

我们给商品流水表添加一个字段：商品名称（goodsname）。新的商品流水表如下所示：

修改完表的结构之后，我们把商品名称数据填入新加的字段中，这样一来，流水表中就有了商品名称信息，不用再通过与商品信息表进行连接来获取了。

现在，如果我们要再次进行刚刚的查询，就不需要再做关联查询了，因为商品流水表里面已经有了商品名称字段：

mysql> SELECT a.goodsname,a.quantity
-> FROM demo.trans AS a
-> WHERE a.transdate>'2021-04-11 09:00:00'
-> AND a.transdate<'2021-04-11 12:00:00'
-> AND a.itemnumber = 355;
+--------------+----------+
| goodsname | quantity |
+--------------+----------+
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
| 贵烟（跨越） | 1.000 |
+--------------+----------+
5 rows in set (6.38 sec)

结果显示，卖了 5 个“贵烟（跨越）”，花费了 6.38 秒。查询的结果与之前相同，但是速度更快了。而且，这个查询变成了单表查询，语句也更加简单了。

不过，你要注意的一点是，这样一来，商品流水表中包含了一个冗余字段“商品名称”，不但存储空间变大了，而且，如果某个商品名称做了修改，一定要对应修改流水表里的商品名称。否则，就会出现两个表里的商品名称不一致的情况。

所以，在实际的工作场景中，你需要权衡增加冗余字段的利与弊。这里给你一个建议：增加冗余字段一定要符合 2 个条件，第一个是，这个冗余字段不需要经常进行修改；第二个是，这个冗余字段查询的时候不可或缺。只有满足这两个条件，才可以考虑增加冗余字段，否则就不值得增加这个冗余字段了。

除了优化数据类型与合理增加冗余字段之外，我们还可以通过对大表进行拆分的方法优化查询。

拆分表

跟刚刚的在表中增加冗余字段的方法相反，拆分表的思路是，把 1 个包含很多字段的表拆分成 2 个或者多个相对较小的表。

这样做的原因是，这些表中某些字段的操作频率很高，经常要进行查询或者更新操作，而另外一些字段的使用频率却很低，如果放在一个表里面，每次查询都要读取大记录，会消耗较多的资源。

这个时候，如果把这个大表拆分开，把使用频率高的字段放在一起形成一个表，把剩下的使用频率低的字段放在一起形成一个表，这样查询操作每次读取的记录比较小，查询效率自然也就提高了。

举个小例子，比如流水单头表中包含流水单号、会员编号、收款机编号、整单折让、整单折扣、微信收款金额、支付宝收款金额、现金金额等字段。

流水单头表：

我们来分析下这个表中的字段。

在这个表中，会员编号涉及会员销售，会被经常查询。收款机信息经常用于销售统计，整单折让和整单折扣用于优惠计算，也经常要被引用。

其他信息，包括微信金额、支付宝金额和现金金额，只有在财务统计收款方式的时候，才会用到。

所以，我们可以把这个表拆分成 2 个独立的表：这个表中常用的字段，也就是会员编号、收款机编号、整单折扣和整单折让字段，加上流水单号，就是流水单头表 1，剩下的字段加上流水单号字段，就是流水单头表 2。

流水单头表 1：

流水单头表 2：

这样一来，在查询的时候，只需要访问比较小的流水单头表 1 或流水单头表 2，这就提高了查询的效率。

使用非空约束

在设计字段的时候，如果业务允许，我建议你尽量使用非空约束。这样做的好处是，可以省去判断是否为空的开销，提高存储效率。而且，非空字段也容易创建索引。使用非空约束，甚至可以节省存储空间（每个字段 1 个比特）。

以商品信息表为例，我们可以设定所有的字段满足非空约束，如下所示：

mysql> DESCRIBE demo.goodsmaster;
+---------------+---------------+------+-----+---------+-------+
| Field         | Type          | Null | Key | Default | Extra |
+---------------+---------------+------+-----+---------+-------+
| itemnumber    | int           | NO   | PRI | NULL    |       |
| barcode       | text          | NO   |     | NULL    |       |
| goodsname     | text          | NO   |     | NULL    |       |
| specification | text          | NO   |     | NULL    |       |
| unit          | text          | NO   |     | NULL    |       |
| salesprice    | decimal(10,2) | NO   | UNI | 0.00    |       |
+---------------+---------------+------+-----+---------+-------+
6 rows in set (0.01 sec)

这样一来，我们就省去了判断空值的开销，还能够节省一些存储空间。

总结

这节课，我给你介绍了几个从设计角度提升查询性能的方法：

修改数据类型以节省存储空间；

在利大于弊的情况下增加冗余字段；

把大表中查询频率高的字段和查询频率低的字段拆分成不同的表；

尽量使用非空约束。

这些都可以帮助你进一步提升系统的查询效率，让你开发的应用更加简洁高效。

但是，我要提醒你的是，这些方法都是有利有弊的，比如，修改数据类型，节省存储空间的同时，你要考虑到数据不能超过取值范围；增加冗余字段的时候，不要忘了确保数据一致性；把大表拆分，也意味着你的查询会增加新的连接，从而增加额外的开销和运维的成本。因此，你一定要结合实际的业务需求进行权衡。

思考题

假设我们有一个这样的订单表，如下所示：

经过统计发现，商品信息、订货数量和交易时间这 3 个字段使用得很频繁，地址和电话这 2 个字段使用得相对比较少。针对这样的表格，你会怎么优化呢？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

26 | 如何充分利用系统资源？

你好，我是朱晓峰。

内存和 CPU 都是有限的资源，因此，把它们的作用发挥到极致，对提高应用的承载能力来说至关重要。

磁盘读写需要计算位置、发出读写指令等，这些都要消耗 CPU 资源，很容易成为提升系统效能的瓶颈。

如果采取“先把数据放在内存，然后集中写入磁盘”的办法，可以节省 CPU 资源和磁盘读取的时间，但是也会面临系统故障时会丢失数据的风险；相反，如果每次都写入磁盘，数据最安全，但是频繁的磁盘读写，会导致系统效率低下。这就需要我们提升优化资源配置的能力。

今天，我就给你介绍一下优化系统配置的方法，同时还会讲解系统自带的监控工具，从而帮助你合理配置系统资源，精准发现系统资源的瓶颈，进一步提升你处理大并发、大数据的能力。

优化系统资源配置

对 CPU 资源的掌控，关系到系统整体的成败。因为 CPU 资源是系统最核心的资源，无可替代，而且获取成本高。如果应用无法控制 CPU 的使用率，就有可能是失败的，不管你的界面多么人性化，功能多么强大。

因此，我们需要管理好系统配置，把资源效率提升到极致。系统参数控制着资源的配置，调整系统参数的值，可以帮助我们提升资源的利用效率。

我来借助一个小例子，给你介绍下怎么通过对系统变量进行调优，来提升系统的整体效率。

我曾参与过一个点餐系统应用的开发，其实就是一个为客户提供点餐服务的应用，类似于美团。商家购买服务，入住平台，开通之后，商家可以在系统中录入自己能够提供的各类餐食品种，客户通过手机 App、微信小程序等点餐，商家接到订单以后进行制作，并根据客户需求提供堂食或者送餐服务。

系统中包括订单表（orderlist）、客户信息表（clientlist）和客户储值表（clientdeposit）。

订单表：

客户信息表：

客户储值表：

刚刚上线的时候，系统运行状态良好。但是，随着入住的商家不断增多，使用系统的用户量越来越多，每天的订单数据达到了 2 万条以上。这个时候，系统开始出现问题，CPU 使用率不断飙升。终于，有一天午餐高峰的时候，CPU 使用率达到 99%，这实际上就意味着，系统的计算资源已经耗尽，再也无法处理任何新的订单了。换句话说，系统已经崩溃了。

这个时候，我们想到了对系统参数进行调整，因为参数的值决定了资源配置的方式和投放的程度。为了解决这个问题，我们一共调整了 3 个系统参数，分别是 InnoDB_flush_log_at_trx_commit、InnoDB_buffer_pool_size、InnoDB_buffer_pool_instances。

接下来，我就给你讲一讲怎么对这三个参数进行调整。

1. 调整系统参数 InnoDB_flush_log_at_trx_commit

这个参数适用于 InnoDB 存储引擎。因为刚刚的表用的存储引擎都是 InnoDB，因此，这个参数对我们系统的效能就有影响。需要注意的是，如果你用的存储引擎不是 InnoDB，调整这个参数对系统性能的提升就没有什么用了。

这个参数存储在 MySQL 的配置文件 my.ini 里面，默认的值是 1，意思是每次提交事务的时候，都把数据写入日志，并把日志写入磁盘。这样做的好处是数据安全性最佳，不足之处在于每次提交事务，都要进行磁盘写入的操作。在大并发的场景下，过于频繁的磁盘读写会导致 CPU 资源浪费，系统效率变低。

这个参数的值还有 2 个可能的选项，分别是 0 和 2。其中，0 表示每隔 1 秒将数据写入日志，并将日志写入磁盘；2 表示，每次提交事务的时候都将数据写入日志，但是日志每间隔 1 秒写入磁盘。

最后，我们把这个参数的值改成了 2。这样一来，就不用每次提交事务的时候都启动磁盘读写了，在大并发的场景下，可以改善系统效率，降低 CPU 使用率。即便出现故障，损失的数据也比较小。0 虽然效率更高一些，但是数据安全性方面不如 2。

2. 调整系统参数 InnoDB_buffer_pool_size

这个参数的意思是，InnoDB 存储引擎使用缓存来存储索引和数据。这个值越大，可以加载到缓存区的索引和数据量就越多，需要的磁盘读写就越少。

因为我们的 MySQL 服务器是数据库专属服务器，只用来运行 MySQL 数据库服务，没有其他应用了，而我们的计算机是 64 位机，内存也有 128G。于是我们把这个参数的值调整为 64G。这样一来，磁盘读写次数可以大幅降低，我们就可以充分利用内存，释放出一些 CPU 的资源。

3. 调整系统参数 InnoDB_buffer_pool_instances

这个参数的意思是，将 InnoDB 的缓存区分成几个部分，这样一来，就可以提高系统的并行处理能力，因为可以允许多个进程同时处理不同部分的缓存区。这就好比买电影票，如果大家都挤在一个窗口、一个接一个地进行交易，效率肯定是很慢的。如果一次开很多售票窗口，多笔交易同时进行，那速度就快得多了。

我们把 InnoDB_buffer_pool_instances 的值修改为 64，意思就是把 InnoDB 的缓存区分成 64 个分区，这样就可以同时有多个进程进行数据操作，CPU 的效率就高多了。

修改好了系统参数的值，我们需要重新保存 MySQL 的配置文件 my.ini，并且重启 MySQL 数据库服务器。

这里有个坑你要注意：由于 my.ini 文件是文本格式文件，你完全可以用记事本对文件进行修改操作。但是，如果你只是简单地进行保存，就会发现，MySQL 服务器停止之后，再次启动时没有响应，服务器起不来了。其实，这就是文件的码制出了问题。

记事本保存文件默认的码制是 UTF-8，但配置文件的码制必须是 ANSI 才行。所以，当你修改完 MySQL 的配置文件 my.ini 之后，保存的时候，记得用 ANSI 的格式。如下图所示：

经过我们对系统参数的调整，重启 MySQL 服务器之后，系统效率提高了，CPU 资源的使用率下来了，系统得以正常运行。

咱们来小结下。CPU 资源是系统的核心资源，获取成本非常高。CPU 的特点就是阻塞，只要 CPU 一开始计算，就意味着等待。遇到 CPU 资源不足的问题，可以从 2 个思路去解决：

疏通拥堵路段，消除瓶颈，让等待的时间更短；

开拓新的通道，增加并行处理能力。

刚刚的调优思路，其实就是围绕着这 2 个点展开的。如果遇到 CPU 资源不足的问题，我建议你也从这 2 个角度出发去思考解决办法。

如何利用系统资源来诊断问题？

在刚刚的例子中，我提到了解决 CPU 资源不足需要消除瓶颈。而消除瓶颈的第一步就是要发现它。如何发现呢？幸运的是，MySQL 提供了很好的工具：Performance Schema。

这是一种专门用来监控服务器执行情况的存储引擎，它会把监控服务器执行情况的数据记录在系统自带的数据库 performance_schema 中。我们可以利用监控的数据，对服务器中执行查询的问题进行诊断。

我还是以刚刚的那个点餐系统为例，来解释一下。

当我们调整完系统参数之后，系统恢复了运行。可是随着数据量的不断增大，单日订单量超过 20 万，我们再次遇到了问题：CPU 飙升到 99%，系统无法工作了。这个时候，我们就可以利用 performance_schema 记录的监控数据来发现问题。

我先讲一讲怎么让 Performance Schema 监控查询执行事件，并且把我们需要的监控数据记录下来。

如何启用系统监控？

系统数据库 performance_schema 中的表 setup_instruments 和 setup_consumers 中的数据，是启用监控的关键。

setup_instruments 保存的数据，表示哪些对象发生的事件可以被系统捕获（在 MySQL 中，把这些事件称作信息生产者）。

我们可以通过下面的代码，来查看一下当前 MySQL 会监控哪些事件的信息：

mysql> SELECT NAME,ENABLED,TIMED
-> FROM performance_schema.setup_instruments
-> LIMIT 1,10;
+---------------------------------------------------------+---------+-------+
| NAME | ENABLED | TIMED |
+---------------------------------------------------------+---------+-------+
| wait/synch/mutex/sql/TC_LOG_MMAP::LOCK_tc | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_commit | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_commit_queue | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_done | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_flush_queue | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_index | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_log | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_binlog_end_pos | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_sync | YES | YES |
| wait/synch/mutex/sql/MYSQL_BIN_LOG::LOCK_sync_queue | YES | YES |
+---------------------------------------------------------+---------+-------+
10 rows in set (0.00 sec)

表的内容有很多，为了方便演示，这里我只查询了前 10 条数据。在这个表中，你要注意 3 个字段。

NAME：表示事件的名称；

ENABLED：表示是否启用了对这个事件的监控；

TIMED：表示是否收集事件的时间信息。

这里我们启用所有事件的监控，以便诊断问题：

UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES';

setup_instruments 表中的数据指定了是否对某一事件进行监控，而 setup_consumers 这个表保存的数据，则指定了是否保存监控事件发生的信息。在 MySQL 中，Performance Schema 把监控到的事件信息存入 performance_schema 中的数据表中，这些数据表保存了事件的监控信息，扮演了事件监控信息消费者的角色，被称为消费者。

下面我们来修改设置，使系统保存所有事件信息，并了解一下 setup_consumers 这个表里都能够控制保存哪些事件的信息。我在数据旁边加了注释，来说明保存的事件信息的种类。

UPDATE performance_schema.setup_consumers
SET ENABLED = 'YES'

mysql> SELECT *
-> FROM performance_schema.setup_consumers
-> ;
+----------------------------------+---------+
| NAME | ENABLED |
+----------------------------------+---------+
| events_stages_current | YES |            -- 当前阶段
| events_stages_history | YES |            -- 阶段历史
| events_stages_history_long | YES |       -- 阶段长历史
| events_statements_current | YES |        -- 当前语句
| events_statements_history | YES |        -- 历史语句
| events_statements_history_long | YES |   -- 长历史语句
| events_transactions_current | YES |      -- 当前事务
| events_transactions_history | YES |      -- 历史事务
| events_transactions_history_long | YES | -- 长历史事务
| events_waits_current | YES |             -- 当前等待
| events_waits_history | YES |             -- 等待历史
| events_waits_history_long | YES |        -- 等待长历史
| global_instrumentation | YES |
| thread_instrumentation | YES |
| statements_digest | YES |
+----------------------------------+---------+
15 rows in set (0.01 sec)

更新之后，所有种类事件信息的保存都已经启用。这样一来，所有查询事件都能够被监控，事件的相关信息也都能够被记录下来了。如果查询中出现问题，那么，异常信息就会被记录下来。接下来，我们就利用系统监控到的信息来诊断一下问题。

利用监控信息诊断问题

为了利用保存下来的监控事件信息来诊断系统问题，我先介绍一下几个保存监控信息数据的系统数据表。

第 1 个表是 performance_schema.events_statements_current。

这个表中记录的是当前系统中的查询事件。表中的每一行对应一个进程，一个进程只有一行数据，显示的是每个进程中被监控到的查询事件。

第 2 个表是 performance_schema.events_statements_history。

这个表中记录了系统中所有进程中最近发生的查询事件。这个表中包含的查询事件都是已经完成了的。另外，表中可以为每个进程保存的最大记录数由系统变量决定。

下面的代码可以查询当前系统中可以为每个进程保存的最大记录数的值：

mysql> show variables like '%performance_schema_events_statements_history_size%'
-> ;
+---------------------------------------------------+-------+
| Variable_name | Value |
+---------------------------------------------------+-------+
| performance_schema_events_statements_history_size | 10 |  -- 最多10条
+---------------------------------------------------+-------+
1 row in set, 1 warning (0.01 sec)

结果显示，当前的设定是：这个表中为每个进程保留最多 10 条记录。

我要说的第 3 个表，是 performance_schema.events_statements_history_long。

这个表中记录了系统中所有进程中最近发生的查询事件，表中包含的查询事件都是已经完成了的。同时，这个表中可以保存的记录数由系统变量决定。下面的代码用来查询当前系统设置的这个表可以保存的最大记录数。

mysql> show variables like '%performance_schema_events_statements_history_long_size%'
-> ;
+--------------------------------------------------------+-------+
| Variable_name | Value |
+--------------------------------------------------------+-------+
| performance_schema_events_statements_history_long_size | 10000 |
+--------------------------------------------------------+-------+
1 row in set, 1 warning (0.00 sec)

结果显示，当前系统的设定是，这个表中最多保留 10000 条记录。

知道了这几个保存事件信息的系统数据表，我们重新回到项目中来，看看如何利用这几个系统数据表中的信息，来发现问题。

我们先用下面的语句，来看一下哪些查询消耗的时间多。这个表的数据量很大，为了方便你查看，我只让它显示最初的 2 行数据。

mysql> SELECT
-> TRUNCATE(TIMER_WAIT / 1000000000000, 6) AS duration, -- 计算查询的时长，单位是微微秒，需要转换成秒
-> sql_text,
-> EVENT_ID
-> FROM
-> performance_schema.events_statements_history_long
-> WHERE
-> TRUNCATE(TIMER_WAIT / 1000000000000, 6) <> 0
-> AND sql_text IS NOT NULL
-> ORDER BY TRUNCATE(TIMER_WAIT / 1000000000000, 6) DESC
-> LIMIT 1,2;
+----------+---------------------------------+----------+
| duration | sql_text | EVENT_ID |
+----------+---------------------------------+----------+
| 137.2529 | select count(*) from demo.trans | 17 |
| 137.2420 | select count(*) from demo.trans | 907 |
+----------+---------------------------------+----------+
2 rows in set (0.00 sec)

结果显示，持续时间最长的 2 个事件分别是事件编号为 17 和 907 的事件。这样一来，我们就可以发现到底是哪些查询消耗了最多的 CPU 资源，就可以有针对性地进行优化了。

下面我具体给你解释一下这个查询。

字段 TIMER_WAIT：表示这个查询消耗了多少时间，单位是微微秒，也就是万亿分之一秒。

TRUNCATE(X,D) 函数：表示给 X 保留 D 位小数，注意这个函数是直接截取，没有四舍五入。

字段 sql_text：表示执行的 SQL 语句的内容。

EVENT_ID：表示事件编号。

这个查询的意思是，按照查询花费时间多少排序，查询出花费时间最多的 2 个查询的事件编号、执行时长和查询的内容。

通过查询这个表的内容，我们发现，有大量的查询出现在执行时间较长的列表中，这些查询都涉及数据表 demo.clientdeposit。

为了支持顾客储值消费，我们新加了这个表，但是这个表的使用频率很高，数据量较大，我们却忘记了创建索引，这就导致了 CPU 资源不足。确定了问题，解决起来就很容易了，创建了索引之后，CPU 资源消耗直接下降到了 20% 左右。

总之，Performance Schema 是一种很好的工具，可以监控到 MySQL 服务器内部执行的信息。如果你遇到查询中出现难以解决的问题，就可以调取数据库 performance_shema 中的监控数据，分析服务器中的执行情况，从而定位和解决问题。

总结

这节课，我们学习了通过系统参数来配置资源、提高查询效率的方法。

系统参数 InnoDB_flush_log_at_trx_commit 适用于 InnoDB 存储引擎。默认的值是 1，意思是每次提交事务的时候，都把数据写入日志，并把日志写入磁盘。0 表示每隔 1 秒将数据写入日志，并将日志写入磁盘。2 表示每次事务提交的时候，将数据写入日志，但是日志每隔 1 秒写入磁盘。

系统参数 InnoDB_buffer_pool_size 表示 InnoDB 存储引擎使用多少缓存来存储索引和数据。这个值越大，可以加载到缓存区的索引和数据量就越多，需要的磁盘读写就越少。

系统参数 InnoDB_buffer_pool_instances 的意思是，将 InnoDB 的缓存区分成几个部分，可以提高系统的并行处理能力。

而且，我还给你介绍了怎么通过使用 Performance Schema 来监控服务器运行中的事件。系统数据库 performance_schema 中的表 setup_instruments 和 setup_consumers 中的数据，是启用监控的关键。

setup_instruments 保存的数据，表示哪些对象发生的事件可以被系统捕获。

setup_consumers 保存的数据用来控制保存哪些事件的信息。

我们可以通过修改这两个表的内容来开启系统监控，并且把监控数据保存到相应的数据表中，方便我们对查询的执行情况进行诊断。

最后，我还想额外给你一个建议：如果你遇到了因为无法控制 CPU 的使用率而导致系统崩溃的情况，首先应该想到的是应用本身有缺陷，然后找到自身的问题，并加以解决，而不是增加系统资源的投入，采购功能强大的 CPU 和扩大内存等。

原因有 2 个：资源永远是有限的，如何在有限的资源前提下提高系统的承载能力，是我们应该首先考虑的；资源的投入都是经过谨慎评估的，除非你有充足的理由确信，同等条件下，你开发的应用承载能力已经超过了业界最高水平，否则就说明应用本身还有提升的空间。

所以，课下你一定要把今天的内容多学几遍，一定要掌握通过 MySQL 自身的事件监控机制来诊断问题的方法，它可以帮助你找到真正的瓶颈。

思考题

前面提到，我把 InnoDB_flush_log_at_trx_commit 的值改成了 2，因为 0 虽然效率更高一些，但是在数据安全性方面不如 2。你知道为什么 0 的效率更高一些，但是数据安全性却不如 2 吗？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

特别发送（一） | 经典面试题讲解第一弹

你好，我是朱晓峰。

到这里，“实践篇”的内容咱们就学完了。今天，我们来学点儿不一样的——5 道经典面试题。这些都是在实际面试中的原题，当然，我没有完全照搬，而是结合咱们课程的具体情况，有针对性地进行了调整。我不仅会给你提供答案，还会和你一起分析，让你能够灵活地吃透这些题目，并能举一反三。

话不多说，我们现在开始。我先带你从一道简单的关于“索引”的面试题入手，索引在面试题里经常出现，来看看这一道你能不能做对。

第一题

下面关于索引的描述，正确的是：

建立索引的主要目的是减少冗余数据，使数据表占用更少的空间，并且提高查询的速度

一个表上可以建立一个或者多个索引

组合索引可以有效提高查询的速度，比单字段索引更高效，所以，我们应该创建一个由所有的字段组成的组合索引，这样就可以解决所有问题了

因为索引可以提高查询效率，所以索引建得越多越好

解析：这道题的正确答案是选项 2，我们来分析一下其他选项。

选项 1 说对了一半，索引可以提高查询效率，但是创建索引不能减少冗余数据，而且索引还要占用额外的存储空间，所以选项 1 不对。

选项 3 不对的原因有 2 个。第一，组合索引不一定比单字段索引高效，因为组合索引的字段是有序的，遵循左对齐的原则。如果查询的筛选条件不包含组合索引中最左边的字段，那么组合索引就完全不能用。第二，创建索引也是有成本的，需要占用额外的存储空间。用所有的字段创建组合索引的存储成本比较高，而且利用率比较低，完全用上的可能性几乎不存在，所以很少有人会这样做。而且一旦更改任何一个字段的数据，就必须要改索引，这样操作成本也比较高。

选项 4 错误，因为索引有成本，很少作为筛选条件的字段，没有必要创建索引。

如果这道题你回答错了，一定要回去复习下第 11 讲的内容。

第二题

假设我们有这样一份学生成绩单，所有同学的成绩都各不相同，请编写一个简单的 SQL 语句，查询分数排在第三名的同学的成绩：

解析：这道题考查的是我们对查询语句的掌握情况。针对题目中的场景，可以分两步来进行查询。

第一步，按照成绩高低进行排序：

mysql> SELECT *
-> FROM demo.test1
-> ORDER BY score DESC; -- DESC表示降序排列
+----+------+-------+
| id | name | score |
+----+------+-------+
| 2 | 李四 | 90.00 |
| 4 | 赵六 | 88.00 |
| 1 | 张三 | 80.00 |
| 3 | 王五 | 76.00 |
| 5 | 孙七 | 67.00 |
+----+------+-------+
5 rows in set (0.00 sec)

第二步，找出排名第三的同学和对应的成绩。我们可以用第 4 讲里学过的对返回记录进行限定的关键字 LIMIT：

mysql> SELECT *
-> FROM demo.test1
-> ORDER BY score DESC
-> LIMIT 2,1;
+----+------+-------+
| id | name | score |
+----+------+-------+
| 1 | 张三 | 80.00 |
+----+------+-------+
1 row in set (0.00 sec)

在 MySQL 中，LIMIT 后面可以跟 2 个参数，第一个参数表示记录的起始位置（第一个记录的位置是 0），第二个参数表示返回几条记录。因此，“LIMIT 2,1”就表示从第 3 条记录开始，返回 1 条记录。这样，就可以查出排名第三的同学的成绩了。

第三题

现在我们有两个表，分别是人员表（demo.person）和地址表（demo.address），要求你使用关联查询查出完整信息。无论有没有地址信息，人员的信息必须全部包含在结果集中。

人员表：

地址表：

解析： 这个是典型的外查询，咱们在第 6 讲里学过。题目要求我们查出人员表中的全部信息，而地址表中信息则可以为空，就可以用下面的查询代码：

mysql> SELECT *
-> FROM demo.person AS a
-> LEFT JOIN demo.address AS b -- 左连接，确保demo.person中的记录全部包括在结果集中
-> ON (a.id=b.id);
+----+-------+-------+------+---------+------+-----------+
| id | fname | lname | id | country | city | address |
+----+-------+-------+------+---------+------+-----------+
| 1 | 张 | 三 | 1 | 中国 | 北京 | 海淀123 |
| 2 | 李 | 四 | 2 | 美国 | 纽约 | 奥巴尼333 |
| 3 | 王 | 五 | NULL | NULL | NULL | NULL |
+----+-------+-------+------+---------+------+-----------+
3 rows in set (0.02 sec)

第四题

假设有这样一个教学表（demo.teach)，里面包含了人员编号（id）、姓名（fname）和对应的老师的人员编号（teacherid）。如果一个人是学生，那么他一定有对应的老师编号，通过这个编号，就可以找到老师的信息；如果一个人是老师，那么他对应的老师编号就是空。比如说，下表中李四的老师编号是 101，我们就可以通过搜索人员编号，找到 101 的名称是张三，那么李四的老师就是张三；而张三自己就是老师，所以他对应的老师编号是空。

要求：请写一个 SQL 语句，查询出至少有 2 名学生的老师姓名。

说明一下，在刚刚的数据表中，张三有 3 名学生，分别是李四、王五和周八。赵六有一名学生，是孙七。因此，正确的 SQL 语句的查询结果应该是：

解析：

针对这道题，我们可以按照这样的思路去做：

通过统计学生对应的老师编号，就可以获取至少有 2 个学生的老师的编号。

通过关联查询和自连接获取需要的信息。所谓的自连接，就是数据表与自身进行连接。你可以认为是把数据表复制成一模一样的 2 个表，通过给数据表起不同的名字来区分它们，这样方便对表进行操作，然后就可以对这 2 个表进行连接操作了。

通过使用条件语句 WHERE 和 HAVING 对数据进行筛选：先用 WHERE 筛选出所有的老师编号，再用 HAVING 筛选出有 2 个以上学生的老师编号。

首先，我们来获取老师编号，如下：

mysql> SELECT teacherid
    -> FROM demo.teach
    -> WHERE teacherid is not NULL     -- 用WHERE筛选出所有的老师编号
    -> GROUP BY teacherid
    -> HAVING COUNT(*)>=2;             -- 用HAVING筛选出有2个以上学生的老师编号
+-----------+
| teacherid |
+-----------+
|       101 |
+-----------+
1 row in set (0.00 sec)

接着，通过自连接，来获取老师的姓名：

mysql> SELECT a.id,a.fname
    -> FROM demo.teach AS a
    -> JOIN
    -> (
    ->  SELECT teacherid
    ->  FROM demo.teach
    ->  WHERE teacherid IS NOT NULL
    ->  GROUP BY teacherid
    ->  HAVING COUNT(*)>=2
    -> ) AS b
    -> ON (a.id = b.teacherid);
+-----+-------+
| id  | fname |
+-----+-------+
| 101 | 张三  |
+-----+-------+
1 row in set (0.00 sec)

第五题

假设某中学高三年级有多位同学，分成多个班，我们有统一记录学生成绩的表（demo.student) 和班级信息表（demo.class），具体信息如下所示：

学生成绩表：

班级信息表：

要求：写一个 SQL 查询语句，查出每个班级前三名的同学。

说明一下，针对上面的数据，正确的 SQL 查询应该得出下面的结果：

解析：

从题目给出的查询结果看，不需要考虑并列的情况。那么，现在要选出分数排名前三的同学，其实只要找出 3 个最好的分数以及对应的同学就可以了。

这道题需要用到我们讲过的关联查询和子查询的知识。

WHERE 语句的筛选条件表达式中，也可以包括一个子查询的结果。

第一步，我们假设有一个分数 X，就是那个第 N 好的分数，算一下有多少个同学的成绩优于这个分数：

SELECT COUNT(DISTINCT b.points)
FROM demo.student AS b
WHERE b.points > X;

这个查询的结果小于 3 的话，就代表这个分数 X 是排名第三的分数了。

第二步，查询出哪些同学满足成绩排名前三的这个档次：

mysql> SELECT a.stdname,a.points
    -> FROM demo.student AS a
    -> WHERE 3 >                       -- 比这个成绩好的不超过3，说明这是第三好的成绩
    -> (
    ->   SELECT COUNT(DISTINCT b.points)   -- 统计一下有几个成绩
    ->   FROM demo.student AS b
    ->   WHERE b.points > a.points         -- 比这个成绩好
    -> );
+---------+--------+
| stdname | points |
+---------+--------+
| 张三    |     85 |
| 李四    |     80 |
| 赵六    |     90 |
| 周八    |     85 |
+---------+--------+
4 rows in set (0.00 sec)

第三步，与班级表关联，按班级统计前三名同学的成绩，并且获取班级信息：

mysql> SELECT c.classname,a.stdname,a.points
    -> FROM demo.student AS a
    -> JOIN demo.class AS c
    -> ON (a.classid = c.id)          -- 关联班级信息
    -> WHERE 3 >
    -> (
    ->   SELECT COUNT(DISTINCT b.points)
    ->   FROM demo.student AS b
    ->   WHERE b.points > a.points
    ->   AND b.classid = a.classid   -- 按班级分别查询
    -> )
    -> ORDER BY c.classname,a.points DESC;
+-----------+---------+--------+
| classname | stdname | points |
+-----------+---------+--------+
| 创新班    | 赵六    |     90 |
| 创新班    | 张三    |     85 |
| 创新班    | 周八    |     85 |
| 创新班    | 郑九    |     70 |
| 普通班    | 李四    |     80 |
| 普通班    | 王五    |     65 |
+-----------+---------+--------+
6 rows in set (0.00 sec)

总结

今天，我们借助几个面试题，回顾了索引的概念、查询、子查询和关联查询的知识，以及条件语句 WHERE 和 HAVING 的不同使用方法。如果你发现哪些内容掌握得还没有那么牢固，一定要及时回去复习一下。

在真正的面试中，很少有单纯考查知识点本身的题目，更多的是考查你在解决实际问题的过程中，对知识的灵活运用能力。所以，在学习每一节课时，你一定要结合我给出的实际项目，去真正实践一下，这样才能以不变应万变，在面试中有好的表现。

特别放送（三）| MySQL 8 都有哪些新特征？

你好，我是朱晓峰。今天，我来和你聊一聊 MySQL 8 的新特征。

作为应用最广泛的三大关系型数据库之一，MySQL 的背后有一个强大的开发团队，使 MySQL 能够持续迭代和创新，满足不断变化的用户需求。在 MySQL 8 中，就有很多新特征。

今天，我就给你介绍两个重要的新特征：窗口函数和公用表表达式（Common Table Expressions，简称 CTE）。它们可以帮助我们用相对简单的查询语句，实现更加强大的查询功能。

什么是窗口函数？

窗口函数的作用类似于在查询中对数据进行分组，不同的是，分组操作会把分组的结果聚合成一条记录，而窗口函数是将结果置于每一条数据记录中。一会儿我会借助一个例子来对比下，在此之前，你要先掌握窗口函数的语法结构。

窗口函数的语法结构是：

函数 OVER（[PARTITION BY 字段]）

或者是：

函数 OVER 窗口名 … WINDOW 窗口名 AS （[PARTITION BY 字段名]）

现在，我借助一个小例子来解释一下窗口函数的用法。

假设我现在有这样一个数据表，它显示了某购物网站在每个城市每个区的销售额：

mysql> SELECT * FROM demo.test1;
+----+------+--------+------------+
| id | city | county | salesvalue |
+----+------+--------+------------+
|  1 | 北京 | 海淀   |      10.00 |
|  2 | 北京 | 朝阳   |      20.00 |
|  3 | 上海 | 黄埔   |      30.00 |
|  4 | 上海 | 长宁   |      10.00 |
+----+------+--------+------------+
4 rows in set (0.00 sec)

现在我想计算一下，这个网站在每个城市的销售总额、在全国的销售总额、每个区的销售额占所在城市销售额中的比率，以及占总销售额中的比率。

如果用分组和聚合函数，就需要分好几步来计算。

第一步，计算总销售金额，并存入临时表 demo.a：

mysql> CREATE TEMPORARY TABLE demo.a       -- 创建临时表
    -> SELECT SUM(salesvalue) AS salesvalue -- 计算总计金额
    -> FROM demo.test1;
Query OK, 1 row affected (0.02 sec)
Records: 1  Duplicates: 0  Warnings: 0

我们查看一下临时表 demo.a 的内容，来验证一下计算结果：

mysql> SELECT * FROM demo.a;
+------------+
| salesvalue |
+------------+
|      70.00 |
+------------+
1 row in set (0.00 sec)

结果显示，总计金额已经存入临时表 demo.a 中了。

第二步，计算每个城市的销售总额并存入临时表 demo.b：

mysql> CREATE TEMPORARY TABLE demo.b    -- 创建临时表
    -> SELECT city,SUM(salesvalue) AS salesvalue  -- 计算城市销售合计
    -> FROM demo.test1
    -> GROUP BY city;
Query OK, 2 rows affected (0.01 sec)
Records: 2  Duplicates: 0  Warnings: 0

我们查看一下临时表 demo.b 的内容，验证一下计算的结果：

mysql> SELECT * FROM demo.b;
+------+------------+
| city | salesvalue |
+------+------------+
| 北京 |      30.00 |
| 上海 |      40.00 |
+------+------------+
2 rows in set (0.00 sec)

结果显示，每个城市的销售总计金额已经计算成功了。

第三步，计算各区的销售占所在城市的总计金额的比例，和占全部销售总计金额的比例。我们可以通过下面的连接查询获得需要的结果：

mysql> SELECT a.city AS 城市,a.county AS 区,a.salesvalue AS 区销售额,
    -> b.salesvalue AS 市销售额,a.salesvalue/b.salesvalue AS 市比率,
    -> c.salesvalue AS 总销售额,a.salesvalue/c.salesvalue AS 总比率
    -> FROM demo.test1 AS a
    -> JOIN demo.b AS b ON (a.city=b.city) -- 连接市统计结果临时表
    -> JOIN demo.a AS c                    -- 连接总计金额临时表
    -> ORDER BY a.city,a.county;
+------+------+----------+----------+----------+----------+----------+
| 城市 | 区   | 区销售额 | 市销售额 | 市比率   | 总销售额 | 总比率   |
+------+------+----------+----------+----------+----------+----------+
| 上海 | 长宁 |    10.00 |    40.00 | 0.250000 |    70.00 | 0.142857 |
| 上海 | 黄埔 |    30.00 |    40.00 | 0.750000 |    70.00 | 0.428571 |
| 北京 | 朝阳 |    20.00 |    30.00 | 0.666667 |    70.00 | 0.285714 |
| 北京 | 海淀 |    10.00 |    30.00 | 0.333333 |    70.00 | 0.142857 |
+------+------+----------+----------+----------+----------+----------+
4 rows in set (0.01 sec)

结果显示：市销售金额、市销售占比、总销售金额、总销售占比都计算出来了。

同样的查询，如果用窗口函数，就简单多了。我们可以用下面的代码来实现：

mysql> SELECT city AS 城市,county AS 区,salesvalue AS 区销售额,
    -> SUM(salesvalue) OVER(PARTITION BY city) AS 市销售额,  -- 计算市销售额
    -> salesvalue/SUM(salesvalue) OVER(PARTITION BY city) AS 市比率,
    -> SUM(salesvalue) OVER() AS 总销售额,   -- 计算总销售额
    -> salesvalue/SUM(salesvalue) OVER() AS 总比率
    -> FROM demo.test1
    -> ORDER BY city,county;
+------+------+----------+----------+----------+----------+----------+
| 城市 | 区   | 区销售额 | 市销售额 | 市比率   | 总销售额 | 总比率   |
+------+------+----------+----------+----------+----------+----------+
| 上海 | 长宁 |    10.00 |    40.00 | 0.250000 |    70.00 | 0.142857 |
| 上海 | 黄埔 |    30.00 |    40.00 | 0.750000 |    70.00 | 0.428571 |
| 北京 | 朝阳 |    20.00 |    30.00 | 0.666667 |    70.00 | 0.285714 |
| 北京 | 海淀 |    10.00 |    30.00 | 0.333333 |    70.00 | 0.142857 |
+------+------+----------+----------+----------+----------+----------+
4 rows in set (0.00 sec)

结果显示，我们得到了与上面那种查询同样的结果。

你看，使用窗口函数，我们只用了一步就完成了查询，过程简单多了。而且，由于没有用到临时表，执行的效率也更高了。很显然，在这种需要用到分组统计的结果对每一条记录进行计算的场景下，使用窗口函数更好。

除了可以进行分组统计，窗口函数还有一些自己独有的函数，可以对分组内的数据进行处理，比较常用的就是排序函数 RANK()、DENSE_RANK() 和 ROW_NUMBER()。

为了帮助你理解这几个函数的作用，我举个小例子。

假设我们有这样一张学生成绩表：

mysql> SELECT * FROM demo.test2;
+----+---------+--------+
| id | student | points |
+----+---------+--------+
|  1 | 张三    |     89 |
|  2 | 李四    |     77 |
|  3 | 王五    |     88 |
|  4 | 赵六    |     90 |
|  5 | 孙七    |     90 |
|  6 | 周八    |     88 |
+----+---------+--------+
6 rows in set (0.00 sec)

如果我们需要对表中的数据进行排序，就可以使用排序函数，代码如下所示：

mysql> SELECT student,points,
    -> RANK() OVER w AS 排序1,
    -> DENSE_RANK() OVER w AS 排序2,
    -> ROW_NUMBER() OVER w AS 排序3
    -> FROM demo.test2
    -> WINDOW w AS (ORDER BY points DESC);
+---------+--------+-------+-------+-------+
| student | points | 排序1 | 排序2 | 排序3 |
+---------+--------+-------+-------+-------+
| 赵六    |     90 |     1 |     1 |     1 |
| 孙七    |     90 |     1 |     1 |     2 |
| 张三    |     89 |     3 |     2 |     3 |
| 王五    |     88 |     4 |     3 |     4 |
| 周八    |     88 |     4 |     3 |     5 |
| 李四    |     77 |     6 |     4 |     6 |
+---------+--------+-------+-------+-------+
6 rows in set (0.01 sec)

结果显示：

RANK() 函数把并列计算在内，并且并列影响排位；

DENSE_RANK() 函数也计算并列，但是并列不影响排位；

ROW_NUMBER() 函数不计算并列，只是简单排序。

因此，我们就可以根据这些函数的特点，计算分组中的排位信息。如果不计算并列，就用 ROW_NUMBER() 函数；计算并列但不占用位次，就用 DENSE_RANK() 函数；计算并列且占用位次，就用 RANK() 函数。

接下来，我们再来学习 MySQL 8 的另一个重要新特征：公用表表达式。

什么是公用表表达式？

公用表表达式是一个命名的临时结果集。它存在于单个查询语句中，主要作用就是可以代替子查询，并且可以被后面的查询多次引用。

依据语法结构和执行方式的不同，公用表表达式分为普通公用表表达式和递归公用表表达式 2 种。

什么是普通公用表表达式？

普通公用表表达式的语法结构是：

WITH
CTE名称 AS （子查询）
SELECT|DELETE|UPDATE 语句;

普通公用表表达式类似于子查询，不过，跟子查询不同的是，它可以被多次引用，而且可以被其他的普通公用表表达式所引用。

举个小例子，假设我们有一个商品信息表（demo.goodsmaster），它保存的是商品信息，还有一个每日销售统计表（demo.dailystatistics），保存的是每日的销售统计信息。现在超市经营者想要查出都卖了什么商品，我们就可以先通过子查询查出所有销售过的商品的商品编号，然后查出这些商品的商品信息，代码如下所示：

mysql> SELECT * FROM demo.goodsmaster
-> WHERE itemnumber IN
-> (SELECT DISTINCT itemnumber     -- 子查询，查出所有销售过的商品的商品编号
-> FROM demo.dailystatistics);
+------------+---------+-----------+---------------+------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice |
+------------+---------+-----------+---------------+------+------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 |
| 2 | 0002 | 笔 | 黑色 | 支 | 3.00 |
| 3 | 0003 | 胶水 | 无机 | 瓶 | 15.00 |
+------------+---------+-----------+---------------+------+------------+
3 rows in set (0.01 sec)

这个查询也可以用普通公用表表达式的方式完成：

mysql> WITH
-> cte AS (SELECT DISTINCT itemnumber FROM demo.dailystatistics)
-> SELECT *
-> FROM demo.goodsmaster a JOIN cte
-> ON (a.itemnumber = cte.itemnumber);
+------------+---------+-----------+---------------+------+------------+------------+
| itemnumber | barcode | goodsname | specification | unit | salesprice | itemnumber |
+------------+---------+-----------+---------------+------+------------+------------+
| 1 | 0001 | 书 | 16开 | 本 | 89.00 | 1 |
| 2 | 0002 | 笔 | 黑色 | 支 | 3.00 | 2 |
| 3 | 0003 | 胶水 | 无机 | 瓶 | 15.00 | 3 |
+------------+---------+-----------+---------------+------+------------+------------+
3 rows in set (0.00 sec)

可以看到，普通公用表表达式代替了第一种查询方式中的子查询，并且得到了同样的结果。

这个例子说明，公用表表达式可以起到子查询的作用。以后如果遇到需要使用子查询的场景，你可以在查询之前，先定义公用表表达式，然后在查询中用它来代替子查询。而且，跟子查询相比，公用表表达式有一个优点，就是定义过公用表表达式之后的查询，可以像一个表一样多次引用公用表表达式，而子查询则不能。

好了，我们再来学习下递归公用表表达式。

什么是递归公用表表达式？

递归公用表表达式也是一种公用表表达式，只不过，除了普通公用表表达式的特点以外，它还有自己的特点，就是可以调用自己。它的语法结构是：

WITH RECURSIVE
CTE名称 AS （子查询）
SELECT|DELETE|UPDATE 语句;

递归公用表表达式由 2 部分组成，分别是种子查询和递归查询，中间通过关键字 UION [ALL]进行连接。这里的种子查询，意思就是获得递归的初始值。这个查询只会运行一次，以创建初始数据集，之后递归查询会一直执行，直到没有任何新的查询数据产生，递归返回。

同样，为了帮助你理解递归公用表表达式的工作原理，我来举个小例子。

假设我们有这样一张人员信息表（demo.teach），里面包含人员编号、名称和老师编号。

如果甲是乙的老师，那么，我们可以把乙叫做甲的徒子，如果同时乙又是丙的老师，那么丙就是乙的徒子，是甲的徒孙。

下面我们尝试用查询语句列出所有具有徒孙身份的人员信息。

如果用我们之前学过的知识来解决，会比较复杂，至少要进行 4 次查询才能搞定：

第一步，先找出初代老师，就是不以任何人为老师的人，把结果存入临时表；

第二步，找出所有以初代老师为师的人，得到一个徒子集，把结果存入临时表；

第三步，找出所有以徒子为师的人，得到一个徒孙集，把结果存入临时表。

第四步，找出所有以徒孙为师的人，得到一个结果集。

如果第四步的结果集为空，则计算结束，第三步的结果集就是我们需要的徒孙集了，否则就必须继续进行第四步，一直到结果集为空为止。比如上面的这个数据表，就需要到第五步，才能得到空结果集。而且，最后还要进行第六步：把第三步和第四步的结果集合并，这样才能最终获得我们需要的结果集。

如果用递归公用表表达式，就非常简单了。我介绍下具体的思路。

用递归公用表表达式中的种子查询，找出初代老师。字段 n 表示代次，初始值为 1，表示是第一代老师。

用递归公用表表达式中的递归查询，查出以这个递归公用表表达式中的人为老师的人，并且代次的值加 1。直到没有人以这个递归公用表表达式中的人为老师了，递归返回。

在最后的查询中，选出所有代次大于等于 3 的人，他们肯定是第三代及以上代次的学生了，也就是徒孙了。这样就得到了我们需要的结果集。

这里看似也是 3 步，实际上是一个查询的 3 个部分，只需要执行一次就可以了。而且也不需要用临时表保存中间结果，比刚刚的方法简单多了。

下面是具体的代码：

mysql> WITH RECURSIVE
-> cte AS (
-> SELECT id,fname,teacherid,1 AS n FROM demo.teach WHERE id = 101 -- 种子查询，找到第一代老师
-> UNION ALL
-> SELECT a.id,a.fname,a.teacherid,n+1 FROM demo.teach AS a JOIN cte
-> ON (a.teacherid = cte.id) -- 递归查询，找出以递归公用表表达式的人为老师的人
-> )
-> SELECT id,fname FROM cte WHERE n>=3;  -- 从结果集中筛选代次大于等于3的，得到所有徒孙集
+------+-------+
| id | fname |
+------+-------+
| 103 | 王五 |
| 105 | 孙七 |
| 106 | 周八 |
+------+-------+
3 rows in set (0.00 sec)

结果显示，王五、孙七和周八是徒孙。结果显然是正确的。

总之，递归公用表表达式对于查询一个有共同的根节点的树形结构数据，非常有用。它可以不受层级的限制，轻松查出所有节点的数据。如果用其他的查询方式，就比较复杂了。

总结

这节课，我们学习了 MySQL 8 的 2 个重要新功能：窗口函数和公用表表达式。

窗口函数的特点是可以分组，而且可以在分组内排序。另外，窗口函数不会因为分组而减少原表中的行数，这对我们在原表数据的基础上进行统计和排序非常有用。

公用表表达式的作用是可以替代子查询，而且可以被多次引用。递归公用表表达式对查询有一个共同根节点的树形结构数据非常高效，可以轻松搞定其他查询方式难以处理的查询。

当然，除了今天学习的窗口函数和公用表表达式，MySQL 8 还有许多其他的新特征，比如，完善了对空间位置信息的处理；支持对表的 DDL 操作（创建、修改和删除表）的原子性，使得CREATE TABLE ...SELECT语句能够成为一个原子操作，提高了数据安全性，等等。

如果你想要从旧版本切换到 MySQL 8，课下可以点击这个链接进一步了解一下。

思考题

假设我有一个会员销售统计表（demo.memtrans），其中包括会员名称、商品名称和销售金额，具体数据如下：

请使用窗口函数查询会员名称、商品名称、销售金额、总计金额和销售占比。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

特别放送（二）| 经典面试题讲解第二弹

你好，我是朱晓峰。

到这里，“进阶篇”的内容咱们就学完了。今天，我给你准备了 7 道面试题。这些面试题涵盖了这个模块的核心内容，我们一起借助面试题来复习一下。我不仅会给你提供正确答案，还会带你深入分析这些问题，让你真正能够做到举一反三。

好了，话不多说，我们现在开始。

第一题

日志文件对数据库的故障恢复至关重要。下面这些关于日志文件的描述，正确的是：

MySQL 日志记录的顺序可以与并发操作的执行顺序不一致

为了确保数据库是可恢复的，必须确保先写数据库后写日志

日志文件中检查点的主要作用是提高系统出现故障后恢复的效率

系统故障恢复必须使用日志文件以保证数据库系统重启时能正常恢复，事务故障恢复不一定需要使用日志文件

答案：选项 3。

解析：

选项 1 是错误的。MySQL 日志记录的顺序是严格按照操作执行的顺序记录的，不会出现日志记录的顺序与并发操作执行的顺序不一致的情况。

选项 2 也是错误的。MySQL 的日志系统遵循 WAL（Write-Ahead Logging）机制，也就是所谓的先写日志，后写数据库的机制。由于记录日志是顺序写入磁盘，而写入数据库的磁盘操作需要对磁头定位，因而写入日志的速度要远比写入数据快。为了提高执行的效率，MySQL 执行的是先写日志，日志写入成功之后，就回复客户端操作成功，对数据库的磁盘写入则在之后的某个阶段执行。这样，即便遇到系统故障，由于有了日志记录，就可以通过日志对数据库进行恢复了。WAL 机制包括 3 个规则：

对数据的修改操作必须在写入数据库之前写入到日志文件中；

日志必须严格按序记录，就是说，如果 A 操作发生在 B 操作之前，那么在日志中，A 操作的记录必须在 B 操作的记录之前；

事务提交之后，必须在日志写入成功之后，才能回复事务处理成功。

选项 3 是正确的，检查点的作用是加快数据恢复的效率。当修改数据时，为了提高存取效率，采用的策略是先记录日志，实际的数据修改则发生在内存中，这些数据修改是没有写入数据库的，叫做脏页。MySQL 把这些脏页分成小的批次，逐步写入磁盘中。因为如果把所有的脏页都一次性写入磁盘，会导致磁盘写入时间过长，影响到用户 SQL 操作的执行。检查点的作用就是标记哪些脏页已经写入磁盘。这样，当遇到故障时，MySQL 可以从检查点的位置开始，执行日志记录的 SQL 操作，而不是把整个日志都检查一遍，所以，大大提高了故障恢复的效率。

选项 4 也是错误的。系统故障恢复必须使用日志文件以保证数据库系统重启时能正常恢复，这个表述是正确的，但后面的表述“事务故障恢复不一定需要使用日志文件”则是错误的。事务故障的恢复也必须要用到日志文件，事务故障恢复需要用到的日志文件有 3 个，分别是回滚日志、重做日志和二进制日志。

回滚日志：如果事务发生故障，可以借助回滚日志，恢复到故障前的状态，所以回滚日志对事务故障恢复有用。

重做日志：事务中的操作对数据更新进行提交时，会记录到重做日志，对数据的更新则只会发生在内存中，实际的数据更新写入磁盘，则会由后台的其他进程异步执行。如果这个时候事务故障，内存中的数据丢失，就必须要借助重做日志来找回。所以，重做日志对事务故障恢复有用。

二进制日志：在主从服务器架构的模式下，从服务器完全依赖二进制日志同步主服务器的操作，如果事务发生故障，从服务器只能依靠主服务器的二进制日志恢复。

第二题

MySQL 支持事务处理吗？

参考答案：

这个问题跟数据表用的是什么存储引擎有关。如果用的是 Innodb，则支持事务处理；如果用的是 MyISAM，则不支持事务处理。

MySQL 8.0 默认的存储引擎是 Innodb，Innodb 是支持事务处理的。在默认的情况下，MySQL 启用 AUTOCOMMIT 模式，也就是每一个 SQL 操作都是一个事务操作，如果操作执行没有返回错误，MySQL 会提交事务；如果操作返回错误，MySQL 会执行事务回滚。

你也可以通过执行“START TRANSACTION”或者“BEGIN”来开始一个事务，这种情况下，就可以在事务处理中包含多个 SQL 操作语句，一直到“COMMIT”语句提交事务，或者是“ROLLBACK”语句回滚事务，来结束一个事务操作。

MyISAM 存储引擎是不支持事务操作的，如果你对一个存储引擎是 MyISAM 的数据表执行事务操作，不管你是否执行“COMMIT”或者是“ROLLBACK”，都不会影响操作的结果。你可以通过下面的 SQL 语句来查看表的存储引擎：

mysql> SHOW CREATE TABLE demo.test;
+-------+------------------------------------------------------------------------------------------------------------------+
| Table | Create Table |
+-------+------------------------------------------------------------------------------------------------------------------+
| test | CREATE TABLE `test` (
`aa` int DEFAULT NULL
) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
+-------+------------------------------------------------------------------------------------------------------------------+
1 row in set (0.02 sec)

第三题

下面这些关于 MySQL 视图的描述中，错误的是：

视图是 MySQL 提供给用户以多种角度观察数据库中数据的重要机制

视图可以对重构数据库提供一定程度的逻辑独立性

所有视图都是可查询和可更新的

对视图的一切操作都会转换成对数据表的操作

答案：选项 3。

解析：

选项 1 的描述是正确的。视图其实就是存储在服务器中的查询语句，可以通过编写不同的查询语句，从数据库中抽取需要的数据。也就相当于从多种角度观察数据库中的数据。

选项 2 的描述是正确的。视图通过查询语句从数据库中抽取需要的数据，如果遇到数据表结构的变更，可以通过修改查询语句，使视图返回的结果集中的字段保持不变。因此，视图可以一定程度不受实际数据表结构的变化的影响，具备一定的逻辑独立性。

选项 3 的描述是错误的，所有的视图都可以查询，但不是所有的视图都可以更新。比如，定义视图的查询语句如果包含分组，由于无法对实际数据表中的记录定位，所以这样的视图是不支持数据更新操作的。

选项 4 的描述也是正确的。视图不过是一段查询语句，视图并不保存实际数据，对视图的任何操作，最终都会转换到实际数据表。

第四题

下面关于存储过程的描述中，不正确的是：

存储过程实际上是一组 SQL 语句

存储过程预先被编译存放在数据库服务器上

存储过程与数据库无关

存储过程可以完成某一特定的业务逻辑

答案：选项 3。

解析：

选项 1 的描述正确，存储过程的程序体就是一组 SQL 语句，调用存储过程，实际上就是执行了一组 SQL 操作。

选项 2 的描述正确，存储过程创建完成之后，就是经过了编译的过程，以服务可以执行的形式存放在服务器中。

选项 3 的描述是错误的，存储过程是数据库的一部分，不能独立于数据库而存在。

选项 4 的描述是正确的，创建存储过程的目的，就是为了高效地完成某一特定的业务逻辑。

第五题

在 MySQL 中，有学生表（学号，姓名，年龄，所在系），其中学号是主键。在这个表上建有视图 V1，V1 的定义语句是：

CREATE VIEW V1 AS SELECT 姓名，年龄，所在系
FROM 学生表 WHERE 年龄>=(SELECT AVG(年龄) FROM 学生表);

有下列操作语句：

A.UPDATE V1 SET 年龄=年龄-1;
B.SELECT * FROM V1 WHERE 年龄>20;
C.DELETE FROM V1 WHERE 年龄>20;
D.INSERT INTO V1 VALUES (‘张三’,20,’计算机系’);

以上语句能够正确执行的是：

A、B 和 D

仅 B

仅 B 和 C

仅 A、B 和 C

答案：选项 2。

解析：这道题考核的要点是，如果定义视图的 SQL 语句中包含了聚合函数，就不能对视图中的数据进行 DML 操作。

第六题

什么是游标？如何知道游标已经走到了最后？

参考答案：

由 SELECT 语句返回的完整行集（包括满足 WHERE 语句中条件的所有行）称为结果集。MySQL 中的 SQL 操作，会对整个结果集起作用。应用程序，特别是交互式的联机应用程序，并不总能将整个结果集作为一个单元来进行有效的处理，这些应用程序需要一种机制，以便每次处理一行或者一部分行。游标就是提供这种机制的，是对结果集的一种扩展。

具体而言，MySQL 的游标有以下特点：

可以逐条查看数据集中的记录；

一次只能查看一条记录；

只能向一个方向扫描数据，并且不能跳跃；

游标是只读的。

可以使用条件处理语句“DECLARE CONTINUE HANDLER FOR NOT FOUND”，来判断游标到达了结果集的最后。

第七题

下面关于触发器的叙述中，错误的是：

触发器是一种特殊的存储程序

触发器必须创建在一个特殊的表中

触发器通过 CALL 调用

触发器有助于实现数据库的完整性、安全性和主动性

答案：选项 3。

解析：

触发器也是存储在服务器端，是一种存储程序，触发器监控的对象是一个特定的表，触发器可以记录日志、进行安全性检查，并且无需应用程序的控制，可以由数据操作的事件驱动，所以选项 1、2、4 都是正确的。

触发器不能由 CALL 调用，而是由事件驱动，所以 3 是错误的。

总结

今天，我们借助几个面试题，回顾了日志、游标、视图、存储过程和触发器的相关内容。如果你发现回答这些试题有困难，一定要及时回去复习一下。

刷题不是目的，真正的目的是对你掌握知识的熟练程度进行检验，发现问题，查漏补缺。只有深入了解相关的知识背景、工具的运行机制和原理，才能牢固掌握，在实践中灵活运用。

特别放送（四）| 位置信息：如何进行空间定位？

你好，我是朱晓峰。今天，我来和你聊一聊怎么进行空间定位。

我们每天都会用到空间数据，比如你在网上购买一件商品，你手机上的 App 就能够算出你是不是需要负担运费，负担多少运费。这其实就是因为手机获取到了你的空间位置信息，发送到网购平台，然后根据你所在的位置是否属于偏远地区，来决定你是否需要负担运费，如果需要的话，应该负担多少。

而从应用开发者的角度出发，我们需要知道怎么进行空间定位，获取用户的空间位置信息，以及如何计算发货点与客户地址的距离，这些都要借助与空间数据相关的技术才能解决。

今天，我还是借助一个真实的项目，来给你介绍下空间数据类型、空间数据处理函数，以及如何为空间数据创建索引，帮助你提升开发基于空间数据应用的能力。

在我们超市项目的实施过程中，超市经营者提出了这样一个要求：要给距离门店 5 公里范围内的、从线上下单的客户提供送货上门的服务。要想解决这个问题，就需要用到空间数据了。

空间数据类型与空间函数

我先给你介绍下空间数据类型和空间函数。

MySQL 支持的空间数据类型分为 2 类：

一类是包含单个值的几何类型（GEOMETRY）、点类型（POINT）、线类型（LINESTRINIG）和多边形类型（POLYGON）；

另一类是包含多个值的多点类型（MULTIPOINT）、多线类型（MULTILINESTRING）、多多边形类型（MULTIPOLYGON）和几何集类型（GEOMETRYCOLLECTION）。

我简单说明一下这几种空间数据类型的特点。

几何类型是一个通用的空间数据类型，你可以把点类型、线类型和多边形类型数据的值赋予几何类型数据。但是点类型、线类型和多边形类型数据则不具备这种通用性，你只能赋予它们各自类型数据的值。

几何集类型数据可以保存点类型数据、线类型数据和多边形类型数据值的集合。多点类型、多线类型和多多边形类型则分别只能保存点类型数据、线类型数据和多边形类型数据值的集合。

下面我们重点介绍一下点类型，因为这种类型是最简单、最基础的空间类型，也最常用。

点类型（POINT）

点类型是最简单的空间数据类型，代表了坐标空间中的单个位置。在不同比例尺的坐标空间中，一个点可以有不同的含义。例如，在较大比例尺的世界地图中，一个点可能代表一座城市；而在较小比例尺的城市地图中，一个点可能只代表一个车站。

点类型数据的属性有 2 种：

坐标空间中的 X 轴的值（比如地理坐标中的经度值）；

坐标空间中的 Y 轴的值（比如地理坐标中的纬度值）。

点类型数据的维度是 0，边界为空。

空间函数

MySQL 支持的空间函数有一百多种，我们没有必要全部都掌握。所以，我给你重点介绍几个比较常用的空间函数 ST_Distance_Sphere()、MBRContains()、MBRWithin() 和 ST_GeomFromText()。

1.ST_Distance_Sphere() 函数

我们先从计算空间距离的函数 ST_Distance_Sphere() 说起，这个函数的语法结构和功能如下所示：

ST_Distance_Sphere(g1,g2)：g1 与 g2 为 2 个点，函数返回球体上 2 个点 g1 与 g2 之间的最小球面距离。

2.MBRContains() 和 MBRWithin() 函数

在学习 MBRContains() 和 MBRWithin() 函数之前，我们要先了解一个概念，也就是最小边界矩形（MBR，Minimum Bounding Rectangle ）。

最小边界矩形是指以二维坐标表示的若干二维形状（例如点、直线、多边形）的最大范围，即以给定的二维形状各顶点中的最大横坐标、最小横坐标、最大纵坐标、最小纵坐标决定的边界的矩形。

知道了这个概念，你就能更好地理解这两个函数了。

MBRContains(g1,g2)：如果几何图形 g1 的最小边界矩形包含了几何图形 g2 的最小边界矩形，则返回 1，否则返回 0。

MBRWithin(g1,g2)：与 MBRContains(g1,g2) 函数正好相反，MBRWithin(g1,g2) 表示，如果几何图形 g1 的最小边界矩形，包含在几何图形 g2 的最小边界矩形之内，则返回 1，否则返回 0。

3.ST_GeomFromText()

这个函数的作用是通过 WKT 形式创建几何图形。而 ST_GeomFromText(WKT,SRID) 就表示，返回用 WKT 形式和 SRID 指定的参照系表达的几何图形。

这里的 WKT 是一种文本标记语言，用来表示几何对象。SRID（Spatial Reference Identifier）是空间参照标识符，默认是 0，表示平面坐标系。我们平时常用的 SRID 是 4326，是目前世界通用的以地球质心为原点的地心坐标系。

知道了这些基础知识，我们就可以着手解决超市经营者提出的需求了。

这家超市有很多门店，该怎么计算是否应该送货上门呢？如果应该送货上门，应该从哪家门店送货呢？我带你分析下具体的思路。

第一步，把门店的位置信息录入数据表中；

第二步，根据下单客户的送货地址，获取到地理位置信息；

第三步，计算各门店位置与送货地址的距离，找出最近的门店安排送货，如果没有一家门店与客户的距离在 5 公里以内，则提示不能送货。

下面我们就来实际操作一下。

首先，我们创建一个门店表（demo.mybranch），包含门店编号、名称、位置等信息。

mysql> CREATE TABLE demo.mybranch
-> (
-> branchid SMALLINT PRIMARY KEY,
-> branchname VARCHAR(50) NOT NULL,
-> address GEOMETRY NOT NULL SRID 4326
-> );
Query OK, 0 rows affected (0.07 sec)

这里需要注意一下，我这里的 address 字段，定义的空间数据类型是 GEOMETRY，SRID 是 4326。因为 GEOMETRY 类型比较通用，可以赋予任何类型的空间数据值，而且方便后面创建索引。SRID 值为 4326，表示采用地心坐标系，这样计算出来的距离才比较准确。当然，你完全可以使用空间数据类型 POINT，也能达到同样的效果。

现在，我们把门店位置信息录入表中：

mysql> INSERT INTO demo.mybranch VALUES
    -> (1,'西直门店',ST_GeomFromText('POINT(39.938099 116.350266)', 4326)),          -- 西直门店的经纬度信息
    -> (2,'东直门店',ST_GeomFromText('POINT(39.941143 116.433769)', 4326)),
    -> (3,'崇文门店',ST_GeomFromText('POINT(39.896877 116.416977)', 4326)),
    -> (4,'五道口店',ST_GeomFromText('POINT(39.9921 116.34584)', 4326)),
    -> (5,'清河店',ST_GeomFromText('POINT(39.743378 116.332878)', 4326));
Query OK, 5 rows affected (0.03 sec)
Records: 5  Duplicates: 0  Warnings: 0

结果显示，数据插入成功了。这里有 2 个问题需要你注意。

第一，我是用门店的经度和纬度值，来表示门店的地理位置。要获得门店的地理位置，你可以通过地图数据获得，但是这样做成本比较高。还有一种办法，就是通过大厂提供的免费的 API 接口获取，比如百度地图 API，这样比较简单。

第二，WKT 格式表达一个点的时候，在关键字 POINT 后面的括号中，要先写这个点的纬度，后写这个点的经度。这与一般的习惯相反，不要搞错。而且，经度值与纬度值之间用空格隔开，而不是用逗号。

准备好各门店的位置信息之后，我们就可以通过空间函数来计算距离了。

假设我们获取到客户所在位置的地理坐标为：纬度是 39.994671，经度是 116.330788，那么，我们就可以通过下面的 SQL 语句查询到这个位置与各个门店的距离：

mysql> SELECT branchid,branchname,st_distance_sphere(ST_GeomFromText('POINT(39.994671 116.330788)',4326),address) AS distance
    -> FROM demo.mybranch;
+----------+------------+--------------------+
| branchid | branchname | distance           |
+----------+------------+--------------------+
|        1 | 西直门店   |  6505.859589677078 |
|        2 | 东直门店   |  10604.07854447186 |
|        3 | 崇文门店   |  13123.76779555601 |
|        4 | 五道口店   |  1313.741752971374 |
|        5 | 清河店     | 27943.114458834025 |
+----------+------------+--------------------+
5 rows in set (0.00 sec)

结果显示，所有门店与客户位置之间的距离，都已经计算出来了。

需要注意的是，这个结果中查出来的距离是以米为单位的。根据这个查询的结果，五道口店的球面最短距离只有 1313 米，也就是 1.3 公里，满足送货上门的条件。其他门店的最短距离都在 5 公里以上。因此，应该从五道口店送货上门。到这里，超市经营者的要求就得到了满足。

好了，到这里，我们已经知道了如何定位一个空间位置，以及如何计算 2 个位置之间的距离。接下来，我们就再来了解下如何通过创建索引来提升空间数据的查询效率。

用空间数据创建索引

对于空间数据的查询，一般分为 2 种：一种是查询包括一个点的空间对象；另外一种是查询与某一个区域有交集的空间对象。

为了提高查询的速度，就可以用空间数据字段创建空间索引。MySQL 支持使用 InnoDB 存储引擎，或者是 MyISAM 存储引擎的数据表，创建空间索引。

我们有三种创建空间索引的方式。

第一，我们可以在创建数据表时创建空间索引，语法结构是：

CREATE TABLE 表名 (字段名 GEOMETY NOT NULL SRID 4326，SPATIAL INDEX(空间数据字段名));

第二种是在修改表时创建空间索引，语法结构是：

ALTER TABLE 表名 ADD SPATIAL INDEX (空间数据字段名);

第三种是单独创建空间索引，语法结构是：

CREATE SPATIAL INDEX 索引名 ON 表名(空间数据字段名);

这里要提醒你注意的是：空间索引与普通索引不同，必须要用关键字 SPATIAL，而且，创建空间索引的空间数据字段不能为空。空间索引创建一个 R 树索引，支持区域扫描，对提升空间数据查询的效率很有帮助。

我还是以刚才的超市门店位置数据为例，来简单说明一下如何用空间类型字段创建空间索引。我我们先用下面的代码，单独创建一下空间索引：

mysql> CREATE SPATIAL INDEX index_address ON demo.mybranch(address);
Query OK, 0 rows affected, 1 warning (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 1

结果显示，创建成功了。现在我们来确认一下，刚才创建的空间索引能不能起到优化查询的作用。

在 MySQL 中，只有在 WHERE 条件筛选语句中包含类似 MBRContains() 和 MBRWithin() 这样的函数，空间索引才会起作用。

现在，我们来借助一个小例子验证一下，我们创建的空间索引能不能对空间数据的查询起到优化的作用。

假设我们创建了一个多边形的地理区域，代码如下所示：

mysql> SET @poly =
    -> 'Polygon((
    '> 40.016712 116.319618,
    '> 40.016712 116.412773,
    '> 39.907024 116.412773,
    '> 39.907024 116.319618,
    '> 40.016712 116.319618))';
Query OK, 0 rows affected (0.00 sec)

这里有个坑，你一定要注意，多边形的区域起点和终点一定要一致，否则就不是一个封闭的区域，MySQL 就会提示非法的地理位置数据。

然后，我们查询下有多少门店在这个区域中。你可以用下面的代码来实现：

mysql> SELECT branchid,branchname FROM demo.mybranch
    -> WHERE MBRContains(ST_GeomFromText(@poly,4326),address);
+----------+------------+
| branchid | branchname |
+----------+------------+
|        1 | 西直门店   |
|        4 | 五道口店   |
+----------+------------+
2 rows in set (0.00 sec)

结果显示，有 2 个门店在这个地理区域范围内。下面我们用查询分析语句来分析一下这个查询，看看有没有用到空间索引：

mysql> EXPLAIN SELECT * FROM demo.mybranch
    -> WHERE MBRContains(ST_GeomFromText(@poly,4326),address);
+----+-------------+----------+------------+-------+---------------+---------------+---------+------+------+----------+-------------+
| id | select_type | table    | partitions | type  | possible_keys | key           | key_len | ref  | rows | filtered | Extra       |
+----+-------------+----------+------------+-------+---------------+---------------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | mybranch | NULL       | range | index_address | index_address | 34      | NULL |    5 |   100.00 | Using where |
+----+-------------+----------+------------+-------+---------------+---------------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

结果显示，我们创建的索引起了作用，MySQL 优化器使用空间索引进行了区域扫描，提高了查询的效率。

总之，MySQL 为空间数据提供了一套完整的解决方案。从空间数据类型到空间函数，再到空间索引，可以让我们像处理普通数据那样，来存储、处理和查询空间数据。这样一来，开发基于空间数据的应用就十分方便了。

总结

这节课，我给你介绍了 MySQL 的空间数据，包括空间数据类型 POINT，空间数据处理函数 ST_Distance_Sphere()、MBRContains()、MBRWithin() 和 ST_GeomFromText()，以及创建空间索引的方法。

MySQL 的空间数据是非常有用的数据类型，通过各种空间数据处理函数，可以开发出路径规划、线路导航、自动驾驶等各种应用。虽然现在还存在数据量大、查询效率比较低等问题，但是通过不断使用新的技术，比如空间索引中引入 R 树索引等，进步是非常明显的。

如果你在实际工作中，需要开发基于空间数据的应用，课下可以再参考下链接中的内容。

思考题

在这节课中，我定义的门店表（demo.mybranch）中，地址的空间数据类型是几何类型 GEOMETRY，请你改用点类型 POINT，完成从创建表到查询最近门店的全部操作。

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

27 | 手把手带你设计一个完整的连锁超市信息系统数据库（上）

你好，我是朱晓峰。

从创建第一个 MySQL 数据库开始到现在，我们已经学完了 MySQL 的核心操作。最后这两节课，我想带你实际设计一个超市信息系统的数据库。毕竟，设计数据库很考验我们综合运用单个技术点的能力。所以，通过这个项目，我不仅会带你把前面的内容串联起来，而且还会教你设计数据库的通用思路。

为什么选择超市项目呢？一方面呢，超市的场景与我们的日常生活密切相关，你比较容易理解其中的业务逻辑，另一方面，超市的业务又相当复杂，几乎能用到我们学到的所有知识点，利于我们对前面学过的内容进行整合。

今天，我就带你一起，从需求分析开始入手，一直到容灾和备份，完成一个全流程的连锁超市数据库设计。这节课，我会主要给你讲解需求分析、ER 模型、分库分表和数据库设计方案。我们做项目，首先要从大处着眼，把握方向，这样才不容易出现大的偏差。下节课，我们再设计具体的细节，比如创建数据表、外键约束，设计灾备方案和备份数据等。

在开始设计之前，咱们得先了解一下项目背景。

随着互联网使用的日益广泛，传统的桌面管理系统的数据不能互通、资源利用率低等弊端越来越明显，显然不能满足用户的需求了。因此，我们需要开发一款基于云服务的连锁超市管理信息系统。具体的要求是：

基于浏览器，无需安装，账号开通即可使用，方便快捷；

数据部署在云端，由运营商负责维护，安全可靠；

用户无需自备服务器，只需租用信息服务，资源利用率高。

知道了具体要求，那该怎么进行设计呢？下面我带你来分析一下。

如何设计数据结构？

用户账号开通即可使用，所以必然要设计分层的数据结构；数据要部署在云端，所以必然要使用云。根据这些要求，我们可以设计一个基于云服务的 2 层数据结构，这个结构的示意图如下所示：

我来解释一下图中展示的内容。

首先，你可以看到，所有的系统资源和服务都部署在云端。

其次，我们来看下数据结构层面，主要有 2 层。

第一层是商户。每个入驻的商户都有一个组号，所有与这个商户有关的数据，通过这个组号进行识别。

第二层是分支机构。分支机构从属于商户，相同商户的分支机构有相同的组号。分支机构分为几种，包括总部、门店、配送中心等。门店又分为直营店和加盟店。每个分支机构有一个机构编号，同一分支机构的数据，有相同的组号和机构编号。

这样一来，新商户只需要开通账号，分配一个新的组号，就可以使用了。组号用于隔离商户之间的数据，使商户之间互相不影响。

最后，数据由我们统一进行运维，安全性有保障。商户自己不需要采购服务器，只需租用服务，资源的利用率更高。

系统的整体结构设计思路有了，那具体在应用层面如何实现呢？

我先用一张图来展示具体的应用构成：

这个图展示了应用的 3 个层级。

展现层：包括门店收款机 App、移动端的手机 App、小程序，以及通过浏览器访问的后台管理部分。

服务层：包括云端的销售模块、库存模块、营运模块、会员模块等。

数据层：MySQL 数据库。

门店收款 App、移动端的手机 App，小程序等与数据库设计无关，我就不多说了。下面我重点介绍一下后台管理部分下面的服务层和数据层的相关内容。

服务层包括了销售、库存、营运、会员等管理模块。下面我就以库存管理中的盘点模块为例，详细介绍一下。因为这个模块比较简单，容易理解。

盘点，简单来说，就是把超市仓库里的商品都数一遍，然后跟电脑里的库存数比对，看看有没有不对的地方。实际数出来的库存数量叫做盘存数量，电脑中的库存数量叫做结存数量，比对的结果叫做盈亏数量。要是盘存数量比结存数量多，叫盘盈，否则叫做盘亏。

盘点操作是超市库存管理模块中的一个重要环节，是掌握实际库存的唯一办法。盘点盈亏数据也是衡量超市管理水平的重要指标。

盘点作业一般都在晚上门店营业结束以后进行。这也很好理解，毕竟，在白天营业的过程中，商品不断被顾客取走，又不断得到补充，库存处于一种变化状态，无法获取准确数据。

下面我来介绍下盘点的步骤。

先生成一张盘点表，把当前系统中的库存数量提取出来，获得结存数量；

把员工实际数出来的库存数据录入盘点表中，获得盘存数量；

计算盈亏数量，公式是“盈亏数量 = 盘存数量 - 结存数量”；

数据确认无误后，验收盘点表，并调整当前库存：库存数量 = 库存数量 + 盈亏数量。

经过这些操作，系统中的库存数量与实际的库存数量就一致了，盘点盈亏也被记录下来了，体现在日报等报表中，超市经营者可以进行查看，为经营决策提供依据。

介绍完了盘点业务，现在回到数据库设计的主题上来，看看如何把盘点业务用数据表的形式表现出来。

盘点业务都是在门店进行，由员工实际操作，对仓库中的商品进行清点。因此，盘点业务模块中肯定要包含员工、门店、仓库、商品等实体。这个时候，我们就可以使用 ER 模型这个工具，来理清盘点模块的业务逻辑。

盘点模块的 ER 模型

我先把模型直接展示给你，一会儿我再带你具体分析一下。

首先，我们来分析下模型中的实体和关系。

这个 ER 模型中包括了 5 个实体，分别是：

商户

门店

员工

商品

仓库

其中，商户和商品是强实体，门店、仓库和员工是弱实体。

这个 ER 模型中还包含了 5 个关系，我们按照 1 对多和多对多来分下类。

1 对多：

商户与门店的从属关系

门店与员工的雇佣关系

门店与仓库的拥有关系

多对多：

仓库与商品的库存关系

仓库、商品和员工参与的盘点关系

接下来，我们再分析一下这 5 个实体各自的属性。

商户：组号、名称、地址、电话、联系人。

门店：组号、门店编号、名称、地址、电话、类别。

员工：组号、门店编号、工号、名称、身份证、电话、职责。

仓库：组号、门店编号、仓库编号、类别。

商品：组号、条码、名称、规格、单位、价格。

除此之外，还有 2 个多对多关系的属性。

仓库与商品的库存关系：库存数量。

仓库、商品和员工参与的盘点关系：盘存数量、结存数量和盈亏数量。

通过建立 ER 模型，我们理清了业务逻辑。接下来，我们就可以把盘点业务中这些实体和关系落实到实际的数据表了。

ER 模型转换成数据表

你还记得在第 23 讲里学的转换规则吗？强实体和弱实体转换成独立的数据表，多对多的关系转换成独立的数据表，1 对多的关系转换成外键约束。

首先，我们把强实体转换成独立的数据表。

商户表（demo.enterprice）：

商品信息表（demo.goodsmaster）：

接着，我们把弱实体转换成独立的数据表。

门店表（demo.branch）：

员工表（demo.employee）：

仓库表（demo.stockmaster）：

第三步，把多对多的关系转换成独立的数据表。

库存表（demo.inventory）：

盘点关系可以转换成 2 个表，分别是盘点单头表和盘点单明细表，这样做是为了满足第三范式的要求，防止冗余。

盘点单头表（demo.invcounthead）：

盘点单明细表（demo.invcountdetails）：

这样一来，我们就把 ER 模型中的实体和多对多的关系，转换成了独立的数据表。

这里你要注意的是，我在盘点单明细表中保留了组号和门店编号。这是因为，虽然这 2 个字段是冗余数据，但是可以加快查询的速度，经过权衡利弊，最后决定还是加上。

在我把 1 对多的关系转换成外键约束之前，我们还要进行一项重要的工作：分库分表。因为外键约束与数据表以及表中的字段有关，分库分表会影响到表和表中的字段。

而且外键约束需要在创建数据表的时候创建，所以咱们下节课和创建数据表一起讲，这节课我们先学习下分库分表。

在前面的课程中，每节课我们都是以具体技术点为核心展开的。而分库分表，只有在进行数据库系统整体设计的阶段才会用到。所以，今天我就结合咱们这个项目的系统设计，来给你具体讲一讲如何进行分库分表。

为什么要分库分表呢？当数据量足够大的时候，即便我们把索引都建好，系统资源调优到极致，仍然有可能遇到运行缓慢、CPU 使用率居高不下的情况。因为单个数据库中单个表的数据量高到一定程度，超过了系统的承载能力。

面对这种情况，我们有 2 种选择：一种是购买更多的资源，增加内存，增加 CPU 的算力，但是这样会增加系统的成本。这个时候，我们就可以用另一种方法，也就是接下来我要讲的分库分表。

如何进行分库分表？

所谓的分库分表，其实就是把大的数据库拆成小数据库，把大表拆成小表，让单一数据库、单一数据表的数据量变小。这样每次查询时，需要扫描的数据量减少了，也就达到了提升查询执行效率的目的。

分库分表又可以分成垂直分表、垂直分库、水平分库和水平分表。

垂直分表

所谓垂直分表，就是把一个有很多字段的表，按照使用频率的不同，拆分成 2 个或多个表。

为了帮助你理解，还是用我们的盘点模块中的表来演示说明一下。

每个商户都有一个自己的商品信息表，数据量比较大。所以，我们可以拆分下这个表。我们把经常使用的字段条码、名称和价格，拆分成商品常用信息表（demo.goods_o）；把剩下的字段，也就是规格和单位拆分成商品不常用信息表（demo.goods_f）。

商品常用信息表：

商品不常用信息表：

至于商户表、门店表、 员工表、仓库表，这些表的数据量有限，不需要拆分。库存表、盘点单头表和盘点单明细表，虽然数据量大，但是评估之后，我们发现字段的使用频率都很高，拆分的价值不大，所以也不需要拆分。

下面我再介绍一下什么是垂直分库。

垂直分库

垂直分库的意思是，把不同模块的数据表分别存放到不同的数据库中。这样做的好处是，每个数据库只保存特定模块的数据，系统只有用到特定模块的数据时，才会访问这个数据库。这样就减少了数据库访问的次数，就相当于是把数据访问的流量分散了。

这个可能不太好理解，我来画一个简单的示意图：

在这个图中，数据不再存储在一个数据库中，而是根据业务模块的不同，分别存储在不同的数据库中，比如销售数据库、库存数据库、营运数据库、会员数据库等。这样一来，业务模块可以主要与自己的数据库进行数据交互。业务内的数据交互多了，业务与业务之间的数据交互就可以大大减少了。

这样做的好处主要有 3 个：

单个数据库的数据量减小了；

单个数据库的访问流量分散了；

系统整体的故障风险减小了。

下面我再介绍一下什么是水平分库和水平分表。

水平分库和水平分表

当垂直分表已经穷尽，垂直分库也不能再拆分的时候，我们还可以做水平分库和水平分表。

水平分表的意思是，把数据表的内容，按照一定的规则拆分出去。

盘点数据会不断累积，数据量越来越大。为了提升系统效率，我们制定了水平分表的策略。

第一步，我们把盘点单头表和盘点单明细表水平拆分：把验收处理过的盘点单头表和盘点单明细表拆分到盘点单头历史表和盘点单明细历史表。

这样做的好处是，盘点单头表和盘点单明细表经常需要进行插入、删除和修改操作，只保留当前正在处理的数据，可以提升效率，避免在一个不断增长的大表中进行 DML 操作。

而盘点单头历史表和盘点单明细历史表中的数据虽然不断增长，但数据不会修改，只进行查询操作。用经常作为筛选条件的字段创建索引，可以大大加快查询的速度。

拆分出来的盘点单头历史表（demo.invcountheadhist）与盘点单头表类似，不同之处是增加了验收人编号（confirmer）和验收日期（confirmationdate）。

盘点单头表历史表：

拆分出来的盘点单明细历史表（demo.invcountdetailshist）的字段则与盘点单明细表一样。

盘点单明细历史表：

第二步，我们把组号大于 500、小于 1000 的商户数据，拆分到另外的数据表里进行保存。这里的数字是我们根据对入驻平台商户的数据量进行评估之后得出的，在实际工作中，你可以根据实际情况来决定。原则是：确保单个数据表中的数据量适中，不会成为操作的瓶颈。

这样，我们就完成了对盘点模块中数据表的水平拆分。

接下来，我们来进行水平分库。水平分库的目的是使单个数据库中的数据量不会太大。这样可以确保我们设计出来的数据库，在大数据环境下，也能高效运行。

水平分库的意思与水平分表类似，就是按照一定的规则，把数据库中的数据拆分出去，保存在新的数据库当中。

新的数据库可以在相同的服务器上，也可以在不同的服务器上。比如，我们可以把组号大于 500、小于 1000 的用户数据拆分出来，保存到新的服务器的数据库中。不过，保存到新的服务器，也就意味着增加系统的开销。

因此，我们可以以 500 个商户为单位，每 500 个商户，在相同的服务器上创建一套新的数据库；每 5000 个商户，购置新的服务器。这样，我们就完成了对数据库进行分库的设计。

总结

这节课，我们一起设计了一个基于云服务的连锁超市管理系统数据库。你要重点掌握如何进行需求分析、如何把分析的结果转换成数据库的设计，以及如何在总体设计的阶段通过使用分库分表使设计出来的数据库能够处理大量数据。

在实际项目中，你要重点关注 3 个方面。

第一，要充分理解项目需求。有的时候，客户自己也不清楚自己的需求。这个时候，就需要你帮助客户理清思路。你可以把客户的需求用图表等方式整理出来，再跟客户一起讨论。在这个阶段，投入较多的时间是值得的，如果等系统开发完成之后再改，成本就很高了。

第二，使用 ER 模型工具来整理思路，可以提高效率，提高设计的质量。

第三，要充分考虑到系统投入运行之后的承载能力。如果有可能处理大量数据，就需要考虑分库分表的策略。

最后，还有几点你需要注意下。

分库分表的策略，需要在设计阶段完成。如果缺乏整体分库分表的策略而匆忙上线，遇到瓶颈时再解决，花费的成本要远远高于在设计阶段投入的时间成本。

分库分表的策略，必然带来开发和运维方面成本的提升，因此，你需要在设计阶段就有一个整体规划。比如，在类的设计里面，用正则表达式计算访问的服务器、数据库和数据表的名称。

分库分表一般适用于比较大的项目，如果你开发的应用数据量小，系统规模有限，团队成员不多，不如就用一个服务器、一个数据库。因为分库分表对数据量小的项目没有什么作用，却会大大提升开发的复杂度，增加开发、运维和项目管理的成本。所以，你要综合考虑利与弊。

思考题

假设我有一个数据库 demo，当中有一个商品流水表（demo.trans），示例如下：

在设计阶段，如果预见到未来数据量会非常庞大，你会如何制定分库分表策略？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你分享给你的朋友或同事，我们下节课见。

28 | 手把手带你设计一个完整的连锁超市信息系统数据库（下）

你好，我是朱晓峰。

上节课，我们完成了项目的需求分析和业务流程的梳理，为设计数据库做好了准备工作，接下来我们就可以开始具体的设计了。所以，今天，我就带你来建库建表、创建外键约束、视图、存储过程和触发器，最后制定容灾和备份的策略，从而完成一个完整的连锁超市项目数据库的设计，帮助你提高设计高效可靠的数据库的能力。

首先，我们一起来创建数据库和数据表。

如何创建数据库和数据表？

经过上节课的分库分表操作，我们把数据库按照业务模块，拆分成了多个数据库。其中，盘点模块中的数据表分别被拆分到了营运数据库（operation）和库存数据库（inventory）中。

下面我们就按照上节课的分库策略，分别创建营运数据库和库存数据库：

mysql> CREATE DATABASE operation;
Query OK, 1 row affected (0.03 sec)

mysql> CREATE DATABASE inventory;
Query OK, 1 row affected (0.02 sec)

接下来，我们来分别创建下这两个数据库中的表。

商户表、门店表、员工表、商品常用信息表和商品不常用信息表从属于营运数据库，我们先把这 5 个表创建出来。

商户表（operation.enterprice）：

mysql> CREATE TABLE operation.enterprice
-> (
-> groupnumber SMALLINT PRIMARY KEY,  -- 组号
-> groupname VARCHAR(100) NOT NULL,   -- 名称
-> address TEXT NOT NULL,             -- 地址
-> phone VARCHAR(20) NOT NULL,        -- 电话
-> contactor VARCHAR(50) NOT NULL     -- 联系人
-> );
Query OK, 0 rows affected (0.05 sec)

门店表（operation.branch）：

mysql> CREATE TABLE operation.branch
-> (
-> branchid SMALLINT PRIMARY KEY,        -- 门店编号
-> groupnumber SMALLINT NOT NULL,        -- 组号
-> branchname VARCHAR(100) NOT NULL,     -- 门店名称
-> address TEXT NOT NULL,                -- 地址
-> phone VARCHAR(20) NOT NULL,           -- 电话
-> branchtype VARCHAR(20) NOT NULL,      -- 门店类别
-> CONSTRAINT fk_branch_enterprice FOREIGN KEY (groupnumber) REFERENCES operation.enterprice(groupnumber)  -- 外键约束，组号是外键
-> );
Query OK, 0 rows affected (0.07 sec)

员工表（operation.employee）：

mysql> CREATE TABLE operation.employee
-> (
-> employeeid SMALLINT PRIMARY KEY,     -- 员工编号
-> groupnumber SMALLINT NOT NULL,       -- 组号
-> branchid SMALLINT NOT NULL,          -- 门店编号
-> workno VARCHAR(20) NOT NULL,         -- 工号
-> employeename VARCHAR(100) NOT NULL,  -- 员工名称
-> pid VARCHAR(20) NOT NULL,            -- 身份证
-> address VARCHAR(100) NOT NULL,       -- 地址
-> phone VARCHAR(20) NOT NULL,          -- 电话
-> employeeduty VARCHAR(20) NOT NULL,   -- 职责
-> CONSTRAINT fk_employee_branch FOREIGN KEY (branchid) REFERENCES operation.branch(branchid)
-> );
Query OK, 0 rows affected (0.07 sec)

商品常用信息表（operation.goods_o）：

mysql> CREATE TABLE operation.goods_o
-> (
-> itemnumber MEDIUMINT PRIMARY KEY,   -- 商品编号
-> groupnumber SMALLINT NOT NULL,      -- 组号
-> barcode VARCHAR(50) NOT NULL,       -- 条码
-> goodsname TEXT NOT NULL,            -- 名称
-> salesprice DECIMAL(10,2) NOT NULL,  -- 售价
-> PRIMARY KEY (groupnumber,itemnumber)-- 主键
-> );
Query OK, 0 rows affected (0.06 sec)

商品不常用信息表（operation.goods_f）：

mysql> CREATE TABLE operation.goods_f
-> (
-> itemnumber MEDIUMINT PRIMARY KEY,       -- 商品编号
-> groupnumber SMALLINT NOT NULL,          -- 组号
-> specification TEXT NOT NULL,            -- 规格
-> unit VARCHAR(20) NOT NULL,              -- 单位
-> PRIMARY KEY (groupnumber,itemnumber)    -- 主键
-> );
Query OK, 0 rows affected (0.06 sec)

好了，现在我们来创建库存数据库中的表。仓库表、库存表、盘点单头表、盘点单明细表、盘点单头历史表和盘点单明细历史表，从属于库存数据库。

仓库表（inventory.stockmaster）：

mysql> CREATE TABLE inventory.stockmaster
-> (
-> stockid SMALLINT PRIMARY KEY,     -- 仓库编号
-> groupnumber SMALLINT NOT NULL,    -- 组号
-> branchid SMALLINT NOT NULL,       -- 门店编号
-> stockname VARCHAR(100) NOT NULL,  -- 仓库名称
-> stockkind VARCHAR(20) NOT NULL,   -- 仓库种类
-> CONSTRAINT fk_stock_branch FOREIGN KEY (branchid) REFERENCES operation.branch(branchid)           -- 外键约束，门店编号是外键
-> );
Query OK, 0 rows affected (0.07 sec)

库存表（inventory.inventory）：

mysql> CREATE TABLE inventory.inventory
-> (
-> id INT PRIMARY KEY AUTO_INCREMENT,       -- 库存编号
-> groupnumber SMALLINT NOT NULL,           -- 组号
-> branchid SMALLINT NOT NULL,              -- 门店编号
-> stockid SMALLINT NOT NULL,               -- 仓库编号
-> itemnumber MEDIUMINT NOT NULL,           -- 商品编号
-> itemquantity DECIMAL(10,3) NOT NULL      -- 商品数量
-> );
Query OK, 0 rows affected (0.06 sec)

盘点单头表（inventory.invcounthead）：

mysql> CREATE TABLE inventory.invcounthead
-> (
-> listnumber INT PRIMARY KEY,              -- 单号
-> groupnumber SMALLINT NOT NULL,           -- 组号
-> branchid SMALLINT NOT NULL,              -- 门店编号
-> stockid SMALLINT NOT NULL,               -- 仓库编号
-> recorder SMALLINT NOT NULL,              -- 录入人编号
-> recordingdate DATETIME NOT NULL          -- 录入时间
-> );
Query OK, 0 rows affected (0.07 sec)

盘点单明细表（inventorry.invcountdetails）：

mysql> CREATE TABLE inventory.invcountdetails
-> (
-> id INT PRIMARY KEY AUTO_INCREMENT,   -- 明细编号
-> listnumber INT NOT NULL,             -- 单号
-> groupnumber SMALLINT NOT NULL,       -- 组号
-> branchid SMALLINT NOT NULL,          -- 门店编号
-> stockid SMALLINT NOT NULL,           -- 仓库编号
-> itemnumber MEDIUMINT NOT NULL,       -- 商品编号
-> accquant DECIMAL(10,3) NOT NULL,     -- 结存数量
-> invquant DECIMAL(10,3) NOT NULL,     -- 盘存数量
-> plquant DECIMAL(10,3) NOT NULL       -- 盈亏数量
-> );

Query OK, 0 rows affected (0.07 sec)

盘点单头历史表（inventory.invcountheadhist）：

mysql> CREATE TABLE inventory.invcountheadhist
-> (
-> listnumber INT PRIMARY KEY,       -- 单号
-> groupnumber SMALLINT NOT NULL,    -- 组号
-> branchid SMALLINT NOT NULL,       -- 门店编号
-> stockid SMALLINT NOT NULL,        -- 仓库编号
-> recorder SMALLINT NOT NULL,       -- 录入人编号
-> recordingdate DATETIME NOT NULL,  -- 录入时间
-> confirmer SMALLINT NOT NULL,      -- 验收人编号
-> confirmationdate DATETIME NOT NULL -- 验收时间
-> );
Query OK, 0 rows affected (0.10 sec)

盘点单明细历史表（inventorry.invcountdetailshist）：

mysql> CREATE TABLE inventory.invcountdetailshist
-> (
-> id INT PRIMARY KEY AUTO_INCREMENT,   -- 明细编号
-> listnumber INT NOT NULL,             -- 单号
-> groupnumber SMALLINT NOT NULL,       -- 组号
-> branchid SMALLINT NOT NULL,          -- 门店编号
-> stockid SMALLINT NOT NULL,           -- 仓库编号
-> itemnumber MEDIUMINT NOT NULL,       -- 商品编号
-> accquant DECIMAL(10,3) NOT NULL,     -- 结存数量
-> invquant DECIMAL(10,3) NOT NULL,     -- 盘存数量
-> plquant DECIMAL(10,3) NOT NULL       -- 盈亏数量
-> );
Query OK, 0 rows affected (1.62 sec)

至此，我们完成了创建数据库和数据表的工作。为了提高查询的速度，我们还要为数据表创建索引。下面我们就来实际操作一下。

如何创建索引？

索引对提升数据查询的效率作用最大，没有之一。我们创建索引的策略是：

所有的数据表都必须创建索引；

只要是有可能成为查询筛选条件的字段，都必须创建索引。

这样做的原因是，当数据量特别大的时候，如果没有索引，一旦出现大并发，没有索引的表很可能会成为访问的瓶颈。而且这个问题十分隐蔽，不容易察觉，系统也不会报错，但是却会消耗大量的 CPU 资源，导致系统事实上的崩溃。

在之前的操作中，我们一共创建了 11 个数据表，下面我们就来一一为这些表创建索引。

商户表中的组号字段，常被用于筛选条件。我们用商户表的组号字段为商户表创建索引，代码如下所示：

mysql> CREATE INDEX index_enterprice_groupname ON operation.enterprice (groupname);
Query OK, 0 rows affected (0.06 sec)
Records: 0 Duplicates: 0 Warnings: 0

门店表的组号字段也常被用作筛选条件，所以，我们用组号字段为门店表创建索引，代码如下所示：

mysql> CREATE INDEX index_branch_groupnumber ON operation.branch (groupnumber);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

除了组号字段，门店名称字段也常用在查询的筛选条件中，下面我们就用门店名称字段为门店表创建索引：

mysql> CREATE INDEX index_branch_branchname ON operation.branch (branchname);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

门店类别字段也是用作筛选条件的字段之一，我们可以用门店类别字段为门店表创建索引，如下所示：

mysql> CREATE INDEX index_branch_branchtype ON operation.branch (branchtype);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

在员工表中，组号、门店编号、身份证号、电话和职责字段常被用作查询的筛选条件，下面我们就分别用这几个字段为员工表创建索引。

第一步，用组号字段为员工表创建索引：

mysql> CREATE INDEX index_employee_groupnumer ON operation.employee (groupnumber);
Query OK, 0 rows affected (0.06 sec)
Records: 0  Duplicates: 0  Warnings: 0

第二步，用门店编号字段为员工表创建索引：

mysql> CREATE INDEX index_employee_branchid ON operation.employee (branchid);
Query OK, 0 rows affected (0.07 sec)
Records: 0  Duplicates: 0  Warnings: 0

第三步，用身份证字段为员工表创建索引：

mysql> CREATE INDEX index_employee_pid ON operation.employee (pid);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

第四步，用电话字段为员工表创建索引：

mysql> CREATE INDEX index_employee_phone ON operation.employee (phone);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

最后，我们用职责字段为员工表创建索引：

mysql> CREATE INDEX index_employee_duty ON operation.employee (employeeduty);
Query OK, 0 rows affected (0.09 sec)
Records: 0  Duplicates: 0  Warnings: 0

对于商品常用信息表（operation.goods_o），我们发现，组号和售价字段常被用在查询筛选条件语句中，所以，我们分别用这两个字段为商品常用信息表创建索引。

首先，用组号字段为商品常用信息表创建索引，如下所示：

mysql> CREATE INDEX index_goodso_groupnumber ON operation.goods_o (groupnumber);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后，用价格字段为商品常用信息表创建索引，如下所示：

mysql> CREATE INDEX index_goodso_salesprice ON operation.goods_o (salesprice);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

对于商品不常用信息表，只有组号字段经常用在查询的筛选条件中，所以，我们只需要用组号字段为商品不常用信息表创建索引。代码如下：

mysql> CREATE INDEX index_goodsf_groupnumber ON operation.goods_f (groupnumber);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

到这里，我们就完成了为营运数据库中的表创建索引的工作，下面我们来为库存数据库中的表创建索引。

首先是仓库表。这个表中经常被用做筛选条件的字段，是组号和门店编号字段。

我们先用组号字段为仓库表创建索引，代码如下：

mysql> CREATE INDEX index_stock_groupnumber ON inventory.stockmaster (groupnumber);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后我们用门店编号字段为仓库表创建索引，代码如下：

mysql> CREATE INDEX index_stock_branchid ON inventory.stockmaster (branchid);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

接下来，我们为库存表创建索引。库存表中常用于筛选的字段有组号、门店编号和商品编号字段。

我们先用组号字段来创建索引，代码如下：

mysql> CREATE INDEX index_inventory_groupnumber ON inventory.inventory (groupnumber);
Query OK, 0 rows affected (0.11 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后，我们用门店编号字段创建索引，代码如下：

mysql> CREATE INDEX index_inventory_branchid ON inventory.inventory (branchid);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

最后用商品编号字段创建索引，代码如下：

mysql> CREATE INDEX index_inventory_itemnumber ON inventory.inventory (itemnumber);
Query OK, 0 rows affected (0.07 sec)
Records: 0  Duplicates: 0  Warnings: 0

盘点单头表也需要创建索引，常用的筛选字段是门店编号。那么，我们就用门店编号为盘点单头表创建索引，代码如下：

mysql> CREATE INDEX index_invcounthead_branchid ON inventory.invcounthead (branchid);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

盘点单头明细表中常用于筛选的字段有门店编号和商品编号，我们分别用这 2 个字段创建索引。

首先是用门店编号字段创建索引，代码如下：

mysql> CREATE INDEX index_invcountdetails_branchid ON inventory.invcountdetails (branchid);
Query OK, 0 rows affected (0.08 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后是用商品编号字段创建索引，代码如下：

mysql> CREATE INDEX index_invcountdetails_itemnumber ON inventory.invcountdetails (itemnumber);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

盘点单头历史表数据量比较大，主要用于查询，常用的筛选字段有门店编号和验收时间。我们先用门店编号字段创建索引，代码如下：

mysql> CREATE INDEX index_invcounthaedhist_branchid ON inventory.invcountheadhist (branchid);
Query OK, 0 rows affected (0.06 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后用验收时间字段创建索引，代码如下：

mysql> CREATE INDEX index_invcounthaedhist_confirmationdate ON inventory.invcountheadhist (confirmationdate);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

盘点单明细历史表是整个盘点模块中数据量最大的表，主要用于查询，索引对提升查询效率来说非常重要。要是忘了创建索引，很可能成为整个系统的瓶颈。

这个表中用作筛选条件的字段主要有门店编号和商品编号，我们分别用它们创建索引。首先是门店编号字段，创建索引的代码如下：

mysql> CREATE INDEX index_invcountdetailshist_branchid ON inventory.invcountdetailshist (branchid);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

然后是商品编号字段，创建索引的代码如下：

mysql> CREATE INDEX index_invcountdetailshist_itemnumber ON inventory.invcountdetailshist (itemnumber);
Query OK, 0 rows affected (0.04 sec)
Records: 0  Duplicates: 0  Warnings: 0

到这里，索引就全部创建完成了。

由于我们把盘点单拆分成了单头和明细两个表，在应用中，经常要用到单头和明细的全部信息，所以，为了使代码更加简洁，查询更加方便，我们要为这两个表创建视图。

如何创建视图？

首先，我们为盘点单创建视图，代码如下：

mysql> CREATE VIEW view_invcount
    -> AS
    -> SELECT a.*,b.itemnumber,b.accquant,b.invquant,b.plquant
    -> FROM inventory.invcounthead AS a
    -> JOIN
    -> inventory.invcountdetails AS b
    -> ON (a.listnumber=b.listnumber);
Query OK, 0 rows affected (0.04 sec)

然后，我们为盘点单历史表创建视图，代码如下：

mysql> CREATE VIEW view_invcounthist
    -> AS
    -> SELECT a.*,b.itemnumber,b.accquant,b.invquant,b.plquant
    -> FROM inventory.invcountheadhist AS a
    -> JOIN inventory.invcountdetailshist AS b
    -> ON (a.listnumber=b.listnumber);
Query OK, 0 rows affected (0.02 sec)

如何创建存储过程？

在整个盘点模块中，有一个核心的计算模块，就是盘点单验收模块。这个计算模块，每次盘点结束都会被调用。为了提升执行效率，让代码更加模块化，使代码的可读性更好，我们可以把盘点表验收这个模块的数据处理逻辑，用存储过程的方式保存在服务器上，以方便应用程序进行调用。

下面我具体介绍存储过程的入口参数和数据处理逻辑。

存储过程的入口参数是单号和验收人的员工编号。存储过程的数据处理逻辑是：先用盈亏数量调整库存，计算方式是新库存 = 老库存 + 盈亏数量；然后把盘点单数据移到盘点单历史中去。

把盘点单明细表中的数据插入到盘点单明细历史表中；

把盘点单头表中的数据，插入到盘点单头历史表中；

删除盘点单明细表中的数据；

删除盘点单头表中的数据。

按照这个参数定义和计算逻辑，我们就可以用下面的代码来创建存储过程了：

CREATE DEFINER=`root`@`localhost` PROCEDURE `invcountconfirm`(mylistnumber INT,myconfirmer SMALLINT)
BEGIN
DECLARE done INT DEFAULT FALSE;
DECLARE mybranchid INT;
DECLARE myitemnumber INT;
DECLARE myplquant DECIMAL(10,3);
DECLARE cursor_invcount CURSOR FOR
SELECT branchid,itemnumber,plquant
FROM inventory.invcountdetails
WHERE listnumber = mylistnumber;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
DECLARE EXIT HANDLER FOR SQLEXCEPTION ROLLBACK;

START TRANSACTION;
 OPEN cursor_invcount; -- 打开游标
 FETCH cursor_invcount INTO mybranchid,myitemnumber,myplquant; -- 读入第一条记录
REPEAT
  UPDATE inventory.inventory
  SET itemquantity = itemquantity + myplquant    -- 更新库存
        WHERE itemnumber = myitemnumber
        AND branchid = mybranchid;
  FETCH cursor_invcount INTO mybranchid,myitemnumber,myplquant; -- 读入下一条记录
 UNTIL done END REPEAT;

 CLOSE cursor_invcount;

  INSERT INTO inventory.invcountdetailshist
(listnumber,groupnumber,branchid,stockid,itemnumber,accquant,invquant,plquant)
  SELECT listnumber,groupnumber,branchid,stockid,itemnumber,accquant,invquant,plquant
  FROM inventory.invcountdetails
  WHERE listnumber=mylistnumber;  -- 把这一单的盘点单明细插入历史表

  INSERT INTO inventory.invcountheadhist
(listnumber,groupnumber,branchid,stockid,recorder,recordingdate,confirmer,confirmationdate)
  SELECT listnumber,groupnumber,branchid,stockid,recorder,recordingdate,myconfirmer,now()
  FROM inventory.invcounthead
  WHERE listnumber=mylistnumber;  -- 把这一单的盘点单头插入历史
  DELETE FROM inventory.invcountdetails WHERE listnumber = mylistnumber; -- 删除这一单的盘点单明细表数据
 DELETE FROM inventory.invcounthead WHERE listnumber = mylistnumber; -- 删除这一单的盘点单头表数据
COMMIT;
END

具体的操作我都标注在代码里面了，你可以看一下。

如何创建触发器？

创建完了存储过程，我们已经完成了一大半，但是别急，还有一步工作没有做，就是创建触发器。

由于我们根据分库分表的策略，把商品信息表拆分成了商品常用信息表和商品不常用信息表，这样就很容易产生数据不一致的情况。为了确保商品常用信息表和商品不常用信息表中的数据保持一致，我们可以创建触发器，保证这 2 个表中删除其中一个表的一条记录的操作，自动触发删除另外一个表中对应的记录的操作。这样一来，就防止了一个表中的记录在另外一个表中不存在的情况，也就确保了数据的一致性。

创建触发器的代码如下所示：

DELIMITER //
CREATE TRIGGER operation.del_goodso BEFORE DELETE  -- 在删除前触发
ON operation.goods_o
FOR EACH ROW                              -- 表示每删除一条记录，触发一次
BEGIN                                     -- 开始程序体
 DELETE FROM operation.goods_f
    WHERE groupnumber=OLD.groupnumber
    AND itemnumber=OLD.itemnumber;
END
//
DELIMITER ;

DELIMITER //
CREATE TRIGGER operation.del_goodsf BEFORE DELETE  -- 在删除前触发
ON operation.goods_f
FOR EACH ROW                              -- 表示每删除一条记录，触发一次
BEGIN                                     -- 开始程序体
 DELETE FROM operation.goods_o
    WHERE groupnumber=OLD.groupnumber
    AND itemnumber=OLD.itemnumber;
END
//
DELIMITER ;

到这儿呢，数据库数据表以及相关的索引、存储过程和触发器就都创建完了。可以说，我们已经完成了数据库的设计。

但是，在实际工作中，如果你只进行到这一步就打住了，那就还不能算是一个优秀的开发者。因为你考虑问题还不够全面。一个合格的系统设计者，不但要准确把握客户的需求，预见项目实施的前景，还要准备好对任何可能意外的应对方案。实际做项目时，不是纸上谈兵，什么情况都可能发生，我们需要未雨绸缪。所以，下面我们就来设计下项目的容灾和备份策略。

如何制定容灾和备份策略？

为了防止灾害出现，我设置了主从架构。为了方便你理解，我采用的是一主一从的架构，你也可以搭建一主多从的架构，原理都是一样的。

主从架构的核心是，从服务器实时自动同步主服务器的数据，一旦主服务器宕机，可以切换到从服务器继续使用。这样就可以把灾害损失降到最低。

下面我就和你一起，搭建一下主从服务器。

如何搭建主从服务器？

第一步，确保从服务器可以访问主服务器（在同一网段），例如，可以把主服务器的 IP 地址设置为：

主服务器IP：192.168.1.100

需要注意的是，主服务器入口方向的 3306 号端口需要打开，否则从服务器无法访问主服务器的 MySQL 服务器。

同时，我们把从服务器的 IP 地址设置为：

从服务器IP： 192.168.1.110

第二步，修改主从服务器的系统配置文件 my.ini，使主从服务器有不同的 ID 编号，并且指定需要同步的数据库。

在主服务器的配置文件中，我们把主服务器的编号修改为：server-id = 1。

# ***** Group Replication Related *****
# Specifies the base name to use for binary log files. With binary logging
# enabled, the server logs all statements that change data to the binary
# log, which is used for backup and replication.
log-bin=mysql-bin          -- 二进制日志名称
binlog-do-db = operation   -- 需要同步的数据库：营运数据库
binlog-do-db = inventory   -- 需要同步的数据库：库存数据库

# ***** Group Replication Related *****
# Specifies the server ID. For servers that are used in a replication topology,
# you must specify a unique server ID for each replication server, in the
# range from 1 to 2^32 − 1. “Unique” means that each ID must be different
# from every other ID in use by any other source or replica.
server-id=1          -- 主服务器的ID设为1

然后，我们来修改从服务器的配置文件 my.ini，把从服务器的编号设置为 server-id = 2。

# ***** Group Replication Related *****
# Specifies the base name to use for binary log files. With binary logging
# enabled, the server logs all statements that change data to the binary
# log, which is used for backup and replication.
log-bin=mysql-bin              -- 二进制日志名称
replicate_do_db = operation    -- 需要同步过来的数据库：营运数据库
replicate_do_db = inventory    -- 需要同步过来的数据库：库存数据库

# ***** Group Replication Related *****
# Specifies the server ID. For servers that are used in a replication topology,
# you must specify a unique server ID for each replication server, in the
# range from 1 to 2^32 − 1. “Unique” means that each ID must be different
# from every other ID in use by any other source or replica.
server-id=2                       -- 从服务器的编号为2

第三步，在主从服务器上都保存配置文件，然后分别重启主从服务器上的 MySQL 服务器。

第四步，为了使从服务器可以访问主服务器，在主服务器上创建数据同步用户，并赋予所有权限。这样，从服务器就可以实时读取主服务器的数据了。

mysql> CREATE USER 'myreplica'@'%' IDENTIFIED BY 'mysql';
Query OK, 0 rows affected (0.02 sec)

mysql> GRANT ALL ON *.* TO 'myreplica'@'%';
Query OK, 0 rows affected (0.99 sec)

第五步，在从服务器上启动数据同步，开始从主服务器中同步数据。

mysql>change master to master_host='192.168.1.100',master_port=3306,master_user='myreplica',master_password='mysql’,master_log_file='mysql-bin.000001',master_log_pos=535;
Query OK, 0 rows affected (0.02 sec)

启动同步的时候，你需要注意的是，必须指明主服务器上二进制日志中的位置 master_log_pos。也就是说，你准备从主服务器的二进制日志的哪个位置开始同步数据。你可以通过在主服务器上，用 SQL 语句“SHOW BINLOG EVENTS IN 二进制日志名” 获取这个值。下面的代码可以启动同步：

mysql>start slave;
Query OK, 0 rows affected (0.02 sec)

如何制定数据备份策略？

设置了主从服务器，也不是万无一失。

我曾经就遇到过这样一件事：我们把主从服务器搭在了某大厂几台不同的云服务器上，自以为没问题了，没想到大厂也有失手的时候，居然整个地区全部宕机，导致我们的主从服务器同时无法使用，近千家商户无法开展业务，损失惨重。

所以，无论系统的架构多么可靠，我们也不能大意。备份仍然是必不可少的步骤。我们可以在应用层面调用类似下面的命令进行备份：

H:\>mysqldump -u root -p --databases
inventory operation > H:\backup\Monday\mybackup.sql

我在项目中设定的策略是，每天晚上 12:00 做一个自动备份，循环备份 7 天，创建 7 个文件夹，从 Monday 到 Sunday，每个文件夹中保存对应的备份文件，新的覆盖旧的。

这个逻辑也很简单，你很容易理解，我就不多解释了，你不要忘了做这一步工作就可以了。

总结

今天这节课，我给你详细讲解了建库建表、创建索引、存储过程、触发器，以及容灾和备份策略。有几点你需要格外重视一下。

索引是提升查询执行速度的关键，创建的原则是：所有的数据表都要创建索引；有可能作为筛选条件的字段，都要用来创建索引。

另外，容灾和备份是数据库系统设计中必不可少的部分。因为在现实生活中，什么情况都可能发生，我们无法预见，但是可以尽量避免。在设计阶段的未雨绸缪，可以帮助我们减少很多损失。

最后我要提醒你的是，MySQL 的相关知识实践性非常强，决不能停留在纸面上。我在课中演示的的代码，都是在实际环境中运行过的，你课下一定要跟着实际操作一下。毕竟，学习知识最好的办法，就是在解决实际问题中学习。

思考题

在今天的课程中，我演示了搭建主从服务器的过程。其中，在第四步，我专门创建了一个用来同步数据的账号“myreplica”。我想请你思考一下，我为什么要这样做？直接用“root”账号不行吗？

欢迎在留言区写下你的思考和答案，我们一起交流讨论。如果你觉得今天的内容对你有所帮助，也欢迎你把它分享给你的朋友或同事，我们下节课见。

结束语 | 不盲从于群体思维，走一条适合自己的路

你好，我是朱晓峰。

到这里，咱们的课程就全部结束了。不过，课程结束更新，并不意味着 MySQL 的学习结束了。相反，也许对于你来说，这是一个新的开始。

我一直强调，在真实项目中进行学习，是掌握 MySQL 的最好方法。所以，在课程结束以后，你一定要把学到的内容真正应用到实际工作中，反复实践，这样才能真正成为优秀的数据库开发人员。

那在今天这篇结束语里呢，我想暂且抛开 MySQL，和你分享一个我的人生准则：不盲从于群体思维，按自己的意愿和个人的特质去做选择，走一条适合自己的路。

为什么在这告别之际，我最想给你分享这一点呢？

因为我们每个人都生活在群体之中，每天都在和不同的人打交道，尤其是现在信息极度丰富，每天都有人在表达着自己的观点，试图成为“意见领袖”，一不小心，别人的想法就会左右我们的行为。

但每个个体都是与众不同的，有的人擅长把细节做到极致，有的人擅长把控全局；有的人对数字特别敏感，有的人长于艺术想象……无论是学习技术，还是选择职业路径，只有适合自己的，才是我们成为人生赢家的捷径。

说到这儿，我想和你分享一个我的真实经历。

大学毕业以后，我进入一家外企工作，从普通程序员做起，一直做到构架师。工作很稳定，待遇也好，家人觉得很不错，希望我买套房子，赶紧结婚生子。

可是，我对这样的生活一点也不满意。因为我发现，在熟悉了工作内容之后，日常的一切对我已经没有什么挑战了，每天不过是重复熟悉的流程，感觉一年跟一天没有什么差别。这样的人生，不是我想要的。

我决定辞职，去美国留学，这件事情花光了我多年的积蓄。当时，很多人都在质疑我，觉得我是放弃了光明的前途，走向了未知的恐怖。但是，未知并不意味着恐怖，甚至可能是出乎预料的精彩。

事实证明，那段经历彻底改变了我。我学到了崭新的知识和不同的思维方式，也正因此，我又有机会进入到世界顶级的企业工作，成为了摩根大通银行东京分行副总裁，保证了一系列重大项目的落地和实施。

试想一下，假使我当时听从别人的劝导，选择过安安稳稳的日子，现在可能还在公司里面打工，计算着还有多少年可以退休。而现在的我，在开拓职业新赛道，同时也有机会写下这门课。

其实，人类的一个特点就是很容易接受暗示，尤其是在你犹豫不决的时候，往往一个简单暗示，就会影响你的决定。所以，你一定要慎重对待群体建议，在做每一个重要的决定前，都要先想想这个决定是为自己而做，还是为迎合群体而做。

你可能会说，我知道不要盲从群体思维，但是怎么按照自己的意愿去生活呢？接下来，我就结合我的人生经历，给你分享 3 条建议。

首先，你要制定长期规划，形成自己的一个稳定的内在架构。

有句话叫做：“谋定而后动，知止而有得。”如果做什么都没有规划，凡事跟着感觉走，是很难有作为的。而所谓的长期规划，我认为可以容纳进一套架构里。实际上，不只企业有架构，我们每个人都要形成自己的一个架构。

什么意思呢？我给你举个简单的小例子，你一听就明白了。

记得在银行工作的时候，有一次跟我们部门的一位传奇的交易员聊天，他每年为公司创造的利润超过你的想象。我问他怎么知道一笔交易能够盈利呢，他的回答出乎我的意料，他说：“我不知道这笔交易能不能赢，但是我的计算模型告诉我，我赢的可能性大于输的可能性。我一直这样做，只要交易足够多，我就不可能输。”

你看，我们眼中的一笔笔独立的交易，都是他长线规划中的一环。他实际上是预先做了计算的，一切都是按照计划进行的。虽然他不知道下一笔交易能不能赢，但按照计划行事，赢的可能性就比较大，只要时间足够长，交易量足够多，那么在最终的结果里，他肯定是赢家。

所以，要想走在自己期待的道路上，就要把视角放在更广阔的空间维度和更长久的时间维度上，建立起自己的一套架构，并不断用经验和经历去修正它，让它尽可能稳固，这样你才能在做决定时，有个指导，至少不会让你跑偏。

其次，对待任何结果，不过多计较一时的得失。

正确的决定，并不一定会带来好的结果；错误的决定，也可能会侥幸成功，重要的是客观地分析造成这个结果的原因，而不是简单地以结果的好坏，来判断决定的正确性。

我还是想分享一个我经历过的一件事。

记得云服务器刚开始流行起来的时候，因为我们觉得云服务器比自己搭建服务器，在可靠性、资源利用率和成本等方面都很有优势，于是，我们就给客户推荐了大厂的云服务器。可是，没想到大厂也有失手的时候，整个地区全部宕机，所有的客户都没法访问云服务器了。

这个时候，如果是你，你会怎么做呢？放弃云服务器，重新回归本地服务器，肯定会防止类似的事件再次发生，但问题是，这样的选择真的是正确的吗？

当时，云服务器确实比较新，大厂也可能会失手，但是，一次失败并不意味着云服务器就不能用了。从长远来说，云服务器比本地服务器的优势多太多了，大厂的技术也在不断成熟，给客户推荐云服务器肯定仍然是正确的选择。

实际上，任何事情都有成功和失败的概率。正确的选择是成功概率大的那个方案，不要因为一次偶然的失败，就轻易改变我们的选择。

总之，一定要学会不简单地以结果来评判对错，不妨问问自己，当初决定这样做的理由是什么？为什么会导致这个结果？是必然的呢？还是偶然因素造成的？哪个方案成功的概率最大？等等，在回答这些问题的过程中理清思路，努力做出全面客观的判断。

最后，正确的要坚持，但如果发现错了，果断放弃非常重要。

几乎所有人都在强调，做事要坚持不懈，这当然是非常重要的，毕竟不能忽视时间的力量。但是，如果你已经意识到方向错了，就一定要果断放弃。

我曾经非常迷信某大厂的软件开发生态，觉得它们能够提供全套的开发环境，稳定性好，特别人性化。自以为站在巨人的肩膀上，就可以借到力了。可是有一次，我们给客户定制的软件在客户的机器上无法启动，由于大厂的开发环境都是封闭的，我们无法了解内部到底发生了什么。这样一来，问题就一直得不到解决。最后，我们不得不回到几个月前的版本，重新编写相应的模块。

这件事使我意识到，要开发出真正满足客户需求的软件，必须要了解底层，开源的开发环境，才是最佳的选择。因为开源软件可以通过阅读源代码，了解所有的技术细节。

这个时候，我意识到，如果明知现在的方向不对，还要一直走下去，虽然比较省力，但是很多项目就不能做了，那样就会把路越走越窄。

最后，我们果断放弃了某大厂的软件，逐步切换到开源体系。刚开始的时候，因为要熟悉新的操作系统、新的编程语言、新的数据库软件，要学的东西太多，每一步都十分痛苦，很多人离开了。

但是，随着对开源体系越来越熟悉，发现原来的项目可以做，甚至很多原来不能做的项目也可以做了，这样一来，路就越走越宽了。

放弃正在走的错误的道路就意味着改变，而人十分容易产生惰性，一旦适应了现在的环境，就不愿意改变，这样就很容易忽视正确的东西。微小积累会引发持续改变这件事，不仅会在正确的地方得到验证，更会在错误的道路上体现得淋漓尽致。因此，在错误处调头，虽难，但意义重大。

好了，这些就是我在这几十年中，踩过无数坑、走了无数弯路后提炼出来的生活准则，很高兴有机会分享给你，但愿对你有所帮助。希望你不仅成为一个具有很强技术能力的人，同时还能真正走出一条适合自己的路，成为人生赢家。

课程的最后，我准备了一份调研问卷，希望你能花 1 分钟填写下，聊聊你对这门课的想法。同时，我也给你准备了礼物，只要填写问卷，就有机会获得一个手绘护腕垫或者是价值 99 元的课程阅码。期待你的畅所欲言。