# Mysql 技能树 - 用 MySQL 解决实际问题 ## 0. 目录设计 - 实践 - 创建数据库、数据表、对表中的数据进行增删改查操作、使用函数、表与表之间的关联操作 - 进阶 - 程序存储在服务器上、利用突发事件来调用程序、在不改变存储结构的前提下创建虚拟表以方便查询等 - 优化 - 数据库的设计规范,还会带你创建数据模型,帮助你来理清设计思路 - 案例 - 你从 0 到 1 设计一个连锁超市的信息系统数据库 ## 1. 前言 - 鸡汤理论 - 在工作中,最重要的绝对不是你的知识储备量,而是你**解决实际问题的能力** - **正确的学习方法,远比你投入的时间更重要** - 快速应用在实战项目: 项目的实际需求 --> 解决问题所需的知识点 --> 用好这些知识的实战经验 - 配置一个自己顺手的数据库操作环境 - ## 1. 实践 ### 1.8 聚合函数:怎么高效地进行分组统计? - MySQL 中有 5 种聚合函数较为常用,分别是求和函数 SUM()、求平均函数 AVG()、最大值函数 MAX()、最小值函数 MIN() 和计数函数 COUNT() - 项目需求是这样的:超市经营者提出,他们需要统计某个门店,每天、每个单品的销售情况,包括销售数量和销售金额等。这里涉及 3 个数据表,具体信息如下所示: - 销售明细表(demo.transactiondetails): - ![聚合函数销售明细表](pic/聚合函数销售明细表.png) - 销售单头表(demo.transactionhead): - ![销售单头表](pic/聚合函数销售单头表.png) - 商品信息表(demo.goodsmaster): - ![聚合函数商品信息表](pic/聚合函数商品信息表.png) - SUM() - SUM()函数可以返回指定字段值的和。我们可以用它来获得用户某个门店,每天,每种商品的销售总计数据: ```sql mysql> SELECT -> LEFT(b.transdate, 10), -- 从关联表获取交易时间,并且通过LEFT函数,获取交易时 -> 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:**表示按照指定的字段排序**。超市经营者指定按照日期和单品统计,那么,统计的结果按照交易日期和商品名称的顺序排序,会更加清晰。 - 上述步骤是分为3步 - 第一步,完成 3 个表的连接 - 第二步,对结果集按照交易时间和商品名称进行分组 - 第三步,对各组的销售数量和销售金额进行统计,并且按照交易日期和商品名称排序 - **如果用户需要知道全部商品销售的总计数量和总计金额,我们也可以把数据集的整体看作一个分组,进行计算**。这样就不需要分组关键字 GROUP BY,以及排序关键字 ORDER BY 了。你甚至不需要从关联表中获取数据,也就不需要连接了。就像下面这样: ```sql mysql> SELECT -> SUM(quantity), -- 总计数量 -> SUM(salesvalue)-- 总计金额 -> FROM -> demo.transactiondetails; +---------------+-----------------+ | SUM(quantity) | SUM(salesvalue) | +---------------+-----------------+ | 27.000 | 639.00 | +---------------+-----------------+ 1 row in set (0.05 sec) ``` - **AVG()、MAX()和 MIN()** - 举个例子,如果用户需要计算每天、每种商品,平均一次卖出多少个、多少钱,这个时候,我们就可以用到 AVG()函数了 ```sql 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) ``` - **MAX()和 MIN()** - MAX() 表示获取指定字段在分组中的最大值,MIN() 表示获取指定字段在分组中的最小值。它们的实现原理差不多,下面我就重点讲一下 MAX(),知道了它的用法,MIN() 也就 很好理解了。 - 假如用户要求计算每天里的一次销售的最大数量和最大金额,就可以用下面的代码,得到我们需要的结果: ```sql 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 是分别计算的**。下面我们就来分析一下刚刚的查询。 - MAX(字段)这个函数返回分组集中最大的那个值。如果你要查询 MAX(字段1)和 MAX(字段 2),而它们是相互独立、分别计算的,你千万不要想当然地认为结果 在同一条记录上。 - **COUNT()** - 怎么解决卡顿的问题呢?我们想到了一个分页的策略。 - 所谓的分页策略,其实就是,不把查询的结果一次性全部返回给客户端,而是根据用户电脑屏幕的大小,计算一屏可以显示的记录数,每次只返回用户电脑屏幕可以显示的数据 集。接着,再通过翻页、跳转等功能按钮,实现查询目标的精准锁定。这样一来,每次查询的数据量较少,也就大大提高了系统响应速度。 - 这个策略能够实现的一个关键,就是要**计算出符合条件的记录一共有多少条**,之后才能计算出一共有几页、能不能翻页或跳转。 - 要计算记录数,就要用到 COUNT() 函数了。这个函数有两种情况。 - COUNT(*):统计一共有多少条记录; - COUNT(字段):统计有多少个不为空的字段值。 - 如果 COUNT(*)与 GROUP BY 一起使用,就表示统计分组内有多少条数据。它也可以单独使用,这就相当于数据集全体是一个分组,统计全部数据集的记录数。 - 假设我有个销售流水明细表如下: ```sql 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 行,需要多少页才能显示完这个表的全部数据呢? ```sql mysql> SELECT COUNT(*) -> FROM demo.transactiondetails; +----------+ | COUNT(*) | +----------+ | 6 | +----------+ 1 row in set (0.03 sec) ``` - 我们这里只有 6 条数据,一屏就可以显示了,所以一共 1 页。 - 那么,如果超市经营者想知道,每天、每种商品都有几次销售,我们就需要按天、按商品名称,进行分组查询: ```sql 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) ``` - 运行这段代码,我们就得到了每天、每种商品有几次销售的全部结果。 - COUNT(字段) - COUNT(字段)用来统计分组内这个字段的值出现了多少次。如果字段值是空,就不统计。 - 为了说明它们的区别,我举个小例子。假设我们有这样的一个商品信息表,里面包括了商品编号、条码、名称、规格、单位和售价的信息。 ```sql 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 次: ```sql mysql> SELECT COUNT(goodsname) -- 统计商品名称字段 -> FROM demo.goodsmaster; +------------------+ | COUNT(goodsname) | +------------------+ | 3 | +------------------+ 1 row in set (0.00 sec) ``` - 如果我们统计字段“specification”,用 COUNT(specification),结果是 1 次: ```sql 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 次。 - 理解了这一点,**你就可以利用计数函数对某个字段计数时,不统计空值的特点,对表中字段的非空值进行计数了**。 ### 1.13 临时表:复杂查询,如何保存中间结果? - 拆解一个复杂的查询,通过临时表来保存中间结果,从而把一个复杂查询变得简单而且容易实现。 - 临时表是什么? - 临时表是一种特殊的表,用来存储查询的中间结果,并且会**随着当前连接的结束而自动删除**。MySQL 中有 2 种临时表,分别是**内部临时表和外部临时表**: - 内部临时表主要用于性能优化,由系统自动产生,我们无法看到; - 外部临时表通过 SQL 语句创建,我们可以使用。 - 外部临时表 - 临时表的创建语法结构: ```sql CREATE TEMPORARY TABLE 表名( 字段名 字段类型, ... ); ``` - 跟普通表相比,临时表有 3 个不同的特征: - 临时表的创建语法需要用到**关键字 TEMPORARY**; - 临时表创建完成之后,**只有当前连接可见,其他连接是看不到的,具有连接隔离性**; - 临时表在**当前连接结束之后,会被自动删除**。 - 因为临时表有连接隔离性,不同连接创建相同名称的临时表也不会产生冲突,**适合并发程序的运行**。而且,连接结束之后,临时表会自动删除,也不用担心大量无用的中间数据会 残留在数据库中。因此,我们就可以利用这些特点,**用临时表来存储 SQL 查询的中间结果**。 - 如何用临时表简化复杂查询? - 举个例子,超市经营者想要查询 2020 年 12 月的一些特定商品销售数量、进货数量、返厂数量,那么,我们就要先把销售、进货、返厂这 3 个模块分开计算,用临时表来存储中间 计算的结果,最后合并在一起,形成超市经营者想要的结果集。 - 假设我们的销售流水表(mysales)如下所示: - ![销售流水表](pic/销售流水表.png) - 用下面的 SQL 语句,查询出每个单品的销售数量和销售金额,并存入临时表: ```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)。 - ![临时表进货子表](pic/临时表进货子表.png) - 用下面的 SQL 语句计算进货数据,并且保存在临时表里面: ```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)。 - 返厂单头表包括返厂单编号、供货商编号、仓库编号、操作员编号和验收日期: - ![临时表返厂单表头](pic/临时表返厂单表头.png) - 返厂单明细表包括返厂单编号、商品编号、返厂数量、返厂价格和返厂金额: - ![临时表返厂单明细表](pic/临时表返厂单明细表.png) - 我们可以使用下面的 SQL 语句计算返厂信息,并且保存到临时表中。 ```sql mysql> CREATE TEMPORARY TABLE demo.myreturn -> SELECT b.itemnumber,SUM(b.quantity) AS quantity,SUM(b.returnvalue) AS retur -> 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 月的商品销售数量、进货数 量和返厂数量。 - 代码如下所示: ```sql 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 临时表存储在内存中**。 - 创建一个内存中的临时表: ```sql mysql> CREATE TEMPORARY TABLE demo.mytrans -> ( -> itemnumber int, -> groupnumber int, -> branchnumber int -> ) ENGINE = MEMORY; (临时表数据存在内存中) Query OK, 0 rows affected (0.00 sec) ``` - 接下来,我们在磁盘上创建一个同样结构的临时表。**在磁盘上创建临时表时,只要我们不指定存储引擎,MySQL 会默认存储引擎是 InnoDB,并且把表存放在磁盘上**。 ```sql mysql> CREATE TEMPORARY TABLE demo.mytransdisk -> ( -> itemnumber int, -> groupnumber int, -> branchnumber int -> ); Query OK, 0 rows affected (0.00 sec) ``` - 现在,我们向刚刚的两张表里都插入同样数量的记录,然后再分别做一个查询: ```sql 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 毫秒。显然,内存中的临时表查询速度更快。 - 不过,内存中的临时表也有缺陷。因为数据完全在内存中,所以,一旦断电,数据就消失了,无法找回。**不过临时表只保存中间结果,所以还是可以用的**。 - 内存临时表和磁盘临时表的优缺点: - ![内存临时表和磁盘临时表的优缺点](pic/内存临时表和磁盘临时表的优缺点.png) - 总结: - 我们学习了临时表的概念,以及使用临时表来存储中间结果以拆分复杂查询的方法。临时表可以存储在磁盘中,也可以通过指定引擎的办法存储在内存中,以加快存取速度。 - 其实,临时表有很多好处,除了可以帮助我们把复杂的 SQL 查询拆分成多个简单的 SQL查询,而且,因为临时表是连接隔离的,不同的连接可以使用相同的临时表名称,相互之 间不会受到影响。除此之外,临时表会在连接结束的时候自动删除,不会占用磁盘空间。 - 当然,临时表也有不足,比如会挤占空间。在使用临时表的时候,要从简化查询和挤占资源两个方面综合考虑,既不能过度加重系统的负担,同时又能够通过存储中间结果,最大限度地简化查询。 ## 2. 进阶 ### 2.1 视图:如何简化查询? - 视图是一种虚拟表,我们可以把一段查询语句作为视图存储在数据库中,在需要的时候,可以把视图看做一个表,对里面的数据进行查询。 - 让查询变得简单,而且,**视图没有实际存储数据,还避免了数据存储过程中可能产生的冗余,提高了存储的效率**。 - 视图的创建及其好处 - 创建视图的语法结构: ```sql CREATE [OR REPLACE] VIEW 视图名称 [(字段列表)] AS 查询语句 ``` - 现在,假设我们要查询一下商品的每日销售明细,这就要从销售流水表(demo.trans)和 商品信息表(demo.goodsmaster)中获取到销售数据和对应的商品信息数据。 - 销售流水表包含流水单号、商品编号、销售数量、销售金额和交易时间等信息: - ![销售流水表](pic/销售流水表.png) - 商品信息表包含商品编号、条码、名称和售价等信息: - ![商品信息表](pic/视图商品信息表.png) - 在不使用视图的情况下,我们可以通过对销售流水表和商品信息表进行关联查询,得到每天商品销售统计的结果,包括销售日期、商品名称、每天销售数量的合计和每天销售金额 的合计,如下所示: ```sql 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)如下所示: - ![视图历史库存表](pic/视图历史库存表.png) - 接下来我们的查询步骤会使用到子查询和派生表,很容易理解,你知道含义就行了。 - 子查询:就是嵌套在另一个查询中的查询。 - 派生表:如果我们在查询中把子查询的结果作为一个表来使用,这个表就是派生表。 - 这个查询的具体步骤是: - 通过子查询获得单品销售统计的查询结果; - 把第一步中的查询结果作为一个派生表,跟历史库存表进行连接,查询获得包括销售日期、商品名称、销售数量和历史库存数量在内的最终结果。 ```sql 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) ``` - 可以看到,这个查询语句是比较复杂的,可读性和可维护性都比较差。那该怎么办呢?其实,针对这种情况,我们就可以使用视图。 - 我们可以把商品的每日销售统计查询做成一个视图,存储在数据库里,代码如下所示: ```sql 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) ``` - 这样一来,我们每次需要查询每日商品销售数据的时候,就可以直接查询视图,不需要再写一个复杂的关联查询语句了。 - 我们来试试用一个查询语句直接从视图中进行查询: ```sql 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) ``` - 结果显示,这两种查询方式得到的结果是一样的。 - 如果我们要进一步查询“每日单品销售的数量与当日的库存数量的对比”,就可以把刚刚定义的视图作为一个数据表来使用。我们把它跟历史库存表连接起来,来获取销售数量和 历史库存数量。 ```sql 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) ``` - 结果显示,这里的查询结果和我们刚刚使用派生表的查询结果是一样的。但是,使用视图的查询语句明显简单多了,可读性更好,也更容易维护。 - 如何操作视图和视图中的数据? - 创建完了视图,我们还经常需要对视图进行一些操作,比如修改、查看和删除视图。 - 如何操作视图? - 修改、查看、删除视图的操作比较简单,你只要掌握具体的语法就行了。 - 修改视图的语法如下所示: ```sql ALTER VIEW 视图名 AS 查询语句; ``` - 查看视图的语法是: ```sql 查看视图: DESCRIBE 视图名; ``` - 删除视图要使用 DROP 关键词,具体方法如下: ```sql 删除视图: DROP VIEW 视图名; ``` - 如何操作视图中的数据? - 视图本身是一个虚拟表,所以,对视图中的数据进行插入、修改和删除操作,实际都是通过对实际数据表的操作来实现的。 - 在视图中插入数据 - 借用刚刚的视图 demo.view_goodsmaster 来给你解释下。假设商品信息表中的规格字段(specification)被删除了,当我们尝试用 INSERT INTO 语句向视图中插入一条记录 的时候,就会提示错误了: ```sql 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 ins ... ``` - 这是因为,**只有视图中的字段跟实际数据表中的字段完全一样,MySQL 才允许通过视图插入数据**。刚刚的视图中包含了实际数据表所没有的字段“specification”,所以在插入 数据时,系统就会提示错误。 - 为了解决这个问题,我们来修改一下视图,让它只包含实际数据表中有的字段,也就是商品编号、条码、名称和售价。代码如下: ```sql 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) ``` - 对视图进行修改之后,我们重新尝试向视图中插入一条记录: ```sql mysql> INSERT INTO demo.view_goodsmaster -> (itemnumber,barcode,goodsname,salesprice) -> VALUES -> (5,'0005','测试',100); Query OK, 1 row affected (0.02 sec) ``` - 结果显示,插入成功了。 - 实际数据表中的数据情况 ```sql 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 的商品数据了。 - **删除视图中的数据** - 我们可以通过 DELETE 语句,删除视图中的数据: ```sql mysql> DELETE FROM demo.view_goodsmaster -- 直接在视图中删除数据 -> WHERE itemnumber = 5; Query OK, 1 row affected (0.02 sec) ``` - 现在我们来查看视图和实际数据表的内容,会发现商品编号是 5 的商品都已经被删除了。 ```sql 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) ``` - 修改视图中的数据 - 我们可以通过 UPDATE 语句对视图中的数据进行修改: ```sql 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 ``` - 结果显示,更新成功了。现在我们来查看一下视图和实际数据表,代码如下所示: ```sql 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),包括商品编号、条码、名称、规格和售价等信息: - ![视图实际的数据表](pic/视图实际的数据表.png) - 在这个表的基础上,我们建一个视图,查询所有价格超过 50 元的商品: ```sql mysql> CREATE VIEW demo.view_goodsmaster AS -> SELECT * -> FROM demo.goodsmaster -> WHERE salesprice > 50; Query OK, 0 rows affected (0.03 sec) ``` - 接着,我们在这个视图的基础上做一个查询,来验证一下视图的内容: ```sql 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”删掉,就可以用下面的代码: ```sql mysql> ALTER TABLE demo.goodsmaster DROP COLUMN specification; Query OK, 0 rows affected (0.13 sec) Records: 0 Duplicates: 0 Warnings: 0 ``` - 这样一来,因为少了一个字段,而我们的语句又是直接查询数据表的,代码就会提示错误: ```sql mysql> SELECT barcode,goodsname,specification -> FROM demo.goodsmaster; ERROR 1054 (42S22): Unknown column 'specification' in 'field list' ``` - 你看,代码提示字段“specification”不存在。 - 但是,如果查询的是视图,就可以通过修改视图来规避这个问题。我们可以用下面的代码把刚才的视图修改一下: ```sql 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”。 ```sql mysql> SELECT barcode,goodsname,specification -> FROM demo.view_goodsmaster; +---------+-----------+---------------+ | barcode | goodsname | specification | +---------+-----------+---------------+ | 0001 | 本 | | +---------+-----------+---------------+ 1 row in set (0 00 sec) ``` - 结果显示,运行成功了。这个视图查询,就没有受到实际数据表中删除字段的影响。 - 视图有这么多好处,那我以后都用视图可以吗?其实不是的,视图也有自身的不足。 - 如果我们在实际数据表的基础上创建了视图,那么,**如果实际数据表的结构变更了,我们就需要及时对相关的视图进行相应的维护**。特别是当视图是由视图生成的时候,维护会变 得比较复杂。因为创建视图的 SQL 查询可能会对字段重命名,也可能包含复杂的逻辑,这些都会增加维护的成本。 - 一张图来汇总下视图的优缺点: - ![视图的优缺点](pic/视图的优缺点.png) - 虽然可以更新视图数据,但总的来说,视图作为虚拟表,主要用于方便查询。我不建议你更新视图的数据,因为对视图数据的更改,都是通过对实际 数据表里数据的操作来完成的,而且有很多限制条件。 - 视图虽然有很多优点。但是在创建视图、简化查询的同时,也要考虑到视图太多而导致的数据库维护成本的问题。 - 视图不是越多越好,特别是嵌套的视图(就是在视图的基础上创建视图),我不建议你使用,因为逻辑复杂,可读性不好,容易变成系统的潜在隐患。 ### 2.2 存储过程:如何提高程序的性能和安全性? ## 3. 优化 ### 3.1 范式:如何消除冗余和高效存取? - 现象,什么样的表需要重新使用范式进行设计 - ![冗余供货表](pic/冗余供货表.png) - 表里重复的数据非常多:比如第一行和第二行的数据,同样是 3478 号单据,供货商编号、供货商名称和仓库,这 **3 个字段的信息完全相同**。可是这 2 条数据的**后 半部分又不相同**,因此,并不能认为它们是冗余数据而删除。 - 坏味道: - 在我们的工作场景中,这种由于数据表结构设计不合理,而导致的数据重复的现象并不少见,往往是系统虽然能够运行,承载能力却很差,稍微有点流量,就会出现**内存不足、 CUP 使用率飙升的情况,甚至会导致整个项目失败**。 - **第一范式** - 这张进货单表重新设计的第一步,就是要把所有的列,也就是字段,都确认一遍,确保**每个字段只包含一种数据**。如果各种数据都混合在一起,就无法通过后面的拆解,把重复的数据去掉。 - 其实,这就是第一范式所要求的:**所有的字段都是基本数据字段,不可进一步拆分**。 - 在我们的这张表里,“property”这一字段可以继续拆分。其他字段已经都是基本数据字段,不能再拆了。 - 经过优化,我们**把“property”这一字段,拆分成“specification(规格)”和“unit(单位)”**,这 2 个字段如下: - ![第一范式拆分供货表](pic/第一范式拆分供货表.png) - 这样处理之后,字段多了一个,但是每一个字段都成了**不可拆分的最小信息单元**,我们就可以在这个表的基础之上,着手进行进一步的优化了。这就要用到数据表设计的第二范式. - **第二范式** - 通过观察,我们可以发现,这个表的前 2 条记录的前 4 个字段完全一样。那可不可以通过拆分,把它们变成一条记录呢?当然是可以的,而且为了优化,必须要进行拆分。 - 第二范式就告诉了我们拆分的原则:**在满足第一范式的基础上,还要满足数据表里的每一条数据记录,都是可唯一标识的。而且所有字段,都必须完全依赖主键,不能只依赖主键的一部分。** - 根据这个要求,我们可以对表进行重新设计。 - 重新设计的第一步,就是要确定这个表的主键。通过观察发现,字段“listnumber”+“barcode”可以唯一标识每一条记录,可以作为主键。确定好了主键 以后,我们判断一下,哪些字段完全依赖主键,哪些字段只依赖于主键的一部分。同时,把只依赖于主键一部分的字段拆分出去,形成新的数据表。 - 首先,进货单明细表里面的“goodsname” “specification” “unit” 这些信息是商品的属性,只依赖于“barcode”,不完全依赖主键,可以拆分出去。我们把这 3 个字段加上 它们所依赖的字段“barcode”,拆分形成一个新的数据表“商品信息表”。 - 这样一来,原来的数据表就被拆分成了两个表。 - 商品信息表: - ![第二范式拆分后的商品信息表](pic/第二范式拆分后的商品信息表.png) - 进货单表: - ![第二范式拆分后的进货单表](pic/第二范式拆分后的进货单表.png) - 同样道理,字段“supplierid”“suppliername”“stock”只依赖于“listnumber”,不完全依赖于主键,所以,我们可以把“supplierid”“suppliername”“stock”这 3 个字段拆出去,再加上它们依赖的字段“listnumber”,就形成了一个新的表“进货单头表”。剩下的字段,会组成新的表,我们叫它“进货单明细表”。 - 这样一来,原来的数据表就拆分成了 3 个表。 - 进货单头表: - ![第二范式拆分下的进货单头表](pic/第二范式拆分下的进货单头表.png) - 进货单明细表: - ![第二范式拆分下的进货单明细表](pic/第二范式拆分下的进货单明细表.png) - 商品信息表: - ![第二范式拆分下的商品信息表](pic/第二范式拆分下的商品信息表.png) - 我们再来分析一下拆分后的 3 个表,保证这 3 个表都满足第二范式的要求。 - 在“商品信息表”中,字段“barcode”是有可能存在重复的,比如,用户门店可能有散装称重商品和自产商品,会存在条码共用的情况。所以,所有的字段都不能唯一标识表里 的记录。这个时候,我们必须给这个表加上一个主键,比如说是自增字段“itemnumber”。 - 现在,我们就可以把进货单明细表里面的字段“barcode”都替换成字段“itemnumber”,这就得到了新的进货单明细表和商品信息表。 - 进货单明细表(新): - ![进货单明细表(新)](pic/进货单明细表(新).png) - 商品信息表(新): - ![商品信息表(新)](pic/商品信息表(新).png) - 这样一来,我们拆分后的 3 个数据表中的数据都不存在重复,可以唯一标识。而且,表中的其他字段,都完全依赖于表的主键,不存在部分依赖的情况。所以,拆分后的 3 个数据 表就全部满足了第二范式的要求。 - **第三范式** - 我们的进货单头表,还有数据冗余的可能。因为“suppliername”依赖“supplierid”。那么,这个时候,就可以按照第三范式的原则进行拆分了。 - 第三范式要求数据表**在满足第二范式的基础上,不能包含那些可以由非主键字段派生出来的字段,或者说,不能存在依赖于非主键字段的字段**。 - 在刚刚的进货单头表中,字段“suppliername”依赖于非主键字段“supplierid”。因此,这个表不满足第三范式的要求。 - 那接下来,我们就进一步拆分下进货单头表,把它拆解成供货商表和进货单头表。 - 供货商表: - ![供货商表](pic/供货商表.png) - 进货单头表: - ![进货单头表](pic/进货单头表.png) - 这样一来,供货商表和进货单头表中的所有字段,都完全依赖于主键,不存在任何一个字段依赖于非主键字段的情况了。所以,这 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 个表: - 供货商表: - ![供货商表](pic/供货商表.png) - 进货单头表: - ![进货单头表](pic/进货单头表.png) - 进货单明细表: - ![进货单明细表(新)](pic/进货单明细表(新).png) - 商品信息表: - ![商品信息表(新)](pic/商品信息表(新).png) - 这样一来,我们就避免了冗余数据,而且还能够满足业务的需求,这样的数据表设计,才是合格的设计。 - 一般来说,MySQL 的数据库设计满足第三范式,就足够了。不过,第三范式,并不是终极范式,还有 **BCNF 范式(也叫 BC 范式)、第四范式和第五范式**。 ### 3.2 ER模型:如何理清数据库设计思路? - 接上面对表按范式进行拆解 - 但是,当我们按照这样的方式拆分一连串数据表时,却发现越拆越多,而且支离破碎。事实上,**局部最优的表,不仅有可能存在进一步拆分的情况,还有可能会出现数据缺失**。 - 毕竟,数据库设计是牵一发而动全身的。那有没有什么办法提前看到数据库的全貌呢? - ER 模型就是一个这样的工具。ER 模型也叫作实体关系模型,是用来描述现实生活中客观存在的事物、事物的属性,以及事物之间关系的一种数据模型。 - 在开发基于数据库的信息系统的**设计阶段**,通常使用 ER 模型**来描述信息需求和信息特性**,帮助我们理清业务逻辑,从而设计出优秀的数据库。 - ER 模型包括哪些要素? - 在 ER 模型里面,有三个要素,分别是**实体、属性和关系**。 - 实体。在 ER 模型中,用**矩形来表示**。实体分为两类,分别是**强实体和弱实体**。强实体是指不依赖于其他实体的实体;弱实体是指对另一个实体有很强 的依赖关系的实体。 - 属性,则是指**实体的特性**。比如超市的地址、联系电话、员工数等。在 ER 模型中用**椭圆形来表示**。 - 关系,则是指**实体之间的联系**。比如超市把商品卖给顾客,就是一种超市与顾客之间的联系。在 ER 模型中用**菱形来表示**。 - 需要注意的是,有的时候,**实体和属性不容易区分** - 该如何区分实体和属性呢? - 一个原则:我们要从系统整体的角度出发去看,**可以独立存在的是实体,不可再分的是属性**。也就是说,属性不需要进一步描述,不能包含其他属性。 - 在 ER 模型的 3 个要素中,关系又可以分为 3 种类型,分别是 **1 对 1、1 对多和多对多** - 1 对 1:**指实体之间的关系是一一对应的**,比如个人与身份证信息之间的关系就是 1 对1 的关系。一个人只能有一个身份证信息,一个身份证信息也只属于一个人。 - 1 对多:**指一边的实体通过关系,可以对应多个另外一边的实体**。相反,另外一边的实体通过这个关系,则只能对应唯一的一边的实体。比如超市与超市里的收款机之间的从 属关系,超市可以拥有多台收款机,但是每一条收款机只能从属于一个超市。 - 多对多:**指关系两边的实体都可以通过关系对应多个对方的实体**。比如在进货模块中,供货商与超市之间的关系就是多对多的关系,一个供货商可以给多个超市供货,一个超 市也可以从多个供货商那里采购商品。 - 超市业务创建 ER 模型 - ![超市业务创建ER模型](pic/超市业务创建ER模型.png) - 在这个图中,供货商和超市之间的供货关系,两边的数字都不是 1,表示多对多的关系。 - 同样,超市和顾客之间的零售关系,也是多对多的关系。 - 这个 ER 模型,包括了 3 个实体之间的 2 种关系: - 超市从供货商那里采购商品; - 超市把商品卖给顾客。 - 有了这个 ER 模型,我们就可以从整体上理解超市的业务了。但是,这里没有包含属性,这样就无法体现实体和关系的具体特征。现在,我们需要把属性加上,用椭圆来表示,这样 我们得到的 ER 模型就更加完整了。 - ER 模型的细化 - 进货模块 - 实体及属性 - 供货商:名称、地址、电话、联系人。 - 商品:条码、名称、规格、单位、价格。 - 门店:编号、地址、电话、联系人。 - 仓库:编号、名称。 - 员工:工号、姓名、住址、电话、身份证号、职位。 - 实体关系 - 其中,供货商、商品和门店是强实体,因为它们不需要依赖其他任何实体。 - 而仓库和员工是弱实体,因为它们虽然都可以独立存在,但是它们都依赖门店这个实体,因此都是弱实体。 - ER 模型如下: - ![ER模型](pic/ER模型.png) - 这里我是用粗框矩形表示弱实体,用粗框菱形,表示弱实体与它依赖的强实体之间的关系。 - 零售模块 - 零售业务包括普通零售和会员零售两种模式。普通零售包含的实体,包括门店、商品和收银款台;会员零售包含的实体,包括门店、商品、会员和收银款台。 - 实体及属性 - 商品:条码、名称、规格、单位、价格。 - 会员:卡号、发卡门店、名称、电话、身份证、地址、积分、储值。 - 门店:编号、地址、电话、联系人。 - 收银款台:编号、名称。 - 实体关系 - 其中,商品和门店不依赖于任何其他实体,所以是强实体; - 会员和收银款台都依赖于门店,所以是弱实体。 - 零售模块的 ER 模型了: - ![零售ER模型](pic/零售ER模型.png) - 完整的 ER 模型: - ![完整ER模型](pic/完整ER模型.png) - 如何把 ER 模型图转换成数据表? - 通过绘制 ER 模型,我们已经理清了业务逻辑,现在,我们就要进行非常重要的一步了:把绘制好的 ER 模型,转换成具体的数据表。 - 我来介绍下转换的原则。 - 一个实体通常转换成一个数据表; - 一个多对多的关系,通常也转换成一个数据表; - 一个 1 对 1,或者 1 对多的关系,往往通过表的外键来表达,而不是设计一个新的数据表; - 属性转换成表的字段。 ### 3.3 查询有点慢,语句该如何写? - 查询分析语句 - 虽然 MySQL 的查询分析语句并不能直接优化查询,但是却可以帮助你了解 SQL 语句的执行计划,有助于你分析查询效率低下的原因,进而有针对性地进行优化。查询分析语句的 语法结构是: - { EXPLAIN | DESCRIBE | DESC }查询语句; ```shell 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 秒,这个速度显然太慢了。 - 现在,我们用下面的语句分析一下这个查询的具体细节: ```shell 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 | +----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+ 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。 - SIMPLE:表示简单查询,不包含子查询和联合查询。 - PRIMARY:表示是最外层的查询。 - UNION:表示联合查询中的第二个或者之后的查询。 - DEPENDENTUNION:表示联合查询中的第二个或者之后的查询,而且这个查询受外查询的影响。 - 关于这个 DEPENDENTUNION 取值, ```shell 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 = '笔' -> ); ... 2 rows in set (0.00 sec) ``` - MySQL 在执行的时候,会把这个语句进行优化,重新写成下面的语句: ```sql 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 ); ``` - 在这里,子查询中的联合查询是: ```sql 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 分别创建索引: ```shell 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 ``` - 2 种查询优化的方法 - 怎么在包含关键字“LIKE”和“OR”的条件语句中,利用索引提高查询效率。 - 使用关键字“LIKE” - “LIKE”经常被用在查询的限定条件中,通过通配符“%”来筛选符合条件的记录。比如 - WHERE字段 LIKE ‘aa%’,表示筛选出所有以“aa”开始的记录; - WHERE字段 LIKE ‘%aa%’,表示所有字段中包含“aa”的记录。 - 这里你要注意的是,**通配符在前面的筛选条件是不能用索引的**。也就是说,WHERE字段LIKE‘%aa’和WHERE字段 LIKE ‘%aa%’都不能使用索引,但是通配符在后面的筛选条 件,就可以使用索引。 - 使用关键字“OR” - 关键字“OR”表示“或”的关系,“WHERE 表达式 1 OR 表达式 2”,就表示表达式 1 或者表达式 2 中只要有一个成立,整个 WHERE 条件就是成立的。 - 需要注意的是,**只有当条件语句中只有关键字“OR”,并且“OR”前后的表达式中的字段都建有索引的时候,查询才能用到索引**。 - 我刚才已经用字段条码给商品流水表创建了一个索引,现在我再用商品编号“itemnumber”创建一个索引: ```shell mysql> CREATE INDEX trans_itemnumber ON demo.trans(itemnumber); Query OK, 0 rows affected (20.24 sec) Records: 0 Duplicates: 0 Warnings: 0 ``` - 我们先看一下关键字“OR”前后的表达式中的字段都创建了索引的情况: ```shell mysql> EXPLAIN SELECT * FROM demo.trans -> WHERE barcode LIKE '6953150%' -> OR itemnumber = 1; +----+-------------+-------+------------+-------------+----------------------- | id | select_type | table | partitions | type | possible_keys | key | key_len +----+-------------+-------+------------+-------------+----------------------- | 1 | SIMPLE | trans | NULL | index_merge | trans_barcode,trans_itemnumber | t +----+-------------+-------+------------+-------------+----------------------- 1 row in set, 1 warning (0.01 sec) ``` - 我们先看一下关键字“OR”前后的表达式中的字段都创建了索引的情况: ```shell mysql> EXPLAIN SELECT * FROM demo.trans -> WHERE barcode LIKE '6953150%' -> OR itemnumber = 1; +----+-------------+-------+------------+-------------+----------------------- | id | select_type | table | partitions | type | possible_keys | key | key_len +----+-------------+-------+------------+-------------+----------------------- | 1 | SIMPLE | trans | NULL | index_merge | trans_barcode,trans_itemnumber | t +----+-------------+-------+------------+-------------+----------------------- 1 row in set, 1 warning (0.01 sec) ``` - 说明优化器选择了合并索引的方式。因此,这个关键字“OR”前后的表达式中的字段都创建了索引的查询,是可以用到索引的。 ### 3.4 表太大了,如何设计才能提高性能?