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.

761 lines
46 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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(strn)**表示返回字符串 str 最左边的 n 个字符**。我们这里的 LEFTa.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)
```
- 千万不要以为 MAXb.quantity和 MAXb.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”出现了多少次就要用到函数 COUNTgoodsname结果是 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.
## 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 表太大了,如何设计才能提高性能?