You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

91 KiB

Mysql 技能树 - 用 MySQL 解决实际问题

0. 目录设计

  • 实践
    • 创建数据库、数据表、对表中的数据进行增删改查操作、使用函数、表与表之间的关联操作
  • 进阶
    • 程序存储在服务器上、利用突发事件来调用程序、在不改变存储结构的前提下创建虚拟表以方便查询等
  • 优化
    • 数据库的设计规范,还会带你创建数据模型,帮助你来理清设计思路
  • 案例
    • 你从 0 到 1 设计一个连锁超市的信息系统数据库

1. 前言

  • 鸡汤理论
    • 在工作中,最重要的绝对不是你的知识储备量,而是你解决实际问题的能力
    • 正确的学习方法,远比你投入的时间更重要
    • 快速应用在实战项目: 项目的实际需求 --> 解决问题所需的知识点 --> 用好这些知识的实战经验
  • 配置一个自己顺手的数据库操作环境

1. 实践

1.8 聚合函数:怎么高效地进行分组统计?

  • MySQL 中有 5 种聚合函数较为常用,分别是求和函数 SUM()、求平均函数 AVG()、最大值函数 MAX()、最小值函数 MIN() 和计数函数 COUNT()

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

  • 销售明细表demo.transactiondetails)

  • 聚合函数销售明细表

  • 销售单头表demo.transactionhead)

  • 销售单头表

  • 商品信息表demo.goodsmaster

  • 聚合函数商品信息表

  • SUM

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

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(strn)表示返回字符串 str 最左边的 n 个字符。我们这里的 LEFTa.transdate,10表示返回交易时间字符串最左边的 10 个字符。在 MySQL 中, DATETIME 类型的默认格式是YYYY-MM-DD也就是说年份 4 个字符,之后是“-”,然后是月份 2 个字符,之后又是“-”,然后是日 2 个字符,所以完整的年月日 是 10 个字符。用户要求按照日期统计,所以,我们需要从日期时间数据中,把年月日的部分截取出来
  • ORDER BY表示按照指定的字段排序。超市经营者指定按照日期和单品统计,那么,统计的结果按照交易日期和商品名称的顺序排序,会更加清晰。
  • 上述步骤是分为3步
    • 第一步,完成 3 个表的连接
    • 第二步,对结果集按照交易时间和商品名称进行分组
    • 第三步,对各组的销售数量和销售金额进行统计,并且按照交易日期和商品名称排序
  • 如果用户需要知道全部商品销售的总计数量和总计金额,我们也可以把数据集的整体看作一个分组,进行计算。这样就不需要分组关键字 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)
  • AVG、MAX和 MIN
  • 举个例子,如果用户需要计算每天、每种商品,平均一次卖出多少个、多少钱,这个时候,我们就可以用到 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)
  • 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)
  • 千万不要以为 MAXb.quantity和 MAXb.salesvalue算出的结果一定是同一条记录的数据。实际上MySQL 是分别计算的。下面我们就来分析一下刚刚的查询。

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

  • COUNT

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

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

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

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

    • COUNT*):统计一共有多少条记录;
    • 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)
  • 运行这段代码,我们就得到了每天、每种商品有几次销售的全部结果。

  • 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”出现了多少次就要用到函数 COUNTgoodsname结果是 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 次。
  • 理解了这一点,你就可以利用计数函数对某个字段计数时,不统计空值的特点,对表中字段的非空值进行计数了

1.13 临时表:复杂查询,如何保存中间结果?

  • 拆解一个复杂的查询,通过临时表来保存中间结果,从而把一个复杂查询变得简单而且容易实现。

  • 临时表是什么?

  • 临时表是一种特殊的表,用来存储查询的中间结果,并且会随着当前连接的结束而自动删除。MySQL 中有 2 种临时表,分别是内部临时表和外部临时表

    • 内部临时表主要用于性能优化,由系统自动产生,我们无法看到;
    • 外部临时表通过 SQL 语句创建,我们可以使用。
  • 外部临时表

  • 临时表的创建语法结构:

CREATE TEMPORARY TABLE 表名(
字段名 字段类型,
...
);
  • 跟普通表相比,临时表有 3 个不同的特征:

    • 临时表的创建语法需要用到关键字 TEMPORARY
    • 临时表创建完成之后,只有当前连接可见,其他连接是看不到的,具有连接隔离性
    • 临时表在当前连接结束之后,会被自动删除
  • 因为临时表有连接隔离性,不同连接创建相同名称的临时表也不会产生冲突,适合并发程序的运行。而且,连接结束之后,临时表会自动删除,也不用担心大量无用的中间数据会 残留在数据库中。因此,我们就可以利用这些特点,用临时表来存储 SQL 查询的中间结果

  • 如何用临时表简化复杂查询?

  • 举个例子,超市经营者想要查询 2020 年 12 月的一些特定商品销售数量、进货数量、返厂数量,那么,我们就要先把销售、进货、返厂这 3 个模块分开计算,用临时表来存储中间 计算的结果,最后合并在一起,形成超市经营者想要的结果集。

  • 假设我们的销售流水表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 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 月的商品销售数量、进货数 量和返厂数量。
  • 代码如下所示:
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查询而且因为临时表是连接隔离的不同的连接可以使用相同的临时表名称相互之 间不会受到影响。除此之外,临时表会在连接结束的时候自动删除,不会占用磁盘空间。
    • 当然,临时表也有不足,比如会挤占空间。在使用临时表的时候,要从简化查询和挤占资源两个方面综合考虑,既不能过度加重系统的负担,同时又能够通过存储中间结果,最大限度地简化查询。

2. 进阶

2.1 视图:如何简化查询?

  • 视图是一种虚拟表,我们可以把一段查询语句作为视图存储在数据库中,在需要的时候,可以把视图看做一个表,对里面的数据进行查询。
  • 让查询变得简单,而且,视图没有实际存储数据,还避免了数据存储过程中可能产生的冗余,提高了存储的效率
  • 视图的创建及其好处
  • 创建视图的语法结构:
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 视图名;
  • 如何操作视图中的数据?

  • 视图本身是一个虚拟表,所以,对视图中的数据进行插入、修改和删除操作,实际都是通过对实际数据表的操作来实现的。

  • 在视图中插入数据

  • 借用刚刚的视图 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 ins ...
  • 这是因为,只有视图中的字段跟实际数据表中的字段完全一样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.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 语句,删除视图中的数据:

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)
  • 修改视图中的数据
  • 我们可以通过 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 查询可能会对字段重命名,也可能包含复杂的逻辑,这些都会增加维护的成本。
  • 一张图来汇总下视图的优缺点:
  • 视图的优缺点
  • 虽然可以更新视图数据,但总的来说,视图作为虚拟表,主要用于方便查询。我不建议你更新视图的数据,因为对视图数据的更改,都是通过对实际 数据表里数据的操作来完成的,而且有很多限制条件。
  • 视图虽然有很多优点。但是在创建视图、简化查询的同时,也要考虑到视图太多而导致的数据库维护成本的问题。
  • 视图不是越多越好,特别是嵌套的视图(就是在视图的基础上创建视图),我不建议你使用,因为逻辑复杂,可读性不好,容易变成系统的潜在隐患。

2.2 存储过程:如何提高程序的性能和安全性?

  • 在我们的超市项目中,每天营业结束后,超市经营者都要计算当日的销量,核算成本和毛利等营业数据,这也就意味着每天都要做重复的数据统计工作。其实,这种数据量大,而 且计算过程复杂的场景,就非常适合使用存储过程。

  • 简单来说呢,存储过程就是把一系列 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 | transdat
+---------------+------------------+-----------+----------+------------+------
| 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 | avgim
+------------+---------+-----------+---------------+------+------------+------
| 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),
             -> 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`
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)
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 | sal
+----+------------+----------+-------------+--------+--------+-------------+--
| 13 | 1 | 3.000 | 267.00 | 100.50 | 166.50 | 0.6236 | 2020-12-01 00:00:00 |
| 14 | 2 | 2.000 | 10.00 | 7.00 | 3.00 | 0.3000 | 2020-12-01 00:00:00 |
+----+------------+----------+-------------+--------+--------+-------------+--
2 rows in set (0.00 sec)
  • 看到了吗?我们已经能够在单品统计表中,查询到 2020 年 12 月 01 日的单品统计结果了。这也就意味着我们的存储过程被执行了,它计算出了我们需要的单品统计结果,并且 把统计结果存入了单品统计表中。
  • 如何修改和删除存储过程?
    • 如果你需要修改存储过程的内容,我建议你在 IDE 中操作。这是因为你可以在里面直接修改存储过程,而如果用 SQL 命令来修改存储过程,就必须删除存储过程再重新创 建,相比之下,在 IDE 中修改比较简单。
    • 在 MySQL 中,存储过程不像普通的编程语言(比如 VC++、Java 等)那样有专门的集成 开发环境。因此,你可以通过 SELECT 语句,把程序执行的中间结果查询出来,来调试一 个 SQL 语句的正确性。调试成功之后,把 SELECT 语句后移到下一个 SQL 语句之后,再调试下一个 SQL 语句。这样逐步推进,就可以完成对存储过程中所有操作的调试了。当 然,你也可以把存储过程中的 SQL 语句复制出来,逐段单独调试。
  • 删除存储过程很简单,你知道具体的语法就行了:
DROP PROCEDURE 存储过程名称;

2.3 游标:对于数据集中的记录,该怎么逐条处理?

  • 所谓的游标,也就是能够对结果集中的每一条记录进行定位,并对指向的记录中的数据进行操作的数据结构。

  • 游标的使用步骤

  • 游标只能在存储程序内使用,存储程序包括存储过程和存储函数。关于存储过程,我们上节课刚刚学过,这里我简单介绍一下存储函数。创建存储函数的语法是:

CREATE FUNCTION 函数名称 (参数)RETURNS 数据类型 程序体
  • 存储函数与存储过程很像,但有几个不同点:

    • 存储函数必须返回一个值或者数据表,存储过程可以不返回。
    • 存储过程可以通过 CALL 语句调用,存储函数不可以。
    • 存储函数可以放在查询语句中使用,存储过程不行。
    • 存储过程的功能更加强大,包括能够执行对表的操作(比如创建表,删除表等)和事务操作,这些功能是存储函数不具备的。
    • 游标在存储函数中的使用方法和在存储过程中的使用方法是一样的。
  • 在使用游标的时候,主要有 4 个步骤。

  • 第一步,定义游标。语法结构如下:

DECLARE 游标名CURSOR FOR 查询语句
  • 这里就是声明一个游标,它可以操作的数据集是“查询语句”返回的结果集。
  • 第二步,打开游标。语法结构如下:
OPEN 游标名称;
  • 打开游标之后,系统会为游标准备好查询的结果集,为后面游标的逐条读取结果集中的记录做准备。
  • 第三步,从游标的数据结果集中读取数据。语法结构是这样的:
FETCH 游标名 INTO 变量列表;
  • 这里的意思是通过游标,把当前游标指向的结果集中那一条记录的数据,赋值给列表中的变量。
  • 需要注意的是,游标的查询结果集中的字段数,必须跟 INTO 后面的变量数一致否则在存储过程执行的时候MySQL 会提示错误。
  • 第四步,关闭游标。语法结构如下:
CLOSE 游标名;
  • 用完游标之后,你一定要记住及时关闭游标。因为游标会占用系统资源,如果不及时关闭,游标会一直保持到存储过程结束,影响系统运行的效率。而关闭游标的操作,会释放 游标占用的系统资源。

  • 知道了基本步骤,下面我就结合超市项目的实际案例,带你实战一下。

  • 案例串讲

  • 在超市项目的进货模块中,有一项功能是对进货单数据进行验收。其实就是在对进货单的数据确认无误后,对进货单的数据进行处理,包括增加进货商品的库存,并修改商品的平 均进价。下面我用实际数据来演示一下这个操作流程。

  • 这里我们要用到进货单头表demo.importheadl、进货单明细表demo.importdetails、库存表demo.inventory和商品信息表 demo.goodsmaster

  • 进货单头表:

  • 游标进货单头表

  • 进货单明细表:

  • 游标进货单明细表

  • 库存表:

  • 游标库存表

  • 商品信息表:

  • 游标商品信息表

  • 要验收进货单,我们就需要对每一个进货商品进行两个操作:

    • 在现有库存数量的基础上,加上本次进货的数量;
    • 根据本次进货的价格、数量,现有商品的平均进价和库存,计算新的平均进价:(本次进货价格 * 本次进货数量 + 现有商品平均进价 * 现有商品库存)/(本次进货数量 + 现 有库存数量)
  • 因为我们需要通过应用程序来控制操作流程,做成一个循环操作,每次只查询一种商品的数据记录并进行处理,一直到把进货单中的数据全部处理完。这样一来,应用必须发送很 多的 SQL 指令到服务器,跟服务器的交互多,不仅代码复杂,而且也不够安全。

  • 这个时候,如果使用游标,就很容易了。因为所有的操作都可以在服务器端完成,应用程序只需要发送一个命令调用存储过程就可以了。现在,我们就来看看如何用游标来解决这 个问题。

  • 我用代码创建了一个存储过程 demo.mytest。创建存储过程的代码如下

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)/
-> WHERE a.itemnumber=b.itemnumber AND b.stockid=mystockid AND a.itemnumber=my
-> -- 更新库存
-> 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 | avgim
+------------+---------+-----------+---------------+------+------------+------
| 1 | 0001 |  | 16 |  | 89.00 | 31.00 |
| 2 | 0002 |  | NULL |  | 5.00 | 2.87 |
+------------+---------+-----------+---------------+------+------------+------
2 rows in set (0.00 sec)
  • 很显然,库存和平均价格都被正确地计算出来了。
  • 有个小问题要提醒你注意:如果一个操作要用到另外一个操作的结果,那我们一定不能搞错操作的顺序

2.4 触发器:如何让数据修改自动触发关联操作,确保数据一致性?

2.5 权限管理:如何控制数据库访问,消除安全隐患?

3. 优化

3.1 范式:如何消除冗余和高效存取?

  • 现象,什么样的表需要重新使用范式进行设计

  • 冗余供货表

  • 表里重复的数据非常多:比如第一行和第二行的数据,同样是 3478 号单据,供货商编号、供货商名称和仓库,这 3 个字段的信息完全相同。可是这 2 条数据的后 半部分又不相同,因此,并不能认为它们是冗余数据而删除。

  • 坏味道:

    • 在我们的工作场景中,这种由于数据表结构设计不合理,而导致的数据重复的现象并不少见,往往是系统虽然能够运行,承载能力却很差,稍微有点流量,就会出现内存不足、 CUP 使用率飙升的情况,甚至会导致整个项目失败
  • 第一范式

    • 这张进货单表重新设计的第一步,就是要把所有的列,也就是字段,都确认一遍,确保每个字段只包含一种数据。如果各种数据都混合在一起,就无法通过后面的拆解,把重复的数据去掉。
    • 其实,这就是第一范式所要求的:所有的字段都是基本数据字段,不可进一步拆分
    • 在我们的这张表里“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 个表都满足第二范式的要求。
      • 在“商品信息表”中字段“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 的数据库设计满足第三范式,就足够了。不过,第三范式,并不是终极范式,还有 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模型
      • 在这个图中,供货商和超市之间的供货关系,两边的数字都不是 1表示多对多的关系。
      • 同样,超市和顾客之间的零售关系,也是多对多的关系。
      • 这个 ER 模型,包括了 3 个实体之间的 2 种关系:
        • 超市从供货商那里采购商品;
        • 超市把商品卖给顾客。
        • 有了这个 ER 模型,我们就可以从整体上理解超市的业务了。但是,这里没有包含属性,这样就无法体现实体和关系的具体特征。现在,我们需要把属性加上,用椭圆来表示,这样 我们得到的 ER 模型就更加完整了。
  • ER 模型的细化

  • 进货模块

    • 实体及属性
      • 供货商:名称、地址、电话、联系人。
      • 商品:条码、名称、规格、单位、价格。
      • 门店:编号、地址、电话、联系人。
      • 仓库:编号、名称。
      • 员工:工号、姓名、住址、电话、身份证号、职位。
    • 实体关系
      • 其中,供货商、商品和门店是强实体,因为它们不需要依赖其他任何实体。
      • 而仓库和员工是弱实体,因为它们虽然都可以独立存在,但是它们都依赖门店这个实体,因此都是弱实体。
      • ER 模型如下:
      • ER模型
      • 这里我是用粗框矩形表示弱实体,用粗框菱形,表示弱实体与它依赖的强实体之间的关系。
  • 零售模块

    • 零售业务包括普通零售和会员零售两种模式。普通零售包含的实体,包括门店、商品和收银款台;会员零售包含的实体,包括门店、商品、会员和收银款台。
    • 实体及属性
      • 商品:条码、名称、规格、单位、价格。
      • 会员:卡号、发卡门店、名称、电话、身份证、地址、积分、储值。
      • 门店:编号、地址、电话、联系人。
      • 收银款台:编号、名称。
    • 实体关系
      • 其中,商品和门店不依赖于任何其他实体,所以是强实体;
      • 会员和收银款台都依赖于门店,所以是弱实体。
    • 零售模块的 ER 模型了:
      • 零售ER模型
  • 完整的 ER 模型:

    • 完整ER模型
  • 如何把 ER 模型图转换成数据表?

    • 通过绘制 ER 模型,我们已经理清了业务逻辑,现在,我们就要进行非常重要的一步了:把绘制好的 ER 模型,转换成具体的数据表。
    • 我来介绍下转换的原则。
      • 一个实体通常转换成一个数据表;
      • 一个多对多的关系,通常也转换成一个数据表;
      • 一个 1 对 1或者 1 对多的关系,往往通过表的外键来表达,而不是设计一个新的数据表;
      • 属性转换成表的字段。

3.3 查询有点慢,语句该如何写?

  • 查询分析语句
    • 虽然 MySQL 的查询分析语句并不能直接优化查询,但是却可以帮助你了解 SQL 语句的执行计划,有助于你分析查询效率低下的原因,进而有针对性地进行优化。查询分析语句的 语法结构是:
    • { EXPLAIN | DESCRIBE | DESC }查询语句;
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 |
+----+-------------+-------+------------+------+---------------+------+---------+------+---------+----------+-------------+
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 取值,

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 在执行的时候,会把这个语句进行优化,重新写成下面的语句:
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
  • 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”创建一个索引
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
+----+-------------+-------+------------+-------------+-----------------------
| 1 | SIMPLE | trans | NULL | index_merge | trans_barcode,trans_itemnumber | t
+----+-------------+-------+------------+-------------+-----------------------
1 row in set, 1 warning (0.01 sec)
  • 我们先看一下关键字“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
+----+-------------+-------+------------+-------------+-----------------------
| 1 | SIMPLE | trans | NULL | index_merge | trans_barcode,trans_itemnumber | t
+----+-------------+-------+------------+-------------+-----------------------
1 row in set, 1 warning (0.01 sec)
  • 说明优化器选择了合并索引的方式。因此这个关键字“OR”前后的表达式中的字段都创建了索引的查询是可以用到索引的。

3.4 表太大了,如何设计才能提高性能?